redesign activation gate to expected value + make pipelines cron-configurable
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:
@@ -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
@@ -68,7 +68,7 @@ from app.config import settings
|
|||||||
from app.database import async_session_factory, engine
|
from app.database import async_session_factory, engine
|
||||||
from app.middleware import register_exception_handlers
|
from app.middleware import register_exception_handlers
|
||||||
from app.models.user import User
|
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.admin import router as admin_router
|
||||||
from app.routers.auth import router as auth_router
|
from app.routers.auth import router as auth_router
|
||||||
from app.routers.health import router as health_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:
|
async with async_session_factory() as session:
|
||||||
await _create_default_admin(session)
|
await _create_default_admin(session)
|
||||||
|
schedule_config = await load_schedule_config(session)
|
||||||
|
|
||||||
configure_scheduler()
|
configure_scheduler(schedule_config)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started")
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.schemas.admin import (
|
|||||||
DataCleanupRequest,
|
DataCleanupRequest,
|
||||||
JobToggle,
|
JobToggle,
|
||||||
RecommendationConfigUpdate,
|
RecommendationConfigUpdate,
|
||||||
|
ScheduleConfigUpdate,
|
||||||
SentimentConfigUpdate,
|
SentimentConfigUpdate,
|
||||||
SentimentTestRequest,
|
SentimentTestRequest,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
@@ -176,6 +177,28 @@ async def update_activation_settings(
|
|||||||
return APIEnvelope(status="success", data=updated)
|
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)
|
@router.get("/admin/settings/sentiment", response_model=APIEnvelope)
|
||||||
async def get_sentiment_settings(
|
async def get_sentiment_settings(
|
||||||
_admin: User = Depends(require_admin),
|
_admin: User = Depends(require_admin),
|
||||||
|
|||||||
+149
-26
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy import case, func, or_, select
|
from sqlalchemy import case, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -168,6 +169,17 @@ _job_runtime: dict[str, dict[str, object]] = {
|
|||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
"message": 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.
|
# 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
|
# (name, coroutine) — the names match the individual jobs so each step still
|
||||||
# updates its own runtime status while the pipeline runs.
|
# 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"),
|
("data_collector", "collect_ohlcv"),
|
||||||
("sentiment_collector", "collect_sentiment"),
|
("sentiment_collector", "collect_sentiment"),
|
||||||
("rr_scanner", "scan_rr"),
|
("rr_scanner", "scan_rr"),
|
||||||
@@ -1008,28 +1022,41 @@ _PIPELINE_STEPS = [
|
|||||||
("market_regime", "compute_market_regime"),
|
("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) →
|
async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||||
market regime. Each step respects its own enable flag and manages its own
|
"""Run an ordered list of (step_name, coroutine_name) steps.
|
||||||
runtime status; a failing step is logged and the pipeline continues.
|
|
||||||
|
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}))
|
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)
|
_runtime_start(job_name, total=total)
|
||||||
|
|
||||||
funcs = globals()
|
funcs = globals()
|
||||||
done = 0
|
done = 0
|
||||||
try:
|
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)
|
_runtime_progress(job_name, processed=done, total=total, current_ticker=step_name)
|
||||||
try:
|
try:
|
||||||
await funcs[func_name]()
|
await funcs[func_name]()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Daily pipeline step %s failed", step_name)
|
logger.exception("%s step %s failed", job_name, step_name)
|
||||||
done += 1
|
done += 1
|
||||||
_runtime_finish(job_name, "completed", processed=done, total=total, message="Pipeline complete")
|
_runtime_finish(job_name, "completed", processed=done, total=total, message="Pipeline complete")
|
||||||
logger.info(json.dumps({"event": "job_complete", "job": job_name}))
|
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
|
# Frequency helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1057,22 +1095,91 @@ def _parse_frequency(freq: str) -> dict[str, int]:
|
|||||||
return _FREQUENCY_MAP.get(freq.lower(), {"hours": 24})
|
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
|
# Scheduler setup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def configure_scheduler() -> None:
|
def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
|
||||||
"""Add all jobs to the scheduler with configured intervals.
|
"""Add all jobs to the scheduler.
|
||||||
|
|
||||||
Call this once before scheduler.start(). Removes any existing jobs first
|
Call this once before scheduler.start(). Removes any existing jobs first to
|
||||||
to ensure idempotency.
|
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()
|
scheduler.remove_all_jobs()
|
||||||
|
|
||||||
# Pipeline members: registered but PAUSED (next_run_time=None) so they never
|
# 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
|
# auto-fire on their own timer — the pipelines drive them in order. The long
|
||||||
# long interval is just a backstop after a manual trigger (which re-arms an
|
# interval is just a backstop after a manual trigger (which re-arms an
|
||||||
# interval job). They stay manually triggerable from Admin → Jobs.
|
# interval job). They stay manually triggerable from Admin → Jobs.
|
||||||
_members = [
|
_members = [
|
||||||
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
|
(collect_ohlcv, "data_collector", "Data Collector (OHLCV)"),
|
||||||
@@ -1087,23 +1194,30 @@ def configure_scheduler() -> None:
|
|||||||
replace_existing=True, next_run_time=None,
|
replace_existing=True, next_run_time=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Daily Pipeline — the single ordered daily flow
|
# Cron-driven jobs (admin-configurable times)
|
||||||
scheduler.add_job(
|
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,
|
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(
|
scheduler.add_job(
|
||||||
sync_ticker_universe, "interval", hours=24,
|
sync_ticker_universe, "interval", hours=24,
|
||||||
id="ticker_universe_sync", name="Ticker Universe Sync", replace_existing=True,
|
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)
|
alerts_interval = _parse_frequency(settings.alerts_frequency)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
dispatch_alerts_job, "interval", **alerts_interval,
|
dispatch_alerts_job, "interval", **alerts_interval,
|
||||||
@@ -1117,7 +1231,16 @@ def configure_scheduler() -> None:
|
|||||||
logger.info(
|
logger.info(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"event": "scheduler_configured",
|
"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"],
|
"independent": ["ticker_universe_sync", "alerts", "backtest"],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class TickerUniverseUpdate(BaseModel):
|
|||||||
|
|
||||||
class ActivationConfigUpdate(BaseModel):
|
class ActivationConfigUpdate(BaseModel):
|
||||||
"""Activation gate: what counts as an actionable signal."""
|
"""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_rr: float | None = Field(default=None, ge=0)
|
||||||
min_confidence: float | None = Field(default=None, ge=0, le=100)
|
min_confidence: float | None = Field(default=None, ge=0, le=100)
|
||||||
min_target_probability: 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
|
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):
|
class SentimentConfigUpdate(BaseModel):
|
||||||
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
|
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
|
||||||
the stored key."""
|
the stored key."""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Admin service: user management, system settings, data cleanup, job control."""
|
"""Admin service: user management, system settings, data cleanup, job control."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from passlib.hash import bcrypt
|
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.trade_setup import TradeSetup
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = {
|
RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = {
|
||||||
"recommendation_high_confidence_threshold": 70.0,
|
"recommendation_high_confidence_threshold": 70.0,
|
||||||
"recommendation_moderate_confidence_threshold": 50.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
|
# Track Record's qualified stats. The outcome evaluator deliberately ignores
|
||||||
# these — every setup is evaluated so the gate itself can be validated.
|
# 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
|
# The core test is expected value (in R): probability-weighted asymmetry, so a
|
||||||
# action (LONG_HIGH / SHORT_HIGH), a clean read (risk Low / no conflicts), and a
|
# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and
|
||||||
# probable primary target.
|
# 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] = {
|
_ACTIVATION_FLOAT_KEYS: dict[str, str] = {
|
||||||
|
"min_expected_value": "activation_min_expected_value",
|
||||||
"min_rr": "activation_min_rr",
|
"min_rr": "activation_min_rr",
|
||||||
"min_confidence": "activation_min_confidence",
|
"min_confidence": "activation_min_confidence",
|
||||||
"min_target_probability": "activation_min_target_probability",
|
"min_target_probability": "activation_min_target_probability",
|
||||||
@@ -48,11 +53,12 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
|
|||||||
"exclude_conflicts": "activation_exclude_conflicts",
|
"exclude_conflicts": "activation_exclude_conflicts",
|
||||||
}
|
}
|
||||||
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
|
||||||
"min_rr": 2.0,
|
"min_expected_value": 0.15,
|
||||||
"min_confidence": 70.0,
|
"min_rr": 1.2,
|
||||||
"min_target_probability": 60.0,
|
"min_confidence": 55.0,
|
||||||
"require_high_conviction": True,
|
"min_target_probability": 0.0,
|
||||||
"exclude_conflicts": True,
|
"require_high_conviction": False,
|
||||||
|
"exclude_conflicts": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +201,8 @@ async def update_activation_config(
|
|||||||
db: AsyncSession, updates: dict[str, float | bool]
|
db: AsyncSession, updates: dict[str, float | bool]
|
||||||
) -> dict[str, float | bool]:
|
) -> dict[str, float | bool]:
|
||||||
"""Update the activation gate. Accepts public keys; only supplied keys change."""
|
"""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:
|
if "min_rr" in updates and updates["min_rr"] < 0:
|
||||||
raise ValidationError("min_rr must be >= 0")
|
raise ValidationError("min_rr must be >= 0")
|
||||||
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
|
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)
|
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:
|
def _recommendation_public_to_storage_key(key: str) -> str:
|
||||||
return f"recommendation_{key}"
|
return f"recommendation_{key}"
|
||||||
|
|
||||||
@@ -486,6 +547,7 @@ VALID_JOB_NAMES = {
|
|||||||
"market_regime",
|
"market_regime",
|
||||||
"backtest",
|
"backtest",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
|
"intraday_pipeline",
|
||||||
}
|
}
|
||||||
|
|
||||||
JOB_LABELS = {
|
JOB_LABELS = {
|
||||||
@@ -499,6 +561,7 @@ JOB_LABELS = {
|
|||||||
"market_regime": "Market Regime",
|
"market_regime": "Market Regime",
|
||||||
"backtest": "Backtest",
|
"backtest": "Backtest",
|
||||||
"daily_pipeline": "Daily Pipeline",
|
"daily_pipeline": "Daily Pipeline",
|
||||||
|
"intraday_pipeline": "Intraday Pipeline",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Jobs driven by the daily_pipeline (in order) rather than their own timer.
|
# Jobs driven by the daily_pipeline (in order) rather than their own timer.
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ from app.services.outcome_service import (
|
|||||||
evaluate_setup_against_bars,
|
evaluate_setup_against_bars,
|
||||||
)
|
)
|
||||||
from app.services.price_service import query_ohlcv
|
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 (
|
from app.services.recommendation_service import (
|
||||||
_choose_recommended_action,
|
_choose_recommended_action,
|
||||||
_classify_by_probability,
|
_classify_by_probability,
|
||||||
@@ -131,6 +135,10 @@ def _window_setups(
|
|||||||
primary = _select_primary_target(targets)
|
primary = _select_primary_target(targets)
|
||||||
if primary is None:
|
if primary is None:
|
||||||
continue
|
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}
|
per_dir[direction] = {"stop": stop, "targets": targets, "primary": primary}
|
||||||
|
|
||||||
available = set(per_dir.keys())
|
available = set(per_dir.keys())
|
||||||
@@ -160,12 +168,13 @@ def _window_setups(
|
|||||||
stop_loss=stop,
|
stop_loss=stop,
|
||||||
entry_price=entry,
|
entry_price=entry,
|
||||||
)
|
)
|
||||||
# meets_core = clears every gate EXCEPT target probability, so the report
|
# meets_core = clears every gate EXCEPT the expected-value floor, so the
|
||||||
# can sweep the min_target_probability threshold without re-replaying.
|
# report can sweep the min_expected_value threshold without re-replaying.
|
||||||
core_config = {**activation, "min_target_probability": 0.0}
|
core_config = {**activation, "min_expected_value": float("-inf")}
|
||||||
meets_core = setup_qualifies(setup_ns, core_config)
|
meets_core = setup_qualifies(setup_ns, core_config)
|
||||||
|
ev = expected_value_r(setup_ns)
|
||||||
best_prob = best_target_probability(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({
|
out.append({
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
@@ -175,10 +184,11 @@ def _window_setups(
|
|||||||
"confidence": confidences[direction],
|
"confidence": confidences[direction],
|
||||||
"primary_prob": float(primary["probability"]),
|
"primary_prob": float(primary["probability"]),
|
||||||
"best_prob": best_prob,
|
"best_prob": best_prob,
|
||||||
|
"ev": ev,
|
||||||
"meets_core": meets_core,
|
"meets_core": meets_core,
|
||||||
"action": action,
|
"action": action,
|
||||||
"risk_level": risk_level,
|
"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
|
return out
|
||||||
|
|
||||||
@@ -216,6 +226,7 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
|
|||||||
"confidence": s["confidence"],
|
"confidence": s["confidence"],
|
||||||
"primary_prob": s["primary_prob"],
|
"primary_prob": s["primary_prob"],
|
||||||
"best_prob": s["best_prob"],
|
"best_prob": s["best_prob"],
|
||||||
|
"ev": s["ev"],
|
||||||
"meets_core": s["meets_core"],
|
"meets_core": s["meets_core"],
|
||||||
"qualified": s["qualified"],
|
"qualified": s["qualified"],
|
||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
@@ -288,14 +299,17 @@ async def run_backtest(
|
|||||||
longs = [c for c in qualified if c["direction"] == "long"]
|
longs = [c for c in qualified if c["direction"] == "long"]
|
||||||
shorts = [c for c in qualified if c["direction"] == "short"]
|
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
|
# (holding the other conditions fixed) so the trade-off between how many
|
||||||
# setups qualify and their expectancy is visible without re-replaying.
|
# 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 = []
|
sweep = []
|
||||||
for threshold in (60, 55, 50, 45, 40, 35, 30):
|
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["best_prob"] >= threshold]
|
cands = [
|
||||||
sweep.append({"min_target_probability": threshold, **_bucket_stats(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 {
|
return {
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -310,7 +324,7 @@ async def run_backtest(
|
|||||||
"long": _bucket_stats(longs),
|
"long": _bucket_stats(longs),
|
||||||
"short": _bucket_stats(shorts),
|
"short": _bucket_stats(shorts),
|
||||||
},
|
},
|
||||||
"min_target_probability": current_min_tp,
|
"min_expected_value": current_min_ev,
|
||||||
"sweep": sweep,
|
"sweep": sweep,
|
||||||
"calibration": _calibration(candidates),
|
"calibration": _calibration(candidates),
|
||||||
"note": (
|
"note": (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Shared definition of a 'qualified' (actionable) trade setup.
|
"""Shared definition of a 'qualified' (actionable) trade setup.
|
||||||
|
|
||||||
A single predicate, driven by the admin activation config, used by the
|
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
|
performance stats (server) and mirrored on the frontend. The core gate is
|
||||||
confidence, an actionable setup must show genuine conviction: a high-conviction
|
expected value (in R): a setup must promise positive, probability-weighted
|
||||||
recommended action, a clean (conflict-free) read, and a probable primary target.
|
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
|
from __future__ import annotations
|
||||||
@@ -20,6 +22,37 @@ def best_target_probability(setup: Any) -> float:
|
|||||||
return max(probs, default=0.0)
|
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:
|
def live_risk_reward(setup: Any, current_price: float) -> float | None:
|
||||||
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
|
"""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,
|
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
|
||||||
recommended_action, risk_level and a ``targets`` list of dicts.
|
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"]:
|
if setup.rr_ratio < config["min_rr"]:
|
||||||
return False
|
return False
|
||||||
@@ -56,6 +94,13 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
||||||
return False
|
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 config.get("require_high_conviction"):
|
||||||
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AlertTestResult,
|
AlertTestResult,
|
||||||
PipelineReadiness,
|
PipelineReadiness,
|
||||||
RecommendationConfig,
|
RecommendationConfig,
|
||||||
|
ScheduleConfig,
|
||||||
SentimentProviderConfig,
|
SentimentProviderConfig,
|
||||||
SentimentTestResult,
|
SentimentTestResult,
|
||||||
SystemSetting,
|
SystemSetting,
|
||||||
@@ -85,6 +86,18 @@ export function updateActivationSettings(payload: Partial<ActivationConfig>) {
|
|||||||
.then((r) => r.data);
|
.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() {
|
export function getSentimentSettings() {
|
||||||
return apiClient
|
return apiClient
|
||||||
.get<SentimentProviderConfig>('admin/settings/sentiment')
|
.get<SentimentProviderConfig>('admin/settings/sentiment')
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/
|
|||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
|
|
||||||
const DEFAULTS: ActivationConfig = {
|
const DEFAULTS: ActivationConfig = {
|
||||||
min_rr: 2,
|
min_expected_value: 0.15,
|
||||||
min_confidence: 70,
|
min_rr: 1.2,
|
||||||
min_target_probability: 60,
|
min_confidence: 55,
|
||||||
require_high_conviction: true,
|
min_target_probability: 0,
|
||||||
exclude_conflicts: true,
|
require_high_conviction: false,
|
||||||
|
exclude_conflicts: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ActivationSettings() {
|
export function ActivationSettings() {
|
||||||
@@ -39,13 +40,27 @@ export function ActivationSettings() {
|
|||||||
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
|
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
|
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
|
Signals "Qualified only" view, and the Track Record's qualified stats. The core test is
|
||||||
still evaluated regardless — tighten the gate, then watch qualified expectancy in the
|
<span className="text-gray-300"> expected value</span> — probability-weighted asymmetry —
|
||||||
Track Record to find what actually wins.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<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 − (1−p), in R. 0.15 ≈ +0.15× risk/trade. The core gate.</span>
|
||||||
|
</label>
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
|
||||||
<input
|
<input
|
||||||
@@ -56,7 +71,7 @@ export function ActivationSettings() {
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, min_rr: Number(e.target.value) }))}
|
||||||
className="w-full input-glass px-3 py-2 text-sm"
|
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>
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Confidence (%)</span>
|
<span className="text-xs text-gray-400">Min Confidence (%)</span>
|
||||||
@@ -70,6 +85,12 @@ export function ActivationSettings() {
|
|||||||
className="w-full input-glass px-3 py-2 text-sm"
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
|
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
|
||||||
<input
|
<input
|
||||||
@@ -83,9 +104,6 @@ export function ActivationSettings() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-gray-600">Best target's probability must clear this. 0 disables.</span>
|
<span className="text-[11px] text-gray-600">Best target's probability must clear this. 0 disables.</span>
|
||||||
</label>
|
</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">
|
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -115,6 +133,7 @@ export function ActivationSettings() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function JobControls() {
|
|||||||
: 'Inactive'}
|
: 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
{job.via_pipeline ? (
|
{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 && (
|
job.enabled && job.next_run_at && (
|
||||||
<span className="text-[11px] text-gray-500">
|
<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 && (
|
{report.sweep && report.sweep.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||||
Min target-probability sweep
|
Min expected-value sweep
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2 text-[11px] text-gray-500">
|
<p className="mb-2 text-[11px] text-gray-500">
|
||||||
How many setups qualify — and how they perform — at each gate threshold (other
|
How many setups qualify — and how they perform — at each expected-value gate (other
|
||||||
gate conditions held fixed). Lower = more trades, watch that expectancy holds.
|
gate conditions held fixed). EV is in R: 0.15 means +0.15× your risk per trade on
|
||||||
Your current setting is highlighted; set it in Admin → Settings → Activation.
|
average. Lower = more trades, watch that expectancy holds. Your current setting is
|
||||||
|
highlighted; set it in Admin → Settings → Activation.
|
||||||
</p>
|
</p>
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
<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">Qualified</th>
|
||||||
<th className="px-4 py-2.5 text-right">Wins</th>
|
<th className="px-4 py-2.5 text-right">Wins</th>
|
||||||
<th className="px-4 py-2.5 text-right">Losses</th>
|
<th className="px-4 py-2.5 text-right">Losses</th>
|
||||||
@@ -183,12 +184,12 @@ export function BacktestPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.sweep.map((row) => {
|
{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 (
|
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">
|
<td className="num px-4 py-2.5 text-gray-200">
|
||||||
{current && <span className="mr-1 text-blue-300">★</span>}
|
{current && <span className="mr-1 text-blue-300">★</span>}
|
||||||
{row.min_target_probability}%
|
{row.min_expected_value.toFixed(2)}
|
||||||
</td>
|
</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-gray-200">{row.total}</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
||||||
|
|||||||
@@ -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() {
|
export function useSentimentSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['admin', 'sentiment-settings'],
|
queryKey: ['admin', 'sentiment-settings'],
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ((setup.confidence_score ?? 0) < config.min_confidence) 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 ?? '')) {
|
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||||
return false;
|
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. */
|
/** Short human summary of the active gate, e.g. for tooltips/labels. */
|
||||||
export function activationSummary(config: ActivationConfig): string {
|
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.require_high_conviction) parts.push('high-conviction');
|
||||||
if (config.exclude_conflicts) parts.push('clean');
|
if (config.exclude_conflicts) parts.push('clean');
|
||||||
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
|
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export interface PerformanceStats {
|
|||||||
|
|
||||||
// Activation gate: what counts as an actionable signal
|
// Activation gate: what counts as an actionable signal
|
||||||
export interface ActivationConfig {
|
export interface ActivationConfig {
|
||||||
|
min_expected_value: number;
|
||||||
min_rr: number;
|
min_rr: number;
|
||||||
min_confidence: number;
|
min_confidence: number;
|
||||||
min_target_probability: number;
|
min_target_probability: number;
|
||||||
@@ -165,6 +166,14 @@ export interface ActivationConfig {
|
|||||||
exclude_conflicts: boolean;
|
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
|
// Runtime sentiment LLM configuration
|
||||||
export interface SentimentProviderConfig {
|
export interface SentimentProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -212,7 +221,7 @@ export interface BacktestCalibrationRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestSweepRow extends BacktestBucket {
|
export interface BacktestSweepRow extends BacktestBucket {
|
||||||
min_target_probability: number;
|
min_expected_value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestReport {
|
export interface BacktestReport {
|
||||||
@@ -224,7 +233,7 @@ export interface BacktestReport {
|
|||||||
overall_qualified: BacktestBucket;
|
overall_qualified: BacktestBucket;
|
||||||
overall_all: BacktestBucket;
|
overall_all: BacktestBucket;
|
||||||
by_direction: Record<string, BacktestBucket>;
|
by_direction: Record<string, BacktestBucket>;
|
||||||
min_target_probability: number;
|
min_expected_value: number;
|
||||||
sweep: BacktestSweepRow[];
|
sweep: BacktestSweepRow[];
|
||||||
calibration: BacktestCalibrationRow[];
|
calibration: BacktestCalibrationRow[];
|
||||||
note: string;
|
note: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { DataCleanup } from '../components/admin/DataCleanup';
|
|||||||
import { JobControls } from '../components/admin/JobControls';
|
import { JobControls } from '../components/admin/JobControls';
|
||||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||||
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
|
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
|
||||||
|
import { ScheduleSettings } from '../components/admin/ScheduleSettings';
|
||||||
import { SettingsForm } from '../components/admin/SettingsForm';
|
import { SettingsForm } from '../components/admin/SettingsForm';
|
||||||
import { TickerManagement } from '../components/admin/TickerManagement';
|
import { TickerManagement } from '../components/admin/TickerManagement';
|
||||||
import { TickerUniverseBootstrap } from '../components/admin/TickerUniverseBootstrap';
|
import { TickerUniverseBootstrap } from '../components/admin/TickerUniverseBootstrap';
|
||||||
@@ -41,6 +42,7 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'Jobs' && (
|
{activeTab === 'Jobs' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<ScheduleSettings />
|
||||||
<JobControls />
|
<JobControls />
|
||||||
<PipelineReadinessPanel />
|
<PipelineReadinessPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,30 +25,35 @@ class TestActivationConfig:
|
|||||||
async def test_defaults_when_unset(self, session: AsyncSession):
|
async def test_defaults_when_unset(self, session: AsyncSession):
|
||||||
config = await get_activation_config(session)
|
config = await get_activation_config(session)
|
||||||
assert config == {
|
assert config == {
|
||||||
"min_rr": 2.0,
|
"min_expected_value": 0.15,
|
||||||
"min_confidence": 70.0,
|
"min_rr": 1.2,
|
||||||
"min_target_probability": 60.0,
|
"min_confidence": 55.0,
|
||||||
"require_high_conviction": True,
|
"min_target_probability": 0.0,
|
||||||
"exclude_conflicts": True,
|
"require_high_conviction": False,
|
||||||
|
"exclude_conflicts": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def test_update_and_read_back(self, session: AsyncSession):
|
async def test_update_and_read_back(self, session: AsyncSession):
|
||||||
updated = await update_activation_config(
|
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
|
assert updated["min_confidence"] == 60.0
|
||||||
|
|
||||||
config = await get_activation_config(session)
|
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
|
assert config["min_confidence"] == 60.0
|
||||||
|
|
||||||
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
||||||
await update_activation_config(session, {"min_confidence": 80.0})
|
await update_activation_config(session, {"min_confidence": 80.0})
|
||||||
config = await get_activation_config(session)
|
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
|
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):
|
async def test_conviction_flags_round_trip(self, session: AsyncSession):
|
||||||
await update_activation_config(
|
await update_activation_config(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ async def test_run_backtest_smoke(session):
|
|||||||
# the oscillating series should yield at least some resolved setups
|
# the oscillating series should yield at least some resolved setups
|
||||||
assert report["candidates"] >= 1
|
assert report["candidates"] >= 1
|
||||||
|
|
||||||
# sweep: lowering the threshold can only add qualifiers, never remove them
|
# sweep: lowering the EV threshold can only add qualifiers, never remove them
|
||||||
sweep = sorted(report["sweep"], key=lambda r: r["min_target_probability"], reverse=True)
|
sweep = sorted(report["sweep"], key=lambda r: r["min_expected_value"], reverse=True)
|
||||||
counts = [r["total"] for r in sweep]
|
counts = [r["total"] for r in sweep]
|
||||||
assert counts == sorted(counts) # ascending as threshold descends
|
assert counts == sorted(counts) # ascending as threshold descends
|
||||||
# every calibration row is internally consistent
|
# every calibration row is internally consistent
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
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_rr": 2.0,
|
||||||
"min_confidence": 70.0,
|
"min_confidence": 70.0,
|
||||||
"min_target_probability": 60.0,
|
"min_target_probability": 60.0,
|
||||||
@@ -21,73 +39,101 @@ def _setup(**kwargs):
|
|||||||
confidence_score=80.0,
|
confidence_score=80.0,
|
||||||
recommended_action="LONG_HIGH",
|
recommended_action="LONG_HIGH",
|
||||||
risk_level="Low",
|
risk_level="Low",
|
||||||
targets=[{"probability": 65.0}],
|
targets=[{"probability": 50.0, "is_primary": True}],
|
||||||
)
|
)
|
||||||
base.update(kwargs)
|
base.update(kwargs)
|
||||||
return SimpleNamespace(**base)
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
class TestSetupQualifies:
|
class TestExpectedValue:
|
||||||
def test_clean_high_conviction_setup_passes(self):
|
def test_uses_primary_target_not_best(self):
|
||||||
assert setup_qualifies(_setup(), FULL_GATE) is True
|
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):
|
def test_falls_back_to_best_when_no_primary_flag(self):
|
||||||
assert setup_qualifies(_setup(rr_ratio=1.5), FULL_GATE) is False
|
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):
|
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):
|
def test_no_targets_defers_to_rr_and_confidence(self):
|
||||||
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), FULL_GATE) is False
|
# 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):
|
def test_conviction_and_conflict_ignored_by_default(self):
|
||||||
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), FULL_GATE) is False
|
# Moderate action + medium risk still pass when tighteners are off.
|
||||||
|
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
|
||||||
def test_short_high_passes(self):
|
assert setup_qualifies(s, DEFAULT_GATE) is True
|
||||||
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_over_progressed_setup_fails_on_live_rr(self):
|
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)
|
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):
|
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)
|
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):
|
def test_past_stop_fails_live_rr(self):
|
||||||
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
|
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):
|
def test_missing_min_ev_key_skips_ev(self):
|
||||||
# Historical setups have no current_price → live check skipped
|
# Legacy callers without min_expected_value: EV defaults to -inf (no floor).
|
||||||
assert setup_qualifies(_setup(), FULL_GATE) is True
|
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):
|
class TestStrictTighteners:
|
||||||
assert setup_qualifies(_setup(confidence_score=None), FULL_GATE) is False
|
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:
|
class TestBestTargetProbability:
|
||||||
|
|||||||
@@ -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 * * *"})
|
||||||
@@ -88,6 +88,7 @@ class TestConfigureScheduler:
|
|||||||
"market_regime",
|
"market_regime",
|
||||||
"backtest",
|
"backtest",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
|
"intraday_pipeline",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_configure_is_idempotent(self):
|
def test_configure_is_idempotent(self):
|
||||||
@@ -100,6 +101,7 @@ class TestConfigureScheduler:
|
|||||||
"alerts",
|
"alerts",
|
||||||
"backtest",
|
"backtest",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
|
"intraday_pipeline",
|
||||||
"data_collector",
|
"data_collector",
|
||||||
"fundamental_collector",
|
"fundamental_collector",
|
||||||
"market_regime",
|
"market_regime",
|
||||||
|
|||||||
Reference in New Issue
Block a user