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 +110 -0
- jaxfolio/backtest/__init__.py +39 -0
- jaxfolio/backtest/engine.py +141 -0
- jaxfolio/backtest/metrics.py +133 -0
- jaxfolio/constraints/__init__.py +15 -0
- jaxfolio/constraints/projections.py +87 -0
- jaxfolio/custom.py +189 -0
- jaxfolio/data/__init__.py +28 -0
- jaxfolio/data/loaders.py +149 -0
- jaxfolio/data/returns.py +96 -0
- jaxfolio/data/synthetic.py +90 -0
- jaxfolio/llm/__init__.py +45 -0
- jaxfolio/llm/agents.py +116 -0
- jaxfolio/llm/client.py +200 -0
- jaxfolio/llm/sentiment.py +75 -0
- jaxfolio/llm/strategies.py +134 -0
- jaxfolio/llm/views.py +154 -0
- jaxfolio/moments/__init__.py +19 -0
- jaxfolio/moments/estimators.py +127 -0
- jaxfolio/optimizers/__init__.py +41 -0
- jaxfolio/optimizers/base.py +97 -0
- jaxfolio/optimizers/classical.py +467 -0
- jaxfolio/optimizers/graph.py +211 -0
- jaxfolio/optimizers/learning.py +185 -0
- jaxfolio/options/__init__.py +71 -0
- jaxfolio/options/greeks.py +109 -0
- jaxfolio/options/overlay.py +124 -0
- jaxfolio/options/pricing.py +173 -0
- jaxfolio/options/strategies.py +304 -0
- jaxfolio/registry.py +131 -0
- jaxfolio/results.py +83 -0
- jaxfolio/toolkit.py +86 -0
- jaxfolio/types.py +109 -0
- jaxfolio/viz/__init__.py +39 -0
- jaxfolio/viz/plots.py +922 -0
- jaxfolio/viz/theme.py +112 -0
- jaxfolio-0.1.0.dist-info/METADATA +225 -0
- jaxfolio-0.1.0.dist-info/RECORD +40 -0
- jaxfolio-0.1.0.dist-info/WHEEL +4 -0
- jaxfolio-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|