mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 20:21:58 +03:00
live market data via Yahoo Finance: AAPL TSLA NVDA etc
This commit is contained in:
parent
f1427d08f3
commit
661a4500ca
6 changed files with 147 additions and 3 deletions
|
|
@ -30,6 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="statusbar">
|
<div id="statusbar">
|
||||||
<span id="rates-info" title=""></span>
|
<span id="rates-info" title=""></span>
|
||||||
|
<span id="market-info" title="" style="display:none"></span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<div id="export-wrap">
|
<div id="export-wrap">
|
||||||
<button id="export-btn" title="Export">⤓</button>
|
<button id="export-btn" title="Export">⤓</button>
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,87 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- market data (stocks, commodities via Yahoo Finance)
|
||||||
|
|
||||||
|
const MARKET_TTL_SECS: u64 = 900; // 15 minutes
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct MarketCache {
|
||||||
|
fetched_at: u64,
|
||||||
|
prices: std::collections::HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_market_data(
|
||||||
|
app: AppHandle,
|
||||||
|
symbols: Vec<String>,
|
||||||
|
) -> Result<std::collections::HashMap<String, f64>, String> {
|
||||||
|
let cache_path = data_dir(&app).join("market.json");
|
||||||
|
|
||||||
|
// try cache first
|
||||||
|
if let Ok(raw) = fs::read_to_string(&cache_path) {
|
||||||
|
if let Ok(cache) = serde_json::from_str::<MarketCache>(&raw) {
|
||||||
|
if now_secs().saturating_sub(cache.fetched_at) < MARKET_TTL_SECS {
|
||||||
|
let syms: std::collections::HashSet<&str> = symbols.iter().map(|s| s.as_str()).collect();
|
||||||
|
let hit: std::collections::HashMap<String, f64> = cache
|
||||||
|
.prices
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(k, _)| syms.contains(k.as_str()))
|
||||||
|
.collect();
|
||||||
|
if !hit.is_empty() {
|
||||||
|
return Ok(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if symbols.is_empty() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.user_agent("Mozilla/5.0")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let joined = symbols.join(",");
|
||||||
|
let url = format!(
|
||||||
|
"https://query1.finance.yahoo.com/v7/finance/quote?symbols={}",
|
||||||
|
joined
|
||||||
|
);
|
||||||
|
let body: serde_json::Value = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut prices = std::collections::HashMap::new();
|
||||||
|
if let Some(results) = body["quoteResponse"]["result"].as_array() {
|
||||||
|
for item in results {
|
||||||
|
let sym = item["symbol"].as_str().unwrap_or("").to_string();
|
||||||
|
// regularMarketPrice is in the quote currency (USD for US stocks)
|
||||||
|
if let Some(price) = item["regularMarketPrice"].as_f64() {
|
||||||
|
if price > 0.0 && !sym.is_empty() {
|
||||||
|
prices.insert(sym, price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !prices.is_empty() {
|
||||||
|
let cache = MarketCache { fetched_at: now_secs(), prices: prices.clone() };
|
||||||
|
if let Ok(raw) = serde_json::to_string(&cache) {
|
||||||
|
write_atomic(&cache_path, &raw).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(prices)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- documents in the (possibly custom) data folder
|
// ---------- documents in the (possibly custom) data folder
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -523,6 +604,7 @@ fn main() {
|
||||||
data_dir_has_documents,
|
data_dir_has_documents,
|
||||||
migrate_data_dir,
|
migrate_data_dir,
|
||||||
fetch_rates,
|
fetch_rates,
|
||||||
|
fetch_market_data,
|
||||||
load_extensions,
|
load_extensions,
|
||||||
open_extensions_folder,
|
open_extensions_folder,
|
||||||
get_launch_file,
|
get_launch_file,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com"
|
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
unpin: "Unpin",
|
unpin: "Unpin",
|
||||||
moveUp: "Move up",
|
moveUp: "Move up",
|
||||||
moveDown: "Move down",
|
moveDown: "Move down",
|
||||||
|
marketUpdated: "Market data updated",
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
|
|
@ -124,6 +125,7 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
unpin: "Открепить",
|
unpin: "Открепить",
|
||||||
moveUp: "Выше",
|
moveUp: "Выше",
|
||||||
moveDown: "Ниже",
|
moveDown: "Ниже",
|
||||||
|
marketUpdated: "Рыночные данные обновлены",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
51
src/main.ts
51
src/main.ts
|
|
@ -5,7 +5,7 @@ import { SumEditor } from "./ui/editor";
|
||||||
import {
|
import {
|
||||||
AppData, DocMeta, SettingsData, defaultSettingsData,
|
AppData, DocMeta, SettingsData, defaultSettingsData,
|
||||||
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
||||||
fetchRates, loadExtensionScripts, openExtensionsFolder, isTauri,
|
fetchRates, fetchMarketData, loadExtensionScripts, openExtensionsFolder, isTauri,
|
||||||
getLaunchFile, onOpenFile, onFileDrop,
|
getLaunchFile, onOpenFile, onFileDrop,
|
||||||
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
|
||||||
chooseFolder, dataDirHasDocuments, migrateDataDir,
|
chooseFolder, dataDirHasDocuments, migrateDataDir,
|
||||||
|
|
@ -30,6 +30,7 @@ let data: AppData;
|
||||||
let lastResults: LineResult[] = [];
|
let lastResults: LineResult[] = [];
|
||||||
let selectedRange: [number, number] | null = null;
|
let selectedRange: [number, number] | null = null;
|
||||||
let ratesFetchedAt = 0;
|
let ratesFetchedAt = 0;
|
||||||
|
let liveRates: Record<string, number> = {};
|
||||||
|
|
||||||
function uid(): string {
|
function uid(): string {
|
||||||
return Math.random().toString(36).slice(2, 10);
|
return Math.random().toString(36).slice(2, 10);
|
||||||
|
|
@ -529,7 +530,10 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
const payload = await fetchRates(force);
|
const payload = await fetchRates(force);
|
||||||
info.classList.remove("spin");
|
info.classList.remove("spin");
|
||||||
if (payload) {
|
if (payload) {
|
||||||
engine.setRates(payload.rates);
|
liveRates = Object.fromEntries(
|
||||||
|
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)])
|
||||||
|
);
|
||||||
|
applyAllRates();
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
ratesFetchedAt = payload.fetchedAt;
|
ratesFetchedAt = payload.fetchedAt;
|
||||||
if (force) toast(t("ratesUpdated"));
|
if (force) toast(t("ratesUpdated"));
|
||||||
|
|
@ -537,6 +541,45 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
renderRatesInfo();
|
renderRatesInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- market data
|
||||||
|
|
||||||
|
const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META", "SBER", "GAZP"];
|
||||||
|
|
||||||
|
let marketPrices: Record<string, number> = {};
|
||||||
|
let marketFetchedAt = 0;
|
||||||
|
|
||||||
|
function applyAllRates(): void {
|
||||||
|
const marketRates: Record<string, number> = {};
|
||||||
|
for (const [sym, price] of Object.entries(marketPrices)) {
|
||||||
|
if (price > 0) marketRates[sym] = 1 / price;
|
||||||
|
}
|
||||||
|
engine.setRates({ ...liveRates, ...marketRates });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMarket(force = false): Promise<void> {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
const info = $("#market-info") as HTMLElement;
|
||||||
|
info.style.display = "";
|
||||||
|
info.textContent = "Market…";
|
||||||
|
const prices = await fetchMarketData(MARKET_SYMBOLS);
|
||||||
|
if (Object.keys(prices).length > 0) {
|
||||||
|
marketPrices = prices;
|
||||||
|
marketFetchedAt = Math.floor(Date.now() / 1000);
|
||||||
|
applyAllRates();
|
||||||
|
editor.refresh();
|
||||||
|
if (force) toast(t("marketUpdated"));
|
||||||
|
}
|
||||||
|
renderMarketInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarketInfo(): void {
|
||||||
|
const el = $("#market-info") as HTMLElement;
|
||||||
|
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
|
||||||
|
el.style.display = "";
|
||||||
|
el.textContent = `Market: ${agoText(marketFetchedAt)} ↺`;
|
||||||
|
el.title = t("refreshRates");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- status bar
|
// ---------- status bar
|
||||||
|
|
||||||
/** total of the selection (≥2 lines) or of the whole sheet */
|
/** total of the selection (≥2 lines) or of the whole sheet */
|
||||||
|
|
@ -839,7 +882,9 @@ async function boot(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#rates-info").addEventListener("click", () => void refreshRates(true));
|
$("#rates-info").addEventListener("click", () => void refreshRates(true));
|
||||||
|
$("#market-info").addEventListener("click", () => void refreshMarket(true));
|
||||||
setInterval(renderRatesInfo, 60_000);
|
setInterval(renderRatesInfo, 60_000);
|
||||||
|
setInterval(renderMarketInfo, 60_000);
|
||||||
|
|
||||||
void onFileDrop((content) => newDoc(content));
|
void onFileDrop((content) => newDoc(content));
|
||||||
|
|
||||||
|
|
@ -849,6 +894,8 @@ async function boot(): Promise<void> {
|
||||||
void onAppQuit(() => flushAppData());
|
void onAppQuit(() => flushAppData());
|
||||||
void refreshRates();
|
void refreshRates();
|
||||||
setInterval(() => void refreshRates(), 60 * 60 * 1000);
|
setInterval(() => void refreshRates(), 60 * 60 * 1000);
|
||||||
|
void refreshMarket();
|
||||||
|
setInterval(() => void refreshMarket(), 15 * 60 * 1000);
|
||||||
|
|
||||||
const launched = await getLaunchFile();
|
const launched = await getLaunchFile();
|
||||||
if (launched) newDoc(launched);
|
if (launched) newDoc(launched);
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,18 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- market data
|
||||||
|
|
||||||
|
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {
|
||||||
|
if (!isTauri() || symbols.length === 0) return {};
|
||||||
|
try {
|
||||||
|
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("fetchMarketData failed", e);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- extensions
|
// ---------- extensions
|
||||||
|
|
||||||
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue