add backtest harness (Phase 1): historical replay + hit-rate & calibration reports
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 25s

Replays the price-derived engine over stored OHLCV: at each weekly as-of date,
rebuild the setup from bars <= D (no lookahead) and walk the actual forward bars
for the realized outcome. Reports realized hit-rate/expectancy of qualified
setups (and all setups, by direction) plus a probability calibration curve
(predicted target prob vs realized hit rate).

Reuses pure functions throughout; extracted compute_technical_from_arrays /
compute_momentum_from_closes from scoring_service so live and backtest stay in
sync. Runs as a weekly/triggerable 'backtest' job caching the report in a
SystemSetting; GET /backtest/report serves it. Sentiment/fundamentals held
neutral (no point-in-time history) — calibrates the price/S-R/probability machinery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:14:07 +02:00
parent 6d951bd760
commit 6df67ad7ae
7 changed files with 548 additions and 12 deletions
+61
View File
@@ -35,6 +35,7 @@ from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.providers.protocol import SentimentData
from app.services import fundamental_service, ingestion_service, sentiment_service
from app.services.alert_service import dispatch_alerts
from app.services.backtest_service import run_and_store as run_backtest_and_store
from app.services.market_regime_service import update_market_regime
from app.services.outcome_service import evaluate_pending_setups
from app.services.rr_scanner_service import scan_all_tickers
@@ -145,6 +146,17 @@ _job_runtime: dict[str, dict[str, object]] = {
"finished_at": None,
"message": None,
},
"backtest": {
"running": False,
"status": "idle",
"processed": 0,
"total": None,
"progress_pct": None,
"current_ticker": None,
"started_at": None,
"finished_at": None,
"message": None,
},
}
@@ -851,6 +863,45 @@ async def compute_market_regime() -> None:
}))
# ---------------------------------------------------------------------------
# Job: Backtest
# ---------------------------------------------------------------------------
async def run_backtest_job() -> None:
"""Replay the price-derived engine over history and cache the report."""
job_name = "backtest"
logger.info(json.dumps({"event": "job_start", "job": job_name}))
_runtime_start(job_name)
def _on_progress(done: int, count: int, symbol: str) -> None:
_runtime_progress(job_name, processed=done, total=count, current_ticker=symbol or None)
try:
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
report = await run_backtest_and_store(db, _on_progress)
_runtime_finish(
job_name, "completed",
processed=report.get("tickers", 0), total=report.get("tickers", 0),
message=f"{report.get('candidates', 0)} setups, {report.get('qualified', 0)} qualified",
)
logger.info(json.dumps({"event": "job_complete", "job": job_name, "candidates": report.get("candidates")}))
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=None, message=str(exc))
logger.error(json.dumps({
"event": "job_error",
"job": job_name,
"error_type": type(exc).__name__,
"message": str(exc),
}))
# ---------------------------------------------------------------------------
# Job: Ticker Universe Sync
# ---------------------------------------------------------------------------
@@ -1010,6 +1061,16 @@ def configure_scheduler() -> None:
replace_existing=True,
)
# Backtest — weekly historical replay (expensive; mostly run on demand)
scheduler.add_job(
run_backtest_job,
"interval",
hours=168,
id="backtest",
name="Backtest",
replace_existing=True,
)
logger.info(
json.dumps({
"event": "scheduler_configured",