Python for Market Data, lesson 1
Arrays, not loops
numpy: doing maths on a whole column at once.
Market data is long columns of numbers, and research code does its maths on whole columns at once with numpy. Every later lesson in this module works that way. You need basic Python (lists, loops and functions) and no finance.
By the end, you can:
Part 1. Returns from a whole column
Turn an array of prices into daily returns without a loop.
A daily return is today's price over yesterday's, minus 1. With a list you'd loop: for each day, divide by the day before. numpy does every day in one line, once you line the prices up right.
Example 1.1
Five prices, four returns
p = np.array([100.0, 102.0, 101.0, 104.0, 103.0]) holds five closing prices, oldest first. Work out every daily return without a loop.
- Drop the first price
p[1:]→ [102, 101, 104, 103]Every price but the first: each day that has a day before it.
- Drop the last price
p[:-1]→ [100, 102, 101, 104]Every price but the last: the day before each of those.
- Divide, element by element
p[1:] / p[:-1]→ [1.0200, 0.9902, 1.0297, 0.9904]The two arrays have the same length, so numpy divides the first by the first, the second by the second, and so on.
- Take 1 away
- 1→ [0.0200, −0.0098, 0.0297, −0.0096]One number, taken from every element.
Answerr = p[1:] / p[:-1] - 1: four returns, +2.00%, −0.98%, +2.97% and −0.96%.
Stack the two slices and each price sits right over the one before it, so one division does every day at once. A Python loop does the same sums one by one, and pays a toll on each: fetch the number, check its type, divide, store. numpy pays that toll once per call, then loops in compiled code.
p[1:] drops the first price and p[:-1] drops the last. Stacked, each price sits over the one before it, and one division gives every return.Array, slice and vectorised
An array is numpy's block of numbers, all of one type, packed side by side. p[1:] is a slice: a stretch of the array, picked by position. Code that works on a whole array in one call is vectorised. The loop still runs, inside numpy's compiled code.
r = p[1:] / p[:-1] - 1
Two edges. n prices give n − 1 returns: the first day has no day before it. And numpy's lead grows with length. On a handful of numbers its fixed cost per call can make it no faster than a loop; on a million it's many times faster, as the timed code at the end shows.
Your turn
p = np.array([50.0, 55.0, 44.0, 44.0]). What does p[1:] / p[:-1] - 1 give?
Part 2. Masks pick and count days
Count and pick the days that meet a condition, with a mask.
Many questions are about some of the days: how many were up, what the up days added, which days fell more than 2%. A comparison on the whole array answers them, again with no loop.
Example 2.1
Up days
r holds the four returns from part 1: [0.0200, −0.0098, 0.0297, −0.0096]. How many days were up, what share of days is that, and what did the up days add up to?
- Compare every day with 0
up = r > 0→ [True, False, True, False]numpy compares each element with the single 0 and keeps the answers, one per day.
- Count the up days
up.sum()→ 2True counts as 1 and False as 0.
- Take the share
up.mean()→ 0.5The mean of True and False values is the share that are True.
- Add up only the up days
r[up].sum()→ 0.0497r[up]keeps the elements whereupis True: 0.0200 and 0.0297.
Answer2 up days out of 4, a share of 0.5, adding up to 0.0497, or 4.97%.
r > 0 works like the - 1 in part 1: numpy stretches the single number to the array's length, then goes element by element. What comes back is an array of True and False that lines up with r, day for day. That's why it can count the days, and pick them out.
Broadcasting and mask
Stretching a single number across a whole array is broadcasting. The array of True and False from a comparison is a mask. r[mask] keeps the elements where the mask is True, and mask.mean() is the share that are True.
Two conditions need & for and, | for or, and brackets round each one: r[(r > 0) & (r < 0.025)]. Python's own and raises a ValueError on arrays, and without the brackets Python reads the line in the wrong order and fails.
Also watch the flat days. r > 0 leaves them out and r >= 0 counts them, so say which you mean.
Your turn
r = np.array([0.02, -0.01, 0.03, 0.00]). What does (r >= 0).mean() return?
r >= 0 is [True, False, True, True]. Three of the four are True, and the mean of True and False is the share that are True: 0.75. (r >= 0).sum() would give the count, 3.Part 3. Days down, stocks across
Work on many stocks at once, and choose the axis to squash.
Research uses many stocks at once. Put days down the rows and stocks across the columns, and every line so far works on all of them: P[1:] / P[:-1] - 1 gives every stock's returns. Then you usually want one number per stock, or one per day.
Example 3.1
Averages two ways
R holds four days of returns, in %, for GULL, KITE and WREN, one column each: GULL 1, 3, 2, 2; KITE −2, 0, 5, −3; WREN 4, −6, 2, 4. Find each stock's average return and each day's average.
- Read the shape
R.shape→ (4, 3)Four rows of days, three columns of stocks. Five days of prices would give these four rows of returns.
- Squash the days
R.mean(axis=0)→ [2, 0, 1]axis=0runs down the rows, so each column becomes one number: one per stock. - Squash the stocks
R.mean(axis=1)→ [1, −1, 3, 1]axis=1runs across the columns: one number per day.
AnswerThe stocks average 2%, 0% and 1% a day. The days average 1%, −1%, 3% and 1%.
axis=0 squashes down the days and leaves one number per stock. axis=1 squashes across the stocks and leaves one per day.Think of axis as the direction that disappears. Squash axis 0, the rows, and the shape (4, 3) loses its 4: you get (3,). Squash axis 1 and it loses its 3: (4,). The mean of each row is the return of a portfolio with equal money in each stock that day.
Shape and axis
An array's shape counts its elements in each direction: (4, 3) is 4 rows and 3 columns. The axis argument says which direction a sum, mean or standard deviation runs along. That direction disappears from the shape.
Leave out axis and numpy squashes everything into one number: R.mean() is 1, the average of all twelve cells. For a table of stocks that's rarely what you want, and it raises no error, so check the shape of what comes back.
Your turn
P holds 250 days of prices for 4 stocks, shape (250, 4), and R = P[1:] / P[:-1] - 1. What shape is R.std(axis=0)?
R has one row fewer than P: (249, 4). axis=0 squashes the days and leaves one volatility per stock: (4,). If you said (249,), you squashed the stocks instead, which is axis=1.Part 4. The worst fall from a peak
Find a price series' maximum drawdown with a running maximum.
A common interview task: given an array of prices, find the biggest fall from a peak, without a loop. Say it in words first. On each day, how far is the price below the highest price so far? The biggest of those gaps is the answer.
Example 4.1
Eight days of prices
p = np.array([100, 108, 103, 111, 96, 99, 115, 104.0]). What's the biggest fall from a peak to a later low, and on which day does it bottom out?
- Find the running peak[100, 108, 108, 111, 111, 111, 115, 115]
peak = np.maximum.accumulate(p)carries the highest price so far along the array, in one call. - Measure each day against it
dd = p / peak - 1→ [0, 0, −4.6%, 0, −13.5%, −10.8%, 0, −9.6%]How far each day sits below the peak before it.
- Take the lowest
dd.min()→ −13.5%dd.argmin()gives its position, 4: the day the price fell from 111 to 96.
AnswerA maximum drawdown of −13.5%, from 111 on day 3 to 96 on day 4.
Maximum drawdown
The biggest fall from a running peak to a later low, as a share of the peak. Risk reports quote it next to volatility: it's the loss of someone who bought at the worst moment.
(p / np.maximum.accumulate(p) - 1).min()
The trap is the highest price against the lowest. Here that's 96 / 115 − 1 = −16.5%, but the low, on day 4, came before the high, on day 6, so nobody ever lost that much. The running peak only looks back, which keeps the order right.
To check fast code, write the slow, obvious version too and compare. Two different ways of computing a thing rarely make the same mistake.
import numpy as np
p = np.array([100, 108, 103, 111, 96, 99, 115, 104.0])
peak = np.maximum.accumulate(p) # the highest price so far
dd = p / peak - 1 # how far below that peak
print(dd.round(3))
# [ 0. 0. -0.046 0. -0.135 -0.108 0. -0.096]
print(dd.min().round(3), dd.argmin())
# -0.135 4
# The same answer the slow way, to check
worst, high = 0.0, p[0]
for x in p:
high = max(high, x)
worst = min(worst, x / high - 1)
print(round(worst, 3))
# -0.135Your turn
p = np.array([50, 60, 45, 55, 70, 63.0]). What is its maximum drawdown?
Where you'll meet it
Turning a loop into array code is a common task in quant developer and research screens: returns, a running maximum, a drawdown, a count of up days. A frequent follow-up is a step that needs the one before, such as a position that depends on yesterday's. Making it fast, the last lesson in this module, covers those.
Research code runs over years of data for thousands of stocks. Twenty years of about 252 trading days for 3,000 stocks is about 15 million prices for each field, such as the close. On a laptop, a Python loop takes about a second for one pass over that, and the numpy line a few thousandths of a second. pandas, which the rest of this module uses, keeps its number columns as numpy arrays.
import time
import numpy as np
rng = np.random.default_rng(0)
p = 100 * np.cumprod(1 + rng.normal(0, 0.01, 1_000_000))
def returns_loop(p):
out = []
for i in range(1, len(p)):
out.append(p[i] / p[i - 1] - 1)
return np.array(out)
t0 = time.perf_counter()
a = returns_loop(p)
t1 = time.perf_counter()
b = p[1:] / p[:-1] - 1
t2 = time.perf_counter()
print(len(b), np.allclose(a, b))
# 999999 True
print("numpy at least 20x faster:", t1 - t0 > 20 * (t2 - t1))
# numpy at least 20x faster: TrueTimes change from machine to machine, so the code prints only what holds on any of them: the answers agree, and the gap is wide. In a Jupyter notebook, %timeit runs a line many times and reports the typical time.
Check yourself
Three questions from across the lesson. Try each one before you look back.
1
p holds a million prices. How does [np.log(x) for x in p] compare with np.log(p)?
np.log(p) pays it once and loops in compiled code. On a laptop the gap is around a hundred times, for the same answers.2
You want the returns between 0 and 1%. Which line works?
& combines two masks element by element, and the brackets make each comparison happen first. and raises a ValueError on arrays, the third line fails because & binds before >, and + on two masks acts as or, so it keeps every day.3
An array p holds 251 daily closes. How long are p / np.maximum.accumulate(p) - 1 and p[1:] / p[:-1] - 1?
What to remember
1Returns from a whole column
p[1:] / p[:-1] - 1turns n prices into n − 1 returns with no loop: five prices give four returns.2Masks pick and count days
A comparison makes a mask:
(r > 0).sum()counts the up days and(r > 0).mean()is their share, 0.5 for two in four.3Days down, stocks across
axisnames the direction that disappears: on shape (4, 3),axis=0leaves one number per stock, (3,), andaxis=1one per day, (4,).4The worst fall from a peak
Maximum drawdown is the lowest value of
p / np.maximum.accumulate(p) - 1: −13.5% for a fall from 111 to 96.
Python for Market Data overviewSpotted a mistake? Tell us.