156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
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<MarketContext> {
|
||
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;
|
||
}
|
||
}
|
||
}
|