Add smart-money option signal path and tape filters

This commit is contained in:
dirtydishes 2026-04-28 16:29:44 -04:00
parent 758f111d7e
commit 27b0a399e6
23 changed files with 1827 additions and 175 deletions

View file

@ -1,5 +1,7 @@
export const STREAM_OPTION_PRINTS = "OPTIONS_PRINTS";
export const SUBJECT_OPTION_PRINTS = "options.prints";
export const STREAM_OPTION_SIGNAL_PRINTS = "OPTIONS_SIGNAL_PRINTS";
export const SUBJECT_OPTION_SIGNAL_PRINTS = "options.prints.signal";
export const STREAM_OPTION_NBBO = "OPTIONS_NBBO";
export const SUBJECT_OPTION_NBBO = "options.nbbo";
export const STREAM_EQUITY_PRINTS = "EQUITY_PRINTS";

View file

@ -20,11 +20,14 @@ import type {
InferredDarkEvent,
FlowPacket,
OptionNBBO,
OptionPrint
OptionPrint,
OptionFlowFilters,
OptionFlowView
} from "@islandflow/types";
import {
normalizeOptionPrint,
optionPrintsTableDDL,
optionPrintsTableMigrations,
OPTION_PRINTS_TABLE
} from "./option-prints";
import { normalizeOptionNBBO, optionNBBOTableDDL, OPTION_NBBO_TABLE } from "./option-nbbo";
@ -221,6 +224,9 @@ export const ensureOptionPrintsTable = async (
await client.exec({
query: optionPrintsTableDDL()
});
for (const query of optionPrintsTableMigrations()) {
await client.exec({ query });
}
};
export const ensureOptionNBBOTable = async (
@ -499,19 +505,78 @@ const normalizeNumericFields = (
const normalizeOptionRow = (row: unknown): unknown => {
if (row && typeof row === "object") {
return normalizeNumericFields(row as Record<string, unknown>, [
const record = normalizeNumericFields(row as Record<string, unknown>, [
"source_ts",
"ingest_ts",
"seq",
"ts",
"price",
"size"
"size",
"notional"
]);
if ("is_etf" in record) {
record.is_etf = Boolean(record.is_etf);
}
if ("signal_pass" in record) {
record.signal_pass = Boolean(record.signal_pass);
}
if (record.signal_reasons == null) {
record.signal_reasons = [];
}
return record;
}
return row;
};
export type OptionPrintQueryFilters = {
view?: OptionFlowView;
minNotional?: number;
security?: "stock" | "etf" | "all";
optionTypes?: string[];
nbboSides?: string[];
};
const buildOptionPrintFilterConditions = (
filters: OptionPrintQueryFilters | undefined,
tracePrefix: string | undefined
): string[] => {
const conditions: string[] = [];
const traceCondition = buildTracePrefixCondition(tracePrefix);
if (traceCondition) {
conditions.push(traceCondition);
}
if (!filters) {
return conditions;
}
if ((filters.view ?? "signal") === "signal") {
conditions.push("signal_pass = 1");
}
if (typeof filters.minNotional === "number" && Number.isFinite(filters.minNotional)) {
conditions.push(`notional >= ${filters.minNotional}`);
}
if (filters.security === "stock") {
conditions.push("(is_etf = 0 OR is_etf IS NULL)");
} else if (filters.security === "etf") {
conditions.push("is_etf = 1");
}
if (filters.optionTypes && filters.optionTypes.length > 0) {
conditions.push(`option_type IN (${buildStringList(filters.optionTypes)})`);
}
if (filters.nbboSides && filters.nbboSides.length > 0) {
conditions.push(`nbbo_side IN (${buildStringList(filters.nbboSides)})`);
}
return conditions;
};
const normalizeOptionNbboRow = (row: unknown): unknown => {
if (row && typeof row === "object") {
return normalizeNumericFields(row as Record<string, unknown>, [
@ -683,11 +748,12 @@ const normalizeAlertRow = (row: unknown): AlertRecord | null => {
export const fetchRecentOptionPrints = async (
client: ClickHouseClient,
limit: number,
tracePrefix?: string
tracePrefix?: string,
filters?: OptionPrintQueryFilters
): Promise<OptionPrint[]> => {
const safeLimit = clampLimit(limit);
const condition = buildTracePrefixCondition(tracePrefix);
const whereClause = condition ? ` WHERE ${condition}` : "";
const conditions = buildOptionPrintFilterConditions(filters, tracePrefix);
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
const result = await client.query({
query: `SELECT * FROM ${OPTION_PRINTS_TABLE}${whereClause} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
format: "JSONEachRow"
@ -855,16 +921,19 @@ export const fetchOptionPrintsAfter = async (
afterTs: number,
afterSeq: number,
limit: number,
tracePrefix?: string
tracePrefix?: string,
filters?: OptionPrintQueryFilters
): Promise<OptionPrint[]> => {
const safeLimit = clampLimit(limit);
const safeAfterTs = clampCursor(afterTs);
const safeAfterSeq = clampCursor(afterSeq);
const traceCondition = buildTracePrefixCondition(tracePrefix);
const traceClause = traceCondition ? ` AND ${traceCondition}` : "";
const conditions = [
`((ts, seq) > (${safeAfterTs}, ${safeAfterSeq}))`,
...buildOptionPrintFilterConditions(filters, tracePrefix)
];
const result = await client.query({
query: `SELECT * FROM ${OPTION_PRINTS_TABLE} WHERE (ts, seq) > (${safeAfterTs}, ${safeAfterSeq})${traceClause} ORDER BY ts ASC, seq ASC LIMIT ${safeLimit}`,
query: `SELECT * FROM ${OPTION_PRINTS_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ts ASC, seq ASC LIMIT ${safeLimit}`,
format: "JSONEachRow"
});
@ -1122,14 +1191,14 @@ export const fetchOptionPrintsBefore = async (
beforeTs: number,
beforeSeq: number,
limit: number,
tracePrefix?: string
tracePrefix?: string,
filters?: OptionPrintQueryFilters
): Promise<OptionPrint[]> => {
const safeLimit = clampLimit(limit);
const conditions = [buildBeforeTupleCondition("ts", "seq", beforeTs, beforeSeq)];
const traceCondition = buildTracePrefixCondition(tracePrefix);
if (traceCondition) {
conditions.push(traceCondition);
}
const conditions = [
buildBeforeTupleCondition("ts", "seq", beforeTs, beforeSeq),
...buildOptionPrintFilterConditions(filters, tracePrefix)
];
const result = await client.query({
query: `SELECT * FROM ${OPTION_PRINTS_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,

View file

@ -14,16 +14,38 @@ CREATE TABLE IF NOT EXISTS ${OPTION_PRINTS_TABLE} (
price Float64,
size UInt32,
exchange String,
conditions Array(String)
conditions Array(String),
underlying_id Nullable(String),
option_type Nullable(String),
notional Nullable(Float64),
nbbo_side Nullable(String),
is_etf Nullable(Bool),
signal_pass Nullable(Bool),
signal_reasons Array(String) DEFAULT [],
signal_profile Nullable(String)
)
ENGINE = MergeTree
ORDER BY (ts, option_contract_id)
`;
};
export const optionPrintsTableMigrations = (): string[] => {
return [
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS underlying_id Nullable(String)`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS option_type Nullable(String)`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS notional Nullable(Float64)`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS nbbo_side Nullable(String)`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS is_etf Nullable(Bool)`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_pass Nullable(Bool)`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_reasons Array(String) DEFAULT []`,
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_profile Nullable(String)`
];
};
export const normalizeOptionPrint = (print: OptionPrint): OptionPrint => {
return {
...print,
conditions: print.conditions ?? []
conditions: print.conditions ?? [],
signal_reasons: print.signal_reasons ?? []
};
};

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from "bun:test";
import { createClickHouseClient, fetchOptionPrintsBefore, fetchOptionPrintsByTraceIds } from "../src/clickhouse";
import {
createClickHouseClient,
fetchOptionPrintsBefore,
fetchOptionPrintsByTraceIds,
fetchRecentOptionPrints
} from "../src/clickhouse";
import { normalizeOptionPrint, optionPrintsTableDDL, OPTION_PRINTS_TABLE } from "../src/option-prints";
const basePrint = {
@ -38,12 +43,24 @@ describe("option-prints storage helpers", () => {
};
};
await fetchRecentOptionPrints(client, 25, undefined, {
view: "signal",
security: "stock",
nbboSides: ["AA", "A"],
optionTypes: ["call"],
minNotional: 25_000
});
await fetchOptionPrintsBefore(client, 100, 5, 20, "alpaca");
await fetchOptionPrintsByTraceIds(client, ["trace-1", "trace-2"]);
expect(queries[0]).toContain("(ts, seq) < (100, 5)");
expect(queries[0]).toContain("startsWith(trace_id, 'alpaca')");
expect(queries[0]).toContain("ORDER BY ts DESC, seq DESC LIMIT 20");
expect(queries[1]).toContain("trace_id IN ('trace-1', 'trace-2')");
expect(queries[0]).toContain("signal_pass = 1");
expect(queries[0]).toContain("(is_etf = 0 OR is_etf IS NULL)");
expect(queries[0]).toContain("nbbo_side IN ('AA', 'A')");
expect(queries[0]).toContain("option_type IN ('call')");
expect(queries[0]).toContain("notional >= 25000");
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
expect(queries[1]).toContain("startsWith(trace_id, 'alpaca')");
expect(queries[1]).toContain("ORDER BY ts DESC, seq DESC LIMIT 20");
expect(queries[2]).toContain("trace_id IN ('trace-1', 'trace-2')");
});
});

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { OptionNbboSideSchema, OptionTypeSchema, OptionsSignalModeSchema } from "./options-flow";
export const EventMetaSchema = z.object({
source_ts: z.number().int().nonnegative(),
@ -16,7 +17,18 @@ export const OptionPrintSchema = EventMetaSchema.merge(
price: z.number().nonnegative(),
size: z.number().int().positive(),
exchange: z.string().min(1),
conditions: z.array(z.string().min(1)).optional()
conditions: z.array(z.string().min(1)).optional(),
underlying_id: z.preprocess((value) => (value === null ? undefined : value), z.string().min(1).optional()),
option_type: z.preprocess((value) => (value === null ? undefined : value), OptionTypeSchema.optional()),
notional: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()),
nbbo_side: z.preprocess((value) => (value === null ? undefined : value), OptionNbboSideSchema.optional()),
is_etf: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()),
signal_pass: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()),
signal_reasons: z.array(z.string().min(1)).optional(),
signal_profile: z.preprocess(
(value) => (value === null ? undefined : value),
OptionsSignalModeSchema.optional()
)
})
);

View file

@ -1,3 +1,4 @@
export * from "./events";
export * from "./live";
export * from "./options-flow";
export * from "./sp500";

View file

@ -10,6 +10,10 @@ import {
OptionNBBOSchema,
OptionPrintSchema
} from "./events";
import {
OptionFlowFiltersSchema,
optionFlowFilterKey
} from "./options-flow";
export const CursorSchema = z.object({
ts: z.number().int().nonnegative(),
@ -47,7 +51,15 @@ export type LiveGenericChannel = z.infer<typeof LiveGenericChannelSchema>;
export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [
z.object({
channel: LiveGenericChannelSchema
channel: z.literal("options"),
filters: OptionFlowFiltersSchema.optional()
}),
z.object({
channel: z.literal("flow"),
filters: OptionFlowFiltersSchema.optional()
}),
z.object({
channel: z.enum(["nbbo", "equities", "equity-joins", "classifier-hits", "alerts", "inferred-dark"])
}),
z.object({
channel: z.literal("equity-candles"),
@ -165,6 +177,9 @@ export type LiveServerMessage = z.infer<typeof LiveServerMessageSchema>;
export const getSubscriptionKey = (subscription: LiveSubscription): string => {
switch (subscription.channel) {
case "options":
case "flow":
return `${subscription.channel}|${optionFlowFilterKey(subscription.filters)}`;
case "equity-candles":
return `${subscription.channel}|${subscription.underlying_id}|${subscription.interval_ms}`;
case "equity-overlay":

View file

@ -0,0 +1,464 @@
import { z } from "zod";
import type { FlowPacket, OptionNBBO, OptionPrint } from "./events";
export const SyntheticMarketModeSchema = z.enum(["realistic", "active", "firehose"]);
export type SyntheticMarketMode = z.infer<typeof SyntheticMarketModeSchema>;
export const OptionTypeSchema = z.enum(["call", "put"]);
export type OptionType = z.infer<typeof OptionTypeSchema>;
export const OptionNbboSideSchema = z.enum(["AA", "A", "MID", "B", "BB", "MISSING", "STALE"]);
export type OptionNbboSide = z.infer<typeof OptionNbboSideSchema>;
export const OptionFlowViewSchema = z.enum(["signal", "raw"]);
export type OptionFlowView = z.infer<typeof OptionFlowViewSchema>;
export const OptionSecurityTypeSchema = z.enum(["stock", "etf"]);
export type OptionSecurityType = z.infer<typeof OptionSecurityTypeSchema>;
export const OptionsSignalModeSchema = z.enum(["smart-money", "balanced", "all"]);
export type OptionsSignalMode = z.infer<typeof OptionsSignalModeSchema>;
export const OptionFlowFiltersSchema = z.object({
view: OptionFlowViewSchema.optional(),
securityTypes: z.array(OptionSecurityTypeSchema).optional(),
nbboSides: z.array(OptionNbboSideSchema).optional(),
optionTypes: z.array(OptionTypeSchema).optional(),
minNotional: z.number().nonnegative().optional()
});
export type OptionFlowFilters = z.infer<typeof OptionFlowFiltersSchema>;
export type ParsedOptionContract = {
root: string;
expiry: string;
strike: number;
right: "C" | "P";
};
export type SyntheticModeResolution = {
market: SyntheticMarketMode;
options: SyntheticMarketMode;
equities: SyntheticMarketMode;
};
export type OptionsSignalConfig = {
mode: OptionsSignalMode;
minNotional: number;
etfMinNotional: number;
bidSideMinNotional: number;
midMinNotional: number;
missingNbboMinNotional: number;
largePrintMinSize: number;
largePrintMinNotional: number;
sweepMinNotional: number;
autoKeepMinNotional: number;
nbboMaxAgeMs: number;
etfUnderlyings: Set<string>;
};
export type DerivedOptionPrintMetadata = {
underlying_id?: string;
option_type?: OptionType;
notional?: number;
nbbo_side?: OptionNbboSide;
is_etf?: boolean;
};
export type OptionSignalDecision = {
signalPass: boolean;
signalReasons: string[];
signalProfile: OptionsSignalMode;
};
const parseDashedContract = (value: string): ParsedOptionContract | null => {
const parts = value.split("-");
if (parts.length < 6) {
return null;
}
const rightRaw = parts.at(-1) ?? "";
if (rightRaw !== "C" && rightRaw !== "P") {
return null;
}
const strikeRaw = parts.at(-2) ?? "";
const strike = Number(strikeRaw);
const expiryParts = parts.slice(-5, -2);
const expiry = expiryParts.join("-");
const root = parts.slice(0, -5).join("-");
if (!root || !expiry || !Number.isFinite(strike)) {
return null;
}
return {
root,
expiry,
strike,
right: rightRaw
};
};
const parseOccContract = (value: string): ParsedOptionContract | null => {
if (value.length < 15) {
return null;
}
const tail = value.slice(-15);
const root = value.slice(0, -15).trim();
const expiryRaw = tail.slice(0, 6);
const right = tail.slice(6, 7);
const strikeRaw = tail.slice(7);
if (!/^\d{6}$/.test(expiryRaw) || !/^\d{8}$/.test(strikeRaw)) {
return null;
}
if (right !== "C" && right !== "P") {
return null;
}
const year = 2000 + Number(expiryRaw.slice(0, 2));
const month = Number(expiryRaw.slice(2, 4)) - 1;
const day = Number(expiryRaw.slice(4, 6));
const expiryDate = new Date(Date.UTC(year, month, day));
const expiry = expiryDate.toISOString().slice(0, 10);
const strike = Number(strikeRaw) / 1000;
if (!root || !Number.isFinite(strike)) {
return null;
}
return {
root,
expiry,
strike,
right
};
};
export const parseOptionContractId = (value: string | undefined): ParsedOptionContract | null => {
if (!value) {
return null;
}
return parseDashedContract(value) ?? parseOccContract(value);
};
export const resolveSyntheticMarketModes = (input: {
syntheticMarketMode?: string | null | undefined;
syntheticOptionsMode?: string | null | undefined;
syntheticEquitiesMode?: string | null | undefined;
}): SyntheticModeResolution => {
const market = SyntheticMarketModeSchema.catch("realistic").parse(
input.syntheticMarketMode ?? "realistic"
);
const options = SyntheticMarketModeSchema.catch(market).parse(
input.syntheticOptionsMode ?? market
);
const equities = SyntheticMarketModeSchema.catch(market).parse(
input.syntheticEquitiesMode ?? market
);
return { market, options, equities };
};
export const classifyOptionNbboSide = (
price: number,
quote: Pick<OptionNBBO, "bid" | "ask" | "ts"> | null | undefined,
tradeTs: number,
maxAgeMs: number
): OptionNbboSide => {
if (!quote || !Number.isFinite(price)) {
return "MISSING";
}
const bid = quote.bid;
const ask = quote.ask;
if (!Number.isFinite(bid) || !Number.isFinite(ask) || ask <= 0) {
return "MISSING";
}
const ageMs = Math.abs(tradeTs - quote.ts);
if (ageMs > maxAgeMs) {
return "STALE";
}
const spread = Math.max(0, ask - bid);
const epsilon = Math.max(0.01, spread * 0.05);
if (price > ask + epsilon) {
return "AA";
}
if (price >= ask - epsilon) {
return "A";
}
if (price < bid - epsilon) {
return "BB";
}
if (price <= bid + epsilon) {
return "B";
}
return "MID";
};
export const deriveOptionPrintMetadata = (
print: Pick<OptionPrint, "option_contract_id" | "price" | "size" | "ts">,
quote: Pick<OptionNBBO, "bid" | "ask" | "ts"> | null | undefined,
config: Pick<OptionsSignalConfig, "nbboMaxAgeMs" | "etfUnderlyings">
): DerivedOptionPrintMetadata => {
const parsed = parseOptionContractId(print.option_contract_id);
const underlying = parsed?.root?.toUpperCase();
const optionType = parsed?.right === "C" ? "call" : parsed?.right === "P" ? "put" : undefined;
const notional = Number.isFinite(print.price) && Number.isFinite(print.size)
? Number((print.price * print.size * 100).toFixed(2))
: undefined;
return {
underlying_id: underlying,
option_type: optionType,
notional,
nbbo_side: classifyOptionNbboSide(print.price, quote, print.ts, config.nbboMaxAgeMs),
is_etf: underlying ? config.etfUnderlyings.has(underlying) : undefined
};
};
const hasCondition = (conditions: string[] | undefined, expected: string): boolean => {
return (conditions ?? []).some((condition) => condition.toUpperCase() === expected);
};
const balancedThresholds = (config: OptionsSignalConfig): OptionsSignalConfig => ({
...config,
minNotional: Math.min(config.minNotional, 5_000),
etfMinNotional: Math.min(config.etfMinNotional, 25_000),
bidSideMinNotional: Math.min(config.bidSideMinNotional, 15_000),
midMinNotional: Math.min(config.midMinNotional, 12_500),
missingNbboMinNotional: Math.min(config.missingNbboMinNotional, 25_000),
sweepMinNotional: Math.min(config.sweepMinNotional, 15_000),
autoKeepMinNotional: Math.min(config.autoKeepMinNotional, 75_000)
});
export const evaluateOptionSignal = (
print: Pick<
OptionPrint,
"size" | "conditions" | "signal_profile" | "underlying_id" | "option_type" | "notional" | "nbbo_side" | "is_etf"
>,
baseConfig: OptionsSignalConfig
): OptionSignalDecision => {
const mode = print.signal_profile ?? baseConfig.mode;
if (mode === "all") {
return {
signalPass: true,
signalReasons: ["mode:all"],
signalProfile: "all"
};
}
const config = mode === "balanced" ? balancedThresholds(baseConfig) : baseConfig;
const reasons: string[] = [];
const notional = print.notional ?? 0;
const side = print.nbbo_side ?? "MISSING";
const isSweepOrIso = hasCondition(print.conditions, "SWEEP") || hasCondition(print.conditions, "ISO");
if (notional < config.minNotional) {
return {
signalPass: false,
signalReasons: ["reject:min-notional"],
signalProfile: mode
};
}
if (notional >= config.autoKeepMinNotional) {
reasons.push("keep:auto-large");
}
if (print.is_etf && notional < config.etfMinNotional) {
return {
signalPass: false,
signalReasons: ["reject:etf-min-notional"],
signalProfile: mode
};
}
if ((side === "B" || side === "BB") && notional < config.bidSideMinNotional) {
return {
signalPass: false,
signalReasons: ["reject:bid-side-min-notional"],
signalProfile: mode
};
}
if (side === "MID" && !isSweepOrIso && notional < config.midMinNotional) {
return {
signalPass: false,
signalReasons: ["reject:mid-min-notional"],
signalProfile: mode
};
}
if ((side === "MISSING" || side === "STALE") && notional < config.missingNbboMinNotional) {
return {
signalPass: false,
signalReasons: ["reject:missing-nbbo-min-notional"],
signalProfile: mode
};
}
if ((side === "A" || side === "AA") && notional >= config.minNotional) {
reasons.push("keep:ask-side");
}
if (isSweepOrIso && notional >= config.sweepMinNotional) {
reasons.push("keep:sweep-or-iso");
}
if (print.size >= config.largePrintMinSize && notional >= config.largePrintMinNotional) {
reasons.push("keep:large-contract-count");
}
if (reasons.length === 0) {
return {
signalPass: false,
signalReasons: ["reject:no-signal-rule"],
signalProfile: mode
};
}
return {
signalPass: true,
signalReasons: reasons,
signalProfile: mode
};
};
const sortStrings = (values: string[] | undefined): string[] | undefined => {
if (!values || values.length === 0) {
return undefined;
}
return [...new Set(values)].sort();
};
export const normalizeOptionFlowFilters = (
filters: OptionFlowFilters | undefined
): OptionFlowFilters | undefined => {
if (!filters) {
return undefined;
}
return {
view: filters.view,
securityTypes: sortStrings(filters.securityTypes) as OptionSecurityType[] | undefined,
nbboSides: sortStrings(filters.nbboSides) as OptionNbboSide[] | undefined,
optionTypes: sortStrings(filters.optionTypes) as OptionType[] | undefined,
minNotional:
typeof filters.minNotional === "number" && Number.isFinite(filters.minNotional)
? filters.minNotional
: undefined
};
};
export const optionFlowFilterKey = (filters: OptionFlowFilters | undefined): string => {
return JSON.stringify(normalizeOptionFlowFilters(filters) ?? {});
};
export const matchesOptionPrintFilters = (
print: Pick<OptionPrint, "is_etf" | "nbbo_side" | "option_type" | "notional" | "signal_pass">,
filters: OptionFlowFilters | undefined
): boolean => {
if (!filters) {
return true;
}
const view = filters.view ?? "signal";
if (view === "signal" && print.signal_pass === false) {
return false;
}
if (filters.securityTypes?.length) {
const securityType: OptionSecurityType = print.is_etf ? "etf" : "stock";
if (!filters.securityTypes.includes(securityType)) {
return false;
}
}
if (filters.nbboSides?.length) {
const side = print.nbbo_side ?? "MISSING";
if (!filters.nbboSides.includes(side)) {
return false;
}
}
if (filters.optionTypes?.length) {
const optionType = print.option_type;
if (!optionType || !filters.optionTypes.includes(optionType)) {
return false;
}
}
if (typeof filters.minNotional === "number" && (print.notional ?? 0) < filters.minNotional) {
return false;
}
return true;
};
export const matchesFlowPacketFilters = (
packet: FlowPacket,
filters: OptionFlowFilters | undefined
): boolean => {
if (!filters) {
return true;
}
const features = packet.features ?? {};
const totalNotional = typeof features.total_notional === "number" ? features.total_notional : Number(features.total_notional ?? 0);
if (typeof filters.minNotional === "number" && (!Number.isFinite(totalNotional) || totalNotional < filters.minNotional)) {
return false;
}
if (filters.securityTypes?.length) {
const isEtf = typeof features.is_etf === "boolean" ? features.is_etf : features.is_etf === 1;
const securityType: OptionSecurityType = isEtf ? "etf" : "stock";
if (!filters.securityTypes.includes(securityType)) {
return false;
}
}
if (filters.optionTypes?.length) {
const optionType =
typeof features.option_type === "string"
? features.option_type
: typeof features.structure_rights === "string"
? features.structure_rights.toLowerCase()
: null;
if (
!optionType ||
!filters.optionTypes.some((selected) => optionType.includes(selected))
) {
return false;
}
}
if (filters.nbboSides?.length) {
const sideToFeature: Record<OptionNbboSide, string> = {
AA: "nbbo_aa_count",
A: "nbbo_a_count",
MID: "nbbo_mid_count",
B: "nbbo_b_count",
BB: "nbbo_bb_count",
MISSING: "nbbo_missing_count",
STALE: "nbbo_stale_count"
};
const matchesSide = filters.nbboSides.some((side) => {
const value = features[sideToFeature[side]];
return typeof value === "number" ? value > 0 : Number(value ?? 0) > 0;
});
if (!matchesSide) {
return false;
}
}
return true;
};

View file

@ -8,7 +8,21 @@ import {
describe("live protocol types", () => {
it("builds stable keys for generic and parameterized subscriptions", () => {
expect(getSubscriptionKey({ channel: "flow" })).toBe("flow");
expect(getSubscriptionKey({ channel: "flow" })).toBe("flow|{}");
expect(
getSubscriptionKey({
channel: "options",
filters: {
view: "signal",
securityTypes: ["stock"],
nbboSides: ["A", "AA"],
optionTypes: ["call", "put"],
minNotional: 25000
}
})
).toBe(
'options|{"view":"signal","securityTypes":["stock"],"nbboSides":["A","AA"],"optionTypes":["call","put"],"minNotional":25000}'
);
expect(
getSubscriptionKey({
channel: "equity-candles",
@ -25,7 +39,7 @@ describe("live protocol types", () => {
const parsed = LiveClientMessageSchema.parse({
op: "subscribe",
subscriptions: [
{ channel: "flow" },
{ channel: "flow", filters: { nbboSides: ["AA", "A"], minNotional: 50000 } },
{ channel: "equity-candles", underlying_id: "SPY", interval_ms: 60000 }
]
});

View file

@ -0,0 +1,132 @@
import { describe, expect, it } from "bun:test";
import {
deriveOptionPrintMetadata,
evaluateOptionSignal,
resolveSyntheticMarketModes,
type OptionsSignalConfig
} from "../src/options-flow";
const baseConfig: OptionsSignalConfig = {
mode: "smart-money",
minNotional: 10_000,
etfMinNotional: 50_000,
bidSideMinNotional: 25_000,
midMinNotional: 20_000,
missingNbboMinNotional: 50_000,
largePrintMinSize: 500,
largePrintMinNotional: 10_000,
sweepMinNotional: 25_000,
autoKeepMinNotional: 100_000,
nbboMaxAgeMs: 1_500,
etfUnderlyings: new Set(["SPY", "QQQ"])
};
describe("options-flow helpers", () => {
it("resolves synthetic modes with per-service overrides", () => {
expect(
resolveSyntheticMarketModes({
syntheticMarketMode: "active",
syntheticOptionsMode: "firehose"
})
).toEqual({
market: "active",
options: "firehose",
equities: "active"
});
});
it("derives underlying, notional, nbbo side, and etf metadata", () => {
const metadata = deriveOptionPrintMetadata(
{
option_contract_id: "SPY-2025-01-17-450-C",
price: 2.5,
size: 100,
ts: 5_000
},
{
bid: 2.3,
ask: 2.5,
ts: 4_500
},
baseConfig
);
expect(metadata.underlying_id).toBe("SPY");
expect(metadata.option_type).toBe("call");
expect(metadata.notional).toBe(25_000);
expect(metadata.nbbo_side).toBe("A");
expect(metadata.is_etf).toBe(true);
});
it("accepts and rejects smart-money thresholds at boundaries", () => {
const acceptedAsk = evaluateOptionSignal(
{
size: 100,
conditions: [],
underlying_id: "AAPL",
option_type: "call",
notional: 10_000,
nbbo_side: "A",
is_etf: false
},
baseConfig
);
expect(acceptedAsk.signalPass).toBe(true);
const rejectedLow = evaluateOptionSignal(
{
size: 100,
conditions: [],
underlying_id: "AAPL",
option_type: "call",
notional: 9_999,
nbbo_side: "A",
is_etf: false
},
baseConfig
);
expect(rejectedLow.signalPass).toBe(false);
const rejectedBid = evaluateOptionSignal(
{
size: 100,
conditions: [],
underlying_id: "AAPL",
option_type: "put",
notional: 24_999,
nbbo_side: "B",
is_etf: false
},
baseConfig
);
expect(rejectedBid.signalPass).toBe(false);
const acceptedSweep = evaluateOptionSignal(
{
size: 100,
conditions: ["SWEEP"],
underlying_id: "AAPL",
option_type: "call",
notional: 25_000,
nbbo_side: "MID",
is_etf: false
},
baseConfig
);
expect(acceptedSweep.signalPass).toBe(true);
const rejectedEtf = evaluateOptionSignal(
{
size: 100,
conditions: [],
underlying_id: "SPY",
option_type: "call",
notional: 49_999,
nbbo_side: "A",
is_etf: true
},
baseConfig
);
expect(rejectedEtf.signalPass).toBe(false);
});
});