Skip to content

Commit 9bca1a3

Browse files
committed
fix: drawdown metrics do not match portfolio snapshot data (#407)
- Fix get_max_daily_drawdown to compute worst single-day decline instead of peak-to-trough drawdown (was identical to max_drawdown) - Fix get_max_drawdown_duration to count calendar days using timestamps instead of counting snapshot entries - Sort equity curve by created_at in get_equity_curve to ensure chronological order for all downstream metrics - Add 15 new tests for daily drawdown, duration, and consistency
1 parent 4ec9600 commit 9bca1a3

3 files changed

Lines changed: 369 additions & 28 deletions

File tree

investing_algorithm_framework/services/metrics/drawdown.py

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,17 @@ def get_max_drawdown(snapshots: List[PortfolioSnapshot]) -> float:
109109

110110
def get_max_daily_drawdown(snapshots: List[PortfolioSnapshot]) -> float:
111111
"""
112-
Calculate the maximum daily drawdown of the portfolio as a percentage from the peak.
112+
Calculate the worst single-day decline of the portfolio as a percentage.
113113
114-
This is the largest drop in equity (in percentage) from a peak to a trough
115-
during the backtest period, calculated on a daily basis.
114+
This is the largest day-over-day percentage drop in equity,
115+
NOT the peak-to-trough drawdown (use get_max_drawdown for that).
116116
117117
Args:
118118
snapshots (List[PortfolioSnapshot]): List of portfolio snapshots
119119
120120
Returns:
121-
float: The maximum daily drawdown as a negative percentage (e.g., -5.0 for a 5% drawdown).
121+
float: The maximum single-day drawdown as a positive percentage
122+
(e.g., 0.05 for a 5% single-day decline).
122123
"""
123124
# Create DataFrame from snapshots
124125
data = [(s.created_at, s.total_value) for s in snapshots]
@@ -136,54 +137,58 @@ def get_max_daily_drawdown(snapshots: List[PortfolioSnapshot]) -> float:
136137
# Filter out non-positive values
137138
positive_values = daily_df[daily_df['total_value'] > 0]['total_value']
138139

139-
if positive_values.empty:
140+
if positive_values.empty or len(positive_values) < 2:
140141
return 0.0
141142

142-
peak = positive_values.iloc[0]
143-
max_daily_drawdown_pct = 0.0
143+
# Compute day-over-day returns; the worst single-day decline
144+
# is the most negative return (ignore positive returns)
145+
daily_returns = positive_values.pct_change().dropna()
146+
negative_returns = daily_returns[daily_returns < 0]
144147

145-
for equity in positive_values:
146-
if equity > peak:
147-
peak = equity
148-
149-
# Avoid division by zero (shouldn't happen but extra safety)
150-
if peak <= 0:
151-
continue
152-
153-
drawdown_pct = (equity - peak) / peak
154-
max_daily_drawdown_pct = min(max_daily_drawdown_pct, drawdown_pct)
148+
if negative_returns.empty:
149+
return 0.0
155150

156-
return abs(max_daily_drawdown_pct) # Return as positive percentage
151+
return abs(negative_returns.min())
157152

158153
def get_max_drawdown_duration(snapshots: List[PortfolioSnapshot]) -> int:
159154
"""
160155
Calculate the maximum duration of drawdown in days.
161156
162-
This is the longest period where the portfolio equity was below its peak.
157+
This is the longest period (in calendar days) where the portfolio
158+
equity was below its peak.
163159
164160
Args:
165161
snapshots (List[PortfolioSnapshot]): List of portfolio snapshots
166162
167163
Returns:
168-
int: The maximum drawdown duration in days.
164+
int: The maximum drawdown duration in calendar days.
169165
"""
170166
equity_curve = get_equity_curve(snapshots)
171167
if not equity_curve:
172168
return 0
173169

174170
peak = equity_curve[0][0]
175171
max_duration = 0
176-
current_duration = 0
172+
drawdown_start = None
177173

178-
for equity, _ in equity_curve:
174+
for equity, timestamp in equity_curve:
179175
if equity < peak:
180-
current_duration += 1
176+
# Entering or continuing a drawdown
177+
if drawdown_start is None:
178+
drawdown_start = timestamp
181179
else:
182-
max_duration = max(max_duration, current_duration)
183-
current_duration = 0
184-
peak = equity # Reset peak to current equity
180+
# Recovered to or above the peak
181+
if drawdown_start is not None:
182+
elapsed = (timestamp - drawdown_start).days
183+
max_duration = max(max_duration, elapsed)
184+
drawdown_start = None
185+
peak = equity
185186

186-
max_duration = max(max_duration, current_duration) # Final check
187+
# If still in drawdown at the end of the series
188+
if drawdown_start is not None and len(equity_curve) > 0:
189+
last_timestamp = equity_curve[-1][1]
190+
elapsed = (last_timestamp - drawdown_start).days
191+
max_duration = max(max_duration, elapsed)
187192

188193
return max_duration
189194

investing_algorithm_framework/services/metrics/equity_curve.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ def get_equity_curve(
2121
total_size = snapshot.total_value
2222
series.append((total_size, timestamp))
2323

24+
# Sort by timestamp to ensure chronological order
25+
series.sort(key=lambda x: x[1])
26+
2427
return series

0 commit comments

Comments
 (0)