Files
worldmonitor/src/services/daily-market-brief.ts
Elie Habib 2939b1f4a1 feat(finance-panels): add 7 macro/market panels + Daily Brief context (issues #2245-#2253) (#2258)
* feat(fear-greed): add regime state label, action stance badge, divergence warnings

Closes #2245

* feat(finance-panels): add 7 new finance panels + Daily Brief macro context

Implements issues #2245 (F&G Regime), #2246 (Sector Heatmap bars),
#2247 (MacroTiles), #2248 (FSI), #2249 (Yield Curve), #2250 (Earnings
Calendar), #2251 (Economic Calendar), #2252 (COT Positioning),
#2253 (Daily Brief prompt extension).

New panels:
- MacroTilesPanel: CPI YoY, Unemployment, GDP, Fed Rate tiles via FRED
- FSIPanel: Financial Stress Indicator gauge (HYG/TLT/VIX/HY-spread)
- YieldCurvePanel: SVG yield curve chart with inverted/normal badge
- EarningsCalendarPanel: Finnhub earnings calendar with BMO/AMC/BEAT/MISS
- EconomicCalendarPanel: FOMC/CPI/NFP events with impact badges
- CotPositioningPanel: CFTC disaggregated COT positioning bars
- MarketPanel: adds sorted bar chart view above sector heatmap grid

New RPCs:
- ListEarningsCalendar (market/v1)
- GetCotPositioning (market/v1)
- GetEconomicCalendar (economic/v1)

Seed scripts:
- seed-earnings-calendar.mjs (Finnhub, 14-day window, TTL 12h)
- seed-economic-calendar.mjs (Finnhub, 30-day window, TTL 12h)
- seed-cot.mjs (CFTC disaggregated text file, TTL 7d)
- seed-economy.mjs: adds yield curve tenors DGS1MO/3MO/6MO/1/2/5/30
- seed-fear-greed.mjs: adds FSI computation + sector performance

Daily Brief: extends buildDailyMarketBrief with optional regime,
yield curve, and sector context fed to the LLM summarization prompt.

All panels default enabled in FINANCE_PANELS, disabled in FULL_PANELS.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(finance-panels): address code review P1/P2 findings

P1 - Security/Correctness:
- EconomicCalendarPanel: add escapeHtml on all 7 Finnhub-sourced fields
- EconomicCalendarPanel: fix panel contract (public fetchData():boolean,
  remove constructor self-init, add retry callbacks to all showError calls)
- YieldCurvePanel: fix NaN in xPos() when count <= 1 (divide-by-zero)
- seed-earnings-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-economic-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-earnings-calendar: add isMain guard around runSeed() call
- health.js + bootstrap.js: register earningsCalendar, econCalendar, cotPositioning keys
- health.js dataSize(): add earnings + instruments to property name list

P2 - Quality:
- FSIPanel: change !resp.fsiValue → resp.fsiValue <= 0 (rejects valid zero)
- data-loader: fix Promise.allSettled type inference via indexed destructure
- seed-fear-greed: allowlist cnnLabel against known values before writing to Redis
- seed-economic-calendar: remove unused sleep import
- seed-earnings-calendar + econ-calendar: increase TTL 43200 → 129600 (36h = 3x interval)
- YieldCurvePanel: use SERIES_IDS const in RPC call (single source of truth)

* fix(bootstrap): remove on-demand panel keys from bootstrap.js

earningsCalendar, econCalendar, cotPositioning panels fetch via RPC
on demand — they have no getHydratedData consumer in src/ and must
not be in api/bootstrap.js. They remain in api/health.js BOOTSTRAP_KEYS
for staleness monitoring.

* fix(compound-engineering): fix markdown lint error in local settings

* fix(finance-panels): resolve all P3 code-review findings

- 030: MacroTilesPanel: add `deltaFormat?` field to MacroTile interface,
  define per-tile delta formatters (CPI pp, GDP localeString+B),
  replace fragile tile.id switch in tileHtml with fmt = deltaFormat ?? format
- 031: FSIPanel: check getHydratedData('fearGreedIndex') at top of
  fetchData(); extract fsi/vix/hySpread from headerMetrics and render
  synchronously; fall back to live RPC only when bootstrap absent
- 032: All 6 finance panels: extract lazy module-level client singletons
  (EconomicServiceClient or MarketServiceClient) so the client is
  constructed at most once per panel module lifetime, not on every fetchData
- 033: get-fred-series-batch: add BAMLC0A0CM and SOFR to ALLOWED_SERIES
  (both seeded by seed-economy.mjs but previously unreachable via RPC)

* fix(finance-panels): health.js SEED_META, FSI calibration, seed-cot catch handler

- health.js: add SEED_META entries for earningsCalendar (1440min), econCalendar
  (1440min), cotPositioning (14400min) — without these, stopped seeds only alarm
  CRIT:EMPTY after TTL expiry instead of earlier WARN:STALE_SEED
- seed-cot.mjs: replace bare await with .catch() handler consistent with other seeds
- seed-fear-greed.mjs: recalibrate FSI thresholds to match formula output range
  (Low>=1.5, Moderate>=0.8, Elevated>=0.3; old values >=0.08/0.05/0.03 were
  calibrated for [0,0.15] but formula yields ~1-2 in normal conditions)
- FSIPanel.ts: fix gauge fillPct range to [0, 2.5] matching recalibrated thresholds
- todos: fix MD022/MD032 markdown lint errors in P3 review files

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-03-26 08:03:09 +04:00

456 lines
16 KiB
TypeScript

import type { MarketData, NewsItem } from '@/types';
import type { MarketWatchlistEntry } from './market-watchlist';
import { getMarketWatchlistEntries } from './market-watchlist';
import type { SummarizationResult } from './summarization';
export interface DailyMarketBriefItem {
symbol: string;
name: string;
display: string;
price: number | null;
change: number | null;
stance: 'bullish' | 'neutral' | 'defensive';
note: string;
relatedHeadline?: string;
}
export interface DailyMarketBrief {
available: boolean;
title: string;
dateKey: string;
timezone: string;
summary: string;
actionPlan: string;
riskWatch: string;
items: DailyMarketBriefItem[];
provider: string;
model: string;
fallback: boolean;
generatedAt: string;
headlineCount: number;
}
export interface RegimeMacroContext {
compositeScore: number;
compositeLabel: string;
fsiValue: number;
fsiLabel: string;
vix: number;
hySpread: number;
cnnFearGreed: number;
cnnLabel: string;
momentum?: { score: number };
sentiment?: { score: number };
}
export interface YieldCurveContext {
inverted: boolean;
spread2s10s: number;
rate2y: number;
rate10y: number;
rate30y: number;
}
export interface SectorBriefContext {
topName: string;
topChange: number;
worstName: string;
worstChange: number;
countPositive: number;
total: number;
}
export interface BuildDailyMarketBriefOptions {
markets: MarketData[];
newsByCategory: Record<string, NewsItem[]>;
timezone?: string;
now?: Date;
targets?: MarketWatchlistEntry[];
regimeContext?: RegimeMacroContext;
yieldCurveContext?: YieldCurveContext;
sectorContext?: SectorBriefContext;
summarize?: (
headlines: string[],
onProgress?: undefined,
geoContext?: string,
lang?: string,
) => Promise<SummarizationResult | null>;
}
async function getDefaultSummarizer(): Promise<NonNullable<BuildDailyMarketBriefOptions['summarize']>> {
const { generateSummary } = await import('./summarization');
return generateSummary;
}
async function getPersistentCacheApi(): Promise<{
getPersistentCache: <T>(key: string) => Promise<{ data: T } | null>;
setPersistentCache: <T>(key: string, data: T) => Promise<void>;
}> {
const { getPersistentCache, setPersistentCache } = await import('./persistent-cache');
return { getPersistentCache, setPersistentCache };
}
const CACHE_PREFIX = 'premium:daily-market-brief:v1';
const DEFAULT_SCHEDULE_HOUR = 8;
const DEFAULT_TARGET_COUNT = 4;
const BRIEF_NEWS_CATEGORIES = ['markets', 'economic', 'crypto', 'finance'];
const COMMON_NAME_TOKENS = new Set(['inc', 'corp', 'group', 'holdings', 'company', 'companies', 'class', 'common', 'plc', 'limited', 'ltd', 'adr']);
function resolveTimeZone(timezone?: string): string {
const candidate = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
try {
Intl.DateTimeFormat('en-US', { timeZone: candidate }).format(new Date());
return candidate;
} catch {
return 'UTC';
}
}
function getLocalDateParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string } {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: resolveTimeZone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hour12: false,
});
const parts = formatter.formatToParts(date);
const read = (type: string): string => parts.find((part) => part.type === type)?.value || '';
return {
year: read('year'),
month: read('month'),
day: read('day'),
hour: read('hour'),
};
}
function getDateKey(date: Date, timezone: string): string {
const parts = getLocalDateParts(date, timezone);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function getLocalHour(date: Date, timezone: string): number {
return Number.parseInt(getLocalDateParts(date, timezone).hour || '0', 10) || 0;
}
function formatTitleDate(date: Date, timezone: string): string {
return new Intl.DateTimeFormat('en-US', {
timeZone: resolveTimeZone(timezone),
month: 'short',
day: 'numeric',
}).format(date);
}
function sanitizeCacheKeyPart(value: string): string {
return value.replace(/[^a-z0-9/_-]+/gi, '-').toLowerCase();
}
function getCacheKey(timezone: string): string {
return `${CACHE_PREFIX}:${sanitizeCacheKeyPart(resolveTimeZone(timezone))}`;
}
function isMeaningfulToken(token: string): boolean {
return token.length >= 3 && !COMMON_NAME_TOKENS.has(token);
}
function getSymbolTokens(item: Pick<MarketData, 'symbol' | 'display' | 'name'>): string[] {
const raw = [
item.symbol,
item.display,
...item.name.toLowerCase().split(/[^a-z0-9]+/gi),
];
const out: string[] = [];
const seen = new Set<string>();
for (const token of raw) {
const normalized = token.trim().toLowerCase();
if (!isMeaningfulToken(normalized) || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
}
function matchesMarketHeadline(market: Pick<MarketData, 'symbol' | 'display' | 'name'>, title: string): boolean {
const normalizedTitle = title.toLowerCase();
return getSymbolTokens(market).some((token) => {
if (token.length <= 4) {
return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(normalizedTitle);
}
return normalizedTitle.includes(token);
});
}
function collectHeadlinePool(newsByCategory: Record<string, NewsItem[]>): NewsItem[] {
return BRIEF_NEWS_CATEGORIES
.flatMap((category) => newsByCategory[category] || [])
.filter((item) => !!item?.title)
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
}
function resolveTargets(markets: MarketData[], explicitTargets?: MarketWatchlistEntry[]): MarketData[] {
const explicitEntries = explicitTargets?.length ? explicitTargets : null;
const watchlistEntries = explicitEntries ? null : getMarketWatchlistEntries();
const targetEntries = explicitEntries || (watchlistEntries && watchlistEntries.length > 0 ? watchlistEntries : []);
const bySymbol = new Map(markets.map((market) => [market.symbol, market]));
const resolved: MarketData[] = [];
const seen = new Set<string>();
for (const entry of targetEntries) {
const match = bySymbol.get(entry.symbol);
if (!match || seen.has(match.symbol)) continue;
seen.add(match.symbol);
resolved.push(match);
if (resolved.length >= DEFAULT_TARGET_COUNT) return resolved;
}
if (!explicitEntries && !(watchlistEntries && watchlistEntries.length > 0)) {
for (const market of markets) {
if (seen.has(market.symbol)) continue;
seen.add(market.symbol);
resolved.push(market);
if (resolved.length >= DEFAULT_TARGET_COUNT) break;
}
}
return resolved;
}
function getStance(change: number | null): DailyMarketBriefItem['stance'] {
if (typeof change !== 'number') return 'neutral';
if (change >= 1) return 'bullish';
if (change <= -1) return 'defensive';
return 'neutral';
}
function formatSignedPercent(value: number | null): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return 'flat';
const sign = value > 0 ? '+' : '';
return `${sign}${value.toFixed(1)}%`;
}
function buildItemNote(change: number | null, relatedHeadline?: string): string {
const stance = getStance(change);
const moveNote = stance === 'bullish'
? 'Momentum is constructive; favor leaders over laggards.'
: stance === 'defensive'
? 'Price action is under pressure; protect capital first.'
: 'Tape is balanced; wait for confirmation before pressing size.';
return relatedHeadline
? `${moveNote} Headline driver: ${relatedHeadline}`
: moveNote;
}
function buildRuleSummary(items: DailyMarketBriefItem[], headlineCount: number): string {
const bullish = items.filter((item) => item.stance === 'bullish').length;
const defensive = items.filter((item) => item.stance === 'defensive').length;
const neutral = items.length - bullish - defensive;
const bias = bullish > defensive
? 'Risk appetite is leaning positive across the tracked watchlist.'
: defensive > bullish
? 'The watchlist is trading defensively and breadth is soft.'
: 'The watchlist is mixed and conviction is limited.';
const breadth = `Leaders: ${bullish}, neutral setups: ${neutral}, defensive names: ${defensive}.`;
const headlines = headlineCount > 0
? `News flow remains active with ${headlineCount} relevant headline${headlineCount === 1 ? '' : 's'} in scope.`
: 'Headline flow is thin, so price action matters more than narrative today.';
return `${bias} ${breadth} ${headlines}`;
}
function buildActionPlan(items: DailyMarketBriefItem[], headlineCount: number): string {
const bullish = items.filter((item) => item.stance === 'bullish').length;
const defensive = items.filter((item) => item.stance === 'defensive').length;
if (defensive > bullish) {
return headlineCount > 0
? 'Keep gross exposure light, wait for downside to stabilize, and let macro headlines clear before adding risk.'
: 'Keep exposure light and wait for price to reclaim short-term momentum before adding risk.';
}
if (bullish >= 2) {
return headlineCount > 0
? 'Lean into relative strength, but size entries around macro releases and company-specific headlines.'
: 'Lean into the strongest names on pullbacks and avoid chasing extended opening moves.';
}
return 'Stay selective, trade the cleanest relative-strength setups, and let index direction confirm before scaling.';
}
function buildRiskWatch(items: DailyMarketBriefItem[], headlines: NewsItem[]): string {
const defensive = items.filter((item) => item.stance === 'defensive').map((item) => item.display);
const headlineTitles = headlines.slice(0, 2).map((item) => item.title);
if (defensive.length > 0 && headlineTitles.length > 0) {
return `Watch ${defensive.join(', ')} for further weakness while monitoring: ${headlineTitles.join(' | ')}`;
}
if (defensive.length > 0) {
return `Watch ${defensive.join(', ')} for further weakness and avoid averaging into fading momentum.`;
}
if (headlineTitles.length > 0) {
return `Headline watch: ${headlineTitles.join(' | ')}`;
}
return 'Risk watch is centered on macro follow-through, index breadth, and any abrupt reversal in the strongest names.';
}
function buildSummaryInputs(items: DailyMarketBriefItem[], headlines: NewsItem[]): { headlines: string[]; marketContext: string } {
const marketContext = items.map((item) => {
const change = formatSignedPercent(item.change);
return `${item.name} (${item.display}) ${change}`;
}).join(', ');
const headlineLines = headlines.slice(0, 6).map((item) => item.title.trim()).filter(Boolean);
return { headlines: headlineLines, marketContext };
}
function buildExtendedMarketContext(
baseContext: string,
regime?: RegimeMacroContext,
yieldCurve?: YieldCurveContext,
sector?: SectorBriefContext,
): string {
const parts: string[] = [`Markets: ${baseContext}`];
if (regime && regime.compositeScore > 0) {
const lines = [
`Fear & Greed: ${regime.compositeScore.toFixed(0)} (${regime.compositeLabel})`,
];
if (regime.fsiValue > 0) lines.push(`FSI: ${regime.fsiValue.toFixed(2)} (${regime.fsiLabel})`);
if (regime.vix > 0) lines.push(`VIX: ${regime.vix.toFixed(1)}`);
if (regime.hySpread > 0) lines.push(`HY Spread: ${regime.hySpread.toFixed(0)}bps`);
if (regime.cnnFearGreed > 0) lines.push(`CNN F&G: ${regime.cnnFearGreed.toFixed(0)} (${regime.cnnLabel})`);
if (regime.momentum) lines.push(`Momentum: ${regime.momentum.score.toFixed(0)}/100`);
if (regime.sentiment) lines.push(`Sentiment: ${regime.sentiment.score.toFixed(0)}/100`);
parts.push(`Market Stress Indicators:\n${lines.join('\n')}`);
}
if (yieldCurve && yieldCurve.rate10y > 0) {
const spreadStr = (yieldCurve.spread2s10s >= 0 ? '+' : '') + yieldCurve.spread2s10s.toFixed(0);
parts.push([
`Yield Curve: ${yieldCurve.inverted ? 'INVERTED' : 'NORMAL'} (2s/10s ${spreadStr}bps)`,
`2Y: ${yieldCurve.rate2y.toFixed(2)}% 10Y: ${yieldCurve.rate10y.toFixed(2)}% 30Y: ${yieldCurve.rate30y.toFixed(2)}%`,
].join('\n'));
}
if (sector && sector.total > 0) {
const topSign = sector.topChange >= 0 ? '+' : '';
const worstSign = sector.worstChange >= 0 ? '+' : '';
parts.push([
`Sectors: ${sector.countPositive}/${sector.total} positive`,
`Top: ${sector.topName} ${topSign}${sector.topChange.toFixed(1)}% Worst: ${sector.worstName} ${worstSign}${sector.worstChange.toFixed(1)}%`,
].join('\n'));
}
return parts.join('\n\n');
}
export function shouldRefreshDailyBrief(
brief: DailyMarketBrief | null | undefined,
timezone = 'UTC',
now = new Date(),
scheduleHour = DEFAULT_SCHEDULE_HOUR,
): boolean {
if (!brief?.available) return true;
const resolvedTimezone = resolveTimeZone(timezone || brief.timezone);
const dateKey = getDateKey(now, resolvedTimezone);
if (brief.dateKey === dateKey) return false;
return getLocalHour(now, resolvedTimezone) >= scheduleHour;
}
export async function getCachedDailyMarketBrief(timezone?: string): Promise<DailyMarketBrief | null> {
const resolvedTimezone = resolveTimeZone(timezone);
const { getPersistentCache } = await getPersistentCacheApi();
const envelope = await getPersistentCache<DailyMarketBrief>(getCacheKey(resolvedTimezone));
return envelope?.data ?? null;
}
export async function cacheDailyMarketBrief(brief: DailyMarketBrief): Promise<void> {
const { setPersistentCache } = await getPersistentCacheApi();
await setPersistentCache(getCacheKey(brief.timezone), brief);
}
export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOptions): Promise<DailyMarketBrief> {
const now = options.now || new Date();
const timezone = resolveTimeZone(options.timezone);
const trackedMarkets = resolveTargets(options.markets, options.targets).slice(0, DEFAULT_TARGET_COUNT);
const relevantHeadlines = collectHeadlinePool(options.newsByCategory);
const items: DailyMarketBriefItem[] = trackedMarkets.map((market) => {
const relatedHeadline = relevantHeadlines.find((headline) => matchesMarketHeadline(market, headline.title))?.title;
return {
symbol: market.symbol,
name: market.name,
display: market.display,
price: market.price,
change: market.change,
stance: getStance(market.change),
note: buildItemNote(market.change, relatedHeadline),
...(relatedHeadline ? { relatedHeadline } : {}),
};
});
if (items.length === 0) {
return {
available: false,
title: `Daily Market Brief • ${formatTitleDate(now, timezone)}`,
dateKey: getDateKey(now, timezone),
timezone,
summary: 'Market data is not available yet for the daily brief.',
actionPlan: '',
riskWatch: '',
items: [],
provider: 'rules',
model: '',
fallback: true,
generatedAt: now.toISOString(),
headlineCount: 0,
};
}
const { headlines: summaryHeadlines, marketContext } = buildSummaryInputs(items, relevantHeadlines);
const extendedContext = buildExtendedMarketContext(marketContext, options.regimeContext, options.yieldCurveContext, options.sectorContext);
let summary = buildRuleSummary(items, relevantHeadlines.length);
let provider = 'rules';
let model = '';
let fallback = true;
if (summaryHeadlines.length >= 1) {
try {
const summaryProvider = options.summarize || await getDefaultSummarizer();
const generated = await summaryProvider(
summaryHeadlines,
undefined,
extendedContext,
'en',
);
if (generated?.summary) {
summary = generated.summary.trim();
provider = generated.provider;
model = generated.model;
fallback = false;
}
} catch (err) {
console.warn('[DailyBrief] AI summarization failed, using rules-based fallback:', (err as Error).message);
}
}
return {
available: true,
title: `Daily Market Brief • ${formatTitleDate(now, timezone)}`,
dateKey: getDateKey(now, timezone),
timezone,
summary,
actionPlan: buildActionPlan(items, relevantHeadlines.length),
riskWatch: buildRiskWatch(items, relevantHeadlines),
items,
provider,
model,
fallback,
generatedAt: now.toISOString(),
headlineCount: relevantHeadlines.length,
};
}