import { mkdir } from "node:fs/promises"; export type EventCalendarKind = "earnings" | "dividend" | "corporate_action" | "m_and_a" | "news" | "other"; export type EventCalendarEntry = { underlying_id: string; event_ts: number; event_kind: EventCalendarKind; announced_ts: number; source?: string; source_event_id?: string; }; export type EventCalendarMatch = EventCalendarEntry & { days_to_event: number; }; export type EventCalendarProvider = { findNextEvent(underlyingId: string, asOfTs: number): EventCalendarMatch | null; }; export type AlphaVantageEarningsCalendarOptions = { apiKey: string; horizon?: "3month" | "6month" | "12month"; symbol?: string; nowTs?: number; fetchFn?: typeof fetch; }; const MS_PER_DAY = 86_400_000; const ALPHA_VANTAGE_URL = "https://www.alphavantage.co/query"; const EVENT_KINDS = new Set([ "earnings", "dividend", "corporate_action", "m_and_a", "news", "other" ]); const normalizeUnderlying = (underlyingId: string): string => underlyingId.trim().toUpperCase(); const asNumber = (value: unknown): number | null => { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim()) { const parsed = Number(value); if (Number.isFinite(parsed)) { return parsed; } const ts = Date.parse(value); return Number.isFinite(ts) ? ts : null; } return null; }; const asString = (value: unknown): string | null => (typeof value === "string" && value.trim() ? value.trim() : null); const parseCsvLine = (line: string): string[] => { const values: string[] = []; let current = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; const next = line[index + 1]; if (char === '"' && quoted && next === '"') { current += '"'; index += 1; } else if (char === '"') { quoted = !quoted; } else if (char === "," && !quoted) { values.push(current); current = ""; } else { current += char; } } values.push(current); return values.map((value) => value.trim()); }; const parseCsv = (csv: string): Record[] => { const lines = csv .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const [headerLine, ...dataLines] = lines; if (!headerLine) { return []; } const headers = parseCsvLine(headerLine); return dataLines.map((line) => { const values = parseCsvLine(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])); }); }; export const parseAlphaVantageEarningsCalendar = ( csv: string, announcedTs: number = Date.now() ): EventCalendarEntry[] => { return parseCsv(csv).flatMap((row): EventCalendarEntry[] => { const symbol = asString(row.symbol); const reportDate = asString(row.reportDate); if (!symbol || !reportDate) { return []; } const eventTs = Date.parse(`${reportDate}T21:00:00Z`); if (!Number.isFinite(eventTs)) { return []; } return [ { underlying_id: normalizeUnderlying(symbol), event_ts: eventTs, event_kind: "earnings", announced_ts: Math.trunc(announcedTs), source: "alpha_vantage", source_event_id: `${normalizeUnderlying(symbol)}:${reportDate}:earnings` } ]; }); }; export const parseEventCalendarEntries = (value: unknown): EventCalendarEntry[] => { const rows = Array.isArray(value) ? value : []; return rows.flatMap((row): EventCalendarEntry[] => { if (!row || typeof row !== "object") { return []; } const record = row as Record; const underlying = asString(record.underlying_id ?? record.underlying ?? record.symbol); const eventTs = asNumber(record.event_ts ?? record.event_time ?? record.event_date); const announcedTs = asNumber(record.announced_ts ?? record.available_ts ?? record.as_of_ts ?? record.created_ts) ?? 0; const rawKind = asString(record.event_kind ?? record.kind ?? record.type) ?? "other"; const eventKind = EVENT_KINDS.has(rawKind as EventCalendarKind) ? (rawKind as EventCalendarKind) : "other"; if (!underlying || eventTs === null || eventTs < 0 || announcedTs < 0) { return []; } return [ { underlying_id: normalizeUnderlying(underlying), event_ts: Math.trunc(eventTs), event_kind: eventKind, announced_ts: Math.trunc(announcedTs), ...(asString(record.source) ? { source: asString(record.source) ?? undefined } : {}), ...(asString(record.source_event_id ?? record.id) ? { source_event_id: asString(record.source_event_id ?? record.id) ?? undefined } : {}) } ]; }); }; export const createStaticEventCalendarProvider = (entries: EventCalendarEntry[]): EventCalendarProvider => { const byUnderlying = new Map(); for (const entry of entries) { const key = normalizeUnderlying(entry.underlying_id); const normalized = { ...entry, underlying_id: key }; const bucket = byUnderlying.get(key) ?? []; bucket.push(normalized); byUnderlying.set(key, bucket); } for (const bucket of byUnderlying.values()) { bucket.sort((a, b) => a.event_ts - b.event_ts || a.announced_ts - b.announced_ts); } return { findNextEvent(underlyingId, asOfTs) { const key = normalizeUnderlying(underlyingId); if (!key || !Number.isFinite(asOfTs)) { return null; } const bucket = byUnderlying.get(key) ?? []; const entry = bucket.find((candidate) => candidate.announced_ts <= asOfTs && candidate.event_ts >= asOfTs); return entry ? { ...entry, days_to_event: (entry.event_ts - asOfTs) / MS_PER_DAY } : null; } }; }; export const createEmptyEventCalendarProvider = (): EventCalendarProvider => createStaticEventCalendarProvider([]); export const loadEventCalendarProviderFromFile = async (path: string): Promise => { const text = await Bun.file(path).text(); return createStaticEventCalendarProvider(parseEventCalendarEntries(JSON.parse(text))); }; export const fetchAlphaVantageEarningsCalendar = async ( options: AlphaVantageEarningsCalendarOptions ): Promise => { const horizon = options.horizon ?? "3month"; const url = new URL(ALPHA_VANTAGE_URL); url.searchParams.set("function", "EARNINGS_CALENDAR"); url.searchParams.set("horizon", horizon); url.searchParams.set("apikey", options.apiKey); if (options.symbol) { url.searchParams.set("symbol", normalizeUnderlying(options.symbol)); } const response = await (options.fetchFn ?? fetch)(url); const text = await response.text(); if (!response.ok) { throw new Error(`Alpha Vantage earnings calendar request failed: ${response.status} ${text.slice(0, 160)}`); } if (/^(?:\s*\{|\s*Thank you for using Alpha Vantage)/i.test(text)) { throw new Error(`Alpha Vantage returned a non-calendar response: ${text.slice(0, 200)}`); } return parseAlphaVantageEarningsCalendar(text, options.nowTs ?? Date.now()); }; export const writeEventCalendarEntries = async (path: string, entries: EventCalendarEntry[]): Promise => { const directory = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; if (directory) { await mkdir(directory, { recursive: true }); } const file = Bun.file(path); await Bun.write(file, `${JSON.stringify(entries, null, 2)}\n`); };