[ANN] PortfolioOptimisers.jl: Ape together strong

PortfolioOptimisers.jl

Editorial note: If you read this post and then go to the docs’ homepage or the package’s readme and they seem familiar, it’s because I started writing this before I had a proper readme.

Portfolio optimisation is the science of reducing investment risk by being clever about how you distribute your money. Ironically, some of the most robust ways to ensure risk is minimised is to distribute your money equally among a portfolio of proven assets. There exist however, a rather large number of methods, risk measures, constraints, prior statistics estimators, etc. Which give a huge number of combinations.

PortfolioOptimisers.jl is an attempt at providing as many as possible, and to make it possible to add more by leveraging Julia’s type system.

The feature list is quite large and under active development. New features will be added over time. Check out the examples and API documentation for details.

Please feel free to file issues and/or start discussions if you have any issues using the library, or if I haven’t got to writing docs/examples for something you need. That way I know what to prioritise.

Caveats

Documentation

  • Mathematical formalism: I’ve got API documentation for a lot of features, but the mathematical formalisms aren’t yet thoroughly explained. It’s more of a high level view.
  • Citation needed: I haven’t gone over all the citations for the docs because stabilising the API, adding new features, and writing the API docs has taken priority.
  • Docstring examples: some features require set up steps, and I haven’t had the patience to do that. Mostly the examples are still mostly for doctesting my implementation of Base.show for my types, and showcasing low-hanging fruit of functionality.

API

  • Unstable: there will likely be breaking changes as I figure out better, more general ways to do things, or better naming conventions.

Internals

  • Dependencies: some deps are only used for certain small things, I may end up removing them in favour of having just the small bit of functionality the package needs. I’m very open to replacement suggestions.

Features

The feature list is rather large, so I will attempt to summarise it via interpretative dance as best I can. There are also some experimental features (some tracking risk measures) that I’m not sure how well they’d perform, but they’re interesting nonetheless, especially when used in clustering optimisations. Luckily, those haven’t been documented yet, so I haven’t had to reckon with the consequences of my actions just yet.

Without further ado, here is a summary of the features in this package.

Price data

Every optimisation but the finite allocation work off of returns data. Some optimisations may use price data in the future.

  • Preprocessing to drop highly correlated and/or incomplete data. These are not well integrated yet, but the functions exist.
  • Computing them, validating and cleaning up data.

Co-moment matrix processing

Price data is often noisy and follows general macroeconomic trends. Every optimisation model is at risk of overfitting the data. In particular, those which rely on summary statistics (moments) can be overly sensitive to the input data, for example a covariance matrix. It is therefore important to have methods that increase the robustness of their estimation.

  • Positive definite projection.
  • Matrix denoising.
    • Spectral, shrunk, fixed.
  • Matrix detoning.

Moment estimation

Many of these can be used in conjunction. For example, some covariance estimators use expected returns, or variance estimators in their calculation, and some expected returns use the covariance in turn. Also, some accept weight vectors.

  • Expected returns.
    • Arithmetic expected returns.
    • Shrunk expected returns.
      • James-Stein, Bayes-Stein, Bodnar-Okhrin-Parolya. All of them with Grand Mean, Volatility Weighted, Mean Squared Error targets.
    • Equilibrium expected returns.
    • Excess expected returns.
  • Variance.
  • Covariance/Correlation matrix.
    • Custom: estimator + processing pipeline.
    • Pearson: weighted, unweighted, any StatsBase.CovarianceEstimator.
      • Full.
      • Semi.
    • Gerber.
      • Gerber 0, 1, 2. Standardised and unstandardised.
    • Smyth-Broby.
      • Smyth-Broby 0, 1, 2. Standardised and unstandardised.
      • Smyth-Broby-Gerber 0, 1, 2. Standardised and unstandardised.
    • Distance covariance.
    • Lower tail dependence.
    • Kendall.
    • Spearman.
    • Mutual information.
      • Predefined, Hacine-Gharbi-Ravier, Knuth, Scott, Freedman-Draconis bin widths.
    • Coskewness.
      • Full.
      • Semi.
    • Cokurtosis.
      • Full.
      • Semi.
    • Implied volatility.

Regression

Factor models and implied volatility use regression in their estimation.

  • Stepwise.
    • Forward and Backward.
      • P-value, Corrected and “vanilla” Akaike info, Bayesian info, R-squared, and Adjusted R-squared criteria.
  • Dimensional reduction.
    • Principal Component.
    • Probabilistic Principal Component.

Ordered weights array and Linear moments

  • Ordered weights arrays.
    • Gini Mean Difference.
    • Conditional Value at Risk.
    • Weighted Conditional Value at Risk.
    • Tail Gini.
    • Worst Realisation.
    • Range.
    • Conditional Value at Risk Range.
    • Weighted Conditional Value at Risk Range.
    • Tail Gini Range.
  • Linear Moments Convex Risk Measure: linear moments can be combined using different minimisation targets.
    • Normalised Constant Relative Risk Aversion.
    • Minimum Squared Distance.
    • Minimum Sum Squares.

Distance

Distance matrices are used for clustering. They are related to correlation distances, but all positive and with zero diagonal.

  • Distance: these compare pairwise relationships.
  • Distance of distances: these are computed by applying a distance metric to every pair of columns/rows of the distance matrix. They compare the entire space and often give more stable clusters.

Individual entries can be raised to an integer power and scaled according to whether that power is even or odd. The following methods can be used to compute distance matrices.

  • Simple.
  • Absolute.
  • Logarithmic.
  • Correlation.
  • Variation of Information.
    • Predefined, Hacine-Gharbi-Ravier, Knuth, Scott, Freedman-Draconis bin widths.
  • Canonical: depends on the covariance/correlation estimator used.

Phylogeny

These define asset relationships. They can be used to set constraints on and/or compute the relatedness of assets in a portfolio.

  • Clustering.
    • Optimal number of clusters:
      • Predefined, Second order difference, Standardised silhouette scores.
    • Hierarchical clustering.
    • Direct Bubble Hierarchy Trees.
      • Local Global sparsification of the inverse covariance/correlation matrix.
  • Phylogeny matrices.
    • Network (Minimum Spanning Tree) adjacency.
    • Clustering adjacency.
  • Centrality vectors and average centrality.
    • Betweenness, Closeness, Degree, Eigenvector, Katz, Pagerank, Radiality, Stress centrality measures.
  • Asset phylogeny score.

Constraint generation

These let users easily manually or programatically define optimisation constraints.

  • Equation parsing.
  • Linear weights.
  • Risk budget.
  • Asset set matrices.
  • Phylogeny.
    • Phylogeny matrix.
      • Semi definite.
      • Mixed integer programming.
    • Centrality.
  • Weight bounds.
  • Buy-in threshold.

Prior statistics

As previously mentioned, every optimisation but the finite allocation work off of returns data. These returns can be adjusted and summarised using these estimators. Like the moment estimators, these can be mixed in various ways.

  • Empirical.
  • Factor model.
  • High order moments (coskewness and cokurtosis).
  • Black-Litterman.
    • Vanilla.
    • Bayesian.
    • Factor model.
    • Augmented.
  • Entropy pooling.
  • Opinion pooling.

Uncertainty sets

These sets can be used to make some optimisations more robust. Namely, there exist uncertainty sets on expected returns and covariance. They can be used on any optimisation which uses any one of these quantities.

  • Box.
    • Delta.
    • Normally distributed returns.
    • Autoregressive Conditional Heteroskedasticity.
      • Circular, moving, stationary bootstrap.
  • Ellipse uncertainty sets.
    • Normally distributed returns.
    • Autoregressive Conditional Heteroskedasticity.
      • Circular, moving, stationary bootstrap.

Turnover (Rebalancing)

These penalise moving away from a benchmark vector of weights.

  • Risk measure (experimental).
  • Constraints.
  • Fees.

Fees

These encode various types of fees, which can be used in portfolio optimisation and analysis.

  • Relative long.
  • Relative short
  • Fixed long.
  • Fixed short.
  • Turnover (rebalance).

Tracking

These can be used to track the performance of an index, indicator, or portfolio.

  • Risk measure (experimental).
  • Constraints.

There are four things that can be tracked.

  • Returns via L1 or L2 norm.
    • Asset weights.
    • Returns vector.
  • Risk tracking via asset weights.
    • Dependent variables (experimental).
    • Independent variables.

Risk measures

Different optimisations support different risk measures, most measures can also be used to quantify a portfolio’s risk-return characteristics.

  • Variance.
  • Risk Contribution Variance.
    • Asset risk contribution.
    • Factor risk contribution.
  • Uncertainty set variance.
  • Standard deviation.
  • First lower moment.
  • Second lower moment.
    • Semi variance.
    • Semi deviation.
  • Second central moment (historical returns, no covariance matrix).
    • Variance.
    • Standard deviation.
  • Mean absolute deviation.
  • Third lower moment (historical returns, no coskewness matrix).
    • Standardised (semi skewness).
    • Unstandardised.
  • Fourth lower moment (historical returns, no cokurtosis matrix).
    • Standardised (semi kurtosis).
    • Unstandardised.
  • Third central moment (historical returns, no coskewness matrix).
    • Standardised (skewness).
    • Unstandardised.
  • Fourth central moment (historical returns, no cokurtosis matrix).
    • Standardised (kurtosis).
    • Unstandardised.
  • Square root kurtosis.
    • Full.
    • Semi.
  • Negative skewness.
    • Full.
    • Semi (experimental).
  • Negative quadratic skewness.
    • Full.
    • Semi (experimental).
  • Value at Risk.
  • Conditional Value at Risk.
  • Distributionally Robust Conditional Value at Risk.
  • Entropic Value at Risk.
  • Relativistic Value at Risk.
  • Value at Risk Range.
  • Conditional Value at Risk Range.
  • Distributionally Robust Conditional Value at Risk Range.
  • Entropic Value at Risk Range.
  • Relativistic Value at Risk Range.
  • Drawdown at Risk.
    • Absolute (simple returns).
    • Relative (compounded returns).
  • Conditional Drawdown at Risk.
    • Absolute (simple returns).
    • Relative (compounded returns).
  • Entropic Drawdown at Risk.
    • Absolute (simple returns).
    • Relative (compounded returns).
  • Relativistic Drawdown at Risk.
    • Absolute (simple returns).
    • Relative (compounded returns).
  • Ordered Weights Array risk measure.
  • Ordered Weights Array range risk measure.
  • Average Drawdown.
  • Ulcer Index.
  • Maximum Drawdown.
  • Brownian Distance Variance.
  • Worst Realisation.
  • Range.
  • Equal risk.
  • Turnover risk.
  • Tracking risk.
  • Mean return risk.
  • Ratio of measures.

Portfolio statistics

These are used to summarise a portfolio’s risk and return characteristics.

  • Expected returns.
    • Arithmetic.
    • Kelly (Logarithmic).
  • Risk-adjusted return ratio.
    • Vanilla.
    • Sharpe ratio information criterion.
  • Risk contribution.
    • Asset risk contribution.
    • Factor risk contribution.

Optimisation

There are many different optimisation methods, each with different characteristics and configurable options, including exclusive constraint types and risk measures. Though all of them have an optional fallback method in case the optimisation fails.

  • Clustering.
    • Hierarchical Risk Parity.
    • Hierarchical Equal Risk Contribution.
    • Nested Clustered Optimisation.
    • Schur Complement Hierarchical Risk Parity.
  • JuMP-based.
    • Mean Risk.
    • Factor Risk Contribution.
    • Near Optimal Centering.
    • Risk Budgeting.
      • Asset risk budgeting.
      • Factor risk budgeting.
    • Relaxed Risk Budgeting.
  • Stacking.
  • Naive.
    • Inverse volatility.
    • Equal weighted.
    • Random weighted.
  • Finite Allocation.
    • Discrete.
    • Greedy.

Optimisation constraints

Many of these use the various constraint generation mechanisms mentioned above. These constrain the optimisation so the results meet the user’s requirements. Some have specific requirements like a Mixed Integer Programming capable solver, others cannot be used in conjunction with each other, and there exist combinations that make problems infeasible.

  • JuMP-based.
    • Risk constraints.
      • Maximum risk for all supported measures (can be simultaneously provided).
    • Return constraints.
      • Minimum return.
      • Expected return uncertainty set.
    • Pareto front/surface/hypersurface (efficient frontier 2D, 3D, ND).
      • Via risk constraints.
      • Via return constraints.
    • Objective functions.
      • Minimum risk.
      • Maximum utility.
      • Maximum risk-adjusted return ratio.
      • Maximum return.
    • Budget constraints.
      • Long and/or short budget.
        • Exact.
        • Upper and lower bounds.
      • Cost budget.
      • Market impact budget.
    • Weight bounds.
    • Linear weights.
    • Cardinality.
      • Asset.
      • Set.
    • Group cardinality.
      • Asset.
      • Set.
    • Long and short buy-in threshold.
    • Turnover.
    • Fees.
    • Tracking.
    • Phylogeny.
    • Centrality.
    • Regularisation.
      • L1.
      • L2.
    • Custom: via subtyping and multiple dispatch.
      • Constraint.
      • Objective.
      • Objective penalty.
  • Non-JuMP-based.
    • Weight bounds.
      • Upper.
      • Lower.
    • Weight finaliser.
  • Optimisers without a fixed risk measure.
    • Scalarisers for multiple simultaneous risk measures.
      • Weighted sum.
      • Max risk.
      • LogSumExp.

Plotting

  • Simple or compound cumulative returns.
    • Portfolio.
    • Assets.
  • Portfolio composition.
    • Single portfolio.
    • Multi portfolio.
      • Stacked bar.
      • Stacked area.
  • Risk contribution.
    • Asset risk contribution.
    • Factor risk contribution.
  • Asset dendrogram.
  • Asset clusters + optional dendrogram.
  • Simple or compound drawdowns.
  • Portfolio returns histogram + density.
  • 2/3D risk measure scatter plots.

I’ve done a fair bit of work in this area and can testify that:

attempt at providing as many as possible

you weren’t exaggerating! That is a very comprehensive list of techniques. This is a great resource, I look forward to digging into it. Thank you!

Glad you like it.

I’m very open to suggestions and contributions.

I’d also like to add stuff like synthetic returns, but i gotta wait for Copulas.jl to add vines. Adding cross validation would also be nice.

PortfolioOptimisers.jl v0.14.2 Release

Breaking changes

  • Lots of quality of life breaking releases since the inaugural post.
    • Better names for abstract and concrete types.
    • Shorter and unambiguous fieldnames.

Bug fixes

  • Lots of minor and quality of life bug fixes. Most don’t affect any results aside from disallowing certain types/values that would error further down the chain rather than at instantiation.
  • One pretty big bug one relating to high order prior statistics using low order factor prior statistics.

New features

  • New risk measures and formulations.
  • Enable existing measures on more optimisation types.

Docs

  • Lots of new API docs, including instructions on defining interfaces for new estimators and algorithms.
  • A few new examples.
  • Better readmes.

Project maturity

  • Stabler API, still expect breaking changes with v0.X releases.

Roadmap

I don’t have a timeline or a serious plan, but there are a few features I’d like the package to have. In order of desirability:

  1. Cross validation prediction.
  2. Pipelines, i.e. data validation → optimisation → prediction → post-processing
  3. Report generation.
    3a. Better plots.
    3b. Perhaps excel output.
  4. A non-shitpost logo (though tbh I kinda like that it’s a shitpost).

Feedback and contributions welcome

The package is quite functional, but it is missing a lot of docs and sample use. If anyone out there is using/wants to use the package I’d love to hear about how it can be improved and would love some contributions/help with implementation and design.

PortfolioOptimisers.jl v0.17.0

Breaking changes

  • Renamed a few internal fields to make it clear they are incompatible with more advanced features like cross validation and the outer optimisation on nested clusters and stacking optimisers. This is because they need to generate returns data dynamically according to the cross validation folds/inner optimisation results.

New features

Cross validation and hyper parameter tuning come with a simple example each.

  • Cross validation.
    • KFold: basic splitting into training and testing sets.
    • Walk Forward: enables dynamic optimisation with periodic rebalancing.
      • Index based.
      • Date based: It’s now possible to rebalance on weird dates like the 2nd tuesday of every 3 weeks or something equally arbitrary.
    • Combinatorial: combinatorially generated paths for much more robustness.
    • Multiple Randomised: temporal and cross sectional (sampling the asset universe) walk forward path randomisation for much more robustness but less generality.
  • Ability to define which constraints which use benchmark weights can carry forward previous optimisation weights in walk forward and multiple randomised cross validation.
  • Time-dependent constraints to be used in walk forward and multiple randomised cross validation. None have been implemented as I didn’t have the creativity, but the interface exists and is implemented.
  • NestedClusters and Stacking optimisers can now use cross validation to predict the returns to be used in their outer estimators.
  • Hyper parameter tuning: it’s now possible to evaluate out-of-sample values for all the different non-finite allocation optimisers which eliminates much of the guesswork.
    • Grid search cross validation.
    • Randomised search cross validation.
  • Prediction machinery. These came with the cross validation and are the predicted performance of an optimisation, they are the out-of-sample performance of an optimisation.

Plans

I’ve been burning the candle at both ends so I’ll ease off from my anxiety-induced freneticism to make the package “feature complete”. I classify the remaining features as nice-to-haves. For now the top priority would be the API docs and improved examples.

Features

  • Pipelines, most of the hard work is done. And this isn’t a game-changing feature, more of a nice to have. I’ll implement them eventually.
  • Synthetic returns prior. I need a package to implement vine copulas.
  • Expand the hyperparameter tuning to do adaptive refinement of parameters.
  • Adding more quality of life features like summaries, reports, post-processing bells and whistles (intersects heavily with plots).

Documentation

  • API: I just discovered DocStringExtensions.jl, but I’m having trouble implementing my own templates (if anyone has an example please help a brother out and point me to it). I’d have to redo a bunch of the docs I already have, but I think it’s worth it. Especially for a package like this one.
  • Examples: more examples with more plots and fewer tables would be nice. This means I have to implement the plots though.
  • Formalisms and citations. I really need to keep adding these, there are just so many features that it’s overwhelming at times, but I’ll be passively adding these as patch releases.

Plotting

  • More plots in general with nicer APIs, especially now that cross validation and prediction features have been added to the package, it would be nice to have plots that show how the predictions behave as a function of time.

Future breaking changes

  • I never expect more breaking changes, but there always are. However, they should slow down now. I’m liking how the APIs for the important parts have been turning out. However, there’s always a better name for a property or a type and I never hesitate to make the change if I think it’ll make interfacing with the package more predictable and logical.

Feedback and contributions always welcome

As always, ideas, contributions and criticisms are always welcome.

PortfolioOptimisers.jl v0.19.0

Highlights

New features

Breaking

  • Added benchmark prices. They can be specified per asset (as a matrix), or as an index (as a vector). This enables optimising on excess returns which is a form of tracking. It’s compatible with every optimiser, cross validation, and prediction. Breaking because I had to add a flag to all optimisation estimation structs even though it didn’t break existing code or APIs.
  • Improved the consistency of various moment estimators which internally compute the variance. They also used to contain expected returns estimators, but we now use the ones inside the variance estimator because the expected returns estimator was computing the mean used to compute the variance.
  • expend_train -> expand_train keyword in WalkForwardEstimator subtypes to make it consistent with reduce_test.

Non-breaking

  • SubsetResampling optimiser. This takes random, unique, samples of the asset universe and optimises each one in turn. The final weights are the (optionally weighted) average weights of each asset in each optimisation. If an asset wasn’t sampled for a given optimisation, its weight is taken to be zero. It interpolates between the equal weighted portfolio and the pure internal optimiser. It’s quite cool, i like this optimiser a lot because it adds noise that reduces overfitting for mid assets, but buries shit ones and lets great ones shine through. It’s like a targeted yet stochastic combination of the L_n penalty families.
  • LogRiskBudgeting what used to be the normal risk budgeting algorithm can now accept an orthant vector, which means risk budgeting portfolios can now take on negative weights. But they have to be specified a priori. Limited to asset risk budgeting, factor risk budgeting already allows for negative asset weights. It might be possible to generalise but i haven’t thought it through.
  • MixedIntegerRiskBudgeting explores all orthants to get a low risk portfolion that meets the risk budget threshold. It’s very expensive because there are 2^N sign combinations, where N is the number of assets. You don’t have to specify the orthant vector, it is obtained on the fly. It also doesn’t guarantee the lowest risk portfolio simply because the MIP optimiser might give up depending on your settings. Limited to asset risk budgeting, factor risk budgeting already allows for negative asset weights. It might be possible to generalise but i haven’t thought it through.
  • Windowed variants of all moment estimators. They simply wrap one of the already provided estimators. You can provide a window which can be an integer to take the last window number of entries, or a vector to index those specific indices. The moments will be computed based on that window rather than the entire history. They also support weighted estimators by currying the outer weights inward with the right indexing.

Docs

  • Added a quick start guide.
  • I’ve added more docstring examples and defined some of the necessary interfaces one would need to implement to add custom behaviour.
  • I caved and unleashed the clankers on the API docs. I only asked for API documentation of function signatures, variable, type and function explanations. Almost 18k lines later here we are. It seems to have done a fairly good job, but one does not simply read 18k lines and find all issues at once. But at least, the APIs are now documented so people can actually use the entire library without looking at the code. I will methodically go over the new docs to improve their quality.

Bug fixes

  • Fixed a bug in risk budgeting optimisers where constraints were being correctly built from the wrong variable, which meant unnecessary coupling between systems that also needlessly made certain constraint combinations impossible to define.

Future plans

  • Improve the documentation.
    • The clanker-made docs are competent, but not to the standard I’d like.
    • They’re missing interface definitions, sources, language standardisation, nicer mathematical notation, not all methods are documented (mainly internal ones).
    • Split the API docs into public and private.
    • More examples and entries to the user guide.
  • Plots, this is a sore point for me.
    • I have had trouble with Makie.jl due to its less than stellar support for dendrograms.
    • Plots has the problem that I have to load GraphRecipes.jl and StatsPlots.jl to load the extension, which is less than ideal.
    • I’d like to have interactive plots with mouse-over labels but that would lock me into Plotly
    • The API of my plotting functions sucks and I haven’t figured out a way to make it better. Recipes seem fragile but I’ll maybe ask a clanker to figure it out for me.
    • Need plots for prediction results, not just optimisation results.
  • Cross sectional factor prior.
  • Copula based synthetic returns. Needs vine copulas, I’ve not found any Julia implementation.
  • Remove python dependencies. Needs bootstrapping and bin width computation functions, but I’ve found no suitable Julia library that does either of these.
  • Pipelines.
  • Online statistical methods maybe? Though I’m not sure how to make most of the current ones can be updated. I’m inclined to let it ride as it is, it may not be worth it. Unless someone else wants to give it a stab and open a PR.

You can now do ridiculous things like this. I don’t see why anyone would, but you can. Seriously, don’t try this, it’ll probably run for hours. This is barely scratching the surface of all the features in the library.

using PortfolioOptimisers, StatsPlots, GraphRecipes, Clarabel, HiGHS, Pajarito, JuMP,
      YFinance, TimeSeries, DataFrames

# Use MIP solvers because we will use buy in threshold constraints.
Highs_resetGlobalScheduler(1)
slv = [Solver(; name = :mip1,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip2,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.95)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip3,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.90)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip4,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.85)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip5,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.80)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip6,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.75)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip7,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.7)),
              check_sol = (; allow_local = true, allow_almost = true)),
       Solver(; name = :mip8,
              solver = optimizer_with_attributes(Pajarito.Optimizer,
                                                 JuMP.MOI.Silent() => false,
                                                 "oa_solver" => optimizer_with_attributes(HiGHS.Optimizer,
                                                                                          JuMP.MOI.Silent() => false,
                                                                                          "threads" => 6),
                                                 "conic_solver" => optimizer_with_attributes(Clarabel.Optimizer,
                                                                                             JuMP.MOI.Silent() => false,
                                                                                             "max_step_fraction" => 0.6,
                                                                                             "max_iter" => 1500,
                                                                                             "tol_gap_abs" => 1e-4,
                                                                                             "tol_gap_rel" => 1e-4,
                                                                                             "tol_ktratio" => 1e-3,
                                                                                             "tol_feas" => 1e-4,
                                                                                             "tol_infeas_abs" => 1e-4,
                                                                                             "tol_infeas_rel" => 1e-4,
                                                                                             "reduced_tol_gap_abs" => 1e-4,
                                                                                             "reduced_tol_gap_rel" => 1e-4,
                                                                                             "reduced_tol_ktratio" => 1e-3,
                                                                                             "reduced_tol_feas" => 1e-4,
                                                                                             "reduced_tol_infeas_abs" => 1e-4,
                                                                                             "reduced_tol_infeas_rel" => 1e-4)),
              check_sol = (; allow_local = true, allow_almost = true))];

# Convert YFinance data to TimeArray.
function stock_price_to_time_array(x)
    # Only get the keys that are not ticker or datetime.
    coln = collect(keys(x))[3:end]
    # Convert the dictionary into a matrix.
    m = hcat([x[k] for k in coln]...)
    return TimeArray(x["timestamp"], m, Symbol.(coln), x["ticker"])
end

# We'll use PCA regression for factor modelling.
assets = sort!(["JCI", "TGT", "CMCSA", "CPB", "MO", "APA", "MRSH", "JPM", "ZION", "PSA",
                "BAX", "BMY", "LUV", "PCAR", "TXT", "TMO", "DE", "MSFT", "HPQ", "SEE", "VZ",
                "CNP", "NI", "T", "BA"]);
factors = sort!(["MTUM", "QUAL", "VLUE", "SIZE", "USMV"]);

Date_0 = "2024-01-01";
Date_1 = "2026-10-05";

# Download the price data using YFinance.
prices = get_prices.([assets; factors]; startdt = Date_0, enddt = Date_1);
prices = stock_price_to_time_array.(prices);
prices = hcat(prices...);
cidx = colnames(prices)[occursin.(r"adj", string.(colnames(prices)))];
prices = prices[cidx];
TimeSeries.rename!(prices, Symbol.([assets; factors]));

# Compute returns.
rd = prices_to_returns(prices[Symbol.(assets)], prices[Symbol.(factors)]);

# Asset sets
sets = AssetSets(; dict = Dict("nx" => rd.nx));

# Covariance estimator.
ce = PortfolioOptimisersCovariance(; ce = SmythBrobyCovariance());

# Expected returns estimator.
me = ShrunkExpectedReturns(; ce = ce);

# Clusters estimator.
cle = ClustersEstimator(; ce = ce);

# Cross validation.
n_folds, n_test_folds = optimal_number_folds(length(rd.ts), 60, 10);
cv = OptimisationCrossValidation(;
                                 cv = CombinatorialCrossValidation(; n_folds = n_folds,
                                                                   n_test_folds = n_test_folds,
                                                                   purged_size = 3,
                                                                   embargo_size = 7));

# When used in optimisations, vectors of risk measures 
# get scalarised via one of three methods (default is weighted sum).
# Expensive JuMP compatible risk measures. 
jr0 = [NegativeSkewness(; settings = RiskMeasureSettings(; scale = 20)),
       RelativisticDrawdownatRisk()];
jr1 = [Kurtosis(; settings = RiskMeasureSettings(; scale = 30)), RelativisticValueatRisk()];

# Clustering risk measures.
hr0 = [MedianAbsoluteDeviation(; settings = HierarchicalRiskMeasureSettings(; scale = 15)),
       RelativeEntropicDrawdownatRisk()];

# Prior statistics.
pe = HighOrderFactorPriorEstimator(;
                                   pe = FactorPrior(;
                                                    pe = EmpiricalPrior(; me = me, ce = ce),
                                                    re = DimensionReductionRegression()));
# Weight bounds
wb = WeightBounds(; lb = -1, ub = 1);

# Buy in threshold (MIP constraint)
st = Threshold(; val = 0.1);
lt = ThresholdEstimator(; val = UniformValues());

# Base optimisers.
jopt = JuMPOptimiser(; pe = pe, slv = slv, wb = wb, sbgt = 1, lt = lt, st = st, sets = sets);
hopt = HierarchicalOptimiser(; pe = pe, cle = cle, cle_pr = false, slv = slv);

# JuMP optimisers.
jopt0 = NearOptimalCentering(; r = jr0, obj = MaximumRatio(; rf = 4.2 / 252 / 100),
                             opt = jopt);
jopt1 = MeanRisk(; r = jr1, obj = MaximumRatio(; rf = 4.2 / 252 / 100), opt = jopt);

# Hierarchical optimisers.
hopt0 = HierarchicalEqualRiskContribution(; ri = hr0, ro = jr0, opt = hopt);

# Clustering optimiser.
nopt = NestedClustered(; pe = pe, wb = wb, cle = cle, cle_pr = false, opti = jopt0,
                       opto = jopt0, cv = cv);

# Stacking optimiser.
sopt = Stacking(; pe = pe, wb = wb, opto = jopt0, opti = [jopt0, jopt1, hopt0, nopt],
                cv = cv);

# Subset resampling optimiser.
rsopt = SubsetResampling(; pe = pe, wb = wb, opt = sopt, subset_size = 0.9, n_subsets = 10);

# Optimise
res = optimise(rsopt, rd)

PortfolioOptimisers.jl v0.24.0

PortfolioOptimisers.jl builds portfolios through a data → moments → prior → optimisation → post-processing pipeline. This release is a maturity pass on that pipeline: the Python dependency is gone, failure diagnostics from nested optimisers are now structured instead of stringly-typed, the equation parser that reads untrusted constraint/view strings got a real security review, and a handful of long-standing naming inconsistencies were cleaned up (breaking, but with a one-line fix each).

Highlights

  • Examples and user guide: There is now a comprehensive set of deep examples and a shallow user guide that showcase the library’s features.

  • Better compatibility documentation: There is now a table of solver requirements and suggestions for the various risk measures in the base risk measures API page. There are plans to do the same with optimisation constraints.

  • Aliases: There is now a large set of convenience aliases which are a suprising quality of life improvement. There is a dedicated page for them.

  • No more Python: Histogram bin selection and block-bootstrap uncertainty sets used to shell out to astropy and arch via PythonCall/CondaPkg. Both are now native Julia, one less interpreter, two fewer PyPI/conda packages, and no more CondaPkg.toml in the repo.

  • Structured failure diagnostics: Nested optimisers (NearOptimalCentering, Stacking/meta-optimisation, SubsetResampling) used to collapse every sub-solve failure into one opaque string. They now propagate the individual OptimisationFailure objects from each failed sub-problem, so you can actually see which stage failed and why.

  • Risk-measure/optimiser compatibility is now a trait, not prose: supports_risk_measure(::Type{<:Opt}, ::Type{<:RM}) and supported_risk_measures(::Type{<:Opt}) are real, dispatchable functions, and the user-guide compatibility table is now generated from them, so the docs and the dispatch logic can’t drift apart.

  • Type hierarchy tree: an automatically generated type hierarchy tree with links to the API docs.

  • The equation parser got a real security pass: Constraint equations, Black-Litterman views, and entropy-pooling views are untrusted strings by design (they come from config files, spreadsheets, UIs). The parser’s callable-function resolution was rewritten from “gate a name, then resolve it against all of Base” to an explicit enumerated allowlist, and string length / AST depth are now capped to close a stack-exhaustion denial-of-service path.

  • Global configuration via Preferences.jl: Pretty-printing collapse, fuzzy “did you mean?” suggestion distance, and equation-parser resource caps are now ScopedConfig holders: thread-safe, atomically-swapped global defaults with task-scoped with\_\* overrides, seedable per-project via Preferences.jl.

  • LxNorm generalizes beyond tracking error: The L1/L2/SquaredL2/Lp/LInf norm family moved out of the tracking-error file into 01_Base.jl as a shared primitive, which is what let entropy pooling reconcile multiple CVaR views using the same norms instead of a hard-coded, uncustomisable LinearAlgebra.norm.

  • A dozen Gerber/Smyth-Broby “marker” types collapsed into one kernel: SmythBrobyKernel replaces Gerber0/1/2, SmythBroby0/1/2, SmythBrobyGerber0/1/2, SmythBrobyCount0/1/2, same two orthogonal axes (normalisation variant × scoring family), one accumulation loop instead of ~980 lines of near-duplicated code.

  • Matrix post processing pipeline simplified and made much more extensible.

  • Gerber Information Quality: new GerberIQ statistic for correlation and covariance estimation. It uses a template based system to compress co-movement information with a low signal-to-noise ratio.

Breaking Changes & Migration Guide

Matrix Processing simplified

  • DenoiseDetoneAlgMatrixProcessingMatrixProcessing and the associated permutation types removed in favour of a tuple which defines what and in which order the processing steps are applied.

FiniteAllocationInput replaces positional allocator arguments

optimise(allocator, w, prices, cash, T, fees) is now optimise(allocator, FiniteAllocationInput(; w, prices, cash)). The shared problem data for both finite-allocation optimisers (DiscreteAllocation, GreedyAllocation) is bundled into one struct instead of loose positionals.

# before
greedy = optimise(GreedyAllocation(), res.w, prices, cash)

# after
greedy = optimise(GreedyAllocation(), FiniteAllocationInput(res.w, prices, cash))

# alias
greedy = optimise(GreedyAllocation(), FAI(res.w, prices, cash))

LxTracking family renamed to LxNorm

L1Tracking, L2Tracking, SquaredL2Tracking, LpTracking, LInfTrackingL1Norm, L2Norm, SquaredL2Norm, LpNorm, LInfNorm. Straight rename, same semantics, just find/replace.

# before
alg = L1Tracking()

# after
alg = L1Norm()

Disambiguation renames

  • FullFullMoment, SemiSemiMoment (the lower/higher partial moment algorithm selector).

  • ForwardForwardSelection, BackwardBackwardElimination (stepwise regression algorithms).

  • RiskRatioRiskMeasureRiskRatio.

  • NonOptimisationRiskRatioRiskMeasureNonOptimisationRiskRatio.

# before
r = LowOrderMoment(; alg = SecondMoment(; alg1 = Semi(), alg2 = SOCRiskExpr()))

# after
r = LowOrderMoment(; alg = SecondMoment(; alg1 = SemiMoment(), alg2 = SOCRiskExpr()))

The rule going forward (see ADR 0015): a type is named bare unless another, non-sibling construct already owns that word, RiskMeasure/Estimator/Result suffixes exist only to disambiguate, never as a blanket category marker.

CombinatorialCrossValidation

  • warn_combmax_comb, and it now throws

Previously, exceeding warn_comb (default 100,000 combinations) logged a warning and proceeded anyway. It’s now max_comb, and exceeding it raises an ArgumentError instead.

Removed defaults that caused friction

A few default keyword values were removed in favor of requiring an explicit choice at the call site, if you were relying on an implicit default in 10_JuMPModelOptimisation.jl or the moment/covariance estimators touched by the ergonomics pass, you’ll need to pass the value explicitly now.

Python dependency removed

If you were constructing AstroPyBins directly, or depended on the arch-package-backed block bootstrap in 04_BootstrapUncertaintySets.jl, those code paths are now native-Julia reimplementations under the same public API, no PythonCall/CondaPkg install required, but check numerical output if you had pinned exact Python-library behavior.

Bug Fixes

  • HERC’s LogSumExpScalariser path was wrong: The per-optimiser copy of the log-sum-exp reduction in HierarchicalEqualRiskContribution applied the reduction over the wrong risk slice, producing incorrect scalarised weights. Fixed as part of consolidating all scalariser logic into one scalarise seam; the reference test fixture changed accordingly.

  • SubsetResampling risk-measure support fixed: correctly reports/enforces which risk measures it supports, and raises ArgumentError (rather than a deeper MethodError) for unsupported ones.

  • Uncertainty-set variance handling made consistent: its risk computation matches its optimisation formulation, making it consistent with hierarchical optimisers and NearOptimalCentering.

  • Recursion stack overflow fixed: a max-recursion and max-string-length cap added to parse_length to prevent accidental stack overflows.

  • Nondeterministic logging failures fixed: log output no longer varies run-to-run in ways that broke tests or made warnings unreliable.

  • One scalarise seam: HierarchicalRiskParity, HierarchicalEqualRiskContribution, and NearOptimalCentering each carried their own copy of vector-of-risk-measures-to-scalar reduction logic (~20 near-identical methods across scalariser type × shape). Consolidating them also surfaced and fixed a real bug.

Usability Improvements

  • Better, bounded failure messages: JuMPResult now builds its failure @warn via a shared failed_solve_msg builder: one bounded line per failed solver stage (name, stage, first line of the error) instead of dumping the entire trials dict, solver settings, and full exception payloads into the log. The full diagnostics are still available on .trials if you need them.

  • Fuzzy “did you mean?” suggestions for unrecognised variable names in constraint/view strings, tunable via set_string_distance!/with_string_distance (distance metric and minimum similarity score).

  • Scoped configuration overrides: with_compact_show, with_string_distance, and with_equation_limits let you override a global setting for the dynamic extent of a single call, e.g. silencing “did you mean?” suggestions inside a meta-optimiser’s inner loop (where an asset legitimately absent from a subset isn’t a typo) without touching the global default or affecting concurrent tasks.

  • Per-project defaults via Preferences.jl: The above configuration knobs can now be seeded once per project instead of called at every session start.

  • Improved error messages: in the phylogeny, prior, and uncertainty-set base modules.

  • A generated, always-current risk-measure/optimiser compatibility table in the user guide (see Highlights).

  • Lenient constraint parsing kept, with better diagnostics: rather than making a misspelled asset name in a constraint string fail closed by default (which would break the legitimate meta-optimiser pattern of writing one constraint over the full universe and letting it apply partially to subsets), the lenient default stayed, and the warning it produces was made clearer instead.

New Features

  • SmythBrobyKernel: unifies the Gerber/Smyth-Broby co-movement covariance family behind one data-carrying algorithm struct, replacing ~12 separate marker types.

  • GerberIQ: provides a new co-movement based covariance and correlation estimator.

  • AbstractMIPSpace: unifies asset-space and sub-group (SMIP) mixed-integer constraint construction over a single dispatchable abstraction.

  • NormError / LxNorm: promoted to a shared 01_Base.jl primitive, and entropy pooling can now reconcile multiple CVaR entropy pooling views using any of the LxNorm family (previously hard-coded to a single, uncustomisable norm).

  • Covariance entropy pooling views.

  • ProcessedAttributes / ProcessedRiskBudgetingAttributes abstract supertypes added to give the processed-attribute struct hierarchy (JuMP optimiser and risk-budgeting variants) an actual shared type instead of everything sitting directly under AbstractResult.

  • New/expanded examples: including 2_moments_priors/10_Windowed, 7_putting/04_Factor_Views, and general rebuilds across the example set to track the renames above.

  • 27 ADRs now document the architecture decisions behind this release, from the pipeline’s core Estimator/Algorithm/Result/Factory/View vocabulary to the security-hardening rationale, for anyone extending the library.

The documentation badges in your README.md on the front page of your github, lead to 404 errors. I’m getting the links below:

https://dcelisgarza.github.io/PortfolioOptimisers.jl/stable/
https://dcelisgarza.github.io/PortfolioOptimisers.jl/dev/

Landing pages work again. Thanks for the report @loisel .

Turns out I had renamed the root pages and documenter didn’t like that. It’s fixed now. The docs had deployed and other links worked, as well as the navigation to the landing page. But the web address of the landing page was now dofferent. I’ve fixed it and added some code to make sure that doesn’t happen again.

I’m an investment professional, so your package picked my curiosity. If I may ask, do you use it personally, and it what context? Who is the (future) intended audience?

I have personally used a previous version. It’s archived and was never registered because it had some ugly spaghetti code that made it increasingly difficult to develop. So I rewrote it from scratch to make it more maintainable, testable, and robust.

I had 3 long-only portfolios (myself, my mum and my partner) over 2-3 years starting in 2022. They all beat the market by quite a margin, especially in 2022 where they made money when the S&P lost about 20%. We had to take out the money out earlier this year for a house deposit.

My goal is to make it useful for anyone regardless of expertise level. I’ve been mostly focusing on adding features, and only now that it’s very feature rich am i adding convenience functionality (mainly aliases).

I’m about to release a new version which adds pipelines and the ability to perform cross validations and hyper-parameter tuning on them. That’s to say everything from asset selection to priors, to clustering, to optimisations.

I’ll then move on to working on enabling time-dependent constraints. So it’s possible to provide either a vector or a function which will vary the huge number of constraints depending on the walk-forward fold being computed.

I also want to have vine-copula based synthetic returns generation for a synthetic data prior. And cross-sectional factor priors.

PortfolioOptimisers.jl v0.25.0

Thanks to the clanker currently known as Fable, I finally had the energy and rote work capacity to rather quickly implement, test, and document three long-standing, major pieces of functionality I’ve wanted to have for a long time.

Asset pre-selection

I have to be honest, this was in the backlog because it’s not complicated or exciting. I had some scaffolding in place (bare functions) but with a bit of prompting there is now a nice, general set of asset pre-selectors.

They make use of existing machinery to filter out assets without going through costly optimisations. More will come in time, but the necessary plumbing and structural code is there for quickly implementing new ones. Here’s a short example.

Pipelines

This is a long time coming, it lets you do every process from a simple fit call. It also enables cross validation and hyperparameter tuning on the entire process explicitly rather than through optimisations. It also comes with an accompanying example.

Time dependent constraints

It’s now possible to provide time-dependent constraints. These let users apply different constraints to different cross-validation folds. They come in two different flavours:

  • Static: These are predefined a-priori and applied per fold.
  • Dynamic: These are computed on the fly using all the information available at a given fold. They can be provided as functions or as callable structs.

Both support providing vectors of constraints, so at each fold you can apply multiples of the same type of constraints. Here’s its example.

This machinery unlocks other potential capabilities such as varying the objective function and even risk measure per fold, different inner optimisers per fold, etc. But I have to think about it, probably with the help of the clanker who will be formerly known as Fable in the not too distant future.

PortfolioOptimisers.jl v0.26.0

Many people are saying it, loooots of people saying it, this is beeg, yuge, even. Very good looking, real capable piece of software, no bugs, zero bugs, purrrfect. If it weren’t my library I’d smooch it, grab it by the private API and not even quaahleefy the accesses. Big beautiful PR, some would say the biggest, wouldn’t you say, that’s right, I like you you know that. No I didn’t vibe code this, this all in my brain, beeg very smooth, really smooth brain, you’d kiss it if you saw it, go ahead, yeaaahh it’s alright.

Anyway with that nice reflection written by the orange goblin living rent-free in my head, I bring the very fruitful culmination of what I’m starting to discover is a very effective clanker-human collaboration. It’s like having an incredible copy editor who remembers to dot and cross all the T’s and I’s you forgot and really don’t want to meticulously go round and do. And it then takes your sorry ass tests and expands them so they cover all the bases, and documents your APIs because it’s now got enough in its memory to know what things are and what the standards are.

This brings four new features, but one is pretty big. Lets start with the small one.

Small features

  • I added some new weight norm constraints that bound the number of effective assets according to some given norm.

  • Expanded weight bounds to be able to be automatically generated by providing an AbstractEstimatorValueAlgorithm. I’ll see if the same mechanism can be applied to other constraints.

  • train_test_split is finally a thing, comes with its own example (might still be building).

Breaking

  • neaw2n: just a rename to bring it in line with the rest of its family of constraints.
  • l2 which adds/subtracts an L2 norm penalty to the objective used to take a number but now takes an L2Regularisation or a vector of them because I decided to offer different formulations and the code already existed.

Non-breaking

  • wnp: bounds the number of effective assets but instead of using an L2-norm uses an Lp-norm or vector of them.
  • wninf: does the same but uses the infinity norm, so the effective number of assets is bound by the reciprocal of the largest absolute weight.

Major pipeline improvements

Optimisers and all their fields can be time-dependent

I don’t think there’s another library out there that can do this. There’s skfolio with some extra prior statistics computations. But that’s mainly Julia’s lack of regression support like scikit learn, or the up until recently lack of vine copulas in the ecosystem. There’s also Riskfolio which is the goat for how many risk measures that man puts out, but it’s much more static than either skfolio or PortfolioOptimisers.jl. But I think this might be the only one capable of doing something so powerful.

It’s now possible to tag any field in any optimiser, or indeed full optimisers as TimeDependent such that they can be swapped in a cross-validation fold. This unlocks some real potential, especially when combined with the callable struct formulation or a function formulation (essentially the same but a callable struct can keep state in a non-global). This essentially enables call-backs for every single constraint in any cross validation fold, including parameter sweeps.

There’s an example (might still be building) which shows how you can detect a regime switch and change to a more robust optimiser on the fly for that fold.

But that’s not all, it’s possible to do this with full optimisers as well. It unlocks being able to tune hyperparameters or train an entire model under different regimes.

The result of a pipeline is a prediction, which is a returns vector (the portfolio returns) we’ll call R. Predictions don’t have a vector of portfolio weights because they are made of various different portfolios at different times (folds), and those returns are concatenated into R. But if we take the asset returns series for the training period X, we can compute the would-be portfolio weights by inverting the usual relationship:

R = X * w -> w = pinv(X) * R

I think I’ll add this to the relevant structs, or perhaps have it as a separate function. I need to think about how to best do it because it may differ between prediction structures. But it will definitely be added once i have a better understanding, i’ll ask my clanker mate to help me prototype and work through it together. This would make it so predictions from pipelines and validators can actually serve as a kind of meta-meta optimiser.

Pipelines starting from prices

I went back and forth on whether I wanted this, but in the end my the vibes coming from my fuck it, we ball gremlin beat those from my it’s so over one with “facts and logic” (just do it bro). So it’s now also possible to declare a pipeline that will take prices as a starting point instead of returns. They may not behave as well in cross-validation, as each fold would recompute its returns at the start of the fold, but that may also useful in some contexts. As I said, I was in two minds about it, but I’ll allow it. It offers more flexibility.

Final remarks

I think the library’s now very usable. The examples and user guide are quite well-fleshed out. Anything more is going to be extra credit. Honestly Claude and Kaimon.jl have sped things up substantially, it’s much more fun when you don’t have to write the entire test suite yourself and hunt down missing methods/hidden typos or plugging in all the piping only to realise you missed one bit, or the type parameters you defined are too narrow/broad. It has greatly helped catch all of those small mistakes and it’s been much easier and painless to prototype and tweak things. 10/10 would recommend.

Haven’t used the package yet, but just wanted to comment and remark how nicely documented everything is!

I’m gonna take credit for that because I was writing the API docs by hand and I was crashing out over how many there were and how slowly that was going. I think i’d written about 200, and there were at least another ~400 to go (symbols that is, so that’s an undercount). So I wrote some standards and got clankers to write the API docs.

They still have a long way to go, they’re missing references, some get the meaning of certain variables wrong, some of the maths could be better typeset. But damnit it, it helped me so much and freed me up to do other things.

Also any and all feedback is welcome @Snowy , I’m adding convenience aliases and it would be good to know what I should prioritise. Also any suggestions on making the docs easier to use is greatly appreciated.