diff --git a/prod/nautilus_event_trader.py b/prod/nautilus_event_trader.py new file mode 100644 index 0000000..304f63c --- /dev/null +++ b/prod/nautilus_event_trader.py @@ -0,0 +1,5147 @@ +#!/usr/bin/env python3 +""" +DOLPHIN Nautilus Event-Driven Trader +""" +import sys +import json +import hashlib +import math +import os +import time +import signal +import threading +import urllib.request +import uuid +from dataclasses import replace +from typing import Any, Mapping, Optional +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from pathlib import Path +from collections import deque + +# Stablecoins / pegged assets that must never be traded +_STABLECOIN_SYMBOLS = frozenset({ + 'USDCUSDT', 'BUSDUSDT', 'FDUSDUSDT', 'USDTUSDT', 'TUSDUSDT', + 'DAIUSDT', 'FRAXUSDT', 'USDDUSDT', 'USTCUSDT', 'EURUSDT', +}) + +sys.path.insert(0, '/mnt/dolphinng5_predict') +sys.path.insert(0, '/mnt/dolphinng5_predict/nautilus_dolphin') + +from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine +from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDPosition +from nautilus_dolphin.nautilus.adaptive_circuit_breaker import AdaptiveCircuitBreaker +from nautilus_dolphin.nautilus.ob_features import OBFeatureEngine +from nautilus_dolphin.nautilus.ob_provider import MockOBProvider +from nautilus_dolphin.nautilus.esof_size_gate import ( + parse_esof_payload, esof_gate_from_payload, esof_score_from_payload, + esof_size_mult_from_score, ESOF_STALE_FALLBACK_MULT, ESOF_FRESHNESS_S, +) +from prod.clean_arch.adapters.eigen_scan_normalizer import normalize_ng7_scan +from prod.clean_arch.obf_tp_observation import inject_obf_midprice +from prod.clean_arch.tp_curve import compute_our_leverage, compute_soft_tp_pct +try: + sys.path.insert(0, '/mnt/dolphinng5_predict/Observability') + from esof_advisor import compute_esof as _compute_esof_inline +except Exception: + _compute_esof_inline = None +try: + from adaptive_exit.market_state_runtime import MarketStateRuntime +except Exception: + MarketStateRuntime = None +try: + from adaptive_exit.advanced_sl import AdvancedSLRuntime +except Exception: + AdvancedSLRuntime = None +try: + from adaptive_exit.sc_threshold_advisor import SCThresholdAdvisor +except Exception: + SCThresholdAdvisor = None +try: + from adaptive_exit.sc_gauge_advisor import SCGaugeAdvisor, build_obf_snapshot_from_engine +except Exception: + SCGaugeAdvisor = None + build_obf_snapshot_from_engine = None +try: + from adaptive_exit.bounce_advisor import BounceAdvisor +except Exception: + BounceAdvisor = None +try: + from adaptive_exit.post_win_long_overlay import PostWinExecutionFSM +except Exception: + PostWinExecutionFSM = None +try: + from nautilus_dolphin.nautilus.alpha_exit_v7_engine import AlphaExitEngineV7, TradeContextV7 +except Exception: + AlphaExitEngineV7 = None + TradeContextV7 = None + +BLUE_CH_DB = "dolphin" + +try: + from prod.ch_writer import ch_put, ts_us as _ch_ts_us +except ImportError: + def ch_put(*a, **kw): pass + def _ch_ts_us(): return 0 +try: + from prod.execution_quality import build_execution_quality_record + from prod.execution_quality import build_trade_execution_quality_summary +except Exception: + build_execution_quality_record = None + build_trade_execution_quality_summary = None + +try: + from announcement_router import build_announcement_center +except ImportError: + from prod.announcement_router import build_announcement_center + +sys.path.insert(0, '/mnt/dolphinng5_predict/prod') +from dolphin_exit_handler import install_exit_handler +install_exit_handler("nautilus_trader") +from prod.clean_arch.runtime.runner_heartbeat import ( + build_runner_heartbeat_payload, + write_runner_heartbeat, +) + +HZ_CLUSTER = "dolphin" +HZ_HOST = "127.0.0.1:5701" +EIGEN_DIR = Path('/mnt/dolphinng6_data/eigenvalues') + +CAPITAL_DISK_CHECKPOINT = Path("/tmp/dolphin_capital_checkpoint.json") +CAPITAL_CORRECTIVE_REPLAY = Path("/tmp/dolphin_latest_nautilus_replay.json") +CAPITAL_UPDATE_LEDGER = Path("/tmp/dolphin_capital_update_ledger.json") +CAPITAL_CORRECTIVE_REPLAY_HZ_KEY = "capital_correction_replay" +ANNOUNCEMENT_CONFIG = Path("/mnt/dolphinng5_predict/prod/configs/position_notifications_blue.json") +ANNOUNCEMENT_RUNTIME_ENV = Path("/mnt/dolphin_training/observability_notifications_blue.runtime.json") + +# Economic dust floor for OPEN position_state rows and retract remainders. +# A remainder at/below this is a FULL CLOSE, never an OPEN snapshot. The +# lifecycle invariant "OPEN ⇒ size > dust" is enforced at the single write +# gate (_ps_write_open); zero/dust-size OPEN rows are the malformed class +# behind the 2026-06-11 restore restart-loop (MALFORMED_OPEN_RESTORE_BUG.md). +# $0.01 sits far above the round(notional,4)=0 boundary (5e-5), so a row +# that passes the gate can never round to a zero notional on disk. +POSITION_DUST_NOTIONAL_USD = 0.01 + +ENGINE_KWARGS = dict( + initial_capital=25000.0, vel_div_threshold=-0.02, vel_div_extreme=-0.05, + min_leverage=0.5, max_leverage=8.0, # note: create_d_liq_engine overrides to D_LIQ_SOFT_CAP=8.0 + leverage_convexity=3.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% + use_direction_confirm=True, dc_lookback_bars=7, dc_min_magnitude_bps=0.75, + dc_skip_contradicts=True, dc_leverage_boost=1.0, dc_leverage_reduce=0.5, + use_asset_selection=True, min_irp_alignment=0.0, # gold spec: no IRP filter + use_sp_fees=True, use_sp_slippage=True, + sp_maker_entry_rate=0.62, sp_maker_exit_rate=0.50, + use_ob_edge=True, ob_edge_bps=5.0, ob_confirm_rate=0.40, + lookback=100, use_alpha_layers=True, use_dynamic_leverage=True, seed=42, + allow_subday_acb_exit=False, +) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = float(raw) + except (TypeError, ValueError): + return default + return value if math.isfinite(value) else default + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = int(float(raw)) + except (TypeError, ValueError): + return default + return value + + +def _direction_from_env(value: Optional[str] = None) -> int: + raw = os.environ.get("DOLPHIN_DIRECTION", "short_only") if value is None else value + text = str(raw or "short_only").strip().lower() + if text in {"short", "short_only", "sell", "-1"}: + return -1 + if text in {"long", "long_only", "buy", "+1", "1"}: + return 1 + raise ValueError( + f"Unsupported DOLPHIN_DIRECTION={raw!r}; use short_only or long_only" + ) + + +def _direction_label(direction: int) -> str: + return "LONG" if int(direction) == 1 else "SHORT" + + +def _normalize_v7_exit_reason(reason: str) -> str: + text = str(reason or "").strip() + if text == "V7_MAE_SL_VOL_NORM": + return "V7.1_MAE_SL_VOL_NORM" + return text + + +def _safe_float(value, default: float = 0.0) -> float: + try: + out = float(value) + except (TypeError, ValueError): + return default + return out if math.isfinite(out) else default + + +def _flatten_env_payload(payload, prefix: str = "") -> dict: + flat = {} + if not isinstance(payload, dict): + return flat + for key, value in payload.items(): + if not isinstance(key, str) or not key.strip(): + continue + full_key = f"{prefix}_{key}" if prefix else key + if isinstance(value, dict): + flat.update(_flatten_env_payload(value, full_key)) + else: + flat[full_key.upper()] = value + return flat + + +def _seed_runtime_env(path: Path) -> None: + if not path.exists(): + return + try: + payload = json.loads(path.read_text()) + except Exception: + return + for key, value in _flatten_env_payload(payload).items(): + if key not in os.environ and value not in (None, "", "__CHANGE_ME__", "__REPLACE_ME__"): + os.environ[key] = str(value) + +BTC_VOL_WINDOW = 50 + +# Per-bucket SL % used when HIBERNATE fires while a position is open. +# Instead of immediate HIBERNATE_HALT, we arm TP (existing fixed_tp_pct) + +# a per-bucket stop-loss so the position exits cleanly rather than being +# force-closed at whatever price the halt fires at. +# Values derived from AE shadow data + bucket trade analysis (2026-04-19). +# B3 wide: shadow shows mae_norm 5-5.1 before FIXED_TP; 3.5×ATR fires on noise. +# B4 tight: 34.8% WR, 0.80 R:R — cut fast, no recovery value. +# B6 widest: extreme vol (vol_daily_pct 760-864); normal ATR excursions are large. +_BUCKET_SL_PCT: dict = { + 0: 0.015, # Low-vol high-corr nano-cap + 1: 0.012, # Med-vol low-corr mid-price (XRP/XLM class) + 2: 0.015, # Mega-cap BTC/ETH — default (not traded) + 3: 0.025, # High-vol mid-corr STAR bucket (ENJ/ADA/DOGE) — needs room + 4: 0.008, # Worst bucket (BNB/LTC/LINK) — cut fast + 5: 0.018, # High-vol low-corr micro-price (ATOM/TRX class) + 6: 0.030, # Extreme-vol mid-corr (FET/ZRX) — widest + 'default': 0.015, +} +# Gold-calibrated from full 5-year BTC history: 0.00026414 (stricter, ~2.7x tighter). +# 2026-04-07: switched to 56-day gold window value (0.00009868) — the exact threshold +# used in the T=2155 ROI=+189% backtest. More permissive; paper trading to gather data. +# 2026-05-09 weekend mode: runtime-configurable lower gate for low-vol tape. +# +# Legacy references preserved: +# VOL_P60_THRESHOLD_LEGACY_MAIN = 0.00026414 +# VOL_P60_THRESHOLD_GOLD_56D = 0.00009868 +VOL_P60_THRESHOLD_LEGACY_MAIN = 0.00026414 +VOL_P60_THRESHOLD_GOLD_56D = 0.00009868 +VOL_P60_THRESHOLD_WEEKEND_DEFAULT = 0.00003 +VOL_P60_THRESHOLD_RELAXED_TEMP = 0.00015838 +# Backward-compatible alias retained for older tests and tooling. +VOL_P60_THRESHOLD = VOL_P60_THRESHOLD_LEGACY_MAIN + + +def _vol_p60_threshold_from_env(default: float = VOL_P60_THRESHOLD_LEGACY_MAIN) -> float: + raw = os.environ.get("DOLPHIN_VOL_P60_THRESHOLD") + if raw is None: + return float(default) + try: + out = float(str(raw).strip()) + except Exception: + return float(default) + if not math.isfinite(out) or out <= 0.0: + return float(default) + return float(out) + +# Algorithm Versioning +# v1_shakedown: v50-v150 (noise bug), loose vol gate +# v2_gold_fix: CORRECTED v50-v750 macro divergence (matches parquet backtest) +ALGO_VERSION = "v2_gold_fix_v50-v750" + +# Persistent, version-tagged trade log (survives reboots; sorts by date). +# Keep a local fallback path so mount hiccups never break runtime callbacks. +_LOG_DIR_PRIMARY = "/mnt/dolphinng5_predict/prod/logs" +_LOG_DIR_FALLBACK = "/tmp/dolphin_logs/trader" +_LOG_IO_LAST_WARN_TS = 0.0 +running = True +_PROCESS_BOOT_TS = time.time() +_SIGTERM_STARTUP_GRACE_S = 20.0 + +# ── Scan-flow watchdog (2026-06-10) ────────────────────────────────────────── +# BLUE went deaf 3× on 2026-06-09 (scan listener/worker stalled silently while +# supervisord showed RUNNING) and lost most of a trading session. The watchdog +# detects a stalled scan path and self-exits with WATCHDOG_EXIT_CODE so +# supervisord (autorestart=true) brings the process back clean. Restore of +# capital + position state on boot is the proven recovery path. +SCAN_STALL_S = 120.0 # scan path considered stalled after this +WATCHDOG_RESTART_MIN_UPTIME_S = 600.0 # never self-restart during warm-up +WATCHDOG_PROBE_INTERVAL_S = 30.0 # spacing between HZ deafness probes +UPSTREAM_DARK_LOG_EVERY_S = 300.0 # CRITICAL reminder cadence when dark +WATCHDOG_EXIT_CODE = 86 +# Scanner restarts reset scan_number to 0. A backwards jump larger than this +# is a restart (accept + re-anchor ratchet), not a stale duplicate (drop). +SCAN_NUMBER_RESET_GAP = 1000 + +def _trade_log_paths(ts_dt: datetime) -> tuple[str, str]: + log_date = ts_dt.strftime("%Y%m%d") + fname = f"nautilus_trader_{log_date}_{ALGO_VERSION}.log" + return os.path.join(_LOG_DIR_PRIMARY, fname), os.path.join(_LOG_DIR_FALLBACK, fname) + +def log(msg): + global _LOG_IO_LAST_WARN_TS + ts_dt = datetime.now(timezone.utc) + ts = ts_dt.isoformat() + line = f"[{ts}] {msg}" + print(line, flush=True) + primary_path, fallback_path = _trade_log_paths(ts_dt) + try: + os.makedirs(_LOG_DIR_PRIMARY, exist_ok=True) + with open(primary_path, 'a') as f: + f.write(line + '\n') + return + except OSError as e: + now = time.time() + if now - _LOG_IO_LAST_WARN_TS >= 60.0: + _LOG_IO_LAST_WARN_TS = now + print(f"[{ts}] LOG_PATH_FALLBACK: primary log write failed: {e}", flush=True) + try: + os.makedirs(_LOG_DIR_FALLBACK, exist_ok=True) + with open(fallback_path, 'a') as f: + f.write(line + '\n') + except Exception: + # Last-resort: stdout still has the log line. + pass + + +def _chain_digest(payload: dict) -> str: + """Stable digest for BLUE exit-chain state.""" + body = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode() + return hashlib.sha256(body).hexdigest() + + +def _build_chain_state( + *, + trade_id: str, + asset: str, + side: str, + entry_price: float, + quantity: float, + notional: float, + entry_bar: int, + entry_ts: int, + retraction_legs: int = 0, + realized_pnl_legs_total: float = 0.0, + chain_root_trade_id: str | None = None, + chain_head_leg_id: str | None = None, + chain_prev_leg_id: str = "", + chain_mode: str = "LIVE", +) -> dict: + """Build a deterministic chain snapshot for the current open trade head.""" + root = str(chain_root_trade_id or trade_id or "") + seq = max(0, int(retraction_legs)) + head = str(chain_head_leg_id or (f"{trade_id}:open" if seq <= 0 else f"{trade_id}:x{seq:03d}")) + prev = str(chain_prev_leg_id or "") + anchor = { + "trade_id": str(trade_id or ""), + "chain_root_trade_id": root, + "chain_head_leg_id": head, + "chain_prev_leg_id": prev, + "chain_seq": seq, + "chain_mode": str(chain_mode or "LIVE"), + "asset": str(asset or ""), + "side": str(side or "").upper(), + "entry_price": round(float(entry_price or 0.0), 12), + "quantity": round(float(quantity or 0.0), 12), + "notional": round(float(notional or 0.0), 12), + "entry_bar": int(entry_bar or 0), + "entry_ts": int(entry_ts or 0), + "retraction_legs": seq, + "realized_pnl_legs_total": round(float(realized_pnl_legs_total or 0.0), 12), + } + anchor["chain_token"] = _chain_digest(anchor) + anchor["chain_version"] = 1 + anchor["chain_kind"] = "ROOT" if seq <= 0 else "LEG" + return anchor + +class DolphinLiveTrader: + def __init__(self): + self.eng = None + self.hz_client = None + self.features_map = None + self.safety_map = None + self.pnl_map = None + self.state_map = None + self.heartbeat_map = None + self.control_map = None + self.eng_lock = threading.Lock() + self._heartbeat_stop = threading.Event() + self._runtime_command_lock = threading.Lock() + self._dedup_lock = threading.Lock() # guards atomic check-and-set on last_scan_number + self._scan_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scan") + self.last_scan_number = -1 + # Scan-flow watchdog state. Event ts proves the HZ listener is alive; + # accept ts proves the worker thread is draining; the dupe counter + # separates "worker stuck" from "upstream flooding duplicates". + self._last_scan_event_ts = time.time() + self._last_scan_accept_ts = time.time() + self._dupe_drops_total = 0 + self._watchdog_stop = threading.Event() + self._probe_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wdprobe") + self.last_file_mtime = 0 + self.bar_idx = 0 + self.current_day = None + self.trades_executed = 0 + self.scans_processed = 0 + self.btc_prices = deque(maxlen=BTC_VOL_WINDOW + 2) + self.cached_posture = "APEX" + self.posture_cache_time = 0 + self.ob_assets = [] + self.ob_eng = None + self.acb = None + self.last_w750_vel = None + self._pending_entries: dict = {} # trade_id → entry snapshot (for CH trade_events) + self._last_exf: dict = {} + self._last_engine_snapshot_payload = None + self._exf_log_time = 0.0 # throttle for on_exf_update logging + self._ae = None # AdaptiveExitEngine shadow (parallel, never real exits) + self._v7_exit_engine = None # AlphaExitEngineV7 live BLUE exit control + journal + self._v7_contexts: dict = {} # trade_id → TradeContextV7 + self._v7_decisions: dict = {} # trade_id → latest v7 decision + self._v7_decision_seq: dict = {} # trade_id → monotonic eval sequence + self._v7_journal_enabled: bool = _env_bool("DOLPHIN_ENABLE_V7_JOURNAL", True) + self._v7_journal_db: str = BLUE_CH_DB + self._v7_journal_table: str = "v7_decision_events" + self._v7_live_exit_enabled: bool = False + self._sc_advisor = None # SC threshold advisor (shadow-only) + self._sc_advisor_last_log = 0.0 + self._sc_gauge = None # SC bucket gauge advisor (shadow-only) + self._sc_gauge_last_log = 0.0 + self._bounce_advisor = None # inverse-ARS bounce advisor (shadow-only) + self._bounce_advisor_last_log = 0.0 + self._bounce_price_history: dict[str, deque] = {} + self._last_prices_dict: dict[str, float] = {} + self._market_state_runtime = MarketStateRuntime() if MarketStateRuntime is not None else None + self._tp_base_pct = float(ENGINE_KWARGS.get("fixed_tp_pct", 0.0020)) + self._advanced_sl = AdvancedSLRuntime.load() if AdvancedSLRuntime is not None else None + self._advanced_sl_live_exit_enabled: bool = _env_bool("DOLPHIN_ENABLE_ADVANCED_SL_LIVE", False) + if self._advanced_sl is not None and self._advanced_sl_live_exit_enabled: + self._advanced_sl.config = replace(self._advanced_sl.config, enabled=True) + self._catastrophic_floor_pct: float = max( + 0.0, + _env_float("DOLPHIN_CATASTROPHIC_FLOOR_PCT", 0.0120), + ) + self._overlay_catastrophic_floor_pct: float = max( + 0.0, + _env_float("DOLPHIN_OVERLAY_CATASTROPHIC_FLOOR_PCT", 0.0050), + ) + self._overlay_catastrophic_max_loss_usd: float = max( + 0.0, + _env_float("DOLPHIN_OVERLAY_CATASTROPHIC_MAX_LOSS_USD", 500.0), + ) + self._overlay_advsl_live_exit_enabled: bool = _env_bool("DOLPHIN_OVERLAY_ADVSL_LIVE", True) + self._overlay_advsl_min_bars: int = max(0, _env_int("DOLPHIN_OVERLAY_ADVSL_MIN_BARS", 6)) + self._overlay_advsl_mfe_max_pct: float = max(0.0, _env_float("DOLPHIN_OVERLAY_ADVSL_MFE_MAX_PCT", 0.0020)) + self._overlay_advsl_pressure_min: float = max(0.0, _env_float("DOLPHIN_OVERLAY_ADVSL_PRESSURE_MIN", 1.85)) + self._overlay_advsl_mae_risk_min: float = max(0.0, _env_float("DOLPHIN_OVERLAY_ADVSL_MAE_RISK_MIN", 0.50)) + self._hibernate_protect_active: str | None = None # trade_id being protected + self._bucket_assignments: dict = {} # asset → KMeans bucket_id (loaded from pkl) + self._last_esof_size_mult: float = 1.0 + self._restore_failed: bool = False + self._restore_failure_reason: str = "" + self._restore_source: str = "" + self.trade_direction: int = _direction_from_env() + self.vol_p60_threshold: float = _vol_p60_threshold_from_env() + self._runtime_direction: int = self.trade_direction + self._efsm = PostWinExecutionFSM() if PostWinExecutionFSM is not None else None + self._trade_announcement_center = None + self._processed_retract_commands: deque = deque(maxlen=5000) + self._processed_retract_set: set[str] = set() + _seed_runtime_env(ANNOUNCEMENT_RUNTIME_ENV) + if ANNOUNCEMENT_CONFIG.exists(): + try: + self._trade_announcement_center = build_announcement_center( + ANNOUNCEMENT_CONFIG, + hz_getter=self._get_hz, + logger=None, + ) + log(" Position announcements: loaded") + except Exception as e: + log(f" Position announcements: {e}") + self._trade_announcement_center = None + if self._efsm is not None: + log(" EFSM: loaded (post-win LONG overlay)") + if self._advanced_sl is not None: + log(" AdvancedSL: loaded (shadow prototype)") + + def _get_hz(self): + """Return a live Hazelcast client for announcement channels.""" + hz = self.hz_client + if hz is None: + return None + try: + if not hz.lifecycle_service.is_running(): + return None + except Exception: + return None + return hz + + def _latest_maras_context(self) -> dict: + """Best-effort MARAS context for meta exit gates.""" + try: + if self.features_map is None: + return {} + raw = self.features_map.blocking().get("maras_latest") + if not raw: + return {} + payload = json.loads(raw) if isinstance(raw, str) else raw + if not isinstance(payload, dict): + return {} + return { + "composite_hash": payload.get("composite_hash", payload.get("hash", 0)), + "scalar_hash": payload.get("scalar_hash", 0), + "regime": payload.get("regime", ""), + "final_score": payload.get("final_score", 0.0), + "confidence": payload.get("confidence", 0.0), + } + except Exception: + return {} + + def _resolve_runtime_direction(self) -> int: + """Resolve active trade direction for the next eligible entry.""" + base = int(self.trade_direction) + if base != -1 or self._efsm is None: + return base + with self.eng_lock: + has_open_position = getattr(self.eng, "position", None) is not None + if has_open_position: + return base + return 1 if int(self._efsm.pending_slots) > 0 else base + + def _apply_runtime_direction(self) -> None: + """Apply current runtime direction to the engine regime.""" + resolved = self._resolve_runtime_direction() + with self.eng_lock: + if getattr(self.eng, "regime_direction", self.trade_direction) != resolved: + self.eng.regime_direction = resolved + self._runtime_direction = resolved + + def _build_engine(self): + log("Building NDAlphaEngine...") + engine_kwargs = dict(ENGINE_KWARGS) + engine_kwargs["allow_subday_acb_exit"] = _env_bool( + "DOLPHIN_ALLOW_ACB_SUBDAY_EXIT", + bool(engine_kwargs.get("allow_subday_acb_exit", False)), + ) + self.eng = create_d_liq_engine(**engine_kwargs) + # TP profit-floor ratchet (LINK 5e05eeeb, 2026-06-11): once the BASE + # 0.20% TP has been crossed, regression back to base exits (TP_FLOOR) + # instead of riding the OB-widened threshold back to a loss. Class + # default is OFF (backtest/champion parity); live default is ON. + # Kill switch: DOLPHIN_TP_FLOOR=0. + self.eng.exit_manager.tp_floor_enabled = _env_bool("DOLPHIN_TP_FLOOR", True) + log(f" Engine: {type(self.eng).__name__}") + log(f" TP profit-floor: {'ON' if self.eng.exit_manager.tp_floor_enabled else 'OFF'}") + log(f" Direction: {_direction_label(self.trade_direction)} ({self.trade_direction:+d})") + log( + " VOL gate threshold: " + f"{self.vol_p60_threshold:.8f} " + f"(legacy_main={VOL_P60_THRESHOLD_LEGACY_MAIN:.8f}, gold_56d={VOL_P60_THRESHOLD_GOLD_56D:.8f}, " + f"relaxed_temp={VOL_P60_THRESHOLD_RELAXED_TEMP:.7f})" + ) + log(f" ACB subday exits: {'ON' if engine_kwargs['allow_subday_acb_exit'] else 'OFF'}") + log(f" Leverage: soft={self.eng.base_max_leverage}x abs={self.eng.abs_max_leverage}x") + + if EIGEN_DIR.exists(): + try: + date_strings = sorted([d.name for d in EIGEN_DIR.iterdir() if d.is_dir()]) + self.acb = AdaptiveCircuitBreaker() + self.acb.preload_w750(date_strings) + self.eng.set_acb(self.acb) + log(" ACBv6: loaded") + except Exception as e: + log(f" ACBv6: {e}") + else: + self.acb = AdaptiveCircuitBreaker() + self.eng.set_acb(self.acb) + log(" ACBv6: loaded (no preload dates)") + + self.eng.set_esoteric_hazard_multiplier(0.0) # gold spec: init guard, MUST precede set_mc_forewarner + log(f" Hazard: set_esoteric_hazard_multiplier(0.0) — soft={self.eng.base_max_leverage}x") + + MC_MODELS_DIR = '/mnt/dolphinng5_predict/nautilus_dolphin/mc_results/models' + MC_BASE_CFG = { + 'trial_id': 0, 'vel_div_threshold': -0.020, 'vel_div_extreme': -0.050, + 'use_direction_confirm': True, 'dc_lookback_bars': 7, + 'dc_min_magnitude_bps': 0.75, 'dc_skip_contradicts': True, + 'dc_leverage_boost': 1.00, 'dc_leverage_reduce': 0.50, + 'vd_trend_lookback': 10, 'min_leverage': 0.50, 'max_leverage': 8.00, # gold spec + 'leverage_convexity': 3.00, 'fraction': 0.20, 'use_alpha_layers': True, + 'use_dynamic_leverage': True, 'fixed_tp_pct': 0.0020, 'stop_pct': 1.00, + 'max_hold_bars': 250, 'use_sp_fees': True, 'use_sp_slippage': True, # gold spec + 'sp_maker_entry_rate': 0.62, 'sp_maker_exit_rate': 0.50, + 'use_ob_edge': True, 'ob_edge_bps': 5.00, 'ob_confirm_rate': 0.40, + 'ob_imbalance_bias': -0.09, 'ob_depth_scale': 1.00, + 'use_asset_selection': True, 'min_irp_alignment': 0.0, + 'asset_selector_lookback': 10, 'lookback': 100, # gold spec + 'acb_beta_high': 0.80, 'acb_beta_low': 0.20, 'acb_w750_threshold_pct': 60, + } + if Path(MC_MODELS_DIR).exists(): + try: + from mc.mc_ml import DolphinForewarner + forewarner = DolphinForewarner(models_dir=MC_MODELS_DIR) + self.eng.set_mc_forewarner(forewarner, MC_BASE_CFG) + log(" MC-Forewarner: wired") + except Exception as e: + log(f" MC-Forewarner: {e}") + + try: + from adaptive_exit.adaptive_exit_engine import AdaptiveExitEngine + self._ae = AdaptiveExitEngine.load() + log(" AdaptiveExitEngine: loaded (shadow mode — no real exits)") + except Exception as e: + log(f" AdaptiveExitEngine: {e} — shadow disabled") + + if AlphaExitEngineV7 is not None and self._v7_journal_enabled: + try: + self._v7_exit_engine = AlphaExitEngineV7(bar_duration_sec=11.0) + self._ensure_v7_journal_table() + log(" AlphaExitEngineV7: loaded (live BLUE exit control + journal)") + except Exception as e: + log(f" AlphaExitEngineV7: {e} — shadow disabled") + self._v7_exit_engine = None + self._v7_live_exit_enabled = self._v7_exit_engine is not None + if self.eng is not None: + self.eng.exit_decision_provider = self._v7_live_exit_decision if self._v7_live_exit_enabled else None + + self._load_bucket_assignments() + + if SCThresholdAdvisor is not None: + try: + self._sc_advisor = SCThresholdAdvisor.load( + strategy="blue", + shadow_db=BLUE_CH_DB, + ) + log(" SCThresholdAdvisor: loaded (shadow mode — no sizing changes)") + except Exception as e: + log(f" SCThresholdAdvisor: {e} — shadow disabled") + self._sc_advisor = None + + if SCGaugeAdvisor is not None: + try: + self._sc_gauge = SCGaugeAdvisor.load( + strategy="blue", + shadow_db=BLUE_CH_DB, + ) + log(" SCGaugeAdvisor: loaded (shadow mode — no sizing changes)") + except Exception as e: + log(f" SCGaugeAdvisor: {e} — shadow disabled") + self._sc_gauge = None + + if BounceAdvisor is not None: + try: + self._bounce_advisor = BounceAdvisor.load( + strategy="blue", + shadow_db=BLUE_CH_DB, + ) + log(" BounceAdvisor: loaded (shadow mode — no execution changes)") + except Exception as e: + log(f" BounceAdvisor: {e} — shadow disabled") + self._bounce_advisor = None + + def _load_bucket_assignments(self): + """Load KMeans asset→bucket_id mapping for hibernate protection SL levels.""" + try: + import pickle + pkl_path = Path('/mnt/dolphinng5_predict/adaptive_exit/models/bucket_assignments.pkl') + with open(pkl_path, 'rb') as f: + data = pickle.load(f) + self._bucket_assignments = data.get('assignments', {}) + log(f" BucketAssignments: {len(self._bucket_assignments)} assets loaded for hibernate protection") + except Exception as e: + log(f" BucketAssignments: {e} — hibernate protect will use default SL={_BUCKET_SL_PCT['default']*100:.1f}%") + + def _announce_position_event( + self, + *, + kind: str, + severity: str, + title: str, + message: str, + metadata: dict | None = None, + ) -> None: + center = getattr(self, "_trade_announcement_center", None) + if center is None: + return + try: + center.note_event( + kind=kind, + severity=severity, + title=title, + message=message, + metadata=metadata or {}, + ) + except Exception as e: + log(f" Position announcement failed: {e}") + + def _read_esof_payload(self) -> dict | None: + """Read the freshest EsoF advisory payload from HZ, if available.""" + if not self.features_map: + return None + for key in ("esof_latest", "esof_advisor_latest"): + try: + raw = self.features_map.blocking().get(key) + except Exception: + continue + payload = parse_esof_payload(raw) + if payload: + return payload + return None + + def _sync_esof_size_gate(self) -> None: + """Update the shared engine with the current continuous EsoF size multiplier. + + When the HZ payload is stale or missing (daemon died, HZ restarted), + falls back to inline computation using the canonical compute_esof() from + esof_advisor.py — single implementation, no parallel code. + """ + payload = self._read_esof_payload() + score = esof_score_from_payload(payload, max_age_s=ESOF_FRESHNESS_S) + source = "hz" + + if score is None and _compute_esof_inline is not None: + try: + inline = _compute_esof_inline() + score = esof_score_from_payload(inline, max_age_s=None) + if score is not None: + source = "inline" + payload = inline + except Exception: + pass + + mult = esof_size_mult_from_score(score) + with self.eng_lock: + if hasattr(self.eng, "set_esof_advisory_score"): + self.eng.set_esof_advisory_score(score) + if mult != self._last_esof_size_mult: + self._last_esof_size_mult = mult + if score is None: + log(f"EsoF size gate: STALE-FALLBACK mult={mult:.2f} (no HZ + no inline)") + elif source == "inline": + log(f"EsoF size gate: INLINE sc={score:+.3f} mult={mult:.2f} (HZ stale)") + else: + log(f"EsoF size gate: sc={score:+.3f} mult={mult:.2f}") + + def _tp_curve_context(self, *, notional: float | None = None) -> dict[str, Any]: + pos = getattr(self.eng, "position", None) + capital = float(getattr(self.eng, "capital", 0.0) or 0.0) + if notional is None: + if pos is not None: + pos_notional = _safe_float(getattr(pos, "notional", 0.0), 0.0) + if pos_notional <= 0.0: + pos_notional = _safe_float( + getattr(pos, "size", 0.0) * getattr(pos, "entry_price", 0.0), + 0.0, + ) + notional = pos_notional + else: + notional = 0.0 + our_leverage = compute_our_leverage(notional=notional, capital=capital) + tp_effective_pct = compute_soft_tp_pct(self._tp_base_pct, our_leverage) + bundle = {} + if self._market_state_runtime is not None and getattr(self._market_state_runtime, "latest_bundle_dict", None): + bundle = dict(self._market_state_runtime.latest_bundle_dict) + return { + "tp_base_pct": float(self._tp_base_pct), + "tp_effective_pct": float(tp_effective_pct), + "our_leverage": float(our_leverage), + "market_state_bundle_json": json.dumps(bundle, default=str, sort_keys=True) if bundle else "{}", + } + + def _sync_tp_threshold(self) -> None: + """Read live TP threshold from HZ control plane and propagate to engine. + + HZ key: DOLPHIN_FEATURES["live_tp_threshold"] → JSON {"tp_pct": 0.0020, "ts": ...} + If absent or stale, keeps the current default (0.0020 from ENGINE_KWARGS). + A tighter TP cuts open positions immediately; a wider TP extends the hold. + """ + try: + 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}% " + f"(soft curve, lev={ctx.get('our_leverage', 0.0):.2f}x)" + ) + except Exception: + pass + + def _inject_obf_midprice(self, prices_dict: dict) -> dict: + """Override scan price for the open position's asset with live OB mid-price. + + Scan prices are quantized to ~4 decimal places (e.g. 0.1255 vs 0.1256), + which is too coarse for a 0.20% TP on low-priced assets. The OBF universe + service has live WebSocket bid/ask at ~0.5s resolution with full precision. + This method substitutes the scan price with (best_bid + best_ask) / 2 for + the position's asset only, so TP evaluation sees the freshest available + observation without changing the TP threshold itself. + """ + try: + pos = self.eng.position + if pos is None or not pos.asset: + return prices_dict + raw = self.features_map.blocking().get("obf_universe_latest") + return inject_obf_midprice( + prices_dict, + position_asset=str(pos.asset or ""), + obf_payload=raw, + max_age_s=3.0, + now_s=time.time(), + ) + except Exception: + return prices_dict + + def _sync_sc_threshold_advisor(self, scan_number: int, vel_div: float) -> None: + """Shadow-only advisory layer for tracking / future threshold learning.""" + if self._sc_advisor is None: + return + try: + payload = self._read_esof_payload() + trade_history = getattr(self.eng, "trade_history", []) + open_tid = next(iter(self._pending_entries.keys()), "") + pending = self._pending_entries.get(open_tid, {}) if open_tid else {} + rec = self._sc_advisor.evaluate( + trade_id=str(open_tid or ""), + asset=str(pending.get("asset", "")), + sc=_safe_float(payload.get("advisory_score", payload.get("score", 0.0)) if payload else None), + vel_div=float(vel_div or 0.0), + exf_snapshot=getattr(self, "_last_exf", {}) or {}, + trade_history=trade_history, + current_mult=float(self._last_esof_size_mult or 1.0), + esof_payload=payload, + scan_number=int(scan_number or 0), + bar_idx=int(self.bar_idx), + strategy="blue", + log_shadow=True, + ) + if open_tid: + pending["sc_threshold_advisor"] = rec + pending["sc_exec_mult"] = float(self._last_esof_size_mult or 1.0) + self._pending_entries[open_tid] = pending + try: + self._record_sc_haircut(trade_id=open_tid, pending=pending, source="sc_threshold") + except Exception as e: + log(f"SC haircut record failed for {open_tid}: {e}") + now = time.time() + if now - self._sc_advisor_last_log >= 300: + self._sc_advisor_last_log = now + log( + f"SC_ADVISOR: sc={rec['sc']:+.3f} cur={rec['current_mult']:.2f} " + f"rec={rec['recommended_mult']:.2f} cut={rec['recommended_sc_cut']:+.2f} " + f"conf={rec['confidence']:.2f} src={rec['decision_source']}" + ) + except Exception as e: + log(f"SC_ADVISOR error: {e}") + + def _current_obf_snapshot(self, asset: str, bar_idx: int) -> dict[str, dict]: + if build_obf_snapshot_from_engine is None or self.ob_eng is None or not asset: + return {} + try: + return build_obf_snapshot_from_engine(self.ob_eng, asset, bar_idx) + except Exception: + return {} + + def _record_bounce_prices(self, prices_dict: dict[str, float]) -> None: + """Maintain rolling price histories for the bounce advisor.""" + if not prices_dict: + return + for asset, px in prices_dict.items(): + try: + price = float(px) + except Exception: + continue + if not math.isfinite(price) or price <= 0.0: + continue + hist = self._bounce_price_history.get(asset) + if hist is None: + hist = deque(maxlen=512) + self._bounce_price_history[asset] = hist + hist.append(price) + + def _bounce_price_path(self, asset: str) -> list[float]: + hist = self._bounce_price_history.get(asset) + if not hist: + return [] + return [float(px) for px in hist if math.isfinite(float(px))] + + def _bounce_eval( + self, + *, + trade_id: str, + asset: str, + side: str, + source: str, + scan_number: int, + entry_ts: datetime | None, + current_price: float, + entry_price: float, + quantity: float, + notional: float, + leverage: float, + vel_div: float, + current_mult: float, + bars_held: int, + log_shadow: bool = True, + ) -> dict | None: + """Evaluate the bounce advisor on a rolling price path and persist the row.""" + if self._bounce_advisor is None or not trade_id or not asset: + return None + price_path = self._bounce_price_path(asset) + if len(price_path) < 3: + return None + rec = self._bounce_advisor.evaluate( + trade_id=str(trade_id), + asset=str(asset), + side=str(side or "SHORT"), + price_path=price_path, + entry_ts=entry_ts or datetime.now(timezone.utc), + entry_price=float(entry_price or 0.0), + current_price=float(current_price or 0.0), + quantity=float(quantity or 0.0), + notional=float(notional or 0.0), + leverage=float(leverage or 0.0), + current_mult=float(current_mult or 1.0), + vel_div=float(vel_div or 0.0), + scan_number=int(scan_number or 0), + bar_idx=int(self.bar_idx), + bars_held=int(max(0, bars_held)), + source=str(source or "entry"), + obf_snapshot=self._current_obf_snapshot(asset, self.bar_idx), + log_shadow=log_shadow, + use_ta=True, + use_obf=True, + ) + if rec: + rec["price_path"] = price_path[-128:] + return rec + + def _ensure_v7_journal_table(self) -> None: + """Create the V7 decision journal if it does not already exist.""" + ddl = f""" + CREATE TABLE IF NOT EXISTS {self._v7_journal_db}.{self._v7_journal_table} + ( + ts DateTime64(6, 'UTC'), + ts_day Date MATERIALIZED toDate(ts), + strategy LowCardinality(String), + source LowCardinality(String), + trade_id String, + asset LowCardinality(String), + side LowCardinality(String), + entry_price Float64, + current_price Float64, + quantity Float64, + notional Float64, + leverage Float32, + bar_idx UInt32, + decision_seq UInt32, + bars_held UInt16, + action LowCardinality(String), + reason LowCardinality(String), + pnl_pct Float32, + mfe Float32, + mae Float32, + mfe_risk Float32, + mae_risk Float32, + exit_pressure Float32, + rv_comp Float32, + mae_thresh1 Float32, + bounce_score Float32, + bounce_risk Float32, + ob_imbalance Float32, + vel_div_entry Float32, + vel_div_now Float32, + v50_vel Float32, + v750_vel Float32, + exf_funding Float32, + exf_dvol Float32, + exf_fear_greed Float32, + exf_taker Float32, + posture LowCardinality(String), + tp_base_pct Float32 DEFAULT 0, + dynamic_tp_pct Float32 DEFAULT 0, + tp_mod_factor Float32 DEFAULT 0, + cascade_count UInt16 DEFAULT 0, + ob_regime_signal Int8 DEFAULT 0, + tp_floor_armed UInt8 DEFAULT 0 + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(ts) + ORDER BY (ts_day, trade_id, decision_seq, ts) + TTL ts_day + toIntervalDay(180) + """ + try: + req = urllib.request.Request( + "http://localhost:8123/", + data=ddl.encode(), + method="POST", + ) + req.add_header("X-ClickHouse-User", "dolphin") + req.add_header("X-ClickHouse-Key", "dolphin_ch_2026") + urllib.request.urlopen(req, timeout=5).close() + except Exception as exc: + log(f"[V7_JOURNAL] table ensure failed: {exc}") + + def _record_v7_decision( + self, + *, + trade_id: str, + asset: str, + side: str, + decision: dict, + current_price: float, + ob_imbalance: float, + vel_div_now: float, + v50_vel: float, + v750_vel: float, + source: str = "scan_eval", + bar_idx: int | None = None, + ) -> None: + """Persist a V7 evaluation for observability and offline comparison.""" + if not self._v7_journal_enabled or self._v7_exit_engine is None: + return + pending = self._pending_entries.get(trade_id, {}) + seq = int(self._v7_decision_seq.get(trade_id, 0)) + 1 + self._v7_decision_seq[trade_id] = seq + entry_price = float(pending.get("entry_price", 0.0) or 0.0) + quantity = float(pending.get("quantity", 0.0) or 0.0) + row = { + "ts": _ch_ts_us(), + "strategy": "blue", + "source": source, + "trade_id": str(trade_id or ""), + "asset": str(asset or pending.get("asset", "")), + "side": str(side or pending.get("side", "")), + "entry_price": entry_price, + "current_price": float(current_price or 0.0), + "quantity": quantity, + "notional": float(quantity * entry_price), + "leverage": float(pending.get("leverage", 0.0) or 0.0), + "bar_idx": int(max(0, self.bar_idx - 1 if bar_idx is None else bar_idx)), + "decision_seq": seq, + "bars_held": int(decision.get("bars_held", 0) or 0), + "action": str(decision.get("action", "UNKNOWN") or "UNKNOWN"), + "reason": _normalize_v7_exit_reason(decision.get("reason") or ""), + "pnl_pct": float(decision.get("pnl_pct", 0.0) or 0.0), + "mfe": float(decision.get("mfe", 0.0) or 0.0), + "mae": float(decision.get("mae", 0.0) or 0.0), + "mfe_risk": float(decision.get("mfe_risk", 0.0) or 0.0), + "mae_risk": float(decision.get("mae_risk", 0.0) or 0.0), + "exit_pressure": float(decision.get("exit_pressure", 0.0) or 0.0), + "rv_comp": float(decision.get("rv_comp", 0.0) or 0.0), + "mae_thresh1": float(decision.get("mae_thresh1", 0.0) or 0.0), + "bounce_score": float(decision.get("bounce_score", 0.0) or 0.0), + "bounce_risk": float(decision.get("bounce_risk", 0.0) or 0.0), + "ob_imbalance": float(ob_imbalance or 0.0), + "vel_div_entry": float(pending.get("vel_div_entry", 0.0) or 0.0), + "vel_div_now": float(vel_div_now or 0.0), + "v50_vel": float(v50_vel or 0.0), + "v750_vel": float(v750_vel or 0.0), + "exf_funding": float(self._last_exf.get("funding", 0.0) or 0.0), + "exf_dvol": float(self._last_exf.get("dvol", 0.0) or 0.0), + "exf_fear_greed": float(self._last_exf.get("fear_greed", 0.0) or 0.0), + "exf_taker": float(self._last_exf.get("taker", 0.0) or 0.0), + "posture": str(pending.get("posture", self.cached_posture) or ""), + } + # TP-threshold observability (LINK 5e05eeeb incident, 2026-06-11): + # the EFFECTIVE TP gate is OB-modulated (cascade ×1.40 etc.) and was + # never logged — making the miss undiagnosable from the tape. Pull + # the exit manager's last evaluation for this trade; fall back to + # any diag fields carried on the decision dict itself. + try: + _tp_diag = dict(getattr(self.eng.exit_manager, "last_eval", {}) or {}) + if str(_tp_diag.get("trade_id") or "") != str(trade_id or ""): + _tp_diag = {} + except Exception: + _tp_diag = {} + def _dg(key, default=0.0): + v = decision.get(key, _tp_diag.get(key, default)) + return v if v is not None else default + row.update({ + "tp_base_pct": float(_dg("tp_base_pct")), + "dynamic_tp_pct": float(_dg("dynamic_tp_pct")), + "tp_mod_factor": float(_dg("tp_mod_factor")), + "cascade_count": int(_dg("cascade_count", 0)), + "ob_regime_signal": int(_dg("ob_regime_signal", 0)), + "tp_floor_armed": 1 if _dg("tp_floor_armed", False) else 0, + }) + try: + ch_put(self._v7_journal_table, row) + except Exception as exc: + log(f"[V7_JOURNAL] write failed: {exc}") + + def _v7_live_exit_decision( + self, + *, + pos, + bar_idx: int, + prices: dict, + vel_div: float, + v50_vel: float, + v750_vel: float, + ) -> dict | None: + """Live BLUE exit hook backed by AlphaExitEngineV7. + + The orchestrator calls this before falling back to the base exit manager. + Returns a V7 decision dict or None if the trade cannot yet be evaluated. + """ + if self._v7_exit_engine is None or pos is None: + return None + + trade_id = str(getattr(pos, "trade_id", "") or "") + asset = str(getattr(pos, "asset", "") or "") + if not trade_id or not asset: + return None + + pending = self._pending_entries.get(trade_id, {}) + ctx_v7 = self._v7_contexts.get(trade_id) + eval_bar = max(0, int(bar_idx) - 1) + + if ctx_v7 is None: + try: + ctx_v7 = self._v7_exit_engine.make_context( + entry_price=float( + pending.get("entry_price", getattr(pos, "entry_price", 0.0)) + or getattr(pos, "entry_price", 0.0) + or 0.0 + ), + entry_bar=int(pending.get("entry_bar", eval_bar) or eval_bar), + side=1 if str(pending.get("side", "SHORT") or "SHORT") == "SHORT" else 0, + ) + if self._last_exf: + ctx_v7.set_exf( + funding=float(self._last_exf.get("funding", 0.0) or 0.0), + dvol=float(self._last_exf.get("dvol", 0.0) or 0.0), + fear_greed=float(self._last_exf.get("fear_greed", 0.0) or 0.0), + taker=float(self._last_exf.get("taker", 0.0) or 0.0), + ) + self._v7_contexts[trade_id] = ctx_v7 + self._v7_decision_seq.setdefault(trade_id, 0) + except Exception as exc: + log(f" V7 live context init failed for {trade_id}: {exc}") + return None + elif self._last_exf: + try: + ctx_v7.set_exf( + funding=float(self._last_exf.get("funding", 0.0) or 0.0), + dvol=float(self._last_exf.get("dvol", 0.0) or 0.0), + fear_greed=float(self._last_exf.get("fear_greed", 0.0) or 0.0), + taker=float(self._last_exf.get("taker", 0.0) or 0.0), + ) + except Exception: + pass + + ob_imb = 0.0 + if self.ob_eng is not None: + try: + ob_sig = self.ob_eng.get_signal(asset, float(eval_bar)) + ob_imb = float(getattr(ob_sig, "imbalance_ma5", 0.0) or 0.0) + except Exception as exc: + log(f" V7 live OB signal failed for {trade_id}: {exc}") + + cur_px = float( + prices.get(asset, getattr(pos, "current_price", 0.0)) + or getattr(pos, "current_price", 0.0) + or 0.0 + ) + if cur_px <= 0.0: + return None + + decision = self._v7_exit_engine.evaluate( + ctx_v7, + cur_px, + eval_bar, + ob_imb, + asset=asset, + ) + self._v7_decisions[trade_id] = decision + self._record_v7_decision( + trade_id=trade_id, + asset=asset, + side=str(pending.get("side", "SHORT") or "SHORT"), + decision=decision, + current_price=cur_px, + ob_imbalance=ob_imb, + vel_div_now=vel_div, + v50_vel=v50_vel, + v750_vel=v750_vel, + source="live_exit", + bar_idx=eval_bar, + ) + + action = str(decision.get("action", "HOLD") or "HOLD") + if action != "HOLD": + log( + " V7 live decision: " + f"{trade_id} {asset} action={action} reason={decision.get('reason', '')} " + f"pressure={float(decision.get('exit_pressure', 0.0) or 0.0):+.3f} " + f"pnl_pct={float(decision.get('pnl_pct', 0.0) or 0.0):+.3f}" + ) + return decision + + def _sync_sc_gauge_advisor(self, scan_number: int, vel_div: float) -> None: + """Shadow-only bucket gauge advisory surface.""" + if self._sc_gauge is None: + return + try: + payload = self._read_esof_payload() + trade_history = getattr(self.eng, "trade_history", []) + open_tid = next(iter(self._pending_entries.keys()), "") + pending = self._pending_entries.get(open_tid, {}) if open_tid else {} + asset = str(pending.get("asset", "")) + rec = self._sc_gauge.evaluate( + trade_id=str(open_tid or ""), + asset=asset, + sc=_safe_float(payload.get("advisory_score", payload.get("score", 0.0)) if payload else None), + vel_div=float(vel_div or 0.0), + exf_snapshot=getattr(self, "_last_exf", {}) or {}, + obf_snapshot=self._current_obf_snapshot(asset, self.bar_idx), + trade_history=trade_history, + current_mult=float(self._last_esof_size_mult or 1.0), + esof_payload=payload, + scan_number=int(scan_number or 0), + bar_idx=int(self.bar_idx), + strategy="blue", + log_shadow=True, + ) + if open_tid: + pending["sc_bucket_gauge"] = rec + pending["sc_bucket_gauge_exec_mult"] = float(self._last_esof_size_mult or 1.0) + self._pending_entries[open_tid] = pending + now = time.time() + if now - self._sc_gauge_last_log >= 300: + self._sc_gauge_last_log = now + log( + f"SC_GAUGE: sc={rec['sc']:+.3f} bucket={rec['bucket_id']} " + f"cur={rec['current_mult']:.2f} rec={rec['recommended_size_mult']:.2f} " + f"tp={rec['recommended_tp_mult']:.2f} hold={rec['recommended_hold_mult']:.2f} " + f"cut={rec['recommended_sc_cut']:+.2f} conf={rec['confidence']:.2f}" + ) + except Exception as e: + log(f"SC_GAUGE error: {e}") + + def _resolve_trade_id(self, explicit: str | None = None, *, create_if_missing: bool = False) -> str: + """Resolve a trade_id from the event, live position, or pending entry.""" + tid = str(explicit or "").strip() + if tid: + return tid + pos = getattr(self.eng, "position", None) + if pos is not None: + pos_tid = str(getattr(pos, "trade_id", "") or "").strip() + if pos_tid: + return pos_tid + if len(self._pending_entries) == 1: + pending_tid = next(iter(self._pending_entries.keys())) + if pending_tid: + return pending_tid + if create_if_missing: + return uuid.uuid4().hex[:16] + return "" + + def _query_clickhouse_tsv( + self, + sql: str, + *, + db_candidates: tuple[str, ...] = ("dolphin", "dolphin_prodgreen"), + timeout: float = 5.0, + ) -> tuple[str, str]: + """Run a small ClickHouse HTTP query and return (raw_text, db_used).""" + import base64 as _b64 + + auth = "Basic " + _b64.b64encode(b"dolphin:dolphin_ch_2026").decode() + last_exc: Exception | None = None + for db in db_candidates: + try: + req = urllib.request.Request( + f"http://localhost:8123/?database={db}", + data=sql.encode(), + headers={"Authorization": auth}, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode().strip(), db + except Exception as exc: + last_exc = exc + raise last_exc or RuntimeError("ClickHouse query failed") + + def _parse_capital_blob(self, raw, source: str) -> tuple[float, dict] | None: + """Parse a HZ/JSON state blob and validate the capital payload.""" + if not raw: + return None + try: + data = json.loads(raw) if isinstance(raw, str) else (raw if isinstance(raw, dict) else {}) + capital = float(data.get("capital", 0) or 0) + if capital >= 1.0 and math.isfinite(capital): + return capital, data + log(f" restore candidate rejected from {source}: capital={capital!r}") + except Exception as exc: + log(f" restore candidate parse failed from {source}: {exc}") + return None + + def _parse_timestamp_seconds(self, value) -> float | None: + """Parse epoch/ISO timestamps into UTC epoch seconds.""" + if value is None: + return None + try: + if isinstance(value, (int, float)): + ts = float(value) + elif isinstance(value, str): + text = value.strip() + if not text: + return None + try: + ts = float(text) + except ValueError: + dt = datetime.fromisoformat(text.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + ts = dt.timestamp() + else: + return None + if not math.isfinite(ts): + return None + # Normalize millisecond / microsecond / nanosecond epochs down to seconds. + # CH event clocks are often stored as ts_us, while HZ blobs tend to be seconds. + scale_hops = 0 + while ts > 1.0e11 and scale_hops < 4: + ts /= 1000.0 + scale_hops += 1 + return ts if ts > 0 else None + except Exception: + return None + + def _extract_state_timestamp(self, blob: dict) -> float | None: + """Extract the best timestamp from a state blob.""" + if not isinstance(blob, dict): + return None + for key in ("updated_at", "timestamp", "ts", "iso"): + if key not in blob: + continue + parsed = self._parse_timestamp_seconds(blob.get(key)) + if parsed is not None: + return parsed + return None + + def _mark_restore_failure(self, reason: str) -> None: + """Mark restore as failed and force the trader into halt mode.""" + self._restore_failed = True + self._restore_failure_reason = reason + try: + with self.eng_lock: + if self.eng is not None: + self.eng.regime_dd_halt = True + self.eng._day_posture = "HIBERNATE" + except Exception: + pass + log(f"RESTORE HALT: {reason}") + + def _restore_capital_from_legacy_checkpoint(self) -> bool: + """Legacy escape hatch for the old scalar checkpoint path.""" + if not _env_bool("DOLPHIN_ALLOW_LEGACY_CAPITAL_CHECKPOINT", False): + return False + + def _try_load(raw, source): + parsed = self._parse_capital_blob(raw, source) + if parsed is None: + return False + capital, _ = parsed + self.eng.capital = capital + self._restore_source = source + log(f" Capital restored from legacy {source}: ${capital:,.2f}") + return True + + try: + raw = self.state_map.blocking().get("capital_checkpoint") + if _try_load(raw, "HZ capital_checkpoint"): + return True + except Exception as e: + log(f" capital HZ legacy restore failed: {e}") + + try: + if CAPITAL_DISK_CHECKPOINT.exists(): + raw = CAPITAL_DISK_CHECKPOINT.read_text() + if _try_load(raw, "disk capital_checkpoint"): + return True + except Exception as e: + log(f" capital disk legacy restore failed: {e}") + return False + + def _restore_capital_from_state(self) -> bool: + """Restore capital from live HZ state or ledger-backed snapshots.""" + parsed_state = {} + self._restore_state_snapshots = {} + source_rank = { + "capital_update_ledger": 65, + "status_snapshots": 50, + "latest_nautilus": 40, + "engine_snapshot": 30, + "pnl_day": 25, + "correction_replay_local": 20, + "correction_replay_hz": 10, + "trade_events": 5, + } + if CAPITAL_CORRECTIVE_REPLAY.exists(): + try: + replay_blob = json.loads(CAPITAL_CORRECTIVE_REPLAY.read_text()) + replay_capital = _safe_float(replay_blob.get("capital", 0.0), 0.0) + replay_ts = replay_blob.get("updated_at") or replay_blob.get("ts") + replay_ts_f = None + if isinstance(replay_ts, (int, float)): + replay_ts_f = float(replay_ts) + elif isinstance(replay_ts, str): + try: + replay_ts_f = datetime.fromisoformat(replay_ts.replace("Z", "+00:00")).timestamp() + except Exception: + replay_ts_f = None + if replay_capital >= 1.0: + parsed_state["correction_replay_local"] = ( + "local corrective replay", + replay_capital, + replay_blob, + replay_ts_f, + ) + except Exception as e: + log(f" capital corrective replay read failed: {e}") + try: + if CAPITAL_UPDATE_LEDGER.exists(): + raw = CAPITAL_UPDATE_LEDGER.read_text() + ledger_rows = json.loads(raw) if raw else [] + if isinstance(ledger_rows, list) and ledger_rows: + last = ledger_rows[-1] if isinstance(ledger_rows[-1], dict) else None + if isinstance(last, dict): + capital_after = _safe_float(last.get("capital_after", last.get("capital", 0.0)), 0.0) + if capital_after >= 1.0 and math.isfinite(capital_after): + parsed_state["capital_update_ledger_local"] = ( + "local capital_update_ledger", + capital_after, + dict(last), + self._extract_state_timestamp(last), + ) + except Exception as e: + log(f" capital ledger disk read failed: {e}") + try: + raw_ledger = None + if self.state_map is not None: + raw_ledger = self.state_map.blocking().get("capital_update_ledger") + ledger_rows = json.loads(raw_ledger) if isinstance(raw_ledger, str) and raw_ledger else list(raw_ledger or []) + if isinstance(ledger_rows, list) and ledger_rows: + last = ledger_rows[-1] if isinstance(ledger_rows[-1], dict) else None + if isinstance(last, dict): + capital_after = _safe_float(last.get("capital_after", last.get("capital", 0.0)), 0.0) + if capital_after >= 1.0: + parsed_state["capital_update_ledger"] = ( + "capital_update_ledger", + capital_after, + dict(last), + self._extract_state_timestamp(last), + ) + except Exception as e: + log(f" capital ledger restore failed: {e}") + for key, label in ( + ("capital_update_ledger_local", "local capital_update_ledger"), + ("capital_update_ledger", "capital_update_ledger"), + ("correction_replay_local", "local corrective replay"), + (CAPITAL_CORRECTIVE_REPLAY_HZ_KEY, "HZ corrective replay"), + ("latest_nautilus", "HZ latest_nautilus"), + ("engine_snapshot", "HZ engine_snapshot"), + ): + try: + raw = self.state_map.blocking().get(key) + except Exception as e: + log(f" capital {key} read failed: {e}") + raw = None + parsed = self._parse_capital_blob(raw, label) + if parsed is not None: + capital, blob = parsed + parsed_key = ( + "correction_replay_local" + if key == "correction_replay_local" + else "correction_replay_hz" if key == CAPITAL_CORRECTIVE_REPLAY_HZ_KEY else key + ) + parsed_state[parsed_key] = ( + label, + capital, + blob, + self._extract_state_timestamp(blob), + ) + if key in ("latest_nautilus", "engine_snapshot") and isinstance(blob, dict): + self._restore_state_snapshots[key] = dict(blob) + + day_key = datetime.now(timezone.utc).strftime('%Y-%m-%d') + if self.pnl_map is not None: + try: + raw = self.pnl_map.blocking().get(day_key) + except Exception as e: + log(f" capital pnl_map[{day_key}] read failed: {e}") + raw = None + parsed = self._parse_capital_blob(raw, f"HZ pnl[{day_key}]") + if parsed is not None: + capital, blob = parsed + parsed_state["pnl_day"] = ( + f"HZ pnl[{day_key}]", + capital, + blob, + self._extract_state_timestamp(blob), + ) + + def _select_restore_candidate() -> tuple[str, str, float, dict, float | None] | None: + candidates: list[tuple[float, int, str, str, float, dict, float | None]] = [] + for key, (label, capital, blob, ts) in parsed_state.items(): + if not (math.isfinite(capital) and capital >= 1.0): + continue + candidates.append( + ( + ts if ts is not None else float("-inf"), + source_rank.get(key, 0), + key, + label, + capital, + blob, + ts, + ) + ) + if not candidates: + return None + force_latest_seed = _env_bool("DOLPHIN_FORCE_LATEST_NAUTILUS_RESTORE", False) + if force_latest_seed and "latest_nautilus" in parsed_state: + label, capital, blob, ts = parsed_state["latest_nautilus"] + if math.isfinite(capital) and capital >= 1.0: + return "latest_nautilus", label, capital, blob, ts + if "capital_update_ledger_local" in parsed_state: + label, capital, blob, ts = parsed_state["capital_update_ledger_local"] + if math.isfinite(capital) and capital >= 1.0: + return "capital_update_ledger_local", label, capital, blob, ts + candidates.sort(key=lambda item: (item[0], item[1]), reverse=True) + _, _, key, label, capital, blob, ts = candidates[0] + return key, label, capital, blob, ts + + for sql, label in ( + ( + "SELECT ts, capital, trades_executed, posture, phase " + "FROM status_snapshots ORDER BY ts DESC LIMIT 1 FORMAT TabSeparated", + "status_snapshots", + ), + ( + "SELECT ts, capital_after, capital_before, pnl, exit_reason, trade_id " + "FROM trade_events " + "WHERE strategy='blue' AND capital_after > 0 " + "ORDER BY ts DESC LIMIT 1 FORMAT TabSeparated", + "trade_events", + ), + ): + try: + raw, db = self._query_clickhouse_tsv(sql) + if not raw: + continue + cols = raw.split("\t") + capital = None + if label == "status_snapshots" and len(cols) >= 2: + capital = float(cols[1]) + parsed_state["status_snapshots"] = ( + f"status_snapshots[{db}]", + capital, + {"capital": capital, "ts": cols[0]}, + self._parse_timestamp_seconds(cols[0]), + ) + elif label == "trade_events" and len(cols) >= 4: + cap_after = float(cols[1]) + cap_before = float(cols[2]) + pnl = float(cols[3]) + expected = cap_before + pnl + if math.isfinite(cap_after) and math.isfinite(expected): + if abs(cap_after - expected) <= max(1.0, abs(expected) * 0.002): + capital = cap_after + else: + log( + f" restore candidate rejected from {db}.{label}: " + f"capital_after={cap_after:.2f} expected={expected:.2f} " + f"exit_reason={cols[4] if len(cols) > 4 else ''}" + ) + if capital is not None and math.isfinite(capital) and capital >= 1.0: + parsed_state["trade_events"] = ( + f"{db}.{label}", + capital, + {"capital": capital, "ts": cols[0], "trade_id": cols[5] if len(cols) > 5 else ""}, + self._parse_timestamp_seconds(cols[0]), + ) + except Exception as e: + log(f" capital {label} replay failed: {e}") + + chosen = _select_restore_candidate() + if chosen is not None: + key, label, capital, replay_blob, _ = chosen + self.eng.capital = capital + self._restore_source = label + if key in ("correction_replay_local", "correction_replay_hz"): + self._publish_corrective_replay(replay_blob) + log(f" Capital restored from {label}: ${capital:,.2f}") + return True + + if self._restore_capital_from_legacy_checkpoint(): + return True + + self._mark_restore_failure("no sane capital source found (HZ state and ledger replay unavailable)") + return False + + # ── CH position-state persistence ───────────────────────────────────────── + + def _ps_write_open( + self, + tid: str, + entry: dict, + *, + ts: int | None = None, + entry_bar: int | None = None, + bars_held: int = 0, + pnl: float = 0.0, + ) -> bool: + """Persist OPEN row to position_state. SINGLE write gate for OPEN rows. + + Lifecycle invariant (MALFORMED_OPEN_RESTORE_BUG.md, distal fix): + an OPEN row MUST represent a position with economic size. Writes with + quantity <= 0 or notional <= POSITION_DUST_NOTIONAL_USD are REFUSED — + a dust/zero remainder is a lifecycle CLOSE and must go through + _ps_write_closed. Returns True if the row was emitted. + + The keyword overrides let the partial-retract path persist the + remaining leg through this same gate (ts=now, continued entry_bar, + accumulated bars_held / realized pnl) instead of bypassing it with a + raw ch_put — the bypass is how zero-size OPEN snapshots were born. + """ + try: + quantity = float(entry.get('quantity', 0.0) or 0.0) + entry_price = float(entry.get('entry_price', 0.0) or 0.0) + notional = round(quantity * entry_price, 4) + if quantity <= 0.0 or notional <= POSITION_DUST_NOTIONAL_USD: + log( + " position_state OPEN write REFUSED (lifecycle invariant): " + f"trade={tid} qty={quantity} notional={notional} — " + "dust/zero remainders must close, not snapshot as OPEN" + ) + return False + market_state_bundle_json = str(entry.get("market_state_bundle_json", "") or "") + ch_put("position_state", { + "ts": int(ts if ts is not None else entry['entry_ts']), + "trade_id": tid, + "asset": entry['asset'], + "direction": -1 if entry['side'] == 'SHORT' else 1, + "entry_price": entry_price, + "quantity": quantity, + "notional": notional, + "leverage": entry['leverage'], + "bucket_id": int(getattr(self, "_bucket_assignments", {}).get(entry['asset'], -1)), + "entry_bar": int(entry_bar if entry_bar is not None else self.bar_idx), + "status": "OPEN", + "exit_reason": "", + "pnl": float(pnl), + "bars_held": int(bars_held), + "market_state_bundle_json": market_state_bundle_json, + "tp_base_pct": float(entry.get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(entry.get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(entry.get("our_leverage", 0.0) or 0.0), + }) + return True + except Exception as e: + log(f" position_state OPEN write failed: {e}") + return False + + def _ps_write_closed(self, tid: str, pending: dict, x: dict): + """Persist CLOSED row to position_state on exit (supersedes OPEN row via ReplacingMergeTree).""" + try: + market_state_bundle_json = str(pending.get("market_state_bundle_json", "") or "") + ch_put("position_state", { + "ts": _ch_ts_us(), + "trade_id": tid, + "asset": pending.get('asset', ''), + "direction": -1 if pending.get('side') == 'SHORT' else 1, + "entry_price": pending.get('entry_price', 0.0), + "quantity": pending.get('quantity', 0.0), + "notional": round(pending.get('quantity', 0.0) * pending.get('entry_price', 0.0), 4), + "leverage": pending.get('leverage', 0.0), + "bucket_id": int(getattr(self, "_bucket_assignments", {}).get(pending.get('asset', ''), -1)), + "entry_bar": 0, + "status": "CLOSED", + "exit_reason": str(x.get('reason', 'UNKNOWN')), + "pnl": float(x.get('net_pnl', 0) or 0), + "bars_held": int(x.get('bars_held', 0) or 0), + "market_state_bundle_json": market_state_bundle_json, + "tp_base_pct": float(pending.get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(pending.get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(pending.get("our_leverage", 0.0) or 0.0), + }) + except Exception as e: + log(f" position_state CLOSED write failed: {e}") + + def _fallback_pending_for_close(self, trade_id: str, exit_payload: Mapping[str, Any]) -> dict: + """Best-effort pending snapshot when in-memory pending metadata is unavailable.""" + side = str(exit_payload.get("side", "") or "").upper() + if side not in {"SHORT", "LONG"}: + direction = int(_safe_float(exit_payload.get("direction", -1), -1)) + side = "SHORT" if direction == -1 else "LONG" + entry_price = _safe_float(exit_payload.get("entry_price", 0.0), 0.0) + quantity = _safe_float(exit_payload.get("quantity", 0.0), 0.0) + leverage = _safe_float(exit_payload.get("leverage", 0.0), 0.0) + asset = str(exit_payload.get("asset", "") or "") + return { + "trade_id": str(trade_id or ""), + "asset": asset, + "side": side, + "entry_price": entry_price if entry_price > 0 else 0.0, + "quantity": quantity if quantity > 0 else 0.0, + "notional": (entry_price * quantity) if entry_price > 0 and quantity > 0 else 0.0, + "leverage": leverage if leverage > 0 else 0.0, + "entry_date": str(self.current_day or ""), + "posture": "FALLBACK_CLOSE", + "vel_div_entry": 0.0, + "boost_at_entry": 1.0, + "beta_at_entry": 1.0, + } + + def _restore_open_max_age_seconds(self) -> float: + """Max tolerated age for an OPEN row before restore treats it as stale ghost state.""" + env_value = _safe_float(os.environ.get("DOLPHIN_RESTORE_OPEN_MAX_AGE_SEC"), float("nan")) + if math.isfinite(env_value) and env_value > 0: + return float(env_value) + return 12.0 * 3600.0 + + def _restore_position_state(self): + """On startup: check CH for an OPEN position and restore engine state.""" + try: + import urllib.request, base64 as _b64 + # IMPORTANT: + # Never filter status='OPEN' first, otherwise stale historical OPEN rows + # can be resurrected forever even after a newer CLOSED row exists. + # Resolve latest row per trade_id first, then keep only currently-OPEN. + sql = ( + "SELECT trade_id, asset, direction, entry_price, quantity, " + "notional, leverage, bucket_id, bars_held, last_ts " + "FROM (" + " SELECT " + " trade_id, " + " argMax(asset, ts) AS asset, " + " argMax(direction, ts) AS direction, " + " argMax(entry_price, ts) AS entry_price, " + " argMax(quantity, ts) AS quantity, " + " argMax(notional, ts) AS notional, " + " argMax(leverage, ts) AS leverage, " + " argMax(bucket_id, ts) AS bucket_id, " + " argMax(bars_held, ts) AS bars_held, " + " argMax(status, ts) AS status, " + " argMax(ts, ts) AS last_ts " + " FROM dolphin.position_state " + " GROUP BY trade_id" + ") " + "WHERE status = 'OPEN' AND quantity > 0 AND notional > 0 " + "ORDER BY last_ts DESC LIMIT 1 FORMAT TabSeparated" + ) + + def _restore_from_hz_snapshot(reason: str) -> bool: + """Fallback restore path when ClickHouse is unavailable or empty. + + We prefer latest_nautilus/engine_snapshot because these are the live + BLUE state surfaces and can still be coherent even if CH restore + is temporarily unavailable. The restored open leg is re-seeded back + into position_state so future restarts can recover without replaying + the entire incident. + """ + snapshot_sources = ( + ("latest_nautilus", "HZ latest_nautilus"), + ("engine_snapshot", "HZ engine_snapshot"), + ) + cached_snapshots = getattr(self, "_restore_state_snapshots", {}) or {} + for key, label in snapshot_sources: + blob = cached_snapshots.get(key) + if not isinstance(blob, dict): + try: + raw = self.state_map.blocking().get(key) + except Exception as e: + log(f" {label} read failed during restore fallback: {e}") + raw = None + parsed = self._parse_capital_blob(raw, label) + if parsed is None: + continue + _, blob = parsed + if not isinstance(blob, dict): + continue + open_positions = blob.get("open_positions") + if not isinstance(open_positions, list) or len(open_positions) != 1: + continue + pos_blob = open_positions[0] + if not isinstance(pos_blob, dict): + continue + + trade_id = str(pos_blob.get("trade_id", "") or "").strip() + asset = str(pos_blob.get("asset", "") or "").strip() + side = str(pos_blob.get("side", "") or "").upper() + direction = -1 if side == "SHORT" else 1 if side == "LONG" else 0 + entry_price = float(pos_blob.get("entry_price", 0.0) or 0.0) + quantity = float(pos_blob.get("quantity", 0.0) or 0.0) + notional = float(pos_blob.get("notional", quantity * entry_price) or 0.0) + leverage = float(pos_blob.get("leverage", 0.0) or 0.0) + stored_bars = int(pos_blob.get("bars_held", 0) or blob.get("bars_held", 0) or 0) + # Continuity formula identical to the CH path: anchor on + # THIS session's bar counter (negative entry_bar is fine, + # Int32 in CH) so bars_held resumes at stored_bars. The + # old snapshot_bar-based form anchored on the PREVIOUS + # session's counter, producing entry_bar >> bar_idx and + # therefore NEGATIVE bars_held after a restart. + restored_entry_bar = self.bar_idx - max(0, stored_bars) + snapshot_ts = self._extract_state_timestamp(blob) + entry_ts_us = int((snapshot_ts if snapshot_ts is not None else time.time()) * 1_000_000) + + if not trade_id: + continue + if not asset: + continue + if direction not in (-1, 1): + continue + if not (math.isfinite(entry_price) and entry_price > 0): + continue + if not (math.isfinite(quantity) and quantity > 0): + continue + if not (math.isfinite(notional) and notional > 0): + notional = quantity * entry_price + if not (math.isfinite(leverage) and leverage > 0): + continue + + chain_recon = self._load_chain_ledger_state(trade_id) + chain_meta = {} + if chain_recon: + chain_meta.update(chain_recon) + nested_chain = chain_recon.get("chain") + if isinstance(nested_chain, dict): + chain_meta.update(nested_chain) + chain_seed_pending = { + "asset": asset, + "side": side or ("SHORT" if direction == -1 else "LONG"), + "entry_price": entry_price, + "quantity": quantity, + "notional": notional, + "notional_entry": notional, + "leverage": leverage, + "entry_bar": int(chain_meta.get("entry_bar", restored_entry_bar) if chain_recon else restored_entry_bar), + "entry_ts": int(chain_meta.get("entry_ts", entry_ts_us) or entry_ts_us) if chain_recon else entry_ts_us, + "retraction_legs": int(chain_meta.get("retraction_legs", chain_meta.get("chain_seq", 0)) or 0) if chain_recon else 0, + "realized_pnl_legs_total": float(chain_meta.get("realized_pnl_legs_total", 0.0) or 0.0) if chain_recon else 0.0, + } + try: + chain_state = self._chain_state_from_reconstruction(trade_id, chain_seed_pending, chain_recon) + except Exception as chain_err: + log(f" position_state HZ fallback chain restore failed: {chain_err}") + self._mark_restore_failure(str(chain_err)) + return False + + pos = NDPosition( + trade_id=trade_id, + asset=asset, + direction=direction, + entry_price=entry_price, + entry_bar=restored_entry_bar, + notional=notional, + leverage=leverage, + fraction=notional / max(self.eng.capital * leverage, 1.0), + entry_vel_div=0.0, + bucket_idx=0, + current_price=entry_price, + ) + with self.eng_lock: + self.eng.position = pos + self.eng.exit_manager.setup_position( + trade_id, + entry_price, + direction, + restored_entry_bar, + stop_pct_override=float(getattr(self, "_catastrophic_floor_pct", 0.0120) or 0.0120), + ) + self._pending_entries[trade_id] = { + "trade_id": trade_id, + "asset": asset, + "side": side or ("SHORT" if direction == -1 else "LONG"), + "entry_price": entry_price, + "quantity": quantity, + "notional": notional, + "notional_entry": notional, + "leverage": leverage, + "vel_div_entry": 0.0, + "boost_at_entry": 1.0, + "beta_at_entry": 1.0, + "posture": "RESTORED", + "entry_ts": entry_ts_us, + "entry_date": (self.current_day or ""), + "retraction_legs": int(chain_state.get("chain_seq", 0) or 0), + "realized_pnl_legs_total": float(chain_state.get("realized_pnl_legs_total", 0.0) or 0.0), + "chain_root_trade_id": chain_state.get("chain_root_trade_id", trade_id), + "chain_head_leg_id": chain_state.get("chain_head_leg_id", f"{trade_id}:open"), + "chain_prev_leg_id": chain_state.get("chain_prev_leg_id", ""), + "chain_seq": int(chain_state.get("chain_seq", 0) or 0), + "chain_token": chain_state.get("chain_token", ""), + "chain_mode": chain_state.get("chain_mode", "LIVE"), + "chain_version": int(chain_state.get("chain_version", 1) or 1), + "chain_kind": chain_state.get("chain_kind", "ROOT"), + } + v7_exit_engine = getattr(self, "_v7_exit_engine", None) + if v7_exit_engine is not None: + try: + ctx = v7_exit_engine.make_context( + entry_price=entry_price, + entry_bar=restored_entry_bar, + side=1 if direction == -1 else 0, + ) + self._v7_contexts[trade_id] = ctx + self._v7_decision_seq[trade_id] = 0 + except Exception as e: + log(f" V7 live restore context failed (HZ fallback): {e}") + self._seed_posture_for_restored_position() + with self.eng_lock: + self._apply_catastrophic_floor_to_open_position() + try: + self._ps_write_open(trade_id, self._pending_entries[trade_id]) + except Exception as e: + log(f" position_state HZ fallback OPEN write failed: {e}") + self._restore_source = label + self._restore_failed = False + self._restore_failure_reason = "" + log( + f" position_state RESTORED from {label} ({reason}): " + f"{asset} {side or ('SHORT' if direction == -1 else 'LONG')} " + f"entry={entry_price} notional={notional:.0f} bars_held≈{stored_bars} trade={trade_id}" + ) + return True + return False + + def _hz_snapshot_is_flat(reason: str) -> bool: + """Accept flat HZ state when CH restore is temporarily unavailable.""" + snapshot_sources = ( + ("latest_nautilus", "HZ latest_nautilus"), + ("engine_snapshot", "HZ engine_snapshot"), + ) + cached_snapshots = getattr(self, "_restore_state_snapshots", {}) or {} + for key, label in snapshot_sources: + blob = cached_snapshots.get(key) + if not isinstance(blob, dict): + try: + raw = self.state_map.blocking().get(key) + except Exception as e: + log(f" {label} flat-check read failed during restore fallback: {e}") + raw = None + parsed = self._parse_capital_blob(raw, label) + if parsed is None: + continue + _, blob = parsed + if not isinstance(blob, dict): + continue + open_positions = blob.get("open_positions") + if isinstance(open_positions, list) and len(open_positions) == 0: + log(f" position_state: CH restore unavailable ({reason}); {label} is flat") + return True + return False + + req = urllib.request.Request( + "http://localhost:8123/?database=dolphin", + data=sql.encode(), + headers={"Authorization": "Basic " + + _b64.b64encode(b"dolphin:dolphin_ch_2026").decode()}) + with urllib.request.urlopen(req, timeout=5) as r: + row = r.read().decode().strip() + if not row: + log(" position_state: no open position to restore in CH; trying HZ fallback") + if _restore_from_hz_snapshot("CH empty"): + return + return + + def _reject_restore_candidate(message: str, *, halt_on_exhaustion: bool = True) -> bool: + log(f" position_state open candidate rejected: {message}") + if _restore_from_hz_snapshot(message): + return True + if _hz_snapshot_is_flat(message): + return True + # Fallbacks exhausted: no HZ position AND no HZ flat-proof. + # Two garbage classes diverge here: + # - zero-size OPEN rows are the DOCUMENTED malformed/tombstone + # class (MALFORMED_OPEN_RESTORE_BUG.md): definitionally not + # live positions → flat continuation is correct + # (halt_on_exhaustion=False at those call sites); + # - corrupt direction/entry_price/leverage is UNKNOWN state — + # trading from flat over it risks a single-slot violation + # (XTZ 863c21da class) → halt via restore-failure. + if halt_on_exhaustion: + self._mark_restore_failure(message) + return False + + cols = row.split('\t') + if len(cols) < 10: + log(f" position_state: unexpected row format: {row}") + if _restore_from_hz_snapshot("CH malformed"): + return + self._mark_restore_failure("position_state row malformed") + return + + trade_id = cols[0] + asset = cols[1] + direction = int(cols[2]) + entry_price = float(cols[3]) + quantity = float(cols[4]) + notional = float(cols[5]) + leverage = float(cols[6]) + bucket_id = int(cols[7]) + stored_bars = int(cols[8]) + last_ts = self._parse_timestamp_seconds(cols[9]) + + if not trade_id.strip(): + self._mark_restore_failure("position_state row missing trade_id") + return + if not asset.strip(): + self._mark_restore_failure(f"position_state row missing asset for trade {trade_id}") + return + if direction not in (-1, 1): + if _reject_restore_candidate(f"position_state row invalid direction for trade {trade_id}: {direction}"): + return + return + if not (math.isfinite(entry_price) and entry_price > 0): + if _reject_restore_candidate(f"position_state row invalid entry_price for trade {trade_id}: {entry_price}"): + return + return + if not (math.isfinite(quantity) and quantity > 0): + # zero/dust size = documented tombstone class → no halt + if _reject_restore_candidate( + f"position_state row invalid quantity for trade {trade_id}: {quantity}", + halt_on_exhaustion=False): + return + return + if not (math.isfinite(notional) and notional > 0): + # zero/dust size = documented tombstone class → no halt + if _reject_restore_candidate( + f"position_state row invalid notional for trade {trade_id}: {notional}", + halt_on_exhaustion=False): + return + return + if not (math.isfinite(leverage) and leverage > 0): + if _reject_restore_candidate(f"position_state row invalid leverage for trade {trade_id}: {leverage}"): + return + return + if stored_bars < 0: + self._mark_restore_failure(f"position_state row invalid bars_held for trade {trade_id}: {stored_bars}") + return + if last_ts is not None: + age_sec = max(0.0, time.time() - last_ts) + max_age_sec = self._restore_open_max_age_seconds() + if age_sec > max_age_sec: + log( + " position_state stale OPEN candidate rejected: " + f"trade={trade_id} age={age_sec:.0f}s limit={max_age_sec:.0f}s " + f"asset={asset} side={'SHORT' if direction == -1 else 'LONG'}" + ) + stale_pending = { + "asset": asset, + "side": "SHORT" if direction == -1 else "LONG", + "entry_price": entry_price, + "quantity": quantity, + "leverage": leverage, + } + self._ps_write_closed( + trade_id, + stale_pending, + { + "reason": "RESTORE_STALE_OPEN_REJECT", + "net_pnl": 0.0, + "bars_held": stored_bars, + }, + ) + return + derived_notional = quantity * entry_price + if math.isfinite(derived_notional) and derived_notional > 0: + if abs(notional - derived_notional) > max(1.0, abs(derived_notional) * 0.01): + log( + " position_state notional mismatch: " + f"stored={notional:.6f} derived={derived_notional:.6f} trade={trade_id} " + "— using derived value" + ) + notional = derived_notional + + # entry_bar so the MAX_HOLD countdown CONTINUES from where it left + # off. At boot self.bar_idx is 0, so this is typically negative — + # that is intentional: bars_held = bar_idx − entry_bar then equals + # stored_bars immediately. The old max(0, …) clamp zeroed the + # clock on every restore (XTZ 863c21da "bars_held≈0"; MAX_HOLD + # never fired and the phantom rode 1h to STOP_LOSS). + # position_state.entry_bar is Int32 — negative is storable. + restored_entry_bar = self.bar_idx - max(0, stored_bars) + chain_recon = self._load_chain_ledger_state(trade_id) + chain_meta = {} + if chain_recon: + chain_meta.update(chain_recon) + nested_chain = chain_recon.get("chain") + if isinstance(nested_chain, dict): + chain_meta.update(nested_chain) + chain_seed_pending = { + "asset": asset, + "side": 'SHORT' if direction == -1 else 'LONG', + "entry_price": entry_price, + "quantity": quantity, + "notional": notional, + "notional_entry": notional, + "leverage": leverage, + "entry_bar": int(chain_meta.get("entry_bar", restored_entry_bar) if chain_recon else restored_entry_bar), + "entry_ts": int(chain_meta.get("entry_ts", 0) or 0) if chain_recon else 0, + "retraction_legs": int(chain_meta.get("retraction_legs", chain_meta.get("chain_seq", 0)) or 0) if chain_recon else 0, + "realized_pnl_legs_total": float(chain_meta.get("realized_pnl_legs_total", 0.0) or 0.0) if chain_recon else 0.0, + } + try: + chain_state = self._chain_state_from_reconstruction(trade_id, chain_seed_pending, chain_recon) + except Exception as chain_err: + self._mark_restore_failure(str(chain_err)) + return + + pos = NDPosition( + trade_id = trade_id, + asset = asset, + direction = direction, + entry_price = entry_price, + entry_bar = restored_entry_bar, + notional = notional, + leverage = leverage, + fraction = notional / max(self.eng.capital * leverage, 1.0), + entry_vel_div = 0.0, + bucket_idx = 0, # signal-strength bucket (not KMeans); 0=safe default + current_price = entry_price, + ) + with self.eng_lock: + self.eng.position = pos + self.eng.exit_manager.setup_position( + trade_id, + entry_price, + direction, + restored_entry_bar, + stop_pct_override=float(getattr(self, "_catastrophic_floor_pct", 0.0120) or 0.0120), + ) + # NOTE: do NOT arm hibernate protect here. + # _day_posture starts as 'APEX' — the posture sync block on the + # first incoming scan will detect the APEX→HIBERNATE transition + # and call _hibernate_protect_position() at the right moment. + + # Rebuild _pending_entries so the exit CH write fires correctly + side = 'SHORT' if direction == -1 else 'LONG' + self._pending_entries[trade_id] = { + 'trade_id': trade_id, + 'asset': asset, + 'side': side, + 'entry_price': entry_price, + 'quantity': quantity, + 'notional': float(quantity * entry_price), + 'notional_entry': float(quantity * entry_price), + 'leverage': leverage, + 'vel_div_entry': 0.0, + 'boost_at_entry': 1.0, + 'beta_at_entry': 1.0, + 'posture': 'RESTORED', + 'entry_ts': int(chain_meta.get("entry_ts", _ch_ts_us()) or _ch_ts_us()) if chain_recon else _ch_ts_us(), + 'entry_date': (self.current_day or ''), + 'retraction_legs': int(chain_state.get("chain_seq", 0) or 0), + 'realized_pnl_legs_total': float(chain_state.get("realized_pnl_legs_total", 0.0) or 0.0), + 'chain_root_trade_id': chain_state.get("chain_root_trade_id", trade_id), + 'chain_head_leg_id': chain_state.get("chain_head_leg_id", f"{trade_id}:open"), + 'chain_prev_leg_id': chain_state.get("chain_prev_leg_id", ""), + 'chain_seq': int(chain_state.get("chain_seq", 0) or 0), + 'chain_token': chain_state.get("chain_token", ""), + 'chain_mode': chain_state.get("chain_mode", "LIVE"), + 'chain_version': int(chain_state.get("chain_version", 1) or 1), + 'chain_kind': chain_state.get("chain_kind", "ROOT"), + } + if self._v7_exit_engine is not None: + try: + ctx = self._v7_exit_engine.make_context( + entry_price=entry_price, + entry_bar=restored_entry_bar, + side=1 if direction == -1 else 0, + ) + self._v7_contexts[trade_id] = ctx + self._v7_decision_seq[trade_id] = 0 + except Exception as e: + log(f" V7 live restore context failed: {e}") + self._seed_posture_for_restored_position() + with self.eng_lock: + self._apply_catastrophic_floor_to_open_position() + log(f" position_state RESTORED: {asset} {side} entry={entry_price} " + f"notional={notional:.0f} bars_held≈{stored_bars} trade={trade_id}") + + except Exception as e: + log(f" position_state restore error: {e}") + if _restore_from_hz_snapshot(str(e)): + return + if _hz_snapshot_is_flat(str(e)): + return + self._mark_restore_failure(f"position_state restore error: {e}") + + def _seed_posture_for_restored_position(self) -> None: + """Make the next scan observe a posture transition for restored legs.""" + try: + if self.eng is None or getattr(self.eng, "position", None) is None: + return + if getattr(self.eng, "_day_posture", "APEX") == "HIBERNATE": + self.eng._day_posture = "APEX" + log(" position_state restore: re-seeded day posture to APEX for hibernate sync") + except Exception as e: + log(f" position_state posture reseed failed: {e}") + + def _rehydrate_engine_position_from_bingx(self, *, source: str = "startup") -> None: + """Keep the local engine slot aligned with the exchange slot when live on BingX. + + This is intentionally conservative in BLUE: when exchange is flat, clear any + stale local slot artifacts. Projection of non-flat exchange state is handled + by the execution/runtime layer. + """ + try: + exec_venue_name = getattr(self, "_exec_venue_name", None) + exec_venue = exec_venue_name() if callable(exec_venue_name) else "" + if str(exec_venue).upper() != "BINGX" or not bool(getattr(self, "live_mode", False)): + return + + engine = getattr(self, "engine", None) + if engine is None: + return + + get_live_positions = getattr(self, "_get_bingx_live_positions", None) + live_positions = get_live_positions() if callable(get_live_positions) else {} + if not isinstance(live_positions, dict): + live_positions = {} + + current_pos = getattr(engine, "position", None) + if live_positions or current_pos is None: + return + + stale_tid = str(getattr(current_pos, "trade_id", "") or "") + state = engine.get_state() if hasattr(engine, "get_state") else {} + if not isinstance(state, dict): + state = {} + state["position"] = None + try: + if hasattr(engine, "restore_state"): + engine.restore_state(state) + else: + engine.position = None + except Exception: + engine.position = None + + open_positions = getattr(self, "_exec_open_positions", None) + if isinstance(open_positions, dict): + open_positions.pop(stale_tid, None) + pending_entries = getattr(self, "_pending_entries", None) + if isinstance(pending_entries, dict): + pending_entries.pop(stale_tid, None) + + rt_exit_mgr = getattr(self, "_rt_exit_mgr", None) + if rt_exit_mgr is not None and stale_tid: + unregister = getattr(rt_exit_mgr, "unregister", None) + if callable(unregister): + try: + unregister(stale_tid) + except Exception: + pass + + logger = getattr(self, "log", None) or getattr(self, "_log", None) + if logger is not None and hasattr(logger, "warning"): + logger.warning( + f"[BINGX_REHYDRATE] cleared stale engine slot from {source}: " + f"exchange flat, trade_id={stale_tid or ''}" + ) + except Exception as exc: + logger = getattr(self, "log", None) or getattr(self, "_log", None) + if logger is not None and hasattr(logger, "debug"): + logger.debug(f"[BINGX_REHYDRATE] stale-slot cleanup failed: {exc}") + + def _hibernate_protect_position(self): + """Arm per-bucket TP+SL instead of immediate HIBERNATE_HALT. + + Must be called under eng_lock with an open position. + Sets stop_pct_override on the live exit_manager state so the position + exits via FIXED_TP or STOP_LOSS rather than being force-closed. + Records trade_id in _hibernate_protect_active so the exit path can + re-label the reason and finalize posture once the position closes. + """ + pos = self.eng.position + if pos is None: + return + bucket = getattr(self, "_bucket_assignments", {}).get(pos.asset, 'default') + sl_pct = _BUCKET_SL_PCT.get(bucket, _BUCKET_SL_PCT['default']) + tp_pct = self.eng.exit_manager.fixed_tp_pct + + # Patch the live exit_manager state for this trade_id + em_state = self.eng.exit_manager._positions.get(pos.trade_id) + if em_state is not None: + em_state['stop_pct_override'] = sl_pct + else: + # Position not registered in exit_manager (shouldn't happen, but be safe) + log(f" HIBERNATE_PROTECT: trade {pos.trade_id} not in exit_manager — arming anyway via re-setup") + self.eng.exit_manager.setup_position( + pos.trade_id, pos.entry_price, pos.direction, pos.entry_bar, + stop_pct_override=sl_pct, + ) + + self._hibernate_protect_active = pos.trade_id + log(f"HIBERNATE_PROTECT armed: {pos.asset} B{bucket} " + f"SL={sl_pct*100:.2f}% TP={tp_pct*100:.2f}% trade={pos.trade_id}") + + def _apply_catastrophic_floor_to_open_position(self): + """Keep a bounded live loss floor armed on the current BLUE position.""" + floor_pct, floor_label = self._catastrophic_floor_for_open_position() + if floor_pct <= 0.0: + return + if self.eng is None: + return + pos = getattr(self.eng, "position", None) + if pos is None: + return + trade_id = str(getattr(pos, "trade_id", "") or "") + if not trade_id: + return + + try: + em_state = self.eng.exit_manager._positions.get(trade_id) + if em_state is None: + self.eng.exit_manager.setup_position( + trade_id, + pos.entry_price, + pos.direction, + pos.entry_bar, + stop_pct_override=floor_pct, + ) + log( + f"CATASTROPHIC_FLOOR armed: {pos.asset} " + f"SL={floor_pct*100:.2f}% mode={floor_label} trade={trade_id}" + ) + return + + current = _safe_float(em_state.get("stop_pct_override"), 0.0) + if current <= 0.0 or current > floor_pct: + em_state["stop_pct_override"] = floor_pct + log( + f"CATASTROPHIC_FLOOR armed: {pos.asset} " + f"SL={floor_pct*100:.2f}% mode={floor_label} trade={trade_id}" + ) + except Exception as e: + log(f" CATASTROPHIC_FLOOR failed: {e}") + + def _catastrophic_floor_for_open_position(self) -> tuple[float, str]: + base_floor = float(getattr(self, "_catastrophic_floor_pct", 0.0) or 0.0) + if self.eng is None: + return base_floor, "base" + pos = getattr(self.eng, "position", None) + if pos is None: + return base_floor, "base" + trade_id = str(getattr(pos, "trade_id", "") or "") + pending = self._pending_entries.get(trade_id, {}) if trade_id else {} + if not bool(pending.get("overlay_flip", False)): + return base_floor, "base" + + overlay_floor = float(getattr(self, "_overlay_catastrophic_floor_pct", 0.0) or 0.0) + candidates = [value for value in (base_floor, overlay_floor) if value > 0.0] + floor_pct = min(candidates) if candidates else 0.0 + + notional = _safe_float( + pending.get("notional_entry", pending.get("notional", getattr(pos, "notional", 0.0))), + 0.0, + ) + max_loss_usd = float(getattr(self, "_overlay_catastrophic_max_loss_usd", 0.0) or 0.0) + if notional > 0.0 and max_loss_usd > 0.0: + floor_pct = min(floor_pct, max_loss_usd / notional) if floor_pct > 0.0 else max_loss_usd / notional + + reason = str(pending.get("overlay_reason", "") or "overlay") + return max(0.0, floor_pct), f"overlay:{reason}" + + def _overlay_advsl_should_exit( + self, + trade_id: str, + pending: Mapping[str, Any], + v7_decision: Mapping[str, Any], + bars_held: int, + current_price: float, + ) -> tuple[bool, str]: + if not bool(getattr(self, "_overlay_advsl_live_exit_enabled", False)): + return False, "disabled" + if not bool(pending.get("overlay_flip", False)): + return False, "not_overlay" + if int(bars_held or 0) < int(getattr(self, "_overlay_advsl_min_bars", 0) or 0): + return False, "min_hold" + + entry = _safe_float(pending.get("entry_price"), 0.0) + if entry <= 0.0 or current_price <= 0.0: + return False, "bad_price" + side = str(pending.get("side", "SHORT") or "SHORT").upper() + favorable = ((current_price - entry) / entry) if side == "LONG" else ((entry - current_price) / entry) + adverse = max(0.0, -favorable) + lifetime_mfe = max(0.0, _safe_float(v7_decision.get("mfe"), 0.0)) + pressure = _safe_float(v7_decision.get("exit_pressure"), 0.0) + mae_risk = _safe_float(v7_decision.get("mae_risk"), 0.0) + + floor_pct, floor_label = self._catastrophic_floor_for_open_position() + meaningful_mfe = float(getattr(self, "_overlay_advsl_mfe_max_pct", 0.0) or 0.0) + pressure_min = float(getattr(self, "_overlay_advsl_pressure_min", 0.0) or 0.0) + mae_risk_min = float(getattr(self, "_overlay_advsl_mae_risk_min", 0.0) or 0.0) + no_meaningful_mfe = lifetime_mfe <= meaningful_mfe + pressure_gate = pressure >= pressure_min and mae_risk >= mae_risk_min + + if adverse >= floor_pct and floor_pct > 0.0 and (no_meaningful_mfe or pressure_gate): + return True, ( + f"{floor_label}:adverse={adverse:.5f}:mfe={lifetime_mfe:.5f}:" + f"pressure={pressure:.2f}:mae_risk={mae_risk:.2f}" + ) + return False, "hold" + + def _connect_hz(self): + log("Connecting to Hazelcast...") + import hazelcast + self.hz_client = hazelcast.HazelcastClient( + cluster_name=HZ_CLUSTER, + cluster_members=[HZ_HOST], + invocation_timeout=3.0, # prevent indefinite scan-loop stall when HZ is unresponsive + ) + self.features_map = self.hz_client.get_map("DOLPHIN_FEATURES") + self.safety_map = self.hz_client.get_map("DOLPHIN_SAFETY") + self.pnl_map = self.hz_client.get_map("DOLPHIN_PNL_BLUE") + self.state_map = self.hz_client.get_map("DOLPHIN_STATE_BLUE") + self.heartbeat_map = self.hz_client.get_map("DOLPHIN_HEARTBEAT") + self.control_map = self.hz_client.get_map("DOLPHIN_CONTROL_PLANE") + if self._advanced_sl is not None: + try: + self._advanced_sl.bind_hz(features_map=self.features_map, state_map=self.state_map) + self._advanced_sl.publish_control_plane() + except Exception: + pass + # Immediate heartbeat — prevents Cat1=0 during startup gap + try: + write_runner_heartbeat( + self.heartbeat_map, + build_runner_heartbeat_payload( + flow="nautilus_event_trader", + phase="starting", + run_date=self.current_day, + runner="blue", + ), + ) + except Exception: + pass + log(" Hz connected") + + def _heartbeat_loop(self): + """Out-of-band heartbeat writer (independent of scan loop).""" + while not self._heartbeat_stop.is_set(): + try: + if self.heartbeat_map is not None: + now = time.time() + write_runner_heartbeat( + self.heartbeat_map, + build_runner_heartbeat_payload( + flow="nautilus_event_trader", + phase="trading", + run_date=self.current_day, + runner="blue", + extra={ + "last_scan_age_s": round(now - self._last_scan_accept_ts, 1), + "last_event_age_s": round(now - self._last_scan_event_ts, 1), + "scans_processed": self.scans_processed, + }, + ), + ) + if self.control_map is not None: + self._drain_runtime_commands() + except Exception as e: + # Never route heartbeat failures through the mounted trade log: + # if that filesystem is sick, the exception handler must still + # survive so the loop can retry on the next tick. + try: + print( + f"[{datetime.now(timezone.utc).isoformat()}] " + f"Heartbeat loop put failed: {e}", + flush=True, + ) + except Exception: + pass + finally: + self._heartbeat_stop.wait(10.0) + + # ── Scan-flow watchdog ──────────────────────────────────────────────────── + # Detection only — no alpha/engine involvement. Distinguishes three stall + # modes and recovers the two that a process restart fixes: + # 1. worker stuck (events fresh, accepts stale, no dupe churn) → restart + # 2. listener deaf (events stale but HZ key still advancing) → restart + # 3. upstream dark (HZ key frozen too) → log only + # Uses print() not log(): log() appends to the CIFS share and the watchdog + # must stay alive precisely when that mount is sick. + + def _probe_latest_scan_number(self, timeout_s: float = 10.0): + """Read latest_eigen_scan from HZ off-thread; None on timeout/error.""" + try: + fut = self._probe_executor.submit( + lambda: self.features_map.blocking().get('latest_eigen_scan') + ) + try: + raw = fut.result(timeout=timeout_s) + except Exception: + fut.cancel() + raise + if not raw: + return None + scan = json.loads(raw) if isinstance(raw, str) else raw + if scan.get('version') == 'NG7': + inner = scan.get('scan_number') + if inner is None and isinstance(scan.get('scan'), dict): + inner = scan['scan'].get('scan_number') + return int(inner or 0) + return int(scan.get('scan_number') or 0) + except Exception: + return None + + def _watchdog_restart(self, reason: str): + print(f"[{datetime.now(timezone.utc).isoformat()}] " + f"WATCHDOG_RESTART: {reason} — exiting {WATCHDOG_EXIT_CODE} for " + f"supervisord respawn (capital/position restore on boot)", flush=True) + os._exit(WATCHDOG_EXIT_CODE) + + def _scan_watchdog_loop(self): + last_probe_num = None + last_probe_ts = 0.0 + last_dark_log_ts = 0.0 + dupes_at_stall = None + while not self._watchdog_stop.is_set(): + self._watchdog_stop.wait(15.0) + if self._watchdog_stop.is_set() or not running: + return + now = time.time() + acc_age = now - self._last_scan_accept_ts + ev_age = now - self._last_scan_event_ts + if acc_age < SCAN_STALL_S: + last_probe_num = None + dupes_at_stall = None + continue + uptime_ok = (now - _PROCESS_BOOT_TS) > WATCHDOG_RESTART_MIN_UPTIME_S + if ev_age < SCAN_STALL_S: + # Listener delivering but worker not accepting. + if dupes_at_stall is None: + dupes_at_stall = self._dupe_drops_total + continue + if self._dupe_drops_total > dupes_at_stall: + if now - last_dark_log_ts > UPSTREAM_DARK_LOG_EVERY_S: + last_dark_log_ts = now + print(f"[{datetime.now(timezone.utc).isoformat()}] " + f"WATCHDOG: upstream repeating duplicate scan_number " + f"{self.last_scan_number} for {acc_age:.0f}s — scanner stuck, " + f"no restart (restart will not help)", flush=True) + elif uptime_ok: + self._watchdog_restart( + f"scan worker stalled {acc_age:.0f}s with events still arriving " + f"(likely blocked I/O while holding eng_lock)") + continue + # Both event stream and accepts stale → probe HZ for deafness. + probe = self._probe_latest_scan_number() + if probe is None: + # Persistent probe failure = our HZ client is dead (listener + # and probe share it). 2026-06-10 15:18 incident: scanner kept + # writing, PINK kept receiving, but BLUE's client died — probe + # returned None forever and the old logic mislabeled it + # "upstream dark" and never restarted. Three consecutive + # failures (~45 s) with uptime past warm-up → self-restart. + self._probe_fail_streak = getattr(self, "_probe_fail_streak", 0) + 1 + if self._probe_fail_streak >= 3 and uptime_ok: + self._watchdog_restart( + f"HZ probe failed {self._probe_fail_streak}x while no " + f"events for {ev_age:.0f}s — HZ client presumed dead") + else: + self._probe_fail_streak = 0 + if probe is not None: + if last_probe_num is None: + last_probe_num = probe + last_probe_ts = now + elif (now - last_probe_ts) >= WATCHDOG_PROBE_INTERVAL_S: + if probe != last_probe_num and uptime_ok: + self._watchdog_restart( + f"listener deaf: HZ latest_eigen_scan advanced " + f"{last_probe_num} → {probe} but no events for {ev_age:.0f}s") + last_probe_num = probe + last_probe_ts = now + if now - last_dark_log_ts > UPSTREAM_DARK_LOG_EVERY_S: + last_dark_log_ts = now + print(f"[{datetime.now(timezone.utc).isoformat()}] " + f"WATCHDOG: NO SCANS for {acc_age:.0f}s (HZ scan_number probe=" + f"{probe}) — upstream scanner appears DARK; open positions are " + f"UNMANAGED until scans resume", flush=True) + + def _read_posture(self): + now = time.time() + if now - self.posture_cache_time < 10: + return self.cached_posture + try: + posture_raw = self.safety_map.blocking().get("latest") or self.safety_map.blocking().get("posture") + if posture_raw: + if isinstance(posture_raw, str): + try: + parsed = json.loads(posture_raw) + self.cached_posture = parsed.get("posture", posture_raw) + except (json.JSONDecodeError, AttributeError): + self.cached_posture = posture_raw + else: + self.cached_posture = posture_raw.get("posture", "APEX") + self.posture_cache_time = now + except: + pass + return self.cached_posture + + def _rollover_day(self): + today = datetime.now(timezone.utc).strftime('%Y-%m-%d') + if today == self.current_day: + return + posture = self._read_posture() + with self.eng_lock: + if today != self.current_day: # double-checked: only one thread calls begin_day + if getattr(self, 'acb', None): + try: + exf_raw = self.features_map.blocking().get('exf_latest') if self.features_map else None + es_raw = self.features_map.blocking().get('latest_eigen_scan') if self.features_map else None + + exf_snapshot = json.loads(exf_raw) if isinstance(exf_raw, str) else (exf_raw or {}) + eigen_scan = json.loads(es_raw) if isinstance(es_raw, str) else (es_raw or {}) + + w750_vel = eigen_scan.get('w750_velocity', 0.0) + + if exf_snapshot: + self.acb.get_dynamic_boost_from_hz( + date_str=today, + exf_snapshot=exf_snapshot, + w750_velocity=float(w750_vel) if w750_vel else None, + direction=self.trade_direction, + ) + log(f"ACB: Pre-warmed cache for {today} from HZ") + except Exception as e: + log(f"ACB Rollover Error: {e}") + + self.eng.begin_day(today, posture=posture, direction=self.trade_direction) + self.bar_idx = 0 + self.current_day = today + log( + f"begin_day({today}) called with posture={posture} " + f"direction={_direction_label(self.trade_direction)}" + ) + + def _mark_retract_command_seen(self, command_id: str) -> None: + if not command_id or command_id in self._processed_retract_set: + return + self._processed_retract_commands.append(command_id) + self._processed_retract_set.add(command_id) + + def _mark_runtime_command_seen(self, command_id: str) -> None: + """Mark a runtime command id as processed for idempotency.""" + self._mark_retract_command_seen(command_id) + + def _enqueue_blue_runtime_command(self, cmd: dict) -> bool: + """Append a command to the BLUE runtime command queue.""" + if self.control_map is None: + return False + try: + raw_q = self.control_map.blocking().get("blue_runtime_commands") + q = json.loads(raw_q) if isinstance(raw_q, str) and raw_q else [] + if not isinstance(q, list): + q = [] + q.append(cmd) + q = q[-200:] + self.control_map.blocking().put("blue_runtime_commands", json.dumps(q)) + return True + except Exception as e: + log(f" BLUE runtime command enqueue failed: {e}") + return False + + def _capital_state_payload( + self, + capital: float, + *, + reason: str = "", + source: str = "", + trade_id: str = "", + asset: str = "", + replay_blob: Mapping[str, Any] | None = None, + ) -> dict: + """Build a canonical capital payload for HZ and disk persistence.""" + payload = dict(replay_blob or {}) + payload["capital"] = float(capital) + payload["ts"] = float(time.time()) + payload["updated_at"] = datetime.now(timezone.utc).isoformat() + payload["reason"] = str(reason or payload.get("reason", "") or "") + payload["source"] = str(source or payload.get("source", "") or "") + if trade_id: + payload["trade_id"] = str(trade_id) + if asset: + payload["asset"] = str(asset) + payload.setdefault("strategy", "nautilus-blue") + payload.setdefault("engine", "nautilus_event_trader") + return payload + + def _capital_ledger_event_payload( + self, + *, + capital_before: float, + capital_after: float, + reason: str = "", + source: str = "", + trade_id: str = "", + asset: str = "", + event_ts: float | None = None, + applies_before_ts: float | None = None, + mode: str = "terminal_update", + replay_blob: Mapping[str, Any] | None = None, + ) -> dict: + payload = dict(replay_blob or {}) + payload["capital_before"] = float(capital_before) + payload["capital_after"] = float(capital_after) + payload["capital"] = float(capital_after) + payload["capital_delta"] = float(capital_after - capital_before) + payload["ts"] = float(event_ts if event_ts is not None else time.time()) + payload["updated_at"] = datetime.now(timezone.utc).isoformat() + payload["reason"] = str(reason or payload.get("reason", "") or "") + payload["source"] = str(source or payload.get("source", "") or "") + payload["mode"] = str(mode) + if applies_before_ts is not None: + payload["applies_before_ts"] = float(applies_before_ts) + if trade_id: + payload["trade_id"] = str(trade_id) + if asset: + payload["asset"] = str(asset) + payload.setdefault("strategy", "nautilus-blue") + payload.setdefault("engine", "nautilus_event_trader") + return payload + + def _record_capital_ledger_event(self, entry: Mapping[str, Any]) -> None: + """Append a capital event to the durable BLUE ledger surfaces.""" + try: + raw = None + ledger = [] + if self.state_map is not None: + raw = self.state_map.blocking().get("capital_update_ledger") + if raw: + ledger = json.loads(raw) if isinstance(raw, str) else list(raw) + if not isinstance(ledger, list): + ledger = [] + ledger.append(dict(entry)) + ledger = ledger[-1000:] + ledger_payload = json.dumps(ledger) + if self.state_map is not None: + self.state_map.blocking().put("capital_update_ledger", ledger_payload) + if self.control_map is not None: + self.control_map.blocking().put("blue_capital_update_ledger_latest", json.dumps(dict(entry))) + CAPITAL_UPDATE_LEDGER.write_text(ledger_payload) + except Exception as e: + log(f" capital ledger write failed: {e}") + + def _current_capital_state_timestamp(self) -> float | None: + """Return the freshest timestamp currently known for BLUE capital state.""" + candidates: list[float] = [] + + def _maybe_add_blob(raw, source: str) -> None: + parsed = self._parse_capital_blob(raw, source) + if parsed is None: + return + _, blob = parsed + ts = self._extract_state_timestamp(blob) + if ts is not None: + candidates.append(ts) + + try: + if self.state_map is not None: + for key in ("latest_nautilus", "engine_snapshot", CAPITAL_CORRECTIVE_REPLAY_HZ_KEY, "capital_checkpoint"): + try: + _maybe_add_blob(self.state_map.blocking().get(key), f"HZ {key}") + except Exception: + continue + except Exception: + pass + try: + if self.pnl_map is not None: + day_key = datetime.now(timezone.utc).strftime("%Y-%m-%d") + _maybe_add_blob(self.pnl_map.blocking().get(day_key), f"HZ pnl[{day_key}]") + except Exception: + pass + try: + if CAPITAL_DISK_CHECKPOINT.exists(): + raw = CAPITAL_DISK_CHECKPOINT.read_text() + data = json.loads(raw) if raw else {} + ts = self._extract_state_timestamp(data if isinstance(data, dict) else {}) + if ts is not None: + candidates.append(ts) + except Exception: + pass + try: + if CAPITAL_UPDATE_LEDGER.exists(): + raw = CAPITAL_UPDATE_LEDGER.read_text() + rows = json.loads(raw) if raw else [] + if isinstance(rows, list) and rows: + last = rows[-1] if isinstance(rows[-1], dict) else None + if isinstance(last, dict): + ts = self._extract_state_timestamp(last) + if ts is not None: + candidates.append(ts) + except Exception: + pass + return max(candidates) if candidates else None + + def _resolved_capital_state_value(self, fallback: float | None = None) -> tuple[float | None, str, float | None]: + """Return the freshest authoritative BLUE capital value available locally.""" + candidates: list[tuple[float, int, float, str, float | None]] = [] + source_rank = { + "capital_update_ledger": 65, + "latest_nautilus": 40, + "engine_snapshot": 30, + "pnl_day": 25, + "correction_replay_local": 20, + "correction_replay_hz": 10, + "capital_checkpoint": 5, + } + + def _maybe_add_blob(raw, source: str, rank_key: str) -> None: + parsed = self._parse_capital_blob(raw, source) + if parsed is None: + return + capital, blob = parsed + ts = self._extract_state_timestamp(blob) + candidates.append( + ( + ts if ts is not None else float("-inf"), + source_rank.get(rank_key, 0), + capital, + source, + ts, + ) + ) + + try: + if CAPITAL_CORRECTIVE_REPLAY.exists(): + try: + replay_blob = json.loads(CAPITAL_CORRECTIVE_REPLAY.read_text()) + except Exception: + replay_blob = None + if isinstance(replay_blob, dict): + replay_capital = _safe_float(replay_blob.get("capital", 0.0), 0.0) + if replay_capital >= 1.0 and math.isfinite(replay_capital): + replay_ts = self._extract_state_timestamp(replay_blob) + candidates.append( + ( + replay_ts if replay_ts is not None else float("-inf"), + source_rank["correction_replay_local"], + replay_capital, + "local corrective replay", + replay_ts, + ) + ) + except Exception: + pass + + try: + if self.state_map is not None: + raw_ledger = self.state_map.blocking().get("capital_update_ledger") + ledger_rows = json.loads(raw_ledger) if isinstance(raw_ledger, str) and raw_ledger else list(raw_ledger or []) + if isinstance(ledger_rows, list) and ledger_rows: + last = ledger_rows[-1] if isinstance(ledger_rows[-1], dict) else None + if isinstance(last, dict): + capital_after = _safe_float(last.get("capital_after", last.get("capital", 0.0)), 0.0) + if capital_after >= 1.0 and math.isfinite(capital_after): + ledger_ts = self._extract_state_timestamp(last) + candidates.append( + ( + ledger_ts if ledger_ts is not None else float("-inf"), + source_rank["capital_update_ledger"], + capital_after, + "capital_update_ledger", + ledger_ts, + ) + ) + for key, label, rank_key in ( + ("latest_nautilus", "HZ latest_nautilus", "latest_nautilus"), + ("engine_snapshot", "HZ engine_snapshot", "engine_snapshot"), + (CAPITAL_CORRECTIVE_REPLAY_HZ_KEY, "HZ corrective replay", "correction_replay_hz"), + ("capital_checkpoint", "HZ capital_checkpoint", "capital_checkpoint"), + ): + try: + raw = self.state_map.blocking().get(key) + except Exception: + raw = None + _maybe_add_blob(raw, label, rank_key) + except Exception: + pass + + try: + if self.pnl_map is not None: + day_key = datetime.now(timezone.utc).strftime("%Y-%m-%d") + raw = self.pnl_map.blocking().get(day_key) + _maybe_add_blob(raw, f"HZ pnl[{day_key}]", "pnl_day") + except Exception: + pass + + try: + if CAPITAL_DISK_CHECKPOINT.exists(): + raw = CAPITAL_DISK_CHECKPOINT.read_text() + data = json.loads(raw) if raw else {} + if isinstance(data, dict): + capital = _safe_float(data.get("capital", 0.0), 0.0) + ts = self._extract_state_timestamp(data) + if capital >= 1.0 and math.isfinite(capital): + candidates.append( + ( + ts if ts is not None else float("-inf"), + source_rank["capital_checkpoint"], + capital, + "disk capital_checkpoint", + ts, + ) + ) + except Exception: + pass + + if candidates: + candidates.sort(key=lambda item: (item[0], item[1]), reverse=True) + _, _, capital, source, ts = candidates[0] + return capital, source, ts + + if fallback is not None: + try: + fallback_f = float(fallback) + except Exception: + fallback_f = None + if fallback_f is not None and math.isfinite(fallback_f) and fallback_f >= 1.0: + return fallback_f, "engine_fallback", None + return None, "unresolved", None + + def _resolved_realized_trade_pnl( + self, + pending: Mapping[str, Any], + outcome: Mapping[str, Any], + *, + exit_price: float | None = None, + ) -> tuple[float, str]: + """Resolve realized PnL from the most trustworthy available close payload fields.""" + raw_net = _safe_float(outcome.get("net_pnl", outcome.get("pnl", 0.0)), float("nan")) + if math.isfinite(raw_net) and abs(raw_net) >= 1e-9: + return raw_net, "net_pnl" + + pnl_pct = _safe_float(outcome.get("pnl_pct", 0.0), float("nan")) + notional = _safe_float(pending.get("notional_entry", pending.get("notional", 0.0)), float("nan")) + if math.isfinite(pnl_pct) and math.isfinite(notional) and abs(pnl_pct) > 0.0 and notional > 0.0: + return pnl_pct * notional, "pnl_pct_notional" + + entry_price = _safe_float(pending.get("entry_price", 0.0), float("nan")) + qty = _safe_float(pending.get("quantity", 0.0), float("nan")) + resolved_exit = exit_price if exit_price is not None else _safe_float(outcome.get("exit_price", 0.0), float("nan")) + if math.isfinite(entry_price) and math.isfinite(qty) and math.isfinite(resolved_exit): + if entry_price > 0.0 and qty > 0.0 and resolved_exit > 0.0: + side = str(pending.get("side", "SHORT") or "SHORT").upper() + direction = -1.0 if side == "SHORT" else 1.0 + return direction * ((resolved_exit - entry_price) * qty), "entry_exit_qty" + + if math.isfinite(raw_net): + return raw_net, "raw_net" + return 0.0, "unresolved" + + @staticmethod + def _truthy_flag(value: Any) -> bool: + """Interpret loose flag values from runtime payloads.""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on", "y", "t"} + return False + + def _resolved_capital_apply_pnl(self, outcome: Mapping[str, Any], realized_pnl: float) -> tuple[float, str]: + """Resolve capital delta for close handling, suppressing known already-realized exits.""" + if self._truthy_flag(outcome.get("capital_already_realized", False)): + return 0.0, "already_realized" + return float(realized_pnl or 0.0), "direct" + + def _commit_capital_state( + self, + capital: float, + *, + reason: str = "", + source: str = "", + trade_id: str = "", + asset: str = "", + replay_blob: Mapping[str, Any] | None = None, + update_replay_key: bool = False, + mirror_control_plane: bool = True, + ) -> dict | None: + """Write capital to all canonical BLUE bookkeeping surfaces.""" + capital = float(capital) + if capital < 1.0 or not math.isfinite(capital): + return None + payload = self._capital_state_payload( + capital, + reason=reason, + source=source, + trade_id=trade_id, + asset=asset, + replay_blob=replay_blob, + ) + runtime_snapshot = getattr(self, "_last_engine_snapshot_payload", None) + if isinstance(runtime_snapshot, Mapping): + merged_payload = dict(runtime_snapshot) + merged_payload.update(payload) + payload = merged_payload + checkpoint_payload = json.dumps({"capital": capital, "ts": payload["ts"]}) + state_payload = json.dumps(payload) + try: + if self.state_map is not None: + self.state_map.blocking().put("capital_checkpoint", checkpoint_payload) + self.state_map.blocking().put("latest_nautilus", state_payload) + self.state_map.blocking().put("engine_snapshot", state_payload) + if update_replay_key: + self.state_map.blocking().put(CAPITAL_CORRECTIVE_REPLAY_HZ_KEY, state_payload) + except Exception as e: + log(f" capital state HZ save failed: {e}") + if update_replay_key: + try: + CAPITAL_CORRECTIVE_REPLAY.write_text(state_payload) + except Exception as e: + log(f" capital corrective replay save failed: {e}") + try: + if self.pnl_map is not None: + day_key = datetime.now(timezone.utc).strftime('%Y-%m-%d') + self.pnl_map.blocking().put(day_key, state_payload) + except Exception as e: + log(f" capital pnl state save failed: {e}") + try: + CAPITAL_DISK_CHECKPOINT.write_text(checkpoint_payload) + except Exception as e: + log(f" capital disk save failed: {e}") + if mirror_control_plane and self.control_map is not None: + try: + self.control_map.blocking().put("blue_capital_update_latest", state_payload) + except Exception as e: + log(f" capital control plane mirror failed: {e}") + self.eng.capital = capital + return payload + + def _apply_trade_capital_update( + self, + realized_pnl: float, + *, + reason: str, + source: str, + trade_id: str, + asset: str, + mirror_control_plane: bool = True, + ) -> tuple[float, float]: + """Apply a realized trade PnL to live capital and persist it immediately.""" + capital_before, capital_source, capital_ts = self._resolved_capital_state_value( + fallback=float(getattr(self.eng, "capital", 0.0) or 0.0) + ) + capital_before = float(capital_before or 0.0) + if capital_before < 1.0 or not math.isfinite(capital_before): + capital_before = 0.0 + else: + self.eng.capital = capital_before + capital_after = capital_before + float(realized_pnl or 0.0) + payload = self._commit_capital_state( + capital_after, + reason=reason, + source=source, + trade_id=trade_id, + asset=asset, + mirror_control_plane=mirror_control_plane, + ) + if payload is not None: + ledger_entry = self._capital_ledger_event_payload( + capital_before=capital_before, + capital_after=capital_after, + reason=reason, + source=source, + trade_id=trade_id, + asset=asset, + event_ts=self._parse_timestamp_seconds(payload.get("ts")), + applies_before_ts=self._parse_timestamp_seconds(payload.get("ts")), + mode="terminal_update", + ) + self._record_capital_ledger_event(ledger_entry) + if capital_source != "engine_fallback": + log( + " capital update base resolved from " + f"{capital_source}" + + (f" ts={capital_ts:.3f}" if capital_ts is not None else "") + + f": before={capital_before:.2f} after={capital_after:.2f}" + ) + return capital_before, capital_after + + def _apply_internal_capital_update(self, cmd: dict) -> tuple[dict | None, str]: + """Apply an in-band capital update command to the live BLUE engine.""" + raw_capital = cmd.get("capital", None) + capital = _safe_float(raw_capital, float("nan")) + if capital < 1.0 or not math.isfinite(capital): + return None, "BAD_CAPITAL" + replay_blob = cmd.get("replay_blob") if isinstance(cmd.get("replay_blob"), Mapping) else None + capital_before = float(getattr(self.eng, "capital", capital) or capital) + event_ts = self._parse_timestamp_seconds( + cmd.get("event_ts") + or cmd.get("ts") + or (replay_blob.get("updated_at") if replay_blob else None) + or (replay_blob.get("ts") if replay_blob else None) + ) + applies_before_ts = self._parse_timestamp_seconds(cmd.get("applies_before_ts")) + historical_only = False + if replay_blob is not None: + replay_ts = self._extract_state_timestamp(replay_blob) + current_ts = self._current_capital_state_timestamp() + if replay_ts is not None and current_ts is not None and replay_ts + 1.0 < current_ts: + historical_only = True + ledger_entry = self._capital_ledger_event_payload( + capital_before=capital_before, + capital_after=capital_before, + reason=str(cmd.get("reason", "CAPITAL_UPDATE") or "CAPITAL_UPDATE"), + source=str(cmd.get("source", "control_plane") or "control_plane"), + trade_id=str(cmd.get("trade_id", "") or ""), + asset=str(cmd.get("asset", "") or ""), + event_ts=event_ts, + applies_before_ts=current_ts, + mode="historical_replay_only", + replay_blob=replay_blob, + ) + self._record_capital_ledger_event(ledger_entry) + log( + " capital update recorded as historical replay " + f"ts={replay_ts:.3f} current_ts={current_ts:.3f}" + ) + return ledger_entry, "RECORDED_HISTORICAL" + payload = self._commit_capital_state( + capital, + reason=str(cmd.get("reason", "CAPITAL_UPDATE") or "CAPITAL_UPDATE"), + source=str(cmd.get("source", "control_plane") or "control_plane"), + trade_id=str(cmd.get("trade_id", "") or ""), + asset=str(cmd.get("asset", "") or ""), + replay_blob=replay_blob, + update_replay_key=bool(replay_blob), + mirror_control_plane=True, + ) + if payload is None: + return None, "BAD_CAPITAL" + ledger_entry = self._capital_ledger_event_payload( + capital_before=capital_before, + capital_after=capital, + reason=str(cmd.get("reason", "CAPITAL_UPDATE") or "CAPITAL_UPDATE"), + source=str(cmd.get("source", "control_plane") or "control_plane"), + trade_id=str(cmd.get("trade_id", "") or ""), + asset=str(cmd.get("asset", "") or ""), + event_ts=event_ts, + applies_before_ts=applies_before_ts, + mode="terminal_update" if not historical_only else "historical_replay_only", + replay_blob=replay_blob, + ) + self._record_capital_ledger_event(ledger_entry) + return payload, "APPLIED" + + @staticmethod + def _sc_trim_fraction(current_mult: float, target_mult: float) -> float: + """Translate a desired remaining multiplier into a retract fraction.""" + cur = float(current_mult or 0.0) + tgt = float(target_mult or 0.0) + if not math.isfinite(cur) or not math.isfinite(tgt): + return 0.0 + cur = max(0.0, cur) + tgt = max(0.0, tgt) + if cur <= 0.0 or tgt >= cur: + return 0.0 + return max(0.0, min(1.0, 1.0 - (tgt / cur))) + + def _record_sc_haircut(self, *, trade_id: str, pending: dict, source: str) -> dict | None: + """Record SC haircut guidance as sizing metadata only. + + SC is not an actuation surface. It records a haircut target that later + sizing logic can use, but it does not enqueue a live retract command. + """ + if not trade_id: + return None + pos = getattr(self.eng, "position", None) + if pos is None: + return None + pos_tid = str(getattr(pos, "trade_id", "") or "") + if pos_tid and pos_tid != str(trade_id): + return None + + recs: list[float] = [] + sc_rec = pending.get("sc_threshold_advisor") + if isinstance(sc_rec, dict): + recs.append(float(sc_rec.get("recommended_mult", 1.0) or 1.0)) + gauge_rec = pending.get("sc_bucket_gauge") + if isinstance(gauge_rec, dict): + recs.append(float(gauge_rec.get("recommended_size_mult", 1.0) or 1.0)) + if not recs: + return None + + target_mult = max(0.0, min(recs)) + current_notional = float(getattr(pos, "notional", pending.get("notional", 0.0)) or 0.0) + entry_notional = float( + pending.get("notional_entry", pending.get("notional", current_notional)) or current_notional + ) + if current_notional <= 0.0 or entry_notional <= 0.0: + return None + current_mult = current_notional / entry_notional + last_target = float(pending.get("sc_haircut_last_target_mult", 1.0) or 1.0) + if target_mult >= current_mult - 1e-6 or target_mult >= last_target - 1e-6: + return None + + frac = self._sc_trim_fraction(current_mult=current_mult, target_mult=target_mult) + if frac <= 0.0: + return None + + pending["sc_haircut_target_mult"] = target_mult + pending["sc_haircut_fraction"] = frac + pending["sc_haircut_source"] = str(source or "sc") + pending["sc_haircut_last_updated_ts"] = float(time.time()) + pending["sc_haircut_last_target_mult"] = target_mult + self._pending_entries[trade_id] = pending + log( + f" SC haircut record: {trade_id} target={target_mult:.2f} " + f"cur={current_mult:.2f} frac={frac:.3f} source={source}" + ) + return { + "trade_id": trade_id, + "target_mult": target_mult, + "current_mult": current_mult, + "fraction": frac, + "source": str(source or "sc"), + } + + def _apply_sc_entry_size_multiplier(self, trade_id: str, entry: dict, pending: dict) -> float: + """Apply the live EsoF/SC size gate to an entry before persistence. + + This is the actual sizing actuation surface for the deterministic SC gate. + It keeps the haircut size-only: no retract/close commands are enqueued. + """ + mult = float(self._last_esof_size_mult or 1.0) + if not math.isfinite(mult): + mult = 1.0 + mult = max(0.0, min(1.0, mult)) + pending["sc_exec_mult"] = mult + if mult >= 0.999: + return mult + + entry_price = float(entry.get("entry_price", pending.get("entry_price", 0.0)) or 0.0) + base_notional = float(entry.get("notional", pending.get("notional", 0.0)) or 0.0) + if base_notional <= 0.0 and entry_price > 0.0: + quantity = float(entry.get("quantity", pending.get("quantity", 0.0)) or 0.0) + base_notional = quantity * entry_price + if base_notional <= 0.0: + return mult + + effective_notional = round(base_notional * mult, 12) + if effective_notional <= 0.0: + return mult + + base_quantity = float(entry.get("quantity", pending.get("quantity", 0.0)) or 0.0) + if base_quantity <= 0.0 and entry_price > 0.0: + base_quantity = base_notional / entry_price + effective_quantity = round(effective_notional / max(entry_price, 1e-12), 6) if entry_price > 0.0 else base_quantity * mult + base_leverage = float(entry.get("leverage", pending.get("leverage", 0.0)) or 0.0) + effective_leverage = round(base_leverage * mult, 6) if base_leverage > 0.0 else base_leverage + + entry.setdefault("notional_entry", base_notional) + entry["notional"] = effective_notional + entry["quantity"] = effective_quantity + if effective_leverage > 0.0: + entry["leverage"] = effective_leverage + entry["sc_exec_mult"] = mult + entry["sc_exec_notional"] = effective_notional + entry["sc_exec_quantity"] = effective_quantity + + pending.setdefault("notional_entry", base_notional) + pending["notional"] = effective_notional + pending["quantity"] = effective_quantity + if effective_leverage > 0.0: + pending["leverage"] = effective_leverage + pending["sc_exec_notional"] = effective_notional + pending["sc_exec_quantity"] = effective_quantity + pending["sc_exec_leverage"] = effective_leverage if effective_leverage > 0.0 else base_leverage + + pos = getattr(self.eng, "position", None) + if pos is not None and str(getattr(pos, "trade_id", "") or "") in ("", str(trade_id)): + try: + pos.notional = effective_notional + except Exception: + pass + try: + pos.quantity = effective_quantity + except Exception: + pass + if effective_leverage > 0.0: + try: + pos.leverage = effective_leverage + except Exception: + pass + + log( + f" SC haircut execute: {trade_id} mult={mult:.3f} " + f"notional={base_notional:.6f}->{effective_notional:.6f} " + f"qty={base_quantity:.6f}->{effective_quantity:.6f}" + ) + return mult + + def _build_retract_exit(self, *, trade_id: str, reason: str, bars_held: int, pnl_pct: float, net_pnl: float) -> dict: + return { + "trade_id": trade_id, + "reason": reason, + "bars_held": int(max(0, bars_held)), + "pnl_pct": float(pnl_pct), + "net_pnl": float(net_pnl), + # Full retract legs already realize pnl incrementally; close-path capital + # application must be a no-op to avoid double-booking. + "capital_already_realized": True, + # Preserve explicit economic fields for observability/reporting. + "economic_pnl": float(net_pnl), + "economic_pnl_pct": float(pnl_pct), + } + + def _build_trade_execution_quality_summary( + self, + *, + trade_id: str, + pending: dict, + exit_payload: dict, + capital_before: float, + capital_after: float, + realized_pnl: float, + exit_price: float, + source: str, + ) -> dict: + if build_trade_execution_quality_summary is None: + raise RuntimeError("execution quality summary helper unavailable") + return build_trade_execution_quality_summary( + trade_id=trade_id, + pending=pending, + exit_payload=exit_payload, + capital_before=capital_before, + capital_after=capital_after, + realized_pnl=realized_pnl, + exit_price=exit_price, + source=source, + ts=_ch_ts_us(), + ) + + def _persist_trade_execution_quality(self, record: dict) -> None: + try: + ch_put("trade_execution_quality", record) + except Exception as e: + log(f" trade_execution_quality CH write failed: {e}") + try: + if self.state_map is not None: + self.state_map.blocking().put("last_trade_execution_quality", record) + except Exception as e: + log(f" trade execution quality HZ state write failed: {e}") + try: + if self.control_map is not None: + self.control_map.blocking().put("blue_last_trade_execution_quality", record) + except Exception as e: + log(f" trade execution quality control plane write failed: {e}") + + def _chain_state_for_pending( + self, + trade_id: str, + pending: dict, + *, + chain_mode: str = "LIVE", + chain_head_leg_id: str | None = None, + chain_prev_leg_id: str | None = None, + chain_seq: int | None = None, + ) -> dict: + """Return the canonical linked-list state for the current open trade head.""" + seq = int(chain_seq if chain_seq is not None else pending.get("retraction_legs", 0) or 0) + quantity = float(pending.get("quantity", 0.0) or 0.0) + entry_price = float(pending.get("entry_price", 0.0) or 0.0) + notional = float(pending.get("notional", pending.get("notional_entry", 0.0)) or 0.0) + entry_bar = int(pending.get("entry_bar", 0) or 0) + entry_ts = int(pending.get("entry_ts", 0) or 0) + realized = float(pending.get("realized_pnl_legs_total", 0.0) or 0.0) + return _build_chain_state( + trade_id=str(trade_id or ""), + asset=str(pending.get("asset", "") or ""), + side=str(pending.get("side", "") or "SHORT"), + entry_price=entry_price, + quantity=quantity, + notional=notional, + entry_bar=entry_bar, + entry_ts=entry_ts, + retraction_legs=seq, + realized_pnl_legs_total=realized, + chain_root_trade_id=str(pending.get("chain_root_trade_id", trade_id) or trade_id), + chain_head_leg_id=chain_head_leg_id or pending.get("chain_head_leg_id"), + chain_prev_leg_id=chain_prev_leg_id if chain_prev_leg_id is not None else str(pending.get("chain_prev_leg_id", "") or ""), + chain_mode=chain_mode, + ) + + def _load_chain_ledger_state(self, trade_id: str) -> dict | None: + """Load the latest reconstruction payload for a trade, if ClickHouse is reachable.""" + try: + import base64 as _b64 + escaped_tid = str(trade_id or "").replace("'", "''") + sql = ( + "SELECT event_type, event_id, payload_json " + "FROM dolphin.trade_reconstruction " + f"WHERE trade_id = '{escaped_tid}' " + "ORDER BY ts DESC LIMIT 1 FORMAT JSONEachRow" + ) + req = urllib.request.Request( + "http://localhost:8123/?database=dolphin", + data=sql.encode(), + headers={"Authorization": "Basic " + + _b64.b64encode(b"dolphin:dolphin_ch_2026").decode()}, + ) + with urllib.request.urlopen(req, timeout=5) as r: + raw = r.read().decode().strip() + if not raw: + return None + row = json.loads(raw.splitlines()[0]) + payload = json.loads(row.get("payload_json", "{}") or "{}") + payload["event_type"] = row.get("event_type", "") + payload["event_id"] = row.get("event_id", "") + return payload + except Exception: + return None + + def _chain_state_from_reconstruction(self, trade_id: str, pending: dict, recon: dict | None) -> dict: + """Merge reconstruction payload chain hints with the current live state.""" + chain_data = {} + seq = 0 + prev_leg_id = "" + head_leg_id = f"{trade_id}:open" + chain_mode = "LEGACY" + if recon: + chain_data.update(recon) + nested = recon.get("chain") + if isinstance(nested, dict): + chain_data.update(nested) + seq = int(chain_data.get("chain_seq", chain_data.get("retraction_legs", 0)) or 0) + prev_leg_id = str(chain_data.get("chain_prev_leg_id", "") or "") + head_leg_id = str(chain_data.get("chain_head_leg_id", "") or head_leg_id) + chain_mode = str(chain_data.get("chain_mode", "LIVE") or "LIVE") + if "chain_token" not in chain_data: + chain_mode = "LEGACY_REBUILT" + chain = self._chain_state_for_pending( + trade_id, + pending, + chain_mode=chain_mode, + chain_head_leg_id=head_leg_id, + chain_prev_leg_id=prev_leg_id, + chain_seq=seq, + ) + if chain_data.get("chain_token"): + expected = str(chain_data.get("chain_token", "") or "") + if expected != chain.get("chain_token"): + # Do not hard-halt restore on legacy/stale token drift. + # Keep trading continuity with a rebuilt chain and surface the + # mismatch loudly for follow-up reconciliation. + derived = str(chain.get("chain_token", "") or "") + log( + " chain token mismatch on restore: " + f"trade={trade_id} stored={expected[:12]} derived={derived[:12]} " + "— continuing with derived token" + ) + chain["chain_mode"] = "LEGACY_REBUILT_MISMATCH" + # A log line is not forensics — emit a first-class journal + # event so the mismatch is queryable (the XTZ 863c21da + # incident took a day to reconstruct from grep). + try: + ch_put("trade_reconstruction", { + "ts": _ch_ts_us(), + "trade_id": trade_id, + "event_type": "CHAIN_TOKEN_MISMATCH", + "event_id": f"{trade_id}:chain_mismatch", + "payload_json": json.dumps({ + "stored_token": expected, + "derived_token": derived, + "chain_mode": "LEGACY_REBUILT_MISMATCH", + "pending": {k: pending.get(k) for k in + ("asset", "side", "entry_price", "quantity", + "notional", "entry_bar") if k in pending}, + }, default=str), + "market_state_bundle_json": "", + "tp_base_pct": 0.0, + "tp_effective_pct": 0.0, + "our_leverage": 0.0, + }) + except Exception: + pass + return chain + + def _apply_internal_retract(self, cmd: dict, prices_dict: dict) -> tuple[dict | None, str]: + """Apply partial retraction on in-memory BLUE position; returns (forced_exit, status).""" + with self.eng_lock: + pos = getattr(self.eng, "position", None) + if pos is None: + return None, "NO_POSITION" + tid = str(getattr(pos, "trade_id", "") or "") + if not tid: + return None, "NO_TRADE_ID" + req_tid = str(cmd.get("trade_id", "") or "").strip() + if req_tid and req_tid != tid: + return None, f"TRADE_MISMATCH open={tid} cmd={req_tid}" + pending = self._pending_entries.get(tid) or {} + side = str(pending.get("side", "SHORT") or "SHORT").upper() + entry_price = float(pending.get("entry_price", getattr(pos, "entry_price", 0.0)) or 0.0) + if entry_price <= 0: + return None, "BAD_ENTRY_PRICE" + open_notional = float(getattr(pos, "notional", 0.0) or 0.0) + if open_notional <= 0: + return None, "ZERO_NOTIONAL" + frac = float(cmd.get("fraction", 0.0) or 0.0) + if not (0.0 < frac <= 1.0): + return None, "BAD_FRACTION" + expected_chain = self._chain_state_for_pending(tid, pending) + cmd_chain_token = str(cmd.get("chain_token", "") or "").strip() + cmd_chain_head = str(cmd.get("chain_head_leg_id", "") or "").strip() + cmd_chain_root = str(cmd.get("chain_root_trade_id", "") or "").strip() + cmd_chain_seq = int(cmd.get("chain_seq", expected_chain["chain_seq"]) or expected_chain["chain_seq"]) + if not cmd_chain_token or not cmd_chain_head or not cmd_chain_root: + return None, "NO_CHAIN_LINK" + if cmd_chain_root != expected_chain["chain_root_trade_id"]: + return None, f"CHAIN_ROOT_MISMATCH expected={expected_chain['chain_root_trade_id']} cmd={cmd_chain_root}" + if cmd_chain_head != expected_chain["chain_head_leg_id"] or cmd_chain_token != expected_chain["chain_token"]: + return None, ( + f"CHAIN_MISMATCH head={expected_chain['chain_head_leg_id']} " + f"seq={expected_chain['chain_seq']} token={expected_chain['chain_token'][:12]}" + ) + if cmd_chain_seq != expected_chain["chain_seq"]: + return None, ( + f"CHAIN_SEQ_MISMATCH expected={expected_chain['chain_seq']} cmd={cmd_chain_seq}" + ) + reduce_notional = min(open_notional, open_notional * frac) + if reduce_notional <= 0.0: + return None, "ZERO_REDUCE_NOTIONAL" + current_price = float(prices_dict.get(pos.asset, getattr(pos, "current_price", entry_price)) or entry_price) + if current_price <= 0: + current_price = entry_price + direction = -1.0 if side == "SHORT" else 1.0 + pnl_pct_now = direction * ((current_price - entry_price) / entry_price) + net_pnl_leg = pnl_pct_now * reduce_notional + bars_held = max(0, int(self.bar_idx - int(pending.get("entry_bar", max(0, self.bar_idx - 1)) or max(0, self.bar_idx - 1)))) + capital_before, capital_after = self._apply_trade_capital_update( + net_pnl_leg, + reason=str(cmd.get("reason", "RETRACT")), + source=str(cmd.get("source", "internal")), + trade_id=tid, + asset=str(getattr(pos, "asset", pending.get("asset", ""))), + mirror_control_plane=True, + ) + remaining_notional = max(0.0, open_notional - reduce_notional) + remaining_qty = round((remaining_notional / entry_price), 6) if entry_price > 0 else 0.0 + pos.notional = remaining_notional + pos.current_price = current_price + try: + pos.pnl_pct = pnl_pct_now + except Exception: + pass + pending.setdefault("notional_entry", float(pending.get("notional", open_notional) or open_notional)) + pending["notional"] = remaining_notional + pending["quantity"] = remaining_qty + pending["retraction_legs"] = int(pending.get("retraction_legs", 0) or 0) + 1 + pending["realized_pnl_legs_total"] = float(pending.get("realized_pnl_legs_total", 0.0) or 0.0) + net_pnl_leg + leg_seq = int(pending["retraction_legs"]) + leg_id = f"{tid}:x{leg_seq:03d}" + chain_state = self._chain_state_for_pending( + tid, + { + **pending, + "chain_root_trade_id": expected_chain["chain_root_trade_id"], + "chain_prev_leg_id": expected_chain["chain_head_leg_id"], + "chain_head_leg_id": leg_id, + "chain_mode": "LIVE", + }, + chain_mode="LIVE", + chain_head_leg_id=leg_id, + chain_prev_leg_id=expected_chain["chain_head_leg_id"], + chain_seq=leg_seq, + ) + self._pending_entries[tid] = pending + pending.update(chain_state) + current_bars_held = bars_held + entry_bar = int(pending.get("entry_bar", max(0, self.bar_idx - current_bars_held)) or max(0, self.bar_idx - current_bars_held)) + # Full close when the remainder is economic dust — threshold is + # POSITION_DUST_NOTIONAL_USD, deliberately ALIGNED with the + # _ps_write_open lifecycle gate so no remainder can exist that is + # "open" in memory but rounds to a zero-size row on disk + # (the malformed-OPEN class, MALFORMED_OPEN_RESTORE_BUG.md). + fully_closed = remaining_notional <= POSITION_DUST_NOTIONAL_USD or remaining_qty <= 0.0 + # The leg ledger rows (trade_exit_legs + trade_reconstruction) are + # written for EVERY leg including the terminal one. The previous + # full-close early-return skipped them, losing the final leg from + # the §38.9 replay surface. + ch_put("trade_exit_legs", { + "ts": _ch_ts_us(), + "date": str(pending.get("entry_date", self.current_day or "")), + "strategy": "blue", + "trade_id": tid, + "chain_root_trade_id": str(chain_state.get("chain_root_trade_id", tid) or tid), + "chain_head_leg_id": str(chain_state.get("chain_head_leg_id", leg_id) or leg_id), + "chain_prev_leg_id": str(chain_state.get("chain_prev_leg_id", "") or ""), + "chain_seq": int(chain_state.get("chain_seq", leg_seq) or leg_seq), + "chain_token": str(chain_state.get("chain_token", "") or ""), + "chain_mode": str(chain_state.get("chain_mode", "LIVE") or "LIVE"), + "exit_leg_id": leg_id, + "exit_seq": leg_seq, + "command_id": str(cmd.get("command_id", "")), + "source": str(cmd.get("source", "internal")), + "reason": str(cmd.get("reason", "RETRACT")), + "asset": str(getattr(pos, "asset", pending.get("asset", ""))), + "side": side, + "entry_price": entry_price, + "exit_price": current_price, + "fraction": frac, + "capital_before": capital_before, + "capital_after": capital_after, + "exit_notional": reduce_notional, + "remaining_notional": remaining_notional, + "remaining_qty": remaining_qty, + "pnl_pct_leg": pnl_pct_now, + "pnl_leg": net_pnl_leg, + "pnl_realized_total": float(pending.get("realized_pnl_legs_total", 0.0) or 0.0), + "bars_held": bars_held, + }) + ch_put("trade_reconstruction", { + "ts": _ch_ts_us(), + "trade_id": tid, + "event_type": "FULL_RETRACT_EXIT" if fully_closed else "PARTIAL_EXIT", + "event_id": leg_id, + "payload_json": json.dumps({ + "command": cmd, + "entry_price": entry_price, + "exit_price": current_price, + "exit_notional": reduce_notional, + "remaining_notional": remaining_notional, + "pnl_pct_leg": pnl_pct_now, + "pnl_leg": net_pnl_leg, + "pnl_realized_total": float(pending.get("realized_pnl_legs_total", 0.0) or 0.0), + "bar_idx": int(self.bar_idx), + "chain": chain_state, + }), + "market_state_bundle_json": str(pending.get("market_state_bundle_json", "") or ""), + "tp_base_pct": float(pending.get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(pending.get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(pending.get("our_leverage", 0.0) or 0.0), + }) + if fully_closed: + self.eng.position = None + try: + self.eng.exit_manager._positions.pop(tid, None) + except Exception: + pass + total_realized = float(pending.get("realized_pnl_legs_total", 0.0) or 0.0) + denom = max(float(pending.get("notional_entry", open_notional) or open_notional), 1e-12) + forced = self._build_retract_exit( + trade_id=tid, + reason=str(cmd.get("reason", "RETRACT_FULL")), + bars_held=bars_held, + pnl_pct=total_realized / denom, + net_pnl=total_realized, + ) + return forced, "FULL_CLOSE" + # Partial remainder: persist through the canonical OPEN write gate + # (lifecycle invariant enforced there) instead of a raw ch_put — + # the bypass was the causal origin of zero-size OPEN snapshots. + wrote = self._ps_write_open( + tid, + { + **pending, + "asset": str(getattr(pos, "asset", pending.get("asset", ""))), + "side": side, + "entry_price": entry_price, + "quantity": pending["quantity"], + "leverage": pending.get("leverage", getattr(pos, "leverage", 0.0)), + }, + ts=_ch_ts_us(), + entry_bar=entry_bar, + bars_held=current_bars_held, + pnl=float(pending.get("realized_pnl_legs_total", 0.0) or 0.0), + ) + if not wrote: + # Gate refused (dust slipped past fully_closed somehow) — + # surface loudly; the invariant says this must not happen. + log( + f"RETRACT WARNING: remainder for {tid} refused by OPEN gate " + f"(qty={pending['quantity']} notional={remaining_notional:.6f}) — " + "treat as accounting anomaly" + ) + return None, "PARTIAL_OK" + + def _process_runtime_commands(self, prices_dict: dict) -> dict | None: + """Drain BLUE runtime commands from control plane and apply retractions.""" + if self.control_map is None: + return None + key = "blue_runtime_commands" + try: + raw = self.control_map.blocking().get(key) + if not raw: + return None + queue = json.loads(raw) if isinstance(raw, str) else list(raw) + if not isinstance(queue, list) or not queue: + return None + self.control_map.blocking().put(key, json.dumps([])) + except Exception as e: + log(f"RUNTIME_CMD read failed: {e}") + return None + forced_exit = None + for cmd in queue: + if not isinstance(cmd, dict): + continue + cid = str(cmd.get("command_id", "") or "") + if cid and cid in self._processed_retract_set: + hotkey = str(cmd.get("action", "") or "").upper() or "RUNTIME" + ch_put("hotkey_audit", { + "ts": int(time.time() * 1000), + "hotkey": f"{hotkey}_REPLAY", + "request_json": json.dumps(cmd, default=str), + "result": "IDEMPOTENT_REPLAY", + "effect_json": json.dumps({}, default=str), + }) + continue + action = str(cmd.get("action", "") or "").upper() + if action == "RETRACT": + fx, status = self._apply_internal_retract(cmd, prices_dict) + self._mark_runtime_command_seen(cid) + ch_put("hotkey_audit", { + "ts": int(time.time() * 1000), + "hotkey": "RETRACT", + "request_json": json.dumps(cmd, default=str), + "result": status, + "effect_json": json.dumps({"forced_exit": bool(fx)}, default=str), + }) + if fx is not None: + forced_exit = fx + continue + if action in ("SET_CAPITAL", "CAPITAL_UPDATE"): + effect, status = self._apply_internal_capital_update(cmd) + self._mark_runtime_command_seen(cid) + ch_put("hotkey_audit", { + "ts": int(time.time() * 1000), + "hotkey": "CAPITAL_UPDATE", + "request_json": json.dumps(cmd, default=str), + "result": status, + "effect_json": json.dumps(effect or {}, default=str), + }) + continue + return forced_exit + + def _drain_runtime_commands(self, prices_dict: dict | None = None) -> dict | None: + """Serialize queue draining so the scan and heartbeat paths do not race.""" + lock = getattr(self, "_runtime_command_lock", None) + if lock is None: + lock = threading.Lock() + self._runtime_command_lock = lock + with lock: + return self._process_runtime_commands(dict(prices_dict or self._last_prices_dict or {})) + + def _compute_vol_ok(self, scan): + assets = scan.get('assets', []) + prices = scan.get('asset_prices', []) + if not assets or not prices: + return True + prices_dict = dict(zip(assets, prices)) + btc_price = prices_dict.get('BTCUSDT') + if btc_price is None: + return True + self.btc_prices.append(float(btc_price)) + if len(self.btc_prices) < BTC_VOL_WINDOW: + return True + import numpy as np + arr = np.array(self.btc_prices) + dvol = float(np.std(np.diff(arr) / arr[:-1])) + return dvol > float(self.vol_p60_threshold) + + @staticmethod + def _normalize_ng7(scan: dict) -> dict: + """Promote NG7-format scan to the canonical BLUE-compatible flat dict.""" + return normalize_ng7_scan(scan) + + def on_scan(self, event): + """Reactor-thread entry point — dispatches immediately to worker thread.""" + if self._restore_failed or not event.value: + return + listener_time = time.time() + self._last_scan_event_ts = listener_time + self._scan_executor.submit(self._process_scan, event, listener_time) + + def _process_scan(self, event, listener_time): + try: + if self._restore_failed or not event.value: + return + + scan = json.loads(event.value) if isinstance(event.value, str) else event.value + + # Normalise NG7 format → NG5-compatible flat dict before any field access + if scan.get('version') == 'NG7': + scan = self._normalize_ng7(scan) + + scan_number = int(scan.get('scan_number') or 0) + + # Dedup: scan_number is authoritative (monotonically increasing). + # file_mtime / timestamp are unreliable across NG7 restart probes. + # Exception: the scanner resets numbering to 0 on restart — a large + # backwards jump must re-anchor the ratchet, or BLUE drops every + # scan until manually restarted (near-miss on 2026-06-09/10). + with self._dedup_lock: + if scan_number > 0 and scan_number <= self.last_scan_number: + if scan_number < self.last_scan_number - SCAN_NUMBER_RESET_GAP: + log(f"WARN scanner restart detected: scan_number {self.last_scan_number} → " + f"{scan_number} — re-anchoring dedup ratchet") + else: + self._dupe_drops_total += 1 + return + self.last_scan_number = scan_number + self._last_scan_accept_ts = time.time() + self.scans_processed += 1 + + self._rollover_day() + + assets = scan.get('assets') or [] + if assets and not self.ob_assets: + self._wire_obf(assets) + + prices = scan.get('asset_prices') or [] + if assets and prices and len(assets) != len(prices): + log(f"WARN scan #{scan_number}: assets/prices mismatch " + f"({len(assets)}≠{len(prices)}) — dropped") + return + prices_dict = dict(zip(assets, prices)) if assets and prices else {} + self._last_prices_dict = dict(prices_dict) + # Remove stablecoins — they should never be selected as a trade asset + for sym in _STABLECOIN_SYMBOLS: + prices_dict.pop(sym, None) + + self._record_bounce_prices(prices_dict) + + vol_ok = self._compute_vol_ok(scan) + + vel_div = float(scan.get('vel_div') or 0.0) + if not math.isfinite(vel_div): + log(f"WARN scan #{scan_number}: non-finite vel_div={vel_div} — clamped to 0.0") + vel_div = 0.0 + + v50_vel = float(scan.get('w50_velocity') or 0.0) + v750_vel = float(scan.get('w750_velocity') or 0.0) + if not math.isfinite(v50_vel): v50_vel = 0.0 + if not math.isfinite(v750_vel): v750_vel = 0.0 + self.last_w750_vel = v750_vel + + # Feed live OB data into OBF engine for this bar (AGENT_SPEC_OBF_LIVE_SWITCHOVER) + if self.ob_eng is not None and self.ob_assets: + self.ob_eng.step_live(self.ob_assets, self.bar_idx) + + # Live posture sync — update engine posture + regime_dd_halt together + posture_now = self._read_posture() + with self.eng_lock: + prev_posture = getattr(self.eng, '_day_posture', 'APEX') + if posture_now != prev_posture: + if posture_now in ('TURTLE', 'HIBERNATE'): + self.eng.regime_dd_halt = True # always block new entries + if posture_now == 'HIBERNATE' and self.eng.position is not None: + open_tid = str(getattr(self.eng.position, "trade_id", "") or "") + if not open_tid: + self._mark_restore_failure("HIBERNATE posture with open position missing trade_id") + return + if open_tid not in self._pending_entries: + self._mark_restore_failure( + f"HIBERNATE posture with open position missing pending entry: {open_tid}" + ) + return + if (posture_now == 'HIBERNATE' + and self.eng.position is not None + and not self._hibernate_protect_active): + # Position in flight: arm TP+SL instead of letting + # _manage_position() fire HIBERNATE_HALT next bar. + # _day_posture stays at prev value — no HALT fires. + self._hibernate_protect_position() + else: + self.eng._day_posture = posture_now + log(f"POSTURE_SYNC: {posture_now} — halt set") + else: + self.eng._day_posture = posture_now + self.eng.regime_dd_halt = False + if self._hibernate_protect_active: + log(f"POSTURE_SYNC: {posture_now} — posture recovered, clearing protect mode") + self._hibernate_protect_active = None + else: + log(f"POSTURE_SYNC: {posture_now} — halt lifted") + + # EsoF value gate — exposure only, no alpha or selection changes. + self._sync_esof_size_gate() + self._sync_tp_threshold() + self._sync_sc_threshold_advisor(scan_number=scan_number, vel_div=vel_div) + self._sync_sc_gauge_advisor(scan_number=scan_number, vel_div=vel_div) + self._apply_runtime_direction() + if self._market_state_runtime is not None: + try: + self._market_state_runtime.update_scan_state( + scan_payload=scan, + prices_dict=prices_dict, + scan_number=scan_number, + vel_div=vel_div, + v50_vel=v50_vel, + v750_vel=v750_vel, + vol_ok=vol_ok, + posture=posture_now, + exf_snapshot=getattr(self, "_last_exf", {}) or {}, + esof_payload=self._read_esof_payload(), + top_k_assets=5, + ) + except Exception as e: + log(f" MarketStateRuntime scan update failed: {e}") + + if self.eng.position is not None and prices_dict: + prices_dict = self._inject_obf_midprice(prices_dict) + + step_start = time.time() + with self.eng_lock: + self._apply_catastrophic_floor_to_open_position() + result = self.eng.step_bar( + bar_idx=self.bar_idx, vel_div=vel_div, prices=prices_dict, + vol_regime_ok=vol_ok, v50_vel=v50_vel, v750_vel=v750_vel + ) + self.bar_idx += 1 + scan_to_fill_ms = (time.time() - listener_time) * 1000 + step_bar_ms = (time.time() - step_start) * 1000 + log(f"LATENCY scan #{scan_number}: scan→fill={scan_to_fill_ms:.1f}ms step_bar={step_bar_ms:.1f}ms vel_div={vel_div:.5f}") + + ch_put("eigen_scans", { + "ts": _ch_ts_us(), + "scan_number": scan_number, + "scan_uuid": str(scan.get("scan_uuid") or ""), + "vel_div": vel_div, + "w50_velocity": v50_vel, + "w750_velocity": v750_vel, + "instability_50": float(scan.get("instability_50") or 0.0), + "scan_to_fill_ms": scan_to_fill_ms, + "step_bar_ms": step_bar_ms, + }) + + if result.get('entry'): + self.trades_executed += 1 + e = result['entry'] + log(f"ENTRY: {e} [{ALGO_VERSION}]") + # Cache entry fields for CH trade_events on exit + tid = self._resolve_trade_id(e.get('trade_id'), create_if_missing=True) + e['trade_id'] = tid + if tid: + efsm_decision = None + overlay_flip = False + if self._efsm is not None and int(e.get('direction', -1)) == 1 and int(self.trade_direction) == -1: + efsm_decision = self._efsm.tag_next_entry( + asset=str(e.get('asset', '') or ''), + entry_ts=datetime.now(timezone.utc), + metadata={"trade_id": tid}, + ) + overlay_flip = bool(efsm_decision and efsm_decision.action == "TAG" and efsm_decision.side == "LONG") + self._pending_entries[tid] = { + 'trade_id': tid, + 'asset': e.get('asset', ''), + 'side': 'SHORT' if e.get('direction', -1) == -1 else 'LONG', + 'entry_price': float(e.get('entry_price', 0) or 0), + 'quantity': round(float(e.get('notional', 0) or 0) / float(e.get('entry_price', 1) or 1), 6), + 'notional': float(e.get('notional', 0) or 0), + 'notional_entry': float(e.get('notional', 0) or 0), + 'leverage': float(e.get('leverage', 0) or 0), + 'vel_div_entry': float(e.get('vel_div', 0) or 0), + 'boost_at_entry': float(getattr(getattr(self, 'eng', None), 'acb_boost', 1.0) or 1.0), + 'beta_at_entry': float(getattr(getattr(self, 'eng', None), 'acb_beta', 1.0) or 1.0), + 'posture': posture_now, + 'entry_ts': _ch_ts_us(), + 'entry_date': (self.current_day or ''), + 'entry_bar': self.bar_idx, + 'overlay_flip': overlay_flip, + 'overlay_reason': getattr(efsm_decision, "reason", "") if efsm_decision else "", + 'overlay_slot': int(getattr(efsm_decision, "consumed_slot", 0) or 0) if efsm_decision else 0, + 'retraction_legs': 0, + 'realized_pnl_legs_total': 0.0, + } + _tp_ctx = self._tp_curve_context(notional=float(self._pending_entries[tid]["notional"] or 0.0)) + self._pending_entries[tid].update(_tp_ctx) + self._apply_sc_entry_size_multiplier(tid, e, self._pending_entries[tid]) + self._pending_entries[tid].update(self._chain_state_for_pending( + tid, + self._pending_entries[tid], + chain_mode="LIVE", + chain_head_leg_id=f"{tid}:open", + chain_prev_leg_id="", + chain_seq=0, + )) + if overlay_flip: + log( + f"EFSM TAG: trade_id={tid} asset={e.get('asset','')} " + f"slot={self._pending_entries[tid]['overlay_slot']} " + f"reason={self._pending_entries[tid]['overlay_reason']}" + ) + with self.eng_lock: + self._apply_catastrophic_floor_to_open_position() + # Persist position to CH so restarts can recover it + self._ps_write_open(tid, self._pending_entries[tid]) + ch_put("trade_reconstruction", { + "ts": _ch_ts_us(), + "trade_id": tid, + "event_type": "OPEN", + "event_id": f"{tid}:open", + "payload_json": json.dumps(self._pending_entries[tid], default=str), + "market_state_bundle_json": str(self._pending_entries[tid].get("market_state_bundle_json", "") or ""), + "tp_base_pct": float(self._pending_entries[tid].get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(self._pending_entries[tid].get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(self._pending_entries[tid].get("our_leverage", 0.0) or 0.0), + }) + self._announce_position_event( + kind="trade_entry", + severity="info", + title=f"[BLUE] ENTRY {e.get('asset', '')} {self._pending_entries[tid]['side']}", + message=( + f"entry={float(e.get('entry_price', 0) or 0):.6f} " + f"qty={self._pending_entries[tid]['quantity']:.6f} " + f"lev={self._pending_entries[tid]['leverage']:.2f}x" + ), + metadata={ + "trade_id": tid, + "asset": self._pending_entries[tid]["asset"], + "side": self._pending_entries[tid]["side"], + "entry_price": self._pending_entries[tid]["entry_price"], + "quantity": self._pending_entries[tid]["quantity"], + "leverage": self._pending_entries[tid]["leverage"], + "vel_div_entry": self._pending_entries[tid]["vel_div_entry"], + "boost_at_entry": self._pending_entries[tid]["boost_at_entry"], + "beta_at_entry": self._pending_entries[tid]["beta_at_entry"], + "posture": self._pending_entries[tid]["posture"], + "entry_ts": self._pending_entries[tid]["entry_ts"], + }, + ) + if self._v7_exit_engine is not None: + try: + side = 1 if e.get('direction', -1) == -1 else 0 + ctx = self._v7_exit_engine.make_context( + entry_price=float(e.get('entry_price', 0) or 0), + entry_bar=max(0, self.bar_idx - 1), + side=side, + ) + if self._last_exf: + ctx.set_exf( + funding=float(self._last_exf.get('funding', 0.0) or 0.0), + dvol=float(self._last_exf.get('dvol', 0.0) or 0.0), + fear_greed=float(self._last_exf.get('fear_greed', 0.0) or 0.0), + taker=float(self._last_exf.get('taker', 0.0) or 0.0), + ) + self._v7_contexts[tid] = ctx + self._v7_decisions.pop(tid, None) + self._v7_decision_seq[tid] = 0 + except Exception as e: + log(f" V7 live context init failed for {tid}: {e}") + # Shadow AE: notify of entry (vel_div at entry bar is in scope) + if self._ae is not None: + try: + self._ae.on_entry( + trade_id=tid, + asset=e.get('asset', ''), + direction=int(e.get('direction', -1)), + entry_price=float(e.get('entry_price', 0) or 0), + vel_div_entry=vel_div, + ) + except Exception: + pass + if self._sc_advisor is not None: + try: + payload = self._read_esof_payload() + rec = self._sc_advisor.evaluate( + trade_id=tid, + asset=e.get('asset', ''), + sc=_safe_float(payload.get('advisory_score', payload.get('score', 0.0)) if payload else None), + vel_div=vel_div, + exf_snapshot=getattr(self, "_last_exf", {}) or {}, + trade_history=getattr(self.eng, 'trade_history', []), + current_mult=float(self._last_esof_size_mult or 1.0), + esof_payload=payload, + scan_number=scan_number, + bar_idx=self.bar_idx, + strategy="blue", + log_shadow=True, + ) + self._pending_entries[tid]['sc_threshold_advisor'] = rec + self._pending_entries[tid]['sc_exec_mult'] = float(self._last_esof_size_mult or 1.0) + try: + self._record_sc_haircut( + trade_id=tid, + pending=self._pending_entries[tid], + source="sc_threshold_entry", + ) + except Exception as e: + log(f"SC haircut record failed for {tid}: {e}") + except Exception: + pass + if self._sc_gauge is not None: + try: + payload = self._read_esof_payload() + rec = self._sc_gauge.evaluate( + trade_id=tid, + asset=e.get('asset', ''), + sc=_safe_float(payload.get('advisory_score', payload.get('score', 0.0)) if payload else None), + vel_div=vel_div, + exf_snapshot=getattr(self, "_last_exf", {}) or {}, + obf_snapshot=self._current_obf_snapshot(e.get('asset', ''), self.bar_idx), + trade_history=getattr(self.eng, 'trade_history', []), + current_mult=float(self._last_esof_size_mult or 1.0), + esof_payload=payload, + scan_number=scan_number, + bar_idx=self.bar_idx, + strategy="blue", + log_shadow=True, + ) + self._pending_entries[tid]['sc_bucket_gauge'] = rec + self._pending_entries[tid]['sc_bucket_gauge_exec_mult'] = float(self._last_esof_size_mult or 1.0) + try: + self._record_sc_haircut( + trade_id=tid, + pending=self._pending_entries[tid], + source="sc_bucket_gauge", + ) + except Exception as e: + log(f"SC haircut record failed for {tid}: {e}") + except Exception: + pass + if self._bounce_advisor is not None: + try: + entry_ts_val = float(self._pending_entries[tid].get('entry_ts', 0) or 0) + entry_ts_dt = datetime.fromtimestamp(entry_ts_val / 1_000_000, tz=timezone.utc) if entry_ts_val else None + bounce_rec = self._bounce_eval( + trade_id=tid, + asset=str(e.get('asset', '')), + side=self._pending_entries[tid]['side'], + source="entry", + scan_number=scan_number, + entry_ts=entry_ts_dt, + current_price=float(prices_dict.get(e.get('asset', ''), e.get('entry_price', 0)) or e.get('entry_price', 0) or 0), + entry_price=float(e.get('entry_price', 0) or 0), + quantity=float(self._pending_entries[tid].get('quantity', 0) or 0), + notional=float(e.get('notional', 0) or 0), + leverage=float(e.get('leverage', 0) or 0), + vel_div=vel_div, + current_mult=float(self._last_esof_size_mult or 1.0), + bars_held=0, + log_shadow=True, + ) + if bounce_rec: + self._pending_entries[tid]['bounce_advisor_entry'] = bounce_rec + self._pending_entries[tid]['bounce_advisor_latest'] = bounce_rec + except Exception as e: + log(f" BounceAdvisor entry eval failed for {tid}: {e}") + + # V7 remains the authoritative live exit brain, but the explicit + # retract bridge must stay active even when the engine callback is + # wired. Otherwise RETRACT decisions stay observational only. + if (self._v7_exit_engine is not None + and self.eng is not None + and getattr(self.eng, 'position', None) is not None): + pos = self.eng.position + tid_v7 = getattr(pos, 'trade_id', '') + pending_v7 = self._pending_entries.get(tid_v7, {}) + ctx_v7 = self._v7_contexts.get(tid_v7) + if ctx_v7 is None and pending_v7: + try: + ctx_v7 = self._v7_exit_engine.make_context( + entry_price=float(pending_v7.get('entry_price', pos.entry_price) or pos.entry_price or 0.0), + entry_bar=int(pending_v7.get('entry_bar', max(0, self.bar_idx - 1)) or max(0, self.bar_idx - 1)), + side=1 if pending_v7.get('side', 'SHORT') == 'SHORT' else 0, + ) + if self._last_exf: + ctx_v7.set_exf( + funding=float(self._last_exf.get('funding', 0.0) or 0.0), + dvol=float(self._last_exf.get('dvol', 0.0) or 0.0), + fear_greed=float(self._last_exf.get('fear_greed', 0.0) or 0.0), + taker=float(self._last_exf.get('taker', 0.0) or 0.0), + ) + self._v7_contexts[tid_v7] = ctx_v7 + self._v7_decision_seq.setdefault(tid_v7, 0) + except Exception as e: + log(f" V7 live context restore failed for {tid_v7}: {e}") + ctx_v7 = None + if ctx_v7 is not None and pending_v7: + try: + if self.ob_eng is not None: + ob_sig = self.ob_eng.get_signal(pos.asset, float(max(0, self.bar_idx - 1))) + ob_imb = float(getattr(ob_sig, 'imbalance_ma5', 0.0) or 0.0) + else: + ob_imb = 0.0 + cur_px = float(prices_dict.get(pos.asset, pos.current_price) or pos.current_price or 0.0) + if cur_px > 0.0: + v7dec = self._v7_exit_engine.evaluate( + ctx_v7, + cur_px, + max(0, self.bar_idx - 1), + ob_imb, + asset=pos.asset, + ) + prev_v7dec = self._v7_decisions.get(tid_v7) + prev_v7_action = str( + prev_v7dec.get("action", "") + if isinstance(prev_v7dec, dict) + else getattr(prev_v7dec, "action", "") + ).upper() + self._v7_decisions[tid_v7] = v7dec + self._record_v7_decision( + trade_id=tid_v7, + asset=pos.asset, + side=pending_v7.get('side', 'SHORT'), + decision=v7dec, + current_price=cur_px, + ob_imbalance=ob_imb, + vel_div_now=vel_div, + v50_vel=v50_vel, + v750_vel=v750_vel, + bar_idx=max(0, self.bar_idx - 1), + ) + v7_action = str(v7dec.get("action", "") if isinstance(v7dec, dict) else getattr(v7dec, "action", "")).upper() + if v7_action == "RETRACT" and prev_v7_action != "RETRACT": + try: + cmd = { + "command_id": f"v7-retract-{uuid.uuid4().hex[:16]}", + "trade_id": tid_v7, + "action": "RETRACT", + "fraction": 0.50, + "reason": "V7_RETRACT", + "source": "v7", + "ts": float(time.time()), + "asset": pos.asset, + "chain_root_trade_id": str(pending_v7.get("chain_root_trade_id", tid_v7) or tid_v7), + "chain_head_leg_id": str(pending_v7.get("chain_head_leg_id", f"{tid_v7}:open") or f"{tid_v7}:open"), + "chain_prev_leg_id": str(pending_v7.get("chain_prev_leg_id", "") or ""), + "chain_seq": int(pending_v7.get("chain_seq", pending_v7.get("retraction_legs", 0)) or 0), + "chain_token": str(pending_v7.get("chain_token", "") or ""), + } + raw_q = self.control_map.blocking().get("blue_runtime_commands") if self.control_map else None + q = json.loads(raw_q) if isinstance(raw_q, str) and raw_q else [] + if not isinstance(q, list): + q = [] + q.append(cmd) + q = q[-200:] + if self.control_map is not None: + self.control_map.blocking().put("blue_runtime_commands", json.dumps(q)) + except Exception as e: + log(f" V7 retract enqueue failed for {tid_v7}: {e}") + if self._bounce_advisor is not None: + try: + entry_ts_val = float(pending_v7.get('entry_ts', 0) or 0) + entry_ts_dt = datetime.fromtimestamp(entry_ts_val / 1_000_000, tz=timezone.utc) if entry_ts_val else None + bounce_rec = self._bounce_eval( + trade_id=tid_v7, + asset=pos.asset, + side=pending_v7.get('side', 'SHORT'), + source="open_scan", + scan_number=scan_number, + entry_ts=entry_ts_dt, + current_price=cur_px, + entry_price=float(pending_v7.get('entry_price', pos.entry_price) or pos.entry_price or 0.0), + quantity=float(pending_v7.get('quantity', getattr(pos, 'quantity', 0.0)) or getattr(pos, 'quantity', 0.0) or 0.0), + notional=float(pending_v7.get('notional', getattr(pos, 'notional', 0.0)) or getattr(pos, 'notional', 0.0) or 0.0), + leverage=float(pending_v7.get('leverage', getattr(pos, 'leverage', 0.0)) or getattr(pos, 'leverage', 0.0) or 0.0), + vel_div=vel_div, + current_mult=float(self._last_esof_size_mult or 1.0), + bars_held=max(0, int(self.bar_idx - int(pending_v7.get('entry_bar', max(0, self.bar_idx - 1)) or max(0, self.bar_idx - 1)))), + log_shadow=True, + ) + if bounce_rec: + pending_v7['bounce_advisor_latest'] = bounce_rec + self._pending_entries[tid_v7] = pending_v7 + except Exception as e: + log(f" BounceAdvisor open-scan eval failed for {tid_v7}: {e}") + except Exception as e: + log(f" V7 live evaluate failed for {tid_v7}: {e}") + + _forced_exit = self._drain_runtime_commands(prices_dict) + if _forced_exit is not None and not result.get('exit'): + result['exit'] = _forced_exit + + if result.get('exit'): + x = result['exit'] + tid = x.get('trade_id') + # Hibernate-protected exits: re-label reason, finalize posture + if tid and self._hibernate_protect_active == tid: + _orig = x.get('reason', '') + _map = {'FIXED_TP': 'HIBERNATE_TP', 'STOP_LOSS': 'HIBERNATE_SL', + 'MAX_HOLD': 'HIBERNATE_MAXHOLD'} + x['reason'] = _map.get(_orig, f'HIBERNATE_{_orig}') + self._hibernate_protect_active = None + # Position closed — now safe to commit posture to HIBERNATE + _cur_posture = self._read_posture() + if _cur_posture == 'HIBERNATE': + self.eng._day_posture = 'HIBERNATE' + log(f"HIBERNATE_PROTECT: closed via {x['reason']} — posture finalized HIBERNATE") + else: + log(f"HIBERNATE_PROTECT: closed via {x['reason']} — posture recovered to {_cur_posture}") + x['reason'] = _normalize_v7_exit_reason(x.get('reason', '')) + log(f"EXIT: {x} [{ALGO_VERSION}]") + _exit_reason_raw = str(x.get('reason', '')) + if _exit_reason_raw in ('FIXED_TP', 'HIBERNATE_TP', 'TP_FLOOR', 'HIBERNATE_TP_FLOOR'): + _tp_used = self.eng.exit_manager.fixed_tp_pct + _pos = self.eng.position + _bars = int(x.get('bars_held', 0) or 0) + # Effective (OB-modulated) gate: _execute_exit() rebuilds + # the exit dict and drops evaluate()'s diag keys, so read + # the manager's last_eval (same source the v7 journal uses). + _le = dict(getattr(self.eng.exit_manager, 'last_eval', {}) or {}) + _dyn = float(x.get('dynamic_tp_pct', _le.get('dynamic_tp_pct', 0.0)) or 0.0) + _mod = float(x.get('tp_mod_factor', _le.get('tp_mod_factor', 0.0)) or 0.0) + _casc = int(x.get('cascade_count', _le.get('cascade_count', 0)) or 0) + log(f" TP_EXIT: tp_pct={_tp_used*100:.2f}% dyn_tp={_dyn*100:.2f}% " + f"mod={_mod:.2f}x cascade={_casc} " + f"bars_held={_bars} pnl_pct={float(x.get('pnl_pct',0) or 0):+.4f}") + tid = self._resolve_trade_id(x.get('trade_id'), create_if_missing=True) + x['trade_id'] = tid + pending = self._pending_entries.pop(tid, {}) if tid else {} + if tid: + self._v7_contexts.pop(tid, None) + self._v7_decisions.pop(tid, None) + self._v7_decision_seq.pop(tid, None) + if tid and not pending: + fallback_pending = self._fallback_pending_for_close(tid, x) + self._ps_write_closed(tid, fallback_pending, x) + log( + " EXIT pending metadata missing; wrote fallback CLOSED tombstone " + f"for trade={tid} asset={fallback_pending.get('asset', '')}" + ) + if pending: + # exact bar price the engine exited against — prices_dict is still in scope + exit_price = float(prices_dict.get(pending['asset'], 0) or 0) + if self._sc_advisor is not None: + try: + _rec = pending.get('sc_threshold_advisor') + if _rec: + self._sc_advisor.observe_outcome( + _rec, + executed_mult=float(pending.get('sc_exec_mult', self._last_esof_size_mult) or 1.0), + pnl_pct=float(x.get('pnl_pct', 0) or 0), + exit_reason=str(x.get('reason', 'UNKNOWN')), + ) + except Exception: + pass + if self._sc_gauge is not None: + try: + _rec = pending.get('sc_bucket_gauge') + if _rec: + self._sc_gauge.observe_outcome( + _rec, + executed_mult=float(pending.get('sc_bucket_gauge_exec_mult', self._last_esof_size_mult) or 1.0), + pnl_pct=float(x.get('pnl_pct', 0) or 0), + exit_reason=str(x.get('reason', 'UNKNOWN')), + ) + except Exception: + pass + if self._bounce_advisor is not None: + try: + _bounce_rec = pending.get('bounce_advisor_entry') + if _bounce_rec: + self._bounce_advisor.observe_outcome( + _bounce_rec, + pnl_pct=float(x.get('pnl_pct', 0) or 0), + exit_reason=str(x.get('reason', 'UNKNOWN')), + ) + except Exception as e: + log(f" BounceAdvisor outcome update failed for {tid}: {e}") + if self._market_state_runtime is not None: + try: + self._market_state_runtime.online_update_from_trade( + asset=str(pending.get("asset", "")), + entry_price=float(pending.get("entry_price", 0) or 0), + exit_price=float(exit_price), + direction=-1 if str(pending.get("side", "SHORT")).upper() == "SHORT" else 1, + pnl_pct=float(x.get("pnl_pct", 0) or 0), + bars_held=int(x.get("bars_held", 0) or 0), + exit_reason=str(x.get("reason", "UNKNOWN")), + trade_id=str(tid or ""), + leverage=float(pending.get("leverage", 1.0) or 1.0), + ) + except Exception as e: + log(f" MarketStateRuntime outcome update failed for {tid}: {e}") + if self._efsm is not None: + try: + _efsm_out = self._efsm.observe_closed_trade( + trade_id=str(tid or ""), + asset=str(pending.get("asset", "") or ""), + side=str(pending.get("side", "SHORT") or "SHORT"), + pnl=float(x.get("net_pnl", 0) or 0), + pnl_pct=float(x.get("pnl_pct", 0) or 0), + leverage=float(pending.get("leverage", 0) or 0), + closed_ts=datetime.now(timezone.utc), + was_overlay_flip=bool(pending.get("overlay_flip", False)), + metadata={"exit_reason": str(x.get("reason", "UNKNOWN"))}, + ) + if _efsm_out.action in {"ARMED", "TAG", "RESET"}: + log(f"EFSM { _efsm_out.action }: { _efsm_out.to_dict() }") + except Exception as e: + log(f" EFSM observe_closed_trade failed for {tid}: {e}") + realized_pnl, realized_pnl_source = self._resolved_realized_trade_pnl( + pending, + x, + exit_price=exit_price, + ) + if realized_pnl_source != "net_pnl": + log( + " realized pnl resolved from " + f"{realized_pnl_source}: raw_net={float(x.get('net_pnl', 0) or 0):+.6f} " + f"resolved={realized_pnl:+.6f}" + ) + capital_apply_pnl, capital_apply_source = self._resolved_capital_apply_pnl(x, realized_pnl) + if capital_apply_source != "direct": + log( + " close capital delta suppressed: " + f"source={capital_apply_source} trade={tid} " + f"economic_pnl={realized_pnl:+.6f}" + ) + capital_before, capital_after = self._apply_trade_capital_update( + capital_apply_pnl, + reason=str(x.get("reason", "UNKNOWN")), + source="trade_close", + trade_id=str(tid or ""), + asset=str(pending.get("asset", "")), + mirror_control_plane=True, + ) + execution_quality = self._build_trade_execution_quality_summary( + trade_id=str(tid or ""), + pending=pending, + exit_payload=x, + capital_before=capital_before, + capital_after=capital_after, + realized_pnl=realized_pnl, + exit_price=exit_price, + source="trade_close", + ) + self._persist_trade_execution_quality(execution_quality) + pending.update(self._tp_curve_context(notional=float(pending.get("notional", 0) or 0))) + ch_put("trade_events", { + "ts": _ch_ts_us(), + "date": pending['entry_date'], + "strategy": "blue", + "trade_id": tid, + "asset": pending['asset'], + "side": pending['side'], + "entry_price": pending['entry_price'], + "exit_price": exit_price, + "quantity": pending['quantity'], + "capital_before": capital_before, + "capital_after": capital_after, + "pnl": realized_pnl, + "pnl_pct": float(x.get('pnl_pct', 0) or 0), + "exit_reason": str(x.get('reason', 'UNKNOWN')), + "vel_div_entry": pending['vel_div_entry'], + "boost_at_entry": pending['boost_at_entry'], + "beta_at_entry": pending['beta_at_entry'], + "posture": pending['posture'], + "leverage": pending['leverage'], + "bars_held": int(x.get('bars_held', 0) or 0), + "regime_signal": 0, + "tp_threshold": float(self.eng.exit_manager.fixed_tp_pct), + "execution_quality_json": json.dumps(execution_quality, default=str), + "market_state_bundle_json": str(pending.get("market_state_bundle_json", "") or ""), + "tp_base_pct": float(pending.get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(pending.get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(pending.get("our_leverage", 0.0) or 0.0), + }) + ch_put("trade_reconstruction", { + "ts": _ch_ts_us(), + "trade_id": str(tid or ""), + "event_type": "CLOSE", + "event_id": f"{tid}:close", + "payload_json": json.dumps({ + "exit": x, + "pending": pending, + "exit_price": exit_price, + "retraction_legs": int(pending.get("retraction_legs", 0) or 0), + "retraction_realized_total": float(pending.get("realized_pnl_legs_total", 0.0) or 0.0), + "chain": { + "trade_id": tid, + "chain_root_trade_id": pending.get("chain_root_trade_id", tid), + "chain_head_leg_id": pending.get("chain_head_leg_id", f"{tid}:open"), + "chain_prev_leg_id": pending.get("chain_prev_leg_id", ""), + "chain_seq": int(pending.get("retraction_legs", 0) or 0), + "chain_token": pending.get("chain_token", ""), + "chain_mode": pending.get("chain_mode", "LIVE"), + }, + "execution_quality": execution_quality, + }, default=str), + "market_state_bundle_json": str(pending.get("market_state_bundle_json", "") or ""), + "tp_base_pct": float(pending.get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(pending.get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(pending.get("our_leverage", 0.0) or 0.0), + }) + # Mark position closed in CH (supersedes OPEN row via ReplacingMergeTree) + self._ps_write_closed(tid, pending, x) + self._announce_position_event( + kind="trade_exit", + severity="info" if float(x.get("pnl_pct", 0) or 0) >= 0 else "warning", + title=f"[BLUE] EXIT {pending.get('asset', '')} {pending.get('side', '')}", + message=( + f"reason={x.get('reason', 'UNKNOWN')} " + f"pnl={float(x.get('net_pnl', 0) or 0):+.2f} " + f"pnl_pct={float(x.get('pnl_pct', 0) or 0):+.3%}" + ), + metadata={ + "trade_id": tid, + "asset": pending.get("asset", ""), + "side": pending.get("side", ""), + "entry_price": pending.get("entry_price", 0), + "exit_price": exit_price, + "quantity": pending.get("quantity", 0), + "pnl": realized_pnl, + "pnl_pct": float(x.get("pnl_pct", 0) or 0), + "exit_reason": str(x.get("reason", "UNKNOWN")), + "bars_held": int(x.get("bars_held", 0) or 0), + "posture": pending.get("posture", ""), + "overlay_flip": bool(pending.get("overlay_flip", False)), + "overlay_reason": str(pending.get("overlay_reason", "")), + "overlay_slot": int(pending.get("overlay_slot", 0) or 0), + }, + ) + # Shadow AE: record outcome for online update + if self._ae is not None and tid: + try: + self._ae.on_exit( + trade_id=tid, + actual_exit_reason=str(x.get('reason', 'UNKNOWN')), + pnl_pct=float(x.get('pnl_pct', 0) or 0), + ) + except Exception: + pass + + # Shadow AE: per-bar evaluate for all open trades — daemon thread, zero hot-path impact + if self._ae is not None and self._pending_entries: + _ae_ref = self._ae + _pending_snap = dict(self._pending_entries) # shallow copy under GIL + _prices_snap = dict(prices_dict) + _vel_now = vel_div + _bar = self.bar_idx + def _ae_eval(): + for _tid, _p in _pending_snap.items(): + try: + _cur = _prices_snap.get(_p['asset'], 0) or 0 + if not _cur: + continue + _entry_px = float(_p.get('entry_price', 0) or 0) + _bars_held = max(0, int(_bar - int(_p.get('entry_bar', _bar)))) + _shadow_pnl_pct = ((_entry_px - _cur) / _entry_px) if _entry_px > 0 else 0.0 + _recent_prices = self._bounce_price_path(_p['asset']) + _shadow = _ae_ref.evaluate( + trade_id=_tid, + asset=_p['asset'], + direction=-1, + entry_price=_entry_px, + current_price=_cur, + bars_held=_bars_held, + vel_div_now=_vel_now, + ) + _ae_ref.log_shadow(_shadow, pnl_pct=_shadow_pnl_pct) + if self._advanced_sl is not None: + try: + _ms_state = dict(self._market_state_runtime.latest_state) if self._market_state_runtime and getattr(self._market_state_runtime, "latest_state", None) else {} + _ms_bundle = dict(self._market_state_runtime.latest_bundle_dict) if self._market_state_runtime and getattr(self._market_state_runtime, "latest_bundle_dict", None) else {} + _v7 = dict(self._v7_decisions.get(_tid, {}) or {}) + _maras_ctx = self._latest_maras_context() + _adv_meta = {} + if self._efsm is not None and hasattr(self._efsm, "exit_policy_meta"): + try: + _adv_meta = self._efsm.exit_policy_meta(_maras_ctx) + except Exception: + _adv_meta = {} + _adv = self._advanced_sl.evaluate( + trade_id=_tid, + asset=_p['asset'], + side=str(_p.get("side", "SHORT") or "SHORT"), + entry_price=_entry_px, + current_price=_cur, + bars_held=_bars_held, + recent_prices=_recent_prices, + ae_shadow=_shadow, + v7_decision=_v7, + market_state=_ms_state, + market_bundle=_ms_bundle, + exf_snapshot=dict(self._last_exf or {}), + meta_performance=_adv_meta, + ) + self._advanced_sl.log_shadow(_adv, pnl_pct=_shadow_pnl_pct) + _overlay_exit = False + _overlay_exit_detail = "" + try: + _overlay_exit, _overlay_exit_detail = self._overlay_advsl_should_exit( + _tid, + _p, + _v7, + _bars_held, + _cur, + ) + except Exception: + _overlay_exit = False + _overlay_exit_detail = "" + if ( + (self._advanced_sl_live_exit_enabled and bool(getattr(_adv, "would_exit", False))) + or _overlay_exit + ): + try: + raw_q = self.control_map.blocking().get("blue_runtime_commands") if self.control_map else None + q = json.loads(raw_q) if isinstance(raw_q, str) and raw_q else [] + if not isinstance(q, list): + q = [] + _reason = ( + f"OVERLAY_ADVSL_{_overlay_exit_detail}" + if _overlay_exit + else f"ADVSL_{_adv.reason}" + ) + q.append({ + "command_id": f"advsl-exit-{uuid.uuid4().hex[:16]}", + "trade_id": _tid, + "action": "RETRACT", + "fraction": 1.0, + "reason": _reason, + "source": "advanced_sl", + "ts": float(time.time()), + "asset": _p["asset"], + "chain_root_trade_id": str(_p.get("chain_root_trade_id", _tid) or _tid), + "chain_head_leg_id": str(_p.get("chain_head_leg_id", f"{_tid}:open") or f"{_tid}:open"), + "chain_prev_leg_id": str(_p.get("chain_prev_leg_id", "") or ""), + "chain_seq": int(_p.get("chain_seq", _p.get("retraction_legs", 0)) or 0), + "chain_token": str(_p.get("chain_token", "") or ""), + }) + q = q[-200:] + if self.control_map is not None: + self.control_map.blocking().put("blue_runtime_commands", json.dumps(q)) + log( + " AdvancedSL live exit enqueue: " + f"{_tid} {_p['asset']} reason={_reason} " + f"score={float(getattr(_adv, 'score', 0.0) or 0.0):+.3f} " + f"pnl_pct={_shadow_pnl_pct:+.3f}" + ) + except Exception as e: + log(f" AdvancedSL live exit enqueue failed for {_tid}: {e}") + except Exception: + pass + except Exception: + pass + threading.Thread(target=_ae_eval, daemon=True).start() + + self._push_state(scan_number, vel_div, vol_ok, self._read_posture()) + + except Exception as e: + log(f"ERROR in _process_scan: {e}") + + def on_exf_update(self, event): + if not event.value: return + snapshot = json.loads(event.value) if isinstance(event.value, str) else event.value + if not self.current_day or not self.acb: return + try: + self._last_exf = { + 'funding': float(snapshot.get('funding_btc', 0.0)), + 'dvol': float(snapshot.get('dvol_btc', 50.0)), + 'fear_greed': float(snapshot.get('fng', 50.0)), + 'taker': float(snapshot.get('taker', 0.5)), + } + w750_vel = getattr(self, 'last_w750_vel', None) + acb_info = self.acb.get_dynamic_boost_from_hz( + date_str=self.current_day, + exf_snapshot=snapshot, + w750_velocity=float(w750_vel) if w750_vel else None, + direction=self.trade_direction, + ) + with self.eng_lock: + if hasattr(self.eng, 'update_acb_boost'): + subday_exit = self.eng.update_acb_boost( + boost=acb_info['boost'], + beta=acb_info['beta'] + ) + if subday_exit is not None: + log(f"SUBDAY_EXIT: {subday_exit} [{ALGO_VERSION}]") + tid = self._resolve_trade_id(subday_exit.get('trade_id'), create_if_missing=True) + subday_exit['trade_id'] = tid + pending = {} + if tid: + pending = self._pending_entries.pop(tid, {}) + if pending and self._sc_advisor is not None: + try: + _rec = pending.get('sc_threshold_advisor') + if _rec: + self._sc_advisor.observe_outcome( + _rec, + executed_mult=float(pending.get('sc_exec_mult', self._last_esof_size_mult) or 1.0), + pnl_pct=float(subday_exit.get('pnl_pct', 0) or 0), + exit_reason=str(subday_exit.get('reason', 'SUBDAY_ACB_NORMALIZATION')), + ) + except Exception: + pass + if pending and self._sc_gauge is not None: + try: + _rec_g = pending.get('sc_bucket_gauge') + if _rec_g: + self._sc_gauge.observe_outcome( + _rec_g, + executed_mult=float(pending.get('sc_bucket_gauge_exec_mult', self._last_esof_size_mult) or 1.0), + pnl_pct=float(subday_exit.get('pnl_pct', 0) or 0), + exit_reason=str(subday_exit.get('reason', 'SUBDAY_ACB_NORMALIZATION')), + ) + except Exception: + pass + if pending and self._bounce_advisor is not None: + try: + _bounce_rec = pending.get('bounce_advisor_entry') + if _bounce_rec: + self._bounce_advisor.observe_outcome( + _bounce_rec, + pnl_pct=float(subday_exit.get('pnl_pct', 0) or 0), + exit_reason=str(subday_exit.get('reason', 'SUBDAY_ACB_NORMALIZATION')), + ) + except Exception as e: + log(f" BounceAdvisor outcome update failed for {tid}: {e}") + if self._market_state_runtime is not None: + try: + self._market_state_runtime.online_update_from_trade( + asset=str(pending.get("asset", "")), + entry_price=float(pending.get("entry_price", 0) or 0), + exit_price=float(subday_exit.get("exit_price", 0) or 0), + direction=-1 if str(pending.get("side", "SHORT")).upper() == "SHORT" else 1, + pnl_pct=float(subday_exit.get("pnl_pct", 0) or 0), + bars_held=int(subday_exit.get("bars_held", 0) or 0), + exit_reason=str(subday_exit.get("reason", "SUBDAY_ACB_NORMALIZATION")), + trade_id=str(tid or ""), + leverage=float(pending.get("leverage", 1.0) or 1.0), + ) + except Exception as e: + log(f" MarketStateRuntime outcome update failed for {tid}: {e}") + if self._efsm is not None: + try: + _efsm_sub = self._efsm.observe_closed_trade( + trade_id=str(tid or ""), + asset=str(pending.get("asset", "") or ""), + side=str(pending.get("side", "SHORT") or "SHORT"), + pnl=float(subday_exit.get("net_pnl", 0) or 0), + pnl_pct=float(subday_exit.get("pnl_pct", 0) or 0), + leverage=float(pending.get("leverage", 0) or 0), + closed_ts=datetime.now(timezone.utc), + was_overlay_flip=bool(pending.get("overlay_flip", False)), + metadata={"exit_reason": str(subday_exit.get("reason", "SUBDAY_ACB_NORMALIZATION"))}, + ) + if _efsm_sub.action in {"ARMED", "TAG", "RESET"}: + log(f"EFSM { _efsm_sub.action }: { _efsm_sub.to_dict() }") + except Exception as e: + log(f" EFSM observe_closed_trade failed for {tid}: {e}") + realized_pnl, realized_pnl_source = self._resolved_realized_trade_pnl( + pending, + subday_exit, + exit_price=float(subday_exit.get("exit_price", 0) or 0), + ) + if realized_pnl_source != "net_pnl": + log( + " realized pnl resolved from " + f"{realized_pnl_source}: raw_net={float(subday_exit.get('net_pnl', 0) or 0):+.6f} " + f"resolved={realized_pnl:+.6f}" + ) + capital_apply_pnl, capital_apply_source = self._resolved_capital_apply_pnl(subday_exit, realized_pnl) + if capital_apply_source != "direct": + log( + " close capital delta suppressed: " + f"source={capital_apply_source} trade={tid} " + f"economic_pnl={realized_pnl:+.6f}" + ) + capital_before, capital_after = self._apply_trade_capital_update( + capital_apply_pnl, + reason=str(subday_exit.get("reason", "SUBDAY_ACB_NORMALIZATION")), + source="trade_close", + trade_id=str(tid or ""), + asset=str(pending.get("asset", subday_exit.get("asset", ""))), + mirror_control_plane=True, + ) + execution_quality = self._build_trade_execution_quality_summary( + trade_id=str(tid or ""), + pending=pending, + exit_payload=subday_exit, + capital_before=capital_before, + capital_after=capital_after, + realized_pnl=realized_pnl, + exit_price=float(subday_exit.get("exit_price", 0) or 0), + source="trade_close", + ) + self._persist_trade_execution_quality(execution_quality) + pending.update(self._tp_curve_context(notional=float(pending.get("notional", 0) or 0))) + ch_put("trade_events", { + "ts": _ch_ts_us(), + "date": self.current_day or '', + "strategy": "blue", + "trade_id": tid, + "asset": pending.get('asset', subday_exit.get('asset', '')), + "side": pending.get('side', 'SHORT'), + "entry_price": pending.get('entry_price', 0), + "exit_price": float(subday_exit.get('exit_price', 0) or 0), + "quantity": round(float(pending.get('notional', 0) or 0) / max(float(pending.get('entry_price', 1) or 1), 1e-12), 6), + "capital_before": capital_before, + "capital_after": capital_after, + "pnl": realized_pnl, + "pnl_pct": float(subday_exit.get('pnl_pct', 0) or 0), + "exit_reason": str(subday_exit.get('reason', 'SUBDAY_ACB_NORMALIZATION')), + "vel_div_entry": float(pending.get('vel_div_entry', 0) or 0), + "boost_at_entry": float(pending.get('boost_at_entry', 0) or 0), + "beta_at_entry": float(pending.get('beta_at_entry', 0) or 0), + "posture": pending.get('posture', ''), + "leverage": float(pending.get('leverage', 0) or 0), + "bars_held": int(subday_exit.get('bars_held', 0) or 0), + "regime_signal": 0, + "execution_quality_json": json.dumps(execution_quality, default=str), + "market_state_bundle_json": str(pending.get("market_state_bundle_json", "") or ""), + "tp_base_pct": float(pending.get("tp_base_pct", 0.0) or 0.0), + "tp_effective_pct": float(pending.get("tp_effective_pct", 0.0) or 0.0), + "our_leverage": float(pending.get("our_leverage", 0.0) or 0.0), + }) + self._announce_position_event( + kind="trade_exit", + severity="info" if float(subday_exit.get("pnl_pct", 0) or 0) >= 0 else "warning", + title=f"[BLUE] EXIT {pending.get('asset', '')} {pending.get('side', '')}", + message=( + f"reason={subday_exit.get('reason', 'SUBDAY_ACB_NORMALIZATION')} " + f"pnl={float(subday_exit.get('net_pnl', 0) or 0):+.2f} " + f"pnl_pct={float(subday_exit.get('pnl_pct', 0) or 0):+.3%}" + ), + metadata={ + "trade_id": tid, + "asset": pending.get("asset", subday_exit.get("asset", "")), + "side": pending.get("side", "SHORT"), + "entry_price": pending.get("entry_price", 0), + "exit_price": float(subday_exit.get("exit_price", 0) or 0), + "quantity": round(float(pending.get("notional", 0) or 0) / max(float(pending.get("entry_price", 1) or 1), 1e-12), 6), + "pnl": realized_pnl, + "pnl_pct": float(subday_exit.get("pnl_pct", 0) or 0), + "exit_reason": str(subday_exit.get("reason", "SUBDAY_ACB_NORMALIZATION")), + "bars_held": int(subday_exit.get("bars_held", 0) or 0), + "posture": pending.get("posture", ""), + "overlay_flip": bool(pending.get("overlay_flip", False)), + "overlay_reason": str(pending.get("overlay_reason", "")), + "overlay_slot": int(pending.get("overlay_slot", 0) or 0), + }, + ) + close_pending = pending if pending else self._fallback_pending_for_close(str(tid or ""), subday_exit) + self._ps_write_closed(str(tid or ""), close_pending, subday_exit) + if tid and not pending: + log( + " SUBDAY_EXIT pending metadata missing; wrote fallback CLOSED tombstone " + f"for trade={tid} asset={close_pending.get('asset', '')}" + ) + now = time.time() + if now - self._exf_log_time >= 300: + self._exf_log_time = now + log(f"ACB subday: boost={acb_info['boost']:.4f} beta={acb_info['beta']:.4f} " + f"signals={acb_info['signals']:.1f} src={acb_info.get('source','?')}") + # ACB_EXIT disabled: update_acb_boost() called to keep boost/beta current + # (ACBv6 intact), but SUBDAY_ACB_NORMALIZATION exits are suppressed. + except ValueError as e: + log(f"ACB Stale Data Fallback: {e}") + except Exception as e: + log(f"on_exf_update Error: {e}") + + def _wire_obf(self, assets): + if not assets or self.ob_assets: + return + self.ob_assets = assets + from nautilus_dolphin.nautilus.hz_ob_provider import HZOBProvider + live_ob = HZOBProvider( + hz_cluster=HZ_CLUSTER, + hz_host=HZ_HOST, + assets=assets, + ) + self.ob_eng = OBFeatureEngine(live_ob) + # No preload_date() call — live mode uses step_live() per scan + self.eng.set_ob_engine(self.ob_eng) + log(f" OBF wired: HZOBProvider, {len(assets)} assets (LIVE mode)") + + def _save_capital(self): + """Persist capital to HZ (primary) and disk (fallback) so restarts survive HZ loss.""" + capital = getattr(self.eng, 'capital', None) + if capital is None or not math.isfinite(capital) or capital < 1.0: + return + self._commit_capital_state( + float(capital), + reason="ENGINE_SAVE", + source="engine_snapshot", + mirror_control_plane=False, + ) + + def _publish_corrective_replay(self, replay_blob: Mapping[str, Any]) -> None: + """Publish a corrective replay seed back into HZ and disk.""" + try: + capital = _safe_float(replay_blob.get("capital", 0.0), 0.0) + if capital < 1.0 or not math.isfinite(capital): + return + self._commit_capital_state( + capital, + reason=str(replay_blob.get("reason", "") or "CORRECTIVE_REPLAY"), + source="corrective_replay", + trade_id=str(replay_blob.get("trade_id", "") or ""), + asset=str(replay_blob.get("asset", "") or ""), + replay_blob=replay_blob, + update_replay_key=True, + mirror_control_plane=True, + ) + except Exception as e: + log(f" corrective replay publish failed: {e}") + + def request_capital_update( + self, + capital: float, + *, + reason: str = "CAPITAL_UPDATE", + source: str = "control_plane", + trade_id: str = "", + asset: str = "", + event_ts: float | None = None, + applies_before_ts: float | None = None, + replay_blob: Mapping[str, Any] | None = None, + ) -> dict: + """Queue a capital update onto the BLUE runtime command channel.""" + cmd = { + "command_id": f"cap-update-{uuid.uuid4().hex[:16]}", + "action": "SET_CAPITAL", + "capital": float(capital), + "reason": str(reason or "CAPITAL_UPDATE"), + "source": str(source or "control_plane"), + "ts": float(time.time()), + } + if event_ts is not None: + cmd["event_ts"] = float(event_ts) + if applies_before_ts is not None: + cmd["applies_before_ts"] = float(applies_before_ts) + if trade_id: + cmd["trade_id"] = str(trade_id) + if asset: + cmd["asset"] = str(asset) + if replay_blob is not None: + cmd["replay_blob"] = dict(replay_blob) + if self._enqueue_blue_runtime_command(cmd): + return cmd + raise RuntimeError("BLUE runtime command queue unavailable") + + def _restore_capital(self): + """Restore capital from live HZ state or ledger-backed snapshots. + + The raw scalar checkpoint is legacy-only and requires the explicit + DOLPHIN_ALLOW_LEGACY_CAPITAL_CHECKPOINT=1 escape hatch. + """ + self._restore_failed = False + self._restore_failure_reason = "" + self._restore_source = "" + if self._restore_capital_from_state(): + return + log(" Capital: no sane state source found — restore halted") + + def _push_state(self, scan_number, vel_div, vol_ok, posture): + try: + with self.eng_lock: + capital = getattr(self.eng, 'capital', 25000.0) + # Engine uses a single NDPosition object, not a list + pos = getattr(self.eng, 'position', None) + if pos is not None: + pending = self._pending_entries.get(getattr(pos, "trade_id", ""), {}) + open_notional = float(getattr(pos, 'notional', 0) or 0) + open_positions_list = [{ + 'trade_id': getattr(pos, 'trade_id', ''), + 'asset': pos.asset, + 'side': 'SHORT' if pos.direction == -1 else 'LONG', + 'entry_price': pos.entry_price, + 'quantity': round(open_notional / pos.entry_price, 6) if pos.entry_price else 0, + 'notional': open_notional, + 'retraction_legs': int(pending.get('retraction_legs', 0) or 0), + 'realized_pnl_legs_total': float(pending.get('realized_pnl_legs_total', 0.0) or 0.0), + 'chain_root_trade_id': str(pending.get('chain_root_trade_id', getattr(pos, 'trade_id', '')) or getattr(pos, 'trade_id', '')), + 'chain_head_leg_id': str(pending.get('chain_head_leg_id', f"{getattr(pos, 'trade_id', '')}:open") or f"{getattr(pos, 'trade_id', '')}:open"), + 'chain_prev_leg_id': str(pending.get('chain_prev_leg_id', '') or ''), + 'chain_seq': int(pending.get('chain_seq', pending.get('retraction_legs', 0)) or 0), + 'chain_token': str(pending.get('chain_token', '') or ''), + 'leverage': float(getattr(pos, 'leverage', 0) or 0), + 'unrealized_pnl': round(pos.pnl_pct * open_notional, 2), + }] + else: + open_notional = 0.0 + open_positions_list = [] + cur_leverage = (open_notional / capital) if capital and capital > 0 and math.isfinite(capital) else 0.0 + + snapshot = { + 'capital': capital if math.isfinite(capital) else None, + 'open_positions': open_positions_list, + 'algo_version': ALGO_VERSION, + 'last_scan_number': scan_number, 'last_vel_div': vel_div, + 'vol_ok': vol_ok, 'posture': posture, + 'vol_gate_threshold': float(self.vol_p60_threshold), + 'scans_processed': self.scans_processed, + 'trades_executed': self.trades_executed, + 'bar_idx': self.bar_idx, + 'timestamp': datetime.now(timezone.utc).isoformat(), + # Leverage envelope — for TUI slider + 'leverage_soft_cap': getattr(self.eng, 'base_max_leverage', 8.0), + 'leverage_abs_cap': getattr(self.eng, 'abs_max_leverage', 9.0), + 'open_notional': round(open_notional, 2), + 'current_leverage': round(cur_leverage, 4), + 'trade_direction_base': int(self.trade_direction), + 'trade_direction_runtime': int(self._runtime_direction), + # Launch metadata for observability only; no trading behavior. + 'bingx_environment': str(os.environ.get("DOLPHIN_BINGX_ENV", "ENGINE") or "ENGINE").strip().upper(), + 'bingx_sizing_mode': str(os.environ.get("DOLPHIN_BINGX_SIZING_MODE", "engine") or "engine").strip().lower(), + 'bingx_allow_mainnet': bool(_env_bool("DOLPHIN_BINGX_ALLOW_MAINNET", False)), + 'bingx_default_leverage': _safe_float(os.environ.get("DOLPHIN_BINGX_DEFAULT_LEVERAGE"), 1.0), + 'bingx_exchange_leverage_cap': int( + _safe_float( + os.environ.get( + "DOLPHIN_BINGX_EXCHANGE_LEVERAGE_CAP", + getattr(self.eng, 'abs_max_leverage', 3.0), + ), + 3.0, + ) + ), + 'efsm': self._efsm.snapshot() if self._efsm is not None else None, + 'advanced_sl': self._advanced_sl.snapshot_dict() if self._advanced_sl is not None else None, + } + self._last_engine_snapshot_payload = dict(snapshot) + future = self.state_map.put('engine_snapshot', json.dumps(snapshot)) + future.add_done_callback(lambda f: None) + # Heartbeat — MHS checks age < 30s; force blocking put to avoid + # silent async drop/stall under client backpressure. + if self.heartbeat_map is not None: + hb = build_runner_heartbeat_payload( + flow="nautilus_event_trader", + phase="trading", + run_date=self.current_day, + runner="blue", + ) + try: + write_runner_heartbeat(self.heartbeat_map, hb) + except Exception as hb_err: + log(f" Heartbeat put failed: {hb_err}") + # Persist capital so next restart resumes from here + if capital is not None and math.isfinite(capital) and capital >= 1.0: + self._save_capital() + except Exception as e: + log(f" Failed to push state: {e}") + + def run(self): + global running + log("=" * 70) + log("🐬 DOLPHIN Nautilus Event-Driven Trader Starting") + log("=" * 70) + + self._build_engine() + self._connect_hz() + threading.Thread(target=self._heartbeat_loop, daemon=True).start() + threading.Thread(target=self._scan_watchdog_loop, daemon=True, + name="scan_watchdog").start() + self._restore_capital() + if self._restore_failed: + log(f"RESTORE HALT: {self._restore_failure_reason}") + self.shutdown() + return + self._rollover_day() + self._restore_position_state() + if self._restore_failed: + log(f"RESTORE HALT: {self._restore_failure_reason}") + self.shutdown() + return + # Seed the live snapshot immediately so engine_snapshot and + # capital_checkpoint reflect the restored capital before scan traffic. + try: + posture = self._read_posture() + self._push_state(self.bar_idx, 0.0, True, posture) + except Exception as e: + log(f" Startup seed push failed: {e}") + + def listener(event): + self.on_scan(event) + + self.features_map.add_entry_listener( + key='latest_eigen_scan', include_value=True, + updated_func=listener, added_func=listener + ) + + def exf_listener(event): + self.on_exf_update(event) + + self.features_map.add_entry_listener( + key='exf_latest', include_value=True, + updated_func=exf_listener, added_func=exf_listener + ) + + log("✅ Hz listener registered") + log(f"🏷️ ALGO_VERSION: {ALGO_VERSION}") + log("⏳ Waiting for scans...") + global running + if not running: + log(" Startup SIGTERM latch cleared before main scan loop") + running = True + + try: + while running: + time.sleep(1) + except KeyboardInterrupt: + log("Interrupted") + finally: + self.shutdown() + + def shutdown(self): + log("Shutting down...") + self._watchdog_stop.set() + self._scan_executor.shutdown(wait=False) + if self.eng and self.current_day: + try: + with self.eng_lock: + summary = self.eng.end_day() + log(f"end_day: {summary}") + except Exception as e: + log(f"end_day failed: {e}") + if self._market_state_runtime is not None: + try: + self._market_state_runtime.save() + except Exception: + pass + if self.hz_client: + try: + self.hz_client.shutdown() + log("Hz disconnected") + except: + pass + log(f"🛑 Stopped. Scans: {self.scans_processed}, Trades: {self.trades_executed}") + +def signal_handler(signum, frame): + global running + age_s = time.time() - _PROCESS_BOOT_TS + if signum == signal.SIGTERM and age_s < _SIGTERM_STARTUP_GRACE_S: + log(f"Signal {signum} received during startup grace ({age_s:.1f}s) — ignored") + return + log(f"Signal {signum} received") + running = False + +def main(): + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + trader = DolphinLiveTrader() + trader.run() + +if __name__ == '__main__': + main() diff --git a/prod/tests/test_malformed_open_distal.py b/prod/tests/test_malformed_open_distal.py new file mode 100644 index 0000000..b91a098 --- /dev/null +++ b/prod/tests/test_malformed_open_distal.py @@ -0,0 +1,436 @@ +"""Distal fix for MALFORMED_OPEN_RESTORE_BUG — regression suite (Option A). + +Pins the causal fix set applied 2026-06-12: + 1. `_ps_write_open` is the SINGLE gate for OPEN position_state rows and + enforces the lifecycle invariant (qty > 0 AND notional > dust). + 2. The partial-retract path persists remainders THROUGH that gate + (previously a raw ch_put bypass — the causal origin of zero-size + OPEN snapshots). + 3. The dust threshold (POSITION_DUST_NOTIONAL_USD) is shared between the + full-close decision and the write gate, so no remainder can be "open" + in memory yet round to a zero-size row on disk. + 4. The terminal retract leg writes its trade_exit_legs + + trade_reconstruction rows (the old early-return lost the final leg + from the §38.9 replay surface). + 5. Restore-candidate rejection with exhausted fallbacks marks + `_restore_failed` (halt) instead of silently trading from flat + (XTZ 863c21da single-slot-violation class). + 6. Chain-token mismatch emits a queryable CHAIN_TOKEN_MISMATCH journal + event, not just a log line. + 7. Restored entry_bar preserves bars_held continuity (negative entry_bar + allowed; the old max(0, ...) clamp reset the MAX_HOLD clock). +""" + +from __future__ import annotations + +import json +import threading +import tempfile +from dataclasses import dataclass +import importlib.util +from pathlib import Path + +import pytest + +_MOD_PATH = Path("/mnt/dolphinng5_predict/prod/nautilus_event_trader.py") +_SPEC = importlib.util.spec_from_file_location("nautilus_event_trader_distal_mod", _MOD_PATH) +assert _SPEC and _SPEC.loader +mod = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(mod) # type: ignore[arg-type] + + +# ── harness (mirrors test_multi_exit_retraction_integration.py) ───────────── + +@dataclass +class _Pos: + trade_id: str + asset: str + entry_price: float + notional: float + current_price: float = 0.0 + pnl_pct: float = 0.0 + + +class _ExitMgr: + def __init__(self) -> None: + self._positions: dict[str, dict] = {} + + +class _Eng: + def __init__(self, pos: _Pos | None, capital: float = 25_000.0) -> None: + self.position = pos + self.capital = capital + self.exit_manager = _ExitMgr() + self.regime_dd_halt = False + self._day_posture = "APEX" + if pos is not None: + self.exit_manager._positions[pos.trade_id] = {"dummy": True} + + +class _Map: + def __init__(self, initial: dict | None = None) -> None: + self._d = dict(initial or {}) + self._lock = threading.Lock() + + def blocking(self): + return self + + def get(self, key): + with self._lock: + return self._d.get(key) + + def put(self, key, val): + with self._lock: + self._d[key] = val + + class _F: + def add_done_callback(self, _cb): + return None + return _F() + + +def _mk_trader() -> "mod.DolphinLiveTrader": + t = object.__new__(mod.DolphinLiveTrader) + tmpdir = Path(tempfile.mkdtemp(prefix="dolphin_distal_test_")) + mod.CAPITAL_DISK_CHECKPOINT = tmpdir / "capital_checkpoint.json" + mod.CAPITAL_CORRECTIVE_REPLAY = tmpdir / "capital_replay.json" + mod.CAPITAL_UPDATE_LEDGER = tmpdir / "capital_update_ledger.json" + t.eng_lock = threading.Lock() + t.state_map = _Map({}) + t.pnl_map = _Map({}) + t.control_map = _Map({"blue_runtime_commands": "[]"}) + t._processed_retract_commands = mod.deque(maxlen=5000) + t._processed_retract_set = set() + t._runtime_command_lock = threading.Lock() + t._pending_entries = {} + t._last_prices_dict = {} + t.current_day = "2026-06-12" + t.bar_idx = 100 + t._restore_failed = False + t._restore_failure_reason = "" + return t + + +def _seed_pending(t, trade_id: str, *, notional: float = 1000.0, + entry_price: float = 1.0) -> None: + t._pending_entries[trade_id] = { + "asset": "STXUSDT", + "side": "SHORT", + "entry_price": entry_price, + "entry_bar": 90, + "entry_date": "2026-06-12", + "notional": notional, + "notional_entry": notional, + "retraction_legs": 0, + "realized_pnl_legs_total": 0.0, + "leverage": 2.0, + } + pending = t._pending_entries[trade_id] + pending.update(t._chain_state_for_pending( + trade_id, pending, + chain_mode="LIVE", + chain_head_leg_id=f"{trade_id}:open", + chain_prev_leg_id="", + chain_seq=0, + )) + + +def _retract_cmd(t, trade_id: str, *, command_id: str, fraction: float) -> dict: + pending = t._pending_entries[trade_id] + return { + "command_id": command_id, + "trade_id": trade_id, + "action": "RETRACT", + "fraction": fraction, + "reason": "HOTKEY_RETRACT", + "source": "tui_hotkey", + "chain_root_trade_id": pending["chain_root_trade_id"], + "chain_head_leg_id": pending["chain_head_leg_id"], + "chain_prev_leg_id": pending["chain_prev_leg_id"], + "chain_seq": pending["chain_seq"], + "chain_token": pending["chain_token"], + } + + +def _run_retract(t, rows, *, fraction: float, notional: float = 1000.0, + price: float = 1.0): + pos = _Pos("T-1", "STXUSDT", 1.0, notional, current_price=price) + t.eng = _Eng(pos) + _seed_pending(t, "T-1", notional=notional) + cmd = _retract_cmd(t, "T-1", command_id=f"c-{fraction}", fraction=fraction) + t.control_map.put("blue_runtime_commands", json.dumps([cmd])) + return t._process_runtime_commands({"STXUSDT": price}) + + +# ── 1+2: lifecycle invariant at the single write gate ─────────────────────── + +def test_ps_write_open_refuses_zero_quantity(monkeypatch): + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + t = _mk_trader() + ok = t._ps_write_open("T-z", { + "asset": "STXUSDT", "side": "SHORT", "entry_price": 1.0, + "quantity": 0.0, "leverage": 2.0, "entry_ts": 1, + }) + assert ok is False + assert rows == [] + + +def test_ps_write_open_refuses_dust_notional(monkeypatch): + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + t = _mk_trader() + # qty>0 but notional rounds to <= $0.01 (the malformed-row recipe) + ok = t._ps_write_open("T-d", { + "asset": "STXUSDT", "side": "SHORT", "entry_price": 0.0001, + "quantity": 40.0, "leverage": 2.0, "entry_ts": 1, # notional 0.004 + }) + assert ok is False + assert rows == [] + + +def test_ps_write_open_accepts_valid_entry(monkeypatch): + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + t = _mk_trader() + ok = t._ps_write_open("T-v", { + "asset": "STXUSDT", "side": "SHORT", "entry_price": 1.0, + "quantity": 1000.0, "leverage": 2.0, "entry_ts": 777, + }) + assert ok is True + assert len(rows) == 1 + tbl, row = rows[0] + assert tbl == "position_state" + assert row["status"] == "OPEN" + assert row["quantity"] > 0 and row["notional"] > 0 + assert row["ts"] == 777 and row["bars_held"] == 0 + + +# ── 2+3: retract remainder goes through the gate; thresholds aligned ──────── + +def test_partial_retract_open_row_never_zero_sized(monkeypatch): + """Sweep fractions: every surviving OPEN row must satisfy the invariant.""" + for fraction in (0.1, 0.5, 0.9, 0.99, 0.999): + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row, _r=rows: _r.append((tbl, row))) + monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123) + t = _mk_trader() + forced = _run_retract(t, rows, fraction=fraction) + open_rows = [r for tbl, r in rows + if tbl == "position_state" and r.get("status") == "OPEN"] + if forced is None: + assert len(open_rows) == 1, f"fraction={fraction}" + assert open_rows[0]["quantity"] > 0.0 + assert open_rows[0]["notional"] > mod.POSITION_DUST_NOTIONAL_USD + else: + assert open_rows == [], f"fraction={fraction} full-close wrote OPEN row" + + +def test_dust_remainder_is_full_close(monkeypatch): + """$1000 × 0.99999 retract leaves $0.01 — at/below dust ⇒ FULL_CLOSE, + no OPEN snapshot, forced exit carries capital_already_realized.""" + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123) + t = _mk_trader() + forced = _run_retract(t, rows, fraction=0.99999) + assert forced is not None + assert forced.get("capital_already_realized") is True + assert t.eng.position is None + open_rows = [r for tbl, r in rows + if tbl == "position_state" and r.get("status") == "OPEN"] + assert open_rows == [] + + +def test_full_retract_writes_terminal_leg_rows(monkeypatch): + """fraction=1.0: terminal leg MUST appear in trade_exit_legs and + trade_reconstruction (FULL_RETRACT_EXIT) — the §38.9 replay surface.""" + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123) + t = _mk_trader() + forced = _run_retract(t, rows, fraction=1.0, price=0.95) + assert forced is not None and forced["reason"] == "HOTKEY_RETRACT" + legs = [r for tbl, r in rows if tbl == "trade_exit_legs"] + assert len(legs) == 1 + assert legs[0]["exit_seq"] == 1 + assert legs[0]["remaining_notional"] <= mod.POSITION_DUST_NOTIONAL_USD + recon = [r for tbl, r in rows if tbl == "trade_reconstruction"] + assert any(r["event_type"] == "FULL_RETRACT_EXIT" for r in recon) + open_rows = [r for tbl, r in rows + if tbl == "position_state" and r.get("status") == "OPEN"] + assert open_rows == [] + # capital realized exactly once for the leg: 5% on $1000 short = +$50 + assert pytest.approx(t.eng.capital, abs=1e-6) == 25_050.0 + assert pytest.approx(forced["net_pnl"], abs=1e-6) == 50.0 + + +def test_partial_then_full_chain_keeps_all_legs(monkeypatch): + """Two-leg chain: every leg lands in trade_exit_legs; totals coherent.""" + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123) + t = _mk_trader() + pos = _Pos("T-1", "STXUSDT", 1.0, 1000.0, current_price=0.95) + t.eng = _Eng(pos) + _seed_pending(t, "T-1", notional=1000.0) + + cmd1 = _retract_cmd(t, "T-1", command_id="c-a", fraction=0.5) + t.control_map.put("blue_runtime_commands", json.dumps([cmd1])) + assert t._process_runtime_commands({"STXUSDT": 0.95}) is None + + cmd2 = _retract_cmd(t, "T-1", command_id="c-b", fraction=1.0) + t.control_map.put("blue_runtime_commands", json.dumps([cmd2])) + forced = t._process_runtime_commands({"STXUSDT": 0.95}) + assert forced is not None + + legs = [r for tbl, r in rows if tbl == "trade_exit_legs"] + assert [l["exit_seq"] for l in legs] == [1, 2] + # both legs at +5%: 0.05*500 + 0.05*500 = 50 total + assert pytest.approx(forced["net_pnl"], abs=1e-6) == 50.0 + assert pytest.approx(legs[0]["pnl_leg"] + legs[1]["pnl_leg"], abs=1e-6) == 50.0 + + +# ── 5: reject-exhaustion halts instead of silently trading flat ───────────── + +def test_restore_reject_exhaustion_marks_failure(monkeypatch): + """CH returns a candidate with invalid leverage; HZ has neither a + position nor flat-proof ⇒ _restore_failed must be set (halt).""" + monkeypatch.setattr(mod, "ch_put", lambda *a, **k: None) + + class _Resp: + def __init__(self, body: bytes): + self._b = body + + def read(self): + return self._b + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + # leverage = 0 → invalid candidate; fresh ts to pass staleness + # columns: trade_id asset direction entry_price quantity notional + # leverage bucket_id bars_held last_ts (10 fields) + import datetime as _dt + ts = _dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + row = f"T-bad\tXTZUSDT\t-1\t0.2276\t1000\t227.6\t0\t0\t14\t{ts}" + import urllib.request as _ur + monkeypatch.setattr(_ur, "urlopen", lambda *a, **k: _Resp(row.encode())) + + t = _mk_trader() + t.eng = _Eng(None) + t._restore_state_snapshots = {} + t._parse_timestamp_seconds = mod.DolphinLiveTrader._parse_timestamp_seconds.__get__(t) + t._restore_position_state() + assert t._restore_failed is True + assert "leverage" in t._restore_failure_reason + + +# ── 6: chain mismatch is a queryable journal event ─────────────────────────── + +def test_chain_token_mismatch_emits_journal_event(monkeypatch): + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123) + t = _mk_trader() + pending = { + "asset": "XTZUSDT", "side": "SHORT", "entry_price": 0.2276, + "quantity": 1000.0, "notional": 227.6, "notional_entry": 227.6, + "leverage": 2.0, "entry_bar": 0, + "retraction_legs": 0, "realized_pnl_legs_total": 0.0, + } + recon = {"chain_token": "deadbeef" * 8, "chain_seq": 0, + "chain_head_leg_id": "T-x:open", "chain_mode": "LIVE"} + chain = t._chain_state_from_reconstruction("T-x", pending, recon) + assert chain["chain_mode"] == "LEGACY_REBUILT_MISMATCH" + mismatch_rows = [r for tbl, r in rows + if tbl == "trade_reconstruction" + and r.get("event_type") == "CHAIN_TOKEN_MISMATCH"] + assert len(mismatch_rows) == 1 + payload = json.loads(mismatch_rows[0]["payload_json"]) + assert payload["stored_token"].startswith("deadbeef") + assert payload["derived_token"] != payload["stored_token"] + + +def test_chain_token_match_emits_nothing(monkeypatch): + rows = [] + monkeypatch.setattr(mod, "ch_put", lambda tbl, row: rows.append((tbl, row))) + t = _mk_trader() + pending = { + "asset": "XTZUSDT", "side": "SHORT", "entry_price": 0.2276, + "quantity": 1000.0, "notional": 227.6, "notional_entry": 227.6, + "leverage": 2.0, "entry_bar": 0, + "retraction_legs": 0, "realized_pnl_legs_total": 0.0, + } + # derive the true token with the SAME chain parameters the recon will + # carry (mode LIVE), then feed it back as the stored token + expected = t._chain_state_for_pending( + "T-y", pending, chain_mode="LIVE", + chain_head_leg_id="T-y:open", chain_prev_leg_id="", chain_seq=0, + ) + recon = {"chain_token": expected["chain_token"], "chain_seq": 0, + "chain_head_leg_id": "T-y:open", "chain_mode": "LIVE"} + chain = t._chain_state_from_reconstruction("T-y", pending, recon) + assert chain["chain_mode"] != "LEGACY_REBUILT_MISMATCH" + assert [r for tbl, r in rows + if r.get("event_type") == "CHAIN_TOKEN_MISMATCH"] == [] + + +# ── 7: bars_held continuity across restore ────────────────────────────────── + +def test_restored_entry_bar_preserves_bars_held(monkeypatch): + """boot bar_idx=0, stored_bars=34 ⇒ entry_bar=-34 ⇒ bars_held resumes + at 34 immediately (the XTZ bars_held≈0 / MAX_HOLD-reset fix).""" + captured = {} + monkeypatch.setattr(mod, "ch_put", lambda *a, **k: None) + + class _Resp: + def __init__(self, body: bytes): + self._b = body + + def read(self): + return self._b + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + import datetime as _dt + ts = _dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + row = f"T-c\tXTZUSDT\t-1\t0.2276\t248173\t56484.4\t6.37\t0\t34\t{ts}" + import urllib.request as _ur + monkeypatch.setattr(_ur, "urlopen", lambda *a, **k: _Resp(row.encode())) + + class _RecordingExitMgr(_ExitMgr): + def setup_position(self, trade_id, entry_price, direction, entry_bar, + **kw): + captured["entry_bar"] = entry_bar + self._positions[trade_id] = {"entry_bar": entry_bar} + + t = _mk_trader() + t.bar_idx = 0 # fresh boot + t.eng = _Eng(None) + t.eng.exit_manager = _RecordingExitMgr() + t._restore_state_snapshots = {} + t._parse_timestamp_seconds = mod.DolphinLiveTrader._parse_timestamp_seconds.__get__(t) + t._load_chain_ledger_state = lambda _tid: None + t._v7_exit_engine = None + t._seed_posture_for_restored_position = lambda: None + t._apply_catastrophic_floor_to_open_position = lambda: None + t._restore_position_state() + + assert t._restore_failed is False + assert t.eng.position is not None + assert captured["entry_bar"] == -34 + # bars_held = current_bar - entry_bar = 0 - (-34) = 34 → clock continues + assert (t.bar_idx - captured["entry_bar"]) == 34 + + +if __name__ == "__main__": + import sys + sys.exit(pytest.main([__file__, "-v"])) diff --git a/prod/tests/test_tp_floor.py b/prod/tests/test_tp_floor.py new file mode 100644 index 0000000..4c30bd4 --- /dev/null +++ b/prod/tests/test_tp_floor.py @@ -0,0 +1,289 @@ +"""TP profit-floor (TP_FLOOR) + TP-threshold diagnostics — regression suite. + +Incident: LINKUSDT 5e05eeeb (2026-06-11). The OB tail-avoidance layer +silently widened the "fixed" 0.20% TP by x1.40 during a cascade +(alpha_exit_manager.evaluate, cascade branch). The trade peaked at +0.265% +(between base 0.19998% and widened 0.27998%), held four consecutive scans, +reversed, and died at STOP_LOSS -$1,248.71. + +This suite pins: + 1. Default-OFF parity: with tp_floor_enabled=False (the class default), + behavior is bit-identical to the pre-change engine, INCLUDING the + cascade-widened HOLD that caused the incident. + 2. The golden LINK replay: with the floor ON, the trade exits TP_FLOOR + on the first regression scan below base TP (+0.1617%), not STOP_LOSS. + 3. Arming and edge rules, modulation interactions, LONG symmetry, + STOP_LOSS / MAX_HOLD untouched, diagnostics present on every decision. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, "/mnt/dolphinng5_predict/nautilus_dolphin") + +import pytest + +from nautilus_dolphin.nautilus.alpha_exit_manager import AlphaExitManager + + +# ── OB engine mock — exactly the surface evaluate() consumes ───────────────── + +class _Sig: + def __init__(self, imbalance_ma5=0.0, withdrawal_velocity=0.0): + self.imbalance_ma5 = imbalance_ma5 + self.withdrawal_velocity = withdrawal_velocity + + +class _Macro: + def __init__(self, cascade_count=0, regime_signal=0): + self.cascade_count = cascade_count + self.regime_signal = regime_signal + + +class MockOBEngine: + def __init__(self, cascade_count=0, regime_signal=0, imbalance_ma5=0.0, + withdrawal_velocity=0.0): + self._sig = _Sig(imbalance_ma5, withdrawal_velocity) + self._macro = _Macro(cascade_count, regime_signal) + + def get_signal(self, asset, ts): + return self._sig + + def get_macro(self): + return self._macro + + +# ── LINK 5e05eeeb constants (from the live tape) ───────────────────────────── + +LINK_ENTRY = 7.729 +LINK_TP = 0.0019998464 # tp_effective_pct as recorded +LINK_DIR = -1 # SHORT +# (price, expected pnl_pct fraction) sequence from dolphin.v7_decision_events +LINK_TAPE = [ + (7.7225, 0.00084), # bars 0-1 + (7.7185, 0.00136), # bars 4-7 + (7.7175, 0.00149), # bars 5-9 + (7.7085, 0.00265), # bars 10-12 — ABOVE base TP, below widened TP + (7.7125, 0.00213), # bar 12-13 — still above base TP + (7.7165, 0.00162), # bar 13-14 — REGRESSED below base TP +] + + +def _mgr(floor=False, ob=None, tp=LINK_TP, stop=1.0, max_hold=250): + m = AlphaExitManager(fixed_tp_pct=tp, stop_pct=stop, max_hold_bars=max_hold, + tp_floor_enabled=floor) + if ob is not None: + m.ob_engine = ob + return m + + +def _short(m, trade_id="t", entry=LINK_ENTRY, bar=0): + m.setup_position(trade_id, entry, LINK_DIR, bar) + return trade_id + + +# ── 1. Default-off parity (incident behavior preserved bit-exact) ─────────── + +def test_default_is_off(): + assert AlphaExitManager().tp_floor_enabled is False + + +def test_cascade_widened_hold_unchanged_when_floor_off(): + """The incident, replayed: floor OFF + cascade ON -> HOLD through the + whole profitable window and no TP_FLOOR ever — pre-change behavior.""" + m = _mgr(floor=False, ob=MockOBEngine(cascade_count=3)) + t = _short(m) + bar = 0 + for price, _pnl in LINK_TAPE: + bar += 1 + r = m.evaluate(t, price, bar, asset="LINKUSDT") + assert r["action"] == "HOLD", (price, r) + # diagnostics still present even when floor is off + assert r["dynamic_tp_pct"] == pytest.approx(LINK_TP * 1.40, rel=1e-9) + assert r["tp_mod_factor"] == pytest.approx(1.40, rel=1e-9) + assert r["cascade_count"] == 3 + assert r["tp_floor_armed"] is True + + +def test_no_ob_engine_fixed_tp_fires_at_base(): + """Without an OB engine there is no modulation: first scan at +0.265% + fires plain FIXED_TP (sanity that base behavior is intact).""" + m = _mgr(floor=False) + t = _short(m) + r = m.evaluate(t, 7.7085, 10, asset="LINKUSDT") + assert (r["action"], r["reason"]) == ("EXIT", "FIXED_TP") + assert r["dynamic_tp_pct"] == pytest.approx(LINK_TP) + assert r["tp_mod_factor"] == pytest.approx(1.0) + + +# ── 2. Golden LINK replay with the floor ON ───────────────────────────────── + +def test_link_golden_replay_floor_exits_on_regression(): + """THE fix: cascade widens TP to 0.27998%; the tape peaks at 0.265% + (HOLD, matching live); on the first scan back below base TP the floor + fires TP_FLOOR at +0.1617% — instead of riding to -1.26% STOP_LOSS.""" + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=3)) + t = _short(m) + actions = [] + bar = 0 + result = None + for price, _pnl in LINK_TAPE: + bar += 1 + result = m.evaluate(t, price, bar, asset="LINKUSDT") + actions.append(result["action"]) + if result["action"] == "EXIT": + break + assert result["action"] == "EXIT" + assert result["reason"] == "TP_FLOOR" + # exits on the LAST tape row (the regression scan), not earlier + assert actions == ["HOLD"] * (len(LINK_TAPE) - 1) + ["EXIT"] + assert result["pnl_pct"] == pytest.approx( + LINK_DIR * (7.7165 - LINK_ENTRY) / LINK_ENTRY) # +0.16173% + assert result["pnl_pct"] > 0.0 # banked a WIN + assert result["tp_floor_armed"] is True + + +def test_floor_does_not_fire_while_above_base(): + """pnl at 0.2135% (above base 0.19998%) must NOT trigger the floor — + the widened FIXED_TP logic stays in charge of capturing more.""" + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=1)) + t = _short(m) + m.evaluate(t, 7.7085, 1, asset="LINKUSDT") # arm (0.265%) + r = m.evaluate(t, 7.7125, 2, asset="LINKUSDT") # 0.2135% > base + assert r["action"] == "HOLD" + + +# ── 3. Arming rules ────────────────────────────────────────────────────────── + +def test_floor_unarmed_below_base_never_fires(): + """Excursion never reached base TP -> dips can not trigger TP_FLOOR.""" + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2)) + t = _short(m) + r1 = m.evaluate(t, 7.7185, 1, asset="LINKUSDT") # +0.136% < base + assert (r1["action"], r1["tp_floor_armed"]) == ("HOLD", False) + r2 = m.evaluate(t, 7.7350, 2, asset="LINKUSDT") # negative excursion + assert r2["action"] == "HOLD" + r3 = m.evaluate(t, 7.760, 3, asset="LINKUSDT") # -0.40% — still HOLD + assert r3["action"] == "HOLD" + + +def test_marginal_cross_then_reverse_exits_near_base(): + """Cross base TP by a hair (1.0001x), reverse: floor exits ~at base — + economically a 0.20% TP (the operator's stated intent). An EXACT-ulp + touch is allowed not to arm (float round-trip); crossing must arm.""" + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2)) + t = _short(m) + cross = LINK_ENTRY * (1.0 - LINK_TP * 1.0001) # just through base + r1 = m.evaluate(t, cross, 1, asset="LINKUSDT") + # armed on the crossing bar; pnl marginally ABOVE base -> no fire yet + # (pnl <= base is false by the 1.0001 margin) + assert r1["action"] == "HOLD" and r1["tp_floor_armed"] is True + back = LINK_ENTRY * (1.0 - LINK_TP * 0.95) # regression below base + r2 = m.evaluate(t, back, 2, asset="LINKUSDT") + assert (r2["action"], r2["reason"]) == ("EXIT", "TP_FLOOR") + assert r2["pnl_pct"] == pytest.approx(LINK_TP * 0.95, rel=1e-6) + + +def test_set_live_tp_pct_rebases_floor(): + """The soft-leverage sync re-bases the floor each scan.""" + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), tp=0.0020) + t = _short(m) + m.evaluate(t, 7.7085, 1, asset="LINKUSDT") # armed vs 0.20% + m.set_live_tp_pct(0.0030) # TP widened to 0.30% + # 0.265% max_favorable is now BELOW the new base -> floor disarmed + r = m.evaluate(t, 7.7125, 2, asset="LINKUSDT") # 0.2135% + assert r["action"] == "HOLD" + assert r["tp_floor_armed"] is False + + +# ── 4. Other exits untouched ───────────────────────────────────────────────── + +def test_stop_loss_unaffected(): + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), stop=0.012) + t = _short(m) + r = m.evaluate(t, LINK_ENTRY * 1.013, 1, asset="LINKUSDT") # -1.3% (short) + assert (r["action"], r["reason"]) == ("EXIT", "STOP_LOSS") + + +def test_max_hold_unaffected(): + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), max_hold=5) + t = _short(m) + r = None + for bar in range(1, 7): + r = m.evaluate(t, LINK_ENTRY * 1.0005, bar, asset="LINKUSDT") # small loss + if r["action"] == "EXIT": + break + assert (r["action"], r["reason"]) == ("EXIT", "MAX_HOLD") + + +def test_widened_fixed_tp_still_fires_above_widened(): + """Cascade ON, pnl ABOVE the widened threshold -> FIXED_TP (continuation + capture preserved; the floor must not pre-empt it).""" + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2)) + t = _short(m) + deep = LINK_ENTRY * (1.0 - LINK_TP * 1.40 * 1.1) # 10% past widened + r = m.evaluate(t, deep, 1, asset="LINKUSDT") + assert (r["action"], r["reason"]) == ("EXIT", "FIXED_TP") + assert r["pnl_pct"] > LINK_TP * 1.40 + + +def test_withdrawal_tightening_fires_fixed_tp_not_floor(): + """regime_signal=1 with profit tightens TP x0.60 -> FIXED_TP fires below + base; the floor never engages (pnl above dynamic but below base is + impossible here because dynamic < base).""" + m = _mgr(floor=True, ob=MockOBEngine(regime_signal=1)) + t = _short(m) + px = LINK_ENTRY * (1.0 - LINK_TP * 0.8) # pnl = 0.8x base + r = m.evaluate(t, px, 1, asset="LINKUSDT") + assert (r["action"], r["reason"]) == ("EXIT", "FIXED_TP") + assert r["tp_mod_factor"] == pytest.approx(0.60, rel=1e-9) + + +# ── 5. LONG symmetry ───────────────────────────────────────────────────────── + +def test_long_floor_symmetry(): + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), tp=0.0020) + m.setup_position("L", 100.0, +1, 0) + r1 = m.evaluate("L", 100.25, 1, asset="X") # +0.25% in band -> HOLD + assert r1["action"] == "HOLD" and r1["tp_floor_armed"] is True + r2 = m.evaluate("L", 100.15, 2, asset="X") # regression below base + assert (r2["action"], r2["reason"]) == ("EXIT", "TP_FLOOR") + assert r2["pnl_pct"] == pytest.approx(0.0015, rel=1e-6) + + +# ── 6. Diagnostics contract ────────────────────────────────────────────────── + +DIAG_KEYS = ("tp_base_pct", "dynamic_tp_pct", "tp_mod_factor", + "cascade_count", "ob_regime_signal", "tp_floor_armed") + + +def test_diagnostics_on_every_decision_and_last_eval(): + m = _mgr(floor=True, ob=MockOBEngine(cascade_count=4, regime_signal=0)) + t = _short(m) + r = m.evaluate(t, 7.7225, 1, asset="LINKUSDT") + for k in DIAG_KEYS: + assert k in r, k + assert r["cascade_count"] == 4 + le = m.last_eval + assert le["trade_id"] == t and le["bar"] == 1 + for k in DIAG_KEYS: + assert k in le, k + + +def test_diagnostics_defaults_without_ob_engine(): + m = _mgr(floor=True) + t = _short(m) + r = m.evaluate(t, 7.7225, 1, asset="LINKUSDT") + assert r["cascade_count"] == 0 + assert r["ob_regime_signal"] == 0 + assert r["tp_mod_factor"] == pytest.approx(1.0) + + +def test_no_state_return_unchanged(): + m = _mgr(floor=True) + r = m.evaluate("ghost", 1.0, 1) + assert (r["action"], r["reason"]) == ("HOLD", "NO_STATE") + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))