Add smart money event calendar enrichment
This commit is contained in:
parent
6108aea166
commit
6b794ec7ac
11 changed files with 270 additions and 8 deletions
|
|
@ -2,6 +2,9 @@
|
|||
"name": "@islandflow/refdata",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./event-calendar": "./src/event-calendar.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts"
|
||||
},
|
||||
|
|
|
|||
116
services/refdata/src/event-calendar.ts
Normal file
116
services/refdata/src/event-calendar.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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;
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
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);
|
||||
|
||||
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)));
|
||||
};
|
||||
|
|
@ -1,10 +1,28 @@
|
|||
import { createLogger } from "@islandflow/observability";
|
||||
import { createEmptyEventCalendarProvider, loadEventCalendarProviderFromFile } from "./event-calendar";
|
||||
|
||||
const service = "refdata";
|
||||
const logger = createLogger({ service });
|
||||
|
||||
logger.info("service starting");
|
||||
|
||||
const eventCalendarPath = process.env.REFDATA_EVENT_CALENDAR_PATH ?? process.env.SMART_MONEY_EVENT_CALENDAR_PATH;
|
||||
|
||||
if (eventCalendarPath) {
|
||||
try {
|
||||
await loadEventCalendarProviderFromFile(eventCalendarPath);
|
||||
logger.info("event calendar loaded", { path: eventCalendarPath });
|
||||
} catch (error) {
|
||||
logger.warn("event calendar unavailable", {
|
||||
path: eventCalendarPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
createEmptyEventCalendarProvider();
|
||||
logger.info("event calendar disabled");
|
||||
}
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
logger.info("service stopping", { signal });
|
||||
process.exit(0);
|
||||
|
|
|
|||
31
services/refdata/tests/event-calendar.test.ts
Normal file
31
services/refdata/tests/event-calendar.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { createStaticEventCalendarProvider, parseEventCalendarEntries } from "../src/event-calendar";
|
||||
|
||||
describe("event calendar refdata", () => {
|
||||
it("parses provider rows and filters by timestamp availability", () => {
|
||||
const entries = parseEventCalendarEntries([
|
||||
{
|
||||
symbol: "aapl",
|
||||
event_date: "2025-01-31T21:00:00Z",
|
||||
event_kind: "earnings",
|
||||
announced_ts: "2025-01-20T21:00:00Z",
|
||||
source: "fixture"
|
||||
},
|
||||
{
|
||||
symbol: "AAPL",
|
||||
event_date: "2025-02-28T21:00:00Z",
|
||||
type: "mystery",
|
||||
announced_ts: "2025-02-01T21:00:00Z"
|
||||
}
|
||||
]);
|
||||
|
||||
const provider = createStaticEventCalendarProvider(entries);
|
||||
const beforeAnnouncement = provider.findNextEvent("AAPL", Date.parse("2025-01-15T15:00:00Z"));
|
||||
const afterAnnouncement = provider.findNextEvent("aapl", Date.parse("2025-01-21T15:00:00Z"));
|
||||
|
||||
expect(beforeAnnouncement).toBeNull();
|
||||
expect(afterAnnouncement?.event_kind).toBe("earnings");
|
||||
expect(afterAnnouncement?.underlying_id).toBe("AAPL");
|
||||
expect(afterAnnouncement?.days_to_event).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue