Coverage for src / monte_neo / metrics / drawdown.py: 66%
67 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-28 16:27 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-28 16:27 +0200
1"""Drawdown metrics.
3Calculates maximum drawdown and related metrics.
4"""
6from __future__ import annotations
8import numpy as np
10from monte_neo.utils.logger import get_logger
12logger = get_logger(__name__)
15class DrawdownMetric:
16 """Drawdown calculator."""
18 def calculate_max(self, equity: list[float] | np.ndarray) -> float:
19 """Calculate maximum drawdown.
21 Max Drawdown = (Peak - Trough) / Peak
23 Args:
24 equity: Equity curve values.
26 Returns:
27 Maximum drawdown as decimal (e.g., 0.20 = 20%).
28 """
29 if not len(equity):
30 return 0.0
32 equity = np.array(equity)
34 # Running maximum
35 running_max = np.maximum.accumulate(equity)
37 # Drawdown at each point
38 drawdowns = (running_max - equity) / running_max
40 return float(np.max(drawdowns))
42 def calculate_avg(self, equity: list[float] | np.ndarray) -> float:
43 """Calculate average drawdown.
45 Args:
46 equity: Equity curve values.
48 Returns:
49 Average drawdown as decimal.
50 """
51 if not len(equity):
52 return 0.0
54 equity = np.array(equity)
55 running_max = np.maximum.accumulate(equity)
56 drawdowns = (running_max - equity) / running_max
58 # Only count non-zero drawdowns
59 dd_values = drawdowns[drawdowns > 0]
61 if len(dd_values) == 0:
62 return 0.0
64 return float(np.mean(dd_values))
66 def calculate_duration(self, equity: list[float] | np.ndarray) -> int:
67 """Calculate maximum drawdown duration.
69 Args:
70 equity: Equity curve values.
72 Returns:
73 Maximum number of periods in drawdown.
74 """
75 if not len(equity):
76 return 0
78 equity = np.array(equity)
79 running_max = np.maximum.accumulate(equity)
81 # Find where we're in drawdown
82 in_drawdown = equity < running_max
84 # Count consecutive drawdown periods
85 max_duration = 0
86 current_duration = 0
88 for is_dd in in_drawdown:
89 if is_dd:
90 current_duration += 1
91 max_duration = max(max_duration, current_duration)
92 else:
93 current_duration = 0
95 return max_duration
97 def get_drawdown_curve(
98 self,
99 equity: list[float] | np.ndarray,
100 ) -> np.ndarray:
101 """Get the full drawdown curve.
103 Args:
104 equity: Equity curve values.
106 Returns:
107 Array of drawdown values at each point.
108 """
109 if not len(equity):
110 return np.array([])
112 equity = np.array(equity)
113 running_max = np.maximum.accumulate(equity)
114 drawdowns = (running_max - equity) / running_max
116 return drawdowns
118 def get_underwater_curve(
119 self,
120 equity: list[float] | np.ndarray,
121 ) -> np.ndarray:
122 """Get underwater curve (negative drawdowns).
124 Args:
125 equity: Equity curve values.
127 Returns:
128 Array of negative drawdown values.
129 """
130 return -self.get_drawdown_curve(equity)
132 def analyze_drawdowns(
133 self,
134 equity: list[float] | np.ndarray,
135 n_worst: int = 5,
136 ) -> list[dict]:
137 """Analyze worst drawdown periods.
139 Args:
140 equity: Equity curve values.
141 n_worst: Number of worst drawdowns to return.
143 Returns:
144 List of drawdown info dictionaries.
145 """
146 if not len(equity):
147 return []
149 equity = np.array(equity)
150 running_max = np.maximum.accumulate(equity)
151 drawdowns = (running_max - equity) / running_max
153 # Find drawdown periods
154 periods = []
155 in_dd = False
156 start_idx = 0
157 peak_val = 0
159 for i, (dd, eq, peak) in enumerate(zip(drawdowns, equity, running_max)):
160 if dd > 0 and not in_dd:
161 # Start of drawdown
162 in_dd = True
163 start_idx = i
164 peak_val = peak
165 elif dd == 0 and in_dd:
166 # End of drawdown
167 in_dd = False
168 periods.append(
169 {
170 "start_idx": start_idx,
171 "end_idx": i,
172 "duration": i - start_idx,
173 "max_drawdown": float(np.max(drawdowns[start_idx:i])),
174 "peak_value": float(peak_val),
175 "trough_value": float(np.min(equity[start_idx:i])),
176 }
177 )
179 # Handle ongoing drawdown
180 if in_dd:
181 periods.append(
182 {
183 "start_idx": start_idx,
184 "end_idx": len(equity) - 1,
185 "duration": len(equity) - start_idx,
186 "max_drawdown": float(np.max(drawdowns[start_idx:])),
187 "peak_value": float(peak_val),
188 "trough_value": float(np.min(equity[start_idx:])),
189 }
190 )
192 # Sort by max_drawdown and return worst N
193 periods.sort(key=lambda x: x["max_drawdown"], reverse=True)
194 return periods[:n_worst]