From d763f77516490bbf98835316e6055968b4f6c698 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 13:00:41 +0200 Subject: [PATCH] =?UTF-8?q?uv(parity):=20exhaustive=20exit-doctrine=20re-c?= =?UTF-8?q?rawl=20x3=20(BLUE=20TP,=20BLUE=20SL+ADVSL,=20UV)=20=E2=80=94=20?= =?UTF-8?q?supersedes=20phase-1=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TP: cascade x1.40 SATURATED not dormant (313870/314158 decisions; effective TP ~0.26-0.28%); withdrawal x0.60 provably unreachable (elif shadow); per-asset x0.75 never observed; TP_FLOOR = 46% of TP-family closes. SL/ADVSL: ADVSL live loss-cutter (1347 CLOSE + 965 PARTIAL_EXIT/30d, always fraction=1.0) via trader RETRACT pipeline; shadow table never existed (silent DDL bug) -> ADVSL sub-modes dormant; withdrawal 0.10 WIDENS floor. UV: multi-leg implemented in Rust FSM, promotion never sets ratios; venue carries-not-consumes. 16 weirdnesses to port as-is. --- .../uv_cert/exit_crawl/BLUE_SL_ADVSL_CRAWL.md | 615 ++++++++++++++++++ prod/docs/uv_cert/exit_crawl/BLUE_TP_CRAWL.md | 585 +++++++++++++++++ prod/docs/uv_cert/exit_crawl/UV_EXIT_CRAWL.md | 514 +++++++++++++++ 3 files changed, 1714 insertions(+) create mode 100644 prod/docs/uv_cert/exit_crawl/BLUE_SL_ADVSL_CRAWL.md create mode 100644 prod/docs/uv_cert/exit_crawl/BLUE_TP_CRAWL.md create mode 100644 prod/docs/uv_cert/exit_crawl/UV_EXIT_CRAWL.md diff --git a/prod/docs/uv_cert/exit_crawl/BLUE_SL_ADVSL_CRAWL.md b/prod/docs/uv_cert/exit_crawl/BLUE_SL_ADVSL_CRAWL.md new file mode 100644 index 0000000..e107961 --- /dev/null +++ b/prod/docs/uv_cert/exit_crawl/BLUE_SL_ADVSL_CRAWL.md @@ -0,0 +1,615 @@ +# BLUE STOP-LOSS + ADVSL crawl — parity-port reference + +Date: 2026-07-12 (crawl). READ-ONLY audit of the live BLUE codepaths. +Sources: code (exact file:line), live process env (`/proc/431423/environ`), +ClickHouse `dolphin` DB (UTC), trader logs `/mnt/dolphinng5_predict/prod/logs/nautilus_trader_YYYYMMDD_v2_gold_fix_v50-v750.log` +(UTC timestamps in-file; operator quotes CET). Line numbers are as of branch `tools/pi_wake_agent` working tree. + +Files crawled: + +- `/mnt/dolphinng5_predict/prod/nautilus_event_trader.py` (5419 lines) — "the trader" +- `/mnt/dolphinng5_predict/adaptive_exit/advanced_sl.py` (1203 lines, parsed in full) +- `/mnt/dolphinng5_predict/adaptive_exit/market_state_outputs.py` +- `/mnt/dolphinng5_predict/adaptive_exit/post_win_long_overlay.py` (EFSM — `exit_policy_meta`) +- `/mnt/dolphinng5_predict/adaptive_exit/meta_exit_performance.py` (`ExitMetaPerformanceTracker`) +- `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/alpha_exit_manager.py` (339 lines, parsed in full) +- `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/alpha_exit_v7_engine.py` (decision-dict section) + +Note: BLUE submits **no venue orders**. Every exit in this document is in-memory +portfolio accounting (engine position → capital update → CH journal → HZ state). +There is no order-submission path anywhere in `nautilus_event_trader.py`. + +--- + +## 0. Live process environment (confirmed on PID 431423) + +``` +DOLPHIN_CATASTROPHIC_FLOOR_PCT=0.0120 # == code default (trader:462) +DOLPHIN_ENABLE_ADVANCED_SL_LIVE=1 # ADVSL live exits ON (trader:457, default False) +DOLPHIN_OVERLAY_ADVSL_LIVE=1 # overlay-ADVSL ON (trader:472, default True) +DOLPHIN_OVERLAY_ADVSL_MAE_RISK_MIN=0.50 # trader:476 +DOLPHIN_OVERLAY_ADVSL_MFE_MAX_PCT=0.0020 # trader:474 +DOLPHIN_OVERLAY_ADVSL_MIN_BARS=6 # trader:473 +DOLPHIN_OVERLAY_ADVSL_PRESSURE_MIN=1.85 # trader:475 +DOLPHIN_OVERLAY_CATASTROPHIC_FLOOR_PCT=0.0050 # trader:466 +DOLPHIN_OVERLAY_CATASTROPHIC_MAX_LOSS_USD=500 # trader:470 +``` + +--- + +## 1. Layer map — six SL-ish mechanisms, one exit funnel + +``` + per-bar (scan ~5s), inside eng.step_bar() + ┌─ V7 live exit provider (eng.exit_decision_provider, trader:685/1231) — EXIT/RETRACT/EXTEND + ├─ AlphaExitManager.evaluate() — TP_FLOOR → [vd off] → [tf off] → FIXED_TP → STOP_LOSS → MAX_HOLD + │ STOP_LOSS gate = st["stop_pct_override"] or self.stop_pct(=1.0, disabled) + │ stop_pct_override is ARMED by the trader: + │ A. catastrophic floor 1.2% (every scan, trader:4192; restore trader:2022/2287/2064/2335; entry trader:4275) + │ B. overlay floor min(1.2%, 0.5%, $500/notional) for EFSM overlay_flip trades (trader:2496-2521) + │ C. hibernate-protect per-bucket SL (trader:2424-2454, _BUCKET_SL_PCT trader:242) + │ D. withdrawal-stress hard SL 0.10 (inside exit manager itself, alpha_exit_manager:183) + └─ async, daemon thread per scan (trader:4815-4920): + AdvancedSL.evaluate() (advanced_sl:592) ──would_exit──► RETRACT cmd fraction=1.0 + _overlay_advsl_should_exit() (trader:2523) ──True─────► RETRACT cmd fraction=1.0 + V7 action==RETRACT (trader:4495) ─────────────────────► RETRACT cmd fraction=0.50 + all → HZ DOLPHIN_CONTROL_PLANE["blue_runtime_commands"] + drained on scan thread (trader:4551 → 3926) → + _apply_internal_retract (trader:3696) → PARTIAL_EXIT leg(s) + → full close → forced exit dict → CLOSE finalizer (trader:4555+) +``` + +Precedence facts: + +- Engine-internal exits (TP_FLOOR/FIXED_TP/STOP_LOSS/MAX_HOLD/V7) win over queued + RETRACTs for the same bar: `_forced_exit` is only adopted `if ... not result.get('exit')` + (trader:4551-4553). +- The heartbeat thread also drains the queue but with `allow_retract=False` + (trader:2633) — RETRACTs are deliberately left queued so position mutation + only ever happens on the scan thread. +- The catastrophic floor is re-armed **every scan before step_bar** (trader:4191-4192), + so it always back-stops whatever another mechanism set. + +--- + +## 2. AlphaExitManager — the STOP_LOSS kernel + +File: `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/alpha_exit_manager.py` + +- Defaults (ctor, :27-29): `fixed_tp_pct=0.0099`, `stop_pct=1.0` (**-100%, never fires + by itself** — header comment "Stop Loss: pnl_pct <= -1.0 (DISABLED)"), `max_hold_bars=120`. +- Per-position state (`setup_position`, :100-119) carries `stop_pct_override` + (`None` → global 1.0), `tp_pct_override`, `max_hold_override`. +- `evaluate()` (:121-330): + - `pnl_pct = d * (current_price - entry) / entry` (:146); `max_favorable` ratchet (:148). + - `dynamic_sl_pct = st.get("stop_pct_override") or self.stop_pct` (:152) — + NOTE: `or`-semantics, so an override of `0.0` falls back to global (can't disable via 0). + - OB modulation block (:163-202): + - cascade (`macro.cascade_count > 0`): TP ×1.40, max_hold ×0.5 (:174-178). + - **withdrawal stress** (`macro.regime_signal == 1`): `dynamic_sl_pct = 0.10` + hard 10% stop (:183) — this OVERWRITES any armed per-trade floor for that bar + (widens 1.2% → 10% while the stress regime lasts); plus TP ×0.60 if in profit + (:187) and max_hold ×0.4 if OB against us (:189-190). + - calm+favoring: max_hold ×1.5 (:193-195); per-asset withdrawal: max_hold ≤×0.40, + TP ×0.75 if in profit (:198-202). + - TP diagnostics into `self.last_eval` on every call (:208-223) — consumed by + the v7 journal and the trader's TP_EXIT log line (trader:4582). + - Exit order: `TP_FLOOR` ratchet (:232, if `tp_floor_enabled`; live via DOLPHIN_TP_FLOOR) + → vd exits (:242-277, `vd_enabled=False` — dormant) → tf exits (:280-312, + `tf_enabled=False` — dormant) → `FIXED_TP` (:315) → **`STOP_LOSS` at + `pnl_pct <= -dynamic_sl_pct` (:320-322)** → `MAX_HOLD` (:325). + +So BLUE's actual stop-loss = `stop_pct_override` armed by the trader (§3-§5), evaluated +here, exit reason string `STOP_LOSS`. + +--- + +## 3. Catastrophic floor (base 1.2%) + +- Config: `self._catastrophic_floor_pct = max(0, env DOLPHIN_CATASTROPHIC_FLOOR_PCT, default 0.0120)` + (trader:460-463). Live env = 0.0120, same as default. +- `_catastrophic_floor_for_open_position()` (trader:2496-2521): + - non-overlay trade (`pending["overlay_flip"]` falsy) → `(0.0120, "base")` (:2505-2506). + - overlay trade → §4. +- `_apply_catastrophic_floor_to_open_position()` (trader:2456-2494): + - no-ops without engine/position/trade_id or floor ≤ 0. + - if the exit manager has no state for the trade → full `setup_position(..., stop_pct_override=floor)` + (:2473-2479). + - else **tighten-only ratchet** (:2486-2488): replace only when + `current <= 0.0 or current > floor_pct`. It never *widens* an existing tighter stop. + - logs `CATASTROPHIC_FLOOR armed: {asset} SL=x.xx% mode={base|overlay:...} trade=...`. +- Call sites (all under `eng_lock`): + 1. every scan, immediately before `eng.step_bar` (trader:4191-4192) — the standing re-arm; + 2. on new entry after `_pending_entries` is built (trader:4274-4275); + 3. on both restore paths after the position is rebuilt (trader:2063-2064 HZ-fallback, + 2334-2335 position_state) — and on restore `setup_position` itself is already called + with `stop_pct_override=0.0120` (trader:2017-2023 and 2282-2288), so a restored + position is floor-armed even before the first scan. +- Evidence (live): log 2026-07-11 has exactly one + `CATASTROPHIC_FLOOR armed: DOGEUSDT SL=1.20% mode=base trade=…` (arm log fires only + on state change, thanks to the ratchet). ClickHouse, last 30 days: + `STOP_LOSS` CLOSE ×204, avg pnl_pct −1.178%, min −3.731% (gap through the floor), + max −0.341%, avg bars_held 29.7 — i.e. the 1.2% floor is the thing that actually + fires as `STOP_LOSS`. **Live, active.** + +--- + +## 4. Overlay floor 0.50% + $500 USD max-loss (EFSM overlay LONG trades only) + +- Applies only when `pending["overlay_flip"] == True` — trades tagged LONG by the + post-win EFSM overlay at entry (trader:4222-4234; `overlay_flip` stored :4251). +- `_catastrophic_floor_for_open_position()` overlay branch (trader:2508-2521): + - `floor_pct = min(base 0.0120, overlay 0.0050)` (positive candidates only); + - USD cap: `floor_pct = min(floor_pct, max_loss_usd / notional)` with + `notional = pending.notional_entry|notional|pos.notional`, `max_loss_usd = 500` (:2512-2518) + — for entry notional > $100k the USD cap is the binding constraint; + - returns `(floor, f"overlay:{overlay_reason}")`. +- Armed through the same `_apply_catastrophic_floor_to_open_position()` ratchet → exits + as plain `STOP_LOSS` through the exit manager (no distinct reason string). +- Dormancy: only as live as EFSM overlay tagging; no `mode=overlay:` arm lines in the + sampled 2026-07-10/11 logs. **Present, conditionally live, rarely exercised.** + +--- + +## 5. Hibernate-protect per-bucket SL + +- Table `_BUCKET_SL_PCT` (trader:242-251), derived 2026-04-19 from AE-shadow + bucket + analysis (comment :234-241): + `{0: 0.015, 1: 0.012, 2: 0.015, 3: 0.025, 4: 0.008, 5: 0.018, 6: 0.030, 'default': 0.015}`. + Bucket = KMeans assignment `self._bucket_assignments[asset]` loaded from pkl + (fallback log trader:732 uses `default`=1.5%). +- Trigger: posture sync detects transition to `HIBERNATE` while a position is open and + protect not yet active (trader:4144-4150) → `_hibernate_protect_position()` (trader:2424-2454): + - sets `em_state['stop_pct_override'] = sl_pct` **unconditionally** (:2443) — this can + *widen* a previously-armed 1.2% floor to e.g. 2.5%/3.0% for buckets 3/6; + - BUT the per-scan catastrophic re-arm (§3, tighten-only) runs next scan and pulls any + bucket SL wider than 1.2% back down to 1.2%. Net effect: bucket SL only survives when + it is TIGHTER than the floor (B4 0.008, B1 0.012-tie). The wide-bucket half of the + table is effectively dead under the current floor. Nobody guards against this. + - `_day_posture` intentionally NOT set to HIBERNATE while protecting (comment :4148-4149) + so `HIBERNATE_HALT` doesn't fire; `_hibernate_protect_active = trade_id` (:2452). + - explicitly NOT armed during restore (comment trader:2289-2292) — first scan's posture + sync does it. +- Exit relabel (trader:4558-4571): if the closing trade is the protected one, reason is + mapped `{FIXED_TP→HIBERNATE_TP, STOP_LOSS→HIBERNATE_SL, MAX_HOLD→HIBERNATE_MAXHOLD}`, + fallback `f"HIBERNATE_{orig}"`; posture then finalized. +- Evidence: 30-day trade_events show **zero** HIBERNATE_TP/SL/MAXHOLD, but + `HIBERNATE_HIBERNATE_HALT` ×7 on 2026-06-25 — i.e. the only recent hibernate-protected + closes were `HIBERNATE_HALT` exits that hit the map *fallback* (double-prefix label), + meaning the halt fired anyway while protect was active. No `HIBERNATE_PROTECT` lines in + 2026-07-10/11 logs. **Present, rarely triggered, and its wide-bucket arm is neutered by + the floor ratchet.** + +--- + +## 6. AdvancedSL runtime (`adaptive_exit/advanced_sl.py`) — full parse + +### 6.1 Constants / plumbing + +- CH endpoint: `CLICKHOUSE_HTTP_URL|USER|PASSWORD` env, defaults + `http://localhost:8123/`, `dolphin`, `dolphin_ch_2026` (:22-24). +- `ADVANCED_SL_DB="dolphin"`, `ADVANCED_SL_TABLE="advanced_sl_shadow"` (:26-27). +- HZ control-plane keys (:28-31): `advanced_sl_control` (config), `advanced_sl_latest` + (last decision), `advanced_sl_monitor_latest`, `advanced_sl_monitor_report`. +- Snapshot files: `/mnt/dolphin_training/advanced_sl/latest_advanced_sl.json`, + `monitor_report.json` (:33-34). +- `_ch_query()` (:66-80): **appends `FORMAT TSVWithNames` to any SQL that lacks the word + FORMAT** — including the CREATE TABLE DDL (see §6.7 dormancy finding). + +### 6.2 AdvancedSLConfig (:330-368) — defaults (live config == defaults per snapshot, plus `enabled: true`) + +| field | default | role | +|---|---|---| +| min_hold_bars | 20 | hard gate floor | +| hold_fraction_of_market_state | 0.25 | effective_min_hold = max(min_hold, 0.25×market_state_max_hold_bars) | +| pressure_min | 1.85 | hard gate: v7 exit_pressure ≥ | +| continuation_max | 0.42 | hard gate: continuation_prob ≤ | +| stress_min | 0.52 | hard gate: state_stress ≥ | +| snapback_max | 0.56 | hard gate: snapback_score ≤ | +| cut_score_min | 0.70 | score threshold | +| recent_window | 16 | price tail length | +| price_band_pct | 0.0015 | time_at_price band | +| stall_floor | 0.00015 | stall log-diff floor | +| momentum_floor | 0.00010 | momentum normalizer | +| pressure/continuation/stress/snapback/stall weights | 0.30/0.30/0.20/0.15/0.05 | score terms | +| support/forecast/decay weights | 0.12/0.12/0.10 | score terms (post-hoc additions; weights sum > 1) | +| base_tp_pct / base_max_hold_bars | 0.0095 / 120 | from `market_state_outputs.py:7-8` (`DEFAULT_BASE_TP_PCT`, `DEFAULT_BASE_MAX_HOLD_BARS`) | +| enabled | False (live: True) | **cosmetic — never consulted by evaluate() or the trader gate** | +| catastrophic_mode_enabled | **False (live: False)** | gates the internal ASL catastrophic block — DORMANT | +| catastrophic_floor_base/min/max | 0.0100 / 0.0080 / 0.0120 | meta-adjusted floor clamp | +| catastrophic_emergency_floor | 0.0150 | unconditional cut level | +| catastrophic_min_hold_bars | 20 | tp_unlikely gate | +| catastrophic_tp_unlikely_mfe_frac / now_frac | 0.35 / 0.10 | tp_unlikely gates | +| catastrophic_min_support / min_edge | 8 / 0.0 | meta gate | +| catastrophic_unknown_meta_allows_base | True | support==0 ⇒ base floor allowed | + +`from_dict` (:374-381) merges only known keys over a fallback config. + +### 6.3 Scoring functions (exact formulas) + +All prices below are the last `recent_window` prices (trader passes +`self._bounce_price_path(asset)`; current price appended if missing; min length 3; +fallback `[entry, current]`) (:610-616). + +- `_favorable_series` (:109): `sign·(px−entry)/entry`, sign=+1 LONG / −1 SHORT (:96-97). +- `_momentum_score(side, prices, floor)` (:116-124): + `clamp( sign·mean(last≤5 log-diffs) / max(floor,1e-12), −1, 1 )`. +- `_stall_fraction(prices, floor)` (:127-133): fraction of |log-diffs| < floor. +- `_to_band` → `time_at_price` (:100-106): fraction of window within ±price_band_pct of + the window's FIRST price. Computed, journaled, **never used in the decision**. +- `_mean_reversion_snapback_score` (:136-154): + `recovery = clamp((fav[-1]−min_fav)/|min_fav|,0,1)` if min_fav<0 else 0; + `recent_momentum = clamp((fav[-1]−fav[-min(4,n)])/(|fav[-min(4,n)]|+1e-12),−1,1)`; + score = `clamp(0.60·recovery + 0.25·max(0,recent_momentum) + 0.15·clamp(mr_strength,0,1), 0,1)`. +- `_recent_curve_metrics` (:166-194) on favorable series: + `slope = mean(last≤4 diffs)`; `accel = mean(last≤3 diff²)`; + `drawdown_from_peak = max(0, peak−current)`; `recovery_ratio = clamp(current/peak,0,1)`; + `decay_bars` = trailing run of negative diffs. +- `_market_support_score` (:197-252) from market_state ∪ market_bundle: + `0.28·confidence + 0.18·top_similarity + 0.14·min(target_count/5,1) + 0.14·asset_match + + 0.12·clamp((cont+1)/2,0,1) − 0.08·bounce − 0.04·entropy − 0.04·stall + label_boost`, + label_boost = +0.06 CALM/DIRECTED_MEAN_REVERT, −0.06 CHOPPY; clamp [0,1]. +- `_v7_forecast_score` (:255-305) (inputs from v7 decision + curve metrics): + `0.40 + 0.12·tanh(sign·vel_div_now/0.004) + 0.08·tanh(sign·ob_imbalance/0.75) + + 0.06·clamp(rv_comp/3,0,1) − 0.10·clamp(|bounce_score|,0,1) − 0.06·clamp(bounce_risk,0,1) + − 0.10·clamp(mae_risk/2,0,1) − 0.08·clamp(mfe_risk/2,0,1) − 0.10·clamp(exit_pressure/3,0,1) + − 0.08·drawdown_score − 0.05·slope_score − 0.05·accel_score + 0.08·recovery_score + + 0.14·clamp(market_support,0,1) + 0.04·clamp(current_fav/tp_scale,−1,1)`, clamp [0,1]; + where drawdown_score=clamp(dd/tp,0,1), slope_score=clamp(max(0,−slope)/(0.75·tp),0,1), + accel_score=clamp(max(0,−accel)/(0.50·tp),0,1). `tp_scale = market_state_tp_pct`. +- `_pnl_decay_score` (:308-313): + `clamp(0.40·drawdown_score + 0.30·slope_score + 0.20·accel_score + 0.10·clamp(decay_bars/5,0,1), 0,1)`. +- `_derived_exf_stress` (:316-327): + `0.30·clamp(|funding|·5000,0,1) + 0.25·clamp((dvol−50)/50,0,1) + + 0.25·clamp((40−fear)/40,0,1) + 0.20·clamp((0.9−taker)/0.4,0,1)`. +- `_market_state_from_inputs` (:545-590): + `state_stress = clamp(0.34·choppy + 0.22·clamp(dd_pressure,0,1) + 0.18·(1−trend_persistence) + + 0.12·(1−compression) + 0.14·derived_exf_stress, 0,1)`; + also returns market max_hold_bars (default 120 only when state present, else 0), profile + label, confidence, tp_pct (default 0.0095), mean_reversion_strength. + **Bug (cosmetic): annotated `tuple[float, int, str, float, float]` (5) but returns 6 values.** + The `stress_fields` set (:555-566) is built and never used — dead code. + +### 6.4 evaluate() decision logic (:592-803) + +Inputs from the trader (trader:4847-4861): `side` from pending (SHORT unless overlay LONG), +`recent_prices=_bounce_price_path(asset)`, `ae_shadow` = live AE shadow decision (carries +`p_continuation`), `v7_decision = self._v7_decisions[tid]` (dict from AlphaExitEngineV7, +fields `exit_pressure, mfe, mae, mfe_risk, mae_risk, bounce_score, bounce_risk, rv_comp, +vel_div_now?, ob_imbalance?` — engine dict has no vel_div_now/ob_imbalance keys, so those +forecast terms read 0.0), `market_state/bundle` from MarketStateRuntime, `exf_snapshot = +self._last_exf`, `meta_performance = efsm.exit_policy_meta(maras_ctx)`. + +Steps, in order: + +1. `refresh_from_control_plane()` (15s TTL) — re-reads `advanced_sl_control` JSON from HZ + features/state maps and merges into config (:519-531). The trader itself publishes that + key at boot from its own config (trader:2590, publish :533-543) — circular; effectively + a hot-reconfig hook nobody currently drives externally. +2. `pressure = v7.exit_pressure`; if ≤0 fallback + `clamp(1.25·max(0,−favorable) + 0.75·stall + 0.75·mean_reversion, 0, 4)` (:636-639) — + a synthesized pressure on a different scale than V7's. +3. `p_cont = ae_shadow.p_continuation|p_cont (default 0.5)` (:640). +4. `snapback_score = clamp(0.60·mean_reversion_score + 0.25·max(0,momentum) + 0.15·mr_strength, 0,1)` + (:648-654) — NOTE this **re-blends** `_mean_reversion_snapback_score`, which already + internally blended 0.60/0.25/0.15; mr_strength and momentum are double-counted. +5. `effective_min_hold = max(min_hold_bars(20), int(market_hold_bars·0.25))` (:655-658). +6. `continuation_prob = clamp(0.50 + 0.35·favorable − 0.35·stress − 0.30·snapback + + 0.15·max(0,momentum) + 0.05·confidence, 0,1)` (:659-668); if ae_shadow has + `p_continuation` the heuristic is REPLACED by it (:669-670), then unconditionally + re-blended `0.55·prob + 0.45·p_cont` (:671-672) — so with ae_shadow present the final + value is `0.55·p_cont + 0.45·p_cont = p_cont`… no: it's `0.55·(replaced=p_cont) + + 0.45·p_cont = p_cont`. With ae_shadow absent it's `0.55·heuristic + 0.45·0.5`. +7. Meta/catastrophic block (:693-720), **dormant** (`catastrophic_mode_enabled=False` in + live config — confirmed via snapshot file): + `catastrophic_floor = clamp(0.0100 + meta.floor_adjustment, 0.0080, 0.0120)`; + `tp_unlikely = bars≥max(20,eff_min_hold) ∧ lifetime_mfe < 0.35·tp ∧ favorable < 0.10·tp`; + `meta_gate = support≥8 ∧ edge≥0`; `meta_unknown_allowed = support≤0`; + `catastrophic_cut = enabled ∧ (adverse ≥ 0.0150 ∨ (adverse ≥ floor ∧ tp_unlikely ∧ (meta_gate ∨ meta_unknown_allowed)))`. + `lifetime_mfe = max(v7.mfe, max(favorable_series), 0)` (:688-692). + Meta source: `ExitMetaPerformanceTracker.lookup` (meta_exit_performance.py:60-76) — + in-memory, keyed `hash:` or `regime:`; returns + `support_n, advsl_edge, win_rate, floor_adjustment ∈ {0, ±0.0010}` (max ±0.0020); + exposed via `PostWinExecutionFSM.exit_policy_meta` (post_win_long_overlay.py:299-302). + In-memory only ⇒ resets on every restart; support rarely reaches 8. +8. Score (:721-731): + `score = 0.30·clamp(pressure/1.85, 0, 2) + 0.30·(1−continuation_prob) + 0.20·stress + + 0.15·(1−snapback) + 0.05·stall + 0.05·clamp(|favorable|,0,1) + + 0.12·(1−market_support) + 0.12·(1−forecast) + 0.10·pnl_decay`. + (Note the un-configured extra `0.05·|favorable|` term hardcoded inline.) +9. Hard gates (:732-738): `bars_held ≥ effective_min_hold ∧ pressure ≥ 1.85 ∧ + continuation_prob ≤ 0.42 ∧ stress ≥ 0.52 ∧ snapback ≤ 0.56`; + `would_exit = all(gates) ∧ score ≥ 0.70` (:739). +10. `recovery_cut` (:740-749): same first four gates, plus `favorable ≥ 0`, + `drawdown_from_peak ≥ max(0.10·tp, 0.0005)`, `recovery_ratio ≥ 0.95`, `score ≥ 0.70` + (snapback gate waived) → forces would_exit. Never observed firing (no + `ASL_RECOVERY_CUT` rows anywhere) — near-contradictory conditions + (favorable ≥ 0 pushes continuation_prob UP via +0.35·favorable). +11. `catastrophic_cut` forces would_exit (dormant, §7). +12. Reason ladder (:754-772), first match: + `ASL_CATASTROPHIC_FLOOR` → `ASL_MIN_HOLD` → `ASL_LOW_PRESSURE` → + `ASL_GOOD_CONTINUATION` → `ASL_REGIME_NOT_STRESSED` → `ASL_RECOVERY_CUT` → + `ASL_SNAPBACK_WINDOW` → `ASL_SCORE_LOW` → `ASL_PRESSURE_CONT_STRESS`. + In 30 days of trade_events, the ONLY exit-firing reason observed is + `ADVSL_ASL_PRESSURE_CONT_STRESS` (the generic all-gates-passed label). +13. Returns frozen `AdvancedSLDecision` (:384-417): all scores + `action∈{EXIT,HOLD}`, + `reason`, `min_hold_bars`, `effective_min_hold_bars`, full `config` dict, + market-state echo fields, `exf_stress`, `would_exit`. + +### 6.5 Multi-leg machinery — inside AdvancedSL: NONE + +Despite the "multi-leg" billing, `advanced_sl.py` contains **no leg sizing, no schedule, +no per-leg PnL, no residual re-watch**. Its live decision is binary (would_exit), and the +trader always enqueues **`fraction: 1.0`** (trader:4894) — a FULL retraction. All actual +multi-leg mechanics live in the trader's RETRACT pipeline (§8) and are shared with V7 +(fraction 0.50) and TUI hotkeys. The module's other half (`simulate_trade`, `run_backtest`, +`sweep_configs`, `run_monitor_once`, `main`; :946-1203) is an offline replayer/calibration +monitor (5-min loop) that replays `dolphin.trade_events` + `adaptive_exit_shadow` + +`v7_decision_events`; **no such monitor process is currently running** (ps checked). + +### 6.6 Snapshot / HZ / shadow-journal plumbing + +- `bind_hz(features_map, state_map)` (:434-436) ← trader:2589 right after HZ connect; + then `publish_control_plane()` (trader:2590) writes the config JSON to + `advanced_sl_control` on both maps. +- `log_shadow(decision, pnl_pct)` (:805-855), called per evaluation (trader:4862): + 1. INSERT row into `dolphin.advanced_sl_shadow` — silently swallowed on failure (:838-841); + 2. write full payload to snapshot file + HZ key `advanced_sl_latest` (:842-855). +- `AdvancedSLRuntime.load()` (:438-457) at trader init (:456) runs `ensure_shadow_table()` + + control-plane refresh. + +### 6.7 DORMANCY FINDING — the CH shadow journal has never worked + +- `SELECT database,name FROM system.tables WHERE name ILIKE '%advanced%' OR name ILIKE '%advsl%'` + → **empty**. `dolphin.advanced_sl_shadow` does not exist in any database. +- Root cause: `ensure_shadow_table()` (:459-499) sends its CREATE TABLE DDL through + `_ch_query()`, which appends `FORMAT TSVWithNames` to any statement lacking the word + FORMAT (:68-69). `CREATE TABLE … TTL … FORMAT TSVWithNames` is a ClickHouse syntax + error → exception → `except Exception: pass` (:496-499). Permissions are NOT the issue + (`SHOW GRANTS FOR dolphin` includes CREATE). +- Consequence: **every** `log_shadow` CH insert and every monitor REPORT insert + (:1127-1158) fails silently. The only durable traces of ADVSL decisions are: + the one-row snapshot JSON (last decision overwrites; verified fresh — 2026-07-11 UTC, + DOGEUSDT HOLD `ASL_GOOD_CONTINUATION`, pressure=3.0, continuation_prob=0.955, score=0.933, + effective_min_hold=27), the HZ `advanced_sl_latest` key, the enqueue log lines, and the + resulting trade_events/trade_exit_legs rows. There is no queryable per-decision history. + +--- + +## 7. ADVSL live-exit gate + trader-side wiring + +- Load: trader:56-58 (`from adaptive_exit.advanced_sl import AdvancedSLRuntime`, None on + ImportError); instance :456; live flag :457 (`DOLPHIN_ENABLE_ADVANCED_SL_LIVE`, default + False, **live =1**); if live, `config = replace(config, enabled=True)` :458-459 — + cosmetic, `enabled` is never read as a gate. Boot log says + `AdvancedSL: loaded (shadow prototype)` (:513) — stale label, it is a live exit source. +- Per scan with open pendings, a **daemon thread** `_ae_eval` is spawned (trader:4815-4920): + for each pending trade → AE shadow evaluate → `advanced_sl.evaluate(...)` (:4847) → + `log_shadow` (:4862) → `_overlay_advsl_should_exit(...)` (:4866) → + if `(advsl_live ∧ _adv.would_exit) ∨ overlay_exit` (:4876-4879): + read-modify-write HZ `blue_runtime_commands` queue, append + `{command_id: advsl-exit-, trade_id, action: RETRACT, fraction: 1.0, + reason: OVERLAY_ADVSL_ | ADVSL_, source: "advanced_sl", ts, asset, + chain_root/head/prev/seq/token from pending}` (:4890-4904), queue capped at last 200, + log `AdvancedSL live exit enqueue: …` (:4908-4913). +- **No dedup at the emitter**: while would_exit stays true, a fresh command (fresh + command_id) is enqueued EVERY scan (~5s). Downstream absorbs the spam (§8 guards): + `dolphin.hotkey_audit` lifetime RETRACT results: `NO_POSITION` ×48,257 (!), + `PARTIAL_OK` ×2,513, `FULL_CLOSE` ×2,147, plus hundreds of + `TRADE_MISMATCH open= cmd=` rows — commands landing after the position + already closed or rolled to the next trade. Functional, but noisy by design. +- Exceptions anywhere in the thread body are swallowed (`except Exception: pass`). + +### Overlay-ADVSL gate `_overlay_advsl_should_exit` (trader:2523-2560) + +Applies only to `overlay_flip` (EFSM LONG) trades, env-gated `DOLPHIN_OVERLAY_ADVSL_LIVE` +(default True, live =1). Logic: + +``` +bars_held ≥ 6 (OVERLAY_ADVSL_MIN_BARS) +favorable = side-signed pnl pct; adverse = max(0, −favorable) +lifetime_mfe = max(0, v7.mfe); pressure = v7.exit_pressure; mae_risk = v7.mae_risk +floor_pct, floor_label = _catastrophic_floor_for_open_position() # overlay floor §4 +no_meaningful_mfe = lifetime_mfe ≤ 0.0020 (MFE_MAX_PCT) +pressure_gate = pressure ≥ 1.85 ∧ mae_risk ≥ 0.50 +EXIT iff adverse ≥ floor_pct > 0 ∧ (no_meaningful_mfe ∨ pressure_gate) +→ reason detail "{floor_label}:adverse=..:mfe=..:pressure=..:mae_risk=.." +``` + +Reason string becomes `OVERLAY_ADVSL_{detail}`. **Dormant in practice**: zero +`OVERLAY_ADVSL%` rows in 30 days of trade_events; zero log hits 2026-07-10/11. + +--- + +## 8. How an ADVSL decision becomes an exit — the RETRACT pipeline + +Emitters into HZ `DOLPHIN_CONTROL_PLANE["blue_runtime_commands"]` (JSON list, cap 200): + +1. **advanced_sl** — fraction **1.0** (trader:4890-4907), reason `ADVSL_*`/`OVERLAY_ADVSL_*`. +2. **v7** — fraction **0.50**, reason `V7_RETRACT`, on RETRACT-edge (only when previous + action wasn't RETRACT — edge-triggered, unlike ADVSL) (trader:4494-4519). V7's RETRACT + action originates in `alpha_exit_v7_engine.py` (~:743-756): exit_pressure above + threshold with `mae ≥ composite_pressure_mae_floor`, reason `V7_RISK_DOMINANT`. +3. **tui_hotkey** — operator partial/full retraction, reason `HOTKEY_RETRACT` + (external writer; only test writers exist in-repo besides the trader: + `prod/tests/test_malformed_open_distal.py`). + +Drain: scan thread each bar, after step_bar and the V7 block — +`_drain_runtime_commands(prices_dict)` (trader:4551, def :4004-4019, serialized by +`_runtime_command_lock`) → `_process_runtime_commands` (:3926-4002): queue is atomically +swapped to `[]` (or RETRACTs deferred when `allow_retract=False` — the heartbeat path +:2633); idempotency via `_processed_retract_set` (command_id, deque 5000, :496-497) with +`IDEMPOTENT_REPLAY` audit rows; each RETRACT → `_apply_internal_retract` + `hotkey_audit` +row; `SET_CAPITAL/CAPITAL_UPDATE` also handled here. + +`_apply_internal_retract(cmd, prices_dict)` (trader:3696-3924), under `eng_lock`: + +- Guards (each returns a status string journaled to hotkey_audit): `NO_POSITION`, + `NO_TRADE_ID`, `TRADE_MISMATCH`, `BAD_ENTRY_PRICE`, `ZERO_NOTIONAL`, `BAD_FRACTION` + (frac must be in (0,1]), **chain-linkage gate**: command must carry chain_token + + chain_head_leg_id + chain_root_trade_id matching the CURRENT + `_chain_state_for_pending` digest (`NO_CHAIN_LINK` / `CHAIN_ROOT_MISMATCH` / + `CHAIN_MISMATCH` / `CHAIN_SEQ_MISMATCH`, :3719-3736). This is what makes the + every-scan enqueue spam safe: after one leg applies, the chain head/seq/token advance + and every stale duplicate is rejected. +- Leg economics (:3737-3767): `reduce_notional = open_notional·frac`; + `pnl_pct_now = direction·(cur−entry)/entry`; `net_pnl_leg = pnl_pct_now·reduce_notional`; + capital applied IMMEDIATELY per leg via `_apply_trade_capital_update` (:3747); + `pos.notional -= reduce_notional`; pending updated: + `retraction_legs += 1`, `realized_pnl_legs_total += net_pnl_leg`, + `notional/quantity` reduced (qty recomputed `remaining_notional/entry_price`, 6dp), + `notional_entry` preserved. +- New chain leg id `f"{tid}:x{seq:03d}"`, chain state re-digested + (`_build_chain_state` trader:352-394 — sha256 over the sorted anchor dict). +- Journals, for EVERY leg including the terminal one (:3794-3881): + `dolphin.trade_exit_legs` (full leg record incl. `exit_notional`, + `remaining_notional/qty`, `pnl_pct_leg`, `pnl_leg`, `pnl_realized_total`), + `dolphin.trade_events` with `event_type="PARTIAL_EXIT"` (even for the terminal leg), + `dolphin.trade_reconstruction` (`FULL_RETRACT_EXIT` or `PARTIAL_EXIT`). +- Full close when `remaining_notional ≤ POSITION_DUST_NOTIONAL_USD ∨ remaining_qty ≤ 0` + (:3793): engine position cleared, exit-manager state popped, and a forced exit dict is + built (`_build_retract_exit` :3510-3523) with + `pnl_pct = realized_total/notional_entry`, `net_pnl = realized_total`, and + `capital_already_realized: True` so the CLOSE finalizer does NOT re-book capital + (checked via `_resolved_capital_apply_pnl`, log "close capital delta suppressed"). +- Partial remainder ("residual re-watch"): position simply stays open at reduced size — + the exit-manager per-trade state (incl. stop_pct_override) is untouched, the + catastrophic floor re-arms next scan, ADVSL/V7 keep evaluating the same trade_id, and + the remainder is re-persisted through the canonical OPEN gate `_ps_write_open` + (:3901-3915; refusal logged as accounting anomaly). +- The forced exit dict re-enters the normal CLOSE finalizer (trader:4552-4770): + full `trade_events` CLOSE row (event_id `{tid}:close`, `pnl = realized_pnl`, + `pnl_realized_total` from pending), `trade_reconstruction` CLOSE, + `_ps_write_closed`, EFSM/market-state/advisor outcome updates, announcements. + **Net observable shape of one full ADVSL exit: 1 trade_exit_legs row + 1 PARTIAL_EXIT + trade_events row + 1 CLOSE trade_events row** (same reason string on all three). + +### Multi-leg event fields (trader:850-901) — emitters and consumers + +Fields: `retraction_legs`, `retraction_realized_total`, `pnl_leg`, `pnl_realized_total`, +`exit_leg_id`, `exit_seq`, `exit_notional`, `remaining_notional`, `remaining_qty`, +plus the `chain_*` family. + +- EMITTED by `_trade_event_payload` at: PARTIAL_EXIT (:3829), CLOSE (:4715), and the + SUBDAY_EXIT path (`SUBDAY_ACB_NORMALIZATION`, ~:5070); by `trade_exit_legs` put (:3798); + by `trade_reconstruction` puts (:3860, :4742); mirrored into the HZ state push + (:5245-5251) and `advanced_sl.snapshot_dict` into state (:5293). +- CONSUMED by: (a) BLUE's own restore — both restore paths read + `retraction_legs`/`realized_pnl_legs_total` back out of the latest + `trade_reconstruction` payload (`_load_chain_ledger_state` :3602, seeded at :1992-1993 + and :2258-2259) so a restarted BLUE continues the chain with correct seq/token; + (b) the chain-token verification itself (every future RETRACT is validated against the + digest of these fields); (c) downstream parity stack — VIOLET DDL + `prod/clickhouse/violet/03_trade_exit_legs.sql`, `prod/launch_dolphin_violet.py`, + `prod/clean_arch/persistence/pink_clickhouse.py`, dita_v2 docs/tests, and the UV cert + docs (`prod/docs/uv_cert/UV_EXIT_DOCTRINE_PARITY_PASS_20260712.md`). + +--- + +## 9. Precedence / interaction matrix + +| # | Mechanism | Acts through | Threshold (live) | Wins over / loses to | +|---|---|---|---|---| +| 1 | Withdrawal-stress hard SL | exit_manager, per-bar, overwrites `dynamic_sl_pct=0.10` | −10% while `macro.regime_signal==1` | WIDENS the armed floor for the stress bar(s) — floor value in `st` untouched, restored next non-stress bar | +| 2 | Catastrophic floor (base) | `stop_pct_override` → STOP_LOSS | −1.2% | tighten-only ratchet, re-armed every scan; overrides wide bucket SLs | +| 3 | Overlay floor + USD cap | same override | −min(1.2%, 0.5%, 500/notional) | overlay_flip trades only | +| 4 | Hibernate bucket SL | same override, unconditional write | −0.8%…−3.0% by bucket | wide buckets clawed back to 1.2% by #2 next scan; tight buckets (0.8%) survive | +| 5 | ADVSL live exit | RETRACT frac 1.0, async queue | 5 hard gates + score ≥ 0.70 | loses same-bar to any engine exit (4552); chain-token race-safe | +| 6 | Overlay-ADVSL | RETRACT frac 1.0 | adverse ≥ overlay floor + mfe/pressure gate | overlay trades only; observed never | +| 7 | V7 RETRACT | RETRACT frac 0.50 | exit_pressure > threshold, edge-triggered | halves position; ADVSL/floor keep watching remainder | +| 8 | ASL catastrophic block | would_exit inside evaluate() | −1.0%..−1.5% meta-adjusted | **dormant** (`catastrophic_mode_enabled=false`) | +| 9 | exit_manager global stop_pct | evaluate() fallback | −100% | never fires | + +--- + +## 10. Dormancy scoreboard (evidence-backed) + +| Mechanism | Status | Evidence | +|---|---|---| +| STOP_LOSS via catastrophic floor | **LIVE, firing** | 204 CLOSEs / 30d, avg −1.178% ≈ floor; `CATASTROPHIC_FLOOR armed … SL=1.20% mode=base` in 2026-07-11 log | +| ADVSL live full-retract exits | **LIVE, firing heavily** | trade_events 30d: `ADVSL_ASL_PRESSURE_CONT_STRESS` CLOSE ×1347 + PARTIAL_EXIT ×965 (since 2026-06-15); trade_exit_legs 14d: 686 legs, **Σ pnl_leg = −$53,425.56** (it is a loss-cutter); 5 enqueue log lines on 2026-07-10, 0 on 2026-07-11 | +| V7_RETRACT half-legs | LIVE | 1301 PARTIAL_EXIT / 30d; 735 legs / 14d, Σ −$14,920.55 | +| HOTKEY_RETRACT | LIVE (operator) | 279 CLOSE + 248 PARTIAL_EXIT / 30d; 204 legs / 14d, Σ +$7,788.56 | +| ADVSL CH shadow journal | **DEAD since inception** | table absent everywhere; FORMAT-append DDL bug (§6.7) | +| ADVSL reasons other than PRESSURE_CONT_STRESS firing an exit | never | only reason string in trade_events; `ASL_RECOVERY_CUT`/`ASL_CATASTROPHIC_FLOOR` absent | +| ASL catastrophic mode | dormant | `catastrophic_mode_enabled: false` in live snapshot config | +| Overlay-ADVSL | armed, never fired | 0 `OVERLAY_ADVSL%` rows / 30d; 0 log hits | +| Overlay floor / USD cap | armed, no observed arms | no `mode=overlay:` log lines in sample | +| Hibernate protect | rare + partially broken | only `HIBERNATE_HIBERNATE_HALT` ×7 (2026-06-25); no protect lines in July logs | +| vd / tf exits in exit manager | dormant | `vd_enabled=False`, `tf_enabled=False` defaults | +| exit_meta floor_adjustment | effectively inert | in-memory only, resets on restart, min_support=8 | +| ADVSL calibration monitor (`main --once` loop) | not running | ps; REPORT inserts would silently fail anyway | +| TP_FLOOR (context) | LIVE | 1510 CLOSEs / 30d | + +Curiosity: `EXP_VOL_GATE_RELAX_DO_NOT_ACCOUNT|` prefixed variants of ADVSL / +STOP_LOSS / TP_FLOOR reasons appear only on 2026-07-04 (a gate-relax experiment day) — +port must tolerate prefixed reason strings. + +--- + +## 11. Weirdness ledger (do NOT idealize — port the behavior, flag the mess) + +1. **Silent-dead CH journal** (§6.7): `_ch_query` appends `FORMAT TSVWithNames` to the + CREATE TABLE DDL → table never created → every shadow insert fails inside + `except: pass`. The system's only decision history is a single overwritten JSON file. +2. **Double event per ADVSL exit**: terminal retract leg writes `PARTIAL_EXIT` trade_events + + forced CLOSE writes a second row. Capital is NOT double-booked + (`capital_already_realized`), but naive row counting double-counts exits + (1347 CLOSE vs 965 PARTIAL_EXIT — the mismatch is pre-2026-06-18 code that skipped + terminal-leg events, per the comment at trader:3794-3797). +3. **Emitter spam absorbed downstream**: ADVSL enqueues a fresh full-retract command every + scan while would_exit holds (no per-trade edge trigger, unlike V7). 48k `NO_POSITION` + + chain-token rejections are the working-as-coded garbage collector. +4. **`enabled` config flag is decorative**; the real gate is the trader-side env bool. + Boot log still calls ADVSL a "shadow prototype". +5. **Reason ladder mostly labels HOLDs**; the only exit label in the wild is the generic + `ASL_PRESSURE_CONT_STRESS`. Score weights sum to ≈1.39 (three bolt-on terms + a + hardcoded `0.05·|favorable|`), so `cut_score_min=0.70` is not on a 0-1 scale. +6. **Snapback double-blend** (§6.4 step 4) — mr_strength/momentum counted twice. +7. **`continuation_prob` triple pipeline** (§6.4 step 6) — heuristic, replaced by + p_continuation if present, then re-blended with the same p_cont (identity when + ae_shadow present). +8. **`_market_state_from_inputs` returns 6 values against a 5-tuple annotation**; its + `stress_fields` set is dead code; `time_at_price` is computed and journaled but unused. +9. **v7 dict keys mismatch**: `_v7_forecast_score` reads `vel_div_now`/`ob_imbalance`/ + `bounce_risk`/`rv_comp` — `rv_comp`, `bounce_risk` exist in the V7 dict, but + `vel_div_now`/`ob_imbalance` do not (journal rows have them, the in-memory decision + dict does not) → those two forecast terms are always 0 live. +10. **Withdrawal-stress bar wins over the floor**: `dynamic_sl_pct = 0.10` overwrite at + alpha_exit_manager:183 temporarily widens an armed 1.2% floor to 10% during macro + withdrawal-stress bars. +11. **Wide bucket SLs are unreachable**: hibernate-protect writes them unconditionally + (trader:2443) but the every-scan tighten-only floor re-arm claws anything >1.2% back. +12. **`HIBERNATE_HIBERNATE_HALT`**: relabel map (trader:4561) lacks `HIBERNATE_HALT`, so + the fallback double-prefixes; the 7 such closes also show protect did not prevent the + halt. +13. **`stop_pct_override` uses `or`**, so `0.0` cannot disable the stop (falls back to 1.0). +14. **Daemon-thread evaluation**: one fire-and-forget thread per scan; slow CH/HZ can stack + threads; all failures invisible. +15. **Meta layer is amnesiac**: `ExitMetaPerformanceTracker` is process-memory only — + `floor_adjustment` never survives a restart and effectively never leaves 0. +16. **Control-plane self-loop**: the trader publishes `advanced_sl_control` from its own + defaults at boot, then re-reads it every 15s; an external writer COULD hot-retune every + threshold in §6.2 (including enabling catastrophic mode) with no audit trail. + +--- + +## 12. Parity-port checklist (UV) + +- Port `AlphaExitManager.evaluate` ordering exactly (TP_FLOOR before everything; SL after + FIXED_TP; MAX_HOLD last) with the OB modulation table, incl. the 0.10 withdrawal stop. +- Port floor arming as *state on the exit manager*, re-asserted every decision tick with + tighten-only semantics; entry + both restore paths must arm it (restore also passes it + directly to setup_position). +- Port the RETRACT command shape (command_id/trade_id/action/fraction/reason/source/ts/ + asset/chain_* fields), chain digest (`sha256` over the sorted anchor dict, trader:346-394), + and all guard statuses — UV replay tooling keys off `hotkey_audit.result` strings. +- ADVSL evaluate() must reproduce the formulas in §6.3-6.4 bit-for-bit if RESIDUAL=0 is the + target — including the double-blends, the dead terms, and the >1 weight sum. Do not + "fix" them in the port; fix upstream first, then re-vendor. +- Reasons to expect in fills/journals: `STOP_LOSS`, `ADVSL_ASL_PRESSURE_CONT_STRESS`, + `V7_RETRACT`, `HOTKEY_RETRACT`, `TP_FLOOR`, `HIBERNATE_*` (incl. the double-prefix), + optional `OVERLAY_ADVSL_*`, `EXP_…|`-prefixed variants. +- If the port stands up its own `advanced_sl_shadow`, use the DDL at advanced_sl.py:460-495 + but do NOT route it through `_ch_query` (the FORMAT bug); decide deliberately whether to + reproduce the missing-table behavior or repair it. diff --git a/prod/docs/uv_cert/exit_crawl/BLUE_TP_CRAWL.md b/prod/docs/uv_cert/exit_crawl/BLUE_TP_CRAWL.md new file mode 100644 index 0000000..dd72a13 --- /dev/null +++ b/prod/docs/uv_cert/exit_crawl/BLUE_TP_CRAWL.md @@ -0,0 +1,585 @@ +# BLUE TAKE-PROFIT CODEPATH CRAWL — exhaustive, parity-port grade + +- **Date:** 2026-07-12 (UTC) +- **Method:** parser-grade read of every import/call that touches TP, from scan ingress to the + comparison that fires `FIXED_TP`, plus empirical dormancy verification (live logs + ClickHouse). +- **Scope:** READ-ONLY crawl. Nothing was modified, restarted, or written to any DB. +- **Live process verified:** pid 431423, `/home/dolphin/siloqy_env/bin/python3 + /mnt/dolphinng5_predict/prod/nautilus_event_trader.py`, cwd `/mnt/dolphinng5_predict/prod`, + `DOLPHIN_LOG_ROOT=/tmp/dolphin_logs`. Live stdout log: + `/tmp/dolphin_logs/supervisor/nautilus_trader.log` (starts 2026-06-20T11:29Z, active at crawl + time 2026-07-12T10:52Z). **Trap:** `/root/dolphin_logs/supervisor/nautilus_trader.log` is a + STALE copy ending 2026-05-16 (pre-TP-diagnostics era) — do not use it as evidence. + Note: both logs are ISO-stamped `+00:00` (UTC), same clock frame as ClickHouse `ts`. + +Files crawled (canonical, as imported by the live process — `PYTHONPATH` includes +`/mnt/dolphinng5_predict/nautilus_dolphin`): + +| Role | Path | +|---|---| +| Live trader (scan loop) | `/mnt/dolphinng5_predict/prod/nautilus_event_trader.py` | +| Soft TP curve | `/mnt/dolphinng5_predict/prod/clean_arch/tp_curve.py` | +| OBF mid-price injection helper | `/mnt/dolphinng5_predict/prod/clean_arch/obf_tp_observation.py` | +| Exit manager (TP comparison) | `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/alpha_exit_manager.py` | +| Orchestrator / engine base (`NDAlphaEngine`) | `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/esf_alpha_orchestrator.py` | +| Engine factory + subclass ladder | `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/proxy_boost_engine.py` | +| OB feature engine (cascade/withdrawal macro) | `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/ob_features.py` | +| Live OB provider (Hazelcast) | `/mnt/dolphinng5_predict/nautilus_dolphin/nautilus_dolphin/nautilus/hz_ob_provider.py` | + +--- + +## 1. Where the TP constant is born and how it reaches the exit manager + +### 1.1 `ENGINE_KWARGS` (trader :130-143) + +```python +ENGINE_KWARGS = dict( + initial_capital=25000.0, ... + fraction=0.20, fixed_tp_pct=0.0020, stop_pct=1.0, max_hold_bars=250, # TP research 2026-05-11: 0.95→0.20% + ... +) +``` + +- `fixed_tp_pct = 0.0020` (0.20%). `stop_pct = 1.0` (100% — SL structurally disabled at this layer; + real stops come from override machinery, §5). `max_hold_bars = 250`. +- Trader caches the base at construction: `self._tp_base_pct = float(ENGINE_KWARGS.get("fixed_tp_pct", 0.0020))` + (trader :455). This cached value — NOT the engine's current threshold — is the anchor for the soft + curve (§2), so runtime tightening never compounds. + +### 1.2 Engine construction chain (trader `_build_engine` :599-623) + +``` +trader:606 self.eng = create_d_liq_engine(**engine_kwargs) +proxy_boost_engine:443-462 create_d_liq_engine() -> LiquidationGuardEngine( + extended_soft_cap=8.0, extended_abs_cap=9.0, + mc_leverage_ref=5.0, margin_buffer=0.95, + adaptive_beta=True, **engine_kwargs) +class ladder: LiquidationGuardEngine(:392) -> ExtendedLeverageEngine(:311) + -> AdaptiveBoostEngine(:209) -> ProxyBaseEngine(:110) -> NDAlphaEngine +NDAlphaEngine = class at esf_alpha_orchestrator.py:97 (constructor :100-269) +``` + +- `fixed_tp_pct` flows untouched through every subclass `**kwargs` into + `NDAlphaEngine.__init__(fixed_tp_pct=0.0020)` (orchestrator :115, default there is the legacy + champion 0.0099 — overridden by ENGINE_KWARGS). +- Orchestrator :193-202 constructs the exit manager: + +```python +self.exit_manager = AlphaExitManager( + fixed_tp_pct=fixed_tp_pct, stop_pct=stop_pct, max_hold_bars=max_hold_bars, + tf_exhaust_ratio=..., tf_flip_ratio=..., tf_consec_bars=..., tf_min_hold_frac=..., + tf_enabled=tf_enabled, # default False; ENGINE_KWARGS does not set it → False +) +``` + +- `AlphaExitManager.__init__` (aem :25-80): defaults `fixed_tp_pct=0.0099`, `stop_pct=1.0`, + `max_hold_bars=120`, `vd_enabled=False`, `tf_enabled=False`, `tp_floor_enabled=False`, + `self.ob_engine = None` (aem :74 — **the attribute always exists**, see §7), + `self.last_eval = {}` (aem :80 — per-decision TP diagnostics). + +### 1.3 TP_FLOOR enable (trader :607-614) + +```python +# trader:612 +self.eng.exit_manager.tp_floor_enabled = _env_bool("DOLPHIN_TP_FLOOR", True) +``` + +- Class default OFF (backtest/champion parity, aem :57); **live default ON** unless env + `DOLPHIN_TP_FLOOR=0`. Boot log line `TP profit-floor: ON` confirmed 53× in the live log (one per + restart) — currently ON. +- **PARITY TRAP:** `NDAlphaEngine.reset()` (orchestrator :697-755) rebuilds `AlphaExitManager` + (:726-740) forwarding `fixed_tp_pct/stop_pct/max_hold/tf_*/vd_*` but **NOT `tp_floor_enabled`** + — a reset would silently revert TP_FLOOR to OFF. Mitigation in practice: the live trader never + calls `eng.reset()` (grep: 0 call sites in `nautilus_event_trader.py`). A port that resets + per-day must re-apply the flag. + +--- + +## 2. Runtime TP threshold: the leverage-conditioned soft curve + +### 2.1 `tp_curve.py` — transcribed verbatim (constants :13-16, functions :33-64) + +```python +DEFAULT_SOFT_TP_LEVERAGE_REF = 2.0 +DEFAULT_SOFT_TP_CURVE_POWER = 3.0 +DEFAULT_SOFT_TP_TIGHTEN_AT_REF = 0.00013 +DEFAULT_SOFT_TP_FLOOR_PCT = 0.00187 + +def compute_our_leverage(*, notional, capital) -> float: + notional_f = abs(_safe_float(notional, 0.0)) + capital_f = _safe_float(capital, 0.0) + if capital_f <= 0.0: return 0.0 + value = notional_f / capital_f + return value if math.isfinite(value) and value >= 0.0 else 0.0 + +def compute_soft_tp_pct(base_tp_pct, our_leverage, *, + reference_leverage=2.0, curve_power=3.0, + tighten_at_reference=0.00013, floor_pct=0.00187) -> float: + base = max(0.0, _safe_float(base_tp_pct, 0.0)) + lev = max(0.0, _safe_float(our_leverage, 0.0)) + ref = max(1e-9, _safe_float(reference_leverage, 2.0)) + power = max(1.0, _safe_float(curve_power, 3.0)) + tighten = max(0.0, _safe_float(tighten_at_reference, 0.00013)) + x = _clamp(lev / ref, 0.0, 1.0) + effective = base - tighten * (x ** power) + return _clamp(effective, max(0.0, floor_pct), base if base > 0.0 else float("inf")) +``` + +- Formula: `tp_eff = clamp(0.0020 − 0.00013·min(lev/2, 1)³, 0.00187, 0.0020)`. + "Leverage" here = `position_notional / capital` (system sizing leverage, NOT exchange leverage). + Range is tiny by design: 0.20% at flat/low leverage, bending to 0.187% at lev ≥ 2.0. + `_safe_float` maps NaN/inf/non-numeric → default; negative leverage → 0. + +### 2.2 `_tp_curve_context` (trader :805-832) + +- `capital = eng.capital`; `notional`: explicit arg if given, else open position's + `pos.notional` (fallback `size*entry_price`), else 0.0 (no position → `lev=0` → `tp_eff=base`). +- Returns `{tp_base_pct, tp_effective_pct, our_leverage, market_state_bundle_json, **maras_ctx}`. +- Called from 3 places: `_sync_tp_threshold` (:914, notional=None → live position), entry snapshot + (:4257, notional=entry notional), close/retract journal enrichment (:4714 and the RETRACT leg + path, notional=pending notional). + +### 2.3 `_sync_tp_threshold` (trader :906-926) — the ONLY runtime writer of the engine threshold + +```python +ctx = self._tp_curve_context() +tp_pct = float(ctx.get("tp_effective_pct", 0.0) or 0.0) +if tp_pct <= 0: return +with self.eng_lock: + old = self.eng.set_live_tp_pct(tp_pct) +if abs(old - tp_pct) > 1e-6: + log(f"TP threshold: {old*100:.2f}% → {tp_pct*100:.2f}% (soft curve, lev={...:.2f}x)") +``` + +- Docstring mentions an HZ key `DOLPHIN_FEATURES["live_tp_threshold"]` — **stale doc**: the body + reads no HZ key; the value comes purely from the soft curve. Whole body is `try/except: pass`. +- Forwarding chain: `NDAlphaEngine.set_live_tp_pct` (orchestrator :860-866) → + `AlphaExitManager.set_live_tp_pct` (aem :82-98): + +```python +def set_live_tp_pct(self, tp_pct: float) -> float: + tp_pct = float(tp_pct) + if tp_pct <= 0: return self.fixed_tp_pct + old = self.fixed_tp_pct + self.fixed_tp_pct = tp_pct + for st in self._positions.values(): + st["tp_pct_override"] = tp_pct # mirror onto every open position + return old +``` + +- So in live operation `fixed_tp_pct` and every open position's `tp_pct_override` are always the + same soft-curve value. Called once per scan (§3), i.e. every ~11 s bar. +- Empirical: 420 `TP threshold:` transitions in the live log since 2026-06-20, oscillating + 0.20% ↔ 0.19% with `lev` up to 1.80x. Journal `tp_base_pct` distribution (CH): 0.00200 + (267,453 rows) dominant, then 0.00191 (25,175), 0.00199, 0.00195, … — matches the curve's + 0.0020→0.001905 range at lev≈1.8. The 0.00187 floor requires lev ≥ 2.0. + +--- + +## 3. Scan loop → TP comparison: the full call chain + +All in `_process_scan` (trader :4051-4925), submitted from the HZ reactor via +`on_scan` :4043-4049 to a worker executor (:4049). + +``` +on_scan(event) trader:4043 (reactor thread → executor) +└─ _process_scan(event, listener_time) trader:4051 + ├─ dedup ratchet / NG7 normalize :4056-4088 + ├─ _wire_obf(assets) [first scan only] :4094-4095 → §7.1 + ├─ prices_dict = dict(zip(assets, prices)) :4102 (stablecoins stripped :4105-4106) + ├─ ob_eng.step_live(ob_assets, bar_idx) :4124-4125 (live OB features for THIS bar) → §7.2 + ├─ posture sync (HIBERNATE protect arm) :4128-4161 → §5.3 + ├─ _sync_esof_size_gate() :4164 + ├─ _sync_tp_threshold() :4165 ← soft curve applied BEFORE step_bar + ├─ _apply_runtime_direction() :4168 + ├─ if eng.position and prices_dict: + │ prices_dict = _inject_obf_midprice(prices_dict) :4187-4188 → §4 + ├─ with eng_lock: + │ _apply_catastrophic_floor_to_open_position() :4192 (SL-side only, §5.5) + │ result = eng.step_bar(bar_idx, vel_div, prices_dict, vol_ok, v50, v750) :4193-4196 + │ bar_idx += 1 + ├─ entry handling: pending_entries[tid].update(_tp_curve_context(notional=entry_notional)) :4257-4258 + ├─ V7 open-scan evaluate + journal (tp diag from exit_manager.last_eval) :4440-4549, §6 + ├─ _drain_runtime_commands(prices_dict) → forced exits bypass TP entirely :4551-4553 + └─ exit handling: TP_EXIT diagnostics log + trade_events payload :4555-4588, §6.2 +``` + +Inside the engine (`step_bar`, orchestrator :272-321 — wrapper that appends price histories, +updates `regime_size_mult`, then `process_bar` :323-355 with the PERSISTENT `self._global_bar_idx`; +note the trader-passed `bar_idx` is ignored by the core in favour of `_global_bar_idx`): + +``` +process_bar orchestrator:323 +├─ if position: _manage_position(bar_idx, prices, vel_div, v50, v750) :343-346 +│ ├─ pos.current_price = prices[pos.asset] :362-363 ← OBF-injected mid lands HERE +│ ├─ if _day_posture == 'HIBERNATE': decision = EXIT/HIBERNATE_HALT :365-371 (bypasses TP) +│ ├─ provider = self.exit_decision_provider :373 (V7 live exit hook, §6.1) +│ │ decision = provider(...) :375-386 +│ │ only a provider EXIT is accepted; HOLD/RETRACT/None falls through :387-389 +│ ├─ decision = exit_manager.evaluate(pos.trade_id, pos.current_price, bar_idx, +│ │ asset=pos.asset, current_timestamp=float(bar_idx), +│ │ regime_size_mult=..., vel_div=..., v50_vel=..., v750_vel=...) :390-395 +│ └─ if EXIT: _execute_exit(reason, bar_idx, pnl_pct_raw, bars_held) :397-404 +└─ if no position and bar_idx > _last_exit_bar and not regime_dd_halt + and _bar_count >= lookback and vol_ok: _try_entry(...) :348-353 +``` + +`_execute_exit` (orchestrator :406-503) — TP-relevant mechanics: + +- `FIXED_TP` slippage 0.0002 applied to `pos.current_price` (:416-418, :432); pnl **recomputed** + from that slipped exit price (:433-434), then SP maker refunds (+0.0002 with p=0.62 entry / + p=0.50 exit, :437-441), then OB edge bonus if `ob_engine.get_placement(...).depth_quality > 0.5` + (+`fill_probability*5bps`, :444-448), then blended fees (:459-466). +- `exit_manager.reset_position(trade_id)` (:491) clears per-trade override state. +- **The returned exit dict (:495-503) drops all of evaluate()'s TP diag keys** — which is why the + trader re-reads `exit_manager.last_eval` at :4582 (§6.2). +- Exit-reason counters: `FIXED_TP` → `tp_exits` (:416-418 / :428). + +--- + +## 4. What price the TP comparison sees — OBF mid-price injection + +### 4.1 `_inject_obf_midprice` (trader :928-951) + +- Runs only when a position is open and prices arrived (:4187). Reads HZ features map key + `"obf_universe_latest"` (`self.features_map.blocking().get(...)`, :942) — the OBF universe + service's live WebSocket book (~0.5 s resolution, full precision) — and calls the pure helper + with `max_age_s=3.0, now_s=time.time()`. Entire body `try/except → return prices_dict` unchanged. + +### 4.2 `inject_obf_midprice` (obf_tp_observation.py :51-96) — exact semantics + +1. No position asset / no payload / JSON-parse failure / non-dict → return copy unchanged. +2. Staleness: payload timestamp from first present of `_snapshot_utc, updated_at, timestamp, ts, + iso` (:42-48; ints/floats used raw, ISO strings parsed, naive → UTC). If found and + `now − ts > 3.0 s` → unchanged. **If no timestamp field found, the payload is treated as fresh.** +3. Per-asset blob must be a dict with `best_bid > 0` and `best_ask > 0` (poison-safe `_safe_float`, + :88-91), else unchanged. +4. `out[position_asset] = (best_bid + best_ask)/2` — **only the position's asset** is overridden; + the scan price (quantized ~4 dp) is used for everything else. The TP *threshold* is untouched; + only the observation is refreshed. + +Consequence: the value compared against the TP gate is +`pnl_pct = direction · (OBF_mid − entry_price)/entry_price`, and the same OBF mid becomes the base +of the slipped exit price in `_execute_exit`. Entry prices, by contrast, come from the raw scan +tape (`_try_entry` :622 `entry_price = prices.get(trade_asset, 0)` — no injection, no entry slip +per :621). + +--- + +## 5. Per-trade override machinery — every writer of `tp_pct_override` / `stop_pct_override` / `max_hold_override` + +`AlphaExitManager.setup_position` (aem :100-119) initializes per-trade state: + +```python +self._positions[trade_id] = { + "entry_price":..., "direction":..., "entry_bar":..., "max_favorable": 0.0, + "vd_inv_count":0, "vd_exh_count":0, "entry_tf_spread": v50−v750, "tf_exh_count":0, "tf_flip_count":0, + "stop_pct_override": stop_pct_override, # None = global self.stop_pct + "tp_pct_override": tp_pct_override, # exp15 + "max_hold_override": max_hold_override, # exp15 +} +``` + +### 5.1 Entry-time overrides (exp15 machinery) — orchestrator :246-249, :642-656 + +```python +# consumed in _try_entry: +_stop_ov = getattr(self, '_pending_stop_override', None) +_tp_ov = getattr(self, '_pending_tp_override', None) +_hold_ov = getattr(self, '_pending_max_hold_override', None) +self._pending_* = None # one-shot consume +self.exit_manager.setup_position(trade_id, entry_price, trade_direction, bar_idx, + entry_v50=v50_vel, entry_v750=v750_vel, + stop_pct_override=_stop_ov, + tp_pct_override=_tp_ov, max_hold_override=_hold_ov) +``` + +- **`_pending_tp_override` / `_pending_max_hold_override` are set NOWHERE in production code.** + Exhaustive grep across `nautilus_dolphin/` finds writers only in + `esf_alpha_orchestrator_AGENT_fork.py` (:218-219, :580-584) — a research fork not imported by + the live trader, and the trader itself contains zero `tp_pct_override` references. The exp15 TP + override machinery is **plumbing-only / DORMANT in live**: at entry, `tp_pct_override` is always + `None`. +- `_pending_stop_override` IS set live: `LiquidationGuardEngine._try_entry` + (proxy_boost_engine :416-420) sets it to `_liq_stop_pct = (1/9)·0.95 = 0.10556` (:413) before + every entry — the 10.6% liquidation-floor stop. TP-side untouched. + +### 5.2 Runtime writer (the only live one): `set_live_tp_pct` + +aem :96-97 (§2.3) stamps `tp_pct_override = tp_pct` on every tracked position whenever the soft +curve moves. Hence per-trade TP override in live == current soft-curve threshold, refreshed every +scan where the value changed (guarded `tp_pct <= 0` no-op). + +### 5.3 HIBERNATE protect (trader :2424-2454, armed from posture sync :4128-4153) + +When posture flips to HIBERNATE with an open position, instead of letting `_manage_position` fire +`HIBERNATE_HALT`, the trader patches the LIVE exit-manager state: +`em_state['stop_pct_override'] = _BUCKET_SL_PCT[bucket]` (per-bucket 0.8%-3.0%, trader :242-251) +and keeps `_day_posture` at the previous value. TP side: **unchanged** — it logs +`tp_pct = eng.exit_manager.fixed_tp_pct` (:2438) and lets the normal (possibly OB-widened) FIXED_TP +close the trade; the exit is then re-labelled `HIBERNATE_TP`/`HIBERNATE_SL`/`HIBERNATE_MAXHOLD` +(:4559-4571). If the trade is missing from the exit manager, it re-runs `setup_position` with only +`stop_pct_override` (:4447-4450) — note this would reset `max_favorable` (TP_FLOOR arming) for +that trade. + +### 5.4 Catastrophic floor (trader :2456-2500+, called at :4192 and :4274-4275) + +Ratchets `stop_pct_override` down to `DOLPHIN_CATASTROPHIC_FLOOR_PCT` (default 0.0120; overlay +variant 0.0050) — only ever tightens (`current <= 0 or current > floor`). **SL-side only; never +touches TP fields.** Listed here because it shares the same `_positions[tid]` dict the TP override +lives in. + +### 5.5 Restore path + +On restart-restore the trader re-registers open positions via `setup_position` (BINGX rehydrate / +restore code); any re-setup re-initializes `max_favorable = 0.0` and `tp_pct_override = None` +until the next `_sync_tp_threshold` change re-stamps it — a small parity nuance: after a restart, +`tp_pct_override` may be `None`, and `evaluate` then falls back to `self.fixed_tp_pct` +(same value, so behaviourally neutral; see §6 `or` semantics). + +--- + +## 6. `AlphaExitManager.evaluate()` — the TP gate itself (aem :121-330) + +Called per bar with `current_price` (OBF-injected mid), `current_bar` = engine global bar idx, +`current_timestamp = float(bar_idx)`, `asset = pos.asset`. + +### 6.0 Pre-gate arithmetic (:138-159) + +```python +st = self._positions[trade_id] # missing → HOLD/NO_STATE :138-139 +pnl_pct = d * (current_price - entry) / entry # entry>0 guard, :146 +if pnl_pct > st["max_favorable"]: st["max_favorable"] = pnl_pct # :148-149 + +dynamic_tp_pct = st.get("tp_pct_override") or self.fixed_tp_pct # :151 ← `or`, not `is None` +dynamic_sl_pct = st.get("stop_pct_override") or self.stop_pct # :152 +dynamic_max_hold = st.get("max_hold_override") or self.max_hold_bars # :153 +base_tp_pct = dynamic_tp_pct # :159 captured BEFORE OB modulation +``` + +- `or`-semantics trap: an override of exactly `0` falls back to the global (harmless in live — + `set_live_tp_pct` refuses `<= 0`). + +### 6.1 OB modulation block (:163-202) — the "dormancy" question + +Guard (:163): `if hasattr(self, 'ob_engine') and self.ob_engine is not None and asset is not None:` +— `hasattr` is **trivially true** (attr created in `__init__` :74); the real gate is +`ob_engine is not None`, i.e. whether §7 attached an engine. + +```python +ob_signal = self.ob_engine.get_signal(asset, current_timestamp) # :164 +macro = self.ob_engine.get_macro() # :165 (no arg → LATEST live macro) +eff_imb = -ob_signal.imbalance_ma5 if d == -1 else ob_signal.imbalance_ma5 # :171 + +# TAIL AVOIDANCE 1 — cascade (market panic): :174-178 +if getattr(macro, 'cascade_count', 0.0) > 0.0: + dynamic_tp_pct *= 1.40 # WIDEN TP → 0.28% typ. + dynamic_max_hold = int(self.max_hold_bars * 0.5) # 125 bars + +# TAIL AVOIDANCE 2 — withdrawal stress: :181-190 +elif macro.regime_signal == 1: + dynamic_sl_pct = 0.10 # hard 10% stop + if pnl_pct > 0.0: dynamic_tp_pct *= 0.60 # TIGHTEN TP → 0.12% typ. + if eff_imb < -0.10: dynamic_max_hold = int(self.max_hold_bars * 0.4) + +# CONVEXITY — calm & trending for us: :193-195 +elif macro.regime_signal == -1 and eff_imb > 0.15: + dynamic_max_hold = int(self.max_hold_bars * 1.5) + +# Per-asset withdrawal (only when macro NOT in cascade/stress): :198-202 +if (getattr(macro, 'cascade_count', 0) == 0 and macro.regime_signal != 1 + and ob_signal.withdrawal_velocity < -0.20): + dynamic_max_hold = min(dynamic_max_hold, int(self.max_hold_bars * 0.40)) + if pnl_pct > 0.0: dynamic_tp_pct *= 0.75 # TIGHTEN TP → 0.15% typ. +``` + +Structural exclusivity: cascade / withdrawal / convexity are an `if/elif/elif`; the per-asset +block additionally requires `cascade_count == 0 AND regime_signal != 1`, so **the multipliers can +never stack** — `tp_mod_factor ∈ {1.40, 0.60, 0.75, 1.00}` exactly. + +### 6.2 Per-decision diagnostics (:204-223) — logged on EVERY decision, HOLD included + +```python +_floor_armed = bool(base_tp_pct > 0 and st["max_favorable"] >= base_tp_pct) # :208-210 +diag = {"tp_base_pct": base_tp_pct, "dynamic_tp_pct": dynamic_tp_pct, + "tp_mod_factor": dynamic_tp_pct/base_tp_pct if base_tp_pct>0 else 1.0, + "cascade_count": _cascade_count, "ob_regime_signal": _ob_regime_signal, + "tp_floor_armed": _floor_armed} # :211-218 +self.last_eval = {"trade_id":..., "bar":..., "pnl_pct":..., "max_favorable":..., **diag} # :219-223 +``` + +Consumers: +- v7 journal `_record_v7_decision` (trader :1204-1225): merges `decision[key]` else + `exit_manager.last_eval[key]` (trade-id-matched, :1209-1214) into row columns + `tp_base_pct, dynamic_tp_pct, tp_mod_factor, cascade_count, ob_regime_signal, tp_floor_armed` + of `dolphin.v7_decision_events` (DDL trader :1119-1124; `ENGINE MergeTree`, TTL 180d). + Journaled from two sources per bar while a position is open: `source="live_exit"` (provider hook, + :1316-1328 — last_eval one bar stale there) and `source="scan_eval"` (:4482-4493 — last_eval + fresh from this bar's `evaluate`). +- TP exit console line (trader :4574-4588): for reasons `FIXED_TP/HIBERNATE_TP/TP_FLOOR/ + HIBERNATE_TP_FLOOR`, logs `TP_EXIT: tp_pct=… dyn_tp=… mod=…x cascade=… bars_held=… pnl_pct=…`, + pulling `dynamic_tp_pct/tp_mod_factor/cascade_count` from `last_eval` because `_execute_exit` + strips diag keys (§3). +- `trade_events` payload (trader :879-884): `tp_threshold = eng.exit_manager.fixed_tp_pct` + (read at event-build time — the *unmodulated* live threshold), plus per-trade + `tp_base_pct/tp_effective_pct/our_leverage` from the pending snapshot (soft-curve context + captured at entry :4257 and refreshed at close :4714). + +### 6.3 Check order — what can preempt FIXED_TP (in exact evaluation order) + +Above the exit manager entirely: (a) HIBERNATE_HALT (orchestrator :365-371, only when posture +committed), (b) the V7 provider (`exit_decision_provider`, wired at trader :685 whenever +`AlphaExitEngineV7` loaded — `_v7_live_exit_enabled = self._v7_exit_engine is not None`, :683): +a provider **EXIT** (reasons like `V7.1_MAE_SL_VOL_NORM`) short-circuits `evaluate()` for that bar +(orchestrator :387-395); HOLD/RETRACT falls through. (c) Forced exits from +`_drain_runtime_commands` (trader :4551) bypass TP. + +Inside `evaluate()`: + +1. **TP_FLOOR ratchet** (:225-234) — FIRST, deliberately before everything so no modulation can + mask it (the LINK 5e05eeeb lesson): + `if tp_floor_enabled and _floor_armed and pnl_pct <= base_tp_pct → EXIT "TP_FLOOR"`. + Arming is on **base** (unmodulated, soft-curve) TP via `max_favorable`; while pnl stays above + base, the widened FIXED_TP gate keeps chasing upside. +2. vel_div adverse-turn exits `VD_INVALIDATION`/`VD_EXHAUSTION` (:242-277) — **`vd_enabled=False` + in live** (never set anywhere); dead code, OB-subject debounced logic. +3. TF-spread recovery `TF_FLIP`/`TF_EXHAUSTION` (:280-312) — **`tf_enabled=False` in live**; dead. +4. **FIXED_TP** (:314-317): `if dynamic_tp_pct > 0 and pnl_pct >= dynamic_tp_pct → EXIT "FIXED_TP"`. + This is the comparison that fires the take-profit: `>=`, against the OB-modulated + `dynamic_tp_pct`, using OBF-mid-based `pnl_pct`. +5. STOP_LOSS (:320-322): `pnl_pct <= -dynamic_sl_pct`. +6. MAX_HOLD (:325-327): `bars_held >= dynamic_max_hold`. +7. HOLD (:329-330). + +Numerically, in live: base ∈ [0.187%, 0.20%] (soft curve) and the effective FIXED_TP gate is +almost always ×1.40 → ≈ 0.262%-0.28% (see §8), with TP_FLOOR exiting at base on give-backs. + +--- + +## 7. ob_engine attachment — is the modulation block reachable? (code answer: YES) + +### 7.1 Wiring (trader `_wire_obf` :5135-5148, invoked from `_process_scan` :4094-4095 on the first +scan carrying an asset list) + +```python +from nautilus_dolphin.nautilus.hz_ob_provider import HZOBProvider +live_ob = HZOBProvider(hz_cluster="dolphin", hz_host="127.0.0.1:5701", assets=assets) +self.ob_eng = OBFeatureEngine(live_ob) # REAL engine, REAL provider +self.eng.set_ob_engine(self.ob_eng) # :5147 +``` + +- `NDAlphaEngine.set_ob_engine` (orchestrator :833-842) propagates to + `signal_gen.ob_engine`, `asset_selector.ob_engine`, and **`exit_manager.ob_engine`** (:842). + `reset()` re-propagates it (:753-755). +- `MockOBProvider` is imported at trader :36 but **never instantiated** anywhere in the file + (grep: exactly 1 occurrence, the import). The provider is `HZOBProvider` — live Hazelcast + OB snapshots (`asset_*_ob` keys from `obf_prefect_flow.py`), lazy-connect, push-listener cached. + Boot log `OBF wired: HZOBProvider, 50 assets (LIVE mode)` appears once per restart. + +### 7.2 Live macro production (`ob_features.py`) + +- Per scan, trader :4124-4125 calls `ob_eng.step_live(ob_assets, bar_idx)` (ob_features :535-671): + fetches a snapshot per asset, computes per-asset imbalance/depth features, per-asset + `withdrawal_velocity = compute_velocity_nb(depth_1pct_history, 60)` (60-snapshot lookback), and + cross-asset macro `process_macro_regime_nb(imb, vel, CASCADE_THRESHOLD=-0.10)` (:620-622, + constants :349-351): + - `cascade_count = Σ(velocity_i < −0.10)` (:640) — count of assets whose 60-step depth-drain + rate is below −10%. + - `regime_signal` (:248-265): `1` (STRESS) iff `cascade ≥ max(2, n/2)` AND `mean_vel < −0.10`; + `−1` (CALM) iff `mean_vel > 0.10`; else `0`. +- `get_macro()` with no arg (aem :165) → latest live macro (:716-731); missing → `NEUTRAL_MACRO` + (cascade 0, regime 0 → modulation no-op). `get_signal(asset, bar)` (:687-695) resolves the bar + directly in live mode with **fallback to the latest known bar** (:763-771); missing asset → + `NEUTRAL_SIGNAL` (imbalance 0, withdrawal 0). Staleness guard (:644-653) only logs after 3 + empty fetches — features silently persist from the last good bar (cache holds 500 bars). + +So statically: guard is live, engine is real, macro is real. The question is which *branches* fire. + +--- + +## 8. DORMANCY VERIFICATION — per-branch verdicts (empirical) + +Evidence sources: +- **Log:** `/tmp/dolphin_logs/supervisor/nautilus_trader.log` (2026-06-20 → 2026-07-12, UTC). +- **CH:** `dolphin.v7_decision_events` (per-decision diag; columns landed 2026-06-12) and + `dolphin.trade_events` — `curl -u dolphin:dolphin_ch_2026 http://localhost:8123/`. CH `ts` UTC. + +Measurements (reproduced verbatim): + +``` +-- TP_EXIT lines in live log (since 2026-06-20): + grep TP_EXIT … | grep -o "mod=…x" | sort | uniq -c → 198 mod=1.40x / 2 mod=1.00x + +-- All-time distinct tp_mod_factor in dolphin.v7_decision_events: + 0 143,754 (legacy rows pre-2026-06-12 + rows with diag missing → column DEFAULT 0) + 1.0 288 + 1.4 313,870 + (NO 0.6, NO 0.75, NO stacked values — ever) + +-- Last 30 days: mod=1.4 → 306,939 decisions; mod=1.0 → 262; (0 → 9,490 diag-missing) + +-- Rows with ob_regime_signal = 1 (STRESS observed 5,821 times since 2026-06-11): + tp_mod_factor = 1.4 for ALL 5,821 → cascade always co-occurred; the elif never reached ×0.60 + +-- Rows with cascade_count = 0 (diag era): tp_mod_factor = 1.0 only (288 rows) → ×0.75 never hit + +-- cascade_count on diag rows (since 2026-06-11): p05=10 median=18 p95=25 max=37 (of ~50 assets) + +-- trade_events CLOSE reasons since 2026-06-11: + FIXED_TP 1,784 · TP_FLOOR 1,522 · ADVSL_ASL_PRESSURE_CONT_STRESS 1,347 · MAX_HOLD 384 · + HOTKEY_RETRACT 279 · STOP_LOSS 238 · EXP_VOL_GATE_RELAX|… variants 115 · HIBERNATE_HIBERNATE_HALT 7 + +-- tp_floor_armed=1 on 12,604 decisions since 2026-06-12; TP threshold soft-curve transitions: 420 log lines. +``` + +### Verdicts + +| Branch | Multiplier | Verdict | Evidence | +|---|---|---|---| +| Cascade (aem :174-178) | TP ×1.40, max_hold ×0.5 | **FIRES-LIVE — effectively ALWAYS-ON (saturated)** | 313,870 journal decisions at mod=1.4 vs 288 at 1.0 all-time; 198/200 TP exits in the live log widened to dyn_tp 0.26-0.28%; cascade_count median 18/50 assets, p05=10 → `cascade_count > 0` is true on ~99.9% of decisions. | +| Withdrawal stress (aem :181-190) | TP ×0.60 (pnl>0), SL→10%, hold ×0.4 | **NEVER-OBSERVED (structurally shadowed)** | mod=0.60 appears 0 times all-time. Reachability: it is the `elif` after cascade; every one of the 5,821 STRESS (`regime_signal=1`) decisions ALSO had `cascade_count > 0`, so the branch was never entered — consistent with the regime-1 definition itself requiring `cascade ≥ max(2, n/2)`, which guarantees `cascade_count > 0`. **`regime_signal==1 ⇒ cascade_count>0` by construction (ob_features :259), so the ×0.60/10%-SL/0.4-hold branch is dead code under the current macro definition.** | +| Per-asset withdrawal (aem :198-202) | TP ×0.75 (pnl>0), hold ≤×0.40 | **NEVER-OBSERVED** | mod=0.75 appears 0 times all-time; requires `cascade_count == 0` (p05=10 → almost never true) AND `withdrawal_velocity < −0.20` AND `pnl>0`. The only cascade=0 diag rows (288) all show mod=1.0. | +| No modulation | ×1.00 | Rare | 288 decisions / 2 TP exits — brief windows where zero assets breached the −0.10 velocity threshold. | + +**Answer to the operator's "OB TP coefficient might be dormant" warning: it is the OPPOSITE of +dormant.** The wiring is real (HZOBProvider → OBFeatureEngine → `set_ob_engine` → `exit_manager +.ob_engine`), the `hasattr` guard is trivially satisfied, and the cascade ×1.40 widening is in +force on essentially every decision — the live effective TP gate is ≈0.262-0.28%, not 0.20%. The +"take profit at 0.20%" behaviour survives only because TP_FLOOR (armed on the base threshold) +exits regressions — which is why TP_FLOOR accounts for 46% of all TP-family closes (1,522 vs +1,784 FIXED_TP). The two tightening branches (×0.60, ×0.75) have never fired and — given the +cascade/regime definitions — cannot fire in practice: ×0.60 is provably unreachable +(regime 1 implies cascade > 0), ×0.75 requires a market state (zero cascading assets while one +asset drains >20%) that has not occurred in >450k logged decisions. + +Calibration note for the port (observation, not a change request): `CASCADE_THRESHOLD = −0.10` +against a 60-snapshot depth-velocity computed per ~11 s live scan means "any 1 of ~50 assets lost +>10% of near-book depth over ~11 min" — permanently true in this universe. In the 30 s-snapshot +backtest frame the same constant was far more selective. A parity port must replicate the LIVE +behaviour (×1.40 essentially always + TP_FLOOR backstop), not the intended semantics. + +--- + +## 9. Complete TP-write/read site index (for the port checklist) + +Writers of the effective gate, in precedence order at comparison time: + +1. `ENGINE_KWARGS.fixed_tp_pct = 0.0020` — trader :134 (birth). +2. Soft curve `compute_soft_tp_pct(0.0020, notional/capital)` — tp_curve :43-64, applied every + scan via trader :4165 → :906-926 → orchestrator :860-866 → aem :82-98 (sets BOTH + `fixed_tp_pct` and every open trade's `tp_pct_override`). Bounds [0.00187, 0.0020]. +3. Per-trade `tp_pct_override` at entry — always `None` in live (exp15 dormant, §5.1). +4. OB modulation ×1.40 / ×0.60 / ×0.75 — aem :174-202, transient per evaluation (never persisted). +5. TP_FLOOR — aem :232-234, exits at BASE (post-soft-curve, pre-modulation) once + `max_favorable ≥ base`; enabled via `DOLPHIN_TP_FLOOR` default ON (trader :612). + +Readers/consumers: FIXED_TP comparison aem :315; TP_FLOOR comparison aem :232; hibernate-protect +log trader :2438; trade_events `tp_threshold` trader :879; TP_EXIT log trader :4576-4588; +v7 journal columns trader :1218-1225; pending-entry snapshot fields +`tp_base_pct/tp_effective_pct/our_leverage` trader :4257 (entry) / :4714 (close) / retract legs. + +Known dead/disabled TP-adjacent paths (do NOT port as live behaviour): `vd_enabled` exits +(aem :242-277), `tf_enabled` exits (aem :280-312), exp15 entry-time TP/hold overrides (§5.1), +`_sync_tp_threshold`'s docstring HZ key (stale doc), `esf_alpha_orchestrator_AGENT_fork.py` +(research fork), `NDAlphaEngine.reset()`'s exit-manager rebuild dropping `tp_floor_enabled` +(orchestrator :726-740 — latent trap, unused live). diff --git a/prod/docs/uv_cert/exit_crawl/UV_EXIT_CRAWL.md b/prod/docs/uv_cert/exit_crawl/UV_EXIT_CRAWL.md new file mode 100644 index 0000000..f1f3452 --- /dev/null +++ b/prod/docs/uv_cert/exit_crawl/UV_EXIT_CRAWL.md @@ -0,0 +1,514 @@ +# UV EXIT-DOCTRINE CRAWL — current code, for BLUE parity comparison + +Date: 2026-07-12. READ-ONLY crawl of `/root/uv-wt/t0-integration/prod/clean_arch/violet/uv/` +(+ `prod/clean_arch/dita_v2/` for the kernel seam). All `file:line` references are against +that worktree. No code was modified. + +Files crawled in full: + +| File | Role | +|---|---| +| `violet/uv/brain_tpsl.py` (339 lines) | Doctrine constants, Position, TpSlHandler (tick → decision → EXIT intent) | +| `violet/uv/tpsl_ticker.py` (413 lines) | 1 s BingX mark-price poll thread, MAX_HOLD on_bar path, kernel reconcile | +| `violet/uv/max_hold_modulation.py` (183 lines) | Certified BLUE MAX_HOLD 5-branch port + OBF ingress provider | +| `violet/uv/blue_prime/runner.py` (493 lines) | TPSL construction / receipt-gated registration / ON_BAR invocation | +| `violet/uv/blue_prime/promotion.py` (590 lines) | Decision → KernelIntent, EXIT passthrough path | +| `violet/uv/exec/asex_kernel_executor.py` (303 lines) | Receipt statuses (CLOSED / exit_complete) | +| `dita_v2/contracts.py`, `dita_v2/rust_backend.py`, `dita_v2/_rust_kernel/src/lib.rs`, `dita_v2/bingx_venue.py`, `dita_v2/real_zinc_plane.py` | `exit_leg_ratios` consumer inventory | + +--- + +## 1. Exit-doctrine constants ("do as BLUE does" — contract values, not knobs) + +`brain_tpsl.py:23-31` (operator-confirmed 2026-07-10; comment: "Change here, with a test, or not at all"): + +| Constant | Value | Meaning | Line | +|---|---|---|---| +| `DOCTRINE_TP_PCT` | `0.0035` | fixed TP ceiling (+0.35 % favorable move) | brain_tpsl.py:26 | +| `DOCTRINE_SL_PCT` | `0.012` | BLUE contract stop (−1.2 %) | brain_tpsl.py:27 | +| `DOCTRINE_TRAIL_PCT` | `0.002` | trailing TP: exit on 0.20 % retrace from best | brain_tpsl.py:28 | +| `DOCTRINE_TRAIL_ACT_PCT` | `0.002` | trail arms once favorable move ≥ +0.20 % | brain_tpsl.py:29 | +| `DOCTRINE_MAX_HOLD_BARS` | `120` | bar cutoff — **fallback only** (see §6) | brain_tpsl.py:30 | +| `DOCTRINE_POLL_S` | `1.0` | interim tick cadence (OBF/WS sub-ms ticks = future C11) | brain_tpsl.py:31 | + +MAX_HOLD companion constants, `max_hold_modulation.py:15-16`: + +| Constant | Value | +|---|---| +| `BLUE_BASE_MAX_HOLD_BARS` | `250` (the BLUE base the 5-branch modulation scales) | +| `FALLBACK_MAX_HOLD_BARS` | `120` (defined; the ticker actually falls back to its own `_max_hold_bars`, seeded from `DOCTRINE_MAX_HOLD_BARS=120` — same value, two homes) | + +Production always runs the doctrine values: `maybe_build_ticker()` (`tpsl_ticker.py:388-413`) takes +**no** tp/sl/trail parameters — "No env vars, no arming knobs (2026-07-10 ruling)" (tpsl_ticker.py:395-400). +Constructor params on `TpSlTicker.__init__` exist "for TESTS, not ops" (tpsl_ticker.py:105-107). +Doctrine banner logged at startup: `tpsl_ticker.py:408-412` +(`"TPSL DOCTRINE: tp=%.2f%% sl=%.2f%% trail=%.2f%%@%.2f%% max_hold=%d bars poll=%.1fs"`). +Note the module docstring `tpsl_ticker.py:11-13` still describes env `UV_TP_PCT`/`UV_SL_PCT` and +`UV_TPSL_ENABLE=1` opt-in (lines 4-5) — **stale docstring**; the code path is unconditional doctrine. +Same for the runner comment at `runner.py:287` ("None when UV_TPSL_ENABLE!=1") — `maybe_build_ticker` +never returns None. + +--- + +## 2. Position validation (poison-proof ingress) + +`Position` dataclass, `brain_tpsl.py:42-71`. TP/SL are **required** fields with no class defaults +(D5, brain_tpsl.py:50-52). `__post_init__` (brain_tpsl.py:54-71) raises `InvalidPositionError` on: +empty `trade_id`/`asset`; `side` not in `{"SHORT","LONG"}`; non-finite or ≤0 `entry_price`, +`quantity`, `leverage`, `take_profit_pct`, `stop_loss_pct`. Tick-side poison guard (D6): +`PoisonTickError` class exists (brain_tpsl.py:38-39) but `on_tick` handles poison inline — +non-finite/≤0 price increments `poison_ticks` stat and returns `[]` (brain_tpsl.py:175-178). + +--- + +## 3. TpSlHandler — tick evaluation (the decision brain) + +Class at `brain_tpsl.py:113-338`. Holds `_positions: dict[trade_id → Position]`, +`_best_move: dict[trade_id → best favorable pct_move]` (trailing state), `_pending_exits: +dict[trade_id → trigger_type]`, and a stats dict (brain_tpsl.py:114-133). + +### 3.1 `on_tick` (brain_tpsl.py:165-199) + +Per tick: poison guard (§2) → for each position on `tick.symbol`, skip if `pending_exit` (line 185-186) +→ `_evaluate` → if triggered, `_fire_exit` + `_handle_exit_submission`; decision appended to results +only when state == `"closed"`. D4: rejected exits keep the position monitored. + +### 3.2 `_evaluate` — priority order (brain_tpsl.py:201-247) + +Favorable-move formula (brain_tpsl.py:203-206): + +``` +SHORT: pct_move = (entry_price − tick.price) / entry_price +LONG : pct_move = (tick.price − entry_price) / entry_price +``` + +Evaluation order (D7 — loss-protection wins ties): + +1. **Trailing state update first** (brain_tpsl.py:211-217): if `trail_pct > 0`, + `best = max(best_move[tid], pct_move)`; `act = trail_activation_pct or trail_pct`; + `_trail_hit = best >= act and (best − pct_move) >= trail_pct`. Trailing measures + **retrace-from-best**, not absolute level. The flag is only computed here; firing is deferred. +2. **SL** (brain_tpsl.py:220-225): `pct_move <= −stop_loss_pct` → `trigger_type="stop_loss"`. +3. **Trailing TP** (brain_tpsl.py:228-233): `_trail_hit` → `trigger_type="trailing_tp"`. + Deliberately before fixed TP: "locks retraces the fixed level misses" (line 227). +4. **Fixed TP** (brain_tpsl.py:236-241): `pct_move >= take_profit_pct` → `trigger_type="take_profit"`. +5. Otherwise `triggered=False, trigger_type="none"` (brain_tpsl.py:243-247). + +`ExitPriority` enum (brain_tpsl.py:99-110) reserves `CATASTROPHIC = 0` and `ADVSL = 3` as +**future** slots — named but unimplemented (the `_PRIORITY_MAP` at 107-110 only maps +stop_loss/take_profit and is itself currently unused by any consumer in this file). + +### 3.3 Pending-exit lifecycle (partial-fill safety) + +`_handle_exit_submission` (brain_tpsl.py:249-274): +- submission not accepted → `exit_rejected` stat, position stays watched, returns `"rejected"`. +- receipt present and `receipt.exit_complete` falsy → mark `_pending_exits[trade_id] = trigger_type`, + log `"TPSL: exit pending"` with status/fill/remaining, return `"pending"`. While pending, `on_tick` + skips the position (brain_tpsl.py:185-186) — no duplicate exit spam. +- receipt None or `exit_complete` → `remove_position` + `_count_terminal_trigger`, return `"closed"`. + +`clear_pending_exit` (brain_tpsl.py:152-154) re-enables a retry "after kernel truth proves no exit +remains in flight" — invoked only from the ticker's reconcile (§5.3). + +### 3.4 Kernel-truth retirement + +`confirm_closed` (brain_tpsl.py:156-160): "Retire a watch only after kernel/Zinc truth reports +terminal state" — pops the pending trigger_type and counts it (`tp_fired`/`sl_fired`/`trail_fired`, +brain_tpsl.py:276-282). `replace_position` (brain_tpsl.py:144-147) swaps in reconciled kernel truth +without touching `_best_move` (trailing memory survives reconciles; note: it also survives with the +OLD entry_price baseline if the kernel reports a different fill price — the pct_move baseline shifts +but `_best_move` is not rebased. Minor parity wrinkle to remember when porting AdvancedSL). + +### 3.5 `_fire_exit` — decision → EXIT intent dict (brain_tpsl.py:284-332) + +Builds the EXIT decision dict: + +```python +{"action": "EXIT", "has_entry": False, "asset", "side": pos.side, # D3: real side + "trade_id": pos.trade_id, # D2: ORIGINAL trade_id (kernel correlation) + "target_size": pos.quantity, # D2: full close quantity + "entry": {asset, side, trade_id, price=decision.price, leverage, size}} +``` + +Delivery: prefers `bridge.try_promote_result(decision, ctx=None)` (brain_tpsl.py:316-322, +atomic intent+receipt), falls back to `bridge.try_promote` + `bridge.last_receipt` +(brain_tpsl.py:323-325). No bridge → logged would-exit, `ExitSubmission(False)` (brain_tpsl.py:308-314). + +**Always a single full-quantity close.** `exit_leg_ratios` is never set → KernelIntent default +`(1.0,)` (contracts.py:319). No partial/multi-leg exits are ever emitted by UV (§9). + +--- + +## 4. Price source and cadence + +`tpsl_ticker.py:39-79`: +- Hosts: `_VST_HOST = https://open-api-vst.bingx.com`, `_LIVE_HOST = https://open-api.bingx.com`, + selected by `DOLPHIN_BINGX_ENV` (default VST) (tpsl_ticker.py:39-40, 67-68). +- Endpoint: `GET /openApi/swap/v2/quote/premiumIndex?symbol=BTC-USDT` → `data.markPrice` + (tpsl_ticker.py:41, 65-76). Symbol mapping `BTCUSDT → BTC-USDT` at tpsl_ticker.py:59-62. +- Fail-soft: any fetch failure returns `0.0` = skipped tick (tpsl_ticker.py:77-79). +- Cadence: `DOCTRINE_POLL_S = 1.0`, floor 0.5 s (`max(0.5, poll_s)`, tpsl_ticker.py:115). The + docstring's `UV_TPSL_POLL_S` env (line 8) is not read anywhere — cadence is the doctrine constant. +- Ticker thread `_run` (tpsl_ticker.py:356-385): each cycle = `_sync_from_kernel()` → fetch price per + watched asset → `handler.on_tick(TickEvent)` under lock → heartbeat every 60 polls + (tpsl_ticker.py:374-382) → `_stop.wait(poll_s)`. Catches everything: "the ticker must never die + silently" (tpsl_ticker.py:383-384). + +So: **TP / SL / trailing are driven by 1 s REST mark price; MAX_HOLD is driven by the ~6 s scan bar** +(on_bar). Two clocks, one handler. + +Trailing wiring: `tpsl_ticker.py:113-114` sets `handler.trail_pct` / `handler.trail_activation_pct` +from the doctrine constants — trailing is always armed in production. + +--- + +## 5. TpSlTicker — registration, MAX_HOLD, kernel reconcile + +### 5.1 `register_entry` (tpsl_ticker.py:138-153) + +Runner-thread API. Under `_lock`: `handler.add_position(Position(..., take_profit_pct=self._tp_pct, +stop_loss_pct=self._sl_pct))` + `_bars[trade_id] = 0`. Logs `"TPSL WATCH"`. Thread contract +(tpsl_ticker.py:85-88): `on_tick` only on ticker thread; `register_entry`/`stop` from runner thread. + +### 5.2 `on_bar` — the MAX_HOLD path (tpsl_ticker.py:170-213) + +Called by the runner **once per processed scan** (= one bar, doctrine). Per watched position: +- skip if pending exit (line 180-181); +- `bars = _bars[tid] + 1` (line 182-183); +- `max_hold_bars, journal_row = _resolve_max_hold(pos, bars)` (line 184) — recomputed **every scan** + from the same scan snapshot BLUE's exit manager sees; +- `_journal_max_hold(journal_row)` (line 185) — every evaluation journaled, hit or not; +- if `bars >= max_hold_bars`: price = `_last_price.get(asset) or _fetch(asset) or entry_price` + (line 188 — entry_price fallback means a dead feed still force-closes, at 0 recorded move); + builds `TpSlDecision(trigger_type="max_hold", ...)` (lines 189-195) and pushes it through the + same `_fire_exit` / `_handle_exit_submission` machinery as tick exits (lines 196-200). + `"MAX_HOLD EXIT"` on close (with branch), `"MAX_HOLD EXIT REJECTED ... still watched"` on reject + (lines 203-213). Note `"max_hold"` is not counted by `_count_terminal_trigger` + (brain_tpsl.py:276-282 counts only tp/sl/trail) — max_hold closes are visible in logs/journal but + not in the fired-stats counters. + +### 5.3 `_resolve_max_hold` — ingress + loud fallback (tpsl_ticker.py:215-255) + +- If an ingress provider is wired and returns a `MaxHoldIngress`: `blue_max_hold_yield(ingress)` + → `(result.max_hold_bars, result.journal_fields(bars_held))` (lines 219-226). +- Any provider exception is logged (`LOGGER.exception`) and falls through (lines 227-228). +- **No-ingress fallback**: `_max_hold_fallbacks += 1`; first occurrence per trade logs LOUD + `"MAX_HOLD NO-INGRESS FALLBACK: ... fixed_limit=120"` (lines 230-238); returns + `(120, journal_row{branch: "NO_INGRESS_FALLBACK", fallback: 1, all-zero OBF fields})` + (lines 239-255). Comment at 126-129: "No-ingress FALLBACK only (doctrine constant, no env — + 2026-07-10 ruling). Armed BLUE-parity operation injects the complete OBF ingress and recomputes + 100/125/250/375 every scan." +- Journaling failure is CRITICAL-logged (`"MAX_HOLD INGRESS NOT PERSISTED"`) and counted, never + blocks the exit (tpsl_ticker.py:257-272). Journal sink = `PrimeJournal.write_max_hold` + (blue_prime/journal.py:330). + +### 5.4 `_sync_from_kernel` — Zinc slot truth reconcile (tpsl_ticker.py:284-353) + +Runs at the top of every ticker cycle. "The ticker never owns an order book or position ledger" +(tpsl_ticker.py:287-290). Against `position_provider()` slots (= `zinc_plane.read_slots`, +runner.py:245): +- terminal (`slot.closed` or `fsm_state ∈ {IDLE, POSITION_CLOSED, CLOSED, TRADE_TERMINAL_WRITTEN}`, + set at tpsl_ticker.py:43-48) → `confirm_closed` + drop bar counter (lines 306-310); +- live slot → `replace_position` with kernel-truth entry_price/size/side/leverage, keeping the + original tp/sl pcts (lines 311-323); +- pending exit but slot no longer `EXIT_WORKING` and no `active_exit_order` → `clear_pending_exit` + ("retry enabled", lines 324-327); +- **restart rehydration**: kernel slot open (`fsm_state ∈ _OPEN_STATES`, tpsl_ticker.py:49-56) with + valid size/price/leverage/side but not watched → `add_position` with doctrine tp/sl, + `_bars.setdefault(tid, 0)`, `"TPSL REHYDRATE"` (lines 329-353). Rehydrated positions restart the + hold clock at 0 — bars_held is not recoverable from the slot (BLUE-parity gap for restarts). + +--- + +## 6. MAX_HOLD modulation — the certified BLUE port + +`max_hold_modulation.py` — "Bit-faithful BLUE MAX_HOLD modulation... branch order mirrors +`AlphaExitManager.evaluate` in the read-only BLUE source" (lines 1-6). Pure function; acquisition +and journaling at the edges. + +### 6.1 Ingress contract (`MaxHoldIngress`, max_hold_modulation.py:19-44) + +Fields: `trade_id, asset, side, evaluation_bar, cascade_count, regime_signal, imbalance_ma5, +withdrawal_velocity, base_max_hold_bars=250, source="blue_obf"`. Validation rejects empty ids, +bad side, negative bar/cascade, non-positive base, non-finite floats. + +### 6.2 `blue_max_hold_yield` — formulas (max_hold_modulation.py:65-103) + +``` +effective_imbalance = −imbalance_ma5 if side==SHORT else imbalance_ma5 (:69-73) +base = 250 + +if cascade_count > 0: → int(base*0.5) = 125 CASCADE (:77-79) +elif regime_signal == 1: WITHDRAWAL_STRESS (:80-81) + and effective_imbalance < −0.10: → int(base*0.4) = 100 WITHDRAWAL_STRESS_ADVERSE (:82-84) +elif regime_signal == −1 + and effective_imbalance > 0.15: → int(base*1.5) = 375 FAVORABLE_EXTENSION (:85-87) + +# independent override (can cut the favorable extension): +if cascade_count == 0 and regime_signal != 1 + and withdrawal_velocity < −0.20: → min(current, int(base*0.40)) = 100 + PER_ASSET_WITHDRAWAL (:89-96) +else → 250 NEUTRAL +``` + +"Order is doctrinal. A positive cascade preempts regime_signal entirely" (:76). The resulting cap +set is exactly BLUE's {100, 125, 250, 375}. `MaxHoldYield.journal_fields` (:47-62) flattens +ingress + `bars_held`, `effective_imbalance`, `max_hold_bars`, `branch`, `fallback`. + +### 6.3 `build_engine_ingress_provider` (max_hold_modulation.py:120-182) + +Captures "the same OBF variables the embedded BLUE manager sees per scan": +- `engine.exit_manager.ob_engine` (fallbacks `engine.ob_engine`/`ob_eng`); None → no ingress (:124-129). +- Requires `manager.max_hold_bars` and `engine._global_bar_idx` hydrated; **hard-raises** if the + embedded BLUE base ≠ 250 (`"embedded BLUE max_hold_bars=...; expected 250"`, :131-139). +- `evaluation_bar = _global_bar_idx − 1`; ≤0 → no ingress (:140-143). +- Clock-drift guard (:145-152): if the OBF engine exposes `_live_mode`/`_live_bar_idx`, they must be + `True` and == evaluation_bar — otherwise return None rather than let `get_signal()` serve a stale + cache entry ("hide clock drift"). This is the hook-02 snapshot contract. +- Pulls `ob_engine.get_signal(asset, evaluation_bar)` + `ob_engine.get_macro()`; requires + `cascade_count, regime_signal, imbalance_ma5, withdrawal_velocity` all present (`_required_value` + never invents a neutral, :109-117, :154-169); builds the `MaxHoldIngress` (:170-180). +- Any missing dependency → None → §5.3 loud 120 fallback. + +**This provider is the ingress pattern (`MaxHoldIngressProvider = Callable[[position, bars_held], +MaxHoldIngress | None]`, :106) that the PORT-SURFACE section names as the template.** + +--- + +## 7. Runner wiring (blue_prime/runner.py) + +- Imports: `build_engine_ingress_provider` + `maybe_build_ticker` (runner.py:203-204). +- Executor/venue arm path (runner.py:240-260): non-dark mode builds + `build_launcher_bundle(max_slots=10, prefix="uv_exec", venue_mode="BINGX")`, + `ASExKernelExecutor(bundle.kernel)`, `position_provider = bundle.zinc_plane.read_slots`, + E-capital provider, E-feed. +- Bridge (runner.py:279-286): `PromotionBridge(gate, intent_source, journal, + submitter=_exec_executor, tradable_assets, capital_provider)`. +- **Ticker construction** (runner.py:288-293): + + ```python + tpsl_ticker = maybe_build_ticker( + bridge, + position_provider=_position_provider, + max_hold_ingress_provider=build_engine_ingress_provider(harness.engine), + max_hold_journal=journal.write_max_hold, + ) + ``` + +- **ON_BAR invocation** (runner.py:332-343): immediately after `harness.step(...)` (the engine + step_bar) and **before** promotion — "Existing positions see the same post-OBF scan as BLUE's exit + manager... a position entered on this scan starts its hold clock on the next scan, exactly as + BLUE." Wrapped so an on_bar exception can never kill the scan loop (`"MAX_HOLD ON_BAR ERROR"`). +- **Receipt-gated registration** (runner.py:355-383): after `bridge.try_promote_result(decision=out, + ctx)`, registration requires ALL of: promoted; sized intent present (T19-FIX: register from + `bridge` result's SIZED intent, "never the raw entry dict — the dict lacks bridged quantity/price, + which watched ghost positions (qty=0) on 2026-07-10 first-arming"); `receipt.entry_protectable` + (ENTER + has_fill + avg price > 0, see §8); `action == "ENTER" and target_size > 0`. Registration + uses **receipt truth**: `entry_price=float(_receipt.average_fill_price)`, + `quantity=float(_receipt.fill_quantity)` (runner.py:366-373). Promoted-but-not-protectable → + `"ENTRY NOT WATCHED YET"` warning with status/fill/remaining (runner.py:376-383); the §5.4 + reconcile later rehydrates it from the slot if it fills. +- Shutdown (runner.py:460-484): `_e_feed.stop()` → `tpsl_ticker.stop()` → `hz.shutdown()` → + `_exec_executor.close()` → `_exec_bundle.close()`. + +--- + +## 8. Promotion — how a TpSl decision becomes an EXIT intent + +`blue_prime/promotion.py`. + +### 8.1 Action resolution (promotion.py:119-136) + +`_resolve_action`: explicit `action=="ENTER"/"EXIT"` wins; unknown explicit action → suppress; +no action + `has_entry` → ENTER (legacy); no action + no entry → **None** ("NEVER inferred as EXIT; +that inference was the R1 regression: every no-entry scan spammed the kernel with zero-size exits"). + +### 8.2 EXIT gate path (`_try_promote_locked`, promotion.py:401-509) + +Serialized under `_promote_lock` RLock (promotion.py:356-360, 393) — the tick thread and scan thread +promote through the same critical section. Sequence: +1. Two-man arming gate (env `UV_PROMOTED==1` + arming file, re-evaluated every call — + promotion.py:60-107, 409-410). Not armed → suppressed+journaled. +2. `_action is None` → suppress `no_entry` (:415-417). +3. **D8 zero-qty guard** (:418-426): explicit EXIT with `target_size` (or entry.size) ≤0/non-finite + → `LOGGER.error("PROMOTION: EXIT with no positive quantity — suppressed")`, reason `exit_zero_qty`. +4. Universe gate (:428-439): applies to ENTER only — "**EXITs always pass: closing must never be + blocked by the gate**" (:431). +5. Capital + translation `build_kernel_intent_from_decision` (:441-448). +6. Delivery: `submitter.submit(intent)` (ASExKernelExecutor) when armed, else + `intent_source.enqueue(intent)` shadow queue (:450-457). +7. Receipt-status audit fix (:459-479): `KERNEL_REJECTED`/`VENUE_REJECTED` → journaled SUPPRESSED + (`receipt:`), returns False ("unsuppressed phantom rows were the 25-row reconciliation gap + in the 2026-07-10 audit"). Also re-checked at :502-503. +8. Success → journal `kind='BRIDGE'` row incl. receipt fields (:527-560), `last_intent` set (:483). + +### 8.3 EXIT translation (`build_kernel_intent_from_decision`, promotion.py:139-308) + +For `action is KernelCommandType.EXIT` (promotion.py:235-242): + +```python +target_size = decision.target_size or entry.size or entry.target_size # position qty passthrough +leverage = int(decision.leverage or entry.leverage or 1) # position's leverage +_bridge_note = "exit_passthrough" # sizing bridge NEVER run +``` + +"re-sizing a close would orphan part of the position" (:237). `trade_id` correlation (:268-271): +decision-level original trade_id first — "a fresh id would orphan the open position in the kernel." +`reference_price` = entry.entry_price/reference_price/price (:197-203) = the trigger tick price +placed by `_fire_exit`. `slot_id` = decision/entry slot_id or **0** (:273) — all UV intents share +slot 0 (see EXIT_ASSET_MISMATCH guard, §9.3). + +**`exit_leg_ratios` is never populated** — the constructed `KernelIntent` (promotion.py:295-308) +omits it, so the contract default `(1.0,)` (contracts.py:319) rides through. UV's exits are always +single-leg 100 % closes. + +### 8.4 Full call chains + +**Tick exit (sub-second path):** +``` +TpSlTicker._run (1 s poll, tpsl_ticker.py:356) + → _sync_from_kernel (Zinc truth) tpsl_ticker.py:284 + → TpSlHandler.on_tick(TickEvent) brain_tpsl.py:165 + → _evaluate: SL → trailing → TP brain_tpsl.py:201 + → _fire_exit → dict{action:"EXIT", target_size=qty} brain_tpsl.py:284 + → PromotionBridge.try_promote_result promotion.py:386 + → build_kernel_intent_from_decision (exit_passthrough) promotion.py:238 + → ASExKernelExecutor.submit(intent) asex_kernel_executor.py:275 + → single-writer ASExWorker → kernel.process_intent asex_kernel_executor.py:238 + → Rust FSM EXIT (lib.rs:1418) → venue.submit → fills → CLOSED + → ExecutionReceipt(status, exit_complete) asex_kernel_executor.py:117-118 + → _handle_exit_submission: closed | pending | rejected brain_tpsl.py:249 + → (pending) later reconcile via _sync_from_kernel → confirm_closed +``` + +**MAX_HOLD exit (bar path):** +``` +runner scan loop → harness.step() → tpsl_ticker.on_bar() runner.py:328-337 + → bars+1 → _resolve_max_hold tpsl_ticker.py:184,215 + → build_engine_ingress_provider(engine)(pos, bars) max_hold_modulation.py:120 + → blue_max_hold_yield → {100,125,250,375} | 120 fallback max_hold_modulation.py:65 + → journal.write_max_hold(row) tpsl_ticker.py:257 / journal.py:330 + → bars >= cap → TpSlDecision("max_hold") → same _fire_exit path as above +``` + +--- + +## 9. Kernel seam — receipts and `exit_leg_ratios` inventory + +### 9.1 Receipt statuses (exec/asex_kernel_executor.py) + +`ReceiptStatus` (asex_kernel_executor.py:30-37): `KERNEL_REJECTED, VENUE_REJECTED, ACKED, +PARTIAL_FILL, FULL_FILL, CLOSED`. Derivation `_receipt_from_outcome` (:127-206): +- rejected if any emitted event kind ∈ {ORDER_REJECT, RATE_LIMITED} → VENUE_REJECTED (:136-140, 180-181); +- `not outcome.accepted` → KERNEL_REJECTED (:182-183); +- **CLOSED** iff `intent.action is EXIT` **and** a FULL_FILL event exists **and** final FSM state ∈ + `{IDLE, POSITION_CLOSED, CLOSED, TRADE_TERMINAL_WRITTEN}` (:174-189); +- else FULL_FILL / PARTIAL_FILL / ACKED (:190-195). +- `fill_quantity` = Σ fill-event sizes; `average_fill_price` = size-weighted over priced fills; + `remaining_quantity` from last fill event (:141-169). + +Exit-relevant properties: `exit_complete = status is CLOSED` (:116-118) — the exact flag +`_handle_exit_submission` gates retirement on; `entry_protectable = ENTER ∧ has_fill ∧ +average_fill_price > 0` (:108-114) — the runner's registration gate. Idempotency: +`_intent_fingerprint` (:47-61) hashes economic content; same intent_id + different content → +`IntentIdentityCollision`; same id + same content → cached receipt replay (:225-235). Single-writer: +only the ASExWorker thread calls `kernel.process_intent` (:1-6, 209-249). + +### 9.2 `KernelIntent.exit_leg_ratios` — every consumer in dita_v2 (prod code) + +Definition: `contracts.py:319` (`KernelIntent.exit_leg_ratios: Tuple[float, ...] = (1.0,)`); +slot-side state `contracts.py:211-212` (`TradeSlot.exit_leg_ratios`, `active_leg_index`) with +`next_exit_ratio` (:244-248, clamped [0,1], default 1.0 past end) and `consume_exit_leg` +(:250-253, advances `active_leg_index`); serialized in `TradeSlot.to_dict` (:293-294). + +| Consumer | file:line | What it does | +|---|---|---| +| Python→Rust intent payload | rust_backend.py:484 (`_intent_to_payload`) | carries `list(intent.exit_leg_ratios)` into the Rust FSM | +| Intent finiteness guard | rust_backend.py:446-449 (`_first_invalid_intent_field`) | rejects non-finite ratios (INVALID_INTENT) at the kernel boundary | +| Slot round-trip Rust→Python | rust_backend.py:421-422 (`_slot_from_payload`) | restores `exit_leg_ratios` + `active_leg_index` on slot snapshots | +| Slot view leg ops | rust_backend.py:616-623 (`KernelSlotView.next_exit_ratio` / `consume_exit_leg`) | Python mirror that mutates the backend slot | +| **Rust FSM — leg state** | `_rust_kernel/src/lib.rs:339,462` (struct fields), `lib.rs:425-438` (`next_exit_ratio`/`consume_exit_leg`) | canonical leg machinery | +| **Rust FSM — ENTER stamps ratios** | lib.rs:1356-1361 | slot.exit_leg_ratios = intent's ratios (or `[1.0]`), `active_leg_index = 0` | +| **Rust FSM — EXIT sizes the leg** | lib.rs:1434-1444 | `exit_ratio = slot.next_exit_ratio(); exit_size = max(initial_size, size)*ratio`; stamped as the attached exit order's `intended_size` | +| **Rust FSM — fill-side leg progression** | lib.rs:2154-2198 | on non-partial exit fill: `all_legs_done_pre` computed BEFORE `consume_exit_leg()` (G4 fix); `should_close = size<=1e-12 ∨ (!partial ∧ all_legs_done)`; else → back to `POSITION_OPEN` awaiting next leg; partial → `EXIT_WORKING` | +| Venue adapter (BingX) | bingx_venue.py:409 | copies `intent.exit_leg_ratios` onto the LegacyIntent — **but submits `target_size`**, ratios are carried, not consumed | +| Zinc plane persistence | real_zinc_plane.py:102-103 (slot read), :256 (intent row `exit_leg_ratios` column) | persisted/journaled, not decisioned | +| Slot hydration elsewhere | flat_and_start_pink.py:284,336,402; monitor_pink.py:104,130,177 | ops tooling, always `(1.0,)` | +| Test/gen-only | gen2.py, gen_live_tests.py, _gen_test.py, _build_pink_bodies.py, test_asex_account.py:23, test_pink_persistence.py:413-424,752-766, test_flaws.py:1119 (G4 regression), test_ditav2_chaos_fuzz_adversarial.py:346 | exercise multi-leg (0.5/1.0, 0.3/1.0, 0.25/0.25/1.0, 0.25/0.5/1.0, 0.33/0.33/1.0) | + +### 9.3 Verdict: multi-leg exit IS implemented in the kernel FSM — and UV never uses it + +The Rust FSM genuinely implements multi-leg: ENTER stamps the ladder (lib.rs:1356-1361), each EXIT +intent derives the leg's `intended_size` from `initial_size × next_exit_ratio` (lib.rs:1434-1436), +and fill handling advances/closes the ladder with the G4-corrected ordering (lib.rs:2154-2198), +returning the slot to `POSITION_OPEN` between legs. Two caveats for a port: +1. **The venue order quantity is still `intent.target_size`** (bingx_venue.py:404 → + `target_size=float(intent.target_size)`); the FSM's ratio-derived `exit_size` shapes the attached + order's accounting, so the caller must send per-leg quantities consistent with the declared + ratios (exactly what the dita_v2 generated live tests do). +2. **UV's promotion path never sets `exit_leg_ratios`** (promotion.py:295-308 omits it) and + `_fire_exit` always sends `target_size = pos.quantity` — so in UV today every exit is a single + 100 % leg and the kernel ladder machinery sits idle but ready. Additional guard relevant to + exits: `_exit_asset_mismatch_outcome` (rust_backend.py:764-792) rejects an EXIT naming a + different asset than slot 0 holds (2026-07-10/11 phantom-pair fix). + +--- + +## 10. WHAT IS MISSING relative to BLUE + +BLUE's live exit stack (nautilus_event_trader + alpha_exit_manager + tp_curve.py + AdvancedSL): + +| # | BLUE mechanism | BLUE behavior (reference) | UV today | Status | +|---|---|---|---|---| +| M1 | **Cubic TP curve** (`tp_curve.py`) | TP level is a cubic function of conviction/signal strength, not a constant | flat `DOCTRINE_TP_PCT = 0.0035` for every trade (brain_tpsl.py:26; stamped per-position at tpsl_ticker.py:148) | **MISSING** — no per-trade TP computation anywhere in `violet/uv/` | +| M2 | **OB TP modulation** (order-book cascade widening, `alpha_exit_manager.py:153` ×1.40 class) | TP silently widened/modulated by OB cascade state each bar | none — `Position.take_profit_pct` is immutable after registration; `replace_position` preserves it (tpsl_ticker.py:321) | **MISSING** (note: BLUE's unlogged ×1.40 was itself the LINK −$1.2K diagnosis; port must include TP_FLOOR + logging) | +| M3 | **TP_FLOOR ratchet** | Hard floor under any modulated TP so widening can't erase the fixed TP | none — no floor concept; nothing modulates TP yet, so nothing enforces a floor either | **MISSING** | +| M4 | **Catastrophic SL** | Immediate unconditional exit at extreme adverse move, priority above everything | `ExitPriority.CATASTROPHIC = 0` reserved (brain_tpsl.py:102) but never evaluated; only the single −1.2 % contract stop exists | **MISSING** (named placeholder only) | +| M5 | **Overlay SL** (EFSM post-win LONG overlay risk logic) | Direction-overlay-aware stop handling on BLUE's live LONG side | UV models base direction only; EFSM mirror exists in runner (`EfsmMirror`, runner.py:211) but feeds no exit logic | **MISSING** | +| M6 | **Bucket SL** | Per-bucket (cohort/asset-class) stop aggregation | none — exits are strictly per-position; no cross-position state in TpSlHandler | **MISSING** | +| M7 | **Withdrawal SL** | Withdrawal-velocity-driven stop tightening | only the MAX_HOLD `PER_ASSET_WITHDRAWAL` branch (cap→100 bars) uses withdrawal_velocity (max_hold_modulation.py:89-96); no price-stop analogue | **PARTIAL** (hold-time only, not stop-level) | +| M8 | **AdvancedSL multi-leg runtime** | Staged partial exits (leg ladders) driven by a runtime SL/TP state machine | kernel FSM fully supports leg ladders (§9.3) but UV never emits `exit_leg_ratios ≠ (1.0,)` and has no leg planner; `ExitPriority.ADVSL = 3` reserved unused (brain_tpsl.py:104) | **MISSING** (kernel-ready, brain-absent) | +| — | Trailing TP | retrace-from-best 0.20 % after +0.20 % arm | implemented (brain_tpsl.py:211-233) | present | +| — | Fixed TP / contract SL | +0.35 % / −1.2 % | implemented (brain_tpsl.py:220-241) | present | +| — | MAX_HOLD OBF modulation | 5-branch 100/125/250/375 | certified port (max_hold_modulation.py) + per-scan journal | present | + +Cross-cutting gaps also worth flagging for parity: +- **Price cadence**: 1 s REST mark-price vs BLUE's event-driven bars/ticks; sub-ms OBF/WS is C11 + (tpsl_ticker.py:8-9, brain_tpsl.py:31). +- **bars_held not persisted**: rehydrated positions restart the hold clock at 0 (§5.4). +- **Trailing best not rebased** on kernel-truth entry-price reconcile (§3.4). +- **"max_hold" trigger not counted** in terminal stats (§5.2). +- Stale docstrings claiming env-gated TPSL (tpsl_ticker.py:4-13, runner.py:287) vs the actual + always-on doctrine factory. + +--- + +## 11. PORT-SURFACE — where each BLUE mechanism attaches + +The proven template is the **MaxHoldIngress pattern** (max_hold_modulation.py): a frozen validated +ingress dataclass + a pure `blue_*_yield()` function (bit-faithful branch order, property-testable) ++ a `Callable[[position, bars_held], Ingress | None]` provider built over `harness.engine`'s +embedded BLUE objects (`build_engine_ingress_provider`) + loud journaled fallback in the ticker + +wiring through `maybe_build_ticker(...)` kwargs in `runner.py:288-293`. Every port below should +follow it: pure yield module, engine-ingress provider, per-evaluation journal row, no-ingress LOUD +fallback to the current doctrine constant. + +| BLUE mechanism | Attach point (class/function/seam) | Shape of the port | +|---|---|---| +| **M1 cubic TP curve** | `TpSlTicker.register_entry` (tpsl_ticker.py:138-149) — the single place `take_profit_pct` is stamped onto a `Position` | new `tp_curve_modulation.py` with `blue_tp_yield(TpIngress) -> tp_pct`; a `tp_ingress_provider(position_like) -> TpIngress|None` kwarg on `TpSlTicker.__init__`/`maybe_build_ticker`; fallback = `DOCTRINE_TP_PCT`, journaled. Runner passes conviction/signal from the SIZED intent (`_promoted_intent.metadata["promo_raw_conviction"]`, promotion.py:285) at registration time | +| **M2 OB TP modulation** | `TpSlTicker.on_bar` (tpsl_ticker.py:170-186) — the per-scan hook that already iterates positions with the fresh OBF snapshot; mutate via a new `TpSlHandler.retarget_tp(trade_id, tp_pct)` (sibling of `replace_position`, brain_tpsl.py:144) | same OBF acquisition as `build_engine_ingress_provider` (max_hold_modulation.py:120-182 — reuse the `_live_mode/_live_bar_idx` clock guard verbatim); per-scan recompute + journal, exactly like `_resolve_max_hold`/`_journal_max_hold` (tpsl_ticker.py:215-272) | +| **M3 TP_FLOOR ratchet** | inside the M1/M2 yield function itself (clamp before return), enforced a second time in `TpSlHandler._evaluate` before the TP check (brain_tpsl.py:236) | doctrine constant `DOCTRINE_TP_FLOOR_PCT` in brain_tpsl.py:26-31 block; journal the pre-clamp vs post-clamp value (the BLUE LINK lesson: the widening was *unlogged*) | +| **M4 catastrophic SL** | `TpSlHandler._evaluate` (brain_tpsl.py:201-247) — add the check ABOVE the SL check, honoring `ExitPriority.CATASTROPHIC = 0` (brain_tpsl.py:102); new `trigger_type="catastrophic"` + `_count_terminal_trigger` arm (brain_tpsl.py:276) | pure threshold constant (doctrine block); tick-driven, no ingress needed; also add `"max_hold"`/`"catastrophic"` to the stats counter while there | +| **M5 overlay SL** | two seams: side/overlay truth enters at `runner.py` where `EfsmMirror` already lives (runner.py:211, 314) and at registration (runner.py:366-373 — pass overlay flag into `register_entry`); evaluation joins `_evaluate` as a side-aware stop branch | extend `Position` with an `overlay` field (validated in `__post_init__`); ingress = EFSM state provider following the MaxHoldIngress pattern | +| **M6 bucket SL** | new aggregate pass in `TpSlTicker._run` between `_sync_from_kernel()` and the per-asset tick loop (tpsl_ticker.py:360-372) — the only place all watched positions + fresh prices coexist | bucket map provider kwarg on `maybe_build_ticker`; fires the same `_fire_exit` per member position (D4 semantics free) | +| **M7 withdrawal SL** | `TpSlTicker.on_bar` (bar-cadence, OBF-driven) as an SL-tightening sibling of M2: `TpSlHandler.retarget_sl(trade_id, sl_pct)` | reuses `withdrawal_velocity` already flowing through `MaxHoldIngress` (max_hold_modulation.py:27, 159) — the ingress provider can be shared, one acquisition, two yields | +| **M8 AdvancedSL multi-leg** | (a) leg plan at entry: `build_kernel_intent_from_decision` (promotion.py:295-308) gains `exit_leg_ratios=` on the ENTER intent — the Rust FSM stamps the ladder at lib.rs:1356-1361; (b) leg-sized exits: `TpSlHandler._fire_exit` (brain_tpsl.py:284-306) sends `target_size = leg quantity` (per §9.3 the venue submits `target_size`; keep it consistent with the declared ratios); (c) leg progression truth: `_sync_from_kernel` already reads `active_exit_order`/size back (tpsl_ticker.py:311-327) — extend to read `active_leg_index` from the slot | kernel is ready today (lib.rs:1418-1459, 2154-2198 incl. G4 fix); the whole port is brain-side: a leg planner in TpSlHandler + partial-exit `_pending_exits` semantics (current pending logic already tolerates `FULL_FILL`-per-leg receipts — a leg fill returns status FULL_FILL, not CLOSED, so `exit_complete=False` keeps it pending until reconcile; that reconcile path must learn "leg done ≠ trade done") | +| **Future sub-ms price plane (C11)** | `TpSlTicker.__init__ price_fetch` seam (tpsl_ticker.py:100, 118) and `_run` loop (tpsl_ticker.py:356) | swap REST poll for OBF/WS tick feed; `TickEvent` already carries `source_ts_ns`/`ingest_mono_ns` (brain_tpsl.py:74-79) for latency accounting | + +**Rule of thumb for all ports**: constants go in the brain_tpsl.py doctrine block (contract values, +change-with-a-test); acquisition goes in a `build_*_ingress_provider` over `harness.engine`; math +goes in a pure `blue_*_yield` module mirroring the BLUE source's predicate order; every evaluation +is journaled via a `journal.write_*` sink; and no ingress ⇒ LOUD logged fallback to the current +doctrine constant — never a silent invented neutral (`_required_value`, max_hold_modulation.py:109-117). + +*End of crawl.*