From 661a4500ca97b153037239ebe0df0e24cc35b80e Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:40:40 +0300 Subject: [PATCH] live market data via Yahoo Finance: AAPL TSLA NVDA etc --- index.html | 1 + src-tauri/src/main.rs | 82 +++++++++++++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/i18n.ts | 2 + src/main.ts | 51 +++++++++++++++++++++++- src/storage.ts | 12 ++++++ 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 7d51072..bc9b7ff 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,7 @@
+
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3cb99a2..163b874 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -202,6 +202,87 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result, +} + +#[tauri::command] +async fn fetch_market_data( + app: AppHandle, + symbols: Vec, +) -> Result, 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::(&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 = 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 #[tauri::command] @@ -523,6 +604,7 @@ fn main() { data_dir_has_documents, migrate_data_dir, fetch_rates, + fetch_market_data, load_extensions, open_extensions_folder, get_launch_file, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2299cbd..452f3c6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "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": { diff --git a/src/i18n.ts b/src/i18n.ts index 2e1a5f3..85fb69e 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -62,6 +62,7 @@ const dict: Record> = { unpin: "Unpin", moveUp: "Move up", moveDown: "Move down", + marketUpdated: "Market data updated", }, ru: { settings: "Настройки", @@ -124,6 +125,7 @@ const dict: Record> = { unpin: "Открепить", moveUp: "Выше", moveDown: "Ниже", + marketUpdated: "Рыночные данные обновлены", }, }; diff --git a/src/main.ts b/src/main.ts index 26f5988..a832280 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { SumEditor } from "./ui/editor"; import { AppData, DocMeta, SettingsData, defaultSettingsData, loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings, - fetchRates, loadExtensionScripts, openExtensionsFolder, isTauri, + fetchRates, fetchMarketData, loadExtensionScripts, openExtensionsFolder, isTauri, getLaunchFile, onOpenFile, onFileDrop, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, @@ -30,6 +30,7 @@ let data: AppData; let lastResults: LineResult[] = []; let selectedRange: [number, number] | null = null; let ratesFetchedAt = 0; +let liveRates: Record = {}; function uid(): string { return Math.random().toString(36).slice(2, 10); @@ -529,7 +530,10 @@ async function refreshRates(force = false): Promise { const payload = await fetchRates(force); info.classList.remove("spin"); if (payload) { - engine.setRates(payload.rates); + liveRates = Object.fromEntries( + Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]) + ); + applyAllRates(); editor.refresh(); ratesFetchedAt = payload.fetchedAt; if (force) toast(t("ratesUpdated")); @@ -537,6 +541,45 @@ async function refreshRates(force = false): Promise { renderRatesInfo(); } +// ---------- market data + +const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META", "SBER", "GAZP"]; + +let marketPrices: Record = {}; +let marketFetchedAt = 0; + +function applyAllRates(): void { + const marketRates: Record = {}; + 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 { + 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 /** total of the selection (≥2 lines) or of the whole sheet */ @@ -839,7 +882,9 @@ async function boot(): Promise { }); $("#rates-info").addEventListener("click", () => void refreshRates(true)); + $("#market-info").addEventListener("click", () => void refreshMarket(true)); setInterval(renderRatesInfo, 60_000); + setInterval(renderMarketInfo, 60_000); void onFileDrop((content) => newDoc(content)); @@ -849,6 +894,8 @@ async function boot(): Promise { void onAppQuit(() => flushAppData()); void refreshRates(); setInterval(() => void refreshRates(), 60 * 60 * 1000); + void refreshMarket(); + setInterval(() => void refreshMarket(), 15 * 60 * 1000); const launched = await getLaunchFile(); if (launched) newDoc(launched); diff --git a/src/storage.ts b/src/storage.ts index d4cf55d..0f6b882 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -205,6 +205,18 @@ export async function fetchRates(force = false): Promise { return null; } +// ---------- market data + +export async function fetchMarketData(symbols: string[]): Promise> { + if (!isTauri() || symbols.length === 0) return {}; + try { + return await invoke>("fetch_market_data", { symbols }); + } catch (e) { + console.warn("fetchMarketData failed", e); + return {}; + } +} + // ---------- extensions export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {