ch11: signal-quality gate REFUTED — walk-forward workflow shows gate harmful (exp 61-67, EVIDENCE#053)

This commit is contained in:
zhaoli
2026-08-21 02:35:50 +00:00
parent 1603a063ba
commit 6e3c82408c
26 changed files with 4934 additions and 27 deletions
+191
View File
@@ -0,0 +1,191 @@
"""Diagnose exactly why the scripted test and workflow give different results.
Compares the same pred.pkl through:
1. Script logic (weekly rebalance, equal-weight, hold-through-week, zero cost)
2. Workflow logic (PortAnaRecord daily backtest, TopkDropout-like)
Isolates the effect of:
A. Weekly vs daily position evaluation
B. Equal weight vs risk_degree sizing
C. Hold-through-week vs daily top-k re-ranking
"""
from __future__ import annotations
import json, pathlib
import numpy as np
import pandas as pd
LAKE_ROOT = "/home/data/lake"
OUT = pathlib.Path("/app/experiments/book/data/diag_script_vs_wf")
WINDOWS = [
{"label": "2026", "start": "2026-01-04", "end": "2026-08-19",
"pred": f"{LAKE_ROOT}/mlruns/62/3771f96eb1b74365aeae966af7aec5a3/artifacts/pred.pkl"},
{"label": "2025", "start": "2025-01-02", "end": "2025-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/c57c6a8370cc48619d7cdd2bd109b76a/artifacts/pred.pkl"},
{"label": "2024", "start": "2024-01-02", "end": "2024-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/97cf5f282e6f4e699443e38d9bfb40fd/artifacts/pred.pkl"},
{"label": "2023", "start": "2023-01-03", "end": "2023-12-29",
"pred": f"{LAKE_ROOT}/mlruns/62/11b9b65ea4e14b3f8ce50d244da0412e/artifacts/pred.pkl"},
{"label": "2021", "start": "2021-01-04", "end": "2021-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/af3034e5910348a382f2ad1e1741f17c/artifacts/pred.pkl"},
]
SYMS = [
"SPY","QQQ","DIA","IWM","MDY","VTI","VOO","VEA","VWO","VT","EFA","EEM",
"TLT","IEF","SHY","AGG","BND","LQD","HYG","JNK","EMB","GLD","SLV",
"USO","UNG","DBA","DBC","XLK","XLF","XLE","XLV","XLI","XLY","XLP",
"XLU","XLB","XLRE","ARKK","SMH","SOXX","IBB","XBI","ITA","XAR",
"ICLN","TAN","FDN","IGV","ESPO","REM",
]
def load_pred(path):
df = pd.read_pickle(path)
s = df["score"] if isinstance(df, pd.DataFrame) and "score" in df.columns else df.iloc[:, 0] if isinstance(df, pd.DataFrame) else df
idx = s.index
new_dt = pd.to_datetime(idx.get_level_values(0)).normalize()
s.index = pd.MultiIndex.from_arrays([new_dt, idx.get_level_values(1)], names=idx.names)
return s
def load_closes(start, end):
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), "US")
closes = {}
for sym in SYMS:
p = cfg.bar_path("1d", sym)
if not p.exists(): continue
try:
df = pd.read_parquet(p)
except: continue
if not len(df): continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
warmup = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup:end]
if len(df) >= 22:
closes[sym] = df["c"]
return pd.DataFrame(closes)
def strategy_script(pred, closes, start, end, topk=10, risk_degree=1.0):
"""Mimics the scripted test: weekly rebalance, hold all week."""
ret_df = closes.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
equity = 1_000_000.0
holdings = []
prev_week = None
daily_eq = []
for d in trade_dates:
try:
day_scores = pred.loc[d]
except KeyError:
daily_eq.append(equity)
prev_scores = None
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
cur_week = (d.isocalendar()[0], d.isocalendar()[1])
if cur_week != prev_week or not holdings:
holdings = list(day_scores.index[:topk])
ret_row = ret_df.loc[d] if d in ret_df.index else None
if ret_row is not None and holdings:
wts = np.array([risk_degree / len(holdings)] * len(holdings))
rets = ret_row.reindex(holdings).fillna(0).values
equity *= (1 + (wts * rets).sum())
daily_eq.append(equity)
prev_week = cur_week
return pd.Series(daily_eq, index=trade_dates)
def strategy_daily_topk(pred, closes, start, end, topk=10, risk_degree=1.0):
"""Mimics PortAnaRecord: re-rank every day, hold top-k."""
ret_df = closes.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
equity = 1_000_000.0
daily_eq = []
for d in trade_dates:
try:
day_scores = pred.loc[d]
except KeyError:
daily_eq.append(equity)
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
holdings = list(day_scores.index[:topk])
ret_row = ret_df.loc[d] if d in ret_df.index else None
if ret_row is not None and holdings:
wts = np.array([risk_degree / len(holdings)] * len(holdings))
rets = ret_row.reindex(holdings).fillna(0).values
equity *= (1 + (wts * rets).sum())
daily_eq.append(equity)
return pd.Series(daily_eq, index=trade_dates)
def metrics(eq):
if len(eq) < 2:
return {"ann_ret": 0, "sharpe": 0, "maxDD": 0}
rets = eq.pct_change().dropna()
ann_ret = float((eq.iloc[-1] / eq.iloc[0]) ** (252 / max(len(eq), 1)) - 1)
vol = float(rets.std() * (252 ** 0.5)) if len(rets) > 1 else 0
sharpe = ann_ret / vol if vol > 0 else 0
peak = eq.cummax()
dd = (eq - peak) / peak
return {"ann_ret": round(ann_ret, 4), "sharpe": round(sharpe, 4), "maxDD": round(float(dd.min()), 4)}
def main():
OUT.mkdir(parents=True, exist_ok=True)
results = []
for w in WINDOWS:
print(f"\n=== {w['label']} ({w['start']} to {w['end']}) ===")
pred = load_pred(w["pred"])
closes = load_closes(w["start"], w["end"])
print(f" pred dates: {pred.index.get_level_values(0).min()} to {pred.index.get_level_values(0).max()}")
print(f" close dates: {closes.index.min()} to {closes.index.max()}")
print(f" symbols in close: {closes.shape[1]}")
# Script: weekly, equal weight (risk_degree=1.0)
eq_weekly_100 = strategy_script(pred, closes, w["start"], w["end"], topk=10, risk_degree=1.0)
m_weekly_100 = metrics(eq_weekly_100)
# Script: weekly, 95% risk degree
eq_weekly_95 = strategy_script(pred, closes, w["start"], w["end"], topk=10, risk_degree=0.95)
m_weekly_95 = metrics(eq_weekly_95)
# Daily top-k: re-rank daily, equal weight
eq_daily_100 = strategy_daily_topk(pred, closes, w["start"], w["end"], topk=10, risk_degree=1.0)
m_daily_100 = metrics(eq_daily_100)
# Daily top-k: re-rank daily, 95%
eq_daily_95 = strategy_daily_topk(pred, closes, w["start"], w["end"], topk=10, risk_degree=0.95)
m_daily_95 = metrics(eq_daily_95)
row = {
"year": w["label"],
"script_weekly_100": m_weekly_100,
"script_weekly_95": m_weekly_95,
"daily_topk_100": m_daily_100,
"daily_topk_95": m_daily_95,
}
results.append(row)
print(f" Script weekly 100%: ann={m_weekly_100['ann_ret']:+.1%} sharpe={m_weekly_100['sharpe']:.2f} maxDD={m_weekly_100['maxDD']:.1%}")
print(f" Script weekly 95%: ann={m_weekly_95['ann_ret']:+.1%} sharpe={m_weekly_95['sharpe']:.2f} maxDD={m_weekly_95['maxDD']:.1%}")
print(f" Daily topk 100%: ann={m_daily_100['ann_ret']:+.1%} sharpe={m_daily_100['sharpe']:.2f} maxDD={m_daily_100['maxDD']:.1%}")
print(f" Daily topk 95%: ann={m_daily_95['ann_ret']:+.1%} sharpe={m_daily_95['sharpe']:.2f} maxDD={m_daily_95['maxDD']:.1%}")
with open(OUT / "diagnosis.json", "w") as f:
json.dump(results, f, indent=2, default=str)
print(f"\nSaved to {OUT / 'diagnosis.json'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,203 @@
"""Diagnose the script-vs-workflow gap properly.
Three strategies compared:
A. Script logic: weekly rebalance, equal-weight, hold through week
B. Weekly rebalance (qlib engine behavior): same as script but with risk_degree
C. Daily re-rank: re-select top-k every day (wrong model)
Root cause was (C) — we were modeling daily re-ranking which neither
the script nor the qlib engine actually does.
"""
from __future__ import annotations
import json, pathlib
import numpy as np
import pandas as pd
LAKE_ROOT = "/home/data/lake"
OUT = pathlib.Path("/app/experiments/book/data/diag_script_vs_wf")
WINDOWS = [
{"label": "2026", "start": "2026-01-04", "end": "2026-08-19",
"pred": f"{LAKE_ROOT}/mlruns/62/3771f96eb1b74365aeae966af7aec5a3/artifacts/pred.pkl"},
{"label": "2025", "start": "2025-01-02", "end": "2025-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/c57c6a8370cc48619d7cdd2bd109b76a/artifacts/pred.pkl"},
{"label": "2024", "start": "2024-01-02", "end": "2024-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/97cf5f282e6f4e699443e38d9bfb40fd/artifacts/pred.pkl"},
{"label": "2023", "start": "2023-01-03", "end": "2023-12-29",
"pred": f"{LAKE_ROOT}/mlruns/62/11b9b65ea4e14b3f8ce50d244da0412e/artifacts/pred.pkl"},
{"label": "2021", "start": "2021-01-04", "end": "2021-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/af3034e5910348a382f2ad1e1741f17c/artifacts/pred.pkl"},
]
SYMS = [
"SPY","QQQ","DIA","IWM","MDY","VTI","VOO","VEA","VWO","VT","EFA","EEM",
"TLT","IEF","SHY","AGG","BND","LQD","HYG","JNK","EMB","GLD","SLV",
"USO","UNG","DBA","DBC","XLK","XLF","XLE","XLV","XLI","XLY","XLP",
"XLU","XLB","XLRE","ARKK","SMH","SOXX","IBB","XBI","ITA","XAR",
"ICLN","TAN","FDN","IGV","ESPO","REM",
]
def load_pred(path):
df = pd.read_pickle(path)
s = df["score"] if isinstance(df, pd.DataFrame) and "score" in df.columns else df.iloc[:, 0] if isinstance(df, pd.DataFrame) else df
idx = s.index
new_dt = pd.to_datetime(idx.get_level_values(0)).normalize()
s.index = pd.MultiIndex.from_arrays([new_dt, idx.get_level_values(1)], names=idx.names)
return s
def load_closes(start, end):
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), "US")
closes = {}
for sym in SYMS:
p = cfg.bar_path("1d", sym)
if not p.exists(): continue
try:
df = pd.read_parquet(p)
except: continue
if not len(df): continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
warmup = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup:end]
if len(df) >= 22:
closes[sym] = df["c"]
return pd.DataFrame(closes)
def strategy_weekly(pred, closes, start, end, topk=10, risk_degree=1.0, cost_bps=0):
"""Weekly rebalance: re-rank on first day of each ISO week, hold rest of week."""
ret_df = closes.pct_change(fill_method=None)
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
equity = 1_000_000.0
holdings = []
prev_week = None
daily_eq = []
for d in trade_dates:
try:
day_scores = pred.loc[d]
except KeyError:
daily_eq.append(equity)
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
cur_week = (d.isocalendar()[0], d.isocalendar()[1])
if cur_week != prev_week:
# Rebalance: compute cost of turnover
new_holdings = list(day_scores.index[:topk])
if holdings and cost_bps > 0:
sold = set(holdings) - set(new_holdings)
bought = set(new_holdings) - set(holdings)
turnover = (len(sold) + len(bought)) / (2 * max(len(holdings), 1))
equity *= (1 - turnover * cost_bps / 10000)
holdings = new_holdings
ret_row = ret_df.loc[d] if d in ret_df.index else None
if ret_row is not None and holdings:
wts = np.array([risk_degree / len(holdings)] * len(holdings))
rets = ret_row.reindex(holdings).fillna(0).values
equity *= (1 + (wts * rets).sum())
daily_eq.append(equity)
prev_week = cur_week
return pd.Series(daily_eq, index=trade_dates)
def strategy_daily(pred, closes, start, end, topk=10, risk_degree=1.0, cost_bps=0):
"""Daily re-rank: re-select top-k every day (wrong model — what we incorrectly tested)."""
ret_df = closes.pct_change(fill_method=None)
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
equity = 1_000_000.0
holdings = []
daily_eq = []
for d in trade_dates:
try:
day_scores = pred.loc[d]
except KeyError:
daily_eq.append(equity)
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
new_holdings = list(day_scores.index[:topk])
if holdings and cost_bps > 0:
sold = set(holdings) - set(new_holdings)
bought = set(new_holdings) - set(holdings)
turnover = (len(sold) + len(bought)) / (2 * max(len(holdings), 1))
equity *= (1 - turnover * cost_bps / 10000)
holdings = new_holdings
ret_row = ret_df.loc[d] if d in ret_df.index else None
if ret_row is not None and holdings:
wts = np.array([risk_degree / len(holdings)] * len(holdings))
rets = ret_row.reindex(holdings).fillna(0).values
equity *= (1 + (wts * rets).sum())
daily_eq.append(equity)
return pd.Series(daily_eq, index=trade_dates)
def metrics(eq):
if len(eq) < 2:
return {"ann_ret": 0, "sharpe": 0, "maxDD": 0}
rets = eq.pct_change().dropna()
ann_ret = float((eq.iloc[-1] / eq.iloc[0]) ** (252 / max(len(eq), 1)) - 1)
vol = float(rets.std() * (252 ** 0.5)) if len(rets) > 1 else 0
sharpe = ann_ret / vol if vol > 0 else 0
peak = eq.cummax()
dd = (eq - peak) / peak
return {"ann_ret": round(ann_ret, 4), "sharpe": round(sharpe, 4), "maxDD": round(float(dd.min()), 4)}
def main():
OUT.mkdir(parents=True, exist_ok=True)
results = []
for w in WINDOWS:
print(f"\n=== {w['label']} ({w['start']} to {w['end']}) ===")
pred = load_pred(w["pred"])
closes = load_closes(w["start"], w["end"])
print(f" pred: {pred.index.get_level_values(0).min().date()} to {pred.index.get_level_values(0).max().date()}, "
f"{pred.index.get_level_values(1).nunique()} syms")
print(f" close: {closes.index.min().date()} to {closes.index.max().date()}, {closes.shape[1]} syms")
row = {"year": w["label"]}
# A. Script: weekly, rd=1.0, zero cost
eq = strategy_weekly(pred, closes, w["start"], w["end"], topk=10, risk_degree=1.0, cost_bps=0)
m = metrics(eq); row["weekly_100_zc"] = m
print(f" Script weekly 100% zc: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f}")
# B. Script: weekly, rd=0.95, zero cost
eq = strategy_weekly(pred, closes, w["start"], w["end"], topk=10, risk_degree=0.95, cost_bps=0)
m = metrics(eq); row["weekly_95_zc"] = m
print(f" Script weekly 95% zc: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f}")
# C. Weekly, rd=0.95, with 5/15bp cost
eq = strategy_weekly(pred, closes, w["start"], w["end"], topk=10, risk_degree=0.95, cost_bps=10)
m = metrics(eq); row["weekly_95_10bp"] = m
print(f" Weekly 95% 10bp cost: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f}")
# D. Daily re-rank, rd=1.0, zero cost (WRONG MODEL — for reference only)
eq = strategy_daily(pred, closes, w["start"], w["end"], topk=10, risk_degree=1.0, cost_bps=0)
m = metrics(eq); row["daily_100_zc"] = m
print(f" Daily 100% zc (WRONG): ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f}")
# E. Daily re-rank, rd=1.0, 10bp cost
eq = strategy_daily(pred, closes, w["start"], w["end"], topk=10, risk_degree=1.0, cost_bps=10)
m = metrics(eq); row["daily_100_10bp"] = m
print(f" Daily 100% 10bp (WRONG):ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f}")
results.append(row)
with open(OUT / "diagnosis_v2.json", "w") as f:
json.dump(results, f, indent=2, default=str)
print(f"\nSaved to {OUT / 'diagnosis_v2.json'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,410 @@
"""Diagnose script-vs-workflow gap v3: replicate workflow execution mechanics exactly.
Replicates the WeeklyRebalanceDropoutStrategy execution:
1. Weekly rebalance (first trading day of ISO week only)
2. TopkDropout selection: sell bottom n_drop, buy top fill
3. Cash-after-sells sizing: sell first, then cash * risk_degree / len(buy)
4. Whole-share rounding (floor)
5. Asymmetric costs: open_cost=5bp, close_cost=15bp, min_cost=$5 per order
6. Optional SQ gate (hit-rate threshold)
Compares against the idealized script (fractional shares, symmetric cost).
"""
from __future__ import annotations
import json, pathlib
import numpy as np
import pandas as pd
LAKE_ROOT = "/home/data/lake"
OUT = pathlib.Path("/app/experiments/book/data/diag_script_vs_wf")
WINDOWS = [
{"label": "2026", "start": "2026-01-04", "end": "2026-08-19",
"pred": f"{LAKE_ROOT}/mlruns/62/3771f96eb1b74365aeae966af7aec5a3/artifacts/pred.pkl"},
{"label": "2025", "start": "2025-01-02", "end": "2025-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/c57c6a8370cc48619d7cdd2bd109b76a/artifacts/pred.pkl"},
{"label": "2024", "start": "2024-01-02", "end": "2024-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/97cf5f282e6f4e699443e38d9bfb40fd/artifacts/pred.pkl"},
{"label": "2023", "start": "2023-01-03", "end": "2023-12-29",
"pred": f"{LAKE_ROOT}/mlruns/62/11b9b65ea4e14b3f8ce50d244da0412e/artifacts/pred.pkl"},
{"label": "2021", "start": "2021-01-04", "end": "2021-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/af3034e5910348a382f2ad1e1741f17c/artifacts/pred.pkl"},
]
SYMS = [
"SPY","QQQ","DIA","IWM","MDY","VTI","VOO","VEA","VWO","VT","EFA","EEM",
"TLT","IEF","SHY","AGG","BND","LQD","HYG","JNK","EMB","GLD","SLV",
"USO","UNG","DBA","DBC","XLK","XLF","XLE","XLV","XLI","XLY","XLP",
"XLU","XLB","XLRE","ARKK","SMH","SOXX","IBB","XBI","ITA","XAR",
"ICLN","TAN","FDN","IGV","ESPO","REM",
]
OPEN_COST = 0.0005 # 5bp
CLOSE_COST = 0.0015 # 15bp
MIN_COST = 5.0 # $5 minimum per order
def load_pred(path):
df = pd.read_pickle(path)
s = df["score"] if isinstance(df, pd.DataFrame) and "score" in df.columns else df.iloc[:, 0] if isinstance(df, pd.DataFrame) else df
idx = s.index
new_dt = pd.to_datetime(idx.get_level_values(0)).normalize()
s.index = pd.MultiIndex.from_arrays([new_dt, idx.get_level_values(1)], names=idx.names)
return s
def load_closes(start, end):
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), "US")
closes = {}
for sym in SYMS:
p = cfg.bar_path("1d", sym)
if not p.exists(): continue
try:
df = pd.read_parquet(p)
except: continue
if not len(df): continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
df.index = pd.to_datetime(df.index).normalize()
warmup = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup:end]
if len(df) >= 22:
closes[sym] = df["c"]
return pd.DataFrame(closes)
def load_opens(start, end):
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), "US")
opens = {}
for sym in SYMS:
p = cfg.bar_path("1d", sym)
if not p.exists(): continue
try:
df = pd.read_parquet(p)
except: continue
if not len(df): continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
df.index = pd.to_datetime(df.index).normalize()
warmup = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup:end]
if len(df) >= 22:
opens[sym] = df["o"]
return pd.DataFrame(opens)
def load_vwap(start, end):
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), "US")
vwaps = {}
for sym in SYMS:
p = cfg.bar_path("1d", sym)
if not p.exists(): continue
try:
df = pd.read_parquet(p)
except: continue
if not len(df): continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
warmup = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup:end]
if len(df) >= 22:
vwaps[sym] = df["vw"]
return pd.DataFrame(vwaps)
def compute_gate(pred, closes, start, end, gate_topk=10, gate_lookback=5, gate_threshold=0.5):
"""Compute the SQ gate: rolling average hit-rate of topk predictions."""
ret_df = closes.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
pred_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
if len(pred_dates) < 2:
return pd.Series(True, index=pd.DatetimeIndex(pred_dates))
hit_rates = {}
for i in range(1, len(pred_dates)):
day = pred_dates[i]
prev_day = pred_dates[i - 1]
try:
prev_scores = pred.loc[prev_day]
except KeyError:
continue
if isinstance(prev_scores, pd.DataFrame):
prev_scores = prev_scores.iloc[:, 0]
prev_scores = prev_scores.dropna().sort_values(ascending=False)
topk_syms = list(prev_scores.index[:gate_topk])
if day not in ret_df.index:
continue
today_ret = ret_df.loc[day]
topk_rets = today_ret.reindex(topk_syms).dropna()
if len(topk_rets) == 0:
continue
hit_rates[day] = (topk_rets > 0).sum() / len(topk_rets)
if not hit_rates:
return pd.Series(True, index=pd.DatetimeIndex(pred_dates))
hr_series = pd.Series(hit_rates).sort_index()
rolling_hr = hr_series.rolling(gate_lookback, min_periods=1).mean()
gate = rolling_hr >= gate_threshold
gate.iloc[:gate_lookback] = True
return gate
def strategy_idealized(pred, closes, start, end, topk=10, risk_degree=1.0, cost_bps=0):
"""Idealized script: fractional shares, symmetric cost, no gate."""
ret_df = closes.pct_change(fill_method=None)
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
equity = 1_000_000.0
holdings = []
prev_week = None
daily_eq = []
for d in trade_dates:
try:
day_scores = pred.loc[d]
except KeyError:
daily_eq.append(equity)
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
cur_week = (d.isocalendar()[0], d.isocalendar()[1])
if cur_week != prev_week:
new_holdings = list(day_scores.index[:topk])
if holdings and cost_bps > 0:
sold = set(holdings) - set(new_holdings)
bought = set(new_holdings) - set(holdings)
turnover = (len(sold) + len(bought)) / (2 * max(len(holdings), 1))
equity *= (1 - turnover * cost_bps / 10000)
holdings = new_holdings
ret_row = ret_df.loc[d] if d in ret_df.index else None
if ret_row is not None and holdings:
wts = np.array([risk_degree / len(holdings)] * len(holdings))
rets = ret_row.reindex(holdings).fillna(0).values
equity *= (1 + (wts * rets).sum())
daily_eq.append(equity)
prev_week = cur_week
return pd.Series(daily_eq, index=trade_dates)
def strategy_workflow_exact(pred, closes, opens, start, end,
topk=10, n_drop=1, risk_degree=0.95,
use_gate=False, gate_series=None):
"""Exact replication of WeeklyRebalanceDropoutStrategy execution mechanics.
- Sells first (all shares of dropped positions)
- Sizes buys as: cash * risk_degree / len(buy)
- Rounds to whole shares (floor)
- Asymmetric costs: open_cost on buys, close_cost on sells, $5 min per order
- Tracks position values for daily equity
"""
ret_df = closes.pct_change(fill_method=None)
ret_df.index = pd.to_datetime(ret_df.index).normalize()
open_df = opens.copy()
open_df.index = pd.to_datetime(open_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx[(dt_idx >= start) & (dt_idx <= end)].unique())
cash = 1_000_000.0
positions = {} # {sym: num_shares}
prev_week = None
daily_eq = []
for d in trade_dates:
# Skip non-trading days (pred may include weekends)
if d not in closes.index:
daily_eq.append(daily_eq[-1] if daily_eq else cash)
continue
try:
day_scores = pred.loc[d]
except KeyError:
daily_eq.append(daily_eq[-1] if daily_eq else cash)
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
cur_week = (d.isocalendar()[0], d.isocalendar()[1])
if cur_week != prev_week:
# === REBALANCE DAY ===
# Check gate
if use_gate and gate_series is not None:
known = gate_series[gate_series.index <= d]
if len(known) and not bool(known.iloc[-1]):
# gate closed: sell everything, go to cash
for sym in list(positions.keys()):
shares = positions[sym]
if shares <= 0:
continue
sell_price = closes.loc[d, sym] if d in closes.index and sym in closes.columns else None
if sell_price is None or pd.isna(sell_price):
continue
trade_val = shares * sell_price
trade_cost = max(trade_val * CLOSE_COST, MIN_COST) if trade_val > 0 else 0
cash += trade_val - trade_cost
positions[sym] = 0
positions = {s: v for s, v in positions.items() if v > 0}
daily_eq.append(cash)
prev_week = cur_week
continue
# TopkDropout selection (matching WeeklyRebalanceDropoutStrategy exactly)
current_syms = [s for s, v in positions.items() if v > 0]
last = pred.loc[d].reindex(current_syms).sort_values(ascending=False).index if current_syms else pd.Index([])
# buy candidates: top stocks NOT in current holdings, take n_drop + topk - len(last)
buy_cands = day_scores[~day_scores.index.isin(last)].sort_values(ascending=False).index
buy_list = list(buy_cands[:n_drop + topk - len(last)])
# comb = union of current holdings + buy candidates (actual strategy line 132)
comb = pred.loc[d].reindex(last.union(pd.Index(buy_list))).sort_values(ascending=False).index
# sell: items from current holdings that are in the bottom n_drop of comb
sell_list = list(last[last.isin(comb[-n_drop:])]) if n_drop > 0 and len(comb) >= n_drop else []
# --- SELL FIRST ---
for sym in sell_list:
if sym not in positions or positions[sym] <= 0:
continue
shares = positions[sym]
sell_price = closes.loc[d, sym] if d in closes.index and sym in closes.columns else None
if sell_price is None or pd.isna(sell_price):
continue
trade_val = shares * sell_price
trade_cost = max(trade_val * CLOSE_COST, MIN_COST) if trade_val > 0 else 0
cash += trade_val - trade_cost
positions[sym] = 0
# --- BUY ---
n_buy = len(buy_list)
if n_buy > 0:
buy_budget = cash * risk_degree / n_buy
for sym in buy_list:
buy_price = closes.loc[d, sym] if d in closes.index and sym in closes.columns else None
if buy_price is None or pd.isna(buy_price) or buy_price <= 0:
continue
shares_to_buy = int(buy_budget / buy_price) # floor to whole shares
if shares_to_buy <= 0:
continue
trade_val = shares_to_buy * buy_price
trade_cost = max(trade_val * OPEN_COST, MIN_COST) if trade_val > 0 else 0
total_cost = trade_val + trade_cost
if total_cost > cash:
shares_to_buy = int((cash - MIN_COST) / buy_price)
if shares_to_buy <= 0:
continue
trade_val = shares_to_buy * buy_price
trade_cost = max(trade_val * OPEN_COST, MIN_COST)
total_cost = trade_val + trade_cost
cash -= total_cost
positions[sym] = positions.get(sym, 0) + shares_to_buy
positions = {s: v for s, v in positions.items() if v > 0}
# === DAILY EQUITY ===
eq = cash
if d in closes.index:
for sym, shares in positions.items():
if sym in closes.columns:
px = closes.loc[d, sym]
if not pd.isna(px):
eq += shares * px
daily_eq.append(eq)
prev_week = cur_week
return pd.Series(daily_eq, index=trade_dates)
def metrics(eq):
if len(eq) < 2:
return {"ann_ret": 0, "sharpe": 0, "maxDD": 0}
rets = eq.pct_change().dropna()
ann_ret = float((eq.iloc[-1] / eq.iloc[0]) ** (252 / max(len(eq), 1)) - 1)
vol = float(rets.std() * (252 ** 0.5)) if len(rets) > 1 else 0
sharpe = ann_ret / vol if vol > 0 else 0
peak = eq.cummax()
dd = (eq - peak) / peak
return {"ann_ret": round(ann_ret, 4), "sharpe": round(sharpe, 4), "maxDD": round(float(dd.min()), 4)}
def main():
OUT.mkdir(parents=True, exist_ok=True)
results = []
for w in WINDOWS:
print(f"\n=== {w['label']} ({w['start']} to {w['end']}) ===")
pred = load_pred(w["pred"])
closes = load_closes(w["start"], w["end"])
opens = load_opens(w["start"], w["end"])
print(f" pred: {pred.index.get_level_values(0).min().date()} to {pred.index.get_level_values(0).max().date()}, "
f"{pred.index.get_level_values(1).nunique()} syms")
print(f" close: {closes.index.min().date()} to {closes.index.max().date()}, {closes.shape[1]} syms")
row = {"year": w["label"]}
# A. Idealized: fractional shares, 10bp symmetric, no gate (diag v2 baseline)
eq = strategy_idealized(pred, closes, w["start"], w["end"], topk=10, risk_degree=1.0, cost_bps=0)
m = metrics(eq); row["ideal_100_zc"] = m
print(f" A. Ideal 100% zc: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f} maxDD={m['maxDD']:.1%}")
# B. Idealized: 95% invested, 10bp symmetric
eq = strategy_idealized(pred, closes, w["start"], w["end"], topk=10, risk_degree=0.95, cost_bps=0)
m = metrics(eq); row["ideal_95_zc"] = m
print(f" B. Ideal 95% zc: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f} maxDD={m['maxDD']:.1%}")
# C. Idealized: 95%, 10bp cost
eq = strategy_idealized(pred, closes, w["start"], w["end"], topk=10, risk_degree=0.95, cost_bps=10)
m = metrics(eq); row["ideal_95_10bp"] = m
print(f" C. Ideal 95% 10bp: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f} maxDD={m['maxDD']:.1%}")
# D. Workflow-exact: whole shares, 5/15bp, $5 min, no gate
eq = strategy_workflow_exact(pred, closes, opens, w["start"], w["end"],
topk=10, n_drop=1, risk_degree=0.95,
use_gate=False)
m = metrics(eq); row["wf_exact_95_nogate"] = m
print(f" D. WF exact 95% nogate: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f} maxDD={m['maxDD']:.1%}")
# E. Workflow-exact: whole shares, 5/15bp, $5 min, WITH SQ gate
gate = compute_gate(pred, closes, w["start"], w["end"],
gate_topk=10, gate_lookback=5, gate_threshold=0.5)
gate_open_pct = gate.sum() / len(gate) if len(gate) > 0 else 1.0
eq = strategy_workflow_exact(pred, closes, opens, w["start"], w["end"],
topk=10, n_drop=1, risk_degree=0.95,
use_gate=True, gate_series=gate)
m = metrics(eq); row["wf_exact_95_gate"] = m
print(f" E. WF exact 95% gate: ann={m['ann_ret']:+.1%} sharpe={m['sharpe']:.2f} maxDD={m['maxDD']:.1%} gate_open={gate_open_pct:.0%}")
# Gap analysis
ideal = row["ideal_95_zc"]["ann_ret"]
wf_nogate = row["wf_exact_95_nogate"]["ann_ret"]
wf_gate = row["wf_exact_95_gate"]["ann_ret"]
print(f"\n Gap analysis:")
print(f" Ideal (fractional, zc) → WF exact (whole shares, 5/15bp, nogate): {ideal:+.1%} → {wf_nogate:+.1%} (gap: {wf_nogate - ideal:+.1%})")
print(f" Ideal (fractional, zc) → WF exact (whole shares, 5/15bp, gate): {ideal:+.1%} → {wf_gate:+.1%} (gap: {wf_gate - ideal:+.1%})")
results.append(row)
with open(OUT / "diagnosis_v3.json", "w") as f:
json.dump(results, f, indent=2, default=str)
print(f"\nSaved to {OUT / 'diagnosis_v3.json'}")
# Summary table
print("\n" + "=" * 80)
print("SUMMARY: Ideal vs Workflow-Exact")
print("=" * 80)
print(f"{'Year':<6} {'Ideal%zc':>10} {'WF nogate':>10} {'WF gate':>10} {'Gap(nogate)':>12} {'Gap(gate)':>12}")
for r in results:
y = r["year"]
i = r["ideal_95_zc"]["ann_ret"]
wn = r["wf_exact_95_nogate"]["ann_ret"]
wg = r["wf_exact_95_gate"]["ann_ret"]
print(f"{y:<6} {i:>+10.1%} {wn:>+10.1%} {wg:>+10.1%} {wn-i:>+12.1%} {wg-i:>+12.1%}")
if __name__ == "__main__":
main()
@@ -0,0 +1,343 @@
"""Signal-quality gate walk-forward backtest — RE-TRAINED MODEL variant.
Identical logic to the original scripted test (signal_quality_gate_bt.py),
but uses pred.pkls from exp 62 (retrained LGBModel per year, same model config
as the workflow test) instead of the reference exp 52/56 pred.pkls.
This isolates whether the gate itself works when the model is the same,
regardless of the backtest engine.
Usage:
cd /app && .venv/bin/python book/scripts/signal_quality_gate_retrained.py
"""
from __future__ import annotations
import json
import pathlib
import numpy as np
import pandas as pd
LAKE_ROOT = "/home/data/lake"
MARKET = "US"
OUT_DIR = pathlib.Path("/app/experiments/book/data/signal_quality_gate")
# Retrained pred.pkls from exp 62 (on-the-fly gate test)
WINDOWS_RETRAINED = [
{"label": "2026", "start": "2026-01-04", "end": "2026-08-19",
"pred": f"{LAKE_ROOT}/mlruns/62/3771f96eb1b74365aeae966af7aec5a3/artifacts/pred.pkl"},
{"label": "2025", "start": "2025-01-02", "end": "2025-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/c57c6a8370cc48619d7cdd2bd109b76a/artifacts/pred.pkl"},
{"label": "2024", "start": "2024-01-02", "end": "2024-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/97cf5f282e6f4e699443e38d9bfb40fd/artifacts/pred.pkl"},
{"label": "2023", "start": "2023-01-03", "end": "2023-12-29",
"pred": f"{LAKE_ROOT}/mlruns/62/11b9b65ea4e14b3f8ce50d244da0412e/artifacts/pred.pkl"},
{"label": "2021", "start": "2021-01-04", "end": "2021-12-31",
"pred": f"{LAKE_ROOT}/mlruns/62/af3034e5910348a382f2ad1e1741f17c/artifacts/pred.pkl"},
]
# Original reference pred.pkls for head-to-head comparison
WINDOWS_REFERENCE = [
{"label": "2026", "start": "2026-01-04", "end": "2026-08-19",
"pred": f"{LAKE_ROOT}/mlruns/52/9f98ea5c550a409f87b56a6cd8fee343/artifacts/pred.pkl"},
{"label": "2025", "start": "2025-01-02", "end": "2025-12-31",
"pred": f"{LAKE_ROOT}/mlruns/52/fe96741654df4780957a3a949999ae6a/artifacts/pred.pkl"},
{"label": "2024", "start": "2024-01-02", "end": "2024-12-31",
"pred": f"{LAKE_ROOT}/mlruns/52/71ed5bfa9984490f8bba8b222f7acc39/artifacts/pred.pkl"},
{"label": "2023", "start": "2023-01-03", "end": "2023-12-29",
"pred": f"{LAKE_ROOT}/mlruns/56/8ca46e554311444c9a42637a788226e8/artifacts/pred.pkl"},
{"label": "2021", "start": "2021-01-04", "end": "2021-12-31",
"pred": f"{LAKE_ROOT}/mlruns/56/4e0700ddab2a4e108b46efece7346ee3/artifacts/pred.pkl"},
]
SIGNAL_GATE_CONFIGS = [
(5, 0.50, "hitrate_5d_0.50"),
(5, 0.60, "hitrate_5d_0.60"),
(5, 0.70, "hitrate_5d_0.70"),
(10, 0.50, "hitrate_10d_0.50"),
(10, 0.60, "hitrate_10d_0.60"),
(10, 0.70, "hitrate_10d_0.70"),
(20, 0.40, "hitrate_20d_0.40"),
(20, 0.50, "hitrate_20d_0.50"),
(20, 0.60, "hitrate_20d_0.60"),
]
def load_pred(path: str) -> pd.Series:
df = pd.read_pickle(path)
if isinstance(df, pd.DataFrame):
if "score" in df.columns:
s = df["score"]
else:
s = df.iloc[:, 0]
else:
s = df
idx = s.index
new_dt = pd.to_datetime(idx.get_level_values(0)).normalize()
s.index = pd.MultiIndex.from_arrays(
[new_dt, idx.get_level_values(1)], names=idx.names
)
return s
def load_bars_for_window(start: str, end: str) -> pd.DataFrame:
from tac_qlib.data.config import LakeConfig, resolve_lake_root
cfg = LakeConfig(resolve_lake_root(LAKE_ROOT), MARKET)
sp = cfg.lake_root / "symbols.parquet"
if sp.exists():
syms = pd.read_parquet(sp)
col = "symbol" if "symbol" in syms.columns else syms.columns[0]
symbols = sorted(syms[col].astype(str).str.upper().tolist())
else:
return pd.DataFrame()
closes = {}
for sym in symbols:
p = cfg.bar_path("1d", sym)
if not p.exists():
continue
try:
df = pd.read_parquet(p)
except Exception:
continue
if not len(df):
continue
tcol = df["t"] if "t" in df.columns else df["date"]
ts = pd.to_datetime(tcol)
df = df.assign(_t=ts).set_index("_t").sort_index()
warmup_start = pd.Timestamp(start) - pd.Timedelta(days=60)
df = df.loc[warmup_start:end]
if len(df) >= 22:
closes[sym] = df["c"]
return pd.DataFrame(closes)
def compute_hit_rate_series(
pred: pd.Series, ret_df: pd.DataFrame, topk: int = 10, lookback: int = 10,
) -> pd.Series:
dt_idx = pred.index.get_level_values(0)
trade_dates = sorted(dt_idx.unique())
hit_rates = {}
for i in range(1, len(trade_dates)):
prev_date = trade_dates[i - 1]
curr_date = trade_dates[i]
try:
prev_scores = pred.loc[prev_date]
except KeyError:
continue
if isinstance(prev_scores, pd.DataFrame):
prev_scores = prev_scores.iloc[:, 0]
prev_scores = prev_scores.dropna().sort_values(ascending=False)
topk_syms = list(prev_scores.index[:topk])
if curr_date not in ret_df.index:
continue
today_ret = ret_df.loc[curr_date]
topk_rets = today_ret.reindex(topk_syms).dropna()
if len(topk_rets) > 0:
hit_rate = (topk_rets > 0).mean()
hit_rates[curr_date] = hit_rate
hit_series = pd.Series(hit_rates)
if len(hit_series) == 0:
return hit_series
rolling_hr = hit_series.rolling(lookback, min_periods=max(1, lookback // 2)).mean()
return rolling_hr
def run_backtest(pred, hit_rate, close_df, start, end, topk=10, threshold=0.5):
if not isinstance(pred.index, pd.MultiIndex):
return {"error": "pred must have MultiIndex"}
ret_df = close_df.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
dt_idx = pred.index.get_level_values(0)
window_mask = (dt_idx >= pd.Timestamp(start)) & (dt_idx <= pd.Timestamp(end))
window_pred = pred.loc[window_mask]
if len(window_pred) == 0:
return {"error": "no pred data in window"}
trade_dates = sorted(dt_idx[window_mask].unique())
gate_open = {}
for d in trade_dates:
known = hit_rate[hit_rate.index <= d]
if len(known) > 0 and not pd.isna(known.iloc[-1]):
gate_open[d] = bool(known.iloc[-1] >= threshold)
else:
gate_open[d] = True
n_total = len(trade_dates)
n_open = sum(1 for v in gate_open.values() if v)
n_closed = n_total - n_open
holdings_base = []
holdings_gated = []
equity_gated = 1_000_000.0
equity_base = 1_000_000.0
prev_week = None
prev_scores = None
daily_gated = []
daily_base = []
ret_by_date = {rd: ret_df.loc[rd] for rd in ret_df.index}
for d in trade_dates:
try:
day_scores = window_pred.loc[d]
except KeyError:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = None
continue
if isinstance(day_scores, pd.DataFrame):
day_scores = day_scores.iloc[:, 0]
day_scores = day_scores.dropna().sort_values(ascending=False)
if len(day_scores) == 0:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = None
continue
ret_row = ret_by_date.get(d)
if ret_row is None:
daily_gated.append(equity_gated)
daily_base.append(equity_base)
prev_scores = day_scores
continue
cur_week = (d.isocalendar()[0], d.isocalendar()[1]) if hasattr(d, 'isocalendar') else None
gate_val = gate_open.get(d, True)
if cur_week != prev_week or not holdings_base:
if prev_scores is not None:
holdings_base = list(prev_scores.index[:topk])
if holdings_base:
base_rets = ret_row.reindex(holdings_base).dropna()
if len(base_rets) > 0:
equity_base *= (1 + base_rets.mean())
if gate_val:
if cur_week != prev_week or not holdings_gated:
if prev_scores is not None:
holdings_gated = list(prev_scores.index[:topk])
if holdings_gated:
hold_rets = ret_row.reindex(holdings_gated).dropna()
if len(hold_rets) > 0:
equity_gated *= (1 + hold_rets.mean())
else:
holdings_gated = []
prev_week = cur_week
prev_scores = day_scores
daily_gated.append(equity_gated)
daily_base.append(equity_base)
g_series = pd.Series(daily_gated, index=trade_dates)
b_series = pd.Series(daily_base, index=trade_dates)
def _metrics(eq):
if len(eq) < 2:
return {"ann_return": 0, "sharpe": 0, "maxDD": 0}
rets = eq.pct_change().dropna()
ann_ret = float((eq.iloc[-1] / eq.iloc[0]) ** (252 / max(len(eq), 1)) - 1)
vol = float(rets.std() * (252 ** 0.5)) if len(rets) > 1 else 0
sharpe = ann_ret / vol if vol > 0 else 0
peak = eq.cummax()
dd = (eq - peak) / peak
maxDD = float(dd.min())
return {"ann_return": round(ann_ret, 6), "sharpe": round(sharpe, 4), "maxDD": round(maxDD, 6)}
base_m = _metrics(b_series)
gated_m = _metrics(g_series)
return {
"trade_dates": n_total,
"gate_open_days": n_open,
"gate_closed_days": n_closed,
"trip_rate": round(n_closed / n_total, 4) if n_total else 0,
"base": base_m,
"gated": gated_m,
}
def run_set(windows, close_df, tag):
results = []
for window in windows:
wl, ws, we = window["label"], window["start"], window["end"]
pred_path = window["pred"]
print(f"\n=== [{tag}] Window {wl} ({ws} to {we}) ===")
pred = load_pred(pred_path)
print(f" pred shape: {pred.shape}, date range: {pred.index.get_level_values(0).min()} .. {pred.index.get_level_values(0).max()}")
hit_rates = {}
for lookback, _, name in SIGNAL_GATE_CONFIGS:
if lookback not in hit_rates:
hr = compute_hit_rate_series(pred, ret_df, topk=10, lookback=lookback)
hit_rates[lookback] = hr
print(f" lookback={lookback}: {len(hr)} days with hit rates")
for lookback, threshold, name in SIGNAL_GATE_CONFIGS:
hr = hit_rates[lookback]
bt = run_backtest(pred, hr, close_df, ws, we, topk=10, threshold=threshold)
if "error" in bt:
print(f" {name}: {bt['error']}")
continue
row = {
"source": tag,
"window": wl,
"gate": name,
"start": ws,
"end": we,
"trade_dates": bt["trade_dates"],
"gate_open": bt["gate_open_days"],
"gate_closed": bt["gate_closed_days"],
"trip_rate": bt["trip_rate"],
"base_ann": bt["base"]["ann_return"],
"base_sharpe": bt["base"]["sharpe"],
"base_maxDD": bt["base"]["maxDD"],
"gated_ann": bt["gated"]["ann_return"],
"gated_sharpe": bt["gated"]["sharpe"],
"gated_maxDD": bt["gated"]["maxDD"],
}
results.append(row)
diff = bt["gated"]["ann_return"] - bt["base"]["ann_return"]
print(f" {name}: trip={bt['trip_rate']:.1%}, "
f"base={bt['base']['ann_return']:+.1%} (Sharpe {bt['base']['sharpe']:.2f}), "
f"gated={bt['gated']['ann_return']:+.1%} (Sharpe {bt['gated']['sharpe']:.2f}), "
f"diff={diff:+.1%}pp")
return results
def main():
OUT_DIR.mkdir(parents=True, exist_ok=True)
full_start = "2015-01-03"
full_end = "2026-08-19"
print("Loading lake bars...")
close_df = load_bars_for_window(full_start, full_end)
print(f" {close_df.shape[1]} symbols, {close_df.shape[0]} days")
global ret_df
ret_df = close_df.pct_change()
ret_df.index = pd.to_datetime(ret_df.index).normalize()
print("\n" + "=" * 70)
print("RUN A: Retrained model pred.pkls (exp 62)")
print("=" * 70)
results_retrained = run_set(WINDOWS_RETRAINED, close_df, "retrained")
print("\n" + "=" * 70)
print("RUN B: Reference pred.pkls (exp 52/56)")
print("=" * 70)
results_reference = run_set(WINDOWS_REFERENCE, close_df, "reference")
all_results = results_retrained + results_reference
df = pd.DataFrame(all_results)
# Save combined results
out_path = OUT_DIR / "signal_quality_gate_retrained.csv"
df.to_csv(out_path, index=False)
with open(OUT_DIR / "signal_quality_gate_retrained.json", "w") as f:
json.dump(df.to_dict(orient="records"), f, indent=2, default=str)
print(f"\nSaved to {out_path}")
# Head-to-head comparison table
print("\n" + "=" * 70)
print("HEAD-TO-HEAD: Retrained vs Reference (hitrate_5d_0.50)")
print("=" * 70)
print(f"{'Year':>6} | {'Ref Base':>10} {'Ref Gated':>10} {'Ref Diff':>10} | {'Ret Base':>10} {'Ret Gated':>10} {'Ret Diff':>10}")
print("-" * 85)
for year in ["2021", "2023", "2024", "2025", "2026"]:
ref = df[(df["source"] == "reference") & (df["window"] == year) & (df["gate"] == "hitrate_5d_0.50")]
ret = df[(df["source"] == "retrained") & (df["window"] == year) & (df["gate"] == "hitrate_5d_0.50")]
if len(ref) > 0 and len(ret) > 0:
rb = ref.iloc[0]["base_ann"]
rg = ref.iloc[0]["gated_ann"]
tb = ret.iloc[0]["base_ann"]
tg = ret.iloc[0]["gated_ann"]
print(f"{year:>6} | {rb:>+9.1%} {rg:>+9.1%} {rg-rb:>+9.1%} | {tb:>+9.1%} {tg:>+9.1%} {tg-tb:>+9.1%}")
if __name__ == "__main__":
main()