PINK: E2E trace analysis — Pass 20 config/math signs/BingX protocol (W1-W14)

Twentieth pass: int() on 3 env vars uncaught ValueError (W1 Critical),
DITA_V2_PREFIX default "dita_v2" multi-process shared memory corruption (W2
Critical), funding sign opposite Python V2 vs Rust same raw value opposite
capital effect (W3 Critical), listenKeyExpired frames silently swallowed
continue skips expiry check dead code (W4 Critical), RECV_WINDOW_MS no upper
bound replay attacks (W5 High), ACTIVE_SLOT_LIMIT stored never enforced by
Rust kernel (W6 High), no fill history fetched during WS reconnect gap-backfill
fills lost (W7 High), rate limit detection fails on HTTP 429 no matching
message instant retry (W8 High), CONTROL_PLANE=REAL_ZINC silently falls back
to in-memory (W9 High), all BingxHttpError mapped to REJECTED can't distinguish
errors (W10 High), os.environ bracket access vs .get() inconsistent (W11 High).
361 total flaws across 20 passes.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
Codex
2026-06-02 17:13:21 +02:00
parent ded4b59891
commit b270b164ba
6 changed files with 618 additions and 27 deletions

View File

@@ -7135,3 +7135,347 @@ If an object has both `close()` and `disconnect()`, only `close()` is called. Th
| U | Pass 18 (Rust Test Gaps/Accounting/FFI Types) | 14 | 3 | 4 | 4 | 3 | 0 |
| V | Pass 19 (Lifecycle/Rust Subtleties/Test Infra) | 14 | 5 | 2 | 4 | 3 | 0 |
| **Total** | | **347** | **35** | **101** | **100** | **64** | **37** |
---
## PASS 20 — CONFIGURATION MANAGEMENT, MATH SIGN CONVENTIONS, BINGX PROTOCOL
### W1: `int()` on three env vars (`RECV_WINDOW_MS`, `DEFAULT_LEVERAGE`, `EXCHANGE_LEVERAGE_CAP`) — `ValueError` uncaught, immediate crash on non-numeric input
**File:** `launcher.py:189-191`
```python
recv_window_ms = int(os.environ.get("DOLPHIN_BINGX_RECV_WINDOW_MS", "5000"))
default_leverage = int(os.environ.get("DOLPHIN_BINGX_DEFAULT_LEVERAGE", "1"))
exchange_leverage_cap = int(os.environ.get("DOLPHIN_BINGX_EXCHANGE_LEVERAGE_CAP", "3"))
```
Three consecutive `int()` calls on raw env var strings with **no try/except**. If any of these env vars is set to a non-numeric value (e.g., `DOLPHIN_BINGX_RECV_WINDOW_MS=abc` from a typo in Docker config), `int("abc")` raises `ValueError` which propagates uncaught through `build_bingx_exec_client_config()` → `build_launcher_bundle()` → crashes the process.
Compare with `DITA_V2_ACTIVE_SLOT_LIMIT` (launcher.py:140-144) which correctly wraps `int()` in `try/except Exception: pass`. The slot limit parsing is safe; these three are not.
**Severity: Critical**
### W2: `DITA_V2_PREFIX` default `"dita_v2"` — multi-process with `ZINC=REAL` causes silent shared-memory data corruption
**File:** `launcher.py:311`
```python
resolved_prefix = (prefix or os.environ.get("DITA_V2_PREFIX", "dita_v2")).strip()
```
The prefix defaults to `"dita_v2"` if not set. When two processes on the same machine use `DITA_V2_ZINC=REAL` (or one process restarts without cleaning shared memory), both try to `SharedRegion.create("dita_v2_intent", ...)`, `"dita_v2_state"`, `"dita_v2_control"`.
On Linux, shared memory segments (`/dev/shm/`) persist until explicitly unlinked or the system reboots. A second process:
- Gets `EEXIST` from `SharedRegion.create()` — which `real_zinc_plane.py`'s `__init__` does NOT handle (no `try/except`)
- Or if the region already exists with different size, get a mismatch error
- Or if the region is simply opened (not created), both processes read/write the same memory — **simultaneous writes corrupt the state**
The `_write_region` functions are non-atomic (T7), so two processes writing concurrently see partial updates.
**Severity: Critical**
### W3: Funding sign convention opposite between Python V2 `apply_funding()` and Rust `apply_funding_fee()` — same raw exchange value produces opposite capital effect
**Files:** `_rust_kernel/src/lib.rs:839-841` (Rust), `account.py:299` (Python V2)
```rust
// Rust: amount > 0 = received (capital ↑)
self.k_funding_net -= amount;
// If amount = -3.75 (paid out): k_funding_net -= (-3.75) = k_funding_net + 3.75
// k_capital = seed + realized - fees - k_funding_net = seed + realized - fees - (+3.75) = capital ↓
```
```python
# Python V2: amount > 0 = paid out (capital ↓)
self._k_funding += amount
self._k_capital = self._seed + self._k_realized - self._k_fees - self._k_funding
# If amount = -3.75 (paid out): _k_funding += (-3.75) = -3.75
# k_capital = seed + realized - fees - (-3.75) = seed + realized - fees + 3.75 = capital ↑ WRONG
```
Both use `k_capital = seed + realized - fees - funding`. But:
- **Rust**: A funding **payment** (capital decreases) is represented as a **negative** `amount`. `k_funding_net -= (-3.75) = +3.75`, then `capital - 3.75` = capital decreases. Correct.
- **Python V2**: A funding **payment** (capital decreases) is represented as a **positive** `amount`. `_k_funding += 7.25`, then `capital - 7.25` = capital decreases. Also correct for its own convention — but opposite sign convention.
**The same raw exchange value** (e.g., `funding_amount = -3.75` from BingX WS showing a funding cost):
| System | Input | `k_funding` | k_capital effect | Correct? |
|--------|-------|-------------|-----------------|----------|
| Rust | `-3.75` | `funding_net = 0 - (-3.75) = +3.75` | `capital - 3.75` (decrease) | ✅ |
| Python V2 | `-3.75` | `_k_funding = 0 + (-3.75) = -3.75` | `capital - (-3.75) = capital + 3.75` (increase) | ❌ WRONG |
The parity test (`test_exchange_event_seam_parity.py:426`) compares WS path vs Poll path — both use Python V2 `apply_funding()` with the same convention, so they match each other but **both are wrong in absolute value**. The Rust kernel produces the correct absolute value.
**Severity: Critical**
### W4: `BingxUserStream` `listenKeyExpired` frames silently swallowed — `continue` at line 272 skips the expiry check at line 275, dead code
**File:** `bingx_user_stream.py:272-276`
```python
# Line 272-276 — the main WS message dispatch:
kind = frame.get("e", "")
if kind in self._NORMALISE_MAP:
yield self._NORMALISE_MAP[kind](frame)
else:
continue # <-- UNKNOWN event type → continue
# Line 275: THIS LINE IS NEVER REACHED for listenKeyExpired
if kind == "listenKeyExpired":
raise RuntimeError("listenKeyExpired")
```
The `else: continue` at line 273 skips the `listenKeyExpired` check at line 275. When BingX sends `{"e": "listenKeyExpired"}`, the dispatch:
1. Check `kind in self._NORMALISE_MAP` — `"listenKeyExpired"` is NOT in the map
2. Falls to `else: continue` — skips the rest of the loop body
3. Line 275 is **dead code** — never reaches the `raise RuntimeError`
The stream stays connected with a dead listenKey. The keepalive loop (which runs independently) keeps sending PUT keepalive requests to the dead key. The 24-hour rotation timer eventually fires, but in the meantime (potentially hours), all WS events are silently lost.
The `raise RuntimeError("listenKeyExpired")` at line 276 was clearly intended to trigger a reconnect, but the `continue` before it makes it unreachable.
**Severity: Critical**
### W5: `int()` on `DOLPHIN_BINGX_RECV_WINDOW_MS` with no bounds check — extreme values can enable replay attacks
**File:** `launcher.py:189`
```python
recv_window_ms = int(os.environ.get("DOLPHIN_BINGX_RECV_WINDOW_MS", "5000"))
```
The recv window is used in HMAC-signed BingX requests as the `recvWindow` parameter. It defines the timestamp tolerance for signed requests — how far off the request timestamp can be from the server's clock.
A value like `86400000` (24 hours) means any signed request is valid for 24 hours from its timestamp. An attacker who intercepts a signed request can replay it for an entire day.
There's no upper bound. The code only clamps `max(1, recv_window_ms)` — so the minimum is 1ms but the maximum is unbounded.
**Severity: High**
### W6: `DITA_V2_ACTIVE_SLOT_LIMIT` stored in control snapshot but never enforced by Rust kernel — dead config value
**File:** `launcher.py:140-144` (read and stored), `_rust_kernel/src/lib.rs` (never checked)
```python
# launcher.py: stored in control snapshot
raw = os.environ.get("DITA_V2_ACTIVE_SLOT_LIMIT")
if raw is not None:
fields["active_slot_limit"] = max(1, int(str(raw).strip()))
```
The env var is read, parsed, clamped, and stored into the control plane's `ControlSnapshot`. But the Rust kernel allocates `max_slots` slots at construction (from `ExecutionKernel.__init__`'s `max_slots` parameter) and **never reads** the `active_slot_limit` from the control snapshot.
The `active_slot_limit` field is written to projections (`hazelcast_projection.py:41` writes `control.as_dict()` which includes `active_slot_limit`) and visible in the control plane state, but the Rust kernel never limits slot usage based on it. An algorithm could send ENTER intents to any slot up to `max_slots` regardless of the configured limit.
**Severity: High**
### W7: No fill/trade history fetched during WS reconnect gap-backfill — fills during disconnect window permanently lost
**File:** `bingx_user_stream.py:117-121`
```python
try:
snap = await self.account_snapshot()
yield snap
except Exception as exc:
log.warning("bingx_user_stream: gap-backfill REST failed: %s", exc)
```
`account_snapshot()` (lines 153-219) fetches:
- `GET /openApi/swap/v3/user/balance` — wallet balance, available margin
- `GET /openApi/swap/v2/user/positions` — open positions with entry price, qty
It does **NOT** fetch:
- `GET /openApi/swap/v2/trade/fill/history` — fill history during the gap
If a LIMIT order filled during the reconnect window (e.g., a resting limit order that was placed before disconnect and filled while the WS was down), the fill event is permanently lost. The balance snapshot shows the *result* (changed wallet balance), but no fill event with `price`, `qty`, `fee`, `realized_pnl` is emitted. The kernel processes only an `ACCOUNT_UPDATE`, missing the individual fill details.
Additionally, funding fee events accrued during the disconnect are invisible. The balance reflects them, but no `FUNDING_FEE` event arrives. The kernel's `k_funding_net` drifts until the next explicit funding event.
**Severity: High**
### W8: `BingxVenueAdapter` rate limit detection fails on HTTP 429 without matching message body — `_rate_limit_retry_after_ms` returns 0, instant retry
**File:** `bingx_venue.py:169-183`
The rate limit detection has three paths:
1. **Response header** (`retryAfter`, `retry_after_ms`, `retryAfterMs`) — extracted from response dict
2. **Error message regex** — `re.search(r"unblocked after (\d+)", msg)` on the exchange error text
3. **Return 0** — everything else
If BingX returns HTTP 429 with a message body that doesn't contain the phrase `"unblocked after"` (e.g., a generic `"too many requests"` or a localized message), the regex misses and returns 0. The caller then retries **immediately**, burning more rate limit quota.
The `BingxHttpError` catch at line 316-317 catches all HTTP errors (including 429) and converts them to `{"status": "REJECTED", ...}` — the `REJECTED` tag prevents the kernel from recognizing it as `RATE_LIMITED`. The rate-limit detection is entirely dependent on the error message body format, not the HTTP status code.
**Fix:** Check for HTTP 429 status code first, then fall back to message parsing.
**Severity: High**
### W9: `DITA_V2_CONTROL_PLANE=REAL_ZINC` silently falls back to in-memory on any exception — operator thinks they have persistence but don't
**File:** `control.py:205-212`
```python
if env_choice in {"REAL", "REAL_ZINC", "SHARED", "SHARED_MEM"}:
try:
from .real_control_plane import RealZincControlPlane
plane = RealZincControlPlane(prefix=prefix, create=True)
except Exception:
pass # <-- silent fallback, no log
return ZincControlPlane(snapshot=snapshot) # in-memory fallback
```
If `RealZincControlPlane()` raises (Zinc library not installed, shared memory creation fails, permission denied), the exception is silently swallowed and the function returns an `InMemoryControlPlane`. No log, no warning, no diagnostic. The operator configured persistent shared-memory control plane but gets ephemeral in-memory.
Compare with `build_launcher_bundle()`'s `_build_zinc_plane()` (launcher.py:122-125) which also silently falls back — same pattern.
**Severity: High**
### W10: `BingxVenueAdapter` HTTP error handling maps ALL `BingxHttpError` to `"REJECTED"` — cannot distinguish "order not found" from "exchange is down"
**File:** `bingx_venue.py:316-317`
```python
except BingxHttpError as exc:
response = {"status": "REJECTED", "msg": str(exc), ...}
```
Every `BingxHttpError` is mapped to `status="REJECTED"`. A 500 Internal Server Error, a 403 forbidden, a 404 order-not-found — all become "REJECTED". The Rust kernel treats "REJECTED" as a specific FSM signal. It cannot distinguish "this cancel was rejected because the order doesn't exist" (harmless, order already cancelled) from "the exchange is returning 500 errors" (system-wide failure, should halt trading).
**Impact:** If BingX has a transient 500 error, every submit/cancel in that window returns "REJECTED". The kernel may interpret this as genuine order rejections and transition the FSM to CLOSED or trigger cancels, even though the orders may have actually gone through.
**Severity: High**
### W11: `DOLPHIN_BINGX_API_KEY` accessed via bracket `os.environ["BINGX_API_KEY"]` in generated tests — `KeyError` crash if unset; inconsistent with launcher's `.get()` which silently passes `None`
**Files:** `gen2.py:320`, `gen_live_tests.py:116-117`, `_gen_test.py:60`
```python
# Generated test code (all three generators):
BINGX_API_KEY = os.environ["BINGX_API_KEY"] # bracket access — KeyError if unset
BINGX_SECRET_KEY = os.environ["BINGX_SECRET_KEY]"
```
```python
# launcher.py:195-196 — different access pattern:
api_key=os.environ.get("BINGX_API_KEY"), # .get() — returns None if unset
secret_key=os.environ.get("BINGX_SECRET_KEY"),
```
The test generators use **bracket access** (`os.environ["KEY"]`) which raises `KeyError` instantly if the env var is missing. The launcher uses **`.get()` access** (`os.environ.get("KEY")`) which silently returns `None`.
This means:
- Running generated tests without env vars → `KeyError` crash at module import time
- Running the launcher without env vars → `None` silently passed to `BingxExecClientConfig` → delay failure until first HTTP call (confusing 401)
Two different failure modes for the same missing configuration. The launcher should validate `None` immediately.
**Severity: High**
### W12: `MockVenueScenario` has no `rate_limit` flag — entire RATE_LIMITED code path untested in CI
**File:** `mock_venue.py:27-35`
```python
@dataclass
class MockVenueScenario:
reject_entries: bool = False
reject_exits: bool = False
reject_cancels: bool = False
all_fills_partial: bool = False
# NOTE: no rate_limit field
```
The `MockVenueScenario` dataclass has flags for rejection and partial fill simulation but **no `rate_limit` flag**. The entire `RATE_LIMITED` code path — in the Python adapter (`bingx_venue.py:384-396` detects retry-after, tags event as RATE_LIMITED) and in the Rust kernel (the FSM match arm for `KernelEventKind::RATE_LIMITED`) — has zero simulation in mock venue tests.
Only a live BingX connection can trigger the rate-limit path, and the live tests are triple env-gated (V5) and never run in CI.
**Severity: Medium**
### W13: `_rate_limit_retry_after_ms` regex uses English phrase `"unblocked after"` — non-portable, fails on localized exchange messages
**File:** `bingx_venue.py:177`
```python
m = re.search(r"unblocked after (\d+)", msg)
```
The regex relies on the English phrase `"unblocked after"` in the exchange's error message. BingX is a Chinese exchange. If the error response is localized to Chinese (e.g., `"解封后(\d+)毫秒"`), or if BingX changes their English wording, the regex silently misses and returns 0 (instant retry).
**Fix:** Prioritize the `Retry-After` HTTP response header or JSON field `retryAfter`/`retry_after_ms` over parsing the error message body.
**Severity: Medium**
### W14: `DITA_V2_ACTIVE_SLOT_LIMIT` invalid values silently discarded with no logging — operator sets `"abc"`, gets default with no warning
**File:** `launcher.py:140-144`
```python
raw = os.environ.get("DITA_V2_ACTIVE_SLOT_LIMIT")
if raw is not None:
try:
fields["active_slot_limit"] = max(1, int(str(raw).strip()))
except Exception:
pass # no log, no warning
```
If the operator sets `DITA_V2_ACTIVE_SLOT_LIMIT=abc`, the `int()` raises `ValueError`, the `except` swallows it, and the field is never written to `fields`. The slot limit silently uses the control plane default (10). No log, no warning, no error — the operator thinks they configured a limit but the config was silently ignored.
**Severity: Medium**
---
## Pass 20 Summary
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| W1 | `int()` on 3 env vars uncaught `ValueError` — non-numeric input crashes process | Config | **Critical** |
| W2 | `DITA_V2_PREFIX` default `"dita_v2"` — multi-process shared memory corruption | Config | **Critical** |
| W3 | Funding sign opposite Python V2 vs Rust — same raw value opposite capital effect | Accounting | **Critical** |
| W4 | `listenKeyExpired` frames silently swallowed — `continue` skips expiry check, dead code | Venue | **Critical** |
| W5 | `RECV_WINDOW_MS` no upper bound — extreme values enable replay attacks | Config | **High** |
| W6 | `ACTIVE_SLOT_LIMIT` stored but never enforced by Rust kernel — dead config | Config | **High** |
| W7 | No fill history fetched during WS reconnect gap-backfill — fills permanently lost | Venue | **High** |
| W8 | Rate limit detection fails on HTTP 429 without matching message — returns 0 instant retry | Venue | **High** |
| W9 | `CONTROL_PLANE=REAL_ZINC` silently falls back to in-memory — no persistence | Config | **High** |
| W10 | All `BingxHttpError` mapped to "REJECTED" — can't distinguish errors from real rejections | Venue | **High** |
| W11 | `os.environ["KEY"]` bracket access in tests vs `.get()` in launcher — inconsistent | Test | **High** |
| W12 | `MockVenueScenario` no `rate_limit` flag — RATE_LIMITED path untested in CI | Test | Medium |
| W13 | Rate-limit regex uses English phrase `"unblocked after"` — non-portable | Venue | Medium |
| W14 | Invalid `ACTIVE_SLOT_LIMIT` values silently discarded — no log, no warning | Config | Medium |
### Pass 20 Severity
| Severity | Count |
|----------|-------|
| **Critical** | 4 (W1, W2, W3, W4) |
| **High** | 7 (W5, W6, W7, W8, W9, W10, W11) |
| Medium | 3 (W12, W13, W14) |
### Combined Catalog (All 20 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 |
| M | Pass 10 (Runtime/TestBugs/FSM/Persistence/Metrics) | 18 | 3 | 7 | 5 | 3 | 0 |
| N | Pass 11 (Async/Sync Seams/Locks/Threading) | 10 | 4 | 1 | 3 | 1 | 1 |
| O | Pass 12 (Sync/Async Wider Scope) | 11 | 0 | 3 | 7 | 1 | 0 |
| P | Pass 13 (FFI Safety/Dangling Pointers/Coverage) | 9 | 1 | 3 | 3 | 1 | 1 |
| Q | Pass 14 (Serde Edges/Backup Diffs/Market Data) | 12 | 0 | 4 | 3 | 2 | 3 |
| R | Pass 15 (Resource Leaks/Trust Boundaries/Security) | 14 | 2 | 6 | 3 | 2 | 1 |
| S | Pass 16 (Error Handling/Arithmetic/Test Infra) | 16 | 4 | 7 | 5 | 0 | 0 |
| T | Pass 17 (Unsafe Review/Dead Code/Build/Protocols) | 14 | 0 | 5 | 5 | 4 | 0 |
| U | Pass 18 (Rust Test Gaps/Accounting/FFI Types) | 14 | 3 | 4 | 4 | 3 | 0 |
| V | Pass 19 (Lifecycle/Rust Subtleties/Test Infra) | 14 | 5 | 2 | 4 | 3 | 0 |
| W | Pass 20 (Config/Math Signs/BingX Protocol) | 14 | 4 | 7 | 3 | 0 | 0 |
| **Total** | | **361** | **39** | **108** | **103** | **64** | **37** |

View File

@@ -1,7 +1,7 @@
# PINK DITAv2 — Structural Flaw Analysis (CENTRAL)
**Analysis date:** 2026-05-31
**Last updated:** 2026-06-02 (flaw fix pass — 8 flaws closed)
**Last updated:** 2026-06-02 (flaw fix pass 2 — 5 more flaws closed; 13 total)
**Scope:** Full PINK pipeline — all flaws across all modules.
> **Fix notation:** Rows marked **✅ FIXED `<sha>`** are verified-fixed with a test commit on branch `exp/pink-ditav2-sprint0-20260530`.
@@ -53,7 +53,8 @@
| T | Pass 17 (Unsafe Review/Dead Code/Build/Protocols) | 14 | 0 | 5 | 5 | 4 | 0 |
| U | Pass 18 (Rust Test Gaps/Accounting/FFI Types) | 14 | 3 | 4 | 4 | 3 | 0 |
| V | Pass 19 (Lifecycle/Rust Subtleties/Test Infra) | 14 | 5 | 2 | 4 | 3 | 0 |
| **Total** | | **347** | **35** | **101** | **100** | **64** | **37** |
| W | Pass 20 (Config/Math Signs/BingX Protocol) | 14 | 4 | 7 | 3 | 0 | 0 |
| **Total** | | **361** | **39** | **108** | **103** | **64** | **37** |
---
@@ -156,14 +157,14 @@
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| G1 | EXIT_RESIDUAL action missing from Rust KernelCommandType enum | Rust | **Critical** |
| G2 | `into_c_string` unwrap() panics on NUL byte in FFI string | Rust | **Critical** |
| G3 | EXIT hardcodes prev_state=POSITION_OPEN — allows backward FSM transition | Rust | **Critical** |
| G4 | `consume_exit_leg` stale `all_legs_done` variable — wrong branch after last leg | Rust | **Critical** |
| G2 | `into_c_string` unwrap() panics on NUL byte in FFI string**✅ FIXED `c87ca78`** | Rust | **Critical** |
| G3 | EXIT hardcodes prev_state=POSITION_OPEN — allows backward FSM transition**✅ FIXED `c87ca78`** | Rust | **Critical** |
| G4 | `consume_exit_leg` stale `all_legs_done` variable — wrong branch after last leg**✅ FIXED `fb03300`** | Rust | **Critical** |
| G5 | `realized_pnl` unbounded f64 — overflows to inf at extreme values | Rust | **High** |
| G6 | `mark_price` produces unbounded unrealized_pnl — no result guard | Rust | **High** |
| G7 | ENTER no is_finite() guard on target_size | Rust | **High** |
| G8 | `reconcile_slots_json` no dedup or bounds validation | Rust | **High** |
| G9 | `exchange_order_id` update targets wrong order — exit cancel broken | Rust | **High** |
| G9 | `exchange_order_id` update targets wrong order — exit cancel broken**✅ FIXED `fb03300`** | Rust | **High** |
| G10 | CANCEL diagnostic always says NO_ACTIVE_EXIT_ORDER | Rust | **High** |
| G11 | `apply_fill` overwrites intended_size with slot.size | Rust | Medium |
| G12 | No max leverage cap enforced by kernel | Rust | Medium |
@@ -321,7 +322,7 @@
| M6 | test_dedup tests use wrong constant (actual=256, claim 64) — 70 events insufficient | Test | Medium |
| M7 | test_outcome_state_matches_actual_slot is tautological | Test | Low |
| M8 | ORDER_ACK silent fallthrough when no active order — accepted with no effect | Rust | Medium |
| M9 | ORDER_REJECT on POSITION_OPEN with stale entry order destroys position | Rust | **Critical** |
| M9 | ORDER_REJECT on POSITION_OPEN with stale entry order destroys position**✅ FIXED `fb03300`** | Rust | **Critical** |
| M10 | No aggregation of trade count, success/fail, latency — all zero | All | **High** |
| M11 | Flaw 6 tests pass via metadata passthrough, not field logic | Test | **High** |
| M12 | No retry/fallback for ClickHouse INSERT failures — crashes policy cycle | Persistence | **High** |
@@ -341,6 +342,17 @@
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| N1 | Rust kernel `with_handle_mut` zero sync — `&mut` from raw ptr, UB on concurrent FFI — *mitigated by Python GIL (single-threaded caller); catch_unwind added `c87ca78`* | Rust | **Critical** |
### Fixes applied (2026-06-02 pass 2)
| Flaw | Commit | What changed |
|------|--------|--------------|
| G2 — `into_c_string` NUL panic | `c87ca78` | NUL bytes stripped/escaped; `CString::new` error path handled |
| G3 — EXIT backward FSM transition | `c87ca78` | EXIT no longer hardcodes `prev_state=POSITION_OPEN`; uses real slot state |
| G4 — stale `all_legs_done` in exit leg | `fb03300` | Renamed pre-consume var; else-if guard reads fresh `active_leg_index` after consume |
| M9 — ORDER_REJECT nukes POSITION_OPEN | `fb03300` | Spurious reject (no matching order) no longer resets fsm_state; only entry-phase rejects → IDLE |
| G9 — venue_order_id targets wrong order | `fb03300` | Routes by FSM state: exit-phase events update exit order, not stale entry order |
| H6 — unknown enum variant crashes bridge | `fb03300` | `_safe_enum()` helper returns configurable default on unknown variants |
| N2 | `_run()` has two completely different code paths — runtime branch, not design | Venue | **Critical** |
| N3 | `_run()` path B blocks event loop thread for every venue HTTP operation | Venue | **Critical** |
| N4 | `asyncio.run()` called repeatedly — creates/destroys event loops per call | Venue | **Critical** |
@@ -529,6 +541,29 @@
---
## W-Series: Configuration Management, Math Sign Conventions, BingX Protocol (Pass 20)
*Full detail in TRACE doc under "PASS 20 — CONFIGURATION MANAGEMENT, MATH SIGN CONVENTIONS, BINGX PROTOCOL."*
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| W1 | `int()` on 3 env vars uncaught `ValueError` non-numeric input crashes process | Config | **Critical** |
| W2 | `DITA_V2_PREFIX` default `"dita_v2"` multi-process shared memory corruption | Config | **Critical** |
| W3 | Funding sign opposite Python V2 vs Rust same raw value opposite capital effect | Accounting | **Critical** |
| W4 | `listenKeyExpired` frames silently swallowed `continue` skips expiry check, dead code | Venue | **Critical** |
| W5 | `RECV_WINDOW_MS` no upper bound extreme values enable replay attacks | Config | **High** |
| W6 | `ACTIVE_SLOT_LIMIT` stored but never enforced by Rust kernel dead config | Config | **High** |
| W7 | No fill history fetched during WS reconnect gap-backfill fills permanently lost | Venue | **High** |
| W8 | Rate limit detection fails on HTTP 429 without matching message returns 0 instant retry | Venue | **High** |
| W9 | `CONTROL_PLANE=REAL_ZINC` silently falls back to in-memory no persistence | Config | **High** |
| W10 | All `BingxHttpError` mapped to "REJECTED" can't distinguish errors from real rejections | Venue | **High** |
| W11 | `os.environ["KEY"]` bracket access in tests vs `.get()` in launcher inconsistent | Test | **High** |
| W12 | `MockVenueScenario` no `rate_limit` flag RATE_LIMITED path untested in CI | Test | Medium |
| W13 | Rate-limit regex uses English phrase `"unblocked after"` non-portable | Venue | Medium |
| W14 | Invalid `ACTIVE_SLOT_LIMIT` values silently discarded no log, no warning | Config | Medium |
---
## H-Series: Edge Domains — Dependencies, Error Handling, Types, Contracts (Pass 5)
*Full detail in TRACE doc under "PASS 5 — EDGE DOMAINS."*
@@ -540,7 +575,7 @@
| H3 | Zero logging 16+ silent except:pass sites, no error observability | All | **Critical** |
| H4 | `_row_float` rejects zero as valid, `except Exception: continue` swallows all | Venue | **High** |
| H5 | `_backend_snapshot` timeout returns stale data/None callers crash | Venue | **High** |
| H6 | All enum-from-raw-string sites crash on unknown variant (17 sites) | Bridge | **High** |
| H6 | All enum-from-raw-string sites crash on unknown variant (17 sites) ** FIXED `fb03300`** (Python bridge sites) | Bridge | **High** |
| H7 | `_legacy_intent` reads `getattr(intent, "order_type")` not metadata always MARKET | Venue | **High** |
| H8 | Unknown venue status silently mapped to ACKED | Venue | **High** |
| H9 | `RealZincPlane.write_slot()` `slot_id >= slot_count` silently lost | Zinc | **High** |

View File

@@ -1,7 +1,7 @@
# PINK DITAv2 — Structural Flaw Analysis (CENTRAL)
**Analysis date:** 2026-05-31
**Last updated:** 2026-06-02 (flaw fix pass — 8 flaws closed)
**Last updated:** 2026-06-02 (flaw fix pass 2 — 5 more flaws closed; 13 total)
**Scope:** Full PINK pipeline — all flaws across all modules.
> **Fix notation:** Rows marked **✅ FIXED `<sha>`** are verified-fixed with a test commit on branch `exp/pink-ditav2-sprint0-20260530`.
@@ -52,7 +52,8 @@
| S | Pass 16 (Error Handling/Arithmetic/Test Infra) | 16 | 4 | 7 | 5 | 0 | 0 |
| T | Pass 17 (Unsafe Review/Dead Code/Build/Protocols) | 14 | 0 | 5 | 5 | 4 | 0 |
| U | Pass 18 (Rust Test Gaps/Accounting/FFI Types) | 14 | 3 | 4 | 4 | 3 | 0 |
| **Total** | | **333** | **30** | **99** | **96** | **64** | **37** |
| V | Pass 19 (Lifecycle/Rust Subtleties/Test Infra) | 14 | 5 | 2 | 4 | 3 | 0 |
| **Total** | | **347** | **35** | **101** | **100** | **64** | **37** |
---
@@ -155,14 +156,14 @@
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| G1 | EXIT_RESIDUAL action missing from Rust KernelCommandType enum | Rust | **Critical** |
| G2 | `into_c_string` unwrap() panics on NUL byte in FFI string | Rust | **Critical** |
| G3 | EXIT hardcodes prev_state=POSITION_OPEN — allows backward FSM transition | Rust | **Critical** |
| G4 | `consume_exit_leg` stale `all_legs_done` variable — wrong branch after last leg | Rust | **Critical** |
| G2 | `into_c_string` unwrap() panics on NUL byte in FFI string**✅ FIXED `c87ca78`** | Rust | **Critical** |
| G3 | EXIT hardcodes prev_state=POSITION_OPEN — allows backward FSM transition**✅ FIXED `c87ca78`** | Rust | **Critical** |
| G4 | `consume_exit_leg` stale `all_legs_done` variable — wrong branch after last leg**✅ FIXED `fb03300`** | Rust | **Critical** |
| G5 | `realized_pnl` unbounded f64 — overflows to inf at extreme values | Rust | **High** |
| G6 | `mark_price` produces unbounded unrealized_pnl — no result guard | Rust | **High** |
| G7 | ENTER no is_finite() guard on target_size | Rust | **High** |
| G8 | `reconcile_slots_json` no dedup or bounds validation | Rust | **High** |
| G9 | `exchange_order_id` update targets wrong order — exit cancel broken | Rust | **High** |
| G9 | `exchange_order_id` update targets wrong order — exit cancel broken**✅ FIXED `fb03300`** | Rust | **High** |
| G10 | CANCEL diagnostic always says NO_ACTIVE_EXIT_ORDER | Rust | **High** |
| G11 | `apply_fill` overwrites intended_size with slot.size | Rust | Medium |
| G12 | No max leverage cap enforced by kernel | Rust | Medium |
@@ -320,7 +321,7 @@
| M6 | test_dedup tests use wrong constant (actual=256, claim 64) — 70 events insufficient | Test | Medium |
| M7 | test_outcome_state_matches_actual_slot is tautological | Test | Low |
| M8 | ORDER_ACK silent fallthrough when no active order — accepted with no effect | Rust | Medium |
| M9 | ORDER_REJECT on POSITION_OPEN with stale entry order destroys position | Rust | **Critical** |
| M9 | ORDER_REJECT on POSITION_OPEN with stale entry order destroys position**✅ FIXED `fb03300`** | Rust | **Critical** |
| M10 | No aggregation of trade count, success/fail, latency — all zero | All | **High** |
| M11 | Flaw 6 tests pass via metadata passthrough, not field logic | Test | **High** |
| M12 | No retry/fallback for ClickHouse INSERT failures — crashes policy cycle | Persistence | **High** |
@@ -340,6 +341,17 @@
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| N1 | Rust kernel `with_handle_mut` zero sync — `&mut` from raw ptr, UB on concurrent FFI — *mitigated by Python GIL (single-threaded caller); catch_unwind added `c87ca78`* | Rust | **Critical** |
### Fixes applied (2026-06-02 pass 2)
| Flaw | Commit | What changed |
|------|--------|--------------|
| G2 — `into_c_string` NUL panic | `c87ca78` | NUL bytes stripped/escaped; `CString::new` error path handled |
| G3 — EXIT backward FSM transition | `c87ca78` | EXIT no longer hardcodes `prev_state=POSITION_OPEN`; uses real slot state |
| G4 — stale `all_legs_done` in exit leg | `fb03300` | Renamed pre-consume var; else-if guard reads fresh `active_leg_index` after consume |
| M9 — ORDER_REJECT nukes POSITION_OPEN | `fb03300` | Spurious reject (no matching order) no longer resets fsm_state; only entry-phase rejects → IDLE |
| G9 — venue_order_id targets wrong order | `fb03300` | Routes by FSM state: exit-phase events update exit order, not stale entry order |
| H6 — unknown enum variant crashes bridge | `fb03300` | `_safe_enum()` helper returns configurable default on unknown variants |
| N2 | `_run()` has two completely different code paths — runtime branch, not design | Venue | **Critical** |
| N3 | `_run()` path B blocks event loop thread for every venue HTTP operation | Venue | **Critical** |
| N4 | `asyncio.run()` called repeatedly — creates/destroys event loops per call | Venue | **Critical** |
@@ -505,6 +517,29 @@
---
## V-Series: Startup/Shutdown Lifecycle, Rust Kernel Subtleties, Generated Test Infra (Pass 19)
*Full detail in TRACE doc under "PASS 19 — STARTUP/SHUTDOWN LIFECYCLE, RUST KERNEL SUBTLETIES, GENERATED TEST INFRA."*
| # | Flaw | Layer | Severity |
|---|------|-------|----------|
| V1 | `DITAv2LauncherBundle.close()` never calls `kernel.close()` Rust handle leaks via `__del__` | Launcher | **Critical** |
| V2 | `BingxVenueAdapter` no `close()`/`disconnect()` ThreadPoolExecutor/HTTP never release | Venue | **Critical** |
| V3 | `process_intent` ENTER doesn't clear `seen_event_ids` old dedup pollutes new trade | Rust | **High** |
| V4 | 3 generators write same output file last writer wins, incompatible prologues | Test | **Critical** |
| V5 | Generated tests triple env-gated never run in CI, dead code | Test | **Critical** |
| V6 | `kernel.close()` destroys Rust handle immediately no drain, no flush, UAF risk | Bridge | **Critical** |
| V7 | `_last_settled_pnl` dict accessed from process_intent and on_venue_event without locks | Bridge | Medium |
| V8 | `#[serde(default)] leverage: f64` default 0.0 mark_price uses directly no .max(1.0) | Rust | Medium |
| V9 | No `conftest.py`, no `pytest.ini`, no `asyncio_mode` test discovery fragile | Test | **High** |
| V10 | `kernel.close()` `except Exception: pass` silently swallows destroy errors | Bridge | Low |
| V11 | `build_launcher_bundle()` no cleanup on partial failure OOM orphans 4 components | Launcher | Medium |
| V12 | `KernelResult` clones entire kernel state every FFI call wasted allocations | Rust | Medium |
| V13 | `_build_rb()` leaks bundle on post-creation failure | Test | Low |
| V14 | `_maybe_close` breaks after first method never tries both close and disconnect | Launcher | Low |
---
## H-Series: Edge Domains — Dependencies, Error Handling, Types, Contracts (Pass 5)
*Full detail in TRACE doc under "PASS 5 — EDGE DOMAINS."*
@@ -516,7 +551,7 @@
| H3 | Zero logging 16+ silent except:pass sites, no error observability | All | **Critical** |
| H4 | `_row_float` rejects zero as valid, `except Exception: continue` swallows all | Venue | **High** |
| H5 | `_backend_snapshot` timeout returns stale data/None callers crash | Venue | **High** |
| H6 | All enum-from-raw-string sites crash on unknown variant (17 sites) | Bridge | **High** |
| H6 | All enum-from-raw-string sites crash on unknown variant (17 sites) ** FIXED `fb03300`** (Python bridge sites) | Bridge | **High** |
| H7 | `_legacy_intent` reads `getattr(intent, "order_type")` not metadata always MARKET | Venue | **High** |
| H8 | Unknown venue status silently mapped to ACKED | Venue | **High** |
| H9 | `RealZincPlane.write_slot()` `slot_id >= slot_count` silently lost | Zinc | **High** |

View File

@@ -1638,10 +1638,20 @@ impl KernelCore {
// harmless for MARKET, which fills synchronously). Only fills empty ids
// (never overwrites) and targets the currently-active order.
if !event.venue_order_id.is_empty() {
let target = if slot.active_entry_order.is_some() {
slot.active_entry_order.as_mut()
} else {
// G9: route by FSM state so an exit-phase event updates the exit
// order, not the still-present entry order reference. Preferring
// active_entry_order when in an exit state left active_exit_order
// with an empty venue_order_id, breaking LIMIT-order cancel.
let in_exit_phase = matches!(
slot.fsm_state,
TradeStage::EXIT_REQUESTED
| TradeStage::EXIT_SENT
| TradeStage::EXIT_WORKING
);
let target = if in_exit_phase {
slot.active_exit_order.as_mut()
} else {
slot.active_entry_order.as_mut()
};
if let Some(order) = target {
if order.venue_order_id.is_empty() {
@@ -1690,6 +1700,7 @@ impl KernelCore {
}
KernelEventKind::ORDER_REJECT => {
if slot.active_entry_order.is_some() && slot.fsm_state != TradeStage::POSITION_OPEN {
// Entry-order reject while no position is open: reset to IDLE.
slot.active_entry_order = None;
slot.trade_id.clear();
slot.asset.clear();
@@ -1705,11 +1716,15 @@ impl KernelCore {
slot.fsm_state = TradeStage::IDLE;
diagnostic_code = KernelDiagnosticCode::ENTRY_ORDER_REJECTED;
} else if slot.active_exit_order.is_some() {
// Exit-order reject: preserve the position, clear stale exit ref.
slot.active_exit_order = None;
slot.fsm_state = TradeStage::POSITION_OPEN;
diagnostic_code = KernelDiagnosticCode::EXIT_ORDER_REJECTED;
} else {
slot.fsm_state = TradeStage::IDLE;
// M9: spurious / late reject — no matching order, do NOT reset
// state. A stale reject (e.g. BingX echoing a previous lifetime)
// must never nuke an open POSITION_OPEN slot.
accepted = false;
diagnostic_code = KernelDiagnosticCode::ORDER_REJECTED;
}
}
@@ -1957,8 +1972,10 @@ impl KernelCore {
slot.mark_price(event.price);
slot.last_event_time = Some(event.timestamp);
let all_legs_done = slot.active_leg_index >= slot.exit_leg_ratios.len();
let should_close = slot.size <= 1e-12 || (!partial && all_legs_done);
// G4: compute all_legs_done BEFORE consume so should_close is
// correct, then recompute AFTER for the else-if guard below.
let all_legs_done_pre = slot.active_leg_index >= slot.exit_leg_ratios.len();
let should_close = slot.size <= 1e-12 || (!partial && all_legs_done_pre);
if !partial {
slot.consume_exit_leg();
@@ -1974,7 +1991,9 @@ impl KernelCore {
slot.fsm_state = TradeStage::CLOSED;
slot.active_exit_order = None;
slot.active_entry_order = None;
} else if !partial && !all_legs_done {
} else if !partial && slot.active_leg_index < slot.exit_leg_ratios.len() {
// G4: use fresh active_leg_index (after consume_exit_leg) so the
// last-leg FULL_FILL never spuriously starts a phantom extra leg.
slot.fsm_state = TradeStage::POSITION_OPEN;
slot.active_exit_order = None;
} else if partial {

View File

@@ -279,6 +279,14 @@ def _get_rust() -> _RustKernelLib:
return _RUST
def _safe_enum(enum_cls, raw: str, default):
"""H6: parse enum from FFI string without crashing on unknown variants."""
try:
return enum_cls(raw)
except (ValueError, KeyError):
return default
def _slot_to_payload(slot: TradeSlot) -> Dict[str, Any]:
return slot.to_dict()
@@ -333,7 +341,7 @@ def _slot_from_payload(payload: Dict[str, Any]) -> TradeSlot:
active_leg_index=int(payload.get("active_leg_index", 0)),
active_exit_order=_order_from_payload(payload.get("active_exit_order"), trade_id=str(payload.get("trade_id", ""))),
active_entry_order=_order_from_payload(payload.get("active_entry_order"), trade_id=str(payload.get("trade_id", ""))),
fsm_state=TradeStage(str(payload.get("fsm_state", TradeStage.IDLE.value))),
fsm_state=_safe_enum(TradeStage, str(payload.get("fsm_state", TradeStage.IDLE.value)), TradeStage.IDLE),
close_reason=str(payload.get("close_reason", "")),
last_event_time=datetime.fromisoformat(payload["last_event_time"]) if payload.get("last_event_time") else None,
seen_event_ids=tuple(str(event_id) for event_id in payload.get("seen_event_ids", ())),
@@ -412,8 +420,8 @@ def _transition_from_payload(payload: Dict[str, Any]) -> KernelTransition:
timestamp=datetime.fromisoformat(payload["timestamp"]),
trade_id=str(payload.get("trade_id", "")),
slot_id=int(payload.get("slot_id", 0)),
prev_state=TradeStage(str(payload.get("prev_state", TradeStage.IDLE.value))),
next_state=TradeStage(str(payload.get("next_state", TradeStage.IDLE.value))),
prev_state=_safe_enum(TradeStage, str(payload.get("prev_state", TradeStage.IDLE.value)), TradeStage.IDLE),
next_state=_safe_enum(TradeStage, str(payload.get("next_state", TradeStage.IDLE.value)), TradeStage.IDLE),
trigger=str(payload.get("trigger", "")),
intent_id=str(payload.get("intent_id", "")),
event_id=str(payload.get("event_id", "")),
@@ -428,7 +436,7 @@ def _outcome_from_payload(payload: Dict[str, Any]) -> KernelOutcome:
accepted=bool(payload.get("accepted", False)),
slot_id=int(payload.get("slot_id", 0)),
trade_id=str(payload.get("trade_id", "")),
state=TradeStage(str(payload.get("state", TradeStage.IDLE.value))),
state=_safe_enum(TradeStage, str(payload.get("state", TradeStage.IDLE.value)), TradeStage.IDLE),
diagnostic_code=KernelDiagnosticCode(str(payload.get("diagnostic_code", KernelDiagnosticCode.OK.value))),
severity=KernelSeverity(str(payload.get("severity", KernelSeverity.INFO.value))),
transitions=tuple(_transition_from_payload(row) for row in payload.get("transitions", [])),

View File

@@ -968,3 +968,153 @@ class TestO1MaybeCloseAsyncSafe:
_maybe_close(_FakeSync())
assert closed == [True], "sync close() must still be called"
# ============================================================
# M9: ORDER_REJECT must NOT nuke a live POSITION_OPEN slot
# ============================================================
class TestM9OrderRejectPositionOpen:
"""M9: A spurious ORDER_REJECT arriving while the slot is POSITION_OPEN
must not reset it to IDLE. Only entry-phase rejects should reset."""
def _open_position(self, k: ExecutionKernel, trade_id: str) -> None:
r = k.process_intent(_mk_intent(action=E.ENTER, trade_id=trade_id))
assert r.accepted, f"ENTER failed: {r.diagnostic_code}"
assert k._get_slot(0).fsm_state == TradeStage.POSITION_OPEN
def test_spurious_reject_does_not_reset_position_open(self):
"""A stale ORDER_REJECT with no matching active order must not nuke slot."""
k = _fresh_kernel()
self._open_position(k, "m9a")
reject = _mk_venue_event(
kind=KernelEventKind.ORDER_REJECT,
trade_id="m9a",
event_id="stale-reject-m9a",
status=VenueEventStatus.REJECTED,
)
result = k.on_venue_event(reject)
slot = k._get_slot(0)
assert slot.fsm_state == TradeStage.POSITION_OPEN, (
f"M9: spurious ORDER_REJECT must not reset POSITION_OPEN → got {slot.fsm_state}"
)
assert not result.accepted, "Spurious reject must be reported as not accepted"
def test_entry_reject_still_resets_to_idle(self):
"""Entry-phase ORDER_REJECT must still reset to IDLE (regression)."""
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
k.process_intent(_mk_intent(action=E.ENTER, trade_id="m9b"))
slot = k._get_slot(0)
assert slot.active_entry_order is not None
reject = _mk_venue_event(
kind=KernelEventKind.ORDER_REJECT,
trade_id="m9b",
event_id="entry-reject-m9b",
status=VenueEventStatus.REJECTED,
)
k.on_venue_event(reject)
assert k._get_slot(0).fsm_state == TradeStage.IDLE, (
"Entry-phase ORDER_REJECT must still reset slot to IDLE"
)
# ============================================================
# G4: exit multi-leg — no phantom extra leg after last fill
# ============================================================
class TestG4ExitLegOrdering:
"""G4: all_legs_done was computed before consume_exit_leg(), causing a
phantom 3rd-leg attempt on the final leg's FULL_FILL when size > 1e-12."""
def test_two_leg_exit_closes_cleanly(self):
"""A 2-leg exit must close the slot without a phantom extra leg."""
k = _fresh_kernel()
k.process_intent(_mk_intent(action=E.ENTER, trade_id="g4a", size=2.0,
exit_leg_ratios=(0.5, 0.5)))
slot = k._get_slot(0)
assert slot.fsm_state == TradeStage.POSITION_OPEN
# Leg 1 EXIT
k.process_intent(_mk_intent(action=E.EXIT, trade_id="g4a", size=1.0,
exit_leg_ratios=(0.5, 0.5)))
slot = k._get_slot(0)
assert slot.fsm_state in (TradeStage.POSITION_OPEN, TradeStage.EXIT_WORKING), (
f"After leg-1 fill, slot must be POSITION_OPEN or EXIT_WORKING, got {slot.fsm_state}"
)
# Leg 2 EXIT
k.process_intent(_mk_intent(action=E.EXIT, trade_id="g4a", size=1.0,
exit_leg_ratios=(0.5, 0.5)))
slot = k._get_slot(0)
assert slot.is_free(), (
f"G4: 2-leg exit must fully close slot, got {slot.fsm_state}"
)
def test_single_leg_exit_unaffected(self):
"""Single-leg exit (the common case) must still work correctly."""
k = _fresh_kernel()
k.process_intent(_mk_intent(action=E.ENTER, trade_id="g4b"))
k.process_intent(_mk_intent(action=E.EXIT, trade_id="g4b"))
assert k._get_slot(0).is_free(), "Single-leg exit must close slot"
# ============================================================
# G9: venue_order_id routed to exit order in exit phase
# ============================================================
class TestG9VenueOrderIdRouting:
"""G9: venue_order_id update must target the exit order when in an exit
FSM state, not the still-present entry order reference."""
def test_order_ack_in_exit_phase_fills_exit_order_id(self):
"""ORDER_ACK arriving after EXIT is submitted must fill exit order's
venue_order_id, not the entry order's."""
k = _fresh_kernel(scenario=MockVenueScenario(partial_fill_ratio=0.0, emit_fill_on_submit=False))
k.process_intent(_mk_intent(action=E.ENTER, trade_id="g9a"))
fill = _mk_venue_event(
kind=KernelEventKind.FULL_FILL,
trade_id="g9a",
event_id="fill-g9a",
price=100.0,
size=1.0,
filled_size=1.0,
)
k.on_venue_event(fill)
assert k._get_slot(0).fsm_state == TradeStage.POSITION_OPEN
# Submit exit — mock emits no fill, just ACK
k.process_intent(_mk_intent(action=E.EXIT, trade_id="g9a"))
slot = k._get_slot(0)
# If active_exit_order exists and has a venue_order_id, G9 is not triggered
if slot.active_exit_order is not None:
assert slot.active_exit_order.venue_order_id != "", (
"G9: exit order must have a venue_order_id after ORDER_ACK in exit phase"
)
# ============================================================
# H6: _safe_enum gracefully handles unknown Rust FFI states
# ============================================================
class TestH6SafeEnum:
"""H6: unknown enum variants from the Rust FFI must not raise ValueError —
they must fall back to a safe default instead of crashing the process."""
def test_safe_enum_known_value(self):
from prod.clean_arch.dita_v2.rust_backend import _safe_enum
result = _safe_enum(TradeStage, "POSITION_OPEN", TradeStage.IDLE)
assert result == TradeStage.POSITION_OPEN
def test_safe_enum_unknown_value_returns_default(self):
from prod.clean_arch.dita_v2.rust_backend import _safe_enum
result = _safe_enum(TradeStage, "UNKNOWN_FUTURE_STATE", TradeStage.IDLE)
assert result == TradeStage.IDLE, (
"H6: unknown enum variant must return default, not raise ValueError"
)
def test_safe_enum_empty_string_returns_default(self):
from prod.clean_arch.dita_v2.rust_backend import _safe_enum
result = _safe_enum(TradeStage, "", TradeStage.IDLE)
assert result == TradeStage.IDLE