import { readFileSync, writeFileSync, existsSync } from 'fs'; import { YahooFinanceClient } from '../adapters/YahooFinanceClient'; import { REGIME } from '../config/constants'; import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types/index'; 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'; // NOTE: regimes must stay consistent with rateRegime()/volRegime() below — // 4.5% ⇒ NORMAL (2–5%), VIX 20 ⇒ NORMAL (15–25). private static readonly DEFAULTS: MarketContext = { sp500Price: 5000, riskFreeRate: 4.5, vixLevel: 20, rateRegime: 'NORMAL', volatilityRegime: 'NORMAL', benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 }, }; /** Hysteresis band: the 10Y must cross a regime boundary by this much to flip. */ private static readonly REGIME_HYSTERESIS = 0.25; private static rateRegime(rate: number): MarketContext['rateRegime'] { return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH; } /** * Rate regime with hysteresis (PRODUCT.md P0.5). * * The raw thresholds (2% / 5%) flip the INFLATED scoring gates between * back-to-back requests when the 10Y hovers near a boundary. With a known * previous regime, the rate must cross the boundary by ±0.25% before the * regime switches. A two-step jump (LOW→HIGH) applies immediately. * Public static for direct unit testing. */ static resolveRateRegime( rate: number, previous: MarketContext['rateRegime'] | null, ): MarketContext['rateRegime'] { const raw = BenchmarkProvider.rateRegime(rate); if (!previous || raw === previous) return raw; const h = BenchmarkProvider.REGIME_HYSTERESIS; if (previous === REGIME.NORMAL && raw === REGIME.HIGH) return rate > 5 + h ? REGIME.HIGH : REGIME.NORMAL; if (previous === REGIME.HIGH && raw === REGIME.NORMAL) return rate < 5 - h ? REGIME.NORMAL : REGIME.HIGH; if (previous === REGIME.NORMAL && raw === REGIME.LOW) return rate < 2 - h ? REGIME.LOW : REGIME.NORMAL; if (previous === REGIME.LOW && raw === REGIME.NORMAL) return rate > 2 + h ? REGIME.NORMAL : REGIME.LOW; return raw; // LOW↔HIGH double jump — no damping } private static volRegime(vix: number): MarketContext['volatilityRegime'] { return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH; } private static pe(summary: any): number | null { return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null; } private cache: { data: MarketContext | null; expiresAt: number }; private logger: Logger; /** Last known rate regime — survives cache expiry so hysteresis has memory. */ private lastRegime: MarketContext['rateRegime'] | null = null; constructor( private readonly client: YahooFinanceClient, { logger }: BenchmarkProviderOptions = {}, ) { 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; // Even an expired cache remembers the previous regime for hysteresis this.lastRegime = file.data?.rateRegime ?? null; 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 { if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data; try { const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([ this.client.fetchSummary('^GSPC'), this.client.fetchSummary('^TNX'), this.client.fetchSummary('^VIX'), this.client.fetchSummary('SPY'), this.client.fetchSummary('XLK'), this.client.fetchSummary('XLRE'), this.client.fetchSummary('LQD'), ]); const riskFreeRate = (sp500 as any)?.price?.regularMarketPrice !== undefined ? ((tn10y as any)?.price?.regularMarketPrice ?? 0) : 0; const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0; const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0; if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)'); const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100; const context: MarketContext = { sp500Price, riskFreeRate, vixLevel, rateRegime: BenchmarkProvider.resolveRateRegime(riskFreeRate, this.lastRegime), volatilityRegime: BenchmarkProvider.volRegime(vixLevel), benchmarks: { marketPE: BenchmarkProvider.pe(spy) ?? 22, techPE: BenchmarkProvider.pe(xlk) ?? 30, reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100, igSpread: Math.max(0.1, lqdYield - riskFreeRate), }, }; const expiresAt = Date.now() + BenchmarkProvider.TTL_MS; this.cache = { data: context, expiresAt }; this.lastRegime = context.rateRegime; this.saveDiskCache(context, expiresAt); return context; } catch (err) { this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message); return this.cache.data ?? BenchmarkProvider.DEFAULTS; } } }