Skip to content

Commit a06ab95

Browse files
committed
Adds max drawdown date to eval metrics.
1 parent 70b0783 commit a06ab95

File tree

2 files changed

+51
-18
lines changed

2 files changed

+51
-18
lines changed

src/pybroker/eval.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pybroker.vect import highv, inverse_normal_cdf, normal_cdf
1313
from collections import deque
1414
from dataclasses import dataclass, field
15+
from datetime import datetime
1516
from numba import njit
1617
from numpy.typing import NDArray
1718
from 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,

tests/test_eval.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pandas as pd
1313
import pytest
1414
import re
15+
from datetime import datetime
1516
from pybroker.eval import (
1617
EvalMetrics,
1718
EvaluateMixin,
@@ -225,20 +226,24 @@ def test_calmar_ratio(values, bars_per_year, expected_calmar):
225226

226227

227228
@pytest.mark.parametrize(
228-
"values, expected_dd",
229+
"values, expected_dd, expected_index",
229230
[
230-
([0, 0.1, 0.15, -0.05, 0.1, -0.25, -0.15, 0], -36.25),
231-
([0, -0.2], -20),
232-
([-0.1], -10),
233-
([0, 0, 0, 0], 0),
234-
([0], 0),
235-
([], 0),
231+
([0, 0.1, 0.15, -0.05, 0.1, -0.25, -0.15, 0], -36.25, 6),
232+
([0, -0.2], -20, 1),
233+
([-0.1], -10, 0),
234+
([0, 0, 0, 0], 0, None),
235+
([0], 0, None),
236+
([], 0, None),
236237
],
237238
)
238-
def test_max_drawdown_percent(values, expected_dd):
239+
def test_max_drawdown_percent(values, expected_dd, expected_index):
239240
returns = np.array(values)
240-
dd = max_drawdown_percent(returns)
241+
dd, index = max_drawdown_percent(returns)
241242
assert round(dd, 2) == expected_dd
243+
if expected_index is None:
244+
assert index is None
245+
else:
246+
assert index == expected_index
242247

243248

244249
@pytest.mark.parametrize(
@@ -547,6 +552,7 @@ def test_evaluate(
547552
assert metrics.total_loss == -237770.88
548553
assert metrics.max_drawdown == -56721.59999999998
549554
assert metrics.max_drawdown_pct == -7.908428778116649
555+
assert metrics.max_drawdown_date == datetime(2022, 1, 25, 5, 0)
550556
assert metrics.win_rate == 52.57731958762887
551557
assert metrics.loss_rate == 47.42268041237113
552558
assert metrics.winning_trades == 204
@@ -606,6 +612,7 @@ def test_evaluate_when_portfolio_empty(self, trades_df, calc_bootstrap):
606612
"annual_return_pct",
607613
"annual_std_error",
608614
"annual_volatility_pct",
615+
"max_drawdown_date",
609616
):
610617
assert getattr(result.metrics, field) is None
611618
else:
@@ -617,7 +624,11 @@ def test_evaluate_when_single_market_value(
617624
):
618625
mixin = EvaluateMixin()
619626
result = mixin.evaluate(
620-
pd.DataFrame([[1000, 0]], columns=["market_value", "fees"]),
627+
pd.DataFrame(
628+
[[1000, 0]],
629+
columns=["market_value", "fees"],
630+
index=[pd.Timestamp("2023-04-12 00:00:00")],
631+
),
621632
trades_df,
622633
calc_bootstrap,
623634
bootstrap_sample_size=10,
@@ -631,6 +642,7 @@ def test_evaluate_when_single_market_value(
631642
"annual_return_pct",
632643
"annual_std_error",
633644
"annual_volatility_pct",
645+
"max_drawdown_date",
634646
):
635647
assert getattr(result.metrics, field) is None
636648
else:

0 commit comments

Comments
 (0)