Add Alpha Vantage event calendar provider
This commit is contained in:
parent
9bace6932e
commit
dd32be7717
8 changed files with 237 additions and 8 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
export type EventCalendarKind = "earnings" | "dividend" | "corporate_action" | "m_and_a" | "news" | "other";
|
||||
|
||||
export type EventCalendarEntry = {
|
||||
|
|
@ -17,7 +19,16 @@ 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",
|
||||
|
|
@ -47,6 +58,77 @@ const asNumber = (value: unknown): number | 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[] => {
|
||||
|
|
@ -114,3 +196,36 @@ export const loadEventCalendarProviderFromFile = async (path: string): Promise<E
|
|||
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`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { createLogger } from "@islandflow/observability";
|
||||
import { createEmptyEventCalendarProvider, loadEventCalendarProviderFromFile } from "./event-calendar";
|
||||
import {
|
||||
createEmptyEventCalendarProvider,
|
||||
fetchAlphaVantageEarningsCalendar,
|
||||
loadEventCalendarProviderFromFile,
|
||||
writeEventCalendarEntries,
|
||||
type AlphaVantageEarningsCalendarOptions
|
||||
} from "./event-calendar";
|
||||
|
||||
const service = "refdata";
|
||||
const logger = createLogger({ service });
|
||||
|
|
@ -7,6 +13,70 @@ const logger = createLogger({ service });
|
|||
logger.info("service starting");
|
||||
|
||||
const eventCalendarPath = process.env.REFDATA_EVENT_CALENDAR_PATH ?? process.env.SMART_MONEY_EVENT_CALENDAR_PATH;
|
||||
const eventCalendarProvider = process.env.REFDATA_EVENT_CALENDAR_PROVIDER ?? process.env.EVENT_CALENDAR_PROVIDER;
|
||||
const refreshMs = Math.max(0, Number(process.env.REFDATA_EVENT_CALENDAR_REFRESH_MS ?? 86_400_000) || 0);
|
||||
|
||||
const getAlphaVantageOptions = (): AlphaVantageEarningsCalendarOptions | null => {
|
||||
const apiKey = process.env.ALPHA_VANTAGE_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.warn("alpha vantage event calendar disabled; missing ALPHA_VANTAGE_API_KEY");
|
||||
return null;
|
||||
}
|
||||
|
||||
const horizon = process.env.ALPHA_VANTAGE_EARNINGS_HORIZON;
|
||||
return {
|
||||
apiKey,
|
||||
horizon: horizon === "6month" || horizon === "12month" ? horizon : "3month",
|
||||
symbol: process.env.ALPHA_VANTAGE_EARNINGS_SYMBOL || undefined
|
||||
};
|
||||
};
|
||||
|
||||
const refreshEventCalendar = async (): Promise<void> => {
|
||||
if (!eventCalendarPath) {
|
||||
logger.warn("event calendar refresh disabled; missing SMART_MONEY_EVENT_CALENDAR_PATH or REFDATA_EVENT_CALENDAR_PATH");
|
||||
return;
|
||||
}
|
||||
if (eventCalendarProvider !== "alpha_vantage") {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = getAlphaVantageOptions();
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await fetchAlphaVantageEarningsCalendar(options);
|
||||
await writeEventCalendarEntries(eventCalendarPath, entries);
|
||||
logger.info("event calendar refreshed", {
|
||||
provider: "alpha_vantage",
|
||||
path: eventCalendarPath,
|
||||
count: entries.length,
|
||||
horizon: options.horizon,
|
||||
symbol: options.symbol ?? "ALL"
|
||||
});
|
||||
};
|
||||
|
||||
if (eventCalendarProvider === "alpha_vantage") {
|
||||
try {
|
||||
await refreshEventCalendar();
|
||||
} catch (error) {
|
||||
logger.warn("event calendar refresh failed", {
|
||||
provider: "alpha_vantage",
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshMs > 0) {
|
||||
setInterval(() => {
|
||||
refreshEventCalendar().catch((error) => {
|
||||
logger.warn("event calendar refresh failed", {
|
||||
provider: "alpha_vantage",
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
});
|
||||
}, refreshMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventCalendarPath) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { createStaticEventCalendarProvider, parseEventCalendarEntries } from "../src/event-calendar";
|
||||
import {
|
||||
createStaticEventCalendarProvider,
|
||||
parseAlphaVantageEarningsCalendar,
|
||||
parseEventCalendarEntries
|
||||
} from "../src/event-calendar";
|
||||
|
||||
describe("event calendar refdata", () => {
|
||||
it("parses provider rows and filters by timestamp availability", () => {
|
||||
|
|
@ -28,4 +32,24 @@ describe("event calendar refdata", () => {
|
|||
expect(afterAnnouncement?.underlying_id).toBe("AAPL");
|
||||
expect(afterAnnouncement?.days_to_event).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("normalizes Alpha Vantage earnings CSV rows", () => {
|
||||
const entries = parseAlphaVantageEarningsCalendar(
|
||||
[
|
||||
"symbol,name,reportDate,fiscalDateEnding,estimate,currency",
|
||||
"aapl,Apple Inc,2025-01-31,2024-12-31,2.11,USD",
|
||||
"MSFT,Microsoft Corp,2025-02-05,2024-12-31,3.04,USD"
|
||||
].join("\n"),
|
||||
Date.parse("2025-01-15T12:00:00Z")
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toMatchObject({
|
||||
underlying_id: "AAPL",
|
||||
event_kind: "earnings",
|
||||
announced_ts: Date.parse("2025-01-15T12:00:00Z"),
|
||||
source: "alpha_vantage",
|
||||
source_event_id: "AAPL:2025-01-31:earnings"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue