Skip to content

← Stdlib Mastery step 17 of 55

Easy Primitives

accumulate and pairwise: max drawdown in one pass

Compute the maximum drawdown of a price series in a single pass, plus the consecutive deltas.

def max_drawdown(prices: Sequence[float]) -> float: ...
def deltas(prices: Sequence[float]) -> list[float]: ...
def solve(prices: list[float]) -> dict[str, object]: ...

Max drawdown is the largest fractional fall from a running peak:

$$\text{drawdown} = \max_i \left(1 - \frac{p_i}{\text{peak}_i}\right), \qquad \text{peak}_i = \max(p_0 \ldots p_i)$$

Empty and single-element series give 0.0, as does any monotonically non-decreasing series. Skip any point whose running peak is not positive.

Note that this is not max(prices) - min(prices): the trough has to come after the peak. On [10, 100, 50, 200] the largest fall is 100 to 50, and the naive formula reports a drawdown that never happened.

deltas returns the consecutive differences via itertools.pairwise[] for fewer than two prices, no special case required.

solve returns:

{"max_drawdown": float, "deltas": list[float], "seeded_length": int}

where seeded_length is len(list(accumulate(prices, max, initial=0.0))). That last one exists to make you notice the off-by-one: initial prepends the seed, so the output is one longer than the input. It is the standard cause of a mysterious length change when someone adds initial=0 to make the empty case work.

The production consequence. The index-arithmetic reading of the drawdown definition — for each point, scan backwards for the peak — is $O(n^2)$. It is fine on a fixture of ten prices and unusable on a year of ticks. The peak sequence is itself a running maximum, which accumulate(prices, max) produces in one pass, so zipping prices against their peaks makes the whole thing linear with no explicit mutable state.

Your submission must pass mypy --strict, and max(..., default=0.0) is what keeps the empty case from raising ValueError.

Loading visualization…