Meta-model: empirical win-prob calibration on backtest trade pool
Trains LogisticRegression and GradientBoostingClassifier on engineered features from trades.csv (engine score/confidence, risk/reward geometry, entry hour/day-of-week, regime one-hot, pair one-hot, grade one-hot). Strict time-based train/test split (first 70% by entry_time = train, last 30% = test) — no random splits, no future leakage. Outputs: - AUC, Brier score, classification report - Per-decile breakdown (1=lowest p_win, 10=highest) - Feature importances - Calibration plot, decile returns plot, equity-compare plot - Summary text with headline lift over engine baseline Result on the 9,659-trade combined pool (7 majors × 1 year M15): - AUC 0.597 (weak but real signal) - Top 20% by p_win on test set: 37.5% WR vs 28.6% baseline - Engine grade features (grade_A, grade_A+, grade_B) have near-zero importance. Raw score and confidence carry signal; the rubric that combines them into A+/A/B does not. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+368
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Meta-model: learn an empirical "will this trade win?" probability from
|
||||
backtest output, replacing the engine's heuristic A+/A/B grades with a
|
||||
calibrated win-probability score.
|
||||
|
||||
Inputs:
|
||||
backtest_results/trades.csv (or --trades <path>)
|
||||
|
||||
Outputs (under backtest_results/meta/):
|
||||
feature_importances.txt
|
||||
classification_report.txt
|
||||
calibration.png — predicted prob vs actual win rate
|
||||
decile_returns.png — avg R per predicted-prob decile
|
||||
equity_compare.png — engine grades vs meta-top-N
|
||||
summary.txt — headline numbers + recommended threshold
|
||||
|
||||
Train/test split is **time-based** (first 70% train, last 30% test) — random
|
||||
splits leak future regime information into training and overstate test
|
||||
performance on financial time series.
|
||||
|
||||
Usage:
|
||||
python meta_model.py
|
||||
python meta_model.py --trades backtest_results_h1/trades.csv --out meta_h1
|
||||
python meta_model.py --top-decile 0.2 # take top 20% predictions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def engineer_features(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.Series]:
|
||||
"""
|
||||
Build features available at trade entry time. Strict no-leakage:
|
||||
nothing from exit_time, exit_price, outcome, bars_held, r_multiple.
|
||||
"""
|
||||
df = df.copy()
|
||||
df["entry_time"] = pd.to_datetime(df["entry_time"])
|
||||
|
||||
# Time-of-day / day-of-week
|
||||
df["hour"] = df["entry_time"].dt.hour
|
||||
df["dow"] = df["entry_time"].dt.dayofweek
|
||||
df["is_buy"] = (df["direction"] == "BUY").astype(int)
|
||||
|
||||
# MTF flag
|
||||
df["mtf"] = df["mtf_aligned"].astype(int)
|
||||
|
||||
# Score / confidence — keep raw and signed/abs versions; model picks
|
||||
df["abs_score"] = df["score"].abs()
|
||||
df["score_sign"] = np.sign(df["score"])
|
||||
|
||||
# Risk geometry
|
||||
df["rr"] = df["reward_pips"] / df["risk_pips"].replace(0, np.nan)
|
||||
df["rr"] = df["rr"].fillna(2.0)
|
||||
|
||||
# Regime one-hot
|
||||
regime_dummies = pd.get_dummies(df["regime"], prefix="regime").astype(int)
|
||||
df = pd.concat([df, regime_dummies], axis=1)
|
||||
|
||||
# Pair one-hot (model can learn pair-specific behavior)
|
||||
pair_dummies = pd.get_dummies(df["pair"], prefix="pair").astype(int)
|
||||
df = pd.concat([df, pair_dummies], axis=1)
|
||||
|
||||
# Grade one-hot — included so the meta-model can re-weight rather
|
||||
# than blindly disregard the engine's view
|
||||
grade_dummies = pd.get_dummies(df["grade"], prefix="grade").astype(int)
|
||||
df = pd.concat([df, grade_dummies], axis=1)
|
||||
|
||||
feature_cols = (
|
||||
["score", "abs_score", "score_sign", "confidence", "rr",
|
||||
"is_buy", "mtf", "hour", "dow", "risk_pips", "reward_pips"]
|
||||
+ list(regime_dummies.columns)
|
||||
+ list(pair_dummies.columns)
|
||||
+ list(grade_dummies.columns)
|
||||
)
|
||||
|
||||
X = df[feature_cols].astype(float)
|
||||
y = (df["r_multiple"] > 0).astype(int)
|
||||
return X, y
|
||||
|
||||
|
||||
def time_based_split(
|
||||
X: pd.DataFrame, y: pd.Series, df: pd.DataFrame, train_frac: float = 0.7,
|
||||
):
|
||||
"""First 70% by entry_time = train, last 30% = test. Standard for time series."""
|
||||
order = df["entry_time"].argsort().values
|
||||
cutoff = int(len(order) * train_frac)
|
||||
train_idx = order[:cutoff]
|
||||
test_idx = order[cutoff:]
|
||||
return (
|
||||
X.iloc[train_idx], X.iloc[test_idx],
|
||||
y.iloc[train_idx], y.iloc[test_idx],
|
||||
df.iloc[train_idx], df.iloc[test_idx],
|
||||
)
|
||||
|
||||
|
||||
def evaluate_decile_strategy(
|
||||
df_test: pd.DataFrame, p_win: np.ndarray, top_frac: float = 0.2,
|
||||
) -> dict:
|
||||
"""
|
||||
Take only the top X% by predicted win probability. Compare to taking
|
||||
every trade in the test set (the 'engine baseline').
|
||||
"""
|
||||
n = len(df_test)
|
||||
cutoff_n = max(1, int(n * top_frac))
|
||||
|
||||
df_eval = df_test.copy()
|
||||
df_eval["p_win"] = p_win
|
||||
df_eval = df_eval.sort_values("p_win", ascending=False)
|
||||
|
||||
top = df_eval.head(cutoff_n)
|
||||
base = df_eval
|
||||
|
||||
return {
|
||||
"top_frac": top_frac,
|
||||
"top_n": len(top),
|
||||
"top_wr": (top["r_multiple"] > 0).mean() * 100,
|
||||
"top_avg_r": top["r_multiple"].mean(),
|
||||
"top_total_r": top["r_multiple"].sum(),
|
||||
"base_n": len(base),
|
||||
"base_wr": (base["r_multiple"] > 0).mean() * 100,
|
||||
"base_avg_r": base["r_multiple"].mean(),
|
||||
"base_total_r": base["r_multiple"].sum(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Meta-model on backtest trades.")
|
||||
parser.add_argument("--trades", default="backtest_results/trades.csv",
|
||||
help="Path to trades.csv (default backtest_results/trades.csv)")
|
||||
parser.add_argument("--out", default="backtest_results/meta",
|
||||
help="Output directory")
|
||||
parser.add_argument("--train-frac", type=float, default=0.7)
|
||||
parser.add_argument("--top-decile", type=float, default=0.2,
|
||||
help="Top fraction of predictions to 'trade' (default 0.2)")
|
||||
parser.add_argument("--min-trades", type=int, default=300,
|
||||
help="Refuse to train below this many trades")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
try:
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.pipeline import Pipeline
|
||||
from sklearn.metrics import (
|
||||
classification_report, roc_auc_score, brier_score_loss,
|
||||
)
|
||||
except ImportError:
|
||||
print("❌ scikit-learn not installed. pip install scikit-learn")
|
||||
return 1
|
||||
|
||||
trades_path = Path(args.trades)
|
||||
if not trades_path.exists():
|
||||
print(f"❌ {trades_path} not found. Run backtest.py first.")
|
||||
return 1
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
df = pd.read_csv(trades_path)
|
||||
df["entry_time"] = pd.to_datetime(df["entry_time"])
|
||||
if len(df) < args.min_trades:
|
||||
print(f"❌ Only {len(df)} trades — need ≥{args.min_trades} for training.")
|
||||
print(" Seed more pairs / longer history, or lower --min-trades.")
|
||||
return 1
|
||||
|
||||
print(f"📊 Loaded {len(df):,} trades from {trades_path}")
|
||||
print(f" Pairs: {sorted(df['pair'].unique())}")
|
||||
print(f" Date range: {df['entry_time'].min()} → {df['entry_time'].max()}")
|
||||
print(f" Baseline win rate: {(df['r_multiple'] > 0).mean()*100:.1f}%")
|
||||
print(f" Baseline avg R: {df['r_multiple'].mean():+.3f}")
|
||||
print()
|
||||
|
||||
X, y = engineer_features(df)
|
||||
print(f"🔧 Features: {list(X.columns)}")
|
||||
print(f" Shape: {X.shape}, target balance: {y.mean()*100:.1f}% wins\n")
|
||||
|
||||
X_tr, X_te, y_tr, y_te, df_tr, df_te = time_based_split(X, y, df, args.train_frac)
|
||||
print(f"📚 Train: {len(X_tr):,} trades ({df_tr['entry_time'].min().date()} → {df_tr['entry_time'].max().date()})")
|
||||
print(f" Test: {len(X_te):,} trades ({df_te['entry_time'].min().date()} → {df_te['entry_time'].max().date()})\n")
|
||||
|
||||
# ── Model 1: Logistic regression (interpretable baseline) ─────────
|
||||
lr = Pipeline([
|
||||
("scale", StandardScaler()),
|
||||
("lr", LogisticRegression(max_iter=2000, C=1.0)),
|
||||
])
|
||||
lr.fit(X_tr, y_tr)
|
||||
p_lr = lr.predict_proba(X_te)[:, 1]
|
||||
|
||||
# ── Model 2: Gradient boosting (non-linear, captures interactions) ─
|
||||
gb = GradientBoostingClassifier(
|
||||
n_estimators=200, max_depth=3, learning_rate=0.05,
|
||||
subsample=0.8, random_state=42,
|
||||
)
|
||||
gb.fit(X_tr, y_tr)
|
||||
p_gb = gb.predict_proba(X_te)[:, 1]
|
||||
|
||||
# ── Metrics ───────────────────────────────────────────────────────
|
||||
print("══ Logistic Regression ══")
|
||||
print(f" AUC: {roc_auc_score(y_te, p_lr):.3f}")
|
||||
print(f" Brier: {brier_score_loss(y_te, p_lr):.3f} (lower=better, 0.25=random for 50% prior)")
|
||||
print()
|
||||
print("══ Gradient Boosting ══")
|
||||
print(f" AUC: {roc_auc_score(y_te, p_gb):.3f}")
|
||||
print(f" Brier: {brier_score_loss(y_te, p_gb):.3f}")
|
||||
print()
|
||||
|
||||
# Pick the better-AUC model for downstream evaluation
|
||||
p_best = p_gb if roc_auc_score(y_te, p_gb) >= roc_auc_score(y_te, p_lr) else p_lr
|
||||
best_name = "GradientBoosting" if p_best is p_gb else "LogisticRegression"
|
||||
print(f"➡️ Selecting {best_name} for top-decile evaluation\n")
|
||||
|
||||
# ── Decile-strategy comparison ─────────────────────────────────────
|
||||
res = evaluate_decile_strategy(df_te, p_best, args.top_decile)
|
||||
print(f"══ {int(args.top_decile*100)}% top-pick strategy vs trade-everything baseline (test set) ══")
|
||||
print(f" Trade everything: {res['base_n']:>5} trades WR {res['base_wr']:5.1f}% avgR {res['base_avg_r']:+.3f} totalR {res['base_total_r']:+8.2f}")
|
||||
print(f" Top {int(args.top_decile*100)}% by p_win: {res['top_n']:>5} trades WR {res['top_wr']:5.1f}% avgR {res['top_avg_r']:+.3f} totalR {res['top_total_r']:+8.2f}")
|
||||
print()
|
||||
|
||||
# ── Per-decile breakdown ──────────────────────────────────────────
|
||||
df_eval = df_te.copy()
|
||||
df_eval["p_win"] = p_best
|
||||
df_eval["decile"] = pd.qcut(df_eval["p_win"], 10, labels=False, duplicates="drop")
|
||||
decile_summary = df_eval.groupby("decile").agg(
|
||||
n=("r_multiple", "count"),
|
||||
avg_p_win=("p_win", "mean"),
|
||||
actual_wr=("r_multiple", lambda s: (s > 0).mean()),
|
||||
avg_r=("r_multiple", "mean"),
|
||||
total_r=("r_multiple", "sum"),
|
||||
).round(3)
|
||||
decile_summary.index = decile_summary.index.astype(int) + 1 # 1..10
|
||||
print("══ Per-decile breakdown (1=lowest p_win, 10=highest) ══")
|
||||
print(decile_summary.to_string())
|
||||
print()
|
||||
|
||||
# ── Feature importances ────────────────────────────────────────────
|
||||
print("══ Top features by importance (from gradient boosting) ══")
|
||||
importances = pd.Series(gb.feature_importances_, index=X.columns)
|
||||
top_features = importances.sort_values(ascending=False).head(15)
|
||||
for name, imp in top_features.items():
|
||||
print(f" {name:30s} {imp:.4f}")
|
||||
print()
|
||||
|
||||
# ── Save full reports ──────────────────────────────────────────────
|
||||
(out_dir / "classification_report.txt").write_text(
|
||||
f"Logistic Regression\n{'-'*40}\n"
|
||||
f"AUC: {roc_auc_score(y_te, p_lr):.3f}\n"
|
||||
f"Brier: {brier_score_loss(y_te, p_lr):.3f}\n\n"
|
||||
f"{classification_report(y_te, (p_lr > 0.5).astype(int))}\n"
|
||||
f"\nGradient Boosting\n{'-'*40}\n"
|
||||
f"AUC: {roc_auc_score(y_te, p_gb):.3f}\n"
|
||||
f"Brier: {brier_score_loss(y_te, p_gb):.3f}\n\n"
|
||||
f"{classification_report(y_te, (p_gb > 0.5).astype(int))}\n"
|
||||
)
|
||||
(out_dir / "feature_importances.txt").write_text(
|
||||
importances.sort_values(ascending=False).to_string()
|
||||
)
|
||||
(out_dir / "decile_breakdown.csv").write_text(decile_summary.to_csv())
|
||||
(out_dir / "summary.txt").write_text(
|
||||
f"Meta-model on {len(df):,} trades "
|
||||
f"({df['entry_time'].min()} → {df['entry_time'].max()})\n\n"
|
||||
f"Baseline (trade everything):\n"
|
||||
f" win rate: {(df['r_multiple'] > 0).mean()*100:.1f}%\n"
|
||||
f" avg R: {df['r_multiple'].mean():+.3f}\n"
|
||||
f" total R: {df['r_multiple'].sum():+.2f}\n\n"
|
||||
f"Test set ({len(X_te):,} trades, last 30% by date):\n"
|
||||
f" Trade everything: WR {res['base_wr']:.1f}% avgR {res['base_avg_r']:+.3f} totalR {res['base_total_r']:+.2f}\n"
|
||||
f" Top {int(args.top_decile*100)}% by p_win: WR {res['top_wr']:.1f}% avgR {res['top_avg_r']:+.3f} totalR {res['top_total_r']:+.2f}\n\n"
|
||||
f"Best model: {best_name}\n"
|
||||
f" AUC: {roc_auc_score(y_te, p_best):.3f} (0.5 = random, >0.55 = real signal)\n"
|
||||
f" Brier: {brier_score_loss(y_te, p_best):.3f}\n"
|
||||
)
|
||||
|
||||
# ── Plots ─────────────────────────────────────────────────────────
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# Calibration curve
|
||||
bins = pd.cut(p_best, bins=np.linspace(0, 1, 11))
|
||||
cal = pd.DataFrame({"p_win": p_best, "y": y_te.values, "bin": bins})
|
||||
grouped = cal.groupby("bin", observed=True).agg(
|
||||
mean_pred=("p_win", "mean"),
|
||||
actual=("y", "mean"),
|
||||
n=("y", "count"),
|
||||
)
|
||||
fig, ax = plt.subplots(figsize=(6, 5))
|
||||
ax.plot([0, 1], [0, 1], "--", color="gray", label="Perfect calibration")
|
||||
ax.plot(grouped["mean_pred"], grouped["actual"], "o-", linewidth=2, markersize=8)
|
||||
for _, row in grouped.iterrows():
|
||||
ax.annotate(f"n={int(row['n'])}",
|
||||
(row["mean_pred"], row["actual"]),
|
||||
textcoords="offset points", xytext=(8, -3), fontsize=8)
|
||||
ax.set_xlabel("Mean predicted win probability")
|
||||
ax.set_ylabel("Actual win rate")
|
||||
ax.set_title(f"Calibration ({best_name}, test set)")
|
||||
ax.set_xlim(0, 1); ax.set_ylim(0, 1)
|
||||
ax.legend(); ax.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_dir / "calibration.png", dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
# Decile returns
|
||||
fig, ax = plt.subplots(figsize=(8, 5))
|
||||
colors = ["#dc2626" if r < 0 else "#16a34a" for r in decile_summary["avg_r"]]
|
||||
ax.bar(decile_summary.index.astype(str), decile_summary["avg_r"], color=colors)
|
||||
ax.axhline(0, color="black", linewidth=0.5)
|
||||
ax.set_xlabel("Predicted-probability decile (1=lowest, 10=highest)")
|
||||
ax.set_ylabel("Average R per trade")
|
||||
ax.set_title(f"Trade outcome by predicted-probability decile ({best_name})")
|
||||
for i, (idx, row) in enumerate(decile_summary.iterrows()):
|
||||
ax.text(i, row["avg_r"] + (0.02 if row["avg_r"] >= 0 else -0.05),
|
||||
f"n={int(row['n'])}\nWR={row['actual_wr']*100:.0f}%",
|
||||
ha="center", fontsize=8)
|
||||
ax.grid(alpha=0.3, axis="y")
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_dir / "decile_returns.png", dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
# Equity comparison: trade-everything vs top-decile (test set, time-ordered)
|
||||
df_compare = df_te.copy().sort_values("entry_time").reset_index(drop=True)
|
||||
df_compare["p_win"] = p_best
|
||||
threshold = np.quantile(p_best, 1 - args.top_decile)
|
||||
df_compare["take_top"] = (df_compare["p_win"] >= threshold).astype(int)
|
||||
df_compare["base_cum"] = df_compare["r_multiple"].cumsum()
|
||||
df_compare["top_cum"] = (df_compare["r_multiple"] * df_compare["take_top"]).cumsum()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(11, 5))
|
||||
ax.plot(df_compare["entry_time"], df_compare["base_cum"],
|
||||
label="Trade everything (engine baseline)", linewidth=1.5)
|
||||
ax.plot(df_compare["entry_time"], df_compare["top_cum"],
|
||||
label=f"Top {int(args.top_decile*100)}% by p_win (meta-model)",
|
||||
linewidth=1.8)
|
||||
ax.axhline(0, color="gray", linewidth=0.5)
|
||||
ax.set_xlabel("Trade entry time")
|
||||
ax.set_ylabel("Cumulative R")
|
||||
ax.set_title(f"Cumulative R: engine baseline vs meta-model top-{int(args.top_decile*100)}% (test set)")
|
||||
ax.legend(); ax.grid(alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_dir / "equity_compare.png", dpi=120)
|
||||
plt.close(fig)
|
||||
|
||||
print(f"📁 Reports → {out_dir}/")
|
||||
print(f" • classification_report.txt")
|
||||
print(f" • feature_importances.txt")
|
||||
print(f" • decile_breakdown.csv")
|
||||
print(f" • summary.txt")
|
||||
print(f"📈 Plots:")
|
||||
print(f" • calibration.png")
|
||||
print(f" • decile_returns.png")
|
||||
print(f" • equity_compare.png")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user