jaxfolio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jaxfolio/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """jaxfolio — portfolio optimization and options strategies in JAX.
2
+
3
+ A modern, JAX-powered library covering traditional, learning-based, and
4
+ graph-based portfolio optimizers, a differentiable options toolkit, a
5
+ walk-forward backtester, and dark-themed visualizations.
6
+
7
+ Quickstart
8
+ ----------
9
+ >>> import jaxfolio as jf
10
+ >>> returns = jf.generate_returns(n_assets=8, seed=0)
11
+ >>> result = jf.maximum_sharpe(returns)
12
+ >>> result.top(3)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from jaxfolio.custom import CustomStrategy, custom_strategy
18
+ from jaxfolio.data import (
19
+ generate_prices,
20
+ generate_returns,
21
+ load_csv,
22
+ load_option_chain,
23
+ load_parquet,
24
+ load_yfinance,
25
+ to_returns,
26
+ train_test_split,
27
+ )
28
+ from jaxfolio.llm import (
29
+ llm_agent_portfolio,
30
+ llm_black_litterman,
31
+ llm_sentiment_portfolio,
32
+ )
33
+ from jaxfolio.optimizers import (
34
+ black_litterman,
35
+ deep_sharpe,
36
+ equal_weight,
37
+ hierarchical_equal_risk,
38
+ hierarchical_risk_parity,
39
+ inverse_volatility,
40
+ kelly,
41
+ maximum_diversification,
42
+ maximum_sharpe,
43
+ mean_variance,
44
+ min_cvar,
45
+ minimum_variance,
46
+ mst_centrality,
47
+ online_gradient,
48
+ risk_parity,
49
+ )
50
+ from jaxfolio.registry import (
51
+ _register_builtins,
52
+ get_strategy,
53
+ list_strategies,
54
+ register_strategy,
55
+ strategy_info,
56
+ unregister,
57
+ )
58
+ from jaxfolio.types import OptimizerConfig, PortfolioResult
59
+
60
+ # Register the built-in optimizers so they show up in the strategy registry
61
+ # alongside any user-defined strategies.
62
+ _register_builtins()
63
+
64
+ __version__ = "0.1.0"
65
+
66
+ __all__ = [
67
+ "__version__",
68
+ # types
69
+ "PortfolioResult",
70
+ "OptimizerConfig",
71
+ # registry & custom strategies
72
+ "register_strategy",
73
+ "get_strategy",
74
+ "list_strategies",
75
+ "strategy_info",
76
+ "unregister",
77
+ "custom_strategy",
78
+ "CustomStrategy",
79
+ # data
80
+ "generate_prices",
81
+ "generate_returns",
82
+ "load_csv",
83
+ "load_parquet",
84
+ "load_yfinance",
85
+ "load_option_chain",
86
+ "to_returns",
87
+ "train_test_split",
88
+ # classical optimizers
89
+ "equal_weight",
90
+ "inverse_volatility",
91
+ "minimum_variance",
92
+ "mean_variance",
93
+ "maximum_sharpe",
94
+ "maximum_diversification",
95
+ "risk_parity",
96
+ "kelly",
97
+ "min_cvar",
98
+ "black_litterman",
99
+ # learning optimizers
100
+ "deep_sharpe",
101
+ "online_gradient",
102
+ # graph optimizers
103
+ "hierarchical_risk_parity",
104
+ "hierarchical_equal_risk",
105
+ "mst_centrality",
106
+ # LLM (local-model) strategies
107
+ "llm_black_litterman",
108
+ "llm_sentiment_portfolio",
109
+ "llm_agent_portfolio",
110
+ ]
@@ -0,0 +1,39 @@
1
+ """Backtesting engine and performance metrics."""
2
+
3
+ from jaxfolio.backtest.engine import (
4
+ BacktestResult,
5
+ backtest,
6
+ compare,
7
+ metrics_table,
8
+ )
9
+ from jaxfolio.backtest.metrics import (
10
+ annualized_return,
11
+ annualized_volatility,
12
+ calmar_ratio,
13
+ conditional_value_at_risk,
14
+ cumulative_returns,
15
+ drawdown_series,
16
+ max_drawdown,
17
+ sharpe_ratio,
18
+ sortino_ratio,
19
+ summary,
20
+ value_at_risk,
21
+ )
22
+
23
+ __all__ = [
24
+ "backtest",
25
+ "compare",
26
+ "metrics_table",
27
+ "BacktestResult",
28
+ "annualized_return",
29
+ "annualized_volatility",
30
+ "sharpe_ratio",
31
+ "sortino_ratio",
32
+ "max_drawdown",
33
+ "calmar_ratio",
34
+ "value_at_risk",
35
+ "conditional_value_at_risk",
36
+ "cumulative_returns",
37
+ "drawdown_series",
38
+ "summary",
39
+ ]
@@ -0,0 +1,141 @@
1
+ """A vectorized walk-forward backtester.
2
+
3
+ The backtester is optimizer-agnostic: it takes any callable
4
+ ``optimizer(returns_window) -> PortfolioResult`` and rebalances on a fixed
5
+ schedule, applying linear transaction costs on turnover. It returns a
6
+ :class:`BacktestResult` holding the strategy return series, weight history, and a
7
+ metrics summary — ready for the plotting utilities.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from jaxfolio.backtest import metrics as M
19
+ from jaxfolio.types import PortfolioResult
20
+
21
+ Optimizer = Callable[[pd.DataFrame], PortfolioResult]
22
+
23
+
24
+ @dataclass
25
+ class BacktestResult:
26
+ """Output of a backtest run."""
27
+
28
+ name: str
29
+ returns: pd.Series
30
+ weights: pd.DataFrame
31
+ turnover: pd.Series
32
+ metrics: dict[str, float] = field(default_factory=dict)
33
+
34
+ @property
35
+ def equity_curve(self) -> pd.Series:
36
+ return pd.Series(M.cumulative_returns(self.returns), index=self.returns.index)
37
+
38
+ @property
39
+ def drawdown(self) -> pd.Series:
40
+ return pd.Series(M.drawdown_series(self.returns), index=self.returns.index)
41
+
42
+
43
+ def backtest(
44
+ returns: pd.DataFrame,
45
+ optimizer: Optimizer,
46
+ *,
47
+ name: str | None = None,
48
+ lookback: int = 252,
49
+ rebalance_every: int = 21,
50
+ transaction_cost: float = 0.0010,
51
+ periods_per_year: int = 252,
52
+ risk_free: float = 0.0,
53
+ ) -> BacktestResult:
54
+ """Walk-forward backtest of a single optimizer.
55
+
56
+ Parameters
57
+ ----------
58
+ returns:
59
+ Asset return panel (rows = periods, columns = assets).
60
+ optimizer:
61
+ Callable mapping a trailing return window to a :class:`PortfolioResult`.
62
+ lookback:
63
+ Number of trailing periods handed to the optimizer at each rebalance.
64
+ rebalance_every:
65
+ Rebalance frequency in periods (21 ≈ monthly for daily data).
66
+ transaction_cost:
67
+ Proportional cost per unit of turnover (one-way), e.g. ``0.0010`` = 10bps.
68
+
69
+ Returns
70
+ -------
71
+ BacktestResult
72
+ Net-of-cost strategy returns, weight history, turnover, and metrics.
73
+ """
74
+ assets = list(returns.columns)
75
+ n = len(assets)
76
+ dates = returns.index
77
+ ret_vals = returns.to_numpy()
78
+
79
+ weights = np.zeros(n)
80
+ weight_hist: dict[pd.Timestamp, np.ndarray] = {}
81
+ turnover_hist: dict[pd.Timestamp, float] = {}
82
+ strat_returns = np.zeros(len(returns))
83
+
84
+ for t in range(lookback, len(returns)):
85
+ # Rebalance at the cadence; otherwise let weights drift with returns.
86
+ if (t - lookback) % rebalance_every == 0:
87
+ window = returns.iloc[t - lookback : t]
88
+ result = optimizer(window)
89
+ target = _align_weights(result, assets)
90
+ cost = transaction_cost * np.abs(target - weights).sum()
91
+ turnover_hist[dates[t]] = float(np.abs(target - weights).sum())
92
+ weights = target
93
+ else:
94
+ cost = 0.0
95
+
96
+ period_ret = ret_vals[t]
97
+ gross = float(np.dot(weights, period_ret))
98
+ strat_returns[t] = gross - cost
99
+ weight_hist[dates[t]] = weights.copy()
100
+
101
+ # Drift weights with realized returns (buy-and-hold between rebalances).
102
+ grown = weights * (1.0 + period_ret)
103
+ total = grown.sum()
104
+ if total > 0:
105
+ weights = grown / total
106
+
107
+ ret_series = pd.Series(strat_returns, index=dates, name=name or "strategy")
108
+ ret_series = ret_series.iloc[lookback:]
109
+ w_df = pd.DataFrame.from_dict(weight_hist, orient="index", columns=assets)
110
+ to_series = pd.Series(turnover_hist, name="turnover")
111
+
112
+ summary = M.summary(ret_series, risk_free=risk_free, periods_per_year=periods_per_year)
113
+ summary["avg_turnover"] = float(to_series.mean()) if len(to_series) else 0.0
114
+ return BacktestResult(
115
+ name=name or "strategy",
116
+ returns=ret_series,
117
+ weights=w_df,
118
+ turnover=to_series,
119
+ metrics=summary,
120
+ )
121
+
122
+
123
+ def compare(
124
+ returns: pd.DataFrame,
125
+ optimizers: dict[str, Optimizer],
126
+ **kwargs,
127
+ ) -> dict[str, BacktestResult]:
128
+ """Backtest several optimizers on the same data and return a name->result map."""
129
+ return {name: backtest(returns, opt, name=name, **kwargs) for name, opt in optimizers.items()}
130
+
131
+
132
+ def metrics_table(results: dict[str, BacktestResult]) -> pd.DataFrame:
133
+ """Assemble a tidy metrics comparison table across backtest results."""
134
+ rows = {name: res.metrics for name, res in results.items()}
135
+ return pd.DataFrame(rows).T
136
+
137
+
138
+ def _align_weights(result: PortfolioResult, assets: list[str]) -> np.ndarray:
139
+ """Reorder/pad a result's weights to the backtest's asset ordering."""
140
+ lookup = dict(zip(result.assets, result.weights, strict=True))
141
+ return np.array([lookup.get(a, 0.0) for a in assets], dtype=float)
@@ -0,0 +1,133 @@
1
+ """Performance and risk metrics for return series.
2
+
3
+ All functions accept a 1-D array-like of periodic returns and return plain
4
+ floats. Annualization uses ``periods_per_year`` (252 for daily by default).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ _PPY = 252
13
+
14
+
15
+ def _to_array(returns) -> np.ndarray:
16
+ if isinstance(returns, pd.Series | pd.DataFrame):
17
+ returns = returns.to_numpy()
18
+ return np.asarray(returns, dtype=float).reshape(-1)
19
+
20
+
21
+ def annualized_return(returns, periods_per_year: int = _PPY) -> float:
22
+ """Geometric annualized return (CAGR) of a periodic return series."""
23
+ r = _to_array(returns)
24
+ if r.size == 0:
25
+ return 0.0
26
+ growth = np.prod(1.0 + r)
27
+ years = r.size / periods_per_year
28
+ if growth <= 0:
29
+ return -1.0
30
+ return float(growth ** (1.0 / years) - 1.0)
31
+
32
+
33
+ def annualized_volatility(returns, periods_per_year: int = _PPY) -> float:
34
+ """Annualized standard deviation of returns."""
35
+ r = _to_array(returns)
36
+ return float(np.std(r, ddof=1) * np.sqrt(periods_per_year)) if r.size > 1 else 0.0
37
+
38
+
39
+ def sharpe_ratio(returns, risk_free: float = 0.0, periods_per_year: int = _PPY) -> float:
40
+ """Annualized Sharpe ratio (``risk_free`` is a per-period rate)."""
41
+ r = _to_array(returns) - risk_free
42
+ sd = np.std(r, ddof=1)
43
+ if sd == 0:
44
+ return 0.0
45
+ return float(np.mean(r) / sd * np.sqrt(periods_per_year))
46
+
47
+
48
+ def sortino_ratio(returns, risk_free: float = 0.0, periods_per_year: int = _PPY) -> float:
49
+ """Annualized Sortino ratio (downside-deviation denominator).
50
+
51
+ With no downside (zero denominator), returns ``+inf`` for positive mean
52
+ excess return, ``-inf`` for negative, and ``0`` for a flat series — so a
53
+ downside-free strategy ranks above one with drawdowns rather than tying at 0.
54
+ """
55
+ r = _to_array(returns) - risk_free
56
+ downside = r[r < 0]
57
+ dd = np.sqrt(np.mean(downside**2)) if downside.size else 0.0
58
+ mean = float(np.mean(r)) if r.size else 0.0
59
+ if dd == 0:
60
+ return float(np.sign(mean) * np.inf) if mean != 0 else 0.0
61
+ return float(mean / dd * np.sqrt(periods_per_year))
62
+
63
+
64
+ def cumulative_returns(returns) -> np.ndarray:
65
+ """Cumulative wealth path starting from 1.0 (equity curve)."""
66
+ r = _to_array(returns)
67
+ return np.cumprod(1.0 + r)
68
+
69
+
70
+ def drawdown_series(returns) -> np.ndarray:
71
+ """Drawdown at each point: ``equity / running_peak - 1`` (<= 0)."""
72
+ equity = cumulative_returns(returns)
73
+ peak = np.maximum.accumulate(equity)
74
+ return equity / peak - 1.0
75
+
76
+
77
+ def max_drawdown(returns) -> float:
78
+ """Maximum peak-to-trough drawdown (a negative number)."""
79
+ dd = drawdown_series(returns)
80
+ return float(dd.min()) if dd.size else 0.0
81
+
82
+
83
+ def calmar_ratio(returns, periods_per_year: int = _PPY) -> float:
84
+ """Annualized return divided by the absolute max drawdown.
85
+
86
+ With no drawdown (zero denominator), returns ``+inf`` for a positive
87
+ annualized return, ``-inf`` for negative, and ``0`` for flat — so a
88
+ drawdown-free strategy is not mis-ranked as the worst performer.
89
+ """
90
+ ann = annualized_return(returns, periods_per_year)
91
+ mdd = abs(max_drawdown(returns))
92
+ if mdd == 0:
93
+ return float(np.sign(ann) * np.inf) if ann != 0 else 0.0
94
+ return float(ann / mdd)
95
+
96
+
97
+ def value_at_risk(returns, alpha: float = 0.95) -> float:
98
+ """Historical Value-at-Risk at confidence ``alpha`` (a positive loss)."""
99
+ r = _to_array(returns)
100
+ if r.size == 0:
101
+ return 0.0
102
+ return float(-np.quantile(r, 1.0 - alpha))
103
+
104
+
105
+ def conditional_value_at_risk(returns, alpha: float = 0.95) -> float:
106
+ """Historical CVaR / expected shortfall at confidence ``alpha``."""
107
+ r = _to_array(returns)
108
+ if r.size == 0:
109
+ return 0.0
110
+ var = -value_at_risk(r, alpha)
111
+ tail = r[r <= var]
112
+ return float(-tail.mean()) if tail.size else float(-var)
113
+
114
+
115
+ def hit_rate(returns) -> float:
116
+ """Fraction of periods with a positive return."""
117
+ r = _to_array(returns)
118
+ return float(np.mean(r > 0)) if r.size else 0.0
119
+
120
+
121
+ def summary(returns, risk_free: float = 0.0, periods_per_year: int = _PPY) -> dict[str, float]:
122
+ """Bundle the headline metrics into a single dict for reporting/plots."""
123
+ return {
124
+ "annual_return": annualized_return(returns, periods_per_year),
125
+ "annual_volatility": annualized_volatility(returns, periods_per_year),
126
+ "sharpe": sharpe_ratio(returns, risk_free, periods_per_year),
127
+ "sortino": sortino_ratio(returns, risk_free, periods_per_year),
128
+ "max_drawdown": max_drawdown(returns),
129
+ "calmar": calmar_ratio(returns, periods_per_year),
130
+ "var_95": value_at_risk(returns, 0.95),
131
+ "cvar_95": conditional_value_at_risk(returns, 0.95),
132
+ "hit_rate": hit_rate(returns),
133
+ }
@@ -0,0 +1,15 @@
1
+ """Constraint projections for weight vectors."""
2
+
3
+ from jaxfolio.constraints.projections import (
4
+ normalize_weights,
5
+ project_box_budget,
6
+ project_simplex,
7
+ softmax_weights,
8
+ )
9
+
10
+ __all__ = [
11
+ "project_simplex",
12
+ "project_box_budget",
13
+ "normalize_weights",
14
+ "softmax_weights",
15
+ ]
@@ -0,0 +1,87 @@
1
+ """Constraint projections used by the projected-gradient optimizers.
2
+
3
+ All projections are pure JAX and jit/vmap-safe. The two workhorses are:
4
+
5
+ * :func:`project_simplex` — Euclidean projection onto the probability simplex
6
+ ``{w : w >= 0, sum(w) = 1}`` (long-only, fully-invested).
7
+ * :func:`project_box_budget` — projection onto a box ``[lo, hi]`` intersected
8
+ with the budget hyperplane ``sum(w) = budget`` (allows bounded shorting).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import jax
14
+ import jax.numpy as jnp
15
+
16
+ Array = jnp.ndarray
17
+
18
+
19
+ def project_simplex(v: Array, budget: float = 1.0) -> Array:
20
+ """Euclidean projection of ``v`` onto the scaled simplex summing to ``budget``.
21
+
22
+ Implements the classic Duchi et al. (2008) sort-based algorithm, which is
23
+ exact and differentiable almost everywhere.
24
+ """
25
+ n = v.shape[0]
26
+ u = jnp.sort(v)[::-1]
27
+ cssv = jnp.cumsum(u) - budget
28
+ ind = jnp.arange(1, n + 1)
29
+ cond = u - cssv / ind > 0
30
+ # rho = number of positive-threshold coordinates.
31
+ rho = jnp.sum(cond)
32
+ theta = cssv[rho - 1] / rho
33
+ return jnp.maximum(v - theta, 0.0)
34
+
35
+
36
+ def project_box_budget(
37
+ v: Array,
38
+ lower: float = 0.0,
39
+ upper: float = 1.0,
40
+ budget: float = 1.0,
41
+ *,
42
+ max_iter: int = 50,
43
+ tol: float = 1e-10,
44
+ ) -> Array:
45
+ """Project ``v`` onto ``{w : lower <= w <= upper, sum(w) = budget}``.
46
+
47
+ Solves for the Lagrange multiplier ``tau`` on the budget constraint by
48
+ bisection: ``w(tau) = clip(v - tau, lower, upper)`` is monotone decreasing in
49
+ ``tau``, so we bisect until ``sum(w(tau)) == budget``. Feasible whenever
50
+ ``n*lower <= budget <= n*upper``.
51
+ """
52
+ n = v.shape[0]
53
+
54
+ def sum_at(tau: Array) -> Array:
55
+ return jnp.sum(jnp.clip(v - tau, lower, upper))
56
+
57
+ # Bracket tau: sum decreases as tau grows.
58
+ lo = jnp.min(v) - upper
59
+ hi = jnp.max(v) - lower
60
+
61
+ def body(_, bounds):
62
+ lo, hi = bounds
63
+ mid = 0.5 * (lo + hi)
64
+ s = sum_at(mid)
65
+ # If sum too big, need larger tau -> move lo up; else move hi down.
66
+ too_big = s > budget
67
+ lo = jnp.where(too_big, mid, lo)
68
+ hi = jnp.where(too_big, hi, mid)
69
+ return (lo, hi)
70
+
71
+ lo, hi = jax.lax.fori_loop(0, max_iter, body, (lo, hi))
72
+ tau = 0.5 * (lo + hi)
73
+ w = jnp.clip(v - tau, lower, upper)
74
+ # Correct tiny residual so weights sum exactly to budget.
75
+ w = w + (budget - jnp.sum(w)) / n
76
+ return w
77
+
78
+
79
+ def normalize_weights(w: Array, budget: float = 1.0) -> Array:
80
+ """Rescale weights to sum to ``budget`` (assumes a non-zero sum)."""
81
+ s = jnp.sum(w)
82
+ return jnp.where(jnp.abs(s) > 1e-12, w * (budget / s), jnp.full_like(w, budget / w.shape[0]))
83
+
84
+
85
+ def softmax_weights(logits: Array, budget: float = 1.0) -> Array:
86
+ """Map unconstrained logits to long-only weights via softmax (sums to budget)."""
87
+ return budget * jax.nn.softmax(logits)