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
+45 -27
View File
@@ -31,14 +31,29 @@ RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = {
DEFAULT_TICKER_UNIVERSE = "sp500"
SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# Activation thresholds: what counts as a signal worth acting on.
# Used as Signals-page default filters, the Dashboard's qualified-setup
# metrics, and the Track Record's "qualified only" view. The outcome
# evaluator deliberately ignores these — every setup gets evaluated so the
# thresholds themselves can be validated against outcomes.
ACTIVATION_DEFAULTS: dict[str, float] = {
"activation_min_rr": 2.0,
"activation_min_confidence": 70.0,
# Activation gate: what counts as a signal worth acting on. Used by the
# Dashboard's "Qualified" metric, the Signals "Qualified only" view, and the
# Track Record's qualified stats. The outcome evaluator deliberately ignores
# these — every setup is evaluated so the gate itself can be validated.
#
# Beyond raw R:R and confidence, the gate demands conviction: a high-conviction
# action (LONG_HIGH / SHORT_HIGH), a clean read (risk Low / no conflicts), and a
# probable primary target.
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence",
"min_target_probability": "activation_min_target_probability",
}
_ACTIVATION_BOOL_KEYS: dict[str, str] = {
"require_high_conviction": "activation_require_high_conviction",
"exclude_conflicts": "activation_exclude_conflicts",
}
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
@@ -157,40 +172,43 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
# Activation thresholds
# ---------------------------------------------------------------------------
async def get_activation_config(db: AsyncSession) -> dict[str, float]:
"""Return activation thresholds with public keys (min_rr, min_confidence)."""
async def get_activation_config(db: AsyncSession) -> dict[str, float | bool]:
"""Return the activation gate config with public keys."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.like("activation_%"))
)
config = dict(ACTIVATION_DEFAULTS)
for setting in result.scalars().all():
if setting.key in config:
stored = {s.key: s.value for s in result.scalars().all()}
config: dict[str, float | bool] = dict(ACTIVATION_DEFAULTS)
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
if storage_key in stored:
try:
config[setting.key] = float(setting.value)
config[public_key] = float(stored[storage_key])
except (TypeError, ValueError):
pass
return {
"min_rr": config["activation_min_rr"],
"min_confidence": config["activation_min_confidence"],
}
for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items():
if storage_key in stored:
config[public_key] = str(stored[storage_key]).strip().lower() == "true"
return config
async def update_activation_config(
db: AsyncSession, updates: dict[str, float]
) -> dict[str, float]:
"""Update activation thresholds. Accepts public keys min_rr / min_confidence."""
db: AsyncSession, updates: dict[str, float | bool]
) -> dict[str, float | bool]:
"""Update the activation gate. Accepts public keys; only supplied keys change."""
if "min_rr" in updates and updates["min_rr"] < 0:
raise ValidationError("min_rr must be >= 0")
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
raise ValidationError("min_confidence must be between 0 and 100")
if "min_target_probability" in updates and not 0 <= updates["min_target_probability"] <= 100:
raise ValidationError("min_target_probability must be between 0 and 100")
key_map = {
"min_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence",
}
for public_key, storage_key in key_map.items():
if public_key in updates:
for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items():
if public_key in updates and updates[public_key] is not None:
await update_setting(db, storage_key, str(float(updates[public_key])))
for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items():
if public_key in updates and updates[public_key] is not None:
await update_setting(db, storage_key, "true" if updates[public_key] else "false")
return await get_activation_config(db)