Remove overlay backtesting and scoring
parent
1d9507540b
commit
76581ead04
@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from apps.backtester.run import _build_merged_snapshot_store
|
||||
from libs.backtest.allocator import _macro_regime_state
|
||||
from libs.backtest.manifests import load_manifest, resolve_config
|
||||
from libs.backtest.overlay import (
|
||||
build_overlay_curve,
|
||||
load_equity_curve_csv,
|
||||
load_merged_store_from_snapshot_dir,
|
||||
summarize_overlay_curve,
|
||||
)
|
||||
from libs.common.config import get_settings
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> dt.date | None:
|
||||
if not value:
|
||||
return None
|
||||
return dt.date.fromisoformat(value)
|
||||
|
||||
|
||||
def _compute_regimes(
|
||||
*,
|
||||
snapshot_dir: str | Path,
|
||||
split: str,
|
||||
config_path: str | Path,
|
||||
start_date: dt.date | None,
|
||||
end_date: dt.date | None,
|
||||
) -> dict[dt.date, str]:
|
||||
del split # overlay regimes should cover the full requested window, not a single split
|
||||
manifest = load_manifest(config_path)
|
||||
config = resolve_config(manifest, config_root=".")
|
||||
|
||||
raw_snapshot_dir = Path(snapshot_dir)
|
||||
if (raw_snapshot_dir / "train.parquet").exists() or (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=str(raw_snapshot_dir),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
store = _build_merged_snapshot_store(
|
||||
manifest,
|
||||
config,
|
||||
snapshot_dir_override=None,
|
||||
)
|
||||
|
||||
if start_date or end_date:
|
||||
lower = start_date or dt.date.min
|
||||
upper = end_date or dt.date.max
|
||||
store = store.slice_by_date_range(lower, upper)
|
||||
|
||||
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 _load_spec(path: str | Path) -> dict[str, Any]:
|
||||
return json.loads(Path(path).read_text())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Evaluate a regime-switched overlay from book equity curves")
|
||||
parser.add_argument("--spec", required=True, help="Path to overlay spec JSON")
|
||||
parser.add_argument("--output-dir", required=True, help="Directory to write overlay outputs")
|
||||
args = parser.parse_args()
|
||||
|
||||
spec = _load_spec(args.spec)
|
||||
initial_equity = float(spec.get("initial_equity", 10_000.0))
|
||||
|
||||
curves = {
|
||||
book["label"]: load_equity_curve_csv(book["equity_csv"])
|
||||
for book in spec["books"]
|
||||
}
|
||||
regime_source = spec["regime_source"]
|
||||
start_date = _parse_date(spec.get("start_date"))
|
||||
end_date = _parse_date(spec.get("end_date"))
|
||||
regimes = _compute_regimes(
|
||||
snapshot_dir=regime_source["snapshot_dir"],
|
||||
split=regime_source.get("split", "train"),
|
||||
config_path=regime_source["config_path"],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
curve = build_overlay_curve(
|
||||
curves=curves,
|
||||
allocations=spec["allocations"],
|
||||
regimes_by_date=regimes,
|
||||
initial_equity=initial_equity,
|
||||
)
|
||||
if start_date:
|
||||
curve = curve[curve["date"] >= start_date]
|
||||
if end_date:
|
||||
curve = curve[curve["date"] <= end_date]
|
||||
|
||||
summary = summarize_overlay_curve(curve, initial_equity=initial_equity)
|
||||
summary["overlay_name"] = spec.get("overlay_name", Path(args.spec).stem)
|
||||
summary["books"] = [book["label"] for book in spec["books"]]
|
||||
summary["allocations"] = spec["allocations"]
|
||||
|
||||
out_dir = Path(args.output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
curve.assign(date=curve["date"].astype(str)).to_csv(out_dir / "overlay_equity.csv", index=False)
|
||||
(out_dir / "overlay_summary.json").write_text(json.dumps(summary, indent=2) + "\n")
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v1",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v1b",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v1c",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 0.5,
|
||||
"mom": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.114.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/fallback_book_compare/return_max_long_v6.114_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv"
|
||||
}
|
||||
],
|
||||
"overlay_name": "return_book_overlay_v2",
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0,
|
||||
"mom": 1
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0,
|
||||
"mom": 1
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1,
|
||||
"mom": 0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 1,
|
||||
"mom": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.114.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/fallback_book_compare/return_max_long_v6.114_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv"
|
||||
}
|
||||
],
|
||||
"overlay_name": "return_book_overlay_v2b",
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0,
|
||||
"mom": 1
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0,
|
||||
"mom": 1
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1,
|
||||
"mom": 0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 0,
|
||||
"mom": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v3",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv",
|
||||
"experiment_config": "configs/experiments/return_max_long_v6.221.json"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv",
|
||||
"experiment_config": "configs/experiments/return_max_long_v6new.54.json"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {"core": 0.5, "mom": 0.5},
|
||||
"neutral": {"core": 0.0, "mom": 1.0},
|
||||
"risk_off": {"core": 1.0, "mom": 0.0},
|
||||
"unknown": {"core": 1.0, "mom": 0.0}
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v3_oot",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2020-01-02",
|
||||
"end_date": "2021-12-31",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/datasets/snapshots/midlarge-liquid-long-v1-oot-2020-2021",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_oot_inputs/csv/core_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_oot_inputs/csv/mom_equity.csv"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0.5,
|
||||
"mom": 0.5
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0.0,
|
||||
"mom": 1.0
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v3b",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2022-03-03",
|
||||
"end_date": "2026-03-13",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/parquet/midlarge-liquid-long-v1_bucketfix_full_audit_mom_v2",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6.221_equity.csv",
|
||||
"experiment_config": "configs/experiments/return_max_long_v6.221.json"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_v1_inputs/return_max_long_v6new.54_equity.csv",
|
||||
"experiment_config": "configs/experiments/return_max_long_v6new.54.json"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {"core": 0.5, "mom": 0.5},
|
||||
"neutral": {"core": 0.5, "mom": 0.5},
|
||||
"risk_off": {"core": 1.0, "mom": 0.0},
|
||||
"unknown": {"core": 1.0, "mom": 0.0}
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"overlay_name": "return_book_overlay_v3b_oot",
|
||||
"initial_equity": 10000,
|
||||
"start_date": "2020-01-02",
|
||||
"end_date": "2021-12-31",
|
||||
"regime_source": {
|
||||
"config_path": "configs/experiments/return_max_long_v6.221.json",
|
||||
"snapshot_dir": "data/datasets/snapshots/midlarge-liquid-long-v1-oot-2020-2021",
|
||||
"split": "train"
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"label": "core",
|
||||
"equity_csv": "runs/book_overlay_oot_inputs/csv/core_equity.csv"
|
||||
},
|
||||
{
|
||||
"label": "mom",
|
||||
"equity_csv": "runs/book_overlay_oot_inputs/csv/mom_equity.csv"
|
||||
}
|
||||
],
|
||||
"allocations": {
|
||||
"risk_on": {
|
||||
"core": 0.5,
|
||||
"mom": 0.5
|
||||
},
|
||||
"neutral": {
|
||||
"core": 0.5,
|
||||
"mom": 0.5
|
||||
},
|
||||
"risk_off": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
},
|
||||
"unknown": {
|
||||
"core": 1.0,
|
||||
"mom": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
# Overlay Strategy Leaderboard
|
||||
_Updated: 2026-03-27T03:43:33.985346+00:00_
|
||||
|
||||
_This board is separate from the default single-book leaderboard. Overlay rows are book-of-books evaluations and are not directly comparable to single-book `SQS` rows._
|
||||
|
||||
_`SQS` here is overlay official SQS: common-window overlay score with a stress OOT gate. `T.*` columns are common-window overlay metrics._
|
||||
|
||||
| # | Overlay | SQS | [T]Ret% | [T]Ann% | [T]DD% | Stress Ret% | Stress DD% | Stress Sharpe | Date |
|
||||
|---|---------|-----|----------|----------|--------|-------------|------------|---------------|------|
|
||||
| 1 | return_book_overlay_v4 | 91.4 | +185.3 | +29.7 | 5.0 | +7.4 | 5.8 | +0.80 | 2026-03-27 |
|
||||
| 2 | return_book_overlay_v4b | 91.4 | +187.2 | +30.0 | 5.0 | +7.4 | 5.8 | +0.80 | 2026-03-27 |
|
||||
| 3 | return_book_overlay_v3 | 90.2 | +175.6 | +28.6 | 4.5 | +6.0 | 5.4 | +0.67 | 2026-03-26 |
|
||||
| 4 | return_book_overlay_v3b | 87.7 | +169.5 | +27.9 | 5.0 | +7.4 | 5.8 | +0.80 | 2026-03-26 |
|
||||
| 5 | return_book_overlay_v4c | 60.7 | +232.5 | +34.8 | 5.2 | +3.1 | 3.3 | +0.43 | 2026-03-27 |
|
||||
|
||||
## Recent Overlay Entries
|
||||
### IMP-0794 (2026-03-27) — return_book_overlay_v4b
|
||||
Hypothesis: Core v6.221 plus higher-ranked v6new.207 momentum book with balanced 50/50 neutral allocation.
|
||||
Verdict: **UNKNOWN** (SQS 91.4)
|
||||
|
||||
### IMP-0793 (2026-03-27) — return_book_overlay_v4c
|
||||
Hypothesis: Core v6.221 plus top-return v6new.275 momentum book with aggressive 75/25 risk-on and full-neutral momentum allocation.
|
||||
Verdict: **UNKNOWN** (SQS 60.7)
|
||||
|
||||
### IMP-0792 (2026-03-27) — return_book_overlay_v4
|
||||
Hypothesis: Core v6.221 plus higher-ranked v6new.196 momentum book with balanced 50/50 neutral allocation.
|
||||
Verdict: **UNKNOWN** (SQS 91.4)
|
||||
|
||||
### IMP-0738 (2026-03-26) — return_book_overlay_v3b
|
||||
Hypothesis: Balanced overlay variant with 50/50 neutral allocation between v6.221 and v6new.54.
|
||||
Verdict: **UNKNOWN** (SQS 87.7)
|
||||
|
||||
### IMP-0737 (2026-03-26) — return_book_overlay_v3
|
||||
Hypothesis: Book-of-books overlay using v6.221 as stress fallback and v6new.54 as normal-regime book.
|
||||
Verdict: **UNKNOWN** (SQS 90.2)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,193 +0,0 @@
|
||||
"""Evaluate deterministic book overlays from daily equity curves."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def load_merged_store_from_snapshot_dir(
|
||||
snapshot_dir: str | Path,
|
||||
*,
|
||||
oracle_url: str,
|
||||
db_dsn: str,
|
||||
):
|
||||
"""Load and merge train/valid/test splits from an explicit snapshot directory."""
|
||||
from libs.backtest.snapshot_store import SnapshotStore
|
||||
|
||||
base = Path(snapshot_dir)
|
||||
stores = []
|
||||
for split in ("train", "valid", "test"):
|
||||
if not (base / f"{split}.parquet").exists():
|
||||
continue
|
||||
stores.append(
|
||||
SnapshotStore.load(
|
||||
snapshot_dir=base,
|
||||
split_name=split,
|
||||
oracle_url=oracle_url,
|
||||
db_dsn=db_dsn,
|
||||
)
|
||||
)
|
||||
|
||||
if not stores:
|
||||
raise FileNotFoundError(f"No snapshot splits found under {base}")
|
||||
|
||||
merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = {}
|
||||
merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
|
||||
merged_macro: dict[dt.date, dict[str, Any]] = {}
|
||||
|
||||
for store in stores:
|
||||
for exec_date in store.all_execution_dates():
|
||||
bucket = merged_candidates.setdefault(exec_date, {})
|
||||
for candidate in store.get_candidates_for_date(exec_date):
|
||||
dedupe_key = (
|
||||
candidate.get("event_id"),
|
||||
candidate.get("symbol"),
|
||||
candidate.get("execution_date"),
|
||||
candidate.get("reaction_date"),
|
||||
)
|
||||
bucket.setdefault(dedupe_key, candidate)
|
||||
for symbol, bars in store._bars.items():
|
||||
merged_bars.setdefault(symbol, {}).update(bars)
|
||||
for macro_date, macro_values in store._macro.items():
|
||||
merged_macro.setdefault(macro_date, {}).update(macro_values)
|
||||
|
||||
from libs.backtest.snapshot_store import SnapshotStore
|
||||
|
||||
return SnapshotStore(
|
||||
candidates_by_exec_date={
|
||||
date: list(rows.values())
|
||||
for date, rows in merged_candidates.items()
|
||||
},
|
||||
bars_by_symbol_date=merged_bars,
|
||||
macro_by_date=merged_macro,
|
||||
)
|
||||
|
||||
|
||||
def load_equity_curve_csv(path: str | Path) -> pd.DataFrame:
|
||||
"""Load a paper backtest equity CSV into a normalized daily returns frame."""
|
||||
csv_path = Path(path)
|
||||
df = pd.read_csv(csv_path, parse_dates=["date"])
|
||||
required = {"date", "equity"}
|
||||
missing = required.difference(df.columns)
|
||||
if missing:
|
||||
raise ValueError(f"Missing columns in {csv_path}: {sorted(missing)}")
|
||||
if df.empty:
|
||||
raise ValueError(f"Equity CSV has no rows: {csv_path}")
|
||||
|
||||
df = df.sort_values("date").copy()
|
||||
df["date"] = pd.to_datetime(df["date"]).dt.date
|
||||
df["equity"] = df["equity"].astype(float)
|
||||
df["daily_return"] = df["equity"].pct_change().fillna(0.0)
|
||||
return df[["date", "equity", "daily_return"]]
|
||||
|
||||
|
||||
def validate_allocations(
|
||||
allocations: dict[str, dict[str, float]],
|
||||
labels: set[str],
|
||||
*,
|
||||
tolerance: float = 1e-6,
|
||||
) -> None:
|
||||
"""Ensure each regime allocation references known labels and sums to 1."""
|
||||
if not allocations:
|
||||
raise ValueError("allocations must not be empty")
|
||||
if "unknown" not in allocations:
|
||||
raise ValueError("allocations must include an 'unknown' regime")
|
||||
|
||||
for regime, weights in allocations.items():
|
||||
unknown = set(weights).difference(labels)
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"Allocation for regime '{regime}' references unknown labels: {sorted(unknown)}"
|
||||
)
|
||||
total = sum(float(weight) for weight in weights.values())
|
||||
if abs(total - 1.0) > tolerance:
|
||||
raise ValueError(
|
||||
f"Allocation for regime '{regime}' must sum to 1.0, got {total:.6f}"
|
||||
)
|
||||
|
||||
|
||||
def build_overlay_curve(
|
||||
*,
|
||||
curves: dict[str, pd.DataFrame],
|
||||
allocations: dict[str, dict[str, float]],
|
||||
regimes_by_date: dict[dt.date, str],
|
||||
initial_equity: float = 10_000.0,
|
||||
) -> pd.DataFrame:
|
||||
"""Combine per-book daily returns into a single overlay equity curve."""
|
||||
labels = set(curves)
|
||||
if not labels:
|
||||
raise ValueError("curves must not be empty")
|
||||
validate_allocations(allocations, labels)
|
||||
|
||||
merged: pd.DataFrame | None = None
|
||||
for label, df in curves.items():
|
||||
renamed = df.rename(
|
||||
columns={
|
||||
"equity": f"equity_{label}",
|
||||
"daily_return": f"daily_return_{label}",
|
||||
}
|
||||
)
|
||||
frame = renamed[["date", f"daily_return_{label}"]]
|
||||
merged = frame if merged is None else merged.merge(frame, on="date", how="inner")
|
||||
|
||||
if merged is None or merged.empty:
|
||||
raise ValueError("No overlapping dates across curves")
|
||||
|
||||
merged = merged.sort_values("date").copy()
|
||||
merged["regime"] = merged["date"].map(regimes_by_date).fillna("unknown")
|
||||
|
||||
overlay_returns: list[float] = []
|
||||
for row in merged.itertuples(index=False):
|
||||
weights = allocations.get(row.regime, allocations["unknown"])
|
||||
ret = 0.0
|
||||
for label in labels:
|
||||
ret += float(weights.get(label, 0.0)) * float(getattr(row, f"daily_return_{label}"))
|
||||
overlay_returns.append(ret)
|
||||
|
||||
merged["overlay_return"] = overlay_returns
|
||||
equity = initial_equity
|
||||
overlay_equity: list[float] = []
|
||||
for ret in overlay_returns:
|
||||
equity *= 1.0 + float(ret)
|
||||
overlay_equity.append(equity)
|
||||
merged["overlay_equity"] = overlay_equity
|
||||
return merged[["date", "regime", "overlay_return", "overlay_equity"]]
|
||||
|
||||
|
||||
def summarize_overlay_curve(curve: pd.DataFrame, *, initial_equity: float) -> dict[str, Any]:
|
||||
"""Return total return, drawdown, Sharpe, and regime counts for an overlay."""
|
||||
if curve.empty:
|
||||
raise ValueError("curve must not be empty")
|
||||
|
||||
final_equity = float(curve["overlay_equity"].iloc[-1])
|
||||
total_return_pct = (final_equity / float(initial_equity) - 1.0) * 100.0
|
||||
|
||||
peak = float(initial_equity)
|
||||
max_drawdown_pct = 0.0
|
||||
for equity in curve["overlay_equity"]:
|
||||
peak = max(peak, float(equity))
|
||||
drawdown_pct = (peak - float(equity)) / peak * 100.0 if peak > 0 else 0.0
|
||||
max_drawdown_pct = max(max_drawdown_pct, drawdown_pct)
|
||||
|
||||
rets = curve["overlay_return"].astype(float)
|
||||
if len(rets) >= 2 and float(rets.std()) > 0:
|
||||
sharpe = float(rets.mean() / rets.std() * math.sqrt(252.0))
|
||||
else:
|
||||
sharpe = 0.0
|
||||
|
||||
regime_counts = {
|
||||
str(regime): int(count)
|
||||
for regime, count in curve["regime"].value_counts().sort_index().items()
|
||||
}
|
||||
return {
|
||||
"return_pct": total_return_pct,
|
||||
"max_dd_pct": max_drawdown_pct,
|
||||
"sharpe": sharpe,
|
||||
"final_equity": final_equity,
|
||||
"day_count": int(len(curve)),
|
||||
"regime_day_counts": regime_counts,
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from libs.backtest.overlay import build_overlay_curve, summarize_overlay_curve, validate_allocations
|
||||
|
||||
|
||||
def test_validate_allocations_requires_unknown_and_unit_sum() -> None:
|
||||
try:
|
||||
validate_allocations({"risk_on": {"core": 1.0}}, {"core"})
|
||||
except ValueError as exc:
|
||||
assert "unknown" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
try:
|
||||
validate_allocations({"unknown": {"core": 0.8}}, {"core"})
|
||||
except ValueError as exc:
|
||||
assert "sum to 1.0" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
def test_build_overlay_curve_switches_by_regime() -> None:
|
||||
dates = [dt.date(2024, 1, 2), dt.date(2024, 1, 3), dt.date(2024, 1, 4)]
|
||||
core = pd.DataFrame(
|
||||
{
|
||||
"date": dates,
|
||||
"equity": [100.0, 110.0, 104.5],
|
||||
"daily_return": [0.0, 0.10, -0.05],
|
||||
}
|
||||
)
|
||||
mom = pd.DataFrame(
|
||||
{
|
||||
"date": dates,
|
||||
"equity": [100.0, 105.0, 110.25],
|
||||
"daily_return": [0.0, 0.05, 0.05],
|
||||
}
|
||||
)
|
||||
regimes = {
|
||||
dates[0]: "risk_on",
|
||||
dates[1]: "risk_off",
|
||||
dates[2]: "risk_on",
|
||||
}
|
||||
allocations = {
|
||||
"risk_on": {"core": 0.0, "mom": 1.0},
|
||||
"risk_off": {"core": 1.0, "mom": 0.0},
|
||||
"unknown": {"core": 1.0, "mom": 0.0},
|
||||
}
|
||||
|
||||
curve = build_overlay_curve(
|
||||
curves={"core": core, "mom": mom},
|
||||
allocations=allocations,
|
||||
regimes_by_date=regimes,
|
||||
initial_equity=100.0,
|
||||
)
|
||||
|
||||
assert curve["overlay_return"].round(6).tolist() == [0.0, 0.10, 0.05]
|
||||
assert round(float(curve["overlay_equity"].iloc[-1]), 4) == 115.5
|
||||
|
||||
|
||||
def test_summarize_overlay_curve_reports_return_drawdown_and_regimes() -> None:
|
||||
curve = pd.DataFrame(
|
||||
{
|
||||
"date": [dt.date(2024, 1, 2), dt.date(2024, 1, 3), dt.date(2024, 1, 4)],
|
||||
"regime": ["risk_on", "risk_off", "risk_on"],
|
||||
"overlay_return": [0.0, -0.10, 0.05],
|
||||
"overlay_equity": [100.0, 90.0, 94.5],
|
||||
}
|
||||
)
|
||||
summary = summarize_overlay_curve(curve, initial_equity=100.0)
|
||||
assert round(summary["return_pct"], 4) == -5.5
|
||||
assert round(summary["max_dd_pct"], 4) == 10.0
|
||||
assert summary["regime_day_counts"] == {"risk_off": 1, "risk_on": 2}
|
||||
@ -1,75 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from apps.paper_trader.backtest_sim import run_overlay_backtest_sync
|
||||
|
||||
|
||||
def _write_equity_csv(path: Path, rows: list[tuple[str, float]]) -> None:
|
||||
df = pd.DataFrame(rows, columns=["date", "equity"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_csv(path, index=False)
|
||||
|
||||
|
||||
def test_run_overlay_backtest_sync_replays_frozen_equity_csv(tmp_path: Path) -> None:
|
||||
core_csv = tmp_path / "core.csv"
|
||||
mom_csv = tmp_path / "mom.csv"
|
||||
_write_equity_csv(core_csv, [("2025-01-02", 100.0), ("2025-01-03", 110.0), ("2025-01-06", 121.0)])
|
||||
_write_equity_csv(mom_csv, [("2025-01-02", 100.0), ("2025-01-03", 90.0), ("2025-01-06", 81.0)])
|
||||
|
||||
spec = {
|
||||
"overlay_name": "overlay_demo",
|
||||
"books": [
|
||||
{"label": "core", "equity_csv": str(core_csv)},
|
||||
{"label": "mom", "equity_csv": str(mom_csv)},
|
||||
],
|
||||
"allocations": {
|
||||
"unknown": {"core": 0.5, "mom": 0.5},
|
||||
},
|
||||
}
|
||||
spec_path = tmp_path / "overlay.json"
|
||||
spec_path.write_text(json.dumps(spec))
|
||||
|
||||
result = run_overlay_backtest_sync(
|
||||
overlay_config_path=str(spec_path),
|
||||
capital=100.0,
|
||||
start_date=dt.date(2025, 1, 2),
|
||||
end_date=dt.date(2025, 1, 6),
|
||||
)
|
||||
|
||||
assert result["is_overlay"] is True
|
||||
assert result["overlay_replay_mode"] == "frozen_equity_csv"
|
||||
assert round(result["summary"]["return_pct"], 4) == 0.0
|
||||
assert result["summary"]["trade_count"] == 0
|
||||
|
||||
|
||||
def test_run_overlay_backtest_sync_rebases_window_from_frozen_curves(tmp_path: Path) -> None:
|
||||
core_csv = tmp_path / "core.csv"
|
||||
_write_equity_csv(core_csv, [("2025-01-02", 100.0), ("2025-01-03", 200.0), ("2025-01-06", 400.0)])
|
||||
|
||||
spec = {
|
||||
"overlay_name": "overlay_demo",
|
||||
"books": [
|
||||
{"label": "core", "equity_csv": str(core_csv)},
|
||||
],
|
||||
"allocations": {
|
||||
"unknown": {"core": 1.0},
|
||||
},
|
||||
}
|
||||
spec_path = tmp_path / "overlay.json"
|
||||
spec_path.write_text(json.dumps(spec))
|
||||
|
||||
result = run_overlay_backtest_sync(
|
||||
overlay_config_path=str(spec_path),
|
||||
capital=100.0,
|
||||
start_date=dt.date(2025, 1, 3),
|
||||
end_date=dt.date(2025, 1, 6),
|
||||
)
|
||||
|
||||
assert result["overlay_replay_mode"] == "frozen_equity_csv"
|
||||
assert round(result["summary"]["return_pct"], 4) == 100.0
|
||||
assert round(result["summary"]["final_equity"], 4) == 200.0
|
||||
@ -1,69 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from apps.tools import evaluate_book_overlay as mod
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def __init__(self) -> None:
|
||||
self._dates = [dt.date(2025, 1, 2), dt.date(2025, 1, 3)]
|
||||
|
||||
def slice_by_date_range(self, start: dt.date, end: dt.date):
|
||||
self._dates = [d for d in self._dates if start <= d <= end]
|
||||
return self
|
||||
|
||||
def all_trading_days(self):
|
||||
return list(self._dates)
|
||||
|
||||
def get_macro_for_date(self, date: dt.date):
|
||||
return {"state": f"regime-{date.isoformat()}"}
|
||||
|
||||
|
||||
def test_compute_regimes_uses_merged_store(monkeypatch) -> None:
|
||||
fake_store = _FakeStore()
|
||||
|
||||
monkeypatch.setattr(mod, "load_manifest", lambda path: object())
|
||||
monkeypatch.setattr(mod, "resolve_config", lambda manifest, config_root=".": object())
|
||||
monkeypatch.setattr(mod, "_build_merged_snapshot_store", lambda manifest, config, snapshot_dir_override=None: fake_store)
|
||||
monkeypatch.setattr(mod, "_macro_regime_state", lambda config, macro: macro["state"])
|
||||
|
||||
regimes = mod._compute_regimes(
|
||||
snapshot_dir="data/parquet/example_snapshot",
|
||||
split="train",
|
||||
config_path="configs/experiments/example.json",
|
||||
start_date=dt.date(2025, 1, 2),
|
||||
end_date=dt.date(2025, 1, 3),
|
||||
)
|
||||
|
||||
assert regimes == {
|
||||
dt.date(2025, 1, 2): "regime-2025-01-02",
|
||||
dt.date(2025, 1, 3): "regime-2025-01-03",
|
||||
}
|
||||
|
||||
|
||||
def test_compute_regimes_prefers_explicit_snapshot_dir(monkeypatch, tmp_path) -> None:
|
||||
snapshot_dir = tmp_path / "snap"
|
||||
snapshot_dir.mkdir()
|
||||
(snapshot_dir / "train.parquet").write_text("stub")
|
||||
|
||||
fake_store = _FakeStore()
|
||||
|
||||
monkeypatch.setattr(mod, "load_manifest", lambda path: object())
|
||||
monkeypatch.setattr(mod, "resolve_config", lambda manifest, config_root=".": object())
|
||||
monkeypatch.setattr(mod, "load_merged_store_from_snapshot_dir", lambda snapshot_dir, oracle_url, db_dsn: fake_store)
|
||||
monkeypatch.setattr(mod, "_build_merged_snapshot_store", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not be called")))
|
||||
monkeypatch.setattr(mod, "_macro_regime_state", lambda config, macro: macro["state"])
|
||||
|
||||
regimes = mod._compute_regimes(
|
||||
snapshot_dir=snapshot_dir,
|
||||
split="train",
|
||||
config_path="configs/experiments/example.json",
|
||||
start_date=dt.date(2025, 1, 2),
|
||||
end_date=dt.date(2025, 1, 3),
|
||||
)
|
||||
|
||||
assert regimes == {
|
||||
dt.date(2025, 1, 2): "regime-2025-01-02",
|
||||
dt.date(2025, 1, 3): "regime-2025-01-03",
|
||||
}
|
||||
Loading…
Reference in New Issue