Add activation thresholds: qualified-signal defaults and views
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 24s

Admin-configurable thresholds (min R:R, default 2.0; min confidence,
default 70%) defining what counts as an actionable signal:

- Admin Settings: new Activation Thresholds panel
  (GET/PUT /admin/settings/activation)
- GET /trades/activation exposes values to all users with access
- Signals/Setups: filters initialize from activation values
- Track Record: "Qualified signals only" toggle (default on) via
  min_rr/min_confidence params on /trades/performance; the
  confidence breakdown always covers the full population so the
  thresholds can be validated against outcomes
- Dashboard: "Qualified" metric and qualified-first Top Setups
- Outcome evaluator unchanged: every setup is still evaluated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:16:04 +02:00
parent d139dd0390
commit 6da65b8d8f
20 changed files with 440 additions and 29 deletions
+23
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_admin
from app.models.user import User
from app.schemas.admin import (
ActivationConfigUpdate,
CreateUserRequest,
DataCleanupRequest,
JobToggle,
@@ -148,6 +149,28 @@ async def update_recommendation_settings(
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/activation", response_model=APIEnvelope)
async def get_activation_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.get_activation_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/activation", response_model=APIEnvelope)
async def update_activation_settings(
body: ActivationConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
updated = await admin_service.update_activation_config(
db,
body.model_dump(exclude_unset=True, exclude_none=True),
)
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/ticker-universe", response_model=APIEnvelope)
async def get_ticker_universe_setting(
_admin: User = Depends(require_admin),
+24 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access
from app.schemas.common import APIEnvelope
from app.schemas.trade_setup import RecommendationSummaryResponse, TradeSetupResponse
from app.services import admin_service
from app.services.outcome_service import get_performance_stats
from app.services.rr_scanner_service import get_trade_setup_history, get_trade_setups
@@ -49,8 +50,27 @@ async def list_trade_setups(
return APIEnvelope(status="success", data=data)
@router.get("/trades/activation", response_model=APIEnvelope)
async def get_activation_thresholds(
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Activation thresholds (min R:R, min confidence) for actionable signals.
Readable by any user with access — drives Signals-page default filters
and the Dashboard's qualified-setup metrics. Configured by admins via
PUT /admin/settings/activation.
"""
config = await admin_service.get_activation_config(db)
return APIEnvelope(status="success", data=config)
@router.get("/trades/performance", response_model=APIEnvelope)
async def get_trade_performance(
min_rr: float | None = Query(None, ge=0, description="Only setups with R:R >= this"),
min_confidence: float | None = Query(
None, ge=0, le=100, description="Only setups with confidence >= this"
),
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
@@ -58,8 +78,11 @@ async def get_trade_performance(
Outcomes are written by the nightly outcome_evaluator job (win = target
hit first, loss = stop hit first, expired = neither within the window).
Optional min_rr / min_confidence filters apply to the overall, direction
and action breakdowns; the confidence breakdown always covers all setups
so thresholds can be validated against it.
"""
stats = await get_performance_stats(db)
stats = await get_performance_stats(db, min_rr=min_rr, min_confidence=min_confidence)
return APIEnvelope(status="success", data=stats)
+6
View File
@@ -56,3 +56,9 @@ class RecommendationConfigUpdate(BaseModel):
class TickerUniverseUpdate(BaseModel):
universe: Literal["sp500", "nasdaq100", "nasdaq_all"]
class ActivationConfigUpdate(BaseModel):
"""Activation thresholds: what counts as an actionable signal."""
min_rr: float | None = Field(default=None, ge=0)
min_confidence: float | None = Field(default=None, ge=0, le=100)
+52
View File
@@ -31,6 +31,16 @@ 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,
}
# ---------------------------------------------------------------------------
# User management
@@ -143,6 +153,48 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
return setting
# ---------------------------------------------------------------------------
# Activation thresholds
# ---------------------------------------------------------------------------
async def get_activation_config(db: AsyncSession) -> dict[str, float]:
"""Return activation thresholds with public keys (min_rr, min_confidence)."""
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:
try:
config[setting.key] = float(setting.value)
except (TypeError, ValueError):
pass
return {
"min_rr": config["activation_min_rr"],
"min_confidence": config["activation_min_confidence"],
}
async def update_activation_config(
db: AsyncSession, updates: dict[str, float]
) -> dict[str, float]:
"""Update activation thresholds. Accepts public keys min_rr / min_confidence."""
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")
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:
await update_setting(db, storage_key, str(float(updates[public_key])))
return await get_activation_config(db)
def _recommendation_public_to_storage_key(key: str) -> str:
return f"recommendation_{key}"
+23 -3
View File
@@ -178,12 +178,20 @@ def _confidence_bucket(score: float | None) -> str | None:
return None
async def get_performance_stats(db: AsyncSession) -> dict:
async def get_performance_stats(
db: AsyncSession,
min_rr: float | None = None,
min_confidence: float | None = None,
) -> dict:
"""Aggregate outcome statistics over all evaluated trade setups.
avg_r is the expectancy per trade in R-multiples (win = +rr_ratio,
loss = -1R, expired = 0R). A positive avg_r means the signals have
been profitable on a risk-adjusted basis.
min_rr / min_confidence filter the overall, direction and action
breakdowns. The confidence breakdown deliberately stays unfiltered:
it is the instrument for validating the thresholds themselves.
"""
result = await db.execute(
select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None))
@@ -195,14 +203,26 @@ async def get_performance_stats(db: AsyncSession) -> dict:
)
pending_count = len(pending_result.scalars().all())
def qualifies(setup: TradeSetup) -> bool:
if min_rr is not None and setup.rr_ratio < min_rr:
return False
if min_confidence is not None and (setup.confidence_score or 0.0) < min_confidence:
return False
return True
qualified = [s for s in evaluated if qualifies(s)]
by_direction: dict[str, list[TradeSetup]] = {}
by_action: dict[str, list[TradeSetup]] = {}
by_confidence: dict[str, list[TradeSetup]] = {}
for setup in evaluated:
for setup in qualified:
by_direction.setdefault(setup.direction, []).append(setup)
action = setup.recommended_action or "NONE"
by_action.setdefault(action, []).append(setup)
# Confidence buckets always cover the full evaluated population
for setup in evaluated:
bucket = _confidence_bucket(setup.confidence_score)
if bucket is not None:
by_confidence.setdefault(bucket, []).append(setup)
@@ -210,7 +230,7 @@ async def get_performance_stats(db: AsyncSession) -> dict:
bucket_order = [label for label, _, _ in _CONFIDENCE_BUCKETS]
return {
"overall": _bucket_stats(evaluated),
"overall": _bucket_stats(qualified),
"pending": pending_count,
"by_direction": {k: _bucket_stats(v) for k, v in sorted(by_direction.items())},
"by_action": {k: _bucket_stats(v) for k, v in sorted(by_action.items())},