Files
DOLPHIN/prod/paper_trade_flow.py
hjnormey 01c19662cb initial: import DOLPHIN baseline 2026-04-21 from dolphinng5_predict working tree
Includes core prod + GREEN/BLUE subsystems:
- prod/ (BLUE harness, configs, scripts, docs)
- nautilus_dolphin/ (GREEN Nautilus-native impl + dvae/ preserved)
- adaptive_exit/ (AEM engine + models/bucket_assignments.pkl)
- Observability/ (EsoF advisor, TUI, dashboards)
- external_factors/ (EsoF producer)
- mc_forewarning_qlabs_fork/ (MC regime/envelope)

Excludes runtime caches, logs, backups, and reproducible artifacts per .gitignore.
2026-04-21 16:58:38 +02:00

661 lines
32 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""DOLPHIN Paper Trading — Prefect Flow.
Runs daily at 00:05 UTC. Processes yesterday's live scan data through
the NDAlphaEngine champion stack. Logs virtual P&L to disk + Hazelcast.
Blue deployment: champion SHORT (configs/blue.yml)
Green deployment: bidirectional SHORT+LONG (configs/green.yml) [pending LONG validation]
Usage:
# Register flows (run once, after Prefect server is up):
PREFECT_API_URL=http://localhost:4200/api python paper_trade_flow.py --register
# Run manually for a specific date:
PREFECT_API_URL=http://localhost:4200/api python paper_trade_flow.py \\
--date 2026-02-25 --config configs/blue.yml
"""
import sys, json, yaml, logging, argparse, csv, urllib.request
from pathlib import Path
from datetime import datetime, timedelta, date, timezone
import numpy as np
import pandas as pd
HCM_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(HCM_DIR / 'nautilus_dolphin'))
from prefect import flow, task, get_run_logger
from prefect.schedules import Cron
import hazelcast
logging.basicConfig(level=logging.WARNING) # suppress Prefect noise below WARNING
# ── Paths ───────────────────────────────────────────────────────────────────────
from dolphin_paths import get_eigenvalues_path, get_klines_dir
SCANS_DIR = get_eigenvalues_path() # platform-aware: Win → NG3 dir, Linux → /mnt/ng6_data/eigenvalues
KLINES_DIR = get_klines_dir() # vbt_cache_klines/ — NG5 parquet source (preferred)
MC_MODELS_DIR = str(HCM_DIR / 'nautilus_dolphin' / 'mc_results' / 'models')
# Columns that are eigenvalue metadata, not asset prices
META_COLS = {
'timestamp', 'scan_number',
'v50_lambda_max_velocity', 'v150_lambda_max_velocity',
'v300_lambda_max_velocity', 'v750_lambda_max_velocity',
'vel_div', 'instability_50', 'instability_150',
}
HZ_HOST = "localhost:5701"
HZ_CLUSTER = "dolphin"
# Number of historical eigenvalue dates to use for ACB w750 threshold calibration
ACB_HISTORY_DAYS = 60
# ── Helpers ──────────────────────────────────────────────────────────────────────
def _get_recent_scan_dates(n: int) -> list:
"""Return sorted list of up to n most-recent eigenvalue date dirs."""
try:
dirs = sorted(
d.name for d in SCANS_DIR.iterdir()
if d.is_dir() and len(d.name) == 10 and d.name.startswith('20')
)
return dirs[-n:]
except Exception:
return []
def _fetch_btcusdt_klines_fallback(date_str: str) -> "dict[str, float]":
"""Fetch BTCUSDT 1m klines from Binance futures for date_str.
Returns a dict mapping ISO timestamp strings (minute precision) → close price.
Falls back to empty dict on any error (caller handles missing prices gracefully).
"""
try:
from datetime import timezone as tz
day_start = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=tz.utc)
day_end = day_start + timedelta(days=1)
start_ms = int(day_start.timestamp() * 1000)
end_ms = int(day_end.timestamp() * 1000)
prices: dict[str, float] = {}
# Binance returns max 1500 bars per request; 1440 bars/day fits in one call
url = (
f"https://fapi.binance.com/fapi/v1/klines"
f"?symbol=BTCUSDT&interval=1m"
f"&startTime={start_ms}&endTime={end_ms}&limit=1500"
)
with urllib.request.urlopen(url, timeout=10) as resp:
bars = json.loads(resp.read())
for bar in bars:
ts_ms = int(bar[0])
close = float(bar[4])
ts_iso = datetime.fromtimestamp(ts_ms / 1000, tz=tz.utc).strftime("%Y-%m-%dT%H:%M")
prices[ts_iso] = close
return prices
except Exception:
return {}
def _load_scan_df_from_json(date_str: str) -> pd.DataFrame:
"""Load daily scan JSON files → DataFrame with vel_div + asset prices.
Each scan JSON has:
- windows: {50, 150, 300, 750} → tracking_data.lambda_max_velocity
- pricing_data.current_prices → per-asset USD prices
Returns one row per scan, sorted by scan_number.
When current_prices is empty (scanner bug), falls back to Binance 1m klines.
"""
scan_dir = SCANS_DIR / date_str
if not scan_dir.exists():
return pd.DataFrame()
json_files = sorted(scan_dir.glob("scan_*.json"), key=lambda f: f.name)
# Exclude the _Indicators.json companion files if any
json_files = [f for f in json_files if '__Indicators' not in f.name]
if not json_files:
return pd.DataFrame()
total = len(json_files)
rows = []
for i, jf in enumerate(json_files):
if i % 500 == 0 and i > 0:
print(f" [scan load] {i}/{total} files...", flush=True)
try:
with open(jf, 'r', encoding='utf-8') as fh:
data = json.load(fh)
row = {
'scan_number': data.get('scan_number', 0),
'timestamp': data.get('timestamp', ''),
}
# Eigenvalue velocity per window
windows = data.get('windows', {})
for w_key, vel_col, inst_col in [
('50', 'v50_lambda_max_velocity', 'instability_50'),
('150', 'v150_lambda_max_velocity', 'instability_150'),
('300', 'v300_lambda_max_velocity', None),
('750', 'v750_lambda_max_velocity', None),
]:
w_data = windows.get(str(w_key), {})
td = w_data.get('tracking_data', {})
vel = td.get('lambda_max_velocity')
row[vel_col] = float(vel) if vel is not None else np.nan
if inst_col is not None:
rs = w_data.get('regime_signals', {})
row[inst_col] = float(rs.get('instability_score', 0.0) or 0.0)
# vel_div = w50_vel w150_vel (the primary alpha signal)
v50 = row.get('v50_lambda_max_velocity', np.nan)
v150 = row.get('v150_lambda_max_velocity', np.nan)
row['vel_div'] = (v50 - v150) if (np.isfinite(v50) and np.isfinite(v150)) else np.nan
# Asset prices
pricing = data.get('pricing_data', {})
prices = pricing.get('current_prices', {})
row.update({sym: float(px) for sym, px in prices.items() if px is not None})
rows.append(row)
except Exception:
continue
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows).sort_values('scan_number').reset_index(drop=True)
# Fallback: if BTCUSDT prices are missing (scanner current_prices bug),
# fetch 1m klines from Binance and fill by timestamp.
if 'BTCUSDT' not in df.columns or df['BTCUSDT'].isna().all():
btc_klines = _fetch_btcusdt_klines_fallback(date_str)
if btc_klines:
def _lookup_btc(ts_str: str) -> float:
# ts_str is ISO format from scan JSON; match to minute precision
ts_min = str(ts_str)[:16] # "YYYY-MM-DDTHH:MM"
return btc_klines.get(ts_min, np.nan)
df['BTCUSDT'] = df['timestamp'].apply(_lookup_btc)
# Forward-fill any gaps (scan timestamps between 1m bars)
df['BTCUSDT'] = df['BTCUSDT'].ffill().bfill()
return df
def _load_scan_df_from_parquet(date_str: str) -> pd.DataFrame:
"""Load daily scan data from vbt_cache_klines/ parquet (NG5+ preferred path).
Returns DataFrame with vel_div + asset prices, or empty DataFrame if not available.
"""
parq_path = KLINES_DIR / f"{date_str}.parquet"
if not parq_path.exists():
return pd.DataFrame()
try:
df = pd.read_parquet(parq_path)
if df.empty or 'vel_div' not in df.columns:
return pd.DataFrame()
return df.reset_index(drop=True)
except Exception:
return pd.DataFrame()
# ── Tasks ───────────────────────────────────────────────────────────────────────
@task(name="load_config", retries=0)
def load_config(config_path: str) -> dict:
with open(config_path) as f:
return yaml.safe_load(f)
@task(name="load_day_scans", retries=2, retry_delay_seconds=10)
def load_day_scans(date_str: str) -> pd.DataFrame:
"""Load scan data for one date → VBT-compatible DataFrame.
Prefers vbt_cache_klines/ parquet (NG5 native, single file, fast).
Falls back to eigenvalues/ JSON (NG3 legacy) if parquet unavailable.
Extracts vel_div, eigenvalue features, and asset prices.
"""
log = get_run_logger()
# ── Parquet path (NG5 native — preferred) ──────────────────────────────────
df = _load_scan_df_from_parquet(date_str)
if not df.empty:
log.info(f" [parquet] Loaded {len(df)} scans for {date_str} | cols={len(df.columns)}")
else:
# ── JSON fallback (NG3 legacy) ─────────────────────────────────────────
df = _load_scan_df_from_json(date_str)
if df.empty:
log.warning(f"No usable scan data for {date_str} in {SCANS_DIR}")
return pd.DataFrame()
log.info(f" [json] Loaded {len(df)} scans for {date_str} | cols={len(df.columns)}")
if df.empty:
log.warning(f"No usable scan data for {date_str} in {SCANS_DIR}")
return pd.DataFrame()
# Drop rows with NaN vel_div (warmup period at start of day)
valid = df['vel_div'].notna()
n_dropped = (~valid).sum()
df = df[valid].reset_index(drop=True)
# Verify BTCUSDT prices present (required for vol gate and DC)
if 'BTCUSDT' not in df.columns:
log.error(f"BTCUSDT prices missing from scan data on {date_str} — cannot run engine")
return pd.DataFrame()
log.info(f" Loaded {len(df)} scans for {date_str} | cols={len(df.columns)} | "
f"vel_div range=[{df['vel_div'].min():.4f}, {df['vel_div'].max():.4f}] "
f"| {n_dropped} warmup rows dropped")
return df
@task(name="run_engine_day", retries=0, persist_result=False)
def run_engine_day(date_str: str, df: pd.DataFrame, engine, vol_p60: float, posture: str = 'APEX', direction: int = -1) -> dict:
"""Run one day through NDAlphaEngine. Returns daily stats dict."""
log = get_run_logger()
if df.empty or posture == 'HIBERNATE':
log.warning(f"Empty DataFrame or HIBERNATE for {date_str} — skipping.")
return {'date': date_str, 'pnl': 0.0, 'capital': engine.capital,
'trades': 0, 'boost': 1.0, 'beta': 0.0, 'mc_status': 'NO_DATA', 'posture': posture}
asset_cols = [c for c in df.columns if c not in META_COLS]
# Vol gate: rolling 50-bar std of BTC returns
bp = df['BTCUSDT'].values
dvol = np.full(len(df), np.nan)
for i in range(50, len(bp)):
seg = bp[max(0, i - 50):i]
if len(seg) >= 10 and seg[0] > 0:
dvol[i] = float(np.std(np.diff(seg) / seg[:-1]))
vol_ok = np.where(np.isfinite(dvol), dvol > vol_p60, False)
# 1. Setup day
engine.begin_day(date_str, posture=posture, direction=direction)
# 2. Bar stream (replaces batch process_day)
for ri in range(len(df)):
row = df.iloc[ri]
vd = row.get('vel_div')
if vd is None or not np.isfinite(float(vd)):
engine._global_bar_idx += 1
continue
v50_raw = row.get('v50_lambda_max_velocity')
v750_raw = row.get('v750_lambda_max_velocity')
v50_val = float(v50_raw) if (v50_raw is not None and np.isfinite(float(v50_raw))) else 0.0
v750_val = float(v750_raw) if (v750_raw is not None and np.isfinite(float(v750_raw))) else 0.0
prices = {}
for ac in asset_cols:
p = row.get(ac)
if p is not None and p > 0 and np.isfinite(p):
prices[ac] = float(p)
if not prices:
engine._global_bar_idx += 1
continue
# OB live step: fetch HZ snapshots and compute features BEFORE step_bar().
# This populates the live caches so get_placement/get_signal/get_market()
# return real OBF values instead of NEUTRAL defaults.
if engine.ob_engine is not None:
try:
engine.ob_engine.step_live(list(prices.keys()), ri)
except Exception:
pass # OBF degraded → NEUTRAL values, trading continues
engine.step_bar(
bar_idx=ri,
vel_div=float(vd),
prices=prices,
vol_regime_ok=bool(vol_ok[ri]),
v50_vel=v50_val,
v750_vel=v750_val,
)
# 3. Finalize day
result = engine.end_day()
result['posture'] = posture
log.info(f" {date_str}: PnL={result.get('pnl', 0):+.2f} "
f"T={result.get('trades', 0)} boost={result.get('boost', 1.0):.2f}x "
f"MC={result.get('mc_status', '?')} Posture={posture}")
return result
@task(name="write_hz_state", retries=3, retry_delay_seconds=5, persist_result=False)
def write_hz_state(hz_host: str, hz_cluster: str, imap_name: str, key: str, value: dict):
"""Write state dict to Hazelcast IMap. Creates own client per call (serialization-safe)."""
client = hazelcast.HazelcastClient(cluster_name=hz_cluster, cluster_members=[hz_host])
try:
client.get_map(imap_name).blocking().put(key, json.dumps(value))
finally:
client.shutdown()
@task(name="log_pnl", retries=0, persist_result=False)
def log_pnl(log_dir: Path, date_str: str, result: dict, capital: float):
log_dir.mkdir(parents=True, exist_ok=True)
row = {**result, 'date': date_str, 'capital': capital,
'logged_at': datetime.now(timezone.utc).isoformat()}
log_file = log_dir / f"paper_pnl_{date_str[:7]}.jsonl"
with open(log_file, 'a') as f:
f.write(json.dumps(row) + '\n')
# ── Flow ────────────────────────────────────────────────────────────────────────
@flow(name="dolphin-paper-trade", log_prints=True)
def dolphin_paper_trade_flow(config_path: str = "configs/blue.yml",
run_date: str = None,
instrument: bool = False):
"""Daily paper trading flow. Processes one day of live eigenvalue scans.
Scheduled at 00:05 UTC — processes yesterday's data.
Run manually with run_date='YYYY-MM-DD' for backtesting or debugging.
"""
log = get_run_logger()
cfg = load_config(config_path)
strategy_name = cfg['strategy_name']
eng_cfg = cfg['engine']
pt_cfg = cfg['paper_trade']
hz_cfg = cfg['hazelcast']
target_date = run_date or (date.today() - timedelta(days=1)).isoformat()
log.info(f"=== {strategy_name.upper()} paper trade: {target_date} ===")
# ── Lazy imports (numba JIT happens here) ──────────────────────────────────
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
from nautilus_dolphin.nautilus.adaptive_circuit_breaker import AdaptiveCircuitBreaker
from mc.mc_ml import DolphinForewarner
from nautilus_dolphin.nautilus.ob_features import OBFeatureEngine
from nautilus_dolphin.nautilus.hz_ob_provider import HZOBProvider
client = hazelcast.HazelcastClient(cluster_name=HZ_CLUSTER, cluster_members=[HZ_HOST])
imap_state = client.get_map(hz_cfg['imap_state']).blocking()
# ---- Restore capital ----
STATE_KEY = f"state_{strategy_name}_{target_date}"
restored_capital = pt_cfg['initial_capital']
peak_capital = pt_cfg['initial_capital']
stored_state = {}
engine_state = None
try:
raw = imap_state.get(STATE_KEY) or imap_state.get('latest') or '{}'
stored_state = json.loads(raw)
if stored_state.get('strategy') == strategy_name and stored_state.get('capital', 0) > 0:
restored_capital = float(stored_state['capital'])
peak_capital = float(stored_state.get('peak_capital', restored_capital))
engine_state = stored_state.get('engine_state')
log.info(f"[STATE] Restored capital={restored_capital:.2f} from HZ")
except Exception as e:
log.warning(f"[STATE] HZ restore failed: {e} — using config capital")
# ── Engine — D_LIQ_GOLD config (8x/9x LiquidationGuardEngine) ───────────
# create_d_liq_engine() overrides max_leverage→8.0 / abs_max_leverage→9.0
# internally (D_LIQ_SOFT_CAP / D_LIQ_ABS_CAP constants), regardless of what
# eng_cfg says. This is the certified gold leverage stack.
engine = create_d_liq_engine(
initial_capital = restored_capital,
vel_div_threshold = eng_cfg['vel_div_threshold'],
vel_div_extreme = eng_cfg['vel_div_extreme'],
min_leverage = eng_cfg['min_leverage'],
max_leverage = eng_cfg.get('max_leverage', 8.0),
abs_max_leverage = eng_cfg.get('abs_max_leverage', 9.0),
leverage_convexity = eng_cfg['leverage_convexity'],
fraction = eng_cfg['fraction'],
fixed_tp_pct = eng_cfg['fixed_tp_pct'],
stop_pct = eng_cfg['stop_pct'],
max_hold_bars = eng_cfg['max_hold_bars'],
use_direction_confirm= eng_cfg['use_direction_confirm'],
dc_lookback_bars = eng_cfg['dc_lookback_bars'],
dc_min_magnitude_bps = eng_cfg['dc_min_magnitude_bps'],
dc_skip_contradicts = eng_cfg['dc_skip_contradicts'],
dc_leverage_boost = eng_cfg['dc_leverage_boost'],
dc_leverage_reduce = eng_cfg['dc_leverage_reduce'],
use_asset_selection = eng_cfg['use_asset_selection'],
min_irp_alignment = eng_cfg['min_irp_alignment'],
use_sp_fees = eng_cfg['use_sp_fees'],
use_sp_slippage = eng_cfg['use_sp_slippage'],
sp_maker_entry_rate = eng_cfg['sp_maker_entry_rate'],
sp_maker_exit_rate = eng_cfg['sp_maker_exit_rate'],
use_ob_edge = eng_cfg['use_ob_edge'],
ob_edge_bps = eng_cfg['ob_edge_bps'],
ob_confirm_rate = eng_cfg['ob_confirm_rate'],
lookback = eng_cfg['lookback'],
use_alpha_layers = eng_cfg['use_alpha_layers'],
use_dynamic_leverage = eng_cfg['use_dynamic_leverage'],
seed = eng_cfg.get('seed', 42),
)
engine.set_esoteric_hazard_multiplier(0.0) # gold spec: hazard=0 → base_max_leverage=8.0
if engine_state:
try:
engine.restore_state(engine_state)
log.info("[STATE] Restored full engine state (including open positions)")
except Exception as e:
log.error(f"[STATE] Failed to restore engine state: {e}")
# ── ACB — preload w750 from recent history for valid p60 threshold ─────────
# w750 calibration always uses NPZ history (threshold is a population statistic).
# Daily ExF factors are sourced from HZ exf_latest (pre-lagged) when available;
# fall back to NPZ disk scan if HZ data is absent or stale (>12 h).
acb = AdaptiveCircuitBreaker()
acb.config.EIGENVALUES_PATH = SCANS_DIR # CRITICAL: override Windows default for Linux
recent_dates = _get_recent_scan_dates(ACB_HISTORY_DAYS)
if target_date not in recent_dates:
recent_dates = (recent_dates + [target_date])[-ACB_HISTORY_DAYS:]
acb.preload_w750(recent_dates)
log.info(f" ACB preloaded {len(recent_dates)} dates | w750_threshold={acb._w750_threshold:.6f}")
# ── ACB HZ warm-up: pre-load today's boost from live exf_latest ────────────
# The ExF service applies per-indicator lag BEFORE pushing to HZ, so the
# values in exf_latest are already delay-adjusted. Do NOT re-lag them.
_acb_hz_ok = False
try:
features_map = client.get_map('DOLPHIN_FEATURES').blocking()
exf_raw = features_map.get('exf_latest')
if exf_raw:
exf_snapshot = json.loads(exf_raw)
# Live w750 from latest scan (may be absent during warmup)
scan_raw = features_map.get('latest_eigen_scan')
w750_live: float | None = None
if scan_raw:
scan_data = json.loads(scan_raw)
w750_live = scan_data.get('w750_velocity')
boost_info = acb.get_dynamic_boost_from_hz(
target_date, exf_snapshot, w750_velocity=w750_live
)
stale_s = boost_info.get('max_staleness_s', 0)
log.info(
f" ACB HZ: boost={boost_info['boost']:.4f} beta={boost_info['beta']:.2f} "
f"signals={boost_info['signals']:.1f} staleness={stale_s:.0f}s"
)
_acb_hz_ok = True
else:
log.warning(" ACB HZ: exf_latest not found — falling back to NPZ disk scan")
except ValueError as _ve:
log.warning(f" ACB HZ: snapshot stale ({_ve}) — falling back to NPZ disk scan")
except Exception as _e:
log.warning(f" ACB HZ: read failed ({_e}) — falling back to NPZ disk scan")
if not _acb_hz_ok:
# NPZ fallback: get_dynamic_boost_for_date() will read eigenvalues/ on demand
log.info(f" ACB: using NPZ disk path for {target_date}")
# ── Data Loading & Live OB Integration ────────────────────────────────────
df = load_day_scans(target_date)
OB_ASSETS = [c for c in df.columns if c not in META_COLS] if not df.empty else ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
live_ob = HZOBProvider(hz_cluster=HZ_CLUSTER, hz_host=HZ_HOST)
ob_eng = OBFeatureEngine(live_ob)
# NOTE: HZOBProvider has no historical snapshots, so preload_date() is a no-op.
# OB features are fetched live via ob_eng.step_live() before each step_bar() call.
# ── MC-Forewarner ──────────────────────────────────────────────────────────
forewarner = DolphinForewarner(models_dir=MC_MODELS_DIR)
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': 5.00,
'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': 120,
'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.00, 'ob_confirm_rate': 0.40,
'ob_imbalance_bias': -0.09, 'ob_depth_scale': 1.00,
'use_asset_selection': True, 'min_irp_alignment': 0.45, 'lookback': 100,
'acb_beta_high': 0.80, 'acb_beta_low': 0.20, 'acb_w750_threshold_pct': 60,
}
engine.set_ob_engine(ob_eng)
engine.set_acb(acb)
engine.set_mc_forewarner(forewarner, mc_base_cfg)
engine.set_esoteric_hazard_multiplier(0.0)
if instrument:
engine._bar_log_enabled = True
# vol_p60: 60th percentile of rolling 50-bar BTC return std
# Calibrated from 55-day NG3 champion window (Dec31Feb25).
# TODO: compute adaptively from rolling scan history (Phase MIG3)
vol_p60 = pt_cfg.get('vol_p60', 0.000099)
# ── Run ────────────────────────────────────────────────────────────────────
# df was already loaded above to define OB_ASSETS
# ── DOLPHIN_SAFETY (MIG3) ──────────────────────────────────────────────────
try:
safety_ref = client.get_cp_subsystem().get_atomic_reference('DOLPHIN_SAFETY').blocking()
safety_raw = safety_ref.get()
except Exception:
safety_ref = client.get_map('DOLPHIN_SAFETY').blocking()
safety_raw = safety_ref.get('latest')
safety_state = json.loads(safety_raw) if safety_raw else {}
posture = safety_state.get('posture', 'APEX')
Rm = safety_state.get('Rm', 1.0)
log.info(f"[SURVIVAL STACK] Posture={posture} | Rm={Rm:.3f}")
# Apply Rm to absolute max leverage
effective_max_lev = engine.abs_max_leverage * Rm
engine.abs_max_leverage = max(1.0, effective_max_lev)
if posture == 'STALKER':
engine.abs_max_leverage = min(engine.abs_max_leverage, 2.0)
dir_str = cfg.get('direction', 'short_only')
direction_val = 1 if dir_str in ['long', 'long_only'] else -1
result = run_engine_day(target_date, df, engine, vol_p60, posture=posture, direction=direction_val)
result['strategy'] = strategy_name
result['capital'] = engine.capital
# ── Hazelcast & State Persistence ──────────────────────────────────────────
try:
# PnL write
imap_pnl = client.get_map(hz_cfg['imap_pnl']).blocking()
imap_pnl.put(target_date, json.dumps(result))
# State persist write
new_peak = max(engine.capital, peak_capital)
new_drawdown = 1.0 - (engine.capital / new_peak) if new_peak > 0 else 0.0
new_state = {
'strategy': strategy_name,
'capital': engine.capital,
'date': target_date,
'pnl': result.get('pnl', 0.0),
'trades': result.get('trades', 0),
'peak_capital': new_peak,
'drawdown': new_drawdown,
'last_date': target_date,
'updated_at': datetime.now(timezone.utc).isoformat(),
'engine_state': engine.get_state(),
}
imap_state.put('latest', json.dumps(new_state))
imap_state.put(STATE_KEY, json.dumps(new_state))
log.info(f" HZ write OK → state & {hz_cfg['imap_pnl']}[{target_date}]")
except Exception as e:
log.error(f"[STATE] HZ persist failed: {e}")
# Fallback: write to local JSON ledger
ledger_dir = Path(__file__).parent / pt_cfg.get('log_dir', 'paper_logs')
ledger_dir.mkdir(parents=True, exist_ok=True)
ledger_path = ledger_dir / f"state_ledger_{strategy_name}.jsonl"
with open(ledger_path, 'a') as f:
f.write(json.dumps({
'strategy': strategy_name, 'capital': engine.capital,
'date': target_date, 'pnl': result.get('pnl', 0.0),
'trades': result.get('trades', 0), 'peak_capital': peak_capital,
'drawdown': 1.0 - engine.capital / max(engine.capital, peak_capital) if max(engine.capital, peak_capital) > 0 else 0.0
}) + '\n')
finally:
live_ob.close()
client.shutdown()
# ── Instrumentation (--instrument flag) ────────────────────────────────────
if instrument:
instr_dir = Path(__file__).parent / pt_cfg['log_dir']
instr_dir.mkdir(parents=True, exist_ok=True)
trades_instr_path = instr_dir / f"E2E_trades_{target_date}.csv"
with open(trades_instr_path, 'w', newline='') as f:
cw = csv.writer(f)
cw.writerow(['trade_id', 'asset', 'direction', 'entry_price', 'exit_price',
'entry_bar', 'exit_bar', 'bars_held', 'leverage', 'notional',
'pnl_pct', 'pnl_absolute', 'exit_reason', 'bucket_idx'])
for t in engine.trade_history:
cw.writerow([t.trade_id, t.asset, t.direction,
f"{t.entry_price:.6f}", f"{t.exit_price:.6f}",
t.entry_bar, t.exit_bar, t.bars_held,
f"{t.leverage:.4f}", f"{t.notional:.4f}",
f"{t.pnl_pct:.8f}", f"{t.pnl_absolute:.4f}",
t.exit_reason, t.bucket_idx])
bars_instr_path = instr_dir / f"E2E_bars_{target_date}.csv"
with open(bars_instr_path, 'w', newline='') as f:
cw = csv.writer(f)
cw.writerow(['date', 'bar_idx', 'vel_div', 'vol_ok', 'posture',
'regime_size_mult', 'position_open', 'boost', 'beta'])
for b in engine._bar_log:
cw.writerow([target_date, b['bar_idx'], f"{b['vel_div']:.8f}",
b['vol_ok'], b['posture'], f"{b['regime_size_mult']:.6f}",
b['position_open'], f"{b['boost']:.4f}", f"{b['beta']:.2f}"])
log.info(f" Instrumentation → {trades_instr_path.name} ({len(engine.trade_history)} trades), "
f"{bars_instr_path.name} ({len(engine._bar_log)} bars)")
# ── Disk log ───────────────────────────────────────────────────────────────
log_pnl(Path(__file__).parent / pt_cfg['log_dir'], target_date, result, engine.capital)
log.info(f"=== DONE: {strategy_name} {target_date} | "
f"PnL={result.get('pnl', 0):+.2f} | Capital={engine.capital:,.2f} ===")
return result
# ── CLI entry point ──────────────────────────────────────────────────────────────
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='DOLPHIN paper trading flow')
parser.add_argument('--config', default='configs/blue.yml', help='Strategy config YAML')
parser.add_argument('--date', default=None, help='YYYY-MM-DD (default: yesterday)')
parser.add_argument('--register', action='store_true', help='Register Prefect deployments')
parser.add_argument('--instrument', action='store_true', help='Write per-trade + bar CSVs to log_dir')
args = parser.parse_args()
if args.register:
from prefect.client.schemas.schedules import CronSchedule as CS
for color, cfg_path in [('blue', 'configs/blue.yml'), ('green', 'configs/green.yml')]:
abs_cfg = str(Path(__file__).parent / cfg_path)
deployment = dolphin_paper_trade_flow.to_deployment(
name=f"dolphin-paper-{color}",
parameters={"config_path": abs_cfg},
schedule=CS(cron="5 0 * * *", timezone="UTC"),
work_pool_name="dolphin",
tags=[color, "paper-trade", "dolphin"],
)
deployment.apply()
print(f"Registered: dolphin-paper-{color}")
else:
import os
os.environ.setdefault('PREFECT_API_URL', 'http://localhost:4200/api')
dolphin_paper_trade_flow(config_path=args.config, run_date=args.date,
instrument=args.instrument)