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:
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user