"""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.")