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:
@@ -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;
|
||||
@@ -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)`);
|
||||
}
|
||||
Reference in New Issue
Block a user