Add smart-money option signal path and tape filters
This commit is contained in:
parent
758f111d7e
commit
27b0a399e6
23 changed files with 1827 additions and 175 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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 ?? []
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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')");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./events";
|
||||
export * from "./live";
|
||||
export * from "./options-flow";
|
||||
export * from "./sp500";
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
464
packages/types/src/options-flow.ts
Normal file
464
packages/types/src/options-flow.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 }
|
||||
]
|
||||
});
|
||||
|
|
|
|||
132
packages/types/tests/options-flow.test.ts
Normal file
132
packages/types/tests/options-flow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue