Coverage for src / basanos / analytics / _plots.py: 100%

172 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 05:23 +0000

1"""Plotting utilities for portfolio analytics using Plotly. 

2 

3This module defines the Plots facade which renders common portfolio visuals 

4such as snapshots, lagged performance curves, smoothed-holdings curves, and 

5lead/lag information ratio bar charts. Designed for notebook use. 

6 

7Examples: 

8 >>> import dataclasses 

9 >>> from basanos.analytics._plots import Plots 

10 >>> dataclasses.is_dataclass(Plots) 

11 True 

12""" 

13 

14from __future__ import annotations 

15 

16import dataclasses 

17from typing import TYPE_CHECKING 

18 

19import plotly.express as px 

20import plotly.graph_objects as go 

21import plotly.io as pio 

22import polars as pl 

23from plotly.subplots import make_subplots 

24 

25if TYPE_CHECKING: 

26 # Import the local Portfolio type for type checking and documentation tools. 

27 from .portfolio import Portfolio 

28 

29# Ensure Plotly works with Marimo (set after imports to satisfy linters) 

30pio.renderers.default = "plotly_mimetype" 

31 

32 

33@dataclasses.dataclass(frozen=True) 

34class Plots: 

35 """Facade for portfolio plots built with Plotly. 

36 

37 Provides convenience methods to visualize portfolio performance and 

38 diagnostics directly from a Portfolio instance (e.g., snapshot charts, 

39 lagged performance, smoothed holdings, and lead/lag IR). 

40 """ 

41 

42 portfolio: Portfolio 

43 

44 def lead_lag_ir_plot(self, start: int = -10, end: int = 19) -> go.Figure: 

45 """Plot Sharpe ratio (IR) across lead/lag variants of the portfolio. 

46 

47 Builds portfolios with cash positions lagged from ``start`` to ``end`` 

48 (inclusive) and plots a bar chart of the Sharpe ratio for each lag. 

49 Positive lags delay weights; negative lags lead them. 

50 

51 Args: 

52 start: First lag to include (default: -10). 

53 end: Last lag to include (default: +19). 

54 

55 Returns: 

56 A Plotly Figure with one bar per lag labeled by the lag value. 

57 """ 

58 if not isinstance(start, int) or not isinstance(end, int): 

59 raise TypeError 

60 if start > end: 

61 start, end = end, start 

62 

63 lags = list(range(start, end + 1)) 

64 

65 x_vals: list[int] = [] 

66 y_vals: list[float] = [] 

67 

68 for n in lags: 

69 pf = self.portfolio if n == 0 else self.portfolio.lag(n) 

70 # Compute Sharpe on the portfolio's returns series 

71 sharpe_val = pf.stats.sharpe().get("returns", float("nan")) 

72 # Ensure a float (Stats returns mapping asset->value) 

73 y_vals.append(float(sharpe_val) if sharpe_val is not None else float("nan")) 

74 x_vals.append(n) 

75 

76 fig = go.Figure( 

77 data=[ 

78 go.Bar(x=x_vals, y=y_vals, name="Sharpe by lag", marker_color="#1f77b4"), 

79 ] 

80 ) 

81 fig.update_layout( 

82 title="Lead/Lag Information Ratio (Sharpe) by Lag", 

83 xaxis_title="Lag (steps)", 

84 yaxis_title="Sharpe ratio", 

85 plot_bgcolor="white", 

86 hovermode="x", 

87 ) 

88 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

89 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

90 return fig 

91 

92 def snapshot(self, log_scale: bool = False) -> go.Figure: 

93 """Return a snapshot dashboard of NAV and drawdown. 

94 

95 Args: 

96 log_scale (bool, optional): If True, display NAV on a log scale. Defaults to False. 

97 

98 Returns: 

99 plotly.graph_objects.Figure: A Figure with accumulated NAV (including tilt/timing) 

100 and drawdown shaded area, equipped with a range selector. 

101 """ 

102 # Create subplot grid with domain for stats table 

103 fig = make_subplots( 

104 rows=2, 

105 cols=1, 

106 shared_xaxes=True, 

107 row_heights=[0.66, 0.33], 

108 subplot_titles=["Accumulated Profit", "Drawdown"], 

109 vertical_spacing=0.05, 

110 ) 

111 

112 # --- Row 1: Cumulative Returns 

113 fig.add_trace( 

114 go.Scatter( 

115 x=self.portfolio.nav_accumulated["date"], 

116 y=self.portfolio.nav_accumulated["NAV_accumulated"], 

117 mode="lines", 

118 name="NAV", 

119 showlegend=False, 

120 ), 

121 row=1, 

122 col=1, 

123 ) 

124 

125 fig.add_trace( 

126 go.Scatter( 

127 x=self.portfolio.tilt.nav_accumulated["date"], 

128 y=self.portfolio.tilt.nav_accumulated["NAV_accumulated"], 

129 mode="lines", 

130 name="Tilt", 

131 showlegend=False, 

132 ), 

133 row=1, 

134 col=1, 

135 ) 

136 

137 fig.add_trace( 

138 go.Scatter( 

139 x=self.portfolio.timing.nav_accumulated["date"], 

140 y=self.portfolio.timing.nav_accumulated["NAV_accumulated"], 

141 mode="lines", 

142 name="Timing", 

143 showlegend=False, 

144 ), 

145 row=1, 

146 col=1, 

147 ) 

148 

149 fig.add_trace( 

150 go.Scatter( 

151 x=self.portfolio.drawdown["date"], 

152 y=self.portfolio.drawdown["drawdown_pct"], 

153 mode="lines", 

154 fill="tozeroy", 

155 name="Drawdown", 

156 showlegend=False, 

157 ), 

158 row=2, 

159 col=1, 

160 ) 

161 

162 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1) 

163 

164 # Layout 

165 fig.update_layout( 

166 title="Performance Dashboard", 

167 height=1200, 

168 hovermode="x unified", 

169 plot_bgcolor="white", 

170 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

171 xaxis={ 

172 "rangeselector": { 

173 "buttons": [ 

174 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

175 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

176 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

177 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

178 {"step": "all", "label": "All"}, 

179 ] 

180 }, 

181 "rangeslider": {"visible": False}, 

182 "type": "date", 

183 }, 

184 ) 

185 

186 fig.update_yaxes(title_text="NAV (accumulated)", row=1, col=1, tickformat=".2s") 

187 fig.update_yaxes(title_text="Drawdown", row=2, col=1, tickformat=".0%") 

188 

189 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

190 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

191 

192 if log_scale: 

193 fig.update_yaxes(type="log", row=1, col=1) 

194 # Ensure the first y-axis is explicitly set for environments 

195 # where subplot updates may not propagate to layout alias. 

196 if hasattr(fig.layout, "yaxis"): 

197 fig.layout.yaxis.type = "log" 

198 

199 return fig 

200 

201 def lagged_performance_plot(self, lags: list[int] | None = None, log_scale: bool = False) -> go.Figure: 

202 """Plot NAV_accumulated for multiple lagged portfolios. 

203 

204 Creates a Plotly figure with one line per lag value showing the 

205 accumulated NAV series for the portfolio with cash positions 

206 shifted by that lag. By default, lags [0, 1, 2, 3, 4] are used. 

207 

208 Args: 

209 lags: A list of integer lags to apply; defaults to [0, 1, 2, 3, 4]. 

210 log_scale: If True, set the primary y-axis to logarithmic scale. 

211 

212 Returns: 

213 A Plotly Figure containing one trace per requested lag. 

214 """ 

215 if lags is None: 

216 lags = [0, 1, 2, 3, 4] 

217 if not isinstance(lags, list) or not all(isinstance(x, int) for x in lags): 

218 raise TypeError 

219 

220 fig = go.Figure() 

221 for lag in lags: 

222 pf = self.portfolio if lag == 0 else self.portfolio.lag(lag) 

223 nav = pf.nav_accumulated 

224 fig.add_trace( 

225 go.Scatter( 

226 x=nav["date"], 

227 y=nav["NAV_accumulated"], 

228 mode="lines", 

229 name=f"lag {lag}", 

230 line={"width": 1}, 

231 ) 

232 ) 

233 

234 fig.update_layout( 

235 title="NAV accumulated by lag", 

236 hovermode="x unified", 

237 plot_bgcolor="white", 

238 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

239 xaxis={ 

240 "rangeselector": { 

241 "buttons": [ 

242 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

243 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

244 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

245 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

246 {"step": "all", "label": "All"}, 

247 ] 

248 }, 

249 "rangeslider": {"visible": False}, 

250 "type": "date", 

251 }, 

252 ) 

253 fig.update_yaxes(title_text="NAV (accumulated)") 

254 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

255 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

256 

257 if log_scale: 

258 fig.update_yaxes(type="log") 

259 if hasattr(fig.layout, "yaxis"): 

260 fig.layout.yaxis.type = "log" 

261 

262 return fig 

263 

264 def rolling_sharpe_plot(self, window: int = 63) -> go.Figure: 

265 """Plot rolling annualised Sharpe ratio over time. 

266 

267 Computes the rolling Sharpe for each asset column using the given 

268 window and renders one line per asset. 

269 

270 Args: 

271 window: Rolling-window size in periods. Defaults to 63. 

272 

273 Returns: 

274 A Plotly Figure with one trace per asset. 

275 

276 Raises: 

277 ValueError: If ``window`` is not a positive integer. 

278 """ 

279 if not isinstance(window, int) or window <= 0: 

280 raise ValueError 

281 

282 rolling = self.portfolio.stats.rolling_sharpe(window=window) 

283 

284 fig = go.Figure() 

285 date_col = rolling["date"] if "date" in rolling.columns else None 

286 for col in rolling.columns: 

287 if col == "date": 

288 continue 

289 fig.add_trace( 

290 go.Scatter( 

291 x=date_col, 

292 y=rolling[col], 

293 mode="lines", 

294 name=col, 

295 line={"width": 1}, 

296 ) 

297 ) 

298 

299 fig.add_hline(y=0, line_width=1, line_dash="dash", line_color="gray") 

300 

301 fig.update_layout( 

302 title=f"Rolling Sharpe Ratio ({window}-period window)", 

303 hovermode="x unified", 

304 plot_bgcolor="white", 

305 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

306 xaxis={ 

307 "rangeselector": { 

308 "buttons": [ 

309 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

310 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

311 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

312 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

313 {"step": "all", "label": "All"}, 

314 ] 

315 }, 

316 "rangeslider": {"visible": False}, 

317 "type": "date", 

318 }, 

319 ) 

320 fig.update_yaxes(title_text="Sharpe ratio") 

321 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

322 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

323 return fig 

324 

325 def rolling_volatility_plot(self, window: int = 63) -> go.Figure: 

326 """Plot rolling annualised volatility over time. 

327 

328 Computes the rolling volatility for each asset column using the given 

329 window and renders one line per asset. 

330 

331 Args: 

332 window: Rolling-window size in periods. Defaults to 63. 

333 

334 Returns: 

335 A Plotly Figure with one trace per asset. 

336 

337 Raises: 

338 ValueError: If ``window`` is not a positive integer. 

339 """ 

340 if not isinstance(window, int) or window <= 0: 

341 raise ValueError 

342 

343 rolling = self.portfolio.stats.rolling_volatility(window=window) 

344 

345 fig = go.Figure() 

346 date_col = rolling["date"] if "date" in rolling.columns else None 

347 for col in rolling.columns: 

348 if col == "date": 

349 continue 

350 fig.add_trace( 

351 go.Scatter( 

352 x=date_col, 

353 y=rolling[col], 

354 mode="lines", 

355 name=col, 

356 line={"width": 1}, 

357 ) 

358 ) 

359 

360 fig.update_layout( 

361 title=f"Rolling Volatility ({window}-period window)", 

362 hovermode="x unified", 

363 plot_bgcolor="white", 

364 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

365 xaxis={ 

366 "rangeselector": { 

367 "buttons": [ 

368 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

369 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

370 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

371 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

372 {"step": "all", "label": "All"}, 

373 ] 

374 }, 

375 "rangeslider": {"visible": False}, 

376 "type": "date", 

377 }, 

378 ) 

379 fig.update_yaxes(title_text="Annualised volatility") 

380 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

381 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

382 return fig 

383 

384 def annual_sharpe_plot(self) -> go.Figure: 

385 """Plot annualised Sharpe ratio broken down by calendar year. 

386 

387 Computes the Sharpe ratio for each calendar year from the portfolio 

388 returns and renders a grouped bar chart with one bar per year per 

389 asset. 

390 

391 Returns: 

392 A Plotly Figure with one bar group per asset. 

393 """ 

394 breakdown = self.portfolio.stats.annual_breakdown() 

395 

396 # Extract the sharpe row for each year 

397 sharpe_rows = breakdown.filter(pl.col("metric") == "sharpe") 

398 asset_cols = [c for c in sharpe_rows.columns if c not in ("year", "metric")] 

399 

400 fig = go.Figure() 

401 for asset in asset_cols: 

402 fig.add_trace( 

403 go.Bar( 

404 x=sharpe_rows["year"], 

405 y=sharpe_rows[asset], 

406 name=asset, 

407 ) 

408 ) 

409 

410 fig.add_hline(y=0, line_width=1, line_color="gray") 

411 

412 fig.update_layout( 

413 title="Annual Sharpe Ratio by Year", 

414 barmode="group", 

415 hovermode="x unified", 

416 plot_bgcolor="white", 

417 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

418 ) 

419 fig.update_yaxes(title_text="Sharpe ratio") 

420 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey", title_text="Year") 

421 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

422 return fig 

423 

424 def correlation_heatmap( 

425 self, 

426 frame: pl.DataFrame | None = None, 

427 name: str = "portfolio", 

428 title: str = "Correlation heatmap", 

429 ) -> go.Figure: 

430 """Plot a correlation heatmap for assets and the portfolio. 

431 

432 If ``frame`` is None, uses the portfolio's prices. The portfolio's 

433 profit series is appended under ``name`` before computing the 

434 correlation matrix. 

435 

436 Args: 

437 frame: Optional Polars DataFrame with at least the asset price 

438 columns. If omitted, uses ``self.portfolio.prices``. 

439 name: Column name under which to include the portfolio profit. 

440 title: Plot title. 

441 

442 Returns: 

443 A Plotly Figure rendering the correlation matrix as a heatmap. 

444 """ 

445 if frame is None: 

446 frame = self.portfolio.prices 

447 

448 corr = self.portfolio.correlation(frame, name=name) 

449 

450 # Create an interactive heatmap 

451 fig = px.imshow( 

452 corr, 

453 x=corr.columns, 

454 y=corr.columns, 

455 text_auto=".2f", # show correlation values 

456 color_continuous_scale="RdBu_r", # red-blue diverging colormap 

457 zmin=-1, 

458 zmax=1, # correlation range 

459 title=title, 

460 ) 

461 

462 # Adjust layout 

463 fig.update_layout( 

464 xaxis_title="", yaxis_title="", width=700, height=600, coloraxis_colorbar={"title": "Correlation"} 

465 ) 

466 

467 return fig 

468 

469 def monthly_returns_heatmap(self) -> go.Figure: 

470 """Plot a monthly returns calendar heatmap. 

471 

472 Groups portfolio returns by calendar year and month, then renders a 

473 Plotly heatmap with months on the x-axis and years on the y-axis. 

474 Green cells indicate positive months; red cells indicate negative 

475 months. Cell text shows the percentage return for that month. 

476 

477 Returns: 

478 A Plotly Figure with a calendar heatmap of monthly returns. 

479 

480 Raises: 

481 ValueError: If the portfolio has no ``date`` column. 

482 """ 

483 monthly = self.portfolio.monthly 

484 

485 years = monthly["year"].unique().sort().to_list() 

486 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 

487 

488 z: list[list[float | None]] = [] 

489 text: list[list[str]] = [] 

490 for year in years: 

491 year_data = monthly.filter(pl.col("year") == year) 

492 year_row: list[float | None] = [] 

493 year_text: list[str] = [] 

494 for m in range(1, 13): 

495 month_data = year_data.filter(pl.col("month") == m) 

496 if month_data.is_empty(): 

497 year_row.append(None) 

498 year_text.append("") 

499 else: 

500 ret = float(month_data["returns"][0]) 

501 year_row.append(ret * 100.0) 

502 year_text.append(f"{ret * 100.0:.1f}%") 

503 z.append(year_row) 

504 text.append(year_text) 

505 

506 fig = go.Figure( 

507 data=go.Heatmap( 

508 z=z, 

509 x=month_names, 

510 y=[str(y) for y in years], 

511 text=text, 

512 texttemplate="%{text}", 

513 colorscale="RdYlGn", 

514 zmid=0, 

515 colorbar={"title": "Return (%)"}, 

516 hovertemplate="<b>%{y} %{x}</b><br>Return: %{text}<extra></extra>", 

517 ) 

518 ) 

519 

520 fig.update_layout( 

521 title="Monthly Returns Heatmap", 

522 xaxis_title="Month", 

523 yaxis_title="Year", 

524 plot_bgcolor="white", 

525 yaxis={"type": "category"}, 

526 ) 

527 

528 return fig 

529 

530 def smoothed_holdings_performance_plot( 

531 self, 

532 windows: list[int] | None = None, 

533 log_scale: bool = False, 

534 ) -> go.Figure: 

535 """Plot NAV_accumulated for smoothed-holding portfolios. 

536 

537 Builds portfolios with cash positions smoothed by a trailing rolling 

538 mean over the previous ``n`` steps (window size n+1) for n in 

539 ``windows`` (defaults to [0, 1, 2, 3, 4]) and plots their 

540 accumulated NAV curves. 

541 

542 Args: 

543 windows: List of non-negative integers specifying smoothing steps 

544 to include; defaults to [0, 1, 2, 3, 4]. 

545 log_scale: If True, set the primary y-axis to logarithmic scale. 

546 

547 Returns: 

548 A Plotly Figure containing one line per requested smoothing level. 

549 """ 

550 if windows is None: 

551 windows = [0, 1, 2, 3, 4] 

552 if not isinstance(windows, list) or not all(isinstance(x, int) and x >= 0 for x in windows): 

553 raise TypeError 

554 

555 fig = go.Figure() 

556 for n in windows: 

557 pf = self.portfolio if n == 0 else self.portfolio.smoothed_holding(n) 

558 nav = pf.nav_accumulated 

559 fig.add_trace( 

560 go.Scatter( 

561 x=nav["date"], 

562 y=nav["NAV_accumulated"], 

563 mode="lines", 

564 name=f"smooth {n}", 

565 line={"width": 1}, 

566 ) 

567 ) 

568 

569 fig.update_layout( 

570 title="NAV accumulated by smoothed holdings", 

571 hovermode="x unified", 

572 plot_bgcolor="white", 

573 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

574 xaxis={ 

575 "rangeselector": { 

576 "buttons": [ 

577 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

578 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

579 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

580 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

581 {"step": "all", "label": "All"}, 

582 ] 

583 }, 

584 "rangeslider": {"visible": False}, 

585 "type": "date", 

586 }, 

587 ) 

588 fig.update_yaxes(title_text="NAV (accumulated)") 

589 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

590 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

591 

592 if log_scale: 

593 fig.update_yaxes(type="log") 

594 if hasattr(fig.layout, "yaxis"): 

595 fig.layout.yaxis.type = "log" 

596 

597 return fig 

598 

599 def trading_cost_impact_plot(self, max_bps: int = 20) -> go.Figure: 

600 """Plot the Sharpe ratio as a function of one-way trading costs. 

601 

602 Evaluates the portfolio's annualised Sharpe ratio at each integer 

603 cost level from 0 up to ``max_bps`` basis points and renders the 

604 result as a line chart. The zero-cost Sharpe is shown as a 

605 reference horizontal line so that the reader can quickly gauge 

606 at what cost level the strategy's edge is eroded. 

607 

608 Args: 

609 max_bps: Maximum one-way trading cost to evaluate, in basis 

610 points. Defaults to 20. 

611 

612 Returns: 

613 A Plotly Figure with one line trace showing Sharpe vs. cost. 

614 

615 Raises: 

616 ValueError: If ``max_bps`` is not a positive integer. 

617 """ 

618 impact = self.portfolio.trading_cost_impact(max_bps=max_bps) 

619 

620 cost_vals = impact["cost_bps"].to_list() 

621 sharpe_vals = impact["sharpe"].to_list() 

622 

623 # Baseline Sharpe at zero cost 

624 baseline = float(sharpe_vals[0]) if sharpe_vals and sharpe_vals[0] is not None else float("nan") 

625 

626 fig = go.Figure() 

627 fig.add_trace( 

628 go.Scatter( 

629 x=cost_vals, 

630 y=sharpe_vals, 

631 mode="lines+markers", 

632 name="Sharpe (cost-adjusted)", 

633 marker={"size": 6}, 

634 line={"width": 2, "color": "#1f77b4"}, 

635 ) 

636 ) 

637 if baseline == baseline: # only add when baseline is finite (NaN != NaN) 

638 fig.add_hline( 

639 y=baseline, 

640 line_width=1, 

641 line_dash="dash", 

642 line_color="gray", 

643 annotation_text="0 bps baseline", 

644 annotation_position="top right", 

645 ) 

646 

647 fig.update_layout( 

648 title=f"Trading Cost Impact on Sharpe Ratio (0\u2013{max_bps} bps)", 

649 hovermode="x unified", 

650 plot_bgcolor="white", 

651 ) 

652 fig.update_xaxes( 

653 title_text="One-way cost (basis points)", 

654 showgrid=True, 

655 gridwidth=0.5, 

656 gridcolor="lightgrey", 

657 dtick=1, 

658 ) 

659 fig.update_yaxes( 

660 title_text="Annualised Sharpe ratio", 

661 showgrid=True, 

662 gridwidth=0.5, 

663 gridcolor="lightgrey", 

664 ) 

665 return fig