534 lines
26 KiB
Python
534 lines
26 KiB
Python
|
|
"""Regime-Based Exit Sweep — 5y Klines
|
|||
|
|
=======================================
|
|||
|
|
Tests EXHAUSTION + INVALIDATION exit logic ported from non_naive_exit_manager.py.
|
|||
|
|
|
|||
|
|
For SHORT entry (vel_div <= -ENTRY_T):
|
|||
|
|
NORMALIZATION: vel_div still below EXHST_T → hold (move ongoing)
|
|||
|
|
EXHAUSTION: vel_div > EXHST_T → exit (edge used up, divergence unwound)
|
|||
|
|
INVALIDATION: vel_div > +INV_T → exit (signal fully flipped, wrong-way)
|
|||
|
|
TP: price dropped tp_bps from entry → take profit
|
|||
|
|
STOP: price rose stop_bps from entry → cut loss
|
|||
|
|
MAX_HOLD: safety fallback after max_hold bars
|
|||
|
|
|
|||
|
|
For LONG entry (vel_div <= -ENTRY_T, mean-reversion thesis):
|
|||
|
|
NORMALIZATION: vel_div still below EXHST_T → hold
|
|||
|
|
EXHAUSTION: vel_div > EXHST_T → exit (reversion complete)
|
|||
|
|
INVALIDATION: vel_div < -INV_T → exit (divergence deepened, wrong-way)
|
|||
|
|
TP: price rose tp_bps → take profit
|
|||
|
|
STOP: price dropped stop_bps → cut loss
|
|||
|
|
MAX_HOLD: safety fallback
|
|||
|
|
|
|||
|
|
KEY CALIBRATION NOTE (from distribution analysis 2026-03-09):
|
|||
|
|
vel_div on 1m klines: range [-14.5, +11.3], std=0.505, median=+0.013
|
|||
|
|
41% of bars have vel_div < -0.020 (NOT a rare signal!)
|
|||
|
|
After SHORT entry: at lag+1, only 42% still below -0.020; mean snaps to -0.038
|
|||
|
|
Exhaustion thresholds relative to 1m scale must be calibrated here.
|
|||
|
|
|
|||
|
|
SWEEP PARAMETERS:
|
|||
|
|
Entry threshold (ENTRY_T): 0.020 (fixed)
|
|||
|
|
Exhaustion (EXHST_T): [-0.020, -0.010, 0.000, +0.010, +0.020]
|
|||
|
|
→ "exit when vel_div > EXHST_T" (returned from extreme toward center/positive)
|
|||
|
|
Invalidation (INV_T): [0.020, 0.050, 0.100, 0.200] (abs, direction-aware)
|
|||
|
|
Take profit (TP_BPS): [None, 60, 95] (None = only micro exits)
|
|||
|
|
Hard stop (STOP_BPS): [None, 50, 100, 200] (None = no hard stop)
|
|||
|
|
Max hold (MAX_HOLD): [10, 20, 60] (safety fallback bars)
|
|||
|
|
|
|||
|
|
Logs (ALL painstakingly written):
|
|||
|
|
run_logs/regime_exit_summary_YYYYMMDD_HHMMSS.csv — one row per combo
|
|||
|
|
run_logs/regime_exit_byyear_YYYYMMDD_HHMMSS.csv — per combo × year
|
|||
|
|
run_logs/regime_exit_byreason_YYYYMMDD_HHMMSS.csv — per combo × exit reason
|
|||
|
|
run_logs/regime_exit_top50_YYYYMMDD_HHMMSS.txt — human-readable top-50 table
|
|||
|
|
Console: top-30 and representative tables
|
|||
|
|
"""
|
|||
|
|
import sys, time, csv, gc
|
|||
|
|
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 itertools import product
|
|||
|
|
|
|||
|
|
VBT_DIR = Path(r"C:\Users\Lenovo\Documents\- DOLPHIN NG HD HCM TSF Predict\vbt_cache_klines")
|
|||
|
|
LOG_DIR = Path(r"C:\Users\Lenovo\Documents\- DOLPHIN NG HD HCM TSF Predict\nautilus_dolphin\run_logs")
|
|||
|
|
LOG_DIR.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
ENTRY_T = 0.020 # vel_div threshold for both SHORT and LONG entries
|
|||
|
|
|
|||
|
|
# Sweep grid
|
|||
|
|
EXHST_T_LIST = [-0.020, -0.010, 0.000, +0.010, +0.020]
|
|||
|
|
INV_T_LIST = [0.020, 0.050, 0.100, 0.200]
|
|||
|
|
TP_BPS_LIST = [None, 60, 95]
|
|||
|
|
STOP_BPS_LIST = [None, 50, 100, 200]
|
|||
|
|
MAX_HOLD_LIST = [10, 20, 60]
|
|||
|
|
DIRECTIONS = ['S', 'L']
|
|||
|
|
|
|||
|
|
MAX_HOLD_MAX = max(MAX_HOLD_LIST) # precompute windows up to this
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────
|
|||
|
|
# Accumulators — keyed by (direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold, year)
|
|||
|
|
# Each value: dict with per-exit-reason counts/pnl, plus totals
|
|||
|
|
|
|||
|
|
REASON_NAMES = ['TP', 'STOP', 'EXHST', 'INV', 'MAX_HOLD']
|
|||
|
|
|
|||
|
|
# stats[(key)] = {reason: {wins, losses, gw, gl, n_trades, hold_sum}, ...}
|
|||
|
|
# We'll use a flat dict of dicts per key
|
|||
|
|
def make_reason_dict():
|
|||
|
|
return {r: {'n': 0, 'wins': 0, 'gw': 0.0, 'gl': 0.0, 'hold_sum': 0} for r in REASON_NAMES}
|
|||
|
|
|
|||
|
|
stats = defaultdict(make_reason_dict)
|
|||
|
|
ctrl = defaultdict(lambda: {'dn': 0, 'up': 0, 'n': 0}) # (year,)
|
|||
|
|
|
|||
|
|
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}")
|
|||
|
|
print(f"EXHST_T: {EXHST_T_LIST}")
|
|||
|
|
print(f"INV_T: {INV_T_LIST}")
|
|||
|
|
print(f"TP_BPS: {TP_BPS_LIST}")
|
|||
|
|
print(f"STOP_BPS: {STOP_BPS_LIST}")
|
|||
|
|
print(f"MAX_HOLD: {MAX_HOLD_LIST}")
|
|||
|
|
n_combos = (len(EXHST_T_LIST) * len(INV_T_LIST) * len(TP_BPS_LIST) *
|
|||
|
|
len(STOP_BPS_LIST) * len(MAX_HOLD_LIST) * len(DIRECTIONS))
|
|||
|
|
print(f"Total combos: {n_combos}")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
t0 = time.time()
|
|||
|
|
|
|||
|
|
for i_file, pf in enumerate(parquet_files):
|
|||
|
|
ds = pf.stem # YYYY-MM-DD
|
|||
|
|
year = ds[:4]
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
del df
|
|||
|
|
|
|||
|
|
vd = np.where(np.isfinite(vd), vd, 0.0)
|
|||
|
|
btc = np.where(np.isfinite(btc) & (btc > 0), btc, np.nan)
|
|||
|
|
n = len(btc)
|
|||
|
|
if n < MAX_HOLD_MAX + 5:
|
|||
|
|
del vd, btc
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
n_usable = n - MAX_HOLD_MAX
|
|||
|
|
|
|||
|
|
# Control baseline
|
|||
|
|
ck = (year,)
|
|||
|
|
tp95 = 0.0095
|
|||
|
|
for j in range(0, n_usable, 30):
|
|||
|
|
ep = btc[j]
|
|||
|
|
if not np.isfinite(ep) or ep <= 0:
|
|||
|
|
continue
|
|||
|
|
future = btc[j+1:j+1+60]
|
|||
|
|
fmin = np.nanmin(future) if len(future) else np.nan
|
|||
|
|
fmax = np.nanmax(future) if len(future) else np.nan
|
|||
|
|
if np.isfinite(fmin) and np.isfinite(fmax):
|
|||
|
|
ctrl[ck]['dn'] += int((ep - fmin) / ep >= tp95)
|
|||
|
|
ctrl[ck]['up'] += int((fmax - ep) / ep >= tp95)
|
|||
|
|
ctrl[ck]['n'] += 1
|
|||
|
|
|
|||
|
|
# ── Extract trajectories for both directions ──────────────────────────
|
|||
|
|
# For each potential entry bar, store: entry_vd, entry_btc,
|
|||
|
|
# future_vd[MAX_HOLD_MAX], future_btc[MAX_HOLD_MAX]
|
|||
|
|
valid_entry = np.isfinite(btc[:n_usable]) & (btc[:n_usable] > 0)
|
|||
|
|
|
|||
|
|
for direction in DIRECTIONS:
|
|||
|
|
if direction == 'S':
|
|||
|
|
entry_mask = (vd[:n_usable] <= -ENTRY_T) & valid_entry
|
|||
|
|
else: # LONG
|
|||
|
|
entry_mask = (vd[:n_usable] <= -ENTRY_T) & valid_entry # same entry, different exit logic
|
|||
|
|
|
|||
|
|
sig_idx = np.where(entry_mask)[0]
|
|||
|
|
if len(sig_idx) == 0:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
N_sig = len(sig_idx)
|
|||
|
|
entry_vd = vd[sig_idx] # (N_sig,)
|
|||
|
|
entry_btc = btc[sig_idx] # (N_sig,)
|
|||
|
|
|
|||
|
|
# Build future arrays: (N_sig, MAX_HOLD_MAX)
|
|||
|
|
from numpy.lib.stride_tricks import sliding_window_view
|
|||
|
|
# future vel_div: bars [j+1 .. j+MAX_HOLD_MAX]
|
|||
|
|
vd_windows = np.lib.stride_tricks.sliding_window_view(vd, MAX_HOLD_MAX+1)[:n_usable]
|
|||
|
|
btc_windows = np.lib.stride_tricks.sliding_window_view(btc, MAX_HOLD_MAX+1)[:n_usable]
|
|||
|
|
|
|||
|
|
fut_vd = vd_windows[sig_idx, 1:] # (N_sig, MAX_HOLD_MAX)
|
|||
|
|
fut_btc = btc_windows[sig_idx, 1:] # (N_sig, MAX_HOLD_MAX)
|
|||
|
|
del vd_windows, btc_windows
|
|||
|
|
|
|||
|
|
# Price returns from entry (positive = favourable for direction)
|
|||
|
|
ep_col = entry_btc[:, None] # (N_sig, 1)
|
|||
|
|
if direction == 'S':
|
|||
|
|
price_ret = (ep_col - fut_btc) / ep_col # positive = price went DOWN = SHORT win
|
|||
|
|
else:
|
|||
|
|
price_ret = (fut_btc - ep_col) / ep_col # positive = price went UP = LONG win
|
|||
|
|
|
|||
|
|
# Replace NaN btc with 0 price_ret (no movement signal)
|
|||
|
|
price_ret = np.where(np.isfinite(fut_btc), price_ret, 0.0)
|
|||
|
|
|
|||
|
|
# ── Sweep all parameter combos ────────────────────────────────────
|
|||
|
|
for exhst_t, inv_t, tp_bps, stop_bps, max_hold in product(
|
|||
|
|
EXHST_T_LIST, INV_T_LIST, TP_BPS_LIST, STOP_BPS_LIST, MAX_HOLD_LIST):
|
|||
|
|
|
|||
|
|
H = max_hold # actual hold limit
|
|||
|
|
tp_pct = (tp_bps / 10_000.0) if tp_bps is not None else None
|
|||
|
|
stop_pct = (stop_bps / 10_000.0) if stop_bps is not None else None
|
|||
|
|
|
|||
|
|
fvd = fut_vd[:, :H] # (N_sig, H)
|
|||
|
|
fpr = price_ret[:, :H] # (N_sig, H), positive = profit direction
|
|||
|
|
fbt = fut_btc[:, :H] # (N_sig, H) for exit price
|
|||
|
|
|
|||
|
|
BIG = H + 1 # sentinel "never fires"
|
|||
|
|
|
|||
|
|
# ── EXHAUSTION: vel_div > EXHST_T (returned from -ENTRY_T toward center/positive)
|
|||
|
|
# For SHORT: exit when vel_div > exhst_t (no longer extremely negative)
|
|||
|
|
# For LONG: same (reversion complete when vel_div returns above exhst_t)
|
|||
|
|
exhst_mask = fvd > exhst_t
|
|||
|
|
exhst_bar = np.where(exhst_mask.any(1), np.argmax(exhst_mask, 1), BIG)
|
|||
|
|
|
|||
|
|
# ── INVALIDATION
|
|||
|
|
if direction == 'S':
|
|||
|
|
# SHORT: invalidation if vel_div goes strongly positive (flipped)
|
|||
|
|
inv_mask = fvd > +inv_t
|
|||
|
|
else:
|
|||
|
|
# LONG: invalidation if vel_div deepens more negative
|
|||
|
|
inv_mask = fvd < -inv_t
|
|||
|
|
inv_bar = np.where(inv_mask.any(1), np.argmax(inv_mask, 1), BIG)
|
|||
|
|
|
|||
|
|
# ── TP
|
|||
|
|
if tp_pct is not None:
|
|||
|
|
tp_mask = fpr >= tp_pct
|
|||
|
|
tp_bar = np.where(tp_mask.any(1), np.argmax(tp_mask, 1), BIG)
|
|||
|
|
else:
|
|||
|
|
tp_bar = np.full(N_sig, BIG, dtype=np.int32)
|
|||
|
|
|
|||
|
|
# ── STOP
|
|||
|
|
if stop_pct is not None:
|
|||
|
|
stop_mask = fpr <= -stop_pct
|
|||
|
|
st_bar = np.where(stop_mask.any(1), np.argmax(stop_mask, 1), BIG)
|
|||
|
|
else:
|
|||
|
|
st_bar = np.full(N_sig, BIG, dtype=np.int32)
|
|||
|
|
|
|||
|
|
# ── MAX_HOLD fallback: always fires at bar H-1
|
|||
|
|
mh_bar = np.full(N_sig, H - 1, dtype=np.int32)
|
|||
|
|
|
|||
|
|
# ── Find first-firing exit (priority: TP > STOP > EXHST > INV > MAX_HOLD)
|
|||
|
|
all_bars = np.column_stack([tp_bar, st_bar, exhst_bar, inv_bar, mh_bar])
|
|||
|
|
first_reason_idx = np.argmin(all_bars, axis=1) # 0=TP,1=STOP,2=EXHST,3=INV,4=MAX_HOLD
|
|||
|
|
exit_bar = all_bars[np.arange(N_sig), first_reason_idx]
|
|||
|
|
# Clip to valid range
|
|||
|
|
exit_bar = np.clip(exit_bar, 0, H - 1)
|
|||
|
|
|
|||
|
|
# Exit price return for each trade
|
|||
|
|
exit_pnl = fpr[np.arange(N_sig), exit_bar] # positive = profit
|
|||
|
|
|
|||
|
|
# Win = exit_pnl > 0 (for STOP, it's always a loss; for TP, always a win)
|
|||
|
|
won = exit_pnl > 0
|
|||
|
|
|
|||
|
|
key = (direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold, year)
|
|||
|
|
s = stats[key]
|
|||
|
|
for ri, rname in enumerate(REASON_NAMES):
|
|||
|
|
rmask = (first_reason_idx == ri)
|
|||
|
|
if not rmask.any():
|
|||
|
|
continue
|
|||
|
|
n_r = int(rmask.sum())
|
|||
|
|
wins = int(won[rmask].sum())
|
|||
|
|
gw = float(exit_pnl[rmask & won].sum()) if (rmask & won).any() else 0.0
|
|||
|
|
gl = float((-exit_pnl[rmask & ~won]).sum()) if (rmask & ~won).any() else 0.0
|
|||
|
|
hold_sum = int(exit_bar[rmask].sum())
|
|||
|
|
s[rname]['n'] += n_r
|
|||
|
|
s[rname]['wins'] += wins
|
|||
|
|
s[rname]['gw'] += gw
|
|||
|
|
s[rname]['gl'] += gl
|
|||
|
|
s[rname]['hold_sum'] += hold_sum
|
|||
|
|
|
|||
|
|
del fvd, fpr, fbt, exhst_mask, exhst_bar, inv_mask, inv_bar
|
|||
|
|
del tp_bar, st_bar, mh_bar, all_bars, exit_bar, exit_pnl, won
|
|||
|
|
del fut_vd, fut_btc, price_ret, entry_vd, entry_btc, sig_idx
|
|||
|
|
|
|||
|
|
del vd, btc
|
|||
|
|
|
|||
|
|
if (i_file + 1) % 100 == 0:
|
|||
|
|
gc.collect()
|
|||
|
|
elapsed = time.time() - t0
|
|||
|
|
print(f" [{i_file+1}/{total}] {ds} {elapsed:.0f}s eta={elapsed/(i_file+1)*(total-i_file-1):.0f}s")
|
|||
|
|
|
|||
|
|
elapsed = time.time() - t0
|
|||
|
|
print(f"\nPass complete: {elapsed:.0f}s")
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# AGGREGATE RESULTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
YEARS = ['2021', '2022', '2023', '2024', '2025', '2026']
|
|||
|
|
|
|||
|
|
def aggregate_key(direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold, years=None):
|
|||
|
|
"""Sum stats across years (or a subset) for a given param combo."""
|
|||
|
|
total = {r: {'n': 0, 'wins': 0, 'gw': 0.0, 'gl': 0.0, 'hold_sum': 0} for r in REASON_NAMES}
|
|||
|
|
for yr in (years or YEARS):
|
|||
|
|
k = (direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold, yr)
|
|||
|
|
s = stats.get(k)
|
|||
|
|
if s is None:
|
|||
|
|
continue
|
|||
|
|
for r in REASON_NAMES:
|
|||
|
|
for field in ['n', 'wins', 'gw', 'gl', 'hold_sum']:
|
|||
|
|
total[r][field] += s[r][field]
|
|||
|
|
return total
|
|||
|
|
|
|||
|
|
def summary_metrics(agg):
|
|||
|
|
"""Compute overall PF, WR, n_trades, avg_hold from aggregated reason dict."""
|
|||
|
|
n_tot = sum(agg[r]['n'] for r in REASON_NAMES)
|
|||
|
|
wins = sum(agg[r]['wins'] for r in REASON_NAMES)
|
|||
|
|
gw = sum(agg[r]['gw'] for r in REASON_NAMES)
|
|||
|
|
gl = sum(agg[r]['gl'] for r in REASON_NAMES)
|
|||
|
|
h_sum = sum(agg[r]['hold_sum'] for r in REASON_NAMES)
|
|||
|
|
wr = wins / n_tot * 100 if n_tot else float('nan')
|
|||
|
|
pf = gw / gl if gl > 0 else (999.0 if gw > 0 else float('nan'))
|
|||
|
|
avg_h = h_sum / n_tot if n_tot else float('nan')
|
|||
|
|
return {'n': n_tot, 'wins': wins, 'wr': wr, 'pf': pf, 'gw': gw, 'gl': gl, 'avg_hold': avg_h}
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
# Build summary rows (one per param combo × direction)
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
print("\nBuilding summary rows...")
|
|||
|
|
summary_rows = []
|
|||
|
|
for direction in DIRECTIONS:
|
|||
|
|
for exhst_t, inv_t, tp_bps, stop_bps, max_hold in product(
|
|||
|
|
EXHST_T_LIST, INV_T_LIST, TP_BPS_LIST, STOP_BPS_LIST, MAX_HOLD_LIST):
|
|||
|
|
agg = aggregate_key(direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold)
|
|||
|
|
m = summary_metrics(agg)
|
|||
|
|
# Per-reason breakdown
|
|||
|
|
reason_stats = {}
|
|||
|
|
for r in REASON_NAMES:
|
|||
|
|
rn = agg[r]['n']
|
|||
|
|
reason_stats[f'n_{r}'] = rn
|
|||
|
|
reason_stats[f'pct_{r}'] = round(rn / m['n'] * 100, 1) if m['n'] else float('nan')
|
|||
|
|
row = {
|
|||
|
|
'direction': direction,
|
|||
|
|
'exhst_t': exhst_t,
|
|||
|
|
'inv_t': inv_t,
|
|||
|
|
'tp_bps': tp_bps if tp_bps is not None else 'none',
|
|||
|
|
'stop_bps': stop_bps if stop_bps is not None else 'none',
|
|||
|
|
'max_hold': max_hold,
|
|||
|
|
'n_trades': m['n'],
|
|||
|
|
'wr': round(m['wr'], 3),
|
|||
|
|
'pf': round(m['pf'], 4),
|
|||
|
|
'gw': round(m['gw'], 2),
|
|||
|
|
'gl': round(m['gl'], 2),
|
|||
|
|
'avg_hold': round(m['avg_hold'], 2),
|
|||
|
|
**reason_stats,
|
|||
|
|
}
|
|||
|
|
summary_rows.append(row)
|
|||
|
|
|
|||
|
|
# Sort by PF descending
|
|||
|
|
summary_rows.sort(key=lambda r: -r['pf'])
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
# Build per-year rows
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
print("Building per-year rows...")
|
|||
|
|
year_rows = []
|
|||
|
|
for direction in DIRECTIONS:
|
|||
|
|
for exhst_t, inv_t, tp_bps, stop_bps, max_hold in product(
|
|||
|
|
EXHST_T_LIST, INV_T_LIST, TP_BPS_LIST, STOP_BPS_LIST, MAX_HOLD_LIST):
|
|||
|
|
for yr in YEARS:
|
|||
|
|
agg = aggregate_key(direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold, [yr])
|
|||
|
|
m = summary_metrics(agg)
|
|||
|
|
# Control baseline
|
|||
|
|
ck = (yr,)
|
|||
|
|
c = ctrl.get(ck, {'dn': 0, 'up': 0, 'n': 1})
|
|||
|
|
bl = (c['dn'] / c['n'] * 100) if direction == 'S' else (c['up'] / c['n'] * 100)
|
|||
|
|
edge = m['wr'] - bl if not np.isnan(m['wr']) else float('nan')
|
|||
|
|
year_rows.append({
|
|||
|
|
'direction': direction,
|
|||
|
|
'exhst_t': exhst_t,
|
|||
|
|
'inv_t': inv_t,
|
|||
|
|
'tp_bps': tp_bps if tp_bps is not None else 'none',
|
|||
|
|
'stop_bps': stop_bps if stop_bps is not None else 'none',
|
|||
|
|
'max_hold': max_hold,
|
|||
|
|
'year': yr,
|
|||
|
|
'n_trades': m['n'],
|
|||
|
|
'wr': round(m['wr'], 3),
|
|||
|
|
'pf': round(m['pf'], 4),
|
|||
|
|
'edge_pp': round(edge, 3),
|
|||
|
|
'avg_hold': round(m['avg_hold'], 2),
|
|||
|
|
'ctrl_bl': round(bl, 3),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
# Build per-reason rows
|
|||
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|||
|
|
print("Building per-reason rows...")
|
|||
|
|
reason_rows = []
|
|||
|
|
for direction in DIRECTIONS:
|
|||
|
|
for exhst_t, inv_t, tp_bps, stop_bps, max_hold in product(
|
|||
|
|
EXHST_T_LIST, INV_T_LIST, TP_BPS_LIST, STOP_BPS_LIST, MAX_HOLD_LIST):
|
|||
|
|
agg = aggregate_key(direction, exhst_t, inv_t, tp_bps, stop_bps, max_hold)
|
|||
|
|
n_tot = sum(agg[r]['n'] for r in REASON_NAMES)
|
|||
|
|
for rname in REASON_NAMES:
|
|||
|
|
rv = agg[rname]
|
|||
|
|
rn = rv['n']
|
|||
|
|
if rn == 0:
|
|||
|
|
continue
|
|||
|
|
rwr = rv['wins'] / rn * 100
|
|||
|
|
rpf = rv['gw'] / rv['gl'] if rv['gl'] > 0 else (999.0 if rv['gw'] > 0 else float('nan'))
|
|||
|
|
reason_rows.append({
|
|||
|
|
'direction': direction,
|
|||
|
|
'exhst_t': exhst_t,
|
|||
|
|
'inv_t': inv_t,
|
|||
|
|
'tp_bps': tp_bps if tp_bps is not None else 'none',
|
|||
|
|
'stop_bps': stop_bps if stop_bps is not None else 'none',
|
|||
|
|
'max_hold': max_hold,
|
|||
|
|
'exit_reason': rname,
|
|||
|
|
'n': rn,
|
|||
|
|
'pct_of_total': round(rn / n_tot * 100, 1) if n_tot else float('nan'),
|
|||
|
|
'wins': rv['wins'],
|
|||
|
|
'wr': round(rwr, 3),
|
|||
|
|
'pf': round(rpf, 4),
|
|||
|
|
'avg_hold': round(rv['hold_sum'] / rn, 2) if rn > 0 else float('nan'),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# SAVE CSVs
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|||
|
|
|
|||
|
|
def save_csv(rows, name):
|
|||
|
|
if not rows:
|
|||
|
|
return
|
|||
|
|
path = LOG_DIR / f"regime_exit_{name}_{ts}.csv"
|
|||
|
|
with open(path, 'w', newline='', encoding='utf-8') as f:
|
|||
|
|
w = csv.DictWriter(f, fieldnames=rows[0].keys())
|
|||
|
|
w.writeheader()
|
|||
|
|
w.writerows(rows)
|
|||
|
|
print(f" → {path} ({len(rows)} rows)")
|
|||
|
|
return path
|
|||
|
|
|
|||
|
|
print("\nSaving CSVs...")
|
|||
|
|
save_csv(summary_rows, 'summary')
|
|||
|
|
save_csv(year_rows, 'byyear')
|
|||
|
|
save_csv(reason_rows, 'byreason')
|
|||
|
|
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
# CONSOLE OUTPUT — TOP RESULTS
|
|||
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
def fmt_pf(pf):
|
|||
|
|
if np.isnan(pf):
|
|||
|
|
return ' nan'
|
|||
|
|
elif pf >= 999:
|
|||
|
|
return ' inf '
|
|||
|
|
else:
|
|||
|
|
marker = ' ***' if pf > 1.0 else (' **' if pf > 0.8 else (' *' if pf > 0.6 else ''))
|
|||
|
|
return f'{pf:6.3f}{marker}'
|
|||
|
|
|
|||
|
|
print(f"\n{'='*110}")
|
|||
|
|
print(f" TOP 50 RESULTS BY PROFIT FACTOR")
|
|||
|
|
print(f" (*** = PF>1.0 = RAW PROFITABLE | ** = PF>0.8 | * = PF>0.6)")
|
|||
|
|
print(f"{'='*110}")
|
|||
|
|
hdr = (f" {'Dir':3} {'ExhT':>7} {'InvT':>6} {'TP':>6} {'Stop':>5} {'MH':>4} | "
|
|||
|
|
f"{'N':>8} {'WR%':>6} {'PF':>10} {'AvgHold':>8} | "
|
|||
|
|
f"{'%TP':>5} {'%ST':>5} {'%EX':>5} {'%IN':>5} {'%MH':>5}")
|
|||
|
|
print(hdr)
|
|||
|
|
print(f" {'-'*108}")
|
|||
|
|
for row in summary_rows[:50]:
|
|||
|
|
print(f" {row['direction']:3} {row['exhst_t']:>+7.3f} {row['inv_t']:>6.3f} "
|
|||
|
|
f"{str(row['tp_bps']):>6} {str(row['stop_bps']):>5} {row['max_hold']:>4} | "
|
|||
|
|
f"{row['n_trades']:>8,} {row['wr']:>6.1f}% {fmt_pf(row['pf']):>10} {row['avg_hold']:>8.1f} | "
|
|||
|
|
f"{row.get('pct_TP',0):>5.1f} {row.get('pct_STOP',0):>5.1f} "
|
|||
|
|
f"{row.get('pct_EXHST',0):>5.1f} {row.get('pct_INV',0):>5.1f} {row.get('pct_MAX_HOLD',0):>5.1f}")
|
|||
|
|
|
|||
|
|
# Per-direction best 10
|
|||
|
|
for direction in DIRECTIONS:
|
|||
|
|
d_rows = [r for r in summary_rows if r['direction'] == direction][:10]
|
|||
|
|
print(f"\n{'='*90}")
|
|||
|
|
print(f" BEST 10 — DIRECTION={direction}")
|
|||
|
|
print(f"{'='*90}")
|
|||
|
|
print(hdr)
|
|||
|
|
print(f" {'-'*88}")
|
|||
|
|
for row in d_rows:
|
|||
|
|
print(f" {row['direction']:3} {row['exhst_t']:>+7.3f} {row['inv_t']:>6.3f} "
|
|||
|
|
f"{str(row['tp_bps']):>6} {str(row['stop_bps']):>5} {row['max_hold']:>4} | "
|
|||
|
|
f"{row['n_trades']:>8,} {row['wr']:>6.1f}% {fmt_pf(row['pf']):>10} {row['avg_hold']:>8.1f} | "
|
|||
|
|
f"{row.get('pct_TP',0):>5.1f} {row.get('pct_STOP',0):>5.1f} "
|
|||
|
|
f"{row.get('pct_EXHST',0):>5.1f} {row.get('pct_INV',0):>5.1f} {row.get('pct_MAX_HOLD',0):>5.1f}")
|
|||
|
|
|
|||
|
|
# Per-year breakdown of top-5 overall
|
|||
|
|
print(f"\n{'='*90}")
|
|||
|
|
print(f" PER-YEAR BREAKDOWN OF TOP-10 COMBOS")
|
|||
|
|
print(f"{'='*90}")
|
|||
|
|
top10_keys = [(r['direction'], r['exhst_t'], r['inv_t'], r['tp_bps'], r['stop_bps'], r['max_hold'])
|
|||
|
|
for r in summary_rows[:10]]
|
|||
|
|
for dk in top10_keys:
|
|||
|
|
d, e, inv, tp, st, mh = dk
|
|||
|
|
print(f"\n {d} exhst={e:+.3f} inv={inv:.3f} tp={tp} stop={st} mh={mh}")
|
|||
|
|
print(f" {'Year':<6} {'N':>7} {'WR%':>7} {'PF':>8} {'Edge':>8} {'AvgHold':>8}")
|
|||
|
|
print(f" {'-'*55}")
|
|||
|
|
for yr in YEARS:
|
|||
|
|
yr_rows = [r for r in year_rows
|
|||
|
|
if r['direction']==d and r['exhst_t']==e and r['inv_t']==inv
|
|||
|
|
and str(r['tp_bps'])==str(tp) and str(r['stop_bps'])==str(st)
|
|||
|
|
and r['max_hold']==mh and r['year']==yr]
|
|||
|
|
if yr_rows:
|
|||
|
|
yr_r = yr_rows[0]
|
|||
|
|
print(f" {yr:<6} {yr_r['n_trades']:>7,} {yr_r['wr']:>7.1f}% {yr_r['pf']:>8.3f} "
|
|||
|
|
f"{yr_r['edge_pp']:>+8.2f}pp {yr_r['avg_hold']:>8.1f}b")
|
|||
|
|
|
|||
|
|
# Comparison table: regime exit vs dumb MAX_HOLD
|
|||
|
|
print(f"\n{'='*90}")
|
|||
|
|
print(f" REGIME EXIT vs DUMB TIMER — KEY COMPARISON")
|
|||
|
|
print(f" (tp=95bps, stop=None, entry=±{ENTRY_T})")
|
|||
|
|
print(f"{'='*90}")
|
|||
|
|
print(f" {'Config':<35} {'N':>8} {'WR%':>7} {'PF':>8} {'AvgHold':>8}")
|
|||
|
|
print(f" {'-'*70}")
|
|||
|
|
for direction in DIRECTIONS:
|
|||
|
|
for max_hold in [10, 20, 60]:
|
|||
|
|
# Dumb timer (exhst disabled = exhst_t very high, inv disabled = inv_t very high)
|
|||
|
|
dumb_key = (direction, +0.020, 0.200, 95, None, max_hold) # exhaustion fires after TP only
|
|||
|
|
dumb_rows = [r for r in summary_rows if
|
|||
|
|
r['direction']==direction and r['exhst_t']==0.020 and r['inv_t']==0.200
|
|||
|
|
and str(r['tp_bps'])=='95' and str(r['stop_bps'])=='none' and r['max_hold']==max_hold]
|
|||
|
|
# Best regime exit with tp=95
|
|||
|
|
best_regime = [r for r in summary_rows if
|
|||
|
|
r['direction']==direction and str(r['tp_bps'])=='95'
|
|||
|
|
and str(r['stop_bps'])=='none' and r['max_hold']==max_hold]
|
|||
|
|
if dumb_rows:
|
|||
|
|
dr = dumb_rows[0]
|
|||
|
|
print(f" {direction} DUMB mh={max_hold:>3}b tp=95 stop=none{'':<5} "
|
|||
|
|
f"{dr['n_trades']:>8,} {dr['wr']:>7.1f}% {fmt_pf(dr['pf']):>8} {dr['avg_hold']:>8.1f}b")
|
|||
|
|
if best_regime:
|
|||
|
|
br = best_regime[0]
|
|||
|
|
label = f"exhst={br['exhst_t']:+.3f} inv={br['inv_t']:.3f}"
|
|||
|
|
print(f" {direction} BEST mh={max_hold:>3}b tp=95 stop=none {label:<15} "
|
|||
|
|
f"{br['n_trades']:>8,} {br['wr']:>7.1f}% {fmt_pf(br['pf']):>8} {br['avg_hold']:>8.1f}b")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
# Save top-50 human-readable text file
|
|||
|
|
top50_path = LOG_DIR / f"regime_exit_top50_{ts}.txt"
|
|||
|
|
with open(top50_path, 'w', encoding='utf-8') as f:
|
|||
|
|
f.write(f"REGIME EXIT SWEEP — TOP 50\n")
|
|||
|
|
f.write(f"Generated: {ts}\n")
|
|||
|
|
f.write(f"Entry threshold: ±{ENTRY_T} (both directions: SHORT and LONG at vel_div<=-{ENTRY_T})\n\n")
|
|||
|
|
f.write(hdr + "\n")
|
|||
|
|
f.write(f" {'-'*108}\n")
|
|||
|
|
for row in summary_rows[:50]:
|
|||
|
|
f.write(f" {row['direction']:3} {row['exhst_t']:>+7.3f} {row['inv_t']:>6.3f} "
|
|||
|
|
f"{str(row['tp_bps']):>6} {str(row['stop_bps']):>5} {row['max_hold']:>4} | "
|
|||
|
|
f"{row['n_trades']:>8,} {row['wr']:>6.1f}% {fmt_pf(row['pf']):>10} {row['avg_hold']:>8.1f} | "
|
|||
|
|
f"{row.get('pct_TP',0):>5.1f} {row.get('pct_STOP',0):>5.1f} "
|
|||
|
|
f"{row.get('pct_EXHST',0):>5.1f} {row.get('pct_INV',0):>5.1f} {row.get('pct_MAX_HOLD',0):>5.1f}\n")
|
|||
|
|
f.write(f"\nRuntime: {elapsed:.0f}s Files: {total}\n")
|
|||
|
|
|
|||
|
|
print(f"\n → {top50_path}")
|
|||
|
|
print(f"\nTotal runtime: {elapsed:.0f}s")
|
|||
|
|
print(f"\nKEY: Look for PF > 1.0 — that's the profitable regime-exit configuration.")
|
|||
|
|
print(f" Compare avg_hold vs MAX_HOLD to see how much early the regime exits fire.")
|