Skip to content

Commit 2b3092d

Browse files
author
Tyler Patterson
committed
drawdown details
1 parent e745bde commit 2b3092d

3 files changed

Lines changed: 307 additions & 0 deletions

File tree

quantalytics/analytics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
consecutive_losses,
6464
consecutive_wins,
6565
distribution,
66+
drawdown_details,
6667
expected_return,
6768
exposure,
6869
geometric_mean,
@@ -82,6 +83,7 @@
8283
"compsum",
8384
"comp",
8485
"distribution",
86+
"drawdown_details",
8587
"expected_return",
8688
"geometric_mean",
8789
"ghpr",

quantalytics/analytics/stats.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from warnings import warn
66

77
import numpy as _np
8+
import pandas as _pd
89
from numpy._core.fromnumeric import prod
910
from pandas.core.frame import DataFrame
1011
from pandas.core.indexes.datetimes import DatetimeIndex
@@ -566,3 +567,162 @@ def _calculate_years(
566567
raise ValueError(
567568
"Cannot determine time period. Either provide periods or ensure returns has a DatetimeIndex"
568569
)
570+
571+
572+
@overload
573+
def drawdown_details(drawdown: Series) -> DataFrame: ...
574+
@overload
575+
def drawdown_details(drawdown: DataFrame) -> DataFrame: ...
576+
def drawdown_details(drawdown: Series | DataFrame) -> DataFrame:
577+
"""
578+
Calculate detailed statistics for each individual drawdown period.
579+
580+
Analyzes a drawdown series to identify and characterize each distinct drawdown period,
581+
providing comprehensive statistics including start/end dates, duration, valley (maximum
582+
drawdown point), maximum drawdown percentage, and 99th percentile drawdown (excluding outliers).
583+
584+
Parameters
585+
----------
586+
drawdown : Series or DataFrame
587+
Drawdown series (typically output from `to_drawdown_series`).
588+
Values should be <= 0, where 0 indicates no drawdown and negative values
589+
indicate the depth of drawdown from the running maximum.
590+
If DataFrame, processes each column independently and concatenates results.
591+
592+
Returns
593+
-------
594+
DataFrame
595+
For Series input: DataFrame with one row per drawdown period and columns:
596+
- start: Timestamp when drawdown period began
597+
- valley: Timestamp of maximum drawdown point
598+
- end: Timestamp when drawdown period ended (recovered to 0)
599+
- days: Duration of drawdown period in days
600+
- max drawdown: Maximum drawdown percentage (as positive number, e.g., 15.2 for -15.2%)
601+
- 99% max drawdown: 99th percentile drawdown excluding outliers
602+
603+
For DataFrame input: Multi-level column DataFrame where first level is original
604+
column names and second level contains the statistics above.
605+
606+
Examples
607+
--------
608+
>>> from quantalytics.analytics import to_drawdown_series, drawdown_details
609+
>>> import pandas as pd
610+
>>> returns = pd.Series([0.01, -0.02, -0.01, 0.03, 0.02, -0.05, 0.01])
611+
>>> dd = to_drawdown_series(returns)
612+
>>> details = drawdown_details(dd)
613+
>>> print(details.columns)
614+
Index(['start', 'valley', 'end', 'days', 'max drawdown', '99% max drawdown'], dtype='object')
615+
616+
Notes
617+
-----
618+
- A drawdown period begins when drawdown becomes non-zero and ends when it returns to zero
619+
- If the series starts in a drawdown, the first period's start is set to the series start
620+
- If the series ends in a drawdown, the last period's end is set to the series end
621+
- The 99% max drawdown uses `remove_outliers` to exclude extreme values, providing
622+
a more robust measure of typical drawdown severity
623+
- All drawdown percentages are returned as positive values for easier interpretation
624+
625+
See Also
626+
--------
627+
max_drawdown : Calculate maximum drawdown from returns
628+
to_drawdown_series : Convert returns to drawdown series (in metrics module)
629+
"""
630+
631+
def _drawdown_details(drawdown_series: Series) -> DataFrame:
632+
"""Calculate drawdown details for a single drawdown series."""
633+
# Mark periods with no drawdown (drawdown = 0)
634+
no_dd = drawdown_series == 0
635+
636+
# Extract drawdown start dates (transition from 0 to non-zero)
637+
starts = ~no_dd & no_dd.shift(1)
638+
starts = list(starts[starts.values].index)
639+
640+
# Extract drawdown end dates (transition from non-zero to 0)
641+
ends = no_dd & (~no_dd).shift(1)
642+
ends = list(ends[ends.values].index)
643+
644+
# Handle edge cases: series starting or ending in drawdown
645+
if ends and (not starts or starts[0] > ends[0]):
646+
# Series starts in drawdown
647+
starts.insert(0, drawdown_series.index[0])
648+
if not ends or (starts and starts[-1] > ends[-1]):
649+
# Series ends in drawdown
650+
ends.append(drawdown_series.index[-1])
651+
652+
# Return empty DataFrame if no drawdowns found
653+
if not starts:
654+
return _pd.DataFrame(
655+
index=[],
656+
columns=(
657+
"start",
658+
"valley",
659+
"end",
660+
"days",
661+
"max drawdown",
662+
"99% max drawdown",
663+
),
664+
)
665+
666+
# Build detailed statistics for each drawdown period
667+
data = []
668+
for i in range(len(starts)):
669+
# Check if this drawdown has recovered (ends[i] has drawdown == 0)
670+
# or if series ends in drawdown (ends[i] is last index)
671+
if drawdown_series[ends[i]] == 0:
672+
# Drawdown recovered: exclude the recovery day (ends[i])
673+
last_dd_day = ends[i] - _pd.Timedelta(days=1)
674+
dd_period = drawdown_series[starts[i] : last_dd_day]
675+
days_in_dd = (last_dd_day - starts[i]).days + 1
676+
else:
677+
# Series ends in drawdown: include the last day
678+
dd_period = drawdown_series[starts[i] : ends[i]]
679+
days_in_dd = (ends[i] - starts[i]).days + 1
680+
681+
# Calculate 99th percentile drawdown (excluding outliers)
682+
clean_dd = -remove_outliers(-dd_period, 0.99)
683+
684+
# Collect statistics
685+
data.append(
686+
(
687+
starts[i],
688+
dd_period.idxmin(), # valley = point of max drawdown
689+
ends[i], # end = recovery date or last date
690+
days_in_dd,
691+
dd_period.min() * 100, # Convert to percentage (as negative)
692+
clean_dd.min() * 100, # 99% drawdown as percentage
693+
)
694+
)
695+
696+
# Create DataFrame with results
697+
df = _pd.DataFrame(
698+
data=data,
699+
columns=(
700+
"start",
701+
"valley",
702+
"end",
703+
"days",
704+
"max drawdown",
705+
"99% max drawdown",
706+
),
707+
)
708+
709+
# Format date columns as date strings (without time) for better display
710+
for col in ["start", "valley", "end"]:
711+
df[col] = df[col].apply(
712+
lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)
713+
)
714+
715+
# Convert drawdown percentages to positive values (easier interpretation)
716+
df["max drawdown"] = -df["max drawdown"]
717+
df["99% max drawdown"] = -df["99% max drawdown"]
718+
719+
return df
720+
721+
# Handle DataFrame input by processing each column
722+
if isinstance(drawdown, DataFrame):
723+
dfs = {}
724+
for col in drawdown.columns:
725+
dfs[col] = _drawdown_details(drawdown[col])
726+
return _pd.concat(dfs, axis=1)
727+
728+
return _drawdown_details(drawdown)

tests/test_stats_module.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,148 @@ def test_cagr_dataframe_and_edge_cases(sample_returns):
241241
empty = pd.Series([], dtype=float)
242242
assert math.isnan(stats.cagr(empty, periods=1))
243243
assert math.isnan(stats.cagr(pd.Series([-1.1, 0.05]), periods=2))
244+
245+
246+
def test_drawdown_details_basic():
247+
"""Test basic drawdown_details functionality with simple drawdown pattern."""
248+
dates = pd.date_range("2024-01-01", periods=10, freq="D")
249+
# Create a simple drawdown: 0, -0.05, -0.10, -0.08, 0, 0, -0.03, -0.05, -0.02, 0
250+
drawdown = pd.Series(
251+
[0.0, -0.05, -0.10, -0.08, 0.0, 0.0, -0.03, -0.05, -0.02, 0.0], index=dates
252+
)
253+
254+
result = stats.drawdown_details(drawdown)
255+
256+
# Should identify 2 drawdown periods
257+
assert len(result) == 2
258+
assert list(result.columns) == [
259+
"start",
260+
"valley",
261+
"end",
262+
"days",
263+
"max drawdown",
264+
"99% max drawdown",
265+
]
266+
267+
# First drawdown: index 1-3 (Jan 2-4), recovers on index 4 (Jan 5)
268+
assert result.iloc[0]["days"] == 3
269+
assert result.iloc[0]["max drawdown"] == pytest.approx(10.0) # 10% drawdown
270+
271+
# Second drawdown: index 6-8 (Jan 7-9), recovers on index 9 (Jan 10)
272+
assert result.iloc[1]["days"] == 3
273+
assert result.iloc[1]["max drawdown"] == pytest.approx(5.0) # 5% drawdown
274+
275+
276+
def test_drawdown_details_no_drawdowns():
277+
"""Test drawdown_details with no drawdowns (all zeros)."""
278+
dates = pd.date_range("2024-01-01", periods=5, freq="D")
279+
drawdown = pd.Series([0.0, 0.0, 0.0, 0.0, 0.0], index=dates)
280+
281+
result = stats.drawdown_details(drawdown)
282+
283+
assert len(result) == 0
284+
assert list(result.columns) == [
285+
"start",
286+
"valley",
287+
"end",
288+
"days",
289+
"max drawdown",
290+
"99% max drawdown",
291+
]
292+
293+
294+
def test_drawdown_details_starts_in_drawdown():
295+
"""Test drawdown_details when series starts in drawdown."""
296+
dates = pd.date_range("2024-01-01", periods=5, freq="D")
297+
# Starts at -0.05 (in drawdown), gets worse, then recovers
298+
drawdown = pd.Series([-0.05, -0.08, -0.10, -0.05, 0.0], index=dates)
299+
300+
result = stats.drawdown_details(drawdown)
301+
302+
assert len(result) == 1
303+
# Should use first date as start
304+
assert result.iloc[0]["start"] == dates[0].strftime("%Y-%m-%d")
305+
# Days in drawdown: Jan 1-4 (4 days), recovers on Jan 5
306+
assert result.iloc[0]["days"] == 4
307+
assert result.iloc[0]["max drawdown"] == pytest.approx(10.0)
308+
309+
310+
def test_drawdown_details_ends_in_drawdown():
311+
"""Test drawdown_details when series ends in drawdown."""
312+
dates = pd.date_range("2024-01-01", periods=5, freq="D")
313+
# Starts at 0, enters drawdown, never recovers
314+
drawdown = pd.Series([0.0, -0.02, -0.05, -0.08, -0.10], index=dates)
315+
316+
result = stats.drawdown_details(drawdown)
317+
318+
assert len(result) == 1
319+
# Should use last date as end
320+
assert result.iloc[0]["end"] == dates[-1].strftime("%Y-%m-%d")
321+
assert result.iloc[0]["days"] == 4
322+
assert result.iloc[0]["max drawdown"] == pytest.approx(10.0)
323+
324+
325+
def test_drawdown_details_dataframe_input():
326+
"""Test drawdown_details with DataFrame input (multiple columns)."""
327+
dates = pd.date_range("2024-01-01", periods=6, freq="D")
328+
df = pd.DataFrame(
329+
{
330+
"strategy_a": [0.0, -0.05, -0.10, 0.0, -0.03, 0.0],
331+
"strategy_b": [0.0, -0.02, -0.04, -0.03, 0.0, 0.0],
332+
},
333+
index=dates,
334+
)
335+
336+
result = stats.drawdown_details(df)
337+
338+
# Should have multi-level columns
339+
assert isinstance(result.columns, pd.MultiIndex)
340+
assert "strategy_a" in result.columns.get_level_values(0)
341+
assert "strategy_b" in result.columns.get_level_values(0)
342+
343+
# Each strategy should have 2 drawdown periods
344+
assert len(result) == 2
345+
346+
347+
def test_drawdown_details_valley_identification():
348+
"""Test that valley (max drawdown point) is correctly identified."""
349+
dates = pd.date_range("2024-01-01", periods=7, freq="D")
350+
# Drawdown that gets progressively worse then recovers
351+
drawdown = pd.Series([0.0, -0.02, -0.05, -0.10, -0.08, -0.03, 0.0], index=dates)
352+
353+
result = stats.drawdown_details(drawdown)
354+
355+
assert len(result) == 1
356+
# Valley should be at index 3 (where -0.10 occurs)
357+
assert result.iloc[0]["valley"] == dates[3].strftime("%Y-%m-%d")
358+
359+
360+
def test_drawdown_details_percentages_positive():
361+
"""Test that drawdown percentages are returned as positive values."""
362+
dates = pd.date_range("2024-01-01", periods=5, freq="D")
363+
drawdown = pd.Series([0.0, -0.15, -0.20, -0.10, 0.0], index=dates)
364+
365+
result = stats.drawdown_details(drawdown)
366+
367+
# All drawdown values should be positive (easier interpretation)
368+
assert result.iloc[0]["max drawdown"] > 0
369+
assert result.iloc[0]["99% max drawdown"] > 0
370+
assert result.iloc[0]["max drawdown"] == pytest.approx(20.0) # 20%
371+
372+
373+
def test_drawdown_details_99th_percentile():
374+
"""Test that 99% max drawdown excludes outliers."""
375+
dates = pd.date_range("2024-01-01", periods=100, freq="D")
376+
# Create drawdown with mostly small values and one outlier
377+
drawdown_values = [-0.01] * 98 + [-0.50, 0.0] # One extreme outlier
378+
drawdown_values[0] = 0.0 # Start at no drawdown
379+
drawdown = pd.Series(drawdown_values, index=dates)
380+
381+
result = stats.drawdown_details(drawdown)
382+
383+
assert len(result) == 1
384+
# Max drawdown should include outlier
385+
assert result.iloc[0]["max drawdown"] == pytest.approx(50.0)
386+
# 99% max drawdown should exclude it and be much smaller
387+
assert result.iloc[0]["99% max drawdown"] < result.iloc[0]["max drawdown"]
388+
assert result.iloc[0]["99% max drawdown"] == pytest.approx(1.0, rel=0.5)

0 commit comments

Comments
 (0)