Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions server/worldmonitor/market/v1/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));

export async function fetchYahooQuotesBatch(
symbols: string[],
): Promise<Map<string, { price: number; change: number; sparkline: number[] }>> {
): Promise<{ results: Map<string, { price: number; change: number; sparkline: number[] }>; rateLimited: boolean }> {
const results = new Map<string, { price: number; change: number; sparkline: number[] }>();
let rateLimitHits = 0;
for (let i = 0; i < symbols.length; i++) {
const q = await fetchYahooQuote(symbols[i]!);
if (q) results.set(symbols[i]!, q);
if (q) {
results.set(symbols[i]!, q);
} else {
rateLimitHits++;
}
if (rateLimitHits >= 3 && results.size === 0) break;
}
return results;
return { results, rateLimited: rateLimitHits >= 3 && results.size === 0 };
}

// Yahoo-only symbols: indices and futures not on Finnhub free tier
Expand Down
2 changes: 1 addition & 1 deletion server/worldmonitor/market/v1/get-sector-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function getSectorSummary(
if (sectors.length === 0) {
const batch = await fetchYahooQuotesBatch(sectorSymbols);
for (const s of sectorSymbols) {
const yahoo = batch.get(s);
const yahoo = batch.results.get(s);
if (yahoo) sectors.push({ symbol: s, name: s, change: yahoo.change });
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/worldmonitor/market/v1/list-commodity-quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function listCommodityQuotes(
const batch = await fetchYahooQuotesBatch(symbols);
const quotes: CommodityQuote[] = [];
for (const s of symbols) {
const yahoo = batch.get(s);
const yahoo = batch.results.get(s);
if (yahoo) {
quotes.push({ symbol: s, name: s, display: s, price: yahoo.price, change: yahoo.change, sparkline: yahoo.sparkline });
}
Expand Down
13 changes: 11 additions & 2 deletions server/worldmonitor/market/v1/list-etf-flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,29 @@ export async function listEtfFlows(
try {
const result = await cachedFetchJson<ListEtfFlowsResponse>(REDIS_CACHE_KEY, REDIS_CACHE_TTL, async () => {
const etfs: EtfFlow[] = [];
let misses = 0;
for (const etf of ETF_LIST) {
const chart = await fetchEtfChart(etf.ticker);
if (chart) {
const parsed = parseEtfChartData(chart, etf.ticker, etf.issuer);
if (parsed) etfs.push(parsed);
if (parsed) etfs.push(parsed); else misses++;
} else {
misses++;
}
if (misses >= 3 && etfs.length === 0) break;
}

// Stale-while-revalidate: if Yahoo rate-limited all calls, serve cached data
if (etfs.length === 0 && etfCache) {
return etfCache;
}

if (etfs.length === 0) return null;
if (etfs.length === 0) {
const rateLimited = misses >= 3;
return rateLimited
? { timestamp: new Date().toISOString(), etfs: [], rateLimited: true }
: null;
}

const totalVolume = etfs.reduce((sum, e) => sum + e.volume, 0);
const totalEstFlow = etfs.reduce((sum, e) => sum + e.estFlow, 0);
Expand Down
30 changes: 23 additions & 7 deletions server/worldmonitor/market/v1/list-market-quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,20 @@ export async function listMarketQuotes(
}
}

// Fetch Yahoo Finance quotes for indices/futures (staggered to avoid 429)
if (yahooSymbols.length > 0) {
const batch = await fetchYahooQuotesBatch(yahooSymbols);
for (const s of yahooSymbols) {
const yahoo = batch.get(s);
// Fallback: route Finnhub symbols through Yahoo when key is missing
const missedFinnhub = apiKey
? finnhubSymbols.filter((s) => !quotes.some((q) => q.symbol === s))
: finnhubSymbols;
const allYahoo = [...yahooSymbols, ...missedFinnhub];

// Fetch Yahoo Finance quotes (staggered to avoid 429)
let yahooRateLimited = false;
if (allYahoo.length > 0) {
const batch = await fetchYahooQuotesBatch(allYahoo);
yahooRateLimited = batch.rateLimited;
for (const s of allYahoo) {
if (quotes.some((q) => q.symbol === s)) continue;
const yahoo = batch.results.get(s);
if (yahoo) {
quotes.push({
symbol: s,
Expand All @@ -96,9 +105,16 @@ export async function listMarketQuotes(
return memCached.data;
}

if (quotes.length === 0) return null;
if (quotes.length === 0) {
return yahooRateLimited
? { quotes: [], finnhubSkipped: false, skipReason: '', rateLimited: true }
: null;
}

return { quotes, finnhubSkipped: !apiKey, skipReason: !apiKey ? 'FINNHUB_API_KEY not configured' : '' };
// Only report skipped if Finnhub key missing AND Yahoo fallback didn't cover the gap
const coveredByYahoo = finnhubSymbols.every((s) => quotes.some((q) => q.symbol === s));
const skipped = !apiKey && !coveredByYahoo;
return { quotes, finnhubSkipped: skipped, skipReason: skipped ? 'FINNHUB_API_KEY not configured' : '' };
});

if (result?.quotes?.length) {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions src-tauri/sidecar/local-api-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,8 @@ const trafficLog = [];
let verboseMode = false;
let _verboseStatePath = null;

function loadVerboseState(resourceDir) {
_verboseStatePath = path.join(resourceDir, 'verbose-mode.json');
function loadVerboseState(dataDir) {
_verboseStatePath = path.join(dataDir, 'verbose-mode.json');
try {
const data = JSON.parse(readFileSync(_verboseStatePath, 'utf-8'));
verboseMode = !!data.verboseMode;
Expand Down Expand Up @@ -459,6 +459,7 @@ function resolveConfig(options = {}) {
path.join(resourceDir, 'api'),
path.join(resourceDir, '_up_', 'api'),
].find((candidate) => existsSync(candidate)) ?? path.join(resourceDir, 'api');
const dataDir = String(options.dataDir ?? process.env.LOCAL_API_DATA_DIR ?? resourceDir);
const mode = String(options.mode ?? process.env.LOCAL_API_MODE ?? 'desktop-sidecar');
const cloudFallback = String(options.cloudFallback ?? process.env.LOCAL_API_CLOUD_FALLBACK ?? '') === 'true';
const logger = options.logger ?? console;
Expand All @@ -467,6 +468,7 @@ function resolveConfig(options = {}) {
port,
remoteBase,
resourceDir,
dataDir,
apiDir,
mode,
cloudFallback,
Expand Down Expand Up @@ -1152,7 +1154,7 @@ async function dispatch(requestUrl, req, routes, context) {

export async function createLocalApiServer(options = {}) {
const context = resolveConfig(options);
loadVerboseState(context.resourceDir);
loadVerboseState(context.dataDir);
const routes = await buildRouteTable(context.apiDir);

const server = createServer(async (req, res) => {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,10 +984,14 @@ fn start_local_api(app: &AppHandle) -> Result<(), String> {
"INFO",
&format!("node args: script={script_for_node} resource_dir={resource_for_node}"),
);
let data_dir = logs_dir_path(app)
.map(|p| sanitize_path_for_node(&p))
.unwrap_or_else(|_| resource_for_node.clone());
cmd.arg(&script_for_node)
.env("LOCAL_API_PORT", DEFAULT_LOCAL_API_PORT.to_string())
.env("LOCAL_API_PORT_FILE", &port_file)
.env("LOCAL_API_RESOURCE_DIR", &resource_for_node)
.env("LOCAL_API_DATA_DIR", &data_dir)
.env("LOCAL_API_MODE", "tauri-sidecar")
.env("LOCAL_API_TOKEN", &local_api_token)
.stdout(Stdio::from(log_file))
Expand Down
10 changes: 7 additions & 3 deletions src/app/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,9 +649,13 @@ export class DataLoaderManager implements AppModule {

const finnhubConfigMsg = 'FINNHUB_API_KEY not configured — add in Settings';
this.ctx.latestMarkets = stocksResult.data;
(this.ctx.panels['markets'] as MarketPanel).renderMarkets(stocksResult.data);
(this.ctx.panels['markets'] as MarketPanel).renderMarkets(stocksResult.data, stocksResult.rateLimited);

if (stocksResult.skipped) {
if (stocksResult.rateLimited && stocksResult.data.length === 0) {
const rlMsg = 'Market data temporarily unavailable (rate limited) — retrying shortly';
this.ctx.panels['heatmap']?.showError(rlMsg);
this.ctx.panels['commodities']?.showError(rlMsg);
} else if (stocksResult.skipped) {
this.ctx.statusPanel?.updateApi('Finnhub', { status: 'error' });
if (stocksResult.data.length === 0) {
this.ctx.panels['markets']?.showConfigError(finnhubConfigMsg);
Expand All @@ -678,7 +682,7 @@ export class DataLoaderManager implements AppModule {
const commoditiesPanel = this.ctx.panels['commodities'] as CommoditiesPanel;
const mapCommodity = (c: MarketData) => ({ display: c.display, price: c.price, change: c.change, sparkline: c.sparkline });

let commoditiesLoaded = false;
let commoditiesLoaded = stocksResult.rateLimited && stocksResult.data.length === 0;
for (let attempt = 0; attempt < 3 && !commoditiesLoaded; attempt++) {
if (attempt > 0) {
commoditiesPanel.showRetrying();
Expand Down
5 changes: 3 additions & 2 deletions src/components/ETFFlowsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class ETFFlowsPanel extends Panel {
this.data = await client.listEtfFlows({});
this.error = null;

if (this.data && this.data.etfs.length === 0 && attempt < 2) {
if (this.data && this.data.etfs.length === 0 && !this.data.rateLimited && attempt < 2) {
this.showRetrying();
await new Promise(r => setTimeout(r, 20_000));
continue;
Expand Down Expand Up @@ -86,7 +86,8 @@ export class ETFFlowsPanel extends Panel {

const d = this.data;
if (!d.etfs.length) {
this.setContent(`<div class="panel-loading-text">${t('components.etfFlows.unavailable')}</div>`);
const msg = d.rateLimited ? t('components.etfFlows.rateLimited') : t('components.etfFlows.unavailable');
this.setContent(`<div class="panel-loading-text">${msg}</div>`);
return;
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/MarketPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export class MarketPanel extends Panel {
super({ id: 'markets', title: t('panels.markets') });
}

public renderMarkets(data: MarketData[]): void {
public renderMarkets(data: MarketData[], rateLimited?: boolean): void {
if (data.length === 0) {
this.showError(t('common.failedMarketData'));
this.showError(rateLimited ? t('common.rateLimitedMarket') : t('common.failedMarketData'));
return;
}

Expand Down
2 changes: 2 additions & 0 deletions src/generated/client/worldmonitor/market/v1/service_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ListMarketQuotesResponse {
quotes: MarketQuote[];
finnhubSkipped: boolean;
skipReason: string;
rateLimited?: boolean;
}

export interface MarketQuote {
Expand Down Expand Up @@ -106,6 +107,7 @@ export interface ListEtfFlowsResponse {
timestamp: string;
summary?: EtfFlowsSummary;
etfs: EtfFlow[];
rateLimited?: boolean;
}

export interface EtfFlowsSummary {
Expand Down
2 changes: 2 additions & 0 deletions src/generated/server/worldmonitor/market/v1/service_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ListMarketQuotesResponse {
quotes: MarketQuote[];
finnhubSkipped: boolean;
skipReason: string;
rateLimited?: boolean;
}

export interface MarketQuote {
Expand Down Expand Up @@ -106,6 +107,7 @@ export interface ListEtfFlowsResponse {
timestamp: string;
summary?: EtfFlowsSummary;
etfs: EtfFlow[];
rateLimited?: boolean;
}

export interface EtfFlowsSummary {
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@
},
"etfFlows": {
"unavailable": "ETF data temporarily unavailable",
"rateLimited": "ETF data temporarily unavailable (rate limited) — retrying shortly",
"netFlow": "Net Flow",
"estFlow": "Est. Flow",
"totalVol": "Total Vol",
Expand Down Expand Up @@ -1989,6 +1990,7 @@
"failedSectorData": "Failed to load sector data",
"failedCommodities": "Failed to load commodities",
"failedCryptoData": "Failed to load crypto data",
"rateLimitedMarket": "Market data temporarily unavailable (rate limited) — retrying shortly",
"failedClusterNews": "Failed to cluster news",
"noNewsAvailable": "No news available",
"noActiveTechHubs": "No active tech hubs",
Expand Down
2 changes: 2 additions & 0 deletions src/services/market/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface MarketFetchResult {
data: MarketData[];
skipped?: boolean;
reason?: string;
rateLimited?: boolean;
}

// ========================================================================
Expand Down Expand Up @@ -99,6 +100,7 @@ export async function fetchMultipleStocks(
data,
skipped: resp.finnhubSkipped || undefined,
reason: resp.skipReason || undefined,
rateLimited: resp.rateLimited || undefined,
};
}

Expand Down
22 changes: 21 additions & 1 deletion src/services/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,28 @@ export function installRuntimeFetchPatch(): void {

try {
const t0 = performance.now();
const response = await fetchLocalWithStartupRetry(nativeFetch, localUrl, localInit);
let response = await fetchLocalWithStartupRetry(nativeFetch, localUrl, localInit);
if (debug) console.log(`[fetch] ${target} → ${response.status} (${Math.round(performance.now() - t0)}ms)`);

// Token may be stale after a sidecar restart — refresh and retry once.
if (response.status === 401 && localApiToken) {
if (debug) console.log(`[fetch] 401 from sidecar, refreshing token and retrying`);
try {
const { tryInvokeTauri } = await import('@/services/tauri-bridge');
localApiToken = await tryInvokeTauri<string>('get_local_api_token');
tokenFetchedAt = Date.now();
} catch {
localApiToken = null;
tokenFetchedAt = 0;
}
if (localApiToken) {
const retryHeaders = new Headers(init?.headers);
retryHeaders.set('Authorization', `Bearer ${localApiToken}`);
response = await fetchLocalWithStartupRetry(nativeFetch, localUrl, { ...init, headers: retryHeaders });
if (debug) console.log(`[fetch] retry ${target} → ${response.status}`);
}
}

if (!response.ok) {
if (!allowCloudFallback) {
if (debug) console.log(`[fetch] local-only endpoint ${target} returned ${response.status}; skipping cloud fallback`);
Expand Down
21 changes: 17 additions & 4 deletions src/settings-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ function getSidecarBase(): string {
return getApiBaseUrl() || 'http://127.0.0.1:46123';
}

let _diagToken: string | null = null;

async function diagFetch(path: string, init?: RequestInit): Promise<Response> {
if (!_diagToken) {
try {
_diagToken = await tryInvokeTauri<string>('get_local_api_token');
} catch { /* token unavailable */ }
}
const headers = new Headers(init?.headers);
if (_diagToken) headers.set('Authorization', `Bearer ${_diagToken}`);
return fetch(`${getSidecarBase()}${path}`, { ...init, headers });
}

function initDiagnostics(): void {
const verboseToggle = document.getElementById('verboseApiLog') as HTMLInputElement | null;
const fetchDebugToggle = document.getElementById('fetchDebugLog') as HTMLInputElement | null;
Expand All @@ -196,15 +209,15 @@ function initDiagnostics(): void {
async function syncVerboseState(): Promise<void> {
if (!verboseToggle) return;
try {
const res = await fetch(`${getSidecarBase()}/api/local-debug-toggle`);
const res = await diagFetch('/api/local-debug-toggle');
const data = await res.json();
verboseToggle.checked = data.verboseMode;
} catch { /* sidecar not running */ }
}

verboseToggle?.addEventListener('change', async () => {
try {
const res = await fetch(`${getSidecarBase()}/api/local-debug-toggle`, { method: 'POST' });
const res = await diagFetch('/api/local-debug-toggle', { method: 'POST' });
const data = await res.json();
if (verboseToggle) verboseToggle.checked = data.verboseMode;
setActionStatus(data.verboseMode ? t('modals.settingsWindow.verboseOn') : t('modals.settingsWindow.verboseOff'), 'ok');
Expand All @@ -218,7 +231,7 @@ function initDiagnostics(): void {
async function refreshTrafficLog(): Promise<void> {
if (!trafficLogEl) return;
try {
const res = await fetch(`${getSidecarBase()}/api/local-traffic-log`);
const res = await diagFetch('/api/local-traffic-log');
const data = await res.json();
const entries: Array<{ timestamp: string; method: string; path: string; status: number; durationMs: number }> = data.entries || [];
if (trafficCount) trafficCount.textContent = `(${entries.length})`;
Expand All @@ -244,7 +257,7 @@ function initDiagnostics(): void {

clearBtn?.addEventListener('click', async () => {
try {
await fetch(`${getSidecarBase()}/api/local-traffic-log`, { method: 'DELETE' });
await diagFetch('/api/local-traffic-log', { method: 'DELETE' });
} catch { /* ignore */ }
if (trafficLogEl) trafficLogEl.innerHTML = `<p class="diag-empty">${t('modals.settingsWindow.logCleared')}</p>`;
if (trafficCount) trafficCount.textContent = '(0)';
Expand Down