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,516 @@
#!/usr/bin/env python3
"""
test_exf_acb_marginal.py
========================
Marginal benefit test: add each significant ExF indicator to ACBv6 in isolation
and test whether it improves daily PnL vs champion baseline.
Method:
Baseline: 55-day engine, ACBv6 (funding/dvol/fng/taker from NPZ, lag=0)
Per-indicator: ACBv6 + one new ExF signal (+0.5 to signal count when fires)
Statistics: Wilcoxon signed-rank (N=55 pairs) + 5000-trial permutation test
Firing condition: lagged indicator value above/below median (direction from r-sign)
Indicators (p<0.05 from exf_correlation sweep):
claims lag=1 r=+0.40 p=0.0035 HIGH fires
ycurve lag=2 r=+0.35 p=0.0105 HIGH fires
us10y lag=1 r=+0.32 p=0.0195 HIGH fires
funding_eth lag=0 r=-0.32 p=0.0183 LOW fires
vol24 lag=0 r=+0.32 p=0.0178 HIGH fires
stables lag=2 r=-0.36 p=0.0074 LOW fires
m2 lag=7 r=-0.36 p=0.0195 LOW fires
hashrate lag=7 r=-0.29 p=0.0479 LOW fires
usdc lag=5 r=-0.29 p=0.0412 LOW fires
fund_dbt_eth lag=7 r=+0.47 p=0.0278 HIGH fires (n=22, treat cautiously)
dvol_btc lag=7 r=-0.33 p=0.0619 LOW fires (borderline)
dvol_eth lag=7 r=-0.31 p=0.0777 LOW fires (borderline)
"""
import sys, time, math, json, csv
from pathlib import Path
from datetime import datetime
import numpy as np
import pandas as pd
from scipy import stats
sys.path.insert(0, str(Path(__file__).parent))
# ── JIT warmup ────────────────────────────────────────────────────────────────
print("Compiling numba kernels...")
t0c = time.time()
from nautilus_dolphin.nautilus.alpha_asset_selector import (
compute_irp_nb, compute_ars_nb, rank_assets_irp_nb)
from nautilus_dolphin.nautilus.alpha_bet_sizer import compute_sizing_nb
from nautilus_dolphin.nautilus.alpha_signal_generator import check_dc_nb
from nautilus_dolphin.nautilus.ob_features import (
OBFeatureEngine, compute_imbalance_nb, compute_depth_1pct_nb,
compute_depth_quality_nb, compute_fill_probability_nb, compute_spread_proxy_nb,
compute_depth_asymmetry_nb, compute_imbalance_persistence_nb,
compute_withdrawal_velocity_nb, compute_market_agreement_nb, compute_cascade_signal_nb,
)
from nautilus_dolphin.nautilus.ob_provider import MockOBProvider
_p = np.array([1.0, 2.0, 3.0], dtype=np.float64)
compute_irp_nb(_p, -1); compute_ars_nb(1.0, 0.5, 0.01)
rank_assets_irp_nb(np.ones((10, 2), dtype=np.float64), 8, -1, 5, 500.0, 20, 0.20)
compute_sizing_nb(-0.03, -0.02, -0.05, 3.0, 0.5, 5.0, 0.20, True, True, 0.0,
np.zeros(4, dtype=np.int64), np.zeros(4, dtype=np.int64),
np.zeros(5, dtype=np.float64), 0, -1, 0.01, 0.04)
check_dc_nb(_p, 3, 1, 0.75)
_b = np.array([100.0, 200.0, 300.0, 400.0, 500.0], dtype=np.float64)
_a = np.array([110.0, 190.0, 310.0, 390.0, 510.0], dtype=np.float64)
compute_imbalance_nb(_b, _a); compute_depth_1pct_nb(_b, _a)
compute_depth_quality_nb(210.0, 200.0); compute_fill_probability_nb(1.0)
compute_spread_proxy_nb(_b, _a); compute_depth_asymmetry_nb(_b, _a)
compute_imbalance_persistence_nb(np.array([0.1, -0.1], dtype=np.float64), 2)
compute_withdrawal_velocity_nb(np.array([100.0, 110.0], dtype=np.float64), 1)
compute_market_agreement_nb(np.array([0.1, -0.05], dtype=np.float64), 2)
compute_cascade_signal_nb(np.array([-0.05, -0.15], dtype=np.float64), 2, -0.10)
print(f" JIT: {time.time() - t0c:.1f}s")
from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDAlphaEngine
from nautilus_dolphin.nautilus.adaptive_circuit_breaker import AdaptiveCircuitBreaker
from mc.mc_ml import DolphinForewarner
# ── Constants ─────────────────────────────────────────────────────────────────
VBT_DIR = Path(r"C:\Users\Lenovo\Documents\- DOLPHIN NG HD HCM TSF Predict\vbt_cache")
RUN_LOGS = Path(__file__).parent / "run_logs"
RUN_LOGS.mkdir(exist_ok=True)
MC_DIR = str(Path(__file__).parent / "mc_results" / "models")
META_COLS = {
'timestamp', 'scan_number',
'v50_lambda_max_velocity', 'v150_lambda_max_velocity',
'v300_lambda_max_velocity', 'v750_lambda_max_velocity',
'vel_div', 'instability_50', 'instability_150',
}
ENGINE_KWARGS = dict(
initial_capital=25000.0, vel_div_threshold=-0.02, vel_div_extreme=-0.05,
min_leverage=0.5, max_leverage=5.0, leverage_convexity=3.0,
fraction=0.20, fixed_tp_pct=0.0099, stop_pct=1.0, max_hold_bars=120,
use_direction_confirm=True, dc_lookback_bars=7, dc_min_magnitude_bps=0.75,
dc_skip_contradicts=True, dc_leverage_boost=1.0, dc_leverage_reduce=0.5,
use_asset_selection=True, min_irp_alignment=0.45,
use_sp_fees=True, use_sp_slippage=True,
sp_maker_entry_rate=0.62, sp_maker_exit_rate=0.50,
use_ob_edge=True, ob_edge_bps=5.0, ob_confirm_rate=0.40,
lookback=100, use_alpha_layers=True, use_dynamic_leverage=True, seed=42,
)
MC_BASE_CFG = dict(
trial_id=0,
vel_div_threshold=-0.020, vel_div_extreme=-0.050,
use_direction_confirm=True, dc_lookback_bars=7,
dc_min_magnitude_bps=0.75, dc_skip_contradicts=True,
dc_leverage_boost=1.00, dc_leverage_reduce=0.50,
vd_trend_lookback=10, min_leverage=0.50,
max_leverage=5.00, leverage_convexity=3.00, fraction=0.20,
use_alpha_layers=True, use_dynamic_leverage=True,
fixed_tp_pct=0.0099, stop_pct=1.00, max_hold_bars=120,
use_sp_fees=True, use_sp_slippage=True,
sp_maker_entry_rate=0.62, sp_maker_exit_rate=0.50,
use_ob_edge=True, ob_edge_bps=5.00, ob_confirm_rate=0.40,
ob_imbalance_bias=-0.09, ob_depth_scale=1.00,
use_asset_selection=True, min_irp_alignment=0.45, lookback=100,
acb_beta_high=0.80, acb_beta_low=0.20, acb_w750_threshold_pct=60,
)
OB_ASSETS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
# ── ACBWithExF: wraps ACBv6, injects one ExF signal ───────────────────────────
class ACBWithExF(AdaptiveCircuitBreaker):
"""ACBv6 + one additional ExF indicator (+0.5 signal when fires).
Inherits all NPZ-loaded signals; adds one ExF layer on top.
Clones w750 cache + cut cache from base ACB to avoid re-reading NPZ files.
"""
def __init__(self, indicator: str, threshold: float, lag: int,
direction: int, trading_days: list, exf_series: dict):
super().__init__()
self._exf_ind = indicator
self._exf_thr = threshold
self._exf_lag = lag
self._exf_dir = direction # +1: high fires; -1: low fires
# Build lagged lookup over trading days
self._lagged: dict = {}
for i, ds in enumerate(trading_days):
j = i - lag
if j >= 0:
src = trading_days[j]
self._lagged[ds] = exf_series.get(src, float('nan'))
else:
self._lagged[ds] = float('nan')
n_fires = sum(1 for ds in trading_days if self._fires_on(ds))
n_valid = sum(1 for v in self._lagged.values() if not math.isnan(v))
print(f" [{indicator:14s} lag={lag} dir={'HIGH' if direction>0 else 'LOW ':4s} "
f"thr={threshold:+.4g}] fires={n_fires}/{len(trading_days)} valid={n_valid}")
@classmethod
def clone_from(cls, base: 'AdaptiveCircuitBreaker', indicator: str,
threshold: float, lag: int, direction: int,
trading_days: list, exf_series: dict) -> 'ACBWithExF':
"""Create ACBWithExF pre-loaded from a base ACB's caches."""
obj = cls.__new__(cls)
AdaptiveCircuitBreaker.__init__(obj)
# Copy caches from base to avoid NPZ re-reads
obj._w750_vel_cache = dict(base._w750_vel_cache)
obj._w750_threshold = base._w750_threshold
obj._cache = dict(base._cache)
# ExF fields
obj._exf_ind = indicator
obj._exf_thr = threshold
obj._exf_lag = lag
obj._exf_dir = direction
obj._lagged = {}
for i, ds in enumerate(trading_days):
j = i - lag
if j >= 0:
src = trading_days[j]
obj._lagged[ds] = exf_series.get(src, float('nan'))
else:
obj._lagged[ds] = float('nan')
n_fires = sum(1 for ds in trading_days if obj._fires_on(ds))
n_valid = sum(1 for v in obj._lagged.values() if not math.isnan(v))
print(f" [{indicator:14s} lag={lag} dir={'HIGH' if direction>0 else 'LOW ':4s} "
f"thr={threshold:+.4g}] fires={n_fires}/{len(trading_days)} valid={n_valid}")
return obj
def _fires_on(self, date_str: str) -> bool:
v = self._lagged.get(date_str, float('nan'))
if math.isnan(v):
return False
return (self._exf_dir > 0 and v > self._exf_thr) or \
(self._exf_dir < 0 and v < self._exf_thr)
def get_dynamic_boost_for_date(self, date_str: str, ob_engine=None) -> dict:
info = super().get_dynamic_boost_for_date(date_str, ob_engine)
if self._fires_on(date_str):
base_sig = info['signals']
new_sig = base_sig + 0.5
new_boost = (1.0 + 0.5 * math.log1p(new_sig)) if new_sig >= 1.0 else 1.0
info['boost'] = new_boost
info['signals'] = new_sig
info['exf_signal_add'] = 0.5
else:
info['exf_signal_add'] = 0.0
return info
# ── Engine runner ─────────────────────────────────────────────────────────────
def run_engine(acb, forewarner, pq_data, parquet_files, vol_p60,
ob_eng, label="") -> dict:
engine = NDAlphaEngine(**ENGINE_KWARGS)
engine.set_ob_engine(ob_eng)
engine.set_acb(acb)
engine.set_mc_forewarner(forewarner, MC_BASE_CFG)
engine.set_esoteric_hazard_multiplier(0.0)
dstats = []
for pf in parquet_files:
ds = pf.stem
df, acols, dvol = pq_data[ds]
vol_ok = np.where(np.isfinite(dvol), dvol > vol_p60, False)
s = engine.process_day(ds, df, acols, vol_regime_ok=vol_ok)
dstats.append(s)
tr = engine.trade_history
w = [t for t in tr if t.pnl_absolute > 0]
l = [t for t in tr if t.pnl_absolute <= 0]
gw = sum(t.pnl_absolute for t in w) if w else 0
gl = abs(sum(t.pnl_absolute for t in l)) if l else 0
roi = (engine.capital - 25000) / 25000 * 100
pf_ = gw / gl if gl > 0 else 999.0
wr = len(w) / len(tr) * 100 if tr else 0.0
cap_series = np.array([25000.0] + [25000 + sum(s['pnl'] for s in dstats[:i+1])
for i in range(len(dstats))])
dr = np.diff(cap_series) / 25000 * 100
sh = float(np.mean(dr) / np.std(dr) * np.sqrt(365)) if np.std(dr) > 0 else 0.0
peak = np.maximum.accumulate(cap_series)
dd = float(np.max((peak - cap_series) / np.where(peak > 0, peak, 1) * 100))
daily_pnl = {s['date']: s['pnl'] for s in dstats}
return {
'label': label, 'roi': roi, 'pf': pf_, 'wr': wr, 'sharpe': sh,
'dd': dd, 'trades': len(tr), 'capital': engine.capital,
'daily_pnl': daily_pnl,
}
# ── Statistical tests ─────────────────────────────────────────────────────────
def marginal_test(base_pnl: dict, test_pnl: dict, n_perm: int = 5000) -> dict:
dates = sorted(set(base_pnl) & set(test_pnl))
diffs = np.array([test_pnl[d] - base_pnl[d] for d in dates])
n = len(diffs)
mean_delta = float(np.mean(diffs))
pos_days = int(np.sum(diffs > 0))
neg_days = int(np.sum(diffs < 0))
# Wilcoxon signed-rank (two-sided; p < 0.10 noteworthy, p < 0.05 significant)
if np.all(diffs == 0):
w_stat, w_p = 0.0, 1.0
else:
try:
w_stat, w_p = stats.wilcoxon(diffs)
except Exception:
w_stat, w_p = 0.0, 1.0
# One-sided permutation test (H1: test > baseline)
obs_mean = float(np.mean(diffs))
perm_means = np.zeros(n_perm)
for i in range(n_perm):
signs = np.random.choice([-1, 1], size=n)
perm_means[i] = float(np.mean(np.abs(diffs) * signs))
perm_p = float(np.mean(perm_means >= obs_mean))
return {
'n_days': n, 'mean_delta_pnl': mean_delta,
'pos_days': pos_days, 'neg_days': neg_days,
'wilcoxon_p': float(w_p),
'perm_p': perm_p,
}
# ── Load & cache data ─────────────────────────────────────────────────────────
parquet_files = sorted(p for p in VBT_DIR.glob("*.parquet")
if 'catalog' not in str(p))
date_strings = [pf.stem for pf in parquet_files]
print(f"\n[DATA] {len(parquet_files)} parquet files: {date_strings[0]}{date_strings[-1]}")
# Vol gate (p60 from first 2 days)
print("[DATA] Computing vol p60 gate...")
all_vols = []
for pf in parquet_files[:2]:
df = pd.read_parquet(pf)
if 'BTCUSDT' not in df.columns:
continue
pr = df['BTCUSDT'].values
for i in range(60, len(pr)):
seg = pr[max(0, i - 50):i]
if len(seg) < 10:
continue
v = float(np.std(np.diff(seg) / seg[:-1]))
if v > 0:
all_vols.append(v)
vol_p60 = float(np.percentile(all_vols, 60))
print(f" vol_p60 = {vol_p60:.6f}")
print("[DATA] Loading parquet files...")
pq_data = {}
for pf in parquet_files:
df = pd.read_parquet(pf)
ac = [c for c in df.columns if c not in META_COLS]
bp = df['BTCUSDT'].values if 'BTCUSDT' in df.columns else None
dv = np.full(len(df), np.nan)
if bp is not None:
for i in range(50, len(bp)):
seg = bp[max(0, i - 50):i]
if len(seg) < 10:
continue
dv[i] = float(np.std(np.diff(seg) / seg[:-1]))
pq_data[pf.stem] = (df, ac, dv)
# OB engine (shared across all runs)
_mock_ob = MockOBProvider(
imbalance_bias=-0.09, depth_scale=1.0, assets=OB_ASSETS,
imbalance_biases={"BTCUSDT": -0.086, "ETHUSDT": -0.092,
"BNBUSDT": +0.05, "SOLUSDT": +0.05},
)
ob_eng = OBFeatureEngine(_mock_ob)
ob_eng.preload_date("mock", OB_ASSETS)
# MC-Forewarner (shared)
print("[DATA] Loading MC-Forewarner...")
forewarner = DolphinForewarner(models_dir=MC_DIR)
# Base ACB: load w750 + warm cut cache for all 55 days
print("[DATA] Initializing base ACB, preloading w750 + cut cache...")
base_acb = AdaptiveCircuitBreaker()
base_acb.preload_w750(date_strings)
for ds in date_strings:
base_acb.get_dynamic_boost_for_date(ds) # warm _cache
print(f" w750_threshold = {base_acb._w750_threshold:.6f}")
# ── Load ExF daily data ───────────────────────────────────────────────────────
exf_csvs = sorted(RUN_LOGS.glob("exf_daily_*.csv"), reverse=True)
if not exf_csvs:
raise RuntimeError("No exf_daily_*.csv found. Run test_exf_correlation.py first.")
exf_df = pd.read_csv(exf_csvs[0])
exf_df["date"] = exf_df["date"].astype(str)
exf_df = exf_df.set_index("date")
print(f"[DATA] ExF daily loaded: {exf_csvs[0].name} ({len(exf_df)} rows)")
def exf_series(col: str) -> dict:
"""Extract {date_str: float} for one ExF column, drop NaN."""
if col not in exf_df.columns:
return {}
s = exf_df[col].dropna()
return {str(d): float(v) for d, v in s.items()}
def median_threshold(col: str) -> float:
s = exf_series(col)
if not s:
return 0.0
return float(np.median(list(s.values())))
# ── Define indicator tests ────────────────────────────────────────────────────
INDICATORS = [
# (name, col, lag, direction, note)
("claims", "claims", 1, +1, "p=0.0035 **"),
("ycurve", "ycurve", 2, +1, "p=0.0105 *"),
("stables", "stables", 2, -1, "p=0.0074 ** (Spearman weak)"),
("us10y", "us10y", 1, +1, "p=0.0195 *"),
("funding_eth", "funding_eth", 0, -1, "p=0.0183 * (lag=0)"),
("vol24", "vol24", 0, +1, "p=0.0178 * (Spearman weak, lag=0)"),
("m2", "m2", 7, -1, "p=0.0195 * (lag=7)"),
("usdc", "usdc", 5, -1, "p=0.0412 * (lag=5)"),
("hashrate", "hashrate", 7, -1, "p=0.0479 * (lag=7)"),
("fund_dbt_eth", "fund_dbt_eth", 7, +1, "p=0.0278 * (n=22 only!)"),
("dvol_btc", "dvol_btc", 7, -1, "p=0.062 borderline"),
("dvol_eth", "dvol_eth", 7, -1, "p=0.078 borderline"),
]
# ── Baseline run ──────────────────────────────────────────────────────────────
print(f"\n{'='*70}")
print(" RUN 0/12: BASELINE (ACBv6, no ExF additions)")
print(f"{'='*70}")
t0 = time.time()
baseline = run_engine(base_acb, forewarner, pq_data, parquet_files, vol_p60, ob_eng, "BASELINE")
print(f" {time.time()-t0:.1f}s | ROI={baseline['roi']:+.2f}% PF={baseline['pf']:.3f} "
f"WR={baseline['wr']:.1f}% Sharpe={baseline['sharpe']:.2f} DD={baseline['dd']:.2f}%")
# ── Per-indicator marginal runs ───────────────────────────────────────────────
results = []
np.random.seed(0)
for run_i, (name, col, lag, direction, note) in enumerate(INDICATORS, 1):
print(f"\n{'='*70}")
print(f" RUN {run_i}/{len(INDICATORS)}: {name} [{note}]")
print(f"{'='*70}")
series = exf_series(col)
if not series:
print(f" [SKIP] Column '{col}' not found or all-NaN in exf_daily CSV")
results.append({'indicator': name, 'col': col, 'lag': lag, 'note': note,
'status': 'SKIP', 'roi_delta': 0, 'sharpe_delta': 0,
'wilcoxon_p': 1.0, 'perm_p': 1.0, 'verdict': 'SKIP'})
continue
thr = median_threshold(col)
test_acb = ACBWithExF.clone_from(
base_acb, name, thr, lag, direction, date_strings, series)
t0 = time.time()
run = run_engine(test_acb, forewarner, pq_data, parquet_files,
vol_p60, ob_eng, name)
elapsed = time.time() - t0
stat = marginal_test(baseline['daily_pnl'], run['daily_pnl'])
roi_delta = run['roi'] - baseline['roi']
sharpe_delta = run['sharpe'] - baseline['sharpe']
pf_delta = run['pf'] - baseline['pf']
dd_delta = run['dd'] - baseline['dd']
wr_delta = run['wr'] - baseline['wr']
# Verdict: significant = Wilcoxon p<0.10 AND positive roi_delta AND perm_p<0.15
if stat['wilcoxon_p'] < 0.05 and roi_delta > 0 and stat['perm_p'] < 0.10:
verdict = "SIGNIFICANT ✓"
elif stat['wilcoxon_p'] < 0.10 and roi_delta > 0:
verdict = "MARGINAL"
elif roi_delta > 0:
verdict = "POSITIVE (not sig)"
else:
verdict = "NO BENEFIT"
print(f" {elapsed:.1f}s")
print(f" ROI: {baseline['roi']:+.2f}% → {run['roi']:+.2f}% Δ={roi_delta:+.2f}%")
print(f" Sharpe: {baseline['sharpe']:.3f}{run['sharpe']:.3f} Δ={sharpe_delta:+.3f}")
print(f" PF: {baseline['pf']:.4f}{run['pf']:.4f} Δ={pf_delta:+.4f}")
print(f" DD: {baseline['dd']:.2f}% → {run['dd']:.2f}% Δ={dd_delta:+.2f}%")
print(f" WR: {baseline['wr']:.1f}% → {run['wr']:.1f}% Δ={wr_delta:+.1f}%")
print(f" Trades: {baseline['trades']}{run['trades']}")
print(f" PnL delta: mean={stat['mean_delta_pnl']:+.1f}$/day "
f"pos={stat['pos_days']} neg={stat['neg_days']} days")
print(f" Wilcoxon p={stat['wilcoxon_p']:.4f} perm_p={stat['perm_p']:.4f}")
print(f" VERDICT: {verdict}")
results.append({
'indicator': name, 'col': col, 'lag': lag, 'direction': '+' if direction > 0 else '-',
'note': note, 'threshold': thr, 'status': 'OK',
'baseline_roi': baseline['roi'], 'test_roi': run['roi'], 'roi_delta': roi_delta,
'baseline_pf': baseline['pf'], 'test_pf': run['pf'], 'pf_delta': pf_delta,
'baseline_sh': baseline['sharpe'], 'test_sh': run['sharpe'], 'sharpe_delta': sharpe_delta,
'baseline_dd': baseline['dd'], 'test_dd': run['dd'], 'dd_delta': dd_delta,
'baseline_wr': baseline['wr'], 'test_wr': run['wr'], 'wr_delta': wr_delta,
'mean_delta_pnl': stat['mean_delta_pnl'],
'pos_days': stat['pos_days'], 'neg_days': stat['neg_days'],
'wilcoxon_p': stat['wilcoxon_p'], 'perm_p': stat['perm_p'],
'verdict': verdict,
})
# ── Summary table ─────────────────────────────────────────────────────────────
print(f"\n{'='*110}")
print(" MARGINAL BENEFIT SUMMARY")
print(f"{'='*110}")
print(f" {'indicator':14s} {'lag':3s} {'dir':3s} {'ΔROI':>7s} {'ΔSharpe':>8s} "
f"{'ΔPF':>7s} {'ΔDD':>6s} {'Wilcox_p':>9s} {'perm_p':>7s} verdict")
print(f" {'-'*104}")
ok_results = [r for r in results if r['status'] == 'OK']
ok_results.sort(key=lambda r: r['wilcoxon_p'])
for r in ok_results:
sig_marker = "**" if r['wilcoxon_p'] < 0.05 else (" *" if r['wilcoxon_p'] < 0.10 else " ")
print(f" {r['indicator']:14s} {r['lag']:3d} {r['direction']:3s} "
f"{r['roi_delta']:+7.2f}% {r['sharpe_delta']:+8.3f} "
f"{r['pf_delta']:+7.4f} {r['dd_delta']:+6.2f}% "
f"{r['wilcoxon_p']:9.4f}{sig_marker} {r['perm_p']:7.4f} {r['verdict']}")
# ── Save results ──────────────────────────────────────────────────────────────
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_csv = RUN_LOGS / f"exf_acb_marginal_{ts}.csv"
if ok_results:
keys = list(ok_results[0].keys())
with open(out_csv, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=keys)
w.writeheader()
w.writerows(ok_results)
print(f"\n[SAVED] {out_csv}")
# Identify winners
winners = [r for r in ok_results if r['verdict'] in ("SIGNIFICANT ✓", "MARGINAL") and r['roi_delta'] > 0]
print(f"\n[RESULT] {len(winners)} indicators pass marginal threshold:")
for r in winners:
print(f" {r['indicator']:14s} lag={r['lag']} dir={r['direction']} "
f"ΔROI={r['roi_delta']:+.2f}% Wilcoxon_p={r['wilcoxon_p']:.4f} verdict={r['verdict']}")
if winners:
print("\n[ACTION] These indicators qualify for ACBv7. Run fork step next.")
else:
print("\n[ACTION] No indicator shows statistically significant marginal benefit.")
print(" Champion baseline is robust — ACBv6 remains optimal.")
print("\n[DONE]")