Changelog#
All notable changes to this project are documented here.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
Versions are tag-driven (hatch-vcs).
Unreleased#
Added#
Every engine output now carries the effective target contract.
WeightsOutput,PanelWeightsOutput,ForecastOutput, andPricingOutputgain ahorizon: intfield (the forecast horizonhof the paired(t, t+h]targets, set by the producing driver; default1for a direct construction), and the drivers stampfrequency(thepandas.infer_freqcode of the finalized output prediction dates — not the input view’s calendar, so a model emitting sparser decisions than its view is scaled at the cadence its returns actually realize;Nonewhen not inferable — never guessed) intometa, plusoverlap = horizon - 1whenhorizon > 1. Reports no longer have to prove the horizon from the input side, and annualization can scale by the real data frequency instead of a fixed default.The annualizing evaluators (
SharpeEvaluator,MeanReturnEvaluator,SortinoEvaluator,M2Evaluator,TreynorEvaluator,InformationRatioEvaluator,AlphaEvaluator) acceptperiods_per_year: int | None(defaultNone): whenNonethey derive the periods-per-year scaling along a chain — the output’s stampedmeta['frequency'], else a frequency inferred from the output’s own prediction dates (so a directly constructed output with a regular calendar needs no metadata) — mapped as B/C→252 (business/custom-business daily), D→365 (calendar daily), W→52, M/ME/MS→12, Q→4, A/Y→1. An explicit argument always wins.MissingReturnPolicyandWeightsOutput.scoring_weights()/PanelWeightsOutput.scoring_weights()make incomplete-return scoring explicit and auditable."renormalize_legs"preserves the original positive and negative target exposures separately; driver-produced output metadata records the policy, missing held observations, affected dates, and re-normalized dates.assign_portfolio_binsandaggregate_assigned_portfoliosexpose portfolio formation and holding-period aggregation as separate, testable steps.SortAssignmentscarries the frozen formation membership and its breakpoints;sort_portfoliosremains the convenience wrapper.numeraire.testing.check_fit_independence— a conformance check that an estimator’s output on a view is independent of any earlier fit on different data (fit a prefix, fit the full view, refit a freshly rebuilt content-equal prefix, require the two prefix outputs to be bit-identical). Catches warm-start / cached-statistic state that leaks across fits, including caches keyed on view identity. Added to the defaultcheck_estimatorbattery.Property-based no-look-ahead tests for the timestamp-
asofavailability layer (VintagedBlockandCharBlockvintaged mode), checked against an independent brute-force oracle over irregular calendars, long publication lags and intra-period stamps.Optional
n_obs/n_droppedattrition columns (schema.ATTRITION_COLUMNS) on the tidy result rows of the benchmark-comparison evaluators (OutOfSampleR2Evaluator,SquaredErrorDiffEvaluator,ClarkWestEvaluator,CrossSectionalR2Evaluator,AverageAbsAlphaEvaluator). They record the size of the joint finite sample a metric was scored on and how many candidate observations the joint mask excluded, so selective missingness is auditable on the row itself. The columns are schema-additive:validate_resultnever requires them, but every non-null cell must be a finite, non-negative, integer-valued numeric (non-numeric cells are rejected rather than coerced away).newey_west_lrvaccepts an optionalvalidobservation mask: autocovariances then pair only observed positions exactly the lag apart on the original time axis, keeping HAC lags meaningful for a series with internal gaps. The default (no mask) is the previous dense behavior.numeraire.testing.check_fold_isolation— a conformance check that the engine isolates every fold: a stateful estimator’s matching walk-forward driver produces bit-identical output undern_jobs=1andn_jobs=4(and on a fresh serial rerun). The capability probe itself fits a deepcopy so the supplied estimator reaches every run pristine, and a caller-supplied splitter must yield at least two folds (a single fold never dispatches to the thread pool, so serial-vs-parallel identity would be vacuous). Wherecheck_fit_independenceprobes the estimator’s own fit purity, this probes that the engine’s per-fold isolation holds; a nondeterministic fit also fails it (the failure message says how to tell the causes apart). Runs in the defaultcheck_estimatorbattery immediately aftercheck_fit_independence.
Changed#
Breaking — a non-positive forecast horizon is rejected.
TimeSeriesViewandCrossSectionViewalready refusedhorizon <= 0at construction; the guard now also fires at every per-callhorizonoverride (target_asof,aligned) and on the output dataclasses.horizon = 0pairs a feature with a contemporaneous(t, t]window (a look-ahead) and silently produces zero targets, so it is refused rather than run empty.Breaking — annualizing evaluators refuse an irregular or overlapping output without an explicit
periods_per_year. Withperiods_per_year=None(the new default)SharpeEvaluator,MeanReturnEvaluator,SortinoEvaluator,M2Evaluator,TreynorEvaluator,InformationRatioEvaluator, andAlphaEvaluatorderive the scaling from the output’s stamped frequency, else from its own prediction dates; when neither yields a standard code (irregular dates) or the targets overlap (the canonicalhorizon > 1field, or anoverlapstamp) they raiseValueErrordemanding an explicitperiods_per_yearrather than annualizing daily, monthly, and overlapping series with one silent default. Numbers are unchanged for the standard regular-frequency, horizon-1 case (a monthly output still derives 12, a business-daily output 252; calendar-dailyDderives 365).Breaking — a driver
horizonthat disagrees withview.horizonis an error.backtest_forecast’shorizonargument may now only assert the view’s horizon: passing a value different fromview.horizonraisesValueError(the view is the single source of truth), instead of silently re-targeting. Leave itNone(the default) to useview.horizon.Breaking — the historical-mean forecast benchmark now targets the same horizon as the model.
backtest_forecastcompounds the prevailing-mean benchmark to the model’sh-period target ((1 + mu)^h - 1under the iid convention) instead of carrying a single-period mean. Forhorizon = 1this is exactly the old value (mu), so single-period runs and theirOutOfSampleR2Evaluatornumbers are unchanged; forhorizon > 1the benchmark — and therefore the OOS R² of a multi-period forecast comparison — changes (it was previously mis-scaled against a single-period reference). The bundledbaselines.HistoricalMeancompounds the same way, so its OOS R² against the engine benchmark is exactly zero at every horizon, as documented.Breaking — benchmark-comparison evaluators fail closed above 50% missingness. When the joint finite mask (model ∩ target ∩ benchmark) drops more than half of the candidate observations,
OutOfSampleR2Evaluator,SquaredErrorDiffEvaluator,ClarkWestEvaluator, and the cross-sectional pricing evaluators now raiseValueErrorinstead of scoring a rump sample. There is no warning tier — a majority-missing comparison is refused outright. On the pricing side a candidate is a cell where either the predicted or the realized value is finite: cells absent on both sides are structural (a ragged entering/exiting universe), count neither as observed nor as dropped, and cannot trip the threshold. An empty comparison output — no candidate observations at all, e.g. from a view too short to produce any evaluation window — also raisesValueErrorinstead of crashing or returning an empty/NaN result. Below the threshold, scoring proceeds and then_obs/n_droppedcolumns make the drop visible.Breaking — weight backtests now fail closed on a missing held return.
backtest_weightsandbacktest_paneldefault tomissing_returns="error"; callers must explicitly choose"zero"or"renormalize_legs"when a paper’s convention requires it. The policy is included inconfig_hash, so weight-run hashes change even with an otherwise empty method config. The engine now removes only a mechanically identified horizon tail, not earlier rows or assets merely because their realized return is unavailable.WeightsOutput.weightsandPanelWeightsOutput.weightsalways remain the model’s target decisions, so missingness can no longer silently alter exposure, turnover, HHI, or weight plots. Non-finite target weights are rejected rather than treated as zero.backtest()now performs its capability-probe fit on the selected driver’s first train window — the first fold’s train view (walk-forward), the warm-up prefix (forecast), or the whole view (in-sample) — instead of always fitting the full sample. Fitting the full view ahead of a walk-forward run let a stateful estimator observe post-train data while its capabilities were being read, a silent look-ahead channel; the probe now stays within the same information set the driver’s first fit uses. The user splitter’ssplit(view)is consulted exactly once (its folds are materialized and replayed to the driver), so a splitter whosesplitreturns a one-shot iterator loses no folds. Output is unchanged for stateless estimators.Breaking — every backtest fit now runs on an isolated
copy.deepcopyof the estimator. All four drivers (backtest_weights,backtest_forecast,backtest_panel,backtest_pricing), the in-sample pricing path,backtest()’s capability-probe fit, and thecomparecomparison harness deep-copy the estimator before fitting — uniformly, serial and parallel; the engine never fits the caller’s instance directly. For estimators honoring the isolation contract, a fold’s result no longer depends on which other folds were fitted first or on then_jobsthread schedule (previously the drivers fitted one shared instance, so a stateful estimator’s serial folds chained state and its parallel folds raced). Output is unchanged for stateless estimators; an estimator that deliberately relied on cross-fold warm-start / cached state now sees each fold fitted from its pristine pre-fit state. The isolation contract: estimators must be deepcopy-able (a failing deepcopy raises a contextualTypeErrornaming the method, chaining the original error; an un-copyable resource such as a live DB handle belongs behind a factory that builds it atfittime) and must not share fit-relevant mutable state across copies —copy.deepcopycannot sever class attributes, module globals, or containers a custom__deepcopy__aliases, so an estimator routing state through such channels defeats the isolation and can still observe or mutate the caller’s instance. Copying a pre-fit estimator is cheap next to the fit.Breaking — point-in-time availability is now a real-timestamp comparison.
VintagedBlockandCharBlockpreviously decided what was “known” by comparing calendar month ordinals, so a row or release stamped later in the same month counted as already available — a silent intra-month look-ahead whenever the data or the decision calendar was finer than monthly (daily panels, month-end-stamped rows read on a daily calendar, mid-month releases). Availability is now the unit-free rulestamp <= t: a reference date, vintage, or release is visible on its stamped day and not before. Behavior only changes for data whose stamps are misaligned within a period, and always in the safe direction (a value becomes older orNaN, never newer). For the timestamp-comparison change in isolation, month-end-stamped monthly data is unaffected (the separate availability shift from removing the defaultlagis described in the next bullet). Missing (NaT) availability stamps and tz-aware stamps are now rejected at construction rather than silently mis-scaled (aNaTstamp used to read as “available since the beginning of time”, a tz-aware stamp shifted the boundary by its UTC offset). Duplicate(ref_date, vintage)/(asset, ref_date, vintage)keys, whose real-time edge was order-dependent, now raise as well.Breaking —
VintagedBlockno longer takes alagargument. The oldlag(whole months, default 1) was a coarse availability buffer that cannot be expressed under timestamp resolution. Bake any publication delay into thevintagecolumn at the data end instead — e.g.table.assign(vintage=table["vintage"] + pd.DateOffset(months=1))before constructing the block. Consumers that relied on the defaultlag=1will see availability move up to ~one period earlier (the old default was deliberately over-conservative); this is correct real-time behavior, but a golden number fed by a vintaged source may shift.Breaking —
CharBlockvintaged mode rejects a non-zerolag. In vintaged mode availability is the vintage timestamp, so a row-step lag is meaningless; passinglag != 0together withvintage_colnow raisesValueError. Lagged mode is unchanged: availability is the row’s own date andlagstill steps back that many rows in the asset’s own series.
Fixed#
Benchmark-comparison evaluators no longer let non-finite predictions manufacture apparent skill.
OutOfSampleR2Evaluator,SquaredErrorDiffEvaluator, andClarkWestEvaluatorpreviously scored the model and its benchmark with separatenansumdenominators, and the pricing evaluators averaged predicted and realized returns over separatenanmeansamples. A forecast that was present on some observations and missing on others could therefore be scored against a smaller error base than the fully-observed benchmark — a selectively-missing model reporting false skill (e.g. roughly +50% out-of-sample R² against a zero benchmark). Every such metric now builds one joint finite mask across model, target, and benchmark and scores all terms on the same observations; a per-origin statistic drops (rather than zero-fills) origins with no jointly-finite cell. The Clark-West Newey-West variance is computed on the original origin axis — lag-l autocovariances pair only observed origins exactly l periods apart — so an internal gap in the observed origins does not make observations several periods apart look adjacent.ReferenceResultnow rejects a non-finiteexpectedvalue and a non-finite or negativetoleranceat construction, and snapshotsexpected/toleranceinto read-only copies so mutating the caller’s dicts after construction cannot bypass that validation. ANaN/Infexpected value or an infinite band would previously auto-pass its owncheck, letting a vacuous “verified” reproduction be registered; the existing exact-match guard (a zero band on an integer target stays legal) is unchanged.The forecast and pricing drivers now contain their model’s output to the fold, closing the last two gaps left after the weights/panel guards.
backtest_pricing/backtest_pricing_in_samplereject anexpected_returnspanel whose dates are not a uniqueDatetimeIndexinside the fold’s calendar, or that carries an asset absent from the view — validated before the structural horizon tail is dropped, so a bad date or phantom asset hidden in that tail cannot slip through (previously an out-of-fold or duplicated date was pooled as a genuine OOS observation), and before any emptiness short-circuit, so a zero-row panel cannot smuggle a malformed column either.backtest_forecastrejects a forecast whose asset labels are non-unique or carry a label absent from the view (previously a phantom asset was silently dropped, scored as an abstention; a duplicate label raised a cryptic pandas error). All messages name the method.check_output_shapesmirrors the driver guard exactly for pricing outputs: prediction dates must be a uniqueDatetimeIndexand column labels must stay unique after string normalization.Pricing drivers pool per-fold panels on string-normalized asset labels, matching what they validate. Previously validation ran on
str(column)but concatenation kept the original labels, so one fold emitting the integer column1and another the string column"1"each passed yet pooled into two distinct, half-empty assets — one asset silently became two.Portfolio sorts no longer let holding-period return availability change formation-period breakpoints or bin membership. Signals, returns, weights, eligibility, and breakpoint-universe masks are validated on unique axes and aligned by pandas labels; missing mask values mean false, infinities are rejected, and thin or signal-degenerate breakpoint universes now fail closed instead of silently falling back to all stocks or emitting collapsed quantiles. Value-weighted bins with no positive observed weight remain
NaNinstead of silently becoming equal-weighted.SortResult.countsnow explicitly counts frozen formation members, including members whose realized return is missing.Breaking — declared log returns are converted to simple returns at ingestion. Previously a log-return input flowed unchanged into simple-return algebra: the forward target compounds with
prod(1 + r) - 1and strategy P&L is a weighted sum, so declared log returns were mis-aggregated (forln(1.1)/ln(1.2)the old target was ≈0.2950instead of the correct compounded0.32).TimeSeriesViewandCrossSectionViewnow takereturn_type="simple"(default) or"log"; a"log"input (returns and, forTimeSeriesView, the same-conventionrisk_free) is converted once viaexpm1at construction, and the conversion is recorded on the view’sprovenanceproperty ({"return_input": "log", "converted": "simple"}); merge it into a backtestconfig=(e.g.config={**view.provenance}) to make it part ofconfig_hash. Everything downstream stays on a single simple-return representation. This replaces theexcess="simple" | "log"parameter (theexcess="log"path produced log excess returns that then hit simple compounding — the same defect); excess-of-risk-free is now always the arithmeticr - rfin simple space. Any run that declared log inputs now returns the correct (different) numbers.
[0.2.2] - 2026-07-07#
Documentation and packaging refresh only — no functional changes since 0.2.1.
Changed#
Rebuilt the documentation site and README: an academic structure with a pain-first overview, grouped navigation, an ecosystem page, and a “How to cite” section. The docs now live at https://numeraire.py-numeraire.org/ (Cloudflare Pages), cross-linked with the companion packages’ sites.
Updated the
Documentationproject URL to the new docs domain.
Added#
CITATION.cff(so GitHub renders a “Cite this repository” button) and a BibTeX snippet in the README.
[0.2.1] - 2026-07-06#
Ecosystem release: the plotting and data companion packages are now on PyPI, and this release adds convenience extras to pull them in. Also carries the post-0.2.0 API work (all backward-compatible — old names keep working as deprecated aliases for one release).
Added#
Ecosystem extras —
numeraire[graphics](pullsnumeraire-graphics),numeraire[data](pullsnumeraire-dataset), andnumeraire[all].pip install numerairestays the minimal spine; opt into the companions here.backtest(estimator, view, splitter, *, method, in_sample=False)— a discoverable dispatching entry point that routes by the model’s capability and the view type to the typed driversbacktest_weights/backtest_forecast/backtest_panel/backtest_pricing/backtest_pricing_in_sample.Risk-adjusted evaluators —
TreynorEvaluator,InformationRatioEvaluator,M2Evaluator,SortinoEvaluator;ICEvaluator(rank IC);ExposureEvaluator(per-date leverage / net / turnover / concentration); andfama_macbeth(two-pass cross-sectional regression with Shanken + Newey-West).
Changed#
Renamed for a clearer register (old names remain as deprecated aliases emitting
DeprecationWarning):walk_forward*→backtest_*,adjust_tests→adjust_pvalues,clark_west→clark_west_test,make_sorts→sort_portfolios,OOSR2Evaluator→OutOfSampleR2Evaluator.WalkForwardSplitteris unchanged.
Fixed#
Weights/forecast backtests now align the model’s output to the view’s asset order by label before scoring (previously positional), so a method returning permuted/subset columns is scored correctly rather than silently mis-scored. Clear errors on a missing or misused splitter.
0.2.0 - 2026-07-05#
First tagged release. The spine is capability-complete: to_weights, to_forecast,
and to_pricing are all crystallized protocols with walk-forward drivers, native
evaluators, and a conformance suite.
Added#
Pricing capability —
SupportsPricing.expected_returns,walk_forward_pricing/pricing_in_sample, cross-sectional R² and average-|α| evaluators, andnumeraire.comparison.compareto score competing pricing models (factor models, SDFs, risk-premium estimators) on one common set of test assets. Every result row carries an explicitprotocollabel (in_sample/walk_forward), so explanatory numbers are never confusable with out-of-sample ones.Conformance suite (
numeraire.testing.check_estimator) — capabilities, output shapes, determinism, a no-look-ahead property test, and an engine round-trip: the self-certification any extension runs before its numbers are trusted.Reference registry (
numeraire.reference.ReferenceResult) — pinned published results with tolerance bands and data-access tiers (public/credentialed/restricted); CI stays green on public data while the same case runs verbatim wherever licensed data is present.Bundled baselines (
numeraire.baselines) — equal weight (1/N), minimum variance, mean-variance, and historical mean, registered through the same entry-point mechanism as any external method.Weight-stream simulator —
simulate_weights+RebalanceSchedulewith explicit drift, turnover, and cost conventions.Inference toolkit (
core.stats) — GRS, Clark-West, paired Sharpe (Jobson-Korkie–Memmel), HAC alpha regression, Bonferroni/Holm/BHY adjustments, and certainty-equivalent / return-loss / performance-fee measures.Cross-sectional data layer —
CrossSectionViewwith zero-copy point-in-time windows, a ragged-panel walk-forward engine, parallel fold execution, refit-cadence control, and a validation-split helper.Interop — polars/arrow ingestion at the view boundary (narwhals-optional, zero new hard dependencies) and a skfolio adapter (
[skfolio]extra) that wraps portfolio optimizers asto_weightsestimators.
Python ≥ 3.11, pandas ≥ 2.2.