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
+247
View File
@@ -0,0 +1,247 @@
// Seasonality helpers — pure, cache-only.
// Beginner product language lives in API/UI; this module is math only.
// Historical averages are tendencies, not schedules.
export interface CandlePoint {
ts: string;
c: number;
}
export interface MonthSeasonality {
/** 1–12 */
month: number;
monthName: string;
/** Average monthly return % across sample years. */
avgReturnPct: number;
/** Fraction of years the month finished positive (0–1). */
winRate: number;
/** Number of years in the sample. */
sampleYears: number;
}
export interface SeasonalitySnapshot {
symbol: string;
months: MonthSeasonality[];
/** Current calendar month 1–12. */
currentMonth: number;
/** Avg return for the current month historically. */
currentMonthAvgPct: number | null;
currentMonthWinRate: number | null;
currentMonthSampleYears: number;
/** Half-year: Nov–Apr vs May–Oct classic window (educational). */
halfYear: {
winterAvgPct: number | null; // Nov–Apr
summerAvgPct: number | null; // May–Oct
whichHalf: 'winter' | 'summer';
};
/** Simple US election-cycle year type (calendar year). */
electionCycle: {
year: number;
yearInCycle: 1 | 2 | 3 | 4;
label: string;
};
/** Day-of-month position for turn-of-month note. */
calendar: {
dayOfMonth: number;
nearTurnOfMonth: boolean;
quarter: 1 | 2 | 3 | 4;
nearQuarterEnd: boolean;
};
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
/**
* Group daily closes into calendar-month returns: (monthEnd / monthStart) - 1.
* Incomplete current month is excluded so we do not bias with partial data.
*/
export function monthlyReturnsFromCandles(
candles: CandlePoint[],
now = new Date(),
): Array<{ year: number; month: number; returnPct: number }> {
if (candles.length < 5) return [];
const byYm = new Map<string, { first: number; last: number; year: number; month: number }>();
for (const c of candles) {
const d = new Date(c.ts);
if (!Number.isFinite(d.getTime()) || c.c <= 0) continue;
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
const key = `${year}-${month}`;
const row = byYm.get(key);
if (!row) {
byYm.set(key, { first: c.c, last: c.c, year, month });
} else {
row.last = c.c;
}
}
const curY = now.getUTCFullYear();
const curM = now.getUTCMonth() + 1;
const out: Array<{ year: number; month: number; returnPct: number }> = [];
for (const row of byYm.values()) {
if (row.year === curY && row.month === curM) continue; // skip incomplete month
if (row.first <= 0) continue;
out.push({
year: row.year,
month: row.month,
returnPct: ((row.last - row.first) / row.first) * 100,
});
}
return out;
}
export function aggregateMonthSeasonality(
monthly: Array<{ year: number; month: number; returnPct: number }>,
): MonthSeasonality[] {
const months: MonthSeasonality[] = [];
for (let m = 1; m <= 12; m++) {
const rows = monthly.filter((r) => r.month === m);
if (rows.length === 0) {
months.push({
month: m,
monthName: MONTH_NAMES[m - 1],
avgReturnPct: 0,
winRate: 0,
sampleYears: 0,
});
continue;
}
const avg = rows.reduce((s, r) => s + r.returnPct, 0) / rows.length;
const wins = rows.filter((r) => r.returnPct > 0).length;
months.push({
month: m,
monthName: MONTH_NAMES[m - 1],
avgReturnPct: Math.round(avg * 100) / 100,
winRate: wins / rows.length,
sampleYears: rows.length,
});
}
return months;
}
/** Election cycle: year after election = 1 … election year = 4. Uses US 4-year cycle from 1788. */
export function electionCycleYear(year: number): { yearInCycle: 1 | 2 | 3 | 4; label: string } {
// 2024 was election year → yearInCycle 4; 2025 = 1, 2026 = 2, 2027 = 3, 2028 = 4
const mod = ((year - 1788) % 4 + 4) % 4; // 0 = election year
const yearInCycle = (mod === 0 ? 4 : mod) as 1 | 2 | 3 | 4;
const labels: Record<1 | 2 | 3 | 4, string> = {
1: 'Year after the election',
2: 'Midterm year',
3: 'Pre-election year',
4: 'Election year',
};
return { yearInCycle, label: labels[yearInCycle] };
}
export function buildSeasonalitySnapshot(
symbol: string,
candles: CandlePoint[],
now = new Date(),
): SeasonalitySnapshot {
const monthly = monthlyReturnsFromCandles(candles, now);
const months = aggregateMonthSeasonality(monthly);
const currentMonth = now.getUTCMonth() + 1;
const cur = months.find((m) => m.month === currentMonth);
const winterMonths = [11, 12, 1, 2, 3, 4];
const summerMonths = [5, 6, 7, 8, 9, 10];
function avgFor(ms: number[]): number | null {
const rows = months.filter((m) => ms.includes(m.month) && m.sampleYears > 0);
if (rows.length === 0) return null;
return rows.reduce((s, m) => s + m.avgReturnPct, 0) / rows.length;
}
const winterAvgPct = avgFor(winterMonths);
const summerAvgPct = avgFor(summerMonths);
const whichHalf: 'winter' | 'summer' = winterMonths.includes(currentMonth) ? 'winter' : 'summer';
const dayOfMonth = now.getUTCDate();
const quarter = (Math.floor((currentMonth - 1) / 3) + 1) as 1 | 2 | 3 | 4;
const cycle = electionCycleYear(now.getUTCFullYear());
return {
symbol,
months,
currentMonth,
currentMonthAvgPct: cur && cur.sampleYears > 0 ? cur.avgReturnPct : null,
currentMonthWinRate: cur && cur.sampleYears > 0 ? cur.winRate : null,
currentMonthSampleYears: cur?.sampleYears ?? 0,
halfYear: {
winterAvgPct: winterAvgPct !== null ? Math.round(winterAvgPct * 100) / 100 : null,
summerAvgPct: summerAvgPct !== null ? Math.round(summerAvgPct * 100) / 100 : null,
whichHalf,
},
electionCycle: {
year: now.getUTCFullYear(),
yearInCycle: cycle.yearInCycle,
label: cycle.label,
},
calendar: {
dayOfMonth,
nearTurnOfMonth: dayOfMonth <= 3 || dayOfMonth >= 28,
quarter,
nearQuarterEnd: [3, 6, 9, 12].includes(currentMonth) && dayOfMonth >= 20,
},
};
}
/** Static high-impact US macro windows (month/day ranges) for a beginner calendar. */
export interface SimpleCalendarEvent {
id: string;
title: string;
when: string;
impact: 'high' | 'medium';
plainWhy: string;
}
export function upcomingSimpleEvents(now = new Date()): SimpleCalendarEvent[] {
// Approximate recurring anchors (not exact Fed calendar). Educational only.
const y = now.getUTCFullYear();
const m = now.getUTCMonth() + 1;
const events: SimpleCalendarEvent[] = [
{
id: 'cpi',
title: 'Inflation report (CPI)',
when: 'Usually mid-month',
impact: 'high',
plainWhy: 'Tells how fast prices are rising. Can move interest-rate expectations and the whole stock market.',
},
{
id: 'nfp',
title: 'Jobs report',
when: 'Usually the first Friday of the month',
impact: 'high',
plainWhy: 'Shows how many jobs the economy added. Strong or weak jobs numbers can shift rate and growth views.',
},
{
id: 'fomc',
title: 'Fed interest-rate meeting',
when: 'About every 6–8 weeks',
impact: 'high',
plainWhy: 'The Fed sets short-term policy rates. Markets often reprice around the decision and press conference.',
},
{
id: 'earnings',
title: 'Company earnings season',
when: m % 3 === 1 ? 'Active or starting this quarter' : 'Concentrated after each quarter ends',
impact: 'medium',
plainWhy: 'Lots of companies report results in the same weeks. Single stocks can swing more than usual.',
},
];
// Highlight quarter-end window dressing educational note.
if ([3, 6, 9, 12].includes(m)) {
events.push({
id: 'quarter-end',
title: 'End of the quarter',
when: `Around end of ${MONTH_NAMES[m - 1]} ${y}`,
impact: 'medium',
plainWhy: 'Some funds tidy portfolios before reports. Can create short-term trading noise, not always a new trend.',
});
}
return events;
}