Files
ForexBot2/backtest/research/sizing_model.py
T
Harwinder Sekhon cf2ad73343 research: 3-year stake-sizing campaign + violent-move studies (nothing changes signals)
Walk-forward "second opinion" stake multiplier (meta-labelling on 65 decision-time
features, 3yr Oanda books @ $0.65) FAILED the pre-registered rule on M5 (clean null)
and M15 (lift inside the label-shuffle null, explained by BUY-vs-SELL). Nothing that
changes numbers ships; the 12-month M5 book re-run is byte-identical to the pinned one.

Behaviour-neutral plumbing:
- position_sizing.build_features (65 features; multiplier path returns 1.0, no artifact)
- SignalReport.penalty_tags / penalty_total / momentum_fading (record-keeping)
- TradeSetup.stake_multiplier = None (reserved)
- gpu_backtest --export-features (default export stays the pinned 63 columns; extras
  forced to the tail even when the first trade lacks an indicator)

Research tools (backtest/research): book_report, burst_map, burst_precursors,
open_trade_bursts, calendar_impact, burst_indicator_map, sizing_model, watch_rule_eval.
Findings recorded in backtest/research/sizing_campaign_2026-09.md: M5 stretch measures
are positive in both halves; every open-trade protection against violent bars loses on
both TFs; high-impact USD releases cause 20-31% of violent bars but trades held through
them do better; violent bars come when price is already at an extreme, not from calm.

Module 29 tests (13). Suite: 435 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
2026-09-05 21:19:24 -04:00

962 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stage-D stake-sizing model driver (2026-09 campaign).
Walk-forward fit + evaluation of a per-signal stake multiplier on a trade
book exported by ``gpu_backtest.py --export-features`` (63 base columns +
``stake_multiplier``/``r_sized`` + 65 ``f_*`` decision-time features).
The protocol implemented here is the one pre-registered in
``backtest/research/sizing_campaign_2026-09.md`` and is NOT to be varied to
rescue a result:
* expanding walk-forward, 12-month burn-in, calendar-quarter refits, a
``purge_bars × bar_minutes`` gap between the end of train and the start of
the test quarter, standardisation fit on train only;
* models: ridge on winsorised R (primary), L2 logistic on ``R > 0``
(secondary), a one-feature ``f_is_buy`` simplicity baseline, an
exhaustion-block-only ablation, and an optional lightgbm upper bound;
* size map ``m = round_0.05(clip(normaliser × (0.5 + clip((z−lo)/(hi−lo),0,1)),
0.5, 1.5))`` with ``lo``/``hi``/``normaliser`` calibrated on TRAIN
predictions only;
* a label-shuffle null (training labels permuted, test labels untouched);
* six acceptance clauses, printed PASS/FAIL individually.
Usage:
python backtest/research/sizing_model.py --book BOOK.csv --bar-minutes 5 \
--features f_a,f_b,... [--model ridge|logistic] [--out DIR] \
[--n-shuffle 50] [--skip-lgbm] \
[--write-artifact PATH --tf 5min --confirm-tfs 15min,1h]
Analysis tooling only — never imported by the live code.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import subprocess
import sys
import warnings
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from position_sizing import FEATURE_ORDER, FEATURE_VERSION # noqa: E402 (read-only import)
# ── Pre-registered constants (flag any change loudly) ──────────────────
WINSOR_LO, WINSOR_HI = -1.5, 4.0
LAMBDA_GRID: Tuple[float, ...] = (1.0, 10.0, 100.0, 1000.0)
CV_FOLDS = 5
MIN_TRAIN = 150
MAP_Q_LO, MAP_Q_HI = 0.10, 0.90
MAP_OUT_LO, MAP_OUT_HI = 0.5, 1.5
MAP_CLIP_LO, MAP_CLIP_HI = 0.5, 1.5
MAP_ROUND_TO = 0.05
MEAN_MULT_TOL = 0.05
DD_TOL = 1.10
PF_FLOOR = 1.3
NEWTON_MAX_ITER = 50
NEWTON_TOL = 1e-8
#: The exhaustion-block ablation's fixed feature list.
EXHAUSTION_FEATURES: Tuple[str, ...] = (
"f_swing_dist_atr",
"f_ema21_dist_atr",
"f_ema200_dist_atr",
"f_exhaustion_count",
"f_penalty_total",
"f_tag_bb_overextended",
"f_tag_rsi_extended",
"f_tag_no_pullback",
"f_tag_exhaustion",
)
ISBUY_FEATURES: Tuple[str, ...] = ("f_is_buy",)
#: lightgbm never ships; running the full shuffle null through 200-tree fits
#: costs minutes for a number nobody acts on, so its null is capped here.
#: DEVIATION FROM THE PROTOCOL CONSTANT — printed in the summary when used.
LGBM_MAX_SHUFFLE = 10
LGBM_PARAMS = dict(
n_estimators=200,
learning_rate=0.03,
num_leaves=7,
min_child_samples=30,
subsample=0.8,
colsample_bytree=0.8,
reg_lambda=10,
)
_LINES: List[str] = []
def say(msg: str = "") -> None:
"""Print and tee into the summary buffer."""
print(msg)
_LINES.append(msg)
# ══════════════════════════════════════════════════════════════════════
# Book loading
# ══════════════════════════════════════════════════════════════════════
def load_book(path: Path) -> pd.DataFrame:
"""Read a feature book, parse ``entry_time`` UTC, drop NaN-R rows."""
df = pd.read_csv(path)
if "entry_time" not in df.columns or "r_multiple" not in df.columns:
raise SystemExit(f"{path}: not a trade book (no entry_time/r_multiple)")
df["entry_time"] = pd.to_datetime(df["entry_time"], utc=True, format="mixed")
n0 = len(df)
bad = ~np.isfinite(pd.to_numeric(df["r_multiple"], errors="coerce").to_numpy(float))
if bad.any():
say(f"WARNING: dropping {int(bad.sum())}/{n0} rows with non-finite r_multiple")
df = df.loc[~bad].copy()
df["r_multiple"] = df["r_multiple"].astype(float)
df = df.sort_values("entry_time", kind="mergesort").reset_index(drop=True)
return df
def resolve_features(df: pd.DataFrame, names: Sequence[str], label: str) -> List[str]:
"""Validate a requested feature list against the book and FEATURE_ORDER."""
missing = [n for n in names if n not in df.columns]
if missing:
raise SystemExit(f"{label}: columns absent from book: {', '.join(missing)}")
unknown = [n for n in names if n.startswith("f_") and n not in FEATURE_ORDER]
if unknown:
say(f"NOTE: {label}: {', '.join(unknown)} not in position_sizing.FEATURE_ORDER")
return list(names)
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def git_commit() -> str:
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=PROJECT_ROOT, text=True).strip()
except Exception: # pragma: no cover - research tool
return "unknown"
# ══════════════════════════════════════════════════════════════════════
# Walk-forward geometry
# ══════════════════════════════════════════════════════════════════════
def quarter_starts(first: pd.Timestamp, last: pd.Timestamp) -> List[pd.Timestamp]:
"""Every Jan/Apr/Jul/Oct 1 00:00 UTC in ``[first, last]`` inclusive."""
out: List[pd.Timestamp] = []
year = first.year
while True:
for month in (1, 4, 7, 10):
ts = pd.Timestamp(year=year, month=month, day=1, tz="UTC")
if ts < first:
continue
if ts > last:
return out
out.append(ts)
year += 1
def quarter_label(ts: pd.Timestamp) -> str:
return f"{ts.year}Q{(ts.month - 1) // 3 + 1}"
def build_folds(times: np.ndarray, n_folds: int) -> List[np.ndarray]:
"""Contiguous-by-time index blocks (rows are already time-sorted)."""
n = len(times)
edges = np.linspace(0, n, n_folds + 1).astype(int)
return [np.arange(edges[i], edges[i + 1]) for i in range(n_folds)
if edges[i + 1] > edges[i]]
# ══════════════════════════════════════════════════════════════════════
# Standardisation
# ══════════════════════════════════════════════════════════════════════
def fit_standardiser(X: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (mean, scale, keep) from a training matrix.
NaNs are ignored when computing the mean (they are later filled with it).
``scale`` is the population std with zeros replaced by 1.0; ``keep`` is
False for zero-variance-in-train columns, which are dropped from the fit
and carry a coefficient of 0.
"""
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
mean = np.nanmean(X, axis=0)
std = np.nanstd(X, axis=0)
mean = np.where(np.isfinite(mean), mean, 0.0)
std = np.where(np.isfinite(std), std, 0.0)
keep = std > 0
scale = np.where(keep, std, 1.0)
return mean, scale, keep
def apply_standardiser(X: np.ndarray, mean: np.ndarray, scale: np.ndarray) -> np.ndarray:
"""Fill NaN with the TRAIN mean, then standardise."""
Z = np.array(X, dtype=float, copy=True)
bad = ~np.isfinite(Z)
if bad.any():
Z[bad] = np.take(mean, np.where(bad)[1])
return (Z - mean) / scale
# ══════════════════════════════════════════════════════════════════════
# Models (numpy only)
# ══════════════════════════════════════════════════════════════════════
def _solve(A: np.ndarray, b: np.ndarray) -> np.ndarray:
try:
return np.linalg.solve(A, b)
except np.linalg.LinAlgError: # pragma: no cover - degenerate design
return np.linalg.lstsq(A, b, rcond=None)[0]
def fit_ridge(X: np.ndarray, y: np.ndarray, lam: float) -> Tuple[np.ndarray, float]:
"""Closed-form ridge with an UNPENALISED intercept.
Centring X and y is exactly equivalent to appending a 1-column carrying
zero penalty. Returns ``(coef, intercept)`` with ``pred = intercept + X @ coef``.
"""
if X.shape[1] == 0:
return np.zeros(0), float(y.mean()) if len(y) else 0.0
xm = X.mean(axis=0)
ym = float(y.mean())
Xc = X - xm
w = _solve(Xc.T @ Xc + lam * np.eye(X.shape[1]), Xc.T @ (y - ym))
return w, ym - float(xm @ w)
def fit_logistic(X: np.ndarray, y: np.ndarray, lam: float,
max_iter: int = NEWTON_MAX_ITER,
tol: float = NEWTON_TOL) -> Tuple[np.ndarray, float, float]:
"""L2 logistic via Newton-Raphson, intercept unpenalised.
Returns ``(coef, intercept, grad_norm)`` where ``grad_norm`` is the
infinity-norm of the penalised gradient at the returned point.
"""
n, p = X.shape
A = np.hstack([np.ones((n, 1)), X])
pen = np.zeros(p + 1)
pen[1:] = lam
beta = np.zeros(p + 1)
# Warm-start the intercept at the empirical log-odds.
frac = float(np.clip(y.mean(), 1e-6, 1 - 1e-6))
beta[0] = math.log(frac / (1 - frac))
grad = np.zeros(p + 1)
for _ in range(max_iter):
eta = A @ beta
prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -35, 35)))
grad = A.T @ (y - prob) - pen * beta
W = np.clip(prob * (1 - prob), 1e-10, None)
H = A.T @ (A * W[:, None]) + np.diag(pen)
step = _solve(H, grad)
beta = beta + step
if np.max(np.abs(step)) < tol:
break
eta = A @ beta
prob = 1.0 / (1.0 + np.exp(-np.clip(eta, -35, 35)))
grad = A.T @ (y - prob) - pen * beta
return beta[1:], float(beta[0]), float(np.max(np.abs(grad)))
def predict_linear(X: np.ndarray, coef: np.ndarray, intercept: float) -> np.ndarray:
if X.shape[1] == 0:
return np.full(len(X), intercept, dtype=float)
return intercept + X @ coef
def sigmoid(z: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(z, -35, 35)))
def log_loss(y: np.ndarray, p: np.ndarray) -> float:
p = np.clip(p, 1e-12, 1 - 1e-12)
return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
def choose_lambda(X: np.ndarray, y: np.ndarray, kind: str,
grid: Sequence[float] = LAMBDA_GRID,
n_folds: int = CV_FOLDS) -> Tuple[float, Dict[float, float]]:
"""5-fold time-contiguous CV inside the training window."""
folds = build_folds(np.arange(len(y)), n_folds)
if X.shape[1] == 0 or len(folds) < 2:
return float(grid[0]), {}
scores: Dict[float, float] = {}
for lam in grid:
vals: List[float] = []
for hold in folds:
mask = np.ones(len(y), dtype=bool)
mask[hold] = False
if mask.sum() < 2:
continue
if kind == "logistic":
ytr = y[mask]
if ytr.min() == ytr.max(): # single-class fold
p = np.full(len(hold), float(np.clip(ytr.mean(), 1e-6, 1 - 1e-6)))
else:
c, b, _ = fit_logistic(X[mask], ytr, lam)
p = sigmoid(predict_linear(X[hold], c, b))
vals.append(log_loss(y[hold], p))
else:
c, b = fit_ridge(X[mask], y[mask], lam)
resid = y[hold] - predict_linear(X[hold], c, b)
vals.append(float(np.mean(resid ** 2)))
scores[float(lam)] = float(np.mean(vals)) if vals else float("inf")
best = min(scores, key=lambda k: scores[k])
return best, scores
def lgbm_available() -> bool:
try:
import lightgbm # noqa: F401
return True
except Exception:
return False
def fit_lgbm(X: np.ndarray, y: np.ndarray, seed: int):
import lightgbm as lgb
model = lgb.LGBMRegressor(random_state=seed, verbose=-1, **LGBM_PARAMS)
model.fit(X, y)
return model
# ══════════════════════════════════════════════════════════════════════
# Size map
# ══════════════════════════════════════════════════════════════════════
def round_to(x, step: float = MAP_ROUND_TO) -> np.ndarray:
"""Round to the nearest ``step`` and kill float dust (0.05 → 2 dp)."""
decimals = max(0, int(round(-math.log10(step)))) + 1
return np.round(np.round(np.asarray(x, dtype=float) / step) * step, decimals)
def calibrate_map(z_train: np.ndarray) -> Dict[str, float]:
"""lo/hi/normaliser from TRAIN predictions only."""
lo = float(np.quantile(z_train, MAP_Q_LO))
hi = float(np.quantile(z_train, MAP_Q_HI))
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
return {"lo": lo, "hi": hi, "normalizer": 1.0, "degenerate": True}
raw = MAP_OUT_LO + (MAP_OUT_HI - MAP_OUT_LO) * np.clip((z_train - lo) / (hi - lo), 0, 1)
mean_raw = float(np.mean(raw))
norm = 1.0 / mean_raw if mean_raw > 0 else 1.0
return {"lo": lo, "hi": hi, "normalizer": norm, "degenerate": False}
def apply_map(z: np.ndarray, cal: Dict[str, float]) -> np.ndarray:
z = np.asarray(z, dtype=float)
if cal.get("degenerate"):
return np.ones(len(z))
lo, hi, norm = cal["lo"], cal["hi"], cal["normalizer"]
raw = MAP_OUT_LO + (MAP_OUT_HI - MAP_OUT_LO) * np.clip((z - lo) / (hi - lo), 0, 1)
return round_to(np.clip(raw * norm, MAP_CLIP_LO, MAP_CLIP_HI))
# ══════════════════════════════════════════════════════════════════════
# Metrics
# ══════════════════════════════════════════════════════════════════════
def max_drawdown(r: np.ndarray) -> float:
"""0-prepended cumulative-R drawdown (gpu_backtest.compute_metrics)."""
r = np.asarray(r, dtype=float)
if len(r) == 0:
return 0.0
cum = np.concatenate(([0.0], np.cumsum(r)))
return float((np.maximum.accumulate(cum) - cum).max())
def profit_factor(r: np.ndarray) -> float:
r = np.asarray(r, dtype=float)
gp = float(r[r > 0].sum()) if (r > 0).any() else 0.0
gl = float(-r[r < 0].sum()) if (r < 0).any() else 0.0
return gp / gl if gl > 0 else float("inf")
def multiplier_hist(m: np.ndarray) -> pd.DataFrame:
"""Counts in 0.1-wide bins from 0.5 to 1.5.
``np.histogram`` already closes the final bin on the right, so an
exactly-1.5 multiplier lands in [1.4, 1.5] once and the counts sum to
``len(m)``.
"""
edges = np.round(np.arange(MAP_OUT_LO, MAP_OUT_HI + 1e-9, 0.1), 2)
counts, _ = np.histogram(np.asarray(m, dtype=float), bins=edges)
counts = counts.astype(int)
return pd.DataFrame({
"bin_lo": edges[:-1], "bin_hi": edges[1:], "count": counts,
"pct": np.where(len(m), counts / max(len(m), 1) * 100, 0.0),
})
# ══════════════════════════════════════════════════════════════════════
# Walk-forward driver
# ══════════════════════════════════════════════════════════════════════
def walk_forward(df: pd.DataFrame, features: Sequence[str], model: str,
bar_minutes: int, burn_in_months: int, purge_bars: int,
seed: int, y_train_override: Optional[np.ndarray] = None,
verbose: bool = True) -> Dict:
"""One full expanding walk-forward pass.
``y_train_override`` (full-book length, aligned to ``df``) replaces the
TRAINING labels only — this is how the shuffle null is run. Test labels
always come from the real ``r_multiple``.
"""
times = df["entry_time"].to_numpy()
r_all = df["r_multiple"].to_numpy(float)
r_train_src = r_all if y_train_override is None else np.asarray(y_train_override, float)
X_all = df[list(features)].apply(pd.to_numeric, errors="coerce").to_numpy(float) \
if features else np.zeros((len(df), 0))
book_start = df["entry_time"].iloc[0]
book_end = df["entry_time"].iloc[-1]
burn_end = book_start + pd.DateOffset(months=burn_in_months)
purge = pd.Timedelta(minutes=purge_bars * bar_minutes)
qs = [q for q in quarter_starts(book_start, book_end) if q >= burn_end]
rows: List[Dict] = []
per_q: List[Dict] = []
coef_rows: List[Dict] = []
skipped: List[str] = []
dropped_seen: set = set()
for q_start in qs:
q_end = q_start + pd.DateOffset(months=3)
train_mask = df["entry_time"] < (q_start - purge)
test_mask = (df["entry_time"] >= q_start) & (df["entry_time"] < q_end)
n_tr, n_te = int(train_mask.sum()), int(test_mask.sum())
if n_tr < MIN_TRAIN or n_te == 0:
skipped.append(f"{quarter_label(q_start)} (train={n_tr}, test={n_te})")
continue
tr = np.flatnonzero(train_mask.to_numpy())
te = np.flatnonzero(test_mask.to_numpy())
Xtr_raw, Xte_raw = X_all[tr], X_all[te]
mean, scale, keep = fit_standardiser(Xtr_raw)
dropped = [features[i] for i in np.flatnonzero(~keep)]
if verbose and dropped:
tag = (quarter_label(q_start), tuple(dropped))
if tag not in dropped_seen:
dropped_seen.add(tag)
say(f" WARNING {quarter_label(q_start)}: zero variance in train, "
f"dropped from fit (coef 0): {', '.join(dropped)}")
Xtr = apply_standardiser(Xtr_raw, mean, scale)[:, keep]
Xte = apply_standardiser(Xte_raw, mean, scale)[:, keep]
r_tr = r_train_src[tr]
lam: Optional[float] = None
if model == "logistic":
y_tr = (r_tr > 0).astype(float)
lam, _ = choose_lambda(Xtr, y_tr, "logistic")
coef, b, _ = fit_logistic(Xtr, y_tr, lam)
z_tr = sigmoid(predict_linear(Xtr, coef, b))
z_te = sigmoid(predict_linear(Xte, coef, b))
elif model == "lgbm":
y_tr = np.clip(r_tr, WINSOR_LO, WINSOR_HI)
gbm = fit_lgbm(Xtr, y_tr, seed)
z_tr = np.asarray(gbm.predict(Xtr), dtype=float)
z_te = np.asarray(gbm.predict(Xte), dtype=float)
coef, b = np.zeros(int(keep.sum())), 0.0
else: # ridge / isbuy / exhaustion
y_tr = np.clip(r_tr, WINSOR_LO, WINSOR_HI)
lam, _ = choose_lambda(Xtr, y_tr, "ridge")
coef, b = fit_ridge(Xtr, y_tr, lam)
z_tr = predict_linear(Xtr, coef, b)
z_te = predict_linear(Xte, coef, b)
cal = calibrate_map(z_tr)
m_te = apply_map(z_te, cal)
if model != "lgbm":
full_coef = np.zeros(len(features))
full_coef[np.flatnonzero(keep)] = coef
for name, c in zip(features, full_coef):
coef_rows.append({"quarter": quarter_label(q_start), "feature": name,
"coef_std": float(c), "lambda": lam,
"dropped": name in dropped})
coef_rows.append({"quarter": quarter_label(q_start), "feature": "_intercept",
"coef_std": float(b), "lambda": lam, "dropped": False})
r_te = r_all[te]
for i, idx in enumerate(te):
rows.append({
"entry_time": df["entry_time"].iloc[idx],
"r_multiple": float(r_te[i]),
"mult": float(m_te[i]),
"r_sized": float(r_te[i] * m_te[i]),
"quarter": quarter_label(q_start),
"z": float(z_te[i]),
})
per_q.append({
"quarter": quarter_label(q_start),
"q_start": q_start, "n_train": n_tr, "n_test": n_te,
"flat_r": float(r_te.sum()), "sized_r": float((r_te * m_te).sum()),
"mean_m": float(m_te.mean()), "lambda": lam,
"map_lo": cal["lo"], "map_hi": cal["hi"], "normalizer": cal["normalizer"],
})
oos = pd.DataFrame(rows)
return {"oos": oos, "quarters": pd.DataFrame(per_q),
"coefs": pd.DataFrame(coef_rows), "skipped": skipped,
"quarter_starts": qs}
def evaluate(oos: pd.DataFrame) -> Dict:
"""Headline OOS numbers for one model's walk-forward output."""
if oos.empty:
return {"n": 0, "flat_r": 0.0, "sized_r": 0.0, "lift": 0.0}
r = oos["r_multiple"].to_numpy(float)
rs = oos["r_sized"].to_numpy(float)
m = oos["mult"].to_numpy(float)
med = oos["entry_time"].median()
first = (oos["entry_time"] < med).to_numpy()
second = ~first
return {
"n": int(len(oos)),
"flat_r": float(r.sum()), "sized_r": float(rs.sum()),
"lift": float(rs.sum() - r.sum()),
"flat_pf": profit_factor(r), "sized_pf": profit_factor(rs),
"flat_dd": max_drawdown(r), "sized_dd": max_drawdown(rs),
"mean_m": float(m.mean()), "min_m": float(m.min()), "max_m": float(m.max()),
"median_time": med,
"n_h1": int(first.sum()), "n_h2": int(second.sum()),
"flat_h1": float(r[first].sum()), "flat_h2": float(r[second].sum()),
"sized_h1": float(rs[first].sum()), "sized_h2": float(rs[second].sum()),
}
def acceptance(ev: Dict, real_lift: float, shuffle_p95: float) -> Dict:
"""The six pre-registered clauses."""
c1 = ev["sized_h1"] > ev["flat_h1"] and ev["sized_h2"] > ev["flat_h2"]
c2 = ev["sized_r"] > ev["flat_r"]
c3 = ev["sized_dd"] <= DD_TOL * ev["flat_dd"]
c4 = (ev["sized_pf"] >= PF_FLOOR) and ev["sized_h1"] > 0 and ev["sized_h2"] > 0
c5 = real_lift > shuffle_p95 if np.isfinite(shuffle_p95) else False
c6 = (abs(ev["mean_m"] - 1.0) <= MEAN_MULT_TOL
and ev["min_m"] >= MAP_CLIP_LO - 1e-9 and ev["max_m"] <= MAP_CLIP_HI + 1e-9)
clauses = {"1_both_halves": bool(c1), "2_overall": bool(c2), "3_drawdown": bool(c3),
"4_pf_and_halves": bool(c4), "5_beats_shuffle": bool(c5),
"6_multiplier_bounds": bool(c6)}
clauses["overall"] = bool(all(clauses.values()))
return clauses
def shuffle_null(df: pd.DataFrame, features: Sequence[str], model: str,
bar_minutes: int, burn_in_months: int, purge_bars: int,
n_shuffle: int, seed: int) -> np.ndarray:
"""Lift distribution under permuted TRAINING labels (test labels real)."""
rng = np.random.default_rng(seed)
r_all = df["r_multiple"].to_numpy(float)
lifts: List[float] = []
for _ in range(n_shuffle):
r_perm = r_all[rng.permutation(len(r_all))]
res = walk_forward(df, features, model, bar_minutes, burn_in_months,
purge_bars, seed, y_train_override=r_perm, verbose=False)
ev = evaluate(res["oos"])
lifts.append(ev["lift"])
return np.asarray(lifts, dtype=float)
# ══════════════════════════════════════════════════════════════════════
# Final fit + artifact
# ══════════════════════════════════════════════════════════════════════
def final_fit(df: pd.DataFrame, features: Sequence[str], model: str,
seed: int) -> Dict:
"""Fit on ALL trades; calibrate the map on in-sample predictions."""
X_raw = df[list(features)].apply(pd.to_numeric, errors="coerce").to_numpy(float)
r = df["r_multiple"].to_numpy(float)
mean, scale, keep = fit_standardiser(X_raw)
X = apply_standardiser(X_raw, mean, scale)[:, keep]
if model == "logistic":
y = (r > 0).astype(float)
lam, _ = choose_lambda(X, y, "logistic")
coef, b, gnorm = fit_logistic(X, y, lam)
z = sigmoid(predict_linear(X, coef, b))
target = "r_positive"
else:
y = np.clip(r, WINSOR_LO, WINSOR_HI)
lam, _ = choose_lambda(X, y, "ridge")
coef, b = fit_ridge(X, y, lam)
gnorm = float("nan")
z = predict_linear(X, coef, b)
target = "r_winsorised"
full_coef = np.zeros(len(features))
full_coef[np.flatnonzero(keep)] = coef
cal = calibrate_map(z)
return {"mean": mean, "scale": scale, "keep": keep, "coef": full_coef,
"intercept": float(b), "lambda": float(lam), "z": z,
"mult": apply_map(z, cal), "map": cal, "target": target,
"grad_norm": gnorm}
def predict_multiplier(feature_dict: Dict[str, float], artifact: Dict) -> float:
"""Runtime formula: standardise → linear score → size map → 0.05 grid."""
names = artifact["features"]
mean = artifact["mean"]
scale = artifact["scale"]
coef = artifact["coef"]
z = float(artifact["intercept"])
for i, name in enumerate(names):
raw = feature_dict.get(name, mean[i])
if raw is None or not np.isfinite(float(raw)):
raw = mean[i]
z += float(coef[i]) * (float(raw) - float(mean[i])) / float(scale[i])
if artifact["model"] == "logistic":
z = 1.0 / (1.0 + math.exp(-max(-35.0, min(35.0, z))))
mp = artifact["map"]
if mp["hi"] <= mp["lo"]:
return 1.0
frac = min(1.0, max(0.0, (z - mp["lo"]) / (mp["hi"] - mp["lo"])))
raw_m = mp["out_lo"] + (mp["out_hi"] - mp["out_lo"]) * frac
val = min(mp["clip_hi"], max(mp["clip_lo"], raw_m * mp["normalizer"]))
step = mp["round_to"]
return float(round(round(val / step) * step, 2))
def build_artifact(fit: Dict, features: Sequence[str], model: str, df: pd.DataFrame,
ev: Dict, clauses: Dict, real_lift: float, shuffle: np.ndarray,
n_shuffle: int, book: Path, tf: str, confirm_tfs: List[str],
burn_in_months: int, purge_bars: int, spread: float) -> Dict:
return {
"schema_version": 1,
"feature_version": FEATURE_VERSION,
"pair": "XAU/USD",
"primary_tf": tf,
"confirmation_tfs": confirm_tfs,
"model": model,
"target": fit["target"],
"features": list(features),
"mean": [float(v) for v in fit["mean"]],
"scale": [float(v) for v in fit["scale"]],
"coef": [float(v) for v in fit["coef"]],
"intercept": float(fit["intercept"]),
"map": {
"kind": "clipped_linear_on_quantiles",
"lo": float(fit["map"]["lo"]), "hi": float(fit["map"]["hi"]),
"out_lo": MAP_OUT_LO, "out_hi": MAP_OUT_HI,
"normalizer": float(fit["map"]["normalizer"]),
"clip_lo": MAP_CLIP_LO, "clip_hi": MAP_CLIP_HI,
"round_to": MAP_ROUND_TO,
},
"training": {
"window": [df["entry_time"].iloc[0].isoformat(),
df["entry_time"].iloc[-1].isoformat()],
"spread": spread,
"n_train_trades": int(len(df)),
"n_oos_trades": int(ev.get("n", 0)),
"burn_in_months": burn_in_months,
"purge_bars": purge_bars,
"refit": "calendar_quarter",
"final_fit": "full_window",
"lambda": float(fit["lambda"]),
"git_commit": git_commit(),
"book_sha256": sha256_file(book),
},
"acceptance": {
**{k: v for k, v in clauses.items()},
"flat_r": ev.get("flat_r"), "sized_r": ev.get("sized_r"),
"sized_pf": ev.get("sized_pf"), "flat_dd": ev.get("flat_dd"),
"sized_dd": ev.get("sized_dd"),
"sized_h1": ev.get("sized_h1"), "sized_h2": ev.get("sized_h2"),
"flat_h1": ev.get("flat_h1"), "flat_h2": ev.get("flat_h2"),
"real_lift": float(real_lift),
"shuffle_p95_lift": float(np.percentile(shuffle, 95)) if len(shuffle) else None,
"n_shuffle": int(n_shuffle),
"mean_multiplier": ev.get("mean_m"),
},
}
# ══════════════════════════════════════════════════════════════════════
# Reporting
# ══════════════════════════════════════════════════════════════════════
def fmt(x, nd=2, width=0) -> str:
if x is None:
return "n/a".rjust(width)
if isinstance(x, float) and not np.isfinite(x):
return ("inf" if x > 0 else "-inf").rjust(width)
s = f"{x:.{nd}f}" if isinstance(x, (int, float, np.floating)) else str(x)
return s.rjust(width) if width else s
def print_quarters(q: pd.DataFrame) -> None:
say(f"{'quarter':<9}{'n_tr':>6}{'n_te':>6}{'flat R':>10}{'sized R':>10}"
f"{'mean m':>9}{'lambda':>9}")
for _, row in q.iterrows():
say(f"{row['quarter']:<9}{int(row['n_train']):>6}{int(row['n_test']):>6}"
f"{fmt(row['flat_r'], 2, 10)}{fmt(row['sized_r'], 2, 10)}"
f"{fmt(row['mean_m'], 3, 9)}"
f"{('n/a' if row['lambda'] is None else fmt(row['lambda'], 0)):>9}")
def print_hist(m: np.ndarray) -> None:
h = multiplier_hist(m)
say(f"{'bin':<12}{'count':>8}{'pct':>8}")
for _, row in h.iterrows():
bar = "#" * int(round(row["pct"] / 2))
say(f"{row['bin_lo']:.1f}–{row['bin_hi']:.1f}{'':<4}{int(row['count']):>8}"
f"{row['pct']:>7.1f}% {bar}")
def print_coef_signs(coefs: pd.DataFrame, label: str) -> None:
"""Mean standardised coefficient + sign stability across quarters."""
if coefs.empty:
return
d = coefs[coefs["feature"] != "_intercept"]
say(f" standardised coefficients across quarters ({label}):")
say(f" {'feature':<24}{'mean':>10}{'min':>10}{'max':>10}{'+q':>5}{'-q':>5} sign-stable")
for name, grp in d.groupby("feature", sort=False):
c = grp["coef_std"].to_numpy(float)
pos, neg = int((c > 0).sum()), int((c < 0).sum())
stable = "yes" if (pos == 0 or neg == 0) and (pos + neg) > 0 else "no"
say(f" {name:<24}{c.mean():>10.4f}{c.min():>10.4f}{c.max():>10.4f}"
f"{pos:>5}{neg:>5} {stable}")
def print_comparison(table: List[Dict]) -> None:
say(f"{'model':<12}{'n':>6}{'flat R':>10}{'sized R':>10}{'lift':>9}"
f"{'PF':>7}{'DD':>8}{'mean m':>8}{'p95 null':>10}{'rank%':>7} pass")
for row in table:
say(f"{row['model']:<12}{row['n']:>6}{fmt(row['flat_r'], 2, 10)}"
f"{fmt(row['sized_r'], 2, 10)}{fmt(row['lift'], 2, 9)}"
f"{fmt(row['sized_pf'], 2, 7)}{fmt(row['sized_dd'], 2, 8)}"
f"{fmt(row['mean_m'], 3, 8)}{fmt(row['shuffle_p95'], 2, 10)}"
f"{fmt(row['rank_pct'], 0, 7)} {'PASS' if row['pass'] else 'FAIL'}")
# ══════════════════════════════════════════════════════════════════════
# main
# ══════════════════════════════════════════════════════════════════════
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--book", required=True, type=Path)
ap.add_argument("--bar-minutes", required=True, type=int)
ap.add_argument("--features", required=True,
help="comma-separated f_* column names for the main model")
ap.add_argument("--model", default="ridge", choices=("ridge", "logistic"))
ap.add_argument("--burn-in-months", type=int, default=12)
ap.add_argument("--purge-bars", type=int, default=300)
ap.add_argument("--n-shuffle", type=int, default=50)
ap.add_argument("--seed", type=int, default=20260905)
ap.add_argument("--skip-lgbm", action="store_true")
ap.add_argument("--out", type=Path, default=None)
ap.add_argument("--write-artifact", type=Path, default=None)
ap.add_argument("--tf", default=None, help="primary TF label for the artifact")
ap.add_argument("--confirm-tfs", default="15min,1h")
ap.add_argument("--spread", type=float, default=0.65,
help="cost the book was produced at (recorded in the artifact)")
args = ap.parse_args()
if args.write_artifact and not args.tf:
raise SystemExit("--write-artifact requires --tf")
say("=" * 96)
say("Stage-D stake-sizing walk-forward — RESEARCH ONLY")
say("=" * 96)
say(f"book : {args.book}")
df = load_book(args.book)
say(f"trades : {len(df)} "
f"({df['entry_time'].iloc[0]} → {df['entry_time'].iloc[-1]})")
say(f"bar minutes : {args.bar_minutes} purge = {args.purge_bars} bars = "
f"{args.purge_bars * args.bar_minutes} min "
f"({args.purge_bars * args.bar_minutes / 1440:.2f} d)")
say(f"burn-in : {args.burn_in_months} months "
f"(train-only until {df['entry_time'].iloc[0] + pd.DateOffset(months=args.burn_in_months)})")
say(f"model / seed : {args.model} / {args.seed} shuffles: {args.n_shuffle}")
say(f"winsor / lambda : [{WINSOR_LO}, {WINSOR_HI}] / {list(LAMBDA_GRID)} "
f"CV folds {CV_FOLDS} (time-contiguous)")
say(f"map : m = round_{MAP_ROUND_TO}(clip(norm × (0.5 + clip((z−lo)/(hi−lo),0,1)"
f"), {MAP_CLIP_LO}, {MAP_CLIP_HI})) lo/hi = train q{MAP_Q_LO}/q{MAP_Q_HI}")
say("")
main_feats = resolve_features(df, [f.strip() for f in args.features.split(",") if f.strip()],
"--features")
say(f"main features ({len(main_feats)}): {', '.join(main_feats)}")
specs: List[Tuple[str, List[str], str]] = [(args.model, main_feats, args.model)]
for label, feats in (("isbuy", ISBUY_FEATURES), ("exhaustion", EXHAUSTION_FEATURES)):
have = [f for f in feats if f in df.columns]
if len(have) != len(feats):
say(f"NOTE: {label} baseline missing "
f"{', '.join(f for f in feats if f not in df.columns)} — using {len(have)}")
if have:
specs.append((label, have, "ridge"))
use_lgbm = (not args.skip_lgbm) and lgbm_available()
if not args.skip_lgbm and not use_lgbm:
say("lightgbm not installed — upper bound skipped")
if use_lgbm:
all_f = [c for c in df.columns if c.startswith("f_")]
specs.append(("lgbm", all_f, "lgbm"))
say(f"lightgbm upper bound: {len(all_f)} f_* columns")
say("")
results: Dict[str, Dict] = {}
table: List[Dict] = []
for label, feats, kind in specs:
say("-" * 96)
say(f"MODEL: {label} ({kind}, {len(feats)} feature(s))")
say("-" * 96)
res = walk_forward(df, feats, kind, args.bar_minutes, args.burn_in_months,
args.purge_bars, args.seed, verbose=True)
if res["skipped"]:
say(f" skipped quarters: {'; '.join(res['skipped'])}")
if res["oos"].empty:
say(" no OOS quarters — nothing to evaluate")
continue
ev = evaluate(res["oos"])
n_sh = args.n_shuffle if kind != "lgbm" else min(args.n_shuffle, LGBM_MAX_SHUFFLE)
if kind == "lgbm" and n_sh != args.n_shuffle:
say(f" NOTE (protocol deviation, flagged): lgbm shuffle null capped at "
f"{n_sh} repeats (never ships)")
sh = shuffle_null(df, feats, kind, args.bar_minutes, args.burn_in_months,
args.purge_bars, n_sh, args.seed) if n_sh > 0 else np.array([])
p95 = float(np.percentile(sh, 95)) if len(sh) else float("nan")
rank = float((sh < ev["lift"]).mean() * 100) if len(sh) else float("nan")
cl = acceptance(ev, ev["lift"], p95)
say(f" OOS quarters : {len(res['quarters'])} "
f"({res['quarters']['quarter'].iloc[0]} → {res['quarters']['quarter'].iloc[-1]}), "
f"{ev['n']} trades")
say(f" flat R : {ev['flat_r']:+.2f} PF {fmt(ev['flat_pf'])} "
f"maxDD {ev['flat_dd']:.2f}")
say(f" sized R : {ev['sized_r']:+.2f} PF {fmt(ev['sized_pf'])} "
f"maxDD {ev['sized_dd']:.2f}")
say(f" lift : {ev['lift']:+.2f} R")
say(f" halves (split {ev['median_time']}): "
f"h1 flat {ev['flat_h1']:+.2f} / sized {ev['sized_h1']:+.2f} (n={ev['n_h1']}) | "
f"h2 flat {ev['flat_h2']:+.2f} / sized {ev['sized_h2']:+.2f} (n={ev['n_h2']})")
say(f" multiplier : mean {ev['mean_m']:.3f} min {ev['min_m']:.2f} "
f"max {ev['max_m']:.2f}")
say(f" shuffle null : n={len(sh)} mean {fmt(float(sh.mean()) if len(sh) else None)} "
f"p95 {fmt(p95)} real-lift percentile rank {fmt(rank, 0)}%")
say(" acceptance:")
for k, v in cl.items():
if k == "overall":
continue
say(f" {k:<22} {'PASS' if v else 'FAIL'}")
say(f" {'OVERALL':<22} {'PASS' if cl['overall'] else 'FAIL'}")
say(" per-quarter:")
print_quarters(res["quarters"])
print_coef_signs(res["coefs"], label)
say("")
results[label] = {"res": res, "ev": ev, "shuffle": sh, "p95": p95,
"rank": rank, "clauses": cl, "features": feats, "kind": kind}
table.append({"model": label, "n": ev["n"], "flat_r": ev["flat_r"],
"sized_r": ev["sized_r"], "lift": ev["lift"],
"sized_pf": ev["sized_pf"], "sized_dd": ev["sized_dd"],
"mean_m": ev["mean_m"], "shuffle_p95": p95, "rank_pct": rank,
"pass": cl["overall"]})
if args.model not in results:
raise SystemExit("the main model produced no OOS quarters — check burn-in / "
"book window / MIN_TRAIN")
say("=" * 96)
say("COMPARISON")
say("=" * 96)
print_comparison(table)
say("")
say(f"OOS multiplier histogram ({args.model}):")
print_hist(results[args.model]["res"]["oos"]["mult"].to_numpy())
say("")
main_label = args.model
beats = None
if main_label in results and "isbuy" in results:
beats = results[main_label]["ev"]["lift"] > results["isbuy"]["ev"]["lift"]
say(f"beats isbuy baseline: {'yes' if beats else 'no'} "
f"({results[main_label]['ev']['lift']:+.2f} R vs "
f"{results['isbuy']['ev']['lift']:+.2f} R)")
say(f"OVERALL ACCEPTANCE ({main_label}): "
f"{'PASS' if results[main_label]['clauses']['overall'] else 'FAIL'}")
say("")
# ── artifact ──────────────────────────────────────────────────────
artifact = None
if args.write_artifact:
say("-" * 96)
say("FINAL FIT (all trades) + ARTIFACT")
say("-" * 96)
fit = final_fit(df, main_feats, args.model, args.seed)
say(f" lambda (CV on full window): {fit['lambda']:g}")
if np.isfinite(fit["grad_norm"]):
say(f" logistic |grad|_inf at convergence: {fit['grad_norm']:.3e}")
say(f" map lo/hi/normaliser: {fit['map']['lo']:.5f} / {fit['map']['hi']:.5f} / "
f"{fit['map']['normalizer']:.5f}")
artifact = build_artifact(
fit, main_feats, args.model, df, results[main_label]["ev"],
results[main_label]["clauses"], results[main_label]["ev"]["lift"],
results[main_label]["shuffle"], args.n_shuffle, args.book, args.tf,
[t.strip() for t in args.confirm_tfs.split(",") if t.strip()],
args.burn_in_months, args.purge_bars, args.spread)
# Self-check: the runtime formula must reproduce the in-sample map exactly.
rows = df[main_feats].to_dict("records")
repro = np.array([predict_multiplier(r, artifact) for r in rows])
exact = bool(np.array_equal(repro, fit["mult"]))
say(f" predict_multiplier reproduces in-sample multipliers exactly: "
f"{'YES' if exact else 'NO'} (max |diff| "
f"{float(np.max(np.abs(repro - fit['mult']))):.3e})")
if not exact:
raise SystemExit("predict_multiplier does not reproduce the final fit — abort")
args.write_artifact.parent.mkdir(parents=True, exist_ok=True)
args.write_artifact.write_text(json.dumps(artifact, indent=2, default=str))
say(f" wrote {args.write_artifact}")
say("")
# ── outputs ───────────────────────────────────────────────────────
if args.out:
out = args.out
out.mkdir(parents=True, exist_ok=True)
main_res = results[main_label]
main_res["res"]["oos"].to_csv(out / "oos_trades.csv", index=False)
main_res["res"]["quarters"].to_csv(out / "quarters.csv", index=False)
coefs = pd.concat(
[r["res"]["coefs"].assign(model=lbl) for lbl, r in results.items()
if not r["res"]["coefs"].empty],
ignore_index=True) if results else pd.DataFrame()
coefs.to_csv(out / "coefs_by_quarter.csv", index=False)
pd.DataFrame({
"model": np.repeat([lbl for lbl in results], [len(r["shuffle"]) for r in results.values()]),
"lift": np.concatenate([r["shuffle"] for r in results.values()])
if results else np.array([]),
}).to_csv(out / "shuffle_lifts.csv", index=False)
multiplier_hist(main_res["res"]["oos"]["mult"].to_numpy()).to_csv(
out / "multiplier_hist.csv", index=False)
payload = {
"book": str(args.book), "book_sha256": sha256_file(args.book),
"git_commit": git_commit(), "bar_minutes": args.bar_minutes,
"model": args.model, "features": main_feats, "seed": args.seed,
"n_shuffle": args.n_shuffle, "burn_in_months": args.burn_in_months,
"purge_bars": args.purge_bars,
"beats_isbuy": beats,
"models": {
lbl: {
"kind": r["kind"], "features": r["features"],
"evaluation": {k: (None if isinstance(v, float) and not np.isfinite(v)
else (str(v) if isinstance(v, pd.Timestamp) else v))
for k, v in r["ev"].items()},
"shuffle": {"n": int(len(r["shuffle"])),
"mean": float(r["shuffle"].mean()) if len(r["shuffle"]) else None,
"p95": None if not np.isfinite(r["p95"]) else r["p95"],
"real_lift_percentile": None if not np.isfinite(r["rank"])
else r["rank"]},
"acceptance": r["clauses"],
} for lbl, r in results.items()
},
"artifact": artifact,
}
(out / "results.json").write_text(json.dumps(payload, indent=2, default=str))
(out / "summary.txt").write_text("\n".join(_LINES) + "\n")
print(f"\nwrote outputs to {out}")
if __name__ == "__main__":
main()