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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user