redesign activation gate to expected value + make pipelines cron-configurable
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 28s

Diagnosing "no qualified signals for 5 days": setups were generated but none
qualified. The gate required BOTH a high min_rr (2.0) AND a high
min_target_probability (60), which became contradictory after the Jun-15
probability recalibration — probability already embeds R:R via the 1/(rr+1) ruin
term, so high-R:R targets are inherently low-probability and nothing cleared both.

Gate is now expected value (R): p*rr - (1-p) from the primary target's
probability. R:R and confidence stay as floors; high-conviction / exclude-conflicts
/ min-target-probability become optional tighteners (default off). Defaults:
min_expected_value=0.15, min_rr=1.2, min_confidence=55. EV is only enforced when
computable. Migration 009 clears stored activation_* rows so the new defaults
apply. Backtest sweeps min_expected_value instead of target probability.

Scheduling: pipelines are now cron-configurable in Admin -> Jobs. daily_pipeline
(full, default 0 7 * * *) plus a new light intraday_pipeline (OHLCV + outcome eval,
default hourly US session) that keeps prices/live-R:R current without setup churn.
Fundamentals on its own early weekly cron. Timezone configurable (default
Europe/Berlin). Moving interval->CronTrigger also fixes the restart-deferral bug
where an interval job's countdown resets on every process restart.

319 backend unit tests pass; frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:46:38 +02:00
parent d53b4ffb57
commit c34f3cb1a4
22 changed files with 777 additions and 171 deletions
+71 -8
View File
@@ -1,5 +1,6 @@
"""Admin service: user management, system settings, data cleanup, job control."""
import logging
from datetime import datetime, timedelta, timezone
from passlib.hash import bcrypt
@@ -17,6 +18,8 @@ from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.models.user import User
logger = logging.getLogger(__name__)
RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = {
"recommendation_high_confidence_threshold": 70.0,
"recommendation_moderate_confidence_threshold": 50.0,
@@ -35,10 +38,12 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# 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.
# The core test is expected value (in R): probability-weighted asymmetry, so a
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
# confidence are floors; high-conviction / clean-read / target-probability are
# optional tighteners (off by default — turn on to be more selective).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_expected_value": "activation_min_expected_value",
"min_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence",
"min_target_probability": "activation_min_target_probability",
@@ -48,11 +53,12 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
"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,
"min_expected_value": 0.15,
"min_rr": 1.2,
"min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
@@ -195,6 +201,8 @@ async def update_activation_config(
db: AsyncSession, updates: dict[str, float | bool]
) -> dict[str, float | bool]:
"""Update the activation gate. Accepts public keys; only supplied keys change."""
if "min_expected_value" in updates and not -1.0 <= updates["min_expected_value"] <= 10.0:
raise ValidationError("min_expected_value must be between -1 and 10 (R units)")
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:
@@ -212,6 +220,59 @@ async def update_activation_config(
return await get_activation_config(db)
# ---------------------------------------------------------------------------
# Pipeline schedule (cron)
# ---------------------------------------------------------------------------
async def get_schedule_config(db: AsyncSession) -> dict[str, str]:
"""Cron schedule for the daily/intraday pipelines and fundamentals."""
from app.scheduler import load_schedule_config
return await load_schedule_config(db)
async def update_schedule_config(
db: AsyncSession, updates: dict[str, str]
) -> dict[str, str]:
"""Validate, persist, and apply cron schedule changes to the running scheduler."""
from app.scheduler import (
SCHEDULE_DEFAULTS,
load_schedule_config,
reschedule_jobs,
validate_cron,
)
current = await load_schedule_config(db)
tz = (updates.get("schedule_timezone") or current["schedule_timezone"]).strip()
for key, value in updates.items():
if key not in SCHEDULE_DEFAULTS:
raise ValidationError(f"Unknown schedule key: {key}")
if key == "schedule_timezone":
# Validate the timezone against an existing cron expression.
try:
validate_cron(current["schedule_daily_pipeline_cron"], value)
except Exception as exc:
raise ValidationError(f"Invalid timezone: {value}") from exc
else:
try:
validate_cron(value, tz)
except Exception as exc:
raise ValidationError(f"Invalid cron for {key}: {value!r}") from exc
for key, value in updates.items():
await update_setting(db, key, str(value).strip())
new_config = await load_schedule_config(db)
try:
reschedule_jobs(new_config)
except Exception:
# Scheduler may not be running (e.g. unit tests) — the config is saved
# regardless and applied on next startup.
logger.warning("Could not reschedule jobs after config update", exc_info=True)
return new_config
def _recommendation_public_to_storage_key(key: str) -> str:
return f"recommendation_{key}"
@@ -486,6 +547,7 @@ VALID_JOB_NAMES = {
"market_regime",
"backtest",
"daily_pipeline",
"intraday_pipeline",
}
JOB_LABELS = {
@@ -499,6 +561,7 @@ JOB_LABELS = {
"market_regime": "Market Regime",
"backtest": "Backtest",
"daily_pipeline": "Daily Pipeline",
"intraday_pipeline": "Intraday Pipeline",
}
# Jobs driven by the daily_pipeline (in order) rather than their own timer.