"""Sprint 3 offline groundwork — PINK MARKET multi-leg. Validates, with MockVenue (no exchange contact): 1. Flaw 4 — a two-leg exit closes only after the final leg, with no double-close / double-settle and a correct cumulative realized PnL. 2. The cumulative-ratio sizing-overshoot invariant flagged in Sprint 0: a final EXIT that requests MORE than the remaining position must not oversell — remaining size clamps to 0.0 (never negative) and the slot closes exactly once. 3. The new ``trade_exit_legs`` writer in pink_clickhouse.py emits one BLUE-schema-compatible row per leg, with isolated (non-cumulative) per-leg deltas. Run from repo root: PYTHONPATH=/mnt/dolphinng5_predict pytest prod/tests/test_pink_multi_exit_groundwork.py """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from types import SimpleNamespace from prod.clean_arch.dita_v2 import ( ExecutionKernel, InMemoryControlPlane, KernelCommandType, KernelControlSnapshot, KernelMode, KernelVerbosity, MemoryKernelJournal, MockVenueAdapter, MockVenueScenario, TradeSide, ) from prod.clean_arch.dita_v2.contracts import KernelIntent from prod.clean_arch.dita import ( AccountProjection, AccountSnapshot, Decision, DecisionAction, Intent, TradeSide as PolicyTradeSide, TradeStage, ) from prod.clean_arch.dita_v2.contracts import ( KernelDiagnosticCode, KernelOutcome, KernelSeverity, TradeStage as DitaTradeStage, ) from prod.clean_arch.persistence.pink_clickhouse import PinkClickHousePersistence # -------------------------------------------------------------------------- # Kernel-level invariants (Flaw 4 + overshoot clamp) # -------------------------------------------------------------------------- def _mk_kernel() -> ExecutionKernel: return ExecutionKernel( control_plane=InMemoryControlPlane( KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE) ), venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)), journal=MemoryKernelJournal(), ) def _kintent(action, *, target_size, exit_leg_ratios=(1.0,), reason="TEST", price=100.0): return KernelIntent( timestamp=datetime.now(timezone.utc), intent_id=f"intent-{action.value}-{reason}", trade_id="trade-1", slot_id=0, asset="BTCUSDT", side=TradeSide.SHORT, action=action, reference_price=price, target_size=target_size, leverage=2.0, exit_leg_ratios=tuple(exit_leg_ratios), reason=reason, ) def test_two_leg_exit_no_double_close_realized_accrues_once(): """Flaw 4: SHORT 1.0 @100, exit two 0.5 legs @90 → closes once, realized > 0.""" kernel = _mk_kernel() kernel.process_intent(_kintent(KernelCommandType.ENTER, target_size=1.0, price=100.0)) slot = kernel.slot(0) slot.exit_leg_ratios = (0.5, 0.5) first = kernel.process_intent( _kintent(KernelCommandType.EXIT, target_size=0.5, exit_leg_ratios=(0.5, 0.5), reason="TP1", price=90.0) ) assert first.accepted assert not slot.closed assert slot.fsm_state == DitaTradeStage.POSITION_OPEN assert abs(slot.size - 0.5) < 1e-6 realized_after_leg1 = slot.realized_pnl assert realized_after_leg1 > 0.0 # SHORT entered @100, exited @90 → profit second = kernel.process_intent( _kintent(KernelCommandType.EXIT, target_size=0.5, exit_leg_ratios=(0.5, 0.5), reason="TP2", price=90.0) ) assert second.accepted assert slot.closed assert slot.fsm_state == DitaTradeStage.CLOSED assert abs(slot.size) < 1e-6 # Realized accrued on both legs and is strictly larger than after leg 1. assert slot.realized_pnl > realized_after_leg1 # A further EXIT on the closed slot must be rejected (no double-close). third = kernel.process_intent( _kintent(KernelCommandType.EXIT, target_size=0.5, exit_leg_ratios=(0.5, 0.5), reason="TP3", price=90.0) ) assert not third.accepted def test_final_leg_overshoot_does_not_oversell(): """Overshoot invariant: a final EXIT requesting MORE than remaining must clamp — size never goes negative and the slot closes exactly once.""" kernel = _mk_kernel() kernel.process_intent(_kintent(KernelCommandType.ENTER, target_size=1.0, price=100.0)) slot = kernel.slot(0) slot.exit_leg_ratios = (0.5, 1.0) kernel.process_intent( _kintent(KernelCommandType.EXIT, target_size=0.5, exit_leg_ratios=(0.5, 1.0), reason="TP1", price=90.0) ) assert abs(slot.size - 0.5) < 1e-6 assert not slot.closed # Final leg requests 1.0 but only 0.5 remains. kernel.process_intent( _kintent(KernelCommandType.EXIT, target_size=1.0, exit_leg_ratios=(0.5, 1.0), reason="TP2", price=90.0) ) assert slot.size >= 0.0, f"oversold: size went negative ({slot.size})" assert abs(slot.size) < 1e-6, f"final leg left residual size {slot.size}" assert slot.closed assert slot.fsm_state == DitaTradeStage.CLOSED # -------------------------------------------------------------------------- # trade_exit_legs writer (pink_clickhouse.py) # -------------------------------------------------------------------------- @dataclass class _Sink: calls: list = field(default_factory=list) def __call__(self, table: str, row: dict) -> None: self.calls.append((table, row)) def _snapshot(): return SimpleNamespace( timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), symbol="BTCUSDT", price=100.0, ) def _account(capital: float = 25_000.0) -> AccountProjection: return AccountProjection( runtime_namespace="pink", strategy_namespace="pink", event_namespace="pink", actor_name="PinkDirectRuntime", exec_venue="bingx", data_venue="binance", ledger_authority="exchange", snapshot=AccountSnapshot(capital=capital, equity=capital), ) def _decision(action: DecisionAction, reason: str) -> Decision: return Decision( timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), decision_id="BTCUSDT-D-000000000001", asset="BTCUSDT", action=action, side=PolicyTradeSide.SHORT, reason=reason, confidence=0.9, velocity_divergence=-0.12, irp_alignment=0.8, reference_price=100.0 if action == DecisionAction.ENTER else 90.0, target_size=1.0, leverage=2.0, bars_held=0, stage=TradeStage.ORDER_REQUESTED, metadata={}, ) def _intent(action: DecisionAction, reason: str) -> Intent: return Intent( timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), trade_id="BTCUSDT-T-000000000001", decision_id="BTCUSDT-D-000000000001", asset="BTCUSDT", action=action, side=PolicyTradeSide.SHORT, reason=reason, target_size=1.0, leverage=2.0, reference_price=100.0 if action == DecisionAction.ENTER else 90.0, confidence=0.9, bars_held=0, exit_leg_ratios=(0.5, 0.5), metadata={}, ) def _outcome() -> KernelOutcome: return KernelOutcome( accepted=True, slot_id=0, trade_id="BTCUSDT-T-000000000001", state=DitaTradeStage.POSITION_OPEN, diagnostic_code=KernelDiagnosticCode.OK, severity=KernelSeverity.INFO, transitions=(), emitted_events=(), details={}, ) def _slot(*, size, pnl, active_leg_index, closed=False): return { "slot_id": 0, "trade_id": "BTCUSDT-T-000000000001", "asset": "BTCUSDT", "side": "SHORT", "entry_price": 100.0, "size": size, "initial_size": 1.0, "leverage": 2.0, "realized_pnl": pnl, "unrealized_pnl": 0.0, "closed": closed, "close_reason": "TAKE_PROFIT" if closed else "", "fsm_state": "CLOSED" if closed else "POSITION_OPEN", "exit_leg_ratios": [0.5, 0.5], "active_leg_index": active_leg_index, "active_exit_order": None, "active_entry_order": None, } _LEG_COLUMNS = { "ts", "date", "strategy", "trade_id", "chain_root_trade_id", "chain_head_leg_id", "chain_prev_leg_id", "chain_seq", "chain_token", "chain_mode", "exit_leg_id", "exit_seq", "command_id", "source", "reason", "asset", "side", "entry_price", "exit_price", "fraction", "capital_before", "capital_after", "exit_notional", "remaining_notional", "remaining_qty", "pnl_pct_leg", "pnl_leg", "pnl_realized_total", "bars_held", } def test_trade_exit_legs_two_leg_deltas_and_blue_schema(): """ENTER then two 0.5 exit legs → two trade_exit_legs rows with isolated per-leg deltas and the full BLUE-legacy column set.""" sink = _Sink() account = _account(25_000.0) persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink) # ENTER seeds leg state (prev_size = initial 1.0, prev_realized = 0). persistence.persist_step( snapshot=_snapshot(), decision=_decision(DecisionAction.ENTER, "ENTER"), intent=_intent(DecisionAction.ENTER, "ENTER"), outcome=_outcome(), slot_dict=_slot(size=1.0, pnl=0.0, active_leg_index=0), phase="execution", ) # Leg 0: half closed, cumulative realized = 60, capital = 25_060. account.snapshot.capital = 25_060.0 persistence.persist_step( snapshot=_snapshot(), decision=_decision(DecisionAction.EXIT, "TP1"), intent=_intent(DecisionAction.EXIT, "TP1"), outcome=_outcome(), slot_dict=_slot(size=0.5, pnl=60.0, active_leg_index=1), phase="execution", ) # Leg 1 (final): closed, cumulative realized = 120, capital = 25_120. account.snapshot.capital = 25_120.0 persistence.persist_step( snapshot=_snapshot(), decision=_decision(DecisionAction.EXIT, "TP2"), intent=_intent(DecisionAction.EXIT, "TP2"), outcome=_outcome(), slot_dict=_slot(size=0.0, pnl=120.0, active_leg_index=2, closed=True), phase="execution", ) legs = [row for t, row in sink.calls if t == "trade_exit_legs"] assert len(legs) == 2, f"expected 2 leg rows, got {len(legs)}" leg0, leg1 = legs # Schema: every BLUE-legacy column present on each row. for row in legs: assert _LEG_COLUMNS.issubset(row.keys()), f"missing cols: {_LEG_COLUMNS - row.keys()}" assert row["strategy"] == "pink" assert row["source"] == "ditav2" assert row["chain_root_trade_id"] == "BTCUSDT-T-000000000001" # Leg 0 deltas. assert leg0["exit_seq"] == 0 and leg0["chain_seq"] == 0 assert leg0["exit_leg_id"] == "BTCUSDT-T-000000000001:leg0" assert leg0["chain_prev_leg_id"] == "" assert abs(leg0["fraction"] - 0.5) < 1e-9 assert abs(leg0["pnl_leg"] - 60.0) < 1e-9 # isolated, not cumulative assert abs(leg0["pnl_realized_total"] - 60.0) < 1e-9 assert abs(leg0["capital_before"] - 25_000.0) < 1e-6 assert abs(leg0["capital_after"] - 25_060.0) < 1e-6 assert abs(leg0["remaining_qty"] - 0.5) < 1e-9 assert abs(leg0["exit_notional"] - 0.5 * 90.0) < 1e-6 # exit_qty 0.5 @ exit price 90 # Leg 1 deltas — pnl_leg is the increment (120 - 60), not the total. assert leg1["exit_seq"] == 1 and leg1["chain_seq"] == 1 assert leg1["exit_leg_id"] == "BTCUSDT-T-000000000001:leg1" assert leg1["chain_prev_leg_id"] == "BTCUSDT-T-000000000001:leg0" assert abs(leg1["pnl_leg"] - 60.0) < 1e-9 assert abs(leg1["pnl_realized_total"] - 120.0) < 1e-9 assert abs(leg1["capital_before"] - 25_060.0) < 1e-6 assert abs(leg1["capital_after"] - 25_120.0) < 1e-6 assert abs(leg1["remaining_qty"]) < 1e-9 assert abs(leg1["exit_notional"] - 0.5 * 90.0) < 1e-6 # remaining 0.5 closed