231 lines
7.4 KiB
TypeScript
231 lines
7.4 KiB
TypeScript
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<EventCalendarKind>([
|
|
"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<string, string>[] => {
|
|
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<string, unknown>;
|
|
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<string, EventCalendarEntry[]>();
|
|
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<EventCalendarProvider> => {
|
|
const text = await Bun.file(path).text();
|
|
return createStaticEventCalendarProvider(parseEventCalendarEntries(JSON.parse(text)));
|
|
};
|
|
|
|
export const fetchAlphaVantageEarningsCalendar = async (
|
|
options: AlphaVantageEarningsCalendarOptions
|
|
): Promise<EventCalendarEntry[]> => {
|
|
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<void> => {
|
|
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`);
|
|
};
|