Files
DOLPHIN/nautilus_dolphin/posture_backtest_1m.py

363 lines
14 KiB
Python
Raw Normal View History

"""MacroPostureSwitcher Backtest — 1m Klines (5 years)
======================================================
Signal:
SHORT posture: vel_div >= +ENTRY_T SHORT, exit vel_div <= -ENTRY_T (crossover)
LONG posture: vel_div <= -ENTRY_T LONG, exit vel_div >= +ENTRY_T (crossover)
NONE posture: skip day entirely
Posture inputs (no lookahead):
dvol_btc, fng, funding_btc from NPZ, available at day-start
realized_vol PREVIOUS day's intraday vol (lag-1)
btc_day_return PREVIOUS day's BTC return (lag-1)
Outputs: ROI / WR / PF / Max-DD / Sharpe (all logged to CSV + console)
"""
import sys, time, csv, gc, json
sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from pathlib import Path
from datetime import datetime
from collections import defaultdict
import numpy as np
import pandas as pd
from nautilus_dolphin.nautilus.macro_posture_switcher import MacroPostureSwitcher, Posture
VBT_DIR = Path(r"C:\Users\Lenovo\Documents\- DOLPHIN NG HD HCM TSF Predict\vbt_cache_klines")
EIGEN_PATH = Path(r"C:\Users\Lenovo\Documents\- Dolphin NG HD (NG3)\correlation_arb512\eigenvalues")
LOG_DIR = Path(r"C:\Users\Lenovo\Documents\- DOLPHIN NG HD HCM TSF Predict\nautilus_dolphin\run_logs")
ENTRY_T = 0.020
MAX_HOLD = 20 # bars safety cap (20 min on 1m)
YEARS = ['2021', '2022', '2023', '2024', '2025', '2026']
# ── ExF loader (reuse ACB pattern) ────────────────────────────────────────────
EXF_KEYS = ['dvol_btc', 'fng', 'funding_btc', 'taker']
def load_exf(date_str: str) -> dict:
defaults = {'dvol_btc': 50.0, 'fng': 50.0, 'funding_btc': 0.0, 'taker': 1.0}
dp = EIGEN_PATH / date_str
if not dp.exists():
return defaults
files = sorted(dp.glob('scan_*__Indicators.npz'))[:5]
if not files:
return defaults
buckets = defaultdict(list)
for f in files:
try:
d = np.load(f, allow_pickle=True)
if 'api_names' not in d:
continue
names = list(d['api_names'])
vals = d['api_indicators']
for k in EXF_KEYS:
if k in names:
v = float(vals[names.index(k)])
if np.isfinite(v):
buckets[k].append(v)
except Exception:
pass
out = dict(defaults)
for k, vs in buckets.items():
if vs:
out[k] = float(np.median(vs))
return out
# ── Load all parquet dates ─────────────────────────────────────────────────────
parquet_files = sorted(VBT_DIR.glob("*.parquet"))
parquet_files = [p for p in parquet_files if 'catalog' not in str(p)]
total = len(parquet_files)
print(f"Files: {total} | Entry T: ±{ENTRY_T} MaxHold: {MAX_HOLD}b")
# ── Pass 1: compute prev-day realized_vol and btc_return for each date ─────────
print("Pass 1: computing lag-1 realized_vol and btc_return...")
t0 = time.time()
day_rvol = {} # date_str -> rvol
day_btcret = {} # date_str -> btc_return
for pf in parquet_files:
ds = pf.stem
try:
df = pd.read_parquet(pf, columns=['BTCUSDT'])
except Exception:
continue
btc = df['BTCUSDT'].values.astype(np.float64)
btc = btc[np.isfinite(btc) & (btc > 0)]
if len(btc) < 2:
continue
log_r = np.diff(np.log(btc))
day_rvol[ds] = float(np.std(log_r))
day_btcret[ds] = float((btc[-1] - btc[0]) / btc[0])
dates_sorted = sorted(day_rvol.keys())
# Build lag-1 maps
prev_rvol = {d: day_rvol.get(dates_sorted[i-1]) if i > 0 else None for i, d in enumerate(dates_sorted)}
prev_btcret = {d: day_btcret.get(dates_sorted[i-1]) if i > 0 else None for i, d in enumerate(dates_sorted)}
print(f" Pass 1 done: {time.time()-t0:.0f}s")
# ── Pass 2: trade simulation ───────────────────────────────────────────────────
print("Pass 2: posture + crossover trades...")
switcher = MacroPostureSwitcher(enable_long_posture=True)
# Per-trade log
trade_log = [] # list of dicts
# Per-day stats
day_log = []
# Equity curve (cumulative, compounding)
equity = 1.0
equity_curve = [equity]
stats_yr = defaultdict(lambda: {'wins':0,'losses':0,'gw':0.0,'gl':0.0,'n':0,'paused':0})
stats_pos = defaultdict(lambda: {'wins':0,'losses':0,'gw':0.0,'gl':0.0,'n':0})
for i, pf in enumerate(parquet_files):
ds = pf.stem
year = ds[:4]
# ExF for today (day-start, no lookahead)
exf = load_exf(ds)
pr = prev_rvol.get(ds) # prev day rvol (may be None on day 1)
pb = prev_btcret.get(ds) # prev day btc ret
decision = switcher.decide(
dvol_btc = exf['dvol_btc'],
fng = exf['fng'],
funding_btc = exf['funding_btc'],
realized_vol = pr,
btc_day_return = pb,
)
if decision.posture == Posture.NONE:
stats_yr[year]['paused'] += 1
day_log.append({'date': ds, 'year': year, 'posture': 'NONE',
'fear': round(decision.fear_score, 3),
'n_trades': 0, 'day_ret': 0.0, 'equity': round(equity, 6)})
continue
# Load klines
try:
df = pd.read_parquet(pf)
except Exception:
continue
if 'vel_div' not in df.columns or 'BTCUSDT' not in df.columns:
continue
vd = df['vel_div'].values.astype(np.float64)
btc = df['BTCUSDT'].values.astype(np.float64)
vd = np.where(np.isfinite(vd), vd, 0.0)
btc = np.where(np.isfinite(btc) & (btc > 0), btc, np.nan)
n = len(btc)
del df
if n < MAX_HOLD + 5:
del vd, btc
continue
pos = decision.posture
smult = decision.size_mult
# Set signal and crossover conditions based on posture
if pos == Posture.SHORT:
entry_mask = (vd >= ENTRY_T) & np.isfinite(btc)
cross_back = (vd <= -ENTRY_T)
sign = -1 # SHORT return = (ep - xp) / ep
else: # LONG
entry_mask = (vd <= -ENTRY_T) & np.isfinite(btc)
cross_back = (vd >= ENTRY_T)
sign = +1 # LONG return = (xp - ep) / ep
day_trades = []
for t in range(n - MAX_HOLD):
if not entry_mask[t]:
continue
ep = btc[t]
if not np.isfinite(ep) or ep <= 0:
continue
exit_bar = MAX_HOLD
for k in range(1, MAX_HOLD + 1):
tb = t + k
if tb >= n:
exit_bar = k; break
if cross_back[tb]:
exit_bar = k; break
tb = t + exit_bar
if tb >= n:
continue
xp = btc[tb]
if not np.isfinite(xp) or xp <= 0:
continue
raw_ret = sign * (xp - ep) / ep
sized_ret = raw_ret * smult
day_trades.append({'ret': raw_ret, 'sized': sized_ret, 'hold': exit_bar})
del vd, btc, entry_mask, cross_back
# Day stats
n_t = len(day_trades)
if n_t == 0:
day_log.append({'date': ds, 'year': year, 'posture': pos.value,
'fear': round(decision.fear_score, 3),
'n_trades': 0, 'day_ret': 0.0, 'equity': round(equity, 6)})
continue
wins = sum(1 for t in day_trades if t['ret'] >= 0)
losses = n_t - wins
gw = sum(t['ret'] for t in day_trades if t['ret'] >= 0)
gl = sum(abs(t['ret']) for t in day_trades if t['ret'] < 0)
pf_day = gw / gl if gl > 0 else 999.0
day_ret = sum(t['sized'] for t in day_trades)
# Equity compounding: treat sum of sized returns as portfolio return
# Clip to avoid blowup on extreme days
day_ret_clamped = max(-0.5, min(day_ret, 2.0))
equity *= (1 + day_ret_clamped)
equity_curve.append(equity)
# Accumulate stats
s = stats_yr[year]
s['wins'] += wins; s['losses'] += losses
s['gw'] += gw; s['gl'] += gl
s['n'] += n_t
sp = stats_pos[pos.value]
sp['wins'] += wins; sp['losses'] += losses
sp['gw'] += gw; sp['gl'] += gl
sp['n'] += n_t
day_log.append({
'date': ds, 'year': year, 'posture': pos.value,
'fear': round(decision.fear_score, 3),
'dvol': round(exf['dvol_btc'], 1), 'fng': round(exf['fng'], 1),
'prev_rvol': round(pr, 7) if pr else None,
'n_trades': n_t, 'wins': wins, 'losses': losses,
'pf_day': round(pf_day, 4), 'day_ret': round(day_ret, 6),
'equity': round(equity, 6),
})
if (i + 1) % 200 == 0:
gc.collect()
print(f" [{i+1}/{total}] {ds} eq={equity:.4f} {time.time()-t0:.0f}s")
elapsed = time.time() - t0
print(f"\nPass 2 done: {elapsed:.0f}s")
# ── Metrics ────────────────────────────────────────────────────────────────────
ec = np.array(equity_curve)
roi = (ec[-1] - 1.0) * 100
# Max drawdown from equity curve
running_max = np.maximum.accumulate(ec)
dd = (running_max - ec) / running_max
max_dd = float(np.max(dd)) * 100
# Daily returns (day_log)
daily_rets = np.array([d['day_ret'] for d in day_log if d['n_trades'] > 0])
sharpe = float(np.mean(daily_rets) / np.std(daily_rets) * np.sqrt(252)) if len(daily_rets) > 1 and np.std(daily_rets) > 0 else 0.0
# Overall PF / WR
tot_w = sum(s['wins'] for s in stats_yr.values())
tot_l = sum(s['losses'] for s in stats_yr.values())
tot_gw = sum(s['gw'] for s in stats_yr.values())
tot_gl = sum(s['gl'] for s in stats_yr.values())
tot_n = tot_w + tot_l
pf = tot_gw / tot_gl if tot_gl > 0 else 999.0
wr = tot_w / tot_n * 100 if tot_n > 0 else 0.0
paused_days = sum(s['paused'] for s in stats_yr.values())
active_days = sum(1 for d in day_log if d['posture'] != 'NONE')
# ── Console summary ────────────────────────────────────────────────────────────
print(f"\n{'='*80}")
print(f" MacroPostureSwitcher — 1m Klines Backtest")
print(f" Entry: ±{ENTRY_T} MaxHold: {MAX_HOLD}b Runtime: {elapsed:.0f}s")
print(f"{'='*80}")
print(f" ROI: {roi:>+8.2f}%")
print(f" Max DD: {max_dd:>8.2f}%")
print(f" Sharpe: {sharpe:>8.3f} (annualized, daily)")
print(f" PF: {pf:>8.4f}")
print(f" WR: {wr:>8.2f}%")
print(f" N trades: {tot_n:>8,}")
print(f" Active days: {active_days} Paused: {paused_days}")
print(f" Equity final: {ec[-1]:.4f}x")
print(f"\n Per-year breakdown:")
print(f" {'Year':<6} {'N':>7} {'WR%':>6} {'PF':>7} {'Paused':>7}")
print(f" {'-'*45}")
for yr in YEARS:
s = stats_yr[yr]
n = s['wins'] + s['losses']
if n == 0:
print(f" {yr:<6} {'':>7} {'':>6} {'':>7} {s['paused']:>7}")
continue
wr_yr = s['wins'] / n * 100
pf_yr = s['gw'] / s['gl'] if s['gl'] > 0 else 999.0
print(f" {yr:<6} {n:>7,} {wr_yr:>6.2f}% {pf_yr:>7.4f} {s['paused']:>7}")
print(f"\n Per-posture breakdown:")
print(f" {'Posture':<6} {'N':>8} {'WR%':>6} {'PF':>7}")
print(f" {'-'*35}")
for pos, s in sorted(stats_pos.items()):
n = s['wins'] + s['losses']
if n == 0: continue
wr_p = s['wins'] / n * 100
pf_p = s['gw'] / s['gl'] if s['gl'] > 0 else 999.0
print(f" {pos:<6} {n:>8,} {wr_p:>6.2f}% {pf_p:>7.4f}")
# ── Save logs ──────────────────────────────────────────────────────────────────
LOG_DIR.mkdir(exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
# Day log CSV
day_csv = LOG_DIR / f"posture_1m_daily_{ts}.csv"
if day_log:
with open(day_csv, 'w', newline='') as f:
w = csv.DictWriter(f, fieldnames=day_log[0].keys())
w.writeheader(); w.writerows(day_log)
print(f"\n{day_csv}")
# Equity curve
eq_csv = LOG_DIR / f"posture_1m_equity_{ts}.csv"
with open(eq_csv, 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['idx', 'equity']); w.writerows(enumerate(equity_curve))
print(f"{eq_csv}")
# Summary JSON
summary = {
'mode': '1m_klines_posture_backtest',
'ts': ts, 'runtime_s': round(elapsed, 1),
'entry_t': ENTRY_T, 'max_hold': MAX_HOLD,
'roi_pct': round(roi, 4),
'max_dd_pct': round(max_dd, 4),
'sharpe': round(sharpe, 4),
'pf': round(pf, 4),
'wr_pct': round(wr, 3),
'n_trades': int(tot_n),
'active_days': active_days,
'paused_days': int(paused_days),
'equity_final': round(float(ec[-1]), 6),
'per_year': {yr: {
'n': int(stats_yr[yr]['wins'] + stats_yr[yr]['losses']),
'wr': round(stats_yr[yr]['wins'] / max(1, stats_yr[yr]['wins'] + stats_yr[yr]['losses']) * 100, 3),
'pf': round(stats_yr[yr]['gw'] / max(1e-9, stats_yr[yr]['gl']), 4),
'paused': int(stats_yr[yr]['paused']),
} for yr in YEARS},
'per_posture': {pos: {
'n': int(s['wins'] + s['losses']),
'wr': round(s['wins'] / max(1, s['wins'] + s['losses']) * 100, 3),
'pf': round(s['gw'] / max(1e-9, s['gl']), 4),
} for pos, s in stats_pos.items()},
}
sum_json = LOG_DIR / f"posture_1m_summary_{ts}.json"
with open(sum_json, 'w') as f:
json.dump(summary, f, indent=2)
print(f"{sum_json}")
print(f"\n Runtime: {elapsed:.0f}s")