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:
+439
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user