Add multi-factor conviction gate to activation
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:
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user