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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-04 20:38 +0000
1"""Matplotlib-based chart helpers for StableYieldLab."""
3from __future__ import annotations
5from typing import Mapping
7import pandas as pd
9from ..analytics.performance import nav_series, nav_trajectories
12class Visualizer:
13 """Collection of static helpers that turn analytics outputs into charts."""
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
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()
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()
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()
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()
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()
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()
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."""
210 clean_returns = returns.fillna(0.0)
211 index = clean_returns.index
213 rebalanced_nav = nav_series(clean_returns, initial=float(initial_investment)).rename(
214 "rebalance"
215 )
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")
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)
235 cash_nav = nav_series(
236 cash_rate.to_frame(name="cash"), initial=float(initial_investment)
237 ).rename("cash")
239 nav_df = pd.concat([rebalanced_nav, buy_and_hold_nav, cash_nav], axis=1)
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)
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 )
259 return nav_df
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()
290__all__ = ["Visualizer"]