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
@@ -0,0 +1,40 @@
"""reset activation gate settings for the EV-based redesign
The activation gate was redesigned around expected value (R): the core test is
now ``min_expected_value`` instead of the old, self-contradicting pair of a high
``min_rr`` AND a high ``min_target_probability`` (with the post-recalibration
probability model the two could not be satisfied together, so nothing qualified).
The conviction / conflict toggles are now optional tighteners that default off.
Stored ``activation_*`` rows from the old gate no longer map cleanly onto the new
one, so they are cleared here and the redesigned code defaults take effect. Re-tune
in Admin → Activation (validated against the Track Record's backtest EV sweep).
Revision ID: 009
Revises: 008
Create Date: 2026-06-23 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
sa.text("DELETE FROM system_settings WHERE key LIKE 'activation\\_%' ESCAPE '\\'")
)
def downgrade() -> None:
# One-way data reset — the old per-key values aren't recoverable. Code defaults
# apply until re-tuned, so there is nothing to restore.
pass
+3 -2
View File
@@ -68,7 +68,7 @@ from app.config import settings
from app.database import async_session_factory, engine
from app.middleware import register_exception_handlers
from app.models.user import User
from app.scheduler import configure_scheduler, scheduler
from app.scheduler import configure_scheduler, load_schedule_config, scheduler
from app.routers.admin import router as admin_router
from app.routers.auth import router as auth_router
from app.routers.health import router as health_router
@@ -128,8 +128,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
async with async_session_factory() as session:
await _create_default_admin(session)
schedule_config = await load_schedule_config(session)
configure_scheduler()
configure_scheduler(schedule_config)
scheduler.start()
logger.info("Scheduler started")
+23
View File
@@ -15,6 +15,7 @@ from app.schemas.admin import (
DataCleanupRequest,
JobToggle,
RecommendationConfigUpdate,
ScheduleConfigUpdate,
SentimentConfigUpdate,
SentimentTestRequest,
PasswordReset,
@@ -176,6 +177,28 @@ async def update_activation_settings(
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/schedule", response_model=APIEnvelope)
async def get_schedule_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.get_schedule_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/schedule", response_model=APIEnvelope)
async def update_schedule_settings(
body: ScheduleConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
updated = await admin_service.update_schedule_config(
db,
body.model_dump(exclude_unset=True, exclude_none=True),
)
return APIEnvelope(status="success", data=updated)
@router.get("/admin/settings/sentiment", response_model=APIEnvelope)
async def get_sentiment_settings(
_admin: User = Depends(require_admin),
+149 -26
View File
@@ -19,6 +19,7 @@ import asyncio
from datetime import date, datetime, timedelta, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -168,6 +169,17 @@ _job_runtime: dict[str, dict[str, object]] = {
"finished_at": None,
"message": None,
},
"intraday_pipeline": {
"running": False,
"status": "idle",
"processed": 0,
"total": None,
"progress_pct": None,
"current_ticker": None,
"started_at": None,
"finished_at": None,
"message": None,
},
}
@@ -1000,7 +1012,9 @@ async def sync_ticker_universe() -> None:
# Steps run in dependency order: each uses fresh output from the previous one.
# (name, coroutine) — the names match the individual jobs so each step still
# updates its own runtime status while the pipeline runs.
_PIPELINE_STEPS = [
#
# Daily (full): the complete data→signal refresh, once a day.
_DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("sentiment_collector", "collect_sentiment"),
("rr_scanner", "scan_rr"),
@@ -1008,28 +1022,41 @@ _PIPELINE_STEPS = [
("market_regime", "compute_market_regime"),
]
# Intraday (light): keep prices current and resolve outcomes through the day,
# without the expensive scan/sentiment. The dashboard recomputes live R:R from
# the latest price, so refreshing OHLCV is enough to stop prices lagging; the
# outcome step also closes paper trades that hit their stop/target intraday.
_INTRADAY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("outcome_evaluator", "evaluate_outcomes"),
]
async def run_daily_pipeline() -> None:
"""Run the daily data→signal flow in dependency order.
OHLCV → fundamentals → sentiment → R:R scan → outcome eval (+paper close) →
market regime. Each step respects its own enable flag and manages its own
runtime status; a failing step is logged and the pipeline continues.
async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
"""Run an ordered list of (step_name, coroutine_name) steps.
Each step respects its own enable flag and manages its own runtime status; a
failing step is logged and the pipeline continues with the next one.
"""
job_name = "daily_pipeline"
logger.info(json.dumps({"event": "job_start", "job": job_name}))
total = len(_PIPELINE_STEPS)
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
logger.info(json.dumps({"event": "job_skipped", "job": job_name, "reason": "disabled"}))
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
return
total = len(steps)
_runtime_start(job_name, total=total)
funcs = globals()
done = 0
try:
for step_name, func_name in _PIPELINE_STEPS:
for step_name, func_name in steps:
_runtime_progress(job_name, processed=done, total=total, current_ticker=step_name)
try:
await funcs[func_name]()
except Exception:
logger.exception("Daily pipeline step %s failed", step_name)
logger.exception("%s step %s failed", job_name, step_name)
done += 1
_runtime_finish(job_name, "completed", processed=done, total=total, message="Pipeline complete")
logger.info(json.dumps({"event": "job_complete", "job": job_name}))
@@ -1041,6 +1068,17 @@ async def run_daily_pipeline() -> None:
}))
async def run_daily_pipeline() -> None:
"""Full daily flow: OHLCV → sentiment → R:R scan → outcome eval (+paper
close) → market regime."""
await _run_pipeline("daily_pipeline", _DAILY_PIPELINE_STEPS)
async def run_intraday_pipeline() -> None:
"""Light intraday flow: refresh OHLCV → evaluate outcomes (+paper close)."""
await _run_pipeline("intraday_pipeline", _INTRADAY_PIPELINE_STEPS)
# ---------------------------------------------------------------------------
# Frequency helpers
# ---------------------------------------------------------------------------
@@ -1057,22 +1095,91 @@ def _parse_frequency(freq: str) -> dict[str, int]:
return _FREQUENCY_MAP.get(freq.lower(), {"hours": 24})
# ---------------------------------------------------------------------------
# Schedule config (cron, admin-configurable)
# ---------------------------------------------------------------------------
#
# The cron-driven jobs read their schedule from SystemSettings so it can be
# tuned from Admin → Jobs without a redeploy. A wall-clock CronTrigger also fixes
# the interval-trigger pitfall: an interval job resets its countdown to now+N on
# every process restart, so on a box that's redeployed often it can keep being
# deferred and never fire. Cron fires at a fixed local time regardless.
SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_timezone": "Europe/Berlin",
"schedule_daily_pipeline_cron": "0 7 * * *", # full refresh, ready by ~8am
"schedule_intraday_pipeline_cron": "0 14-22 * * 1-5", # hourly across the US session
"schedule_fundamentals_cron": "0 4 * * 1", # weekly, early Monday (slow job)
}
# job id -> schedule setting key
_CRON_JOBS: dict[str, str] = {
"daily_pipeline": "schedule_daily_pipeline_cron",
"intraday_pipeline": "schedule_intraday_pipeline_cron",
"fundamental_collector": "schedule_fundamentals_cron",
}
def validate_cron(expr: str, timezone: str) -> None:
"""Raise ValueError if the cron expression or timezone is invalid."""
CronTrigger.from_crontab((expr or "").strip(), timezone=(timezone or "").strip())
def _cron_trigger(expr: str, timezone: str, fallback_key: str) -> CronTrigger:
"""Build a CronTrigger, falling back to the default (UTC) on a bad value."""
try:
return CronTrigger.from_crontab(expr.strip(), timezone=timezone.strip())
except Exception:
logger.warning(json.dumps({
"event": "invalid_cron", "expr": expr, "timezone": timezone,
"fallback": SCHEDULE_DEFAULTS[fallback_key],
}))
return CronTrigger.from_crontab(SCHEDULE_DEFAULTS[fallback_key], timezone="UTC")
async def load_schedule_config(db: AsyncSession) -> dict[str, str]:
"""Read the cron schedule config from SystemSettings, defaults for any unset."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.in_(list(SCHEDULE_DEFAULTS)))
)
stored = {s.key: s.value for s in result.scalars().all()}
return {key: (stored.get(key) or default) for key, default in SCHEDULE_DEFAULTS.items()}
def reschedule_jobs(schedule_config: dict[str, str]) -> dict[str, str]:
"""Re-apply cron triggers to the running scheduler after a settings change."""
tz = schedule_config.get("schedule_timezone") or SCHEDULE_DEFAULTS["schedule_timezone"]
applied: dict[str, str] = {}
for job_id, key in _CRON_JOBS.items():
if scheduler.get_job(job_id) is None:
continue
expr = schedule_config.get(key) or SCHEDULE_DEFAULTS[key]
scheduler.reschedule_job(job_id, trigger=_cron_trigger(expr, tz, key))
applied[job_id] = expr
logger.info(json.dumps({"event": "jobs_rescheduled", "applied": applied, "timezone": tz}))
return applied
# ---------------------------------------------------------------------------
# Scheduler setup
# ---------------------------------------------------------------------------
def configure_scheduler() -> None:
"""Add all jobs to the scheduler with configured intervals.
def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
"""Add all jobs to the scheduler.
Call this once before scheduler.start(). Removes any existing jobs first
to ensure idempotency.
Call this once before scheduler.start(). Removes any existing jobs first to
ensure idempotency. ``schedule_config`` supplies the cron strings + timezone
for the cron-driven jobs (daily/intraday pipelines, fundamentals); defaults
are used for anything missing.
"""
cfg = {**SCHEDULE_DEFAULTS, **(schedule_config or {})}
tz = cfg["schedule_timezone"]
scheduler.remove_all_jobs()
# Pipeline members: registered but PAUSED (next_run_time=None) so they never
# auto-fire on their own timer — the daily_pipeline drives them in order. The
# long interval is just a backstop after a manual trigger (which re-arms an
# auto-fire on their own timer — the pipelines drive them in order. The long
# interval is just a backstop after a manual trigger (which re-arms an
# interval job). They stay manually triggerable from Admin → Jobs.
_members = [
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
@@ -1087,23 +1194,30 @@ def configure_scheduler() -> None:
replace_existing=True, next_run_time=None,
)
# Daily Pipeline — the single ordered daily flow
# Cron-driven jobs (admin-configurable times)
scheduler.add_job(
run_daily_pipeline, "interval", hours=24,
run_daily_pipeline,
_cron_trigger(cfg["schedule_daily_pipeline_cron"], tz, "schedule_daily_pipeline_cron"),
id="daily_pipeline", name="Daily Pipeline", replace_existing=True,
)
scheduler.add_job(
run_intraday_pipeline,
_cron_trigger(cfg["schedule_intraday_pipeline_cron"], tz, "schedule_intraday_pipeline_cron"),
id="intraday_pipeline", name="Intraday Pipeline", replace_existing=True,
)
# Fundamentals — quarterly-ish data; weekly by default (conserves API quota).
# Its own early cron so the slow, rate-limited fetch finishes before the day.
scheduler.add_job(
collect_fundamentals,
_cron_trigger(cfg["schedule_fundamentals_cron"], tz, "schedule_fundamentals_cron"),
id="fundamental_collector", name="Fundamental Collector", replace_existing=True,
)
# Independent jobs (own cadence, no ordering dependency)
# Independent interval jobs (own cadence, no ordering dependency)
scheduler.add_job(
sync_ticker_universe, "interval", hours=24,
id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
)
# Fundamentals — quarterly-ish data; weekly by default (conserves API quota)
fund_interval = _parse_frequency(settings.fundamental_fetch_frequency)
scheduler.add_job(
collect_fundamentals, "interval", **fund_interval,
id="fundamental_collector", name="Fundamental Collector", replace_existing=True,
)
alerts_interval = _parse_frequency(settings.alerts_frequency)
scheduler.add_job(
dispatch_alerts_job, "interval", **alerts_interval,
@@ -1117,7 +1231,16 @@ def configure_scheduler() -> None:
logger.info(
json.dumps({
"event": "scheduler_configured",
"daily_pipeline": [name for name, _ in _PIPELINE_STEPS],
"timezone": tz,
"daily_pipeline": {
"cron": cfg["schedule_daily_pipeline_cron"],
"steps": [name for name, _ in _DAILY_PIPELINE_STEPS],
},
"intraday_pipeline": {
"cron": cfg["schedule_intraday_pipeline_cron"],
"steps": [name for name, _ in _INTRADAY_PIPELINE_STEPS],
},
"fundamental_collector": {"cron": cfg["schedule_fundamentals_cron"]},
"independent": ["ticker_universe_sync", "alerts", "backtest"],
})
)
+10
View File
@@ -59,6 +59,7 @@ class TickerUniverseUpdate(BaseModel):
class ActivationConfigUpdate(BaseModel):
"""Activation gate: what counts as an actionable signal."""
min_expected_value: float | None = Field(default=None, ge=-1, le=10)
min_rr: float | None = Field(default=None, ge=0)
min_confidence: float | None = Field(default=None, ge=0, le=100)
min_target_probability: float | None = Field(default=None, ge=0, le=100)
@@ -66,6 +67,15 @@ class ActivationConfigUpdate(BaseModel):
exclude_conflicts: bool | None = None
class ScheduleConfigUpdate(BaseModel):
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field
(min hour dom month dow); timezone is an IANA name (e.g. Europe/Berlin)."""
schedule_timezone: str | None = Field(default=None, max_length=64)
schedule_daily_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_intraday_pipeline_cron: str | None = Field(default=None, max_length=120)
schedule_fundamentals_cron: str | None = Field(default=None, max_length=120)
class SentimentConfigUpdate(BaseModel):
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
the stored key."""
+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.
+26 -12
View File
@@ -36,7 +36,11 @@ from app.services.outcome_service import (
evaluate_setup_against_bars,
)
from app.services.price_service import query_ohlcv
from app.services.qualification import best_target_probability, setup_qualifies
from app.services.qualification import (
best_target_probability,
expected_value_r,
setup_qualifies,
)
from app.services.recommendation_service import (
_choose_recommended_action,
_classify_by_probability,
@@ -131,6 +135,10 @@ def _window_setups(
primary = _select_primary_target(targets)
if primary is None:
continue
# Flag the primary so qualification's EV uses the primary target's
# probability (matching production's enhance_trade_setup).
for t in targets:
t["is_primary"] = t is primary
per_dir[direction] = {"stop": stop, "targets": targets, "primary": primary}
available = set(per_dir.keys())
@@ -160,12 +168,13 @@ def _window_setups(
stop_loss=stop,
entry_price=entry,
)
# meets_core = clears every gate EXCEPT target probability, so the report
# can sweep the min_target_probability threshold without re-replaying.
core_config = {**activation, "min_target_probability": 0.0}
# meets_core = clears every gate EXCEPT the expected-value floor, so the
# report can sweep the min_expected_value threshold without re-replaying.
core_config = {**activation, "min_expected_value": float("-inf")}
meets_core = setup_qualifies(setup_ns, core_config)
ev = expected_value_r(setup_ns)
best_prob = best_target_probability(setup_ns)
min_tp = float(activation.get("min_target_probability", 0.0))
min_ev = float(activation.get("min_expected_value", 0.0))
out.append({
"direction": direction,
"entry": entry,
@@ -175,10 +184,11 @@ def _window_setups(
"confidence": confidences[direction],
"primary_prob": float(primary["probability"]),
"best_prob": best_prob,
"ev": ev,
"meets_core": meets_core,
"action": action,
"risk_level": risk_level,
"qualified": meets_core and best_prob >= min_tp,
"qualified": meets_core and ev is not None and ev >= min_ev,
})
return out
@@ -216,6 +226,7 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
"confidence": s["confidence"],
"primary_prob": s["primary_prob"],
"best_prob": s["best_prob"],
"ev": s["ev"],
"meets_core": s["meets_core"],
"qualified": s["qualified"],
"outcome": outcome,
@@ -288,14 +299,17 @@ async def run_backtest(
longs = [c for c in qualified if c["direction"] == "long"]
shorts = [c for c in qualified if c["direction"] == "short"]
# Threshold sweep: re-apply the gate at several min_target_probability values
# Threshold sweep: re-apply the gate at several min_expected_value values
# (holding the other conditions fixed) so the trade-off between how many
# setups qualify and their expectancy is visible without re-replaying.
current_min_tp = float(activation.get("min_target_probability", 60.0))
current_min_ev = float(activation.get("min_expected_value", 0.15))
sweep = []
for threshold in (60, 55, 50, 45, 40, 35, 30):
cands = [c for c in candidates if c["meets_core"] and c["best_prob"] >= threshold]
sweep.append({"min_target_probability": threshold, **_bucket_stats(cands)})
for threshold in (0.4, 0.3, 0.25, 0.2, 0.15, 0.1, 0.05, 0.0):
cands = [
c for c in candidates
if c["meets_core"] and c["ev"] is not None and c["ev"] >= threshold
]
sweep.append({"min_expected_value": threshold, **_bucket_stats(cands)})
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
@@ -310,7 +324,7 @@ async def run_backtest(
"long": _bucket_stats(longs),
"short": _bucket_stats(shorts),
},
"min_target_probability": current_min_tp,
"min_expected_value": current_min_ev,
"sweep": sweep,
"calibration": _calibration(candidates),
"note": (
+48 -3
View File
@@ -1,9 +1,11 @@
"""Shared definition of a 'qualified' (actionable) trade setup.
A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. Beyond raw R:R and
confidence, an actionable setup must show genuine conviction: a high-conviction
recommended action, a clean (conflict-free) read, and a probable primary target.
performance stats (server) and mirrored on the frontend. The core gate is
expected value (in R): a setup must promise positive, probability-weighted
asymmetry, not just a fat-but-improbable target or a likely-but-thin one. R:R
and confidence remain as floors, and conviction/conflict/target-probability
survive as optional tighteners (off by default).
"""
from __future__ import annotations
@@ -20,6 +22,37 @@ def best_target_probability(setup: Any) -> float:
return max(probs, default=0.0)
def primary_target_probability(setup: Any) -> float | None:
"""Probability of the starred primary target (the one the headline R:R refers
to). Falls back to the best target's probability when none is flagged primary,
and None when there are no targets at all (probability unknowable).
"""
targets = getattr(setup, "targets", None) or []
primary = next(
(t for t in targets if isinstance(t, dict) and t.get("is_primary")), None
)
if primary is not None:
return float(primary.get("probability", 0.0))
probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)]
return max(probs) if probs else None
def expected_value_r(setup: Any) -> float | None:
"""Expected value per unit of risk, in R: ``p·(R:R) (1 p)``.
``p`` is the primary target's hit probability. This single number captures
"is this worth taking": it rewards both a good payoff ratio and a likely
target, so a fat-but-improbable target can't outrank a solid, probable one —
and a high R:R no longer fights a high probability the way the old separate
gates did. Returns None when no target probability is known.
"""
p = primary_target_probability(setup)
if p is None:
return None
p = p / 100.0
return p * setup.rr_ratio - (1.0 - p)
def live_risk_reward(setup: Any, current_price: float) -> float | None:
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
@@ -43,6 +76,11 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
recommended_action, risk_level and a ``targets`` list of dicts.
Gate order: R:R floor → freshness (live R:R) → confidence floor → expected
value (the core test) → optional conviction / conflict / target-probability
tighteners. ``min_expected_value`` defaults to -inf for callers that pass a
legacy config without the key, so they behave exactly as before.
"""
if setup.rr_ratio < config["min_rr"]:
return False
@@ -56,6 +94,13 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
return False
if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False
# Expected value (R): the core gate. Only enforced when computable — setups
# without target probabilities (e.g. legacy historical rows) defer to the
# R:R + confidence floors above rather than being silently dropped.
min_ev = float(config.get("min_expected_value", float("-inf")))
ev = expected_value_r(setup)
if ev is not None and ev < min_ev:
return False
if config.get("require_high_conviction"):
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
return False
+13
View File
@@ -6,6 +6,7 @@ import type {
AlertTestResult,
PipelineReadiness,
RecommendationConfig,
ScheduleConfig,
SentimentProviderConfig,
SentimentTestResult,
SystemSetting,
@@ -85,6 +86,18 @@ export function updateActivationSettings(payload: Partial<ActivationConfig>) {
.then((r) => r.data);
}
export function getScheduleSettings() {
return apiClient
.get<ScheduleConfig>('admin/settings/schedule')
.then((r) => r.data);
}
export function updateScheduleSettings(payload: Partial<ScheduleConfig>) {
return apiClient
.put<ScheduleConfig>('admin/settings/schedule', payload)
.then((r) => r.data);
}
export function getSentimentSettings() {
return apiClient
.get<SentimentProviderConfig>('admin/settings/sentiment')
@@ -4,11 +4,12 @@ import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/
import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ActivationConfig = {
min_rr: 2,
min_confidence: 70,
min_target_probability: 60,
require_high_conviction: true,
exclude_conflicts: true,
min_expected_value: 0.15,
min_rr: 1.2,
min_confidence: 55,
min_target_probability: 0,
require_high_conviction: false,
exclude_conflicts: false,
};
export function ActivationSettings() {
@@ -39,13 +40,27 @@ export function ActivationSettings() {
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
<p className="mt-1 text-xs text-gray-500">
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
Signals "Qualified only" view, and the Track Record's qualified stats. All setups are
still evaluated regardless tighten the gate, then watch qualified expectancy in the
Track Record to find what actually wins.
Signals "Qualified only" view, and the Track Record's qualified stats. The core test is
<span className="text-gray-300"> expected value</span> probability-weighted asymmetry
so R:R and target probability no longer fight each other. All setups are still evaluated
regardless; tune the EV floor against the Track Record's EV sweep to see what actually wins.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Expected Value (R)</span>
<input
type="number"
min={-1}
max={10}
step={0.05}
value={form.min_expected_value}
onChange={(e) => setForm((prev) => ({ ...prev, min_expected_value: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">p·R:R (1p), in R. 0.15 ≈ +0.15× risk/trade. The core gate.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
<input
@@ -56,7 +71,7 @@ export function ActivationSettings() {
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Set above your scanner floor or it does nothing.</span>
<span className="text-[11px] text-gray-600">Floor only — keeps symmetric/negative trades out.</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Confidence (%)</span>
@@ -70,50 +85,54 @@ export function ActivationSettings() {
className="w-full input-glass px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
<input
type="number"
min={0}
max={100}
step={1}
value={form.min_target_probability}
onChange={(e) => setForm((prev) => ({ ...prev, min_target_probability: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Best target's probability must clear this. 0 disables.</span>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.require_high_conviction}
onChange={(e) => setForm((prev) => ({ ...prev, require_high_conviction: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Require high conviction
<span className="mt-0.5 block text-[11px] text-gray-500">
Only LONG (High) / SHORT (High) — the signals must clearly pick a side.
<div className="border-t border-white/[0.06] pt-4">
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
<p className="mt-1 text-[11px] text-gray-600">Off by default — turn on to be more selective on top of the EV gate.</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
<input
type="number"
min={0}
max={100}
step={1}
value={form.min_target_probability}
onChange={(e) => setForm((prev) => ({ ...prev, min_target_probability: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">Best target's probability must clear this. 0 disables.</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.require_high_conviction}
onChange={(e) => setForm((prev) => ({ ...prev, require_high_conviction: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Require high conviction
<span className="mt-0.5 block text-[11px] text-gray-500">
Only LONG (High) / SHORT (High) the signals must clearly pick a side.
</span>
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.exclude_conflicts}
onChange={(e) => setForm((prev) => ({ ...prev, exclude_conflicts: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Exclude conflicted setups
<span className="mt-0.5 block text-[11px] text-gray-500">
Risk level must be Low — drops setups with contradicting signals.
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
<input
type="checkbox"
checked={form.exclude_conflicts}
onChange={(e) => setForm((prev) => ({ ...prev, exclude_conflicts: e.target.checked }))}
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
/>
<span>
Exclude conflicted setups
<span className="mt-0.5 block text-[11px] text-gray-500">
Risk level must be Low drops setups with contradicting signals.
</span>
</span>
</span>
</label>
</label>
</div>
</div>
<div className="flex items-center gap-2">
@@ -147,7 +147,7 @@ export function JobControls() {
: 'Inactive'}
</span>
{job.via_pipeline ? (
<span className="text-[11px] text-gray-500">runs in daily pipeline</span>
<span className="text-[11px] text-gray-500">runs via pipeline</span>
) : (
job.enabled && job.next_run_at && (
<span className="text-[11px] text-gray-500">
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import type { ScheduleConfig } from '../../lib/types';
import { useScheduleSettings, useUpdateScheduleSettings } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ScheduleConfig = {
schedule_timezone: 'Europe/Berlin',
schedule_daily_pipeline_cron: '0 7 * * *',
schedule_intraday_pipeline_cron: '0 14-22 * * 1-5',
schedule_fundamentals_cron: '0 4 * * 1',
};
const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: boolean }[] = [
{
key: 'schedule_timezone',
label: 'Timezone',
hint: 'IANA name, e.g. Europe/Berlin. All times below are in this zone.',
},
{
key: 'schedule_daily_pipeline_cron',
label: 'Daily pipeline (full)',
hint: 'OHLCV → sentiment → R:R scan → outcomes → regime. Default 07:00 so data is ready by 8.',
mono: true,
},
{
key: 'schedule_intraday_pipeline_cron',
label: 'Intraday pipeline (light)',
hint: 'Refresh prices + resolve outcomes. Default hourly across the US session, weekdays.',
mono: true,
},
{
key: 'schedule_fundamentals_cron',
label: 'Fundamentals (weekly)',
hint: 'Slow, rate-limited. Default early Monday so it finishes well before the day starts.',
mono: true,
},
];
export function ScheduleSettings() {
const { data, isLoading, isError, error } = useScheduleSettings();
const update = useUpdateScheduleSettings();
const [form, setForm] = useState<ScheduleConfig>(DEFAULTS);
useEffect(() => {
if (data) setForm(data);
}, [data]);
if (isLoading) return <SkeletonTable rows={2} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load schedule'}</p>;
return (
<div className="glass p-5 space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-200">Pipeline Schedule</h3>
<p className="mt-1 text-xs text-gray-500">
When the jobs run, as 5-field cron (<span className="num">min hour day month weekday</span>).
Saved changes apply to the running scheduler immediately no redeploy. The big nightly run
does the full refresh; the light intraday run just keeps prices current.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{FIELDS.map((f) => (
<label key={f.key} className="block space-y-1">
<span className="text-xs text-gray-400">{f.label}</span>
<input
type="text"
value={form[f.key]}
spellCheck={false}
onChange={(e) => setForm((prev) => ({ ...prev, [f.key]: e.target.value }))}
className={`w-full input-glass px-3 py-2 text-sm ${f.mono ? 'num' : ''}`}
/>
<span className="block text-[11px] text-gray-600">{f.hint}</span>
</label>
))}
</div>
<div className="flex items-center gap-2">
<button
className="btn-primary px-4 py-2 text-sm"
onClick={() => update.mutate(form)}
disabled={update.isPending}
>
{update.isPending ? 'Saving…' : 'Save Schedule'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white"
onClick={() => {
setForm(DEFAULTS);
update.mutate(DEFAULTS);
}}
disabled={update.isPending}
>
Reset to Defaults
</button>
</div>
</div>
);
}
@@ -161,18 +161,19 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Min target-probability sweep
Min expected-value sweep
</p>
<p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each gate threshold (other
gate conditions held fixed). Lower = more trades, watch that expectancy holds.
Your current setting is highlighted; set it in Admin Settings Activation.
How many setups qualify and how they perform at each expected-value gate (other
gate conditions held fixed). EV is in R: 0.15 means +0.15× your risk per trade on
average. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation.
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Min Target Prob</th>
<th className="px-4 py-2.5">Min EV (R)</th>
<th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
@@ -183,12 +184,12 @@ export function BacktestPanel() {
</thead>
<tbody>
{report.sweep.map((row) => {
const current = Math.abs(row.min_target_probability - report.min_target_probability) < 0.5;
const current = Math.abs(row.min_expected_value - report.min_expected_value) < 0.001;
return (
<tr key={row.min_target_probability} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
<tr key={row.min_expected_value} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200">
{current && <span className="mr-1 text-blue-300"></span>}
{row.min_target_probability}%
{row.min_expected_value.toFixed(2)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
+25
View File
@@ -140,6 +140,31 @@ export function useUpdateActivationSettings() {
});
}
export function useScheduleSettings() {
return useQuery({
queryKey: ['admin', 'schedule-settings'],
queryFn: () => adminApi.getScheduleSettings(),
});
}
export function useUpdateScheduleSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: Partial<import('../lib/types').ScheduleConfig>) =>
adminApi.updateScheduleSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'schedule-settings'] });
qc.invalidateQueries({ queryKey: ['admin', 'jobs'] });
addToast('success', 'Schedule updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update schedule');
},
});
}
export function useSentimentSettings() {
return useQuery({
queryKey: ['admin', 'sentiment-settings'],
+5 -1
View File
@@ -48,6 +48,10 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false;
}
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Expected value (R) is the core gate. Only enforced when computable — setups
// without target probabilities defer to the R:R + confidence floors above.
const ev = expectedValueR(setup);
if (ev != null && ev < config.min_expected_value) return false;
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false;
}
@@ -60,7 +64,7 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
/** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string {
const parts = [`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`];
const parts = [`EV ≥ ${config.min_expected_value.toFixed(2)}R`, `R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`];
if (config.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean');
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
+11 -2
View File
@@ -158,6 +158,7 @@ export interface PerformanceStats {
// Activation gate: what counts as an actionable signal
export interface ActivationConfig {
min_expected_value: number;
min_rr: number;
min_confidence: number;
min_target_probability: number;
@@ -165,6 +166,14 @@ export interface ActivationConfig {
exclude_conflicts: boolean;
}
// Cron schedule for the daily/intraday pipelines + fundamentals
export interface ScheduleConfig {
schedule_timezone: string;
schedule_daily_pipeline_cron: string;
schedule_intraday_pipeline_cron: string;
schedule_fundamentals_cron: string;
}
// Runtime sentiment LLM configuration
export interface SentimentProviderConfig {
provider: string;
@@ -212,7 +221,7 @@ export interface BacktestCalibrationRow {
}
export interface BacktestSweepRow extends BacktestBucket {
min_target_probability: number;
min_expected_value: number;
}
export interface BacktestReport {
@@ -224,7 +233,7 @@ export interface BacktestReport {
overall_qualified: BacktestBucket;
overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>;
min_target_probability: number;
min_expected_value: number;
sweep: BacktestSweepRow[];
calibration: BacktestCalibrationRow[];
note: string;
+2
View File
@@ -6,6 +6,7 @@ import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
import { ScheduleSettings } from '../components/admin/ScheduleSettings';
import { SettingsForm } from '../components/admin/SettingsForm';
import { TickerManagement } from '../components/admin/TickerManagement';
import { TickerUniverseBootstrap } from '../components/admin/TickerUniverseBootstrap';
@@ -41,6 +42,7 @@ export default function AdminPage() {
)}
{activeTab === 'Jobs' && (
<div className="space-y-4">
<ScheduleSettings />
<JobControls />
<PipelineReadinessPanel />
</div>
+14 -9
View File
@@ -25,30 +25,35 @@ 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,
"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,
}
async def test_update_and_read_back(self, session: AsyncSession):
updated = await update_activation_config(
session, {"min_rr": 1.5, "min_confidence": 60.0}
session, {"min_expected_value": 0.25, "min_confidence": 60.0}
)
assert updated["min_rr"] == 1.5
assert updated["min_expected_value"] == 0.25
assert updated["min_confidence"] == 60.0
config = await get_activation_config(session)
assert config["min_rr"] == 1.5
assert config["min_expected_value"] == 0.25
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})
config = await get_activation_config(session)
assert config["min_rr"] == 2.0 # default untouched
assert config["min_rr"] == 1.2 # default untouched
assert config["min_confidence"] == 80.0
async def test_rejects_out_of_range_expected_value(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_activation_config(session, {"min_expected_value": 50.0})
async def test_conviction_flags_round_trip(self, session: AsyncSession):
await update_activation_config(
session,
+2 -2
View File
@@ -113,8 +113,8 @@ async def test_run_backtest_smoke(session):
# the oscillating series should yield at least some resolved setups
assert report["candidates"] >= 1
# sweep: lowering the threshold can only add qualifiers, never remove them
sweep = sorted(report["sweep"], key=lambda r: r["min_target_probability"], reverse=True)
# sweep: lowering the EV threshold can only add qualifiers, never remove them
sweep = sorted(report["sweep"], key=lambda r: r["min_expected_value"], reverse=True)
counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends
# every calibration row is internally consistent
+94 -48
View File
@@ -1,12 +1,30 @@
"""Unit tests for the activation qualification predicate."""
"""Unit tests for the activation qualification predicate (EV-based gate)."""
from __future__ import annotations
from types import SimpleNamespace
from app.services.qualification import best_target_probability, setup_qualifies
from app.services.qualification import (
best_target_probability,
expected_value_r,
primary_target_probability,
setup_qualifies,
)
FULL_GATE = {
# Default gate: expected value is the core test; conviction/conflict/target-prob
# are optional tighteners, off here.
DEFAULT_GATE = {
"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,
}
# Strict gate: every optional tightener turned on (the old shipped defaults).
STRICT_GATE = {
"min_expected_value": 0.0,
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
@@ -21,73 +39,101 @@ def _setup(**kwargs):
confidence_score=80.0,
recommended_action="LONG_HIGH",
risk_level="Low",
targets=[{"probability": 65.0}],
targets=[{"probability": 50.0, "is_primary": True}],
)
base.update(kwargs)
return SimpleNamespace(**base)
class TestSetupQualifies:
def test_clean_high_conviction_setup_passes(self):
assert setup_qualifies(_setup(), FULL_GATE) is True
class TestExpectedValue:
def test_uses_primary_target_not_best(self):
s = _setup(
rr_ratio=1.5,
targets=[
{"probability": 80.0},
{"probability": 30.0, "is_primary": True},
],
)
# EV from the primary (30%): 0.3*1.5 - 0.7 = -0.25
assert expected_value_r(s) == -0.25
assert primary_target_probability(s) == 30.0
def test_low_rr_fails(self):
assert setup_qualifies(_setup(rr_ratio=1.5), FULL_GATE) is False
def test_falls_back_to_best_when_no_primary_flag(self):
s = _setup(rr_ratio=2.0, targets=[{"probability": 40.0}, {"probability": 60.0}])
assert primary_target_probability(s) == 60.0
# 0.6*2.0 - 0.4 = 0.8
assert abs(expected_value_r(s) - 0.8) < 1e-9
def test_none_when_no_targets(self):
assert expected_value_r(_setup(targets=[])) is None
assert primary_target_probability(_setup(targets=[])) is None
class TestSetupQualifies:
def test_positive_ev_setup_passes(self):
# primary 50% @ rr 3.0 → EV = 1.0
assert setup_qualifies(_setup(), DEFAULT_GATE) is True
def test_negative_ev_fails(self):
# primary 30% @ rr 1.3 → EV = -0.31, below the 0.15 floor
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_thin_positive_ev_below_floor_fails(self):
# Positive but thin: 0.45*1.3 - 0.55 = 0.035, under the 0.15 floor.
s = _setup(rr_ratio=1.3, targets=[{"probability": 45.0, "is_primary": True}])
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_low_rr_floor_fails(self):
assert setup_qualifies(_setup(rr_ratio=1.0), DEFAULT_GATE) is False
def test_low_confidence_fails(self):
assert setup_qualifies(_setup(confidence_score=60.0), FULL_GATE) is False
assert setup_qualifies(_setup(confidence_score=40.0), DEFAULT_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_no_targets_defers_to_rr_and_confidence(self):
# No probability → EV uncomputable → not blocked on EV; passes on floors.
assert setup_qualifies(_setup(targets=[]), DEFAULT_GATE) is True
# ...but still subject to the rr/confidence floors.
assert setup_qualifies(_setup(targets=[], rr_ratio=1.0), DEFAULT_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_and_conflict_ignored_by_default(self):
# Moderate action + medium risk still pass when tighteners are off.
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
assert setup_qualifies(s, DEFAULT_GATE) is True
def test_over_progressed_setup_fails_on_live_rr(self):
# long target 120, stop 95; price already at 117 → live R:R ≈ 0.14
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=117.0)
assert setup_qualifies(s, FULL_GATE) is False
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_fresh_setup_passes_live_rr(self):
# price near entry (100): live R:R ≈ 3.2, well above min
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=101.0)
assert setup_qualifies(s, FULL_GATE) is True
assert setup_qualifies(s, DEFAULT_GATE) is True
def test_past_stop_fails_live_rr(self):
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
assert setup_qualifies(s, FULL_GATE) is False
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_no_current_price_skips_live_check(self):
# Historical setups have no current_price → live check skipped
assert setup_qualifies(_setup(), FULL_GATE) is True
def test_missing_min_ev_key_skips_ev(self):
# Legacy callers without min_expected_value: EV defaults to -inf (no floor).
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_expected_value"}
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
assert setup_qualifies(s, legacy) is True
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 TestStrictTighteners:
def test_clean_high_conviction_passes(self):
assert setup_qualifies(_setup(targets=[{"probability": 65.0, "is_primary": True}]), STRICT_GATE) is True
def test_moderate_action_fails(self):
s = _setup(recommended_action="LONG_MODERATE", targets=[{"probability": 65.0, "is_primary": True}])
assert setup_qualifies(s, STRICT_GATE) is False
def test_non_low_risk_fails(self):
s = _setup(risk_level="Medium", targets=[{"probability": 65.0, "is_primary": True}])
assert setup_qualifies(s, STRICT_GATE) is False
def test_low_target_probability_fails(self):
assert setup_qualifies(_setup(targets=[{"probability": 40.0, "is_primary": True}]), STRICT_GATE) is False
class TestBestTargetProbability:
+61
View File
@@ -0,0 +1,61 @@
"""Unit tests for the cron pipeline schedule config."""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ValidationError
from app.scheduler import SCHEDULE_DEFAULTS, validate_cron
from app.services.admin_service import get_schedule_config, update_schedule_config
@pytest.fixture
async def session() -> AsyncSession:
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
class TestValidateCron:
def test_accepts_valid(self):
validate_cron("0 7 * * *", "Europe/Berlin")
validate_cron("0 14-22 * * 1-5", "UTC")
def test_rejects_bad_cron(self):
with pytest.raises(Exception):
validate_cron("not a cron", "UTC")
def test_rejects_bad_timezone(self):
with pytest.raises(Exception):
validate_cron("0 7 * * *", "Mars/Phobos")
class TestScheduleConfig:
async def test_defaults_when_unset(self, session: AsyncSession):
config = await get_schedule_config(session)
assert config == SCHEDULE_DEFAULTS
async def test_update_and_read_back(self, session: AsyncSession):
updated = await update_schedule_config(
session, {"schedule_daily_pipeline_cron": "30 6 * * *"}
)
assert updated["schedule_daily_pipeline_cron"] == "30 6 * * *"
# untouched keys keep their defaults
assert updated["schedule_intraday_pipeline_cron"] == SCHEDULE_DEFAULTS["schedule_intraday_pipeline_cron"]
config = await get_schedule_config(session)
assert config["schedule_daily_pipeline_cron"] == "30 6 * * *"
async def test_rejects_bad_cron(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_fundamentals_cron": "every monday"})
async def test_rejects_bad_timezone(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_timezone": "Nowhere/Void"})
async def test_rejects_unknown_key(self, session: AsyncSession):
with pytest.raises(ValidationError):
await update_schedule_config(session, {"schedule_bogus": "0 0 * * *"})
+2
View File
@@ -88,6 +88,7 @@ class TestConfigureScheduler:
"market_regime",
"backtest",
"daily_pipeline",
"intraday_pipeline",
}
def test_configure_is_idempotent(self):
@@ -100,6 +101,7 @@ class TestConfigureScheduler:
"alerts",
"backtest",
"daily_pipeline",
"intraday_pipeline",
"data_collector",
"fundamental_collector",
"market_regime",