Extending: write your own method#
A method is any object with fit(view) -> model, where the fitted model advertises a capability and
implements its extractor. There is no base class to inherit and no run-loop to conform to: you write
a small estimator, self-certify it against the conformance suite, and it plugs into the same engine,
evaluators, and result schema as everything else.
This page builds a complete example — a volatility-scaled (inverse-volatility) weighting strategy — then certifies it, registers it for discovery, and pins a reproduction target.
The two protocols#
EstimatorAnything with
fit(view) -> Model.ModelA fitted object with
capabilities() -> set[str]plus the extractor each declared capability mandates —to_weightsforTO_WEIGHTS,forecastforTO_FORECAST,expected_returnsforTO_PRICING.
Both are Protocols checked by duck typing, so your classes need not import or subclass anything
from the framework to conform — importing the capability constants is enough.
A complete example#
The strategy: at each decision date, weight each asset inversely to its recent return volatility,
normalised to sum to one. The critical detail is that the model windows internally — it reads
only view.window(t) for the row it emits at t — which is what makes it point-in-time safe.
import numpy as np
import pandas as pd
from numeraire.core import capabilities
from numeraire.core.data import TimeSeriesView
from numeraire.core.protocols import DataView
class _InverseVolModel:
"""Fitted model: weights inversely proportional to trailing volatility."""
def __init__(self, lookback: int) -> None:
self._lookback = lookback
def capabilities(self) -> set[str]:
return {capabilities.TO_WEIGHTS}
def to_weights(self, view: DataView) -> pd.DataFrame:
if not isinstance(view, TimeSeriesView):
raise TypeError("InverseVol runs on a TimeSeriesView")
rows, idx = [], []
for t in view.calendar:
hist = view.window(t).returns_frame().to_numpy(dtype=np.float64) # info known at t
if len(hist) < self._lookback:
continue # warm-up
vol = hist[-self._lookback:].std(axis=0)
inv = np.where(vol > 0.0, 1.0 / vol, 0.0)
if inv.sum() == 0.0:
continue
rows.append(inv / inv.sum())
idx.append(t)
if not rows:
return pd.DataFrame(columns=view.assets)
return pd.DataFrame(np.vstack(rows), index=pd.DatetimeIndex(idx), columns=view.assets)
class InverseVol:
"""Inverse-volatility weighting estimator (a ``to_weights`` method)."""
def __init__(self, *, lookback: int = 12) -> None:
if lookback < 2:
raise ValueError("lookback must be >= 2")
self.lookback = lookback
def fit(self, view: DataView) -> _InverseVolModel:
if not isinstance(view, TimeSeriesView):
raise TypeError("InverseVol runs on a TimeSeriesView")
return _InverseVolModel(self.lookback)
That is the whole method. It now runs through backtest() exactly
like a bundled baseline:
from numeraire import SharpeEvaluator, TimeSeriesView, WalkForwardSplitter, backtest
out = backtest(InverseVol(lookback=6), view, WalkForwardSplitter(min_train=40, test_size=8),
method="inverse_vol")
SharpeEvaluator().evaluate(out)
Self-certify with check_estimator#
Before trusting a method’s numbers, run it through numeraire.testing.check_estimator(). It is
the framework’s analogue of scikit-learn’s estimator checks: plain functions that raise
AssertionError on the first violation. You supply the estimator and a deterministic
view_factory — a zero-argument callable that returns an equivalent synthetic view each call.
from numeraire.testing import check_estimator
def make_view() -> TimeSeriesView:
rng = np.random.default_rng(42)
dates = pd.date_range("2000-01-31", periods=80, freq="ME")
r = pd.DataFrame(rng.normal(0.01, 0.05, size=(80, 3)), index=dates, columns=["X", "Y", "Z"])
return TimeSeriesView(r)
check_estimator(InverseVol(lookback=6), make_view, min_train=40) # raises on the first failure
What each check catches:
check_capabilities()fitreturns a model whosecapabilities()intersect the core set, and every crystallised capability it declares actually exposes its extractor method. Catches a model that advertisesto_weightsbut never implemented it.check_output_shapes()Weights columns are a subset of
view.assetsand the index a subset ofview.calendar(or a[date, asset]MultiIndex for a panel); a forecast is apd.Seriesindexed by the assets; a pricing surface is a(date × asset)frame. Catches transposed frames and stray asset labels.check_determinism()The same estimator and the same view produce identical output twice. Catches an un-seeded RNG or other hidden state.
check_no_lookahead()The property test. The model is handed a view spanning data after a cut date
tand must window internally, so its rows at dates≤ tmust be invariant to mutating data strictly aftert. The suite builds a future-perturbed twin of the view and compares; a model that peeks past a prediction date fails here. OurInverseVolpasses because every row attreads onlyview.window(t).This probe runs for
to_weightsandexpected_returns, which both hand the model a multi-date view and rely on it to window. It is not run forto_forecast, and deliberately so: the forecast-origin engine only ever passesforecast()a prefix-truncatedview.window(origin), so a forecast at an origin is structurally incapable of seeing later data — a perturbation probe could never fail. A forecast leak instead surfaces as a disagreement between the engine path and a vectorised full-sample recomputation, which is where forecasting methods should assert their point-in-time safety.check_engine_roundtrip()The estimator runs through its matching walk-forward driver and an evaluator emits rows that validate against the result schema. Catches integration breaks the isolated checks miss.
You can also call the individual checks directly during development; check_estimator simply runs
them in order.
Register for discovery#
Bundled methods and external packages advertise themselves through the numeraire.methods
entry-point group. Declaring an entry point makes your estimator discoverable without any edit to
core — the same mechanism the bundled baselines dogfood. In your package’s pyproject.toml:
[project.entry-points."numeraire.methods"]
inverse_vol = "yourpackage.inverse_vol:InverseVol"
InverseVol = "yourpackage.inverse_vol:InverseVol"
(The registry accepts both snake_case and CamelCase aliases, as the baselines do.) A method
packaged this way — pinning numeraire, registering via the entry point, shipping a
check_estimator conformance test — is a first-class peer of any built-in method.
Pin a reproduction target#
When your method reproduces a published result, record the target as a first-class, tolerance-banded
record with numeraire.reference.ReferenceResult. A reference pins an exact paper, venue, and
table to an expected metric dict plus a per-metric tolerance band — never bit-equality,
because data-vintage revisions move the last decimals. check enforces the band and rejects a
non-finite computed value (guarding against an all-NaN false green).
from numeraire.reference import PUBLIC, ReferenceResult, register_reference
REF = register_reference(ReferenceResult(
name="inverse_vol_demo",
paper="Author (2026)",
venue="Journal",
year=2026,
table="Table 1",
expected={"sharpe": 0.85},
tolerance={"sharpe": 0.10}, # a band, not an exact match
tier=PUBLIC,
))
REF.check({"sharpe": 0.87}) # passes: within the band
The three data-access tiers#
Each reference carries a data-access tier. The tier is a statement about what data is required, never about importance or rank — a reproduction that needs licensed data is a first-class citizen.
PUBLICPublic, redistributable, or synthetic data — the case runs unconditionally, including in CI.
CREDENTIALEDData programmatically fetchable with the user’s own subscription credentials; the case self-skips when those credentials are absent.
RESTRICTEDData anyone may obtain but that is non-redistributable, so it needs a self-obtained local copy; the case self-skips when that copy is absent.
An optional available predicate plus the tier let continuous integration stay green on public data
while the same case runs verbatim wherever the private data is present — one code path, no forked
assertions. reference_params() turns the whole registry into
pytest.param entries (unavailable tiers marked skip) so one parametrised test drives them all.