initial: import DOLPHIN baseline 2026-04-21 from dolphinng5_predict working tree

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.
This commit is contained in:
hjnormey
2026-04-21 16:58:38 +02:00
commit 01c19662cb
643 changed files with 260241 additions and 0 deletions

View File

@@ -0,0 +1,551 @@
"""Combined Two-Strategy Architecture — 5y Klines
===================================================
Tests whether OLD (directional, dvol-gated) and NEW (crossover scalp, hour-gated)
strategies are additive, complementary, or competitive.
STRATEGY A — Directional Bet (dvol macro-gated)
HIGH dvol (>p75): SHORT on vel_div >= +ENTRY_T, exit 95bps TP or 10-bar max-hold
LOW dvol (<p25): LONG on vel_div <= -ENTRY_T, exit 95bps TP or 10-bar max-hold
(10-bar = 10min on 1m klines ≈ legacy 600s optimal hold)
Gate: dvol_btc from NPZ
STRATEGY B — Crossover Scalp (hour-gated)
Entry: vel_div <= -ENTRY_T → LONG
Exit: vel_div >= +ENTRY_T (reversion complete, exhaustion crossover)
InvExit: vel_div <= -INV_T (deepened, wrong-way, cut)
Gate: hour_utc in {9, 12, 18} (London/US-open hours with PF=1.05-1.06)
COMBINED MODES TESTED:
1. A_ONLY — strategy A alone (directional, dvol-gated)
2. B_ONLY — strategy B alone (crossover, hour-gated)
3. A_AND_B — both active simultaneously (independent positions, additive PnL)
4. A_OR_B — regime-switched: A when dvol extreme, B when dvol mid + good hours, else FLAT
5. B_UNGATED — strategy B without any gate (baseline for gate assessment)
6. A_UNGATED — strategy A without dvol gate (directional at all dvol levels)
7. HOUR_SWITCH — B during good hours, FLAT otherwise (no dvol gate)
Painstaking logs:
combined_strategy_summary_TS.csv — per (mode, direction, year)
combined_strategy_byyear_TS.csv — same × year
combined_strategy_overlap_TS.csv — day-level overlap between A and B signals
combined_strategy_byhour_TS.csv — per (mode, hour)
combined_strategy_regime_TS.csv — per (dvol_decile, mode)
combined_strategy_top_TS.txt — human-readable summary
"""
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 numpy.lib.stride_tricks import sliding_window_view
VBT_DIR = Path(r"C:\Users\Lenovo\Documents\- DOLPHIN NG HD HCM TSF Predict\vbt_cache_klines")
EIG_DIR = 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")
LOG_DIR.mkdir(exist_ok=True)
# ── Parameters ────────────────────────────────────────────────────────────
ENTRY_T = 0.020 # vel_div threshold (both arms)
INV_T = 0.100 # invalidation for crossover
TP_BPS = 95 # Strategy A take profit
TP_PCT = TP_BPS / 10_000.0
MH_A = 10 # Strategy A max hold (10 bars = 10min on 1m ≈ legacy 600s)
MH_B = 20 # Strategy B safety max hold
GOOD_HOURS = {9, 12, 18} # hours UTC where crossover PF=1.05-1.06
# ── Load dvol_btc for all dates ───────────────────────────────────────────
print("Preloading dvol_btc...")
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)
dvol_map = {}
DVOL_IDX = None
for pf in parquet_files:
ds = pf.stem
npz_path = EIG_DIR / ds / "scan_000001__Indicators.npz"
if not npz_path.exists(): continue
try:
data = np.load(npz_path, allow_pickle=True)
names = list(data['api_names'])
if DVOL_IDX is None and 'dvol_btc' in names:
DVOL_IDX = names.index('dvol_btc')
if DVOL_IDX is not None and data['api_success'][DVOL_IDX]:
dvol_map[ds] = float(data['api_indicators'][DVOL_IDX])
except: pass
dvol_vals = np.array(sorted(dvol_map.values()))
dvol_p25 = np.percentile(dvol_vals, 25) # 47.5
dvol_p50 = np.percentile(dvol_vals, 50) # 56.3
dvol_p75 = np.percentile(dvol_vals, 75) # 71.8
# Best dvol zone for crossover: p50-p90 (53-75)
dvol_crossover_lo = dvol_p50 # lower bound for "mid" crossover zone
dvol_crossover_hi = dvol_p75 # upper bound
dvol_decile_edges = np.percentile(dvol_vals, np.arange(0, 101, 10))
print(f" dvol: p25={dvol_p25:.1f} p50={dvol_p50:.1f} p75={dvol_p75:.1f}")
print(f" crossover zone: {dvol_crossover_lo:.1f}{dvol_crossover_hi:.1f}")
print(f" Files: {total}\n")
YEARS = ['2021','2022','2023','2024','2025','2026']
MODES = ['A_ONLY','B_ONLY','A_AND_B','A_OR_B','B_UNGATED','A_UNGATED','HOUR_SWITCH']
DVOL_BKTS = [f'D{i+1}' for i in range(10)] # D1=lowest, D10=highest
def make_s():
return {'n':0,'wins':0,'gw':0.0,'gl':0.0,'hold_sum':0}
# Accumulators
stats = defaultdict(make_s) # (mode, component, year) — component: A/B/combined
hour_stats = defaultdict(make_s) # (mode, component, hour)
dvol_stats = defaultdict(make_s) # (dvol_bucket, component)
overlap_log = [] # daily overlap info
daily_rows = [] # per-date × mode
def dvol_bucket(dv):
for i in range(len(dvol_decile_edges)-1):
if dv <= dvol_decile_edges[i+1]:
return DVOL_BKTS[i]
return DVOL_BKTS[-1]
def accum(key, n, wins, gw, gl, hs):
s = stats[key]
s['n']+=n; s['wins']+=wins; s['gw']+=gw; s['gl']+=gl; s['hold_sum']+=hs
def accum_h(key, n, wins, gw, gl, hs):
s = hour_stats[key]
s['n']+=n; s['wins']+=wins; s['gw']+=gw; s['gl']+=gl; s['hold_sum']+=hs
def accum_d(key, n, wins, gw, gl, hs):
s = dvol_stats[key]
s['n']+=n; s['wins']+=wins; s['gw']+=gw; s['gl']+=gl; s['hold_sum']+=hs
# ── Main loop ─────────────────────────────────────────────────────────────
t0 = time.time()
print(f"Main loop ({total} files)...")
for i_file, pf in enumerate(parquet_files):
ds = pf.stem
year = ds[:4]
dvol = dvol_map.get(ds, np.nan)
dvol_bkt = dvol_bucket(dvol) if np.isfinite(dvol) else 'D5'
# Regime classification
if np.isnan(dvol):
dvol_regime = 'MID'
elif dvol > dvol_p75:
dvol_regime = 'HIGH'
elif dvol < dvol_p25:
dvol_regime = 'LOW'
else:
dvol_regime = 'MID'
try:
df = pd.read_parquet(pf)
except: 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)
if hasattr(df.index, 'hour'):
bar_hours = df.index.hour.values
elif pd.api.types.is_datetime64_any_dtype(df.index):
bar_hours = df.index.hour
else:
bar_hours = np.zeros(len(btc), dtype=int)
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)
MH_MAX = max(MH_A, MH_B)
if n < MH_MAX + 5: continue
n_usable = n - MH_MAX
vd_win = sliding_window_view(vd, MH_MAX+1)[:n_usable] # (n_usable, MH_MAX+1)
btc_win = sliding_window_view(btc, MH_MAX+1)[:n_usable]
ep_arr = btc_win[:, 0]
valid = np.isfinite(ep_arr) & (ep_arr > 0)
bar_h_entry = bar_hours[:n_usable] if len(bar_hours) >= n_usable else np.zeros(n_usable, dtype=int)
# ── STRATEGY A — Directional ──────────────────────────────────────────
# HIGH dvol → SHORT on vel_div >= +ENTRY_T, TP=95bps, max-hold=MH_A
# LOW dvol → LONG on vel_div <= -ENTRY_T, TP=95bps, max-hold=MH_A
a_trades_n = a_wins = 0; a_gw = a_gl = a_hs = 0.0
if dvol_regime in ('HIGH','LOW'):
if dvol_regime == 'HIGH':
entry_a = (vd[:n_usable] >= +ENTRY_T) & valid
# SHORT: price must fall >= TP_PCT to hit TP; price rise is loss
def a_pnl(ep, fp): return (ep - fp) / ep # positive = price fell = SHORT win
else: # LOW
entry_a = (vd[:n_usable] <= -ENTRY_T) & valid
def a_pnl(ep, fp): return (fp - ep) / ep # positive = price rose = LONG win
idx_a = np.where(entry_a)[0]
if len(idx_a):
ep_a = ep_arr[idx_a]
# Future prices for MH_A bars
fut_a = btc_win[idx_a, 1:MH_A+1] # (N, MH_A)
ep_col = ep_a[:, None]
if dvol_regime == 'HIGH':
pr_a = (ep_col - fut_a) / ep_col # SHORT price ret (positive=win)
tp_mask = pr_a >= TP_PCT
else:
pr_a = (fut_a - ep_col) / ep_col # LONG price ret
tp_mask = pr_a >= TP_PCT
pr_a = np.where(np.isfinite(fut_a), pr_a, 0.0)
BIG = MH_A + 1
tp_bar = np.where(tp_mask.any(1), np.argmax(tp_mask, 1), BIG)
mh_bar = np.full(len(idx_a), MH_A-1, dtype=np.int32)
exit_bar_a = np.minimum(tp_bar, mh_bar)
exit_pnl_a = pr_a[np.arange(len(idx_a)), exit_bar_a]
won_a = exit_pnl_a > 0
holds_a = exit_bar_a + 1
a_trades_n = len(idx_a)
a_wins = int(won_a.sum())
a_gw = float(exit_pnl_a[won_a].sum()) if won_a.any() else 0.0
a_gl = float((-exit_pnl_a[~won_a]).sum()) if (~won_a).any() else 0.0
a_hs = int(holds_a.sum())
# Ungated strategy A (all dvol levels, always LONG on vel_div<=-ENTRY_T)
a_ung_n = a_ung_w = 0; a_ung_gw = a_ung_gl = a_ung_hs = 0.0
entry_a_ung = (vd[:n_usable] <= -ENTRY_T) & valid
idx_aung = np.where(entry_a_ung)[0]
if len(idx_aung):
ep_aung = ep_arr[idx_aung]
fut_aung = btc_win[idx_aung, 1:MH_A+1]
pr_aung = (fut_aung - ep_aung[:, None]) / ep_aung[:, None]
pr_aung = np.where(np.isfinite(fut_aung), pr_aung, 0.0)
tp_m = pr_aung >= TP_PCT
tp_b = np.where(tp_m.any(1), np.argmax(tp_m, 1), MH_A+1)
mhb = np.full(len(idx_aung), MH_A-1, dtype=np.int32)
eb = np.minimum(tp_b, mhb)
ep = pr_aung[np.arange(len(idx_aung)), eb]
wo = ep > 0
a_ung_n = len(idx_aung); a_ung_w = int(wo.sum())
a_ung_gw = float(ep[wo].sum()) if wo.any() else 0.0
a_ung_gl = float((-ep[~wo]).sum()) if (~wo).any() else 0.0
a_ung_hs = int((eb+1).sum())
# ── STRATEGY B — Crossover Scalp ─────────────────────────────────────
# Always LONG: vel_div <= -ENTRY_T → LONG, exit vel_div >= +ENTRY_T
entry_b = (vd[:n_usable] <= -ENTRY_T) & valid
idx_b = np.where(entry_b)[0]
b_all_n = b_all_w = 0; b_all_gw = b_all_gl = b_all_hs = 0.0
b_hour_n = b_hour_w = 0; b_hour_gw = b_hour_gl = b_hour_hs = 0.0
b_mid_n = b_mid_w = 0; b_mid_gw = b_mid_gl = b_mid_hs = 0.0
if len(idx_b):
ep_b = ep_arr[idx_b]
fut_vd_b = vd_win[idx_b, 1:MH_B+1]
fut_btc_b = btc_win[idx_b, 1:MH_B+1]
pr_b = (fut_btc_b - ep_b[:, None]) / ep_b[:, None]
pr_b = np.where(np.isfinite(fut_btc_b), pr_b, 0.0)
h_entry= bar_h_entry[idx_b]
BIG = MH_B + 1
exhst = fut_vd_b >= +ENTRY_T
inv = fut_vd_b <= -INV_T
exhst_b = np.where(exhst.any(1), np.argmax(exhst, 1), BIG)
inv_b = np.where(inv.any(1), np.argmax(inv, 1), BIG)
mhb = np.full(len(idx_b), MH_B-1, dtype=np.int32)
all_b = np.column_stack([exhst_b, inv_b, mhb])
eb_b = np.clip(all_b[np.arange(len(idx_b)), np.argmin(all_b,1)], 0, MH_B-1)
ep_b_pnl= pr_b[np.arange(len(idx_b)), eb_b]
won_b = ep_b_pnl > 0
hb_b = eb_b + 1
# All trades (ungated B)
b_all_n = len(idx_b); b_all_w = int(won_b.sum())
b_all_gw = float(ep_b_pnl[won_b].sum()) if won_b.any() else 0.0
b_all_gl = float((-ep_b_pnl[~won_b]).sum()) if (~won_b).any() else 0.0
b_all_hs = int(hb_b.sum())
# Hour-gated trades
h_mask = np.isin(h_entry, list(GOOD_HOURS))
if h_mask.any():
b_hour_n = int(h_mask.sum()); b_hour_w = int(won_b[h_mask].sum())
b_hour_gw = float(ep_b_pnl[h_mask & won_b].sum()) if (h_mask & won_b).any() else 0.0
b_hour_gl = float((-ep_b_pnl[h_mask & ~won_b]).sum()) if (h_mask & ~won_b).any() else 0.0
b_hour_hs = int(hb_b[h_mask].sum())
# dvol-mid + hour gated (A_OR_B uses B here)
mid_ok = dvol_regime == 'MID' or np.isnan(dvol)
if mid_ok:
dmh_mask = h_mask # also require good hour when mid-dvol
if dmh_mask.any():
b_mid_n = int(dmh_mask.sum()); b_mid_w = int(won_b[dmh_mask].sum())
b_mid_gw = float(ep_b_pnl[dmh_mask & won_b].sum()) if (dmh_mask & won_b).any() else 0.0
b_mid_gl = float((-ep_b_pnl[dmh_mask & ~won_b]).sum()) if (dmh_mask & ~won_b).any() else 0.0
b_mid_hs = int(hb_b[dmh_mask].sum())
# Hour breakdown for B_UNGATED
for h in np.unique(h_entry):
hm = (h_entry == h)
hn = int(hm.sum())
hw = int(won_b[hm].sum())
hgw = float(ep_b_pnl[hm & won_b].sum()) if (hm & won_b).any() else 0.0
hgl = float((-ep_b_pnl[hm & ~won_b]).sum()) if (hm & ~won_b).any() else 0.0
hhs = int(hb_b[hm].sum())
accum_h(('B_UNGATED','B',int(h)), hn, hw, hgw, hgl, hhs)
# ── Accumulate stats per mode ─────────────────────────────────────────
# A_ONLY (gated by dvol HIGH/LOW)
accum(('A_ONLY','A',year), a_trades_n, a_wins, a_gw, a_gl, a_hs)
# B_ONLY (hour-gated)
accum(('B_ONLY','B',year), b_hour_n, b_hour_w, b_hour_gw, b_hour_gl, b_hour_hs)
# A_AND_B (both simultaneously, additive PnL)
# A: gated by dvol; B: hour-gated. They may partially overlap in entry bars.
# Combined: just sum both sets of trades.
comb_n = a_trades_n + b_hour_n
comb_w = a_wins + b_hour_w
comb_gw = a_gw + b_hour_gw
comb_gl = a_gl + b_hour_gl
comb_hs = a_hs + b_hour_hs
accum(('A_AND_B','combined',year), comb_n, comb_w, comb_gw, comb_gl, comb_hs)
accum(('A_AND_B','A',year), a_trades_n, a_wins, a_gw, a_gl, a_hs)
accum(('A_AND_B','B',year), b_hour_n, b_hour_w, b_hour_gw, b_hour_gl, b_hour_hs)
# A_OR_B (regime-switched: A when dvol extreme, B when mid+good-hour, else FLAT)
aorb_n = a_trades_n + b_mid_n
aorb_w = a_wins + b_mid_w
aorb_gw = a_gw + b_mid_gw
aorb_gl = a_gl + b_mid_gl
aorb_hs = a_hs + b_mid_hs
accum(('A_OR_B','combined',year), aorb_n, aorb_w, aorb_gw, aorb_gl, aorb_hs)
accum(('A_OR_B','A',year), a_trades_n, a_wins, a_gw, a_gl, a_hs)
accum(('A_OR_B','B',year), b_mid_n, b_mid_w, b_mid_gw, b_mid_gl, b_mid_hs)
# B_UNGATED (crossover without any gate)
accum(('B_UNGATED','B',year), b_all_n, b_all_w, b_all_gw, b_all_gl, b_all_hs)
# A_UNGATED (directional LONG at all dvol levels)
accum(('A_UNGATED','A',year), a_ung_n, a_ung_w, a_ung_gw, a_ung_gl, a_ung_hs)
# HOUR_SWITCH (B hour-gated, no dvol gate)
accum(('HOUR_SWITCH','B',year), b_hour_n, b_hour_w, b_hour_gw, b_hour_gl, b_hour_hs)
# dvol_stats for B_UNGATED
accum_d((dvol_bkt,'B_ung'), b_all_n, b_all_w, b_all_gw, b_all_gl, b_all_hs)
accum_d((dvol_bkt,'A_ung'), a_ung_n, a_ung_w, a_ung_gw, a_ung_gl, a_ung_hs)
# Daily overlap log
overlap_log.append({
'date': ds, 'year': year,
'dvol': round(dvol,2) if np.isfinite(dvol) else None,
'dvol_regime': dvol_regime,
'A_trades': a_trades_n, 'B_hour_trades': b_hour_n, 'B_all_trades': b_all_n,
'A_gw': round(a_gw,6), 'A_gl': round(a_gl,6),
'B_hour_gw': round(b_hour_gw,6), 'B_hour_gl': round(b_hour_gl,6),
'combined_gw': round(a_gw+b_hour_gw,6), 'combined_gl': round(a_gl+b_hour_gl,6),
})
del vd, btc, vd_win, btc_win
if (i_file+1) % 300 == 0:
gc.collect()
e = time.time()-t0
print(f" [{i_file+1}/{total}] {ds} {e:.0f}s eta={e/(i_file+1)*(total-i_file-1):.0f}s")
elapsed = time.time()-t0
print(f"\nPass complete: {elapsed:.0f}s\n")
# ── Build output rows ──────────────────────────────────────────────────────
def met(s):
n=s['n']; w=s['wins']; gw=s['gw']; gl=s['gl']; hs=s['hold_sum']
wr = w/n*100 if n else float('nan')
pf = gw/gl if gl>0 else (999.0 if gw>0 else float('nan'))
ah = hs/n if n else float('nan')
return n, round(wr,3), round(pf,4), round(ah,3)
# Summary: per (mode, component) across all years
summary = []
for mode in MODES:
# main component(s)
comps = ['A','B','combined'] if mode in ('A_AND_B','A_OR_B') else \
['A'] if mode in ('A_ONLY','A_UNGATED') else ['B']
for comp in comps:
agg = make_s()
for yr in YEARS:
s = stats.get((mode,comp,yr))
if s:
for f in ['n','wins','hold_sum']: agg[f]+=s[f]
for f in ['gw','gl']: agg[f]+=s[f]
n,wr,pf,ah = met(agg)
summary.append({'mode':mode,'component':comp,'n_trades':n,'wr':wr,'pf':pf,'avg_hold':ah,
'gw':round(agg['gw'],2),'gl':round(agg['gl'],2)})
# Per-year rows
year_rows = []
for mode in MODES:
comps = ['A','B','combined'] if mode in ('A_AND_B','A_OR_B') else \
['A'] if mode in ('A_ONLY','A_UNGATED') else ['B']
for comp in comps:
for yr in YEARS:
s = stats.get((mode,comp,yr), make_s())
n,wr,pf,ah = met(s)
year_rows.append({'mode':mode,'component':comp,'year':yr,
'n_trades':n,'wr':wr,'pf':pf,'avg_hold':ah})
# Hour rows (B_UNGATED)
hour_rows = []
for h in range(24):
s = hour_stats.get(('B_UNGATED','B',h), make_s())
n,wr,pf,ah = met(s)
hour_rows.append({'hour_utc':h,'n_trades':n,'wr':wr,'pf':pf,'avg_hold':ah})
# dvol rows
dvol_rows = []
for bkt in DVOL_BKTS:
for comp in ('B_ung','A_ung'):
s = dvol_stats.get((bkt,comp), make_s())
n,wr,pf,ah = met(s)
dvol_rows.append({'dvol_bucket':bkt,'strategy':comp,'n_trades':n,'wr':wr,'pf':pf,'avg_hold':ah})
# ── 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"combined_strategy_{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)")
print("Saving CSVs...")
save_csv(summary, 'summary')
save_csv(year_rows, 'byyear')
save_csv(hour_rows, 'byhour')
save_csv(dvol_rows, 'bydvol')
save_csv(overlap_log, 'overlap')
# ── Console output ─────────────────────────────────────────────────────────
def pf_str(pf):
if np.isnan(pf): return ' nan'
if pf>=999: return ' inf'
m = '***' if pf>1.0 else ('** ' if pf>0.8 else '* ')
return f'{pf:6.3f}{m}'
print(f"\n{'='*95}")
print(f" COMBINED TWO-STRATEGY ARCHITECTURE — FULL RESULTS")
print(f" Strategy A: directional (95bps TP, {MH_A}-bar max), dvol-gated (HIGH→SHORT, LOW→LONG)")
print(f" Strategy B: crossover scalp (vel_div ±{ENTRY_T} cross), hour-gated ({sorted(GOOD_HOURS)}h UTC)")
print(f" dvol: p25={dvol_p25:.1f} p50={dvol_p50:.1f} p75={dvol_p75:.1f}")
print(f"{'='*95}")
hdr = f" {'Mode':<14} {'Comp':<10} {'N':>9} {'WR%':>7} {'PF':>10} {'AvgHold':>8}"
print(hdr)
print(f" {'-'*63}")
for r in summary:
print(f" {r['mode']:<14} {r['component']:<10} {r['n_trades']:>9,} "
f"{r['wr']:>7.1f}% {pf_str(r['pf']):>10} {r['avg_hold']:>8.2f}b")
# Per-year for key modes
KEY_MODES = ['A_ONLY','B_ONLY','A_AND_B','A_OR_B','B_UNGATED','A_UNGATED']
print(f"\n{'='*95}")
print(f" PER-YEAR BREAKDOWN")
print(f"{'='*95}")
for mode in KEY_MODES:
comps = ['combined'] if mode in ('A_AND_B','A_OR_B') else \
['A'] if mode in ('A_ONLY','A_UNGATED') else ['B']
comp = comps[0]
ydata = {r['year']: r for r in year_rows if r['mode']==mode and r['component']==comp}
print(f"\n {mode} ({comp}):")
print(f" {'Year':<6} {'N':>9} {'WR%':>7} {'PF':>10} {'AvgHold':>8}")
print(f" {'-'*45}")
tot = make_s()
for yr in YEARS:
d = ydata.get(yr)
if d and d['n_trades']>0:
print(f" {yr:<6} {d['n_trades']:>9,} {d['wr']:>7.1f}% {pf_str(d['pf']):>10} {d['avg_hold']:>8.2f}b")
s = stats.get((mode,comp,yr), make_s())
for f in ['n','wins','hold_sum']: tot[f]+=s[f]
for f in ['gw','gl']: tot[f]+=s[f]
n_t,wr_t,pf_t,ah_t = met(tot)
print(f" {'TOTAL':<6} {n_t:>9,} {wr_t:>7.1f}% {pf_str(pf_t):>10} {ah_t:>8.2f}b")
# A+B overlap analysis
print(f"\n{'='*95}")
print(f" A vs B TRADE OVERLAP ANALYSIS")
print(f"{'='*95}")
total_days = len(overlap_log)
a_active_days = sum(1 for r in overlap_log if r['A_trades']>0)
b_active_days = sum(1 for r in overlap_log if r['B_hour_trades']>0)
both_active = sum(1 for r in overlap_log if r['A_trades']>0 and r['B_hour_trades']>0)
print(f" Total days: {total_days}")
print(f" Days with A trades: {a_active_days} ({a_active_days/total_days*100:.1f}%)")
print(f" Days with B trades: {b_active_days} ({b_active_days/total_days*100:.1f}%)")
print(f" Days with BOTH A+B: {both_active} ({both_active/total_days*100:.1f}%) ← overlap days")
print(f" Days with A ONLY: {a_active_days-both_active}")
print(f" Days with B ONLY: {b_active_days-both_active}")
print(f" Days with NEITHER: {total_days-a_active_days-b_active_days+both_active}")
# PnL contribution analysis
total_comb_gw = sum(r['combined_gw'] for r in overlap_log)
total_comb_gl = sum(r['combined_gl'] for r in overlap_log)
total_a_gw = sum(r['A_gw'] for r in overlap_log)
total_a_gl = sum(r['A_gl'] for r in overlap_log)
total_bh_gw = sum(r['B_hour_gw'] for r in overlap_log)
total_bh_gl = sum(r['B_hour_gl'] for r in overlap_log)
print(f"\n PnL contribution (A + B_hour):")
print(f" A: GW={total_a_gw:.4f} GL={total_a_gl:.4f} PF={total_a_gw/total_a_gl:.4f}" if total_a_gl>0 else " A: no trades")
print(f" B_hour: GW={total_bh_gw:.4f} GL={total_bh_gl:.4f} PF={total_bh_gw/total_bh_gl:.4f}" if total_bh_gl>0 else " B_hour: no trades")
print(f" COMB: GW={total_comb_gw:.4f} GL={total_comb_gl:.4f} PF={total_comb_gw/total_comb_gl:.4f}" if total_comb_gl>0 else " COMB: no gl")
# A vs B PF on overlap days vs non-overlap days
a_overlap_gw = a_overlap_gl = b_overlap_gw = b_overlap_gl = 0.0
a_nonoverlap_gw = a_nonoverlap_gl = 0.0
for r in overlap_log:
if r['A_trades']>0 and r['B_hour_trades']>0:
a_overlap_gw += r['A_gw']; a_overlap_gl += r['A_gl']
b_overlap_gw += r['B_hour_gw']; b_overlap_gl += r['B_hour_gl']
elif r['A_trades']>0:
a_nonoverlap_gw += r['A_gw']; a_nonoverlap_gl += r['A_gl']
print(f"\n A PF on OVERLAP days (both A+B active): {a_overlap_gw/a_overlap_gl:.4f}" if a_overlap_gl>0 else "")
print(f" B PF on OVERLAP days (both A+B active): {b_overlap_gw/b_overlap_gl:.4f}" if b_overlap_gl>0 else "")
print(f" A PF on NON-OVERLAP days (only A active): {a_nonoverlap_gw/a_nonoverlap_gl:.4f}" if a_nonoverlap_gl>0 else "")
# Best combined scenario conclusion
print(f"\n{'='*95}")
print(f" CONCLUSION: ARE THEY ADDITIVE?")
print(f"{'='*95}")
b_ung = next((r for r in summary if r['mode']=='B_UNGATED' and r['component']=='B'), None)
b_only = next((r for r in summary if r['mode']=='B_ONLY' and r['component']=='B'), None)
a_only = next((r for r in summary if r['mode']=='A_ONLY' and r['component']=='A'), None)
a_and_b = next((r for r in summary if r['mode']=='A_AND_B' and r['component']=='combined'), None)
a_or_b = next((r for r in summary if r['mode']=='A_OR_B' and r['component']=='combined'), None)
a_ung = next((r for r in summary if r['mode']=='A_UNGATED' and r['component']=='A'), None)
if all([b_ung, b_only, a_only, a_and_b, a_or_b]):
print(f" B_UNGATED (crossover, no gate): PF={pf_str(b_ung['pf'])} N={b_ung['n_trades']:,}")
print(f" B_ONLY (crossover, hour-gated): PF={pf_str(b_only['pf'])} N={b_only['n_trades']:,}")
print(f" A_ONLY (directional, dvol-gated):PF={pf_str(a_only['pf'])} N={a_only['n_trades']:,}")
print(f" A_UNGATED (directional, no gate): PF={pf_str(a_ung['pf'])} N={a_ung['n_trades']:,}")
print(f" A_AND_B (both simultaneous): PF={pf_str(a_and_b['pf'])} N={a_and_b['n_trades']:,}")
print(f" A_OR_B (regime-switched): PF={pf_str(a_or_b['pf'])} N={a_or_b['n_trades']:,}")
best_pf = max([b_ung['pf'],b_only['pf'],a_only['pf'],a_and_b['pf'],a_or_b['pf']])
best_nm = ['B_UNGATED','B_ONLY','A_ONLY','A_AND_B','A_OR_B'][[b_ung['pf'],b_only['pf'],a_only['pf'],a_and_b['pf'],a_or_b['pf']].index(best_pf)]
print(f"\n → BEST: {best_nm} PF={pf_str(best_pf)}")
print(f"\n Runtime: {elapsed:.0f}s")
# Save top-summary text
top_path = LOG_DIR / f"combined_strategy_top_{ts}.txt"
with open(top_path,'w',encoding='utf-8') as f:
f.write(f"COMBINED TWO-STRATEGY ARCHITECTURE\n")
f.write(f"Generated: {ts} Runtime: {elapsed:.0f}s\n")
f.write(f"Strategy A: directional 95bps TP {MH_A}-bar hold dvol-gated\n")
f.write(f"Strategy B: crossover scalp {ENTRY_T} cross hour-gated {sorted(GOOD_HOURS)}h UTC\n")
f.write(f"dvol: p25={dvol_p25:.1f} p50={dvol_p50:.1f} p75={dvol_p75:.1f}\n\n")
f.write(hdr+"\n"+"-"*63+"\n")
for r in summary:
f.write(f" {r['mode']:<14} {r['component']:<10} {r['n_trades']:>9,} "
f"{r['wr']:>7.1f}% {pf_str(r['pf']):>10} {r['avg_hold']:>8.2f}b\n")
print(f"\n{top_path}")