661 lines
32 KiB
Python
661 lines
32 KiB
Python
|
|
"""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 (Dec31–Feb25).
|
|||
|
|
# 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)
|