pyfolioanalytics 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.
- pyfolioanalytics/__init__.py +0 -0
- pyfolioanalytics/backtest.py +117 -0
- pyfolioanalytics/black_litterman.py +41 -0
- pyfolioanalytics/cla.py +302 -0
- pyfolioanalytics/factors.py +67 -0
- pyfolioanalytics/meucci.py +78 -0
- pyfolioanalytics/ml.py +165 -0
- pyfolioanalytics/moments.py +89 -0
- pyfolioanalytics/optimize.py +351 -0
- pyfolioanalytics/portfolio.py +228 -0
- pyfolioanalytics/py.typed +0 -0
- pyfolioanalytics/random_portfolios.py +86 -0
- pyfolioanalytics/risk.py +178 -0
- pyfolioanalytics/rmt.py +203 -0
- pyfolioanalytics/solvers.py +608 -0
- pyfolioanalytics-0.1.0.dist-info/METADATA +61 -0
- pyfolioanalytics-0.1.0.dist-info/RECORD +18 -0
- pyfolioanalytics-0.1.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from typing import Dict, Any, List, Union
|
|
3
|
+
from .portfolio import Portfolio, RegimePortfolio
|
|
4
|
+
from .optimize import optimize_portfolio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BacktestResult:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
weights: pd.DataFrame,
|
|
11
|
+
returns: pd.Series,
|
|
12
|
+
opt_results: List[Dict[str, Any]],
|
|
13
|
+
):
|
|
14
|
+
self.weights = weights
|
|
15
|
+
self.returns = returns
|
|
16
|
+
self.portfolio_returns = returns # Alias for backward compatibility
|
|
17
|
+
self.opt_results = opt_results
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def backtest_portfolio(
|
|
21
|
+
R: pd.DataFrame,
|
|
22
|
+
portfolio: Union[Portfolio, RegimePortfolio],
|
|
23
|
+
rebalance_periods: str = "ME",
|
|
24
|
+
optimize_method: str = "ROI",
|
|
25
|
+
**kwargs,
|
|
26
|
+
) -> BacktestResult:
|
|
27
|
+
"""
|
|
28
|
+
Simple walk-forward backtest with rebalancing.
|
|
29
|
+
"""
|
|
30
|
+
# Handle rebalance_on from PortfolioAnalytics style
|
|
31
|
+
rebalance_on = kwargs.get("rebalance_on")
|
|
32
|
+
if rebalance_on:
|
|
33
|
+
mapping = {
|
|
34
|
+
"months": "ME",
|
|
35
|
+
"quarters": "QE",
|
|
36
|
+
"years": "YE",
|
|
37
|
+
"weeks": "W",
|
|
38
|
+
"days": "D",
|
|
39
|
+
}
|
|
40
|
+
rebalance_periods = mapping.get(rebalance_on, rebalance_periods)
|
|
41
|
+
|
|
42
|
+
# Ensure R index is datetime
|
|
43
|
+
if not isinstance(R.index, pd.DatetimeIndex):
|
|
44
|
+
R.index = pd.to_datetime(R.index)
|
|
45
|
+
|
|
46
|
+
# Identify rebalancing dates
|
|
47
|
+
rebal_dates = pd.date_range(
|
|
48
|
+
start=R.index[0], end=R.index[-1], freq=rebalance_periods
|
|
49
|
+
)
|
|
50
|
+
if rebal_dates[0] > R.index[0]:
|
|
51
|
+
rebal_dates = rebal_dates.insert(0, R.index[0])
|
|
52
|
+
|
|
53
|
+
rolling_window = kwargs.get("rolling_window")
|
|
54
|
+
regimes = kwargs.get("regimes")
|
|
55
|
+
|
|
56
|
+
all_weights = []
|
|
57
|
+
all_opt_results = []
|
|
58
|
+
current_weights = pd.Series(1.0 / len(R.columns), index=R.columns)
|
|
59
|
+
|
|
60
|
+
for i in range(len(rebal_dates) - 1):
|
|
61
|
+
start_date = rebal_dates[i]
|
|
62
|
+
end_date = rebal_dates[i + 1]
|
|
63
|
+
|
|
64
|
+
# Data for optimization
|
|
65
|
+
if rolling_window:
|
|
66
|
+
# Find integer index of start_date
|
|
67
|
+
loc = R.index.get_indexer([start_date], method="pad")[0]
|
|
68
|
+
start_idx = max(0, loc - rolling_window)
|
|
69
|
+
R_train = R.iloc[start_idx:loc]
|
|
70
|
+
else:
|
|
71
|
+
R_train = R[:start_date]
|
|
72
|
+
|
|
73
|
+
if len(R_train) >= 2:
|
|
74
|
+
active_portfolio = portfolio
|
|
75
|
+
if isinstance(portfolio, RegimePortfolio):
|
|
76
|
+
if regimes is not None:
|
|
77
|
+
# Use the regime of the current rebalance date
|
|
78
|
+
current_regime = regimes.asof(start_date)
|
|
79
|
+
active_portfolio = portfolio.get_portfolio(current_regime)
|
|
80
|
+
else:
|
|
81
|
+
active_portfolio = portfolio.get_portfolio("default")
|
|
82
|
+
|
|
83
|
+
res = optimize_portfolio(
|
|
84
|
+
R_train, active_portfolio, optimize_method=optimize_method, **kwargs
|
|
85
|
+
)
|
|
86
|
+
if res["weights"] is not None:
|
|
87
|
+
current_weights = res["weights"]
|
|
88
|
+
opt_info = {
|
|
89
|
+
"date": start_date,
|
|
90
|
+
"weights": current_weights,
|
|
91
|
+
"portfolio": active_portfolio,
|
|
92
|
+
"status": res["status"],
|
|
93
|
+
}
|
|
94
|
+
# Ensure moments and other metadata are passed through if present
|
|
95
|
+
if "moments" in res:
|
|
96
|
+
opt_info["moments"] = res["moments"]
|
|
97
|
+
all_opt_results.append(opt_info)
|
|
98
|
+
|
|
99
|
+
# Apply weights to the period
|
|
100
|
+
R_period = R[start_date:end_date]
|
|
101
|
+
if not R_period.empty:
|
|
102
|
+
weights_df = pd.DataFrame(
|
|
103
|
+
[current_weights] * len(R_period), index=R_period.index
|
|
104
|
+
)
|
|
105
|
+
all_weights.append(weights_df)
|
|
106
|
+
|
|
107
|
+
if not all_weights:
|
|
108
|
+
return BacktestResult(pd.DataFrame(), pd.Series(), [])
|
|
109
|
+
|
|
110
|
+
full_weights = pd.concat(all_weights)
|
|
111
|
+
port_returns = (full_weights * R.loc[full_weights.index]).sum(axis=1)
|
|
112
|
+
|
|
113
|
+
return BacktestResult(full_weights, port_returns, all_opt_results)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Alias for backward compatibility
|
|
117
|
+
optimize_portfolio_rebalancing = backtest_portfolio
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def black_litterman(
|
|
6
|
+
sigma: np.ndarray,
|
|
7
|
+
w_mkt: np.ndarray,
|
|
8
|
+
P: np.ndarray,
|
|
9
|
+
q: np.ndarray,
|
|
10
|
+
tau: float = 0.05,
|
|
11
|
+
risk_aversion: float = 2.5,
|
|
12
|
+
Omega: Optional[np.ndarray] = None,
|
|
13
|
+
) -> Dict[str, Any]:
|
|
14
|
+
"""
|
|
15
|
+
Standard Black-Litterman Model.
|
|
16
|
+
- sigma: Covariance matrix (N x N)
|
|
17
|
+
- w_mkt: Market weights (N x 1)
|
|
18
|
+
- P: View matrix (K x N)
|
|
19
|
+
- q: View returns (K x 1)
|
|
20
|
+
- tau: Scalar indicating confidence in prior (default 0.05)
|
|
21
|
+
- risk_aversion: Lambda (default 2.5)
|
|
22
|
+
- Omega: View uncertainty matrix (K x K). If None, calculated via He-Litterman.
|
|
23
|
+
"""
|
|
24
|
+
# 1. Implied Equilibrium Returns
|
|
25
|
+
Pi = risk_aversion * sigma @ w_mkt
|
|
26
|
+
|
|
27
|
+
# 2. View Uncertainty (Omega)
|
|
28
|
+
if Omega is None:
|
|
29
|
+
# He-Litterman method: Omega = diag(P * (tau * sigma) * P')
|
|
30
|
+
Omega = np.diag(np.diag(P @ (tau * sigma) @ P.T))
|
|
31
|
+
|
|
32
|
+
# 3. Posterior Mean
|
|
33
|
+
# mu_bl = Pi + tau*sigma*P' * (P*tau*sigma*P' + Omega)^-1 * (q - P*Pi)
|
|
34
|
+
M_inv = np.linalg.inv(P @ (tau * sigma) @ P.T + Omega)
|
|
35
|
+
mu_bl = Pi + (tau * sigma @ P.T) @ M_inv @ (q - P @ Pi)
|
|
36
|
+
|
|
37
|
+
# 4. Posterior Covariance
|
|
38
|
+
# sigma_bl = (1+tau)*sigma - tau^2 * sigma * P' * (P*tau*sigma*P' + Omega)^-1 * P * sigma
|
|
39
|
+
sigma_bl = (1 + tau) * sigma - (tau**2 * sigma @ P.T) @ M_inv @ (P @ sigma)
|
|
40
|
+
|
|
41
|
+
return {"mu": mu_bl, "sigma": sigma_bl}
|
pyfolioanalytics/cla.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import List, Tuple, Dict, Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CLA:
|
|
7
|
+
"""
|
|
8
|
+
Critical Line Algorithm (CLA) for Mean-Variance Optimization.
|
|
9
|
+
Based on the implementation by Marcos Lopez de Prado.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
expected_returns: np.ndarray,
|
|
15
|
+
cov_matrix: np.ndarray,
|
|
16
|
+
lower_bounds: np.ndarray,
|
|
17
|
+
upper_bounds: np.ndarray,
|
|
18
|
+
):
|
|
19
|
+
self.mu = expected_returns.reshape(-1, 1)
|
|
20
|
+
self.sigma = cov_matrix
|
|
21
|
+
self.lb = lower_bounds.reshape(-1, 1)
|
|
22
|
+
self.ub = upper_bounds.reshape(-1, 1)
|
|
23
|
+
self.n = len(self.mu)
|
|
24
|
+
|
|
25
|
+
self.w = [] # solution weights at turning points
|
|
26
|
+
self.ls = [] # lambdas at turning points
|
|
27
|
+
self.g = [] # gammas at turning points
|
|
28
|
+
self.f = [] # free sets at turning points
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _infnone(x):
|
|
32
|
+
return float("-inf") if x is None else x
|
|
33
|
+
|
|
34
|
+
def _init_algo(self) -> Tuple[List[int], np.ndarray]:
|
|
35
|
+
# Form structured array of (id, mu)
|
|
36
|
+
idx = np.argsort(self.mu.flatten())
|
|
37
|
+
|
|
38
|
+
# 3) First free weight
|
|
39
|
+
# Start with all at lower bounds
|
|
40
|
+
i, w = self.n, np.copy(self.lb)
|
|
41
|
+
while np.sum(w) < 1.0 and i > 0:
|
|
42
|
+
i -= 1
|
|
43
|
+
idx_i = idx[i]
|
|
44
|
+
w[idx_i] = self.ub[idx_i]
|
|
45
|
+
|
|
46
|
+
# Adjust last modified asset to meet sum(w) = 1
|
|
47
|
+
if np.sum(w) > 1.0:
|
|
48
|
+
w[idx[i]] += 1.0 - np.sum(w)
|
|
49
|
+
|
|
50
|
+
return [idx[i]], w
|
|
51
|
+
|
|
52
|
+
def _get_matrices(
|
|
53
|
+
self, f: List[int], w: np.ndarray
|
|
54
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
55
|
+
b = list(set(range(self.n)) - set(f))
|
|
56
|
+
covarF = self.sigma[np.ix_(f, f)]
|
|
57
|
+
meanF = self.mu[f]
|
|
58
|
+
covarFB = self.sigma[np.ix_(f, b)]
|
|
59
|
+
wB = w[b]
|
|
60
|
+
return covarF, covarFB, meanF, wB
|
|
61
|
+
|
|
62
|
+
def _compute_bi(self, c, bi):
|
|
63
|
+
if c > 0:
|
|
64
|
+
return bi[1]
|
|
65
|
+
if c < 0:
|
|
66
|
+
return bi[0]
|
|
67
|
+
return bi[0]
|
|
68
|
+
|
|
69
|
+
def _compute_lambda(
|
|
70
|
+
self,
|
|
71
|
+
covarF_inv: np.ndarray,
|
|
72
|
+
covarFB: np.ndarray,
|
|
73
|
+
meanF: np.ndarray,
|
|
74
|
+
wB: np.ndarray,
|
|
75
|
+
i: int,
|
|
76
|
+
bi: Any,
|
|
77
|
+
) -> Tuple[Optional[float], Optional[float]]:
|
|
78
|
+
onesF = np.ones((len(meanF), 1))
|
|
79
|
+
c1 = onesF.T @ covarF_inv @ onesF
|
|
80
|
+
c2 = covarF_inv @ meanF
|
|
81
|
+
c3 = onesF.T @ covarF_inv @ meanF
|
|
82
|
+
c4 = covarF_inv @ onesF
|
|
83
|
+
|
|
84
|
+
c = -c1 * c2[i] + c3 * c4[i]
|
|
85
|
+
c_val = c.item()
|
|
86
|
+
if abs(c_val) < 1e-12:
|
|
87
|
+
return None, None
|
|
88
|
+
|
|
89
|
+
if isinstance(bi, list):
|
|
90
|
+
bi = self._compute_bi(c_val, bi)
|
|
91
|
+
|
|
92
|
+
if len(wB) == 0:
|
|
93
|
+
res = (c4[i] - c1 * bi) / c
|
|
94
|
+
else:
|
|
95
|
+
onesB = np.ones((len(wB), 1))
|
|
96
|
+
l1 = onesB.T @ wB
|
|
97
|
+
l2 = covarF_inv @ covarFB
|
|
98
|
+
l3 = l2 @ wB
|
|
99
|
+
l4 = onesF.T @ l3
|
|
100
|
+
res = ((1 - l1 + l4) * c4[i] - c1 * (bi + l3[i])) / c
|
|
101
|
+
return float(res.item()), float(bi)
|
|
102
|
+
|
|
103
|
+
def _compute_w(
|
|
104
|
+
self,
|
|
105
|
+
covarF_inv: np.ndarray,
|
|
106
|
+
covarFB: np.ndarray,
|
|
107
|
+
meanF: np.ndarray,
|
|
108
|
+
wB: np.ndarray,
|
|
109
|
+
lam: float,
|
|
110
|
+
) -> Tuple[np.ndarray, float]:
|
|
111
|
+
onesF = np.ones((len(meanF), 1))
|
|
112
|
+
g1 = onesF.T @ covarF_inv @ meanF
|
|
113
|
+
g2 = onesF.T @ covarF_inv @ onesF
|
|
114
|
+
|
|
115
|
+
if len(wB) == 0:
|
|
116
|
+
g = -lam * g1 / g2 + 1 / g2
|
|
117
|
+
w1 = np.zeros(onesF.shape)
|
|
118
|
+
else:
|
|
119
|
+
onesB = np.ones((len(wB), 1))
|
|
120
|
+
g3 = onesB.T @ wB
|
|
121
|
+
g4 = covarF_inv @ covarFB
|
|
122
|
+
w1 = g4 @ wB
|
|
123
|
+
g5 = onesF.T @ w1
|
|
124
|
+
g = -lam * g1 / g2 + (1 - g3 + g5) / g2
|
|
125
|
+
|
|
126
|
+
g_val = float(g.item())
|
|
127
|
+
w2 = covarF_inv @ onesF
|
|
128
|
+
w3 = covarF_inv @ meanF
|
|
129
|
+
wF = -w1 + g_val * w2 + lam * w3
|
|
130
|
+
return wF, g_val
|
|
131
|
+
|
|
132
|
+
def solve(self):
|
|
133
|
+
f, w = self._init_algo()
|
|
134
|
+
self.w.append(np.copy(w))
|
|
135
|
+
self.ls.append(None)
|
|
136
|
+
self.g.append(None)
|
|
137
|
+
self.f.append(f[:])
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
# Case A: Bound one free weight
|
|
141
|
+
l_in = None
|
|
142
|
+
if len(f) > 1:
|
|
143
|
+
covarF, covarFB, meanF, wB = self._get_matrices(f, w)
|
|
144
|
+
covarF_inv = np.linalg.inv(covarF)
|
|
145
|
+
for j, idx in enumerate(f):
|
|
146
|
+
lam, bi = self._compute_lambda(
|
|
147
|
+
covarF_inv,
|
|
148
|
+
covarFB,
|
|
149
|
+
meanF,
|
|
150
|
+
wB,
|
|
151
|
+
j,
|
|
152
|
+
[self.lb[idx].item(), self.ub[idx].item()],
|
|
153
|
+
)
|
|
154
|
+
if self._infnone(lam) > self._infnone(l_in):
|
|
155
|
+
l_in, i_in, bi_in = lam, idx, bi
|
|
156
|
+
|
|
157
|
+
# Case B: Free one bounded weight
|
|
158
|
+
l_out = None
|
|
159
|
+
b = list(set(range(self.n)) - set(f))
|
|
160
|
+
if len(b) > 0:
|
|
161
|
+
for idx in b:
|
|
162
|
+
f_temp = f + [idx]
|
|
163
|
+
covarF, covarFB, meanF, wB = self._get_matrices(f_temp, w)
|
|
164
|
+
covarF_inv = np.linalg.inv(covarF)
|
|
165
|
+
lam, bi = self._compute_lambda(
|
|
166
|
+
covarF_inv, covarFB, meanF, wB, len(f_temp) - 1, w[idx].item()
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
self.ls[-1] is None or lam < self.ls[-1]
|
|
171
|
+
) and lam > self._infnone(l_out):
|
|
172
|
+
l_out, i_out = lam, idx
|
|
173
|
+
|
|
174
|
+
if self._infnone(l_in) < 0 and self._infnone(l_out) < 0:
|
|
175
|
+
# Minimum Variance Solution
|
|
176
|
+
self.ls.append(0.0)
|
|
177
|
+
covarF, covarFB, meanF, wB = self._get_matrices(f, w)
|
|
178
|
+
covarF_inv = np.linalg.inv(covarF)
|
|
179
|
+
wF, g = self._compute_w(
|
|
180
|
+
covarF_inv, covarFB, np.zeros(meanF.shape), wB, 0.0
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
if self._infnone(l_in) > self._infnone(l_out):
|
|
184
|
+
self.ls.append(l_in)
|
|
185
|
+
f.remove(i_in)
|
|
186
|
+
w[i_in] = bi_in
|
|
187
|
+
else:
|
|
188
|
+
self.ls.append(l_out)
|
|
189
|
+
f.append(i_out)
|
|
190
|
+
covarF, covarFB, meanF, wB = self._get_matrices(f, w)
|
|
191
|
+
covarF_inv = np.linalg.inv(covarF)
|
|
192
|
+
wF, g = self._compute_w(covarF_inv, covarFB, meanF, wB, self.ls[-1])
|
|
193
|
+
|
|
194
|
+
for j, idx in enumerate(f):
|
|
195
|
+
w[idx] = wF[j]
|
|
196
|
+
|
|
197
|
+
self.w.append(np.copy(w))
|
|
198
|
+
self.g.append(g)
|
|
199
|
+
self.f.append(f[:])
|
|
200
|
+
|
|
201
|
+
if self.ls[-1] == 0:
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
self._purge_num_err(1e-10)
|
|
205
|
+
self._purge_excess()
|
|
206
|
+
|
|
207
|
+
def _purge_num_err(self, tol: float):
|
|
208
|
+
i = 0
|
|
209
|
+
while i < len(self.w):
|
|
210
|
+
w = self.w[i]
|
|
211
|
+
if (
|
|
212
|
+
abs(np.sum(w) - 1.0) > tol
|
|
213
|
+
or np.any(w < self.lb - tol)
|
|
214
|
+
or np.any(w > self.ub + tol)
|
|
215
|
+
):
|
|
216
|
+
del self.w[i], self.ls[i], self.g[i], self.f[i]
|
|
217
|
+
else:
|
|
218
|
+
i += 1
|
|
219
|
+
|
|
220
|
+
def _purge_excess(self):
|
|
221
|
+
i = 0
|
|
222
|
+
while i < len(self.w) - 1:
|
|
223
|
+
mu = (self.w[i].T @ self.mu).item()
|
|
224
|
+
j = i + 1
|
|
225
|
+
removed = False
|
|
226
|
+
while j < len(self.w):
|
|
227
|
+
mu_next = (self.w[j].T @ self.mu).item()
|
|
228
|
+
if mu < mu_next:
|
|
229
|
+
del self.w[i], self.ls[i], self.g[i], self.f[i]
|
|
230
|
+
removed = True
|
|
231
|
+
break
|
|
232
|
+
j += 1
|
|
233
|
+
if not removed:
|
|
234
|
+
i += 1
|
|
235
|
+
|
|
236
|
+
def max_sharpe(self, risk_free_rate: float = 0.0) -> np.ndarray:
|
|
237
|
+
if not self.w:
|
|
238
|
+
self.solve()
|
|
239
|
+
|
|
240
|
+
def sr_func(alpha, w0, w1):
|
|
241
|
+
w = alpha * w0 + (1 - alpha) * w1
|
|
242
|
+
ret = (w.T @ self.mu).item() - risk_free_rate
|
|
243
|
+
vol = np.sqrt((w.T @ self.sigma @ w).item())
|
|
244
|
+
if vol < 1e-12:
|
|
245
|
+
return 0.0
|
|
246
|
+
return -(ret / vol) # Minimize negative SR
|
|
247
|
+
|
|
248
|
+
from scipy.optimize import minimize_scalar
|
|
249
|
+
|
|
250
|
+
best_w = self.w[0]
|
|
251
|
+
max_sr = -np.inf
|
|
252
|
+
|
|
253
|
+
for i in range(len(self.w) - 1):
|
|
254
|
+
res = minimize_scalar(
|
|
255
|
+
sr_func,
|
|
256
|
+
bounds=(0, 1),
|
|
257
|
+
args=(self.w[i], self.w[i + 1]),
|
|
258
|
+
method="bounded",
|
|
259
|
+
)
|
|
260
|
+
w_opt = res.x * self.w[i] + (1 - res.x) * self.w[i + 1]
|
|
261
|
+
sr = -res.fun
|
|
262
|
+
if sr > max_sr:
|
|
263
|
+
max_sr = sr
|
|
264
|
+
best_w = w_opt
|
|
265
|
+
return best_w.flatten()
|
|
266
|
+
|
|
267
|
+
def min_volatility(self) -> np.ndarray:
|
|
268
|
+
if not self.w:
|
|
269
|
+
self.solve()
|
|
270
|
+
vols = [np.sqrt((w.T @ self.sigma @ w).item()) for w in self.w]
|
|
271
|
+
return self.w[np.argmin(vols)].flatten()
|
|
272
|
+
|
|
273
|
+
def efficient_frontier(
|
|
274
|
+
self, points: int = 100
|
|
275
|
+
) -> Tuple[np.ndarray, np.ndarray, List[np.ndarray]]:
|
|
276
|
+
if not self.w:
|
|
277
|
+
self.solve()
|
|
278
|
+
mu_list, sigma_list, weights_list = [], [], []
|
|
279
|
+
|
|
280
|
+
n_segments = len(self.w) - 1
|
|
281
|
+
if n_segments <= 0:
|
|
282
|
+
w = self.w[0]
|
|
283
|
+
return (
|
|
284
|
+
np.array([(w.T @ self.mu).item()]),
|
|
285
|
+
np.array([np.sqrt((w.T @ self.sigma @ w).item())]),
|
|
286
|
+
[w.flatten()],
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
points_per_segment = max(2, points // n_segments)
|
|
290
|
+
|
|
291
|
+
for i in range(n_segments):
|
|
292
|
+
alphas = np.linspace(0, 1, points_per_segment)
|
|
293
|
+
if i < n_segments - 1:
|
|
294
|
+
alphas = alphas[:-1] # avoid duplicate points
|
|
295
|
+
|
|
296
|
+
for alpha in alphas:
|
|
297
|
+
w = alpha * self.w[i + 1] + (1 - alpha) * self.w[i]
|
|
298
|
+
weights_list.append(w.flatten())
|
|
299
|
+
mu_list.append((w.T @ self.mu).item())
|
|
300
|
+
sigma_list.append(np.sqrt((w.T @ self.sigma @ w).item()))
|
|
301
|
+
|
|
302
|
+
return np.array(mu_list), np.array(sigma_list), weights_list
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def statistical_factor_model(R: pd.DataFrame, k: int = 3) -> Dict[str, Any]:
|
|
7
|
+
"""
|
|
8
|
+
Extract statistical factors using PCA.
|
|
9
|
+
Returns:
|
|
10
|
+
- factors: Factor returns (T x k)
|
|
11
|
+
- loadings: Factor loadings (N x k)
|
|
12
|
+
- alpha: Intercepts (N x 1)
|
|
13
|
+
- residuals: Residual returns (T x N)
|
|
14
|
+
"""
|
|
15
|
+
T, N = R.shape
|
|
16
|
+
# Center returns
|
|
17
|
+
mu = R.mean()
|
|
18
|
+
R_centered = R - mu
|
|
19
|
+
|
|
20
|
+
# PCA via SVD
|
|
21
|
+
U, S, Vt = np.linalg.svd(R_centered, full_matrices=False)
|
|
22
|
+
|
|
23
|
+
# Factors (principal components)
|
|
24
|
+
# R = U S V'
|
|
25
|
+
# Factors = U S
|
|
26
|
+
factors_mat = U[:, :k] @ np.diag(S[:k])
|
|
27
|
+
factors = pd.DataFrame(
|
|
28
|
+
factors_mat, index=R.index, columns=[f"Factor.{i + 1}" for i in range(k)]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Loadings (eigenvectors)
|
|
32
|
+
# Vt is (N x N), top k rows are loadings
|
|
33
|
+
loadings = Vt[:k, :].T
|
|
34
|
+
|
|
35
|
+
# Alphas and Residuals
|
|
36
|
+
# R = alpha + Loadings * Factors + Residuals
|
|
37
|
+
# For statistical factors, alpha is often mean return
|
|
38
|
+
alpha = mu.values.reshape(-1, 1)
|
|
39
|
+
|
|
40
|
+
# Reconstruction
|
|
41
|
+
R_hat = factors_mat @ loadings.T
|
|
42
|
+
residuals = R_centered.values - R_hat
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
"factors": factors,
|
|
46
|
+
"loadings": pd.DataFrame(loadings, index=R.columns, columns=factors.columns),
|
|
47
|
+
"alpha": pd.Series(alpha.flatten(), index=R.columns),
|
|
48
|
+
"residuals": pd.DataFrame(residuals, index=R.index, columns=R.columns),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def factor_model_covariance(model_results: Dict[str, Any]) -> np.ndarray:
|
|
53
|
+
"""
|
|
54
|
+
Calculate the factor model covariance matrix.
|
|
55
|
+
Sigma = Beta * Sigma_f * Beta' + Diag(Sigma_e)
|
|
56
|
+
"""
|
|
57
|
+
B = model_results["loadings"].values
|
|
58
|
+
factors = model_results["factors"].values
|
|
59
|
+
residuals = model_results["residuals"].values
|
|
60
|
+
|
|
61
|
+
# Covariance of factors
|
|
62
|
+
Sigma_f = np.cov(factors, rowvar=False)
|
|
63
|
+
|
|
64
|
+
# Diagonal matrix of residual variances
|
|
65
|
+
Sigma_e = np.diag(np.var(residuals, axis=0, ddof=1))
|
|
66
|
+
|
|
67
|
+
return B @ Sigma_f @ B.T + Sigma_e
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.optimize import minimize
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def entropy_pooling(
|
|
7
|
+
prior_probs: np.ndarray,
|
|
8
|
+
Aeq: Optional[np.ndarray] = None,
|
|
9
|
+
beq: Optional[np.ndarray] = None,
|
|
10
|
+
Aineq: Optional[np.ndarray] = None,
|
|
11
|
+
bineq: Optional[np.ndarray] = None,
|
|
12
|
+
) -> np.ndarray:
|
|
13
|
+
"""
|
|
14
|
+
Entropy Pooling algorithm.
|
|
15
|
+
Finds posterior probabilities p that minimize KL divergence from prior q,
|
|
16
|
+
subject to linear constraints on p.
|
|
17
|
+
"""
|
|
18
|
+
len(prior_probs)
|
|
19
|
+
q = prior_probs.reshape(-1, 1)
|
|
20
|
+
|
|
21
|
+
# We solve the dual problem for efficiency
|
|
22
|
+
# The dual objective is sum(q * exp(-1 - A' * lambda)) + b' * lambda
|
|
23
|
+
|
|
24
|
+
# Consolidate constraints for the solver
|
|
25
|
+
# This implementation focuses on equality constraints for simplicity
|
|
26
|
+
if Aeq is None:
|
|
27
|
+
return prior_probs
|
|
28
|
+
|
|
29
|
+
k = Aeq.shape[0]
|
|
30
|
+
x0 = np.zeros(k)
|
|
31
|
+
|
|
32
|
+
def dual_objective(x):
|
|
33
|
+
# x is the vector of Lagrange multipliers (lambda)
|
|
34
|
+
# exp_term = q * exp(-Aeq.T @ x)
|
|
35
|
+
# We handle the '-1' constant by normalization later
|
|
36
|
+
ln_q = np.log(q.flatten())
|
|
37
|
+
val = np.exp(ln_q - (Aeq.T @ x))
|
|
38
|
+
res_val = np.sum(val)
|
|
39
|
+
if beq is not None:
|
|
40
|
+
res_val += beq.flatten() @ x
|
|
41
|
+
return res_val
|
|
42
|
+
|
|
43
|
+
res = minimize(
|
|
44
|
+
dual_objective,
|
|
45
|
+
x0,
|
|
46
|
+
method="L-BFGS-B",
|
|
47
|
+
tol=1e-12,
|
|
48
|
+
options={"ftol": 1e-12, "gtol": 1e-12},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if not res.success:
|
|
52
|
+
# Fallback or raise
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
# Recover posterior probabilities
|
|
56
|
+
ln_q = np.log(q.flatten())
|
|
57
|
+
p = np.exp(ln_q - (Aeq.T @ res.x))
|
|
58
|
+
p = p / np.sum(p) # Ensure they sum to 1
|
|
59
|
+
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def meucci_moments(R: np.ndarray, posterior_probs: np.ndarray) -> Dict[str, np.ndarray]:
|
|
64
|
+
"""
|
|
65
|
+
Calculate adjusted mean and covariance based on posterior probabilities.
|
|
66
|
+
"""
|
|
67
|
+
p = posterior_probs.reshape(-1, 1)
|
|
68
|
+
T, N = R.shape
|
|
69
|
+
|
|
70
|
+
# Posterior Mean
|
|
71
|
+
mu_p = R.T @ p
|
|
72
|
+
|
|
73
|
+
# Posterior Covariance
|
|
74
|
+
# Sigma = sum(p_t * (R_t - mu)(R_t - mu)')
|
|
75
|
+
R_centered = R - mu_p.T
|
|
76
|
+
sigma_p = (R_centered.T * p.flatten()) @ R_centered
|
|
77
|
+
|
|
78
|
+
return {"mu": mu_p, "sigma": sigma_p}
|