Alignement des références et diagnostics de performance relative des stratégies
Résumé
Le document décrit des méthodes de comparaison des rendements d’une stratégie à deux références externes : les rendements totaux quotidiens de SPY et les rendements du marché Fama-French, calculés en ajoutant la prime de risque du marché au taux sans risque. Il fournit des fonctions de filtrage par date et d’alignement, dont une jointure par mois pour les jeux de données dont les horodatages mensuels suivent des conventions différentes. L’utilisateur doit s’assurer que les fréquences de rendement sont compatibles et aligner les observations avant de calculer les diagnostics.
Les calculs de diagnostic comprennent le ratio d’information, le bêta, la corrélation et l’erreur de suivi annualisée, avec des résumés facultatifs des rendements de la stratégie, du rendement annualisé, du ratio de Sharpe et du drawdown maximal sur des sous-périodes. Ils éliminent les valeurs manquantes par paires et renvoient des métriques indéfinies lorsque le nombre d’observations est insuffisant ou que la variance est nulle. Ces mesures décrivent un comportement relatif, et non le caractère investissable d’une stratégie ou sa probabilité de persister. Le document fournit des indications de mise en œuvre plutôt que des résultats empiriques ; l’annualisation dépend de la fréquence de période fournie par l’utilisateur.
Idées clés
- Les rendements clôture à clôture de SPY et les rendements du marché Fama-French constituent des séries de comparaison différentes.
- Les horodatages quotidiens peuvent être joints directement, tandis que les séries mensuelles peuvent nécessiter un rapprochement par mois civil.
- Les diagnostics par rapport aux références résument le rendement relatif, le bêta, la corrélation et l’erreur de suivi.
- Les calculs par sous-période peuvent montrer comment la performance d’une stratégie varie selon les périodes définies.
- La fréquence, les observations manquantes et une variance nulle influent sur la validité des métriques publiées.
Étiquettes
Texte intégral
# external_benchmarks.py
```py
"""External benchmark loaders for strategy-analysis notebooks.
Provides SPY (broad-equity ETF) and FF-market (Ken French Mkt-RF + RF)
return series on the canonical schema (`timestamp`, `benchmark_return`).
Both flavors return polars DataFrames; alignment with strategy returns
is the caller's responsibility.
The diagnostic helper `compute_benchmark_diagnostics` returns the
information ratio, beta, correlation, and tracking error of a strategy
return series against a benchmark return series.
"""
from __future__ import annotations
import datetime as _dt
import numpy as np
import polars as pl
from data import load_etfs, load_ff_factors
def _date_or_none(value: str | _dt.date | _dt.datetime | None) -> _dt.date | None:
if value is None:
return None
if isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
return value
if isinstance(value, _dt.datetime):
return value.date()
return _dt.date.fromisoformat(str(value)[:10])
def load_spy_returns(
start: str | _dt.date | None = None,
end: str | _dt.date | None = None,
) -> pl.DataFrame:
"""SPY daily total returns (close-to-close pct change).
Returns columns: `timestamp` (Date), `benchmark_return` (Float64).
"""
df = load_etfs(symbols=["SPY"]).sort("timestamp").select("timestamp", "close")
df = (
df.with_columns(
benchmark_return=pl.col("close").pct_change(),
)
.drop("close")
.drop_nulls("benchmark_return")
)
s, e = _date_or_none(start), _date_or_none(end)
if s is not None:
df = df.filter(pl.col("timestamp") >= s)
if e is not None:
df = df.filter(pl.col("timestamp") <= e)
return df
def load_ff_market_returns(
start: str | _dt.date | None = None,
end: str | _dt.date | None = None,
frequency: str = "daily",
) -> pl.DataFrame:
"""Fama-French market return (Mkt-RF + RF, i.e. nominal market).
`frequency` is `"daily"` or `"monthly"`. Returns columns:
`timestamp` (Date), `benchmark_return` (Float64).
"""
df = load_ff_factors(dataset="ff5", frequency=frequency).sort("timestamp")
df = df.with_columns(
benchmark_return=pl.col("Mkt-RF") + pl.col("RF"),
).select("timestamp", "benchmark_return")
s, e = _date_or_none(start), _date_or_none(end)
if s is not None:
df = df.filter(pl.col("timestamp") >= s)
if e is not None:
df = df.filter(pl.col("timestamp") <= e)
return df
def align_to_strategy(
strategy_df: pl.DataFrame,
benchmark_df: pl.DataFrame,
timestamp_col: str = "ts",
strategy_col: str = "strategy",
benchmark_col: str = "benchmark_return",
) -> pl.DataFrame:
"""Inner-join a benchmark series onto strategy returns.
Both inputs must carry the canonical timestamp column (Date). The
result has columns `[timestamp_col, strategy_col, benchmark_col]`.
Caller-side alignment for monthly cadences should resample first.
"""
bench = benchmark_df.with_columns(
pl.col("timestamp").cast(pl.Date).alias(timestamp_col)
).select(timestamp_col, benchmark_col)
return strategy_df.join(bench, on=timestamp_col, how="inner").sort(timestamp_col)
def align_to_strategy_monthly(
strategy_df: pl.DataFrame,
benchmark_df: pl.DataFrame,
timestamp_col: str = "ts",
strategy_col: str = "strategy",
benchmark_col: str = "benchmark_return",
) -> pl.DataFrame:
"""Month-anchored inner join (FF monthly is first-of-month; benchmark
series often end-of-month). Joins on (year, month) key.
"""
s = strategy_df.with_columns(
_y=pl.col(timestamp_col).dt.year(),
_m=pl.col(timestamp_col).dt.month(),
)
b = benchmark_df.with_columns(
_y=pl.col("timestamp").dt.year(),
_m=pl.col("timestamp").dt.month(),
).select("_y", "_m", benchmark_col)
return s.join(b, on=["_y", "_m"], how="inner").drop("_y", "_m").sort(timestamp_col)
def compute_subperiod_diagnostics(
df: pl.DataFrame,
buckets: list[tuple[str, _dt.date | str, _dt.date | str]],
*,
timestamp_col: str = "ts",
strategy_col: str = "strategy",
benchmark_col: str | None = "benchmark",
periods_per_year: int = 252,
) -> pl.DataFrame:
"""Per-bucket diagnostics for sub-period decomposition tables.
`df` must carry an aligned `(timestamp, strategy[, benchmark])` triple.
`buckets` is a list of `(label, start_date, end_date)` triples; date
bounds are inclusive at both ends. Per-bucket metrics: `n`,
`cum_return`, `ann_return`, `sharpe`, `max_drawdown`. When
`benchmark_col` is provided, the table also includes `beta_vs_bm` and
`info_ratio_vs_bm`.
"""
rows: list[dict[str, object]] = []
for label, start, end in buckets:
s = _date_or_none(start)
e = _date_or_none(end)
sub = df.filter((pl.col(timestamp_col) >= s) & (pl.col(timestamp_col) <= e))
n = sub.height
if n == 0:
rows.append({"bucket": label, "n": 0})
continue
s_arr = sub[strategy_col].to_numpy()
cum = float(np.prod(1 + s_arr) - 1)
if periods_per_year > 0:
ann = (1 + cum) ** (periods_per_year / max(n, 1)) - 1
else:
ann = float("nan")
std = float(np.std(s_arr, ddof=1)) if n > 1 else 0.0
sharpe = (
float(np.mean(s_arr) / std * np.sqrt(periods_per_year)) if std > 0 else float("nan")
)
equity = np.cumprod(1 + s_arr)
peak = np.maximum.accumulate(equity)
max_dd = float(np.min(equity / peak - 1))
row: dict[str, object] = {
"bucket": label,
"n": n,
"cum_return": cum,
"ann_return": ann,
"sharpe": sharpe,
"max_drawdown": max_dd,
}
if benchmark_col is not None and benchmark_col in sub.columns:
b_arr = sub[benchmark_col].to_numpy()
diag = compute_benchmark_diagnostics(s_arr, b_arr, periods_per_year)
row["beta_vs_bm"] = diag["beta"]
row["info_ratio_vs_bm"] = diag["info_ratio"]
rows.append(row)
return pl.DataFrame(rows)
def compute_benchmark_diagnostics(
strategy: np.ndarray,
benchmark: np.ndarray,
periods_per_year: int = 252,
) -> dict[str, float | int]:
"""Information ratio, beta, correlation, tracking error vs benchmark.
Inputs are aligned period returns; lengths must match. NaNs are
dropped pairwise. Returns NaN for any metric whose inputs are
degenerate (all-NaN or zero variance).
"""
s = np.asarray(strategy, dtype=float)
b = np.asarray(benchmark, dtype=float)
if s.shape != b.shape:
raise ValueError(f"shape mismatch: strategy {s.shape} vs benchmark {b.shape}")
mask = np.isfinite(s) & np.isfinite(b)
s, b = s[mask], b[mask]
n = int(s.size)
out: dict[str, float | int] = {"n": n}
if n < 2:
return {
**out,
"info_ratio": float("nan"),
"beta": float("nan"),
"correlation": float("nan"),
"tracking_error": float("nan"),
}
excess = s - b
te_period = float(np.std(excess, ddof=1))
te_ann = te_period * np.sqrt(periods_per_year)
if te_period > 0:
ir = float(np.mean(excess) * periods_per_year / te_ann)
else:
ir = float("nan")
var_b = float(np.var(b, ddof=1))
beta = float(np.cov(s, b, ddof=1)[0, 1] / var_b) if var_b > 0 else float("nan")
if np.std(s, ddof=1) > 0 and np.std(b, ddof=1) > 0:
corr = float(np.corrcoef(s, b)[0, 1])
else:
corr = float("nan")
return {
"n": n,
"info_ratio": ir,
"beta": beta,
"correlation": corr,
"tracking_error": te_ann,
}
```Reproduit dans son intégralité avec attribution, conformément à la licence de la source. Licence: MIT
Ce résumé a été rédigé par l’agent de recherche de Stratmill à partir de la source originale ; il n’en est pas une copie.