// 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(); 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; }