Files
2026-07-13 13:26:28 +08:00

138 lines
4.8 KiB
Python

"""Load period-stratified benchmark metrics for case-study analyses.
The benchmark parquets in ``case_studies/{cs}/benchmark/`` cover both validation
and holdout periods. **Consumers must pull the period-specific block** when
comparing strategy holdout to benchmark holdout, etc. — using the overall
metrics for a period-specific comparison would mix windows.
Part of the downloaded case-study artifacts. JSON schema:
{
"case_study": "etfs",
"label": "fwd_ret_21d",
"method": "...",
"periods_per_year": 252,
"n_symbols_in_universe": 99,
"sharpe": ..., "cagr": ..., "vol": ..., "n_periods": ...,
"ts_min": "...", "ts_max": "...",
"by_period": {
"overall": {"sharpe": ..., "cagr": ..., "vol": ..., "n_periods": ...},
"validation": {"sharpe": ..., "cagr": ..., "vol": ..., "n_periods": ...},
"holdout": {"sharpe": ..., "cagr": ..., "vol": ..., "n_periods": ...},
"validation_window": ["...", "..."],
"holdout_window": ["...", "..."]
}
}
"""
from __future__ import annotations
import json
from datetime import date as _date
from pathlib import Path
from typing import Literal
import polars as pl
import yaml
from utils.paths import get_case_study_dir
Period = Literal["overall", "validation", "holdout"]
def benchmark_dir(case_study: str) -> Path:
return get_case_study_dir(case_study) / "benchmark"
def _to_date(v) -> _date:
"""Parse a YYYY-MM-DD-prefixed string/date to a Python ``date``.
Comparing on ``dt.date()`` is tz-agnostic — it sidesteps the
naive-vs-tz-aware-Datetime cast hazard entirely (Polars silently treats
naive sources as UTC under cast, which would shift boundaries on a
non-UTC tz-aware parquet).
"""
if isinstance(v, _date):
return v
return _date.fromisoformat(str(v)[:10])
def load_benchmark_metrics(
case_study: str,
label: str,
period: Period = "overall",
) -> dict | None:
"""Return the {sharpe, cagr, vol, n_periods} block for the requested period.
None if the JSON is missing or the requested block is not populated (e.g.
holdout block when the case study has no holdout window).
"""
p = benchmark_dir(case_study) / f"{label}.json"
if not p.exists():
return None
meta = json.loads(p.read_text())
bp = meta.get("by_period")
if bp is None:
# Legacy file without stratification — only overall is meaningful
if period == "overall":
return {k: meta[k] for k in ("sharpe", "cagr", "vol", "n_periods") if k in meta}
return None
return bp.get(period)
def load_benchmark_returns(
case_study: str,
label: str,
period: Period = "overall",
) -> pl.DataFrame:
"""Return the daily ``ew_return`` series sliced to the requested period.
Boundary source of truth is the JSON's ``by_period.{validation,holdout}_window``.
When the JSON is present,
its ``by_period`` is authoritative — a missing window means the period was
not populated by the writer (e.g. ``ho_df.height < 2``), and the consumer
gets an empty frame rather than silently re-deriving from ``setup.yaml``.
Falls back to ``setup.yaml.evaluation.{holdout_start, holdout_end}`` only
when the JSON is absent (legacy unstratified files).
"""
p = benchmark_dir(case_study) / f"{label}.parquet"
if not p.exists():
return pl.DataFrame()
df = pl.read_parquet(p)
if period == "overall":
return df
if period not in ("validation", "holdout"):
raise ValueError(
f"Unknown period {period!r}. Expected one of: 'overall', 'validation', 'holdout'."
)
json_p = benchmark_dir(case_study) / f"{label}.json"
if json_p.exists():
# JSON authoritative: respect what the writer recorded.
bp = json.loads(json_p.read_text()).get("by_period", {}) or {}
window = bp.get(f"{period}_window")
if not window:
return pl.DataFrame()
start = _to_date(window[0])
end = _to_date(window[1])
return df.filter(
(pl.col("timestamp").dt.date() >= start) & (pl.col("timestamp").dt.date() <= end)
)
# Legacy fallback: derive boundaries from setup.yaml.
setup_path = get_case_study_dir(case_study) / "config" / "setup.yaml"
if not setup_path.exists():
return df
setup = yaml.safe_load(setup_path.read_text())
e = setup.get("evaluation", {})
hs, he = e.get("holdout_start"), e.get("holdout_end")
if hs is None or he is None:
return df if period == "validation" else pl.DataFrame()
hs_d = _to_date(hs)
he_d = _to_date(he)
if period == "validation":
return df.filter(pl.col("timestamp").dt.date() < hs_d)
return df.filter(
(pl.col("timestamp").dt.date() >= hs_d) & (pl.col("timestamp").dt.date() <= he_d)
)