diff --git a/services/compute/src/classifiers.ts b/services/compute/src/classifiers.ts index 5c9bbff..824886e 100644 --- a/services/compute/src/classifiers.ts +++ b/services/compute/src/classifiers.ts @@ -14,6 +14,8 @@ export type ClassifierConfig = { minAggressorRatio: number; }; +const MS_PER_DAY = 86_400_000; + const clamp = (value: number, min = 0, max = 1): number => { if (!Number.isFinite(value)) { return min; @@ -33,8 +35,29 @@ const getNumberFeature = (packet: FlowPacket, key: string): number => { return typeof value === "number" && Number.isFinite(value) ? value : 0; }; +const getStringFeature = (packet: FlowPacket, key: string): string => { + const value = packet.features[key]; + return typeof value === "string" ? value : ""; +}; + const formatPct = (value: number): string => `${Math.round(value * 100)}%`; +const getAggressorContext = ( + packet: FlowPacket +): { + coverage: number; + aggressiveBuyRatio: number; + aggressiveSellRatio: number; + aggressiveRatio: number; +} => { + return { + coverage: getNumberFeature(packet, "nbbo_coverage_ratio"), + aggressiveBuyRatio: getNumberFeature(packet, "nbbo_aggressive_buy_ratio"), + aggressiveSellRatio: getNumberFeature(packet, "nbbo_aggressive_sell_ratio"), + aggressiveRatio: getNumberFeature(packet, "nbbo_aggressive_ratio") + }; +}; + const applyAggressorAdjustment = ( confidence: number, coverage: number, @@ -61,6 +84,124 @@ const applyAggressorAdjustment = ( return { confidence: adjusted, note }; }; +type LargeActivity = { + count: number; + totalPremium: number; + totalSize: number; + windowMs: number; + premiumZ: number; + sizeZ: number; + premiumBaselineReady: boolean; + sizeBaselineReady: boolean; + passesAbsolute: boolean; + passesZ: boolean; + baselineNote: string; +}; + +const getLargeActivity = (packet: FlowPacket, config: ClassifierConfig): LargeActivity => { + const count = getNumberFeature(packet, "count"); + const totalPremium = getNumberFeature(packet, "total_premium"); + const totalSize = getNumberFeature(packet, "total_size"); + const windowMs = getNumberFeature(packet, "window_ms"); + const premiumZ = getNumberFeature(packet, "total_premium_z"); + const sizeZ = getNumberFeature(packet, "total_size_z"); + const premiumBaseline = getNumberFeature(packet, "total_premium_baseline_n"); + const sizeBaseline = getNumberFeature(packet, "total_size_baseline_n"); + + const premiumBaselineReady = premiumBaseline >= config.zMinSamples; + const sizeBaselineReady = sizeBaseline >= config.zMinSamples; + const passesAbsolute = totalSize >= config.spikeMinSize && totalPremium >= config.spikeMinPremium; + const passesZ = + (premiumBaselineReady && premiumZ >= config.spikeMinPremiumZ) || + (sizeBaselineReady && sizeZ >= config.spikeMinSizeZ); + + const baselineNote = + premiumBaselineReady || sizeBaselineReady + ? `Baseline z-scores: premium ${premiumZ.toFixed(2)}, size ${sizeZ.toFixed(2)}.` + : "Baseline z-scores unavailable."; + + return { + count, + totalPremium, + totalSize, + windowMs, + premiumZ, + sizeZ, + premiumBaselineReady, + sizeBaselineReady, + passesAbsolute, + passesZ, + baselineNote + }; +}; + +const applySideAggressorAdjustment = ( + confidence: number, + coverage: number, + ratio: number, + config: ClassifierConfig, + label: string +): { confidence: number; note: string } => { + const normalizedCoverage = clamp(coverage, 0, 1); + const normalizedRatio = clamp(ratio, 0, 1); + let adjusted = confidence; + + if (normalizedCoverage <= 0) { + return { + confidence: adjusted - 0.15, + note: "Aggressor mix unavailable (no NBBO coverage)." + }; + } + + if (normalizedCoverage < config.minNbboCoverage) { + adjusted -= 0.1; + } + + if (normalizedRatio >= config.minAggressorRatio) { + adjusted += 0.05; + } else { + adjusted -= 0.1; + } + + const note = `Aggressor mix ${formatPct(normalizedRatio)} ${label}, NBBO coverage ${formatPct( + normalizedCoverage + )}.`; + + return { confidence: adjusted, note }; +}; + +const getReferenceTs = (packet: FlowPacket): number | null => { + const endTs = getNumberFeature(packet, "end_ts"); + if (endTs > 0) { + return endTs; + } + + if (Number.isFinite(packet.source_ts) && packet.source_ts > 0) { + return packet.source_ts; + } + + return null; +}; + +const getDteDays = (packet: FlowPacket, contract: ParsedContract): number | null => { + const expiryTs = Date.parse(`${contract.expiry}T00:00:00Z`); + if (!Number.isFinite(expiryTs)) { + return null; + } + + const referenceTs = getReferenceTs(packet); + if (!referenceTs) { + return null; + } + + const diffMs = expiryTs - referenceTs; + if (diffMs < 0) { + return null; + } + + return Math.ceil(diffMs / MS_PER_DAY); +}; + const buildSweepHit = ( packet: FlowPacket, contract: ParsedContract, @@ -130,43 +271,30 @@ const buildSweepHit = ( }; const buildSpikeHit = (packet: FlowPacket, config: ClassifierConfig): ClassifierHit | null => { - const count = getNumberFeature(packet, "count"); - const totalPremium = getNumberFeature(packet, "total_premium"); - const totalSize = getNumberFeature(packet, "total_size"); - const windowMs = getNumberFeature(packet, "window_ms"); - const premiumZ = getNumberFeature(packet, "total_premium_z"); - const sizeZ = getNumberFeature(packet, "total_size_z"); - const premiumBaseline = getNumberFeature(packet, "total_premium_baseline_n"); - const sizeBaseline = getNumberFeature(packet, "total_size_baseline_n"); - const coverage = getNumberFeature(packet, "nbbo_coverage_ratio"); - const aggressiveBuyRatio = getNumberFeature(packet, "nbbo_aggressive_buy_ratio"); - const aggressiveSellRatio = getNumberFeature(packet, "nbbo_aggressive_sell_ratio"); + const activity = getLargeActivity(packet, config); + const { coverage, aggressiveBuyRatio, aggressiveSellRatio } = getAggressorContext(packet); const aggressiveRatio = Math.max(aggressiveBuyRatio, aggressiveSellRatio); - const premiumBaselineReady = premiumBaseline >= config.zMinSamples; - const sizeBaselineReady = sizeBaseline >= config.zMinSamples; - const passesAbsolute = totalSize >= config.spikeMinSize && totalPremium >= config.spikeMinPremium; - const passesZ = - (premiumBaselineReady && premiumZ >= config.spikeMinPremiumZ) || - (sizeBaselineReady && sizeZ >= config.spikeMinSizeZ); - - if (!passesAbsolute && !passesZ) { + if (!activity.passesAbsolute && !activity.passesZ) { return null; } let confidence = 0.5; - if (totalSize >= config.spikeMinSize * 2) { + if (activity.totalSize >= config.spikeMinSize * 2) { confidence += 0.15; } - if (totalPremium >= config.spikeMinPremium * 2) { + if (activity.totalPremium >= config.spikeMinPremium * 2) { confidence += 0.15; } - if (count >= 3) { + if (activity.count >= 3) { confidence += 0.1; } - if (passesZ) { + if (activity.passesZ) { confidence += 0.1; - if (premiumZ >= config.spikeMinPremiumZ + 1 || sizeZ >= config.spikeMinSizeZ + 1) { + if ( + activity.premiumZ >= config.spikeMinPremiumZ + 1 || + activity.sizeZ >= config.spikeMinSizeZ + 1 + ) { confidence += 0.05; } } @@ -174,20 +302,365 @@ const buildSpikeHit = (packet: FlowPacket, config: ClassifierConfig): Classifier const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config); confidence = clamp(aggressor.confidence, 0, 0.9); - const baselineNote = - premiumBaselineReady || sizeBaselineReady - ? `Baseline z-scores: premium ${premiumZ.toFixed(2)}, size ${sizeZ.toFixed(2)}.` - : "Baseline z-scores unavailable."; - return { classifier_id: "unusual_contract_spike", confidence, direction: "neutral", explanations: [ - `Unusual contract spike: ${count} prints in ${Math.round(windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`, - `Premium ${formatUsd(totalPremium)} across ${Math.round(totalSize)} contracts.`, + `Unusual contract spike: ${activity.count} prints in ${Math.round(activity.windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, `Thresholds: >=${config.spikeMinSize} contracts and >=${formatUsd(config.spikeMinPremium)} premium or z>=${config.spikeMinPremiumZ.toFixed(1)}.`, - baselineNote, + activity.baselineNote, + aggressor.note + ] + }; +}; + +const buildOverwriteHit = ( + packet: FlowPacket, + contract: ParsedContract, + config: ClassifierConfig +): ClassifierHit | null => { + if (contract.right !== "C") { + return null; + } + + const activity = getLargeActivity(packet, config); + if (!activity.passesAbsolute && !activity.passesZ) { + return null; + } + + const { coverage, aggressiveSellRatio } = getAggressorContext(packet); + let confidence = 0.45; + if (activity.totalPremium >= config.spikeMinPremium * 2) { + confidence += 0.15; + } + if (activity.totalSize >= config.spikeMinSize * 2) { + confidence += 0.1; + } + if (activity.count >= 3) { + confidence += 0.05; + } + if (activity.passesZ) { + confidence += 0.1; + } + + const aggressor = applySideAggressorAdjustment( + confidence, + coverage, + aggressiveSellRatio, + config, + "sell-side" + ); + confidence = clamp(aggressor.confidence, 0, 0.9); + + return { + classifier_id: "large_call_sell_overwrite", + confidence, + direction: "bearish", + explanations: [ + `Likely call overwrite: ${activity.count} prints in ${Math.round(activity.windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + `Thresholds: >=${config.spikeMinSize} contracts and >=${formatUsd(config.spikeMinPremium)} premium or z>=${config.spikeMinPremiumZ.toFixed(1)}.`, + "Direction inferred from sell-side aggressor mix.", + activity.baselineNote, + aggressor.note + ] + }; +}; + +const buildPutWriteHit = ( + packet: FlowPacket, + contract: ParsedContract, + config: ClassifierConfig +): ClassifierHit | null => { + if (contract.right !== "P") { + return null; + } + + const activity = getLargeActivity(packet, config); + if (!activity.passesAbsolute && !activity.passesZ) { + return null; + } + + const { coverage, aggressiveSellRatio } = getAggressorContext(packet); + let confidence = 0.45; + if (activity.totalPremium >= config.spikeMinPremium * 2) { + confidence += 0.15; + } + if (activity.totalSize >= config.spikeMinSize * 2) { + confidence += 0.1; + } + if (activity.count >= 3) { + confidence += 0.05; + } + if (activity.passesZ) { + confidence += 0.1; + } + + const aggressor = applySideAggressorAdjustment( + confidence, + coverage, + aggressiveSellRatio, + config, + "sell-side" + ); + confidence = clamp(aggressor.confidence, 0, 0.9); + + return { + classifier_id: "large_put_sell_write", + confidence, + direction: "bullish", + explanations: [ + `Likely put write: ${activity.count} prints in ${Math.round(activity.windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + `Thresholds: >=${config.spikeMinSize} contracts and >=${formatUsd(config.spikeMinPremium)} premium or z>=${config.spikeMinPremiumZ.toFixed(1)}.`, + "Direction inferred from sell-side aggressor mix.", + activity.baselineNote, + aggressor.note + ] + }; +}; + +const buildStraddleStrangleHit = ( + packet: FlowPacket, + config: ClassifierConfig +): ClassifierHit | null => { + const structureType = getStringFeature(packet, "structure_type"); + if (structureType !== "straddle" && structureType !== "strangle") { + return null; + } + + const activity = getLargeActivity(packet, config); + const { coverage, aggressiveBuyRatio, aggressiveSellRatio, aggressiveRatio } = + getAggressorContext(packet); + const structureLegs = getNumberFeature(packet, "structure_legs"); + const structureStrikes = getNumberFeature(packet, "structure_strikes"); + const strikeSpan = getNumberFeature(packet, "structure_strike_span"); + + let confidence = 0.45; + if (activity.totalPremium >= config.spikeMinPremium) { + confidence += 0.1; + } + if (activity.totalSize >= config.spikeMinSize) { + confidence += 0.05; + } + if (structureLegs >= 4) { + confidence += 0.05; + } + + const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config); + confidence = clamp(aggressor.confidence, 0, 0.85); + + let volBias = "mixed aggressor skew"; + if (aggressiveBuyRatio >= aggressiveSellRatio + 0.1) { + volBias = "buy-side skew suggests long volatility"; + } else if (aggressiveSellRatio >= aggressiveBuyRatio + 0.1) { + volBias = "sell-side skew suggests short volatility"; + } + + const skewNote = `Aggressor skew: buy ${formatPct(aggressiveBuyRatio)}, sell ${formatPct( + aggressiveSellRatio + )}; ${volBias}.`; + + return { + classifier_id: structureType === "straddle" ? "straddle" : "strangle", + confidence, + direction: "neutral", + explanations: [ + `Likely ${structureType}: ${structureLegs} legs across ${structureStrikes} strikes (span ${strikeSpan}).`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + skewNote, + aggressor.note + ] + }; +}; + +const buildVerticalSpreadHit = ( + packet: FlowPacket, + config: ClassifierConfig +): ClassifierHit | null => { + const structureType = getStringFeature(packet, "structure_type"); + if (structureType !== "vertical") { + return null; + } + + const structureRights = getStringFeature(packet, "structure_rights"); + if (structureRights !== "C" && structureRights !== "P") { + return null; + } + + const activity = getLargeActivity(packet, config); + const { coverage, aggressiveBuyRatio, aggressiveSellRatio } = getAggressorContext(packet); + const structureLegs = getNumberFeature(packet, "structure_legs"); + const structureStrikes = getNumberFeature(packet, "structure_strikes"); + const strikeSpan = getNumberFeature(packet, "structure_strike_span"); + + let confidence = 0.5; + if (activity.totalPremium >= config.spikeMinPremium) { + confidence += 0.1; + } + if (activity.totalSize >= config.spikeMinSize) { + confidence += 0.05; + } + if (structureLegs >= 3) { + confidence += 0.05; + } + + let direction: "bullish" | "bearish" | "neutral" = "neutral"; + let biasNote = "Debit/credit bias unclear (insufficient aggressor data)."; + let aggressorNote = "Aggressor mix unavailable (no NBBO coverage)."; + const hasAggressor = coverage > 0 && aggressiveBuyRatio + aggressiveSellRatio > 0; + if (hasAggressor) { + const buyDominant = aggressiveBuyRatio >= aggressiveSellRatio; + const dominantRatio = buyDominant ? aggressiveBuyRatio : aggressiveSellRatio; + const label = buyDominant ? "buy-side" : "sell-side"; + const aggressor = applySideAggressorAdjustment( + confidence, + coverage, + dominantRatio, + config, + label + ); + confidence = aggressor.confidence; + aggressorNote = aggressor.note; + + const spreadBias = buyDominant ? "debit" : "credit"; + biasNote = `Aggressor skew: buy ${formatPct(aggressiveBuyRatio)}, sell ${formatPct( + aggressiveSellRatio + )}; suggests ${spreadBias} ${structureRights === "C" ? "call" : "put"} vertical.`; + + if (structureRights === "C") { + direction = buyDominant ? "bullish" : "bearish"; + } else { + direction = buyDominant ? "bearish" : "bullish"; + } + } else { + confidence -= 0.1; + } + + confidence = clamp(confidence, 0, 0.85); + + return { + classifier_id: "vertical_spread", + confidence, + direction, + explanations: [ + `Likely vertical spread: ${structureLegs} legs across ${structureStrikes} strikes (span ${strikeSpan}).`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + biasNote, + aggressorNote, + "Direction inferred from debit/credit bias." + ] + }; +}; + +const buildLadderHit = ( + packet: FlowPacket, + config: ClassifierConfig +): ClassifierHit | null => { + const structureType = getStringFeature(packet, "structure_type"); + if (structureType !== "ladder") { + return null; + } + + const activity = getLargeActivity(packet, config); + const { coverage, aggressiveRatio } = getAggressorContext(packet); + const structureRights = getStringFeature(packet, "structure_rights"); + const structureLegs = getNumberFeature(packet, "structure_legs"); + const structureStrikes = getNumberFeature(packet, "structure_strikes"); + const strikeSpan = getNumberFeature(packet, "structure_strike_span"); + + const qualifies = + activity.totalPremium >= config.spikeMinPremium || + activity.totalSize >= config.spikeMinSize || + activity.passesZ; + if (!qualifies) { + return null; + } + + let confidence = 0.45; + if (activity.totalPremium >= config.spikeMinPremium * 2) { + confidence += 0.1; + } + if (activity.totalSize >= config.spikeMinSize * 2) { + confidence += 0.1; + } + if (structureStrikes >= 4) { + confidence += 0.05; + } + if (activity.passesZ) { + confidence += 0.05; + } + + const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config); + confidence = clamp(aggressor.confidence, 0, 0.85); + + let direction: "bullish" | "bearish" | "neutral" = "neutral"; + if (structureRights === "C") { + direction = "bullish"; + } else if (structureRights === "P") { + direction = "bearish"; + } + + return { + classifier_id: "ladder_accumulation", + confidence, + direction, + explanations: [ + `Likely multi-strike ladder accumulation: ${structureLegs} legs across ${structureStrikes} strikes (span ${strikeSpan}).`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + `Thresholds: ladder structure plus >=${config.spikeMinSize} contracts or >=${formatUsd(config.spikeMinPremium)} premium.`, + "Direction inferred from call/put ladder.", + activity.baselineNote, + aggressor.note + ] + }; +}; + +const buildFarDatedHit = ( + packet: FlowPacket, + contract: ParsedContract, + config: ClassifierConfig +): ClassifierHit | null => { + const dteDays = getDteDays(packet, contract); + if (!dteDays || dteDays < 60) { + return null; + } + + const activity = getLargeActivity(packet, config); + if (!activity.passesAbsolute && !activity.passesZ) { + return null; + } + + const { coverage, aggressiveRatio } = getAggressorContext(packet); + let confidence = 0.5; + if (dteDays >= 90) { + confidence += 0.05; + } + if (activity.totalPremium >= config.spikeMinPremium * 2) { + confidence += 0.1; + } + if (activity.totalSize >= config.spikeMinSize * 2) { + confidence += 0.05; + } + if (activity.passesZ) { + confidence += 0.1; + } + + const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config); + confidence = clamp(aggressor.confidence, 0, 0.85); + + return { + classifier_id: "far_dated_conviction", + confidence, + direction: contract.right === "C" ? "bullish" : "bearish", + explanations: [ + `Likely far-dated ${contract.right === "C" ? "call" : "put"} positioning: ${dteDays} DTE for ${packet.features.option_contract_id ?? packet.id}.`, + `Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`, + `Thresholds: DTE >=60 and >=${config.spikeMinSize} contracts or >=${formatUsd(config.spikeMinPremium)} premium (or z-scores).`, + "Direction inferred from call/put right.", + activity.baselineNote, aggressor.note ] }; @@ -222,5 +695,37 @@ export const evaluateClassifiers = ( hits.push(spikeHit); } + if (contract) { + const overwriteHit = buildOverwriteHit(packet, contract, config); + if (overwriteHit) { + hits.push(overwriteHit); + } + + const putWriteHit = buildPutWriteHit(packet, contract, config); + if (putWriteHit) { + hits.push(putWriteHit); + } + + const farDatedHit = buildFarDatedHit(packet, contract, config); + if (farDatedHit) { + hits.push(farDatedHit); + } + } + + const structureHit = buildStraddleStrangleHit(packet, config); + if (structureHit) { + hits.push(structureHit); + } + + const verticalHit = buildVerticalSpreadHit(packet, config); + if (verticalHit) { + hits.push(verticalHit); + } + + const ladderHit = buildLadderHit(packet, config); + if (ladderHit) { + hits.push(ladderHit); + } + return hits; }; diff --git a/services/compute/tests/classifiers.test.ts b/services/compute/tests/classifiers.test.ts index 8903963..81aece7 100644 --- a/services/compute/tests/classifiers.test.ts +++ b/services/compute/tests/classifiers.test.ts @@ -15,12 +15,14 @@ const baseConfig: ClassifierConfig = { minAggressorRatio: 0.55 }; +const DEFAULT_TS = Date.UTC(2024, 0, 2); + const buildPacket = ( overrides: Record ): FlowPacket => { return { - source_ts: 1, - ingest_ts: 1, + source_ts: DEFAULT_TS, + ingest_ts: DEFAULT_TS, seq: 1, trace_id: "trace", id: "packet", @@ -32,6 +34,8 @@ const buildPacket = ( total_size: 20, first_price: 1, last_price: 1.01, + start_ts: DEFAULT_TS - 500, + end_ts: DEFAULT_TS, window_ms: 500, ...overrides }, @@ -100,3 +104,87 @@ describe("classifier z-score behavior", () => { expect((highHit?.confidence ?? 0)).toBeGreaterThan(lowHit?.confidence ?? 0); }); }); + +describe("classifier structure and positioning signals", () => { + test("call overwrite triggers on sell-side aggressor mix", () => { + const packet = buildPacket({ + option_contract_id: "SPY-2024-03-15-450-C", + total_premium: 80_000, + total_size: 800, + nbbo_coverage_ratio: 0.9, + nbbo_aggressive_sell_ratio: 0.7, + nbbo_aggressive_buy_ratio: 0.3 + }); + const hits = evaluateClassifiers(packet, baseConfig); + expect(hits.some((hit) => hit.classifier_id === "large_call_sell_overwrite")).toBe(true); + }); + + test("put write triggers on sell-side aggressor mix", () => { + const packet = buildPacket({ + option_contract_id: "SPY-2024-03-15-450-P", + total_premium: 75_000, + total_size: 700, + nbbo_coverage_ratio: 0.85, + nbbo_aggressive_sell_ratio: 0.68, + nbbo_aggressive_buy_ratio: 0.32 + }); + const hits = evaluateClassifiers(packet, baseConfig); + expect(hits.some((hit) => hit.classifier_id === "large_put_sell_write")).toBe(true); + }); + + test("straddle classifier triggers on structure tag", () => { + const packet = buildPacket({ + structure_type: "straddle", + structure_legs: 2, + structure_strikes: 1, + structure_rights: "C/P", + structure_strike_span: 0 + }); + const hits = evaluateClassifiers(packet, baseConfig); + expect(hits.some((hit) => hit.classifier_id === "straddle")).toBe(true); + }); + + test("vertical spread infers direction from aggressor skew", () => { + const packet = buildPacket({ + structure_type: "vertical", + structure_legs: 2, + structure_strikes: 2, + structure_rights: "C", + structure_strike_span: 5, + total_premium: 55_000, + total_size: 600, + nbbo_coverage_ratio: 0.85, + 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 === "vertical_spread"); + expect(hit?.direction).toBe("bullish"); + }); + + test("ladder accumulation triggers on multi-strike structures", () => { + const packet = buildPacket({ + structure_type: "ladder", + structure_legs: 3, + structure_strikes: 3, + structure_rights: "C", + structure_strike_span: 10, + total_premium: 60_000, + total_size: 650 + }); + const hits = evaluateClassifiers(packet, baseConfig); + expect(hits.some((hit) => hit.classifier_id === "ladder_accumulation")).toBe(true); + }); + + test("far-dated conviction triggers on 60DTE threshold", () => { + const packet = buildPacket({ + option_contract_id: "SPY-2024-04-19-450-C", + end_ts: DEFAULT_TS, + total_premium: 70_000, + total_size: 800 + }); + const hits = evaluateClassifiers(packet, baseConfig); + const hit = hits.find((candidate) => candidate.classifier_id === "far_dated_conviction"); + expect(hit?.direction).toBe("bullish"); + }); +});