Files
market_screener/server/domains/shared/services/BenchmarkProvider.ts
T
2026-06-09 19:34:31 -04:00

156 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (25%), VIX 20 ⇒ NORMAL (1525).
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;
}
}
}