diff --git a/README.md b/README.md index 6c38fe2..644bbf0 100644 --- a/README.md +++ b/README.md @@ -430,7 +430,7 @@ Research-only flags, all off by default (the default report is byte-identical to |---|---| | `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books | | `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample | -| `BACKTEST_SR_VARIANT=` | Research-only S/R arm: `production_control`, `rr_aligned_control`, `rewrite`, `soft_zones`, `confirmed_rounds`, or `gate_v2` | +| `BACKTEST_SR_VARIANT=` | Research-only S/R detector/gate arm; see `docs/research/sr-levels-and-exits.md` for the detector and hidden-feature matrices | | `BACKTEST_ENTRY_START=YYYY-MM-DD` | Restrict candidate entry dates to a validation window | | `BACKTEST_ENTRY_END=YYYY-MM-DD` | Restrict candidate entry dates to a training window | | `BACKTEST_SR_AUDIT=1` | Add momentum-slice candidate rows for paired S/R cohort comparison | diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index f0eaff1..5f4eb67 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -141,6 +141,9 @@ SR_RESEARCH_VARIANTS = { "soft_zones_legacy_primary", "confirmed_rounds_legacy_primary", "gate_v2_legacy_primary", + "legacy_geometry_neutral", + "legacy_pivots_only", + "legacy_traffic_grid_only", } @@ -273,7 +276,19 @@ def _window_setups( sr_variant = _sr_research_variant() detector_variant = sr_variant.removesuffix("_legacy_primary") - if detector_variant in {"production_control", "rr_aligned_control"}: + if sr_variant == "legacy_geometry_neutral": + detected_levels = detect_sr_levels_legacy( + highs, lows, closes, volumes, neutral_strength=True + ) + elif sr_variant == "legacy_pivots_only": + detected_levels = detect_sr_levels_legacy( + highs, lows, closes, volumes, include_volume_profile=False + ) + elif sr_variant == "legacy_traffic_grid_only": + detected_levels = detect_sr_levels_legacy( + highs, lows, closes, volumes, include_pivots=False + ) + elif detector_variant in {"production_control", "rr_aligned_control"}: detected_levels = detect_sr_levels_legacy(highs, lows, closes, volumes) else: detector_cap = 0 if detector_variant == "gate_v2" else MAX_LEVELS @@ -331,7 +346,9 @@ def _window_setups( targets = _prune_floor_pinned_targets(targets) primary_min_rr = ( 1.5 - if sr_variant == "production_control" or sr_variant.endswith("_legacy_primary") + if sr_variant == "production_control" + or sr_variant.endswith("_legacy_primary") + or sr_variant.startswith("legacy_") else float(activation.get("min_rr", 0.0)) ) primary = _select_primary_target( diff --git a/app/services/sr_service.py b/app/services/sr_service.py index b9a9098..08e3176 100644 --- a/app/services/sr_service.py +++ b/app/services/sr_service.py @@ -400,25 +400,36 @@ def detect_sr_levels_legacy( closes: list[float], volumes: list[int], tolerance: float = DEFAULT_TOLERANCE, + *, + include_volume_profile: bool = True, + include_pivots: bool = True, + neutral_strength: bool = False, ) -> list[dict]: - """Exact research control for the deployed pre-rewrite detector.""" + """Deployed detector plus source-isolation controls for local research. + + The defaults remain the exact production control. Keyword-only switches + allow a research arm to remove one source or neutralize strength without + copying or subtly changing the legacy implementation. + """ if not closes: return [] candidates: list[tuple[float, str]] = [] - try: - for price in _legacy_volume_profile_nodes(highs, lows, closes, volumes): - candidates.append((float(price), "volume_profile")) - except ValidationError: - pass - try: - pivots = compute_pivot_points(highs, lows, closes) - candidates.extend( - (float(price), "pivot_point") - for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", []) - ) - except ValidationError: - pass + if include_volume_profile: + try: + for price in _legacy_volume_profile_nodes(highs, lows, closes, volumes): + candidates.append((float(price), "volume_profile")) + except ValidationError: + pass + if include_pivots: + try: + pivots = compute_pivot_points(highs, lows, closes) + candidates.extend( + (float(price), "pivot_point") + for price in pivots.get("swing_highs", []) + pivots.get("swing_lows", []) + ) + except ValidationError: + pass if not candidates: return [] @@ -469,6 +480,9 @@ def detect_sr_levels_legacy( ) _tag_levels(merged, closes[-1]) + if neutral_strength: + for level in merged: + level["strength"] = 50 merged.sort(key=lambda row: row["strength"], reverse=True) return merged diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index d27da95..0484ddc 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -452,6 +452,52 @@ than a pristine holdout; do not sweep variants on it. No deployment follows automatically. A lower validation Sharpe or higher drawdown remains a no-ship result even when CAGR rises. +## 8. Final detector-only result: no rewritten gate arm advances + +The last matrix froze production's effective gate (`primary min_rr=1.5`, +activation `min_rr=2.0`) and varied only level detection/zone policy on entries +through 2024-06-30. Corrected portfolio calendars end after the last position can +resolve; there is no flat-cash tail. + +| arm | Sharpe | CAGR | MaxDD | qualified | net avg R | ex-top-5% | +|---|---:|---:|---:|---:|---:|---:| +| production control | **1.28** | **28.8%** | 21.4% | 676 | **0.230** | **0.066** | +| rewrite + legacy primary | 0.96 | 21.0% | 22.2% | 1,200 | 0.037 | -0.102 | +| soft zones + legacy primary | 1.08 | 25.1% | **17.8%** | 1,161 | 0.045 | -0.096 | +| confirmed rounds + legacy primary | 1.14 | 22.1% | 20.2% | 570 | 0.189 | 0.045 | +| gate v2 + legacy primary | 0.87 | 16.7% | 20.7% | 604 | 0.187 | 0.041 | + +No arm advances to validation. The raw rewrite retains only 249 of 676 production +setups, removes 427 good setups, and adds 951 setups with negative expectancy. +Round confirmation repairs the added cohort but still removes 430 production +setups whose 30-day average (+0.680R) exceeds the additions (+0.511R). Uncapping +recovers only 16 of those missing setups. The old detector averages 43.3 gate +levels versus 15.0 rewritten and 24.0 rewritten-uncapped levels. + +**Standing no-ship decision:** keep both the deployed detector and the legacy 1.5 +primary-selection behavior in the trading path. The rewritten structure may only +proceed as a separately computed display model. Do not merge this research branch +into production as-is. + +### Hidden-feature isolation + +The remaining hypothesis is that the deployed detector accidentally measures +long-memory historical price traffic rather than genuine S/R. A dedicated matrix +holds the complete gate fixed and changes one legacy component at a time: + +- `legacy_geometry_neutral`: old locations, every merged strength fixed at 50; +- `legacy_pivots_only`: unfiltered full-history pivots, no VP grid; +- `legacy_traffic_grid_only`: old range-volume price grid, no pivots. + +Run on macOS: + +```bash +.venv/bin/python scripts/run_sr_v2_matrix.py traffic --workers 14 +``` + +Do not validate any traffic arm yet. First establish whether geometry, pivots, or +the range-occupancy grid reproduces production on pre-2024 training data. + **Next runs, if picked back up:** - A **per-name target model** for clear-air setups instead of a constant k×ATR. This diff --git a/scripts/run_backtest_snapshot.py b/scripts/run_backtest_snapshot.py index 3f5953e..e328802 100644 --- a/scripts/run_backtest_snapshot.py +++ b/scripts/run_backtest_snapshot.py @@ -53,6 +53,8 @@ def _parse_args() -> argparse.Namespace: "soft_zones", "confirmed_rounds", "gate_v2", "rewrite_legacy_primary", "soft_zones_legacy_primary", "confirmed_rounds_legacy_primary", "gate_v2_legacy_primary", + "legacy_geometry_neutral", "legacy_pivots_only", + "legacy_traffic_grid_only", ), 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 eba9df4..e4b1756 100644 --- a/scripts/run_sr_v2_matrix.py +++ b/scripts/run_sr_v2_matrix.py @@ -1,4 +1,4 @@ -"""Run the S/R v2 training matrix or one locked validation comparison. +"""Run S/R detector, hidden-feature, or locked validation comparisons. This is a cross-platform orchestrator around ``run_backtest_snapshot.py``. It contains no backtest logic; every arm still runs through the production-parity @@ -23,6 +23,12 @@ TRAINING_ARMS = ( "gate_v2_legacy_primary", ) LOCKABLE_ARMS = TRAINING_ARMS[1:] +TRAFFIC_ARMS = ( + "production_control", + "legacy_geometry_neutral", + "legacy_pivots_only", + "legacy_traffic_grid_only", +) def _add_common(parser: argparse.ArgumentParser) -> None: @@ -39,6 +45,11 @@ def _args() -> argparse.Namespace: commands = parser.add_subparsers(dest="command", required=True) train = commands.add_parser("train", help="Run all arms before 2024-07-01.") _add_common(train) + traffic = commands.add_parser( + "traffic", + help="Isolate legacy geometry, pivots, and price-traffic grid on training data.", + ) + _add_common(traffic) validate = commands.add_parser( "validate", help="Run production control and one locked arm from 2024-07-01.", @@ -75,17 +86,21 @@ def _run_arm( ) -def _train(args: argparse.Namespace) -> None: - for arm in TRAINING_ARMS: +def _train( + args: argparse.Namespace, + arms: tuple[str, ...], + filename_prefix: str, +) -> None: + for arm in arms: _run_arm( arm, args.snapshot, args.workers, entry_flag="--entry-end", entry_date="2024-06-30", - output=ROOT / "reports" / f"backtest-sr-v2-train-{arm}.json", + output=ROOT / "reports" / f"{filename_prefix}-{arm}.json", ) - print("Training matrix complete. Lock one arm before running validation.") + print("Training matrix complete. Review results before running validation.") def _validate(args: argparse.Namespace) -> None: @@ -118,7 +133,9 @@ def _validate(args: argparse.Namespace) -> None: def main() -> None: args = _args() if args.command == "train": - _train(args) + _train(args, TRAINING_ARMS, "backtest-sr-v2-train") + elif args.command == "traffic": + _train(args, TRAFFIC_ARMS, "backtest-sr-traffic-train") else: _validate(args) diff --git a/tests/unit/test_detect_sr_levels.py b/tests/unit/test_detect_sr_levels.py index cbc65c4..e787a4e 100644 --- a/tests/unit/test_detect_sr_levels.py +++ b/tests/unit/test_detect_sr_levels.py @@ -242,3 +242,28 @@ class TestDetectSrLevels: # The research control intentionally keeps the deployed detector's much # denser output instead of borrowing the rewrite's presentation cap. assert len(levels) > MAX_LEVELS + + def test_legacy_geometry_neutral_changes_only_strength(self): + highs, lows, closes, volumes = _make_series(n=500) + control = detect_sr_levels_legacy(highs, lows, closes, volumes) + neutral = detect_sr_levels_legacy( + highs, lows, closes, volumes, neutral_strength=True + ) + assert [level["price_level"] for level in neutral] == [ + level["price_level"] for level in sorted( + control, key=lambda row: row["price_level"] + ) + ] + assert all(level["strength"] == 50 for level in neutral) + + def test_legacy_source_ablation_isolates_candidates(self): + highs, lows, closes, volumes = _make_series(n=500) + pivots = detect_sr_levels_legacy( + highs, lows, closes, volumes, include_volume_profile=False + ) + traffic = detect_sr_levels_legacy( + highs, lows, closes, volumes, include_pivots=False + ) + assert pivots and traffic + assert all(level["sources"] == ["pivot_point"] for level in pivots) + assert all(level["sources"] == ["volume_profile"] for level in traffic)