mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 02:12:00 +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 id="statusbar">
|
||||
<span id="rates-info" title=""></span>
|
||||
<span id="market-info" title="" style="display:none"></span>
|
||||
<span class="spacer"></span>
|
||||
<div id="export-wrap">
|
||||
<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
|
||||
|
||||
#[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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const dict: Record<string, Record<string, string>> = {
|
|||
unpin: "Unpin",
|
||||
moveUp: "Move up",
|
||||
moveDown: "Move down",
|
||||
marketUpdated: "Market data updated",
|
||||
},
|
||||
ru: {
|
||||
settings: "Настройки",
|
||||
|
|
@ -124,6 +125,7 @@ const dict: Record<string, Record<string, string>> = {
|
|||
unpin: "Открепить",
|
||||
moveUp: "Выше",
|
||||
moveDown: "Ниже",
|
||||
marketUpdated: "Рыночные данные обновлены",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
51
src/main.ts
51
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<string, number> = {};
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
|
|
@ -529,7 +530,10 @@ async function refreshRates(force = false): Promise<void> {
|
|||
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<void> {
|
|||
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
|
||||
|
||||
/** 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));
|
||||
$("#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> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -205,6 +205,18 @@ export async function fetchRates(force = false): Promise<RatesPayload | 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
|
||||
|
||||
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue