Architecture#
numeraire is deliberately small at the centre and open at the edges. This page is the mental
model: the data views, the capability layer, the walk-forward engine family, the evaluators and the
result schema, the portfolio simulator, the open registries, and the one dependency rule that keeps
the whole thing honest.
Views: point-in-time by construction#
A view is a point-in-time aligned window of the data. It exposes a calendar (the decision
dates) and a window(end) that restricts the data to information available up to end. Two
concrete views cover the two halves of empirical asset pricing.
TimeSeriesViewThe market-timing / aggregate-predictor case: a
(date × asset)returns block (one column for a single market series, several for a panel) plus zero or more time-series predictor blocks. Each predictor enters as its ownFeatureBlockwith its own calendar and an availability lag, so heterogeneous macro sources — different frequencies, different publication lags — coexist. Vintaged (point-in-time revised) sources enter as aVintagedBlock, which resolves the real-time edge so no future revision leaks in.CrossSectionViewThe cross-sectional case (Fama–MacBeth, characteristic sorts, panel machine learning), where the predictor
z_{i,t}varies by both date and asset. It is built from a tidy long panel, the universe may enter and exit (ragged), and point-in-time windows are zero-copy prefix slices of the date-sorted panel. For tensor and neural methods it ejects a dense(T × N × K)PanelTensorwith an explicit presence mask.
The (t, t+h] pairing convention#
The single convention every driver and evaluator obeys: features known as of t are paired
with the return realised over (t, t+h]. A feature dated t is never matched to a return that
overlaps t itself. The view owns this pairing — features_asof(t) and target_asof(t, h) — so a
method never indexes the returns array directly and a one-period contemporaneous overlap is
structurally impossible rather than a bug waiting to happen.
Why point-in-time discipline matters#
Empirical asset pricing is unusually exposed to look-ahead bias: because signals and returns
share a time axis, it is trivially easy to let a single period of future information seep into a
prediction, and even a one-period contemporaneous overlap can turn a genuinely negative
out-of-sample result into an apparently significant positive one. The consequence is a number that
cannot be earned in real time — the most expensive kind of research error. numeraire therefore
does not rely on author discipline to avoid leakage. The engine hands a model only a windowed view;
the view’s aligned pairing purges any feature whose target is not yet realised; and the
conformance suite (see Extending: write your own method) ships a property test that perturbs the future and asserts
the past is unchanged. Look-ahead safety is a structural property of the framework, not a checklist.
Capabilities: what a model can produce#
The core is representation-agnostic. It never encodes a linear-factor (α / β / λ) structure, or
any other method-specific form, into its types. Instead a fitted model declares which
capabilities it supports, and the framework dispatches on them. The capability names are a flat,
open registry of string constants (numeraire.core.capabilities), not a closed enum —
extensions may add their own.
Three capabilities have crystallised into frozen method-level protocols:
TO_WEIGHTS—SupportsWeightsto_weights(view) -> (date × asset)portfolio or timing weights. Tangency, SDF, timing, and risk-based rules all live here.TO_FORECAST—SupportsForecastforecast(view) -> pd.Series— a per-asset prediction of the return over the next horizon. The predictive-regression family.TO_PRICING—SupportsPricingexpected_returns(view) -> (date × asset)— the cross-section of expected returns. Factor models, SDFs, and three-pass risk-premium estimators share this one surface; their bespoke accessors (loadings, latent factors, per-candidate premia) stay method-local.
A model is any object with a capabilities() set and whatever extractor methods those capabilities
mandate; an Estimator is any object with fit(view) -> Model.
These are Protocols, not base classes — a method conforms by duck typing, with nothing to inherit.
The walk-forward engine#
The engine is the most-reused, most-bug-prone, method-agnostic part of the framework, so it is kept
deliberately small and shared. For each (train, test) fold it fits the estimator on the train
view, asks the fitted model for its capability output on the test view, and computes realised
profit-and-loss from the original full view — never from anything the model returns.
backtest() is the discoverable entry point: it reads the fitted model’s
capability and the view type and dispatches to the right typed driver below (in_sample=True
selects the in-sample pricing path). One typed driver exists per capability, each returning a
frozen, provenance-stamped output container:
Driver |
Capability |
Output |
|---|---|---|
|
||
|
||
|
||
|
||
|
Every output carries a config_hash (a stable hash of the preprocessing/method config, so
preprocessing is pinned as part of the method) and a data_vintage stamp, which flow into every
result row. The forecast driver additionally decouples the refit cadence from the prediction cadence
(refit_every) — annual refits with monthly predictions, for instance — with each prediction still
consuming its own up-to-date point-in-time window. All drivers accept n_jobs to fan the
independent folds over a thread pool; the mapping is order-preserving, so a parallel run is
identical to the serial one.
The (train, test) folds come from a splitter. The bundled
WalkForwardSplitter yields expanding- or rolling-window folds and
supports an embargo gap on top of the automatic horizon purge; anything with a compatible split
method (including a wrapped scikit-learn splitter) works. validation_split()
carves a point-in-time (fit, valid) split inside a train fold for hyper-parameter tuning.
Evaluators and the result schema#
Evaluators turn an output container into rows of the standard tidy schema. They dispatch by
capability — each carries a requires set — so the metric always matches the object: a timing
strategy is scored by Sharpe, a forecast by out-of-sample R², a pricing model by cross-sectional R²
and average absolute alpha. The native evaluators (numpy/scipy only) cover the performance,
forecast-accuracy, and pricing families; two of them (StrategyReturnEvaluator,
SquaredErrorDiffEvaluator) emit one row per date for plotting
cumulative curves.
Every row conforms to RESULT_COLUMNS —
run_id, method, date, metric, value, universe, capability, protocol, config_hash, data_vintage —
and validate_result() enforces their presence. The schema is the stable
contract between computation and everything downstream (plotting, aggregation, comparison), and its
stability is promised under semantic versioning. The protocol column is what keeps an explanatory
in-sample number distinguishable from an out-of-sample one at every point in the pipeline.
The lower-level statistical machinery the evaluators build on is available directly in
numeraire.core.stats: the Gibbons–Ross–Shanken joint zero-alpha test, the Clark–West
nested-forecast test, the Jobson–Korkie–Memmel paired-Sharpe test, HAC alpha regressions, the
Benjamini–Yekutieli / Holm / Bonferroni multiple-testing adjustments behind the factor-zoo t > 3
hurdle, and the certainty-equivalent / return-loss / performance-fee economic-value measures.
The simulator#
The evaluators score idealised weight streams. When trading frictions matter, the
simulate_weights() accounting simulator turns a stream of target
weights and asset returns into realised gross and net return series with per-rebalance turnover and
costs. Published papers disagree on turnover and cost conventions, so every convention here is an
explicit, named parameter — accounting mode (constant-mix target vs drifted holdings), turnover
definition, proportional cost, cash/risk-free treatment, missing-return policy, and target
normalisation — never an implicit default buried in the accounting. A
RebalanceSchedule decouples the decision calendar from the data
frequency (month-end decisions over daily returns, say).
Open registries#
Extensibility runs through open registries rather than closed enumerations. Evaluators register in
the evaluator registry; methods (including the bundled baselines)
register through the numeraire.methods entry-point group, so an external package is a
first-class peer discovered at install time without any edit to core; reproduction targets register
in the reference registry. Adding a method, a metric, or a replication
target never requires touching the spine.
The boundary rule#
One rule holds the architecture together:
numeraire.coreis exactly the modules that depend on no specific method and that every method depends on. Dependency arrows point towardcore;corenever imports a method, an adapter, or a reference library.
numeraire.baselines ─┐
numeraire.adapters ─┼──▶ numeraire.core (spine: views, engine, evaluators, schema, ...)
external methods ─┘
The rule is enforced in continuous integration by import-linter, configured under
[tool.importlinter] in pyproject.toml. The lint rule is the architecture: if a change appears
to require breaking it, that is a signal the design is wrong, not that the rule should bend. A useful
operational test — code that would be rewritten to try a different algorithm does not belong in
core.
A small number of modules (numeraire.testing, numeraire.reference,
numeraire.comparison) live in numeraire proper rather than numeraire.core. They are core
infrastructure — they import only numeraire.core plus numpy/pandas, never a method — and are
exempt from the ban by construction, since they need to know the concrete view types to do their
job.