Toolkit & custom strategies¶
The public building blocks for authoring strategies. See the Custom strategies guide.
Toolkit¶
toolkit
¶
Public building blocks for authoring custom portfolio strategies.
This module is the stable, documented surface for writing your own optimizer. Everything a built-in optimizer uses internally is re-exported here so a custom strategy can be written the same idiomatic way:
from jaxfolio import toolkit as tk def my_strategy(returns): ... mu, cov, names, _ = tk.moments(returns) ... projection = tk.make_projection(long_only=True, weight_bounds=(0.0, 1.0)) ... def objective(w): ... return w @ cov @ w - 0.1 * (w @ mu) # min-variance with a return tilt ... w, _info = tk.solve_projected_gradient(objective, tk.equal_start(len(names)), projection) ... return tk.finalize_result(w, names, "My Strategy", mu=mu, cov=cov)
The heavy lifting (annualized diagnostics, weight cleanup) lives in
:func:finalize_result, which the built-in classical and graph optimizers also
use, so custom results are indistinguishable from built-in ones downstream
(backtester, compare, plots).
__all__
module-attribute
¶
__all__ = [
"as_matrix",
"moments",
"mean_returns",
"sample_covariance",
"ewma_covariance",
"ledoit_wolf_covariance",
"correlation_from_covariance",
"make_projection",
"project_simplex",
"project_box_budget",
"project_box_budget_vec",
"project_grouped",
"Box",
"Budget",
"Constraint",
"GroupCap",
"GroupFloor",
"CompiledConstraints",
"InfeasibleConstraints",
"compile_constraints",
"check_feasible",
"explain",
"solver_duals",
"ConstraintReport",
"SolverDuals",
"ProjectionDuals",
"project_box_budget_duals",
"project_grouped_duals",
"normalize_weights",
"softmax_weights",
"solve_projected_gradient",
"solve_constrained",
"select_projection",
"equal_start",
"solve_weight_path",
"available_solvers",
"resolve_solver",
"SolverSpec",
"portfolio_return",
"portfolio_variance",
"portfolio_volatility",
"sharpe_ratio",
"finalize_result",
"PERIODS_PER_YEAR",
"PortfolioResult",
"OptimizerConfig",
"TradingCosts",
]
ConstraintReport
dataclass
¶
ConstraintReport(
method: str,
objective: str,
sense: str,
assets: tuple[str, ...],
weights: ndarray,
available: bool,
quality: str,
reason: str | None,
stationarity: float | None,
kkt_residual: float | None,
step_check: float | None,
budget_multiplier: float | None,
budget_shadow_price: float | None,
budget_note: str | None,
constraints: tuple[ConstraintDual, ...],
assets_detail: tuple[AssetAttribution, ...],
warnings: tuple[str, ...] = (),
aux_stationarity: float | None = None,
l2_reg: float = 0.0,
_index: dict[str, int] = dict(),
)
The result of :func:explain — per-constraint and per-asset attribution.
quality grades everything else in the report:
exact
Converged to machine precision; multipliers are trustworthy.
approximate
Converged loosely, or the step probe disagreed. Numbers are shown with a
warning attached.
unreliable
Not converged, or the objective is non-smooth at the optimum. Shadow
prices are suppressed entirely — only primal facts are reported.
unavailable
This optimizer has no multipliers at all; reason says why.
Four renderings, for four audiences: :meth:explain_text reads the report as
prose, :meth:to_table lays the same facts out as fixed-width tables for a
terminal or a log, :meth:to_dict / :meth:to_json serialize it whole for a
machine, and :meth:to_frame / :meth:constraints_frame hand it to Polars.
All five are pure views over the same fields — none of them recompute anything.
binding
¶
binding() -> tuple[ConstraintDual, ...]
to_frame
¶
Per-asset attribution as a Polars frame with an asset column.
Source code in src/jaxfolio/attribution.py
constraints_frame
¶
Per-constraint activity and shadow price, keyed by name.
A different question from :meth:to_frame: which constraint is costing
the portfolio the most, rather than what put each asset where it is.
Source code in src/jaxfolio/attribution.py
explain_text
¶
A readable rendering of the report.
Source code in src/jaxfolio/attribution.py
to_table
¶
The report as fixed-width tables — for a terminal, a log, or a print-out.
Same facts as :meth:explain_text, laid out as columns instead of prose: a
header block, one row per named constraint, one row per asset, and the notes
that qualify them. Plain text, and every string jaxfolio itself wrote is folded
to ASCII, so the report survives a log file, a CI artifact and a non-UTF-8
console. Asset and constraint names are yours and pass through verbatim — a
report that mangles a ticker to fit a charset would be the worse trade.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_assets
|
int | None
|
Show only the first |
None
|
notes
|
bool
|
Include the trailing notes block (units caveat, quality reason, warnings). |
True
|
Examples:
Source code in src/jaxfolio/attribution.py
723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 | |
to_dict
¶
The whole report as a JSON-serializable dict.
Nothing is dropped and nothing is rounded: every constraint, every asset and
every diagnostic, with plain Python scalars throughout (no numpy types). A
number the report declines to stand behind stays None rather than
becoming 0.0 — the distinction is the point of the report, so it must
survive serialization. schema is there so a consumer can tell versions
apart if this grows.
The digest on result.metadata is the small, always-present cousin of
this: five keys for a backtest log, where this is the whole picture.
Source code in src/jaxfolio/attribution.py
to_json
¶
The report as a JSON string. indent=None for a single compact line.
SolverDuals
dataclass
¶
SolverDuals(
weights: ndarray,
gradient: ndarray,
lower: ndarray,
upper: ndarray,
budget: float,
row_names: tuple[str, ...],
row_lower: tuple[float, ...],
row_upper: tuple[float, ...],
group_of: tuple[int, ...],
budget_multiplier: float,
row_multipliers: tuple[float, ...],
reduced_costs: ndarray,
row_identified: tuple[bool, ...],
budget_identified: bool,
kkt_residual: float,
gradient_norm: float,
step: float,
step_check: float,
aux_stationarity: float | None = None,
objective: str = "",
sense: str = "minimize",
smooth: bool = True,
convex: bool = True,
l2_reg: float = 0.0,
solver_kind: str = "spg",
tol: float = 1e-07,
)
First-order information captured at a solved portfolio.
Attached to :attr:jaxfolio.types.PortfolioResult.attribution. Deliberately
numeric and self-contained — plain numpy arrays and tuples, no JAX arrays, no
closures, no reference to the returns panel — so a result stays lightweight
and picklable, and so :func:explain is pure post-processing that never
re-solves.
Attributes:
| Name | Type | Description |
|---|---|---|
weights, gradient |
The solution and the objective gradient there. |
|
lower, upper |
Effective per-asset bounds in force. |
|
budget, row_names, row_lower, row_upper, group_of |
The constraint set, as resolved by
:func: |
|
budget_multiplier, row_multipliers, reduced_costs |
The recovered multipliers, in objective units per period. Non-negative for a binding cap; see the module docstring for the sign convention. |
|
row_identified, budget_identified |
Whether each multiplier is pinned by the data at all. See
:class: |
|
kkt_residual, gradient_norm, step, step_check |
Convergence evidence. |
|
aux_stationarity |
float | None
|
For packed |
tol |
float
|
The convergence tolerance the solve was asked for. The quality grade is judged against it: "did this solve do what it was told to?" is a fairer and more portable question than any fixed absolute threshold. |
objective, sense, smooth, convex |
Provenance the report needs to know what it may claim. |
stationarity
property
¶
||d|| / ||grad f|| — the scale-free measure the quality gate uses.
summary
¶
A small JSON-serializable digest for PortfolioResult.metadata.
Source code in src/jaxfolio/attribution.py
CompiledConstraints
dataclass
¶
CompiledConstraints(
kind: str,
assets: tuple[str, ...],
budget: float,
lower: tuple[float, ...],
upper: tuple[float, ...],
row_names: tuple[str, ...],
row_lower: tuple[float, ...],
row_upper: tuple[float, ...],
group_of: tuple[int, ...],
specs: tuple[Constraint, ...],
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
)
A resolved, feasible constraint set ready to hand to the solver.
Everything is stored host-side as tuples so the object stays hashable and
picklable; the traced JAX arrays are built on demand by :meth:projection.
That also makes :meth:perturb a plain field replacement, which is what lets
a what-if re-solve reuse the compiled kernel.
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
Which projection kernel is needed — see the module constants. Part of the jit cache key. |
assets |
tuple[str, ...]
|
Asset names, in panel order. |
budget |
float
|
Required |
lower, upper |
Effective per-asset bounds, length |
|
row_names |
tuple[str, ...]
|
Names of the constraint rows, index-aligned with |
row_lower, row_upper |
Effective row limits, already clamped to what the box alone permits
( |
|
group_of |
tuple[int, ...]
|
Length |
specs |
tuple[Constraint, ...]
|
The original specifications, for reporting and :meth: |
long_only, weight_bounds |
The implicit bounds this set was compiled against. Retained so
:meth: |
members
¶
Asset names constrained by row name.
projection
¶
Return (projection_fn, pparams) for the cached solver kernel.
aux=True selects the variant that projects only the weight block of a
packed [w, tau] vector (the CVaR optimizer); path=True the
row-wise variant for a (T, N) weight path. Imported lazily because
:mod:jaxfolio.optimizers.base imports this package.
Source code in src/jaxfolio/constraints/compile.py
pparams
¶
The traced projection parameters for this constraint set.
Bounds and limits are returned as jnp arrays for the vector kinds and
as plain Python floats for the two scalar kinds — the latter reproducing
byte-for-byte what the pre-existing select_projection produced.
Source code in src/jaxfolio/constraints/compile.py
perturb
¶
perturb(name: str, **changes: float) -> CompiledConstraints
Return a copy with one constraint's limits changed.
Only values move, so the perturbed set keeps the same kind and row
count and therefore the same compiled kernel — the basis of a cheap
what-if re-solve. Recompiles the specs so the new limits are re-validated
and re-clamped, and so an infeasible relaxation is still caught.
Source code in src/jaxfolio/constraints/compile.py
InfeasibleConstraints
¶
Bases: ValueError
The requested constraint set admits no feasible portfolio.
Raised at compile time, before any moments are estimated or any solve is attempted. Because the supported rows have disjoint supports, the feasibility test is exact — necessary and sufficient — so this is never a false alarm, and conversely a set that compiles can always be satisfied.
Box
dataclass
¶
Box(
lower: float | tuple[float, ...] = 0.0,
upper: float | tuple[float, ...] = 1.0,
assets: tuple[str, ...] | tuple[int, ...] | None = None,
name: str = "box",
)
Bases: Constraint
Per-asset weight bounds lower <= w_i <= upper.
lower and upper are each either a scalar applied to every asset in
scope, or a per-asset sequence. assets=None (the default) scopes the box
to the whole universe and replaces the bounds implied by
OptimizerConfig.long_only / weight_bounds; an assets-scoped box
intersects with whatever is already in force, so per-name overrides
compose::
Box(lower=0.0, upper=0.10) # every asset capped at 10%
Box(upper=0.02, assets=["ILLIQ"], name="illiquid") # tighter on one name
A per-asset sequence must match the length of assets, or of the whole
universe when assets is None.
width
¶
Length of the per-asset bounds, or None if both are scalars.
Budget
dataclass
¶
Bases: Constraint
Total invested weight: sum(w) == total.
Always present. Instantiate one explicitly only to override the default
fully-invested 1.0 — e.g. Budget(0.0) for a dollar-neutral book.
Constraint
¶
Bases: ABC
Base class for every named constraint.
Deliberately not a dataclass: it declares no fields, so subclasses stay free to order their own (a dataclass base would force every subclass field to come after the base's and to carry a default).
GroupCap
dataclass
¶
GroupCap(
name: str,
assets: tuple[str, ...] | tuple[int, ...],
max: float | None = None,
min: float | None = None,
)
Bases: RowConstraint
A cap and/or floor on the summed weight of a named group of assets.
The canonical sector constraint::
GroupCap("tech", ["AAPL", "MSFT", "NVDA"], max=0.30) # tech <= 30%
GroupCap("energy", ["XOM", "CVX"], min=0.05) # energy >= 5%
GroupCap("bonds", ["TLT", "IEF"], min=0.20, max=0.40) # a band
Groups must be disjoint — no asset may appear in two of them. See
:func:jaxfolio.constraints.compile.compile_constraints, which enforces it,
and :mod:jaxfolio.constraints.structured for why.
ProjectionDuals
¶
Bases: NamedTuple
A projected point together with the multipliers that produced it.
A NamedTuple so it is a JAX pytree: it flows through jit, vmap
and lax control flow with no registration.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
Array
|
The projected point. |
budget |
Array
|
Multiplier |
rows |
Array
|
|
bounds |
Array
|
|
identified |
Array
|
|
budget_identified |
Array
|
Is |
feasibility_residual |
Array
|
|
SolverSpec
¶
Bases: NamedTuple
A hashable, equality-stable solver key (safe as a jit static argument).
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
|
factory |
Callable[..., Any] | None
|
The optax factory to call, e.g. |
name |
str
|
Display label used in error messages and diagnostics. |
options |
tuple[tuple[str, Any], ...]
|
Extra keyword arguments for the factory, normalized to a sorted tuple of
|
OptimizerConfig
dataclass
¶
OptimizerConfig(
risk_free_rate: float = 0.0,
long_only: bool = True,
weight_bounds: tuple[float, float] | None = None,
max_iter: int = 2000,
solver: str | Callable[..., Any] = "spg",
solver_options: Mapping[str, Any] | None = None,
learning_rate: float | None = None,
tol: float = 1e-07,
l2_reg: float = 0.0,
constraints: tuple[Any, ...] = (),
attribution: bool = True,
)
Configuration shared by the projected-gradient classical optimizers.
Attributes:
| Name | Type | Description |
|---|---|---|
risk_free_rate |
float
|
Per-period risk-free rate used by Sharpe-style objectives. |
long_only |
bool
|
If |
weight_bounds |
tuple[float, float] | None
|
Explicit |
max_iter |
int
|
Maximum projected-gradient iterations. |
solver |
str | Callable[..., Any]
|
Which projected-gradient solver to use. Three spellings are accepted:
The optax solvers keep smooth fixed-step dynamics, which are preferable
when differentiating through the optimizer to train an allocation
policy. Pass the factory, not a pre-built |
solver_options |
Mapping[str, Any] | None
|
Extra keyword arguments forwarded to the optax factory, e.g.
|
learning_rate |
float | None
|
Step size for the solver. |
tol |
float
|
Convergence tolerance. For |
l2_reg |
float
|
Optional L2 penalty on weights (encourages diversification). |
attribution |
bool
|
Capture the first-order (KKT) diagnostics needed by
:func: |
constraints |
tuple[Any, ...]
|
Named constraint specifications from :mod: Unlike Validation is two-stage, like |
with_constraints
¶
with_constraints(constraints: Any) -> OptimizerConfig
solver_spec
¶
solver_spec() -> SolverSpec
Resolve solver / solver_options into a hashable solver key.
bounds
¶
Resolve the effective per-asset weight bounds.
Honors an explicit weight_bounds; otherwise defaults to (0, 1)
for a long-only portfolio and (-1, 1) when shorting is allowed.
Source code in src/jaxfolio/types.py
PortfolioResult
dataclass
¶
PortfolioResult(
weights: ndarray,
assets: list[str],
method: str,
expected_return: float | None = None,
volatility: float | None = None,
sharpe: float | None = None,
metadata: dict[str, Any] = dict(),
trajectory: ndarray | None = None,
attribution: Any | None = None,
)
The output of an optimizer: weights plus diagnostics.
Attributes:
| Name | Type | Description |
|---|---|---|
weights |
ndarray
|
Portfolio weights as a 1-D numpy array (sums to 1). |
assets |
list[str]
|
Asset names aligned with |
method |
str
|
Human-readable optimizer name. |
expected_return |
float | None
|
Annualized (or per-period, if unscaled) expected portfolio return. |
volatility |
float | None
|
Portfolio volatility on the same scale as |
sharpe |
float | None
|
Sharpe ratio implied by |
metadata |
dict[str, Any]
|
Free-form diagnostics (iterations, converged flag, cluster labels, ...).
Values are kept JSON-serializable by convention; bulk arrays belong in a
dedicated field like |
trajectory |
ndarray | None
|
Optional |
attribution |
Any | None
|
Optional :class: |
as_dict
¶
Return {asset: weight} sorted by descending absolute weight.
TradingCosts
dataclass
¶
TradingCosts(
spread_bps: float | tuple[float, ...] = 0.0,
commission_bps: float | tuple[float, ...] = 0.0,
impact_bps: float | tuple[float, ...] = 0.0,
turnover_penalty: float = 0.0,
smoothing: float = 0.1,
)
Per-period trading frictions for the multi-period optimizer.
The three cost coefficients are quoted in basis points of traded notional
per unit of |dw|, one-way, per period — the units a trader states
them in. Each accepts a scalar (applied to every asset) or a per-asset
sequence of length n; sequences are stored as tuples so the dataclass
stays hashable.
Costs are per period and never annualized: the objective trades them off
against per-period mu and Sigma, so annualizing would double-count.
Attributes:
| Name | Type | Description |
|---|---|---|
spread_bps |
float | tuple[float, ...]
|
Bid-ask half-spread, in bps, charged linearly on |
commission_bps |
float | tuple[float, ...]
|
Proportional commission / fee, in bps, also linear on |
impact_bps |
float | tuple[float, ...]
|
Quadratic market-impact coefficient, in bps per unit of |
turnover_penalty |
float
|
An extra L1 penalty on |
smoothing |
float
|
Starting (widest) relative Huber half-width used to make the L1 cost
differentiable. The absolute width is The optimizer walks a geometrically shrinking ladder from this width and keeps whichever stage is best under the exact objective, so this is a starting point for a search rather than a value the answer hinges on — which is why it is a tuning knob and not part of the API contract. |
Notes
Unit bridge to the backtester: TradingCosts(spread_bps=10.0) prices the
same linear cost that backtest(transaction_cost=0.0010) charges. The
engine keyword is decimal for historical reasons; this class is bps.
Turnover control here is soft — costs are priced, not capped. See the multi-period guide for why a hard turnover budget is not expressible in this solver, and what to do instead.
Examples:
>>> TradingCosts(spread_bps=5.0, commission_bps=1.0).linear_decimal(3)
array([0.0006, 0.0006, 0.0006])
is_active
¶
True if any cost coefficient is non-zero (reporting/metadata only).
Source code in src/jaxfolio/types.py
linear_decimal
¶
Total linear cost (spread_bps + commission_bps) / 1e4, length n.
quadratic_decimal
¶
as_params
¶
Traced objective parameters for n assets.
Returns {"c_lin": (n,), "c_quad": (n,)} as JAX arrays. Both are
traced rather than static, so changing a cost never recompiles the
solver kernel. turnover_penalty is folded into c_lin because it
enters the objective identically; the split is preserved for reporting by
:meth:linear_decimal.
Source code in src/jaxfolio/types.py
explain
¶
explain(
result: PortfolioResult,
*,
prim_tol: float | None = None,
dual_tol: float | None = None,
) -> ConstraintReport
Explain a solved portfolio: which constraints bind, and what they cost.
Pure post-processing — reads only the payload captured at solve time and never
re-solves. Always returns a well-formed report: optimizers that have no
multipliers (closed-form weightings, the graph methods, the learning policies)
come back with available=False and a specific reason, with the primal
facts still filled in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prim_tol
|
float | None
|
How close to a bound counts as on it. Defaults to |
None
|
dual_tol
|
float | None
|
How large a multiplier counts as non-zero. Defaults to |
None
|
Source code in src/jaxfolio/attribution.py
948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 | |
solver_duals
¶
solver_duals(
objective,
obj_params,
compiled: CompiledConstraints,
weights,
*,
objective_name: str,
sense: str = "minimize",
smooth: bool = True,
convex: bool = True,
l2_reg: float = 0.0,
solver_kind: str = "spg",
tol: float = 1e-07,
aux_dim: int = 0,
) -> SolverDuals
Recover the KKT multipliers of a solved portfolio problem.
Runs after the solve, outside any lax control flow, at the cost of one
gradient evaluation and two projections. Solver-agnostic by construction,
which is what lets it retrofit a real stationarity measure onto the optax
solvers (whose own info["residual"] is a weight-update norm, explicitly
not a KKT test).
objective(z, obj_params) is the module-level objective that was minimized;
weights is the returned iterate. For a packed [w, tau] variable pass
aux_dim=1 and the full packed vector — the projection's normal cone
factorizes as N_C(w) x {0}, so the weight-block multipliers are unaffected
and tau's stationarity comes back separately.
Source code in src/jaxfolio/attribution.py
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 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 | |
check_feasible
¶
check_feasible(
constraints: Sequence[Constraint] | None,
assets: Sequence[str],
*,
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
) -> None
Validate a constraint set without solving anything.
Raises the same errors :func:compile_constraints would. Useful for
validating user input up front — e.g. in a UI — since the feasibility test is
exact and costs nothing.
Source code in src/jaxfolio/constraints/compile.py
compile_constraints
¶
compile_constraints(
constraints: Sequence[Constraint] | None,
assets: Sequence[str],
*,
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
) -> CompiledConstraints
Resolve constraint specifications against an asset universe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
constraints
|
Sequence[Constraint] | None
|
Specifications from :mod: |
required |
assets
|
Sequence[str]
|
Asset names in panel order, as returned by
:func: |
required |
long_only
|
bool
|
The implicit bounds, normally |
True
|
weight_bounds
|
bool
|
The implicit bounds, normally |
True
|
Raises:
| Type | Description |
|---|---|
InfeasibleConstraints
|
The set admits no feasible portfolio. Exact, not heuristic. |
ValueError
|
Two rows share an asset (see :mod: |
KeyError
|
A constraint names an asset that is not in the panel. |
Source code in src/jaxfolio/constraints/compile.py
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 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 | |
normalize_weights
¶
Rescale weights to sum to budget (assumes a non-zero sum).
Source code in src/jaxfolio/constraints/projections.py
project_box_budget
¶
project_box_budget(
v: Array,
lower: float = 0.0,
upper: float = 1.0,
budget: float = 1.0,
*,
max_iter: int = 50,
tol: float = 1e-10,
) -> Array
Project v onto {w : lower <= w <= upper, sum(w) = budget}.
Solves for the Lagrange multiplier tau on the budget constraint by
bisection: w(tau) = clip(v - tau, lower, upper) is monotone decreasing in
tau, so we bisect until sum(w(tau)) == budget. Feasible whenever
n*lower <= budget <= n*upper.
Source code in src/jaxfolio/constraints/projections.py
project_simplex
¶
Euclidean projection of v onto the scaled simplex summing to budget.
Implements the classic Duchi et al. (2008) sort-based algorithm, which is exact and differentiable almost everywhere.
Source code in src/jaxfolio/constraints/projections.py
softmax_weights
¶
Map unconstrained logits to long-only weights via softmax (sums to budget).
GroupFloor
¶
GroupFloor(
name: str,
assets: Sequence[str] | Sequence[int],
min: float,
) -> GroupCap
A named floor on a group's summed weight — sugar for GroupCap(min=...).
Returns a :class:GroupCap rather than a distinct class so that a group can
never carry a floor and a cap that disagree, and so the compiler and the
attribution report have exactly one row type to reason about.
Source code in src/jaxfolio/constraints/spec.py
project_box_budget_duals
¶
project_box_budget_duals(
v: Array,
lower: Array,
upper: Array,
budget: Array,
*,
max_iter: int = 50,
) -> ProjectionDuals
Box + budget projection with its multipliers.
The row-free case of :func:project_grouped_duals: rows is a single
all-zero sentinel entry and identified is [False], so a caller can
treat both kernels uniformly.
Source code in src/jaxfolio/constraints/structured.py
project_box_budget_vec
¶
project_box_budget_vec(
v: Array,
lower: Array,
upper: Array,
budget: Array,
*,
max_iter: int = 50,
) -> Array
Project v onto {w : lower <= w <= upper, sum(w) = budget}.
The per-asset-bounds generalization of
:func:jaxfolio.constraints.projections.project_box_budget: lower and
upper are (n,) arrays rather than scalars. Same method — bisect the
budget multiplier tau, since w(tau) = clip(v - tau, lower, upper) is
monotone decreasing in tau — and the same feasibility requirement,
sum(lower) <= budget <= sum(upper).
Source code in src/jaxfolio/constraints/structured.py
project_grouped
¶
project_grouped(
v: Array,
lower: Array,
upper: Array,
budget: Array,
gid: Array,
g_lower: Array,
g_upper: Array,
*,
outer_iter: int = _OUTER_ITER,
inner_iter: int = _INNER_ITER,
) -> Array
Project onto box + budget + disjoint group rows.
See :func:project_grouped_duals for the algorithm and the meaning of every
argument; this is the primal-only wrapper used inside the solver loop.
Source code in src/jaxfolio/constraints/structured.py
project_grouped_duals
¶
project_grouped_duals(
v: Array,
lower: Array,
upper: Array,
budget: Array,
gid: Array,
g_lower: Array,
g_upper: Array,
*,
outer_iter: int = _OUTER_ITER,
inner_iter: int = _INNER_ITER,
) -> ProjectionDuals
Project onto box + budget + disjoint group rows, returning multipliers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
v
|
Array
|
|
required |
lower
|
Array
|
|
required |
upper
|
Array
|
|
required |
budget
|
Array
|
Scalar; |
required |
gid
|
Array
|
|
required |
g_lower
|
Array
|
|
required |
g_upper
|
Array
|
|
required |
Returns:
| Type | Description |
|---|---|
class:`ProjectionDuals`. Validated against CVXPY over 40 random problems
|
|
(``n`` in 6..40, 2..4 rows, long-only and long-short): weights agree to
|
|
2.1e-8, the budget multiplier to 2.2e-9, and every *identified* row
|
|
multiplier to 3.9e-9. See ``ProjectionDuals.identified`` for the rows whose
|
|
multiplier is not pinned by the data.
|
|
Notes
The inner target is clip(freesum_k(lam), L_k, U_k) — not an early exit
when the row is already satisfied. That distinction is load-bearing: the
clipped-target form is what makes the dual decomposition unique, so lam
and theta cannot drift into offsetting each other. It also makes "row is
slack" an exact test (target == freesum), which is how theta_k is
forced to a clean zero instead of the arbitrary value a degenerate bracket
would otherwise return.
The outer bracket needs no theta term even though w depends on
theta: the inner solve pins each group's sum to
clip(freesum_k(lam), L_k, U_k) regardless of how large theta_k gets,
so the total is a function of lam alone — and a non-increasing one, since
every freesum_k and every ungrouped coordinate is non-increasing in
lam and clip is monotone.
Source code in src/jaxfolio/constraints/structured.py
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 390 391 392 393 394 395 396 397 398 | |
as_matrix
¶
Return (matrix, asset_names) from a returns object.
Accepts a Polars DataFrame (numeric columns become asset names), or a raw
array (names default to asset_0...). Date/datetime columns are metadata
and are excluded from the optimizer matrix. Nulls and NaNs become zeros.
Source code in src/jaxfolio/moments/estimators.py
correlation_from_covariance
¶
Convert a covariance matrix to a correlation matrix.
ewma_covariance
¶
ewma_covariance(
returns: Array,
*,
halflife: float = 63.0,
periods_per_year: int | None = None,
) -> Array
Exponentially-weighted covariance matrix.
Recent observations receive more weight; halflife is in periods (default
~one quarter of trading days).
Source code in src/jaxfolio/moments/estimators.py
ledoit_wolf_covariance
¶
ledoit_wolf_covariance(
returns: Array, *, periods_per_year: int | None = None
) -> tuple[Array, float]
Ledoit-Wolf shrinkage toward a scaled-identity target.
Returns (shrunk_cov, shrinkage_intensity). The shrinkage intensity is
estimated analytically (Ledoit & Wolf, 2004) and clipped to [0, 1].
Source code in src/jaxfolio/moments/estimators.py
mean_returns
¶
Sample mean of returns; annualized if periods_per_year is given.
Source code in src/jaxfolio/moments/estimators.py
sample_covariance
¶
Sample covariance matrix (denominator T - 1); optionally annualized.
Requires at least two observations; a single row has no defined covariance.
Source code in src/jaxfolio/moments/estimators.py
make_projection
¶
make_projection(
long_only: bool,
weight_bounds: tuple[float, float],
budget: float = 1.0,
*,
constraints: Sequence[Any] = (),
assets: Sequence[str] | None = None,
) -> Callable[[Array], Array]
Return the w -> w projection matching the requested constraint set.
constraints accepts named specifications from
:mod:jaxfolio.constraints.spec (sector caps, per-asset bound vectors). They
need assets — the panel's asset names in order — to resolve names to
columns; pass integer-indexed specs to skip that. The closure form re-traces
per call anyway, so unlike :func:select_projection there is no cache concern
here.
Source code in src/jaxfolio/optimizers/base.py
portfolio_return
¶
portfolio_variance
¶
portfolio_volatility
¶
select_projection
¶
select_projection(
long_only: bool,
weight_bounds: tuple[float, float],
*,
aux: bool = False,
path: bool = False,
constraints: Sequence[Any] = (),
assets: Sequence[str] | None = None,
)
Return (projection_fn, pparams) for the cached kernel.
aux=True selects the variant that projects only the weight-block of a
packed [w, tau] vector (used by the CVaR optimizer). path=True
selects the row-wise variant for a (T, N) weight path (used by the
multi-period optimizer). pparams is a tuple of Python floats — passed as
a traced argument to the kernel, and identical for all three variants.
constraints accepts named specifications from
:mod:jaxfolio.constraints.spec, which need assets to resolve names to
columns. With no named constraints this returns exactly what it always did:
the same function object and the same Python-float tuple, so no pre-existing
solve changes kernel, cache entry, or dtype.
Source code in src/jaxfolio/optimizers/base.py
sharpe_ratio
¶
Sharpe ratio of a weight vector given moments.
Source code in src/jaxfolio/optimizers/base.py
solve_constrained
¶
solve_constrained(
objective,
obj_params,
w0: Array,
projection,
proj_params,
*,
solver: str | Callable[..., Any] | SolverSpec = "spg",
learning_rate: float | None = None,
max_iter: int = 2000,
tol: float = 1e-07,
solver_options: Mapping[str, Any] | None = None,
) -> tuple[Array, dict[str, Array]]
Cached entry point for the built-in optimizers.
objective and projection must be module-level functions (see
:mod:jaxfolio.optimizers.classical) so the underlying jit cache hits.
solver is "spg", any optax optimizer name or factory, or a
pre-resolved :class:~jaxfolio.solvers.SolverSpec.
Returns (weights, info) with info["iterations"] and
info["residual"] (the solver's convergence measure at the final iterate).
Source code in src/jaxfolio/optimizers/base.py
solve_projected_gradient
¶
solve_projected_gradient(
objective: Objective,
w0: Array,
projection: Callable[[Array], Array],
*,
learning_rate: float | None = None,
max_iter: int = 2000,
tol: float = 1e-07,
solver: str | Callable[..., Any] | SolverSpec = "spg",
solver_options: Mapping[str, Any] | None = None,
) -> tuple[Array, dict[str, Array]]
Minimize objective over the feasible set defined by projection.
Accepts arbitrary Python closures objective(w) -> scalar and
projection(w) -> w (the shape used by custom strategies and the
toolkit). Because the closure identity changes per call this re-traces
each time — for hot loops over a built-in optimizer, prefer the cached
:func:solve_constrained. solver is "spg", any optax optimizer name
or factory, or a pre-resolved :class:~jaxfolio.solvers.SolverSpec.
Returns (weights, info) with info["iterations"] and
info["residual"].
Source code in src/jaxfolio/optimizers/base.py
solve_weight_path
¶
solve_weight_path(
mu_path,
cov,
w_prev,
*,
risk_aversion: float = 1.0,
c_lin=0.0,
c_quad=0.0,
l2_reg: float = 0.0,
trade_eps_rel: float = 0.1,
refine: int = DEFAULT_REFINE,
long_only: bool = True,
weight_bounds: tuple[float, float] = (0.0, 1.0),
solver: Any = "spg",
learning_rate: float | None = None,
max_iter: int = 2000,
tol: float = 1e-06,
solver_options: Mapping[str, Any] | None = None,
) -> tuple[Array, dict[str, Any]]
Solve the multi-period mean-variance problem for a whole weight path.
This is the primitive behind :func:multi_period_mean_variance, exposed for
callers who already hold forecasts and want the raw path back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mu_path
|
Expected returns per period, shape |
required | |
cov
|
Covariance, either |
required | |
w_prev
|
Currently held weights, shape |
required | |
risk_aversion
|
float
|
Coefficient |
1.0
|
c_lin
|
Linear and quadratic trade-cost coefficients in decimal units, broadcast
against |
0.0
|
|
c_quad
|
Linear and quadratic trade-cost coefficients in decimal units, broadcast
against |
0.0
|
|
trade_eps_rel
|
float
|
Starting (widest) Huber half-width, relative to |
0.1
|
refine
|
int
|
Number of geometric refinements of the Huber width beyond the first stage, each shrinking it by a factor of three. Every stage warm-starts from the previous one and reuses the same compiled kernel, because the width is a traced parameter — so the ladder costs iterations, never compilations. The returned path is the stage that scored best under the exact (un-smoothed) objective, not simply the last one: accuracy is not monotone in the width, so selecting is what makes the result reliable. |
DEFAULT_REFINE
|
tol
|
float
|
Convergence tolerance. Defaults to |
1e-06
|
Returns:
| Type | Description |
|---|---|
tuple
|
|
Notes
A high c_lin relative to the Huber width legitimately leaves the
projected-gradient residual on a plateau far above tol while the answer is
exactly right (the correct action is "do not trade", and the residual carries
the un-actioned cost gradient). Read info["converged"] as a diagnostic,
not a failure.
Source code in src/jaxfolio/optimizers/multiperiod.py
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 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 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 | |
equal_start
¶
finalize_result
¶
finalize_result(
weights,
assets: list[str],
method: str,
*,
mu: Array | None = None,
cov: Array | None = None,
returns=None,
risk_free: float = 0.0,
periods_per_year: int = PERIODS_PER_YEAR,
metadata: dict | None = None,
clean_eps: float = 1e-10,
trajectory=None,
attribution=None,
) -> PortfolioResult
Assemble a :class:PortfolioResult with annualized diagnostics.
Provide the moments either directly (mu and cov) or implicitly via
returns (sample moments are computed from it). Weights are coerced to a
flat float array and values below clean_eps in magnitude are zeroed. This
is the single source of truth for the annualized return / volatility / Sharpe
reported on every optimizer result — built-in and custom alike.
trajectory is an optional (T, n) planned weight path for multi-period
optimizers. It is attached verbatim — not cleaned by clean_eps, so the
path keeps the solver's exact iterate while weights is tidied for display.
attribution is an optional :class:jaxfolio.attribution.SolverDuals. It is
attached verbatim and a small JSON-serializable digest of it is merged into
metadata, so consumers that only read metadata still see the
convergence and binding-constraint summary.
Source code in src/jaxfolio/results.py
moments
¶
Return (mu, cov, asset_names, return_matrix) from a returns object.
cov_estimator optionally overrides the default sample covariance.
Source code in src/jaxfolio/results.py
available_solvers
¶
Return the solver names that can be passed as solver=....
("spg",) followed by the sorted names of every optax and optax.contrib
factory that takes a learning_rate. This is a discovery aid for humans and
for error messages — resolution itself is more permissive, so optimizers with
their own adaptive step (dowg, cocob) still work even though they are
not listed here.
Source code in src/jaxfolio/solvers.py
resolve_solver
¶
resolve_solver(
solver: str | Callable[..., Any] | SolverSpec = SPG,
solver_options: Mapping[str, Any] | None = None,
) -> SolverSpec
Normalize a solver spelling into a :class:SolverSpec.
Accepts "spg", an optax optimizer name, an optax factory callable, or an
already-resolved :class:SolverSpec (the function is idempotent, so callers
can normalize defensively). Raises :class:ValueError for an unknown name and
:class:TypeError for an unusable object.
Source code in src/jaxfolio/solvers.py
Solver selection¶
solvers
¶
Solver selection: the built-in SPG method plus any optax optimizer.
The shared projected-gradient solver in :mod:jaxfolio.optimizers.base accepts a
solver spec rather than a hard-coded name. A spec is produced by
:func:resolve_solver, which understands three spellings:
"spg"— the built-in spectral projected-gradient method (default).- any optax optimizer by name —
"adam","adamw","sgd","rmsprop","lion", ... Resolved against :mod:optaxand then :mod:optax.contrib, so anything the installed optax ships is reachable. - an optax factory callable —
optax.adamw,optax.contrib.adopt, or a module-level function of your own that returns aGradientTransformation(the escape hatch foroptax.chain(...)compositions).
Hyperparameters go in solver_options ({"weight_decay": 1e-3}), not in a
pre-built transformation — see the cache note below.
Cache stability
The solver spec is a static argument of the jit-cached kernel, so it has to be
hashable and compare equal across separately constructed instances. Name strings
and module-level factories satisfy that: optax.adamw is the same object every
time you look it up, and :class:SolverSpec is a plain NamedTuple of
hashable fields. A pre-built transformation (optax.adam(1e-2)) does not: it
is a fresh tuple of closures on every call, hashed by identity, so passing one
would recompile the kernel on every single solve. :func:resolve_solver
therefore rejects pre-built transformations and points at the factory spelling.
SolverSpec
¶
Bases: NamedTuple
A hashable, equality-stable solver key (safe as a jit static argument).
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
str
|
|
factory |
Callable[..., Any] | None
|
The optax factory to call, e.g. |
name |
str
|
Display label used in error messages and diagnostics. |
options |
tuple[tuple[str, Any], ...]
|
Extra keyword arguments for the factory, normalized to a sorted tuple of
|
resolve_solver
¶
resolve_solver(
solver: str | Callable[..., Any] | SolverSpec = SPG,
solver_options: Mapping[str, Any] | None = None,
) -> SolverSpec
Normalize a solver spelling into a :class:SolverSpec.
Accepts "spg", an optax optimizer name, an optax factory callable, or an
already-resolved :class:SolverSpec (the function is idempotent, so callers
can normalize defensively). Raises :class:ValueError for an unknown name and
:class:TypeError for an unusable object.
Source code in src/jaxfolio/solvers.py
build_optimizer
¶
build_optimizer(
spec: SolverSpec, learning_rate: float | None = None
) -> GradientTransformation
Instantiate the optax transformation described by spec.
learning_rate=None falls back to :data:DEFAULT_LEARNING_RATE (1e-2)
— optax factories have no default of their own. Factories that take no
learning rate (optax.contrib.dowg, cocob, ...) are called without one,
and an explicitly supplied rate is then an error rather than silently ignored.
Source code in src/jaxfolio/solvers.py
available_solvers
¶
Return the solver names that can be passed as solver=....
("spg",) followed by the sorted names of every optax and optax.contrib
factory that takes a learning_rate. This is a discovery aid for humans and
for error messages — resolution itself is more permissive, so optimizers with
their own adaptive step (dowg, cocob) still work even though they are
not listed here.
Source code in src/jaxfolio/solvers.py
Custom strategy authoring¶
custom
¶
Author your own portfolio strategy with minimal boilerplate.
Two entry points, both producing a proper :class:PortfolioResult that works
everywhere a built-in optimizer does (backtester, compare, plots):
- :func:
custom_strategy/ :meth:CustomStrategy.from_weights— wrap a function that maps returns to a weight vector (array) or a{asset: weight}mapping. Weights are validated, optionally renormalized, and annualized diagnostics are attached automatically. - :meth:
CustomStrategy.from_objective— provide a JAX objectivef(w, ctx)and let the shared projected-gradient solver optimize it under the configured constraints (this is exactly how the built-in classical optimizers are built).
Both optionally register the resulting strategy by name via :mod:jaxfolio.registry.
CustomStrategy
¶
CustomStrategy(
name: str, fn: Callable[[object], PortfolioResult]
)
A user-defined strategy exposed with the standard optimizer interface.
Instances are callable as strategy(returns) -> PortfolioResult, so they
drop straight into :func:jaxfolio.backtest.compare and the plotting layer.
Source code in src/jaxfolio/custom.py
from_weights
classmethod
¶
from_weights(
name: str,
weight_fn: Callable[[object], object],
*,
renormalize: bool = True,
risk_free: float = 0.0,
register: bool = False,
description: str = "",
) -> CustomStrategy
Wrap a returns -> weights function into a full strategy.
weight_fn may return a numpy/JAX array aligned to the asset columns or
a {asset: weight} mapping. If renormalize is True (default)
and the weights sum to a non-zero value, they are scaled to sum to 1.
Source code in src/jaxfolio/custom.py
from_objective
classmethod
¶
from_objective(
name: str,
objective_fn: Callable[[Array, _Context], Array],
*,
config: OptimizerConfig | None = None,
register: bool = False,
description: str = "",
) -> CustomStrategy
Build a strategy by minimizing a JAX objective under constraints.
objective_fn(w, ctx) must be JAX-differentiable and return a scalar to
minimize; ctx is a :class:_Context exposing mu, cov,
returns, assets, n. The shared projected-gradient solver and
the configured constraint set (long-only / bounds) are reused verbatim,
including config.solver / config.solver_options — so a custom
objective can be minimized with any optax optimizer.
Source code in src/jaxfolio/custom.py
custom_strategy
¶
custom_strategy(
name: str,
weight_fn: Callable[[object], object],
*,
renormalize: bool = True,
risk_free: float = 0.0,
register: bool = False,
description: str = "",
) -> CustomStrategy
Shorthand for :meth:CustomStrategy.from_weights (the common case).
Source code in src/jaxfolio/custom.py
Result assembly¶
results
¶
Low-level result assembly and moment extraction.
Kept dependency-light on purpose: this module imports only the moment estimators
(never the optimizer package), so both the optimizers and the higher-level
:mod:jaxfolio.toolkit can import it without creating an import cycle.
moments
¶
Return (mu, cov, asset_names, return_matrix) from a returns object.
cov_estimator optionally overrides the default sample covariance.
Source code in src/jaxfolio/results.py
equal_start
¶
finalize_result
¶
finalize_result(
weights,
assets: list[str],
method: str,
*,
mu: Array | None = None,
cov: Array | None = None,
returns=None,
risk_free: float = 0.0,
periods_per_year: int = PERIODS_PER_YEAR,
metadata: dict | None = None,
clean_eps: float = 1e-10,
trajectory=None,
attribution=None,
) -> PortfolioResult
Assemble a :class:PortfolioResult with annualized diagnostics.
Provide the moments either directly (mu and cov) or implicitly via
returns (sample moments are computed from it). Weights are coerced to a
flat float array and values below clean_eps in magnitude are zeroed. This
is the single source of truth for the annualized return / volatility / Sharpe
reported on every optimizer result — built-in and custom alike.
trajectory is an optional (T, n) planned weight path for multi-period
optimizers. It is attached verbatim — not cleaned by clean_eps, so the
path keeps the solver's exact iterate while weights is tidied for display.
attribution is an optional :class:jaxfolio.attribution.SolverDuals. It is
attached verbatim and a small JSON-serializable digest of it is merged into
metadata, so consumers that only read metadata still see the
convergence and binding-constraint summary.