Add S/R v2 research and validation harness
This commit is contained in:
@@ -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