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. Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
"""Full descriptive report of a backtest trade book (research only).
|
||||
|
||||
Usage:
|
||||
python backtest/research/book_report.py --book BOOK.csv --bar-minutes N \
|
||||
[--split YYYY-MM-DD|median] [--parquet-4h PATH] [--parquet-1d PATH] \
|
||||
[--out DIR]
|
||||
|
||||
Prints (and writes to DIR/report.txt) headline metrics for the whole book
|
||||
and for each half, group tables (weekday / decision hour / session / quality
|
||||
/ regime / vol_regime / direction / strategy / exit_reason / indicators_agree),
|
||||
win-loss streaks, a rolling win-rate summary, and a quantile-bucket screen of
|
||||
every numeric decision-time feature the book carries (`f_*` / `x_*` columns
|
||||
plus score, confidence, rr_planned, indicators_agree/oppose and `ind_*_value`).
|
||||
|
||||
The screen is deliberately half-split: a feature only "passes" when the
|
||||
top-bucket-minus-bottom-bucket mean-R gap has the SAME SIGN in both halves of
|
||||
the book and both extreme buckets are populated in both halves. Bin edges are
|
||||
fit on the WHOLE book (so the halves share edges and stay comparable) — that
|
||||
leaks a little cross-half information into the edges themselves, which is why
|
||||
the sign test, not the size of the gap, is the thing to trust.
|
||||
|
||||
Everything here is measurement, not simulation: it never re-runs the signal
|
||||
engine, it only describes a book that gpu_backtest.py already produced.
|
||||
Analysis tooling only — never imported by the live code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
SESSIONS = (
|
||||
("asian", 3, 6),
|
||||
("london", 7, 12),
|
||||
("overlap", 13, 16),
|
||||
("ny", 17, 22),
|
||||
)
|
||||
|
||||
# Numeric non-`f_`/`x_` columns that are known at DECISION time and are
|
||||
# therefore legal screen candidates. bars_held / r_multiple / mfe_r / mae_r
|
||||
# are post-entry outcomes and must never appear here.
|
||||
BASE_SCREEN_COLS = ("score", "confidence", "rr_planned",
|
||||
"indicators_agree", "indicators_oppose")
|
||||
|
||||
|
||||
def session_of(hour: int) -> str:
|
||||
for name, lo, hi in SESSIONS:
|
||||
if lo <= hour <= hi:
|
||||
return name
|
||||
return "off"
|
||||
|
||||
|
||||
def feature_columns(df: pd.DataFrame) -> list[str]:
|
||||
"""Decision-time feature columns carried by this book, generically."""
|
||||
extra = [c for c in df.columns if c.startswith(("f_", "x_"))]
|
||||
base = [c for c in BASE_SCREEN_COLS if c in df.columns]
|
||||
ind = [c for c in df.columns
|
||||
if c.startswith("ind_") and c.endswith("_value")]
|
||||
out, seen = [], set()
|
||||
for c in extra + base + ind:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
# ───────────────────────────── loading ──────────────────────────────
|
||||
|
||||
def load_book(csv: str, bar_minutes: int = 5,
|
||||
split: str = "median") -> pd.DataFrame:
|
||||
"""Read a gpu_backtest trade book and add the decision-time columns.
|
||||
|
||||
`decision_time` = entry_time − one bar: entries fill at the NEXT bar's
|
||||
open, so the bar the signal actually decided on closed one bar earlier.
|
||||
That is the clock blocked_hours_utc and every session filter evaluate.
|
||||
`half` is 1 before the split instant and 2 at/after it.
|
||||
"""
|
||||
df = pd.read_csv(csv)
|
||||
for col in ("entry_time", "exit_time"):
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_datetime(df[col], utc=True, format="mixed")
|
||||
df["decision_time"] = df["entry_time"] - pd.Timedelta(minutes=bar_minutes)
|
||||
df["decision_hour"] = df["decision_time"].dt.hour
|
||||
df["weekday"] = df["decision_time"].dt.weekday
|
||||
df["session"] = df["decision_hour"].map(session_of)
|
||||
df = df.sort_values("entry_time", kind="mergesort").reset_index(drop=True)
|
||||
df["half"] = _halves(df["entry_time"], split)
|
||||
return df
|
||||
|
||||
|
||||
def split_instant(entry_time: pd.Series, split: str) -> pd.Timestamp:
|
||||
if split == "median":
|
||||
return entry_time.median()
|
||||
return pd.Timestamp(split, tz="UTC")
|
||||
|
||||
|
||||
def _halves(entry_time: pd.Series, split: str) -> pd.Series:
|
||||
cut = split_instant(entry_time, split)
|
||||
return np.where(entry_time < cut, 1, 2)
|
||||
|
||||
|
||||
# ───────────────────────────── metrics ──────────────────────────────
|
||||
|
||||
def base_metrics(r: pd.Series, df: pd.DataFrame | None = None) -> dict:
|
||||
"""Headline stats for a series of R multiples.
|
||||
|
||||
max_dd uses the gpu_backtest.compute_metrics convention: a 0.0 is
|
||||
prepended to the cumulative-R curve so an opening losing streak counts
|
||||
as drawdown from flat, not from an already-negative first trade.
|
||||
pf/expectancy split on r > 0 / r < 0 (exact zeros are neither).
|
||||
"""
|
||||
r = pd.Series(r, dtype="float64").dropna()
|
||||
n = int(len(r))
|
||||
if n == 0:
|
||||
return {"n": 0, "total_r": 0.0, "mean_r": 0.0, "median_r": 0.0,
|
||||
"wr": 0.0, "pf": 0.0, "max_dd": 0.0, "sharpe": 0.0,
|
||||
"expectancy": 0.0, "expectancy_alt": 0.0, "recovery": 0.0,
|
||||
"avg_bars": float("nan"), "avg_mfe": float("nan"),
|
||||
"avg_mae": float("nan")}
|
||||
wins, losses = r[r > 0], r[r < 0]
|
||||
gross_win = float(wins.sum())
|
||||
gross_loss = float(-losses.sum())
|
||||
cum = np.concatenate(([0.0], np.cumsum(r.to_numpy())))
|
||||
max_dd = float((np.maximum.accumulate(cum) - cum).max())
|
||||
std = float(r.std(ddof=0))
|
||||
mean_r = float(r.mean())
|
||||
wr = len(wins) / n
|
||||
lr = len(losses) / n
|
||||
avg_win = float(wins.mean()) if len(wins) else 0.0
|
||||
avg_loss = float(-losses.mean()) if len(losses) else 0.0
|
||||
expectancy_alt = wr * avg_win - lr * avg_loss
|
||||
# Identity check: the textbook expectancy form must equal the plain mean
|
||||
# whenever no trade is exactly flat (and it does even then, since a 0 R
|
||||
# trade contributes 0 to both sides).
|
||||
assert math.isclose(expectancy_alt, mean_r, rel_tol=1e-9, abs_tol=1e-9), (
|
||||
f"expectancy mismatch: {expectancy_alt} vs mean {mean_r}")
|
||||
out = {
|
||||
"n": n,
|
||||
"total_r": float(r.sum()),
|
||||
"mean_r": mean_r,
|
||||
"median_r": float(r.median()),
|
||||
"wr": wr * 100.0,
|
||||
"pf": (gross_win / gross_loss) if gross_loss > 0 else float("inf"),
|
||||
"max_dd": max_dd,
|
||||
"sharpe": (mean_r / std) if std > 0 else 0.0,
|
||||
"expectancy": mean_r,
|
||||
"expectancy_alt": expectancy_alt,
|
||||
"recovery": (float(r.sum()) / max_dd) if max_dd > 0 else float("inf"),
|
||||
"avg_bars": float("nan"),
|
||||
"avg_mfe": float("nan"),
|
||||
"avg_mae": float("nan"),
|
||||
}
|
||||
if df is not None:
|
||||
for key, col in (("avg_bars", "bars_held"), ("avg_mfe", "mfe_r"),
|
||||
("avg_mae", "mae_r")):
|
||||
if col in df.columns:
|
||||
out[key] = float(pd.to_numeric(df[col],
|
||||
errors="coerce").mean())
|
||||
return out
|
||||
|
||||
|
||||
def _pf(r: pd.Series) -> float:
|
||||
"""Profit factor, hour_analysis-compatible (loss side is r <= 0)."""
|
||||
pos = r[r > 0].sum()
|
||||
neg = -r[r <= 0].sum()
|
||||
return float(pos / max(neg, 1e-9))
|
||||
|
||||
|
||||
def group_table(df: pd.DataFrame, by, min_n: int = 10) -> pd.DataFrame:
|
||||
"""Per-group P&L with a half-1 / half-2 sign-stability flag."""
|
||||
keys = [by] if isinstance(by, str) else list(by)
|
||||
rows = []
|
||||
for key, sub in df.groupby(keys, dropna=False, sort=True):
|
||||
r = sub["r_multiple"]
|
||||
h1, h2 = sub[sub["half"] == 1]["r_multiple"], sub[sub["half"] == 2]["r_multiple"]
|
||||
m1 = float(h1.mean()) if len(h1) else float("nan")
|
||||
m2 = float(h2.mean()) if len(h2) else float("nan")
|
||||
stable = bool(len(h1) >= min_n and len(h2) >= min_n
|
||||
and np.sign(m1) != 0 and np.sign(m1) == np.sign(m2))
|
||||
rec = {}
|
||||
# pandas >= 2.2 hands back a 1-tuple even for a single group key.
|
||||
vals = key if isinstance(key, tuple) else (key,)
|
||||
for k, v in zip(keys, vals):
|
||||
rec[k] = v
|
||||
rec.update({
|
||||
"n": int(len(sub)),
|
||||
"total_r": round(float(r.sum()), 2),
|
||||
"mean_r": round(float(r.mean()), 3),
|
||||
"wr": round(float((r > 0).mean() * 100), 2),
|
||||
"pf": round(_pf(r), 2),
|
||||
"n_h1": int(len(h1)),
|
||||
"total_r_h1": round(float(h1.sum()), 2),
|
||||
"mean_r_h1": round(m1, 3) if len(h1) else float("nan"),
|
||||
"n_h2": int(len(h2)),
|
||||
"total_r_h2": round(float(h2.sum()), 2),
|
||||
"mean_r_h2": round(m2, 3) if len(h2) else float("nan"),
|
||||
"sign_stable": stable,
|
||||
})
|
||||
rows.append(rec)
|
||||
out = pd.DataFrame(rows)
|
||||
if not out.empty:
|
||||
out = out.set_index(keys)
|
||||
return out
|
||||
|
||||
|
||||
def streaks(df: pd.DataFrame) -> dict:
|
||||
"""Longest win/loss runs and the longest stretch under water (in trades)."""
|
||||
d = df.sort_values("entry_time", kind="mergesort")
|
||||
r = d["r_multiple"].to_numpy(dtype="float64")
|
||||
best_w = best_l = cur_w = cur_l = 0
|
||||
for x in r:
|
||||
if x > 0:
|
||||
cur_w, cur_l = cur_w + 1, 0
|
||||
elif x < 0:
|
||||
cur_l, cur_w = cur_l + 1, 0
|
||||
else:
|
||||
cur_w = cur_l = 0
|
||||
best_w, best_l = max(best_w, cur_w), max(best_l, cur_l)
|
||||
cum = np.concatenate(([0.0], np.cumsum(r)))
|
||||
peak = np.maximum.accumulate(cum)
|
||||
flat, longest = 0, 0
|
||||
for i in range(1, len(cum)):
|
||||
flat = 0 if cum[i] >= peak[i] else flat + 1
|
||||
longest = max(longest, flat)
|
||||
return {"max_win_streak": best_w, "max_loss_streak": best_l,
|
||||
"longest_flat_trades": longest}
|
||||
|
||||
|
||||
def rolling_wr(df: pd.DataFrame, window: int = 20) -> pd.Series:
|
||||
d = df.sort_values("entry_time", kind="mergesort")
|
||||
s = (d["r_multiple"] > 0).astype(float).rolling(window).mean() * 100
|
||||
s.index = d["entry_time"].to_numpy()
|
||||
return s
|
||||
|
||||
|
||||
def equity_curve(df: pd.DataFrame) -> pd.DataFrame:
|
||||
d = df.sort_values("entry_time", kind="mergesort")
|
||||
cum = d["r_multiple"].cumsum()
|
||||
peak = pd.concat([pd.Series([0.0]), cum]).cummax().iloc[1:]
|
||||
peak.index = cum.index
|
||||
return pd.DataFrame({"entry_time": d["entry_time"].to_numpy(),
|
||||
"cum_r": cum.to_numpy(),
|
||||
"drawdown": (peak - cum).to_numpy()})
|
||||
|
||||
|
||||
# ─────────────────────── quantile bucket screen ─────────────────────
|
||||
|
||||
def quantile_buckets(df: pd.DataFrame, col: str, q: int = 5,
|
||||
min_n: int = 10) -> pd.DataFrame:
|
||||
"""Equal-frequency buckets of `col` with per-half mean R.
|
||||
|
||||
Bin edges are fit on the WHOLE book (both halves share edges).
|
||||
Result carries gap_h1 / gap_h2 / sign_stable in `.attrs` and as
|
||||
repeated columns so the CSV is self-describing.
|
||||
"""
|
||||
v = pd.to_numeric(df.get(col), errors="coerce")
|
||||
d = df.loc[v.notna()].copy()
|
||||
d["_v"] = v.loc[v.notna()]
|
||||
empty = pd.DataFrame(columns=["bin", "lo", "hi", "n", "mean_r", "pf", "wr",
|
||||
"n_h1", "mean_r_h1", "n_h2", "mean_r_h2",
|
||||
"gap_h1", "gap_h2", "sign_stable"])
|
||||
empty.attrs = {"feature": col, "gap_h1": float("nan"),
|
||||
"gap_h2": float("nan"), "sign_stable": False, "n": 0,
|
||||
"mode": "empty"}
|
||||
if d.empty:
|
||||
return empty
|
||||
|
||||
nuniq = int(d["_v"].nunique())
|
||||
if nuniq < 3:
|
||||
mode = "values"
|
||||
d["_bin"] = d["_v"]
|
||||
order = sorted(d["_v"].unique())
|
||||
edges = {b: (float(b), float(b)) for b in order}
|
||||
else:
|
||||
mode = "qcut"
|
||||
binned = pd.qcut(d["_v"], q, duplicates="drop")
|
||||
d["_bin"] = binned
|
||||
order = list(binned.cat.categories)
|
||||
edges = {b: (float(b.left), float(b.right)) for b in order}
|
||||
|
||||
rows = []
|
||||
for b in order:
|
||||
sub = d[d["_bin"] == b]
|
||||
if sub.empty:
|
||||
continue
|
||||
r = sub["r_multiple"]
|
||||
h1 = sub[sub["half"] == 1]["r_multiple"]
|
||||
h2 = sub[sub["half"] == 2]["r_multiple"]
|
||||
lo, hi = edges[b]
|
||||
rows.append({
|
||||
"bin": str(b), "lo": round(lo, 4), "hi": round(hi, 4),
|
||||
"n": int(len(sub)), "mean_r": round(float(r.mean()), 3),
|
||||
"pf": round(_pf(r), 2),
|
||||
"wr": round(float((r > 0).mean() * 100), 2),
|
||||
"n_h1": int(len(h1)),
|
||||
"mean_r_h1": round(float(h1.mean()), 3) if len(h1) else float("nan"),
|
||||
"n_h2": int(len(h2)),
|
||||
"mean_r_h2": round(float(h2.mean()), 3) if len(h2) else float("nan"),
|
||||
})
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return empty
|
||||
|
||||
bot, top = out.iloc[0], out.iloc[-1]
|
||||
gap_h1 = float(top["mean_r_h1"] - bot["mean_r_h1"])
|
||||
gap_h2 = float(top["mean_r_h2"] - bot["mean_r_h2"])
|
||||
populated = min(bot["n_h1"], bot["n_h2"], top["n_h1"], top["n_h2"]) >= min_n
|
||||
signs_ok = (not math.isnan(gap_h1) and not math.isnan(gap_h2)
|
||||
and np.sign(gap_h1) != 0
|
||||
and np.sign(gap_h1) == np.sign(gap_h2))
|
||||
sign_stable = bool(populated and signs_ok and len(out) >= 2)
|
||||
out["gap_h1"] = round(gap_h1, 3) if not math.isnan(gap_h1) else float("nan")
|
||||
out["gap_h2"] = round(gap_h2, 3) if not math.isnan(gap_h2) else float("nan")
|
||||
out["sign_stable"] = sign_stable
|
||||
out.attrs = {"feature": col, "gap_h1": gap_h1, "gap_h2": gap_h2,
|
||||
"sign_stable": sign_stable, "n": int(len(d)), "mode": mode}
|
||||
return out
|
||||
|
||||
|
||||
def feature_screen(df: pd.DataFrame, q: int = 5, min_n: int = 10,
|
||||
columns: Iterable[str] | None = None) -> pd.DataFrame:
|
||||
"""Bucket screen over every decision-time numeric feature in the book."""
|
||||
cols = list(columns) if columns is not None else feature_columns(df)
|
||||
rows = []
|
||||
for c in cols:
|
||||
bk = quantile_buckets(df, c, q=q, min_n=min_n)
|
||||
a = bk.attrs
|
||||
stable = bool(a.get("sign_stable"))
|
||||
g1, g2 = a.get("gap_h1", float("nan")), a.get("gap_h2", float("nan"))
|
||||
min_abs = (min(abs(g1), abs(g2)) if stable else 0.0)
|
||||
rows.append({"feature": c,
|
||||
"gap_h1": round(g1, 3) if g1 == g1 else float("nan"),
|
||||
"gap_h2": round(g2, 3) if g2 == g2 else float("nan"),
|
||||
"min_abs_gap": round(float(min_abs), 3),
|
||||
"sign_stable": stable,
|
||||
"n": int(a.get("n", 0)),
|
||||
"bins": int(len(bk))})
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return out.sort_values(["min_abs_gap", "n"],
|
||||
ascending=[False, False]).reset_index(drop=True)
|
||||
|
||||
|
||||
# ────────────────────── higher-timeframe extension ──────────────────
|
||||
|
||||
def wilder_atr(bars: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
h, l, c = bars["High"], bars["Low"], bars["Close"]
|
||||
tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
|
||||
axis=1).max(axis=1)
|
||||
return tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
|
||||
|
||||
|
||||
def _ext_series(parquet: str, minutes: int) -> pd.DataFrame:
|
||||
bars = pd.read_parquet(parquet).sort_index()
|
||||
bars = bars[~bars.index.duplicated(keep="last")]
|
||||
ext = ((bars["Close"] - bars["Close"].rolling(20).mean())
|
||||
/ wilder_atr(bars, 14))
|
||||
out = pd.DataFrame({
|
||||
"close_time": bars.index + pd.Timedelta(minutes=minutes),
|
||||
"ext": ext.to_numpy(),
|
||||
}).dropna().reset_index(drop=True)
|
||||
# Books parse to datetime64[us, UTC] (format="mixed"), parquet indexes are
|
||||
# [ns, UTC]; merge_asof refuses mismatched resolutions.
|
||||
out["close_time"] = out["close_time"].astype("datetime64[ns, UTC]")
|
||||
return out
|
||||
|
||||
|
||||
def add_htf_extension(df: pd.DataFrame, parquet_4h: str | None,
|
||||
parquet_1d: str | None) -> pd.DataFrame:
|
||||
"""Add direction-signed H4 / D1 stretch-from-mean in ATR units.
|
||||
|
||||
ext = (close − 20-bar SMA of close) / Wilder ATR(14) on that timeframe,
|
||||
taken from the LAST higher-TF bar whose CLOSE time (bar label + one bar)
|
||||
is <= decision_time — i.e. only information the live scan could have had.
|
||||
Signed +1 for BUY, −1 for SELL, so positive always means "extended in the
|
||||
direction of the trade".
|
||||
"""
|
||||
out = df.copy()
|
||||
sign = np.where(out["direction"].astype(str).str.upper().str.startswith("B"),
|
||||
1.0, -1.0)
|
||||
for col, parquet, minutes in (("x_h4_ext_atr", parquet_4h, 240),
|
||||
("x_d1_ext_atr", parquet_1d, 1440)):
|
||||
if not parquet:
|
||||
continue
|
||||
right = _ext_series(parquet, minutes)
|
||||
left = out[["decision_time"]].reset_index()
|
||||
left["decision_time"] = left["decision_time"].astype(
|
||||
"datetime64[ns, UTC]")
|
||||
left = left.sort_values("decision_time", kind="mergesort")
|
||||
merged = pd.merge_asof(left, right, left_on="decision_time",
|
||||
right_on="close_time", direction="backward")
|
||||
merged["_stale_h"] = ((merged["decision_time"] - merged["close_time"])
|
||||
.dt.total_seconds() / 3600.0)
|
||||
merged = merged.set_index("index").reindex(out.index)
|
||||
out[col] = merged["ext"].to_numpy() * sign
|
||||
out.attrs[f"{col}_max_stale_h"] = float(
|
||||
merged["_stale_h"].max()) if len(merged) else float("nan")
|
||||
return out
|
||||
|
||||
|
||||
# ──────────────────────────── reporting ─────────────────────────────
|
||||
|
||||
def _fmt(v, nd=3):
|
||||
if isinstance(v, float):
|
||||
if math.isinf(v):
|
||||
return "inf"
|
||||
if math.isnan(v):
|
||||
return "n/a"
|
||||
return f"{v:.{nd}f}"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _metrics_block(name: str, m: dict) -> list[str]:
|
||||
return [
|
||||
f" {name:<10} n={m['n']:<5} totalR={_fmt(m['total_r'], 2):>8} "
|
||||
f"meanR={_fmt(m['mean_r'])} medR={_fmt(m['median_r'])} "
|
||||
f"WR={_fmt(m['wr'], 2)}% PF={_fmt(m['pf'], 2)}",
|
||||
f" {'':<10} maxDD={_fmt(m['max_dd'], 2)} recovery={_fmt(m['recovery'], 2)}"
|
||||
f" sharpe={_fmt(m['sharpe'])} expectancy={_fmt(m['expectancy'])}"
|
||||
f" (wr·avgW − lr·avgL = {_fmt(m['expectancy_alt'])})",
|
||||
f" {'':<10} avg bars={_fmt(m['avg_bars'], 1)} "
|
||||
f"avg MFE={_fmt(m['avg_mfe'])}R avg MAE={_fmt(m['avg_mae'])}R",
|
||||
]
|
||||
|
||||
|
||||
def build_report(df: pd.DataFrame, book: str, bar_minutes: int, split: str,
|
||||
out_dir: str, q: int = 5, min_n: int = 10) -> str:
|
||||
lines: list[str] = []
|
||||
add = lines.append
|
||||
cut = split_instant(df["entry_time"], split)
|
||||
add("=" * 78)
|
||||
add(f"BOOK REPORT {os.path.basename(book)}")
|
||||
add("=" * 78)
|
||||
add(f"trades : {len(df)}")
|
||||
add(f"entry window : {df['entry_time'].min()} -> {df['entry_time'].max()}")
|
||||
add(f"bar minutes : {bar_minutes} (decision_time = entry_time − 1 bar)")
|
||||
add(f"split rule : {split} -> cut at {cut} "
|
||||
f"(half 1 = before, half 2 = at/after)")
|
||||
add(f"half sizes : h1={int((df['half'] == 1).sum())} "
|
||||
f"h2={int((df['half'] == 2).sum())}")
|
||||
add("session map : asian 3-6, london 7-12, overlap 13-16, ny 17-22, "
|
||||
"else off (on DECISION hour, UTC)")
|
||||
add("bucket edges : quantile bins are fit on the WHOLE book; the two "
|
||||
"halves share the same edges.")
|
||||
feats = feature_columns(df)
|
||||
add(f"screen features : {len(feats)} "
|
||||
f"({sum(1 for c in feats if c.startswith(('f_', 'x_')))} f_/x_ columns)")
|
||||
for k, v in df.attrs.items():
|
||||
add(f"htf staleness : {k} = {_fmt(v, 1)} h (max age of the matched bar)")
|
||||
|
||||
add("")
|
||||
add("── HEADLINE METRICS ──")
|
||||
lines.extend(_metrics_block("full", base_metrics(df["r_multiple"], df)))
|
||||
for h in (1, 2):
|
||||
sub = df[df["half"] == h]
|
||||
lines.extend(_metrics_block(f"half {h}",
|
||||
base_metrics(sub["r_multiple"], sub)))
|
||||
|
||||
group_specs = [
|
||||
("weekday", "weekday"), ("decision_hour", "decision_hour"),
|
||||
("session", "session"), ("quality", "quality"), ("regime", "regime"),
|
||||
("vol_regime", "vol_regime"), ("direction", "direction"),
|
||||
("strategy", "strategy"), ("exit_reason", "exit_reason"),
|
||||
("indicators_agree", "indicators_agree"),
|
||||
]
|
||||
tables = {}
|
||||
for name, col in group_specs:
|
||||
if col not in df.columns:
|
||||
continue
|
||||
t = group_table(df, col, min_n=min_n)
|
||||
tables[name] = t
|
||||
add("")
|
||||
add(f"── BY {name.upper()} ──")
|
||||
add(t.to_string() if not t.empty else " (empty)")
|
||||
|
||||
# (weekday, decision_hour) pivots
|
||||
add("")
|
||||
add("── weekday x decision_hour : TOTAL R ──")
|
||||
wh = group_table(df, ["weekday", "decision_hour"], min_n=min_n)
|
||||
if not wh.empty:
|
||||
piv_r = wh["total_r"].unstack("decision_hour")
|
||||
piv_n = wh["n"].unstack("decision_hour")
|
||||
add(piv_r.fillna(0).round(2).to_string())
|
||||
add("")
|
||||
add("── weekday x decision_hour : N ──")
|
||||
add(piv_n.fillna(0).astype(int).to_string())
|
||||
tables["weekday_hour"] = wh
|
||||
piv_r.to_csv(os.path.join(out_dir, "groups_weekday_hour_total_r.csv"))
|
||||
piv_n.to_csv(os.path.join(out_dir, "groups_weekday_hour_n.csv"))
|
||||
|
||||
st = streaks(df)
|
||||
add("")
|
||||
add("── STREAKS ──")
|
||||
add(f" max consecutive wins : {st['max_win_streak']}")
|
||||
add(f" max consecutive losses : {st['max_loss_streak']}")
|
||||
add(f" longest flat stretch : {st['longest_flat_trades']} trades "
|
||||
f"between equity highs")
|
||||
|
||||
rw = rolling_wr(df, 20).dropna()
|
||||
add("")
|
||||
add("── ROLLING WIN RATE (20 trades) ──")
|
||||
if len(rw):
|
||||
add(f" min={_fmt(rw.min(), 2)}% median={_fmt(rw.median(), 2)}% "
|
||||
f"max={_fmt(rw.max(), 2)}% n={len(rw)}")
|
||||
add(" last 5: " + ", ".join(
|
||||
f"{ts:%Y-%m-%d %H:%M}={v:.2f}%" for ts, v in rw.tail(5).items()))
|
||||
else:
|
||||
add(" (fewer than 20 trades)")
|
||||
|
||||
scr = feature_screen(df, q=q, min_n=min_n)
|
||||
add("")
|
||||
add("── FEATURE SCREEN (top 25 by min |gap| across halves) ──")
|
||||
add(" gap = mean R(top bucket) − mean R(bottom bucket), per half; "
|
||||
"min_abs_gap is 0 unless the sign is stable.")
|
||||
add(scr.head(25).to_string(index=False) if not scr.empty else " (none)")
|
||||
|
||||
stable = scr[scr["sign_stable"]].head(10) if not scr.empty else scr
|
||||
add("")
|
||||
add(f"── BUCKET TABLES FOR THE TOP {len(stable)} SIGN-STABLE FEATURES ──")
|
||||
if stable.empty:
|
||||
add(" (no feature has a sign-stable top-vs-bottom gap)")
|
||||
for feat in stable["feature"]:
|
||||
bk = quantile_buckets(df, feat, q=q, min_n=min_n)
|
||||
add("")
|
||||
add(f" · {feat} (mode={bk.attrs['mode']}, n={bk.attrs['n']}, "
|
||||
f"gap_h1={_fmt(bk.attrs['gap_h1'])}, "
|
||||
f"gap_h2={_fmt(bk.attrs['gap_h2'])})")
|
||||
add(bk.to_string(index=False))
|
||||
bk.to_csv(os.path.join(out_dir, f"buckets_{feat}.csv"), index=False)
|
||||
|
||||
# CSV artefacts
|
||||
for name, t in tables.items():
|
||||
t.to_csv(os.path.join(out_dir, f"groups_{name}.csv"))
|
||||
scr.to_csv(os.path.join(out_dir, "feature_screen.csv"), index=False)
|
||||
equity_curve(df).to_csv(os.path.join(out_dir, "equity.csv"), index=False)
|
||||
all_buckets = []
|
||||
for c in feats:
|
||||
bk = quantile_buckets(df, c, q=q, min_n=min_n)
|
||||
if bk.empty:
|
||||
continue
|
||||
bk = bk.copy()
|
||||
bk.insert(0, "feature", c)
|
||||
all_buckets.append(bk)
|
||||
if all_buckets:
|
||||
pd.concat(all_buckets, ignore_index=True).to_csv(
|
||||
os.path.join(out_dir, "buckets.csv"), index=False)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("--book", required=True)
|
||||
ap.add_argument("--bar-minutes", type=int, required=True)
|
||||
ap.add_argument("--split", default="median",
|
||||
help="'median' (default) or YYYY-MM-DD")
|
||||
ap.add_argument("--parquet-4h", default=None)
|
||||
ap.add_argument("--parquet-1d", default=None)
|
||||
ap.add_argument("--q", type=int, default=5)
|
||||
ap.add_argument("--min-n", type=int, default=10)
|
||||
ap.add_argument("--out", default="backtest/research/book_report_out")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
df = load_book(args.book, args.bar_minutes, args.split)
|
||||
if args.parquet_4h or args.parquet_1d:
|
||||
df = add_htf_extension(df, args.parquet_4h, args.parquet_1d)
|
||||
text = build_report(df, args.book, args.bar_minutes, args.split,
|
||||
args.out, q=args.q, min_n=args.min_n)
|
||||
print(text)
|
||||
with open(os.path.join(args.out, "report.txt"), "w") as fh:
|
||||
fh.write(text + "\n")
|
||||
print(f"\n[written] {os.path.join(args.out, 'report.txt')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user