- SYSTEM_BIBLE.md → v7.0: documents fee-sign fix (Defect A), opening-fee fix (Defect B), WARN-unfreeze, orphan prevention, reset_and_seed startup, and vel_div env-override. - CAPITAL_BOOKKEEPING_DESIGN.md: status updated to PHASE-1 BUGFIXES APPLIED; sections 8.1-8.4 (applied fixes + 34-test coverage) were already present. - rust_backend.py: expose dita_kernel_reset_and_seed() via _RustKernelLib + ExecutionKernel.reset_and_seed(); zeros stale K-accumulators at startup so K=E=live_capital → delta=0 → capital_frozen=False on every clean restart. - pink_direct.py: call kernel.reset_and_seed(live_capital) after _restore_kernel_snapshot() so BingX is always the ledger of record. - launch_dolphin_pink.py: DOLPHIN_PINK_VEL_DIV_THRESHOLD env-var override for on-exchange debugging; BLUE unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
27 KiB
PINK Capital & Trading Bookkeeping Refactor — Design Spec
Status: PHASE-1 BUGFIXES APPLIED (2026-06-08) — fee sign + opening-fee + WARN-unfreeze all fixed and green (34/34 tests). Multi-phase bookkeeping refactor remains pending. Date: 2026-06-07 (design); 2026-06-08 (critical patch applied) Author: Crush AI, per PINK operator directive 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),
but they are independently maintained:
| Responsibility | Python AccountProjection | Rust K-Account |
|---|---|---|
capital |
settle(incremental_pnl) on each fill |
seed + Σrealized − Σfee − Σfunding |
realized_pnl |
Accumulated via settle() |
Accumulated in apply_fill |
fees_paid |
Accumulated via settle(fees=) |
Accumulated in apply_fill |
peak_capital |
Tracked in observe_slots() |
Tracked in Rust snapshot |
open_notional |
Computed in observe_slots() |
Computed in Rust snapshot |
unrealized_pnl |
Read from slot in observe_slots() |
Computed in Rust snapshot |
equity |
capital + unrealized_pnl |
k_capital + k_unrealized |
Both surfaces see the same fills and should agree, but they are fed by different code paths:
- Python
settle()fires inon_venue_event()(rust_backend.py:1033-1036) - Rust K-account fires in
apply_fill()insideon_venue_eventRust FFI call
These are called sequentially but they can drift if:
- A fill event is accepted by Rust but the Python settle path has a different PnL formula (e.g. fee timing differences)
observe_slots()recomputesunrealized_pnlfrom slot fields while Rust uses its own mark-price computation_last_settled_pnldict tracks per-slot PnL settlement in Python but the Rust kernel tracks realized PnL per-slot independently
1.2 The Persistence Layer's Problem
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:
- Rust K-account (authoritative for K-vs-E reconciliation)
- Python AccountProjection (authoritative for persistence reads)
- 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_legcan double-count if a sync step and async pump observe the same fill- Fallback paths set
prev_realized=0on reconcile-detected positions
1.3 What Prior Attempts Got Wrong
From SPRINT2_ACCOUNTING_PARITY.md and CRITICAL_DITAv2_FLAWS.md:
-
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. -
Double-close paths (Flaw 4): Two independent blocks in
apply_filleach set CLOSED. Fixed with singleshould_close, but the settlement still fires from two code paths (Python settle + Rust K-account). -
External balance overwrites: Reconciliation used to overwrite
account.snapshot.capitalmid-loop. Fixed by restricting external writes to startup-only, but the kernel'ssnapshot()still returns two capital values (capitalfrom Python,k_capitalfrom 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:
# BEFORE (current):
self.account.settle(incremental_pnl) # mutates Python capital
self.account.observe_slots(slots) # recomputes unrealized/open_notional
# AFTER (proposed):
# account.settle() is removed from the hot path
# account.observe_slots() is removed from the hot path
# All values come from kernel.snapshot()["account"] which reads Rust directly
The AccountProjection class is kept only because:
PinkClickHousePersistence.__init__takes it as a constructor argument- Test code references
kernel.account.snapshot.capital - BLUE compat requires
account_eventsrows with the same schema
It becomes a lazy proxy that reads from the Rust backend on each access:
class AccountProjectionProxy:
"""Read-only proxy — delegates all reads to the Rust kernel snapshot."""
def __init__(self, kernel):
self._kernel = kernel
@property
def snapshot(self):
return self._kernel._rust_account_snapshot()
3.3 Eliminate _leg_state from Persistence
Replace _leg_state with kernel-owned per-slot leg tracking:
BEFORE:
persistence._leg_state[trade_id] = {
prev_realized: float,
prev_size: float,
prev_leg_id: int
}
→ double-counts on sync+async, never cleared, drifts
AFTER:
kernel.slot(i).realized_pnl → cumulative realized for this slot
kernel.slot(i).active_leg_index → current exit leg
kernel.slot(i).size → remaining position size
Per-leg PnL = current_slot.realized_pnl - prev_call_slot.realized_pnl
(tracked in a local ephemeral variable, not persistent state)
The persistence layer reads from slot_dict (which comes from the kernel) and
computes per-leg deltas statelessly:
def _write_trade_exit_leg(self, snapshot, decision, intent, slot_dict, ...):
cur_realized = float(slot_dict.get("realized_pnl", 0.0))
# Statelessly compute the leg delta from the outcome's emitted_events
# rather than tracking _leg_state across calls
leg_pnl = sum(
float(getattr(e, 'realized_pnl', 0.0) or 0.0)
for e in (outcome.emitted_events or ())
if getattr(e, 'kind', None) in (KernelEventKind.FULL_FILL, KernelEventKind.PARTIAL_FILL)
)
3.4 Move _last_settled_pnl into the Kernel
The per-slot settlement tracking (_last_settled_pnl dict in
rust_backend.py:664) is moved inside the Rust kernel:
// In lib.rs, KernelSlot struct:
pub last_settled_pnl: f64, // tracks what has been pushed to K-account
// In apply_fill():
slot.last_settled_pnl = slot.realized_pnl; // atomic with the fill
This ensures the settlement delta is always consistent with the slot state — no Python dict can drift from the Rust state.
Python bridge change:
# BEFORE:
incremental_pnl = slot.realized_pnl - self._last_settled_pnl.get(slot.slot_id, 0.0)
if abs(incremental_pnl) > 1e-12:
self.account.settle(incremental_pnl)
self._last_settled_pnl[slot.slot_id] = slot.realized_pnl
# AFTER:
# No Python settle call at all — Rust kernel's apply_fill already updated
# k_capital atomically. snapshot() reads k_capital directly.
3.5 Unified snapshot() Output
The merged snapshot dict simplifies to read Rust K-account directly:
def snapshot(self) -> Dict[str, Any]:
rust_snap = _get_rust().snapshot(self._backend)
rust_account = rust_snap.get("account", {})
return {
"control": self.control.as_dict(),
"slots": [self._get_slot(i).to_dict() for i in range(self.max_slots)],
"account": {
# Single canonical capital — from Rust K-account
"capital": rust_account.get("k_capital", 0.0),
"equity": rust_account.get("k_equity", 0.0),
"realized_pnl": rust_account.get("k_realized_pnl", 0.0),
"unrealized_pnl": rust_account.get("k_unrealized_pnl", 0.0),
"fees_paid": rust_account.get("k_fees_paid", 0.0),
"funding_paid": rust_account.get("k_funding_net", 0.0),
"open_positions": rust_account.get("open_positions", 0),
"open_notional": rust_account.get("k_open_notional", 0.0),
"peak_capital": rust_account.get("k_peak_capital", 0.0),
# E-block (exchange facts — for reconcile/observability only)
"e_wallet_balance": rust_account.get("e_wallet_balance", 0.0),
"e_available_margin": rust_account.get("e_available_margin", 0.0),
"e_used_margin": rust_account.get("e_used_margin", 0.0),
# Reconcile
"available_capital": rust_account.get("available_capital", rust_account.get("k_capital", 0.0)),
"reconcile_status": rust_account.get("reconcile_status", "OK"),
"reconcile_delta": rust_account.get("reconcile_delta", 0.0),
"event_seq": rust_account.get("event_seq", 0),
},
}
Key change: "capital" now reads from k_capital (Rust), not
self.account.snapshot.capital (Python). The Python AccountProjection is no
longer the source.
4. MIGRATION PATH (4 Phases)
Phase 1: Rust Kernel Settlement Consolidation (Low Risk)
Goal: Make the Rust K-account the single capital mutation point.
Changes:
- In
lib.rs: ensureapply_fill()updatesk_capitalatomically withk_realized_pnlandk_fees_paidin the same mutation block. - In
lib.rs: addlast_settled_pnl: f64to the slot struct. - In
rust_backend.py: remove the_last_settled_pnldict and theself.account.settle()call fromon_venue_event(). - In
rust_backend.py:snapshot()["account"]["capital"]readsk_capitalfrom Rust, notself.account.snapshot.capital.
Test gate: All 645+ offline tests + 35 flaw tests must pass.
Phase 2: Python AccountProjection → Proxy (Medium Risk)
Goal: Eliminate the Python AccountProjection as a mutable state holder.
Changes:
- Add
AccountProjectionProxythat reads from Rust snapshot. ExecutionKernel.accountbecomes this proxy (property, not stored field).PinkClickHousePersistence.__init__receives the proxy instead of the mutableAccountProjection. All_capital()/_peak_capital()/_trade_seq()reads go through the proxy → Rust.- Remove
observe_slots()calls from the hot path (Rust already computes open_notional, unrealized, etc.)
Test gate: All offline tests + live e2e canary.
Phase 3: Persistence _leg_state Elimination (Medium Risk)
Goal: Remove per-leg tracking from persistence.
Changes:
- Compute per-leg PnL from
outcome.emitted_eventsstatelessly. - Remove
self._leg_statedict fromPinkClickHousePersistence. - Gate on
outcome(which carries the events) rather than size-delta tracking. - Add a
_prev_realizedephemeral in_write_trade_exit_leg(local variable, not persistent state).
Test gate: Multi-leg exit tests (offline + live).
Phase 4: Cleanup & Documentation (Low Risk)
Goal: Remove dead code, update docs.
Changes:
- Remove
AccountProjection.settle()andAccountProjection.observe_slots()from the hot path (keep for tests/backward-compat but mark deprecated). - Update
SYSTEM_BIBLE_v7.md§38.7 with the new authority model. - Update
SPRINT2_ACCOUNTING_PARITY.mdto document the completed refactor. - Add a
CAPITAL_AUTHORITY.mddoc explaining the single-source model.
5. INVARIANTS TO ENFORCE
5.1 Capital Invariants
| # | Invariant | Enforcement |
|---|---|---|
| I1 | snapshot()["account"]["capital"] == k_capital (Rust) |
snapshot() reads Rust directly |
| I2 | Capital is mutated only by apply_fill() in Rust |
No Python settle() calls in hot path |
| I3 | External capital writes happen only at set_seed_capital() (startup) |
Audit all account.snapshot.capital = assignments |
| I4 | k_capital == seed + Σrealized − Σfee − Σfunding (always reconstructable) |
Rust invariant in apply_fill |
| I5 | capital >= 0 always (clamp at seed level) |
Rust guard |
5.2 PnL Invariants
| # | Invariant | Enforcement |
|---|---|---|
| I6 | Per-slot realized PnL accumulates monotonically within a trade | Rust apply_fill only adds, never subtracts |
| I7 | ENTER resets slot.realized_pnl = 0.0 and last_settled_pnl = 0.0 |
Rust ENTER handler |
| I8 | Per-leg PnL in persistence = Σ(event.realized_pnl for events in this leg) |
Stateless computation from outcome |
| I9 | Total realized PnL across all slots = account.realized_pnl |
Snapshot cross-check |
5.3 Reconciliation Invariants
| # | Invariant | Enforcement |
|---|---|---|
| I10 | E-facts never mutate K-capital | on_account_event only updates E-block fields |
| I11 | reconcile_status == ERROR → freeze ENTERs |
is_capital_frozen() gate in process_intent |
| I12 | Reconcile is advisory: K-account is always internally consistent | Rust K-account is a pure fold, no external mutation |
6. BLUE OBSERVABILITY COMPATIBILITY
6.1 ClickHouse Schema (Unchanged)
All existing tables keep their columns. The source of values changes but the schema does not:
| Table | Key Columns | Source Change |
|---|---|---|
trade_events |
capital_before, capital_after, pnl, pnl_pct |
capital_* from k_capital (was Python) |
position_state |
capital, equity, open_notional |
All from Rust K-account |
account_events |
capital, equity, reconcile_status |
All from Rust snapshot |
status_snapshots |
capital, peak_capital, trade_seq |
From Rust K-account |
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
- Rust FSM logic — all 13 flaw fixes remain untouched
- Venue adapter — BingX submission/cancel paths unchanged
- Decision/Intent engines — policy layer unchanged
- WS account stream — event ingestion path unchanged
- Kernel save/restore — crash recovery format unchanged (it already serializes the Rust state including K-account)
- Fee model — calibration and prediction logic unchanged
8. FILE CHANGE MANIFEST
| File | Phase | Change |
|---|---|---|
_rust_kernel/src/lib.rs |
1 | Add last_settled_pnl to slot; ensure apply_fill updates K-account atomically |
_rust_kernel/Cargo.toml |
— | (no change) |
rust_backend.py |
1+2 | Remove _last_settled_pnl dict; remove account.settle() calls; snapshot() reads Rust K-account; add AccountProjectionProxy |
account.py |
2 | AccountProjection gains deprecated markers; AccountProjectionProxy added (or in rust_backend.py) |
pink_clickhouse.py |
3 | Remove _leg_state; compute per-leg PnL from outcome.emitted_events |
pink_direct.py |
2 | Remove direct kernel.account.snapshot.capital = writes (only at startup via set_seed_capital) |
launcher.py |
— | (no change — kernel construction unchanged) |
contracts.py |
— | (no change) |
SYSTEM_BIBLE_v7.md |
4 | Update §38.7 accounting authority model |
CAPITAL_AUTHORITY.md |
4 | NEW — documents the single-source model |
9. DECISION RECORD
9.1 Why Rust K-Account as Authority (not Python)?
The Rust kernel is:
- Atomic:
apply_fillupdates slot + K-account in one FFI call. Pythonsettleis a separate call that can fail independently. - Crash-safe: State is serializable via
save_state(). Python_last_settled_pnlis in-memory only. - Deterministic: The fold
seed + Σrealized − Σfee − Σfundingis pure. Pythonsettle()has clamping logic (min_capital,max_capital) that can mask errors. - Test-covered: 385 FSM matrix tests + 105 extended tests exercise the Rust state machine directly.
9.2 Why Not Remove Python AccountProjection Entirely?
Three blockers:
PinkClickHousePersistence.__init__takes it as a constructor arg — changing this signature cascades through all test construction code.to_account_event()builds the CH row payload from the snapshot — this method lives onAccountProjection.- Tests directly access
kernel.account.snapshot.capital— a proxy preserves backward compat while delegating to Rust.
The proxy approach lets us migrate incrementally: the Python class stays as a read-only facade, and all mutations happen in Rust.
9.3 Why Stateless Per-Leg PnL (not a kernel field)?
Per-leg PnL is a presentation concern, not a state concern. The kernel
tracks cumulative realized PnL per slot. A "leg" is just a fill event within
the slot's lifetime. Computing pnl_leg = Σ(event.realized_pnl for this outcome)
from the outcome's emitted_events is:
- Stateless (no tracking dict)
- Correct (uses the actual fill events, not a delta from a potentially-stale prev)
- Crash-safe (nothing to lose)
- Idempotent (same outcome → same leg PnL)
10. OPEN QUESTIONS
-
Should
k_capitalinclude unrealized PnL inequity?- Current:
equity = capital + unrealized_pnl(Python) - Rust:
k_equity = k_capital + k_unrealized - Answer: Yes, keep both. Capital is realized-only; equity includes unrealized.
- Current:
-
Should funding fees be settled incrementally or at close?
- Current:
on_account_event(FUNDING_FEE)folds intok_funding_net - This already works correctly — no change needed.
- Current:
-
Trade_seq tracking — currently in Python
AccountProjection.snapshot.trade_seq. Should this move to Rust?- Recommendation: Yes, in Phase 1. It's a counter incremented on each fill.
- Risk: Low. It's a simple
+= 1inapply_fill.
-
Min/max capital clamping — Python
AccountProjection.settle()clamps to[min_capital, max_capital]. Rust K-account does not clamp. Should Rust clamp?- Recommendation: No clamping in Rust. Clamping masks errors. If capital goes negative, the reconcile ERROR will freeze ENTERs. Let observability catch it.
APPENDIX A: Current Capital Flow (Before Refactor)
FILL EVENT ARRIVES (on_venue_event)
│
├── Rust FFI: on_venue_event(backend, event)
│ └── apply_fill() updates slot.realized_pnl in Rust
│ (slot state is now authoritative for position/PnL)
│
├── Python: incremental_pnl = slot.realized_pnl - _last_settled_pnl[slot_id]
│ └── account.settle(incremental_pnl) ← mutates Python capital
│ (Python AccountProjection is now authoritative for capital)
│
├── Python: account.observe_slots(slots)
│ └── recomputes open_notional, unrealized, equity
│
└── snapshot() returns BOTH:
capital = account.snapshot.capital (Python)
k_capital = rust_account.k_capital (Rust)
Consumers read "capital" → Python authority
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). WARN kept the freeze → permanent block after first trade.
Fix: lib.rs — WARN unfreezes. Only ERROR (≥ 20 USDT, unexplained) freezes. WARN = "in-flight settlement, tradeable".
8.4 Test coverage
test_fee_sign_accounting.py — 34 tests, all green. Includes:
- Prove-Broken (4): characterises pre-fix buggy behaviour
- Prove-Fixed (7): T-2 and T-1 ground-truth round-trips
- Boundary layer (8): sign translation + cumQty fallback
- Full round-trip integration (10): Rust kernel end-to-end
- Edge cases (4): rebates, partials, adversarial inputs
Acceptance criterion met: |k_capital − bingx_wallet_balance| < 1.0 USDT and capital_frozen=False after every round-trip trade.