পরিমাণগত অর্থায়ন · বই 20 · ক্যারিয়ার

Interviews III: Code

এই বইটি শুধু ইংরেজিতে পাওয়া যায়।

Interviews III: Code · ক্যারিয়ার

2Python and pandas

Knowing what the interpreter really does, then what a library does to your data. The stems say which version they mean where it matters: here CPython 3.10, NumPy 2.2 and pandas 2.3. The pandas questions are mostly about getting the right number, since a fast wrong answer is the usual failure.

Question 2.1 ★ QD, Dev, QR, DS • any • junior

What four lines does Listing 2.1 print on CPython, and why? Rewrite add so that each call without a list starts from an empty one.

def add(x, items=[]):
    items.append(x)
    return items


print(add(1))
print(add(2))
print(add(3, []))
print(add(4))
Listing 2.1. A default argument. code/interviews-3/02-python-and-pandas/python/pp_default.py
Answer

Answer to Question 2.1.

It prints [1], [1, 2], [3] and [1, 2, 4]. A default value is evaluated once, when the def statement runs, so every call that omits the list shares one list, which outlives the call.

The third call passes its own list, so it does not touch the shared one, which is why the fourth continues from [1, 2].

The fix is the usual sentinel: write items=None in the signature, and inside the function say if items is None: items = []. Then a new list is made on each call. Test is None and not if not items, because a caller who passes an empty list expects that same list back.

Follow-up. Where does the list live? On the function object: add.__defaults__ holds it, and you can see it grow.

Follow-up. Is it ever useful? Sometimes as a cheap cache, but a reader will take it for a bug, so use functools.cache instead.

Question 2.2 ★ QD, Dev, QR, DS • any • junior

What two lines does Listing 2.2 print? Fix each so that every key and every row has its own list. Why is dict.fromkeys(names, 0) safe, when dict.fromkeys(names, []) is not?

books = dict.fromkeys(["A", "B"], [])
books["A"].append(1)
print(books)

grid = [[0] * 2] * 2
grid[0][0] = 9
print(grid)
Listing 2.2. Two ways to share a list. code/interviews-3/02-python-and-pandas/python/pp_alias.py
Answer

Answer to Question 2.2.

It prints {'A': [1], 'B': [1]} and [[9, 0], [9, 0]]. In both cases one list object is shared, so a change made through one name is seen through all.

dict.fromkeys evaluates its second argument once and stores that one object under every key. [[0] * 2] * 2 repeats a reference to the same inner row. Python does not copy on repetition, and it never copies on assignment.

The fixes make one list per key or row: {k: [] for k in names}, or a defaultdict(list), and [[0] * 2 for _ in range(2)], where the expression runs once per row.

The 0 is safe because an integer cannot change. d[k] += 1 rebinds that one key to a new integer. It does not alter the shared 0. So sharing does no harm for immutable values, and the inner [0] * 2 in the row above is fine for the same reason.

Classic wrong answer. “[0] * 2 is the bug.” It is not, since the elements are integers. It is the outer * 2 that repeats a list.

Follow-up. How do you check whether two names hold one object? a is b, or compare id(a) and id(b).

Question 2.3 ★ QD, Dev, DS • any • junior

On CPython 3.10, what two lines does Listing 2.3 print? What is the difference between is and ==, and when should you use is?

x, y = int("256"), int("256")
print(x is y, x == y)
x, y = int("257"), int("257")
print(x is y, x == y)
Listing 2.3. Identity and equality. code/interviews-3/02-python-and-pandas/python/pp_is.py
Answer

Answer to Question 2.3.

It prints True True, then False True. == compares values, and is compares identity: whether the two names hold the very same object.

CPython keeps one shared object for each integer from −5-5 to 256, so int(…) returns the cached object for 256, and a new object each time for 257. That is an implementation detail, and other implementations or versions need not follow it.

The listing goes through int on text on purpose. With the literals 257 is 257 in one block, the compiler can merge the constants, and you get True, which adds to the confusion.

Use is for singletons, where identity is the meaning: x is None, or a sentinel object of your own. Use == for everything else, including True and False from a library, where a NumPy boolean is not the object True.

Classic wrong answer. “is is a faster ==.” It is faster, and it answers another question. Code that compares quantities with is passes every small test and then fails at 257 shares.

Question 2.4 ★ QD, Dev, DS • any • junior

What does Listing 2.4 print? Beyond “a list can change and a tuple cannot”, what follows from that difference for dictionary keys, and for memory?

d = {(1, 2): "tuple"}
print(d[(1, 2)])
for key in ([1, 2], (1, [2]), frozenset([1, 2])):
    try:
        print(d.get(key, "absent"))
    except TypeError as e:
        print("TypeError:", e)
Listing 2.4. What can be a key. code/interviews-3/02-python-and-pandas/python/pp_keys.py
Answer

Answer to Question 2.4.

It prints tuple, then a TypeError for the list, a TypeError for the tuple holding a list, and absent for the frozen set. A key must be hashable, and a list is not.

A dictionary stores a key by its hash, and it finds the key again by recomputing that hash. If a key could change after insertion, its hash would change, and the entry would sit in the wrong place, unreachable. So mutable built-ins refuse to be hashed. The check comes on any lookup, as get must hash its argument too.

A tuple’s hash is built from its elements, so a tuple is hashable only if all its elements are. (1, [2]) fails for that reason, and it has no check on being “a tuple”.

For memory, a tuple is smaller, as it needs no spare room for growth. On CPython 3.10, 64-bit, sys.getsizeof gives 56 for a tuple of two and 72 for a list made of two by a list display. It makes little difference for one object, and a lot for a million small records.

Follow-up. Can a tuple’s contents change? The tuple cannot be rebound to other objects, but a list inside it can still be changed. So “immutable” is shallow.

Question 2.5 ★ QD, Dev, DS • any • junior

What two lines does Listing 2.5 print? A colleague wants a cheap snapshot of a book held as a dictionary of lists, taken many times a second. What do you tell them?

import copy

orders = {"A": [1, 2]}
shallow = orders.copy()
deep = copy.deepcopy(orders)
orders["A"].append(3)
orders["B"] = [9]
print(shallow)
print(deep)
Listing 2.5. Two kinds of copy. code/interviews-3/02-python-and-pandas/python/pp_copy.py
Answer

Answer to Question 2.5.

It prints {'A': [1, 2, 3]} and {'A': [1, 2]}. The shallow copy is a new dictionary holding the same lists, so it does not see the new key B, but it does see the append. The deep copy has new lists all the way down.

For the snapshot, deepcopy walks the whole structure with a memo table, at a Python-level cost for every object, so taken many times a second on a large book it will show up on a profile.

Better options depend on what is read. If a snapshot is only for one consumer, send a compact form: a tuple of (price, size) pairs for each side. Tuples of immutable values can be shared safely, so copying just the top level is then enough. Or hold levels as immutable values, replace one on each change, and take the shallow copy at O(levels)O(\text{levels}).

Classic wrong answer. “dict(orders) makes it independent.” That is the same shallow copy. It looks fine in a test where you only add keys.

Follow-up. And for NumPy arrays? arr.copy() is deep for numeric types, and it copies one buffer in one call, with no Python-level step per element.

Question 2.6 ★ QD, Dev, QR, DS • any • junior

What three lines does Listing 2.6 print on CPython 3.10? What does each one tell you about taking the largest value of a price list that can hold a NaN?

nan = float("nan")
print(nan == nan, nan in [nan], float("nan") in [nan])
print(max(nan, 1.0), max(1.0, nan))
print(sorted([2.0, nan, 1.0]))
Listing 2.6. NaN in plain Python. code/interviews-3/02-python-and-pandas/python/pp_nan.py
Answer

Answer to Question 2.6.

It prints False True False, then nan 1.0, then [2.0, nan, 1.0]. NaN compares false with everything, itself included, and that one rule explains all three lines.

nan == nan is false. nan in [nan] is true, since in first checks identity and the object is the same, while a fresh float('nan') is another object, and equality fails.

max keeps its first argument unless a later one compares greater. Nothing compares greater than NaN, so max(nan, 1.0) stays at NaN; and NaN does not compare greater than 1.0, so max(1.0, nan) stays at 1.0. So the result depends on where the NaN falls.

sorted sees every comparison with NaN as false, so it takes the list as already sorted, and leaves it unsorted, with no error. The list then gives a wrong minimum, a wrong maximum and a wrong median.

So for the largest price, filter first: max(x for x in prices if not math.isnan(x)), or np.nanmax. Do not call max and hope.

Classic wrong answer. “Check x == float('nan').” It is never true. Use math.isnan.

Question 2.7 ★ QD, Dev, DS • any • junior

What four lines does Listing 2.7 print? A function takes an iterable of returns, computes its length with a loop, then its sum, and divides. It works on a list and gives a wrong answer on a generator, with no error. What does it return there, and what changes if the sum comes first?

z = zip([1, 2], [3, 4])
print(list(z))
print(list(z))
g = (x * x for x in range(3))
print(sum(g), sum(g))
r = range(3)
print(sum(r), sum(r))
Listing 2.7. One pass or many. code/interviews-3/02-python-and-pandas/python/pp_exhaust.py
Answer

Answer to Question 2.7.

It prints [(1, 3), (2, 4)], [], 5 0 and 3 3. zip and a generator are iterators, which can be walked once. A range is a sequence, which starts a new iterator each time.

So on a generator of 1, 2 and 3, the function counts 3, which uses up the stream, then sums nothing and returns 0/3=0.00 / 3 = 0.0, a plausible mean. With the sum first, it gets the right sum and a length of 0, and raises ZeroDivisionError, which is the kinder failure.

The fix depends on the size. If the data fits, xs = list(xs) at the top. If not, do a single pass that keeps a running count and a running total. Testing iter(xs) is xs tells you whether you hold a one-shot iterator, if a function must refuse one.

Classic wrong answer. “Use itertools.tee.” It works, but it stores everything one branch has read and the other has not, which here is the whole stream, so it saves nothing.

Follow-up. Why does for not raise at the end? An exhausted iterator raises StopIteration at once on each further request, and for treats that as “finished”. The silence is part of the protocol.

Question 2.8 ★ QD, Dev, QR, DS • any • junior

With NumPy 2.2, what three lines does Listing 2.8 print? Is there any warning? Where in a data pipeline would this bite?

import numpy as np

a = np.array([2**31 - 1], dtype=np.int32)
print(a + 1)
b = np.array([1], dtype=np.uint8)
print(b - 2)
print(2**31 - 1 + 1)
Listing 2.8. Fixed-width integers. code/interviews-3/02-python-and-pandas/python/pp_overflow.py
Answer

Answer to Question 2.8.

It prints [-2147483648], [255] and 2147483648. NumPy arrays have fixed-width types and wrap around with no warning, while Python’s own integers grow as needed.

An int32 array stops at 231−12^{31} - 1, and adding 1 wraps to the lowest value. A uint8 array cannot hold a negative value, so 1−21 - 2 wraps to 255. Array arithmetic gives no warning and no exception, since a check per element would cost too much. A NumPy scalar is different: np.int32(2**31 - 1) + 1 does give a RuntimeWarning, so a check on a single value can make you think all is well.

This bites where a column’s type was chosen for one size and then accumulated. A running volume in int32 wraps to a large negative number as soon as it passes about 2.1 billion. Timestamps in nanoseconds need 64 bits. A difference of unsigned sequence numbers gives a huge positive value in place of a small negative one.

Classic wrong answer. “NumPy will raise an error, as Python would.” Python has no integer overflow at all, so there is no analogue to raise.

Follow-up. How do you protect yourself? Use int64 for anything that gets summed, and assert on totals with an independent check: compare the last value with a total computed in Python integers, or assert that a running volume never falls. The running sum’s first difference proves nothing, as it wraps back to the input.

Question 2.9 ★ QR, DS, QD, Analyst • any • junior

In Listing 2.9, with pandas 2.3, which of the lines A, B and C work, and what do they return? Explain each failure.

import pandas as pd

df = pd.DataFrame({"qty": [1, 2, 3], "px": [4, 5, 6]})
lines = {
    "A": lambda: df[(df.qty > 1) and (df.px < 6)],
    "B": lambda: df[df.qty > 1 & df.px < 6],
    "C": lambda: df[(df.qty > 1) & (df.px < 6)],
}
for name, line in lines.items():
    try:
        print(name, list(line().qty))
    except ValueError:
        print(name, "ValueError")
Listing 2.9. Three ways to combine two conditions. code/interviews-3/02-python-and-pandas/python/pp_series_and.py
Answer

Answer to Question 2.9.

Only C works, and it returns the row with qty 2. A and B both raise ValueError: “The truth value of a Series is ambiguous”.

Python’s and has to reduce its left side to one true or false, so it calls bool on a Series, and pandas refuses, as one answer per row cannot make one truth value. There is no way for a library to override and. So pandas uses the bitwise operator &, which it can overload to work row by row.

B fails through precedence. & binds tighter than > and <, so the line means df.qty > (1 & df.px) < 6. That is a chained comparison, which Python expands with an and, so the same error appears. If the columns held floats, 1 & df.px would raise a TypeError first.

C has the brackets, so each comparison gives a Series of booleans, and & combines them. The row with qty 2 has px 5, which passes. The row with qty 3 fails on px.

Follow-up. And with query? df.query('qty > 1 and px < 6') parses the text itself, so and works there.

Follow-up. And in plain NumPy? It is the same rule, with a similar message, once an array has more than one element: np.logical_and or & with brackets.

Question 2.10 ★★ QD, Dev, QR, DS • any • junior, experienced

On CPython 3.10, four threads each run a pure Python loop that does arithmetic, on a machine with four free cores. What speed-up do you get over one thread, and why? Does that make count += 1 safe on a shared global? When do threads help, and what do you use when they do not?

Answer

Answer to Question 2.10.

You get no speed-up, and often a small slowdown. CPython 3.10 has a global interpreter lock: only one thread runs Python bytecode at a time. The lock does not make count += 1 safe, because that statement is several bytecode steps.

The disassembly of count += 1 shows a load, an add and a store, and nothing in the language promises that a switch cannot fall between them. Four threads running the bare statement lost none of 8 million increments, in three runs here on CPython 3.10.12, which is the trap: the test passes, and that is no guarantee. Make the threads wait for each other between the read and the write, and our test loses three updates in every four, every time. So use a threading.Lock, or give each thread its own counter and add them at the end.

Threads help where the lock is released: waiting on a socket or a disk, and inside NumPy operations and many C extensions, which run in parallel on large arrays. For pure Python computation, use processes, at the price of sending data between them.

Classic wrong answer. “The GIL makes Python thread-safe.” It protects the interpreter’s own structures, one bytecode at a time. A compound action in your code is not atomic.

Follow-up. How do processes share a large array? Not by pickling it. The standard module shared_memory gives a named block, and np.ndarray wraps its buffer in each process, so all of them see one copy.

Follow-up. Is this permanent? Python 3.13 added an optional free-threaded build without the lock, so check which build you run before relying on either behaviour.

Question 2.11 ★★ QD, Dev, QR • any • junior

Write a decorator memo that caches a function’s results by its arguments. What does it do to the number of calls of a recursive Fibonacci function for n=30n = 30? What happens with a list as an argument, and are f(1, 2) and f(1, b=2) one entry or two? Why not write cache.get(key) or f(…)?

Answer

Answer to Question 2.11.

Listing 2.18 takes the calls of a recursive Fibonacci for n=30n = 30 from 2 692 537 to 31. A list argument raises TypeError, since it cannot be hashed. f(1, 2) and f(1, b=2) are two entries: right answers, computed twice.

from functools import wraps


def memo(f):
    cache = {}

    @wraps(f)
    def inner(*args, **kwargs):
        key = (args, tuple(sorted(kwargs.items())))
        if key not in cache:
            cache[key] = f(*args, **kwargs)
        return cache[key]

    return inner
Listing 2.18. A memoising decorator. code/interviews-3/02-python-and-pandas/python/iv_memo.py

Without a cache, the number of calls satisfies c(n)=c(n−1)+c(n−2)+1c(n) = c(n - 1) + c(n - 2) + 1, which comes to 2Fn+1−12F_{n+1} - 1, so 2 692 537 for n=30n = 30. With it, each of the 31 values from 0 to 30 is computed once. The recursive calls go through the decorated name, which is why it works at all.

The test key not in cache matters because a result can be 0, None or an empty list, and the form cache.get(key) or f(…) recomputes on each of those, Fibonacci of 0 included.

wraps copies the name and the docstring, so that a stack trace and a profile show the real function, and not inner. To accept lists, convert them to tuples in the key, or leave the caching to the caller.

Classic wrong answer. Using id(arg) in the key, to get round hashing. After the object is freed, its address can come back for another list, and then you read someone else’s cached answer.

Follow-up. What would you use in production? functools.lru_cache with a bound: this cache grows forever, and on a method it also keeps every self alive.

Question 2.12 ★★ QD, Dev, DS • any • junior

A CSV file of 50 GB has columns symbol, price_ticks and qty. On a machine with 16 GB of memory, in plain Python, compute the total notional per symbol. What does your memory use depend on? If a generator holds the file open and its caller stops reading halfway, when does the file close?

Answer

Answer to Question 2.12.

Listing 2.19 reads the file one row at a time and keeps one running total per symbol, so memory depends on the number of distinct symbols, not on the size of the file.

import csv
from collections import defaultdict


def rows(path):
    with open(path, newline="") as f:
        yield from csv.DictReader(f)


def notional_by_symbol(records):
    totals = defaultdict(int)
    for r in records:
        totals[r["symbol"]] += int(r["price_ticks"]) * int(r["qty"])
    return dict(totals)
Listing 2.19. One row at a time. code/interviews-3/02-python-and-pandas/python/iv_stream_csv.py

rows is a generator, so nothing is read until notional_by_symbol asks for it, and each row can be freed once it has been added. In our test, on a file of 200 000 rows, the peak allocation stays below 1 MB, while list(rows(path)) uses more than 10 MB, and that gap grows with the file. Prices stay as integer ticks, so the totals are exact.

If the caller stops halfway, the file stays open while the generator is alive. It closes when the generator is closed, by close() or by CPython’s reference counting when the last reference goes. Both raise GeneratorExit at the yield, which runs the with. Other Python implementations may delay that, so a long-lived caller should call close() or wrap it in contextlib.closing.

Classic wrong answer. pd.read_csv(path) with a larger machine. It builds the whole table in memory, which is what the stem rules out. With chunksize, pandas can do this too, as long as you add up sums and do not average averages.

Follow-up. How do you make it survive a bad line? Count the bad lines and carry on, up to a limit, so that one corrupt line does not kill a 50 GB run, and a corrupt file does not pass in silence.

Question 2.13 ★★ QD, Dev • any • junior

What two lines does Listing 2.10 print, and why do they look inconsistent? How would __slots__ change this class, and what do you gain with a million instances?

class Book:
    orders = []

    def __init__(self, name):
        self.name = name

    def add(self, order):
        self.orders.append(order)


a, b = Book("A"), Book("B")
a.add(1)
print(b.orders)
a.orders = [9]
print(a.orders, b.orders)
Listing 2.10. An attribute of the class. code/interviews-3/02-python-and-pandas/python/pp_class_attr.py
Answer

Answer to Question 2.13.

It prints [1], then [9] [1]. orders is one list on the class, shared by all instances, so a.add(1) appends to it and b sees that. The assignment a.orders = [9] creates a new attribute on a, which hides the class’s list for a alone.

So reading and appending go to the class’s object, while assignment always writes on the instance. That is the whole of the inconsistency. For a plain class attribute, reading checks the instance first and then the class, and assignment writes on the instance. A property or other data descriptor on the class takes precedence over both. The fix is to set self.orders = [] in __init__.

With __slots__ = ('name', 'orders'), instances have no __dict__, just fixed fields. In our test, a slotted instance takes less than half the memory of an ordinary one, on CPython 3.10. A typo such as self.qyt = 1 raises AttributeError at once.

A name in __slots__ cannot also be a class attribute with a default: Python raises ValueError when it builds the class, so the original bug cannot be carried across.

Classic wrong answer. “a.orders = [9] changes the list for everyone.” Only a change to the object does that. This rebinding touches one instance.

Follow-up. And with a million instances, should you use a class at all? Often not. Columns in NumPy arrays hold the same data in a fraction of the space.

Question 2.14 ★★ QD, Dev • any • junior

Write a context manager, as a class and then with contextlib, that logs “acquire” on entry and “release” on exit, even when the body raises. What happens if __exit__ returns true? What goes wrong in the contextlib version if you leave out try and finally?

Answer

Answer to Question 2.14.

Listing 2.20 shows both forms, and each logs “release” when the body raises. If __exit__ returns a true value, the exception is swallowed, and execution continues after the with block as though nothing went wrong.

from contextlib import contextmanager


class Held:
    def __init__(self, log):
        self.log = log

    def __enter__(self):
        self.log.append("acquire")
        return self

    def __exit__(self, exc_type, exc, tb):
        self.log.append("release")
        return False                  # True would swallow the exception


@contextmanager
def held(log):
    log.append("acquire")
    try:
        yield
    finally:
        log.append("release")
Listing 2.20. Two ways to write a context manager. code/interviews-3/02-python-and-pandas/python/iv_context.py

Python calls __exit__ however the block ends, passing the exception’s details if there is one. Returning nothing, which is None, lets the exception go on, and so does False. So the safe rule is to return true only when this manager means to handle that one exception, and to test its type.

In the contextlib version, an exception in the body is raised inside the generator at the yield. Without try and finally, it leaves the generator at that point, and the line after the yield never runs. So a lock stays held, or a file stays open, in exactly the failure case the manager was written for. The normal path shows nothing, so a test must raise on purpose to catch it.

Classic wrong answer. Putting the release in __del__. It runs when the object is collected, if at all, and it runs at no moment you can predict.

Follow-up. What if the release itself raises? The new exception replaces the old one, with the old one chained as its context. So keep the release simple, and log the original.

Question 2.15 ★★ QD, Dev • any • junior

You write a dataclass Order with a field tags: list = []. What happens, and when? Fix it. Then you put Order objects in a set, and it fails too. Why? What does frozen=True give you, and what does it not protect?

Answer

Answer to Question 2.15.

A field tags: list = [] raises ValueError when the class is defined, not when an order is made. Listing 2.21 fixes it with field(default_factory=list). Objects of a plain dataclass cannot go in a set, because it has no hash: @dataclass sets __hash__ to None.

from dataclasses import dataclass, field


@dataclass
class Order:
    id: int
    qty: int
    tags: list = field(default_factory=list)


@dataclass(frozen=True)
class Key:
    symbol: str
    venue: str
Listing 2.21. A mutable default and a frozen key. code/interviews-3/02-python-and-pandas/python/iv_dataclass.py

The error at definition is Python protecting you from the shared default in a plain function: without it, all orders would share one list. A factory runs once per instance.

The hash goes because @dataclass writes an __eq__ by value. Two objects that compare equal must have the same hash, and a mutable value could change after it was put in a set, leaving it in the wrong place. So Python’s rule is: define equality without saying the object is immutable, and you get no hash.

frozen=True makes assignment to a field raise, and it generates __hash__ from the fields, so Key works in a set or as a dictionary key. It does not protect what the fields hold. A frozen object with a list field can still have that list appended to, and hashing it then raises TypeError, as hashing the list does.

Classic wrong answer. “Add unsafe_hash=True.” It runs, and it lets a mutable object sit in a set, where a later change loses it.

Follow-up. Why would you want slots=True? From 3.10 it builds the class with __slots__, for less memory per object and no attribute typos.

Question 2.16 ★★ QD, Dev, DS • any • junior

You have a list of trades, each a pair of a symbol and a notional. Sort them by symbol ascending and, within a symbol, by notional descending. Trades that tie must keep their original order. Show a way that works even when the second key cannot be negated, such as a string. In what order do you sort?

Answer

Answer to Question 2.16.

Sort twice, least important key first, as in Listing 2.22: first by notional descending, then by symbol. Python’s sort is stable, so the second sort keeps the first sort’s order within each symbol.

from operator import itemgetter


def by_symbol_then_notional(trades):
    """Symbol ascending, notional descending; equal trades keep their order."""
    out = sorted(trades, key=itemgetter(1), reverse=True)
    out.sort(key=itemgetter(0))
    return out
Listing 2.22. Two stable sorts, least important first. code/interviews-3/02-python-and-pandas/python/iv_sort.py

A stable sort never swaps two elements with equal keys. So after sorting by notional and then by symbol, trades with one symbol stay ordered by notional. reverse=True also preserves the order of ties, by the language’s definition, so that first pass reverses nothing that ties. Writing sorted(trades, key=…)[::-1] does not have that property, as it flips ties too.

Where the second key is a number, one pass is enough: key=lambda t: (t[0], -t[1]). That fails for a string or a None, neither of which can be negated. The two-pass form works for any key that orders, so a column mixing None with numbers still needs a key that places the None.

Each pass costs O(nlog⁡n)O(n \log n) at worst.

Classic wrong answer. Sorting by symbol first and then by notional. The last sort is the primary key, so that orders all trades by notional and scatters the symbols.

Follow-up. What if there are many keys? Take them from the last to the first, or build one key function returning a tuple, in which each part sorts the way you want.

Question 2.17 ★★ QR, DS, QD • any • junior

In Listing 2.11, r holds returns with one row per day and one column per stock, and the aim is to subtract each day’s cross-sectional mean. With NumPy 2.2, what do the two prints give? Which is right, how can you tell at a glance, and what would have happened with three stocks in place of two?

import numpy as np

r = np.array([[1.0, 2.0], [3.0, 6.0]])
print(r - r.mean(axis=1))
print(r - r.mean(axis=1, keepdims=True))
Listing 2.11. Subtracting each row’s mean. code/interviews-3/02-python-and-pandas/python/pp_broadcast.py
Answer

Answer to Question 2.17.

The first print gives [[-0.5, -2.5], [1.5, 1.5]] and the second [[-0.5, 0.5], [-1.5, 1.5]]. The second is right. Each row should add up to zero after you take its mean out, and in the first the rows add up to −3-3 and 33.

r.mean(axis=1) has shape (2,). NumPy’s broadcasting lines up shapes from the right, so (2,) matches the columns of (2, 2), and it subtracts the first day’s mean from the first stock on all days, and the second day’s mean from the second stock. It is a meaningless result, with no error, because the matrix happens to be square.

keepdims=True gives shape (2, 1), which lines up with the rows and repeats across the columns.

With three stocks, the shapes are (2, 3) and (2,): the last dimensions are 3 and 2, which do not match, so NumPy raises ValueError. So the bug hides when the days equal the stocks, which can happen with a small test matrix or a short window, or in one window of a rolling calculation. It also hides with a single stock, where (n, 1) and (n,) broadcast to (n, n).

Classic wrong answer. “It raises if the shapes do not match.” Only if they do not match after the alignment from the right, and that rule is exactly what lets a square matrix through.

Follow-up. How do you protect yourself? Keep keepdims=True as a habit, and test with non-square inputs of more than one stock, so that a wrong axis fails loudly. A check that rows sum to zero catches it on any shape.

Question 2.18 ★★ QR, DS, QD • any • junior

What does Listing 2.12 print with NumPy 2.2? Which of b, c and d are views? You keep huge[:10] from an array of 10 GB. What happens to the memory? In pandas 2.3, what does df[df.x > 0]['y'] = 1 do to df?

import numpy as np

a = np.arange(5)
b = a[1:3]
b[0] = 99
c = a[[3, 4]]
c[0] = 77
d = a[a > 3]
d[0] = 55
print(a)
Listing 2.12. Views and copies. code/interviews-3/02-python-and-pandas/python/pp_views.py
Answer

Answer to Question 2.18.

It prints [ 0 99 2 3 4]. Only b is a view: a slice shares memory with a, while indexing with a list or a boolean mask makes a copy, so the writes through c and d do not reach a.

The rule follows from what can be expressed. A slice is a start, a stop and a step, so a new header over the same buffer can describe it, at O(1)O(1) cost. Arbitrary positions cannot be described that way, so NumPy copies them. np.shares_memory(a, b) tells you which you have.

small = huge[:10] keeps all 10 GB alive, since small.base points at the original buffer. Use huge[:10].copy() and then let huge go.

df[df.x > 0]['y'] = 1 on pandas 2.3 leaves df as it was, with a SettingWithCopyWarning. The mask gives a new object, and the assignment goes to that object. It is then thrown away. Write df.loc[df.x > 0, 'y'] = 1, which is one operation on df.

Classic wrong answer. “Pandas always copies.” It has not been reliably one thing or the other, which is why the warning exists. Pandas 3 makes copy-on-write the default, so check the version you run.

Follow-up. When is a view a bug and not a saving? When a function modifies an array it was given: the change reaches the caller’s data. Mark inputs read-only, with arr.flags.writeable = False, in shared code.

Question 2.19 ★★ QR, DS, QD, Analyst • any • junior

A DataFrame of trades has columns symbol, day, price and qty, with rows (A, 1, 10, 100), (A, 1, 11, 300), (B, 1, 20, 50) and (A, 2, 12, 0). Compute the volume-weighted average price per symbol and day without apply. What does it give for each group? What does groupby(…).price.mean() give for (A, 1), and why is that wrong?

Answer

Answer to Question 2.19.

(A, 1) gives 10.75, (B, 1) gives 20 and (A, 2) gives NaN, as in Listing 2.23. groupby(…).price.mean() gives 10.5 for (A, 1), which treats a trade of 100 shares and a trade of 300 shares alike.

import pandas as pd


def vwap_by_symbol_day(df: pd.DataFrame) -> pd.Series:
    money = df["price"] * df["qty"]
    keys = [df["symbol"], df["day"]]
    return money.groupby(keys).sum() / df["qty"].groupby(keys).sum()
Listing 2.23. Two group sums and one division. code/interviews-3/02-python-and-pandas/python/iv_vwap.py

The weighted average is (10×100+11×300)/400=4300/400=10.75(10 \times 100 + 11 \times 300) / 400 = 4300 / 400 = 10.75. Compute the two sums per group and divide. The two results have the same group index, so the division lines up by group with no further work.

The NaN for (A, 2) is the honest answer: no shares traded, so no average exists, and 12 would be a price at which nothing was done. Pandas gives NaN for 0/00 / 0 here, and it needs no guard. A plain Python version would raise, and a C++ one would divide by zero.

The usual alternative is groupby(…).apply(lambda g: …), which is correct and runs a Python function once per group, while the listing’s two sums run in compiled code.

Classic wrong answer. df.price * df.qty summed and divided by the whole table’s quantity. That is one number for the table, not one per group.

Follow-up. How do you get it back onto each row? Use transform('sum') in place of sum, which keeps the original index.

Question 2.20 ★★ QR, DS, QD, QT • any • junior

For one symbol, trades happen at integer times 1, 5 and 10, and quotes at times 2, 5 and 9. Attach to each trade the latest quote with pd.merge_asof. Which quote time does each trade get? What changes with allow_exact_matches=False, and with direction='forward', and which of them is a bug in a backtest? What happens if the input is unsorted?

Answer

Answer to Question 2.20.

By default the trades at 1, 5 and 10 get no quote, the quote at 5, and the quote at 9. Listing 2.24 is the usual call. With allow_exact_matches=False, the trade at 5 gets the quote at 2. With direction='forward' they get 2, 5 and no quote, which is the backtest bug.

import pandas as pd


def quotes_at_trades(trades, quotes):
    return pd.merge_asof(
        trades.sort_values("t"), quotes.sort_values("t"),
        on="t", by="symbol", direction="backward",
    )
Listing 2.24. The latest quote at or before each trade. code/interviews-3/02-python-and-pandas/python/iv_merge_asof.py

A forward match gives each trade a quote from its future. A signal built on that looks excellent and cannot be earned, since at time 1 nobody yet knows the quote of time 2.

An exact match is a choice about what the timestamps mean. If a quote at 5 is published before a trade at 5, keep it. If your clocks cannot say which came first, then allow_exact_matches=False is the careful choice, and costs you little.

Both inputs must be sorted on the key, and pandas raises ValueError (“left keys must be sorted”) if they are not, which is why the listing sorts first. With by='symbol', matches stay inside a symbol.

Add tolerance, in practice. Otherwise, a trade after a long gap gets a quote that is hours old, and nothing says so.

Classic wrong answer. merge on the time column, then forward-fill. It works only if you sort and group correctly, it fills across symbols unless grouped, and it costs far more memory.

Follow-up. What does it cost? After the sort, one walk through both: O(m+n)O(m + n) for the merge, and O(mlog⁡m+nlog⁡n)O(m \log m + n \log n) if they arrive unsorted.

Question 2.21 ★★ QR, DS, QD, QT • hedge fund, market maker • junior

Ticks arrive at 09:30:10 at price 10, 09:30:50 at 12, 09:31:20 at 11 and 09:33:05 at 13. Build one-minute open, high, low and close bars with resample. What is in the bar for 09:30, and under what time does pandas label it by default? Why does that label leak the future into a backtest, and what is the fix? What should the bar for 09:32 hold?

Answer

Answer to Question 2.21.

The 09:30 bar holds the ticks from 09:30:00 up to but not including 09:31:00: open 10, high 12, low 10, close 12. By default pandas labels it 09:30, the start of the bar. A join on that label treats 12, a price seen at 09:30:50, as known at 09:30:00. Listing 2.25 labels each bar at its end.

import pandas as pd


def minute_bars(prices: pd.Series) -> pd.DataFrame:
    bars = prices.resample("1min", label="right", closed="left").ohlc()
    bars["close"] = bars["close"].ffill()
    return bars
Listing 2.25. Bars labelled when they are complete. code/interviews-3/02-python-and-pandas/python/iv_bars.py

So a bar is known only once it closes, and label='right' says so: the same bar becomes 09:31. A strategy reading the bar labelled 09:31 at 09:31 then knows only the past. closed is a separate matter, which side of the minute a tick at exactly 09:31:00 falls on, and here it stays left.

The bar for 09:32 has no ticks, and so all four columns are NaN. A close for an empty bar can sensibly carry the last price, as the listing does, since that is still the last known price, which gives 11. Open, high and low should stay NaN or be set equal to it, and any volume column should be 0. A strategy must also know that the bar was empty, as a filled price is not a market that was tradable then.

Classic wrong answer. Filling all columns forward without marking them. A filled bar looks like a quiet market, and a volatility estimate then comes out too low.

Follow-up. What of a session boundary? resample makes bars through the night too, so filter to session hours, or group by day, before filling.

Question 2.22 ★★ QR, DS, QD, Analyst • any • junior

What three lines does Listing 2.13 print with pandas 2.3? Say you have two stocks’ prices and one stock skipped a day: which of the three is the bug that produces no error, and what does it do to a pair’s returns?

import pandas as pd

a = pd.Series([1, 2, 3], index=[1, 2, 3])
b = pd.Series([10, 20, 30], index=[2, 3, 4])
print((a + b).tolist())
print((a.values + b.values).tolist())
print(a.add(b, fill_value=0).tolist())
Listing 2.13. Index alignment. code/interviews-3/02-python-and-pandas/python/pp_align.py
Answer

Answer to Question 2.22.

It prints [nan, 12.0, 23.0, nan], [11, 22, 33] and [1.0, 12.0, 23.0, 30.0]. The second line is the bug that raises no error. The first is pandas working as designed: it lines up by index, over the union of the two.

a + b matches labels: at 2 it adds 2 and 10, at 3 it adds 3 and 20, and labels in only one have no partner, so NaN. a.values + b.values throws the labels away and adds by position, so label 1 of one is added to label 2 of the other.

For a pair, that means comparing Monday’s price of one stock with Tuesday’s price of the other, from the missing day onward, for every later day. Spreads stay plausible, and the correlation falls, or an apparent lead-lag appears. There is no NaN and no warning, and shapes match, because the second series has a later day to make up the count.

So never leave the index when there are two series. Reindex both to one calendar, decide what a missing day means, and keep the NaN until you have decided. add(b, fill_value=0) is for quantities, where 0 means none. For prices it makes a nonsense value, and a 100% move.

Classic wrong answer. “NaN means pandas is broken: use .values.” The NaN is the warning that you asked to avoid.

Follow-up. And how do you see this in a large table? Assert that the two indexes are equal before any positional step, or check a.index.difference(b.index).

Question 2.23 ★★ QR, QT, DS • hedge fund, market maker, bank • junior

A backtest takes independent normal daily returns with zero mean, sets signal = np.sign(ret) and computes pnl = signal * ret. What annual Sharpe ratio (mean over standard deviation, times 252\sqrt{252}) do you get, for a long sample? Derive it. What is the one-word fix in pandas, and what does the Sharpe become? How would you make this kind of bug hard to write in a research library?

Answer

Answer to Question 2.23.

You get an annual Sharpe of about 21, on pure noise. The fix is shift: signal.shift(1) * ret, which gives about 0. Today’s return was in the signal, so the backtest trades on tomorrow’s newspaper. Listing 2.26 runs it.

import numpy as np
import pandas as pd


def sharpes(n=25_200, seed=23):
    rng = np.random.default_rng(seed)
    ret = pd.Series(rng.normal(0, 0.01, n))     # pure noise: nothing to find
    signal = np.sign(ret)
    def annual(pnl):
        return pnl.mean() / pnl.std() * np.sqrt(252)
    return annual(signal * ret), annual(signal.shift(1) * ret)
Listing 2.26. A backtest on noise, before and after the shift. code/interviews-3/02-python-and-pandas/python/iv_noise_backtest.py

With r∼N(0,σ2)r \sim N(0, \sigma^2), the profit is sign(r)×r=∣r∣\text{sign}(r) \times r = |r|. Its mean is σ2/π\sigma\sqrt{2/\pi}, and its variance is σ2(1−2/π)\sigma^2(1 - 2/\pi). The daily ratio is 2/π/1−2/π=1.3236\sqrt{2/\pi} / \sqrt{1 - 2/\pi} = 1.3236, and multiplying by 252\sqrt{252} gives 21.0. The result is the same for any σ\sigma, since a Sharpe ratio has no units.

Our simulation over 25 200 days gives 21.1 and then, with the shift, −0.03-0.03. Noise run through the shifted version should show nothing, and that is the test to keep.

A clean bug-finding habit follows. Run every strategy on shuffled or simulated noise. Anything well above zero there has a data leak.

To make it hard to write, give the research library a single place that joins signals and returns, one that applies the lag itself, so a strategy can only return positions decided with data up to each date. Then a look-ahead has to be written on purpose.

Classic wrong answer. “A Sharpe of 21 means a great strategy, so check the costs.” Any annual Sharpe far above 3 or 4 on daily data should make you look for a bug first.

Question 2.24 ★★ QR, DS, QD, Analyst • any • junior

What four lines does Listing 2.14 print with pandas 2.3? A colleague runs prices.fillna(0) before computing returns. What goes wrong, and what would you do instead?

import numpy as np
import pandas as pd

s = pd.Series([1.0, np.nan, 3.0])
print(s.sum(), s.mean(), s.count(), len(s), round(s.fillna(0).mean(), 2))
print(pd.Series([np.nan]).sum(), pd.Series([np.nan]).sum(min_count=1))
print(pd.DataFrame({"k": ["A", None, "A"], "v": [1, 2, 3]}).groupby("k").v.sum().to_dict())
print((s == np.nan).any())
Listing 2.14. NaN in pandas. code/interviews-3/02-python-and-pandas/python/pp_nan_pandas.py
Answer

Answer to Question 2.24.

It prints 4.0 2.0 2 3 1.33, then 0.0 nan, then {'A': 4}, then False.

sum, mean and count all skip NaN by default, so the mean is 4/24 / 2, and count is 2 while len is 3. fillna(0) first changes the meaning: the mean is now 4/34 / 3. The sum of only NaN is 0, by the same skipping, which can make a missing day look like a real zero; min_count=1 makes it NaN.

groupby drops rows with a missing key by default, so the row with value 2 disappears from the total, with no warning. Use dropna=False to keep it. And s == np.nan is false everywhere, so use s.isna().

prices.fillna(0) says that the price fell to zero. The return on that day is −100%-100\%, and on the next day it is a division by zero, which pandas gives as inf. Sums and means of those columns are then useless. For prices, leave the NaN, or carry the last price forward with ffill if a stale price is acceptable, and say so. Compute returns after that step, and do not fill the returns with the mean, as that invents data.

Classic wrong answer. “NaN is the same as zero in a sum, so filling is harmless.” It is the same in a sum, and it is not the same in a mean, a count, a product or a division.

Question 2.25 ★★ QD, DS, QR • any • junior, experienced

A table has 100 million rows: symbol, a string with 4 000 distinct values, price, a float, and qty, an integer whose range depends on the data. In pandas 2.3, which types would you choose to save memory? About how much does each column take? A colleague suggests float32 for prices and for running volume totals. Show what that breaks.

Answer

Answer to Question 2.25.

Use category for symbol, keep price as float64, and hold qty as int64 or int32 depending on its range. Prices take 800 MB, quantities 800 MB or 400 MB, and symbols 200 MB, against about 1 GB for a plain string column from read_csv and about 6 GB with a string per row.

A plain string column holds 8 bytes per row for the pointer, plus the string objects. Built from Python’s csv module, each row gets its own string, of about 53 bytes for a short one on CPython 3.10, so that comes to about 6 GB. The C parser of pd.read_csv shares equal strings, so there it is nearer 1 GB, though memory_usage(deep=True) still reports about 6 GB, as it counts each element. category stores each distinct string once, and a small integer code per row. For 4 000 values the code is int16: 2 bytes a row, or 200 MB. In our test on 200 000 rows with a string per row it is more than ten times smaller, and against read_csv’s shared strings it saves about four times.

A float32 has 24 bits of mantissa. A price of 101.27 reads back as 101.26999664…, so an equality with a tick grid fails, and a float price near 10 000 has a step of about 0.001. For totals it is much worse. Above 224=16 777 2162^{24} = 16\,777\,216, not every integer exists, so adding 1 to that value leaves it unchanged: a running volume simply stops growing, with no error.

Classic wrong answer. “Downcast everything to the smallest type that fits today.” The next day’s data can overflow an int32 with no warning, as array arithmetic wraps.

Follow-up. What if the strings have few repeats? Then category is worse: it stores all the strings and the codes as well. It suits low-cardinality columns.

Question 2.26 ★★ QD, Dev, QR • any • junior

Write pytest tests for a function that computes a volume-weighted price per group, using parametrize. Why does 0.1 + 0.2 == 0.3 fail, and what does pytest.approx allow by default? Does 1e-13 == pytest.approx(0) pass, and does 1e-9 == pytest.approx(0)? Which edge cases do you add?

Answer

Answer to Question 2.26.

Listing 2.27 is such a test file, and it passes against the function. 0.1 + 0.2 is 0.30000000000000004, since none of the three values has an exact binary form. pytest.approx allows a relative tolerance of 10−610^{-6} or an absolute one of 10−1210^{-12}, whichever is larger. So 1e-13 == approx(0) passes, and 1e-9 == approx(0) fails.

import math

import pandas as pd
import pytest
from iv_vwap import vwap_by_symbol_day


def table(*rows):
    return pd.DataFrame(rows, columns=["symbol", "day", "price", "qty"])


@pytest.mark.parametrize("rows, want", [
    ([("A", 1, 10.0, 100)], 10.0),                       # one trade
    ([("A", 1, 10.0, 100), ("A", 1, 11.0, 300)], 10.75), # weighted, not 10.5
    ([("A", 1, 0.1, 1), ("A", 1, 0.2, 1)], 0.15),        # needs approx
])
def test_one_group(rows, want):
    got = vwap_by_symbol_day(table(*rows))
    assert got.loc[("A", 1)] == pytest.approx(want)


def test_zero_quantity_is_not_a_number():
    assert math.isnan(vwap_by_symbol_day(table(("A", 1, 10.0, 0))).loc[("A", 1)])
Listing 2.27. One test body, many cases. code/interviews-3/02-python-and-pandas/python/vwap_checks.py

parametrize runs one body over a table of cases, and it reports each as a separate test, so one failing row does not hide the others. The cases are chosen to fail a wrong model: 10.75 fails the plain mean, as that gives 10.5.

A relative tolerance is useless near zero, because 10−610^{-6} of zero is zero, and this is why the absolute limit exists. Against a target of 0, anything up to 10−1210^{-12} passes, and a value like 10−910^{-9} fails even if it is only rounding noise, so pass abs= when the scale calls for it.

Add the cases that a table of ordinary rows misses: zero quantity, one row, an empty table, several groups at once, and a group that comes in two separated blocks. Also check a limit with a known answer: if all prices are equal, the average is that price whatever the sizes.

Classic wrong answer. assert abs(a - b) < 1e-9 everywhere. On a value near 101210^{12} that is tighter than the type can express, so a correct calculation fails, and near 10−1210^{-12} it passes anything.

Question 2.27 ★★ QD, Dev, QR • any • junior

What four lines does Listing 2.15 print on CPython 3.10? A fee must be rounded to cents with halves going up, and the value is 2.675 exactly. How do you do it?

from decimal import ROUND_HALF_UP, Decimal

print(round(2.5), round(3.5), round(2.675, 2))
print(Decimal(2.675))
print(Decimal(0.1) == Decimal("0.1"))
print(Decimal("2.675").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
Listing 2.15. Rounding. code/interviews-3/02-python-and-pandas/python/pp_round.py
Answer

Answer to Question 2.27.

It prints 2 4 2.67, then the exact double, 2.67499999999999982…, then False, and then 2.68. Python’s round sends halves to the even neighbour, and 2.675 as a double is not a half at all.

Round half to even keeps long columns of rounded values from drifting upward, so round(2.5) is 2 and round(3.5) is 4. NumPy’s round also sends exact halves to the even neighbour, so “it rounds up” is wrong on both. With decimals it scales first, so on NumPy 2.2 np.round(2.675, 2) is 2.68, where Python gives 2.67.

round(2.675, 2) would be a tie only if the input really were 2.675. The nearest double lies just below it, as the second line shows, so the correct rounding of that double is 2.67. Decimal(2.675) converts the double exactly and shows you the problem, which is why Decimal(0.1) does not equal Decimal('0.1').

So the fee must come in as text, or as an integer number of smaller units. Decimal('2.675') holds the exact value, and quantize with ROUND_HALF_UP gives 2.68. That is the fourth line. State the rounding rule in the code, since a fee schedule or a rule book sets it, and not the language.

Classic wrong answer. round(x + 0.005, 2), or adding a small epsilon. It works on some values and breaks on others, and it adds a bias, which over a day’s fills can become real money.

Follow-up. What about a hot path? Keep integers in smaller units there, and use Decimal at the edges, where amounts are reported or billed.

Question 2.28 ★★ QR, QD, LLE • any • junior, experienced

On CPython 3.10, what do sum([1e16, 1.0, -1e16]) and math.fsum([1e16, 1.0, -1e16]) return, and why? Write your own compensated sum, in one pass with constant memory. Does the classic Kahan method get that list right?

Answer

Answer to Question 2.28.

sum returns 0.0, and math.fsum returns 1.0, the true answer. Doubles near 101610^{16} are 2 apart, so 1016+110^{16} + 1 rounds back to 101610^{16}, and the 1 is gone before the subtraction. Listing 2.28 is Neumaier’s form of compensated summation, which also returns 1.0. Classic Kahan returns 0.0 on this list.

def neumaier(xs):
    total = lost = 0.0
    for x in xs:
        t = total + x
        if abs(total) >= abs(x):
            lost += (total - t) + x   # the low bits of x that the addition dropped
        else:
            lost += (x - t) + total
        total = t
    return total + lost
Listing 2.28. Keeping what the addition rounds away. code/interviews-3/02-python-and-pandas/python/iv_exact_sum.py

Each addition loses some low bits, and those bits can be computed exactly: for two doubles, the larger minus the rounded total, plus the smaller, is exactly what was dropped. The listing adds those to a separate small total, and only combines the two at the end.

Kahan folds the correction into the next input, so the next value, −1016-10^{16}, with its correction of +1+1 sits at −1016+1-10^{16} + 1, which rounds back. The loss is real: its correction assumes that the running total is larger than each new term, and here the new term is as large as the total.

Neither is exact in all cases, and fsum is: it keeps enough partial sums to give the correctly rounded result, at some cost in speed. On a million copies of 0.1, plain sum is off by about 1.3×10−61.3 \times 10^{-6}, while fsum and the listing both give 100 000.0.

Classic wrong answer. “Sort the numbers first.” Adding from small to large helps for values of one sign, but it does nothing here, where the two large values cancel only after the small one has been lost.

Follow-up. Does a newer Python change this? Yes: from 3.12 the built-in sum on floats uses a more accurate algorithm, so the first answer can change with the version.

Question 2.29 ★★ QD, Dev, LLE • crypto, market maker • junior, experienced

An asyncio process has a heartbeat coroutine that must send every 10 ms, and a handler that sometimes does 200 ms of blocking work inside a coroutine. What happens to the heartbeat, and why? Fix it for blocking input or output, and for pure computation. What happens if you call a coroutine and forget the await?

Answer

Answer to Question 2.29.

The heartbeat stops for the whole 200 ms. An event loop is cooperative: on one thread, one coroutine runs until it reaches an await that yields, and blocking work has none. Listing 2.29 shows this as an order of events: with blocking, all the work runs before the next beat.

import asyncio


async def heartbeat(log):
    for _ in range(3):
        log.append("beat")
        await asyncio.sleep(0)


def blocking_work(log):
    log.append("work")


async def handler_blocks(log):
    for _ in range(3):
        blocking_work(log)            # no await: nothing else can run during this


async def handler_yields(log):
    for _ in range(3):
        await asyncio.to_thread(blocking_work, log)


async def main(handler):
    log = []
    await asyncio.gather(heartbeat(log), handler(log))
    return log
Listing 2.29. A handler that blocks and one that yields. code/interviews-3/02-python-and-pandas/python/iv_async.py

On the test’s log, handler_blocks produces one beat, three pieces of work and then two beats. No clock is needed to show it. An exchange that sees no heartbeat for that long may disconnect the session, and every other coroutine has stalled as well, including the ones reading market data.

For blocking input or output, use an asynchronous library, or asyncio.to_thread, which runs the call on a worker thread and yields to the loop while waiting. Since the lock is released during a real wait, the loop keeps running. For pure Python computation that does not help for long, because of the interpreter lock: the thread still competes with the loop. So send it to a process with run_in_executor and a ProcessPoolExecutor.

Classic wrong answer. “Add await asyncio.sleep(0) at the end of the function.” That yields once, after the damage is done. Sprinkling it through the work does help, and it splits the delay into smaller pieces, at the price of cluttering the code.

Follow-up. And calling a coroutine without await? It creates a coroutine object and runs nothing. When that object is collected, Python warns “was never awaited”. The order simply never goes out.

Question 2.30 ★★★ QR, DS, QD • any • junior, experienced

A table holds 10 million rows of symbol, x (a factor return) and y (the stock’s return), for 5 000 symbols, with NaN in either column. In pandas, without apply and without a loop over symbols, compute the least-squares slope of y on x per symbol. For a symbol whose rows are (1, 2), (2, 4), (3, 6) and (100, NaN), what is the right slope? What do you get if you demean each column on its own and then sum, letting pandas skip the NaN? What should a symbol with one row return?

Answer

Answer to Question 2.30.

The right slope is 2, using the three complete rows, and Listing 2.30 computes it. Demeaning each column on its own gives 0.00056. A symbol with one row returns NaN, as does one with a constant x.

import pandas as pd


def group_betas(df: pd.DataFrame) -> pd.Series:
    d = df.dropna(subset=["x", "y"])          # a row counts only if it has both
    means = d.groupby("symbol")[["x", "y"]].transform("mean")
    dx, dy = d["x"] - means["x"], d["y"] - means["y"]
    by = d["symbol"]
    sxx = (dx * dx).groupby(by).sum()
    flat = sxx <= 1e-24 * (d["x"] * d["x"]).groupby(by).sum()    # constant x, up to rounding
    return ((dx * dy).groupby(by).sum() / sxx).mask(flat)
Listing 2.30. One slope per symbol, from group sums. code/interviews-3/02-python-and-pandas/python/iv_group_ols.py

The slope is

∑(x−xˉ)(y−yˉ)/∑(x−xˉ)2,\sum (x - \bar x)(y - \bar y) \Big/ \sum (x - \bar x)^2,

with both means taken over the rows used. transform('mean') gives each row its group’s mean, and the rest is arithmetic on columns plus two group sums, all in compiled code.

The error comes from the row (100, NaN). Column by column, pandas takes xˉ=26.5\bar x = 26.5 over all four rows and adds the 100 into the denominator, while the numerator skips that row because its product is NaN. So the two sums cover different rows, and nothing warns you. Dropping incomplete rows first makes every sum cover the same rows.

A slope from one row or from a constant x has no meaning, and NaN says so. Both would be 0/00 / 0 in exact arithmetic, but in floats a group’s mean can be off by one bit, and a constant x then gives a finite slope of pure rounding. So the listing also returns NaN where the sum of dx2dx^2 is too small, relative to the sum of x2x^2, to be anything else. A filter on a minimum row count belongs after this step, as a slope from 3 rows prints just like a slope from 3 000.

Classic wrong answer. groupby('symbol').apply(lambda g: np.polyfit(g.x, g.y, 1)). It is correct on clean data and runs a Python function 5 000 times; a NaN in a group spoils that group’s fit.

Follow-up. How do you get the intercept? means.y - beta * means.x, taken per symbol over the same complete rows.

Question 2.31 ★★★ QR, DS, QD, QT • hedge fund, market maker • junior, experienced

A quote table has t, symbol, bid and ask, across several days. Some rows have a zero side or a crossed market, some share a timestamp, some arrive out of order, and one mid of 1 000 sits among mids of 100. In pandas, write a cleaning step and then one-minute log returns of the mid, per symbol, with no return across a night. In which order do the steps go? Why must an outlier test not use a centred window? If you reject a mid more than 5% from the median of the 20 before it, what happens to a true jump of 6%?

Answer

Answer to Question 2.31.

Remove impossible rows first, then sort, then remove duplicates, then test outliers against past values only, and resample within each symbol and day, as in Listing 2.31. With a limit of 5% on a median of 20, a true jump of 6% loses its first 10 ticks, and the rest pass.

import numpy as np


def clean(q, window=20, limit=0.05):
    q = q[(q.bid > 0) & (q.ask > q.bid)]                   # bad sides, locked, crossed
    q = q.sort_values(["symbol", "t"], kind="stable")
    q = q.drop_duplicates(["symbol", "t"], keep="last")
    q = q.assign(mid=(q.bid + q.ask) / 2)
    past = q.groupby("symbol").mid.transform(              # the past only: no centring
        lambda s: s.rolling(window, min_periods=5).median().shift(1))
    return q[~((q.mid / past - 1).abs() > limit)]          # NaN compares false: kept


def minute_returns(q, stale_minutes=5):
    q = q.assign(day=q.t.dt.normalize()).set_index("t")
    keys = ["symbol", "day"]
    last = q.groupby(keys).mid.resample("1min", label="right", closed="left").last()
    filled = last.groupby(level=keys).ffill(limit=stale_minutes)
    return np.log(filled).groupby(level=keys).diff()       # never across a night
Listing 2.31. Cleaning quotes, then one-minute returns. code/interviews-3/02-python-and-pandas/python/iv_clean_ticks.py

The order matters. A zero side left in would pull the median, and a late row left unsorted would be compared with the wrong neighbours. keep='last' after a stable sort keeps the last to arrive at one timestamp, which is a choice worth stating.

A centred window compares a tick with ticks that came after it. In a backtest, that removes bad prints using knowledge you did not have then, and it makes the cleaned history smoother than anything you could trade.

The day key stops diff from crossing a night. The carry forward has a limit of 5 minutes, so a stale mid is not presented as a quiet market for hours.

The first five ticks of a symbol have no past median and pass unchecked, so a bad print there survives. Test them against a reference price, such as the previous close.

On the jump, the past median stays at the old level until 10 of the last 20 mids are new. Those ticks look like errors, so a real move and a bad print start out alike. So count rejections per symbol and alert on them, and never let the filter run silently, since a long run of rejects is news.

Classic wrong answer. Fixing the outlier by capping returns at the end. The bad mid then gives two capped returns, one up and one down, and they look like volatility.

Question 2.32 ★★★ QD, QR, LLE, QT • any • junior, experienced

Values arrive one at a time. Write an exponentially weighted mean with constant memory per stream that matches pandas 2.3’s ewm(alpha=a).mean() exactly, NaN included. For a=0.1a = 0.1 and the values 1, 2, 3, what does that give at the third value, and what does adjust=False give? When do the two agree? Now ticks arrive at uneven times: change it so that a weight halves every fixed period of time. What does your version do when two ticks share a timestamp, and after a gap of a day?

Answer

Answer to Question 2.32.

With a=0.1a = 0.1, the default gives 2.0701 at the third value and adjust=False gives 1.29. Listing 2.32 keeps two numbers per stream and matches pandas in the tests, including on NaN. The two forms agree once (1−a)n(1 - a)^n is negligible.

class Ewma:
    """Same numbers as pandas ewm(alpha=alpha, adjust=True, ignore_na=False)."""

    def __init__(self, alpha):
        self.keep = 1.0 - alpha
        self.num = self.den = 0.0
        self.mean = math.nan

    def update(self, x):
        self.num *= self.keep
        self.den *= self.keep
        if not math.isnan(x):
            self.num += x
            self.den += 1.0
            self.mean = self.num / self.den
        return self.mean


class TimeEwma:
    """Weights fall by half every halflife, whatever the spacing of the ticks."""

    def __init__(self, halflife):
        self.rate = math.log(2.0) / halflife
        self.num = self.den = 0.0
        self.t, self.mean = None, math.nan

    def update(self, t, x):
        if self.t is not None:
            keep = math.exp(-self.rate * (t - self.t))
            self.num *= keep
            self.den *= keep
        self.t = t
        if not math.isnan(x):                 # a NaN ages the weights, adds nothing
            self.num += x
            self.den += 1.0
            self.mean = self.num / self.den
        return self.mean
Listing 2.32. Two numbers of state per stream. code/interviews-3/02-python-and-pandas/python/iv_ewma.py

The default is a true weighted mean, ∑i(1−a)ixn−i/∑i(1−a)i\sum_i (1 - a)^i x_{n-i} / \sum_i (1 - a)^i. Here that is (3+0.9×2+0.81×1)/2.71=5.61/2.71(3 + 0.9 \times 2 + 0.81 \times 1) / 2.71 = 5.61 / 2.71. Numerator and denominator each have a one-line update, which is the whole trick. adjust=False is the recursion yn=(1−a)yn−1+axny_n = (1 - a) y_{n-1} + a x_n started at x0x_0, so the first value keeps a weight of (1−a)n(1 - a)^n and not the small share it would get in a long history. In the test the two differ by less than 10−610^{-6} after 200 values.

For a NaN, pandas ages the old weights and adds nothing, which the first class copies. The mean itself does not move at that step, but the next value counts for more.

For uneven times, use a decay of e−λ Δte^{-\lambda \, \Delta t} with λ=ln⁡2/h\lambda = \ln 2 / h. The second class matches ewm called with halflife and times, in the test, NaN included. At the extremes it behaves sensibly. A shared timestamp has Δt=0\Delta t = 0, so the two ticks get equal weight. After a gap much longer than the half-life, the old weights have almost vanished, so the mean jumps to the new tick.

Classic wrong answer. The recursion with a fixed aa on uneven ticks. A burst of 100 ticks in a millisecond then wipes out the past, which makes the mean depend on message rate.

Question 2.33 ★★★ QR, QD, Risk • any • junior, experienced

With NumPy, draw nn samples of kk standard normals with correlation matrix CC. A colleague computes L = np.linalg.cholesky(C), draws z with one sample per row, and returns z @ L. For two variables with correlation 0.8, what covariance matrix do the samples have? Give the correct line and prove it. What happens with pairwise correlations 0.9, 0.9 and −0.9-0.9, why can no data produce them, and what do you do? What happens with a correlation of exactly 1?

Answer

Answer to Question 2.33.

z @ L has covariance L⊤LL^\top L, which for 0.8 is variances 1.64 and 0.36 with covariance 0.48. The correct line is z @ L.T, as in Listing 2.33. The matrix with 0.9, 0.9 and −0.9-0.9 makes cholesky raise LinAlgError, and so does a correlation of exactly 1.

import numpy as np


def correlated_normals(corr, n, rng):
    lower = np.linalg.cholesky(corr)               # corr = lower @ lower.T
    z = rng.standard_normal((n, len(corr)))        # one row per draw
    return z @ lower.T


def nearest_correlation(corr):
    """Clip negative eigenvalues and rescale: valid, close, not the true nearest."""
    w, v = np.linalg.eigh(corr)
    fixed = v @ np.diag(np.clip(w, 1e-8, None)) @ v.T
    d = np.sqrt(np.diag(fixed))
    return fixed / np.outer(d, d)
Listing 2.33. Correlated normals, and a repair. code/interviews-3/02-python-and-pandas/python/iv_correlated.py

Take a row zz with covariance II. Then x=zL⊤x = z L^\top has Cov(x)=L I L⊤=C\text{Cov}(x) = L\, I\, L^\top = C. The usual statement x=Lzx = L z is the same thing with zz as a column, which is how the mistake arises. For 0.8, LL has rows (1,0)(1, 0) and (0.8,0.6)(0.8, 0.6). Both versions give a positive correlation, 0.8 against 0.625, and the wrong one also breaks the unit variances, so testing the sample covariance catches it in one line.

If A moves closely with B and with C, then B and C cannot move oppositely. That shows in the eigenvalues, which here are −0.8-0.8, 1.9 and 1.9. A variance of a portfolio cannot be negative, so no data can give this matrix if all pairs use the same dates. Such matrices arise from pairs estimated over different windows, or from hand edits. So look at the source first. A quick repair clips the negative eigenvalues and rescales the diagonal to 1, which here gives 0.5, 0.5 and −0.5-0.5. That is a big change, and a warning about the inputs.

At a correlation of 1, the matrix is singular and the factor has a zero pivot. Then use one normal for both variables, or draw from the eigenvectors with λ\sqrt{\lambda}.

Classic wrong answer. “Add a small number to the diagonal.” That rescues a matrix just short of valid, and does nothing for an eigenvalue of −0.8-0.8 until the correlations are diluted beyond use.

Question 2.34 ★★★ QR, QD, Risk • bank, hedge fund, market maker • junior, experienced

Under Black–Scholes, take spot and strike both at 100, a 5% rate, 20% volatility and one year: the call is worth 10.4506. Price it by Monte Carlo in NumPy, with antithetic pairs and a standard error, so that the path count is not limited by memory. What goes wrong if you treat the two members of each pair as independent when computing the error? Is the error then too large or too small, and does that depend on the payoff? Give one exact check on your code that needs no closed-form option price.

Answer

Answer to Question 2.34.

Listing 2.34 gives 10.4585 with a standard error of 0.0073, on a million pairs: 1.1 errors from the true value. Treating the two members as independent gives 0.0104 here, too large. On a nearly symmetric payoff, such as a straddle, it is too small, which is the dangerous direction.

import numpy as np


def mc_call(spot, strike, rate, vol, t, pairs, rng, chunk=500_000):
    """Antithetic estimate of a European call: returns (price, standard error)."""
    drift = (rate - 0.5 * vol * vol) * t
    root = vol * np.sqrt(t)
    n, total, squares = 0, 0.0, 0.0
    while n < pairs:                               # memory is bounded by chunk
        m = min(chunk, pairs - n)
        z = rng.standard_normal(m)
        up = np.maximum(spot * np.exp(drift + root * z) - strike, 0.0)
        down = np.maximum(spot * np.exp(drift - root * z) - strike, 0.0)
        one = 0.5 * (up + down)                    # one sample per pair
        total += one.sum()
        squares += (one * one).sum()
        n += m
    mean = total / pairs
    var = max(squares / pairs - mean * mean, 0.0)
    disc = np.exp(-rate * t)
    return disc * mean, disc * np.sqrt(var / pairs)
Listing 2.34. Chunks of antithetic pairs, one sample per pair. code/interviews-3/02-python-and-pandas/python/iv_mc_call.py

The pair average is the independent sample. So the variance must come from those averages, over the number of pairs, which is what the listing does. For the call, up and down are negatively correlated: a high zz pays and a low zz pays nothing. So averaging the pair removes more variance than the naive formula knows, and the naive error is too large. A straddle at the forward pays on both sides, so the members are positively correlated, and the pair carries less information than two draws. The naive error then promises more accuracy than you have, and the test confirms it on such a payoff.

Memory stays bounded because only running totals survive each chunk, so more paths cost time alone. With a path-dependent payoff, the same idea applies to the steps. A full table of a million paths and 252 steps takes about 2 GB, while a loop over steps keeps only the current state.

One exact check needs no option formula: at zero volatility, the price must be S−Ke−rTS - K e^{-rT} when in the money, with an error of rounding size. A second check is statistical: at strike 0, the call is the stock, so the estimate must lie within a few errors of the spot, which tests the drift.

Classic wrong answer. Judging by whether the price is near the closed form. One seed proves little, so check that the gap stays near one error over many seeds and that the error falls as 1/n1 / \sqrt{n}.

Question 2.35 ★★★ QD, QT, QR • any • junior, experienced

Signed orders arrive, and each is cut so that the position stays within ±L\pm L; the next order starts from the cut position. A colleague computes the position as np.clip(np.cumsum(orders), -L, L). Give the smallest example you can where that is wrong. Write it correctly. Can the two-sided version be vectorised? If only an upper cap applies, is there a closed form with cumsum?

Answer

Answer to Question 2.35.

With orders 9, −1-1 and L=8L = 8, the clipped sum gives 8, 8, and the true position is 8, 7. A single order can never show it, so two is the smallest. A cut changes every later position, while cumsum remembers the uncut total. Listing 2.35 has the correct loop.

import numpy as np


def capped_position(trades, limit):
    """Each order is cut to fit; the next one starts from the cut position."""
    pos, out = 0, []
    for q in trades:
        pos = max(-limit, min(limit, pos + q))
        out.append(pos)
    return out


def wrong_clip(trades, limit):
    return np.clip(np.cumsum(trades), -limit, limit).tolist()


def capped_above(trades, limit):
    """A cap on one side only has a closed form."""
    c = np.cumsum(trades)
    return (c - np.maximum(np.maximum.accumulate(c) - limit, 0)).tolist()
Listing 2.35. A cap changes everything after it. code/interviews-3/02-python-and-pandas/python/iv_position.py

The first order is cut from 9 to 8, so one share is never bought. The running sum still counts it, so after −1-1 it says 8 when the position is 7. All it takes is one cut and one reversal.

The two-sided version is path-dependent, since each step needs the last result, so it does not reduce to an array operation in the usual way. Keep the loop, which is clear and tested, and if it shows up on a profile, compile it. That is a fine answer: vectorising is a means, and a wrong fast version is no gain.

With only an upper cap, there is a closed form. Let cc be the cumulative sum. All the amount cut so far is max⁡(0,max⁡j≤icj−L)\max(0, \max_{j \le i} c_j - L), so the position is cic_i minus that, which is capped_above in the listing. The cut total grows only when cc sets a new high above LL. The test compares it with the loop on random orders, and at LL larger than all sums it reduces to cc itself.

Classic wrong answer. Clipping each order to ±L\pm L before the cumsum. That caps the order and not the position, so the sum can still run far past LL.

Follow-up. And with pandas? Series.clip has the same flaw, since it also works on the finished sum.

Question 2.36 ★★★ QD, Dev, LLE • any • junior, experienced

On CPython 3.10, a service builds a new Pricer per request, as in Listing 2.16. Memory grows and never falls, and gc.collect() frees nothing. What holds the objects? After 50 requests, what does Pricer.price.cache_info() report, and why is the cache useless as well as harmful? How do you find this kind of growth in a live process, and what can your tool not see? Fix it. Is maxsize=128 a fix?

from functools import lru_cache


class Pricer:
    def __init__(self, n):
        self.curve = [0.01] * n

    @lru_cache(maxsize=None)
    def price(self, strike):
        return strike * self.curve[0]


def one_request(n=10_000):
    return Pricer(n).price(100)
Listing 2.16. A cache on a method. code/interviews-3/02-python-and-pandas/python/iv_growth.py
Answer

Answer to Question 2.36.

The cache holds them. lru_cache on a method makes self part of the key, and with no bound it keeps every key forever. After 50 requests, cache_info reports 0 hits, 50 misses and a size of 50. Every request has a new self, so a stored result can never be found again.

gc.collect() frees nothing since these are not lost cycles. They are reachable from the function object on the class, which lives as long as the process. In our test a weakref to a finished Pricer is still alive afterwards.

To find growth, compare two tracemalloc snapshots taken some way apart. Here the comparison over 50 requests puts 4.0 MB on the single line that builds curve, and that points to what is kept. It shows where a block was allocated, not who holds it, so gc.get_referrers or a heap tool then answers that part.

It sees only memory requested through Python’s allocators, so a C library calling malloc directly stays invisible. Also, a falling total inside Python need not reduce the process’s resident size, as freed memory can stay with the allocator. So compare the traced total with the resident size: if only the second grows, look below Python.

The fix is to cache a function of plain values at module level, with a bound, so that self is not in the key. Or give each instance its own dictionary, which dies with it. In the test, either version lets the instance go.

maxsize=128 is a bound and not a fix. Memory stops growing at 128 pricers, each still held for nothing, and the hit count stays at 0.

Classic wrong answer. “Python has a garbage collector, so this cannot leak.” It collects only what is unreachable.

Question 2.37 ★★★ QR, QD, DS • any • junior, experienced

A backtest sets its random seed and reads the same data, yet two runs print different numbers. A reduced form is Listing 2.17. On CPython 3.10, why can two runs of this program print different lines, in both print statements? Would the same happen with integer symbol codes? How do you fix it, and why is setting PYTHONHASHSEED the wrong fix? Which other sources of this problem would you check?

scores = {"AAA": 1.0, "BBB": 1.0, "CCC": 1.0, "DDD": 1.0}
weights = {"AAA": 1e16, "BBB": 1.0, "CCC": -1e16, "DDD": 0.0}
symbols = set(scores)
print(max(symbols, key=scores.get))
print(sum(weights[s] for s in symbols))
Listing 2.17. Two runs, two answers. code/interviews-3/02-python-and-pandas/python/pp_hashseed.py
Answer

Answer to Question 2.37.

String hashes are randomised per process, so the iteration order of a set of strings changes from run to run. The first line depends on order because all four symbols tie and max keeps the first it meets. The second depends on order because the floating-point sum absorbs the 1.0 or not, depending on when it comes. Integer hashes are not randomised, so codes would not show this.

In the tests, six values of PYTHONHASHSEED give four winners, and a sum of both 0.0 and 1.0. With a fixed seed, the output repeats. An integer set gives one order across all seeds.

The fix is to stop depending on set order wherever order matters: iterate over sorted(symbols), and state a tie-break in the key, such as (score, symbol). A dictionary is safe, since it keeps insertion order by language rule, as long as what fills it comes in a defined order.

Setting PYTHONHASHSEED makes runs repeat, and that hides the fault. The order is still arbitrary, and a different universe, a Python upgrade or one added symbol changes the result with no code change. It is fine as a diagnostic: if fixing it makes the runs agree, you have found the class of bug.

Check next: parallel work collected in order of completion, directory listings read without sorting, threaded linear algebra, whose rounding can differ with the thread count, and a database query with no ORDER BY. Find the first divergence by logging a hash of the state per day in two runs.

Classic wrong answer. “Set np.random.seed.” It seeds NumPy’s generator and has no bearing on string hashes or set order.

Follow-up. Is a difference in the 15th digit worth chasing? Yes, as tiny gaps can flip a tie or a threshold, and later you cannot tell a code change from noise.

Question 2.38 ★★★★ QR, DS, QD • hedge fund • experienced

A table has date, symbol, price and signal, for 3 000 stocks over 2 500 days, with NaN in signal. For each date, compute the Spearman rank correlation between the signal and the next day’s return of each stock, without apply and without a loop over dates. Why must the next return not come from df.price.shift(-1) on the sorted table? If the signal and the next return are jointly normal with correlation 0.1, what average do you expect? How noisy is one day’s value with 500 stocks? What changes in the t-statistic if the target becomes a five-day return?

Answer

Answer to Question 2.38.

Listing 2.36 computes it with group ranks and group sums, and it matches scipy.stats.spearmanr date by date in the test. For jointly normal variables with correlation 0.1, expect 6πarcsin⁡(0.05)=0.0955\frac{6}{\pi} \arcsin(0.05) = 0.0955. One day’s value with 500 stocks has noise near 1/500=0.0451 / \sqrt{500} = 0.045.

def daily_ic(df):
    """df: date, symbol, price, signal. Rank correlation of signal and next return."""
    df = df.sort_values(["symbol", "date"])
    nxt = df.groupby("symbol").price.shift(-1)           # within a symbol only
    df = df.assign(fwd=nxt / df.price - 1).dropna(subset=["signal", "fwd"])
    r = df.groupby("date")[["signal", "fwd"]].rank()     # ranks among complete rows
    r = r - r.groupby(df["date"]).transform("mean")
    num = (r["signal"] * r["fwd"]).groupby(df["date"]).sum()
    sxx = (r["signal"] ** 2).groupby(df["date"]).sum()
    syy = (r["fwd"] ** 2).groupby(df["date"]).sum()
    return num / (sxx * syy) ** 0.5
Listing 2.36. Rank correlation per date, without a loop over dates. code/interviews-3/02-python-and-pandas/python/iv_ic.py

A rank correlation is a Pearson correlation of ranks, so rank within each date, remove each date’s mean, and form the sum of products over the root of the product of the two sums of squares. Ranking comes after the dropna, so that both columns rank the same rows, for the same reason as in any pairwise statistic.

df.price.shift(-1) on a table sorted by symbol gives the last row of each symbol the first price of the next symbol. That makes a return of something like −40%-40\%, with no NaN to warn you, on one row per symbol. A group shift gives NaN there, and the dropna removes it.

A simulation of 500 stocks and 199 days gives a mean of 0.0955 and a daily spread of 0.041, near that estimate, for a t-statistic of 33, namely the mean divided by the spread over 199\sqrt{199}, that is 0.0955/(0.041/199)0.0955 / (0.041 / \sqrt{199}). So a signal that is small on any one day is plain over a long sample. At the extremes, a signal equal to the return gives exactly 1, its negative gives −1-1, and a constant signal gives NaN.

With a five-day target, neighbouring days share four of their five returns, so the daily values overlap and the plain t-statistic is too high, by about 5\sqrt{5} if the signal changes slowly. So sample every fifth day, or use an error that allows for that overlap.

Classic wrong answer. Averaging IC over stocks, using one time series per stock. That answers a different question: it mixes in each stock’s own level, and needs far more data per stock.

Question 2.39 ★★★★ QD, LLE, DS • any • experienced

With NumPy only, write an as-of join. You get arrays trade_sym, trade_t, quote_sym and quote_t: symbols are integer codes from 0 to 4 999, times are int64 nanoseconds since 1970, near 1.8×10181.8 \times 10^{18}, and nothing is sorted. For each trade, return the row of the latest quote of the same symbol at or before its time, or −1-1. Quotes with equal time and symbol: the last one to arrive wins. There may be no loop over symbols. Why does the key sym * 10**19 + t, or any multiplier large enough for the times, fail? What does your version cost?

Answer

Answer to Question 2.39.

Listing 2.37 replaces times by their ranks across both arrays, builds one integer key per symbol and rank, then does a single binary search per trade. It matches pd.merge_asof and a slow loop on random data. A multiplier large enough for the times overflows int64, whose limit is about 9.2×10189.2 \times 10^{18}.

import numpy as np


def asof(trade_sym, trade_t, quote_sym, quote_t):
    """Row of the latest quote at or before each trade, same symbol; -1 if none."""
    if len(quote_t) == 0:
        return np.full(len(trade_t), -1)
    times, ranks = np.unique(np.concatenate([trade_t, quote_t]), return_inverse=True)
    span = len(times)                      # int64 codes: sym * span cannot overflow
    trade_key = np.asarray(trade_sym, dtype=np.int64) * span + ranks[: len(trade_t)]
    quote_key = np.asarray(quote_sym, dtype=np.int64) * span + ranks[len(trade_t) :]
    order = np.argsort(quote_key, kind="stable")     # equal keys keep arrival order
    i = np.searchsorted(quote_key[order], trade_key, side="right") - 1
    found = order[np.maximum(i, 0)]
    return np.where((i >= 0) & (quote_sym[found] == trade_sym), found, -1)
Listing 2.37. An as-of join from one sort and one search. code/interviews-3/02-python-and-pandas/python/iv_asof_numpy.py

The idea is to reduce two sort keys to one. If the key orders by symbol and then time, a binary search for a trade’s key finds the last quote at or below it, and only one check remains: that this quote has the same symbol. If it has another symbol, then this symbol has no quote at or before the trade, since such a quote would have a key between the two.

With 101910^{19} itself, NumPy 2.2 raises OverflowError, since that does not fit in int64. A multiplier that does fit must still be above 1.8×10181.8 \times 10^{18} for the times, so symbol 5 plus a time, about 1.08×10191.08 \times 10^{19}, already passes the limit, and NumPy wraps with no error, making the order wrong. np.unique with return_inverse fixes that. There are at most m+nm + n distinct times, so the key stays below 5000(m+n)5000 (m + n), and the ranks keep the order, which is all the search needs. The stem gives no type for the codes, so the listing casts them to int64 first: int32 codes would wrap with no error once m+nm + n reaches about a million, and int16 codes raise.

side='right' finds quotes at or before the trade, which is the stem’s rule. With left it would be strictly before. The stable argsort keeps arrival order among equal keys, so the search lands on the last to arrive.

The sorts dominate, at O((m+n)log⁡(m+n))O((m + n) \log (m + n)), and the searches take O(mlog⁡n)O(m \log n). If both inputs arrive sorted, then a merge walk per symbol is linear and it also needs less memory. Here the rank step needs an array of m+nm + n times, and one of ranks.

Classic wrong answer. np.searchsorted on quote_t alone, then a walk backward to the right symbol. On a busy table the walk can pass many quotes of other symbols, so its cost per trade has no bound.

Question 2.40 ★★★★ QR, QD, LLE • any • experienced

x and y are NumPy arrays of daily returns of about 1%, with NaN. For each row, compute the slope of y on x over the last ww rows, using only rows where both are present, or NaN if fewer than min_obs rows qualify. Use no Python loop over rows, and O(n)O(n) time. The usual method differences a cumulative sum. A bad tick then puts 10910^9 in x at row 10 of 1 000. With w=50w = 50, what does that method give at row 999, where the true slope is near 2? Can you avoid the damage and stay O(n)O(n)? What does pandas 2.3.3’s rolling(50).var() show at that row?

Answer

Answer to Question 2.40.

The cumulative-sum method gives a large negative number at row 999, −77-77 on our draw, where the true slope is 2.00. Listing 2.38 avoids this in O(n)O(n), because each window’s sum uses only values inside that window. Pandas 2.3.3’s rolling variance shows a whole multiple of 128/49=2.61128/49 = 2.61 there, −2.6-2.6 on our draw and positive or zero on others, where the true value is about 10−410^{-4}. Its rolling.corr shows NaN, a tiny number, or a value above 1.

import numpy as np


def window_sum(v, w):
    """Sum of the last w values at each row, using only values inside that window."""
    n = len(v)
    a = np.concatenate([v, np.zeros(-n % w)]).reshape(-1, w)
    prefix = np.cumsum(a, axis=1).ravel()
    suffix = np.cumsum(a[:, ::-1], axis=1)[:, ::-1].ravel()
    out = prefix[:n].copy()                     # rows up to w - 1, and ends of blocks
    i = np.arange(w, n)
    i = i[i % w != w - 1]                       # a window spans exactly two blocks
    out[i] = suffix[i - w + 1] + prefix[i]
    return out


def rolling_beta(x, y, w, min_obs):
    """Beta of y on x over the last w rows; a row counts only if both are present."""
    ok = ~(np.isnan(x) | np.isnan(y))
    x0, y0 = np.where(ok, x, 0.0), np.where(ok, y, 0.0)
    n = window_sum(ok.astype(float), w)
    sx, sy = window_sum(x0, w), window_sum(y0, w)
    sxx, sxy = window_sum(x0 * x0, w), window_sum(x0 * y0, w)
    with np.errstate(divide="ignore", invalid="ignore"):
        beta = (n * sxy - sx * sy) / (n * sxx - sx * sx)
    return np.where(n >= min_obs, beta, np.nan)
Listing 2.38. Window sums that forget what left the window. code/interviews-3/02-python-and-pandas/python/iv_rolling_beta.py

Masking both columns first makes all five window sums cover the same rows, and the slope is (nSxy−SxSy)/(nSxx−Sx2)(n S_{xy} - S_x S_y) / (n S_{xx} - S_x^2).

The damage comes from x2=1018x^2 = 10^{18}. Doubles near that size are 128 apart, so once it enters the running total, every later small term is rounded away. Subtracting two such totals gives exactly 0, while the true SxxS_{xx} is near 5×10−35 \times 10^{-3}. The denominator is then −Sx2-S_x^2, which makes the slope large and negative: from −16-16 to −44 000-44\,000 over ten draws. The fault stays in every later row, long after the bad tick has left the window.

The listing cuts the series into blocks of ww. Any window of length ww is then a suffix of one block plus a prefix of the next, and both are plain cumulative sums within a block. So a sum never contains a value from outside its window, and each row costs one addition. In the test it matches a slow fit on every window clear of the bad tick. Windows that contain the tick are still dominated by it, as arithmetic demands, so cleaning remains a separate step.

The pandas figures are what version 2.3.3 does here. Its rolling variance adds and removes values as the window moves, and it leaves a residue of that step on this input, while rolling.cov on these columns is still right. Run a check of this sort on your own library version, because the behaviour may change.

Classic wrong answer. “Use a compensated sum to fix the drift.” It reduces the error, but the bad value still passes through one running state. Restarting at each block removes the dependence.