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
+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",