da83f027e1
Answers "why does a too-far-progressed setup still show": setups are only recalculated by the scheduled R:R scan and manual fetch; at creation entry == current price (0% progress), so over-progression is a between-scans drift effect and must be judged at read time. - /trades now attaches current_price (latest close per ticker). - Qualification drops setups whose R:R recomputed from the current price falls below min_rr — i.e. price already ran toward target (reward consumed) or through the stop. Reuses the existing min_rr threshold instead of a separate progress %; far cleaner (a 3:1 is already ~1:1 by 33% progress). Skipped for historical setups (no current_price). - Fix: useFetchSymbolData now invalidates the trades queries, so a fetch/ recompute actually refreshes confidence/setups in the UI (was the cause of the stale 100% confidence lingering after recompute). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
100 lines
3.7 KiB
Python
100 lines
3.7 KiB
Python
"""Unit tests for the activation qualification predicate."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from app.services.qualification import best_target_probability, setup_qualifies
|
|
|
|
FULL_GATE = {
|
|
"min_rr": 2.0,
|
|
"min_confidence": 70.0,
|
|
"min_target_probability": 60.0,
|
|
"require_high_conviction": True,
|
|
"exclude_conflicts": True,
|
|
}
|
|
|
|
|
|
def _setup(**kwargs):
|
|
base = dict(
|
|
rr_ratio=3.0,
|
|
confidence_score=80.0,
|
|
recommended_action="LONG_HIGH",
|
|
risk_level="Low",
|
|
targets=[{"probability": 65.0}],
|
|
)
|
|
base.update(kwargs)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
class TestSetupQualifies:
|
|
def test_clean_high_conviction_setup_passes(self):
|
|
assert setup_qualifies(_setup(), FULL_GATE) is True
|
|
|
|
def test_low_rr_fails(self):
|
|
assert setup_qualifies(_setup(rr_ratio=1.5), FULL_GATE) is False
|
|
|
|
def test_low_confidence_fails(self):
|
|
assert setup_qualifies(_setup(confidence_score=60.0), FULL_GATE) is False
|
|
|
|
def test_moderate_action_fails_when_high_conviction_required(self):
|
|
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), FULL_GATE) is False
|
|
|
|
def test_neutral_action_fails(self):
|
|
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), FULL_GATE) is False
|
|
|
|
def test_short_high_passes(self):
|
|
assert setup_qualifies(_setup(recommended_action="SHORT_HIGH"), FULL_GATE) is True
|
|
|
|
def test_non_low_risk_fails_when_excluding_conflicts(self):
|
|
assert setup_qualifies(_setup(risk_level="Medium"), FULL_GATE) is False
|
|
assert setup_qualifies(_setup(risk_level="High"), FULL_GATE) is False
|
|
|
|
def test_low_target_probability_fails(self):
|
|
assert setup_qualifies(_setup(targets=[{"probability": 40.0}]), FULL_GATE) is False
|
|
|
|
def test_no_targets_fails_when_probability_required(self):
|
|
assert setup_qualifies(_setup(targets=[]), FULL_GATE) is False
|
|
|
|
def test_over_progressed_setup_fails_on_live_rr(self):
|
|
# long target 120, stop 95; price already at 117 → live R:R ≈ 0.14
|
|
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=117.0)
|
|
assert setup_qualifies(s, FULL_GATE) is False
|
|
|
|
def test_fresh_setup_passes_live_rr(self):
|
|
# price near entry (100): live R:R ≈ 3.2, well above min
|
|
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=101.0)
|
|
assert setup_qualifies(s, FULL_GATE) is True
|
|
|
|
def test_past_stop_fails_live_rr(self):
|
|
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
|
|
assert setup_qualifies(s, FULL_GATE) is False
|
|
|
|
def test_no_current_price_skips_live_check(self):
|
|
# Historical setups have no current_price → live check skipped
|
|
assert setup_qualifies(_setup(), FULL_GATE) is True
|
|
|
|
def test_conviction_filters_can_be_disabled(self):
|
|
relaxed = {
|
|
"min_rr": 2.0,
|
|
"min_confidence": 70.0,
|
|
"min_target_probability": 0.0,
|
|
"require_high_conviction": False,
|
|
"exclude_conflicts": False,
|
|
}
|
|
# Moderate action, medium risk, no targets — still passes on rr+confidence alone
|
|
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium", targets=[])
|
|
assert setup_qualifies(s, relaxed) is True
|
|
|
|
def test_missing_confidence_treated_as_zero(self):
|
|
assert setup_qualifies(_setup(confidence_score=None), FULL_GATE) is False
|
|
|
|
|
|
class TestBestTargetProbability:
|
|
def test_returns_max(self):
|
|
s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])
|
|
assert best_target_probability(s) == 72.0
|
|
|
|
def test_empty_is_zero(self):
|
|
assert best_target_probability(_setup(targets=[])) == 0.0
|