Module 3 · Lists & Loops · Lesson 3.3
Running max
Carry state through a loop with the running-maximum pattern, then use it to find a price series' maximum drawdown in one pass.
Hook
The worst ride along the way
Your fund returned 40% over five years. Investors are pleased, then they ask a harder question: what was the worst peak-to-trough loss along the way? A fund that fell by half before recovering is a very different ride from one that never fell more than a tenth. This lesson finds that number with a single loop.
Concept
Carrying state through a loop
A for loop looks at one item at a time. To answer a question about the whole list, you keep a variable outside the loop and update it inside it. That variable carries what you have learned so far from one step to the next.
The most useful example is the running maximum: the highest value seen so far.
prices = [100, 104, 101, 108, 103]
peak = prices[0]
for price in prices:
if price > peak:
peak = price
print(price, peak)peak starts at the first price. On each step it either stays the same or moves up to a new high, and it never moves down. When the loop ends, peak holds the highest price in the list.
Worked example
Recording the peak every day
A risk chart needs the running peak for every day, not only at the end. Add each day’s peak to a new list:
prices = [100, 104, 101, 108, 103]
peaks = []
peak = prices[0]
for price in prices:
if price > peak:
peak = price
peaks.append(peak)
print(peaks)It prints [100, 104, 104, 108, 108]. On day three the price dips to 101, but the peak stays at 104: the dip is a drawdown from that peak. The problem below measures exactly those dips.
Check
Predict the output
Problem
Maximum drawdown
max_drawdown(prices) -> float
A drawdown is how far a price has fallen from its highest point so far, as a fraction of that high. If LABX climbs to 120 and then falls to 90, the drawdown at 90 is (120 − 90) / 120 = 0.25, a 25% fall.
The maximum drawdown is the largest drawdown over the whole series: the worst peak-to-trough loss an investor would have lived through.
Fill in max_drawdown(prices). It receives a list of daily closing prices, oldest first, and returns the maximum drawdown as a fraction between 0 and 1.
| prices | returns |
|---|---|
[100, 120, 90, 130] |
0.25 |
[100, 90, 80] |
0.2 |
A longer example, a month of LABX, with the running peak and the drawdown shaded:
Shaded: the drawdown, the fall from the running peak.
Show the data
| Day | Close |
|---|---|
| 1 | 100 |
| 2 | 104.77 |
| 3 | 103.37 |
| 4 | 104.16 |
| 5 | 104.45 |
| 6 | 106.19 |
| 7 | 103.23 |
| 8 | 102.36 |
| 9 | 100.81 |
| 10 | 98.65 |
| 11 | 96.98 |
| 12 | 95.97 |
| 13 | 95.4 |
| 14 | 93.67 |
| 15 | 94.44 |
| 16 | 93.4 |
| 17 | 87.59 |
| 18 | 89.68 |
| 19 | 88.97 |
| 20 | 87.64 |
| 21 | 88.09 |
| 22 | 88.48 |
Constraints: prices holds between 1 and 100,000 positive numbers. Your function should look at each price once: the tests time it on long series.
Hints
Stuck? Open one hint at a time
Hint 1 · Nudge
What do you need to remember as you walk through the prices, one at a time?
Hint 2 · Approach
Keep two numbers as you go: the highest price seen so far, and the worst drawdown seen so far. Each new price can raise the peak, or it can set a new worst drawdown against the current peak.
Hint 3 · Pseudo-code
peak = first price
worst = 0
for each price:
if price is above peak: peak = price
drawdown = (peak - price) / peak
if drawdown is above worst: worst = drawdown
return worstSolution
How to solve it
Run your code at least once to unlock the solution.
Reference solution
def max_drawdown(prices):
peak = prices[0]
worst = 0.0
for price in prices:
if price > peak:
peak = price
drawdown = (peak - price) / peak
if drawdown > worst:
worst = drawdown
return worstThe solution carries two pieces of state from one step of the loop to the next. peak is the highest price seen so far, and worst is the largest drawdown seen so far.
For each price, the loop first updates the peak. A price above the old peak becomes the new peak, and its drawdown is zero. Then the loop measures how far this price sits below the peak, and keeps that fall if it is the worst yet.
The order matters. Updating the peak first means a new high never counts as a fall.
Complexity: one pass over the prices, with a constant amount of work per price: O(n) time. Only two numbers are stored, however long the list: O(1) extra space.
A slower alternative. You might instead look back over every earlier price, for each day:
def max_drawdown(prices):
worst = 0.0
for i in range(len(prices)):
peak = max(prices[: i + 1])
worst = max(worst, (peak - prices[i]) / peak)
return worstIt gives the right answers, but max(prices[: i + 1]) rereads the whole history every day. That is about n²/2 steps, O(n²), and it fails the performance test. Try submitting it, then open the Scaling tab to see how its time grows.
How the desk does it. Risk reports quote the maximum drawdown of a strategy’s value over time, not just of one stock. In NumPy, which you meet in lesson 17.1, the whole calculation is one line:
Preview · you'll learn this in lesson 17.1
import numpy as np
def max_drawdown(prices):
prices = np.asarray(prices, dtype=float)
return float((1 - prices / np.maximum.accumulate(prices)).max())np.maximum.accumulate computes every running peak at once. The idea is exactly your loop’s; NumPy only runs it in fast compiled code.
Recap
Three things to keep
- A variable set before a loop and updated inside it carries state from one step to the next.
- The running maximum is the highest value seen so far; update it before you use it.
- Maximum drawdown needs one pass and two variables: O(n) time, O(1) extra space.