Coverage for src/stable_yield_lab/visualization/visualizer.py: 80%

162 statements  

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

1"""Matplotlib-based chart helpers for StableYieldLab.""" 

2 

3from __future__ import annotations 

4 

5from typing import Mapping 

6 

7import pandas as pd 

8 

9from ..analytics.performance import nav_series, nav_trajectories 

10 

11 

12class Visualizer: 

13 """Collection of static helpers that turn analytics outputs into charts.""" 

14 

15 @staticmethod 

16 def _plt(): 

17 try: 

18 import matplotlib.pyplot as plt 

19 except Exception as exc: # pragma: no cover 

20 raise RuntimeError( 

21 "matplotlib is required for visualization. Install via Poetry or pip." 

22 ) from exc 

23 return plt 

24 

25 @staticmethod 

26 def bar_apr( 

27 df: pd.DataFrame, 

28 title: str = "Netto-APY pro Pool", 

29 x_col: str = "name", 

30 y_col: str = "base_apy", 

31 *, 

32 save_path: str | None = None, 

33 show: bool = True, 

34 ) -> None: 

35 if df.empty: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true

36 return 

37 plt = Visualizer._plt() 

38 plt.figure(figsize=(10, 6)) 

39 plt.bar(df[x_col], df[y_col] * 100.0) # percentage 

40 plt.title(title) 

41 plt.ylabel("APY (%)") 

42 plt.xticks(rotation=45, ha="right") 

43 plt.tight_layout() 

44 if save_path: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 plt.savefig(save_path, bbox_inches="tight") 

46 if show: 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true

47 plt.show() 

48 

49 @staticmethod 

50 def scatter_tvl_apy( 

51 df: pd.DataFrame, 

52 title: str = "TVL vs. APY", 

53 x_col: str = "tvl_usd", 

54 y_col: str = "base_apy", 

55 size_col: str | None = "risk_score", 

56 annotate: bool = True, 

57 *, 

58 save_path: str | None = None, 

59 show: bool = True, 

60 ) -> None: 

61 if df.empty: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true

62 return 

63 sizes = None 

64 if size_col in df.columns: 64 ↛ 67line 64 didn't jump to line 67 because the condition on line 64 was always true

65 # scale bubble sizes 

66 sizes = (df[size_col].fillna(2.0) * 40).tolist() 

67 plt = Visualizer._plt() 

68 plt.figure(figsize=(10, 6)) 

69 plt.scatter(df[x_col], df[y_col] * 100.0, s=sizes) # % on y-axis 

70 if annotate: 70 ↛ 78line 70 didn't jump to line 78 because the condition on line 70 was always true

71 for _, row in df.iterrows(): 

72 plt.annotate( 

73 str(row.get("name", "")), 

74 (row[x_col], row[y_col] * 100.0), 

75 textcoords="offset points", 

76 xytext=(5, 5), 

77 ) 

78 plt.xscale("log") 

79 plt.xlabel("TVL (USD, log-scale)") 

80 plt.ylabel("APY (%)") 

81 plt.title(title) 

82 plt.tight_layout() 

83 if save_path: 

84 plt.savefig(save_path, bbox_inches="tight") 

85 if show: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 plt.show() 

87 

88 @staticmethod 

89 def scatter_risk_return( 

90 df: pd.DataFrame, 

91 title: str = "Volatility vs. APY", 

92 x_col: str = "volatility", 

93 y_col: str = "base_apy", 

94 size_col: str = "tvl_usd", 

95 annotate: bool = True, 

96 *, 

97 save_path: str | None = None, 

98 show: bool = True, 

99 ) -> None: 

100 """Plot volatility against APY with bubble sizes scaled by TVL.""" 

101 if df.empty: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 return 

103 plt = Visualizer._plt() 

104 sizes = None 

105 if size_col in df.columns: 105 ↛ 109line 105 didn't jump to line 109 because the condition on line 105 was always true

106 max_val = float(df[size_col].max()) 

107 if max_val > 0: 107 ↛ 109line 107 didn't jump to line 109 because the condition on line 107 was always true

108 sizes = (df[size_col] / max_val * 300).tolist() 

109 plt.figure(figsize=(10, 6)) 

110 plt.scatter(df[x_col], df[y_col] * 100.0, s=sizes) 

111 if annotate and "name" in df.columns: 111 ↛ 119line 111 didn't jump to line 119 because the condition on line 111 was always true

112 for _, row in df.iterrows(): 

113 plt.annotate( 

114 str(row.get("name", "")), 

115 (row[x_col], row[y_col] * 100.0), 

116 textcoords="offset points", 

117 xytext=(5, 5), 

118 ) 

119 plt.xlabel("Volatility") 

120 plt.ylabel("APY (%)") 

121 plt.title(title) 

122 plt.tight_layout() 

123 if save_path: 123 ↛ 125line 123 didn't jump to line 125 because the condition on line 123 was always true

124 plt.savefig(save_path, bbox_inches="tight") 

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

126 plt.show() 

127 

128 @staticmethod 

129 def line_yield( 

130 ts: pd.DataFrame, 

131 title: str = "Yield Over Time", 

132 *, 

133 save_path: str | None = None, 

134 show: bool = True, 

135 ) -> None: 

136 """Plot yield time series for one or multiple pools.""" 

137 if ts.empty: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 return 

139 plt = Visualizer._plt() 

140 plt.figure(figsize=(10, 6)) 

141 for col in ts.columns: 

142 plt.plot(ts.index, ts[col] * 100.0, label=str(col)) 

143 plt.xlabel("Date") 

144 plt.ylabel("APY (%)") 

145 plt.title(title) 

146 if ts.shape[1] > 1: 146 ↛ 148line 146 didn't jump to line 148 because the condition on line 146 was always true

147 plt.legend() 

148 plt.tight_layout() 

149 if save_path: 149 ↛ 151line 149 didn't jump to line 151 because the condition on line 149 was always true

150 plt.savefig(save_path, bbox_inches="tight") 

151 if show: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 plt.show() 

153 

154 @staticmethod 

155 def line_nav( 

156 nav: pd.Series, 

157 title: str = "Net Asset Value", 

158 *, 

159 save_path: str | None = None, 

160 show: bool = True, 

161 ) -> None: 

162 """Plot net asset value time series.""" 

163 if nav.empty: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 return 

165 plt = Visualizer._plt() 

166 plt.figure(figsize=(10, 6)) 

167 plt.plot(nav.index, nav.values) 

168 plt.xlabel("Date") 

169 plt.ylabel("NAV") 

170 plt.title(title) 

171 plt.tight_layout() 

172 if save_path: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true

173 plt.savefig(save_path, bbox_inches="tight") 

174 if show: 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true

175 plt.show() 

176 

177 @staticmethod 

178 def bar_group_chain( 

179 df_group: pd.DataFrame, 

180 title: str = "APY (Kettenvergleich)", 

181 *, 

182 save_path: str | None = None, 

183 show: bool = True, 

184 ) -> None: 

185 if df_group.empty: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 return 

187 plt = Visualizer._plt() 

188 plt.figure(figsize=(8, 5)) 

189 plt.bar(df_group["chain"], df_group["apr_wavg"] * 100.0) 

190 plt.title(title) 

191 plt.ylabel("TVL-gewichteter APY (%)") 

192 plt.tight_layout() 

193 if save_path: 193 ↛ 195line 193 didn't jump to line 195 because the condition on line 193 was always true

194 plt.savefig(save_path, bbox_inches="tight") 

195 if show: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 plt.show() 

197 

198 @staticmethod 

199 def nav_with_benchmarks( 

200 returns: pd.DataFrame, 

201 initial_investment: float, 

202 cash_returns: float | pd.Series | None = None, 

203 *, 

204 labels: Mapping[str, str] | None = None, 

205 save_path: str | None = None, 

206 show: bool = True, 

207 ) -> pd.DataFrame: 

208 r"""Plot portfolio NAV alongside buy-and-hold and cash benchmarks.""" 

209 

210 clean_returns = returns.fillna(0.0) 

211 index = clean_returns.index 

212 

213 rebalanced_nav = nav_series(clean_returns, initial=float(initial_investment)).rename( 

214 "rebalance" 

215 ) 

216 

217 if clean_returns.shape[1] == 0: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 buy_and_hold_nav = pd.Series(index=index, dtype=float, name="buy_and_hold") 

219 else: 

220 num_assets = clean_returns.shape[1] 

221 per_asset_initial = float(initial_investment) / num_assets 

222 asset_navs = nav_trajectories( 

223 clean_returns, 

224 initial_investment=per_asset_initial, 

225 ) 

226 buy_and_hold_nav = asset_navs.sum(axis=1).rename("buy_and_hold") 

227 

228 if isinstance(cash_returns, pd.Series): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 cash_rate = cash_returns.reindex(index, fill_value=0.0) 

230 elif cash_returns is None: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true

231 cash_rate = pd.Series(0.0, index=index) 

232 else: 

233 cash_rate = pd.Series(float(cash_returns), index=index) 

234 

235 cash_nav = nav_series( 

236 cash_rate.to_frame(name="cash"), initial=float(initial_investment) 

237 ).rename("cash") 

238 

239 nav_df = pd.concat([rebalanced_nav, buy_and_hold_nav, cash_nav], axis=1) 

240 

241 default_labels = { 

242 "rebalance": "Rebalanced NAV", 

243 "buy_and_hold": "Buy & Hold", 

244 "cash": "Cash Benchmark", 

245 } 

246 if labels: 246 ↛ 248line 246 didn't jump to line 248 because the condition on line 246 was always true

247 default_labels.update(labels) 

248 rename_map = {col: default_labels.get(col, col) for col in nav_df.columns} 

249 nav_df = nav_df.rename(columns=rename_map) 

250 

251 Visualizer.line_chart( 

252 nav_df, 

253 title="Portfolio NAV vs Benchmarks", 

254 ylabel="Net Asset Value", 

255 save_path=save_path, 

256 show=show, 

257 ) 

258 

259 return nav_df 

260 

261 @staticmethod 

262 def line_chart( 

263 data: pd.DataFrame | pd.Series, 

264 *, 

265 title: str, 

266 ylabel: str, 

267 save_path: str | None = None, 

268 show: bool = True, 

269 ) -> None: 

270 """Plot time-series data as a line chart.""" 

271 df = data.to_frame() if isinstance(data, pd.Series) else data 

272 if df.empty: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 return 

274 plt = Visualizer._plt() 

275 plt.figure(figsize=(10, 6)) 

276 for col in df.columns: 

277 plt.plot(df.index, df[col], label=col) 

278 if len(df.columns) > 1: 

279 plt.legend() 

280 plt.xlabel("Date") 

281 plt.ylabel(ylabel) 

282 plt.title(title) 

283 plt.tight_layout() 

284 if save_path: 

285 plt.savefig(save_path, bbox_inches="tight") 

286 if show: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 plt.show() 

288 

289 

290__all__ = ["Visualizer"]