From f7c2e35e290b003a63892b576a6722537f1c6b53 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Mon, 13 Jul 2026 07:57:52 +0200 Subject: [PATCH] Isolate legacy range-grid features --- app/services/backtest_service.py | 26 +++++------ app/services/sr_service.py | 36 +++++++++++++++- docs/research/sr-levels-and-exits.md | 61 ++++++++++++++++++-------- scripts/run_backtest_snapshot.py | 3 +- scripts/run_sr_v2_matrix.py | 11 +++-- tests/unit/test_backtest_service.py | 62 +++++++++++++++++++++++++-- tests/unit/test_detect_sr_levels.py | 64 ++++++++++++++++++++++++++++ 7 files changed, 223 insertions(+), 40 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 6be648c..4bb850e 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -144,6 +144,8 @@ SR_RESEARCH_VARIANTS = { "legacy_geometry_neutral", "legacy_pivots_only", "legacy_traffic_grid_only", + "legacy_range_grid_touch", + "legacy_range_grid_neutral", } @@ -158,18 +160,7 @@ def _sr_research_variant() -> str: 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 == "legacy_geometry_neutral": - # Neutrality must be enforced after the shared zone cluster: its legacy - # sum mode can otherwise turn two 50-strength constituents back into a - # 100-strength target and silently invalidate the ablation. - for level in zone_levels: - level.strength = 50 - return zone_levels - - -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 == "legacy_geometry_neutral": + if sr_variant in {"legacy_geometry_neutral", "legacy_range_grid_neutral"}: # Neutrality must be enforced after the shared zone cluster: its legacy # sum mode can otherwise turn two 50-strength constituents back into a # 100-strength target and silently invalidate the ablation. @@ -310,6 +301,16 @@ def _window_setups( detected_levels = detect_sr_levels_legacy( highs, lows, closes, volumes, include_pivots=False ) + elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}: + detected_levels = detect_sr_levels_legacy( + highs, + lows, + closes, + volumes, + include_pivots=False, + neutral_strength=sr_variant == "legacy_range_grid_neutral", + explicit_range_grid=True, + ) elif detector_variant in {"production_control", "rr_aligned_control"}: detected_levels = detect_sr_levels_legacy(highs, lows, closes, volumes) else: @@ -351,7 +352,6 @@ def _window_setups( strength_mode=zone_strength_mode, ) zone_levels = _apply_zone_strength_variant(zone_levels, sr_variant) - zone_levels = _apply_zone_strength_variant(zone_levels, sr_variant) targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr) if not targets: fallback_k = _atr_target_fallback_k() diff --git a/app/services/sr_service.py b/app/services/sr_service.py index 08e3176..eb90cc6 100644 --- a/app/services/sr_service.py +++ b/app/services/sr_service.py @@ -394,6 +394,34 @@ def _legacy_volume_profile_nodes( return hvn + lvn +def _legacy_range_grid_nodes( + highs: list[float], + lows: list[float], + closes: list[float], + num_bins: int = 20, +) -> list[float]: + """Return every deployed grid center without the irrelevant volume pass. + + The legacy profile returns both its above-average (HVN) and below-average + (LVN) bins. Their union is therefore the complete range grid except for the + rare bin whose traffic equals the average exactly. This explicit helper + isolates that geometry from the misleading volume-profile label. + """ + if len(closes) < 20: + raise ValidationError( + f"Range grid requires at least 20 bars, got {len(closes)}" + ) + price_min = min(lows) + price_max = max(highs) + if price_max == price_min: + price_max = price_min + 1.0 + bin_width = (price_max - price_min) / num_bins + return [ + round(price_min + (i + 0.5) * bin_width, 4) + for i in range(num_bins) + ] + + def detect_sr_levels_legacy( highs: list[float], lows: list[float], @@ -404,6 +432,7 @@ def detect_sr_levels_legacy( include_volume_profile: bool = True, include_pivots: bool = True, neutral_strength: bool = False, + explicit_range_grid: bool = False, ) -> list[dict]: """Deployed detector plus source-isolation controls for local research. @@ -417,7 +446,12 @@ def detect_sr_levels_legacy( candidates: list[tuple[float, str]] = [] if include_volume_profile: try: - for price in _legacy_volume_profile_nodes(highs, lows, closes, volumes): + nodes = ( + _legacy_range_grid_nodes(highs, lows, closes) + if explicit_range_grid + else _legacy_volume_profile_nodes(highs, lows, closes, volumes) + ) + for price in nodes: candidates.append((float(price), "volume_profile")) except ValidationError: pass diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index 45bc019..aa746f5 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -481,13 +481,13 @@ 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: +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. +- `legacy_traffic_grid_only`: deployed HVN+LVN grid, no pivots. Run on macOS: @@ -498,29 +498,54 @@ Run on macOS: Do not validate any traffic arm yet. First establish whether geometry, pivots, or the range-occupancy grid reproduces production on pre-2024 training data. -Initial result: +Corrected training result: -| arm | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% | -|---|---:|---:|---:|---:|---:| -| production control | 1.28 | 28.8% | 21.4% | 0.230 | 0.066 | -| pivots only | 1.38 | 32.9% | 25.2% | 0.236 | 0.076 | -| traffic grid only | **1.72** | **41.8%** | **16.8%** | **0.271** | **0.136** | +| arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% | +|---|---:|---:|---:|---:|---:|---:| +| production control | 676 | 1.28 | 28.8% | 21.4% | 0.230 | 0.066 | +| corrected neutral geometry | 505 | 1.48 | 33.5% | 18.2% | 0.230 | 0.087 | +| pivots only | 717 | 1.38 | 32.9% | 25.2% | 0.236 | 0.076 | +| traffic grid only | 504 | **1.72** | **41.8%** | **16.8%** | **0.271** | **0.136** | The traffic grid is the first research arm to beat control simultaneously on -Sharpe, CAGR, drawdown, and robust expectancy. It retains 264 production setups, -adds 240 positive-expectancy setups, and removes 412 weaker setups. This supports -the hypothesis that the useful legacy feature is the volume-weighted range- -occupancy grid, not pivots or visually meaningful S/R. +Sharpe, CAGR, drawdown, and robust expectancy. It retains 264 production setups +at +0.214R ex-top-5%, adds 240 at +0.051R, and removes 412 at only +0.010R. +Against corrected neutral geometry, only 239 qualified setups overlap; the 265 +traffic-only setups return +0.140R ex-top-5% versus +0.073R for the 266 +neutral-only setups. This is different selection, not merely a lower trade count. -The first geometry-neutral result is not interpretable: neutral strength was set -before the shared zone cluster, which summed multiple 50-strength constituents -back to 100. Neutrality is now enforced after clustering. Rerun only that arm: +The old `volume_profile` name is misleading. Its helper returns both HVN and LVN +bins, so their union retains almost every one of the 20 evenly spaced centers over +the expanding historical high-low range (19.987 levels on average in the audit). +The later strength calculation does not use volume; it counts bars whose ranges +cross each center. The candidate feature is therefore: + +1. a normalized, expanding 20-bin price-range grid; +2. range-touch occupancy strength; +3. no full-history pivot ladder or pivot/grid strength saturation. + +Two final training arms isolate the first two items explicitly: + +- `legacy_range_grid_touch`: all 20 range centers, no volume calculation, legacy + touch strength; +- `legacy_range_grid_neutral`: identical centers, strength fixed at 50 after + clustering. + +Run only these new arms on macOS: ```bash .venv/bin/python scripts/run_sr_v2_matrix.py traffic \ - --only-arm legacy_geometry_neutral --workers 14 + --only-arm legacy_range_grid_touch --workers 14 + +.venv/bin/python scripts/run_sr_v2_matrix.py traffic \ + --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. + **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 e328802..c5391fa 100644 --- a/scripts/run_backtest_snapshot.py +++ b/scripts/run_backtest_snapshot.py @@ -54,7 +54,8 @@ def _parse_args() -> argparse.Namespace: "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", + "legacy_traffic_grid_only", "legacy_range_grid_touch", + "legacy_range_grid_neutral", ), 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 716fc3b..14d4094 100644 --- a/scripts/run_sr_v2_matrix.py +++ b/scripts/run_sr_v2_matrix.py @@ -22,12 +22,17 @@ TRAINING_ARMS = ( "confirmed_rounds_legacy_primary", "gate_v2_legacy_primary", ) -LOCKABLE_ARMS = TRAINING_ARMS[1:] +RANGE_GRID_ARMS = ( + "legacy_traffic_grid_only", + "legacy_range_grid_touch", + "legacy_range_grid_neutral", +) +LOCKABLE_ARMS = TRAINING_ARMS[1:] + RANGE_GRID_ARMS TRAFFIC_ARMS = ( "production_control", "legacy_geometry_neutral", "legacy_pivots_only", - "legacy_traffic_grid_only", + *RANGE_GRID_ARMS, ) @@ -47,7 +52,7 @@ def _args() -> argparse.Namespace: _add_common(train) traffic = commands.add_parser( "traffic", - help="Isolate legacy geometry, pivots, and price-traffic grid on training data.", + help="Isolate pivots, range-grid geometry, and touch strength on training data.", ) _add_common(traffic) traffic.add_argument( diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index b3b0486..88884b9 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -759,22 +759,76 @@ def test_window_setups_too_short_returns_empty(): def test_sr_research_variant_is_explicit_and_validated(monkeypatch): - monkeypatch.setenv("BACKTEST_SR_VARIANT", "production_control") - assert bt._sr_research_variant() == "production_control" + for variant in ( + "production_control", + "legacy_range_grid_touch", + "legacy_range_grid_neutral", + ): + monkeypatch.setenv("BACKTEST_SR_VARIANT", variant) + assert bt._sr_research_variant() == variant monkeypatch.setenv("BACKTEST_SR_VARIANT", "not-a-variant") with pytest.raises(ValueError, match="Unknown BACKTEST_SR_VARIANT"): bt._sr_research_variant() -def test_geometry_neutral_strength_is_enforced_after_zone_clustering(): +@pytest.mark.parametrize( + ("variant", "neutral_strength"), + [ + ("legacy_range_grid_touch", False), + ("legacy_range_grid_neutral", True), + ], +) +def test_window_setups_routes_explicit_range_grid( + monkeypatch, + variant, + neutral_strength, +): + captured = {} + + def fake_detector(*args, **kwargs): + captured.update(kwargs) + return [] + + monkeypatch.setenv("BACKTEST_SR_VARIANT", variant) + monkeypatch.setattr(bt, "detect_sr_levels_legacy", fake_detector) + records = [ + SimpleNamespace( + date=date(2024, 1, 1) + timedelta(days=i), + open=100.0, + high=101.0, + low=99.0, + close=100.0, + volume=1_000_000, + ) + for i in range(bt.MIN_LOOKBACK) + ] + assert bt._window_setups(records, {}, {}) == [] + assert captured == { + "include_pivots": False, + "neutral_strength": neutral_strength, + "explicit_range_grid": True, + } + + +@pytest.mark.parametrize( + "variant", + ["legacy_geometry_neutral", "legacy_range_grid_neutral"], +) +def test_neutral_strength_is_enforced_after_zone_clustering(variant): levels = [ SimpleNamespace(strength=100), SimpleNamespace(strength=75), ] - result = bt._apply_zone_strength_variant(levels, "legacy_geometry_neutral") + result = bt._apply_zone_strength_variant(levels, variant) assert [level.strength for level in result] == [50, 50] +def test_touch_strength_survives_post_cluster_research_hook(): + levels = [SimpleNamespace(strength=82), SimpleNamespace(strength=37)] + result = bt._apply_zone_strength_variant(levels, "legacy_range_grid_touch") + assert [level.strength for level in result] == [82, 37] + + def test_backtest_entry_bounds_validate_dates(monkeypatch): monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01") monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31") diff --git a/tests/unit/test_detect_sr_levels.py b/tests/unit/test_detect_sr_levels.py index e787a4e..8428443 100644 --- a/tests/unit/test_detect_sr_levels.py +++ b/tests/unit/test_detect_sr_levels.py @@ -6,6 +6,8 @@ from app.services.sr_service import ( MAX_LEVELS, _bar_respect_weight, _cap_levels, + _legacy_range_grid_nodes, + _legacy_volume_profile_nodes, _merge_levels, _round_number_candidates, _strength_from_respects, @@ -267,3 +269,65 @@ class TestDetectSrLevels: assert pivots and traffic assert all(level["sources"] == ["pivot_point"] for level in pivots) assert all(level["sources"] == ["volume_profile"] for level in traffic) + + def test_legacy_profile_union_matches_explicit_range_grid(self): + highs, lows, closes, volumes = _make_series(n=500) + deployed = _legacy_volume_profile_nodes(highs, lows, closes, volumes) + explicit = _legacy_range_grid_nodes(highs, lows, closes) + assert sorted(deployed) == explicit + assert len(explicit) == 20 + + def test_explicit_range_grid_is_volume_independent(self): + highs, lows, closes, volumes = _make_series(n=500) + shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)] + baseline = detect_sr_levels_legacy( + highs, + lows, + closes, + volumes, + include_pivots=False, + explicit_range_grid=True, + ) + shifted = detect_sr_levels_legacy( + highs, + lows, + closes, + shifted_volumes, + include_pivots=False, + explicit_range_grid=True, + ) + assert shifted == baseline + + def test_explicit_range_grid_neutral_changes_only_strength(self): + highs, lows, closes, volumes = _make_series(n=500) + touch = detect_sr_levels_legacy( + highs, + lows, + closes, + volumes, + include_pivots=False, + explicit_range_grid=True, + ) + neutral = detect_sr_levels_legacy( + highs, + lows, + closes, + volumes, + include_pivots=False, + neutral_strength=True, + explicit_range_grid=True, + ) + touch_by_price = {level["price_level"]: level for level in touch} + neutral_by_price = {level["price_level"]: level for level in neutral} + assert neutral_by_price.keys() == touch_by_price.keys() + for price, neutral_level in neutral_by_price.items(): + assert neutral_level["strength"] == 50 + assert { + key: value + for key, value in neutral_level.items() + if key != "strength" + } == { + key: value + for key, value in touch_by_price[price].items() + if key != "strength" + }