**Scope**: Refactor PINK to use DITAv2 kernel's accounting as the single bookkeeping authority, eliminate double-accounting, maintain BLUE observability compatibility
---
## 1. PROBLEM STATEMENT
### 1.1 Current State (Post-Flaw-Fix, Pre-This-Refactor)
The 13 structural flaws have been fixed. The kernel works. 157 live BingX testnet
E2E scenarios pass (147 cleanly; 10 fail on test-design issues unrelated to the
kernel). 645+ offline tests are green.
**But the bookkeeping architecture has a structural tension:**
The kernel (`ExecutionKernel` in `rust_backend.py`) has **two parallel accounting
surfaces** that are both live:
```
Surface A — Python AccountProjection (account.py)
self.account.snapshot.capital ← mutated by account.settle()
self.account.snapshot.realized_pnl ← accumulated by settle()
self.account.snapshot.peak_capital ← tracked in observe_slots()
Surface B — Rust Kernel Atomic K/E Account (in lib.rs)
k_capital = seed + Σrealized − Σfee − Σfunding
k_realized_pnl ← accumulated in apply_fill()
k_fees_paid ← accumulated in apply_fill()
e_wallet_balance ← from WS/REST exchange facts
reconcile_status/delta ← K-vs-E classifier
```
`ExecutionKernel.snapshot()`**merges both** into one dict (lines 1164-1195),
`PinkClickHousePersistence` reads capital/peak/trade_seq from
`self.account.snapshot` (the Python AccountProjection). It also has its own
`_leg_state` dict for per-leg PnL tracking. This creates **three** parallel
tracking layers:
1. Rust K-account (authoritative for K-vs-E reconciliation)
2. Python AccountProjection (authoritative for persistence reads)
3. Persistence `_leg_state` (authoritative for per-leg PnL deltas)
The `_leg_state` dict is the most dangerous:
- Never cleared when a trade closes (grows unbounded)
- Not synchronized with the kernel
-`pnl_leg` can double-count if a sync step and async pump observe the same fill
- Fallback paths set `prev_realized=0` on reconcile-detected positions
### 1.3 What Prior Attempts Got Wrong
From `SPRINT2_ACCOUNTING_PARITY.md` and `CRITICAL_DITAv2_FLAWS.md`:
1.**Terminal-only PnL settlement (Flaw 5)**: Settle fired only on CLOSED state,
not on partial fills. Fixed with incremental delta settle, but the delta
tracking (`_last_settled_pnl`) lives in Python, not in the kernel.
2.**Double-close paths (Flaw 4)**: Two independent blocks in `apply_fill` each
set CLOSED. Fixed with single `should_close`, but the settlement still fires
from two code paths (Python settle + Rust K-account).
3.**External balance overwrites**: Reconciliation used to overwrite
`account.snapshot.capital` mid-loop. Fixed by restricting external writes to
startup-only, but the kernel's `snapshot()` still returns two capital values
(`capital` from Python, `k_capital` from Rust) and consumers don't know which
to trust.
---
## 2. DESIGN GOALS
| # | Goal | Success Criterion |
|---|---|---|
| G1 | **Single capital authority** | One canonical capital number, not two. All consumers read the same source. |
| G2 | **No parallel PnL tracking** | Eliminate `_leg_state` from persistence. Per-leg PnL comes from the kernel. |
| G3 | **Incremental settle in kernel, not Python** | Move `_last_settled_pnl` into the Rust kernel or a kernel-owned Python adapter. The bridge should not manage settlement state. |
| G4 | **BLUE schema compatibility** | `trade_events`, `position_state`, `account_events`, `policy_events` rows maintain the same columns and semantics. TUI/CH queries unchanged. |
| G5 | **Reconciliation as safety rail, not primary** | Exchange facts (E-block) flow into reconcile classification only. They never directly mutate capital. |
| G6 | **Crash-safe, replayable** | Full state can be saved/restored. No in-memory-only tracking that would be lost on crash. |
---
## 3. PROPOSED ARCHITECTURE
### 3.1 Canonical Authority: Rust K-Account
**The Rust kernel's K-account becomes the single capital authority.**
```
┌─────────────────────────────────┐
│ RUST KERNEL (lib.rs) │
│ │
│ K-ACCOUNT (authoritative) │
│ k_capital = seed + Σrealized │
│ − Σfee − Σfunding │
│ k_realized_pnl (per-slot) │
│ k_fees_paid │
│ k_funding_net │
│ k_peak_capital │
│ k_open_notional │
│ k_unrealized_pnl │
│ k_equity │
│ │
│ E-BLOCK (exchange facts) │
│ e_wallet_balance │
│ e_available_margin │
│ e_used_margin │
│ e_positions[] │
│ │
│ RECONCILE │
│ status / delta / explanation │
└──────────┬───────────────────────┘
│
┌──────────▼───────────────────────┐
│ ExecutionKernel.snapshot() │
│ (ONE account dict, from Rust) │
└──────────┬───────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌──────▼──────┐ ┌───────▼───────┐ ┌──────▼──────┐
│ PinkDirect │ │ Persistence │ │ Hz State │
│ Runtime │ │ (CH writer) │ │ Writer │
│ │ │ │ │ │
│ reads │ │ reads │ │ reads │
│ snapshot() │ │ snapshot() │ │ snapshot() │
└─────────────┘ └───────────────┘ └─────────────┘
```
### 3.2 Eliminate the Python AccountProjection as Capital Authority
The Python `AccountProjection` class stays as a **read-only compatibility shim**
but **stops being mutated**:
```python
# BEFORE (current):
self.account.settle(incremental_pnl) # mutates Python capital
| `policy_events` | (no capital fields) | Unchanged |
| `trade_exit_legs` | `pnl_leg`, `cumulative_pnl` | Computed from `outcome.emitted_events` |
### 6.2 Hazelcast Schema (Unchanged)
`_hz_publish()` reads `slot_dict` and `acc` from `kernel.snapshot()`. The dict
shape is identical; values just come from Rust instead of Python.
### 6.3 TUI Compatibility
`dolphin_status.py` reads from ClickHouse tables. No query changes needed.
---
## 7. RISK ANALYSIS
### 7.1 What Could Go Wrong
| Risk | Severity | Mitigation |
|---|---|---|
| Rust K-account formula drifts from Python AccountProjection | HIGH | Phase 1 test gate: add assertions `k_capital ≈ python_capital` before removing Python settle |
| `_leg_state` removal breaks multi-leg exit rows in CH | MEDIUM | Phase 3 test gate: compare CH rows before/after for multi-leg scenarios |
| `AccountProjectionProxy` breaks test code that expects mutable `.snapshot` | LOW | Proxy returns a read-only dataclass; tests that write `account.snapshot.capital = X` are migrated to `set_seed_capital()` |
| Live BingX timing exposes new race | MEDIUM | Phase 2 canary test before full suite |
### 7.2 What We Explicitly Do NOT Change
1.**Rust FSM logic** — all 13 flaw fixes remain untouched
## APPENDIX B: Proposed Capital Flow (After Refactor)
```
FILL EVENT ARRIVES (on_venue_event)
│
├── Rust FFI: on_venue_event(backend, event)
│ └── apply_fill() updates:
│ slot.realized_pnl
│ slot.last_settled_pnl = slot.realized_pnl
│ k_capital += incremental_pnl - incremental_fee
│ k_realized_pnl += incremental_pnl
│ k_fees_paid += fee
│ (ALL atomic in one FFI call)
│
└── snapshot() returns:
capital = k_capital (Rust — single authority)
k_capital = k_capital (same value, kept for compat)
No Python settle() call. No _last_settled_pnl dict.
```
---
## 8. CRITICAL BUG FIXES APPLIED — 2026-06-08
These fixes were applied before the Phase-1 refactor above. They make PINK tradeable on the current pre-refactor codebase and are NOT superseded by the refactor.
### 8.1 Defect A — BingX fee sign convention (FIXED)
**Root cause**: `bingx_user_stream._normalise_order()` preserved BingX's `"n"` field verbatim. BingX VST sends `"n"` NEGATIVE for costs; the Rust kernel expected POSITIVE for costs. A cost of 31 VST arrived as -31 → kernel treated it as a rebate → `k_maker_rebates` grew instead of `k_taker_fees` → `k_fees_paid` became negative → `k_capital` was inflated by ~93 VST per round trip → `reconcile_status="ERROR"` → `capital_frozen=True` → all future ENTERs blocked.
**Fix**: `bingx_user_stream.py` line 337: `fee = -raw_fee` (flip sign at the BingX boundary only — do not touch the kernel).
### 8.2 Defect B — Opening fill fee dropped (FIXED)
**Root cause**: BingX ENTER fills report filled quantity only in `"z"` (cumFilledQty), not `"l"` (lastFilledQty). `_normalise_order` used only `"l"` → `fill_qty=0` → `apply_predicted_fill` computed zero opening fee → k-account never recorded the opening-leg cost.
**Fix**: `bingx_user_stream.py` — fall back to `"z"` when `"l"` is zero or absent.
### 8.3 Architectural Fix — WARN zone unfreezes (FIXED)
**Root cause**: `lib.rs reconcile()` kept `capital_frozen=True` when transitioning through WARN (0.01–20 USDT delta). During a live trade, the ENTER predicted-fee phase temporarily pushes `delta > 20` (ERROR, freeze set), then the EXIT's realized PnL brings it back into WARN (<20).WARNkeptthefreeze→permanentblockafterfirsttrade.