fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+96
View File
@@ -0,0 +1,96 @@
// Investor Flow — FRED Adapter (Slice 20 / M18)
//
// SourceAdapter for FRED (Federal Reserve Economic Data).
// Cache with weekly staleness (macro data is slow-moving).
import type { CacheEntry } from '../cache/CacheRepository.ts';
export interface FredSeries {
seriesId: string;
title: string;
units: string;
observations: Array<{ date: string; value: number }>;
}
export interface FredAdapter {
sourceKind: 'fred';
fetchOne(key: string): Promise<CacheEntry<FredSeries | unknown>>;
series(seriesId: string): Promise<CacheEntry<FredSeries>>;
}
// ─── FRED API Client ────────────────────────────────────────────────────────
const FRED_BASE = 'https://api.stlouisfed.org/fred';
export class FredAdapterImpl implements FredAdapter {
sourceKind = 'fred' as const;
private apiKey: string;
constructor(apiKey?: string) {
this.apiKey = apiKey ?? process.env.FRED_API_KEY ?? '';
}
async fetchOne(key: string): Promise<CacheEntry<FredSeries | unknown>> {
// key format: "fred:series:{seriesId}"
const parts = key.split(':');
if (parts.length >= 3 && parts[0] === 'fred' && parts[1] === 'series') {
const seriesId = parts[2];
return this.series(seriesId);
}
throw new Error(`Unknown FRED cache key: ${key}`);
}
async series(seriesId: string): Promise<CacheEntry<FredSeries>> {
if (!this.apiKey) {
throw new Error('FRED_API_KEY not set. Cannot fetch FRED data.');
}
const url = `${FRED_BASE}/series/observations?series_id=${seriesId}&api_key=${this.apiKey}&file_type=json&limit=100&sort_order=desc`;
const resp = await fetch(url);
if (!resp.ok) {
let detail = '';
try {
const body = await resp.json() as { error_message?: string; error?: { message?: string } };
detail = body.error_message || body.error?.message || '';
} catch { /* non-JSON error body */ }
throw new Error(`FRED API error: ${resp.status} ${resp.statusText}${detail ? ` — ${detail}` : ''}`);
}
const data = await resp.json() as {
title?: string;
units?: string;
observations: Array<{ date: string; value: string }>;
};
const observations = data.observations
.filter((o) => o.value !== '.')
.map((o) => ({ date: o.date, value: parseFloat(o.value) }))
.reverse();
return {
value: {
seriesId,
title: data.title ?? seriesId,
units: data.units ?? '',
observations,
},
isStale: false,
provenance: {
fetchedAt: new Date().toISOString(),
sourceKind: 'fred',
},
};
}
}
// ─── Common FRED Series IDs ─────────────────────────────────────────────────
export const FRED_SERIES = {
GDP: 'GDP',
CPI: 'CPIAUCSL',
UNEMPLOYMENT: 'UNRATE',
TREASURY_10Y: 'GS10',
TREASURY_2Y: 'GS2',
FED_FUNDS: 'FEDFUNDS',
HEAVY_TRUCK_SALES: 'HTRUCKSSAAR',
MANUFACTURING_ACTIVITY: 'IPMAN',
} as const;
+208
View File
@@ -0,0 +1,208 @@
// Investor Flow — Macro Module (Slice 20 / M18)
//
// ADR-0007: No macro-trade recommendations. Alfred caution.
// Short-term commentary includes sample-size disclaimer.
//
// Pure/cache-deterministic: no I/O. Regime classification is pure logic.
// ─── Regime Classification ──────────────────────────────────────────────────
export type MarketRegime = 'trending-up' | 'trending-down' | 'range-bound';
export interface RegimeInput {
/** GDP growth rate (quarterly, annualized %). */
gdpGrowth?: number;
/** CPI inflation rate (YoY %). */
cpi?: number;
/** Unemployment rate (%). */
unemployment?: number;
/** 10Y Treasury yield (%). */
treasury10Y?: number;
/** VIX or volatility proxy. */
vix?: number;
/** S&P 500 50-day EMA slope (positive = up, negative = down). */
spyEmaSlope?: number;
}
export interface RegimeClassification {
regime: MarketRegime;
confidence: number; // 0-100
factors: string[];
explanation: string;
}
export function classifyRegime(input: RegimeInput): RegimeClassification {
const factors: string[] = [];
let upScore = 0;
let downScore = 0;
let rangeScore = 0;
// GDP growth
if (input.gdpGrowth !== undefined) {
if (input.gdpGrowth > 2.5) { upScore += 2; factors.push(`GDP growth ${input.gdpGrowth}% (above trend)`); }
else if (input.gdpGrowth < 1.0) { downScore += 2; factors.push(`GDP growth ${input.gdpGrowth}% (below trend)`); }
else { rangeScore += 1; factors.push(`GDP growth ${input.gdpGrowth}% (near trend)`); }
}
// CPI
if (input.cpi !== undefined) {
if (input.cpi > 4) { downScore += 1; factors.push(`CPI ${input.cpi}% (elevated inflation)`); }
else if (input.cpi < 1) { downScore += 1; factors.push(`CPI ${input.cpi}% (disinflation risk)`); }
else { upScore += 1; factors.push(`CPI ${input.cpi}% (moderate)`); }
}
// Unemployment
if (input.unemployment !== undefined) {
if (input.unemployment > 6) { downScore += 2; factors.push(`Unemployment ${input.unemployment}% (elevated)`); }
else if (input.unemployment < 4) { upScore += 1; factors.push(`Unemployment ${input.unemployment}% (tight labor market)`); }
else { rangeScore += 1; factors.push(`Unemployment ${input.unemployment}% (normal)`); }
}
// VIX
if (input.vix !== undefined) {
if (input.vix > 30) { downScore += 2; factors.push(`VIX ${input.vix} (elevated fear)`); }
else if (input.vix < 15) { upScore += 1; factors.push(`VIX ${input.vix} (complacent)`); }
else { rangeScore += 1; factors.push(`VIX ${input.vix} (normal)`); }
}
// SPY EMA slope
if (input.spyEmaSlope !== undefined) {
if (input.spyEmaSlope > 0.5) { upScore += 2; factors.push(`S&P 500 EMA slope positive`); }
else if (input.spyEmaSlope < -0.5) { downScore += 2; factors.push(`S&P 500 EMA slope negative`); }
else { rangeScore += 1; factors.push(`S&P 500 EMA slope flat`); }
}
const total = upScore + downScore + rangeScore;
let regime: MarketRegime;
let confidence: number;
if (upScore > downScore && upScore > rangeScore) {
regime = 'trending-up';
confidence = total > 0 ? Math.round((upScore / total) * 100) : 50;
} else if (downScore > upScore && downScore > rangeScore) {
regime = 'trending-down';
confidence = total > 0 ? Math.round((downScore / total) * 100) : 50;
} else {
regime = 'range-bound';
confidence = total > 0 ? Math.round((rangeScore / total) * 100) : 50;
}
return {
regime,
confidence,
factors,
explanation: `Regime classified as ${regime} with ${confidence}% confidence based on ${factors.length} factor(s).`,
};
}
// ─── Regime History ─────────────────────────────────────────────────────────
export interface RegimeHistoryEntry {
date: string;
regime: MarketRegime;
confidence: number;
factors: string[];
}
export function trackRegimeHistory(
history: RegimeHistoryEntry[],
classification: RegimeClassification,
date = new Date().toISOString(),
): RegimeHistoryEntry[] {
return [...history, {
date,
regime: classification.regime,
confidence: classification.confidence,
factors: classification.factors,
}];
}
export function detectRegimeShift(history: RegimeHistoryEntry[]): {
shifted: boolean;
from: MarketRegime | null;
to: MarketRegime | null;
date: string | null;
} {
if (history.length < 2) {
return { shifted: false, from: null, to: null, date: null };
}
const prev = history[history.length - 2];
const curr = history[history.length - 1];
if (prev.regime !== curr.regime) {
return { shifted: true, from: prev.regime, to: curr.regime, date: curr.date };
}
return { shifted: false, from: prev.regime, to: curr.regime, date: null };
}
// ─── Portfolio-Impact Commentary ────────────────────────────────────────────
export interface MacroCommentary {
shortTerm: string;
longTerm: string;
disclaimer: string;
}
const SHORT_TERM_NO_EVENTS =
'No major economic reports or central-bank meetings are scheduled in the near term. '
+ 'Any short-term price moves are more likely tied to company-specific news (earnings, product launches) '
+ 'or industry trends rather than broad economic data.';
function shortTermWithEvents(events: string): string {
return `Upcoming economic events that could move markets: ${events}. `
+ `When these reports come out, stock prices can swing quickly as investors adjust their expectations. `
+ `Keep an eye on your positions around these dates — but remember, each report is just one data point.`;
}
const LONG_TERM_MAP: Record<MarketRegime, string> = {
'trending-up':
'The broad economic picture is supportive for stocks and other "risk-on" assets '
+ '(investments that tend to do well when the economy is growing). '
+ 'GDP (the total value of goods and services the economy produces) is growing at an above-average pace. '
+ 'This creates a favorable environment, but it does not guarantee any individual stock will go up. '
+ 'Your job is still to pick the right companies within this backdrop.',
'trending-down':
'The broad economic picture is challenging for stocks. '
+ 'GDP growth may be slowing or shrinking, which tends to drag on corporate earnings. '
+ 'In this environment, focus on protecting your capital — consider the downside risk '
+ 'before adding new positions, and make sure your existing investments have strong reasons '
+ 'to hold up even if the economy weakens further.',
'range-bound':
'The broad economic picture is mixed — not clearly good or bad. '
+ 'GDP growth is near its long-term average, and there are no strong forces pushing '
+ 'the whole market sharply up or down. '
+ 'In this kind of environment, your research on individual companies matters more than '
+ 'trying to time the overall market. Focus on finding good businesses at reasonable prices.',
};
export function generateMacroCommentary(
classification: RegimeClassification,
upcomingEvents: string[] = [],
): MacroCommentary {
return {
shortTerm: upcomingEvents.length > 0
? shortTermWithEvents(upcomingEvents.join(', '))
: SHORT_TERM_NO_EVENTS,
longTerm: LONG_TERM_MAP[classification.regime],
disclaimer: 'This commentary is for educational purposes only and is not investment advice. '
+ 'Past performance does not guarantee future results. Always do your own research '
+ 'before making investment decisions.',
};
}
// ─── Economic Calendar Event ────────────────────────────────────────────────
export interface EconomicEvent {
date: string;
title: string;
impact: 'high' | 'medium' | 'low';
forecast?: string;
previous?: string;
}
export function formatCalendarEvents(events: EconomicEvent[]): string[] {
return events
.filter((e) => e.impact === 'high' || e.impact === 'medium')
.map((e) => `${e.date}: ${e.title} (${e.impact} impact)`);
}