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>
This commit is contained in:
Codex
2026-05-30 19:21:45 +02:00
parent 3d7b00e28d
commit d4b73b236a
12 changed files with 3527 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# Sprint 2 — Accounting + observability parity verification
**Date:** 2026-05-30
**Scope:** Verify (no behaviour change) that the DITAv2 PINK runtime preserves
BLUE-legacy-compatible ClickHouse row shapes in `dolphin_pink`, and that capital
authority in the hot loop is solely the kernel's `AccountProjection`. Offline only
(MockVenue / unit), no exchange contact. Continues [SPRINT0_FLAW_VERIFICATION.md].
## 1. Row-shape parity — `clean_arch/persistence/pink_clickhouse.py`
BLUE-legacy row families written, same schema / no new columns:
| Row family | Writer | Status |
|---|---|---|
| `policy_events` + `v7_decision_events` | `_write_policy_event` | ✅ |
| `account_events` | `_write_account_event` | ✅ |
| `position_state` | `_write_position_state` | ✅ |
| `status_snapshots` | `_write_status_snapshot` | ✅ |
| `trade_events` | `_write_trade_event` | ✅ (terminal close) |
| `trade_reconstruction` | `_write_trade_reconstruction` | ✅ (ENTRY/PARTIAL/EXIT) |
| `anomaly_events` | `_write_anomaly` / `record_anomaly` | ✅ |
| `trade_exit_legs` | — | ⚠️ **listed in docstring, no writer** |
`trade_exit_legs` has no emitter. It is a **multi-leg** row family → relevant to
**Sprint 3** (`DOLPHIN_PINK_PHASE=multi_exit`), not single-leg MARKET. **Not a
Sprint 1/2 blocker.** Action: add the writer when Sprint 3 is taken up, or confirm
BLUE TUI/observability does not require it for single-leg trades.
## 2. Capital authority — single source = kernel `AccountProjection`
`clean_arch/runtime/pink_direct.py` hot loop (`step`, L309-408):
- Capital is **read only** from `kernel.snapshot()["account"]` (L320, L370, L395).
- Capital is **mutated only** by `kernel.process_intent()``account.settle()` on fill.
- **No balance-poll overwrite anywhere in `step()`.** ✅
External capital writes (all outside the hot loop, by design):
- `_reconcile_position_slot` (L188-194) — the **single** place an exchange balance
snapshot seeds `account.snapshot.capital`; called at startup/recovery only.
- `connect()` (L230) seeds from the **env default** `initial_capital`, not an
exchange poll (per code comment L228-229).
- `recover_account()` (L431) re-seeds from `kernel.account.snapshot.capital`
(the kernel's own value) — **not** an exchange poll.
**Doc/code note (no change made):** `reconcile_account()` (L453) *docstring* says it
"re-seeds capital from the exchange balance as a guard against drift," but the code
path (`recover_account`) actually re-seeds from the kernel's own capital — i.e. it
does **not** overwrite from an exchange poll. Behaviour is the safe one; only the
comment overstates. Flagged for accuracy; not edited (no behaviour change w/o auth).
`pink_clickhouse.py` reads capital/peak/seq solely from `account.snapshot`
(`_capital`/`_peak_capital`/`_trade_seq`, L193-201) — no duplicate tracking. ✅
## 3. Offline test results
`siloqy_env`, `PYTHONPATH=/mnt/dolphinng5_predict`, run from repo root.
| Suite | Result |
|---|---|
| `test_pink_clickhouse_persistence.py` | ✅ pass |
| `test_pink_ditav2_accounting_invariants.py` | ✅ pass |
| `test_pink_direct_runtime.py` | ✅ pass |
| **DITAv2 PINK Sprint-2 scope** | **14 passed** |
| `test_bingx_capital_accounting_battery.py` | ❌ 2 failed — **legacy path, out of scope** |
The 2 failures are in the **legacy** Nautilus BingX execution/journal path
(`prod/bingx/execution.py` + `prod/bingx/journal.py`, imported via
`launch_dolphin_live`) — **not** a DITAv2 PINK file, untracked/pre-existing, not
modified by this engagement. Root cause: the fuzz/equivalence tests reuse
`fingerprint="fp"` across iterations, so `bingx_journal.write_snapshot` fingerprint-
dedup short-circuits the sink and `captured["row"]` is never set (`KeyError`). This
lives on the legacy side of the BLUE do-not-touch boundary → **not fixed here**.
## GATE decision
**PASS (DITAv2 PINK scope).** Row-shape parity holds for single-leg MARKET; capital
authority is single (kernel `AccountProjection`) with no hot-loop balance overwrite;
all PINK-scoped offline suites green.
## Carry-forward (Sprint 3)
-**CLOSED (offline groundwork, 2026-05-30):** `trade_exit_legs` writer added to
`pink_clickhouse.py` (`_write_trade_exit_leg`, BLUE-schema-faithful, isolated per-leg
deltas tracked via `self._leg_state`, reset on ENTER). Fires once per exit leg.
-**CLOSED (offline groundwork):** cumulative-ratio exit sizing overshoot validated —
`test_pink_multi_exit_groundwork.py::test_final_leg_overshoot_does_not_oversell` proves a
final EXIT requesting more than the remaining size clamps (size→0, no oversell, closes once).
Validation suite: 3 passed; persistence regression: 10 passed.
-**PENDING (live):** the on-exchange multi-leg run (successive MARKET exits on VST to
confirm Flaw 4 end-to-end) is deferred — requires explicit authorization for additional
live testnet orders beyond the single Sprint 1 round trip.

View File

@@ -177,6 +177,11 @@ class PinkClickHousePersistence:
)
self._sink = sink or self._resolve_sink("pink")
self._v7_sink = v7_sink or self._resolve_v7_sink("pink")
# Per-trade incremental leg state for trade_exit_legs row deltas.
# Keyed by trade_id; reset on ENTER. Tracks the cumulative realized PnL
# and remaining size observed at the previous leg so each leg row carries
# an isolated (non-cumulative) pnl_leg / exit_qty.
self._leg_state: dict[str, dict[str, Any]] = {}
@staticmethod
def _resolve_sink(strategy: str) -> Writer:
@@ -245,6 +250,15 @@ class PinkClickHousePersistence:
return
if decision.action == DecisionAction.ENTER:
# Reset per-trade leg deltas: a fresh position starts with zero
# realized PnL and the full initial size remaining.
self._leg_state[intent.trade_id] = {
"prev_realized": 0.0,
"prev_size": _safe_float(
slot.get("initial_size", slot.get("size", 0.0)), 0.0
) or _safe_float(intent.target_size, 0.0),
"prev_leg_id": "",
}
self._write_trade_reconstruction(
snapshot, intent.trade_id,
event_type="ENTRY_FILLED",
@@ -264,6 +278,9 @@ class PinkClickHousePersistence:
return
partial = slot.get("closed", False) is False and slot.get("size", 0) > 0
# One trade_exit_legs row per exit leg (partial or final), BLUE-schema
# compatible so PINK multi-exit trades reconcile against the same table.
self._write_trade_exit_leg(snapshot, decision, intent, slot, outcome)
self._write_trade_reconstruction(
snapshot, intent.trade_id,
event_type="PARTIAL_EXIT" if partial else "EXIT",
@@ -567,6 +584,91 @@ class PinkClickHousePersistence:
}
self._sink("status_snapshots", row)
def _write_trade_exit_leg(
self, snapshot: Any, decision: Decision, intent: Intent,
slot_dict: dict[str, Any], outcome: KernelOutcome | None,
) -> None:
"""Emit one BLUE-schema-compatible ``trade_exit_legs`` row per exit leg.
The DITAv2 kernel uses a single slot with sequential exit legs rather
than BLUE's chained per-leg trade_ids, so the chain_* columns describe
the leg sequence within this one trade (root = trade_id). Per-leg deltas
(exit_qty, pnl_leg) are computed against the previous leg's snapshot held
in ``self._leg_state`` so each row is isolated, not cumulative.
"""
trade_id = intent.trade_id
prev = self._leg_state.get(trade_id) or {
"prev_realized": 0.0,
"prev_size": _safe_float(slot_dict.get("initial_size", 0.0), 0.0),
"prev_leg_id": "",
}
entry_price = self._slot_entry_price(slot_dict) or _safe_float(intent.reference_price, 0.0)
exit_price = _safe_float(intent.reference_price, 0.0) or _safe_float(decision.reference_price, 0.0)
side = self._slot_side(slot_dict)
if side == TradeSide.FLAT:
side = intent.side
leverage_val = _safe_float(slot_dict.get("leverage", intent.leverage), 1.0)
cur_size = self._slot_size(slot_dict)
cur_realized = _safe_float(slot_dict.get("realized_pnl", 0.0), 0.0)
prev_size = _safe_float(prev.get("prev_size", 0.0), 0.0)
prev_realized = _safe_float(prev.get("prev_realized", 0.0), 0.0)
# active_leg_index is post-fill (already advanced); the leg that just
# filled is therefore one behind. Clamp to a valid ratio index.
ratios = slot_dict.get("exit_leg_ratios", []) or []
leg_index = max(0, int(slot_dict.get("active_leg_index", 0) or 0) - 1)
fraction = _safe_float(ratios[leg_index], 0.0) if 0 <= leg_index < len(ratios) else 0.0
exit_qty = max(0.0, prev_size - cur_size)
pnl_leg = cur_realized - prev_realized
capital_after = self._capital()
capital_before = capital_after - pnl_leg
exit_notional = _notional(exit_qty, exit_price or entry_price)
remaining_notional = _notional(cur_size, entry_price)
denom = abs(exit_qty * entry_price * max(leverage_val, 1e-9))
pnl_pct_leg = pnl_leg / denom if denom > 0 else 0.0
exit_leg_id = f"{trade_id}:leg{leg_index}"
self._sink("trade_exit_legs", {
"ts": snapshot.timestamp.isoformat(),
"date": snapshot.timestamp.date().isoformat(),
"strategy": self.config.strategy,
"trade_id": trade_id,
"chain_root_trade_id": trade_id,
"chain_head_leg_id": f"{trade_id}:leg0",
"chain_prev_leg_id": str(prev.get("prev_leg_id", "") or ""),
"chain_seq": leg_index,
"chain_token": trade_id,
"chain_mode": "LIVE",
"exit_leg_id": exit_leg_id,
"exit_seq": leg_index,
"command_id": decision.decision_id,
"source": "ditav2",
"reason": intent.reason,
"asset": intent.asset,
"side": side.value,
"entry_price": entry_price,
"exit_price": exit_price,
"fraction": fraction,
"capital_before": capital_before,
"capital_after": capital_after,
"exit_notional": exit_notional,
"remaining_notional": remaining_notional,
"remaining_qty": cur_size,
"pnl_pct_leg": pnl_pct_leg,
"pnl_leg": pnl_leg,
"pnl_realized_total": cur_realized,
"bars_held": int(intent.bars_held or 0),
})
# Advance the per-trade leg snapshot for the next leg's delta.
self._leg_state[trade_id] = {
"prev_realized": cur_realized,
"prev_size": cur_size,
"prev_leg_id": exit_leg_id,
}
def _write_trade_event(
self, snapshot: Any, decision: Decision, intent: Intent,
slot_dict: dict[str, Any], outcome: KernelOutcome | None,