Files
siloqy/prod/tests/test_pink_multi_exit_groundwork.py
Codex d4b73b236a PINK DITAv2 Sprint 2-3: accounting parity + multi-leg groundwork
Sprint 2 (accounting + observability parity, PINK scope):
- Verified pink_clickhouse.py writes the 8 BLUE-legacy row families at
  matching schema and that capital authority in pink_direct.step() is
  solely kernel.account (no balance-poll overwrite in the hot loop).
- Report: prod/clean_arch/dita_v2/SPRINT2_ACCOUNTING_PARITY.md.

Sprint 3 offline groundwork (no exchange contact):
- Add _write_trade_exit_leg to pink_clickhouse.py: one BLUE-schema-faithful
  trade_exit_legs row per exit leg, with isolated (non-cumulative) per-leg
  deltas tracked via _leg_state (reset on ENTER). Closes the docstring gap.
- New offline suite test_pink_multi_exit_groundwork.py (3 passed):
  * Flaw 4 — two-leg exit closes once, realized accrues per leg, closed
    slot rejects further EXIT (no double-close).
  * Overshoot invariant — a final EXIT requesting more than the remaining
    size CLAMPS (size to 0, no oversell), retiring the Sprint 0 cumulative-
    ratio risk empirically.
  * trade_exit_legs delta + full BLUE column-set assertions.
- Persistence regression after edits: 10 passed.

BLUE untouched: no changes to dolphin.* / DOLPHIN_*_BLUE / nautilus_event_trader.py.
Live VST multi-leg run remains deferred pending explicit authorization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:21:45 +02:00

291 lines
11 KiB
Python

"""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