Source code for numeraire.baselines.forecast
"""The historical-mean forecast baseline — the Goyal-Welch OOS reference.
The prevailing (expanding) historical mean of the returns block is the benchmark every predictive
regression is scored against: Goyal-Welch (2008) show it is a stubbornly hard forecast to beat out
of sample, and the OOS R^2 in :class:`~numeraire.core.evaluators.OutOfSampleR2Evaluator` measures
MSE
improvement *relative to it*. The walk-forward forecast engine already computes exactly this
benchmark column for free at each origin (``train.returns_frame().mean(axis=0)``); this estimator
exposes the very same quantity as a first-class ``to_forecast`` citizen, so it can be compared,
registered and run through the engine like any other method (its OOS R^2 against the engine
benchmark is ~0 by construction — it *is* the benchmark).
``window`` gives the rolling variant (last ``k`` observations); ``None`` (default) is the expanding
prevailing mean, matching the engine's benchmark convention.
"""
from __future__ import annotations
import pandas as pd
from numeraire.core import capabilities
from numeraire.core.data import TimeSeriesView
from numeraire.core.protocols import DataView
def _as_tsv(view: DataView) -> TimeSeriesView:
if not isinstance(view, TimeSeriesView):
raise TypeError("HistoricalMean requires a TimeSeriesView (asset-returns block)")
return view
class _HistoricalMeanModel:
"""Fitted historical-mean model: forecasts the per-asset sample mean over the fit window."""
def __init__(self, window: int | None) -> None:
self._window = window
def capabilities(self) -> set[str]:
return {capabilities.TO_FORECAST}
def forecast(self, view: DataView) -> pd.Series:
tsv = _as_tsv(view)
rets = tsv.returns_frame()
if self._window is not None:
rets = rets.tail(self._window)
# Per-asset prevailing mean, computed exactly as the engine's benchmark column so the two
# stay bit-identical (OOS R^2 vs the benchmark is exactly zero by construction). For a
# multi-period view the target is the compounded (t, t+h] return, so the mean is compounded
# to the same horizon ((1 + mu)^h - 1); h = 1 returns the mean itself, untouched.
mu = rets.to_numpy(dtype=float).mean(axis=0)
h = tsv.horizon
values = mu if h == 1 else (1.0 + mu) ** h - 1.0
return pd.Series(values, index=[str(c) for c in rets.columns])
[docs]
class HistoricalMean:
"""The prevailing-historical-mean forecaster (Goyal-Welch benchmark).
Parameters
----------
window:
Trailing window (in calendar steps) for the mean; ``None`` (default) is the expanding
prevailing mean — the same quantity the walk-forward engine uses as its OOS R^2 benchmark.
"""
[docs]
def __init__(self, *, window: int | None = None) -> None:
if window is not None and window < 1:
raise ValueError("window must be >= 1")
self.window = window
def fit(self, view: DataView) -> _HistoricalMeanModel:
_as_tsv(view)
return _HistoricalMeanModel(self.window)