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
+25 -3
View File
@@ -24,16 +24,24 @@ async def session() -> AsyncSession:
class TestActivationConfig:
async def test_defaults_when_unset(self, session: AsyncSession):
config = await get_activation_config(session)
assert config == {"min_rr": 2.0, "min_confidence": 70.0}
assert config == {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
async def test_update_and_read_back(self, session: AsyncSession):
updated = await update_activation_config(
session, {"min_rr": 1.5, "min_confidence": 60.0}
)
assert updated == {"min_rr": 1.5, "min_confidence": 60.0}
assert updated["min_rr"] == 1.5
assert updated["min_confidence"] == 60.0
config = await get_activation_config(session)
assert config == {"min_rr": 1.5, "min_confidence": 60.0}
assert config["min_rr"] == 1.5
assert config["min_confidence"] == 60.0
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
await update_activation_config(session, {"min_confidence": 80.0})
@@ -41,6 +49,16 @@ class TestActivationConfig:
assert config["min_rr"] == 2.0 # default untouched
assert config["min_confidence"] == 80.0
async def test_conviction_flags_round_trip(self, session: AsyncSession):
await update_activation_config(
session,
{"require_high_conviction": False, "exclude_conflicts": False, "min_target_probability": 45.0},
)
config = await get_activation_config(session)
assert config["require_high_conviction"] is False
assert config["exclude_conflicts"] is False
assert config["min_target_probability"] == 45.0
async def test_rejects_negative_rr(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_rr": -1.0})
@@ -48,3 +66,7 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_confidence": 120.0})
async def test_rejects_out_of_range_target_probability(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_target_probability": 150.0})
+9 -1
View File
@@ -289,7 +289,15 @@ class TestGetPerformanceStats:
))
await db_session.flush()
stats = await get_performance_stats(db_session, min_rr=2.0, min_confidence=70.0)
# Gate on R:R + confidence only (conviction filters off for this test)
config = {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
stats = await get_performance_stats(db_session, config=config)
# Overall covers only the qualified setup
assert stats["overall"]["total"] == 1
+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
@@ -0,0 +1,94 @@
"""Unit tests for runtime sentiment provider configuration."""
from __future__ import annotations
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ProviderError, ValidationError
from app.models.settings import SystemSetting
from app.services import sentiment_provider_service as sps
@pytest.fixture
async def session() -> AsyncSession:
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
@pytest.fixture(autouse=True)
def _clear_env_keys(monkeypatch):
"""Default: no env keys, so DB-only behavior is deterministic."""
monkeypatch.setattr(sps.settings, "openai_api_key", "")
monkeypatch.setattr(sps.settings, "gemini_api_key", "")
class TestGetConfig:
async def test_defaults_no_key(self, session: AsyncSession):
config = await sps.get_sentiment_config(session)
assert config["provider"] == "openai"
assert config["model"] == "gpt-4o-mini"
assert config["api_key_configured"] is False
assert config["api_key_source"] == "none"
assert set(config["valid_providers"]) == {"openai", "gemini"}
async def test_never_returns_raw_key(self, session: AsyncSession):
await sps.update_sentiment_config(session, api_key="sk-secret-123")
config = await sps.get_sentiment_config(session)
# No field should leak the key
assert "sk-secret-123" not in str(config)
assert config["api_key_configured"] is True
assert config["api_key_source"] == "database"
async def test_env_fallback_reported(self, session: AsyncSession, monkeypatch):
monkeypatch.setattr(sps.settings, "openai_api_key", "sk-from-env")
config = await sps.get_sentiment_config(session)
assert config["api_key_configured"] is True
assert config["api_key_source"] == "environment"
class TestUpdateConfig:
async def test_update_provider_and_model(self, session: AsyncSession):
result = await sps.update_sentiment_config(
session, provider="gemini", model="gemini-2.0-flash"
)
assert result["provider"] == "gemini"
assert result["model"] == "gemini-2.0-flash"
async def test_empty_key_does_not_overwrite(self, session: AsyncSession):
await sps.update_sentiment_config(session, api_key="sk-original")
# Subsequent save without a key must keep the original
await sps.update_sentiment_config(session, provider="openai", api_key="")
result = await session.execute(
select(SystemSetting).where(SystemSetting.key == sps.KEY_API_KEY)
)
assert result.scalar_one().value == "sk-original"
async def test_rejects_invalid_provider(self, session: AsyncSession):
with pytest.raises(ValidationError):
await sps.update_sentiment_config(session, provider="anthropic")
class TestBuildProvider:
async def test_raises_without_key(self, session: AsyncSession):
with pytest.raises(ProviderError):
await sps.build_sentiment_provider(session)
async def test_builds_openai(self, session: AsyncSession):
await sps.update_sentiment_config(session, provider="openai", api_key="sk-x")
provider = await sps.build_sentiment_provider(session)
assert type(provider).__name__ == "OpenAISentimentProvider"
async def test_builds_gemini(self, session: AsyncSession):
await sps.update_sentiment_config(session, provider="gemini", api_key="g-x")
provider = await sps.build_sentiment_provider(session)
assert type(provider).__name__ == "GeminiSentimentProvider"
async def test_uses_env_key_when_db_empty(self, session: AsyncSession, monkeypatch):
monkeypatch.setattr(sps.settings, "openai_api_key", "sk-env")
await sps.update_sentiment_config(session, provider="openai")
provider = await sps.build_sentiment_provider(session)
assert type(provider).__name__ == "OpenAISentimentProvider"