phase-8f: persistant cache locally

This commit is contained in:
Kazuma
2026-06-05 22:52:30 -04:00
parent bd373ab69b
commit 9fb3808eb5
7 changed files with 551 additions and 24 deletions
+10 -2
View File
@@ -11,10 +11,16 @@ export class YahooFinanceClient {
});
}
/** Normalise ticker before hitting Yahoo: BRK.B → BRK-B */
private static normalise(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
const normalised = YahooFinanceClient.normalise(ticker);
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await this.lib.quoteSummary(ticker, { modules: YAHOO_MODULES });
return await this.lib.quoteSummary(normalised, { modules: YAHOO_MODULES });
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
@@ -24,7 +30,9 @@ export class YahooFinanceClient {
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const result = await this.lib.quoteSummary(ticker, { modules: ['calendarEvents'] });
const result = await this.lib.quoteSummary(YahooFinanceClient.normalise(ticker), {
modules: ['calendarEvents'],
});
return result.calendarEvents ?? null;
} catch {
return null;
+1 -5
View File
@@ -13,10 +13,6 @@ export class FinanceController {
private readonly advisor: PortfolioAdvisor,
) {}
private static normalizeYahoo(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', this.portfolio.bind(this));
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this));
@@ -38,7 +34,7 @@ export class FinanceController {
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => FinanceController.normalizeYahoo(h.ticker));
.map((h) => h.ticker.toUpperCase());
const results =
screenable.length > 0
+34 -2
View File
@@ -1,9 +1,16 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { REGIME } from '../config/constants';
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
interface CacheFile {
data: MarketContext;
expiresAt: number;
}
export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
private static readonly CACHE_PATH = '.benchmark-cache.json';
private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000,
@@ -32,10 +39,33 @@ export class BenchmarkProvider {
private readonly client: YahooFinanceClient,
{ logger }: BenchmarkProviderOptions = {},
) {
this.cache = { data: null, expiresAt: 0 };
this.cache = this.loadDiskCache();
this.logger = logger ?? (console as unknown as Logger);
}
private loadDiskCache(): { data: MarketContext | null; expiresAt: number } {
try {
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
} catch {
// corrupt or missing — ignore
}
return { data: null, expiresAt: 0 };
}
private saveDiskCache(data: MarketContext, expiresAt: number): void {
try {
writeFileSync(
BenchmarkProvider.CACHE_PATH,
JSON.stringify({ data, expiresAt } satisfies CacheFile, null, 2),
'utf8',
);
} catch {
// non-fatal — in-memory cache still works
}
}
async getMarketContext(): Promise<MarketContext> {
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
@@ -75,7 +105,9 @@ export class BenchmarkProvider {
},
};
this.cache = { data: context, expiresAt: Date.now() + BenchmarkProvider.TTL_MS };
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
this.cache = { data: context, expiresAt };
this.saveDiskCache(context, expiresAt);
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);