Add multi-factor conviction gate to activation
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 26s

Make "qualified" mean an edge candidate, not just R:R + confidence.
The gate now also requires (all admin-configurable, defaults on):
- high conviction: recommended_action LONG_HIGH / SHORT_HIGH only
- clean read: risk_level Low (no contradicting signals)
- probable primary target: best target probability >= min (default 60)

- Shared predicate: app/services/qualification.py +
  frontend/src/lib/qualification.ts (mirrored)
- Activation config extended (min_target_probability,
  require_high_conviction, exclude_conflicts) with bool-aware
  get/update + validation
- /trades/performance switched to ?qualified_only=true, applying
  the full gate server-side; confidence breakdown stays unfiltered
- Dashboard "Qualified", Signals "Qualified only" toggle, and
  Track Record all use the one gate; Admin gains the new controls

Sentiment provider runtime config (prior change) included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:50:42 +02:00
parent 6da65b8d8f
commit d53ed972d1
25 changed files with 924 additions and 110 deletions
+81
View File
@@ -0,0 +1,81 @@
"""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_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