VIOLET V2c: synthetic intent scripts + ExecStormHarness + 9-scenario matrix
synthetic_intents.py: seeded IntentScriptSpec -> CycleSpec scripts (script_hash + outcomes_hash determinism, V0 discipline); per-scenario router ExecConfig constructed directly (no env mutation); cycle executor runs full ENTER->terminal->flatten lifecycles with per-scenario terminal predicates and cycle-end invariants (working registry empty, driver drained, slot flat). exec_harness.py: composition root — production bundle (MOCK, injected ScriptedVenue), ExecDeadlineDriver ports wired, pump = venue.reconcile() -> kernel + driver.on_fill forwarding (the production seam), gate report via the ExecGateReport schema, archive next to V0 reports. scripted_venue.py amendment: MARKET orders never rest (venue realism — directives are keyed by trade_id and the R1 MARKET fallback shares the position's trade_id). Matrix green through the REAL kernel at 100ms TTL: immediate fill, rest-then-fill (deadline cancelled), fill-races-cancel (no retry), rest-expire-retry (-r1 opens), retry-exhaust skip|market, exit-expire -> MARKET same trade_id, post-only reject, cancel-reject (no strand). Two runs same seed -> identical outcomes_hash. Router 77 green; shared clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
374
prod/clean_arch/violet/synthetic_intents.py
Normal file
374
prod/clean_arch/violet/synthetic_intents.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""VIOLET V2: seeded synthetic intent scripts + the cycle executor.
|
||||
|
||||
Each cycle is one full trade lifecycle (ENTER → terminal → flatten) driven
|
||||
through the REAL kernel + ScriptedVenue + ExecDeadlineDriver, with the
|
||||
scenario controlling venue behavior and router policy. Scripts are seeded
|
||||
and hashed (same discipline as the V0 storm): same seed ⇒ same scenario
|
||||
sequence, prices, trade ids — and the per-cycle outcome tuples are part of
|
||||
the determinism check in the V2 gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from prod.clean_arch.dita_v2.contracts import (
|
||||
KernelCommandType,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
TradeSide,
|
||||
TradeStage,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.exec_router import ExecConfig
|
||||
|
||||
from .clock import mono_ns
|
||||
from .exec_driver import SLOT_OPENISH
|
||||
from .scripted_venue import Directive
|
||||
|
||||
EXIT_URGENT_REASON = "CATASTROPHIC" # always MARKET per router policy
|
||||
|
||||
|
||||
class Scenario(str, Enum):
|
||||
IMMEDIATE_FILL = "immediate_fill"
|
||||
REST_THEN_FILL = "rest_then_fill"
|
||||
FILL_RACES_CANCEL = "fill_races_cancel"
|
||||
REST_EXPIRE_RETRY = "rest_expire_retry"
|
||||
RETRY_EXHAUST_SKIP = "retry_exhaust_skip"
|
||||
RETRY_EXHAUST_MARKET = "retry_exhaust_market"
|
||||
EXIT_EXPIRE_MARKET = "exit_expire_market"
|
||||
POST_ONLY_REJECT = "post_only_reject"
|
||||
CANCEL_REJECT = "cancel_reject"
|
||||
|
||||
|
||||
def router_config_for(scenario: Scenario) -> ExecConfig:
|
||||
"""Constructed DIRECTLY (no env mutation) per the V2 plan."""
|
||||
if scenario == Scenario.REST_EXPIRE_RETRY:
|
||||
return ExecConfig(style="maker_both", entry_miss="retry",
|
||||
entry_retries=1, retry_exhaust="skip")
|
||||
if scenario == Scenario.RETRY_EXHAUST_SKIP:
|
||||
return ExecConfig(style="maker_both", entry_miss="retry",
|
||||
entry_retries=1, retry_exhaust="skip")
|
||||
if scenario == Scenario.RETRY_EXHAUST_MARKET:
|
||||
return ExecConfig(style="maker_both", entry_miss="retry",
|
||||
entry_retries=1, retry_exhaust="market")
|
||||
return ExecConfig(style="maker_both", entry_miss="skip")
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentScriptSpec:
|
||||
n_cycles: int = 18 # 2 × the 9-scenario matrix
|
||||
seed: int = 7
|
||||
asset: str = "STORMUSDT"
|
||||
base_price: float = 100.0
|
||||
walk_bps: float = 8.0
|
||||
scenarios: Optional[List[Scenario]] = None # None → round-robin all
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"n_cycles": self.n_cycles, "seed": self.seed, "asset": self.asset,
|
||||
"base_price": self.base_price, "walk_bps": self.walk_bps,
|
||||
"scenarios": [s.value for s in (self.scenarios or list(Scenario))],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CycleSpec:
|
||||
idx: int
|
||||
scenario: Scenario
|
||||
trade_id: str
|
||||
price: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CycleOutcome:
|
||||
idx: int
|
||||
scenario: str
|
||||
ok: bool
|
||||
entry_path: str # base | r1 | m | none
|
||||
exit_path: str # maker | market_fallback | urgent_market | none
|
||||
detail: str = ""
|
||||
resolution_ms: float = 0.0
|
||||
|
||||
def key(self) -> Dict[str, Any]:
|
||||
"""Determinism-relevant projection (latency excluded)."""
|
||||
return {"idx": self.idx, "scenario": self.scenario, "ok": self.ok,
|
||||
"entry_path": self.entry_path, "exit_path": self.exit_path}
|
||||
|
||||
|
||||
def generate_script(spec: IntentScriptSpec) -> List[CycleSpec]:
|
||||
rng = random.Random(spec.seed)
|
||||
order = spec.scenarios or list(Scenario)
|
||||
px = spec.base_price
|
||||
out: List[CycleSpec] = []
|
||||
for i in range(spec.n_cycles):
|
||||
px = max(0.01, px * (1.0 + rng.uniform(-1, 1) * spec.walk_bps / 1e4))
|
||||
out.append(CycleSpec(idx=i, scenario=order[i % len(order)],
|
||||
trade_id=f"vsyn-{spec.seed}-{i:05d}",
|
||||
price=round(px, 6)))
|
||||
return out
|
||||
|
||||
|
||||
def script_hash(cycles: List[CycleSpec]) -> str:
|
||||
payload = json.dumps(
|
||||
[{"idx": c.idx, "scenario": c.scenario.value,
|
||||
"trade_id": c.trade_id, "price": c.price} for c in cycles],
|
||||
sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def outcomes_hash(outcomes: List[CycleOutcome]) -> str:
|
||||
payload = json.dumps([o.key() for o in outcomes],
|
||||
sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
# ── intent builders (mirror pink_direct's plan→intent mapping) ────────────────
|
||||
|
||||
def build_enter_intent(cycle: CycleSpec, plan: Any, asset: str) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=cycle.trade_id, trade_id=cycle.trade_id, slot_id=0,
|
||||
asset=asset, side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=cycle.price, target_size=1.0, leverage=1.0,
|
||||
exit_leg_ratios=(1.0,), reason=f"vsyn:{cycle.scenario.value}",
|
||||
metadata={"_time_in_force": ("PostOnly" if plan.post_only else "GTC"),
|
||||
"_exec_reason": plan.reason},
|
||||
stage=TradeStage.INTENT_CREATED,
|
||||
order_type=plan.order_type,
|
||||
limit_price=float(plan.limit_price or 0.0),
|
||||
)
|
||||
|
||||
|
||||
def build_exit_intent(trade_id: str, plan: Any, asset: str, *,
|
||||
size: float, price: float) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}-x", trade_id=trade_id, slot_id=0,
|
||||
asset=asset, side=TradeSide.SHORT,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=price, target_size=float(size), leverage=1.0,
|
||||
exit_leg_ratios=(1.0,), reason=plan.reason,
|
||||
metadata={"_time_in_force": ("PostOnly" if plan.post_only else "GTC"),
|
||||
"_exec_reason": plan.reason},
|
||||
stage=TradeStage.INTENT_CREATED,
|
||||
order_type=plan.order_type,
|
||||
limit_price=float(plan.limit_price or 0.0),
|
||||
)
|
||||
|
||||
|
||||
# ── cycle executor ────────────────────────────────────────────────────────────
|
||||
|
||||
class SyntheticIntentDriver:
|
||||
"""Runs scripted cycles against (kernel, venue, exec driver, router).
|
||||
|
||||
The owner (ExecStormHarness) provides ``pump`` — which MUST forward
|
||||
working-order fills to ``driver.on_fill`` — plus slot_view (same shape
|
||||
as the exec driver's port).
|
||||
"""
|
||||
|
||||
def __init__(self, *, kernel: Any, venue: Any, driver: Any,
|
||||
pump, slot_view, ttl_ms: float, logger: Any = None) -> None:
|
||||
self.kernel = kernel
|
||||
self.venue = venue
|
||||
self.driver = driver
|
||||
self.pump = pump
|
||||
self.slot_view = slot_view
|
||||
self.ttl_ms = float(ttl_ms)
|
||||
self.logger = logger
|
||||
|
||||
# — helpers —
|
||||
|
||||
def _slot_flat(self) -> bool:
|
||||
_tid, stage, size = self.slot_view()
|
||||
return size <= 0.0 and stage not in SLOT_OPENISH and stage not in (
|
||||
"ORDER_SENT", "ORDER_ACKED", "ENTRY_WORKING", "ORDER_REQUESTED",
|
||||
"EXIT_REQUESTED")
|
||||
|
||||
async def _await_terminal(self, pred, timeout_s: float) -> bool:
|
||||
deadline = mono_ns() + int(timeout_s * 1e9)
|
||||
while mono_ns() < deadline:
|
||||
await self.pump()
|
||||
if pred():
|
||||
return True
|
||||
await asyncio.sleep(0.005)
|
||||
return False
|
||||
|
||||
def _arm_venue(self, cycle: CycleSpec) -> None:
|
||||
tid, s = cycle.trade_id, cycle.scenario
|
||||
ttl = self.ttl_ms
|
||||
if s == Scenario.IMMEDIATE_FILL:
|
||||
self.venue.set_directive(tid, Directive.IMMEDIATE_FILL)
|
||||
elif s == Scenario.REST_THEN_FILL:
|
||||
self.venue.set_directive(tid, Directive.REST_THEN_FILL,
|
||||
fill_delay_ms=ttl * 0.3)
|
||||
elif s == Scenario.FILL_RACES_CANCEL:
|
||||
self.venue.set_directive(tid, Directive.FILL_RACES_CANCEL)
|
||||
elif s == Scenario.REST_EXPIRE_RETRY:
|
||||
self.venue.set_directive(tid, Directive.REST_THEN_EXPIRE)
|
||||
self.venue.set_directive(f"{tid}-r1", Directive.IMMEDIATE_FILL)
|
||||
elif s in (Scenario.RETRY_EXHAUST_SKIP,):
|
||||
self.venue.set_directive(tid, Directive.REST_THEN_EXPIRE)
|
||||
elif s == Scenario.RETRY_EXHAUST_MARKET:
|
||||
self.venue.set_directive(tid, Directive.REST_THEN_EXPIRE)
|
||||
# -m fallback is MARKET → fills via venue realism, no directive.
|
||||
elif s == Scenario.EXIT_EXPIRE_MARKET:
|
||||
self.venue.set_directive(tid, Directive.IMMEDIATE_FILL)
|
||||
# re-armed to REST_THEN_EXPIRE after the entry fills (same tid).
|
||||
elif s == Scenario.POST_ONLY_REJECT:
|
||||
self.venue.set_directive(tid, Directive.POST_ONLY_REJECT)
|
||||
elif s == Scenario.CANCEL_REJECT:
|
||||
self.venue.set_directive(tid, Directive.CANCEL_REJECT)
|
||||
|
||||
async def _flatten(self, cycle: CycleSpec, outcome: CycleOutcome) -> bool:
|
||||
"""End-of-cycle: close any open position (urgent MARKET) and clear
|
||||
any stuck working slot (direct CANCEL — operator-style cleanup for
|
||||
the CANCEL_REJECT scenario)."""
|
||||
slot_tid, stage, size = self.slot_view()
|
||||
if size > 0.0 and slot_tid:
|
||||
router = self.driver.ports.router
|
||||
plan = router.plan_exit(trade_id=slot_tid, asset="STORMUSDT",
|
||||
position_side="SHORT",
|
||||
reference_price=cycle.price,
|
||||
reason=EXIT_URGENT_REASON)
|
||||
intent = build_exit_intent(slot_tid, plan, "STORMUSDT",
|
||||
size=size, price=cycle.price)
|
||||
await self.kernel.process_intent_async(intent)
|
||||
if outcome.exit_path == "none":
|
||||
outcome.exit_path = "urgent_market"
|
||||
ok = await self._await_terminal(self._slot_flat, 2.0)
|
||||
if not ok:
|
||||
return False
|
||||
elif not self._slot_flat() and slot_tid:
|
||||
# stuck working entry (cancel-reject path): direct CANCEL, the
|
||||
# venue accepts the second attempt (directive consumed realism
|
||||
# is NOT modeled — clear it explicitly instead).
|
||||
self.venue._scripts.pop(slot_tid, None)
|
||||
cancel = KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{slot_tid}-opcxl", trade_id=slot_tid, slot_id=0,
|
||||
asset="STORMUSDT", side=TradeSide.SHORT,
|
||||
action=KernelCommandType.CANCEL,
|
||||
reference_price=cycle.price, target_size=1.0, leverage=1.0,
|
||||
exit_leg_ratios=(1.0,), reason="vsyn:cleanup", metadata={},
|
||||
stage=TradeStage.INTENT_CREATED,
|
||||
)
|
||||
await self.kernel.process_intent_async(cancel)
|
||||
if not await self._await_terminal(self._slot_flat, 2.0):
|
||||
return False
|
||||
return True
|
||||
|
||||
# — the cycle —
|
||||
|
||||
async def run_cycle(self, cycle: CycleSpec) -> CycleOutcome:
|
||||
out = CycleOutcome(idx=cycle.idx, scenario=cycle.scenario.value,
|
||||
ok=False, entry_path="none", exit_path="none")
|
||||
router = self.driver.ports.router
|
||||
# Per-scenario policy swap — safe at cycle boundaries because the
|
||||
# working registry is empty between cycles (asserted at cycle end).
|
||||
router.config = router_config_for(cycle.scenario)
|
||||
t0 = mono_ns()
|
||||
self._arm_venue(cycle)
|
||||
|
||||
plan = router.plan_entry(trade_id=cycle.trade_id, asset="STORMUSDT",
|
||||
position_side="SHORT",
|
||||
reference_price=cycle.price)
|
||||
if plan.suppress:
|
||||
out.detail = f"entry suppressed at quiescence: {plan.reason}"
|
||||
return out
|
||||
intent = build_enter_intent(cycle, plan, "STORMUSDT")
|
||||
await self.kernel.process_intent_async(intent)
|
||||
if plan.is_maker:
|
||||
self.driver.on_submit(plan, intent)
|
||||
|
||||
budget_s = self.ttl_ms / 1000.0 + 1.0
|
||||
s = cycle.scenario
|
||||
|
||||
def _slot_open_with(tid: str) -> bool:
|
||||
slot_tid, stage, size = self.slot_view()
|
||||
return slot_tid == tid and size > 0.0 and stage in SLOT_OPENISH
|
||||
|
||||
if s in (Scenario.IMMEDIATE_FILL, Scenario.REST_THEN_FILL,
|
||||
Scenario.FILL_RACES_CANCEL):
|
||||
if not await self._await_terminal(
|
||||
lambda: _slot_open_with(cycle.trade_id), budget_s):
|
||||
out.detail = "entry never opened"
|
||||
return out
|
||||
out.entry_path = "base"
|
||||
elif s == Scenario.REST_EXPIRE_RETRY:
|
||||
if not await self._await_terminal(
|
||||
lambda: _slot_open_with(f"{cycle.trade_id}-r1"),
|
||||
budget_s + self.ttl_ms / 1000.0):
|
||||
out.detail = "retry never opened"
|
||||
return out
|
||||
out.entry_path = "r1"
|
||||
elif s == Scenario.RETRY_EXHAUST_MARKET:
|
||||
if not await self._await_terminal(
|
||||
lambda: _slot_open_with(f"{cycle.trade_id}-m"),
|
||||
budget_s + 2 * self.ttl_ms / 1000.0):
|
||||
out.detail = "market fallback never opened"
|
||||
return out
|
||||
out.entry_path = "m"
|
||||
elif s in (Scenario.RETRY_EXHAUST_SKIP, Scenario.POST_ONLY_REJECT,
|
||||
Scenario.CANCEL_REJECT):
|
||||
# Terminal = no position, working registry cleared.
|
||||
def _resolved() -> bool:
|
||||
_tid, _stage, size = self.slot_view()
|
||||
return (size <= 0.0 and not router.working_orders()
|
||||
and not self.driver._resolving)
|
||||
if not await self._await_terminal(
|
||||
_resolved, budget_s + 2 * self.ttl_ms / 1000.0):
|
||||
out.detail = "miss path never resolved"
|
||||
return out
|
||||
out.entry_path = "none"
|
||||
|
||||
# EXIT phase for every cycle that holds a position.
|
||||
slot_tid, _stage, size = self.slot_view()
|
||||
if size > 0.0:
|
||||
if s == Scenario.EXIT_EXPIRE_MARKET:
|
||||
out.entry_path = "base" # immediate-fill entry above
|
||||
self.venue.set_directive(slot_tid, Directive.REST_THEN_EXPIRE)
|
||||
xplan = router.plan_exit(trade_id=slot_tid, asset="STORMUSDT",
|
||||
position_side="SHORT",
|
||||
reference_price=cycle.price,
|
||||
reason="TAKE_PROFIT")
|
||||
if not xplan.is_maker:
|
||||
out.detail = f"expected maker exit, got {xplan.reason}"
|
||||
return out
|
||||
xintent = build_exit_intent(slot_tid, xplan, "STORMUSDT",
|
||||
size=size, price=cycle.price)
|
||||
await self.kernel.process_intent_async(xintent)
|
||||
self.driver.on_submit(xplan, xintent)
|
||||
if not await self._await_terminal(self._slot_flat,
|
||||
budget_s + 1.0):
|
||||
out.detail = "exit market fallback never closed"
|
||||
return out
|
||||
out.exit_path = "market_fallback"
|
||||
# all other scenarios flatten below
|
||||
|
||||
if not await self._flatten(cycle, out):
|
||||
out.detail = "flatten failed"
|
||||
return out
|
||||
# Cycle-end invariants — the matrix's hard correctness core.
|
||||
if router.working_orders():
|
||||
out.detail = f"stuck working orders: {router.working_orders()}"
|
||||
return out
|
||||
if not await self.driver.drain(1.0):
|
||||
out.detail = f"driver did not drain: {self.driver.snapshot()}"
|
||||
return out
|
||||
out.resolution_ms = (mono_ns() - t0) / 1e6
|
||||
out.ok = True
|
||||
return out
|
||||
|
||||
async def run(self, spec: IntentScriptSpec) -> List[CycleOutcome]:
|
||||
outcomes = []
|
||||
for cycle in generate_script(spec):
|
||||
outcomes.append(await self.run_cycle(cycle))
|
||||
return outcomes
|
||||
Reference in New Issue
Block a user