Custom strategies¶
A custom strategy in jaxfolio is a first-class citizen: once written, it works
everywhere the built-ins do — the backtester,
compare, the registry, and every
plot. There are three ways to author one, in increasing
order of control.
Mode 1 — a weight function¶
The common case: write a function that maps returns to a weight vector (or a
{asset: weight} mapping) and wrap it with
custom_strategy.
Weights are validated, optionally renormalized to sum to one, and annualized
diagnostics are attached automatically.
import numpy as np
import jaxfolio as jf
from jaxfolio.custom import custom_strategy
def momentum_weights(returns):
"""Weight proportional to trailing cumulative return, clipped at zero."""
cum = (1.0 + returns).prod() - 1.0
return np.clip(cum.to_numpy(), 0.0, None)
momentum = custom_strategy(
"momentum",
momentum_weights,
register=True,
description="Long-only momentum tilt",
)
momentum(returns) # a full PortfolioResult, like any built-in
The weight function may return a NumPy/JAX array aligned to the columns, or a
{asset: weight} dict — anything missing is treated as zero weight.
Mode 2 — a JAX objective¶
To get constrained optimization for free, supply a differentiable objective and let the shared projected-gradient solver minimize it under the configured constraints — exactly how the built-in classical optimizers are built.
import jax.numpy as jnp
from jaxfolio.custom import CustomStrategy
def entropy_minvar(w, ctx):
"""Minimum variance minus an entropy bonus (encourages diversification)."""
variance = w @ ctx.cov @ w
entropy = -jnp.sum(w * jnp.log(w + 1e-9))
return variance - 0.002 * entropy
entropy_strategy = CustomStrategy.from_objective(
"entropy_minvar",
entropy_minvar,
register=True,
description="Entropy-regularized min variance",
)
The objective objective_fn(w, ctx) must be JAX-differentiable and return a
scalar to minimize. ctx is a moment context exposing everything the
built-ins use:
ctx attribute |
Meaning |
|---|---|
ctx.mu |
mean returns, \(\mu\) |
ctx.cov |
covariance, \(\Sigma\) |
ctx.returns |
the raw \(T \times N\) return matrix |
ctx.assets |
asset names |
ctx.n |
number of assets |
Pass a config to
from_objective to control the constraint set (long-only, weight bounds) and
solver settings.
Mode 3 — the toolkit, by hand¶
For full control, assemble a strategy from the public building blocks in
jaxfolio.toolkit — the stable, documented surface
that the built-ins themselves use. Everything an optimizer needs is re-exported
here, so a hand-written strategy reads exactly like a built-in one:
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 toolkit exposes moment estimators (moments, sample_covariance,
ewma_covariance, ledoit_wolf_covariance, …), the projections
(make_projection, project_simplex, project_box_budget), the solver core
(solve_projected_gradient, equal_start), portfolio math (portfolio_return,
portfolio_variance, sharpe_ratio), and — crucially —
finalize_result, which computes the annualized
diagnostics. Because your result goes through the same finalize_result as the
built-ins, it is indistinguishable from them downstream.
The registry¶
Registering a strategy makes it discoverable and mixable by name. Any of the
authoring modes above register with register=True; you can also use the
decorator form directly on an optimizer-shaped function:
from jaxfolio import register_strategy, get_strategy, list_strategies
@register_strategy("my_momentum", description="12-1 momentum tilt")
def my_momentum(returns):
...
list_strategies() # built-ins + your additions
list_strategies(custom_only=True) # just yours
get_strategy("my_momentum")(returns) # look up and run by name
Re-registering a name raises unless overwrite=True;
unregister removes one.
Putting it together¶
Custom strategies drop straight into compare and the dashboard alongside the
built-ins:
from jaxfolio.backtest import compare, metrics_table
results = compare(returns, {
"Momentum (custom)": momentum,
"Entropy MinVar (custom)": entropy_strategy,
"Max Sharpe": jf.maximum_sharpe,
"HRP": jf.hierarchical_risk_parity,
"1/N": jf.equal_weight,
}, lookback=252, rebalance_every=21)
print(metrics_table(results).round(3))