Add S/R v2 research and validation harness
This commit is contained in:
+5
-1
@@ -213,7 +213,11 @@ def sr_levels(draw: st.DrawFn) -> dict[str, Any]:
|
||||
"price_level": draw(st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False)),
|
||||
"type": draw(st.sampled_from(["support", "resistance"])),
|
||||
"strength": draw(st.integers(min_value=0, max_value=100)),
|
||||
"detection_method": draw(st.sampled_from(["volume_profile", "pivot_point", "merged"])),
|
||||
"detection_method": draw(
|
||||
st.sampled_from(
|
||||
["volume_profile", "pivot_point", "merged", "round_number"]
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -730,6 +730,23 @@ def test_window_setups_too_short_returns_empty():
|
||||
assert bt._window_setups([], {}, {}) == []
|
||||
|
||||
|
||||
def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
|
||||
monkeypatch.setenv("BACKTEST_SR_VARIANT", "production_control")
|
||||
assert bt._sr_research_variant() == "production_control"
|
||||
monkeypatch.setenv("BACKTEST_SR_VARIANT", "not-a-variant")
|
||||
with pytest.raises(ValueError, match="Unknown BACKTEST_SR_VARIANT"):
|
||||
bt._sr_research_variant()
|
||||
|
||||
|
||||
def test_backtest_entry_bounds_validate_dates(monkeypatch):
|
||||
monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01")
|
||||
monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31")
|
||||
assert bt._backtest_entry_bounds() == (date(2024, 7, 1), date(2024, 12, 31))
|
||||
monkeypatch.setenv("BACKTEST_ENTRY_START", "2025-01-01")
|
||||
with pytest.raises(ValueError, match="on or before"):
|
||||
bt._backtest_entry_bounds()
|
||||
|
||||
|
||||
def test_replay_ticker_candidates_carry_gate_fields():
|
||||
"""The ablation recomputes floors from candidate fields — a candidate missing
|
||||
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
|
||||
|
||||
@@ -85,6 +85,33 @@ class TestClusterSrZonesStrength:
|
||||
zones = cluster_sr_zones(levels, current_price=200.0, tolerance=0.02)
|
||||
assert zones[0]["strength"] == 30
|
||||
|
||||
def test_soft_strength_uses_max_plus_confluence(self):
|
||||
levels = [
|
||||
{
|
||||
"price_level": 100.0,
|
||||
"strength": 60,
|
||||
"detection_method": "pivot_point",
|
||||
"sources": ["pivot_point"],
|
||||
"rejection_count": 3,
|
||||
},
|
||||
{
|
||||
"price_level": 100.5,
|
||||
"strength": 60,
|
||||
"detection_method": "round_number",
|
||||
"sources": ["round_number"],
|
||||
"rejection_count": 1,
|
||||
},
|
||||
]
|
||||
zones = cluster_sr_zones(
|
||||
levels,
|
||||
current_price=200.0,
|
||||
tolerance=0.02,
|
||||
strength_mode="soft",
|
||||
)
|
||||
assert zones[0]["strength"] == 65
|
||||
assert set(zones[0]["sources"]) == {"pivot_point", "round_number"}
|
||||
assert zones[0]["rejection_count"] == 3
|
||||
|
||||
|
||||
class TestClusterSrZonesTypeTagging:
|
||||
"""Support vs resistance tagging."""
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Unit tests for detect_sr_levels and related pure helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.sr_service import (
|
||||
MAX_LEVELS,
|
||||
_bar_respect_weight,
|
||||
_cap_levels,
|
||||
_merge_levels,
|
||||
_round_number_candidates,
|
||||
_strength_from_respects,
|
||||
detect_sr_levels,
|
||||
detect_sr_levels_legacy,
|
||||
)
|
||||
|
||||
|
||||
def _make_series(
|
||||
n: int = 300,
|
||||
*,
|
||||
base: float = 100.0,
|
||||
support: float = 95.0,
|
||||
resistance: float = 110.0,
|
||||
) -> tuple[list[float], list[float], list[float], list[int]]:
|
||||
"""Synthetic OHLCV that repeatedly tests support/resistance."""
|
||||
highs: list[float] = []
|
||||
lows: list[float] = []
|
||||
closes: list[float] = []
|
||||
volumes: list[int] = []
|
||||
|
||||
price = base
|
||||
for i in range(n):
|
||||
phase = i % 40
|
||||
if phase < 15:
|
||||
# Drift down toward support, bounce
|
||||
target = support
|
||||
price = price + (target - price) * 0.25
|
||||
low = min(price, support) - 0.3
|
||||
high = price + 1.0
|
||||
close = max(price, support + 0.5) if phase > 12 else price
|
||||
elif phase < 30:
|
||||
# Drift up toward resistance, reject
|
||||
target = resistance
|
||||
price = price + (target - price) * 0.25
|
||||
high = max(price, resistance) + 0.3
|
||||
low = price - 1.0
|
||||
close = min(price, resistance - 0.5) if phase > 27 else price
|
||||
else:
|
||||
price = base + (i % 7) * 0.2
|
||||
high = price + 1.0
|
||||
low = price - 1.0
|
||||
close = price
|
||||
|
||||
# Occasional clear swing extremes
|
||||
if i % 55 == 25:
|
||||
high = resistance + 1.0
|
||||
close = resistance - 1.0
|
||||
low = close - 1.0
|
||||
if i % 55 == 50:
|
||||
low = support - 1.0
|
||||
close = support + 1.0
|
||||
high = close + 1.0
|
||||
|
||||
highs.append(high)
|
||||
lows.append(low)
|
||||
closes.append(close)
|
||||
volumes.append(1000 + (i % 10) * 50)
|
||||
price = close
|
||||
|
||||
return highs, lows, closes, volumes
|
||||
|
||||
|
||||
class TestBarRespectWeight:
|
||||
def test_no_interaction(self):
|
||||
assert _bar_respect_weight(100.0, 90.0, 85.0, 88.0, 87.0, 0.005) == 0.0
|
||||
|
||||
def test_support_rejection(self):
|
||||
# Low probes at 100, closes above with recovery wick
|
||||
w = _bar_respect_weight(100.0, 103.0, 99.8, 102.0, 101.0, 0.005)
|
||||
assert w >= 0.9
|
||||
|
||||
def test_resistance_rejection(self):
|
||||
# High probes at 100, closes below
|
||||
w = _bar_respect_weight(100.0, 100.2, 97.0, 98.0, 99.0, 0.005)
|
||||
assert w >= 0.9
|
||||
|
||||
def test_pass_through_lower_weight(self):
|
||||
# Prev below, close above, bar spans through without probing extremes at level
|
||||
w = _bar_respect_weight(100.0, 105.0, 95.0, 104.0, 96.0, 0.005)
|
||||
assert w < 0.5
|
||||
|
||||
|
||||
class TestStrengthFromRespects:
|
||||
def test_pass_through_not_maximal(self):
|
||||
"""Central pass-through levels should not pin at strength 100."""
|
||||
n = 200
|
||||
# Trending series that passes through 100 many times
|
||||
closes = [80.0 + i * 0.25 for i in range(n)]
|
||||
highs = [c + 1.0 for c in closes]
|
||||
lows = [c - 1.0 for c in closes]
|
||||
strength = _strength_from_respects(100.0, highs, lows, closes, 0.005)
|
||||
assert strength < 100
|
||||
|
||||
def test_repeated_rejection_stronger_than_no_touch(self):
|
||||
n = 120
|
||||
level = 100.0
|
||||
# Bars that repeatedly probe support (low near level) and close above
|
||||
highs = [103.0] * n
|
||||
lows = [99.8] * n
|
||||
closes = [102.0] * n
|
||||
strong = _strength_from_respects(level, highs, lows, closes, 0.01, base=10)
|
||||
|
||||
far_highs = [120.0] * n
|
||||
far_lows = [118.0] * n
|
||||
far_closes = [119.0] * n
|
||||
weak = _strength_from_respects(level, far_highs, far_lows, far_closes, 0.01, base=10)
|
||||
assert strong > weak
|
||||
|
||||
|
||||
class TestRoundNumbers:
|
||||
def test_near_spot(self):
|
||||
levels = _round_number_candidates(103.0)
|
||||
assert levels
|
||||
assert all(abs(p - 103.0) / 103.0 <= 0.15 + 1e-9 for p in levels)
|
||||
assert len(levels) <= 8
|
||||
|
||||
def test_non_positive_price(self):
|
||||
assert _round_number_candidates(0.0) == []
|
||||
assert _round_number_candidates(-5.0) == []
|
||||
|
||||
|
||||
class TestCapLevels:
|
||||
def test_interleaves_sides(self):
|
||||
levels = [
|
||||
{"price_level": 90.0, "type": "support", "strength": 80, "detection_method": "x"},
|
||||
{"price_level": 91.0, "type": "support", "strength": 70, "detection_method": "x"},
|
||||
{"price_level": 92.0, "type": "support", "strength": 60, "detection_method": "x"},
|
||||
{"price_level": 110.0, "type": "resistance", "strength": 50, "detection_method": "x"},
|
||||
{"price_level": 111.0, "type": "resistance", "strength": 40, "detection_method": "x"},
|
||||
]
|
||||
capped = _cap_levels(levels, max_levels=4)
|
||||
assert len(capped) == 4
|
||||
types = {lvl["type"] for lvl in capped}
|
||||
assert "support" in types
|
||||
assert "resistance" in types
|
||||
|
||||
|
||||
class TestLevelEvidence:
|
||||
def test_merge_preserves_sources_and_rejection_evidence(self):
|
||||
levels = [
|
||||
{
|
||||
"price_level": 100.0,
|
||||
"type": "",
|
||||
"strength": 55,
|
||||
"detection_method": "pivot_point",
|
||||
"sources": ["pivot_point"],
|
||||
"rejection_count": 3,
|
||||
"last_rejection_age": 12,
|
||||
"weighted_respects": 1.5,
|
||||
},
|
||||
{
|
||||
"price_level": 100.3,
|
||||
"type": "",
|
||||
"strength": 40,
|
||||
"detection_method": "round_number",
|
||||
"sources": ["round_number"],
|
||||
"rejection_count": 1,
|
||||
"last_rejection_age": 4,
|
||||
"weighted_respects": 0.5,
|
||||
},
|
||||
]
|
||||
merged = _merge_levels(levels, tolerance=0.005)
|
||||
assert len(merged) == 1
|
||||
assert set(merged[0]["sources"]) == {"pivot_point", "round_number"}
|
||||
assert merged[0]["rejection_count"] == 3
|
||||
assert merged[0]["last_rejection_age"] == 4
|
||||
|
||||
|
||||
class TestDetectSrLevels:
|
||||
def test_returns_capped_tagged_levels(self):
|
||||
highs, lows, closes, volumes = _make_series()
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
assert levels
|
||||
assert len(levels) <= MAX_LEVELS
|
||||
for lvl in levels:
|
||||
assert lvl["type"] in ("support", "resistance")
|
||||
assert 0 <= lvl["strength"] <= 100
|
||||
assert lvl["detection_method"] in (
|
||||
"volume_profile",
|
||||
"pivot_point",
|
||||
"merged",
|
||||
"round_number",
|
||||
)
|
||||
assert lvl["price_level"] > 0
|
||||
assert lvl["sources"]
|
||||
assert lvl["rejection_count"] >= 0
|
||||
# Sorted by strength desc
|
||||
strengths = [lvl["strength"] for lvl in levels]
|
||||
assert strengths == sorted(strengths, reverse=True)
|
||||
|
||||
def test_far_fewer_than_old_grid(self):
|
||||
"""Should not produce a near-1%-spacing grid of ~70 levels."""
|
||||
highs, lows, closes, volumes = _make_series(n=500)
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
assert len(levels) <= MAX_LEVELS
|
||||
|
||||
def test_empty_input(self):
|
||||
assert detect_sr_levels([], [], [], []) == []
|
||||
|
||||
def test_explicit_tolerance(self):
|
||||
highs, lows, closes, volumes = _make_series()
|
||||
tight = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.001)
|
||||
wide = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.05)
|
||||
# Wider merge should not produce more levels
|
||||
assert len(wide) <= len(tight) + 2 # allow small jitter from scoring
|
||||
|
||||
def test_levels_near_structural_areas(self):
|
||||
"""At least some levels should land near the synthetic S/R band."""
|
||||
highs, lows, closes, volumes = _make_series(
|
||||
n=400, support=95.0, resistance=110.0
|
||||
)
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
prices = [lvl["price_level"] for lvl in levels]
|
||||
near_support = any(abs(p - 95.0) / 95.0 < 0.05 for p in prices)
|
||||
near_resist = any(abs(p - 110.0) / 110.0 < 0.05 for p in prices)
|
||||
# Round numbers / VP may dominate; require at least one structural band hit
|
||||
assert near_support or near_resist or any(
|
||||
abs(p - 100.0) / 100.0 < 0.08 for p in prices
|
||||
)
|
||||
|
||||
def test_strength_not_all_pinned_at_100(self):
|
||||
highs, lows, closes, volumes = _make_series(n=400)
|
||||
levels = detect_sr_levels(highs, lows, closes, volumes)
|
||||
if len(levels) >= 3:
|
||||
pinned = sum(1 for lvl in levels if lvl["strength"] == 100)
|
||||
assert pinned < len(levels)
|
||||
|
||||
def test_legacy_control_retains_old_uncapped_grid(self):
|
||||
highs, lows, closes, volumes = _make_series(n=500)
|
||||
levels = detect_sr_levels_legacy(highs, lows, closes, volumes)
|
||||
assert levels
|
||||
assert all(level["sources"] for level in levels)
|
||||
# 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
|
||||
@@ -164,6 +164,34 @@ class TestComputeVolumeProfile:
|
||||
with pytest.raises(ValidationError, match="Volume Profile requires"):
|
||||
compute_volume_profile(highs, lows, closes, volumes)
|
||||
|
||||
def test_close_bin_volume_no_double_count(self):
|
||||
"""Each bar's volume is counted once (close bin), not per span."""
|
||||
# Wide bars that would span many bins under the old algorithm
|
||||
n = 25
|
||||
closes = [100.0 + (i % 5) for i in range(n)]
|
||||
highs = [c + 20 for c in closes] # wide range
|
||||
lows = [c - 20 for c in closes]
|
||||
volumes = [1000] * n
|
||||
result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20)
|
||||
# Binned total equals true volume (close-bin assignment)
|
||||
# We only expose poc/hvn; reconstruct by checking score fields exist
|
||||
assert result["poc"] > 0
|
||||
# With volume concentrated on a few close prices, HVNs should be few local peaks
|
||||
assert len(result["hvn"]) < 20
|
||||
|
||||
def test_hvn_are_local_peaks_not_all_above_mean(self):
|
||||
"""HVN should be local histogram peaks, not every above-mean bin."""
|
||||
# Two clusters of closes → two volume peaks
|
||||
closes = [80.0] * 10 + [120.0] * 10 + [100.0] * 5
|
||||
highs = [c + 1 for c in closes]
|
||||
lows = [c - 1 for c in closes]
|
||||
volumes = [1000] * len(closes)
|
||||
result = compute_volume_profile(highs, lows, closes, volumes, num_bins=20)
|
||||
# At most a handful of local peaks (not ~half of 20 bins)
|
||||
assert len(result["hvn"]) <= 6
|
||||
# POC should land near one of the high-volume clusters
|
||||
assert result["poc"] < 95 or result["poc"] > 105
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pivot Points
|
||||
@@ -184,6 +212,26 @@ class TestComputePivotPoints:
|
||||
with pytest.raises(ValidationError, match="Pivot Points requires"):
|
||||
compute_pivot_points([1, 2], [0, 1], [0.5, 1.5])
|
||||
|
||||
def test_prominence_filters_tiny_swings(self):
|
||||
# Mix of a large swing (depth ~10) and tiny fractal noise (depth ~1)
|
||||
closes = [
|
||||
10, 10.2, 10.5, 10.2, 10, # tiny high around idx 2
|
||||
10, 15, 20, 15, 10, # large high around idx 7
|
||||
10, 10.3, 10.6, 10.3, 10, # tiny high around idx 12
|
||||
]
|
||||
highs = list(closes)
|
||||
lows = [c - 0.5 for c in closes]
|
||||
highs[2] = 10.8
|
||||
highs[7] = 20.5
|
||||
highs[12] = 10.9
|
||||
lows[7] = 10.0 # large window range at major swing
|
||||
unfiltered = compute_pivot_points(highs, lows, closes, min_prominence=None)
|
||||
filtered = compute_pivot_points(highs, lows, closes, min_prominence=5.0)
|
||||
assert unfiltered["pivot_count"] > 0
|
||||
assert filtered["pivot_count"] < unfiltered["pivot_count"]
|
||||
# Major swing high should survive
|
||||
assert any(h >= 20.0 for h in filtered["swing_highs"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EMA Cross
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from app.services.recommendation_service import (
|
||||
_build_reasoning,
|
||||
_choose_recommended_action,
|
||||
_gate_eligible_levels,
|
||||
_prune_floor_pinned_targets,
|
||||
_select_primary_target,
|
||||
direction_analyzer,
|
||||
@@ -110,7 +111,7 @@ def test_primary_target_is_most_likely_worthwhile_not_lottery():
|
||||
{"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
|
||||
{"price": 140.0, "rr_ratio": 6.0, "probability": 15.0}, # far lottery — not chosen
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 110.0
|
||||
|
||||
@@ -120,13 +121,13 @@ def test_primary_target_skips_sub_threshold_rr():
|
||||
{"price": 102.0, "rr_ratio": 1.0, "probability": 95.0}, # high prob but trivial R:R — skipped
|
||||
{"price": 115.0, "rr_ratio": 2.5, "probability": 60.0}, # most likely above the R:R floor ← primary
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 115.0
|
||||
|
||||
|
||||
def test_primary_target_none_when_empty():
|
||||
assert _select_primary_target([]) is None
|
||||
assert _select_primary_target([], min_rr=1.5) is None
|
||||
|
||||
|
||||
def test_primary_target_never_headlines_a_lottery():
|
||||
@@ -138,7 +139,7 @@ def test_primary_target_never_headlines_a_lottery():
|
||||
{"price": 101.0, "rr_ratio": 0.9, "probability": 55.0}, # likely, no asymmetry
|
||||
{"price": 140.0, "rr_ratio": 5.0, "probability": 3.0}, # asymmetric lottery
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 101.0
|
||||
|
||||
@@ -150,7 +151,17 @@ def test_primary_target_requires_probability_floor():
|
||||
{"price": 130.0, "rr_ratio": 4.0, "probability": 12.0}, # asymmetric but unlikely
|
||||
{"price": 112.0, "rr_ratio": 1.8, "probability": 38.0}, # clears both floors ← primary
|
||||
]
|
||||
primary = _select_primary_target(targets)
|
||||
primary = _select_primary_target(targets, min_rr=1.5)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 112.0
|
||||
|
||||
|
||||
def test_primary_target_uses_activation_rr_not_scanner_floor():
|
||||
targets = [
|
||||
{"price": 108.0, "rr_ratio": 1.6, "probability": 60.0},
|
||||
{"price": 112.0, "rr_ratio": 2.2, "probability": 35.0},
|
||||
]
|
||||
primary = _select_primary_target(targets, min_rr=2.0)
|
||||
assert primary is not None
|
||||
assert primary["price"] == 112.0
|
||||
|
||||
@@ -318,3 +329,48 @@ def test_zone_representative_levels_singletons_unchanged():
|
||||
reps = _zone_representative_levels(levels, entry_price=100.0)
|
||||
assert len(reps) == 2
|
||||
assert {round(r.price_level) for r in reps} == {120, 150}
|
||||
|
||||
|
||||
def test_zone_representative_levels_soft_strength_avoids_resaturation():
|
||||
from types import SimpleNamespace
|
||||
from app.services.recommendation_service import _zone_representative_levels
|
||||
|
||||
levels = [
|
||||
SimpleNamespace(
|
||||
id=1, price_level=183.0, type="resistance", strength=60,
|
||||
detection_method="pivot_point", sources=["pivot_point"],
|
||||
rejection_count=3, last_rejection_age=5,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id=2, price_level=185.0, type="resistance", strength=60,
|
||||
detection_method="round_number", sources=["round_number"],
|
||||
rejection_count=1, last_rejection_age=10,
|
||||
),
|
||||
]
|
||||
reps = _zone_representative_levels(
|
||||
levels, entry_price=180.0, strength_mode="soft"
|
||||
)
|
||||
assert len(reps) == 1
|
||||
assert reps[0].strength == 65
|
||||
assert set(reps[0].sources) == {"pivot_point", "round_number"}
|
||||
assert reps[0].rejection_count == 3
|
||||
|
||||
|
||||
def test_gate_requires_confirmation_for_standalone_round_number():
|
||||
from types import SimpleNamespace
|
||||
|
||||
untouched = SimpleNamespace(
|
||||
detection_method="round_number", sources=["round_number"],
|
||||
rejection_count=1,
|
||||
)
|
||||
confirmed = SimpleNamespace(
|
||||
detection_method="round_number", sources=["round_number"],
|
||||
rejection_count=2,
|
||||
)
|
||||
confluent = SimpleNamespace(
|
||||
detection_method="merged", sources=["round_number", "pivot_point"],
|
||||
rejection_count=0,
|
||||
)
|
||||
assert _gate_eligible_levels(
|
||||
[untouched, confirmed, confluent], confirmed_rounds_only=True
|
||||
) == [confirmed, confluent]
|
||||
|
||||
Reference in New Issue
Block a user