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.