You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
760 lines
27 KiB
Python
760 lines
27 KiB
Python
"""Paper-trading backtest simulator.
|
|
|
|
Refactored to use the SAME BacktestRunner + SnapshotStore as
|
|
apps/backtester/run.py. This guarantees identical scoring, engine
|
|
matching, and position sizing between `fithia2 paper backtest` and
|
|
the research backtester.
|
|
|
|
Previous implementation used PaperTradingEngine + EventDetector +
|
|
MockBroker which had different scoring functions, feature computation,
|
|
and data sources — causing divergent results.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import math
|
|
import statistics
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from libs.common.config import get_settings
|
|
from libs.common.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def run_backtest_session_sync(
|
|
session_name: str,
|
|
config_path: str,
|
|
initial_equity: float,
|
|
start_date: dt.date,
|
|
end_date: dt.date,
|
|
) -> dict[str, Any]:
|
|
"""Run a single strategy using BacktestRunner (same as research backtester).
|
|
|
|
Uses the existing Parquet snapshot + BacktestRunner pipeline so results
|
|
match `python -m apps.backtester.run --manifest <config>` exactly.
|
|
"""
|
|
from apps.backtester.run import (
|
|
BacktestRunner,
|
|
_build_store,
|
|
_build_merged_snapshot_store,
|
|
load_manifest,
|
|
resolve_config,
|
|
)
|
|
|
|
manifest = load_manifest(config_path)
|
|
config = resolve_config(manifest)
|
|
|
|
# Use merged store (train+valid+test) to cover the full date range.
|
|
# Try default parquet_dir first, fall back to data/datasets/snapshots.
|
|
from libs.common.config import get_settings
|
|
settings = get_settings()
|
|
snapshot_dir_override = None
|
|
default_path = Path(settings.parquet_dir) / config.dataset_snapshot_id
|
|
alt_path = Path("data/datasets/snapshots") / config.dataset_snapshot_id
|
|
if not default_path.exists() and alt_path.exists():
|
|
snapshot_dir_override = "data/datasets/snapshots"
|
|
store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=snapshot_dir_override)
|
|
|
|
# Slice to requested date range
|
|
store = store.slice_by_date_range(start_date, end_date)
|
|
|
|
runner = BacktestRunner(
|
|
manifest=manifest,
|
|
config=config,
|
|
store=store,
|
|
initial_equity=initial_equity,
|
|
split_name="paper_backtest",
|
|
)
|
|
|
|
# Run with temporary output directory
|
|
tmp_dir = tempfile.mkdtemp(prefix="paper_bt_")
|
|
try:
|
|
result = runner.run(output_root=tmp_dir)
|
|
except Exception:
|
|
result = runner.run(output_root=None)
|
|
|
|
# Convert to paper backtest format
|
|
return _convert_from_runner(session_name, config_path, initial_equity, result, tmp_dir)
|
|
|
|
|
|
def _convert_from_runner(
|
|
session_name: str,
|
|
config_path: str,
|
|
initial_equity: float,
|
|
result: Any,
|
|
tmp_dir: str,
|
|
) -> dict[str, Any]:
|
|
"""Convert BacktestRunner result to paper backtest output format."""
|
|
equity_curve: list[dict] = []
|
|
trades: list[dict] = []
|
|
|
|
# Load from Parquet artifacts
|
|
try:
|
|
import pyarrow.parquet as pq
|
|
import glob
|
|
import os
|
|
|
|
run_dirs = sorted(glob.glob(os.path.join(tmp_dir, "bt_*")))
|
|
if run_dirs:
|
|
run_dir = Path(run_dirs[0])
|
|
|
|
# Equity curve
|
|
eq_path = run_dir / "artifacts" / "daily_equity_curve.parquet"
|
|
if eq_path.exists():
|
|
eq_df = pq.read_table(str(eq_path)).to_pandas()
|
|
for _, row in eq_df.iterrows():
|
|
d = row.get("date")
|
|
if isinstance(d, str):
|
|
d = dt.date.fromisoformat(d[:10])
|
|
equity_curve.append({
|
|
"date": d,
|
|
"equity": float(row.get("equity", initial_equity)),
|
|
})
|
|
|
|
# Trade blotter
|
|
bl_path = run_dir / "artifacts" / "trade_blotter.parquet"
|
|
if bl_path.exists():
|
|
bl_df = pq.read_table(str(bl_path)).to_pandas()
|
|
for _, row in bl_df.iterrows():
|
|
entry_px = row.get("entry_price")
|
|
exit_px = row.get("exit_price")
|
|
shares = int(row.get("shares", 0))
|
|
pnl_pct = float(row.get("pnl_pct", 0.0))
|
|
pnl_dollar = pnl_pct * float(entry_px or 0) * shares if entry_px else 0.0
|
|
|
|
trade = {
|
|
"symbol": str(row.get("symbol", "")),
|
|
"entry_date": str(row.get("entry_date", "-")),
|
|
"exit_date": str(row.get("exit_date", "-")),
|
|
"entry_price": float(entry_px) if entry_px is not None else None,
|
|
"exit_price": float(exit_px) if exit_px is not None else None,
|
|
"shares": shares,
|
|
"pnl": pnl_dollar,
|
|
"reason": str(row.get("exit_reason", "-")),
|
|
"event_type": str(row.get("event_type", "-")),
|
|
"score": float(row.get("score", 0.0)),
|
|
"engine_id": str(row.get("engine_id", "")),
|
|
}
|
|
# Skip same-day KILL_SWITCH — backtest period end artifact
|
|
if trade["entry_date"] == trade["exit_date"] and trade["reason"] == "KILL_SWITCH":
|
|
continue
|
|
trades.append(trade)
|
|
except Exception as exc:
|
|
logger.warning("backtest_sim_artifact_load_failed", error=str(exc))
|
|
|
|
# Compute summary stats
|
|
final_equity = equity_curve[-1]["equity"] if equity_curve else initial_equity
|
|
total_return_pct = (final_equity - initial_equity) / initial_equity * 100
|
|
|
|
pnls = [t["pnl"] for t in trades]
|
|
wins = [p for p in pnls if p > 0]
|
|
win_rate = len(wins) / len(pnls) * 100 if pnls else 0.0
|
|
|
|
equities = [r["equity"] for r in equity_curve]
|
|
daily_returns = [
|
|
(equities[i] - equities[i - 1]) / equities[i - 1]
|
|
for i in range(1, len(equities))
|
|
if equities[i - 1] > 0
|
|
]
|
|
if len(daily_returns) >= 2:
|
|
mean_r = statistics.mean(daily_returns)
|
|
std_r = statistics.stdev(daily_returns)
|
|
sharpe = (mean_r / std_r) * math.sqrt(252) if std_r > 0 else 0.0
|
|
else:
|
|
sharpe = 0.0
|
|
|
|
peak = initial_equity
|
|
max_dd_pct = 0.0
|
|
for eq in equities:
|
|
if eq > peak:
|
|
peak = eq
|
|
dd = (peak - eq) / peak * 100 if peak > 0 else 0.0
|
|
if dd > max_dd_pct:
|
|
max_dd_pct = dd
|
|
|
|
return {
|
|
"session_name": session_name,
|
|
"config_path": config_path,
|
|
"initial_equity": initial_equity,
|
|
"equity_curve": equity_curve,
|
|
"trades": trades,
|
|
"all_entries": [],
|
|
"all_exits": [],
|
|
"summary": {
|
|
"return_pct": total_return_pct,
|
|
"final_equity": final_equity,
|
|
"max_dd_pct": max_dd_pct,
|
|
"trade_count": len(trades),
|
|
"win_rate": win_rate,
|
|
"sharpe": sharpe,
|
|
},
|
|
}
|
|
|
|
|
|
def _snapshot_needs_refresh(
|
|
snapshot_id: str,
|
|
end_date: dt.date,
|
|
snapshot_dir: str | None = None,
|
|
) -> bool:
|
|
"""Refresh only when no existing snapshot covers the requested end date."""
|
|
return not _snapshot_has_required_coverage(
|
|
snapshot_id=snapshot_id,
|
|
end_date=end_date,
|
|
snapshot_dir=snapshot_dir,
|
|
)
|
|
|
|
|
|
def _snapshot_has_required_coverage(
|
|
snapshot_id: str,
|
|
end_date: dt.date,
|
|
snapshot_dir: str | None = None,
|
|
) -> bool:
|
|
"""Return True when an existing snapshot already covers the requested date."""
|
|
snapshot_path = _resolve_snapshot_path(snapshot_id, snapshot_dir=snapshot_dir)
|
|
if snapshot_path is None:
|
|
return False
|
|
|
|
train_path = snapshot_path / "train.parquet"
|
|
valid_path = snapshot_path / "valid.parquet"
|
|
test_path = snapshot_path / "test.parquet"
|
|
parquet_paths = [path for path in (test_path, valid_path, train_path) if path.exists()]
|
|
if not parquet_paths:
|
|
return False
|
|
|
|
try:
|
|
import pyarrow.parquet as pq
|
|
|
|
max_date: dt.date | None = None
|
|
for parquet_path in parquet_paths:
|
|
table = pq.read_table(str(parquet_path), columns=["event_date"])
|
|
dates = table.column("event_date").to_pylist()
|
|
if not dates:
|
|
continue
|
|
candidate = max(dates)
|
|
if isinstance(candidate, str):
|
|
candidate = dt.date.fromisoformat(candidate[:10])
|
|
if isinstance(candidate, dt.datetime):
|
|
candidate = candidate.date()
|
|
if isinstance(candidate, dt.date) and (max_date is None or candidate > max_date):
|
|
max_date = candidate
|
|
if max_date is None:
|
|
return False
|
|
return max_date >= end_date - dt.timedelta(days=14)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _resolve_snapshot_path(
|
|
snapshot_id: str,
|
|
snapshot_dir: str | None = None,
|
|
) -> Path | None:
|
|
"""Resolve the on-disk snapshot directory using the same fallback order as the runner."""
|
|
candidates: list[Path] = []
|
|
if snapshot_dir is not None:
|
|
candidates.append(Path(snapshot_dir) / snapshot_id)
|
|
else:
|
|
settings = get_settings()
|
|
candidates.append(Path(settings.parquet_dir) / snapshot_id)
|
|
candidates.append(Path("data/datasets/snapshots") / snapshot_id)
|
|
|
|
seen: set[Path] = set()
|
|
for candidate in candidates:
|
|
candidate = candidate.resolve()
|
|
if candidate in seen:
|
|
continue
|
|
seen.add(candidate)
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
async def _refresh_snapshot(
|
|
snapshot_id: str,
|
|
universe_profile: str | None,
|
|
console=None,
|
|
) -> None:
|
|
"""Re-run pipeline steps and re-export the snapshot."""
|
|
if console:
|
|
console.print("\n[bold yellow]Snapshot stale — refreshing pipeline...[/]")
|
|
|
|
# Step 1: Run pending pipeline steps
|
|
if console:
|
|
console.print(" [dim]1/4 Polling new filings...[/]")
|
|
try:
|
|
from apps.pipeline.filing_poller.main import poll_filings
|
|
from libs.common.ids import new_job_run_id
|
|
await poll_filings(new_job_run_id())
|
|
except Exception as exc:
|
|
if console:
|
|
console.print(f" [yellow]Filing poller skipped: {exc}[/]")
|
|
|
|
if console:
|
|
console.print(" [dim]2/4 Fetching exhibits...[/]")
|
|
try:
|
|
from apps.pipeline.filing_fetcher.main import fetch_exhibits
|
|
from libs.common.ids import new_job_run_id
|
|
await fetch_exhibits(new_job_run_id())
|
|
except Exception as exc:
|
|
if console:
|
|
console.print(f" [yellow]Fetcher skipped: {exc}[/]")
|
|
|
|
if console:
|
|
console.print(" [dim]3/4 Parsing events & building features...[/]")
|
|
try:
|
|
from apps.pipeline.event_parser.main import run_event_parser
|
|
from libs.common.ids import new_job_run_id
|
|
await run_event_parser(new_job_run_id())
|
|
except Exception as exc:
|
|
if console:
|
|
console.print(f" [yellow]Parser skipped: {exc}[/]")
|
|
|
|
try:
|
|
from apps.pipeline.feature_builder.main import run_feature_builder
|
|
from libs.common.ids import new_job_run_id
|
|
await run_feature_builder(new_job_run_id())
|
|
except Exception as exc:
|
|
if console:
|
|
console.print(f" [yellow]Feature builder skipped: {exc}[/]")
|
|
|
|
try:
|
|
from apps.pipeline.label_generator.main import run_label_generator
|
|
from libs.common.ids import new_job_run_id
|
|
await run_label_generator(new_job_run_id())
|
|
except Exception as exc:
|
|
if console:
|
|
console.print(f" [yellow]Label generator skipped: {exc}[/]")
|
|
|
|
# Step 2: Re-export snapshot
|
|
if console:
|
|
console.print(" [dim]4/4 Exporting snapshot...[/]")
|
|
try:
|
|
from libs.db.session import get_session
|
|
from libs.export.snapshot_export import export_dataset_snapshot
|
|
|
|
async with get_session() as session:
|
|
await export_dataset_snapshot(
|
|
session=session,
|
|
snapshot_id=snapshot_id,
|
|
split_policy="temporal_70_15_15",
|
|
output_dir="data/datasets/snapshots",
|
|
feature_versions=["market_v1", "event_v1"],
|
|
universe_profile=universe_profile,
|
|
)
|
|
if console:
|
|
console.print(" [green]Snapshot refreshed.[/]")
|
|
except Exception as exc:
|
|
if console:
|
|
console.print(f" [red]Snapshot export failed: {exc}[/]")
|
|
raise
|
|
|
|
|
|
# ── Overlay backtest support ──────────────────────────────────────────
|
|
|
|
|
|
def _is_overlay_config(config_path: str) -> bool:
|
|
"""Return True if config_path is an overlay spec (has 'books' key)."""
|
|
import json
|
|
try:
|
|
data = json.loads(Path(config_path).read_text())
|
|
return "books" in data and "allocations" in data
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _resolve_book_experiment_config(book: dict) -> str | None:
|
|
"""Resolve the experiment config path for an overlay book entry."""
|
|
# Explicit field
|
|
explicit = book.get("experiment_config")
|
|
if explicit and Path(explicit).exists():
|
|
return explicit
|
|
|
|
# Infer from equity_csv filename
|
|
csv_path = book.get("equity_csv", "")
|
|
if csv_path:
|
|
name = Path(csv_path).stem # e.g. "return_max_long_v6.221_equity"
|
|
# Strip common suffixes
|
|
for suffix in ("_equity", "_train", "_valid", "_test"):
|
|
if name.endswith(suffix):
|
|
name = name[: -len(suffix)]
|
|
break
|
|
candidate = f"configs/experiments/{name}.json"
|
|
if Path(candidate).exists():
|
|
return candidate
|
|
|
|
return None
|
|
|
|
|
|
def _overlay_books_are_runnable(overlay_config_path: str) -> bool:
|
|
"""Check if all books in an overlay config have resolvable experiment configs."""
|
|
import json
|
|
try:
|
|
spec = json.loads(Path(overlay_config_path).read_text())
|
|
for book in spec.get("books", []):
|
|
csv_path = book.get("equity_csv")
|
|
if csv_path and Path(csv_path).exists():
|
|
continue
|
|
if _resolve_book_experiment_config(book) is None:
|
|
return False
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _rebase_equity_slice(
|
|
df,
|
|
*,
|
|
initial_equity: float,
|
|
):
|
|
"""Recompute equity within a requested window so the first kept day starts flat."""
|
|
df = df.sort_values("date").copy()
|
|
df["daily_return"] = df["equity"].astype(float).pct_change().fillna(0.0)
|
|
equity = float(initial_equity)
|
|
rebased: list[float] = []
|
|
for ret in df["daily_return"].astype(float):
|
|
equity *= 1.0 + float(ret)
|
|
rebased.append(equity)
|
|
df["equity"] = rebased
|
|
return df[["date", "equity", "daily_return"]]
|
|
|
|
|
|
def _summarize_book_curve(df, *, initial_equity: float) -> dict[str, float]:
|
|
"""Return a paper-backtest-like summary from a rebased equity curve."""
|
|
returns = df["daily_return"].astype(float)
|
|
final_equity = float(df["equity"].iloc[-1])
|
|
return_pct = (final_equity / float(initial_equity) - 1.0) * 100.0
|
|
|
|
peak = float(initial_equity)
|
|
max_dd_pct = 0.0
|
|
for equity in df["equity"].astype(float):
|
|
peak = max(peak, float(equity))
|
|
drawdown_pct = (peak - float(equity)) / peak * 100.0 if peak > 0 else 0.0
|
|
max_dd_pct = max(max_dd_pct, drawdown_pct)
|
|
|
|
if len(returns) >= 2 and float(returns.std()) > 0:
|
|
sharpe = float(returns.mean() / returns.std() * math.sqrt(252.0))
|
|
else:
|
|
sharpe = 0.0
|
|
|
|
return {
|
|
"return_pct": return_pct,
|
|
"final_equity": final_equity,
|
|
"max_dd_pct": max_dd_pct,
|
|
"trade_count": 0,
|
|
"win_rate": 0.0,
|
|
"sharpe": sharpe,
|
|
}
|
|
|
|
|
|
def _load_overlay_book_curve_from_spec(
|
|
book: dict,
|
|
*,
|
|
capital: float,
|
|
start_date: dt.date,
|
|
end_date: dt.date,
|
|
):
|
|
"""Load a frozen overlay input curve from equity_csv and rebase it to the requested window."""
|
|
from libs.backtest.overlay import load_equity_curve_csv
|
|
|
|
csv_path = book.get("equity_csv")
|
|
if not csv_path or not Path(csv_path).exists():
|
|
return None
|
|
|
|
df = load_equity_curve_csv(csv_path)
|
|
df = df[(df["date"] >= start_date) & (df["date"] <= end_date)].copy()
|
|
if df.empty:
|
|
return None
|
|
|
|
rebased = _rebase_equity_slice(df, initial_equity=capital)
|
|
summary = _summarize_book_curve(rebased, initial_equity=capital)
|
|
return {
|
|
"curve": rebased,
|
|
"summary": summary,
|
|
"source": "equity_csv",
|
|
}
|
|
|
|
|
|
def run_overlay_backtest_sync(
|
|
overlay_config_path: str,
|
|
capital: float,
|
|
start_date: dt.date,
|
|
end_date: dt.date,
|
|
console=None,
|
|
) -> dict[str, Any]:
|
|
"""Run an overlay backtest: execute each book strategy, then combine by regime."""
|
|
import json
|
|
import pandas as pd
|
|
from libs.backtest.overlay import build_overlay_curve, summarize_overlay_curve
|
|
|
|
spec = json.loads(Path(overlay_config_path).read_text())
|
|
overlay_name = spec.get("overlay_name", Path(overlay_config_path).stem)
|
|
allocations = spec["allocations"]
|
|
|
|
# ── Run each book strategy ────────────────────────────────────────
|
|
book_results: list[dict[str, Any]] = []
|
|
curves: dict[str, pd.DataFrame] = {}
|
|
|
|
replay_mode = "frozen_equity_csv"
|
|
|
|
for book in spec["books"]:
|
|
label = book["label"]
|
|
loaded = _load_overlay_book_curve_from_spec(
|
|
book,
|
|
capital=capital,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
if loaded is not None:
|
|
if console:
|
|
source_name = Path(book["equity_csv"]).stem
|
|
console.print(f" [dim]Book '{label}':[/] {source_name} [dim](frozen equity_csv)[/]")
|
|
curves[label] = loaded["curve"]
|
|
book_results.append(
|
|
{
|
|
"label": label,
|
|
"result": {
|
|
"session_name": f"{overlay_name}__{label}",
|
|
"summary": loaded["summary"],
|
|
"equity_curve": [
|
|
{"date": row.date, "equity": row.equity}
|
|
for row in loaded["curve"].itertuples(index=False)
|
|
],
|
|
"trades": [],
|
|
},
|
|
"source": loaded["source"],
|
|
}
|
|
)
|
|
continue
|
|
|
|
replay_mode = "rerun_books"
|
|
exp_config = _resolve_book_experiment_config(book)
|
|
if exp_config is None:
|
|
raise ValueError(
|
|
f"Overlay '{overlay_name}': book '{label}' has neither a usable equity_csv nor a resolvable experiment config. "
|
|
f"Add 'equity_csv' or 'experiment_config' to the book entry."
|
|
)
|
|
|
|
if console:
|
|
console.print(f" [dim]Book '{label}':[/] {Path(exp_config).stem} [dim](rerun)[/]")
|
|
|
|
result = run_backtest_session_sync(
|
|
session_name=f"{overlay_name}__{label}",
|
|
config_path=exp_config,
|
|
initial_equity=capital,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
book_results.append({"label": label, "result": result, "source": "rerun"})
|
|
|
|
eq = result.get("equity_curve", [])
|
|
if eq:
|
|
df = pd.DataFrame(eq)
|
|
df["date"] = pd.to_datetime(df["date"]).dt.date
|
|
df["equity"] = df["equity"].astype(float)
|
|
curves[label] = _rebase_equity_slice(df[["date", "equity"]], initial_equity=capital)
|
|
|
|
if not curves:
|
|
raise ValueError(f"Overlay '{overlay_name}': no book produced equity curves")
|
|
|
|
# ── Compute regime for each trading day ───────────────────────────
|
|
regimes = _compute_overlay_regimes(spec, start_date, end_date)
|
|
|
|
# ── Combine using overlay logic ───────────────────────────────────
|
|
overlay_curve = build_overlay_curve(
|
|
curves=curves,
|
|
allocations=allocations,
|
|
regimes_by_date=regimes,
|
|
initial_equity=capital,
|
|
)
|
|
|
|
summary = summarize_overlay_curve(overlay_curve, initial_equity=capital)
|
|
|
|
# Convert overlay equity curve to standard format
|
|
equity_curve = [
|
|
{"date": row.date, "equity": row.overlay_equity}
|
|
for row in overlay_curve.itertuples(index=False)
|
|
]
|
|
|
|
# Aggregate trade count across books
|
|
total_trades = sum(
|
|
br["result"]["summary"]["trade_count"] for br in book_results
|
|
)
|
|
|
|
return {
|
|
"session_name": overlay_name,
|
|
"config_path": overlay_config_path,
|
|
"initial_equity": capital,
|
|
"is_overlay": True,
|
|
"overlay_replay_mode": replay_mode,
|
|
"equity_curve": equity_curve,
|
|
"trades": [],
|
|
"book_results": book_results,
|
|
"allocations": allocations,
|
|
"regime_day_counts": summary.get("regime_day_counts", {}),
|
|
"summary": {
|
|
"return_pct": summary["return_pct"],
|
|
"final_equity": summary["final_equity"],
|
|
"max_dd_pct": summary["max_dd_pct"],
|
|
"trade_count": total_trades,
|
|
"win_rate": 0.0,
|
|
"sharpe": summary["sharpe"],
|
|
},
|
|
}
|
|
|
|
|
|
def _compute_overlay_regimes(
|
|
spec: dict,
|
|
start_date: dt.date,
|
|
end_date: dt.date,
|
|
) -> dict[dt.date, str]:
|
|
"""Compute macro regime for each trading day using the regime_source config.
|
|
|
|
Uses _build_merged_snapshot_store to get a full-period store with macro data,
|
|
covering the paper backtest date range (not just the original snapshot period).
|
|
"""
|
|
from apps.backtester.run import _build_merged_snapshot_store, load_manifest, resolve_config
|
|
from libs.backtest.allocator import _macro_regime_state
|
|
from libs.backtest.overlay import load_merged_store_from_snapshot_dir
|
|
from libs.common.config import get_settings
|
|
|
|
regime_source = spec.get("regime_source", {})
|
|
config_path = regime_source.get("config_path")
|
|
if not config_path:
|
|
return {}
|
|
|
|
manifest = load_manifest(config_path)
|
|
config = resolve_config(manifest)
|
|
|
|
raw_snapshot_dir = regime_source.get("snapshot_dir")
|
|
if raw_snapshot_dir and (
|
|
(Path(raw_snapshot_dir) / "train.parquet").exists()
|
|
or (Path(raw_snapshot_dir) / "test.parquet").exists()
|
|
):
|
|
settings = get_settings()
|
|
store = load_merged_store_from_snapshot_dir(
|
|
raw_snapshot_dir,
|
|
oracle_url=settings.stock_oracle_url,
|
|
db_dsn=settings.postgres_dsn,
|
|
)
|
|
else:
|
|
try:
|
|
store = _build_merged_snapshot_store(
|
|
manifest,
|
|
config,
|
|
snapshot_dir_override=raw_snapshot_dir,
|
|
)
|
|
except FileNotFoundError:
|
|
store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=None)
|
|
store = store.slice_by_date_range(start_date, end_date)
|
|
|
|
regimes: dict[dt.date, str] = {}
|
|
for date in store.all_trading_days():
|
|
regimes[date] = _macro_regime_state(config, store.get_macro_for_date(date))
|
|
return regimes
|
|
|
|
|
|
def run_backtest(
|
|
configs: list[str],
|
|
capital: float,
|
|
start_date: dt.date,
|
|
end_date: dt.date,
|
|
db_dsn: str,
|
|
oracle_url: str,
|
|
console=None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Run multiple strategies sequentially using BacktestRunner.
|
|
|
|
Automatically refreshes the Parquet snapshot if it doesn't cover
|
|
the requested end_date (runs pipeline + re-export).
|
|
|
|
This is a SYNC function — runs async pipeline steps via asyncio.run()
|
|
before the sync BacktestRunner, avoiding nested event loop issues.
|
|
"""
|
|
import asyncio
|
|
from libs.common.time_utils import is_trading_day
|
|
from libs.common.logging import configure_logging
|
|
|
|
all_days = [
|
|
start_date + dt.timedelta(days=i)
|
|
for i in range((end_date - start_date).days + 1)
|
|
]
|
|
trading_days = [d for d in all_days if is_trading_day(d)]
|
|
|
|
if not trading_days:
|
|
raise ValueError(f"No trading days found between {start_date} and {end_date}")
|
|
|
|
if console:
|
|
console.print(f"[bold]Trading days:[/] {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)")
|
|
console.print("[bold]Engine:[/] BacktestRunner (identical to research backtester)")
|
|
|
|
# Check if snapshots need refresh (async pipeline, run before sync backtest)
|
|
for config_path in configs:
|
|
if _is_overlay_config(config_path):
|
|
continue # overlay books handle their own snapshots
|
|
from apps.backtester.run import load_manifest, resolve_config
|
|
manifest = load_manifest(config_path)
|
|
config = resolve_config(manifest)
|
|
snapshot_id = config.dataset_snapshot_id
|
|
|
|
if _snapshot_needs_refresh(snapshot_id, end_date):
|
|
universe_profile = None
|
|
if "midlarge" in snapshot_id:
|
|
universe_profile = "midlarge-liquid-long-v1"
|
|
elif "midwide" in snapshot_id:
|
|
universe_profile = "midwide-liquid-long-v1"
|
|
elif "smallcap" in snapshot_id:
|
|
universe_profile = "smallcap-liquid-long-v1"
|
|
|
|
if console:
|
|
console.print(f"\n[bold yellow]Snapshot '{snapshot_id}' is stale — refreshing...[/]")
|
|
try:
|
|
asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, console=console))
|
|
except Exception:
|
|
if _snapshot_has_required_coverage(snapshot_id, end_date):
|
|
if console:
|
|
console.print(" [yellow]Refresh failed, but existing snapshot still covers the requested period. Using current snapshot.[/]")
|
|
else:
|
|
raise
|
|
|
|
configure_logging("WARNING")
|
|
|
|
results = []
|
|
for config_path in configs:
|
|
session_name = Path(config_path).stem
|
|
|
|
if _is_overlay_config(config_path):
|
|
if console:
|
|
console.print(f"\n[bold magenta]Running overlay:[/] {session_name}")
|
|
result = run_overlay_backtest_sync(
|
|
overlay_config_path=config_path,
|
|
capital=capital,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
console=console,
|
|
)
|
|
else:
|
|
if console:
|
|
console.print(f"\n[bold cyan]Running:[/] {session_name}")
|
|
result = run_backtest_session_sync(
|
|
session_name=session_name,
|
|
config_path=config_path,
|
|
initial_equity=capital,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
results.append(result)
|
|
|
|
if console and result["summary"]["trade_count"] > 0:
|
|
s = result["summary"]
|
|
console.print(
|
|
f" Trades: {s['trade_count']}, "
|
|
f"Return: {s['return_pct']:+.2f}%, "
|
|
f"MaxDD: {s['max_dd_pct']:.2f}%, "
|
|
f"WR: {s['win_rate']:.0f}%"
|
|
)
|
|
|
|
return results
|