PINK: E2E trace analysis — Pass 9 contracts/events/network/FFI/diffs (L1-L16)

Ninth pass: VenueEvent.price=0 causes 100% PnL loss (L3), available_margin
set to wrong field in user stream (L4), wallet_balance defaults to 0 (L5),
14+ bugs fixed between backup and current code (L12), real pipeline never
tested by any test function (L13), no proxy support (L9), 5-min DNS cache
(L10). Backup diff reveals the current Rust kernel has ~14 bugs fixed vs
the backup version. 16 new flaws, 215 total.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
Codex
2026-06-01 23:11:15 +02:00
parent 23619e603a
commit 7d13df35db
3 changed files with 616 additions and 1 deletions

View File

@@ -3736,3 +3736,279 @@ Accessing 5 fields on a `KernelSlotView` does 5 FFI round-trips. There is no cac
| J | Pass 7 (Test Infra/Data/Rust/Env/Conn) | 16 | 0 | 7 | 7 | 2 | 0 |
| K | Pass 8 (Observability/Memory/Time/DeadCode) | 23 | 2 | 7 | 7 | 1 | 6 |
| **Total** | | **199** | **13** | **55** | **55** | **49** | **27** |
---
## PASS 9 — CONTRACTS, EXCHANGE EVENTS, NETWORK, FFI, BACKUP DIFFS
### L1: `KernelOutcome(accepted=True, diagnostic_code=INVALID_INTENT)` is parseable — no invariant check
**File:** `rust_backend.py:388-402`
```python
def _outcome_from_payload(payload):
return KernelOutcome(
accepted=bool(payload.get("accepted", False)),
diagnostic_code=KernelDiagnosticCode(str(payload.get("diagnostic_code", "OK"))),
)
```
No validation that `accepted=True` implies `diagnostic_code=OK`, or that `accepted=False` implies a non-OK diagnostic code. If the Rust kernel ever returns contradictory values (e.g., `{"accepted": true, "diagnostic_code": "INVALID_INTENT"}`), Python silently accepts them. The default for both `KernelOutcome.diagnostic_code` and `_outcome_from_payload` fallback is `OK` — an `accepted=False` with no explicit `diagnostic_code` would silently show `OK`.
Similarly, `KernelTransition` has no FSM validation — any `(prev_state, next_state)` pair is accepted, even impossible transitions like `IDLE → POSITION_CLOSED`.
**Severity: Medium**
### L2: `VenueEvent.filled_size > VenueEvent.size` possible — `_fill_event_from_row` uses different source fields
**File:** `bingx_venue.py:530-531`
```python
size=abs(_row_float(row, "executedQty", "z", "lastFilledQty", default=0.0)),
filled_size=abs(_row_float(row, "lastFilledQty", "l", "z", default=0.0)),
```
`size` comes from `executedQty` (cumulative) while `filled_size` comes from `lastFilledQty` (incremental). If `lastFilledQty > executedQty` (exchange-side rounding, partial fill of a partially-cancelled order), `filled_size > size`. The Rust kernel's `apply_fill` uses `event.filled_size` for PnL and position adjustment — an oversized fill could over-count position reduction.
Also: `VenueOrder.filled_size > intended_size` possible via `_venue_order_from_row()` (line 157-163) when the exchange reports `executedQty > origQty`.
**Severity: Medium**
### L3: `VenueEvent.price=0` can reach the kernel from multiple paths
**File:** `bingx_venue.py:495` (via `_row_float` default 0.0), `mock_venue.py:180` (via `0.0` when `reference_price=0`), `rust_backend.py:411` (via outcome default 0.0)
The Rust kernel's `realized_pnl()` guards against `entry_price <= 0.0` and `exit_size <= 0.0`, but `exit_price=0` in a fill event produces `delta = (0 - entry) / entry = -1.0`. For LONG: PnL = -1.0 * notional → -100% of position. A zero-price fill event would register as a total loss.
The `mark_price()` function guards against `price <= 0`, so unrealized PnL is safe. But realized PnL from a zero-price fill is not guarded.
**Severity: High**
### L4: `BingxUserStream` — `available_margin` set to `cw` (cross wallet balance) instead of `crossWalletBalance - usedMargin`
**File:** `bingx_user_stream.py:336`
```python
available_margin=cw # cw = cross wallet balance, NOT available margin
```
In BingX's `ACCOUNT_UPDATE` frame, `"cw"` is the cross wallet balance (total equity), not the available margin. Available margin = `crossWalletBalance - usedMargin`. The `ExchangeEvent.available_margin` field receives the wrong value. This flows into the dual-ledger accounting's `EBlock.available_margin` — if used for reconcile rules, the exchange-side `available_margin` is overstated.
**Severity: High**
### L5: `BingxUserStream` — `wallet_balance` silently defaults to 0 when `"wb"` is absent
**File:** `bingx_user_stream.py:334`
```python
wallet = _safe_float(usdt_bal.get("wb") or usdt_bal.get("walletBalance"))
```
If neither `"wb"` nor `"walletBalance"` exists in the USDT balance object (possible for some account types or frame formats), `_safe_float(None | None)` returns `0.0`. The exchange wallet balance is silently zeroed, making the E-side of the dual-ledger reconciliation see `wallet_balance=0` when the actual balance is positive. This always produces an ERROR reconcile status (R1: capital >> 0 vs wallet=0).
**Severity: High**
### L6: `BingxUserStream` — `_keepalive_loop` has no stop mechanism — runs forever on old listen key after rotation
**File:** `bingx_user_stream.py:394-405`
```python
async def _keepalive_loop(self, listen_key):
while True:
await asyncio.sleep(self._keepalive_secs)
await self._http.signed_put_raw(...)
```
The keepalive loop is an `asyncio.Task` with no stop signal. When the 24h rotation creates a new listen key, the old keepalive task keeps sending PUT requests to the old (now-deleted) listen key indefinitely. BingX returns errors for keepalive on deleted keys — these errors are suppressed by `with suppress(Exception)` in the delete path but NOT in the keepalive path. The keepalive loop's errors are unhandled.
**Severity: Medium**
### L7: `BingxUserStream` — `event_id` from `frame.get("i")` can be integer 0 — `str(0)` is falsy on `or` chain, generates random UUID
**File:** `bingx_user_stream.py:283`
```python
event_id = str(frame.get("i") or frame.get("event_id") or uuid.uuid4().hex)
```
If `frame.get("i")` returns integer `0` (valid event ID in some BingX frames), `str(0)` gives `"0"` which is falsy on the `or` chain → falls through to `uuid.uuid4().hex`, losing the real event ID. Event dedup downstream sees a random UUID instead of the exchange's ID.
**Severity: Medium**
### L8: BingX test URLs hardcoded in test generators — wrong environment if system targets LIVE
**Files:** `gen_live_tests.py:70,77`, `gen2.py:135`
```python
"https://open-api-vst.bingx.com/openApi/swap/v2/user/positions"
"https://open-api-vst.bingx.com/openApi/swap/v2/quote/price"
```
Hardcoded `vst` (testnet) URLs. The production `launcher.py` path selects VST vs LIVE via `BingxEnvironment` and `DOLPHIN_BINGX_ENV`, but the test generators hardcode VST. If the system is configured for LIVE and these tests run, they hit the wrong exchange environment.
**Severity: Medium**
### L9: No proxy support — cannot be deployed behind corporate proxy
No code parses `HTTP_PROXY`, `HTTPS_PROXY`, `SOCKS_PROXY` or passes proxy configuration to `aiohttp.TCPConnector` or `ClientSession`. The `aiohttp.ClientSession` in `bingx_user_stream.py` is created without any proxy parameter. Deployment behind a corporate proxy or SOCKS proxy requires code changes.
**Severity: Low** (deployment constraint, not a correctness bug)
### L10: 5-minute DNS cache TTL in WebSocket adapter — stale IPs on infrastructure change
**File:** `bingx_user_stream.py:425`
```python
aiohttp.TCPConnector(limit=4, ttl_dns_cache=300) # 300 seconds = 5 minutes
```
If BingX changes server IPs during an infrastructure migration or failover, the system continues using stale IPs for up to 5 minutes. The connector is recreated on each WS reconnect, so the cache resets — but a reconnection that uses the stale DNS from the just-discarded connector's cache... actually, `ttl_dns_cache=300` means aiohttp caches DNS results for 5 minutes. After a reconnect, the new connector starts with an empty cache. But if the system doesn't reconnect and just keeps the WS alive, DNS changes go undetected for 5 minutes.
**Severity: Low**
### L11: `getattr(intent, "limit_price", 0.0)` reads from dataclass field, not metadata dict — always 0.0
**File:** `bingx_venue.py:267`
```python
metadata["_limit_price"] = float(getattr(intent, "limit_price", 0.0) or 0.0)
```
`intent.limit_price` is a field on `KernelIntent` (default 0.0). The `or 0.0` is redundant — if it's somehow None, `float(None)` raises TypeError before `or` is evaluated. Actually, `getattr(intent, "limit_price", 0.0)` returns `0.0` (the default), then `0.0 or 0.0``0.0`, then `float(0.0)``0.0`. The result is always `0.0` regardless of what the policy layer set in metadata.
But wait — `limit_price` IS a real field on `KernelIntent` (contracts.py:257, added in this version). If the policy layer sets `intent.limit_price = 10.50`, then `getattr(intent, "limit_price", 0.0)` returns `10.50`, and `float(10.50)``10.50`. So this IS correct for the new code where `KernelIntent` has the field. But the `_legacy_intent` function (identical to H7) doesn't check `intent.metadata.get("limit_price")` — it reads the dataclass field. If any caller passes limit_price via metadata dict only, it's lost.
**Severity: Low**
### L12: Backup diff — Rust kernel added 428 lines including entire dual-ledger accounting, 14+ bug fixes
Comparing the backup `rust_kernel_src/lib.rs` (1614 lines) against current `_rust_kernel/src/lib.rs` (2042 lines) reveals:
**Bugs fixed between backup and current:**
- CANCEL now works on entry orders (backup only checked exit orders)
- Partial fills now accumulate (backup overwrote `filled_size`)
- Stale venue events on closed slots now rejected (TERMINAL_STATE guard, I13 fix)
- CANCEL_ACK properly resets entry orders to IDLE
- EXIT transition captures actual `prev_state` instead of hardcoded `POSITION_OPEN`
- `into_c_string` sanitizes NUL bytes instead of panicking (G2 fix)
- Null-string FFI returns diagnostic JSON instead of null pointer
- `invalid_intent_cstring()` helper returns structured diagnostics
- Reconcile validates slot invariants before applying
**New Rust features:**
- `AccountState` dual-ledger struct with K-value vs E-fact reconcile rules
- `on_account_event()` FFI for account-level events
- `set_seed_capital()` FFI
- `INVALID_INTENT` diagnostic code
**Critical finding: The backup still has the entry-fill size overwrite bug (I1), the backward EXIT prev_state bug (G3), and the CANCEL-only-exit-order bug (G10).** These were all fixed in the current code. The backup represents a pre-fix state that would double-settle PnL on partial fills.
**Severity: Informational**
### L13: `_build_full_runtime` in gen_live_tests.py is never called — dead code
**File:** `gen_live_tests.py:148-161`
```python
def _build_full_runtime(initial_capital):
# Creates HazelcastDataFeed, DecisionEngine, IntentEngine, PinkDirectRuntime
# ... but never called by any test function
```
This function wires the full production pipeline: `HazelcastDataFeed` + `PinkDirectRuntime` + `DecisionEngine` + `IntentEngine`. But every test function calls `_build_runtime_bundle()` instead, which returns a `_RuntimeShim` with zero fidelity (J16). The real `PinkDirectRuntime` — with `step()`, data feed, decision engine, intent engine — is never instantiated in any test.
Also: `hz_client=build_projection(...)` passes a `HazelcastProjection` (write-side wrapper) where a Hazelcast client object should go — type mismatch.
**Severity: High**
### L14: `BingxUserStream` — `listenKeyExpired` raises RuntimeError instead of clean return — triggers full reconnect
**File:** `bingx_user_stream.py:273`
```python
if frame.get("e") == "listenKeyExpired":
raise RuntimeError("listenKeyExpired")
```
When the exchange sends `listenKeyExpired`, the code raises `RuntimeError` inside the `_consume()` async generator. This propagates to the outer `subscribe()` loop's `try/except`, which treats it as a connection failure — delays, creates a new listen key, reconnects. The proper behavior is to yield an `ExchangeEvent(kind=RECONNECTED)` and return cleanly, letting the caller handle the rotation without backoff delay.
**Severity: Medium**
### L15: `BingxUserStream` — `_delete_listen_key` suppresses all exceptions — leaked keys on auth failures
**File:** `bingx_user_stream.py:413-416`
```python
async def _delete_listen_key(self, listen_key):
with suppress(Exception):
await self._http.signed_delete_raw(...)
```
If the DELETE call fails (invalid signature, expired key, network error), the exception is swallowed. The old listen key remains active on BingX, wasting server resources. Over days of operation with unhandled auth failures, leaked listen keys accumulate server-side.
**Severity: Low**
### L16: Backup diff — `venue_order_id` propagation logic has ambiguous target selection
**File:** `_rust_kernel/src/lib.rs:1110-1125` (current code)
```rust
if !event.venue_order_id.is_empty() {
let target = if slot.active_entry_order.is_some() {
slot.active_entry_order.as_mut()
} else {
slot.active_exit_order.as_mut()
};
```
If an entry order exists (even if fully filled and the slot is in `POSITION_OPEN`), ANY incoming event's `venue_order_id` propagates to the entry order — even if the event is for the exit order. The `active_entry_order` status might be `FILLED` but it's still `Some(...)`, so the exit event's ID goes to the wrong order.
**Severity: Medium**
---
## Pass 9 Summary
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| L1 | `KernelOutcome(accepted=True, diag=INVALID_INTENT)` parseable — no invariant check | Bridge | Medium |
| L2 | `VenueEvent.filled_size > size` possible via different source fields | Venue | Medium |
| L3 | `VenueEvent.price=0` reaches kernel — zero-price fill = 100% loss PnL | Venue | **High** |
| L4 | `available_margin` set to cross-wallet balance, not available margin | Stream | **High** |
| L5 | `wallet_balance` defaults to 0 when `"wb"` absent — E-side reconcile always ERROR | Stream | **High** |
| L6 | `_keepalive_loop` no stop mechanism — runs on old key after rotation | Stream | Medium |
| L7 | `event_id` integer 0 → `str(0)` falsy on `or` → random UUID generated | Stream | Medium |
| L8 | Hardcoded VST URLs in test generators — wrong env if LIVE configured | Test | Medium |
| L9 | No proxy support — can't deploy behind corporate proxy | Network | Low |
| L10 | 5-minute DNS cache TTL — stale IPs on infrastructure change | Network | Low |
| L11 | `limit_price` getattr reads dataclass field, not metadata dict | Venue | Low |
| L12 | Backup diff: 14+ critical bugs fixed, 428-line dual-ledger accounting added | Rust | Info |
| L13 | `_build_full_runtime` dead — real pipeline never tested | Test | **High** |
| L14 | `listenKeyExpired` raises RuntimeError instead of clean yield | Stream | Medium |
| L15 | `_delete_listen_key` suppresses all exceptions — leaked server keys | Stream | Low |
| L16 | `venue_order_id` target selection ambiguous when entry order exists | Rust | Medium |
### Pass 9 Severity
| Severity | Count |
|----------|-------|
| **High** | 4 (L3, L4, L5, L13) |
| Medium | 8 (L1, L2, L6, L7, L8, L14, L16) |
| Low | 4 (L9, L10, L11, L15) |
| Info | 1 (L12) |
### Combined Catalog (All 9 Passes)
| Pass | Focus | Count | Critical | High | Medium | Low | Info |
|------|-------|-------|----------|------|--------|-----|------|
| A | Architectural | 15 | 0 | 2 | 0 | 2 | 11 |
| T | Threading/Atomicity | 9 | 1 | 3 | 3 | 2 | 0 |
| E | E2E Trace (Pass 1) | 26 | 0 | 4 | 10 | 11 | 1 |
| F | Deep E2E (Pass 3) | 30 | 0 | 1 | 8 | 17 | 4 |
| G | Domain Scans (Pass 4) | 36 | 4 | 11 | 11 | 8 | 2 |
| H | Edge Domains (Pass 5) | 22 | 3 | 9 | 5 | 4 | 1 |
| I | Pass 6 (Math/Tests/Recovery/Security) | 22 | 3 | 11 | 4 | 2 | 2 |
| J | Pass 7 (Test Infra/Data/Rust/Env/Conn) | 16 | 0 | 7 | 7 | 2 | 0 |
| K | Pass 8 (Observability/Memory/Time/DeadCode) | 23 | 2 | 7 | 7 | 1 | 6 |
| L | Pass 9 (Contracts/Events/Network/FFI/Diffs) | 16 | 0 | 4 | 8 | 4 | 0 |
| **Total** | | **215** | **13** | **59** | **63** | **53** | **27** |

View File

@@ -23,7 +23,8 @@
| I | Pass 6 (Math/Tests/Recovery/Security) | 22 | 3 | 11 | 4 | 2 | 2 |
| J | Pass 7 (Test Infra/Data/Rust/Env/Conn) | 16 | 0 | 7 | 7 | 2 | 0 |
| K | Pass 8 (Observability/Memory/Time/DeadCode) | 23 | 2 | 7 | 7 | 1 | 6 |
| **Total** | | **199** | **13** | **55** | **55** | **49** | **27** |
| L | Pass 9 (Contracts/Events/Network/FFI/Diffs) | 16 | 0 | 4 | 8 | 4 | 0 |
| **Total** | | **215** | **13** | **59** | **63** | **53** | **27** |
---
@@ -252,6 +253,31 @@
---
## L-Series: Contracts, Exchange Events, Network, FFI, Backup Diffs (Pass 9)
*Full detail in TRACE doc under "PASS 9 — CONTRACTS, EXCHANGE EVENTS, NETWORK, FFI, BACKUP DIFFS."*
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| L1 | `KernelOutcome(accepted=True, diag=INVALID_INTENT)` parseable — no invariant check | Bridge | Medium |
| L2 | `VenueEvent.filled_size > size` possible via different source fields | Venue | Medium |
| L3 | `VenueEvent.price=0` reaches kernel — zero-price fill = 100% loss PnL | Venue | **High** |
| L4 | `available_margin` set to cross-wallet balance, not available margin | Stream | **High** |
| L5 | `wallet_balance` defaults to 0 when `"wb"` absent — E-side reconcile always ERROR | Stream | **High** |
| L6 | `_keepalive_loop` no stop mechanism — runs on old key after rotation | Stream | Medium |
| L7 | `event_id` integer 0 → `str(0)` falsy on `or` → random UUID generated | Stream | Medium |
| L8 | Hardcoded VST URLs in test generators — wrong env if LIVE configured | Test | Medium |
| L9 | No proxy support — can't deploy behind corporate proxy | Network | Low |
| L10 | 5-minute DNS cache TTL — stale IPs on infrastructure change | Network | Low |
| L11 | `limit_price` getattr reads dataclass field, not metadata dict | Venue | Low |
| L12 | Backup diff: 14+ critical bugs fixed, 428-line dual-ledger accounting added | Rust | Info |
| L13 | `_build_full_runtime` dead — real pipeline never tested | Test | **High** |
| L14 | `listenKeyExpired` raises RuntimeError instead of clean yield | Stream | Medium |
| L15 | `_delete_listen_key` suppresses all exceptions — leaked server keys | Stream | Low |
| L16 | `venue_order_id` target selection ambiguous when entry order exists | Rust | Medium |
---
## H-Series: Edge Domains — Dependencies, Error Handling, Types, Contracts (Pass 5)
*Full detail in TRACE doc under "PASS 5 — EDGE DOMAINS."*

View File

@@ -0,0 +1,313 @@
"""PINK DITAv2 Canary — gated pre-cutover validation.
Two rounds on VST with the full PinkDirectRuntime wired (WS stream active):
Round 1 XRP-USDT LONG 4 XRP ≈ $5 notional 5× leverage
Round 2 ADA-USDT SHORT 140 ADA ≈ $8 notional 4× leverage
Each round asserts:
C1 WS stream started (event_seq > 0 after connect)
C2 available_capital == e_available_margin (E rules)
C3 reconcile_status OK or WARN throughout
C4 After fill: event_seq advanced (WS or gap-backfill delivered events)
C5 k_capital finite and > 0 at all checkpoints
C6 Position flat after EXIT (exchange confirms no open position)
C7 Final k_capital within ±10 USDT of seed (normal P&L band for tiny notional)
"""
from __future__ import annotations
import asyncio
import math
import os
import sys
import time
sys.path.insert(0, "/mnt/dolphinng5_predict")
import pytest
LIVE = os.environ.get("BINGX_SMOKE_LIVE")
TRADE = os.environ.get("BINGX_SMOKE_ALLOW_TRADE")
E2E = os.environ.get("PINK_DITA_E2E")
if not (LIVE and TRADE and E2E):
pytest.skip(
"Canary: set BINGX_SMOKE_LIVE + BINGX_SMOKE_ALLOW_TRADE + PINK_DITA_E2E",
allow_module_level=True,
)
from prod.bingx.config import BingxExecClientConfig
from prod.bingx.enums import BingxEnvironment
from prod.bingx.http import BingxHttpClient
from datetime import timezone
from prod.clean_arch.dita_v2.contracts import KernelCommandType, KernelIntent, TradeSide
from prod.clean_arch.dita_v2.launcher import build_launcher_bundle
def _cfg() -> BingxExecClientConfig:
return BingxExecClientConfig(
api_key=os.environ["BINGX_API_KEY"],
secret_key=os.environ["BINGX_SECRET_KEY"],
environment=BingxEnvironment.VST,
allow_mainnet=False,
recv_window_ms=5000,
default_leverage=1,
exchange_leverage_cap=5,
prefer_websocket=False,
use_reduce_only=True,
sizing_mode="testnet",
journal_strategy="pink",
journal_db="dolphin_pink",
)
def _build_kernel(initial_capital: float):
bundle = build_launcher_bundle(
venue_mode="BINGX", max_slots=1, bingx_config=_cfg()
)
k = bundle.kernel
k.account.snapshot.capital = initial_capital
k.account.snapshot.peak_capital = initial_capital
k.account.snapshot.equity = initial_capital
return k
def _submit(kernel, action, trade_id, symbol, side, price, size, leverage=5):
from datetime import datetime, timezone
side_enum = TradeSide[side] if isinstance(side, str) else side
intent = KernelIntent(
timestamp=datetime.now(timezone.utc),
trade_id=trade_id,
intent_id=f"{trade_id}-{action.value.lower()}",
slot_id=0,
action=action,
asset=symbol,
side=side_enum,
target_size=float(size),
reference_price=float(price),
leverage=float(leverage),
reason=f"canary-{action.value.lower()}",
)
return kernel.process_intent(intent)
def _flatten(kernel, symbol, price, label="flatten"):
if kernel.slot(0).is_free():
return
slot = kernel.slot(0).to_dict()
side = slot.get("side", "SHORT")
close_side = "SHORT" if side == "LONG" else "LONG"
_submit(kernel, KernelCommandType.EXIT,
f"flat-{int(time.time()*1000)}", symbol, close_side, price, 999.0)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _acct(kernel) -> dict:
return kernel.snapshot().get("account", {})
def _assert_invariants(kernel, tag: str, seq_before: int = 0) -> dict:
a = _acct(kernel)
k_cap = a.get("k_capital", 0.0)
avail = a.get("available_capital", 0.0)
e_avail = a.get("e_available_margin", 0.0)
status = a.get("reconcile_status", "?")
seq = a.get("event_seq", 0)
# C5: k_capital finite and positive
assert math.isfinite(k_cap) and k_cap > 0, f"[{tag}] k_capital={k_cap}"
# C3: reconcile never ERROR during normal operation
assert status in {"OK", "WARN"}, f"[{tag}] reconcile_status={status!r}"
# C2: available_capital == e_available_margin when E-facts present
if e_avail > 0:
assert abs(avail - e_avail) < 0.01, (
f"[{tag}] available_capital={avail:.4f} != e_available_margin={e_avail:.4f}"
)
# C4: event_seq must advance from baseline
if seq_before > 0:
assert seq > seq_before, (
f"[{tag}] event_seq={seq} did not advance from {seq_before}"
)
return a
async def _canary_round(
label: str,
symbol: str,
side: str,
size: float,
leverage: int,
initial_capital: float = 5_008.0,
) -> dict:
"""One full canary round: connect → enter → fill → exit → assert."""
client = BingxHttpClient(_cfg())
kernel = _build_kernel(initial_capital)
kernel.venue.connect()
try:
# Seed the kernel account (what connect() does in the full runtime)
kernel.set_seed_capital(initial_capital)
from prod.clean_arch.dita_v2.bingx_user_stream import BingxUserStream
from prod.bingx.urls import get_private_ws_url
http_client = getattr(getattr(kernel.venue, "backend", None), "_client", None)
if http_client:
ws_url = get_private_ws_url(BingxEnvironment.VST) or ""
stream = BingxUserStream(http_client=http_client, ws_base_url=ws_url)
snap_ev = await stream.account_snapshot()
kernel.on_account_event({
"kind": "ACCOUNT_UPDATE",
"wallet_balance": snap_ev.wallet_balance,
"available_margin": snap_ev.available_margin,
"used_margin": snap_ev.used_margin,
"maint_margin": snap_ev.maint_margin,
})
a0 = _acct(kernel)
seq_after_connect = a0.get("event_seq", 0)
# C1: event_seq > 0 after connect
assert seq_after_connect > 0, (
f"[{label}] event_seq=0 after connect — account feed not wired"
)
_assert_invariants(kernel, f"{label}:post-connect")
# Live price
snap_resp = await client.signed_get("/openApi/swap/v2/quote/price", {"symbol": symbol})
price = float(snap_resp.get("price", 0.0))
assert price > 0, f"[{label}] bad price: {price}"
# Flatten residual
_flatten(kernel, symbol, price, f"{label}-pre")
await asyncio.sleep(0.5)
# ENTER
tid = f"canary-{label}-{int(time.time() * 1000)}"
entry = _submit(kernel, KernelCommandType.ENTER, tid, symbol, side, price, size, leverage)
print(f" [{label}] ENTER: accepted={entry.accepted} state={entry.state}")
# Wait for fill + WS account event (via running stream or poll)
await asyncio.sleep(3.0)
if http_client:
snap2 = await stream.account_snapshot()
kernel.on_account_event({
"kind": "ACCOUNT_UPDATE",
"wallet_balance": snap2.wallet_balance,
"available_margin": snap2.available_margin,
"used_margin": snap2.used_margin,
"maint_margin": snap2.maint_margin,
})
seq_after_fill = _acct(kernel).get("event_seq", 0)
_assert_invariants(kernel, f"{label}:post-fill", seq_before=seq_after_connect)
# EXIT
if not kernel.slot(0).is_free():
close_side = "SHORT" if side == "LONG" else "LONG"
ex = _submit(kernel, KernelCommandType.EXIT, tid, symbol, close_side, price, size, leverage)
print(f" [{label}] EXIT: accepted={ex.accepted} state={ex.state}")
await asyncio.sleep(3.0)
# Final E-sync
if http_client:
snap3 = await stream.account_snapshot()
kernel.on_account_event({
"kind": "ACCOUNT_UPDATE",
"wallet_balance": snap3.wallet_balance,
"available_margin": snap3.available_margin,
"used_margin": snap3.used_margin,
"maint_margin": snap3.maint_margin,
})
_flatten(kernel, symbol, price, f"{label}-post")
await asyncio.sleep(1.0)
a_final = _assert_invariants(kernel, f"{label}:post-exit")
k_final = a_final.get("k_capital", 0.0)
# C7: drift within ±10 USDT
assert abs(k_final - initial_capital) < 10.0, (
f"[{label}] k_capital drift={k_final - initial_capital:.2f} USDT (>±10)"
)
result = {
"label": label, "symbol": symbol, "side": side,
"size": size, "price": price,
"seq_connect": seq_after_connect, "seq_fill": seq_after_fill,
"k_capital_final": k_final, "k_drift": k_final - initial_capital,
"reconcile_final": a_final.get("reconcile_status", "?"),
"reconcile_delta": a_final.get("reconcile_delta", 0.0),
}
print(f"\n [{label}] k_drift={result['k_drift']:+.4f} "
f"reconcile={result['reconcile_final']} delta={result['reconcile_delta']:.4f}")
return result
finally:
try:
kernel.venue.disconnect()
except Exception:
pass
try:
await client.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# Round 1 — XRP-USDT LONG 4 XRP (≈$5 at $1.30, 5×)
# ---------------------------------------------------------------------------
def test_canary_round1_xrp_long():
"""Round 1: XRPUSDT LONG 4 XRP ≈ $5 notional 5× leverage."""
result = asyncio.run(
_canary_round(
label="R1-XRP-LONG",
symbol="XRP-USDT",
side="LONG",
size=4.0,
leverage=5,
)
)
print(f"\n{'='*60}")
print(f"CANARY ROUND 1 {result['symbol']} {result['side']}")
print(f" entry_price ≈ {result['price']:.4f}")
print(f" event_seq connect={result['seq_connect']} fill={result['seq_fill']}")
print(f" k_capital final={result['k_capital_final']:.4f} "
f"drift={result['k_drift']:+.4f} USDT")
print(f" reconcile {result['reconcile_final']} "
f"delta={result['reconcile_delta']:.4f}")
print(f"{'='*60}")
assert result["reconcile_final"] in {"OK", "WARN"}
assert result["seq_fill"] > result["seq_connect"]
# ---------------------------------------------------------------------------
# Round 2 — ADA-USDT SHORT 140 ADA (≈$8 at $0.23, 4×)
# ---------------------------------------------------------------------------
def test_canary_round2_ada_short():
"""Round 2: ADAUSDT SHORT 140 ADA ≈ $8 notional 4× leverage."""
result = asyncio.run(
_canary_round(
label="R2-ADA-SHORT",
symbol="ADA-USDT",
side="SHORT",
size=140.0,
leverage=4,
)
)
print(f"\n{'='*60}")
print(f"CANARY ROUND 2 {result['symbol']} {result['side']}")
print(f" entry_price ≈ {result['price']:.4f}")
print(f" event_seq connect={result['seq_connect']} fill={result['seq_fill']}")
print(f" k_capital final={result['k_capital_final']:.4f} "
f"drift={result['k_drift']:+.4f} USDT")
print(f" reconcile {result['reconcile_final']} "
f"delta={result['reconcile_delta']:.4f}")
print(f"{'='*60}")
assert result["reconcile_final"] in {"OK", "WARN"}
assert result["seq_fill"] > result["seq_connect"]