feat: add selectable daily backtest cadence
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""Run the four production cadence/lockdown arms on one offline snapshot.
|
||||
|
||||
The command executes the complete backtest once weekly and once daily. Each
|
||||
backtest contains two otherwise identical live-policy portfolio arms: no
|
||||
post-stop lockdown and the production five-session lockdown. It writes both
|
||||
full reports plus one compact four-arm comparison report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"snapshot",
|
||||
help="SQLite snapshot created by scripts/create_backtest_snapshot.py.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out-dir",
|
||||
default="reports",
|
||||
help="Directory for the weekly, daily, and comparison JSON reports.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prefix",
|
||||
default=None,
|
||||
help="Output prefix. Defaults to backtest-cadence-<timestamp>.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Override worker count; on a powerful offline PC use CPU count minus one.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-spawn",
|
||||
action="store_true",
|
||||
help="Enable multiprocessing spawn for the offline Windows run.",
|
||||
)
|
||||
parser.add_argument("--quiet", action="store_true", help="Hide ticker progress.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _sqlite_url(path: Path) -> str:
|
||||
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict) -> None:
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _comparison_arms(report: dict) -> list[dict]:
|
||||
comparison = report.get("production_cadence_comparison") or {}
|
||||
arms = list(comparison.get("arms") or [])
|
||||
if len(arms) != 2:
|
||||
cadence = (report.get("params") or {}).get("entry_cadence", "unknown")
|
||||
raise RuntimeError(
|
||||
f"Expected two live comparison arms for {cadence}; found {len(arms)}"
|
||||
)
|
||||
return arms
|
||||
|
||||
|
||||
def _print_arm(row: dict) -> None:
|
||||
print(
|
||||
f" {row['arm']}: Sharpe {row.get('sharpe')}, "
|
||||
f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, "
|
||||
f"trades {row.get('trades')}, skipped cooldown {row.get('skipped_cooldown', 0)}"
|
||||
)
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
args = _parse_args()
|
||||
snapshot = Path(args.snapshot)
|
||||
if not snapshot.exists():
|
||||
raise SystemExit(f"Snapshot not found: {snapshot}")
|
||||
|
||||
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||
if args.allow_spawn:
|
||||
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||
|
||||
from app.config import settings
|
||||
from app.services.backtest_service import run_backtest
|
||||
|
||||
if args.workers is not None:
|
||||
settings.backtest_workers = args.workers
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
prefix = args.prefix or f"backtest-cadence-{datetime.now():%Y%m%d-%H%M%S}"
|
||||
|
||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
reports: dict[str, dict] = {}
|
||||
try:
|
||||
async with Session() as db:
|
||||
for cadence in ("weekly", "daily"):
|
||||
last_progress: tuple[int, int] | None = None
|
||||
|
||||
def progress(done: int, total: int, symbol: str) -> None:
|
||||
nonlocal last_progress
|
||||
if args.quiet or last_progress == (done, total):
|
||||
return
|
||||
last_progress = (done, total)
|
||||
label = f" {symbol}" if symbol else ""
|
||||
print(
|
||||
f"{cadence} progress: {done}/{total}{label}",
|
||||
end="\r",
|
||||
)
|
||||
|
||||
reports[cadence] = await run_backtest(
|
||||
db,
|
||||
progress_cb=progress,
|
||||
target_model="production_gtl",
|
||||
cadence=cadence,
|
||||
)
|
||||
if not args.quiet:
|
||||
print("")
|
||||
_write_json(out_dir / f"{prefix}-{cadence}.json", reports[cadence])
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
arms = [
|
||||
*_comparison_arms(reports["weekly"]),
|
||||
*_comparison_arms(reports["daily"]),
|
||||
]
|
||||
expected = {
|
||||
"prod_live_setup_weekly",
|
||||
"prod_live_setup_daily",
|
||||
"cooldown_5_weekly",
|
||||
"cooldown_5_daily",
|
||||
}
|
||||
if {row.get("arm") for row in arms} != expected:
|
||||
raise RuntimeError("The generated cadence report does not contain all four arms")
|
||||
arm_order = {
|
||||
"prod_live_setup_weekly": 0,
|
||||
"prod_live_setup_daily": 1,
|
||||
"cooldown_5_weekly": 2,
|
||||
"cooldown_5_daily": 3,
|
||||
}
|
||||
arms.sort(key=lambda row: arm_order[str(row["arm"])])
|
||||
|
||||
comparison = {
|
||||
"generated_at": datetime.now().astimezone().isoformat(),
|
||||
"snapshot": str(snapshot.resolve()),
|
||||
"target_model": "production_gtl",
|
||||
"arms": arms,
|
||||
"full_reports": {
|
||||
cadence: str((out_dir / f"{prefix}-{cadence}.json").resolve())
|
||||
for cadence in ("weekly", "daily")
|
||||
},
|
||||
"note": (
|
||||
"All four arms use the same snapshot, activation settings, target model, "
|
||||
"live Admin exit policy, fees, sizing, and portfolio constraints. Within "
|
||||
"each cadence pair, only the five-session post-stop lockdown differs."
|
||||
),
|
||||
}
|
||||
comparison_path = out_dir / f"{prefix}-comparison.json"
|
||||
_write_json(comparison_path, comparison)
|
||||
|
||||
print(f"Comparison written: {comparison_path}")
|
||||
for row in arms:
|
||||
_print_arm(row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
@@ -32,7 +32,10 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default=None,
|
||||
help="JSON report path. Defaults to reports/backtest-<timestamp>.json.",
|
||||
help=(
|
||||
"JSON report path. Defaults to "
|
||||
"reports/backtest-<cadence>-<timestamp>.json."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
@@ -55,6 +58,15 @@ def _parse_args() -> argparse.Namespace:
|
||||
"structural_sr is a comparison-only chart-S/R model."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cadence",
|
||||
choices=("weekly", "daily"),
|
||||
default="weekly",
|
||||
help=(
|
||||
"Entry replay cadence. Weekly is the resource-safe production default; "
|
||||
"daily performs roughly five times as many setup evaluations."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--holdout-split",
|
||||
default=None,
|
||||
@@ -63,9 +75,9 @@ def _parse_args() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _default_output_path() -> Path:
|
||||
def _default_output_path(cadence: str) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return Path("reports") / f"backtest-{stamp}.json"
|
||||
return Path("reports") / f"backtest-{cadence}-{stamp}.json"
|
||||
|
||||
|
||||
def _pct(value: Any) -> str:
|
||||
@@ -93,6 +105,7 @@ def _print_summary(report: dict) -> None:
|
||||
|
||||
print("")
|
||||
print("Backtest summary")
|
||||
print(f" entry cadence: {(report.get('params') or {}).get('entry_cadence', 'weekly')}")
|
||||
print(f" candidates: {report.get('candidates')}")
|
||||
print(f" qualified: {report.get('qualified')}")
|
||||
print(f" all setups net avg R: {_r(all_setups.get('net_avg_r'))}")
|
||||
@@ -183,7 +196,7 @@ async def _main() -> None:
|
||||
if args.workers is not None:
|
||||
settings.backtest_workers = args.workers
|
||||
|
||||
output = Path(args.out) if args.out else _default_output_path()
|
||||
output = Path(args.out) if args.out else _default_output_path(args.cadence)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||
@@ -208,6 +221,7 @@ async def _main() -> None:
|
||||
db,
|
||||
progress_cb=progress,
|
||||
target_model=args.target_model,
|
||||
cadence=args.cadence,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user