Skip to content
← All articles

pairwise and accumulate: the two loops you stop writing

for i in range(len(xs) - 1) has an off-by-one and an empty-list crash. pairwise has neither, works on iterators, and its tuple is arity-checked — unlike batched's.

Two functions, two loop shapes that stop appearing in your code.

pairwise: consecutive pairs

for earlier, later in pairwise(prices):
    delta = later - earlier

The version it replaces:

for i in range(len(prices) - 1):
    delta = prices[i + 1] - prices[i]

which has three problems. It needs len() and indexing, so it does not work on an iterator. Its bounds are an off-by-one waiting to be edited wrong. And on an empty list len(prices) - 1 is -1, so range(-1) is empty and it happens to work — but the closely-related range(len(prices)) version, which people write just as often, indexes past the end.

pairwise([]) and pairwise([x]) both yield nothing. No branch needed.

accumulate: running totals, running anything

from itertools import accumulate

accumulate([1, 2, 3, 4])            # 1, 3, 6, 10   (running sum)
accumulate(prices, max)             # running maximum
accumulate(rates, lambda a, b: a * b)   # running product

The second argument is any binary function, so “running maximum” — the peak-to-date of a price series — is one call rather than a loop with a mutable accumulator. The output has the same length as the input, and its first element is the first input element unchanged.

Unless you pass initial:

accumulate([1, 2, 3], initial=0)    # 0, 1, 3, 6   -- one longer

initial prepends the seed, so the result is n + 1 items. That is the common cause of a mysterious off-by-one when someone adds initial=0 to make the empty case work and does not notice the length change.

💡zip(prices, accumulate(prices, max)) pairs each price with the click to reveal

running maximum. Why is it safe to iterate prices twice here, and when would it not be? It is safe because prices is a Sequence — a list can be iterated any number of times, and zip pulls from two independent iterators over the same list.

It breaks the moment prices is a generator or a file handle. accumulate and zip would then share one exhausted iterator: accumulate consumes the first item, zip asks for the “first” price and gets the second, and you get a silently misaligned pairing rather than an error.

If the source is genuinely one-shot, either materialise it (prices = list(source) — an honest decision to hold it in memory) or fold the running maximum into a single pass yourself. What you must not do is annotate the parameter Iterable[float] while iterating it twice: the annotation promises something the body violates, and no checker will catch it.

The typing contrast worth memorising

reveal_type(pairwise([1, 2, 3]))     # pairwise[tuple[int, int]]
reveal_type(batched([1, 2, 3], 2))   # batched[tuple[int, ...]]

pairwise yields a fixed-length tuple, so for a, b in pairwise(xs) is arity-checked and for a, b, c in pairwise(xs) is a type error. batched yields a variadic tuple, so for a, b in batched(xs, 2) type-checks and raises at runtime on a short final batch.

Two functions that look alike in a code review and offer opposite guarantees. The only way to know is to have read the signature — which is the general lesson about typed stdlib code: the annotations carry information the names do not.

💡Max drawdown is "the largest drop from a running peak". Why can click to reveal

that not be computed as max(prices) - min(prices), and why does the accumulate version need only one pass? Because the minimum must come after the maximum. On [10, 100, 50, 200], max - min gives 190, but the largest peak-to-trough fall was 100 to 50, a drop of 50. The naive formula reports a drawdown that never happened, and it is wrong in the optimistic direction — it will look like a bigger risk than reality on some series and a smaller one on others.

The correct definition is $\max_i (1 - p_i / \text{peak}_i)$ where $\text{peak}_i = \max(p_0 \ldots p_i)$. The nested-loop reading of that is $O(n^2)$: for each point, scan backwards for the peak. But the peak sequence is itself a running maximum, which accumulate(prices, max) produces in one pass — so zipping prices against their running peaks turns the whole thing into a single linear scan with no explicit state.