Changelog¶
Changelog¶
All notable changes to this project are documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
Nothing yet.
[0.2.0] - 2026-09-15¶
Stage 3: portpy.models (portfolio construction, optimization, and management); a
currency/transaction-cost realism pass over Stage 1's portpy.core/portpy.metrics.costs
that implements what an earlier planning.md revision had already (prematurely) documented;
and a theory/documentation correctness audit of the whole .models module that found and
fixed one significant numerical bug (james_stein's shrinkage intensity, silently
near-zero at default settings) and several documentation/example inaccuracies, then
extended the model validation to a third independent library (skfolio), negative-weight
(short) solutions, and a genuinely mixed-timeframe (equities + 24/7 crypto) universe.
portpy.visualization (Stage 2) was originally planned alongside this release but hasn't
started yet and ships separately in a later version - see
Known limitations below.
Added¶
portpy.models.estimators:expected_returns(mean_historical,ewma,capm_implied,james_steinshrinkage),covariance(sample,ewma,shrinkage,ledoit_wolf,robust), andfactor_models(capm,fama_french3/5-factor,linear_regression,rolling_regression,factor_attribution) - replaces the old experimentalmetrics/regressions.pymodule.portpy.models.optimization: pure solver functions -mean_variance,max_sharpe,min_variance,target_return,target_volatility,efficient_frontier,black_litterman,hierarchical_risk_parity,risk_parity,risk_budgeting,maximum_diversification. Solved via multi-start SLSQP (scipy.optimize) - nocvxpydependency, matching the existing[models]extra's footprint.portpy.models.construction: constraint objects (WeightBounds,GroupCap,TurnoverCap,NetExposure,GrossExposure), and theoptimize/build/efficient_frontierrecipe layer over.optimization(also exposed directly onPortfolio.modelsfor convenience).portpy.models.management:rebalance(target weights -> trade list, with turnover and a flat cost estimate),monitor(drift/exposure/limit checks),compare(before/after or portfolio-vs-portfolio, weight-level always and metric-level whenever both sides arePortfolios with return history).portpy.models.base:ModelResult(the model-layer counterpart toMetricResult-.explain(),.summary(),.compare();.plot()raisesNotImplementedErroruntil.visualizationships) and the shared multi-start solve routine every optimizer uses.Portfolio.models: a namespace of namespaces (.estimators/.optimization/.construction/.management, plus.optimize/.build/.efficient_frontierdirectly), auto-fillingexpected_returns/cov_matrix/current_weights/etc. from the portfolio's own data on keyword-only calls - the same pattern.metricsalready had.- Every model type has a registered
Explanation(category="model"for optimizers/capm/fama_french/rebalance/compare;category="function"for the estimator/utility functions). - Examples:
examples/models_debug.ipynb(exhaustive, function-by-function reference) andexamples/models_tutorial.ipynb(narrative walkthrough: estimate inputs, optimize, apply constraints, rebalance, monitor, compare) - both run against realyfinancedata. - Tests: unit coverage for every
modelssubmodule (tests/models/), plustests/validation/test_models_vs_libraries.pycross-validating every optimizer/estimator againstPyPortfolioOptand/orRiskfolio-Lib(and a plain-numpy OLS closed form for the factor regressions), on synthetic data (always) and real yfinance data (@pytest.mark.network, opt-in). PyPortfolioOptandRiskfolio-Libadded to thedevextra (cross-validation only, same role asempyrical/quantstatsfor Stage 1 - never a runtime dependency).- Currency/cost realism (implementing what an earlier revision of
planning.mdhad promised but the code never actually shipped - see Fixed): portpy.metrics.costs:bid_ask_spreads,broker_commissions,fx_spread_costs, andtax_impact- itemized, per-source cost/tax drag estimators that compose by plain addition, alongside the existing blendednet_of_costs_returns. Each*_bpsargument accepts a scalar, a{asset: bps}dict/Series (or{currency: bps}forfx_spread_costs), for per-asset/per-currency (and, via your own grouping dict, per-region) cost modeling.tax_impactis a deliberately first-order approximation (turnover as a realization proxy, one blended rate) - not a lot-level tax engine; see its docstring and the newdocs/guide/costs-and-taxes.mdguide for exactly what it doesn't model (no cost basis, no loss harvesting, no wash-sale rule).portpy.core.currency:calculate_fx_spread(bid_rates, ask_rates)turns real bid/ask FX quotes into a spread fraction forfx_spread_costs;convert_to_base_currencygained an optionalmax_stalenessto cap how long a forward-filled FX rate may be carried before lapsing toNaN(unlimited by default, for backward compatibility).Portfolio(..., base_currency=...): a new, purely informational constructor argument (same status asasset_classes) - fulfillsplanning.md's claim thatPortfolio"holds" abase_currency, without couplingPortfolioto any FX-conversion logic (conversion stays the user's job, done before construction, matching the existing calendar-alignment stance).examples/metrics_debug.ipynb: extended with sections for all five new functions (real market data,SAP.DE's EUR pricing exercises the currency-cost path).skfolioadded totests/validation/test_models_vs_libraries.pyandexamples/04_crosstest_models_final.ipynbas a third, independent optimization-library cross-check (min_variance,max_sharpe,risk_parity) -planning.mdSection 7 asks for "at least 3 other libraries"; the models validation previously had only two (PyPortfolioOpt,Riskfolio-Lib). Also added to thedevextra.- Negative-weight (short-position) cross-validation:
max_sharpeunder aWeightBounds(low<0)constraint is now explicitly checked against both PyPortfolioOpt and skfolio in both the automated suite and the crosstest notebook - every prior weight-based check used the default long-only bounds, so a genuine short position (not just an unused negative bound) had never actually been cross-validated. - A genuinely mixed-timeframe cross-validation:
examples/04_crosstest_models_final.ipynbnow fetchesBTC/USD(24/7, Alpaca's public crypto endpoint) alongside the Mon-Fri equity universe, aligns them withcore.align_calendars(method="ffill_union"), and cross-checksmin_varianceon the resulting 6-asset,frequency=365portfolio against PyPortfolioOpt - the models crosstest previously only ever used a single shared Mon-Fri calendar.
Fixed¶
hierarchical_risk_parity's post-hocWeightBoundshandling used to clip weights to bounds and then renormalize by dividing by the clipped sum - which can push an already-at-the-cap asset back over its cap, since dividing inflates every weight including the pinned ones. Replaced with a proper water-filling redistribution that only redistributes surplus/deficit through assets not yet pinned at a bound.TurnoverCapbuilt via a plain top-level import (from portpy.models.construction import TurnoverCap) had no way to pick upcurrent_weightsafterward (it's a frozen dataclass) - onlyportfolio.models.construction.TurnoverCap(...)got the auto-fill. Every.modelsentry point that acceptsconstraints=now patches any bareTurnoverCapbefore solving, so which import path built the object no longer matters.- Recategorized six
Explanations that were registered undercategory="chart"even though they're metrics-module functions, not charts (a correction noted since Stage 1 hardening):rolling_sharpe,rolling_volatility,rolling_beta,rolling_correlation->"metric";monthly_returns_table,return_histogram_data->"function". No name is meant to registercategory="chart"under this design - a chart recycles its underlying metric's/model's card instead of registering its own. estimators.covariance's"shrinkage"method's docstring incorrectly described its shrinkage target as "equal-variance" - the code (correctly) shrinks towarddiag(diag(sample)), which keeps each asset's own sample variance and only zeroes cross-correlations, not a target with equal variance across assets. The description now matches the code, and both"shrinkage"and"robust"explicitly note that they target a different matrix than same-named methods in other libraries (PyPortfolioOpt'sshrunk_covarianceshrinks to a scaled-identity target instead;MinCovDetapplies a consistency-reweighting step that a raw MCD implementation doesn't) - a theory-documentation audit found no bugs in the underlying formulas, only this one inaccurate description.planning.md's Section 3/4 currency paragraphs and its "Costs & Real Returns" bullet described a design that was never implemented:Portfolio"tracking"base_currencyand computing FX spread/cost internally, plustax_impact/broker_commissions/bid_ask_spreads/fx_spread_costs/calculate_fx_spreadlisted as existing when none of them did. Both the implementation gap and the planning-document drift are now closed (see Added, andplanning.mditself for the corrected wording).net_of_costs_returnssilently dropped the inputreturnsSeries'.namewheneverturnoverwas passed as apd.Series(pandas sets a binary op's result name toNonewhen the two operands' names differ) - harmless for a bare float result, but meantnet_of_costs_returns(returns, turnover=some_series, ...).namewasn'treturns.namethe way the scalar-turnoverbranch's result already was. Now renamed back explicitly in both branches, andtax_impactfollows the same convention.examples/metrics_debug.ipynb's final "not-yet-implemented subpackages" section (20.3) still claimedportpy.modelswas an empty placeholder with "only a# Yet to be madecomment" - stale since Stage 3 shipped.modelsin full. Corrected to report onlyportpy.strategies/portpy.visualization(still stubs) andportpy.models.simulation(the one remaining stub within.models) as not yet implemented. Its title cell also still read the pre-rename`debug.ipynb`from before it becamemetrics_debug.ipynb- fixed.expected_returns(method="james_stein")computed its shrinkage intensity at the wrong scale, a real bug (not just a documentation issue) found while writing an example formodels_debug.ipynb:phi=(N+2)/((N+2)+T*mahalanobis)mixedt_obs(a count of periodic, e.g. daily, observations) with amahalanobisdistance computed on already-annualizedmu/cov. Annualizing first scales the mahalanobis distance up by roughlyperiods_per_year, which collapsedphitoward 0 for any daily-return dataset regardless of the assets' true dispersion - on real 4-year daily data across 5 assets, the old code producedphi~0.002(shrinkage invisible to 3 decimal places); the same data now producesphi~0.40(a textbook Bayes-Stein intensity). Fixed by computingmahalanobis(and thereforephi) at the same periodic scale ast_obs, then annualizing only the final shrunk estimate -phiis now provably independent ofperiods_per_year, guarded by a new regression test. This silently defeated the entire purpose of the estimator at the defaultperiods_per_year=252for any realistic daily-data use - the most significant fix in this pass.TurnoverCap's docstring called its convention "one-way turnover", butmax_turnoveris compared against the raw, un-halvedsum(abs(w - current_weights))- exactly twicemetrics.costs.turnover_from_weights's actual one-way convention (which halves the same sum).models_tutorial.ipynb's original constraints example inherited this confusion: it labeled aTurnoverCap(max_turnover=0.15)as "10% turnover" in prose while the constraint itself allowed 15% raw / 7.5% one-way - none of those three numbers agreed with each other.TurnoverCap's docstring now states the raw-sum convention explicitly and cross-references the halved one; the tutorial's example and prose now agree.
Documentation¶
- Filled two missing-caveat gaps found in the same audit:
portpy.models.optimization's module docstring now states explicitly that no solver checksexpected_returns/cov_matrixfor a consistent time scale (mixing periodic and annualized inputs is silently accepted and financially meaningless);efficient_frontier's docstring andExplanationnow note that its defaultmax_return(the single highest per-asset expected return) is a long-only ceiling that understates the true frontier wheneverconstraintspermit leverage or shorting.black_litterman'sviewsdocstring now states explicitly that view values must be annualized, matchingcov_matrix's scale.
Changed¶
Portfolio.metrics's auto-fill dispatcher was refactored onto a shared_AutoFillNamespacemixin (still inportfolio.py) so.models(and, later,.visualization) reuse it verbatim instead of re-implementing argument-filling separately. No behavior change for.metrics- the full Stage 1 test suite passes unmodified against the refactored code.- Renamed
examples/debug.ipynb->examples/metrics_debug.ipynbandexamples/tutorial.ipynb->examples/metrics_tutorial.ipynb, for naming consistency with the newexamples/models_debug.ipynb/examples/models_tutorial.ipynb. examples/models_debug.ipynbandexamples/models_tutorial.ipynbrebuilt to matchmetrics_debug.ipynb's structure and rigor rather than their own earlier, weaker pattern:models_debug.ipynbnow tracks call/explain coverage the same way (COVERED/EXPLAINEDsets, an assertion-based coverage audit, a brute-forceExplanation.render()sanity check over every registered "model" card) instead of a bare, non-asserting name dump.models_tutorial.ipynb's prose was trimmed of dramatic/imperative framing ("you don't get to skip this step", "turning an optimizer into something you can actually run") in favor of plain section titles, and both notebooks' James-Stein andTurnoverCapexamples were fixed to actually demonstrate what their surrounding prose claims (see Fixed above for why they previously didn't).
Known limitations¶
portpy.visualization(Stage 2) is not implemented in this release -ModelResult.plot()raisesNotImplementedErrorby design until it ships.portpy.models.simulation(stochastic processes, Monte Carlo, time series, ML) is a separate, later stage (v0.3.0) and is not implemented - only an empty placeholder module exists.hierarchical_risk_parityonly supports theWeightBoundsconstraint (applied post-hoc); every other constraint type is dropped with a warning, since recursive bisection has no natural way to target an exact group cap, turnover budget, or leverage level.target_returnuses an equality constraint (w'mu == target), not "at least target" - a target below the global minimum-variance portfolio's own return still pins you to that lower, needlessly higher-variance point. Some other libraries (e.g. PyPortfolioOpt'sefficient_return) use>=instead, which substitutes the min-variance portfolio once its own return already clears the bar - the two conventions only agree above the min-variance return.
[0.1.2] - 2026-08-18¶
A correctness pass driven by an internal quant review of every formula, docstring, and
caveat in the library (26 findings). Real bugs were fixed in place. Every fix and every
remaining methodology difference was re-validated against empyrical and quantstats
on real market data (not synthetic/mocked series) both before and after the change.
Fixed¶
semi_variance: divided by the count of below-MAR observations instead of the full sample size N, contradicting Markowitz's definition and this library's owndownside_deviation.sqrt(semi_variance(...))now equalsdownside_deviation(..., annualized=False)exactly, as the naming always implied.annualized_return/cagr: the geometric branch raisedTypeError: can't convert complex to floatwhenever the compounded growth factor was<= 0- reachable with leveraged/short portfolios, which this library explicitly supports. Now returns-1.0(a complete loss) instead of crashing.up_capture_ratio/down_capture_ratio: re-annualized a filtered, non-contiguous up-day/down-day subsample viaannualized_return, which is not a valid use of geometric annualization. Now compares compounded sub-period returns directly. Note:empyrical's ownup_capture/down_captureuse the same re-annualization this fix removes, so PortPy's values now intentionally diverge fromempyrical's on this pair of metrics - see the validation report linked in the repo for the measured size of that divergence on real data.DEFAULT_MAR's docstring was attached to the wrong constant (DEFAULT_RISK_FREE_RATE) in theConstantsblock. Both constants now have their own docstring.
Added¶
conditional_drawdown_at_risk: true Conditional Drawdown at Risk (CDaR) - the mean of the worst tail of the drawdown series, the drawdown analogue ofconditional_var. (drawdown_at_riskis only the percentile, "DaR"; its docstring no longer claims to be CDaR.)sterling_ratio(..., drawdown_adjustment=0.0): optional parameter for the classic Deane Sterling Jones "+10%" drawdown-adjustment convention (drawdown_adjustment=0.10). Defaults to0.0, matching prior behavior exactly.normalize_weights(..., method="net"): optional"gross"mode, normalizing by gross instead of net exposure - avoids blow-up for near-dollar-neutral long/short books. Defaults to"net", matching prior behavior exactly.time_to_recovery(..., as_result=False): now supportsas_result=Truelike every other scalar metric, returning a self-explainingMetricResult.
Changed¶
- Expanded caveats across nearly every metric module, matching the source review:
i.i.d./no-autocorrelation assumptions behind every
sqrt(periods_per_year)annualization; sample-vs-population estimators inskewness/kurtosis; missing Dimson/Blume adjustments inbeta; VaR-method reliability limits; calendar-alignment and FX-quote-direction pitfalls inportpy.core; HAC/Newey-West guidance forportpy.metrics.regressions; and more - see each function's docstring and.explain()text. - Recategorized several return/turnover-transform functions
(
simple_returns,log_returns,cumulative_returns,prices_from_returns,rebased_returns,excess_returns,active_returns,turnover_from_weights,net_of_costs_returns) fromcategory="metric"tocategory="function"in the.explain()registry, since they return a series/frame rather than a single explainable value - consistent with howportpy.metrics.rolling's generic engines are already categorized. - Removed a duplicated block of
.explain()registrations foralpha/correlation/r_squared/capture-ratio/batting_averagethat had been accidentally left indistributions.py(those functions live inbenchmarks.py, which already registers them).
Known limitations¶
up_capture_ratio/down_capture_rationo longer matchempyrical's equivalents by construction (see Fixed, above) - this is an intentional, documented methodological difference, not a regression.
[Pre-Release] - 2026-08-04¶
[0.1.0] - 2026-08-04¶
First release. Metrics and core are implemented and tested; visualization, models, and strategies are designed but not yet built — see docs/roadmap.md.
Added¶
Portfolio: the single entry point over price/return data — weights (negative/short positions supported), asset-class tags, and a.metricsnamespace that auto-fillsreturns/prices/weights/rf/periods_per_yearfrom the portfolio's own state on keyword-only calls.portpy.metrics:returns,risk,performance,drawdowns,rolling,distributions,benchmarks(alpha/beta/capture ratios),regressions(OLS via statsmodels),covariance(portfolio-level risk decomposition: variance, diversification ratio, marginal/component contribution to risk), one-shotsummarytables, and transaction-costshelpers. Most scalar metrics acceptas_result=Trueto get a self-explainingMetricResultback.portpy.core:calendarsfor combining assets that trade on different calendars (e.g. 24/7 crypto with Mon-Fri equities);currencyfor multi-currency portfolios;weightsfor weight validation (including long/short books); andassetto represent individual assets.portpy.explain: the explainability layer —Explanationknowledge cards registered per metric (what it is, formula, how to read it, good vs. bad, caveats, a value-specific verdict),MetricResult(afloatsubclass carrying its own name/unit/interpretation), andportpy.explain(name_or_result)as the single dispatch point.- Examples:
01_yfinance_getting_started.py,02_alpaca_multiasset_calendar.py, andtutorial.ipynb— a full walkthrough of every metric function against three real long/short, multi-asset portfolios built from live Alpaca (stocks/ETFs/crypto) and FRED (risk-free rate) data. - Tests: unit coverage for every metric module, core of portpy, plus
tests/validation/test_vs_empyrical_quantstats.py, cross-checking PortPy's numbers againstempyricalandquantstatson synthetic and real (yfinance) market data. - Docs: a full guide + API reference site (
mkdocs+mkdocstrings) covering thePortfolioobject, weights/shorts, calendar & currency alignment, the explainability layer, and every function's API docs. - Project scaffolding:
pyproject.toml,src/layout, CI (lint/type-check/test/build) and PyPI-publish GitHub Actions workflows, MIT license.
Known limitations¶
portpy.visualization,portpy.models, andportpy.strategiesare designed (see docs/architecture.md) but not implemented in this release.