The loss you actually feel
Volatility treats up and down alike, but an investor lives with a different number: how far the account is below the best value it ever had. That is the drawdown. If a portfolio rose to 110 and now stands at 98, it is 10.9% below its peak, whatever the day-to-day volatility says. The maximum drawdown is the deepest such hole in the history, and the time underwater is how long it took to climb out. Clients quit and risk managers cut limits at drawdowns, not at standard deviations.
import pandas as pd
prices = pd.Series(
[100, 105, 103, 98, 104, 110, 108, 107, 111, 95], dtype=float,
index=pd.bdate_range("2026-03-02", periods=10),
)
peak = prices.cummax() # the highest price so far, day by day
drawdown = prices / peak - 1 # 0 on a new high, negative below it
print(peak.tolist()) # [100.0, 105.0, 105.0, 105.0, 105.0, 110.0, 110.0, 110.0, 111.0, 111.0]
print(drawdown.round(4).tolist()) # [0.0, 0.0, -0.019, -0.0667, -0.0095, 0.0, -0.0182, -0.0273, 0.0, -0.1441]The deepest hole
cummax() gives the running peak in one call. Dividing the price by it and subtracting 1 gives the drawdown on every day, always zero or negative. The maximum drawdown is simply the minimum of that series, and idxmin() tells you when it happened. Drawdowns are asymmetric in a way that matters: a 20% loss needs a 25% gain to get back, 1 / (1 - 0.20) - 1, and a 50% loss needs 100%. This is why risk managers fight to keep drawdowns shallow.
print(round(drawdown.min(), 4), drawdown.idxmin().date()) # -0.1441 2026-03-13 111 down to 95
# The climb back is steeper than the fall
for loss in (0.10, 0.20, 0.50):
print(loss, round(1 / (1 - loss) - 1, 3))
# 0.1 0.111
# 0.2 0.25
# 0.5 1.0Time underwater
Depth is half the story; duration is the other half. A strategy can lose only 8% and still spend three years below its old high, which tests any investor's patience. A day is underwater while its drawdown is negative, and a new high ends the stretch. To find the longest stretch with pandas, mark the underwater days with a comparison, label each run of equal values with (flag != flag.shift()).cumsum(), then count within each run. A plain loop that counts and resets works just as well and is easy to read.
underwater = drawdown < 0 # True while below the running peak
run_id = (underwater != underwater.shift()).cumsum() # a new id each time the flag changes
streak = underwater.astype(int).groupby(run_id).cumsum() # 1, 2, 3, ... inside each underwater run
print(streak.tolist()) # [0, 0, 1, 2, 3, 0, 1, 2, 0, 1]
print(int(streak.max())) # 3 the longest wait for a new high, in days# Return divided by the depth of the worst hole: the Calmar ratio
annual_return = 0.12
print(round(annual_return / abs(drawdown.min()), 2)) # 0.83peak = prices.cummax()anddrawdown = prices / peak - 1: zero on a new high, negative below it.- The maximum drawdown is
drawdown.min(); a loss ofxneeds a gain of1 / (1 - x) - 1to recover. - Time underwater is the longest run of days with a negative drawdown; a backtest's worst drawdown is a lower bound, not a guarantee.
Next up: random number generators and seeds, the foundation of every simulation.
Lab · what's graded
- Track the highest price so far with
running_peak25 pts - Measure the distance from the peak with
drawdown_series25 pts - Find the worst fall with
max_drawdown25 pts - Count the longest wait for a new high with
longest_underwater25 pts