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:
Harwinder Sekhon
2026-09-05 21:19:24 -04:00
parent bed8b549ac
commit 6bf58fd9d4
16 changed files with 4076 additions and 3 deletions
+63 -3
View File
@@ -62,6 +62,7 @@ from config import ( # noqa: E402
from providers import BaseDataProvider, make_data_provider # noqa: E402
from engine import IndicatorSignal, TechnicalAnalysis, TechnicalEngine # noqa: E402
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup # noqa: E402
import position_sizing # noqa: E402
# ─────────────────────────────────────────────────────────────────────
# GPU shim — CuPy if available AND CUDA runtime works, else NumPy.
@@ -1074,6 +1075,13 @@ class Trade:
indicators_neutral: int = 0
indicators_total: int = 0 # total scored indicators
# RESEARCH (--export-features only): decision-time feature vector
# (position_sizing.FEATURE_ORDER) and the stake multiplier the setup
# carried. Empty / 1.0 on every normal run, so the default book is
# untouched.
features: Dict[str, float] = field(default_factory=dict)
stake_multiplier: float = 1.0
# Filled on exit:
exit_time: Optional[pd.Timestamp] = None
exit_price: Optional[float] = None
@@ -1454,6 +1462,11 @@ class BacktestConfig:
# afterwards in a fast ordered pass. Results are identical to the
# sequential path by construction.
workers: int = 0
# RESEARCH: also compute position_sizing.build_features() at each
# entry and append them to --export-csv. Off by default — the extra
# columns would break comparison against the pinned 63-column books,
# and the feature build costs time per candidate.
export_features: bool = False
def _is_xau_blocked_by_calendar(ts: pd.Timestamp, cfg: PlatformConfig) -> Optional[str]:
@@ -1610,6 +1623,17 @@ def _replay_span(span: Tuple[int, int]):
adj_tp1 = entry_price - tp1_distance
adj_tp2 = entry_price - tp2_distance
# RESEARCH: decision-time features (same frames the aggregator
# just saw, so live == backtest by construction). Deliberately
# NOT wrapped in try/except — build_features already swallows its
# own failures, so anything escaping here is a real bug.
feats: Dict[str, float] = {}
if bt_cfg.export_features:
feats = position_sizing.build_features(
report, ts_setup, slice_dfs, bt_cfg.primary_tf,
list(bt_cfg.confirmation_tfs),
)
candidates.append((pos, Trade(
pair=bt_cfg.pair,
entry_time=entry_time,
@@ -1635,6 +1659,10 @@ def _replay_span(span: Tuple[int, int]):
indicators_opposing=oppose,
indicators_neutral=neutral,
indicators_total=total,
features=feats,
stake_multiplier=float(ts_setup.stake_multiplier
if ts_setup.stake_multiplier is not None
else 1.0),
trail_arm_r=(platform_cfg.signals.pb_trail_arm_r
if tag == "XAU_PB" else 0.0),
trail_dist_r=(platform_cfg.signals.pb_trail_dist_r
@@ -2157,11 +2185,17 @@ def print_trade_reasoning(trades: List[Trade], limit: int = 5) -> None:
print(f" MFE / MAE: {t.mfe_r:.2f}R / {t.mae_r:.2f}R")
def trades_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
def trades_to_dataframe(trades: List[Trade],
include_features: bool = False) -> pd.DataFrame:
"""Export trades for downstream analysis (e.g. parquet/CSV).
Includes per-instructions: entry reasoning, indicator-by-indicator
signal/value/agreement, and overall agreement count.
`include_features` (research, --export-features) appends
stake_multiplier, r_sized and the position_sizing.FEATURE_ORDER
columns AFTER the existing ones. Default off so the column set stays
byte-identical to the pinned books.
"""
rows = []
for t in trades:
@@ -2200,8 +2234,22 @@ def trades_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
row[f"ind_{name}_value"] = b["value"]
row[f"ind_{name}_conf"] = b["confidence"]
row[f"ind_{name}_agrees"] = b["agrees"]
if include_features:
row["stake_multiplier"] = t.stake_multiplier
row["r_sized"] = t.r_multiple * t.stake_multiplier
for name in position_sizing.FEATURE_ORDER:
row[name] = t.features.get(name, float("nan"))
rows.append(row)
return pd.DataFrame(rows)
df = pd.DataFrame(rows)
if include_features and len(df.columns):
# A later trade can introduce an indicator column the first trade
# lacked (pandas orders columns by first appearance), which would
# land AFTER the extras. Force the extras to the tail so the default
# export is always a strict prefix of the feature export.
extras = ["stake_multiplier", "r_sized", *position_sizing.FEATURE_ORDER]
base_cols = [c for c in df.columns if c not in extras]
df = df[base_cols + [c for c in extras if c in df.columns]]
return df
# ═════════════════════════════════════════════════════════════════════
@@ -2263,6 +2311,11 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
help="Round-trip cost in price units per trade (default: pair's "
"default_spread from PAIR_INFO — XAU $0.30; 0 disables)")
p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
p.add_argument("--export-features", action="store_true",
help="Append the 65 decision-time f_* feature columns "
"(+ stake_multiplier, r_sized) to --export-csv; "
"research flag, default off so pinned 63-column "
"books stay comparable")
p.add_argument("--detail", action="store_true",
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
p.add_argument("--workers", type=int, default=0,
@@ -2416,7 +2469,14 @@ def main(argv: Optional[List[str]] = None) -> int:
spread=spread,
workers=args.workers,
fixed_rr_tp=args.fixed_rr_tp or 0.0,
export_features=args.export_features,
)
if args.export_features:
logger.info(
f"--export-features ON: appending stake_multiplier, r_sized and "
f"{len(position_sizing.FEATURE_ORDER)} f_* feature columns to the "
f"CSV export (research flag; book itself is unchanged)"
)
logger.info(
f"Backend: {'CuPy GPU' if _GPU_AVAILABLE else 'NumPy CPU fallback'} | "
@@ -2430,7 +2490,7 @@ def main(argv: Optional[List[str]] = None) -> int:
print_trade_reasoning(trades, limit=args.reasoning_samples)
if args.export_csv:
df = trades_to_dataframe(trades)
df = trades_to_dataframe(trades, include_features=args.export_features)
df.to_csv(args.export_csv, index=False)
logger.info(f"Exported {len(df)} trades → {args.export_csv}")