2011 lines
95 KiB
Python
2011 lines
95 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
DOLPHIN Nautilus Event-Driven Trader
|
||
"""
|
||
import sys
|
||
import json
|
||
import math
|
||
import os
|
||
import time
|
||
import signal
|
||
import threading
|
||
import urllib.request
|
||
from typing import 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
|
||
try:
|
||
from adaptive_exit.market_state_runtime import MarketStateRuntime
|
||
except Exception:
|
||
MarketStateRuntime = 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 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 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")
|
||
|
||
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")
|
||
ANNOUNCEMENT_CONFIG = Path("/mnt/dolphinng5_predict/prod/configs/position_notifications_blue.json")
|
||
ANNOUNCEMENT_RUNTIME_ENV = Path("/mnt/dolphin_training/observability_notifications_blue.runtime.json")
|
||
|
||
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.0095, stop_pct=1.0, max_hold_bars=250, # gold spec: 250
|
||
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 _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 _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.
|
||
VOL_P60_THRESHOLD = 0.00009868
|
||
|
||
# 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)
|
||
_LOG_DIR = "/mnt/dolphinng5_predict/prod/logs"
|
||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||
_LOG_DATE = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||
TRADE_LOG = f"{_LOG_DIR}/nautilus_trader_{_LOG_DATE}_{ALGO_VERSION}.log"
|
||
running = True
|
||
|
||
def log(msg):
|
||
ts = datetime.now(timezone.utc).isoformat()
|
||
line = f"[{ts}] {msg}"
|
||
print(line, flush=True)
|
||
with open(TRADE_LOG, 'a') as f:
|
||
f.write(line + '\n')
|
||
|
||
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.eng_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
|
||
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._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._market_state_runtime = MarketStateRuntime() if MarketStateRuntime is not None else None
|
||
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.trade_direction: int = _direction_from_env()
|
||
self._trade_announcement_center = None
|
||
_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
|
||
|
||
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)
|
||
log(f" Engine: {type(self.eng).__name__}")
|
||
log(f" Direction: {_direction_label(self.trade_direction)} ({self.trade_direction:+d})")
|
||
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.0095, '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."""
|
||
payload = self._read_esof_payload()
|
||
score, mult = esof_gate_from_payload(payload)
|
||
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: neutral mult={mult:.2f} (no fresh score)")
|
||
else:
|
||
log(f"EsoF size gate: sc={score:+.3f} mult={mult:.2f}")
|
||
|
||
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
|
||
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)
|
||
)
|
||
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": str(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 ""),
|
||
}
|
||
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}")
|
||
|
||
# ── CH position-state persistence ─────────────────────────────────────────
|
||
|
||
def _ps_write_open(self, tid: str, entry: dict):
|
||
"""Persist OPEN row to position_state on entry. Fire-and-forget via ch_put."""
|
||
try:
|
||
ch_put("position_state", {
|
||
"ts": entry['entry_ts'],
|
||
"trade_id": tid,
|
||
"asset": entry['asset'],
|
||
"direction": -1 if entry['side'] == 'SHORT' else 1,
|
||
"entry_price": entry['entry_price'],
|
||
"quantity": entry['quantity'],
|
||
"notional": round(entry['quantity'] * entry['entry_price'], 4),
|
||
"leverage": entry['leverage'],
|
||
"bucket_id": int(self._bucket_assignments.get(entry['asset'], -1)),
|
||
"entry_bar": self.bar_idx,
|
||
"status": "OPEN",
|
||
"exit_reason": "",
|
||
"pnl": 0.0,
|
||
"bars_held": 0,
|
||
})
|
||
except Exception as e:
|
||
log(f" position_state OPEN write failed: {e}")
|
||
|
||
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:
|
||
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(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),
|
||
})
|
||
except Exception as e:
|
||
log(f" position_state CLOSED write failed: {e}")
|
||
|
||
def _restore_position_state(self):
|
||
"""On startup: check CH for an OPEN position and restore engine state."""
|
||
try:
|
||
import urllib.request, base64 as _b64
|
||
sql = ("SELECT trade_id, asset, direction, entry_price, quantity, "
|
||
"notional, leverage, bucket_id, bars_held "
|
||
"FROM dolphin.position_state FINAL "
|
||
"WHERE status = 'OPEN' "
|
||
"ORDER BY ts DESC LIMIT 1 FORMAT TabSeparated")
|
||
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")
|
||
return
|
||
|
||
cols = row.split('\t')
|
||
if len(cols) < 9:
|
||
log(f" position_state: unexpected row format: {row}")
|
||
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])
|
||
|
||
# Estimate entry_bar so MAX_HOLD countdown continues from where it left off
|
||
restored_entry_bar = max(0, self.bar_idx - stored_bars)
|
||
|
||
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,
|
||
)
|
||
# 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] = {
|
||
'asset': asset,
|
||
'side': side,
|
||
'entry_price': entry_price,
|
||
'quantity': quantity,
|
||
'leverage': leverage,
|
||
'vel_div_entry': 0.0,
|
||
'boost_at_entry': 1.0,
|
||
'beta_at_entry': 1.0,
|
||
'posture': 'RESTORED',
|
||
'entry_ts': _ch_ts_us(),
|
||
'entry_date': (self.current_day or ''),
|
||
}
|
||
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}")
|
||
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}")
|
||
|
||
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 = 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 _connect_hz(self):
|
||
log("Connecting to Hazelcast...")
|
||
import hazelcast
|
||
self.hz_client = hazelcast.HazelcastClient(cluster_name=HZ_CLUSTER, cluster_members=[HZ_HOST])
|
||
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")
|
||
# Immediate heartbeat — prevents Cat1=0 during startup gap
|
||
try:
|
||
self.heartbeat_map.blocking().put('nautilus_flow_heartbeat', json.dumps({
|
||
'ts': time.time(),
|
||
'iso': datetime.now(timezone.utc).isoformat(),
|
||
'phase': 'starting',
|
||
'flow': 'nautilus_event_trader',
|
||
}))
|
||
except Exception:
|
||
pass
|
||
log(" Hz connected")
|
||
|
||
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 _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 > VOL_P60_THRESHOLD
|
||
|
||
@staticmethod
|
||
def _normalize_ng7(scan: dict) -> dict:
|
||
"""
|
||
Promote NG7-format scan to NG5-compatible flat dict.
|
||
NG7 embeds eigenvalue windows and prices inside result{} — the engine
|
||
expects flat top-level fields. Mapping derived from continuous_convert.py:
|
||
vel_div = w50_velocity − w750_velocity (fast minus slow eigenvalue velocity)
|
||
w50_velocity = multi_window_results["50"].tracking_data.lambda_max_velocity
|
||
w750_velocity = multi_window_results["750"].tracking_data.lambda_max_velocity
|
||
assets = sorted(current_prices.keys()), BTCUSDT always last
|
||
"""
|
||
result = scan.get('result') or {}
|
||
mw = result.get('multi_window_results') or {}
|
||
|
||
def _vel(win):
|
||
v = (mw.get(str(win)) or {}).get('tracking_data', {}).get('lambda_max_velocity')
|
||
try:
|
||
f = float(v)
|
||
return f if math.isfinite(f) else 0.0
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
v50 = _vel(50)
|
||
v150 = _vel(150)
|
||
v750 = _vel(750)
|
||
|
||
cp = (result.get('pricing_data') or {}).get('current_prices') or {}
|
||
assets = [a for a in cp if a != 'BTCUSDT']
|
||
if 'BTCUSDT' in cp:
|
||
assets.append('BTCUSDT') # BTC always last — matches NG5/Arrow convention
|
||
prices = [float(cp[a]) for a in assets]
|
||
|
||
instability = float((result.get('regime_prediction') or {})
|
||
.get('instability_score') or 0.0)
|
||
|
||
return {
|
||
**scan,
|
||
'vel_div': v50 - v750,
|
||
'w50_velocity': v50,
|
||
'w750_velocity': v750,
|
||
'assets': assets,
|
||
'asset_prices': prices,
|
||
'instability_50': instability,
|
||
}
|
||
|
||
def on_scan(self, event):
|
||
"""Reactor-thread entry point — dispatches immediately to worker thread."""
|
||
if not event.value:
|
||
return
|
||
listener_time = time.time()
|
||
self._scan_executor.submit(self._process_scan, event, listener_time)
|
||
|
||
def _process_scan(self, event, listener_time):
|
||
try:
|
||
if 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.
|
||
with self._dedup_lock:
|
||
if scan_number > 0 and scan_number <= self.last_scan_number:
|
||
return
|
||
self.last_scan_number = scan_number
|
||
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 {}
|
||
# 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
|
||
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_sc_threshold_advisor(scan_number=scan_number, vel_div=vel_div)
|
||
self._sync_sc_gauge_advisor(scan_number=scan_number, vel_div=vel_div)
|
||
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}")
|
||
|
||
step_start = time.time()
|
||
with self.eng_lock:
|
||
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 = e.get('trade_id')
|
||
if tid:
|
||
self._pending_entries[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),
|
||
'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,
|
||
}
|
||
# Persist position to CH so restarts can recover it
|
||
self._ps_write_open(tid, self._pending_entries[tid])
|
||
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)
|
||
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)
|
||
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}")
|
||
|
||
if (self._v7_exit_engine is not None
|
||
and self.eng is not None
|
||
and getattr(self.eng, 'position', None) is not None
|
||
and not self._v7_live_exit_enabled):
|
||
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,
|
||
)
|
||
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),
|
||
)
|
||
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}")
|
||
|
||
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}")
|
||
log(f"EXIT: {x} [{ALGO_VERSION}]")
|
||
tid = x.get('trade_id')
|
||
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 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}")
|
||
ch_put("trade_events", {
|
||
"ts": _ch_ts_us(),
|
||
"date": pending['entry_date'],
|
||
"strategy": "blue",
|
||
"asset": pending['asset'],
|
||
"side": pending['side'],
|
||
"entry_price": pending['entry_price'],
|
||
"exit_price": exit_price,
|
||
"quantity": pending['quantity'],
|
||
"pnl": float(x.get('net_pnl', 0) or 0),
|
||
"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,
|
||
})
|
||
# 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": float(x.get("net_pnl", 0) or 0),
|
||
"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", ""),
|
||
},
|
||
)
|
||
# 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
|
||
_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)
|
||
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 = subday_exit.get('trade_id')
|
||
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}")
|
||
ch_put("trade_events", {
|
||
"ts": _ch_ts_us(),
|
||
"date": self.current_day or '',
|
||
"strategy": "blue",
|
||
"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": float(subday_exit.get('net_pnl', 0) or 0),
|
||
"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,
|
||
})
|
||
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": float(subday_exit.get("net_pnl", 0) or 0),
|
||
"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", ""),
|
||
},
|
||
)
|
||
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
|
||
payload = json.dumps({'capital': capital, 'ts': time.time()})
|
||
# Primary: Hazelcast
|
||
try:
|
||
self.state_map.blocking().put('capital_checkpoint', payload)
|
||
except Exception as e:
|
||
log(f" capital HZ save failed: {e}")
|
||
# Secondary: local disk (survives HZ restart)
|
||
try:
|
||
CAPITAL_DISK_CHECKPOINT.write_text(payload)
|
||
except Exception as e:
|
||
log(f" capital disk save failed: {e}")
|
||
|
||
def _restore_capital(self):
|
||
"""On startup, restore capital from HZ or disk checkpoint."""
|
||
def _try_load(raw, source):
|
||
if not raw:
|
||
return False
|
||
try:
|
||
data = json.loads(raw)
|
||
saved = float(data.get('capital', 0))
|
||
age_h = (time.time() - data.get('ts', 0)) / 3600
|
||
if saved >= 1.0 and math.isfinite(saved) and age_h < 72:
|
||
self.eng.capital = saved
|
||
log(f" Capital restored from {source}: ${saved:,.2f} (age {age_h:.1f}h)")
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
# Primary: Hazelcast
|
||
try:
|
||
raw = self.state_map.blocking().get('capital_checkpoint')
|
||
if _try_load(raw, 'HZ'):
|
||
return
|
||
except Exception as e:
|
||
log(f" capital HZ restore failed: {e}")
|
||
|
||
# Secondary: disk fallback
|
||
try:
|
||
if CAPITAL_DISK_CHECKPOINT.exists():
|
||
raw = CAPITAL_DISK_CHECKPOINT.read_text()
|
||
if _try_load(raw, 'disk'):
|
||
return
|
||
except Exception as e:
|
||
log(f" capital disk restore failed: {e}")
|
||
|
||
log(" Capital: no valid checkpoint — starting at initial_capital")
|
||
|
||
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:
|
||
open_notional = float(getattr(pos, 'notional', 0) or 0)
|
||
open_positions_list = [{
|
||
'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,
|
||
'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,
|
||
'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),
|
||
}
|
||
future = self.state_map.put('engine_snapshot', json.dumps(snapshot))
|
||
future.add_done_callback(lambda f: None)
|
||
# Heartbeat — MHS checks age < 30s; we run every scan (~11s)
|
||
if self.heartbeat_map is not None:
|
||
hb = json.dumps({
|
||
'ts': time.time(),
|
||
'iso': datetime.now(timezone.utc).isoformat(),
|
||
'run_date': self.current_day,
|
||
'phase': 'trading',
|
||
'flow': 'nautilus_event_trader',
|
||
})
|
||
self.heartbeat_map.put('nautilus_flow_heartbeat', hb)
|
||
# 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()
|
||
self._restore_capital()
|
||
self._rollover_day()
|
||
self._restore_position_state()
|
||
|
||
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...")
|
||
|
||
try:
|
||
while running:
|
||
time.sleep(1)
|
||
except KeyboardInterrupt:
|
||
log("Interrupted")
|
||
finally:
|
||
self.shutdown()
|
||
|
||
def shutdown(self):
|
||
log("Shutting down...")
|
||
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
|
||
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()
|