Add S/R v2 research and validation harness

This commit is contained in:
2026-07-12 21:15:18 +02:00
parent 57ac1d2cdd
commit 19b81c169d
19 changed files with 1575 additions and 117 deletions
+61 -5
View File
@@ -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]