Coverage for src/stable_yield_lab/analytics/portfolio.py: 80%

92 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-04 20:38 +0000

1from __future__ import annotations 

2 

3import math 

4 

5import pandas as pd 

6 

7from . import performance 

8from .risk import _require_riskfolio 

9 

10 

11def _normalise_weights(returns: pd.DataFrame, weights: pd.Series | None) -> pd.Series: 

12 """Align and normalise weight vector against ``returns`` columns.""" 

13 

14 if returns.empty: 14 ↛ 15line 14 didn't jump to line 15 because the condition on line 14 was never true

15 return pd.Series(dtype=float) 

16 

17 if weights is None: 17 ↛ 18line 17 didn't jump to line 18 because the condition on line 17 was never true

18 return pd.Series(1.0 / returns.shape[1], index=returns.columns, dtype=float) 

19 

20 aligned = weights.reindex(returns.columns).fillna(0.0) 

21 total = float(aligned.sum()) 

22 if total == 0.0: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true

23 raise ValueError("weights sum to zero") 

24 return aligned / total 

25 

26 

27def allocate_mean_variance( 

28 returns: pd.DataFrame, 

29 *, 

30 bounds: dict[str, tuple[float, float]] | None = None, 

31 risk_measure: str = "MV", 

32 rf: float = 0.0, 

33 l: float = 0.0, 

34) -> pd.Series: 

35 """Optimize portfolio weights using mean-variance framework. 

36 

37 Parameters 

38 ---------- 

39 returns: pd.DataFrame 

40 Wide DataFrame of historical returns (rows=observations, cols=pools). 

41 bounds: dict[str, tuple[float, float]] | None, optional 

42 Mapping of asset -> (min_weight, max_weight). Bounds are applied 

43 post-optimization and weights re-normalized. Defaults to None. 

44 risk_measure: str, optional 

45 Risk measure understood by riskfolio-lib (e.g. "MV", "MAD"). 

46 rf: float, optional 

47 Risk-free rate for the optimizer. Defaults to 0.0. 

48 l: float, optional 

49 Risk aversion factor used by riskfolio-lib. Defaults to 0.0. 

50 """ 

51 _require_riskfolio() 

52 import riskfolio as rp 

53 

54 port = rp.Portfolio(returns=returns) 

55 port.assets_stats(method_mu="hist", method_cov="hist") 

56 weights_df = port.optimization(model="Classic", rm=risk_measure, obj="Sharpe", rf=rf, l=l) 

57 weights = weights_df.squeeze() 

58 

59 if bounds: 59 ↛ 64line 59 didn't jump to line 64 because the condition on line 59 was always true

60 lo = pd.Series({k: v[0] for k, v in bounds.items()}) 

61 hi = pd.Series({k: v[1] for k, v in bounds.items()}) 

62 weights = weights.clip(lower=lo, upper=hi) 

63 

64 weights = weights / weights.sum() 

65 return weights.reindex(returns.columns) 

66 

67 

68def expected_apy(returns: pd.DataFrame, weights: pd.Series, *, freq: int = 52) -> float: 

69 """Compute portfolio expected APY given periodic returns and weights.""" 

70 weights = weights.reindex(returns.columns).fillna(0) 

71 mean_returns = returns.mean() 

72 apy_assets = (1 + mean_returns) ** freq - 1 

73 return float((weights * apy_assets).sum()) 

74 

75 

76def tvl_weighted_risk(returns: pd.DataFrame, weights: pd.Series, *, rm: str = "MV") -> float: 

77 """Compute TVL-weighted risk of a portfolio using riskfolio risk measures.""" 

78 _require_riskfolio() 

79 import riskfolio as rp 

80 

81 weights = weights.reindex(returns.columns).fillna(0) 

82 cov = returns.cov() 

83 rc = rp.Risk_Contribution(weights, returns, cov, rm=rm) 

84 return float(rc.sum()) 

85 

86 

87def tracking_error( 

88 returns: pd.DataFrame | pd.Series, 

89 weights: pd.Series | None = None, 

90 *, 

91 freq: int = 52, 

92 target_periodic_return: float | None = None, 

93) -> tuple[float, float]: 

94 """Compute periodic and annualised tracking error for a rebalanced portfolio. 

95 

96 Parameters 

97 ---------- 

98 returns: 

99 Either a wide DataFrame of asset returns or a Series of portfolio 

100 returns. The returns must be periodic simple returns expressed as 

101 decimal fractions. 

102 weights: 

103 Target weights per asset when ``returns`` is a DataFrame. Missing 

104 weights default to zero and the vector is re-normalised to sum to one. 

105 freq: 

106 Number of compounding periods per year. Must be positive. 

107 target_periodic_return: 

108 Optional benchmark periodic return expressed as a decimal fraction. If 

109 omitted, the realised mean of the portfolio returns is used. 

110 

111 Returns 

112 ------- 

113 tuple[float, float] 

114 Periodic tracking error followed by its annualised counterpart. When 

115 insufficient observations are available the function returns 

116 ``(nan, nan)``. 

117 """ 

118 

119 if freq <= 0: 

120 raise ValueError("freq must be positive") 

121 

122 if isinstance(returns, pd.DataFrame): 

123 if returns.empty: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true

124 return float("nan"), float("nan") 

125 if weights is None: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true

126 raise ValueError("weights are required when returns is a DataFrame") 

127 norm_weights = _normalise_weights(returns, weights) 

128 portfolio_returns = returns.fillna(0.0).mul(norm_weights, axis=1).sum(axis=1) 

129 else: 

130 portfolio_returns = returns.dropna() 

131 

132 if portfolio_returns.empty: 132 ↛ 133line 132 didn't jump to line 133 because the condition on line 132 was never true

133 return float("nan"), float("nan") 

134 

135 benchmark = ( 

136 float(target_periodic_return) 

137 if target_periodic_return is not None 

138 else float(portfolio_returns.mean()) 

139 ) 

140 active = portfolio_returns - benchmark 

141 active = active.dropna() 

142 if active.size < 2: 

143 return float("nan"), float("nan") 

144 

145 periodic_te = float(active.std(ddof=1)) 

146 annualised_te = periodic_te * math.sqrt(freq) 

147 return periodic_te, annualised_te 

148 

149 

150def apy_performance_summary( 

151 returns: pd.DataFrame, 

152 weights: pd.Series | None = None, 

153 *, 

154 freq: int = 52, 

155 initial_nav: float = 1.0, 

156 nav: pd.Series | None = None, 

157) -> tuple[pd.Series, pd.Series]: 

158 """Summarise expected versus realised APY for a rebalanced portfolio. 

159 

160 The function compares the static expectation from :func:`expected_apy` 

161 against the realised performance implied by a rebalance-aware NAV path. 

162 

163 Parameters 

164 ---------- 

165 returns: 

166 Wide DataFrame of periodic simple returns, indexed by timestamp. 

167 weights: 

168 Target weights per asset. When ``None`` an equal-weight portfolio is 

169 assumed. 

170 freq: 

171 Compounding periods per year used to annualise results. 

172 initial_nav: 

173 Starting NAV used for the aggregated portfolio path. 

174 nav: 

175 Optional externally computed NAV path. When provided it must align with 

176 ``returns.index``. 

177 

178 Returns 

179 ------- 

180 tuple[pandas.Series, pandas.Series] 

181 Tuple containing (metrics, nav_path). ``metrics`` is a Series with 

182 expected APY, realised APY, realised total return, active APY and 

183 tracking error figures. ``nav_path`` is the NAV trajectory used for the 

184 realised calculations. 

185 """ 

186 

187 if freq <= 0: 

188 raise ValueError("freq must be positive") 

189 

190 if returns.empty: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 empty_nav = pd.Series(dtype=float) 

192 metrics = pd.Series( 

193 { 

194 "expected_apy": float("nan"), 

195 "realized_apy": float("nan"), 

196 "realized_total_return": float("nan"), 

197 "active_apy": float("nan"), 

198 "tracking_error_periodic": float("nan"), 

199 "tracking_error_annualized": float("nan"), 

200 "horizon_periods": 0.0, 

201 "horizon_years": float("nan"), 

202 "final_nav": float(initial_nav), 

203 }, 

204 dtype=float, 

205 ) 

206 return metrics, empty_nav 

207 

208 norm_weights = _normalise_weights(returns, weights) 

209 clean_returns = returns.fillna(0.0) 

210 portfolio_returns = clean_returns.mul(norm_weights, axis=1).sum(axis=1) 

211 

212 if nav is None: 

213 nav_path = performance.nav_series(clean_returns, norm_weights, initial=initial_nav) 

214 else: 

215 nav_path = nav.reindex(clean_returns.index) 

216 if nav_path.isna().any(): 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 raise ValueError("nav path contains NaN after aligning with returns index") 

218 

219 if nav_path.empty: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 final_nav = float(initial_nav) 

221 realised_total = float("nan") 

222 realised_apy = float("nan") 

223 periods = 0 

224 horizon_years = float("nan") 

225 else: 

226 final_nav = float(nav_path.iloc[-1]) 

227 periods = nav_path.shape[0] 

228 realised_total = final_nav / float(initial_nav) - 1.0 

229 realised_apy = (1.0 + realised_total) ** (freq / periods) - 1.0 

230 horizon_years = periods / freq 

231 

232 expected = expected_apy(returns, norm_weights, freq=freq) 

233 expected_periodic = (1.0 + expected) ** (1.0 / freq) - 1.0 

234 te_periodic, te_annualised = tracking_error( 

235 portfolio_returns, 

236 freq=freq, 

237 target_periodic_return=expected_periodic, 

238 ) 

239 

240 metrics = pd.Series( 

241 { 

242 "expected_apy": expected, 

243 "realized_apy": realised_apy, 

244 "realized_total_return": realised_total, 

245 "active_apy": realised_apy - expected, 

246 "tracking_error_periodic": te_periodic, 

247 "tracking_error_annualized": te_annualised, 

248 "horizon_periods": float(periods), 

249 "horizon_years": horizon_years if periods else float("nan"), 

250 "final_nav": final_nav, 

251 }, 

252 dtype=float, 

253 ) 

254 

255 return metrics, nav_path