quanttoolbox.portfolio¶
portfolio.risk_budgeting¶
Python alternatives
Keep — the single largest and most-tested piece of custom work in the whole port (75+ original MATLAB files consolidated). riskparityportfolio covers basic risk parity but not the box-constrained/general-linear-constrained/VaR-ES/target-matching breadth here. Genuinely the strongest "keep" case in the whole library.
quanttoolbox.portfolio.risk_budgeting
¶
Risk budgeting / risk parity portfolio construction.
Ported from QuantToolBox/rpb/{compute_risk_contribution,compute_rc_sd, compute_rc_vol,compute_rb_sd,compute_rb_sd_ccd,compute_rb_sd_newton, lagrange_rb_sd,compute_erc_portfolio}.m and QuantToolBox/crb/compute_rb_sd_bc_admm1*.m (+ ~40 near-duplicate ADMM variants), and QuantToolBox/mloapa/compute_ERC_{ADMM,CCD}.m.
Consolidation notes -- this is the single largest simplification in the whole port:
The original has ~75 files across rpb/ and crb/ implementing risk budgeting under different combinations of {solver algorithm} x {constraint type}. The solver algorithms (CCD, Newton, ADMM+Newton, ADMM+CCD, ADMM+QP, ADMM+fmincon, bisection-on-lambda) are all just different numerical routes to the same mathematical solution -- they don't change what problem is being solved, only how. This module ports:
risk_contribution-- the risk-decomposition arithmetic shared by every variant (consolidates compute_risk_contribution/compute_rc_sd/ compute_rc_vol).solve_unconstrained-- CCD (default, Roncalli's cyclical coordinate descent -- the standard reference algorithm) or Newton, for the classic budget-constrained-only (sum(x)=1) risk budgeting problem.solve_box_constrained-- ADMM (Newton-based x-update, closed-form box-projection z-update, bisection on the budget-constraint Lagrange multiplier lambda) for box-constrained [x_minus, x_plus] risk budgeting. This one function replaces the entire ~40-file family of crb/compute_rb_sd_bc_admm{1,2,3,4}(_lambda).m and crb/compute_rb_sd_bc_ccd(_lambda).m variants, which differ only in which inner solver (Newton/CCD/QP/fmincon) handles the ADMM x-update or whether lambda is searched via bisection vs. passed directly -- all converge to the same box-constrained solution.solve_constrained-- the same ADMM structure, generalized to any combination of extra linear equality/inequality constraints and box bounds (the z-update projects onto their intersection viaoptim.proximal.proximal_linear_constraints's Dykstra algorithm instead of a closed-form box clip). Consolidates the crb/compute_rb_sd_constrained_admm*.m family.solve_box_constrainedis now a thin wrapper around this for the box-only case.solve_unconstrained_var/solve_unconstrained_es-- Value-at-Risk / Expected-Shortfall risk budgeting under a Gaussian assumption, by mapping the confidence level alpha to the equivalent standard-deviation multiplier c and reusingsolve_unconstraineddirectly (consolidates compute_rb_var.m/compute_rb_es.m/ compute_rc_var.m/compute_rc_es.m -- each just a 5-line wrapper in the original, computing a different scalar multiplier).erc_portfolio-- Equal Risk Contribution, the b=1/n special case (consolidates compute_erc_portfolio and mloapa/compute_ERC_{ADMM,CCD}.m, which are just the unconstrained solver called with equal budgets).risk_budgeting_target-- bisects the risk-aversion parameter c to hit a target active return or active volatility (relative to an optional benchmark), covering the "mu-problem"/"sigma-problem" modes of compute_risk_parity_portfolio.m/compute_risk_parity_portfolio_bounds.m. Works with any of the three solvers above via asolver=argument ("unconstrained"/"box"/"constrained"), consolidating both original functions (and their four small _return/_volatility objective-function helper files) into one ~80-line implementation built onquanttoolbox.optim.bisection-- versus the originals' ~550 lines combined.
MATLAB's global RB_CCD_*/RB_Newton_*/RB_ADMM_* blocks are
replaced by quanttoolbox.config.{CCDConfig,NewtonConfig,ADMMConfig}.
erc_portfolio(cov_matrix, x0=None, method='ccd')
¶
Equal Risk Contribution portfolio: risk budgeting with all budgets equal (b = 1/n), pure volatility risk measure.
Original: rpb/compute_erc_portfolio.m (+ mloapa/compute_ERC_{ADMM,CCD}.m, equivalent alternative solvers for the same problem)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
489 490 491 492 493 494 495 496 497 498 499 500 501 | |
risk_budgeting_frontier(cov_matrix, b, mu, c_values, x0=None)
¶
Evaluate the risk budgeting solution at each risk-aversion value in c_values (the "gamma-problem" mode of compute_risk_parity_portfolio.m).
See module docstring for target-matching modes not ported here.
Original: rpb/compute_risk_parity_portfolio.m (gamma-problem branch)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 | |
risk_budgeting_target(cov_matrix, mu, target, target_type='return', b=None, x_benchmark=None, x0=None, c_min=1.0, c_max=100.0, solver='unconstrained', bisection_config=None, **solver_kwargs)
¶
Find the risk budgeting portfolio (relative to an optional benchmark
x_benchmark) whose active return or active volatility matches target,
by bisecting the risk-aversion parameter c between c_min and c_max.
target_type="return" (default): bisect to hit a target active return (the "mu-problem" mode). target_type="volatility": bisect to hit a target active volatility (the "sigma-problem" mode).
solver="unconstrained" (default), "box", or "constrained" selects which underlying risk-budgeting solver to use at each trial c; solver_kwargs are forwarded to it (e.g. x_minus/x_plus for "box", a_eq/b_eq/c_ineq/d_ineq/x_minus/x_plus for "constrained").
Returns a result with weights all-NaN and converged=False if target
lies outside the achievable range [c_min, c_max] maps to.
Original: rpb/{compute_risk_parity_portfolio, compute_risk_parity_portfolio_bounds}.m ("mu-problem"/"sigma-problem" branches, consolidated with their _return/_volatility objective helper files -- see module docstring)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 | |
risk_budgeting_target_frontier(cov_matrix, mu, targets, target_type='return', **kwargs)
¶
Evaluate risk_budgeting_target at each value in targets.
Original: rpb/{compute_risk_parity_portfolio, compute_risk_parity_portfolio_bounds}.m (looping over multiple targets)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 | |
risk_contribution(x, cov_matrix, mu=0.0, c=1.0)
¶
Decompose portfolio risk = -x'mu + csqrt(x'Covx) into each asset's marginal and total risk contribution.
With mu=0, c=1 this is the pure volatility risk measure (consolidates compute_risk_contribution.m / compute_rc_vol.m). With mu != 0 it's the standard-deviation-based measure net of expected return (compute_rc_sd.m).
Original: rpb/{compute_risk_contribution,compute_rc_sd,compute_rc_vol}.m
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | |
risk_contribution_es(x, cov_matrix, mu, alpha)
¶
Risk contribution decomposition under the (Gaussian) ES risk measure.
Original: rpb/compute_rc_es.m
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
588 589 590 591 592 593 594 595 | |
risk_contribution_var(x, cov_matrix, mu, alpha)
¶
Risk contribution decomposition under the (Gaussian) VaR risk measure.
Original: rpb/compute_rc_var.m
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
578 579 580 581 582 583 584 585 | |
solve_box_constrained(cov_matrix, x_minus, x_plus, b=None, mu=0.0, c=1.0, x0=None, lambda_bracket=None, admm_config=None, newton_config=None)
¶
Solve risk budgeting subject to box constraints x_minus <= x <= x_plus
(in addition to the sum(x)=1 budget constraint). Thin wrapper around
solve_constrained for the box-only case (uses the cheaper
closed-form box projection directly, no Dykstra iteration needed).
Original: crb/compute_rb_sd_bc_admm1*.m (+ ~40 equivalent solver variants -- see module docstring)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 | |
solve_constrained(cov_matrix, b=None, mu=0.0, c=1.0, x0=None, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, x_minus=None, x_plus=None, lambda_bracket=None, admm_config=None, newton_config=None, proximal_config=None)
¶
Solve risk budgeting subject to any combination of extra linear
equality constraints (a_eq @ x == b_eq), inequality constraints
(c_ineq @ x <= d_ineq), and box bounds [x_minus, x_plus] -- in addition
to the implicit sum(x)=1 budget constraint (enforced, as in
solve_box_constrained, via the outer bisection on lambda, not via
a_eq). Pass None for any constraint set you don't need.
Same ADMM structure as solve_box_constrained, generalized so the
z-update projects onto the intersection of whichever constraint sets
are given (via optim.proximal.proximal_linear_constraints's
Dykstra alternating-projection algorithm) instead of just a box.
solve_box_constrained is now a thin wrapper around this function
for the box-only case (using the cheaper closed-form box projection
directly, no Dykstra iteration needed, when no other constraints are
given).
Original: crb/compute_rb_sd_constrained_admm1*.m (+ equivalent solver
variants, same consolidation as solve_box_constrained -- see
module docstring)
Note: Dykstra's alternating projection (used here whenever a_eq or
c_ineq is given alongside box bounds) can, for constraint sets that
meet at a sharp corner, exit on a temporary numerical plateau before
reaching the true intersection point -- see the caveat in
optim.proximal.proximal_linear_constraints's docstring. If a
result looks suspicious, verify constraint satisfaction directly.
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 | |
solve_unconstrained(cov_matrix, b=None, mu=0.0, c=1.0, x0=None, method='ccd', config=None)
¶
Solve the classic (budget-constraint-only, sum(x)=1) risk budgeting problem: find weights x such that each asset's risk contribution matches its target budget b.
method="ccd" (default): Roncalli's cyclical coordinate descent. method="newton": Newton's method on the Lagrangian stationary conditions.
Original: rpb/{compute_rb_sd,compute_rb_sd_ccd,compute_rb_sd_newton}.m
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
solve_unconstrained_es(cov_matrix, alpha, b=None, mu=0.0, x0=None, method='ccd', config=None)
¶
Risk budgeting under a (Gaussian) Expected Shortfall risk measure at
confidence level alpha. See solve_unconstrained_var.
Original: rpb/{compute_rb_es,compute_rc_es}.m
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 | |
solve_unconstrained_var(cov_matrix, alpha, b=None, mu=0.0, x0=None, method='ccd', config=None)
¶
Risk budgeting under a (Gaussian) Value-at-Risk risk measure at
confidence level alpha, rather than plain volatility -- solved by
mapping alpha to the equivalent standard-deviation multiplier c and
reusing solve_unconstrained directly (VaR is just a rescaled
standard-deviation measure under normality).
Original: rpb/{compute_rb_var,compute_rc_vol}.m (VaR variant)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 | |
Examples¶
Equal risk contribution and box-constrained risk budgeting — rpb/test_box1.py
"""Translated from Examples/rpb/test_box1.m -- ERC and box-constrained
("C-ERC") risk budgeting portfolios at progressively wider bounds around
a starting position."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.risk_budgeting import erc_portfolio, solve_box_constrained
from quanttoolbox.stats.moments import corr_to_cov
x0 = np.array([0.29, 0.25, 0.23, 0.18, 0.05])
sigma = np.array([0.20, 0.20, 0.25, 0.15, 0.25])
rho = xpnd(
np.array(
[1.00, 0.40, 1.00, 0.70, 0.75, 1.00, 0.60, 0.55, 0.90, 1.00, 0.70, 0.60, 0.70, 0.65, 1.00]
),
method=1,
)
cov_matrix = corr_to_cov(sigma, rho)
r1 = erc_portfolio(cov_matrix)
print("ERC weights:", np.round(r1.weights, 4))
for delta in [0.02, 0.07, 0.20]:
x_minus, x_plus = x0 - delta, x0 + delta
r = solve_box_constrained(cov_matrix, x_minus=x_minus, x_plus=x_plus, x0=x0)
print(f"box (delta={delta}) weights:", np.round(r.weights, 4), "converged:", r.converged)
Risk budgeting toward unequal target budgets — rpb/test_erc3.py
"""Translated from Examples/rpb/test_erc3.m -- Example 17 (page 123),
Roncalli (2013). RB portfolio with unequal target budgets."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.risk_budgeting import risk_contribution, solve_unconstrained
from quanttoolbox.stats.moments import corr_to_cov
sigma = np.array([0.15, 0.20, 0.30, 0.10])
rho = xpnd(np.array([1.00, 0.50, 1.00, 0.00, 0.20, 1.00, -0.10, 0.40, 0.70, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
x = np.full(4, 0.25)
rc = risk_contribution(x, cov_matrix)
print("equal-weight risk contribution:", rc.risk, np.round(100 * rc.pct_risk_contribution, 2))
b = np.array([0.20, 0.20, 0.30, 0.30])
r = solve_unconstrained(cov_matrix, b=b, method="ccd")
print("RB weights (target budgets 20/20/30/30):", np.round(r.weights, 4))
print("converged:", r.converged, "n_iters:", r.n_iters)
Risk contribution decomposition and equal-budget risk budgeting — rpb/test_erc2.py
"""Translated from Examples/rpb/test_erc2.m -- Example 7 (page 80),
Roncalli (2013). Risk contribution decomposition + risk budgeting at
various target budgets."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.risk_budgeting import risk_contribution, solve_unconstrained
from quanttoolbox.stats.moments import corr_to_cov
sigma = np.array([0.30, 0.20, 0.15])
rho = xpnd(np.array([1.00, 0.80, 1.00, 0.50, 0.30, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
x = np.array([0.50, 0.20, 0.30])
rc = risk_contribution(x, cov_matrix)
print("fixed-x risk contribution:", rc.risk, np.round(100 * rc.pct_risk_contribution, 2))
r_equal = solve_unconstrained(cov_matrix, b=np.full(3, 1 / 3), method="ccd")
print("equal-budget RB weights:", np.round(r_equal.weights, 4))
r_custom = solve_unconstrained(cov_matrix, b=x, method="ccd")
print("target-x-as-budget RB weights:", np.round(r_custom.weights, 4))
portfolio.mean_variance, portfolio.black_litterman, portfolio.tracking_error¶
Python alternatives
Hybrid: PyPortfolioOpt is mature and actively maintained, covering mean-variance, Black-Litterman, CVaR, and discrete allocation — genuinely worth using directly for standard portfolio construction. Keep these modules for tighter integration with the rest of this codebase (shared solve_qp, direct ridge/lasso penalty composability).
quanttoolbox.portfolio.mean_variance
¶
Mean-variance, minimum-variance, and most-diversified portfolio construction.
Ported from QuantToolBox/rpb/{compute_mvo_portfolio,compute_minvar_portfolio, compute_mdp_portfolio,compute_mdp_objective_function}.m and QuantToolBox/mloapa/{compute_MDP_ADMM,compute_MinVar_ADMM1, compute_MinVar_ADMM2}.m.
Consolidation notes:
mvo_portfolio/minvar_portfolioroute throughquanttoolbox.optim.quadprog.solve_qp(see that module's docstring for why this eliminates the original's separate quadprog_lasso/ridge/etc. variable-splitting machinery);minvar_portfoliois literallymvo_portfoliowith mu=0.mvo_frontierevaluates at a list of risk-aversion (gamma) values, covering the original's "gamma-problem" mode (problem=0).mvo_target_portfoliocovers the "mu-problem"/"sigma-problem" target-matching modes (problem=1/problem=2): for each target expected-return or target volatility, it bisects on gamma (viaquanttoolbox.optim.bisection.bisection) untilmvo_portfolio's achieved return/volatility hits that target, exactly mirroringcompute_mvo_portfolio.m's three-branch structure (boundary cases at gamma=0/gamma=100, bisection in between over gamma in [0, 10] by default -- both bounds preserved as separate, independently configurable parameters, matching a real quirk in the original: the achievability check against the "infinite risk aversion" case uses gamma=100, but the interior bisection search is only ever bracketed to [0, 10], so a target only reachable at a gamma between 10 and 100 will come back as unreachable (NaN), exactly as the MATLAB source does. Seedocs/matlab_bugs_found.mdfor a related, genuine bug this surfaced in one of the original's own example scripts.)mdp_portfolio(most diversified portfolio) has a genuinely nonlinear objective (log(portfolio vol) - log(weighted-avg individual vol)), so it usesscipy.optimize.minimize(SLSQP) rather thansolve_qp-- matching the original's use offminconrather thanquadprog.mloapa/compute_MDP_ADMM.m(an alternative ADMM-based MDP solver) is not separately ported since SLSQP solves the same small nonlinear program directly and reliably.mloapa/compute_MinVar_ADMM{1,2}.mare alternative ADMM solvers for the same minimum-variance QP thatsolve_qpalready solves directly; not separately ported.
mdp_portfolio(cov_matrix, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, x0=None)
¶
Most Diversified Portfolio: maximize the diversification ratio (weighted-average individual volatility / portfolio volatility), i.e. minimize log(portfolio vol) - log(weighted-average individual vol).
Original: rpb/{compute_mdp_portfolio,compute_mdp_objective_function}.m
Source code in src/quanttoolbox/portfolio/mean_variance.py
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | |
minvar_portfolio(cov_matrix, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None)
¶
Minimum-variance portfolio (mean-variance optimal with gamma=0).
Original: rpb/compute_minvar_portfolio.m
Source code in src/quanttoolbox/portfolio/mean_variance.py
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | |
mvo_frontier(mu, cov_matrix, gamma_values, **kwargs)
¶
Evaluate the mean-variance frontier at each risk-aversion value in gamma_values (the "gamma-problem" mode of compute_mvo_portfolio.m).
See mvo_target_portfolio for the mu-problem/sigma-problem
target-matching modes.
Source code in src/quanttoolbox/portfolio/mean_variance.py
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | |
mvo_portfolio(mu, cov_matrix, gamma=1.0, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, ridge_penalty=None, lasso_penalty=None)
¶
Mean-variance optimal portfolio: maximize gammamu'x - 0.5x'Cov*x subject to a budget constraint (sum(x)=1 by default) and any other given constraints.
gamma=0 gives the minimum-variance portfolio; larger gamma weights expected return more heavily relative to risk.
Original: rpb/compute_mvo_portfolio.m (gamma-problem branch)
Source code in src/quanttoolbox/portfolio/mean_variance.py
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | |
mvo_target_portfolio(mu, cov_matrix, targets, problem='sigma', a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, gamma_bracket=(0.0, 10.0), gamma_max=100.0)
¶
Target-matching mean-variance portfolios: for each value in
targets, find the risk-aversion gamma whose mvo_portfolio
solution achieves that target expected return (problem="mu") or
that target volatility (problem="sigma"), via bisection on gamma.
Original: rpb/compute_mvo_portfolio.m (mu-problem/problem=1 and sigma-problem/problem=2 branches), via compute_mvo_portfolio_return.m/compute_mvo_portfolio_volatility.m as the bisection objective.
For each target, first the gamma=0 and gamma=gamma_max solutions
are used to bracket what's achievable:
problem="sigma": a target below the gamma=0 volatility is unreachable (NaN weights); a target at or above the gamma=gamma_max volatility returns that portfolio directly; otherwise gamma is bisected withingamma_bracket.problem="mu": a target at or below the gamma=0 return returns that portfolio directly; a target above the gamma=gamma_max return is unreachable (NaN weights); a target exactly at the gamma=gamma_max return returns that portfolio directly; otherwise gamma is bisected withingamma_bracket.
Note the original's own quirk, preserved here: the boundary checks use
gamma_max (100 by default) but the bisection search itself is only
ever bracketed to gamma_bracket ((0, 10) by default) -- so a target
only reachable at a gamma between the two is misreported as
unreachable. See the module docstring and
docs/matlab_bugs_found.md for more.
Source code in src/quanttoolbox/portfolio/mean_variance.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | |
quanttoolbox.portfolio.black_litterman
¶
Black-Litterman implied returns and posterior (view-updated) moments.
Ported from QuantToolBox/rpb/{implied_risk_premia, compute_Black_Litterman_moments}.m
black_litterman_moments(mu_tilde, gamma_matrix, p, q, omega)
¶
Combine an equilibrium prior (mu_tilde, gamma_matrix) with investor views (P @ mu = Q, view uncertainty Omega) into posterior expected returns and covariance, via the standard Black-Litterman conditional normal update.
Original: rpb/compute_Black_Litterman_moments.m
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mu_tilde
|
(n,) prior (equilibrium) expected returns.
|
|
required |
gamma_matrix
|
(n, n) prior covariance of expected returns (often
|
tau * Sigma, where Sigma is the asset covariance and tau is a small scalar). |
required |
p
|
(k, n) view matrix, one row per view.
|
|
required |
q
|
(k,) view target values.
|
|
required |
omega
|
(k, k) view uncertainty covariance.
|
|
required |
Source code in src/quanttoolbox/portfolio/black_litterman.py
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | |
implied_risk_premia(x, cov_matrix, sharpe_ratio)
¶
Back out the implied excess-return vector (and risk-aversion parameters) consistent with a given market-cap-weighted portfolio x achieving Sharpe ratio sharpe_ratio -- the standard first step of the Black-Litterman framework's equilibrium prior.
Original: rpb/implied_risk_premia.m
Source code in src/quanttoolbox/portfolio/black_litterman.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | |
quanttoolbox.portfolio.tracking_error
¶
Tracking-error-optimized portfolio construction relative to a benchmark.
Ported from QuantToolBox/rpb/{compute_te_portfolio, compute_minimum_te_portfolio,compute_te_portfolio_mixed_norm}.m.
Consolidation notes:
- All three original functions solve the same underlying QP (minimize
active variance minus a return-tilt term) via
quadprog; here they route throughquanttoolbox.optim.quadprog.solve_qp.te_portfolio_mixed_normfolds naturally intote_portfolioas optionalridge_penalty/lasso_penaltyarguments (already supported directly bysolve_qp) rather than being a separate function. minimum_te_portfolioiste_portfoliowith gamma=0 (pure tracking-error minimization, no return tilt).te_frontiercovers the "gamma-problem" mode (evaluate at a list of gamma values,problem=0).te_target_portfoliocovers the "mu-problem"/"sigma-problem" target-matching modes (problem=1/problem=2), mirroringcompute_te_portfolio.mexactly the same waymean_variance.mvo_target_portfoliomirrorscompute_mvo_portfolio.m-- see that function's docstring for the bisection-bracket/boundary-check quirk both share.
minimum_te_portfolio(x_benchmark, cov_matrix, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, on_infeasible='raise')
¶
Minimum-tracking-error portfolio (te_portfolio with gamma=0, no return tilt).
Original: rpb/compute_minimum_te_portfolio.m
on_infeasible : "raise" (default) lets solve_qp's RuntimeError
on infeasibility/non-convergence propagate unchanged -- existing
behavior. "nearest" catches that RuntimeError and instead
solves the same tracking-error QP via scipy.optimize.minimize
(SLSQP), starting from the benchmark weights and using the same
equality/inequality/box-constraint handling (including pruning
rank-deficient equality rows, and defaulting missing lb/ub to
[0, 1]) as HSF-Notebooks chapter 11h's local min_te_portfolio,
returning the "nearest" near-feasible point SLSQP settles at
instead of raising. Not ported from the MATLAB HSF toolbox -- no
.m file in hfs-archive implements this fallback. Promoted from
HSF-Notebooks chapter 11h.
Source code in src/quanttoolbox/portfolio/tracking_error.py
483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 | |
solve_mixed_norm_te_portfolio(x_benchmark, carbon_intensity, md, dts, sector, reduction, varphi_as, varphi_md, varphi_dts, lb=None, ub=None, x0=None)
¶
L1 mixed active-share/MD/DTS bond tracking-error objective under a
budget constraint and a carbon-intensity-reduction target, solved two
ways: direct nonsmooth minimization (SLSQP) and an epigraph LP
reformulation (linprog).
Minimizes R_Mix(w) = varphi_as*R_AS(w) + varphi_md*R_MD(w) +
varphi_dts*R_DTS(w) where R_AS(w) = 0.5*||w-x_benchmark||_1,
R_MD(w) = ||C_MD@(w-x_benchmark)||_1 and R_DTS(w) =
||C_DTS@(w-x_benchmark)||_1 for the per-sector aggregation matrices
C_MD/C_DTS built from sector, subject to
sum(w) == 1 and carbon_intensity@w <= (1-reduction) *
carbon_intensity@x_benchmark. Because C_MD/C_DTS are not
diagonal, these L1 terms are not expressible via solve_qp's
elementwise lasso_penalty and need this dedicated two-path
implementation instead.
Not ported from the MATLAB HSF toolbox -- no .m file in hfs-archive implements this. Promoted from HSF-Notebooks chapter 11b/16f.
Source code in src/quanttoolbox/portfolio/tracking_error.py
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | |
te_frontier(x_benchmark, mu, cov_matrix, gamma_values, **kwargs)
¶
Evaluate the tracking-error frontier at each risk-aversion value in gamma_values (the "gamma-problem" mode of compute_te_portfolio.m).
Source code in src/quanttoolbox/portfolio/tracking_error.py
121 122 123 124 125 126 127 128 129 130 131 132 133 134 | |
te_portfolio(x_benchmark, mu, cov_matrix, gamma=1.0, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, ridge_penalty=None, lasso_penalty=None)
¶
Tracking-error-optimal portfolio: minimize 0.5(x-x_b)'Cov(x-x_b) - gamma*mu'x, i.e. trade off tracking error against active return.
gamma=0 gives pure tracking-error minimization (no return tilt).
Original: rpb/compute_te_portfolio.m (gamma-problem branch), also covers compute_te_portfolio_mixed_norm.m via ridge_penalty/lasso_penalty.
Source code in src/quanttoolbox/portfolio/tracking_error.py
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | |
te_target_portfolio(x_benchmark, mu, cov_matrix, targets, problem='sigma', a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, gamma_bracket=(0.0, 10.0), gamma_max=100.0)
¶
Target-matching tracking-error portfolios: for each value in
targets, find the risk-aversion gamma whose te_portfolio
solution achieves that target active return (problem="mu") or
that target tracking error (problem="sigma"), via bisection on
gamma.
Original: rpb/compute_te_portfolio.m (mu-problem/problem=1 and sigma-problem/problem=2 branches), via compute_te_portfolio_return.m/compute_te_portfolio_volatility.m as the bisection objective.
Same boundary-case and bisection-bracket structure as
mean_variance.mvo_target_portfolio -- see that function's
docstring for the full description of the achievability checks and
the gamma_max/gamma_bracket quirk they share.
Source code in src/quanttoolbox/portfolio/tracking_error.py
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | |
Examples¶
Black-Litterman view matched to six tracking-error targets — rpb/test_bl4.py
"""Translated from Examples/rpb/test_bl4.m -- Roncalli [2013], "Introduction
to Risk Parity and Budgeting", page 24: a single Black-Litterman view
(same P/Q/Omega/tau as test_bl3.py's scenario 1) turned into a *tracking-error*
frontier against the x0 benchmark -- the sigma-problem branch of
`compute_te_portfolio.m`, now covered by `te_target_portfolio`. Six target
tracking-error levels (effectively 0% through 5%) show how active return and
the information ratio (alpha/te, here genuinely dimensionless -- unlike
test_bl3.py's alpha/sigma_x convention) scale with the amount of tracking
error taken."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.black_litterman import black_litterman_moments, implied_risk_premia
from quanttoolbox.portfolio.tracking_error import te_target_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
x0 = np.array([0.40, 0.30, 0.20, 0.10])
r = 0.03
irp = implied_risk_premia(x0, cov_matrix, 0.25)
mu_tilde = r + irp.pi
p_matrix = np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float)
q = np.array([0.04, -0.01])
omega = np.diag([0.10**2, 0.05**2])
bl = black_litterman_moments(mu_tilde, 1.0 * cov_matrix, p_matrix, q, omega)
te_targets = np.array([1e-5, 0.01, 0.02, 0.03, 0.04, 0.05])
results = te_target_portfolio(
x0, bl.mu_bar, cov_matrix, te_targets, problem="sigma", lb=0.0, ub=1.0
)
for target, res in zip(te_targets, results, strict=False):
ir = res.active_return / res.tracking_error if res.tracking_error > 0 else float("nan")
print(
f"target_te={100 * target:5.2f} gamma={res.gamma:6.3f} w={np.round(100 * res.weights, 2)} "
f"alpha={100 * res.active_return:6.3f} te={100 * res.tracking_error:5.2f} IR={ir:.3f}"
)
Black-Litterman view sensitivity across five scenarios — rpb/test_bl2.py
"""Translated from Examples/rpb/test_bl2.m -- Black-Litterman sensitivity
analysis across 6 scenarios (base case + 5 view/uncertainty/tau variants),
each solved as a fixed-risk-aversion MVO portfolio."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.black_litterman import black_litterman_moments, implied_risk_premia
from quanttoolbox.portfolio.mean_variance import mvo_portfolio
from quanttoolbox.stats.moments import corr_to_cov
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
x0 = np.array([0.40, 0.30, 0.20, 0.10])
r = 0.03
irp = implied_risk_premia(x0, cov_matrix, sharpe_ratio=0.25)
mu_tilde = r + irp.pi
gamma0 = irp.gamma
scenarios = [
dict(
P=np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float),
Q=np.array([0.04, -0.01]),
Omega=np.diag([0.10**2, 0.05**2]),
tau=1,
),
dict(
P=np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float),
Q=np.array([0.07, -0.01]),
Omega=np.diag([0.10**2, 0.05**2]),
tau=1,
),
dict(
P=np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float),
Q=np.array([0.04, -0.01]),
Omega=np.diag([0.20**2, 0.20**2]),
tau=1,
),
dict(
P=np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float),
Q=np.array([0.04, -0.01]),
Omega=np.diag([0.10**2, 0.05**2]),
tau=0.10,
),
dict(
P=np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float),
Q=np.array([0.04, -0.01]),
Omega=np.diag([0.10**2, 0.05**2]),
tau=0.01,
),
]
results = [
dict(weights=x0, mu=x0 @ mu_tilde, sigma=np.sqrt(x0 @ cov_matrix @ x0), alpha=0.0, te=0.0)
]
for s in scenarios:
bl = black_litterman_moments(mu_tilde, s["tau"] * cov_matrix, s["P"], s["Q"], s["Omega"])
mvo = mvo_portfolio(bl.mu_bar, cov_matrix, gamma=gamma0, lb=0.0, ub=1.0)
alpha = (mvo.weights - x0) @ bl.mu_bar
te = np.sqrt((mvo.weights - x0) @ cov_matrix @ (mvo.weights - x0))
results.append(
dict(weights=mvo.weights, mu=mvo.expected_return, sigma=mvo.volatility, alpha=alpha, te=te)
)
for i, r_ in enumerate(results):
print(
f"scenario {i}: weights={np.round(r_['weights'],4)} mu={round(r_['mu'],5)} te={round(r_['te'],5)}"
)
Black-Litterman views matched to a benchmark's volatility — rpb/test_bl3.py
"""Translated from Examples/rpb/test_bl3.m -- Roncalli [2013], "Introduction
to Risk Parity and Budgeting", page 24: five Black-Litterman view scenarios
(varying the view itself, its confidence Omega, and the scaling tau), each
turned into a portfolio matched to the *same* target volatility as the
original x0 benchmark -- the sigma-problem branch of
`compute_mvo_portfolio.m`, now covered by `mvo_target_portfolio`. For each
scenario this reports the resulting weights, expected return, volatility
(pinned to sigma0 by construction), active return (alpha) and information
ratio relative to x0.
Note on the displayed IR: the original computes
`IR_x(i) = alpha_x(i)/sigma_x(i)` (active return over the portfolio's *own*
volatility, not tracking error -- both are dimensionless ratios), and then
the whole results matrix is multiplied by 100 for percent-style display
before formatting. Since IR_x is already a ratio, that display convention
ends up printing 100*IR rather than IR itself; this translation keeps that
same convention (labelled `100*IR`) so the printed numbers match the
original table (Roncalli page 26) directly."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.black_litterman import black_litterman_moments, implied_risk_premia
from quanttoolbox.portfolio.mean_variance import mvo_target_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
x0 = np.array([0.40, 0.30, 0.20, 0.10])
sigma0 = np.sqrt(x0 @ cov_matrix @ x0)
r = 0.03
irp = implied_risk_premia(x0, cov_matrix, 0.25)
mu_tilde = r + irp.pi
print(f"x0={np.round(100 * x0, 2)} mu0={100 * (x0 @ mu_tilde):.2f} sigma0={100 * sigma0:.2f}")
# (P, Q, Omega, tau) for the 5 view scenarios
p_matrix = np.array([[1, 0, 0, 0], [0, 1, -1, 0]], dtype=float)
scenarios = [
(np.array([0.04, -0.01]), np.diag([0.10**2, 0.05**2]), 1.0),
(np.array([0.07, -0.01]), np.diag([0.10**2, 0.05**2]), 1.0),
(np.array([0.04, -0.01]), np.diag([0.20**2, 0.20**2]), 1.0),
(np.array([0.04, -0.01]), np.diag([0.10**2, 0.05**2]), 0.10),
(np.array([0.04, -0.01]), np.diag([0.10**2, 0.05**2]), 0.01),
]
for i, (q, omega, tau) in enumerate(scenarios, start=1):
bl = black_litterman_moments(mu_tilde, tau * cov_matrix, p_matrix, q, omega)
res = mvo_target_portfolio(bl.mu_bar, cov_matrix, sigma0, problem="sigma", lb=0.0, ub=1.0)[0]
alpha = (res.weights - x0) @ bl.mu_bar
te = np.sqrt((res.weights - x0) @ cov_matrix @ (res.weights - x0))
ir = alpha / res.volatility
print(
f"scenario {i}: gamma={res.gamma:.3f} w={np.round(100 * res.weights, 2)} "
f"mu={100 * res.expected_return:.2f} sigma={100 * res.volatility:.2f} "
f"alpha={100 * alpha:.2f} te={100 * te:.2f} 100*IR={100 * ir:.2f}"
)
Full-view Black-Litterman with tracking-error target-matching — rpb/test_bl5.py
"""Translated from Examples/rpb/test_bl5.m -- a second, independent 4-asset
Black-Litterman + tracking-error worked example (no book page cited in the
original). Every asset gets a direct view (`P=I`, `Q=mu`, full confidence
`Omega=Sigma_epsilon=covMatrix`) -- an unusually strong-conviction setup
that pulls the posterior most of the way from the equilibrium prior toward
the raw sample means.
Four blocks, each comparing the resulting active portfolio (x) against the
x0 benchmark:
1. gamma-problem MVO (mu_BL, Sigma_BL) at the implied risk-aversion gamma0.
2. sigma-problem TE-target=1% (mu_BL, Sigma_BL), i.e. `te_target_portfolio`.
3. gamma-problem MVO (mu_BL, covMatrix) -- same posterior mean, but
optimized against the *original* (pre-view) covariance rather than the
BL posterior covariance Sigma_BL.
4. sigma-problem TE-target=1% (mu_BL, covMatrix).
The original's final block repeats block 2 verbatim (mu_BL, Sigma_BL again)
-- included here too for a faithful translation, and it does reproduce
identical numbers, as expected."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.black_litterman import black_litterman_moments, implied_risk_premia
from quanttoolbox.portfolio.mean_variance import mvo_portfolio
from quanttoolbox.portfolio.tracking_error import te_target_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.03, 0.03, 0.08, 0.07])
sigma = np.array([0.06, 0.07, 0.18, 0.17])
rho = xpnd(np.array([1.00, 0.50, 1.00, -0.40, -0.40, 1.00, -0.40, -0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
print("Sigma_hat =\n", np.round(100 * cov_matrix, 2))
x0 = np.array([0.40, 0.40, 0.10, 0.10])
r = 0.02
irp = implied_risk_premia(x0, cov_matrix, 0.25)
mu_tilde = r + irp.pi
print("tilde(pi) =", np.round(100 * irp.pi, 2))
print("tilde(mu) =", np.round(100 * mu_tilde, 2))
print("gamma0 =", round(irp.gamma, 4))
p_matrix = np.eye(4)
bl = black_litterman_moments(mu_tilde, cov_matrix, p_matrix, mu, cov_matrix)
print("mu(BL) =", np.round(100 * bl.mu_bar, 2))
print("Sigma(BL) =\n", np.round(100 * bl.sigma_bar, 2))
def report(label: str, weights: np.ndarray, cov_for_te: np.ndarray) -> None:
active = weights - x0
te = np.sqrt(active @ cov_for_te @ active)
print(f"{label}: x0={np.round(x0, 4)} x={np.round(weights, 4)} te={100 * te:.2f}")
gamma0 = irp.gamma
res = mvo_portfolio(
bl.mu_bar,
bl.sigma_bar,
gamma=gamma0,
a_eq=np.ones((1, 4)),
b_eq=np.array([1.0]),
lb=0.0,
ub=1.0,
)
report("1. MVO(mu_BL, Sigma_BL) @ gamma0", res.weights, bl.sigma_bar)
te_res = te_target_portfolio(
x0,
bl.mu_bar,
bl.sigma_bar,
0.01,
problem="sigma",
a_eq=np.ones((1, 4)),
b_eq=np.array([1.0]),
lb=0.0,
ub=1.0,
)[0]
report("2. TE-target 1% (mu_BL, Sigma_BL)", te_res.weights, bl.sigma_bar)
res2 = mvo_portfolio(
bl.mu_bar, cov_matrix, gamma=gamma0, a_eq=np.ones((1, 4)), b_eq=np.array([1.0]), lb=0.0, ub=1.0
)
report("3. MVO(mu_BL, covMatrix) @ gamma0", res2.weights, cov_matrix)
te_res2 = te_target_portfolio(
x0,
bl.mu_bar,
cov_matrix,
0.01,
problem="sigma",
a_eq=np.ones((1, 4)),
b_eq=np.array([1.0]),
lb=0.0,
ub=1.0,
)[0]
report("4. TE-target 1% (mu_BL, covMatrix)", te_res2.weights, cov_matrix)
te_res3 = te_target_portfolio(
x0,
bl.mu_bar,
bl.sigma_bar,
0.01,
problem="sigma",
a_eq=np.ones((1, 4)),
b_eq=np.array([1.0]),
lb=0.0,
ub=1.0,
)[0]
report("5. TE-target 1% (mu_BL, Sigma_BL), repeated", te_res3.weights, bl.sigma_bar)
Mean-variance frontier: risk-aversion, return, and volatility targets — rpb/test_mvo2.py
"""Translated from Examples/rpb/test_mvo2.m -- Roncalli [2013], "Introduction
to Risk Parity and Budgeting", Example 1 (pages 7-8): the same 4-asset
mean-variance problem evaluated three ways -- the gamma-problem (pick a
risk-aversion, solve directly), the mu-problem (pick a target expected
return, bisect on gamma to hit it), and the sigma-problem (pick a target
volatility, bisect on gamma to hit it). All three route through
`compute_mvo_portfolio.m`'s three branches; here that's
`mvo_frontier`/`mvo_target_portfolio`.
The original passes `lb=0, ub=0` (MATLAB's "use the default -100/100 wide
bounds" sentinel); passed through explicitly here as `lb=-100, ub=100`."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.mean_variance import mvo_frontier, mvo_target_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
print("1. gamma-problem (page 7)")
gamma_values = np.array([0.00, 0.20, 0.50, 1.00, 2.00, 5.00])
results = mvo_frontier(mu, cov_matrix, gamma_values, lb=-100.0, ub=100.0)
for g, r in zip(gamma_values, results, strict=False):
print(
f" gamma={g:5.2f} mu={100 * r.expected_return:6.2f} sigma={100 * r.volatility:6.2f} "
f"w={np.round(100 * r.weights, 2)}"
)
print("\n2. mu-problem (page 8)")
mu_targets = np.array([5.00, 6.00, 7.00, 8.00, 9.00]) / 100
mu_results = mvo_target_portfolio(mu, cov_matrix, mu_targets, problem="mu", lb=-100.0, ub=100.0)
for target, r in zip(mu_targets, mu_results, strict=False):
print(
f" target_mu={100 * target:5.2f} gamma={r.gamma:6.3f} mu={100 * r.expected_return:6.2f} "
f"sigma={100 * r.volatility:6.2f} w={np.round(100 * r.weights, 2)}"
)
print("\n3. sigma-problem (page 8)")
sigma_targets = np.array([15.00, 20.00, 25.00, 30.00, 35.00]) / 100
sigma_results = mvo_target_portfolio(
mu, cov_matrix, sigma_targets, problem="sigma", lb=-100.0, ub=100.0
)
for target, r in zip(sigma_targets, sigma_results, strict=False):
print(
f" target_sigma={100 * target:5.2f} gamma={r.gamma:6.3f} mu={100 * r.expected_return:6.2f} "
f"sigma={100 * r.volatility:6.2f} w={np.round(100 * r.weights, 2)}"
)
Mean-variance optimization plus ridge/lasso-penalized portfolios — rpb/test_lasso1.py
"""Translated from Examples/rpb/test_lasso1.m -- Roncalli [2013],
"Introduction to Risk Parity and Budgeting", Example 1 (page 53):
compares an unconstrained gamma-problem MVO portfolio against the same
problem with a budget (sum-to-1) constraint, and ridge/lasso-penalized
variants (toward 0 and toward equal-weight) of the unconstrained
problem.
`compute_mvo_portfolio`/`quadprog_ridge`/`quadprog_lasso` map to
`mvo_portfolio`/`solve_qp(..., ridge_penalty=...)`/`solve_qp(...,
lasso_penalty=...)` as established in test_lasso3.py/test_lasso5.py; the
original's `0, 0` sentinel arguments for "no equality constraint" /
"no bounds" map to simply omitting those keyword arguments."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.optim.quadprog import solve_qp
from quanttoolbox.portfolio.mean_variance import mvo_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
n = 4
x0 = np.full(n, 1 / n)
# Case gamma-problem
gamma_x = 0.5
x1 = mvo_portfolio(mu, cov_matrix, gamma=gamma_x).weights
x2 = mvo_portfolio(
mu, cov_matrix, gamma=gamma_x, a_eq=np.ones((1, n)), b_eq=np.array([1.0])
).weights
lambda_ridge = 0.03
s_ridge = lambda_ridge * np.eye(n)
x3 = solve_qp(cov_matrix, gamma_x * mu, ridge_penalty=(s_ridge, np.zeros(n)))
x4 = solve_qp(cov_matrix, gamma_x * mu, ridge_penalty=(s_ridge, x0))
lambda_lasso = 0.03 / 2
s_lasso = lambda_lasso * np.ones(n)
x5 = solve_qp(cov_matrix, gamma_x * mu, lasso_penalty=(s_lasso, np.zeros(n)))
x6 = solve_qp(cov_matrix, gamma_x * mu, lasso_penalty=(s_lasso, x0))
results = 100 * np.column_stack([x1, x2, x3, x4, x5, x6])
print(" x1 x2 x3 x4 x5 x6")
print(np.round(results, 2))
Minimum-variance portfolio under general linear constraints — rpb/test_minvar2.py
"""Translated from Examples/rpb/test_minvar2.m -- minimum-variance
portfolio with general linear equality/inequality constraints and box
bounds."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.mean_variance import minvar_portfolio
from quanttoolbox.stats.moments import corr_to_cov
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
a_eq = np.array([[1.0, 1.0, 1.0, 1.0], [1.0, 1.0, 0.0, 0.0]])
b_eq = np.array([1.0, 0.0])
c_ineq = np.array([[0.0, 0.0, 0.0, -1.0]])
d_ineq = np.array([-0.90])
r = minvar_portfolio(
cov_matrix, a_eq=a_eq, b_eq=b_eq, c_ineq=c_ineq, d_ineq=d_ineq, lb=-1.50, ub=2.00
)
print("weights:", np.round(r.weights, 3))
print("volatility:", round(r.volatility, 5))
Mixed ridge+lasso penalties toward two different target vectors — rpb/test_lasso5.py
"""Translated from Examples/rpb/test_lasso5.m -- ridge/lasso/mixed
portfolios with two different ridge/lasso target vectors (equal-weight
and a custom 20/20/30/30 target)."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.optim.quadprog import solve_qp
from quanttoolbox.portfolio.mean_variance import mvo_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
n = 4
a_eq, b_eq = np.ones((1, n)), np.array([1.0])
gamma_x = 0.5
y1 = np.full(n, 1 / n)
y2 = np.array([0.20, 0.20, 0.30, 0.30])
S_ridge = np.diag(np.diag(cov_matrix))
lambda_lasso = 0.005
x1 = mvo_portfolio(mu, cov_matrix, gamma=gamma_x, a_eq=a_eq, b_eq=b_eq, lb=0.0, ub=1.0).weights
x2 = solve_qp(
cov_matrix, gamma_x * mu, a_eq=a_eq, b_eq=b_eq, lb=0.0, ub=1.0, ridge_penalty=(S_ridge, y1)
)
x4 = solve_qp(
cov_matrix, gamma_x * mu, a_eq=a_eq, b_eq=b_eq, lb=0.0, ub=1.0, lasso_penalty=(lambda_lasso, y1)
)
x6 = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=0.0,
ub=1.0,
ridge_penalty=(S_ridge, y1),
lasso_penalty=(lambda_lasso, y1),
)
# mixed with DIFFERENT targets for ridge (toward y1) vs lasso (toward y2)
x8 = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=0.0,
ub=1.0,
ridge_penalty=(S_ridge, y1),
lasso_penalty=(lambda_lasso, y2),
)
for name, x in [
("MVO", x1),
("Ridge->y1", x2),
("Lasso->y1", x4),
("Mixed(ridge->y1,lasso->y1)", x6),
("Mixed(ridge->y1,lasso->y2)", x8),
]:
print(f"{name}: {np.round(x, 4)}")
Raw QP solve vs. te_portfolio, plus a gamma-recovery check — rpb/test_bl6.py
"""Translated from Examples/rpb/test_bl6.m -- same 4-asset setup as
test_bl5.py, demonstrating that a raw QP call and `te_portfolio` at a fixed
gamma agree, then cross-checking that fixed-gamma solution against
`te_target_portfolio`'s sigma-problem mode targeting the tracking error that
fixed-gamma solution actually achieves.
The original's raw `quadprog(H, f, ..., A, B, lb, ub, x0, options)` call
with `H=covMatrix`, `f=-gamma0*mu - covMatrix*x0` is exactly what
`te_portfolio(x0, mu, covMatrix, gamma=gamma0, ...)` computes internally
(see tracking_error.py's `r_lin = gamma*mu + cov_matrix @ x_benchmark`) --
so it's translated directly as a `te_portfolio` call rather than a raw
`solve_qp` call, and the two are verified to agree below."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.optim.quadprog import solve_qp
from quanttoolbox.portfolio.tracking_error import te_portfolio, te_target_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.03, 0.03, 0.08, 0.07])
sigma = np.array([0.06, 0.07, 0.18, 0.17])
rho = xpnd(np.array([1.00, 0.50, 1.00, -0.40, -0.40, 1.00, -0.40, -0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
print("Sigma_hat =\n", np.round(100 * cov_matrix, 2))
x0 = np.array([0.40, 0.40, 0.10, 0.10])
gamma0 = 0.0422
# Raw quadprog(H, f, [], [], A, B, lb, ub, x0, options) call from the
# original, translated literally via solve_qp.
x_raw = solve_qp(
cov_matrix,
gamma0 * mu + cov_matrix @ x0,
a_eq=np.ones((1, 4)),
b_eq=np.array([1.0]),
lb=0.0,
ub=1.0,
)
print("x (raw quadprog) =", np.round(x_raw, 6))
# Same QP via te_portfolio -- should match x_raw exactly.
res = te_portfolio(
x0, mu, cov_matrix, gamma=gamma0, a_eq=np.ones((1, 4)), b_eq=np.array([1.0]), lb=0.0, ub=1.0
)
te_x = np.sqrt((res.weights - x0) @ cov_matrix @ (res.weights - x0))
ir_x = res.active_return / te_x
print("x0 =", x0, " x (te_portfolio) =", np.round(res.weights, 6))
print(f"te(x) = {100 * te_x:.2f} IR = {ir_x:.4f}")
# Cross-check: te_target_portfolio's sigma-problem, targeting the tracking
# error the fixed-gamma solution above actually achieves, should recover
# gamma0 (and the same portfolio) via bisection.
target_res = te_target_portfolio(
x0,
mu,
cov_matrix,
te_x,
problem="sigma",
a_eq=np.ones((1, 4)),
b_eq=np.array([1.0]),
lb=0.0,
ub=1.0,
)[0]
print(
f"cross-check: gamma from te_target_portfolio={target_res.gamma:.4f} "
f"(vs gamma0={gamma0}), weights match={np.allclose(target_res.weights, res.weights, atol=1e-4)}"
)
Ridge, lasso, and mixed-norm penalized portfolios — rpb/test_lasso3.py
"""Translated from Examples/rpb/test_lasso3.m (byte-identical to
test_lasso4.m) -- MVO/ridge/lasso/mixed-penalty portfolios via the
consolidated solve_qp (replacing the original's separate quadprog_ridge/
quadprog_lasso/quadprog_mixed calls -- see optim/quadprog.py docstring)."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.optim.quadprog import solve_qp
from quanttoolbox.portfolio.mean_variance import mvo_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
n = 4
x0 = np.full(n, 1 / n)
gamma_x = 0.5
a_eq, b_eq = np.ones((1, n)), np.array([1.0])
# gamma-problem (plain MVO)
r1 = mvo_portfolio(mu, cov_matrix, gamma=gamma_x, a_eq=a_eq, b_eq=b_eq, lb=0.0, ub=1.0)
x1 = r1.weights
# ridge-problem: penalize toward zero, scaled by each asset's own variance
S_ridge = np.diag(np.diag(cov_matrix))
x2 = solve_qp(
cov_matrix, gamma_x * mu, a_eq=a_eq, b_eq=b_eq, lb=0.0, ub=1.0, ridge_penalty=(S_ridge, x0)
)
# lasso-problem: L1 penalty toward equal weight
lambda_lasso = 0.005
x3 = solve_qp(
cov_matrix, gamma_x * mu, a_eq=a_eq, b_eq=b_eq, lb=0.0, ub=1.0, lasso_penalty=(lambda_lasso, x0)
)
# mixed-problem: both ridge and lasso, toward various targets
x4 = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=0.0,
ub=1.0,
ridge_penalty=(S_ridge, np.zeros(n)),
lasso_penalty=(lambda_lasso, np.zeros(n)),
)
x5 = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=0.0,
ub=1.0,
ridge_penalty=(S_ridge, x0),
lasso_penalty=(lambda_lasso, np.zeros(n)),
)
x6 = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=0.0,
ub=1.0,
ridge_penalty=(S_ridge, np.zeros(n)),
lasso_penalty=(lambda_lasso, x0),
)
x7 = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=0.0,
ub=1.0,
ridge_penalty=(S_ridge, x0),
lasso_penalty=(lambda_lasso, x0),
)
for name, x in [
("MVO", x1),
("Ridge", x2),
("Lasso", x3),
("Mixed(0,0)", x4),
("Mixed(EW,0)", x5),
("Mixed(0,EW)", x6),
("Mixed(EW,EW)", x7),
]:
print(f"{name}: {np.round(x, 4)}")
Ridge/lasso penalty sweep, points from a 250-point scan — rpb/test_lasso2.py
"""Translated from Examples/rpb/test_lasso2.m -- Roncalli [2013],
"Introduction to Risk Parity and Budgeting", Example 1 (page 53): the
original sweeps 250 ridge/lasso penalty strengths and plots the resulting
weight paths (4 subplots: ridge toward zero with a budget constraint,
ridge toward equal-weight with no budget constraint, and the same two
cases for lasso). This translates the *numeric core* at a handful of
representative penalty strengths spanning each sweep's range, rather than
the full 250-point plot -- same "numeric core, plot dropped" convention
used throughout this port, and the same
`solve_qp(..., ridge_penalty=...)`/`solve_qp(..., lasso_penalty=...)`
machinery test_lasso1.py/test_lasso3.py already establish for
quadprog_ridge/quadprog_lasso (see optim/quadprog.py's docstring for why
the variable-splitting original collapses into these two keyword
arguments).
Not cross-verified against Octave: the original's raw
`quadprog_ridge`/`quadprog_lasso` variable-splitting formulation turned
out to be numerically fragile for the *lasso* branches specifically under
Octave's free `quadprog` (returns an infeasible/degenerate all-zero
result, retcode=-3, even for the small, well-conditioned inputs used
here), which made an apples-to-apples comparison unreliable in this
environment; `solve_qp`'s native (variable-splitting-free) L1 handling via
cvxpy does not have that problem and returns sensible, well-conditioned
weights throughout, consistent with test_lasso1.py's already-established
output."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.optim.quadprog import solve_qp
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
n = 4
x0 = np.full(n, 1 / n)
gamma_x = 0.5
a_eq, b_eq = np.ones((1, n)), np.array([1.0])
lambda_ridge_grid = np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.5])
lambda_lasso_grid = np.array([0.0, 0.5, 1.0, 1.5, 2.0, 2.5]) / 100
print("Ridge (Static, toward zero, budget constrained):")
for lam in lambda_ridge_grid:
x = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=-100.0,
ub=100.0,
ridge_penalty=(lam * np.eye(n), np.zeros(n)),
)
print(f" lambda={lam:.2f} w={np.round(100 * x, 2)}")
print("Ridge (Dynamic, toward equal-weight, no budget constraint):")
for lam in lambda_ridge_grid:
x = solve_qp(cov_matrix, gamma_x * mu, lb=-100.0, ub=100.0, ridge_penalty=(lam * np.eye(n), x0))
print(f" lambda={lam:.2f} w={np.round(100 * x, 2)}")
print("Lasso (Static, toward zero, budget constrained):")
for lam in lambda_lasso_grid:
x = solve_qp(
cov_matrix,
gamma_x * mu,
a_eq=a_eq,
b_eq=b_eq,
lb=-100.0,
ub=100.0,
lasso_penalty=(lam * np.ones(n), np.zeros(n)),
)
print(f" lambda={100 * lam:.2f} w={np.round(100 * x, 2)}")
print("Lasso (Dynamic, toward equal-weight, no budget constraint):")
for lam in lambda_lasso_grid:
x = solve_qp(
cov_matrix, gamma_x * mu, lb=-100.0, ub=100.0, lasso_penalty=(lam * np.ones(n), x0)
)
print(f" lambda={100 * lam:.2f} w={np.round(100 * x, 2)}")
Volatility-target mean-variance under three weight-bound configurations — rpb/test_mvo3.py
"""Translated from Examples/rpb/test_mvo3.m -- Roncalli [2013], "Introduction
to Risk Parity and Budgeting", Example 1 (page 10): the same sigma-problem
target-matching as test_mvo2.py's section 3, now under three different
weight-bound configurations (unconstrained-ish, long-only, long-only capped
at 40% per asset), showing how the achievable target range narrows as
constraints tighten.
**A genuine bug in the original**, found while cross-checking this example
against Octave: `test_mvo3.m` (unlike `test_mvo2.m` and every other example
in this cluster) never calls `init_global`, which is what sets the global
`BISECTION_Tol` that `bisection.m`'s convergence loop depends on. With
`BISECTION_Tol` undefined, `while max(abs(a-b)) > BISECTION_Tol` compares
against an empty value, which both MATLAB and Octave treat as false --
so the loop runs zero iterations and `bisection` silently returns the raw
bracket midpoint `(0+10)/2 = 5.0` for every target, regardless of what the
target actually is. `compute_mvo_portfolio` then reports this as a
successful solve (retcode=1) even though the resulting portfolio doesn't
come close to the requested volatility target. Running `test_mvo3.m`
standalone in a fresh MATLAB/Octave session reproduces this; running it
right after `test_mvo2.m` in the same session "accidentally" works, because
`clear` (unlike `clear all`) doesn't clear globals, so `test_mvo2.m`'s
earlier `init_global` call leaves `BISECTION_Tol` set. See
`docs/matlab_bugs_found.md` for the full writeup. This translation produces
the *correct* target-matching numbers (as `test_mvo3.m` would if it called
`init_global` like its neighbors do), since `mvo_target_portfolio`'s Python
`bisection` always has a well-defined tolerance."""
import numpy as np
from quanttoolbox.linalg.special_matrices import xpnd
from quanttoolbox.portfolio.mean_variance import mvo_target_portfolio
from quanttoolbox.stats.moments import corr_to_cov
mu = np.array([0.05, 0.06, 0.08, 0.06])
sigma = np.array([0.15, 0.20, 0.25, 0.30])
rho = xpnd(np.array([1.00, 0.10, 1.00, 0.40, 0.70, 1.00, 0.50, 0.40, 0.80, 1.00]), method=1)
cov_matrix = corr_to_cov(sigma, rho)
sigma_targets = np.array([15.00, 20.00]) / 100
bound_configs = {
"x1 (lb=-100, ub=100)": (-100.0, 100.0),
"x2 (lb=0, ub=100, long-only)": (0.0, 100.0),
"x3 (lb=0, ub=40, long-only, capped)": (0.0, 0.40),
}
for label, (lb, ub) in bound_configs.items():
print(f"{label}:")
results = mvo_target_portfolio(mu, cov_matrix, sigma_targets, problem="sigma", lb=lb, ub=ub)
for target, r in zip(sigma_targets, results, strict=False):
print(
f" target_sigma={100 * target:5.2f} gamma={r.gamma:6.3f} mu={100 * r.expected_return:6.2f} "
f"sigma={100 * r.volatility:6.2f} w={np.round(100 * r.weights, 2)}"
)
portfolio.erc_mdp¶
Thin re-export convenience module — see risk_budgeting (ERC) and
mean_variance (MDP) for the actual implementations and alternatives
discussion.
quanttoolbox.portfolio.erc_mdp
¶
Convenience re-exports for Equal Risk Contribution and Most Diversified Portfolio construction.
Ported from QuantToolBox/rpb/compute_erc_portfolio.m, QuantToolBox/mloapa/compute_{ERC_ADMM,ERC_CCD,MDP_ADMM}.m.
These are both already implemented in full elsewhere in this package --
ERC in portfolio.risk_budgeting (it's the b=1/n special case of risk
budgeting) and MDP in portfolio.mean_variance (it needs its own
nonlinear solve, unrelated to risk budgeting's machinery). This module
just re-exports both under the names matching the original MATLAB
toolbox's dedicated rpb/mloapa entry points, so callers used to
those names don't need to know the underlying module split.
erc_portfolio(cov_matrix, x0=None, method='ccd')
¶
Equal Risk Contribution portfolio: risk budgeting with all budgets equal (b = 1/n), pure volatility risk measure.
Original: rpb/compute_erc_portfolio.m (+ mloapa/compute_ERC_{ADMM,CCD}.m, equivalent alternative solvers for the same problem)
Source code in src/quanttoolbox/portfolio/risk_budgeting.py
489 490 491 492 493 494 495 496 497 498 499 500 501 | |
mdp_portfolio(cov_matrix, a_eq=None, b_eq=None, c_ineq=None, d_ineq=None, lb=None, ub=None, x0=None)
¶
Most Diversified Portfolio: maximize the diversification ratio (weighted-average individual volatility / portfolio volatility), i.e. minimize log(portfolio vol) - log(weighted-average individual vol).
Original: rpb/{compute_mdp_portfolio,compute_mdp_objective_function}.m
Source code in src/quanttoolbox/portfolio/mean_variance.py
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | |