Join underlying quotes for 0DTE classifier

This commit is contained in:
dirtydishes 2026-01-09 16:11:38 -05:00
parent f96f5699ef
commit 2752025fbc
5 changed files with 145 additions and 3 deletions

View file

@ -67,3 +67,6 @@ CLASSIFIER_SPIKE_MIN_SIZE_Z=2
CLASSIFIER_Z_MIN_SAMPLES=12 CLASSIFIER_Z_MIN_SAMPLES=12
CLASSIFIER_MIN_NBBO_COVERAGE=0.5 CLASSIFIER_MIN_NBBO_COVERAGE=0.5
CLASSIFIER_MIN_AGGRESSOR_RATIO=0.55 CLASSIFIER_MIN_AGGRESSOR_RATIO=0.55
CLASSIFIER_0DTE_MAX_ATM_PCT=0.01
CLASSIFIER_0DTE_MIN_PREMIUM=20000
CLASSIFIER_0DTE_MIN_SIZE=400

View file

@ -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_Z_MIN_SAMPLES` (default `12`) — minimum samples before z-scores apply.
- `CLASSIFIER_MIN_NBBO_COVERAGE` (default `0.5`) — NBBO coverage ratio gate. - `CLASSIFIER_MIN_NBBO_COVERAGE` (default `0.5`) — NBBO coverage ratio gate.
- `CLASSIFIER_MIN_AGGRESSOR_RATIO` (default `0.55`) — aggressor 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 ### Testing + throttling

View file

@ -12,6 +12,9 @@ export type ClassifierConfig = {
zMinSamples: number; zMinSamples: number;
minNbboCoverage: number; minNbboCoverage: number;
minAggressorRatio: number; minAggressorRatio: number;
zeroDteMaxAtmPct: number;
zeroDteMinPremium: number;
zeroDteMinSize: number;
}; };
const MS_PER_DAY = 86_400_000; 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 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 = ( const getAggressorContext = (
packet: FlowPacket packet: FlowPacket
): { ): {
@ -183,6 +193,14 @@ const getReferenceTs = (packet: FlowPacket): number | null => {
return 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 getDteDays = (packet: FlowPacket, contract: ParsedContract): number | null => {
const expiryTs = Date.parse(`${contract.expiry}T00:00:00Z`); const expiryTs = Date.parse(`${contract.expiry}T00:00:00Z`);
if (!Number.isFinite(expiryTs)) { 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 = ( export const evaluateClassifiers = (
packet: FlowPacket, packet: FlowPacket,
config: ClassifierConfig config: ClassifierConfig
@ -710,6 +789,11 @@ export const evaluateClassifiers = (
if (farDatedHit) { if (farDatedHit) {
hits.push(farDatedHit); hits.push(farDatedHit);
} }
const zeroDteHit = buildZeroDteGammaPunchHit(packet, contract, config);
if (zeroDteHit) {
hits.push(zeroDteHit);
}
} }
const structureHit = buildStraddleStrangleHit(packet, config); const structureHit = buildStraddleStrangleHit(packet, config);

View file

@ -115,7 +115,10 @@ const envSchema = z.object({
CLASSIFIER_SPIKE_MIN_SIZE_Z: z.coerce.number().nonnegative().default(2), CLASSIFIER_SPIKE_MIN_SIZE_Z: z.coerce.number().nonnegative().default(2),
CLASSIFIER_Z_MIN_SAMPLES: z.coerce.number().int().nonnegative().default(12), 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_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); const env = readEnv(envSchema);
@ -130,7 +133,10 @@ const classifierConfig: ClassifierConfig = {
spikeMinSizeZ: env.CLASSIFIER_SPIKE_MIN_SIZE_Z, spikeMinSizeZ: env.CLASSIFIER_SPIKE_MIN_SIZE_Z,
zMinSamples: env.CLASSIFIER_Z_MIN_SAMPLES, zMinSamples: env.CLASSIFIER_Z_MIN_SAMPLES,
minNbboCoverage: env.CLASSIFIER_MIN_NBBO_COVERAGE, 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 = { const darkInferenceConfig: DarkInferenceConfig = {
@ -485,6 +491,34 @@ const flushCluster = async (
window_ms: env.CLUSTER_WINDOW_MS 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 = const placementTotal =
cluster.placements.aa + cluster.placements.aa +
cluster.placements.a + cluster.placements.a +

View file

@ -12,7 +12,10 @@ const baseConfig: ClassifierConfig = {
spikeMinSizeZ: 2, spikeMinSizeZ: 2,
zMinSamples: 12, zMinSamples: 12,
minNbboCoverage: 0.5, 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); 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"); const hit = hits.find((candidate) => candidate.classifier_id === "far_dated_conviction");
expect(hit?.direction).toBe("bullish"); 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");
});
}); });