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