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,8 +1,14 @@
import { SP500_SYMBOLS, type OptionNBBO, type OptionPrint } from "@islandflow/types";
import {
SP500_SYMBOLS,
type OptionNBBO,
type OptionPrint,
type SyntheticMarketMode
} from "@islandflow/types";
import type { OptionIngestAdapter, OptionIngestHandlers } from "./types";
type SyntheticOptionsAdapterConfig = {
emitIntervalMs: number;
mode: SyntheticMarketMode;
};
type Burst = {
@ -17,17 +23,18 @@ type Burst = {
seed: number;
};
const SYNTHETIC_SYMBOLS = [
"SPY",
...SP500_SYMBOLS.filter((symbol) => symbol !== "SPY")
];
const SYNTHETIC_SYMBOLS = ["SPY", ...(SP500_SYMBOLS as readonly string[])];
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const EXPIRY_OFFSETS = [0, 1, 7, 14, 28, 45, 60, 90];
const EXCHANGES = ["CBOE", "PHLX", "ISE", "ARCA", "BOX", "MIAX"];
const CONDITIONS = ["SWEEP", "ISO", "FILL", "TEST"];
const BURST_RUN_RANGE: [number, number] = [2, 4];
type SyntheticOptionsProfile = {
burstRunRange: [number, number];
scenarios: Scenario[];
pricePlacements: Record<string, WeightedValue<PricePlacement>[]>;
};
type PricePlacement = "AA" | "A" | "B" | "BB";
type PricePlacement = "AA" | "A" | "MID" | "B" | "BB";
type WeightedValue<T> = {
value: T;
@ -45,7 +52,70 @@ type Scenario = {
conditions?: string[];
};
const SCENARIOS: Scenario[] = [
const REALISTIC_SCENARIOS: Scenario[] = [
{
id: "ask_lift",
weight: 18,
right: "either",
countRange: [1, 2],
sizeRange: [30, 180],
premiumRange: [9_000, 35_000],
priceTrend: "flat",
conditions: ["FILL"]
},
{
id: "mid_block",
weight: 14,
right: "either",
countRange: [1, 2],
sizeRange: [120, 480],
premiumRange: [12_000, 45_000],
priceTrend: "flat",
conditions: ["FILL"]
},
{
id: "bullish_sweep",
weight: 8,
right: "C",
countRange: [2, 3],
sizeRange: [180, 520],
premiumRange: [25_000, 90_000],
priceTrend: "up",
conditions: ["SWEEP"]
},
{
id: "bearish_sweep",
weight: 8,
right: "P",
countRange: [2, 3],
sizeRange: [180, 520],
premiumRange: [25_000, 90_000],
priceTrend: "up",
conditions: ["SWEEP"]
},
{
id: "contract_spike",
weight: 6,
right: "either",
countRange: [2, 3],
sizeRange: [500, 900],
premiumRange: [18_000, 70_000],
priceTrend: "flat",
conditions: ["ISO"]
},
{
id: "noise",
weight: 46,
right: "either",
countRange: [1, 2],
sizeRange: [5, 60],
premiumRange: [500, 6_000],
priceTrend: "flat",
conditions: ["FILL"]
}
];
const ACTIVE_SCENARIOS: Scenario[] = [
{
id: "bullish_sweep",
weight: 35,
@ -88,7 +158,50 @@ const SCENARIOS: Scenario[] = [
}
];
const PRICE_PLACEMENTS: Record<string, WeightedValue<PricePlacement>[]> = {
const REALISTIC_PRICE_PLACEMENTS: Record<string, WeightedValue<PricePlacement>[]> = {
ask_lift: [
{ value: "A", weight: 45 },
{ value: "AA", weight: 20 },
{ value: "MID", weight: 25 },
{ value: "B", weight: 8 },
{ value: "BB", weight: 2 }
],
mid_block: [
{ value: "MID", weight: 60 },
{ value: "A", weight: 20 },
{ value: "B", weight: 20 }
],
bullish_sweep: [
{ value: "AA", weight: 20 },
{ value: "A", weight: 50 },
{ value: "MID", weight: 15 },
{ value: "B", weight: 10 },
{ value: "BB", weight: 5 }
],
bearish_sweep: [
{ value: "AA", weight: 10 },
{ value: "A", weight: 20 },
{ value: "MID", weight: 15 },
{ value: "B", weight: 35 },
{ value: "BB", weight: 20 }
],
contract_spike: [
{ value: "A", weight: 25 },
{ value: "MID", weight: 40 },
{ value: "B", weight: 25 },
{ value: "AA", weight: 5 },
{ value: "BB", weight: 5 }
],
noise: [
{ value: "MID", weight: 40 },
{ value: "A", weight: 20 },
{ value: "B", weight: 20 },
{ value: "AA", weight: 10 },
{ value: "BB", weight: 10 }
]
};
const ACTIVE_PRICE_PLACEMENTS: Record<string, WeightedValue<PricePlacement>[]> = {
bullish_sweep: [
{ value: "AA", weight: 25 },
{ value: "A", weight: 40 },
@ -115,7 +228,52 @@ const PRICE_PLACEMENTS: Record<string, WeightedValue<PricePlacement>[]> = {
]
};
const PLACEMENT_PATTERN: PricePlacement[] = ["A", "AA", "B", "BB"];
const FIREHOSE_PRICE_PLACEMENTS: Record<string, WeightedValue<PricePlacement>[]> = {
...ACTIVE_PRICE_PLACEMENTS,
noise: [
{ value: "A", weight: 20 },
{ value: "AA", weight: 20 },
{ value: "MID", weight: 20 },
{ value: "B", weight: 20 },
{ value: "BB", weight: 20 }
]
};
const PLACEMENT_PATTERN: PricePlacement[] = ["A", "AA", "MID", "B", "BB"];
const SYNTHETIC_PROFILES: Record<SyntheticMarketMode, SyntheticOptionsProfile> = {
realistic: {
burstRunRange: [1, 2],
scenarios: REALISTIC_SCENARIOS,
pricePlacements: REALISTIC_PRICE_PLACEMENTS
},
active: {
burstRunRange: [2, 4],
scenarios: ACTIVE_SCENARIOS,
pricePlacements: ACTIVE_PRICE_PLACEMENTS
},
firehose: {
burstRunRange: [4, 7],
scenarios: ACTIVE_SCENARIOS.map((scenario): Scenario =>
scenario.id === "noise"
? {
...scenario,
weight: 20,
countRange: [5, 8],
sizeRange: [20, 300],
premiumRange: [800, 12_000]
}
: {
...scenario,
weight: scenario.weight + 10,
countRange: [scenario.countRange[0] + 2, scenario.countRange[1] + 3],
sizeRange: [scenario.sizeRange[0], scenario.sizeRange[1] * 2],
premiumRange: [scenario.premiumRange[0], scenario.premiumRange[1] * 1.5]
}
),
pricePlacements: FIREHOSE_PRICE_PLACEMENTS
}
};
const pick = <T,>(items: T[], seed: number): T => {
return items[Math.abs(seed) % items.length];
@ -153,8 +311,12 @@ const pickWeightedValue = <T>(items: WeightedValue<T>[], seed: number): T => {
return pickWeighted(items, seed).value;
};
const pickPlacement = (burst: Burst, index: number): PricePlacement => {
const placementOptions = PRICE_PLACEMENTS[burst.scenarioId] ?? PRICE_PLACEMENTS.noise;
const pickPlacement = (
burst: Burst,
index: number,
profile: SyntheticOptionsProfile
): PricePlacement => {
const placementOptions = profile.pricePlacements[burst.scenarioId] ?? profile.pricePlacements.noise;
const offset = Math.abs(burst.seed) % PLACEMENT_PATTERN.length;
if (index < PLACEMENT_PATTERN.length) {
return PLACEMENT_PATTERN[(offset + index) % PLACEMENT_PATTERN.length];
@ -180,11 +342,11 @@ const formatExpiry = (now: number, offsetDays: number): string => {
return expiryDate.toISOString().slice(0, 10);
};
const buildBurst = (burstIndex: number, now: number): Burst => {
const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsProfile): Burst => {
const symbol = SYNTHETIC_SYMBOLS[burstIndex % SYNTHETIC_SYMBOLS.length];
const symbolHash = hashSymbol(symbol);
const seed = symbolHash + burstIndex * 7;
const scenario = pickWeighted(SCENARIOS, seed);
const scenario = pickWeighted(profile.scenarios, seed);
const baseUnderlying = 30 + (symbolHash % 470);
const expiryOffset = pick(EXPIRY_OFFSETS, symbolHash + burstIndex);
const expiry = formatExpiry(now, expiryOffset);
@ -231,6 +393,7 @@ const buildBurst = (burstIndex: number, now: number): Burst => {
export const createSyntheticOptionsAdapter = (
config: SyntheticOptionsAdapterConfig
): OptionIngestAdapter => {
const profile = SYNTHETIC_PROFILES[config.mode];
return {
name: "synthetic",
start: (handlers: OptionIngestHandlers) => {
@ -250,8 +413,12 @@ export const createSyntheticOptionsAdapter = (
const now = Date.now();
if (!currentBurst || remainingRuns <= 0) {
burstIndex += 1;
currentBurst = buildBurst(burstIndex, now);
remainingRuns = pickInt(BURST_RUN_RANGE[0], BURST_RUN_RANGE[1], burstIndex * 23);
currentBurst = buildBurst(burstIndex, now, profile);
remainingRuns = pickInt(
profile.burstRunRange[0],
profile.burstRunRange[1],
burstIndex * 23
);
}
const burst = currentBurst;
@ -267,13 +434,15 @@ export const createSyntheticOptionsAdapter = (
const bid = Math.max(0.01, Number((mid - spread / 2).toFixed(2)));
const ask = Math.max(bid + 0.01, Number((mid + spread / 2).toFixed(2)));
const tick = Math.max(0.01, Number((spread * 0.25).toFixed(2)));
const placement = pickPlacement(burst, i);
const placement = pickPlacement(burst, i, profile);
let tradePrice = mid;
if (placement === "AA") {
tradePrice = ask + tick;
} else if (placement === "A") {
tradePrice = ask;
} else if (placement === "MID") {
tradePrice = mid;
} else if (placement === "BB") {
tradePrice = Math.max(0.01, bid - tick);
} else {

View file

@ -3,8 +3,10 @@ import { createLogger } from "@islandflow/observability";
import {
SUBJECT_OPTION_NBBO,
SUBJECT_OPTION_PRINTS,
SUBJECT_OPTION_SIGNAL_PRINTS,
STREAM_OPTION_NBBO,
STREAM_OPTION_PRINTS,
STREAM_OPTION_SIGNAL_PRINTS,
connectJetStreamWithRetry,
ensureStream,
publishJson
@ -16,7 +18,16 @@ import {
insertOptionNBBO,
insertOptionPrint
} from "@islandflow/storage";
import { OptionNBBOSchema, OptionPrintSchema, type OptionNBBO, type OptionPrint } from "@islandflow/types";
import {
OptionNBBOSchema,
OptionPrintSchema,
evaluateOptionSignal,
deriveOptionPrintMetadata,
resolveSyntheticMarketModes,
type OptionNBBO,
type OptionPrint,
type OptionsSignalConfig
} from "@islandflow/types";
import { createAlpacaOptionsAdapter } from "./adapters/alpaca";
import { createDatabentoOptionsAdapter } from "./adapters/databento";
import { createIbkrOptionsAdapter } from "./adapters/ibkr";
@ -68,6 +79,17 @@ const envSchema = z.object({
IBKR_CURRENCY: z.string().min(1).default("USD"),
IBKR_PYTHON_BIN: z.string().min(1).default("python3"),
EMIT_INTERVAL_MS: z.coerce.number().int().positive().default(1000),
SYNTHETIC_MARKET_MODE: z.string().default("realistic"),
SYNTHETIC_OPTIONS_MODE: z.string().default(""),
OPTIONS_SIGNAL_MODE: z.enum(["smart-money", "balanced", "all"]).default("smart-money"),
OPTIONS_SIGNAL_MIN_NOTIONAL: z.coerce.number().nonnegative().default(10_000),
OPTIONS_SIGNAL_ETF_MIN_NOTIONAL: z.coerce.number().nonnegative().default(50_000),
OPTIONS_SIGNAL_BID_SIDE_MIN_NOTIONAL: z.coerce.number().nonnegative().default(25_000),
OPTIONS_SIGNAL_MID_MIN_NOTIONAL: z.coerce.number().nonnegative().default(20_000),
OPTIONS_SIGNAL_NBBO_MAX_AGE_MS: z.coerce.number().int().positive().default(1500),
OPTIONS_SIGNAL_ETF_UNDERLYINGS: z
.string()
.default("SPY,QQQ,IWM,DIA,TLT,GLD,SLV,XLF,XLE,XLV,XLI,XLP,XLU,XLY,SMH,ARKK"),
TESTING_MODE: z
.preprocess((value) => {
if (typeof value === "string") {
@ -86,11 +108,34 @@ const envSchema = z.object({
});
const env = readEnv(envSchema);
const syntheticModes = resolveSyntheticMarketModes({
syntheticMarketMode: env.SYNTHETIC_MARKET_MODE,
syntheticOptionsMode: env.SYNTHETIC_OPTIONS_MODE
});
const optionsSignalConfig: OptionsSignalConfig = {
mode: env.OPTIONS_SIGNAL_MODE,
minNotional: env.OPTIONS_SIGNAL_MIN_NOTIONAL,
etfMinNotional: env.OPTIONS_SIGNAL_ETF_MIN_NOTIONAL,
bidSideMinNotional: env.OPTIONS_SIGNAL_BID_SIDE_MIN_NOTIONAL,
midMinNotional: env.OPTIONS_SIGNAL_MID_MIN_NOTIONAL,
missingNbboMinNotional: 50_000,
largePrintMinSize: 500,
largePrintMinNotional: env.OPTIONS_SIGNAL_MIN_NOTIONAL,
sweepMinNotional: env.OPTIONS_SIGNAL_BID_SIDE_MIN_NOTIONAL,
autoKeepMinNotional: 100_000,
nbboMaxAgeMs: env.OPTIONS_SIGNAL_NBBO_MAX_AGE_MS,
etfUnderlyings: new Set(
env.OPTIONS_SIGNAL_ETF_UNDERLYINGS.split(",")
.map((value) => value.trim().toUpperCase())
.filter(Boolean)
)
};
const state = {
shuttingDown: false,
shutdownPromise: null as Promise<void> | null
};
const latestNbboByContract = new Map<string, OptionNBBO>();
const getErrorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error);
@ -169,7 +214,10 @@ const retry = async <T>(
const selectAdapter = (name: string): OptionIngestAdapter => {
if (name === "synthetic") {
return createSyntheticOptionsAdapter({ emitIntervalMs: env.EMIT_INTERVAL_MS });
return createSyntheticOptionsAdapter({
emitIntervalMs: env.EMIT_INTERVAL_MS,
mode: syntheticModes.options
});
}
if (name === "alpaca") {
@ -277,6 +325,19 @@ const run = async () => {
num_replicas: 1
});
await ensureStream(jsm, {
name: STREAM_OPTION_SIGNAL_PRINTS,
subjects: [SUBJECT_OPTION_SIGNAL_PRINTS],
retention: "limits",
storage: "file",
discard: "old",
max_msgs_per_subject: -1,
max_msgs: -1,
max_bytes: -1,
max_age: 0,
num_replicas: 1
});
const clickhouse = createClickHouseClient({
url: env.CLICKHOUSE_URL,
database: env.CLICKHOUSE_DATABASE
@ -303,15 +364,41 @@ const run = async () => {
return;
}
const print = OptionPrintSchema.parse(candidate);
const rawPrint = OptionPrintSchema.parse(candidate);
const derived = deriveOptionPrintMetadata(
rawPrint,
latestNbboByContract.get(rawPrint.option_contract_id),
optionsSignalConfig
);
const signalDecision = evaluateOptionSignal(
{
...rawPrint,
...derived,
signal_profile: optionsSignalConfig.mode
},
optionsSignalConfig
);
const print = OptionPrintSchema.parse({
...rawPrint,
...derived,
signal_pass: signalDecision.signalPass,
signal_reasons: signalDecision.signalReasons,
signal_profile: signalDecision.signalProfile
});
try {
await insertOptionPrint(clickhouse, print);
await publishJson(js, SUBJECT_OPTION_PRINTS, print);
if (print.signal_pass) {
await publishJson(js, SUBJECT_OPTION_SIGNAL_PRINTS, print);
}
logger.info("published option print", {
trace_id: print.trace_id,
seq: print.seq,
option_contract_id: print.option_contract_id
option_contract_id: print.option_contract_id,
signal_pass: print.signal_pass,
nbbo_side: print.nbbo_side,
notional: print.notional
});
} catch (error) {
if (isExpectedShutdownError(error)) {
@ -335,6 +422,14 @@ const run = async () => {
}
const nbbo = OptionNBBOSchema.parse(candidate);
const existing = latestNbboByContract.get(nbbo.option_contract_id);
if (
!existing ||
nbbo.ts > existing.ts ||
(nbbo.ts === existing.ts && nbbo.seq >= existing.seq)
) {
latestNbboByContract.set(nbbo.option_contract_id, nbbo);
}
try {
await insertOptionNBBO(clickhouse, nbbo);