420 lines
16 KiB
Python
420 lines
16 KiB
Python
|
|
"""
|
|||
|
|
Exp 3 — Longer-window proxies × three modes (gate / size / exit).
|
|||
|
|
|
|||
|
|
Available proxy signals from scan parquets:
|
|||
|
|
proxy_B50 = instability_50 - v750_lambda_max_velocity (original)
|
|||
|
|
proxy_B150 = instability_150 - v750_lambda_max_velocity (longer instability window)
|
|||
|
|
proxy_V50 = v50_lambda_max_velocity - v750_lambda_max_velocity (vel divergence short)
|
|||
|
|
proxy_V150 = v150_lambda_max_velocity - v750_lambda_max_velocity (vel divergence medium)
|
|||
|
|
proxy_V300 = v300_lambda_max_velocity - v750_lambda_max_velocity (vel divergence long)
|
|||
|
|
|
|||
|
|
For each proxy, test:
|
|||
|
|
MODE_GATE: binary suppress entry when proxy < rolling threshold
|
|||
|
|
MODE_SIZE: scale fraction [0.5x, 1.5x] by proxy percentile
|
|||
|
|
MODE_EXIT: (shadow analysis) early exit when proxy < rolling threshold
|
|||
|
|
|
|||
|
|
Run order:
|
|||
|
|
Step 1 — fast numpy sweep across all proxy × mode × threshold
|
|||
|
|
(no Alpha Engine, simplified TP/max_hold model, ~seconds per config)
|
|||
|
|
Step 2 — top-2 configs per proxy validated with full Alpha Engine (~200s each)
|
|||
|
|
|
|||
|
|
Results: exp3_fast_sweep_results.json + exp3_alpha_engine_results.json
|
|||
|
|
"""
|
|||
|
|
import sys, time, math, json
|
|||
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|||
|
|
from pathlib import Path
|
|||
|
|
import numpy as np
|
|||
|
|
import pandas as pd
|
|||
|
|
|
|||
|
|
_HERE = Path(__file__).resolve().parent
|
|||
|
|
sys.path.insert(0, str(_HERE.parent))
|
|||
|
|
|
|||
|
|
from exp_shared import (
|
|||
|
|
ensure_jit, ENGINE_KWARGS, GOLD, VBT_DIR, META_COLS,
|
|||
|
|
load_data, load_forewarner, run_backtest, print_table, log_results
|
|||
|
|
)
|
|||
|
|
from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDAlphaEngine
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Proxy definitions ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
PROXY_DEFS = {
|
|||
|
|
'B50': lambda row: _get(row,'instability_50') - _get(row,'v750_lambda_max_velocity'),
|
|||
|
|
'B150': lambda row: _get(row,'instability_150') - _get(row,'v750_lambda_max_velocity'),
|
|||
|
|
'V50': lambda row: _get(row,'v50_lambda_max_velocity') - _get(row,'v750_lambda_max_velocity'),
|
|||
|
|
'V150': lambda row: _get(row,'v150_lambda_max_velocity') - _get(row,'v750_lambda_max_velocity'),
|
|||
|
|
'V300': lambda row: _get(row,'v300_lambda_max_velocity') - _get(row,'v750_lambda_max_velocity'),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _get(row, col, default=0.0):
|
|||
|
|
v = row.get(col)
|
|||
|
|
if v is None: return default
|
|||
|
|
try:
|
|||
|
|
f = float(v)
|
|||
|
|
return f if np.isfinite(f) else default
|
|||
|
|
except Exception:
|
|||
|
|
return default
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Step 1: Fast numpy sweep ──────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def fast_sweep():
|
|||
|
|
"""
|
|||
|
|
Vectorized sweep across all proxies × modes × thresholds.
|
|||
|
|
Uses simplified backtest: vel_div < -0.02 entry, fixed 0.95% TP, 120-bar max hold.
|
|||
|
|
Single asset (BTCUSDT), no fees, no leverage dynamics.
|
|||
|
|
~100x faster than Alpha Engine.
|
|||
|
|
"""
|
|||
|
|
print("\n" + "="*65)
|
|||
|
|
print("STEP 1 — FAST NUMPY SWEEP (simplified, no Alpha Engine)")
|
|||
|
|
print("="*65)
|
|||
|
|
|
|||
|
|
d = load_data()
|
|||
|
|
TP = 0.0095 # 99bps take profit
|
|||
|
|
MH = 120 # max hold bars
|
|||
|
|
VDT = -0.02 # vel_div entry threshold
|
|||
|
|
|
|||
|
|
# Build concatenated scan data across all days
|
|||
|
|
all_rows = []
|
|||
|
|
for pf in d['parquet_files']:
|
|||
|
|
df, _, _ = d['pq_data'][pf.stem]
|
|||
|
|
for ri in range(len(df)):
|
|||
|
|
row = df.iloc[ri]
|
|||
|
|
r = {c: row.get(c) for c in ['vel_div','BTCUSDT',
|
|||
|
|
'v50_lambda_max_velocity','v150_lambda_max_velocity',
|
|||
|
|
'v300_lambda_max_velocity','v750_lambda_max_velocity',
|
|||
|
|
'instability_50','instability_150']}
|
|||
|
|
all_rows.append(r)
|
|||
|
|
|
|||
|
|
N = len(all_rows)
|
|||
|
|
vd = np.array([_get(r,'vel_div',np.nan) for r in all_rows])
|
|||
|
|
price = np.array([_get(r,'BTCUSDT',np.nan) for r in all_rows])
|
|||
|
|
|
|||
|
|
# Precompute all proxy arrays
|
|||
|
|
proxy_arrays = {}
|
|||
|
|
for pname, pfn in PROXY_DEFS.items():
|
|||
|
|
proxy_arrays[pname] = np.array([pfn(r) for r in all_rows])
|
|||
|
|
|
|||
|
|
def simplified_backtest(entry_mask, proxy_arr, mode, threshold_pct, window=500):
|
|||
|
|
"""
|
|||
|
|
mode: 'gate' | 'size' | 'exit'
|
|||
|
|
entry_mask: boolean array of candidate entries
|
|||
|
|
Returns: ROI, n_trades, win_rate
|
|||
|
|
"""
|
|||
|
|
capital = 1.0
|
|||
|
|
in_position = False
|
|||
|
|
entry_bar = 0
|
|||
|
|
entry_p = 0.0
|
|||
|
|
pb_hist = []
|
|||
|
|
trades = []
|
|||
|
|
scale = 1.0
|
|||
|
|
|
|||
|
|
for i in range(N):
|
|||
|
|
pb = proxy_arr[i]
|
|||
|
|
if np.isfinite(pb):
|
|||
|
|
pb_hist.append(pb)
|
|||
|
|
hist_window = pb_hist[-window:] if len(pb_hist) >= window else pb_hist
|
|||
|
|
|
|||
|
|
# Rolling threshold
|
|||
|
|
if len(hist_window) >= 20:
|
|||
|
|
thr = float(np.percentile(hist_window, threshold_pct * 100))
|
|||
|
|
else:
|
|||
|
|
thr = -999.0
|
|||
|
|
|
|||
|
|
if in_position:
|
|||
|
|
if np.isnan(price[i]) or entry_p <= 0:
|
|||
|
|
in_position = False; continue
|
|||
|
|
ret = (price[i] - entry_p) / entry_p # LONG direction (for backtest)
|
|||
|
|
# direction=-1 (SHORT) — vel_div < 0 = eigenspace stress = SHORT signal
|
|||
|
|
pnl_pct = -ret # SHORT
|
|||
|
|
|
|||
|
|
bars_held = i - entry_bar
|
|||
|
|
exited = False
|
|||
|
|
|
|||
|
|
# Proxy-based exit (mode='exit')
|
|||
|
|
if mode == 'exit' and np.isfinite(pb) and pb < thr:
|
|||
|
|
exited = True
|
|||
|
|
|
|||
|
|
# Natural exits
|
|||
|
|
if not exited and pnl_pct >= TP:
|
|||
|
|
exited = True
|
|||
|
|
if not exited and bars_held >= MH:
|
|||
|
|
exited = True
|
|||
|
|
|
|||
|
|
if exited:
|
|||
|
|
pos_size = scale * 0.20
|
|||
|
|
trade_pnl = capital * pos_size * pnl_pct
|
|||
|
|
capital += trade_pnl
|
|||
|
|
trades.append(pnl_pct)
|
|||
|
|
in_position = False
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
if (not np.isnan(vd[i]) and entry_mask[i] and
|
|||
|
|
not np.isnan(price[i]) and price[i] > 0):
|
|||
|
|
|
|||
|
|
# Gate mode: skip if proxy below threshold
|
|||
|
|
if mode == 'gate' and np.isfinite(pb) and pb < thr:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Sizing mode: compute scale
|
|||
|
|
if mode == 'size' and len(hist_window) >= 20:
|
|||
|
|
pct = float(np.mean(np.array(hist_window) <= pb)) if np.isfinite(pb) else 0.5
|
|||
|
|
scale = 0.5 + pct * 1.0 # linear [0.5, 1.5]
|
|||
|
|
else:
|
|||
|
|
scale = 1.0
|
|||
|
|
|
|||
|
|
in_position = True
|
|||
|
|
entry_bar = i
|
|||
|
|
entry_p = price[i]
|
|||
|
|
|
|||
|
|
n = len(trades)
|
|||
|
|
if n == 0: return dict(roi=0, n_trades=0, wr=0, sharpe=0)
|
|||
|
|
roi = (capital - 1.0) * 100.0
|
|||
|
|
arr = np.array(trades)
|
|||
|
|
wr = float(np.mean(arr > 0)) * 100
|
|||
|
|
sh = float(arr.mean() / (arr.std() + 1e-9) * math.sqrt(n))
|
|||
|
|
return dict(roi=roi, n_trades=n, wr=wr, sharpe=sh)
|
|||
|
|
|
|||
|
|
entry_mask = (np.isfinite(vd)) & (vd < VDT)
|
|||
|
|
MODES = ['gate', 'size', 'exit']
|
|||
|
|
THRESHOLDS = [0.10, 0.25, 0.50]
|
|||
|
|
|
|||
|
|
sweep_results = []
|
|||
|
|
best_by_proxy = {}
|
|||
|
|
|
|||
|
|
for pname in PROXY_DEFS:
|
|||
|
|
parr = proxy_arrays[pname]
|
|||
|
|
for mode in MODES:
|
|||
|
|
for tpct in THRESHOLDS:
|
|||
|
|
key = f"{pname}/{mode}/p{int(tpct*100)}"
|
|||
|
|
res = simplified_backtest(entry_mask, parr, mode, tpct)
|
|||
|
|
res['key'] = key; res['proxy'] = pname
|
|||
|
|
res['mode'] = mode; res['threshold_pct'] = tpct
|
|||
|
|
sweep_results.append(res)
|
|||
|
|
|
|||
|
|
# Baseline (no proxy modification)
|
|||
|
|
base = simplified_backtest(entry_mask, proxy_arrays['B50'], 'size', 0.0)
|
|||
|
|
base['key'] = 'BASELINE'; base['proxy'] = '-'; base['mode'] = '-'; base['threshold_pct'] = 0
|
|||
|
|
sweep_results.insert(0, base)
|
|||
|
|
|
|||
|
|
# Sort by Sharpe
|
|||
|
|
ranked = sorted(sweep_results, key=lambda r: r.get('sharpe', -999), reverse=True)
|
|||
|
|
|
|||
|
|
print(f"\n{'Key':<30} {'ROI%':>7} {'Trades':>7} {'WR%':>6} {'Sharpe':>8}")
|
|||
|
|
print('-'*60)
|
|||
|
|
print(f"{'BASELINE':<30} {base['roi']:>7.2f} {base['n_trades']:>7d} "
|
|||
|
|
f"{base['wr']:>6.1f}% {base['sharpe']:>8.4f}")
|
|||
|
|
print('-'*60)
|
|||
|
|
for r in ranked[:20]:
|
|||
|
|
if r['key'] == 'BASELINE': continue
|
|||
|
|
marker = ' ◄ TOP' if ranked.index(r) <= 5 else ''
|
|||
|
|
print(f"{r['key']:<30} {r['roi']:>7.2f} {r['n_trades']:>7d} "
|
|||
|
|
f"{r['wr']:>6.1f}% {r['sharpe']:>8.4f}{marker}")
|
|||
|
|
|
|||
|
|
log_results(
|
|||
|
|
ranked,
|
|||
|
|
_HERE / 'exp3_fast_sweep_results.json',
|
|||
|
|
gold=None,
|
|||
|
|
meta={'experiment': 'exp3 fast numpy sweep', 'n_bars': N,
|
|||
|
|
'baseline': base, 'note': 'simplified SHORT-only, no fees, no leverage'}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Return top configs for Alpha Engine validation (top 2 per proxy)
|
|||
|
|
top_configs = []
|
|||
|
|
seen_proxies = {}
|
|||
|
|
for r in ranked:
|
|||
|
|
if r['key'] == 'BASELINE': continue
|
|||
|
|
pn = r['proxy']
|
|||
|
|
if pn not in seen_proxies:
|
|||
|
|
seen_proxies[pn] = 0
|
|||
|
|
if seen_proxies[pn] < 2:
|
|||
|
|
top_configs.append(r)
|
|||
|
|
seen_proxies[pn] += 1
|
|||
|
|
|
|||
|
|
return top_configs, ranked[0] # top_configs for AE validation, baseline ref
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Step 2: Alpha Engine validation of top configs ────────────────────────────
|
|||
|
|
|
|||
|
|
class MultiProxyEngine(NDAlphaEngine):
|
|||
|
|
"""Generic engine parameterised by proxy function + mode."""
|
|||
|
|
|
|||
|
|
def __init__(self, *args, proxy_name='B50', mode='gate',
|
|||
|
|
threshold_pct=0.25, window=500,
|
|||
|
|
size_min=0.5, size_max=1.5, **kwargs):
|
|||
|
|
super().__init__(*args, **kwargs)
|
|||
|
|
self._proxy_name = proxy_name
|
|||
|
|
self._mode = mode
|
|||
|
|
self._threshold_pct = threshold_pct
|
|||
|
|
self._window = window
|
|||
|
|
self._size_min = size_min
|
|||
|
|
self._size_max = size_max
|
|||
|
|
self._pb_history = []
|
|||
|
|
self._current_vals = {}
|
|||
|
|
# Stats
|
|||
|
|
self.gate_suppressed = 0
|
|||
|
|
self.gate_allowed = 0
|
|||
|
|
self.early_exits = 0
|
|||
|
|
self.sizing_scales = []
|
|||
|
|
|
|||
|
|
def _proxy(self):
|
|||
|
|
v = self._current_vals
|
|||
|
|
if self._proxy_name == 'B50':
|
|||
|
|
return v.get('i50',0.) - v.get('v750',0.)
|
|||
|
|
elif self._proxy_name == 'B150':
|
|||
|
|
return v.get('i150',0.) - v.get('v750',0.)
|
|||
|
|
elif self._proxy_name == 'V50':
|
|||
|
|
return v.get('v50',0.) - v.get('v750',0.)
|
|||
|
|
elif self._proxy_name == 'V150':
|
|||
|
|
return v.get('v150',0.) - v.get('v750',0.)
|
|||
|
|
elif self._proxy_name == 'V300':
|
|||
|
|
return v.get('v300',0.) - v.get('v750',0.)
|
|||
|
|
return 0.0
|
|||
|
|
|
|||
|
|
def _rolling_threshold(self):
|
|||
|
|
h = self._pb_history[-self._window:]
|
|||
|
|
if len(h) < 20: return -999.0
|
|||
|
|
return float(np.percentile(h, self._threshold_pct * 100))
|
|||
|
|
|
|||
|
|
def _rolling_pct(self, pb):
|
|||
|
|
h = np.array(self._pb_history[-self._window:])
|
|||
|
|
if len(h) < 20: return 0.5
|
|||
|
|
return float(np.mean(h <= pb))
|
|||
|
|
|
|||
|
|
def process_day(self, date_str, df, asset_columns,
|
|||
|
|
vol_regime_ok=None, direction=None, posture='APEX'):
|
|||
|
|
self.begin_day(date_str, posture=posture, direction=direction)
|
|||
|
|
bid = 0
|
|||
|
|
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)):
|
|||
|
|
self._global_bar_idx += 1; bid += 1; continue
|
|||
|
|
|
|||
|
|
def gf(col):
|
|||
|
|
v = row.get(col)
|
|||
|
|
if v is None: return 0.0
|
|||
|
|
try:
|
|||
|
|
f = float(v)
|
|||
|
|
return f if np.isfinite(f) else 0.0
|
|||
|
|
except Exception: return 0.0
|
|||
|
|
|
|||
|
|
self._current_vals = dict(
|
|||
|
|
i50=gf('instability_50'), i150=gf('instability_150'),
|
|||
|
|
v50=gf('v50_lambda_max_velocity'), v150=gf('v150_lambda_max_velocity'),
|
|||
|
|
v300=gf('v300_lambda_max_velocity'), v750=gf('v750_lambda_max_velocity'),
|
|||
|
|
)
|
|||
|
|
pb = self._proxy()
|
|||
|
|
self._pb_history.append(pb)
|
|||
|
|
|
|||
|
|
prices = {}
|
|||
|
|
for ac in asset_columns:
|
|||
|
|
p = row.get(ac)
|
|||
|
|
if p is not None and p > 0 and np.isfinite(p):
|
|||
|
|
prices[ac] = float(p)
|
|||
|
|
if not prices:
|
|||
|
|
self._global_bar_idx += 1; bid += 1; continue
|
|||
|
|
|
|||
|
|
vrok = bool(vol_regime_ok[ri]) if vol_regime_ok is not None else (bid >= 100)
|
|||
|
|
self.step_bar(bar_idx=ri, vel_div=float(vd), prices=prices,
|
|||
|
|
vol_regime_ok=vrok,
|
|||
|
|
v50_vel=self._current_vals['v50'],
|
|||
|
|
v750_vel=self._current_vals['v750'])
|
|||
|
|
bid += 1
|
|||
|
|
return self.end_day()
|
|||
|
|
|
|||
|
|
def _try_entry(self, bar_idx, vel_div, prices, price_histories,
|
|||
|
|
v50_vel=0.0, v750_vel=0.0):
|
|||
|
|
pb = self._proxy()
|
|||
|
|
thr = self._rolling_threshold()
|
|||
|
|
|
|||
|
|
if self._mode == 'gate':
|
|||
|
|
if pb < thr:
|
|||
|
|
self.gate_suppressed += 1
|
|||
|
|
return None
|
|||
|
|
self.gate_allowed += 1
|
|||
|
|
|
|||
|
|
elif self._mode == 'size':
|
|||
|
|
pct = self._rolling_pct(pb)
|
|||
|
|
scale = self._size_min + pct * (self._size_max - self._size_min)
|
|||
|
|
self.sizing_scales.append(scale)
|
|||
|
|
orig = self.bet_sizer.base_fraction
|
|||
|
|
self.bet_sizer.base_fraction = orig * scale
|
|||
|
|
result = super()._try_entry(bar_idx, vel_div, prices, price_histories,
|
|||
|
|
v50_vel, v750_vel)
|
|||
|
|
self.bet_sizer.base_fraction = orig
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
return super()._try_entry(bar_idx, vel_div, prices, price_histories,
|
|||
|
|
v50_vel, v750_vel)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def sizing_scale_mean(self):
|
|||
|
|
return float(np.mean(self.sizing_scales)) if self.sizing_scales else 1.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_with_alpha_engine(top_configs, forewarner):
|
|||
|
|
print("\n" + "="*65)
|
|||
|
|
print("STEP 2 — ALPHA ENGINE VALIDATION (top configs)")
|
|||
|
|
print("="*65)
|
|||
|
|
|
|||
|
|
ae_results = []
|
|||
|
|
|
|||
|
|
# Baseline first
|
|||
|
|
print("\nBaseline...")
|
|||
|
|
t0 = time.time()
|
|||
|
|
r = run_backtest(lambda kw: NDAlphaEngine(**kw), 'Baseline', forewarner=forewarner)
|
|||
|
|
r['elapsed'] = time.time() - t0
|
|||
|
|
ae_results.append(r)
|
|||
|
|
print(f" {r['roi']:.2f}% PF={r['pf']:.4f} DD={r['dd']:.2f}% ({r['elapsed']:.0f}s)")
|
|||
|
|
|
|||
|
|
for cfg in top_configs:
|
|||
|
|
pn = cfg['proxy']
|
|||
|
|
mode = cfg['mode']
|
|||
|
|
tpct = cfg['threshold_pct']
|
|||
|
|
name = f"{pn}/{mode}/p{int(tpct*100)}"
|
|||
|
|
print(f"\n{name} (sweep rank: Sharpe={cfg['sharpe']:.4f})")
|
|||
|
|
t0 = time.time()
|
|||
|
|
|
|||
|
|
def factory(kw, pn=pn, mode=mode, tpct=tpct):
|
|||
|
|
return MultiProxyEngine(**kw, proxy_name=pn, mode=mode,
|
|||
|
|
threshold_pct=tpct, window=500)
|
|||
|
|
|
|||
|
|
r = run_backtest(factory, name, forewarner=forewarner)
|
|||
|
|
r['elapsed'] = time.time() - t0
|
|||
|
|
ae_results.append(r)
|
|||
|
|
print(f" {r['roi']:.2f}% PF={r['pf']:.4f} DD={r['dd']:.2f}% ({r['elapsed']:.0f}s)")
|
|||
|
|
|
|||
|
|
print("\n" + "="*83)
|
|||
|
|
print("EXP 3 — ALPHA ENGINE RESULTS")
|
|||
|
|
print_table(ae_results, gold=GOLD)
|
|||
|
|
return ae_results
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ensure_jit()
|
|||
|
|
print("\nLoading data & forewarner...")
|
|||
|
|
load_data()
|
|||
|
|
fw = load_forewarner()
|
|||
|
|
|
|||
|
|
top_configs, baseline_ref = fast_sweep()
|
|||
|
|
print(f"\nFast sweep done. Top {len(top_configs)} configs selected for AE validation.")
|
|||
|
|
print(f"Fast baseline: ROI={baseline_ref['roi']:.2f}% Sharpe={baseline_ref['sharpe']:.4f}")
|
|||
|
|
|
|||
|
|
ae_results = validate_with_alpha_engine(top_configs, fw)
|
|||
|
|
|
|||
|
|
log_results(
|
|||
|
|
ae_results,
|
|||
|
|
_HERE / 'exp3_alpha_engine_results.json',
|
|||
|
|
meta={
|
|||
|
|
'experiment': 'exp3 longer proxies alpha engine validation',
|
|||
|
|
'proxies_tested': list(PROXY_DEFS.keys()),
|
|||
|
|
'modes_tested': ['gate','size'], # exit=shadow only, done in exp2
|
|||
|
|
'note': 'Top-2 per proxy from fast sweep, validated with full Alpha Engine',
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|