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
This commit is contained in:
Harwinder Sekhon
2026-09-05 21:19:24 -04:00
parent 59f69400a8
commit cf2ad73343
16 changed files with 4076 additions and 3 deletions
+439
View File
@@ -9229,6 +9229,445 @@ def _():
os.unlink(path)
# ══════════════════════════════════════════════════════════════════════
# MODULE 29: stake-multiplier sizing — features + record-keeping
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 29: stake-multiplier sizing ═══")
import position_sizing as _ps
#: Every tag name that _compute_final_signal can append to `penalties`.
_M29_KNOWN_TAGS = {
"mtf_disagreement", "news_conflict", "event_caution", "counter_trend",
"extreme_vol", "ema200_near", "ema200_far", "exhaustion",
"no_pullback", "weak_pullback", "rsi_extended", "bb_overextended",
}
def _m29_ramp(n=300, start=2400.0, step=1.2, noise=0.25, seed=1, freq="5min"):
"""Steep, low-noise uptrend — rides the upper Bollinger band, so the
bb_overextended / no_pullback / rsi_extended penalties fire."""
rng = np.random.default_rng(seed)
idx = pd.date_range(end=pd.Timestamp("2026-08-19 12:00", tz="UTC"),
periods=n, freq=freq)
close = start + np.arange(n) * step + rng.normal(0, noise, n)
high = close + np.abs(rng.normal(0, noise, n)) + 0.2
low = close - np.abs(rng.normal(0, noise, n)) - 0.2
open_ = close - rng.normal(0, noise, n)
df = pd.DataFrame({"Open": open_, "High": high, "Low": low,
"Close": close, "Volume": np.full(n, 1000.0)},
index=idx)
df.index.name = "Datetime"
return df
_M29_CACHE = {}
def _m29_live(n=1500):
"""One REAL analyze_from_dataframes run (M5 primary) + a synthetic
BUY setup on its close. Cached — the replay is the slow part."""
key = ("live", n)
if key not in _M29_CACHE:
cfg = PlatformConfig()
cfg.primary_timeframe = Timeframe.M5
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H1]
dfs = {
Timeframe.M5: _m29_ramp(n=n, seed=5),
Timeframe.M15: _m29_ramp(n=400, seed=55, freq="15min"),
Timeframe.H1: _m29_ramp(n=200, seed=95, freq="1h"),
}
report = SignalAggregator(cfg).analyze_from_dataframes(
"XAU/USD", dfs, use_news=False)
px = report.current_price
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=px, stop_loss=px - 6.0, take_profit_1=px + 12.0,
take_profit_2=px + 20.0, risk_reward=2.0, risk_pips=60.0,
reward_pips=120.0, pip_value=0.10, strategy="XAU_ATR")
_M29_CACHE[key] = (cfg, dfs, report, setup)
return _M29_CACHE[key]
def _m29_fixture(direction="BUY", score=42.0):
"""Hand-built report/setup/frames — no engine run, full control over
key_levels, indicators and MTF scores. Levels are pinned RELATIVE to
the frame's last close, which is the price build_features reads."""
dfs = {
Timeframe.M5: _m29_ramp(n=300, seed=7),
Timeframe.M15: _m29_ramp(n=120, seed=8, freq="15min"),
Timeframe.H1: _m29_ramp(n=120, seed=9, freq="1h"),
}
px = float(dfs[Timeframe.M5]["Close"].iloc[-1])
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-08-19T12:00:00+00:00")
report.final_score = score
report.final_confidence = 61.0
report.signal_quality = "A"
report.mtf_agreement = True
report.mtf_scores = {Timeframe.M5.value: 30.0, Timeframe.M15.value: 22.0,
Timeframe.H1.value: 14.0}
report.current_price = px
report.exhaustion_count = 2
report.penalty_tags = {"no_pullback": 8.0, "rsi_extended": 5.0}
report.penalty_total = 10.5
report.momentum_fading = True
report.primary_analysis = TechnicalAnalysis(
symbol="XAU/USD", timeframe=Timeframe.M5.value,
timestamp=report.timestamp,
indicators={
"rsi": IndicatorSignal("RSI", 68.0, SignalStrength.BUY, 70.0, "", 1.5),
"bollinger": IndicatorSignal("BB", 0.9, SignalStrength.SELL, 60.0, "", 1.5),
"adx": IndicatorSignal("ADX", 31.0, SignalStrength.BUY, 60.0, "", 0.5),
"keltner": IndicatorSignal("KC", 4.0, SignalStrength.BUY, 60.0, "", 1.5),
"roc": IndicatorSignal("ROC", 0.8, SignalStrength.BUY, 60.0, "", 1.5),
"macd": IndicatorSignal("MACD", 1.25, SignalStrength.BUY, 60.0, "", 1.5),
"atr": IndicatorSignal("ATR", 5.0, SignalStrength.NEUTRAL, 50.0, "", 0.0),
},
composite_signal=SignalStrength.BUY, composite_score=score,
confidence=61.0, market_regime="trending_up", volatility_regime="normal",
key_levels={
"atr": 5.0, "atr_pct": 0.2,
"ema_medium": px - 10.0, "ema_trend": px - 50.0,
"swing_low_1": px - 30.0, "swing_low_2": px - 40.0,
"swing_low_3": px - 50.0,
"swing_high_1": px + 10.0, "swing_high_2": px + 15.0,
"swing_high_3": px + 20.0,
},
)
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction=direction,
entry_price=px, stop_loss=px - 6.0 if direction == "BUY" else px + 6.0,
take_profit_1=px + 12.0 if direction == "BUY" else px - 12.0,
take_profit_2=px + 20.0 if direction == "BUY" else px - 20.0,
risk_reward=2.0, risk_pips=60.0, reward_pips=120.0, pip_value=0.10,
strategy="XAU_PB")
return report, setup, dfs
@test("sizing: SignalReport records penalty tags incl. bb_overextended")
def _():
_cfg, _dfs, report, _setup = _m29_live()
assert report.penalty_tags, "penalty tags must be recorded"
assert "bb_overextended" in report.penalty_tags, \
f"bb_overextended must be recorded, got {report.penalty_tags}"
assert report.penalty_tags["bb_overextended"] == 8.0, "raw pre-decay amount"
# The tag is invisible in `warnings` — that is exactly why the
# record-keeping dict exists.
assert not any("bb_overextended" in w for w in report.warnings), \
f"bb_overextended must not surface as a warning: {report.warnings}"
unknown = set(report.penalty_tags) - _M29_KNOWN_TAGS
assert not unknown, f"unknown penalty tags recorded: {unknown}"
@test("sizing: penalty_total equals the decayed sum subtracted from confidence")
def _():
_cfg, _dfs, report, _setup = _m29_live()
assert len(report.penalty_tags) >= 2, \
f"need >=2 penalties to exercise the decay, got {report.penalty_tags}"
max_penalty, total, decay = 30.0, 0.0, 1.0
for _name, amount in sorted(report.penalty_tags.items(),
key=lambda kv: kv[1], reverse=True):
applied = amount * decay
if total + applied > max_penalty:
applied = max_penalty - total
total += applied
decay *= 0.5
if total >= max_penalty:
break
assert abs(total - report.penalty_total) < 1e-9, \
f"recomputed {total} != recorded {report.penalty_total}"
@test("sizing: build_features returns every FEATURE_ORDER key, all finite")
def _():
cfg, dfs, report, setup = _m29_live()
feats = _ps.build_features(report, setup, dfs, Timeframe.M5,
cfg.confirmation_timeframes)
assert list(feats.keys()) == list(_ps.FEATURE_ORDER), \
"keys must be exactly FEATURE_ORDER, in order"
assert len(_ps.FEATURE_ORDER) == len(set(_ps.FEATURE_ORDER)), \
"FEATURE_ORDER must not contain duplicates"
for name, value in feats.items():
assert type(value) is float, f"{name} is {type(value)}, not float"
assert np.isfinite(value), f"{name} is not finite: {value}"
assert all(n.startswith("f_") for n in _ps.FEATURE_ORDER)
@test("sizing: exhaustion features are signed by TRADE direction, not score sign")
def _():
report, setup, dfs = _m29_fixture(direction="SELL", score=42.0)
assert report.final_score > 0 and setup.direction == "SELL"
feats = _ps.build_features(report, setup, dfs, Timeframe.M5,
[Timeframe.M15, Timeframe.H1])
# SELL must measure to the HIGHEST tracked swing high (px + 20), not
# the lowest swing low (px - 50), and the EMA21 distance (px - 10)
# must carry the SELL sign. ATR = 5.
assert abs(feats["f_swing_dist_atr"] - 20.0 / 5.0) < 1e-9, \
f"expected swing-high distance 4.0, got {feats['f_swing_dist_atr']}"
assert abs(feats["f_ema21_dist_atr"] - (-10.0 / 5.0)) < 1e-9, \
f"expected SELL-signed EMA21 distance -2.0, got {feats['f_ema21_dist_atr']}"
assert feats["f_score_dir"] < 0, "a SELL on a positive score scores negative"
assert feats["f_is_buy"] == 0.0
@test("sizing: direction-signed features flip between a BUY and its mirrored SELL")
def _():
report, buy, dfs = _m29_fixture(direction="BUY")
_r2, sell, _d2 = _m29_fixture(direction="SELL")
a = _ps.build_features(report, buy, dfs, Timeframe.M5,
[Timeframe.M15, Timeframe.H1])
b = _ps.build_features(report, sell, dfs, Timeframe.M5,
[Timeframe.M15, Timeframe.H1])
for name in ("f_ema21_dist_atr", "f_ret_12_atr", "f_rsi_dir",
"f_score_dir", "f_mtf_primary"):
assert a[name] != 0.0, f"{name} must be non-zero for the flip test"
assert abs(a[name] + b[name]) < 1e-9, \
f"{name} must flip sign: {a[name]} vs {b[name]}"
for name in ("f_atr", "f_hour_utc", "f_atr_pct", "f_weekday",
"f_penalty_total"):
assert a[name] == b[name], f"{name} must be direction-independent"
@test("sizing: decision time is last closed label + candle duration")
def _():
from providers.base import BaseDataProvider as _BDP
for tf in _ps.CANDLE_SECONDS:
assert _ps.CANDLE_SECONDS[tf] == _BDP.CANDLE_SECONDS[tf], \
f"CANDLE_SECONDS copy diverged on {tf}"
for tf, freq in ((Timeframe.M5, "5min"), (Timeframe.M15, "15min"),
(Timeframe.H1, "1h"), (Timeframe.H4, "4h")):
df = _m29_ramp(n=30, freq=freq)
expected = df.index[-1] + pd.Timedelta(seconds=_ps.CANDLE_SECONDS[tf])
assert _ps.decision_time(df, tf) == expected, tf
# The decision clock, not the bar label, drives the time features.
df = _m29_ramp(n=30, freq="1h")
report, setup, _dfs = _m29_fixture()
feats = _ps.build_features(report, setup, {Timeframe.H1: df},
Timeframe.H1, [])
assert feats["f_hour_utc"] == float(
(df.index[-1] + pd.Timedelta(hours=1)).hour)
assert _ps.session_label(4) == "asian" and _ps.session_label(9) == "london"
assert _ps.session_label(14) == "overlap" and _ps.session_label(20) == "ny"
assert _ps.session_label(1) == "off" and _ps.session_label(23) == "off"
@test("sizing: features identical on a full frame and on its live-depth tail")
def _():
cfg, dfs, report, setup = _m29_live()
assert len(dfs[Timeframe.M5]) > _ps.MAX_LOOKBACK_BARS, "need a long frame"
full = _ps.build_features(report, setup, dfs, Timeframe.M5,
cfg.confirmation_timeframes)
tail = _ps.build_features(
report, setup,
{Timeframe.M5: dfs[Timeframe.M5].iloc[-_ps.MAX_LOOKBACK_BARS:],
Timeframe.M15: dfs[Timeframe.M15].iloc[-_ps.CONFIRM_LOOKBACK_BARS:],
Timeframe.H1: dfs[Timeframe.H1].iloc[-_ps.CONFIRM_LOOKBACK_BARS:]},
Timeframe.M5, cfg.confirmation_timeframes)
assert full == tail, {
k: (full[k], tail[k]) for k in full if full[k] != tail[k]}
@test("sizing: stake_multiplier returns 1.0 with no artifact dir, missing file, or stale feature_version")
def _():
cfg, dfs, report, setup = _m29_live()
args = (report, setup, dfs, Timeframe.M5, cfg.confirmation_timeframes)
assert _ps.stake_multiplier(*args) == 1.0, "default (no dir) must be neutral"
assert _ps.stake_multiplier(*args, artifact_dir=None) == 1.0
with tempfile.TemporaryDirectory() as tmp:
assert _ps.stake_multiplier(*args, artifact_dir=tmp) == 1.0, \
"missing artifact file must be neutral"
path = _ps.artifact_path_for(Timeframe.M5, tmp)
assert path.name == "xauusd_5min.json", path.name
path.write_text(json.dumps({"feature_version": 999, "weights": {}}))
assert _ps.load_artifact(path)["feature_version"] == 999
assert _ps.stake_multiplier(*args, artifact_dir=tmp) == 1.0, \
"stale feature_version must be neutral"
path.write_text("{not json")
# mtime granularity: force a cache miss so the invalid body is read
os.utime(path, (0, 0))
assert _ps.load_artifact(path) is None, "invalid JSON -> None"
assert _ps.stake_multiplier(*args, artifact_dir=tmp) == 1.0
assert _ps.load_artifact(os.path.join(tmp, "nope.json")) is None
@test("sizing: build_features never raises on degenerate input")
def _():
report, setup, _dfs = _m29_fixture()
tiny = _m29_ramp(n=3, seed=3)
def _check(feats):
assert list(feats.keys()) == list(_ps.FEATURE_ORDER)
for name, value in feats.items():
assert type(value) is float and np.isfinite(value), (name, value)
return feats
# atr = 0 -> every ATR-normalised feature collapses to 0.0
report.primary_analysis.key_levels = {"atr": 0.0}
f = _check(_ps.build_features(report, setup, {Timeframe.M5: tiny},
Timeframe.M5, [Timeframe.M15]))
for name in ("f_sl_atr", "f_tp1_atr", "f_swing_dist_atr",
"f_ema21_dist_atr", "f_bar_range_atr", "f_ret_6_atr"):
assert f[name] == 0.0, f"{name} must be 0.0 when ATR is unusable"
assert f["f_bars_since_burst"] == 200.0, "sentinel when no burst in window"
assert f["f_rsi"] == 68.0 and f["f_bb_pos"] == 0.9, \
"un-normalised indicator readings survive an unusable ATR"
# empty key_levels + no indicators + no confirmation frames
report.primary_analysis.key_levels = {}
report.primary_analysis.indicators = {}
f = _check(_ps.build_features(report, setup, {Timeframe.M5: tiny},
Timeframe.M5, []))
assert f["f_rsi"] == 50.0 and f["f_bb_pos"] == 0.5, \
"missing indicators fall back to their neutral readings"
assert f["f_ind_agree_frac"] == 0.0 and f["f_ind_oppose_frac"] == 0.0
# no primary analysis at all
report.primary_analysis = None
_check(_ps.build_features(report, setup, {Timeframe.M5: tiny},
Timeframe.M5, [Timeframe.M15, Timeframe.H1]))
# no frames, no setup
_check(_ps.build_features(report, None, {}, Timeframe.M5, []))
_check(_ps.build_features(None, None, {Timeframe.M5: tiny.iloc[:0]},
Timeframe.M5, None))
@test("sizing: TradeSetup.stake_multiplier defaults to None (no sizing overlay ships)")
def _():
# 2026-09-06 verdict: the stake-multiplier model FAILED the pre-registered
# rule on M5 and M15, so no SizingConfig / artifact exists. The field is
# reserved (None = unsized) and the backtester reads it as 1.0.
ts = TradeSetup(pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=2450.0, stop_loss=2444.0, take_profit_1=2462.0,
take_profit_2=2470.0, risk_reward=2.0, risk_pips=60.0,
reward_pips=120.0, pip_value=0.10)
assert ts.stake_multiplier is None
assert not hasattr(PlatformConfig(), "sizing"), "rule 8: no unread config section"
_M29_PINNED_COLS = [
"pair", "strategy", "quality", "regime", "vol_regime", "direction",
"entry_time", "entry_weekday", "entry", "sl", "tp1", "rr_planned", "score",
"confidence", "indicators_agree", "indicators_oppose", "indicators_neutral",
"indicators_total", "entry_reasons", "warnings", "exit_time", "exit",
"exit_reason", "bars_held", "r_multiple", "mfe_r", "mae_r",
]
_M29_IND_NAMES = ["ema_trend", "rsi", "bollinger", "adx", "macd", "roc",
"keltner", "candlestick", "divergence"]
def _m29_trade(r_multiple=1.0, stake=1.0, features=None):
"""A resolved backtest Trade with a full 9-indicator breakdown."""
from gpu_backtest import Trade
t = Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=3300.0, sl=3292.0, tp1=3312.0, tp2=3320.0,
direction="BUY", pip_value=0.10, risk_pips=80.0,
reward_pips=120.0, rr_planned=1.5, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up", volatility_regime="normal",
strategy_tag="GENERIC",
entry_reasons=["r1"], entry_warnings=["w1"])
t.indicator_breakdown = {
name: {"signal": "BUY", "value": 1.0 + i, "confidence": 60.0,
"agrees": True}
for i, name in enumerate(_M29_IND_NAMES)
}
t.indicators_agreeing, t.indicators_total = 9, 9
t.exit_time = pd.Timestamp("2026-01-05 12:00", tz="UTC")
t.exit_price = 3312.0
t.exit_reason = "TP1"
t.bars_held = 24
t.r_multiple = r_multiple
t.mfe_r, t.mae_r = 1.8, 0.3
if features is not None:
t.features = features
t.stake_multiplier = stake
return t
@test("sizing: Trade defaults keep features empty and stake_multiplier 1.0")
def _():
from gpu_backtest import Trade
bare = Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=3300.0, sl=3292.0, tp1=3312.0, tp2=3320.0,
direction="BUY", pip_value=0.10, risk_pips=80.0,
reward_pips=120.0, rr_planned=1.5, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up")
assert bare.features == {}, "an unsized trade carries no features"
assert bare.stake_multiplier == 1.0, "neutral stake by default"
# Two Trades must not share one features dict (mutable default trap).
bare.features["f_hour_utc"] = 9.0
assert Trade(pair="XAU/USD",
entry_time=pd.Timestamp("2026-01-05 10:00", tz="UTC"),
entry_price=3300.0, sl=3292.0, tp1=3312.0, tp2=3320.0,
direction="BUY", pip_value=0.10, risk_pips=80.0,
reward_pips=120.0, rr_planned=1.5, quality="B",
final_score=30.0, final_confidence=60.0,
market_regime="trending_up").features == {}
@test("sizing: trades_to_dataframe default export keeps the pinned column set")
def _():
from gpu_backtest import trades_to_dataframe
feats_a = {name: float(i) for i, name in enumerate(_ps.FEATURE_ORDER)}
trades = [
_m29_trade(r_multiple=1.5, stake=1.25, features=feats_a),
_m29_trade(r_multiple=-1.0, stake=0.75, features=dict(feats_a)),
]
off = trades_to_dataframe(trades)
assert len(off.columns) == 63, \
f"pinned books are 63 columns, got {len(off.columns)}"
assert list(off.columns[:27]) == _M29_PINNED_COLS, list(off.columns[:27])
assert not any(c.startswith("f_") or c in ("stake_multiplier", "r_sized")
for c in off.columns), "feature columns must stay off by default"
on = trades_to_dataframe(trades, include_features=True)
expected = (_M29_PINNED_COLS + list(off.columns[27:])
+ ["stake_multiplier", "r_sized"] + list(_ps.FEATURE_ORDER))
assert len(on.columns) == 63 + 2 + len(_ps.FEATURE_ORDER), \
f"expected {63 + 2 + len(_ps.FEATURE_ORDER)} columns, got {len(on.columns)}"
assert list(on.columns) == expected, \
[c for c in zip(list(on.columns), expected) if c[0] != c[1]][:5]
# The default export must be a strict prefix — pinned books stay diffable.
pd.testing.assert_frame_equal(off, on[off.columns])
for i in range(len(trades)):
assert on["r_sized"].iloc[i] == (
on["r_multiple"].iloc[i] * on["stake_multiplier"].iloc[i])
assert on["f_hour_utc"].iloc[i] == feats_a["f_hour_utc"]
# A trade with no features (flag on, but nothing computed) → NaN, not 0.
partial = trades_to_dataframe([_m29_trade()], include_features=True)
assert np.isnan(partial["f_atr"].iloc[0]), "missing features export as NaN"
assert partial["stake_multiplier"].iloc[0] == 1.0
# 2026-09-05 regression: when the FIRST trade lacks an indicator that a
# later trade carries, pandas appends that indicator's columns at first
# appearance. The extras must still land after every base column so the
# default export stays a strict prefix (seen on the 3yr M5 book, whose
# first trade had no candlestick pattern).
first, second = _m29_trade(features=feats_a), _m29_trade(features=dict(feats_a))
for suffix in ("candlestick",):
first.indicator_breakdown.pop(suffix)
off2 = trades_to_dataframe([first, second])
on2 = trades_to_dataframe([first, second], include_features=True)
assert list(on2.columns[:len(off2.columns)]) == list(off2.columns), \
"extras must follow ALL base columns even when a later trade adds one"
assert list(on2.columns[len(off2.columns):]) == (
["stake_multiplier", "r_sized"] + list(_ps.FEATURE_ORDER))
pd.testing.assert_frame_equal(off2, on2[off2.columns])
@test("sizing: --export-features parses and defaults off")
def _():
from gpu_backtest import _parse_args
assert _parse_args(["--pair", "XAUUSD"]).export_features is False
args = _parse_args(["--pair", "XAUUSD", "--export-features",
"--export-csv", "/tmp/x.csv"])
assert args.export_features is True
assert args.export_csv == "/tmp/x.csv"
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════