#1 (validation) — VIOLET_V34B_LIVE_FACTOR_FIELD_VALIDATION.md documents the
ground truth from BLUE's own code (esf_alpha_orchestrator / adaptive_circuit_breaker
/ nautilus_event_trader): of the 8 sizing inputs, ONLY `posture` is a flat HZ key.
`esof_score` is in HZ but as a payload to parse. The other five — boost, beta,
mc_scale, ob_median_imbalance, ob_agreement_pct, dc_status — are BLUE-organ outputs
(ACB over DOLPHIN_FEATURES.exf_latest, MC flag derivation, OBFeatureEngine, the
per-asset signal generator) and are NOT present as scalars in any HZ map. This
inverts live_factors.py's flat-snapshot premise; its speculative alternate paths
(acb_boost / s_acb_boost / ("acb","boost") / …) match nothing real. The full live
sourcing of the five is a multi-organ sprint (V3.4c), not a HZ scrape.
#2 (sourcing adapter) — live_factor_source.py sources what BLUE actually publishes,
read-only and pure (callers pass already-fetched HZ blobs; no client, no I/O):
- posture ← engine_snapshot['posture'] (DOLPHIN_STATE_BLUE), default APEX
- esof_score ← DOLPHIN_FEATURES['esof_latest'] via BLUE's OWN parse_esof_payload
+ esof_score_from_payload (wrap, don't reimplement; staleness gate
honored when max_age_s supplied)
- boost/beta/mc_scale/ob_*/dc_status ← BLUE's neutral sentinels (1.0/0.0/1.0/None/
None/"NONE") until V3.4c — explicit, never silently faked.
Flows through the validated extract_live_sizing_factors normalizer. ORGAN_DERIVED_
FACTORS names the six deferred to V3.4c so the journal can mark them NEUTRAL.
9 new tests green (posture default/upper, esof dict+raw-JSON+staleness, neutral
integration, all-neutral-when-empty). violet-only; no shared-file edits; no soak.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ShadowDecision already carries the V3.4 5-factor breakdown (base_leverage,
dc_lev_mult, regime_size_mult, market_ob_mult, esof_size_mult), but the journal
row and CH DDL dropped it — so a DARK soak would record decisions WITHOUT the
factor decomposition that V3.4 exists to expose. Thread it through:
- 22_violet_decisions.sql: 5 additive Nullable(Float64) breakdown columns. NULL
on the legacy base-only path; populated once the launcher feeds live factor
planes. Note added: on a pre-existing table use ALTER ... ADD COLUMN instead of
the CREATE IF NOT EXISTS (no live table yet — VIOLET is DARK, never soaked).
- shadow_journal.py: DecisionRow gains the 5 Optional[float] fields (ge=0.0,
finite-guarded); journal() populates them via getattr(..., None) so the
base-only path and duck-typed reject tests stay NULL/rejected rather than
raising on attribute access.
- test_violet_shadow_journal.py: breakdown round-trips on the full path; NULL on
base-only; a negative multiplier is rejected at the row guard. The existing
DecisionRow-fields == DDL-columns parity test still holds with the new columns.
violet-only; no shared-file edits; no soak. 29 violet tests green
(7 journal + 22 engine/DDL-apply).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Built by OA agent per VIOLET_SUB_SPEC__L3_EXCHANGE_LEVERAGE.md; reviewed for
compliance/flaws. VERIFIED: wraps real prod/bingx/leverage.py (untouched), constants
imported from it, NO arbitrary upper caps, exact == bit-identity (1e6 gate 0 mismatches),
ROUND_HALF_EVEN explicitly tested (1.5->2 AND 2.5->2), clamping+non-default caps+frozen
model. 38 tests pass on independent rerun.
REVIEW FIX: to_exchange clamped negative internal_conviction to 0 in the trace field
(reused ConvictionLeverage ge=0); changed trace field to plain float (poison guard only)
so it records the ACTUAL input faithfully; dropped the clamp + unused import. Relocated
8 off-spec leverage-spike scratch files off repo root -> prod/VIOLET_dev/l3_spike/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Additive (non-breaking): decide(factors=None) keeps the V3a base-only path (existing
11 tests unchanged); decide(factors=SizingFactors(...)) produces BLUE-complete
conviction via VioletSizer (base_max=8 + dc/regime(ACB)/ob/esof, capped@9) with the
full factor breakdown on ShadowDecision (base_leverage/dc_lev_mult/regime_size_mult/
market_ob_mult/esof_size_mult, None on the base path). SizingFactors value object =
the live-plane inputs the launcher will source (V3.4b). 6 new tests incl. consistency
vs VioletSizer, STALKER cap, EsoF-stale haircut. 17 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverted a redundant widening of the main MC gate (typical ranges) after confirming
test_gate_mc_extreme_multipliers already bit-identity-tests boost in [1,5], beta in
{0,0.2,0.8,1}, mc in {0,0.5,1}, and the OB agreement boundary (N=200k, exact !=).
Added a cross-reference note. All 6 gates green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Soak surfaced USDCUSDT being shorted. BLUE has a hardcoded exclusion gate around
its (muted-IRP) picking: _STABLECOIN_SYMBOLS removed from prices_dict pre-select
(nautilus_event_trader.py:24/3906). Replicate exactly: VioletDecisionEngine skips
the same 10 symbols in observe() so IRP never sees them. Following BLUE in all
regards (picking unchanged; this is BLUE's separate gate). Set-equality drift
guard vs BLUE source + never-selected test. 11 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22_violet_decisions.sql (dolphin_violet.violet_decisions, DDL-first) +
shadow_journal.py: VioletDecisionJournal validates each actuated ShadowDecision
via DecisionRow (V-TYPES, allow_inf_nan=False) before the CH sink -- malformed
dies at source, never at the spool head. NEVER an order. DecisionRow field set ==
DDL columns (asserted). 4 tests pass. Launcher wiring + DARK soak = operator step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parity_harness.py: median-curve parity of V3a VioletBetSizer vs recorded
dolphin.trade_events (vel_div->leverage), restricted to short-signal domain.
GATE PASSES on prod host: pearson 0.9998, max_abs_err 0.238 (budget 1.0) over
23 bins -> base conviction sizer reproduces BLUE's central tendency. Per-trade
scatter is the deferred SC/ACB/OB/gold modulation layer (separate finding doc).
3 unit + 1 gate green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
exec_driver.py lifts PINK's TTL-resolution logic (_exec_after_submit /
_handle_expired_working, live-proven) into a standalone driver with
injected ports (router/submit/pump/slot_view/venue_flat/ref-price) and
replaces the 1s polling sweep with one DeadlineScheduler deadline per
working order. The driver is the TIMING authority (router clamps TTLs
>=0.5s — its internal deadline is vestigial here); the router stays the
POLICY authority. R1 preserved verbatim: exit TTL -> MARKET escalation on
the SAME trade_id; post-only reject -> schedule_in(0) through the one
shared resolution path; venue-truth requote gate fails safe.
scripted_venue.py subclasses MockVenueAdapter (zero shared edits) with
per-trade directives: IMMEDIATE_FILL / REST_THEN_FILL / REST_THEN_EXPIRE /
POST_ONLY_REJECT / CANCEL_REJECT / FILL_RACES_CANCEL; deferred fills
release through reconcile() (the production pump seam), never the 50ms
subscribe() poll.
17 new tests incl. full-kernel CANCEL->venue mapping, fill-races-cancel,
bounded retry chains, on_fill deadline cancellation, fail-safe probe.
Router 77 green untouched; shared files clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
domain.py: refined scalar aliases (BarsHeld kills the bars_held=-106
UInt16 poison class by construction), DivergenceRow (DDL-shaped, frozen,
extra=forbid), ExecDriverSettings (env boundary for the V2 driver; ttl
override exists because the shared router clamps TTLs >= 0.5s),
ExecGateReport schema, beartype 'typed' decorator with
DOLPHIN_VIOLET_BEARTYPE=0 kill-switch.
divergence.py: rows now parse through DivergenceRow before the sink —
malformed rows die at the source with a rate-limited WARNING + counter,
never at the head of the CH spool.
Properties (hypothesis, derandomized): ExecutionRouter state machine
(fill/retry mutual exclusion via pop-semantics, R1 exit escalation same
trade_id, bounded retry chains, <=1 working ENTER), LatencyHistogram
percentile laws (member-of-samples, monotone, extremes), DivergenceRow
parse laws. 34 new tests; violet suite 64 green; router 77 green; zero
shared-file edits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
launch_dolphin_violet.py: own namespaces hard-set (CH dolphin_violet, HZ
DOLPHIN_STATE_VIOLET/PNL, Zinc prefix violet, DOLPHIN-VIOLET-001); own
credentials (BINGX_VIOLET_API_KEY/SECRET) — DARK idle with periodic WARNING
until provisioned; CH preflight SELECT-probes the required tables and NEVER
creates (DDL-before-code); kernel snapshot path repointed away from PINK's
fixed /tmp/.pink_kernel_state.json; mainnet hard-disabled; observe loop
never calls runtime.step(). ObserveOnlyVenue: submit/cancel raise
ObserveOnlyViolation with full attribute delegation — the kernel's
venue-submit-failure rollback converts a refusal into a synthetic REJECT
(slot back to IDLE), proven against the real kernel. FeedDivergenceMonitor:
per-asset scan-vs-venue divergence rows (bookTicker WS via
prod/bingx/market_stream, REST fallback) with stale-mid suppression and
plane seq propagation — the FET 0.2176-vs-0.1878 detector; runs even DARK
(public data). Supervisord [program:dolphin_violet] autostart=false, no
keys in conf by design. Violet package: 42 tests green + V0 gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ch_writer: _writer_violet (db=dolphin_violet) + ch_put_violet, the existing
per-strategy singleton pattern. bingx/journal: explicit violet entries in
_STRATEGY_DB_MAP/_SINK_MAP/_SINK_NAME_MAP — closes the hazard where the
unknown-strategy fallback routes venue journals into BLUE's dolphin DB
(regression-pinned in the new test, which documents the footgun for any
future strategy color). test_violet_namespace_isolation: violet sink
targets dolphin_violet exclusively; journal resolves violet explicitly;
Zinc region names for prefix violet disjoint from pink; persistence wired
with the violet sink routes only to dolphin_violet; HZ map-name contract
pinned for the C4 launcher. Shared-lib regression: 57/57 across PINK
kernel/runtime + BLUE tp_floor/malformed-open + bingx http safety.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
67 production .py modules that the running PINK service imports but which
were never committed: prod/bingx/ (HTTP client, market/user streams,
journal, config), prod/clean_arch/ adapters/persistence/runtime/dita/dita_v2
production modules and their co-located tests. Rule going forward: every
module imported by launch_dolphin_pink.py / pink_direct.py must appear in
git ls-files. Excludes _backup dirs, __pycache__, and non-code files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PinkAssetPicker/PinkAlphaSizer (BLUE-parity IRP picker + sizer, 2026-06-10)
plus the 2026-06-11 fix: price_of falls back to the dehyphenated symbol —
reconcile/fill paths write venue format (FET-USDT) into the slot while the
scan universe keys are Binance-style (FETUSDT); without the fallback an
adopted slot had no price and TP/SL/MAX_HOLD never evaluated (the unmanaged
FET position incident).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of PINK_ACCOUNTING_EXEC_FIX execution found the Phase 3.2 repair
path triply broken: (1) !closed guard blocked repair on terminal fills —
the common price-less case; (2) wrapper on_account_event was a raw FFI
passthrough so repairs never settled into published capital; (3) live
FILL_SETTLED carried no slot_id and realized_pnl=0 (pre-folded) — repair
was dead code. Fixes: repair allowed on closed slots (flag+dedup keep it
idempotent); wrapper settles the baseline diff on FILL_SETTLED-with-slot_id;
dedicated repair_realized_pnl field avoids double-folding the K-ledger;
_FakeKernelAccount fixture mirrors the Phase 1 anchor_to_exchange contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two related accounting fixes:
1. _calibrate_fee_model startup guard: before calling calibrate_fee,
compute raw deviation from the published taker/maker rate (ignoring
any stale calibration_ratio). If >15%, skip and log WARNING rather
than letting a bad REST fill set calibration_ratio to ~0.8 and cause
ESTIMATED fees to understate actuals by 20% for the entire session.
2. fee_settled_events trade_id: BingX WS does not echo back our
clientOrderId in fill events (field "c" is empty). Was falling back
to BingX's internal orderId (p-e-mq5.../p-x-mq5...) which can't
be joined to trade_events. Now reads trade_id from kernel slot 0
(which retains the trade_id until the next ENTER) so
fee_settled_events.trade_id = BTCUSDT-T-N. Added venue_order_id
field to persist_fee_settled for bidirectional reconciliation.
128/128 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
trade_seq was missing from the kernel account snapshot dict, so the
intent engine always computed trade_id = "BTCUSDT-T-000000000001".
The Rust FSM SLOT_BUSY guard only fires on *different* trade_ids; with
the same ID it resets the slot and submits a new exchange order on each
ENTER signal tick (~86 duplicate orders observed in one session).
Fix:
- Add _slot_was_closed dict to ExecutionKernel; set False on ENTER
accepted (both sync/async), True on on_venue_event when slot.closed
- Increment account.snapshot.trade_seq on the IDLE→CLOSED transition
- Expose trade_seq in snapshot()["account"] so DecisionContext carries
the correct counter → intent engine generates unique IDs per trade
451/451 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defect A (fee sign): bingx_user_stream._normalise_order flipped to
fee = -raw_fee so BingX negative-n costs arrive as positive kernel
costs. k_maker_rebates no longer accumulates phantom rebates.
Defect B (opening fee dropped): fill_qty now falls back to "z"
(cumFilledQty) when "l" (lastFilledQty) is zero/absent, so
apply_predicted_fill computes a non-zero opening-leg fee.
Architectural fix (WARN unfreezes): lib.rs reconcile() now unfreezes
capital_frozen on WARN as well as OK. WARN (0.01-20 USDT delta) is
normal in-flight settlement — only ERROR (≥20, unexplained) should
halt ENTERs. The old keep-state logic trapped the kernel permanently
frozen after the first trade's ENTER predicted-fee phase pushed delta
briefly into ERROR.
Acceptance criterion: |k_capital - bingx_balance| < 1 USDT, frozen=False
after every round-trip trade — verified numerically against T-1/T-2
ground truth from the CRITICAL doc.
Docs: CRITICAL_AGENT-TODO_ACCOUNTING_BUGFIX.md §12-13 (fix record),
CAPITAL_BOOKKEEPING_DESIGN.md §8 (kernel spec), SYSTEM_BIBLE §11.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: open_positions()/open_orders() called _backend_snapshot() ->
_call_backend() -> _run() -> pool.submit(asyncio.run, coro) which spawned a
temporary event loop in a worker thread. httpx AsyncClient created inside that
temp loop, loop closed immediately. All subsequent HTTP calls raised Event loop
is closed or asyncio.locks.Event bound to different loop. Crash triggered WS
stream reconnects; each reconnect re-ran reconcile with N>1 BingX positions and
orphaned all but the largest.
Fix: open_positions()/open_orders() now read backend._state (populated by
await backend.connect() in the main loop). Fallback to _backend_snapshot()
for callers without a connected backend.
Fixes test_bingx_bugs::TestConnectNoDoubleRefresh: connect() is now async.
New test_orphan_prevention.py: 23 tests covering all 5 orphan mechanisms:
A. open_positions/open_orders use backend._state, never hit thread pool
B. connect() awaitable, backend.connect() runs in main event loop
C. Reconcile guard: >1 position logs ERROR and takes only largest
D. clientOrderId p-action-base36-rand4 on every order
E. EXIT sizing capped to kernel slot_size
391 passed, 2 skipped, 0 failed across all 14 test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_reconcile_position_slot passed all N BingX positions (all slot_id=0) to
reconcile_from_slots; with N>1 the kernel silently took one and forgot the
rest. Now: sort by size desc, take only the largest, log ERROR naming every
ignored orphan symbol. Caller must flatten exchange to 0 before restarting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- migrate_pink_sink_schema.sql: ALTER TABLE adds fee/fee_source/is_maker/
slippage_bps/mark_at_submit/exchange_ts to trade_events and trade_exit_legs;
CREATE TABLE fee_settled_events (was missing entirely). DDL already applied.
- monitor_pink.py: wire real PinkClickHousePersistence so monitor roundtrips
write to dolphin_pink CH tables. Adds _make_decision_intent() helper; calls
persist_step() after ENTER and EXIT. Persistence failure is non-fatal (warns
and continues). 42 persistence tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause 1 (http.py): duplicate signature= in POST body — canonical_query
included signature key after build_signed_params injected it, then body
appended &signature= again. Fix: exclude 'signature' from canonical.
Root cause 2 (bingx_direct + http.py): HTTP retry sent same MARKET order to
backup URL (bingx.pro), which hits the same VST account. Without clientOrderId,
each retry opened a new SHORT position; EXIT BUY 10 only closed one. Fix:
restore clientOrderId in hyphen format p-{e/x}-{base36_ts}-{rand4} (pure
alphanumeric rejected by VST; hyphen format accepted). Adds max_retries_override
+ urls_to_try to _request_json for non-idempotent override path.
Root cause 3 (flat_and_start_pink): k.venue.connect() ran backend.connect()
inside asyncio.run() in a thread-pool. httpx session created there references
a dead event loop; order POSTs raise RuntimeError("Event loop is closed").
Fix: await adapter.connect() directly from main event loop.
Also: enter_wall_ms + tight _is_our_position createTime filter to separate
PINK's position from concurrent strategies on shared VST account. 1.5s
settle sleep before flat check.
New test suite test_bingx_http_safety.py: 20 tests covering idempotency,
retry correctness, backup-URL dedup, event-loop hygiene, signing correctness.
Live result: ENTER 290ms, EXIT 260ms — both sub-second. Position flat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug fixes:
1. bingx_venue.py: add cancel_async() — async cancel that awaits backend.cancel()
directly in the main event loop. The sync cancel() path goes through _run()
→ thread-pool → asyncio.run() in a new thread, but aiohttp is bound to the
main loop → deadlock. Identical root cause as the old sync submit() → fixed
via submit_async. Remove dead cancel_order branch (BingxDirectExecutionAdapter
has cancel, not cancel_order).
2. rust_backend.py: process_intent_async CANCEL path now uses cancel_async when
available (matching the submit_async pattern for ENTER/EXIT). Sync cancel()
fallback kept for MockVenueAdapter compat.
3. bingx_direct.py: guard S2 background refresh task per symbol. Old code discarded
the task reference; rapid submits piled up concurrent _refresh_state_background
calls all writing self._state in arbitrary completion order (stale last-writer-
wins). Now: skip creating a new task if one is already pending for the symbol;
store reference and clear via done-callback.
Test additions (test_bingx_bugs.py, 29 tests):
- cancel_async: awaitable, calls backend.cancel directly, maps all statuses
- process_intent_async CANCEL: dispatches cancel_async / falls back to sync
- S2 guard: task stored, no duplicates while pending, new task after done
- _events_from_submit with None snapshots: FILLED/NEW/REJECTED/PARTIAL/RATE_LIMITED
- _filled_size_from_snapshots(None, None): safe 0.0 return
- _events_from_cancel: before/after completely ignored
- connect(): no double refresh_state, no-op if backend has no connect
- submit() sync with None snapshots: FULL_FILL still emitted
- cancel() branch audit: uses cancel not cancel_order, raises for no-cancel backend
Fix: test_exchange_event_seam_parity.py TestMockSubscribe — replace deprecated
asyncio.get_event_loop().run_until_complete() with asyncio.run() (Python 3.12
raises RuntimeError when event loop is closed by earlier suite tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix 1: connect() — remove redundant _backend_snapshot(include_history=True).
backend.connect() already called refresh_state(); this was a second identical
network round-trip at startup (~400ms wasted).
Fix 2: cancel() — remove snapshot_before + snapshot_after. _events_from_cancel
never reads 'before' or 'after' — two gratuitous round-trips per cancel with
zero benefit.
Fix 3: submit() (sync/legacy path) — drop both _backend_snapshot calls, pass
None like submit_async already does. Receipt executedQty fields take precedence;
_filled_size_from_snapshots returning 0.0 is the correct safe fallback.
117/117 tests pass (2 pre-existing pytest-ordering failures in TestMockSubscribe
are unrelated — asyncio.get_event_loop() contamination from other suite files,
25/25 pass when the file runs alone).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
S1 — Leverage cache (bingx_direct.py):
_ensure_leverage(): per-symbol asyncio.Lock + cached value check; skips ~350ms
POST when exchange already has the requested leverage. Saves ~350ms/trade.
Cache updated ONLY on success; failed POST leaves cache stale → correct retry.
Persist: JSON sidecar /tmp/.bingx_leverage_cache_{env}.json; survives restarts.
connect(): _verify_leverage_drift() detects when another process changed leverage
at the exchange and updates cache to exchange truth (logs WARNING on drift).
Multi-runner contract: leverage is account-level on BingX; documented that
concurrent runners with different leverage desires for same symbol conflict.
20 mock tests: same-lev skip, change-triggers-POST, failure-no-cache-update,
concurrent-same-symbol (lock prevents race), drift-detect, persist/restore,
multi-runner known-limitation documentation test.
S2 — Background state refresh (bingx_direct.py):
MARKET fills: asyncio.create_task(_refresh_state_background) — does not block
submit path. WS FILL_SETTLED + ACCOUNT_UPDATE deliver capital truth anyway.
LIMIT fills: synchronous refresh retained (include_history=False, not True) —
needed to detect resting order state for next pump cycle.
Saves ~600–900ms/trade on MARKET exits. ENTER similarly improved.
Gap 1 — VenueEvent friction fields (contracts.py):
Added: fee, fee_asset, fee_source, is_maker, exchange_ts, slippage_bps,
mark_at_submit — all with defaults so existing callers are unaffected.
Detailed inline docs for sign conventions and provenance codes.
Gap 2 — Fee estimation + WS_SETTLED provenance (bingx_direct.py, pink_clickhouse.py):
submit_intent: estimates fee from fill_price × fill_qty × taker/maker rate;
annotates ack_row with _fee_estimated, _fee_source, _is_maker_est.
persist_fee_settled(): new method writes fee_settled_events row when WS
ORDER_TRADE_UPDATE delivers actual commission ("n" field); fee_source="WS_SETTLED".
pink_direct._run_account_stream: calls persist_fee_settled on FILL_SETTLED.
Gap 3 — Slippage measurement (bingx_direct.py, bingx_venue.py, pink_clickhouse.py):
Captures mark_at_submit before the order POST; computes slippage_bps signed
by side: positive = adverse (taker overpaid / maker undersold), negative =
price improvement. Measured for BOTH taker and maker fills for symmetry.
Flows through VenueEvent → trade_events.slippage_bps + trade_exit_legs.slippage_bps.
S3 / SOR — Maker order placement: comprehensive TODO block in submit_intent with:
SHORT/LONG-aware price offset design, OBF integration requirements,
TODO_ADD_PARAMSET_VIBRISS for spread_bps threshold, intelligent timeout_s
calibration requirements, price-impact awareness gap, SOR abstraction CRITICAL TODO.
REST/WS split: documented why BingX (and all retail venues) separate these
and why a unified VenueAdapter protocol is the long-term solution.
151/151 existing tests green + 20 new leverage cache tests = 171 total.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Startup roundtrip now verifies persistence accounting inline:
- Wires PinkClickHousePersistence to a mock sink after EXIT
- Checks trade_events: exit_price != entry_price, pnl finite, capital finite
- Checks trade_exit_legs: pnl_leg, exit_qty, exit_price all populated
- Logs ALL FIELDS CORRECT / ISSUES DETECTED
Flatness check scoped to PINK's traded asset only (other strategies
may have open positions; those are reported informational, not failure).
Adds _normalize_asset() for TRX-USDT → TRXUSDT symbol normalization.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
G21/E23/A13 — exit_price used entry_price (every trade had exit_price==entry_price):
_write_trade_event: exit_price = fill_price_hint > intent.reference_price > decision.reference_price
_write_trade_exit_leg: same priority chain via fill_price_hint parameter
persist_result: extracts fill_price_hint from FULL_FILL/PARTIAL_FILL events in outcome
persist_fill_events: intent.reference_price = actual fill price → propagates correctly
A14 — entry_bar was active_leg_index (exit leg counter, not bar count):
_write_position_state: entry_bar = intent.bars_held (0 when intent is None)
A15 — persist_recovery_state used acc_dict as slot_dict (trade_id always ""):
Now reads kernel.slot(0).to_dict() when kernel is wired; trade_id from real slot
External-position exit_qty=0 fix:
_write_trade_exit_leg: when prev_size<=0 (no prior ENTER tracked), falls back to
initial_size or intent.target_size so exit legs for reconcile-detected positions are meaningful
exit_qty field added to trade_exit_legs rows (was computed but not emitted)
NaN tracing (_checked_float):
Introduces _checked_float() wrapper that logs WARNING + writes anomaly_events spool
row on NaN/inf in financial fields; applied to realized_pnl in exit paths
29 new persistence unit tests (mocked) + chaos/fuzz suite:
exit_price correctness, capital ordering, pnl_leg incremental, entry_bar,
recovery trade_id, external position exits, multi-leg, restart-mid-trade, NaN/None fields
164/164 total (97 flaws + 25 kernel reliability + 29 persistence + 13 phase4) green
FLAWS doc: pass 6 — G21/E23/A13/A14/A15 closed; 26 total fixed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLI: python flat_and_start_pink.py [--flatten] [--no-start]
--flatten : cancel all orders + MARKET-close all positions (correct side
by positionAmt sign, not abs()); verifies account flat after
--no-start: flatten only, skip roundtrip
(no flags): startup roundtrip only — ENTER/EXIT via process_intent_async()
Startup roundtrip exercises the full N2/N3/N4 async hot path:
process_intent_async → submit_async → await backend.submit_intent →
BingX POST → on_venue_event(ORDER_ACK+FULL_FILL) → POSITION_OPEN → CLOSED
Min-order detection: queries /quote/contracts for tradeMinQty/minOrderQuantity;
fallback 10 units. Fixes the 0.001-TRX rejection that BingX returned.
Bugs fixed in flatten:
- positionAmt sign was lost via abs(); SHORT positions now correctly use BUY
(positionAmt < 0) vs SELL for LONG (positionAmt > 0) with reduceOnly=true
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TestFSMOccupancyAndRollback covers the invariants that prevent orphaned
exchange orders — the production failure mode where multiple positions
accumulated because slot state wasn't rolled back on submit failure:
- ENTRY_WORKING blocks new ENTER (different trade_id → SLOT_BUSY)
- POSITION_OPEN blocks new ENTER
- venue.submit raise → synthetic REJECTED → FSM back to IDLE
- After rollback slot immediately reusable
- N consecutive submit failures never strand the slot
- submit-fail then success → exactly 1 position, not N
- 20 rapid enter→exit cycles leave no residual state
- EXIT on IDLE always rejected (no phantom closes)
- 5 assets, 1 slot → only first accepted, rest SLOT_BUSY
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When venue.submit() raises (BingX timeout / network error), the Rust FSM
had already advanced to ORDER_REQUESTED/ENTRY_WORKING with no corresponding
exchange order — stranding the slot. Every subsequent ENTER for a different
asset hit SLOT_BUSY, preventing recovery without a restart. Restarts create
a fresh IDLE kernel, leaving the orphaned exchange position unmanaged.
Fix: catch submit exceptions, synthesise an ORDER_REJECT VenueEvent, feed it
through on_venue_event() so the FSM rolls back to IDLE atomically. The slot
is free on the next cycle with no orphan on the exchange.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously: set_seed_capital(hardcoded_25000) then on_account_event(BingX_100K+)
→ reconcile delta ~75K → capital_frozen=True → no trades allowed.
Fix: _fetch_exchange_wallet_balance() queries BingX wallet balance BEFORE
seeding the kernel. set_seed_capital() and the subsequent ACCOUNT_UPDATE
reconcile now agree → delta ≈ 0 → capital_frozen=False → sizing correct.
Falls back to DOLPHIN_INITIAL_CAPITAL if BingX is unreachable, with WARNING.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>