diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 4bb850e..beb4901 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -92,6 +92,12 @@ STEP_DAYS = 5 # weekly cadence (≈ 5 trading days) MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51) HORIZON = 30 # trading days to resolve an outcome (matches the evaluator) ATR_MULTIPLIER = 1.5 +RANGE_FACTOR_LOOKBACK = 504 +RANGE_FACTOR_MIN_LOG = 1.0 # approximately a 2.7x high/low span +RANGE_FACTOR_VARIANTS = { + "production_range504", + "rewrite_range504_legacy_primary", +} # Cross-sectional signal evaluation (factor IC). Each candidate signal is a # point-in-time number computed from closes alone (sentiment/fundamentals have no @@ -146,6 +152,7 @@ SR_RESEARCH_VARIANTS = { "legacy_traffic_grid_only", "legacy_range_grid_touch", "legacy_range_grid_neutral", + *RANGE_FACTOR_VARIANTS, } @@ -158,6 +165,36 @@ def _sr_research_variant() -> str: return value +def _sr_detector_variant(sr_variant: str) -> str: + """Map factor-gated research arms to the detector they hold fixed.""" + if sr_variant == "production_range504": + return "production_control" + if sr_variant == "rewrite_range504_legacy_primary": + return "rewrite" + return sr_variant.removesuffix("_legacy_primary") + + +def _range_504_log(highs: list[float], lows: list[float]) -> float: + """Multiplicative high/low range over the last two trading years.""" + window_highs = highs[-RANGE_FACTOR_LOOKBACK:] + window_lows = lows[-RANGE_FACTOR_LOOKBACK:] + if not window_highs or not window_lows: + return 0.0 + high = max(window_highs) + low = min(window_lows) + if high <= 0 or low <= 0 or high < low: + return 0.0 + return math.log(high / low) + + +def _range_factor_allows(sr_variant: str, range_504_log: float) -> bool: + """Apply the explicit range factor only in its diagnostic arms.""" + return ( + sr_variant not in RANGE_FACTOR_VARIANTS + or range_504_log >= RANGE_FACTOR_MIN_LOG + ) + + def _apply_zone_strength_variant(zone_levels: list[Any], sr_variant: str) -> list[Any]: """Apply post-cluster research controls without changing zone geometry.""" if sr_variant in {"legacy_geometry_neutral", "legacy_range_grid_neutral"}: @@ -288,7 +325,8 @@ def _window_setups( return [] sr_variant = _sr_research_variant() - detector_variant = sr_variant.removesuffix("_legacy_primary") + detector_variant = _sr_detector_variant(sr_variant) + range_504_log = _range_504_log(highs, lows) if sr_variant == "legacy_geometry_neutral": detected_levels = detect_sr_levels_legacy( highs, lows, closes, volumes, neutral_strength=True @@ -370,7 +408,7 @@ def _window_setups( targets = _prune_floor_pinned_targets(targets) primary_min_rr = ( 1.5 - if sr_variant == "production_control" + if sr_variant in {"production_control", "production_range504"} or sr_variant.endswith("_legacy_primary") or sr_variant.startswith("legacy_") else float(activation.get("min_rr", 0.0)) @@ -419,6 +457,10 @@ def _window_setups( # week are known. run_backtest ranks momentum and finalizes `qualified`. core_config = {**activation, "min_momentum_percentile": 0.0} meets_core = setup_qualifies(setup_ns, core_config) + meets_core = meets_core and _range_factor_allows( + sr_variant, + range_504_log, + ) best_prob = best_target_probability(setup_ns) out.append({ "direction": direction, @@ -445,6 +487,9 @@ def _window_setups( ), "raw_level_count": len(sr_levels), "gate_level_count": len(gate_levels), + "range_504_log": range_504_log, + "range_504_ratio": math.exp(range_504_log), + "range_factor_pass": range_504_log >= RANGE_FACTOR_MIN_LOG, }) return out @@ -608,6 +653,9 @@ def _replay_ticker( "primary_distance_atr": s["primary_distance_atr"], "raw_level_count": s["raw_level_count"], "gate_level_count": s["gate_level_count"], + "range_504_log": s["range_504_log"], + "range_504_ratio": s["range_504_ratio"], + "range_factor_pass": s["range_factor_pass"], "outcome": outcome, "target_hit": target_hit, "realized_r": realized_r, @@ -677,6 +725,7 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict: rejections: list[int] = [] raw_counts: list[int] = [] gate_counts: list[int] = [] + range_logs: list[float] = [] for cand in candidates: sources = list(cand.get("primary_sources") or []) for source in sources: @@ -688,6 +737,7 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict: rejections.append(int(cand.get("primary_rejection_count", 0) or 0)) raw_counts.append(int(cand.get("raw_level_count", 0) or 0)) gate_counts.append(int(cand.get("gate_level_count", 0) or 0)) + range_logs.append(float(cand.get("range_504_log", 0.0) or 0.0)) def avg(values: list[float] | list[int]) -> float | None: return round(sum(values) / len(values), 3) if values else None @@ -703,6 +753,10 @@ def _sr_variant_diagnostics(candidates: list[dict]) -> dict: "avg_primary_rejection_count": avg(rejections), "avg_raw_level_count": avg(raw_counts), "avg_gate_level_count": avg(gate_counts), + "avg_range_504_log": avg(range_logs), + "range_factor_pass": sum( + 1 for value in range_logs if value >= RANGE_FACTOR_MIN_LOG + ), } @@ -738,6 +792,9 @@ def _sr_candidate_audit(candidates: list[dict], min_percentile: float) -> list[d "primary_distance_atr": round(float(cand.get("primary_distance_atr", 0.0)), 6), "raw_level_count": int(cand.get("raw_level_count", 0) or 0), "gate_level_count": int(cand.get("gate_level_count", 0) or 0), + "range_504_log": round(float(cand.get("range_504_log", 0.0)), 6), + "range_504_ratio": round(float(cand.get("range_504_ratio", 1.0)), 6), + "range_factor_pass": bool(cand.get("range_factor_pass")), "outcome": cand.get("outcome"), "net_r": round(float(cand.get("realized_r", 0.0)) - _cost_r(cand), 6), "hold30_r": round(float((cand.get("time_r") or {}).get(30, 0.0)), 6), @@ -3089,6 +3146,9 @@ async def run_backtest( "min_lookback": MIN_LOOKBACK, "cost_per_side_pct": round(COST_PER_SIDE * 100, 3), "sr_variant": _sr_research_variant(), + "range_factor_lookback": RANGE_FACTOR_LOOKBACK, + "range_factor_min_log": RANGE_FACTOR_MIN_LOG, + "range_factor_min_ratio": round(math.exp(RANGE_FACTOR_MIN_LOG), 4), "entry_start": ( _backtest_entry_bounds()[0].isoformat() if _backtest_entry_bounds()[0] is not None else None diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index aa746f5..45a68a1 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -541,10 +541,53 @@ Run only these new arms on macOS: --only-arm legacy_range_grid_neutral --workers 14 ``` -The touch arm should closely reproduce `legacy_traffic_grid_only`; that is the -volume-removal parity check. The touch-versus-neutral comparison then attributes -any remaining difference to occupancy strength. Freeze the winner before running -the post-2024-06-30 validation command; do not tune the bin count on training data. +The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464 +candidates, 504 qualified setups, cohort membership, expectancy, and portfolio +metrics match. Volume contributes nothing. Neutral strength won the training +portfolio comparison (Sharpe 1.98 versus 1.72), but failed the locked validation: + +| validation arm | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% | +|---|---:|---:|---:|---:|---:| +| production control | **2.78** | **73.3%** | **11.7%** | 0.174 | 0.022 | +| neutral range grid | 1.85 | 43.2% | 15.2% | **0.178** | **0.039** | + +The neutral grid is a no-ship. The validation failure prompted a causal audit of +the control rather than another detector sweep. One relationship survives both +periods: dense legacy ladders are a proxy for a wide multiplicative price range. + +| control cohort | training ex-top-5% | validation ex-top-5% | +|---|---:|---:| +| at least 70 legacy levels | +0.165R | +0.185R | +| fewer than 70 levels | +0.004R | -0.379R | + +Level count is not independently useful after controlling for the last 504 +trading days' range. For `log(max(high) / min(low)) >= 1.0315` (about a 2.8x +high/low ratio), the overlap cohort returns +0.292R training and +0.322R +validation ex-top-5%. High density without high range returns -0.096R and ++0.029R. Correlation between the explicit range and legacy level count is 0.864 +training and 0.822 validation. + +This isolates the hidden feature as a two-year realized price-excursion factor, +accidentally encoded by how many full-history pivots survive a 0.5% merge. It is +not evidence that the arbitrary lines are structural. A rounded threshold of +`log range >= 1.0` remains positive across a 0.9/1.0/1.1 sensitivity plateau. + +Two diagnostic-only arms now test whether the explicit scalar replaces the side +effect: + +- `production_range504`: deployed targets plus the explicit range gate; +- `rewrite_range504_legacy_primary`: clean targets, frozen primary selection, + plus the identical range gate. + +Run on pre-2024 training data only: + +```bash +.venv/bin/python scripts/run_sr_v2_matrix.py factor --workers 14 +``` + +The post-2024 window has been opened and is now analysis data, not a valid final +promotion holdout. These arms can isolate mechanism, but neither may ship without +new future data or a separately pre-registered walk-forward protocol. **Next runs, if picked back up:** diff --git a/scripts/run_backtest_snapshot.py b/scripts/run_backtest_snapshot.py index c5391fa..e944881 100644 --- a/scripts/run_backtest_snapshot.py +++ b/scripts/run_backtest_snapshot.py @@ -56,6 +56,7 @@ def _parse_args() -> argparse.Namespace: "legacy_geometry_neutral", "legacy_pivots_only", "legacy_traffic_grid_only", "legacy_range_grid_touch", "legacy_range_grid_neutral", + "production_range504", "rewrite_range504_legacy_primary", ), default=None, help="Research-only S/R detector/gate arm.", diff --git a/scripts/run_sr_v2_matrix.py b/scripts/run_sr_v2_matrix.py index 14d4094..9d4e0c6 100644 --- a/scripts/run_sr_v2_matrix.py +++ b/scripts/run_sr_v2_matrix.py @@ -34,6 +34,10 @@ TRAFFIC_ARMS = ( "legacy_pivots_only", *RANGE_GRID_ARMS, ) +RANGE_FACTOR_ARMS = ( + "production_range504", + "rewrite_range504_legacy_primary", +) def _add_common(parser: argparse.ArgumentParser) -> None: @@ -61,6 +65,17 @@ def _args() -> argparse.Namespace: default=None, help="Rerun one hidden-feature arm without repeating the full matrix.", ) + factor = commands.add_parser( + "factor", + help="Test the explicit 504-day range factor with old and clean detectors.", + ) + _add_common(factor) + factor.add_argument( + "--only-arm", + choices=RANGE_FACTOR_ARMS, + default=None, + help="Run one range-factor arm without repeating the pair.", + ) validate = commands.add_parser( "validate", help="Run production control and one locked arm from 2024-07-01.", @@ -148,6 +163,9 @@ def main() -> None: elif args.command == "traffic": arms = (args.only_arm,) if args.only_arm else TRAFFIC_ARMS _train(args, arms, "backtest-sr-traffic-train") + elif args.command == "factor": + arms = (args.only_arm,) if args.only_arm else RANGE_FACTOR_ARMS + _train(args, arms, "backtest-sr-range-factor-train") else: _validate(args) diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 88884b9..67df269 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -763,6 +763,8 @@ def test_sr_research_variant_is_explicit_and_validated(monkeypatch): "production_control", "legacy_range_grid_touch", "legacy_range_grid_neutral", + "production_range504", + "rewrite_range504_legacy_primary", ): monkeypatch.setenv("BACKTEST_SR_VARIANT", variant) assert bt._sr_research_variant() == variant @@ -771,6 +773,29 @@ def test_sr_research_variant_is_explicit_and_validated(monkeypatch): bt._sr_research_variant() +def test_range_factor_detector_mapping_is_explicit(): + assert bt._sr_detector_variant("production_range504") == "production_control" + assert bt._sr_detector_variant("rewrite_range504_legacy_primary") == "rewrite" + assert bt._sr_detector_variant("soft_zones_legacy_primary") == "soft_zones" + + +def test_range_504_log_uses_only_the_bounded_window(): + highs = [1_000.0, *([100.0] * bt.RANGE_FACTOR_LOOKBACK)] + lows = [10.0, *([50.0] * bt.RANGE_FACTOR_LOOKBACK)] + assert bt._range_504_log(highs, lows) == pytest.approx(math.log(2.0)) + + +def test_range_factor_gate_is_research_arm_only(): + below = bt.RANGE_FACTOR_MIN_LOG - 0.01 + assert not bt._range_factor_allows("production_range504", below) + assert not bt._range_factor_allows("rewrite_range504_legacy_primary", below) + assert bt._range_factor_allows( + "production_range504", + bt.RANGE_FACTOR_MIN_LOG, + ) + assert bt._range_factor_allows("production_control", below) + + @pytest.mark.parametrize( ("variant", "neutral_strength"), [ @@ -863,6 +888,9 @@ def test_replay_ticker_candidates_carry_gate_fields(): for c in cands: assert c.get("action") is not None assert "risk_level" in c + assert c["range_504_log"] >= 0.0 + assert c["range_504_ratio"] >= 1.0 + assert isinstance(c["range_factor_pass"], bool) async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None: