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.

TimeSeriesView

The 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 own FeatureBlock with 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 a VintagedBlock, which resolves the real-time edge so no future revision leaks in.

CrossSectionView

The 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) PanelTensor with 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_WEIGHTSSupportsWeights

to_weights(view) -> (date × asset) portfolio or timing weights. Tangency, SDF, timing, and risk-based rules all live here.

TO_FORECASTSupportsForecast

forecast(view) -> pd.Series — a per-asset prediction of the return over the next horizon. The predictive-regression family.

TO_PRICINGSupportsPricing

expected_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

backtest_weights()

to_weights (time series)

WeightsOutput

backtest_panel()

to_weights (ragged panel)

PanelWeightsOutput

backtest_forecast()

to_forecast

ForecastOutput

backtest_pricing()

to_pricing (out-of-sample)

PricingOutput

backtest_pricing_in_sample()

to_pricing (explanatory)

PricingOutput

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_COLUMNSrun_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.core is exactly the modules that depend on no specific method and that every method depends on. Dependency arrows point toward core; core never 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.