Files
siloqy/prod/clean_arch/dita_v2/CAPITAL_BOOKKEEPING_DESIGN.md
Codex 0eac51d2e9 PINK: docs v7 + reset_and_seed startup fix — 451/451 tests green
- 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>
2026-06-08 12:33:50 +02:00

27 KiB
Raw Blame History

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 in on_venue_event() (rust_backend.py:1033-1036)
  • Rust K-account fires in apply_fill() inside on_venue_event Rust 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() recomputes unrealized_pnl from slot fields while Rust uses its own mark-price computation
  • _last_settled_pnl dict 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:

  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:

# 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_events rows 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:

  1. In lib.rs: ensure apply_fill() updates k_capital atomically with k_realized_pnl and k_fees_paid in the same mutation block.
  2. In lib.rs: add last_settled_pnl: f64 to the slot struct.
  3. In rust_backend.py: remove the _last_settled_pnl dict and the self.account.settle() call from on_venue_event().
  4. In rust_backend.py: snapshot()["account"]["capital"] reads k_capital from Rust, not self.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:

  1. Add AccountProjectionProxy that reads from Rust snapshot.
  2. ExecutionKernel.account becomes this proxy (property, not stored field).
  3. PinkClickHousePersistence.__init__ receives the proxy instead of the mutable AccountProjection. All _capital() / _peak_capital() / _trade_seq() reads go through the proxy → Rust.
  4. 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:

  1. Compute per-leg PnL from outcome.emitted_events statelessly.
  2. Remove self._leg_state dict from PinkClickHousePersistence.
  3. Gate on outcome (which carries the events) rather than size-delta tracking.
  4. Add a _prev_realized ephemeral 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:

  1. Remove AccountProjection.settle() and AccountProjection.observe_slots() from the hot path (keep for tests/backward-compat but mark deprecated).
  2. Update SYSTEM_BIBLE_v7.md §38.7 with the new authority model.
  3. Update SPRINT2_ACCOUNTING_PARITY.md to document the completed refactor.
  4. Add a CAPITAL_AUTHORITY.md doc 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

  1. Rust FSM logic — all 13 flaw fixes remain untouched
  2. Venue adapter — BingX submission/cancel paths unchanged
  3. Decision/Intent engines — policy layer unchanged
  4. WS account stream — event ingestion path unchanged
  5. Kernel save/restore — crash recovery format unchanged (it already serializes the Rust state including K-account)
  6. 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_fill updates slot + K-account in one FFI call. Python settle is a separate call that can fail independently.
  • Crash-safe: State is serializable via save_state(). Python _last_settled_pnl is in-memory only.
  • Deterministic: The fold seed + Σrealized Σfee Σfunding is pure. Python settle() 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:

  1. PinkClickHousePersistence.__init__ takes it as a constructor arg — changing this signature cascades through all test construction code.
  2. to_account_event() builds the CH row payload from the snapshot — this method lives on AccountProjection.
  3. 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

  1. Should k_capital include unrealized PnL in equity?

    • Current: equity = capital + unrealized_pnl (Python)
    • Rust: k_equity = k_capital + k_unrealized
    • Answer: Yes, keep both. Capital is realized-only; equity includes unrealized.
  2. Should funding fees be settled incrementally or at close?

    • Current: on_account_event(FUNDING_FEE) folds into k_funding_net
    • This already works correctly — no change needed.
  3. 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 += 1 in apply_fill.
  4. 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_feesk_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=0apply_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.0120 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.