Files
investor-flow/app/server/src/analysis/seasonality.ts
T
Investor Flow Build e262187c3c 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
2026-07-23 18:02:24 -04:00

248 lines
8.0 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.
// 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;
}