live market data via Yahoo Finance: AAPL TSLA NVDA etc

This commit is contained in:
Horizontal42 2026-07-01 20:40:40 +03:00
parent f1427d08f3
commit 661a4500ca
6 changed files with 147 additions and 3 deletions

View file

@ -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>

View file

@ -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,

View 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": {

View file

@ -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: "Рыночные данные обновлены",
}, },
}; };

View file

@ -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);

View file

@ -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 }[]> {