Includes core prod + GREEN/BLUE subsystems: - prod/ (BLUE harness, configs, scripts, docs) - nautilus_dolphin/ (GREEN Nautilus-native impl + dvae/ preserved) - adaptive_exit/ (AEM engine + models/bucket_assignments.pkl) - Observability/ (EsoF advisor, TUI, dashboards) - external_factors/ (EsoF producer) - mc_forewarning_qlabs_fork/ (MC regime/envelope) Excludes runtime caches, logs, backups, and reproducible artifacts per .gitignore.
285 lines
11 KiB
Python
Executable File
285 lines
11 KiB
Python
Executable File
"""Hour-Gated LONG Crossover — 5y Klines
|
|
========================================
|
|
Option 1: Full hour-gated LONG test.
|
|
|
|
Signal: vel_div <= -ENTRY_T → LONG
|
|
Exit: vel_div >= +ENTRY_T (mean-reversion complete) OR
|
|
MAX_HOLD bars reached (safety cap, 20 bars)
|
|
|
|
Gate variants tested:
|
|
- UNGATED (all 24 hours)
|
|
- BEST3 (hours 9, 12, 18 UTC — London afternoon + US open)
|
|
- BEST5 (hours 8, 9, 12, 13, 18 UTC)
|
|
- WORST5 (complement — worst 5 hours for reference)
|
|
- Each individual hour 0..23
|
|
|
|
Per-year PF breakdown to confirm consistency.
|
|
|
|
Output:
|
|
run_logs/hour_gated_long_YYYYMMDD_HHMMSS.csv
|
|
run_logs/hour_gated_long_top_YYYYMMDD_HHMMSS.txt
|
|
Runtime: ~15s
|
|
"""
|
|
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
|
|
|
|
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")
|
|
|
|
ENTRY_T = 0.020 # vel_div <= -ENTRY_T → LONG; exit vel_div >= +ENTRY_T
|
|
MAX_HOLD = 20 # safety cap in bars (20 bars = 20 min on 1m klines)
|
|
|
|
YEARS = ['2021', '2022', '2023', '2024', '2025', '2026']
|
|
|
|
# Gate definitions: name → set of allowed UTC hours (None = all hours)
|
|
GATES = {
|
|
'UNGATED': None,
|
|
'BEST3': {9, 12, 18},
|
|
'BEST5': {8, 9, 12, 13, 18},
|
|
'WORST5': {1, 2, 3, 4, 5},
|
|
'US_SESS': {13, 14, 15, 16, 17, 18, 19, 20, 21},
|
|
'EU_SESS': {7, 8, 9, 10, 11, 12},
|
|
'ASIA_SESS': {0, 1, 2, 3, 4, 5, 6, 7},
|
|
}
|
|
# Add individual hours
|
|
for h in range(24):
|
|
GATES[f'H{h:02d}'] = {h}
|
|
|
|
# stats[(gate, year)] = {wins, losses, gw, gl, n_trades, total_hold}
|
|
stats = defaultdict(lambda: {'wins': 0, 'losses': 0, 'gw': 0.0, 'gl': 0.0, 'n': 0, 'total_hold': 0})
|
|
|
|
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}")
|
|
print(f"Entry T: vel_div <= -{ENTRY_T} Exit: vel_div >= +{ENTRY_T}")
|
|
print(f"MaxHold: {MAX_HOLD} bars")
|
|
print(f"Gates: {len(GATES)}")
|
|
print()
|
|
|
|
TP_PCT = 0.0095 # 95 bps TP (directional cap for crossover)
|
|
# Note: crossover exits by signal return; TP here acts as hard cap to avoid runaway losers
|
|
# Set TP_PCT = None to disable hard TP
|
|
|
|
t0 = time.time()
|
|
|
|
for i, pf in enumerate(parquet_files):
|
|
ds = pf.stem
|
|
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 or 'timestamp' not in df.columns:
|
|
continue
|
|
|
|
vd = df['vel_div'].values.astype(np.float64)
|
|
btc = df['BTCUSDT'].values.astype(np.float64)
|
|
hrs = df['timestamp'].dt.hour.values.astype(np.int8)
|
|
|
|
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, hrs
|
|
continue
|
|
|
|
# Build trades: iterate entry bars
|
|
# Entry: vel_div[t] <= -ENTRY_T
|
|
# Exit: first bar t+1..t+MAX_HOLD where vel_div >= +ENTRY_T, else t+MAX_HOLD
|
|
entry_mask = (vd <= -ENTRY_T) & np.isfinite(btc)
|
|
|
|
# Precompute exit bars vectorised
|
|
# For each entry bar i, scan forward up to MAX_HOLD bars for crossover
|
|
# Use a vectorised approach: build a boolean array of "crossover" events
|
|
cross_back = (vd >= ENTRY_T) # True where vel_div has returned to +ENTRY_T
|
|
|
|
# We'll build trade outcomes as lists per gate
|
|
# trade: (hour_of_entry, year, ret, hold_bars)
|
|
trades = [] # list of (entry_hour, ret, hold_bars)
|
|
|
|
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
|
|
h = int(hrs[t])
|
|
|
|
# Find exit bar
|
|
exit_bar = MAX_HOLD # default: max hold
|
|
for k in range(1, MAX_HOLD + 1):
|
|
tb = t + k
|
|
if tb >= n:
|
|
exit_bar = k
|
|
break
|
|
# Crossover exit
|
|
if cross_back[tb]:
|
|
exit_bar = k
|
|
break
|
|
|
|
xp = btc[t + exit_bar] if (t + exit_bar) < n else np.nan
|
|
if not np.isfinite(xp) or xp <= 0:
|
|
continue
|
|
|
|
ret = (xp - ep) / ep # LONG return
|
|
trades.append((h, ret, exit_bar))
|
|
|
|
if not trades:
|
|
del vd, btc, hrs, cross_back, entry_mask
|
|
continue
|
|
|
|
hours_arr = np.array([tr[0] for tr in trades], dtype=np.int8)
|
|
rets_arr = np.array([tr[1] for tr in trades], dtype=np.float64)
|
|
holds_arr = np.array([tr[2] for tr in trades], dtype=np.int16)
|
|
|
|
for gate_name, hour_set in GATES.items():
|
|
if hour_set is None:
|
|
mask = np.ones(len(trades), dtype=bool)
|
|
else:
|
|
mask = np.isin(hours_arr, list(hour_set))
|
|
|
|
if not np.any(mask):
|
|
continue
|
|
|
|
r = rets_arr[mask]
|
|
h_bars = holds_arr[mask]
|
|
|
|
# TP cap: if ret > TP_PCT, cap to TP_PCT (trade hit TP before crossover)
|
|
# This is directional cap — in crossover mode usually not hit
|
|
if TP_PCT is not None:
|
|
r = np.clip(r, -np.inf, TP_PCT)
|
|
|
|
wins_mask = r >= 0
|
|
losses_mask = r < 0
|
|
|
|
w = int(np.sum(wins_mask))
|
|
l = int(np.sum(losses_mask))
|
|
gw = float(np.sum(r[wins_mask]))
|
|
gl = float(np.sum(np.abs(r[losses_mask])))
|
|
|
|
s = stats[(gate_name, year)]
|
|
s['wins'] += w
|
|
s['losses'] += l
|
|
s['gw'] += gw
|
|
s['gl'] += gl
|
|
s['n'] += w + l
|
|
s['total_hold'] += int(np.sum(h_bars))
|
|
|
|
del vd, btc, hrs, cross_back, entry_mask, trades
|
|
del hours_arr, rets_arr, holds_arr
|
|
|
|
if (i + 1) % 200 == 0:
|
|
gc.collect()
|
|
elapsed = time.time() - t0
|
|
print(f" [{i+1}/{total}] {ds} {elapsed:.0f}s")
|
|
|
|
elapsed = time.time() - t0
|
|
print(f"\nPass complete: {elapsed:.0f}s\n")
|
|
|
|
# ─── Build results table ───────────────────────────────────────────────────────
|
|
rows = []
|
|
for gate_name, hour_set in GATES.items():
|
|
yr_pfs = {}
|
|
tot_w = tot_l = 0; tot_gw = tot_gl = 0; tot_n = 0; tot_hold = 0
|
|
for yr in YEARS:
|
|
s = stats.get((gate_name, yr), {'wins': 0, 'losses': 0, 'gw': 0.0, 'gl': 0.0, 'n': 0, 'total_hold': 0})
|
|
yr_pfs[yr] = s
|
|
tot_w += s['wins']; tot_l += s['losses']
|
|
tot_gw += s['gw']; tot_gl += s['gl']
|
|
tot_n += s['n']; tot_hold += s['total_hold']
|
|
|
|
if tot_n == 0:
|
|
continue
|
|
|
|
pf = tot_gw / tot_gl if tot_gl > 0 else (999.0 if tot_gw > 0 else float('nan'))
|
|
wr = tot_w / tot_n * 100 if tot_n > 0 else 0.0
|
|
avg_hold = tot_hold / tot_n if tot_n > 0 else 0.0
|
|
n_hrs = len(hour_set) if hour_set else 24
|
|
|
|
row = {
|
|
'gate': gate_name,
|
|
'n_hours': n_hrs,
|
|
'n_trades': tot_n,
|
|
'pf': round(pf, 4),
|
|
'wr': round(wr, 3),
|
|
'avg_hold_bars': round(avg_hold, 2),
|
|
'gross_win': round(tot_gw, 4),
|
|
'gross_loss': round(tot_gl, 4),
|
|
}
|
|
for yr in YEARS:
|
|
s = yr_pfs[yr]
|
|
yn = s['wins'] + s['losses']
|
|
ypf = s['gw'] / s['gl'] if s['gl'] > 0 else (999.0 if s['gw'] > 0 else float('nan'))
|
|
row[f'pf_{yr}'] = round(ypf, 4) if yn > 0 else float('nan')
|
|
row[f'n_{yr}'] = yn
|
|
rows.append(row)
|
|
|
|
# Sort by PF descending
|
|
rows.sort(key=lambda r: r['pf'], reverse=True)
|
|
|
|
# ─── Console output ────────────────────────────────────────────────────────────
|
|
NAMED_GATES = ['UNGATED', 'BEST3', 'BEST5', 'WORST5', 'US_SESS', 'EU_SESS', 'ASIA_SESS']
|
|
|
|
print(f"{'Gate':<14} {'Hrs':>3} {'N':>10} {'PF':>7} {'WR%':>6} {'AvgH':>5} "
|
|
+ " ".join(yr for yr in YEARS))
|
|
print("-" * 100)
|
|
|
|
for row in rows:
|
|
if row['gate'] not in NAMED_GATES and not row['gate'].startswith('H'):
|
|
continue
|
|
yr_str = " ".join(f"{row.get(f'pf_{yr}', float('nan')):>7.3f}" for yr in YEARS)
|
|
print(f"{row['gate']:<14} {row['n_hours']:>3} {row['n_trades']:>10,} "
|
|
f"{row['pf']:>7.4f} {row['wr']:>6.2f}% {row['avg_hold_bars']:>5.2f} {yr_str}")
|
|
|
|
print()
|
|
print("─── Individual hours (sorted by PF) ───")
|
|
h_rows = [r for r in rows if r['gate'].startswith('H')]
|
|
h_rows.sort(key=lambda r: r['pf'], reverse=True)
|
|
print(f"{'Hour':>5} {'N':>8} {'PF':>7} {'WR%':>6} {'AvgH':>5} "
|
|
+ " ".join(yr for yr in YEARS))
|
|
print("-" * 95)
|
|
for row in h_rows:
|
|
yr_str = " ".join(f"{row.get(f'pf_{yr}', float('nan')):>7.3f}" for yr in YEARS)
|
|
h_num = int(row['gate'][1:])
|
|
print(f" {h_num:>3}h {row['n_trades']:>8,} {row['pf']:>7.4f} {row['wr']:>6.2f}% "
|
|
f"{row['avg_hold_bars']:>5.2f} {yr_str}")
|
|
|
|
# ─── Save CSV ──────────────────────────────────────────────────────────────────
|
|
LOG_DIR.mkdir(exist_ok=True)
|
|
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
out_csv = LOG_DIR / f"hour_gated_long_{ts_str}.csv"
|
|
if rows:
|
|
with open(out_csv, 'w', newline='') as f:
|
|
w = csv.DictWriter(f, fieldnames=rows[0].keys())
|
|
w.writeheader(); w.writerows(rows)
|
|
print(f"\n → {out_csv}")
|
|
|
|
# Top summary txt
|
|
out_txt = LOG_DIR / f"hour_gated_long_top_{ts_str}.txt"
|
|
with open(out_txt, 'w', encoding='utf-8') as f:
|
|
f.write(f"Hour-Gated LONG Crossover — 5y Klines\n")
|
|
f.write(f"Entry: vel_div <= -{ENTRY_T} Exit: vel_div >= +{ENTRY_T} MaxHold: {MAX_HOLD}b\n")
|
|
f.write(f"Runtime: {elapsed:.0f}s\n\n")
|
|
f.write(f"{'Gate':<14} {'Hrs':>3} {'N':>10} {'PF':>7} {'WR%':>6} {'AvgH':>5} "
|
|
+ " ".join(yr for yr in YEARS) + "\n")
|
|
f.write("-" * 100 + "\n")
|
|
for row in rows:
|
|
yr_str = " ".join(f"{row.get(f'pf_{yr}', float('nan')):>7.3f}" for yr in YEARS)
|
|
f.write(f"{row['gate']:<14} {row['n_hours']:>3} {row['n_trades']:>10,} "
|
|
f"{row['pf']:>7.4f} {row['wr']:>6.2f}% {row['avg_hold_bars']:>5.2f} {yr_str}\n")
|
|
|
|
print(f" → {out_txt}")
|
|
print(f"\n Runtime: {elapsed:.0f}s")
|
|
print(f"\n KEY: Look for individual hours + BEST3/BEST5 PF vs UNGATED.")
|
|
print(f" Consistent PF > 1.02 across years = real hour-of-day effect.")
|