diff --git a/.env.example b/.env.example index 36bc452..a589881 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,6 @@ CLASSIFIER_SPIKE_MIN_SIZE_Z=2 CLASSIFIER_Z_MIN_SAMPLES=12 CLASSIFIER_MIN_NBBO_COVERAGE=0.5 CLASSIFIER_MIN_AGGRESSOR_RATIO=0.55 +CLASSIFIER_0DTE_MAX_ATM_PCT=0.01 +CLASSIFIER_0DTE_MIN_PREMIUM=20000 +CLASSIFIER_0DTE_MIN_SIZE=400 diff --git a/README.md b/README.md index d163311..56597bc 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,9 @@ Provider links: [Interactive Brokers](https://www.interactivebrokers.com/), [IBK - `CLASSIFIER_Z_MIN_SAMPLES` (default `12`) — minimum samples before z-scores apply. - `CLASSIFIER_MIN_NBBO_COVERAGE` (default `0.5`) — NBBO coverage ratio gate. - `CLASSIFIER_MIN_AGGRESSOR_RATIO` (default `0.55`) — aggressor ratio gate. +- `CLASSIFIER_0DTE_MAX_ATM_PCT` (default `0.01`) — max ATM distance as pct of underlying for 0DTE gamma punch. +- `CLASSIFIER_0DTE_MIN_PREMIUM` (default `20000`) — 0DTE gamma punch premium floor. +- `CLASSIFIER_0DTE_MIN_SIZE` (default `400`) — 0DTE gamma punch size floor. ### Testing + throttling diff --git a/services/compute/src/classifiers.ts b/services/compute/src/classifiers.ts index 824886e..3eef846 100644 --- a/services/compute/src/classifiers.ts +++ b/services/compute/src/classifiers.ts @@ -12,6 +12,9 @@ export type ClassifierConfig = { zMinSamples: number; minNbboCoverage: number; minAggressorRatio: number; + zeroDteMaxAtmPct: number; + zeroDteMinPremium: number; + zeroDteMinSize: number; }; const MS_PER_DAY = 86_400_000; @@ -42,6 +45,13 @@ const getStringFeature = (packet: FlowPacket, key: string): string => { const formatPct = (value: number): string => `${Math.round(value * 100)}%`; +const formatPctPrecise = (value: number, digits = 2): string => { + if (!Number.isFinite(value)) { + return "0%"; + } + return `${(value * 100).toFixed(digits)}%`; +}; + const getAggressorContext = ( packet: FlowPacket ): { @@ -183,6 +193,14 @@ const getReferenceTs = (packet: FlowPacket): number | null => { return null; }; +const getReferenceDay = (packet: FlowPacket): string | null => { + const referenceTs = getReferenceTs(packet); + if (!referenceTs) { + return null; + } + return new Date(referenceTs).toISOString().slice(0, 10); +}; + const getDteDays = (packet: FlowPacket, contract: ParsedContract): number | null => { const expiryTs = Date.parse(`${contract.expiry}T00:00:00Z`); if (!Number.isFinite(expiryTs)) { @@ -666,6 +684,67 @@ const buildFarDatedHit = ( }; }; +const buildZeroDteGammaPunchHit = ( + packet: FlowPacket, + contract: ParsedContract, + config: ClassifierConfig +): ClassifierHit | null => { + const referenceDay = getReferenceDay(packet); + if (!referenceDay || contract.expiry !== referenceDay) { + return null; + } + + const activity = getLargeActivity(packet, config); + if ( + activity.totalPremium < config.zeroDteMinPremium || + activity.totalSize < config.zeroDteMinSize + ) { + return null; + } + + const underlyingMid = getNumberFeature(packet, "underlying_mid"); + if (!Number.isFinite(underlyingMid) || underlyingMid <= 0) { + return null; + } + + const strike = contract.strike; + const atmPct = Math.abs(strike - underlyingMid) / underlyingMid; + if (atmPct > config.zeroDteMaxAtmPct) { + return null; + } + + const { coverage, aggressiveRatio } = getAggressorContext(packet); + let confidence = 0.55; + if (atmPct <= config.zeroDteMaxAtmPct * 0.5) { + confidence += 0.05; + } + if (activity.totalPremium >= config.zeroDteMinPremium * 2) { + confidence += 0.1; + } + if (activity.totalSize >= config.zeroDteMinSize * 2) { + confidence += 0.05; + } + + const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config); + confidence = clamp(aggressor.confidence, 0, 0.9); + + return { + classifier_id: "zero_dte_gamma_punch", + confidence, + direction: contract.right === "C" ? "bullish" : "bearish", + explanations: [ + `Likely 0DTE gamma punch: ${packet.features.option_contract_id ?? packet.id} near ATM.`, + `Underlying mid ${formatUsd(underlyingMid)}, strike ${formatUsd(strike)} (${formatPctPrecise(atmPct)} from ATM).`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + `Thresholds: DTE=0, ATM <=${formatPctPrecise(config.zeroDteMaxAtmPct)}, >=${formatUsd( + config.zeroDteMinPremium + )} premium, >=${config.zeroDteMinSize} contracts.`, + activity.baselineNote, + aggressor.note + ] + }; +}; + export const evaluateClassifiers = ( packet: FlowPacket, config: ClassifierConfig @@ -710,6 +789,11 @@ export const evaluateClassifiers = ( if (farDatedHit) { hits.push(farDatedHit); } + + const zeroDteHit = buildZeroDteGammaPunchHit(packet, contract, config); + if (zeroDteHit) { + hits.push(zeroDteHit); + } } const structureHit = buildStraddleStrangleHit(packet, config); diff --git a/services/compute/src/index.ts b/services/compute/src/index.ts index 042e6df..315c65f 100644 --- a/services/compute/src/index.ts +++ b/services/compute/src/index.ts @@ -115,7 +115,10 @@ const envSchema = z.object({ CLASSIFIER_SPIKE_MIN_SIZE_Z: z.coerce.number().nonnegative().default(2), CLASSIFIER_Z_MIN_SAMPLES: z.coerce.number().int().nonnegative().default(12), CLASSIFIER_MIN_NBBO_COVERAGE: z.coerce.number().min(0).max(1).default(0.5), - CLASSIFIER_MIN_AGGRESSOR_RATIO: z.coerce.number().min(0).max(1).default(0.55) + CLASSIFIER_MIN_AGGRESSOR_RATIO: z.coerce.number().min(0).max(1).default(0.55), + CLASSIFIER_0DTE_MAX_ATM_PCT: z.coerce.number().min(0).max(1).default(0.01), + CLASSIFIER_0DTE_MIN_PREMIUM: z.coerce.number().positive().default(20_000), + CLASSIFIER_0DTE_MIN_SIZE: z.coerce.number().int().positive().default(400) }); const env = readEnv(envSchema); @@ -130,7 +133,10 @@ const classifierConfig: ClassifierConfig = { spikeMinSizeZ: env.CLASSIFIER_SPIKE_MIN_SIZE_Z, zMinSamples: env.CLASSIFIER_Z_MIN_SAMPLES, minNbboCoverage: env.CLASSIFIER_MIN_NBBO_COVERAGE, - minAggressorRatio: env.CLASSIFIER_MIN_AGGRESSOR_RATIO + minAggressorRatio: env.CLASSIFIER_MIN_AGGRESSOR_RATIO, + zeroDteMaxAtmPct: env.CLASSIFIER_0DTE_MAX_ATM_PCT, + zeroDteMinPremium: env.CLASSIFIER_0DTE_MIN_PREMIUM, + zeroDteMinSize: env.CLASSIFIER_0DTE_MIN_SIZE }; const darkInferenceConfig: DarkInferenceConfig = { @@ -485,6 +491,34 @@ const flushCluster = async ( window_ms: env.CLUSTER_WINDOW_MS }; + const parsedContract = parseContractId(cluster.contractId); + if (parsedContract?.root) { + features.underlying_id = parsedContract.root; + const quoteJoin = selectEquityQuote(parsedContract.root, cluster.endTs); + if (!quoteJoin.quote) { + joinQuality.underlying_quote_missing = 1; + } else { + joinQuality.underlying_quote_age_ms = quoteJoin.ageMs; + if (quoteJoin.stale) { + joinQuality.underlying_quote_stale = 1; + } else { + const bid = quoteJoin.quote.bid; + const ask = quoteJoin.quote.ask; + if (Number.isFinite(bid) && Number.isFinite(ask) && ask > 0) { + const mid = (bid + ask) / 2; + const spread = ask - bid; + features.underlying_quote_ts = quoteJoin.quote.ts; + features.underlying_bid = bid; + features.underlying_ask = ask; + features.underlying_mid = roundTo(mid); + features.underlying_spread = roundTo(spread); + } else { + joinQuality.underlying_quote_missing = 1; + } + } + } + } + const placementTotal = cluster.placements.aa + cluster.placements.a + diff --git a/services/compute/tests/classifiers.test.ts b/services/compute/tests/classifiers.test.ts index 81aece7..ab3b110 100644 --- a/services/compute/tests/classifiers.test.ts +++ b/services/compute/tests/classifiers.test.ts @@ -12,7 +12,10 @@ const baseConfig: ClassifierConfig = { spikeMinSizeZ: 2, zMinSamples: 12, minNbboCoverage: 0.5, - minAggressorRatio: 0.55 + minAggressorRatio: 0.55, + zeroDteMaxAtmPct: 0.01, + zeroDteMinPremium: 20_000, + zeroDteMinSize: 400 }; const DEFAULT_TS = Date.UTC(2024, 0, 2); @@ -187,4 +190,19 @@ describe("classifier structure and positioning signals", () => { const hit = hits.find((candidate) => candidate.classifier_id === "far_dated_conviction"); expect(hit?.direction).toBe("bullish"); }); + + test("zero dte gamma punch triggers when ATM and large", () => { + const packet = buildPacket({ + option_contract_id: "SPY-2024-01-02-450-C", + total_premium: 35_000, + total_size: 600, + underlying_mid: 450, + nbbo_coverage_ratio: 0.8, + nbbo_aggressive_buy_ratio: 0.7, + nbbo_aggressive_sell_ratio: 0.3 + }); + const hits = evaluateClassifiers(packet, baseConfig); + const hit = hits.find((candidate) => candidate.classifier_id === "zero_dte_gamma_punch"); + expect(hit?.direction).toBe("bullish"); + }); });