From 86661df7ae62280e8c4b4eb7ff09241267fceeb2 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 5 May 2026 01:29:39 -0400 Subject: [PATCH] Rebuild synthetic smart-money scenarios --- .beads/issues.jsonl | 2 +- SMART_MONEY_REBUILD_PLAN.md | 6 +- .../ingest-options/src/adapters/synthetic.ts | 384 +++++++++++++++++- .../ingest-options/tests/synthetic.test.ts | 83 +++- 4 files changed, 468 insertions(+), 7 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 88c4a23..7dfca78 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,7 +6,7 @@ {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-b6d","title":"Finish smart-money event-calendar enrichment","description":"Finish the smart-money event-calendar provider layer in services/refdata and connect days-to-event / expiry-after-event enrichment into compute using timestamp-available data only.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:26Z","created_by":"dirtydishes","updated_at":"2026-05-04T23:21:09Z","started_at":"2026-05-04T23:18:29Z","closed_at":"2026-05-04T23:21:09Z","close_reason":"Completed event-calendar provider and compute enrichment","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e60","title":"Add smart-money replay evaluation harness","description":"Add replay-style live-vs-batch consistency tests plus evaluation utilities for parent-event precision/recall, calibration, abstention rate, and economic sanity checks.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:25Z","created_by":"dirtydishes","updated_at":"2026-05-04T21:35:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"islandflow-020","title":"Rebuild synthetic smart-money scenarios","description":"Rework services/ingest-options synthetic generation around labeled parent-event templates for the six core smart-money profiles plus neutral background noise, with deterministic test/demo modes and hidden labels for tests.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:24Z","created_by":"dirtydishes","updated_at":"2026-05-04T21:35:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-020","title":"Rebuild synthetic smart-money scenarios","description":"Rework services/ingest-options synthetic generation around labeled parent-event templates for the six core smart-money profiles plus neutral background noise, with deterministic test/demo modes and hidden labels for tests.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:24Z","created_by":"dirtydishes","updated_at":"2026-05-05T05:29:27Z","started_at":"2026-05-05T05:25:39Z","closed_at":"2026-05-05T05:29:27Z","close_reason":"Completed Phase 5 synthetic smart-money scenario rebuild","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-zs0","title":"Migrate terminal UI to smart-money profiles","description":"Migrate apps/web terminal rendering to consume SmartMoneyEvent directly: primary profile, probability ladder, reason codes, and suppression/abstention state, while preserving legacy alert/classifier displays during the bridge.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:23Z","created_by":"dirtydishes","updated_at":"2026-05-04T21:35:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-igk","title":"Add plan mode","description":"Implement a user-facing plan mode in the application so users can switch into planning before taking action. Scope to be clarified from existing app patterns.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:22:37Z","created_by":"dirtydishes","updated_at":"2026-05-04T04:26:18Z","started_at":"2026-05-04T04:22:40Z","closed_at":"2026-05-04T04:26:18Z","close_reason":"Implemented as a global pi extension toggled with Shift+P","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-biq","title":"Finish raw live options delivery and filter/backpressure observability","description":"The smart-money signal path and Tape filters are in place, but the next firehose pass should finish server-side selective raw live delivery for options subscriptions and add explicit filtered-out/backpressure observability for API/web counters. This was discovered while landing islandflow-e4r.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:28:58Z","created_by":"dirtydishes","updated_at":"2026-04-29T03:54:12Z","started_at":"2026-04-29T03:54:12Z","dependencies":[{"issue_id":"islandflow-biq","depends_on_id":"islandflow-e4r","type":"discovered-from","created_at":"2026-04-28T16:28:58Z","created_by":"dirtydishes","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/SMART_MONEY_REBUILD_PLAN.md b/SMART_MONEY_REBUILD_PLAN.md index f2a6efa..7016fbd 100644 --- a/SMART_MONEY_REBUILD_PLAN.md +++ b/SMART_MONEY_REBUILD_PLAN.md @@ -36,9 +36,9 @@ Acceptance: missing event-calendar fields produce neutral `null` feature values Acceptance: abstained events do not emit legacy classifier hits. ### Phase 5: Synthetic Market Redesign -- [ ] Rework synthetic options adapter around labeled parent-event templates. -- [ ] Add deterministic scenario families for all six profiles. -- [ ] Add test/demo operating modes with hidden labels. +- [x] Rework synthetic options adapter around labeled parent-event templates. +- [x] Add deterministic scenario families for all six profiles. +- [x] Add test/demo operating modes with hidden labels. Acceptance: scenario tests assert intended profile wins and wrong nearby profiles remain below threshold. diff --git a/services/ingest-options/src/adapters/synthetic.ts b/services/ingest-options/src/adapters/synthetic.ts index a1d50e1..eaa3f02 100644 --- a/services/ingest-options/src/adapters/synthetic.ts +++ b/services/ingest-options/src/adapters/synthetic.ts @@ -1,7 +1,9 @@ import { SP500_SYMBOLS, + type FlowPacket, type OptionNBBO, type OptionPrint, + type SmartMoneyProfileId, type SyntheticMarketMode } from "@islandflow/types"; import type { OptionIngestAdapter, OptionIngestHandlers } from "./types"; @@ -23,7 +25,9 @@ type Burst = { printCount: number; priceStep: number; scenarioId: string; + label: SyntheticScenarioLabel; seed: number; + flowFeatures: FlowPacket["features"]; }; export type SyntheticContractIvState = { @@ -58,73 +62,157 @@ type WeightedValue = { type Scenario = { id: string; weight: number; + label: SyntheticScenarioLabel; right: "C" | "P" | "either"; countRange: [number, number]; sizeRange: [number, number]; targetNotionalRange: [number, number]; priceTrend: "up" | "down" | "flat"; + expiryOffsets?: number[]; + underlying?: number; + strikeMoneyness?: number; + flowFeatures: FlowPacket["features"]; conditions?: string[]; }; +export type SyntheticScenarioLabel = SmartMoneyProfileId | "neutral_noise"; + +export type SyntheticSmartMoneyScenario = { + id: string; + label: SyntheticScenarioLabel; + hiddenLabel: SyntheticScenarioLabel; +}; + +const SMART_MONEY_SCENARIO_IDS = [ + "institutional_directional", + "retail_whale", + "event_driven", + "vol_seller", + "arbitrage", + "hedge_reactive", + "neutral_noise" +] as const; + const REALISTIC_SCENARIOS: Scenario[] = [ { id: "ask_lift", weight: 18, + label: "institutional_directional", right: "either", countRange: [1, 2], sizeRange: [30, 180], targetNotionalRange: [9_000, 35_000], priceTrend: "flat", + flowFeatures: { + nbbo_coverage_ratio: 0.88, + nbbo_aggressive_ratio: 0.7, + nbbo_aggressive_buy_ratio: 0.66, + nbbo_aggressive_sell_ratio: 0.08, + nbbo_inside_ratio: 0.12, + venue_count: 2 + }, conditions: ["FILL"] }, { id: "mid_block", weight: 14, + label: "arbitrage", right: "either", countRange: [1, 2], sizeRange: [120, 480], targetNotionalRange: [12_000, 45_000], priceTrend: "flat", + flowFeatures: { + structure_type: "vertical", + structure_legs: 2, + structure_strikes: 2, + same_size_leg_symmetry: 0.74, + nbbo_coverage_ratio: 0.82, + nbbo_aggressive_ratio: 0.26, + nbbo_aggressive_buy_ratio: 0.3, + nbbo_aggressive_sell_ratio: 0.24, + nbbo_inside_ratio: 0.42, + venue_count: 2 + }, conditions: ["FILL"] }, { id: "bullish_sweep", weight: 8, + label: "institutional_directional", right: "C", countRange: [2, 3], sizeRange: [180, 520], targetNotionalRange: [25_000, 90_000], priceTrend: "up", + flowFeatures: { + nbbo_coverage_ratio: 0.9, + nbbo_aggressive_ratio: 0.82, + nbbo_aggressive_buy_ratio: 0.78, + nbbo_aggressive_sell_ratio: 0.04, + nbbo_inside_ratio: 0.08, + venue_count: 4 + }, conditions: ["SWEEP"] }, { id: "bearish_sweep", weight: 8, + label: "institutional_directional", right: "P", countRange: [2, 3], sizeRange: [180, 520], targetNotionalRange: [25_000, 90_000], priceTrend: "up", + flowFeatures: { + nbbo_coverage_ratio: 0.9, + nbbo_aggressive_ratio: 0.82, + nbbo_aggressive_buy_ratio: 0.78, + nbbo_aggressive_sell_ratio: 0.04, + nbbo_inside_ratio: 0.08, + venue_count: 4 + }, conditions: ["SWEEP"] }, { id: "contract_spike", weight: 6, + label: "retail_whale", right: "either", countRange: [2, 3], sizeRange: [500, 900], targetNotionalRange: [18_000, 70_000], priceTrend: "flat", + expiryOffsets: [0, 1, 7], + strikeMoneyness: 1.08, + flowFeatures: { + nbbo_coverage_ratio: 0.76, + nbbo_aggressive_ratio: 0.68, + nbbo_aggressive_buy_ratio: 0.62, + nbbo_aggressive_sell_ratio: 0.08, + nbbo_inside_ratio: 0.12, + execution_iv_shock: 0.16, + venue_count: 3 + }, conditions: ["ISO"] }, { id: "noise", weight: 46, + label: "neutral_noise", right: "either", countRange: [1, 2], sizeRange: [5, 60], targetNotionalRange: [500, 6_000], priceTrend: "flat", + flowFeatures: { + nbbo_coverage_ratio: 0.76, + nbbo_aggressive_ratio: 0.24, + nbbo_aggressive_buy_ratio: 0.24, + nbbo_aggressive_sell_ratio: 0.18, + nbbo_inside_ratio: 0.52, + venue_count: 1 + }, conditions: ["FILL"] } ]; @@ -133,41 +221,246 @@ const ACTIVE_SCENARIOS: Scenario[] = [ { id: "bullish_sweep", weight: 35, + label: "institutional_directional", right: "C", countRange: [7, 10], sizeRange: [600, 1800], targetNotionalRange: [120_000, 240_000], priceTrend: "up", + flowFeatures: { + nbbo_coverage_ratio: 0.94, + nbbo_aggressive_ratio: 0.86, + nbbo_aggressive_buy_ratio: 0.82, + nbbo_aggressive_sell_ratio: 0.03, + nbbo_inside_ratio: 0.06, + venue_count: 5 + }, conditions: ["SWEEP"] }, { id: "bearish_sweep", weight: 35, + label: "institutional_directional", right: "P", countRange: [7, 10], sizeRange: [600, 1800], targetNotionalRange: [120_000, 240_000], priceTrend: "up", + flowFeatures: { + nbbo_coverage_ratio: 0.94, + nbbo_aggressive_ratio: 0.86, + nbbo_aggressive_buy_ratio: 0.82, + nbbo_aggressive_sell_ratio: 0.03, + nbbo_inside_ratio: 0.06, + venue_count: 5 + }, conditions: ["SWEEP"] }, { id: "contract_spike", weight: 20, + label: "retail_whale", right: "either", countRange: [5, 8], sizeRange: [1200, 3200], targetNotionalRange: [60_000, 140_000], priceTrend: "flat", + expiryOffsets: [0, 1, 7], + strikeMoneyness: 1.08, + flowFeatures: { + nbbo_coverage_ratio: 0.78, + nbbo_aggressive_ratio: 0.72, + nbbo_aggressive_buy_ratio: 0.66, + nbbo_aggressive_sell_ratio: 0.06, + nbbo_inside_ratio: 0.1, + execution_iv_shock: 0.19, + venue_count: 4 + }, conditions: ["ISO"] }, { id: "noise", weight: 10, + label: "neutral_noise", right: "either", countRange: [2, 4], sizeRange: [10, 200], targetNotionalRange: [500, 5000], priceTrend: "flat", + flowFeatures: { + nbbo_coverage_ratio: 0.72, + nbbo_aggressive_ratio: 0.24, + nbbo_aggressive_buy_ratio: 0.24, + nbbo_aggressive_sell_ratio: 0.2, + nbbo_inside_ratio: 0.52, + venue_count: 1 + }, + conditions: ["FILL"] + } +]; + +const SMART_MONEY_TEMPLATE_SCENARIOS: Scenario[] = [ + { + id: "institutional_directional", + weight: 18, + label: "institutional_directional", + right: "C", + countRange: [8, 10], + sizeRange: [1600, 2400], + targetNotionalRange: [170_000, 230_000], + priceTrend: "up", + expiryOffsets: [28, 45], + strikeMoneyness: 1.01, + flowFeatures: { + nbbo_coverage_ratio: 0.94, + nbbo_aggressive_ratio: 0.86, + nbbo_aggressive_buy_ratio: 0.82, + nbbo_aggressive_sell_ratio: 0.04, + nbbo_inside_ratio: 0.06, + venue_count: 5 + }, + conditions: ["SWEEP"] + }, + { + id: "retail_whale", + weight: 14, + label: "retail_whale", + right: "C", + countRange: [9, 12], + sizeRange: [450, 850], + targetNotionalRange: [35_000, 75_000], + priceTrend: "up", + expiryOffsets: [1, 7], + strikeMoneyness: 1.1, + flowFeatures: { + nbbo_coverage_ratio: 0.82, + nbbo_aggressive_ratio: 0.74, + nbbo_aggressive_buy_ratio: 0.68, + nbbo_aggressive_sell_ratio: 0.04, + nbbo_inside_ratio: 0.08, + execution_iv_shock: 0.19, + venue_count: 4 + }, + conditions: ["ISO"] + }, + { + id: "event_driven", + weight: 12, + label: "event_driven", + right: "C", + countRange: [1, 2], + sizeRange: [700, 1100], + targetNotionalRange: [72_000, 88_000], + priceTrend: "flat", + expiryOffsets: [28, 45], + strikeMoneyness: 1.0, + flowFeatures: { + corporate_event_ts_offset_days: 14, + nbbo_coverage_ratio: 0.38, + nbbo_aggressive_ratio: 0.32, + nbbo_aggressive_buy_ratio: 0.3, + nbbo_aggressive_sell_ratio: 0.08, + nbbo_inside_ratio: 0.28, + nbbo_spread_z: 0.12, + venue_count: 2 + }, + conditions: ["FILL"] + }, + { + id: "vol_seller", + weight: 12, + label: "vol_seller", + right: "either", + countRange: [4, 6], + sizeRange: [1300, 2100], + targetNotionalRange: [150_000, 210_000], + priceTrend: "down", + expiryOffsets: [28, 45], + strikeMoneyness: 1.0, + flowFeatures: { + structure_type: "straddle", + structure_legs: 2, + structure_strikes: 1, + structure_rights: "CP", + conditions: "COMPLEX", + nbbo_coverage_ratio: 0.9, + nbbo_aggressive_ratio: 0.72, + nbbo_aggressive_buy_ratio: 0.08, + nbbo_aggressive_sell_ratio: 0.7, + nbbo_inside_ratio: 0.1, + same_size_leg_symmetry: 0.66, + venue_count: 3 + }, + conditions: ["FILL"] + }, + { + id: "arbitrage", + weight: 12, + label: "arbitrage", + right: "either", + countRange: [4, 6], + sizeRange: [900, 1400], + targetNotionalRange: [70_000, 115_000], + priceTrend: "flat", + expiryOffsets: [28, 45], + strikeMoneyness: 1.0, + flowFeatures: { + structure_type: "vertical", + structure_legs: 2, + structure_strikes: 2, + structure_rights: "CP", + conditions: "COMPLEX", + nbbo_coverage_ratio: 0.86, + nbbo_aggressive_ratio: 0.4, + nbbo_aggressive_buy_ratio: 0.42, + nbbo_aggressive_sell_ratio: 0.38, + nbbo_inside_ratio: 0.32, + same_size_leg_symmetry: 0.92, + venue_count: 3 + }, + conditions: ["FILL"] + }, + { + id: "hedge_reactive", + weight: 12, + label: "hedge_reactive", + right: "P", + countRange: [1, 2], + sizeRange: [2600, 3400], + targetNotionalRange: [35_000, 50_000], + priceTrend: "up", + expiryOffsets: [0, 1], + strikeMoneyness: 1.0, + flowFeatures: { + nbbo_coverage_ratio: 0.86, + nbbo_aggressive_ratio: 0.58, + nbbo_aggressive_buy_ratio: 0.54, + nbbo_aggressive_sell_ratio: 0.12, + nbbo_inside_ratio: 0.16, + underlying_move_bps: -72, + venue_count: 3 + }, + conditions: ["FILL"] + }, + { + id: "neutral_noise", + weight: 20, + label: "neutral_noise", + right: "either", + countRange: [1, 2], + sizeRange: [10, 70], + targetNotionalRange: [800, 7_000], + priceTrend: "flat", + expiryOffsets: [14, 28, 45, 60], + strikeMoneyness: 1.02, + flowFeatures: { + nbbo_coverage_ratio: 0.78, + nbbo_aggressive_ratio: 0.22, + nbbo_aggressive_buy_ratio: 0.22, + nbbo_aggressive_sell_ratio: 0.18, + nbbo_inside_ratio: 0.58, + venue_count: 1 + }, conditions: ["FILL"] } ]; @@ -292,6 +585,25 @@ const SYNTHETIC_PROFILES: Record = } }; +const SMART_MONEY_TEMPLATE_PROFILE: SyntheticOptionsProfile = { + burstRunRange: [1, 1], + scenarios: SMART_MONEY_TEMPLATE_SCENARIOS, + pricePlacements: { + ...ACTIVE_PRICE_PLACEMENTS, + institutional_directional: ACTIVE_PRICE_PLACEMENTS.bullish_sweep, + retail_whale: ACTIVE_PRICE_PLACEMENTS.contract_spike, + event_driven: REALISTIC_PRICE_PLACEMENTS.ask_lift, + vol_seller: [ + { value: "B", weight: 45 }, + { value: "BB", weight: 35 }, + { value: "MID", weight: 20 } + ], + arbitrage: REALISTIC_PRICE_PLACEMENTS.mid_block, + hedge_reactive: ACTIVE_PRICE_PLACEMENTS.bullish_sweep, + neutral_noise: REALISTIC_PRICE_PLACEMENTS.noise + } +}; + const pick = (items: T[], seed: number): T => { return items[Math.abs(seed) % items.length]; }; @@ -414,14 +726,18 @@ const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsPr const seed = symbolHash + burstIndex * 7; const scenario = pickWeighted(profile.scenarios, seed); const baseUnderlying = 30 + (symbolHash % 470); - const expiryOffset = pick(EXPIRY_OFFSETS, symbolHash + burstIndex); + const expiryOffset = pick(scenario.expiryOffsets ?? EXPIRY_OFFSETS, symbolHash + burstIndex); const expiry = formatExpiry(now, expiryOffset); const strikeStep = baseUnderlying >= 200 ? 10 : baseUnderlying >= 100 ? 5 : 2.5; const moneynessSteps = scenario.id === "noise" ? 5 : 2; const strikeOffset = pickInt(-moneynessSteps, moneynessSteps, symbolHash + burstIndex * 11); + const templateStrike = + scenario.strikeMoneyness !== undefined + ? Math.round((baseUnderlying * scenario.strikeMoneyness) / strikeStep) * strikeStep + : null; const strike = Math.max( 1, - Math.round(baseUnderlying / strikeStep) * strikeStep + strikeOffset * strikeStep + templateStrike ?? Math.round(baseUnderlying / strikeStep) * strikeStep + strikeOffset * strikeStep ); const right = scenario.right === "either" @@ -463,6 +779,8 @@ const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsPr printCount, priceStep, scenarioId: scenario.id, + label: scenario.label, + flowFeatures: scenario.flowFeatures, seed }; }; @@ -473,6 +791,68 @@ export const buildSyntheticBurstForTest = ( mode: SyntheticMarketMode ): Burst => buildBurst(burstIndex, now, SYNTHETIC_PROFILES[mode]); +export const listSyntheticSmartMoneyScenariosForTest = (): SyntheticSmartMoneyScenario[] => + SMART_MONEY_SCENARIO_IDS.map((id) => ({ + id, + label: id, + hiddenLabel: id + })); + +export const buildSyntheticSmartMoneyBurstForTest = ( + scenarioId: (typeof SMART_MONEY_SCENARIO_IDS)[number], + now: number +): Burst => { + const scenarioIndex = SMART_MONEY_TEMPLATE_SCENARIOS.findIndex((scenario) => scenario.id === scenarioId); + if (scenarioIndex < 0) { + throw new Error(`Unknown synthetic smart-money scenario: ${scenarioId}`); + } + return buildBurst(scenarioIndex, now, { + ...SMART_MONEY_TEMPLATE_PROFILE, + scenarios: [SMART_MONEY_TEMPLATE_SCENARIOS[scenarioIndex]] + }); +}; + +export const buildSyntheticFlowPacketForTest = ( + scenarioId: (typeof SMART_MONEY_SCENARIO_IDS)[number], + now: number +): { packet: FlowPacket; hiddenLabel: SyntheticScenarioLabel } => { + const burst = buildSyntheticSmartMoneyBurstForTest(scenarioId, now); + const corporateEventOffset = Number(burst.flowFeatures.corporate_event_ts_offset_days ?? 0); + const flowFeatures: FlowPacket["features"] = { + option_contract_id: burst.contractId, + underlying_id: burst.contractId.split("-")[0], + underlying_mid: burst.underlying, + count: burst.printCount, + window_ms: Math.max(0, (burst.printCount - 1) * 45), + total_size: burst.baseSize * burst.printCount, + total_premium: Number((burst.basePrice * burst.baseSize * burst.printCount * OPTION_CONTRACT_MULTIPLIER).toFixed(2)), + total_notional: Number((burst.underlying * burst.baseSize * burst.printCount * OPTION_CONTRACT_MULTIPLIER).toFixed(2)), + first_price: burst.basePrice, + last_price: Number((burst.basePrice * (1 + burst.priceStep * Math.max(0, burst.printCount - 1))).toFixed(2)), + nbbo_missing_count: 0, + nbbo_stale_count: 0, + ...burst.flowFeatures + }; + delete flowFeatures.corporate_event_ts_offset_days; + if (corporateEventOffset > 0) { + flowFeatures.corporate_event_ts = now + corporateEventOffset * MS_PER_DAY; + } + + return { + hiddenLabel: burst.label, + packet: { + source_ts: now, + ingest_ts: now, + seq: SMART_MONEY_SCENARIO_IDS.indexOf(scenarioId) + 1, + trace_id: `synthetic-smart-money:${scenarioId}`, + id: `synthetic-smart-money:${scenarioId}:${now}`, + members: Array.from({ length: burst.printCount }, (_, index) => `${burst.contractId}:${index + 1}`), + features: flowFeatures, + join_quality: {} + } + }; +}; + export const createSyntheticOptionsAdapter = ( config: SyntheticOptionsAdapterConfig ): OptionIngestAdapter => { diff --git a/services/ingest-options/tests/synthetic.test.ts b/services/ingest-options/tests/synthetic.test.ts index e0c8407..6db43a3 100644 --- a/services/ingest-options/tests/synthetic.test.ts +++ b/services/ingest-options/tests/synthetic.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { buildSyntheticBurstForTest, updateSyntheticIvForTest } from "../src/adapters/synthetic"; +import type { OptionPrint } from "@islandflow/types"; +import { buildSmartMoneyEventFromPacket } from "../../compute/src/parent-events"; +import { + buildSyntheticBurstForTest, + buildSyntheticFlowPacketForTest, + createSyntheticOptionsAdapter, + listSyntheticSmartMoneyScenariosForTest, + updateSyntheticIvForTest +} from "../src/adapters/synthetic"; const totalBurstNotional = (burst: { basePrice: number; @@ -87,3 +95,76 @@ describe("synthetic options IV model", () => { expect(state.iv).toBeLessThanOrEqual(2.5); }); }); + +describe("synthetic smart-money scenarios", () => { + it("provides deterministic labeled parent-event templates for all core profiles plus noise", () => { + const scenarios = listSyntheticSmartMoneyScenariosForTest(); + + expect(scenarios.map((scenario) => scenario.id)).toEqual([ + "institutional_directional", + "retail_whale", + "event_driven", + "vol_seller", + "arbitrage", + "hedge_reactive", + "neutral_noise" + ]); + }); + + it("scores each labeled scenario as its intended primary profile", () => { + const now = Date.parse("2026-01-02T15:00:00Z"); + const scenarios = listSyntheticSmartMoneyScenariosForTest().filter( + (scenario) => scenario.hiddenLabel !== "neutral_noise" + ); + + for (const scenario of scenarios) { + const { packet, hiddenLabel } = buildSyntheticFlowPacketForTest(scenario.id, now); + const event = buildSmartMoneyEventFromPacket(packet); + const winningScore = event.profile_scores[0]; + const nearbyWrongScores = event.profile_scores.filter( + (score) => score.profile_id !== hiddenLabel && score.probability >= 0.5 + ); + + expect(event.abstained, scenario.id).toBe(false); + expect(event.primary_profile_id, scenario.id).toBe(hiddenLabel); + expect(winningScore?.profile_id, scenario.id).toBe(hiddenLabel); + expect(winningScore?.probability ?? 0, scenario.id).toBeGreaterThanOrEqual(0.5); + expect(nearbyWrongScores, scenario.id).toEqual([]); + } + }); + + it("keeps neutral background noise below the emission threshold", () => { + const { packet } = buildSyntheticFlowPacketForTest( + "neutral_noise", + Date.parse("2026-01-02T15:00:00Z") + ); + + const event = buildSmartMoneyEventFromPacket(packet); + + expect(event.abstained).toBe(true); + expect(event.primary_profile_id).toBeNull(); + expect(event.profile_scores[0]?.probability ?? 1).toBeLessThan(0.42); + }); + + it("does not expose hidden labels on emitted option prints", async () => { + const adapter = createSyntheticOptionsAdapter({ + emitIntervalMs: 1, + mode: "active" + }); + const trades: OptionPrint[] = []; + const stop = adapter.start({ + onTrade: (trade) => { + trades.push(trade); + } + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + stop(); + + expect(trades.length).toBeGreaterThan(0); + for (const trade of trades) { + expect("hiddenLabel" in trade).toBe(false); + expect("label" in trade).toBe(false); + } + }); +});