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>
291 lines
11 KiB
Python
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
|