315 lines
13 KiB
Python
315 lines
13 KiB
Python
|
|
"""
|
|||
|
|
Exp 2 — proxy_B as premature exit signal, with shadow trades.
|
|||
|
|
|
|||
|
|
Post-hoc "what-if" analysis on the baseline trade set.
|
|||
|
|
1. Run baseline engine; log per-day proxy_B and per-asset prices keyed by
|
|||
|
|
(date_str, bar_idx) — the composite key that matches trade.entry_bar.
|
|||
|
|
2. For each trade: find which day it was on (tracked by engine override),
|
|||
|
|
then check if proxy_B dropped below threshold during the hold.
|
|||
|
|
3. Compute early-exit PnL at the trigger bar using the CORRECT asset price.
|
|||
|
|
4. Compare vs actual PnL.
|
|||
|
|
|
|||
|
|
Shadow insight: avg_pnl_delta = early_exit_pnl - actual_pnl
|
|||
|
|
Positive → early exit would have been better
|
|||
|
|
Negative → holding to natural exit was better (proxy_B is NOT a useful exit signal)
|
|||
|
|
|
|||
|
|
Thresholds tested (rolling percentile of proxy_B, window=500):
|
|||
|
|
T1: exit if proxy_B < p10 (rare trigger)
|
|||
|
|
T2: exit if proxy_B < p25 (moderate)
|
|||
|
|
T3: exit if proxy_B < p50 (aggressive)
|
|||
|
|
|
|||
|
|
Logged to exp2_proxy_exit_results.json.
|
|||
|
|
"""
|
|||
|
|
import sys, time, json
|
|||
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|||
|
|
from pathlib import Path
|
|||
|
|
import numpy as np
|
|||
|
|
|
|||
|
|
_HERE = Path(__file__).resolve().parent
|
|||
|
|
sys.path.insert(0, str(_HERE.parent))
|
|||
|
|
|
|||
|
|
from exp_shared import (
|
|||
|
|
ensure_jit, ENGINE_KWARGS, GOLD, load_data, load_forewarner, log_results
|
|||
|
|
)
|
|||
|
|
from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDAlphaEngine
|
|||
|
|
from nautilus_dolphin.nautilus.adaptive_circuit_breaker import AdaptiveCircuitBreaker
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Engine that logs per-day proxy_B + asset prices + trade dates ─────────────
|
|||
|
|
|
|||
|
|
class ShadowLoggingEngine(NDAlphaEngine):
|
|||
|
|
"""
|
|||
|
|
NDAlphaEngine that captures:
|
|||
|
|
- day_proxy[date][ri] = proxy_b value
|
|||
|
|
- day_prices[date][ri][asset] = price
|
|||
|
|
- trade_dates[trade_idx] = date_str of entry
|
|||
|
|
"""
|
|||
|
|
def __init__(self, *args, **kwargs):
|
|||
|
|
super().__init__(*args, **kwargs)
|
|||
|
|
self.day_proxy = {} # date_str → {ri: proxy_b}
|
|||
|
|
self.day_prices = {} # date_str → {ri: {asset: price}}
|
|||
|
|
self._cur_date = None
|
|||
|
|
self._n_trades_before = 0
|
|||
|
|
self.trade_dates = [] # parallel list to trade_history, entry date per trade
|
|||
|
|
|
|||
|
|
def process_day(self, date_str, df, asset_columns,
|
|||
|
|
vol_regime_ok=None, direction=None, posture='APEX'):
|
|||
|
|
self._cur_date = date_str
|
|||
|
|
self.day_proxy[date_str] = {}
|
|||
|
|
self.day_prices[date_str] = {}
|
|||
|
|
self._n_trades_before = len(self.trade_history)
|
|||
|
|
|
|||
|
|
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: return 0.0
|
|||
|
|
|
|||
|
|
v50 = gf('v50_lambda_max_velocity')
|
|||
|
|
v750 = gf('v750_lambda_max_velocity')
|
|||
|
|
inst = gf('instability_50')
|
|||
|
|
pb = inst - v750
|
|||
|
|
self.day_proxy[date_str][ri] = 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)
|
|||
|
|
self.day_prices[date_str][ri] = dict(prices)
|
|||
|
|
|
|||
|
|
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=v50, v750_vel=v750)
|
|||
|
|
bid += 1
|
|||
|
|
|
|||
|
|
result = self.end_day()
|
|||
|
|
|
|||
|
|
# Tag new trades with this date
|
|||
|
|
new_trades = self.trade_history[self._n_trades_before:]
|
|||
|
|
for _ in new_trades:
|
|||
|
|
self.trade_dates.append(date_str)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ── Shadow analysis ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
def shadow_analysis(eng, threshold_pct, window=500):
|
|||
|
|
"""
|
|||
|
|
For each trade, check if proxy_B dropped below rolling threshold
|
|||
|
|
during hold period (same-day bars between entry_bar and exit_bar).
|
|||
|
|
Uses the correct asset price for PnL computation.
|
|||
|
|
"""
|
|||
|
|
tr = eng.trade_history
|
|||
|
|
dates = eng.trade_dates
|
|||
|
|
|
|||
|
|
if len(dates) < len(tr):
|
|||
|
|
# Pad if any trades weren't tagged (shouldn't happen)
|
|||
|
|
dates = dates + [None] * (len(tr) - len(dates))
|
|||
|
|
|
|||
|
|
# Build rolling proxy_B history across all days (chronological)
|
|||
|
|
# We need a global chronological sequence for percentile computation
|
|||
|
|
all_proxy_seq = []
|
|||
|
|
for pf_stem in sorted(eng.day_proxy.keys()):
|
|||
|
|
day_d = eng.day_proxy[pf_stem]
|
|||
|
|
for ri in sorted(day_d.keys()):
|
|||
|
|
all_proxy_seq.append((pf_stem, ri, day_d[ri]))
|
|||
|
|
|
|||
|
|
results = []
|
|||
|
|
proxy_hist = [] # rolling window of ALL bars seen so far
|
|||
|
|
|
|||
|
|
# Build per-day sorted bar sequences for efficient lookup
|
|||
|
|
day_bars = {d: sorted(eng.day_proxy[d].keys()) for d in eng.day_proxy}
|
|||
|
|
|
|||
|
|
# Build lookup: (date, ri) → index in all_proxy_seq (for rolling history)
|
|||
|
|
seq_idx = {(s, r): i for i, (s, r, _) in enumerate(all_proxy_seq)}
|
|||
|
|
|
|||
|
|
for t, date in zip(tr, dates):
|
|||
|
|
if date is None:
|
|||
|
|
results.append(dict(triggered=False, actual_pnl=t.pnl_pct))
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
entry_bar = int(t.entry_bar) if hasattr(t, 'entry_bar') else 0
|
|||
|
|
exit_bar = int(t.exit_bar) if hasattr(t, 'exit_bar') else entry_bar
|
|||
|
|
actual_pnl = float(t.pnl_pct) if hasattr(t, 'pnl_pct') else 0.0
|
|||
|
|
entry_price = float(t.entry_price) if hasattr(t, 'entry_price') and t.entry_price else 0.0
|
|||
|
|
direction = int(t.direction) if hasattr(t, 'direction') else -1
|
|||
|
|
asset = t.asset if hasattr(t, 'asset') else 'BTCUSDT'
|
|||
|
|
|
|||
|
|
# Rolling threshold: use all bars BEFORE entry on this day
|
|||
|
|
eidx = seq_idx.get((date, entry_bar), 0)
|
|||
|
|
hist_window = [pb for (_, _, pb) in all_proxy_seq[max(0, eidx-window):eidx]]
|
|||
|
|
if len(hist_window) < 20:
|
|||
|
|
results.append(dict(triggered=False, actual_pnl=actual_pnl)); continue
|
|||
|
|
threshold = float(np.percentile(hist_window, threshold_pct * 100))
|
|||
|
|
|
|||
|
|
# Find hold bars on the same day
|
|||
|
|
if date not in day_bars:
|
|||
|
|
results.append(dict(triggered=False, actual_pnl=actual_pnl)); continue
|
|||
|
|
hold_bars = [ri for ri in day_bars[date]
|
|||
|
|
if entry_bar < ri <= exit_bar]
|
|||
|
|
|
|||
|
|
triggered_bar = None
|
|||
|
|
for ri in hold_bars:
|
|||
|
|
if eng.day_proxy[date].get(ri, 999) < threshold:
|
|||
|
|
triggered_bar = ri
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if triggered_bar is None:
|
|||
|
|
results.append(dict(triggered=False, actual_pnl=actual_pnl)); continue
|
|||
|
|
|
|||
|
|
# Correct early-exit price: same asset, triggered bar on same day
|
|||
|
|
early_price = eng.day_prices[date].get(triggered_bar, {}).get(asset, 0.0)
|
|||
|
|
if entry_price > 0 and early_price > 0:
|
|||
|
|
early_pnl = direction * (early_price - entry_price) / entry_price
|
|||
|
|
else:
|
|||
|
|
results.append(dict(triggered=False, actual_pnl=actual_pnl)); continue
|
|||
|
|
|
|||
|
|
bars_saved = exit_bar - triggered_bar
|
|||
|
|
results.append(dict(
|
|||
|
|
triggered=True,
|
|||
|
|
date=date, entry_bar=entry_bar, exit_bar=exit_bar,
|
|||
|
|
triggered_bar=triggered_bar, bars_saved=bars_saved,
|
|||
|
|
asset=asset, direction=direction,
|
|||
|
|
entry_price=entry_price, early_price=early_price,
|
|||
|
|
actual_pnl=actual_pnl,
|
|||
|
|
early_exit_pnl=early_pnl,
|
|||
|
|
pnl_delta=early_pnl - actual_pnl,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
triggered = [r for r in results if r['triggered']]
|
|||
|
|
if not triggered:
|
|||
|
|
return dict(n_triggered=0, n_total=len(results), pct_triggered=0,
|
|||
|
|
avg_actual_pnl_pct=0, avg_early_pnl_pct=0, avg_delta_pct=0,
|
|||
|
|
early_better_rate=0, roi_impact_pp=0)
|
|||
|
|
|
|||
|
|
avg_actual = float(np.mean([r['actual_pnl'] for r in triggered]))
|
|||
|
|
avg_early = float(np.mean([r['early_exit_pnl'] for r in triggered]))
|
|||
|
|
avg_delta = float(np.mean([r['pnl_delta'] for r in triggered]))
|
|||
|
|
early_better = float(np.mean([r['pnl_delta'] > 0 for r in triggered]))
|
|||
|
|
avg_bars_saved = float(np.mean([r['bars_saved'] for r in triggered]))
|
|||
|
|
|
|||
|
|
# Estimated ROI impact (sum of pnl deltas × fraction × 100)
|
|||
|
|
roi_impact = float(sum(r['pnl_delta'] for r in triggered) * 0.20 * 100)
|
|||
|
|
|
|||
|
|
# Per-exit-reason breakdown if available
|
|||
|
|
return dict(
|
|||
|
|
n_triggered=len(triggered),
|
|||
|
|
n_total=len(results),
|
|||
|
|
pct_triggered=len(triggered) / max(1, len(results)) * 100,
|
|||
|
|
avg_actual_pnl_pct=avg_actual * 100,
|
|||
|
|
avg_early_exit_pnl_pct=avg_early * 100,
|
|||
|
|
avg_pnl_delta_pct=avg_delta * 100,
|
|||
|
|
early_better_rate=early_better * 100,
|
|||
|
|
avg_bars_saved=avg_bars_saved,
|
|||
|
|
roi_impact_estimate_pp=roi_impact,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ensure_jit()
|
|||
|
|
print("\nLoading data & forewarner...")
|
|||
|
|
d = load_data()
|
|||
|
|
fw = load_forewarner()
|
|||
|
|
|
|||
|
|
from exp_shared import ENGINE_KWARGS, MC_BASE_CFG
|
|||
|
|
import math
|
|||
|
|
|
|||
|
|
print("\nRunning baseline with shadow logging...")
|
|||
|
|
t0 = time.time()
|
|||
|
|
kw = ENGINE_KWARGS.copy()
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
acb.preload_w750(d['date_strings'])
|
|||
|
|
eng = ShadowLoggingEngine(**kw)
|
|||
|
|
eng.set_ob_engine(d['ob_eng'])
|
|||
|
|
eng.set_acb(acb)
|
|||
|
|
if fw: eng.set_mc_forewarner(fw, MC_BASE_CFG)
|
|||
|
|
eng.set_esoteric_hazard_multiplier(0.0)
|
|||
|
|
|
|||
|
|
daily_caps, daily_pnls = [], []
|
|||
|
|
for pf in d['parquet_files']:
|
|||
|
|
ds = pf.stem
|
|||
|
|
df, acols, dvol = d['pq_data'][ds]
|
|||
|
|
cap_before = eng.capital
|
|||
|
|
vol_ok = np.where(np.isfinite(dvol), dvol > d['vol_p60'], False)
|
|||
|
|
eng.process_day(ds, df, acols, vol_regime_ok=vol_ok)
|
|||
|
|
daily_caps.append(eng.capital)
|
|||
|
|
daily_pnls.append(eng.capital - cap_before)
|
|||
|
|
|
|||
|
|
tr = eng.trade_history
|
|||
|
|
print(f" Done in {time.time()-t0:.0f}s Trades={len(tr)} "
|
|||
|
|
f"Tagged={len(eng.trade_dates)}")
|
|||
|
|
|
|||
|
|
# Confirm baseline metrics match gold
|
|||
|
|
def _abs(t): return t.pnl_absolute if hasattr(t,'pnl_absolute') else t.pnl_pct*250.
|
|||
|
|
wins = [t for t in tr if _abs(t) > 0]
|
|||
|
|
pf = sum(_abs(t) for t in wins) / max(abs(sum(_abs(t) for t in [x for x in tr if _abs(x)<=0])),1e-9)
|
|||
|
|
roi = (eng.capital - 25000) / 25000 * 100
|
|||
|
|
print(f" Baseline: ROI={roi:.2f}% PF={pf:.4f} (gold: 88.55% / 1.215)")
|
|||
|
|
|
|||
|
|
THRESHOLDS = [
|
|||
|
|
('T1: exit if proxy_B < p10', 0.10),
|
|||
|
|
('T2: exit if proxy_B < p25', 0.25),
|
|||
|
|
('T3: exit if proxy_B < p50', 0.50),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
all_results = []
|
|||
|
|
for tname, tpct in THRESHOLDS:
|
|||
|
|
print(f"\n{tname}")
|
|||
|
|
res = shadow_analysis(eng, threshold_pct=tpct, window=500)
|
|||
|
|
res['name'] = tname
|
|||
|
|
all_results.append(res)
|
|||
|
|
print(f" Triggered: {res['n_triggered']}/{res['n_total']} "
|
|||
|
|
f"({res['pct_triggered']:.1f}%)")
|
|||
|
|
if res['n_triggered'] > 0:
|
|||
|
|
print(f" Avg actual PnL: {res['avg_actual_pnl_pct']:+.4f}%")
|
|||
|
|
print(f" Avg early-exit PnL: {res['avg_early_exit_pnl_pct']:+.4f}%")
|
|||
|
|
print(f" Avg delta: {res['avg_pnl_delta_pct']:+.4f}% "
|
|||
|
|
f"(+ = early exit BETTER)")
|
|||
|
|
print(f" Early exit better: {res['early_better_rate']:.1f}% of triggered")
|
|||
|
|
print(f" Avg bars saved: {res['avg_bars_saved']:.1f}")
|
|||
|
|
print(f" Est. ROI impact: {res['roi_impact_estimate_pp']:+.2f}pp")
|
|||
|
|
|
|||
|
|
print("\n" + "="*75)
|
|||
|
|
print("EXP 2 — SHADOW EXIT SUMMARY")
|
|||
|
|
print("="*75)
|
|||
|
|
print(f"{'Threshold':<35} {'Trig%':>6} {'AvgDelta%':>11} "
|
|||
|
|
f"{'EarlyBetter%':>13} {'ROI_pp':>8}")
|
|||
|
|
print('-'*75)
|
|||
|
|
for r in all_results:
|
|||
|
|
if r['n_triggered'] > 0:
|
|||
|
|
print(f" {r['name']:<33} {r['pct_triggered']:>6.1f}% "
|
|||
|
|
f"{r['avg_pnl_delta_pct']:>10.4f}% "
|
|||
|
|
f"{r['early_better_rate']:>12.1f}% "
|
|||
|
|
f"{r['roi_impact_estimate_pp']:>8.2f}pp")
|
|||
|
|
else:
|
|||
|
|
print(f" {r['name']:<33} (no triggers)")
|
|||
|
|
|
|||
|
|
verdict = all_results[0] if all_results else {}
|
|||
|
|
if verdict.get('avg_pnl_delta_pct', -1) > 0:
|
|||
|
|
print("\n → VERDICT: Early exit is BENEFICIAL (delta > 0)")
|
|||
|
|
else:
|
|||
|
|
print("\n → VERDICT: Holding to natural exit is BETTER (early exit hurts)")
|
|||
|
|
|
|||
|
|
log_results(all_results, _HERE / 'exp2_proxy_exit_results.json',
|
|||
|
|
meta={'experiment': 'proxy_B exit shadow (corrected)',
|
|||
|
|
'proxy': 'instability_50 - v750_lambda_max_velocity',
|
|||
|
|
'n_trades': len(tr),
|
|||
|
|
'baseline_roi': roi, 'baseline_pf': pf})
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|