1212from pybroker .vect import highv , inverse_normal_cdf , normal_cdf
1313from collections import deque
1414from dataclasses import dataclass , field
15+ from datetime import datetime
1516from numba import njit
1617from numpy .typing import NDArray
1718from typing import Callable , NamedTuple , Optional
@@ -290,28 +291,36 @@ def calmar_ratio(changes: NDArray[np.float64], bars_per_year: int) -> float:
290291
291292
292293@njit
293- def max_drawdown_percent (returns : NDArray [np .float64 ]) -> float :
294+ def max_drawdown_percent (
295+ returns : NDArray [np .float64 ],
296+ ) -> tuple [float , Optional [int ]]:
294297 """Computes maximum drawdown, measured in percentage loss.
295298
296299 Args:
297300 returns: Array of returns centered at 0.
301+
302+ Returns:
303+ - Maximum drawdown, measured in percentage loss.
304+ - Index of the maximum drawdown.
298305 """
299306 returns = returns + 1
300307 n = len (returns )
301308 if not n :
302- return 0
309+ return 0 , None
303310 cumulative = 1.0
304311 max_equity = 1.0
305312 dd = 0.0
306- for r in returns :
313+ index = None
314+ for i , r in enumerate (returns ):
307315 cumulative *= r
308316 if cumulative > max_equity :
309317 max_equity = cumulative
310318 elif max_equity > 0 :
311319 loss = (cumulative / max_equity - 1 ) * 100
312320 if loss < dd :
313321 dd = loss
314- return dd
322+ index = i
323+ return dd , index
315324
316325
317326@njit
@@ -402,7 +411,7 @@ def drawdown_conf(
402411 changes_sample [j ] = changes [k ]
403412 returns_sample [j ] = returns [k ]
404413 boot_dd [i ] = max_drawdown (changes_sample )
405- boot_dd_pct [i ] = max_drawdown_percent (returns_sample )
414+ boot_dd_pct [i ], _ = max_drawdown_percent (returns_sample )
406415 return DrawdownMetrics (_dd_confs (boot_dd ), _dd_confs (boot_dd_pct ))
407416
408417
@@ -689,6 +698,7 @@ class EvalMetrics:
689698 :attr:`pybroker.config.StrategyConfig.fee_mode` for more info.
690699 max_drawdown: Maximum drawdown, measured in cash.
691700 max_drawdown_pct: Maximum drawdown, measured in percentage.
701+ max_drawdown_date: Date of maximum drawdown.
692702 win_rate: Win rate of trades.
693703 loss_rate: Loss rate of trades.
694704 winning_trades: Number of winning trades.
@@ -742,6 +752,7 @@ class EvalMetrics:
742752 total_fees : float = field (default = 0 )
743753 max_drawdown : float = field (default = 0 )
744754 max_drawdown_pct : float = field (default = 0 )
755+ max_drawdown_date : Optional [datetime ] = field (default = None )
745756 win_rate : float = field (default = 0 )
746757 loss_rate : float = field (default = 0 )
747758 winning_trades : int = field (default = 0 )
@@ -846,6 +857,8 @@ def evaluate(
846857 market_values = portfolio_df ["market_value" ].to_numpy ()
847858 fees = portfolio_df ["fees" ].to_numpy ()
848859 bar_returns = self ._calc_bar_returns (portfolio_df )
860+ bar_return_dates = bar_returns .index .to_series ().reset_index (drop = True )
861+ bar_returns = bar_returns .to_numpy ()
849862 bar_changes = self ._calc_bar_changes (portfolio_df )
850863 if (
851864 not len (market_values )
@@ -882,6 +895,7 @@ def evaluate(
882895 market_values ,
883896 bar_changes ,
884897 bar_returns ,
898+ bar_return_dates ,
885899 pnls ,
886900 return_pcts ,
887901 bars = bars ,
@@ -926,10 +940,10 @@ def evaluate(
926940 logger .calc_bootstrap_metrics_completed ()
927941 return EvalResult (metrics , bootstrap )
928942
929- def _calc_bar_returns (self , df : pd .DataFrame ) -> NDArray [ np . float64 ] :
943+ def _calc_bar_returns (self , df : pd .DataFrame ) -> pd . Series :
930944 prev_market_value = df ["market_value" ].shift (1 )
931945 returns = (df ["market_value" ] - prev_market_value ) / prev_market_value
932- return returns .dropna (). to_numpy ()
946+ return returns .dropna ()
933947
934948 def _calc_bar_changes (self , df : pd .DataFrame ) -> NDArray [np .float64 ]:
935949 changes = df ["market_value" ] - df ["market_value" ].shift (1 )
@@ -940,6 +954,7 @@ def _calc_eval_metrics(
940954 market_values : NDArray [np .float64 ],
941955 bar_changes : NDArray [np .float64 ],
942956 bar_returns : NDArray [np .float64 ],
957+ bar_return_dates : pd .Series ,
943958 pnls : NDArray [np .float64 ],
944959 return_pcts : NDArray [np .float64 ],
945960 bars : NDArray [np .int_ ],
@@ -954,7 +969,12 @@ def _calc_eval_metrics(
954969 ) -> EvalMetrics :
955970 total_fees = fees [- 1 ] if len (fees ) else 0
956971 max_dd = max_drawdown (bar_changes )
957- max_dd_pct = max_drawdown_percent (bar_returns )
972+ max_dd_pct , max_dd_index = max_drawdown_percent (bar_returns )
973+ max_dd_date = (
974+ bar_return_dates .iloc [max_dd_index ].to_pydatetime ()
975+ if max_dd_index
976+ else None
977+ )
958978 sharpe = sharpe_ratio (bar_changes , bars_per_year )
959979 sortino = sortino_ratio (bar_changes , bars_per_year )
960980 pf = profit_factor (bar_changes )
@@ -1029,6 +1049,7 @@ def _calc_eval_metrics(
10291049 end_market_value = market_values [- 1 ],
10301050 max_drawdown = max_dd ,
10311051 max_drawdown_pct = max_dd_pct ,
1052+ max_drawdown_date = max_dd_date ,
10321053 largest_win = largest_win ,
10331054 largest_win_pct = largest_win_pct ,
10341055 largest_win_bars = largest_win_num_bars ,
0 commit comments