Skip to content
← Back to explainers

Python explainer

Adjacent Pairs and Running Totals with pairwise and accumulate

About 4 min to read. Allow extra time to work through examples.

Compute day-to-day changes with pairwise and running totals or peaks with accumulate, and know when looping over a source twice is safe.

This article uses Python. You can make it your practice mode to browse related content across the site.

You have a list of daily prices. You want each day's change from the day before, and the highest price so far on each day. The first compares neighbours. The second compares each price with everything before it. itertools has a tool for each.

from itertools import accumulate, pairwise

prices = [10.0, 100.0, 50.0, 200.0]
changes = [later - earlier for earlier, later in pairwise(prices)]
peaks = list(accumulate(prices, max))
assert changes == [90.0, -50.0, 150.0]
assert peaks == [10.0, 100.0, 100.0, 200.0]

Compare neighbours with pairwise

pairwise(items) yields each item together with the next one. For [a, b, c] it yields (a, b) and then (b, c). An input with fewer than two items yields nothing.

from itertools import pairwise

assert list(pairwise([1, 2, 3])) == [(1, 2), (2, 3)]
assert list(pairwise([])) == []
assert list(pairwise([42.0])) == []

For a list, indexing over range(len(prices) - 1) gives the same pairs. pairwise needs no index arithmetic, and it also works on a one-shot iterator, which has no length and no indexing.

Keep a running value with accumulate

from itertools import accumulate
from operator import mul

assert list(accumulate([1, 2, 3, 4])) == [1, 3, 6, 10]
assert list(accumulate([10, 100, 50, 200], max)) == [10, 100, 100, 200]
assert list(accumulate([2, 3, 4], mul)) == [2, 6, 24]
assert list(accumulate([1, 2, 3], initial=0)) == [0, 1, 3, 6]

accumulate(items, func) yields a running result. The first result is the first item. Each later result is func(previous_result, next_item). With no func, it adds, so you get running totals. With max, you get running peaks. An empty input yields nothing.

With initial=value, accumulate yields that value first and starts the calculation from it. The output is then one item longer than the input, and even an empty input yields the initial value. Passing initial=None is the same as leaving it out.

Pair each price with its peak

from itertools import accumulate

prices = [10.0, 100.0, 50.0, 200.0]
paired = list(zip(prices, accumulate(prices, max)))
assert paired == [(10.0, 10.0), (100.0, 100.0), (50.0, 100.0), (200.0, 200.0)]

This code loops over prices twice at the same time, once in zip and once inside accumulate. That works because each loop over a list gets its own iterator. Do not change the list while the loops run.

Check your understanding What changes if both arguments read from the same one-shot iterator? Show answer

They take turns pulling items from it. zip asks for its left item first, then asks accumulate for its next result, which pulls the next item. With one shared iterator over [10, 20, 30, 40], the pairs become (10, 20) and (30, 40). Neither pair matches a price with its peak.

For a one-shot source, keep the peak in a single loop. Or turn the source into a list first, and then loop over the list twice. An Iterable annotation does not promise that two loops over the argument see the same items.

Unpack only fixed-size tuples

pairwise yields tuples of exactly two items, and its type says so. mypy rejects for a, b, c in pairwise(values). The batched function from the previous article yields tuples whose length the type leaves open, because the last batch can be short. So mypy accepts for a, b in batched(values, 2), and the code raises ValueError at runtime when the last batch has one item.

Measure the fall from a peak

The drawdown at a point is how far the price has fallen from its running peak, as a fraction of that peak. It equals 1 - price / peak. For [10, 100, 50, 200], the running peaks are [10, 100, 100, 200] and the drawdowns are [0, 0, 0.5, 0]. The largest one, the maximum drawdown, is 0.5. The exercise skips points whose peak is zero or negative.

Check your understanding Why doesn't max(prices) - min(prices) give the maximum drawdown? Show answer

That expression gives the full range of the prices as an amount, not a fraction, and it ignores their order. In the example the range is 190, but the largest fall is from 100 to 50. That is a drop of 50, or 0.5 as a fraction.

Taking the max of every prefix again at each point takes quadratic time. Updating a running peak costs O(1) per item, so the whole drawdown takes linear time. The exercise also computes the changes and a length in separate passes, and several linear passes are still linear in total. That is why the exercise takes a list, which you can loop over more than once, and not a one-shot input.

step 20 of 55

Everyday Standard Library Tools

Up next Find the Largest Drop from a Running Peak