본문으로 건너뛰기
라이브러리 문서 전체

비교 가능한 동일 가중 일별 벤치마크

코드 Machine Learning for Trading

요약

이 벤치마크 생성 방법은 사례 연구에서 지정한 자산으로 동일 가중 일별 수익률 시계열을 만듭니다. 먼저 각 세션의 심볼별 마지막 종가를 가져온 다음 종가 간 수익률을 계산하고, 이용 가능한 심볼 수익률의 평균을 구합니다. 자산이 관측 패널에 새로 들어오거나 빠질 때 인위적인 수익률이 생기지 않도록 연속된 세션에 가격이 있는 경우에만 해당 심볼을 포함합니다. 벤치마크는 일별 격자로 만들어 같은 주기의 전략과 수익률 및 연환산 지표를 비교할 수 있습니다.

이 방법은 기업 활동과 데이터 범위도 고려합니다. 조정 계수가 있으면 가격에 적용하고, 조정되지 않은 가격 쌍에서는 가격 비율이 일반적인 분할 비율과 비슷한 수익률을 선별합니다. 조정 가격 데이터와 비조정 가격 데이터의 경계를 가로지르는 쌍은 허위 급등을 피하기 위해 제외합니다. 점검 모드는 생성된 메타데이터와 수익률 파일을 커밋된 버전과 비교합니다. 이러한 안전장치가 모든 데이터 위험을 없애지는 않습니다. 가격 조정 범위는 제한적이며 비율 선별은 지정한 분할 유사 움직임만 식별할 수 있습니다. 따라서 벤치마크 결과는 선택한 자산 집합, 가격 출처, 평가 기간에 달려 있습니다.

핵심 아이디어

  • 일별 전략 평가를 위한 벤치마크를 계산하기 전에 장중 가격을 일별 종가로 집계합니다.
  • 연속된 종가가 유효한 심볼들의 동일 세션 수익률을 평균합니다.
  • 조정 계수가 있는 경우 기업 활동을 반영해 가격을 조정합니다.
  • 조정 가격과 비조정 가격 데이터 범위의 경계를 가로지르는 가격 쌍을 제외합니다.
  • 비조정 수익률에서 분할 유사 가격 비율을 찾되, 이런 휴리스틱의 한계를 고려합니다.

태그

전문
# build_benchmark.py


```py
#!/usr/bin/env python3
"""Regenerate ``case_studies/<cs>/benchmark/<label>.{parquet,json}``.

These files are the buy-and-hold equal-weight return of the declared universe, and every
strategy in a case study is measured against them through
``case_studies.utils.benchmark.load_benchmark_metrics``. They were committed with no
generator anywhere in the repository (agent-workspace #362), which is how the defects
below survived.

**The benchmark must live on the same grid as the strategy.** The backtester aggregates to
a daily series before it measures anything (``to_daily_returns`` /
``extract_daily_returns_frame``) and ``evaluation.periods_per_year`` annualizes that daily
grid. A benchmark built on the trading cadence and annualized at 252 is not comparable to
it and understates itself by ``sqrt(bars_per_year / periods_per_year)`` - 5.1x on
nasdaq100_microstructure's 15-minute grid, 1.7x on crypto_perps_funding's 8-hour one. So
this compounds to a daily close-to-close return first, which is what the ``method`` field
``daily_cross_sectional_mean_close_pct_change`` has always claimed and what the committed
files did not do.

Usage:
    uv run python scripts/build_benchmark.py --case-study nasdaq100_microstructure
    uv run python scripts/build_benchmark.py --case-study nasdaq100_microstructure --check
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import numpy as np
import polars as pl
import yaml

from utils.cv_splits import generate_cv_splits
from utils.paths import get_case_study_dir

# (loader name in the `data` package, bar frequency to request, loader takes `lazy=`).
# Adding an entry is the whole cost of extending this to a case study; an entry not
# exercised by a regenerated benchmark is not an entry.
_LOADERS: dict[str, tuple[str, str, bool]] = {
    "nasdaq100_microstructure": ("load_nasdaq100_bars", "1m", True),
    "crypto_perps_funding": ("load_crypto_perps", "8h", False),
}

# Case studies whose bars are RAW, so a close-to-close return spans corporate actions.
# AlgoSeek's NASDAQ-100 minute archive carries `last_trade_price` and no adjustment field
# in any of its 63 columns: AAPL closes 499.23 on 2020-08-28 and 129.04 on 2020-08-31, and
# TSLA splits 5:1 three days later, both inside the validation window.
# `load_sp500_daily_bars` publishes a cumulative `adj_factor`, and `close * adj_factor` is
# continuous through the split (AAPL 4044.2 -> 4181.4, the +3.4% it actually traded).
#
# Crypto perpetuals have no corporate actions, so they are absent here by fact rather than
# by omission - and the residual screen below is gated on this mapping for that reason.
_ADJUSTMENT_SOURCE: dict[str, str] = {
    "nasdaq100_microstructure": "load_sp500_daily_bars",
}

# Ratios a residual split shows up as, for symbols the adjustment source does not cover.
# Matching a RATIO rather than thresholding a return is what keeps a genuine -40% session
# in the cross-section: only a move within `_SPLIT_TOLERANCE` of one of these is dropped.
_SPLIT_RATIOS = (1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 15.0, 20.0)
_SPLIT_TOLERANCE = 0.02


def _daily_closes(case_study: str, symbols: list[str], start: str, end: str) -> pl.DataFrame:
    """Last traded close per symbol per session, over ``[start, end]``."""
    if case_study not in _LOADERS:
        raise NotImplementedError(
            f"{case_study} has no loader registered in _LOADERS. Add the loader that "
            f"reads its price panel, then regenerate and commit its benchmark files - do "
            f"not leave the committed ones in place, they have no generator behind them."
        )
    import data as _data

    name, frequency, takes_lazy = _LOADERS[case_study]
    kwargs: dict = dict(frequency=frequency, symbols=symbols, start_date=start, end_date=end)
    if takes_lazy:
        kwargs["lazy"] = True
    frame = getattr(_data, name)(**kwargs)
    lf = frame.lazy() if isinstance(frame, pl.DataFrame) else frame
    return (
        lf.select(["timestamp", "symbol", "close"])
        .with_columns(pl.col("timestamp").dt.date().alias("date"))
        .group_by(["symbol", "date"])
        .agg(pl.col("close").sort_by("timestamp").last())
        .collect(engine="streaming")
    )


def _apply_adjustment(case_study: str, closes: pl.DataFrame, symbols: list[str]) -> pl.DataFrame:
    """Attach ``adj_close`` and the ``adj_factor`` that produced it, where one exists."""
    if case_study not in _ADJUSTMENT_SOURCE:
        return closes.with_columns(
            pl.col("close").alias("adj_close"),
            pl.lit(None, dtype=pl.Float64).alias("adj_factor"),
        )

    import data as _data

    factors = (
        getattr(_data, _ADJUSTMENT_SOURCE[case_study])(
            start_date=str(closes["date"].min()),
            end_date=str(closes["date"].max()),
            symbols=symbols,
        )
        .select(
            pl.col("timestamp").cast(pl.Date).alias("date"),
            "symbol",
            pl.col("adj_factor").cast(pl.Float64),
        )
        .unique(subset=["symbol", "date"], keep="first")
    )
    print(
        f"  adjustment: {factors['symbol'].n_unique()} of {closes['symbol'].n_unique()} "
        f"symbols carry an adj_factor; the rest are screened for residual split ratios"
    )
    return closes.join(factors, on=["symbol", "date"], how="left").with_columns(
        pl.when(pl.col("adj_factor").is_not_null())
        .then(pl.col("close") * pl.col("adj_factor"))
        .otherwise(pl.col("close"))
        .alias("adj_close")
    )


def _drop_residual_splits(per_symbol: pl.DataFrame) -> tuple[pl.DataFrame, int]:
    """Null out returns whose raw price ratio is a split ratio, for unadjusted pairs.

    A pair with a factor at both ends is already continuous and is never screened.
    """
    ratio = pl.col("prev_close") / pl.col("close")
    is_split = pl.lit(False)
    for r in _SPLIT_RATIOS:
        for cand in (r, 1.0 / r):
            is_split = is_split | ((ratio / cand - 1.0).abs() < _SPLIT_TOLERANCE)
    flagged = per_symbol.with_columns((is_split & pl.col("unadjusted_pair")).alias("split"))
    return flagged.filter(~pl.col("split")), int(flagged["split"].sum())


def _equal_weight_daily(closes: pl.DataFrame, screen_splits: bool = True) -> pl.DataFrame:
    """Cross-sectional equal-weight mean of each symbol's daily close-to-close return.

    A symbol contributes on a session only when it has a close on that session AND on the
    one before it, so a listing or a delisting never enters as a return.

    **Both endpoints must be on the same price scale.** Falling back to the raw close
    per row divides an adjusted price by an unadjusted one wherever factor coverage
    starts, ends or has a hole: unchanged closes of 100 with factors [8, null, 8] read as
    -87.5% then +700%, and the residual screen cannot see it because the RAW ratio is
    exactly 1. A pair straddling a coverage boundary is dropped, not approximated.
    """
    covered = pl.col("adj_factor").is_not_null()
    per_symbol = (
        closes.sort(["symbol", "date"])
        .with_columns(
            pl.col("adj_close").shift(1).over("symbol").alias("prev_adj"),
            pl.col("close").shift(1).over("symbol").alias("prev_close"),
            covered.alias("covered"),
            covered.shift(1).over("symbol").alias("prev_covered"),
        )
        .with_columns(
            pl.when(pl.col("covered") & pl.col("prev_covered"))
            .then(pl.col("adj_close") / pl.col("prev_adj") - 1.0)
            .when(~pl.col("covered") & ~pl.col("prev_covered"))
            .then(pl.col("close") / pl.col("prev_close") - 1.0)
            .otherwise(None)
            .alias("ret"),
            (~pl.col("covered") & ~pl.col("prev_covered")).alias("unadjusted_pair"),
        )
        .drop_nulls("ret")
    )
    n_split = 0
    if screen_splits:
        per_symbol, n_split = _drop_residual_splits(per_symbol)
    if n_split:
        print(f"  dropped {n_split} symbol-session(s) whose price ratio is a split ratio")
    return (
        per_symbol.group_by("date")
        .agg(pl.col("ret").mean().alias("ew_return"), pl.len().alias("n_symbols"))
        .sort("date")
    )


def _metrics(returns: np.ndarray, periods_per_year: int) -> dict:
    if returns.size < 2:
        return {"sharpe": None, "cagr": None, "vol": None, "n_periods": int(returns.size)}
    sd = float(np.std(returns, ddof=1))
    cum = float(np.prod(1.0 + returns) - 1.0)
    return {
        "sharpe": round(float(np.mean(returns) / sd * np.sqrt(periods_per_year)), 6)
        if sd > 0
        else None,
        "cagr": round(float((1.0 + cum) ** (periods_per_year / returns.size) - 1.0), 6),
        "vol": round(sd * float(np.sqrt(periods_per_year)), 6),
        "n_periods": int(returns.size),
    }


def _evaluation_start(case_study: str, label_path: Path):
    """First session the strategy is measured over, from the splitter itself.

    ``WalkForwardCV`` builds folds BACKWARD from the holdout boundary using ``n_splits``
    and ``val_size``, so advancing the label's first session by ``train_size`` lands in
    the wrong place - on nasdaq it gives 2020-07-02 against the splitter's 2020-06-30,
    silently dropping two evaluated sessions.
    """
    folds = generate_cv_splits(
        pl.scan_parquet(label_path).select("timestamp").unique().collect(),
        case_study_id=case_study,
        date_col="timestamp",
    )
    first = min(f["val_start"] for f in folds)
    return first.date() if hasattr(first, "date") else first


def build(case_study: str, check: bool) -> int:
    case_dir = get_case_study_dir(case_study)
    setup = yaml.safe_load((case_dir / "config" / "setup.yaml").read_text())
    symbols = list(setup["universe"]["symbols"])
    ppy = int(setup["evaluation"]["periods_per_year"])
    holdout_start = pl.Series([str(setup["evaluation"]["holdout_start"])[:10]]).str.to_date()[0]
    holdout_end = pl.Series([str(setup["evaluation"]["holdout_end"])[:10]]).str.to_date()[0]

    labels_dir, bench_dir = case_dir / "labels", case_dir / "benchmark"
    label_files = sorted(labels_dir.glob("*.parquet"))
    if not label_files:
        print(f"{case_study}: no label parquets under {labels_dir}", file=sys.stderr)
        return 1

    spans = {}
    for lf in label_files:
        hi = (
            pl.scan_parquet(lf)
            .select(pl.col("timestamp").dt.date().max().alias("hi"))
            .collect()["hi"][0]
        )
        spans[lf.stem] = (_evaluation_start(case_study, lf), hi)

    lo = min(v[0] for v in spans.values())
    hi = max(v[1] for v in spans.values())
    # A year of run-up so the first evaluated session has a prior close to price against.
    closes = _daily_closes(case_study, symbols, str(lo.replace(year=lo.year - 1)), str(hi))
    closes = _apply_adjustment(case_study, closes, symbols)
    ew_all = _equal_weight_daily(closes, screen_splits=case_study in _ADJUSTMENT_SOURCE)

    failures = 0
    for label, (l_lo, l_hi) in sorted(spans.items()):
        ew = ew_all.filter((pl.col("date") >= l_lo) & (pl.col("date") <= l_hi))
        is_hold = (ew["date"] >= holdout_start) & (ew["date"] <= holdout_end)
        r = ew["ew_return"].to_numpy()
        val_rows, hold_rows = ew.filter(~is_hold), ew.filter(is_hold)
        validation = _metrics(val_rows["ew_return"].to_numpy(), ppy)

        meta = {
            "case_study": case_study,
            "label": label,
            "method": "daily_cross_sectional_mean_close_pct_change",
            "periods_per_year": ppy,
            "n_symbols_declared": len(symbols),
            "n_symbols_in_universe": int(
                closes.filter(
                    (pl.col("date") >= ew["date"].min()) & (pl.col("date") <= ew["date"].max())
                )["symbol"].n_unique()
            ),
            **validation,
            "ts_min": str(ew["date"].min()),
            "ts_max": str(ew["date"].max()),
            "by_period": {
                "overall": _metrics(r, ppy),
                "validation": validation,
                "holdout": _metrics(hold_rows["ew_return"].to_numpy(), ppy),
                "validation_window": [str(val_rows["date"].min()), str(val_rows["date"].max())],
                "holdout_window": [str(hold_rows["date"].min()), str(hold_rows["date"].max())],
            },
        }
        pq, js = bench_dir / f"{label}.parquet", bench_dir / f"{label}.json"
        frame = ew.select(pl.col("date").alias("timestamp"), "ew_return")

        if check:
            # Compare EVERYTHING a consumer reads. Checking only the overall block lets a
            # moved holdout boundary report MATCHES while the validation and holdout
            # blocks load_benchmark_metrics actually returns are stale.
            was = json.loads(js.read_text()) if js.exists() else {}
            diffs = [k for k in meta if k != "by_period" and was.get(k) != meta[k]]
            diffs += [
                f"by_period.{k}"
                for k in meta["by_period"]
                if was.get("by_period", {}).get(k) != meta["by_period"][k]
            ]
            # "parquet" is not a metadata key, so it is kept out of the lookup below
            # entirely rather than appended to `diffs`, where meta[k] would raise
            # KeyError and abort every remaining label.
            parquet_note = None
            if not pq.exists():
                parquet_note = f"  parquet: missing, would write {frame.height} rows"
            elif not pl.read_parquet(pq).equals(frame):
                parquet_note = (
                    f"  parquet: committed {pl.read_parquet(pq).height} rows against {frame.height}"
                )
            print(f"{label}: {'DIFFERS' if diffs or parquet_note else 'MATCHES'} committed")
            for k in diffs:
                failures += 1
                if k.startswith("by_period."):
                    inner = k.split(".", 1)[1]
                    print(
                        f"  {k}: {was.get('by_period', {}).get(inner)} -> {meta['by_period'][inner]}"
                    )
                else:
                    print(f"  {k}: {was.get(k)} -> {meta[k]}")
            if parquet_note:
                failures += 1
                print(parquet_note)
            continue

        bench_dir.mkdir(parents=True, exist_ok=True)
        frame.write_parquet(pq)
        js.write_text(json.dumps(meta, indent=2) + "\n")
        print(
            f"{label}: {frame.height} sessions, {meta['n_symbols_in_universe']} symbols, "
            f"overall {meta['by_period']['overall']['sharpe']}, "
            f"validation {validation['sharpe']}, "
            f"holdout {meta['by_period']['holdout']['sharpe']}"
        )
    return 1 if (check and failures) else 0


if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--case-study", required=True)
    ap.add_argument("--check", action="store_true", help="compare with the committed files")
    args = ap.parse_args()
    raise SystemExit(build(args.case_study, args.check))

```

출처의 라이선스에 따라 출처를 표시하고 전문을 공개합니다. 라이선스: MIT

이 요약은 원문을 바탕으로 Stratmill의 리서치 에이전트가 작성했으며, 원문을 복사한 것이 아닙니다.