islandflow/services/ingest-options/src/enrichment.ts

125 lines
4.1 KiB
TypeScript

import {
OptionPrintSchema,
classifyOptionNbboSide,
deriveOptionPrintMetadata,
evaluateOptionSignal,
type EquityQuote,
type OptionNBBO,
type OptionPrint,
type OptionsSignalConfig
} from "@islandflow/types";
export const MAX_CONTEXT_HISTORY = 64;
export type ContextHistory<T extends { ts: number; seq: number }> = Map<string, T[]>;
export const rememberContext = <T extends { ts: number; seq: number }>(
history: ContextHistory<T>,
key: string,
value: T
): void => {
const bucket = history.get(key) ?? [];
const existingIndex = bucket.findIndex((item) => item.ts === value.ts && item.seq === value.seq);
if (existingIndex >= 0) {
bucket[existingIndex] = value;
} else {
bucket.push(value);
}
bucket.sort((a, b) => {
const delta = a.ts - b.ts;
return delta !== 0 ? delta : a.seq - b.seq;
});
if (bucket.length > MAX_CONTEXT_HISTORY) {
bucket.splice(0, bucket.length - MAX_CONTEXT_HISTORY);
}
history.set(key, bucket);
};
export const selectAtOrBefore = <T extends { ts: number; seq: number }>(
items: readonly T[] | undefined,
ts: number
): T | null => {
if (!items?.length) {
return null;
}
let selected: T | null = null;
for (const item of items) {
if (item.ts > ts) {
continue;
}
if (!selected || item.ts > selected.ts || (item.ts === selected.ts && item.seq >= selected.seq)) {
selected = item;
}
}
return selected;
};
export const enrichOptionPrint = (
rawPrint: OptionPrint,
optionQuote: OptionNBBO | null | undefined,
equityQuote: EquityQuote | null | undefined,
config: OptionsSignalConfig
): OptionPrint => {
const derived = deriveOptionPrintMetadata(rawPrint, optionQuote, config);
const executionNbboSide = optionQuote
? classifyOptionNbboSide(rawPrint.price, optionQuote, rawPrint.ts, config.nbboMaxAgeMs)
: undefined;
const nbboMid =
optionQuote && Number.isFinite(optionQuote.bid) && Number.isFinite(optionQuote.ask)
? Number(((optionQuote.bid + optionQuote.ask) / 2).toFixed(4))
: undefined;
const nbboSpread =
optionQuote && Number.isFinite(optionQuote.bid) && Number.isFinite(optionQuote.ask)
? Number(Math.max(0, optionQuote.ask - optionQuote.bid).toFixed(4))
: undefined;
const underlyingMid =
equityQuote && Number.isFinite(equityQuote.bid) && Number.isFinite(equityQuote.ask)
? Number(((equityQuote.bid + equityQuote.ask) / 2).toFixed(4))
: undefined;
const underlyingSpread =
equityQuote && Number.isFinite(equityQuote.bid) && Number.isFinite(equityQuote.ask)
? Number(Math.max(0, equityQuote.ask - equityQuote.bid).toFixed(4))
: undefined;
const enrichedForSignal: OptionPrint = {
...rawPrint,
...derived,
nbbo_side: executionNbboSide ?? derived.nbbo_side,
...(optionQuote
? {
execution_nbbo_bid: optionQuote.bid,
execution_nbbo_ask: optionQuote.ask,
execution_nbbo_mid: nbboMid,
execution_nbbo_spread: nbboSpread,
execution_nbbo_bid_size: optionQuote.bidSize,
execution_nbbo_ask_size: optionQuote.askSize,
execution_nbbo_ts: optionQuote.ts,
execution_nbbo_age_ms: rawPrint.ts - optionQuote.ts,
execution_nbbo_side: executionNbboSide,
nbbo_side: executionNbboSide
}
: {}),
...(equityQuote && underlyingMid !== undefined
? {
execution_underlying_spot: underlyingMid,
execution_underlying_bid: equityQuote.bid,
execution_underlying_ask: equityQuote.ask,
execution_underlying_mid: underlyingMid,
execution_underlying_spread: underlyingSpread,
execution_underlying_ts: equityQuote.ts,
execution_underlying_age_ms: rawPrint.ts - equityQuote.ts,
execution_underlying_source: "equity_quote_mid" as const
}
: {}),
signal_profile: config.mode
};
const signalDecision = evaluateOptionSignal(enrichedForSignal, config);
return OptionPrintSchema.parse({
...enrichedForSignal,
signal_pass: signalDecision.signalPass,
signal_reasons: signalDecision.signalReasons,
signal_profile: signalDecision.signalProfile
});
};