量化金融 · 第 20 册 · 职业发展

Interviews III: Code

本书仅提供英文版。

Interviews III: Code · 职业发展

7SQL and Data

Writing a query that is right on the awkward rows, and reading one that looks right and is not: joins that lose or multiply rows, the three values of SQL logic, window frames, as-of joins, gaps and bad data. Every table is printed, so you can run the query in your head. A stem names the dialect where the answer depends on it.

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

Here is a table trades, with price a double and qty an integer. Write a query giving, for each symbol and day, the volume-weighted average price, which is the sum of price times quantity divided by the sum of quantity. Include only symbol-days with more than 2 trades. What does it return here?

symboldaypriceqty
AAA2026-03-02100.0200
AAA2026-03-02102.0100
AAA2026-03-02101.0100
BBB2026-03-0250.0400
BBB2026-03-0252.0100
AAA2026-03-03104.0100
Answer

Answer to Question 7.1.

Listing 7.12 groups by symbol and day and filters the groups with HAVING. It returns one row, AAA on 2 March at 100.75. Only that symbol-day has more than 2 trades.

SELECT symbol, day, SUM(price * qty) / SUM(qty) AS vwap
FROM trades
GROUP BY symbol, day
HAVING COUNT(*) > 2;
Listing 7.12. Volume-weighted average price, by symbol and day. code/interviews-3/07-sql-and-data/sql/vwap.sql
symboldayvwap
AAA2026-03-02100.75

The sum of price times quantity is 20 000+10 200+10 100=40 30020\,000 + 10\,200 + 10\,100 = 40\,300, and the sum of quantity is 400, so the result is 100.75. WHERE runs on rows before grouping, so it cannot see a count, and SQLite and DuckDB both reject COUNT(*) there. HAVING runs on the groups.

Classic wrong answer. AVG(price). It gives 101 here, because it weights the 200-share trade the same as the 100-share ones.

Follow-up. What if a symbol-day’s quantities can sum to zero, as with a correction? Write SUM(price * qty) / NULLIF(SUM(qty), 0), which gives NULL there. Without it, SQLite returns NULL, DuckDB returns infinity, or NaN if the numerator is zero too, as with a correction at the same price, and PostgreSQL raises an error, which ends the whole query. Three engines, three behaviours.

Follow-up. And if price were an integer number of ticks? Check the division: on SQLite, integer over integer truncates, and the result would be 100.

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

The left table is orders and the right table is fills. You want every order, with its quantity filled on venue X, or NULL if it has none. What does each query in Listing 7.1 return, and which is right?

order_idsymbol
1AAA
2BBB
3CCC
order_idvenueqty
1X100
1Y50
2Y70
-- Query A
SELECT o.order_id, f.qty
FROM orders o LEFT JOIN fills f ON o.order_id = f.order_id
WHERE f.venue = 'X';

-- Query B
SELECT o.order_id, f.qty
FROM orders o LEFT JOIN fills f ON o.order_id = f.order_id AND f.venue = 'X';
Listing 7.1. Two left joins. code/interviews-3/07-sql-and-data/sql/venue_fills.sql
Answer

Answer to Question 7.2.

Query A returns only one row, order 1 with 100. Query B returns all three orders, and it is the one you want.

order_idqty
1100
2NULL
3NULL

A left join keeps every row of the left table, filling the right side with NULLs where nothing matches, and then WHERE runs on the result. For orders 2 and 3, f.venue is NULL or Y, and NULL = 'X' is not true, so those rows are dropped: the WHERE has silently turned the outer join into an inner one. In query B the venue test is part of the match, so it decides which fills attach, and it cannot remove an order. Order 2 keeps its row with NULL, as its only fill is on Y.

The rule is that a condition on the right table of a left join belongs in ON.

Classic wrong answer. Adding OR f.venue IS NULL to query A. It brings back order 3, which has no fills at all, and still loses order 2, since that order’s row from the join says Y, not NULL. The query looks patched, and it is wrong for exactly the orders that traded elsewhere.

Why it matters. This bug changes a total without any error. A fill report that misses orders on other venues looks like a smaller, plausible number.

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

The left table is orders and the right table is blocked. What does Listing 7.2 return, and why? Rewrite it so that it returns every order whose trader is not in the blocked list. Should order 3 be in your answer?

order_idtrader
1ann
2bob
3NULL
trader
bob
NULL
SELECT order_id
FROM orders
WHERE trader NOT IN (SELECT trader FROM blocked);
Listing 7.2. Orders from traders not blocked. code/interviews-3/07-sql-and-data/sql/not_in.sql
Answer

Answer to Question 7.3.

It returns no rows at all, because blocked contains a NULL. Listing 7.13 is the fix: it returns orders 1 and 3. Whether order 3 belongs depends on what you mean: its trader is unknown, so it is not known to be blocked, and you must decide and say so.

SELECT o.order_id
FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM blocked b WHERE b.trader = o.trader);
Listing 7.13. The anti-join that NULLs cannot break. code/interviews-3/07-sql-and-data/sql/not_exists.sql

SQL logic has three values. 'ann' NOT IN ('bob', NULL) means ann is not bob, and ann is not NULL. The first part is true, and the second is unknown, as nobody knows what NULL stands for. True and unknown is unknown, and WHERE keeps only rows that are true. So one NULL in the list removes every row. SQLite and DuckDB both return that NULL, which you can check by selecting the expression.

NOT EXISTS asks whether a matching row exists, and that always has a yes or no answer. For order 3, b.trader = o.trader never holds, so no row exists, and the order is kept. Order 1 is kept in the same way, and order 2 finds bob and is dropped. To exclude unknown traders as well, add AND o.trader IS NOT NULL. Filtering the subquery with WHERE trader IS NOT NULL instead keeps NOT IN working, and it returns only order 1.

Classic wrong answer. “It returns order 1.” That is what the author expected. The NULL in the blocked list is what stops it, and it can appear in production long after the query was tested.

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

A table quotes has a double column bid, holding the five values 10.0, NULL, 20.0, 20.0, NULL. What do the two statements in Listing 7.3 return, value by value? Which of the last two columns of the first statement is the average bid?

SELECT COUNT(*), COUNT(bid), COUNT(DISTINCT bid), SUM(bid), AVG(bid), SUM(bid) / COUNT(*)
FROM quotes;

SELECT COUNT(*) FROM quotes WHERE bid = NULL;
Listing 7.3. Counting with NULLs. code/interviews-3/07-sql-and-data/sql/counts.sql
Answer

Answer to Question 7.4.

The first statement returns 5, 3, 2, 50, 16.67, 10, and the second returns 0. The fifth column, AVG(bid), is the average of the known bids, 16.67. The sixth treats the unknown bids as zero, and whether that is right depends on what a NULL means here.

COUNT(*) counts rows. COUNT(bid) counts non-NULL values, and COUNT(DISTINCT bid) counts different non-NULL values: 10 and 20. SUM and AVG skip NULLs too, so the sum is 50 and the average is 50/350 / 3, while SUM(bid) / COUNT(*) is 50/550 / 5. AVG(COALESCE(bid, 0)) also gives 10, and it says openly that a NULL counts as zero.

The second statement counts rows where bid = NULL. That comparison is unknown for every row, even the NULL ones, so nothing qualifies. The test for a missing value is bid IS NULL, which counts 2.

Classic wrong answer. “COUNT(DISTINCT bid) is 3, counting NULL as a value.” Aggregates skip NULLs, whereas SELECT DISTINCT and GROUP BY treat all NULLs as one value. That difference catches people out.

Follow-up. What does SUM return on a table with no rows, or only NULLs? NULL, not 0, so a report wants COALESCE(SUM(x), 0). COUNT returns 0.

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

A table fills has two rows, with integer columns filled and ordered holding (30, 100) and (45, 50). What does Listing 7.4 return on SQLite, on DuckDB and on PostgreSQL? Write a version that returns the same on all three.

SELECT SUM(filled) / SUM(ordered) AS ratio FROM fills;
Listing 7.4. The share of the order filled. code/interviews-3/07-sql-and-data/sql/fill_ratio.sql
Answer

Answer to Question 7.5.

SQLite and PostgreSQL return 0. DuckDB returns 0.5. Listing 7.14 returns 0.5 on all three, and casting to double does too.

SELECT SUM(filled) * 1.0 / SUM(ordered) AS ratio FROM fills;
Listing 7.14. The same ratio, with a non-integer in it. code/interviews-3/07-sql-and-data/sql/fill_ratio_fixed.sql

The sums are 75 and 150, both integers. In SQLite and PostgreSQL, integer divided by integer truncates, so 75/15075 / 150 is 0: PostgreSQL’s documentation gives 5 / 2 as 2. DuckDB’s / is floating-point division, and its // is the integer one, so 7 / 2 is 3.5 there, where SQLite gives 3. The SQLite and DuckDB results were checked by running them, and the PostgreSQL result comes from its manual.

Multiplying by 1.0 first makes the numerator non-integer, and the order matters: 1.0 * (a / b) has already truncated. A query moved between engines keeps running and silently changes its answer, so the portable habit is an explicit cast in every ratio.

Classic wrong answer. “SQL has one rule for division.” Each engine has its own. It is the classic reason a query gives a different number after a migration.

Why it matters. A fill ratio of 0 for all but fully filled orders looks like an odd business fact, and it may take days to be traced to the type of a column.

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

A feed sometimes sends a trade again, and sometimes corrects it by sending the same trade_id with a new quantity. In the table trades below, seq is the arrival order. List the IDs that repeat, and say whether their copies agree. Then give the total quantity per symbol, where the latest version of each trade wins. Would SELECT DISTINCT have given the same totals?

seqtrade_idsymbolqty
11AAA100
22AAA200
32AAA200
43BBB50
53BBB60
64BBB70
Answer

Answer to Question 7.6.

IDs 2 and 3 repeat. The copies of 2 agree, and those of 3 do not. With the latest version winning, the totals are AAA 300 and BBB 130. SELECT DISTINCT would give BBB 180, because it keeps both versions of trade 3. Listing 7.15 has both queries.

-- Which IDs repeat, and do the copies agree?
SELECT trade_id, COUNT(*) AS copies, COUNT(DISTINCT qty) AS versions
FROM trades
GROUP BY trade_id
HAVING COUNT(*) > 1;

-- Totals per symbol, where the latest version of each trade wins
WITH ranked AS (
    SELECT symbol, qty,
           ROW_NUMBER() OVER (PARTITION BY trade_id ORDER BY seq DESC) AS rn
    FROM trades
)
SELECT symbol, SUM(qty) AS total
FROM ranked
WHERE rn = 1
GROUP BY symbol
ORDER BY symbol;
Listing 7.15. Finding repeats, then keeping the latest. code/interviews-3/07-sql-and-data/sql/duplicates.sql
trade_idcopiesversions
221
322
symboltotal
AAA300
BBB130

COUNT(DISTINCT qty) is the useful extra column: a value of 1 means a harmless replay, and more than 1 means a correction or a data fault. DISTINCT removes only rows that match in every selected column, so it handles the replay and not the correction. ROW_NUMBER over each ID, in descending arrival order, keeps exactly one row per ID: BBB is then 60+7060 + 70.

Classic wrong answer. Keeping the first copy, or MAX(qty). A correction can go either way, so only the arrival order can settle which is current.

Follow-up. And if there is no arrival column? Then the table cannot tell you. Rows in SQL have no order of their own, so you go back to the source, or you report the disagreement rather than pick one.

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

Here is a table prices. What does Listing 7.5 do on SQLite, on DuckDB and on PostgreSQL? Write a query for the latest price of each symbol that is correct on all three.

symboltsprice
AAA1100.0
AAA3101.0
AAA299.0
BBB550.0
BBB451.0
SELECT symbol, MAX(ts) AS ts, price
FROM prices
GROUP BY symbol;
Listing 7.5. The latest price, perhaps. code/interviews-3/07-sql-and-data/sql/latest_bare.sql
Answer

Answer to Question 7.7.

SQLite happens to return the right prices, DuckDB rejects the query, and so does PostgreSQL. Listing 7.16 is correct on all three.

WITH ranked AS (
    SELECT symbol, ts, price,
           ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY ts DESC) AS rn
    FROM prices
)
SELECT symbol, ts, price
FROM ranked
WHERE rn = 1
ORDER BY symbol;
Listing 7.16. The latest row per group, with a window. code/interviews-3/07-sql-and-data/sql/latest_portable.sql
symboltsprice
AAA3101.0
BBB550.0

In standard SQL, a selected column must be grouped or aggregated. price is neither, so it is a bare column: which row should it come from? DuckDB fails with a binder error, and PostgreSQL’s documentation says it refuses too. SQLite has a documented special case: with a single MIN or MAX, bare columns come from the row holding that extreme, which is why it gets this right. With two such aggregates, it no longer says which row. So the query works here by a rule of one engine, and it reads like a portable query.

The window version says what you mean. If two rows tie on the latest time, ROW_NUMBER picks one arbitrarily, so add a second sort key if that matters. DuckDB also offers shorter forms: arg_max(price, ts), and QUALIFY to filter on a window directly. Both return the same here.

Classic wrong answer. “It takes the price from an arbitrary row in every engine.” Not on SQLite with one MAX. It is a real rule there, though not one to depend on.

Follow-up. A join to a subquery of MAX(ts) per symbol? Also correct and portable. It returns both rows on a tie, which is sometimes what you want.

Question 7.8 ★★ QD, Dev, DS • any • junior, experienced

The table pnl has one row per trader. You are asked for the top 2 traders by profit on each desk. Run ROW_NUMBER, RANK and DENSE_RANK over (PARTITION BY desk ORDER BY pnl DESC) in your head: what does each give on this table? Which traders does a filter of 2 or less keep under each one? Which would you use, and what must you ask first?

desktraderpnl
ratesann50
ratesbob50
ratescid40
ratesdan30
fxeve70
fxfay60
fxgus60
Answer

Answer to Question 7.8.

The three functions differ only on ties, and the ties decide who is kept. Listing 7.17 gives them side by side. A filter of 2 or less keeps two traders on each desk under ROW_NUMBER, adds gus under RANK, and adds gus and cid under DENSE_RANK.

SELECT desk, trader, pnl,
       ROW_NUMBER() OVER (PARTITION BY desk ORDER BY pnl DESC, trader) AS row_num,
       RANK()       OVER w AS rnk,
       DENSE_RANK() OVER w AS dense
FROM pnl
WINDOW w AS (PARTITION BY desk ORDER BY pnl DESC)
Listing 7.17. Three rankings. code/interviews-3/07-sql-and-data/sql/ranks.sql
desktraderpnlrow_numrnkdense
fxeve70111
fxfay60222
fxgus60322
ratesann50111
ratesbob50211
ratescid40332
ratesdan30443

ROW_NUMBER never repeats. It must break ties somehow, and if the order does not settle them, the choice is arbitrary and can change between runs, so the listing sorts on trader too. RANK gives ties the same number and leaves a gap after them, while DENSE_RANK leaves no gap, so a tie at the top pulls in a third person.

What must you ask first? What “top 2” means when two people tie: exactly two rows, or everyone at the top two places, or at the top two distinct values? For a bonus list, you would hardly drop gus for a coin flip, so RANK is the natural reading. For a page showing two rows, it is ROW_NUMBER, with a stated tie-break.

Classic wrong answer. Using ROW_NUMBER without a tie-break. The result looks fine, and it is not reproducible.

Question 7.9 ★★ QD, Dev, DS, QT • any • junior, experienced

The table fills holds signed quantities for one symbol, where seq is the order in which fills occurred, and two fills may share a time ts. A short position is against the rules, so the query in Listing 7.6 checks for a negative running position. What does it print, and does it catch the breach? Fix it.

seqtsqty
110100
220−150-150
320100
430−50-50
SELECT seq, ts, qty, SUM(qty) OVER (ORDER BY ts) AS position
FROM fills
ORDER BY seq;
Listing 7.6. A running position. code/interviews-3/07-sql-and-data/sql/position.sql
Answer

Answer to Question 7.9.

The query prints positions of 100, 50, 50, 0, so it misses the breach: the true position after fill 2 is −50-50. Listing 7.18 fixes it by ordering on seq as well.

SELECT seq, ts, qty,
       SUM(qty) OVER (ORDER BY ts, seq ROWS UNBOUNDED PRECEDING) AS position
FROM fills
ORDER BY seq;
Listing 7.18. A running total, one row at a time. code/interviews-3/07-sql-and-data/sql/position_fixed.sql
seqprinted by the querytrue position
1100100
250−50-50
35050
400

With ORDER BY and no frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and a RANGE frame includes every peer of the current row, meaning all rows with the same ts. So both fills at time 20 are summed together before either is shown, and the dip between them disappears. Sorting on a key that is unique, here ts, seq, leaves no peers. Writing ROWS says so openly, and it also spares the engine from looking for peers.

Classic wrong answer. Sorting the output by seq and thinking that is enough. The final ORDER BY only arranges the output. The window’s own ORDER BY decides what each sum includes.

Follow-up. And if the table has no seq, and two fills share a time? Then the order is not in the data, and no query can recover it. A position check needs a sequence number from the source of the fills.

Why it matters. A limit breached for a moment is still breached. Two fills in one millisecond are normal in a fast market, so this comes up.

Question 7.10 ★★ QD, Dev, DS • any • junior, experienced

The left table is orders and the right table is fills. Listing 7.7 is meant to give, for each desk, the total quantity ordered and the total filled. What does it print, what is wrong with each number, and how do you fix it?

order_iddeskqty
1rates100
2rates50
3fx200
order_idfilled
160
140
3150
SELECT o.desk, SUM(o.qty) AS ordered, SUM(f.filled) AS filled
FROM orders o JOIN fills f ON o.order_id = f.order_id
GROUP BY o.desk
ORDER BY o.desk;
Listing 7.7. Ordered and filled, by desk. code/interviews-3/07-sql-and-data/sql/fanout.sql
Answer

Answer to Question 7.10.

It prints fx 200 and 150, then rates 200 and 100. The filled numbers are right. The rates ordered quantity is wrong: it should be 150, and it is off in two separate ways. Listing 7.19 fixes both.

deskorderedfilled
fx200150
rates200100

A join makes one row per matching pair. Order 1 has two fills, so its quantity of 100 appears twice, and is summed as 200. That is the fan-out. Order 2 has no fills, so the inner join loses it, and its 50 is gone. So two errors are at work, and here 100+100100 + 100 only looks like a plausible total. On other data they could partly cancel, which is worse.

WITH per_order AS (
    SELECT order_id, SUM(filled) AS filled
    FROM fills
    GROUP BY order_id
)
SELECT o.desk, SUM(o.qty) AS ordered, SUM(COALESCE(p.filled, 0)) AS filled
FROM orders o LEFT JOIN per_order p ON o.order_id = p.order_id
GROUP BY o.desk
ORDER BY o.desk;
Listing 7.19. Aggregate first, then join. code/interviews-3/07-sql-and-data/sql/fanout_fixed.sql
deskorderedfilled
fx200150
rates150100

The cure is to bring each table to the grain of the question before joining. With one row per order on each side, the join cannot multiply anything. LEFT JOIN keeps unfilled orders, and COALESCE turns their missing fills into 0.

Classic wrong answer. SUM(DISTINCT o.qty). It would make rates 100+50100 + 50 only if order 2 appeared, and on data where two orders have the same quantity it would drop one of them.

Follow-up. How do you spot a fan-out quickly? Compare COUNT(*) before and after a join, and check that the key is unique on the side you expect.

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

In the table closes, day is a calendar day number, and days 4 and 5 are a weekend with no row. What are the two columns of averages in Listing 7.8, row by row? Which one is a three-day moving average?

dayprice
110.0
220.0
330.0
660.0
790.0
SELECT day, price,
       AVG(price) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS by_rows,
       AVG(price) OVER (ORDER BY day RANGE BETWEEN 2 PRECEDING AND CURRENT ROW) AS by_range
FROM closes
ORDER BY day;
Listing 7.8. Two moving averages. code/interviews-3/07-sql-and-data/sql/moving.sql
Answer

Answer to Question 7.11.

The columns differ from the first row after the gap, as the table shows. by_rows averages the last three observations and by_range the last three calendar days, so by_range is the three-day average in the plain sense. Over the weekend, by_rows is a three-trading-day average.

daypriceby_rowsby_range
110.010.0010.00
220.015.0015.00
330.020.0020.00
660.036.6760.00
790.060.0075.00

A ROWS frame counts rows, whatever their days. On day 6 it takes days 2, 3 and 6, and so it gives (20+30+60)/3(20 + 30 + 60) / 3. A RANGE frame with a numeric offset works on the value of the sort key: it takes day−2≤d≤day\text{day} - 2 \le d \le \text{day}. On day 6 that is days 4 to 6, so only one row qualifies; on day 7 it is days 5 to 7, so it gives (60+90)/2(60 + 90)/2.

Neither is wrong, but they answer different questions. Trading-day averages use ROWS on a clean calendar. A window in time, such as the last 300 seconds, needs RANGE. In either case, the first rows average fewer than three values, so decide whether to print them or to require a full window with COUNT(*) OVER (…).

Classic wrong answer. “Three preceding rows is three days.” A missing row, whether a holiday or a data fault, silently stretches a ROWS window further into the past.

Follow-up. Can RANGE with an offset have several sort keys? No. The offset is applied to one numeric or date value, and SQLite’s manual states the requirement of a single term.

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

Listing 7.9 should turn the table ticks into one bar per minute, with open, high, low and close. How many rows does it return, and what are they? Why? Fix it.

minuteseqprice
11100.0
12103.0
13101.0
24102.0
2599.0
SELECT DISTINCT minute,
       FIRST_VALUE(price) OVER w AS open,
       MAX(price)         OVER w AS high,
       MIN(price)         OVER w AS low,
       LAST_VALUE(price)  OVER w AS close
FROM ticks
WINDOW w AS (PARTITION BY minute ORDER BY seq)
ORDER BY minute, close;
Listing 7.9. Bars from ticks. code/interviews-3/07-sql-and-data/sql/bars_bad.sql
Answer

Answer to Question 7.12.

It returns five rows, not two, one per tick. The frame ends at the current row, so LAST_VALUE, MAX and MIN see only the ticks so far. Listing 7.20 returns the two bars.

minuteopenhighlowclose
1100.0100.0100.0100.0
1100.0103.0100.0101.0
1100.0103.0100.0103.0
2102.0102.099.099.0
2102.0102.0102.0102.0

With ORDER BY in a window and no frame, the default frame runs from the start of the partition to the current row. So LAST_VALUE returns the current row’s price, which is rarely what anyone wants, and the running high and low change as you go. Each row then differs, and DISTINCT removes nothing. FIRST_VALUE is right by luck, since the frame always starts at the beginning.

SELECT DISTINCT minute,
       FIRST_VALUE(price) OVER w AS open,
       MAX(price)         OVER w AS high,
       MIN(price)         OVER w AS low,
       LAST_VALUE(price)  OVER w AS close
FROM ticks
WINDOW w AS (PARTITION BY minute ORDER BY seq
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
ORDER BY minute;
Listing 7.20. The whole partition in the frame. code/interviews-3/07-sql-and-data/sql/bars_fixed.sql
minuteopenhighlowclose
1100.0103.0100.0101.0
2102.0102.099.099.0

A simpler plan for bars is a GROUP BY minute with MIN and MAX, and with open and close taken by ROW_NUMBER in each direction, or by arg_min and arg_max on DuckDB.

Classic wrong answer. “It returns two rows, because DISTINCT collapses each minute.” DISTINCT compares whole rows, after the windows have run.

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

The left table is trades and the right table is quotes, with ts in seconds. For each trade, return the bid and ask of the latest quote at or before the trade, but treat a quote more than 10 seconds old as missing. Use a correlated subquery, not any special join syntax. What do you return for each trade here? What does your query cost with and without an index on quotes.ts?

trade_idts
12
25
312
430
tsbidask
399.9100.1
10100.3100.6
12100.4100.7
Answer

Answer to Question 7.13.

Listing 7.21 finds, for each trade, the time of the latest quote in the allowed span, then joins to fetch the quote. Trade 1 has no quote yet, trade 2 uses the quote at 3, trade 3 the quote at 12, and trade 4 none, since 12 is too old. Without an index it costs O(nm)O(nm) for nn trades and mm quotes; with one, O(nlog⁡m)O(n \log m).

WITH matched AS (
    SELECT t.trade_id, t.ts,
           (SELECT MAX(q.ts) FROM quotes q
             WHERE q.ts <= t.ts AND q.ts >= t.ts - 10) AS quote_ts
    FROM trades t
)
SELECT m.trade_id, m.quote_ts, q.bid, q.ask
FROM matched m LEFT JOIN quotes q ON q.ts = m.quote_ts
ORDER BY m.trade_id;
Listing 7.21. An as-of lookup with a staleness limit. code/interviews-3/07-sql-and-data/sql/asof_correlated.sql
trade_idquote_tsbidask
1NULLNULLNULL
2399.9100.1
312100.4100.7
4NULLNULLNULL

This is an as-of join: each row takes the latest matching row at or before its time. Three edges are worth saying aloud. A quote at the very time of the trade counts, as with trade 3. The left join keeps trades without a usable quote, rather than dropping them. And a staleness limit stops a trade at 30 from being priced off a quote from 18 seconds ago, which the unlimited version would do. Note also that a quote at the same second may in fact have come after the trade: the data can only say what the timestamps say.

The cost is the subquery, which runs per trade. With no index, SQLite’s plan shows a bare SEARCH q for the subquery, which reads all the quotes per trade, and it builds an automatic index for the join back to quotes. With an index on ts, the subquery’s plan says USING COVERING INDEX with (ts>? AND ts<?).

Classic wrong answer. Joining on q.ts <= t.ts and taking MAX(q.bid). That gives the highest bid in the past, not the latest bid.

Follow-up. Several quotes share a time? Then the join returns several rows per trade, so the quotes need a tie-break, such as a sequence number.

Question 7.14 ★★ QD, Dev, LLE, DS • market maker, crypto, hedge fund • junior, experienced

A table msgs has one integer column, seq, the sequence numbers received from a feed that should have no holes. Its rows are 1, 2, 3, 6, 7 and 11. Write a query that returns each missing range as a first and a last number. A shorter query, the largest minus the smallest plus one minus the row count, gives the number missing: why is that not enough? What can no query on this table tell you?

Answer

Answer to Question 7.14.

Listing 7.22 compares each number with the next one and returns the ranges 4 to 5 and 8 to 10. The count alone says 5 are missing, but not where, which is what you need to ask for a replay. No query can see a hole at the start or the end, since those numbers leave no trace in the table.

SELECT seq + 1 AS first_missing, next_seq - 1 AS last_missing
FROM (SELECT seq, LEAD(seq) OVER (ORDER BY seq) AS next_seq FROM msgs)
WHERE next_seq > seq + 1
ORDER BY first_missing;
Listing 7.22. Missing ranges, from each row’s successor. code/interviews-3/07-sql-and-data/sql/seq_gaps.sql
first_missinglast_missing
45
810

LEAD gives each row the next value in order, and a gap is where the difference exceeds 1. The last row has a NULL successor, and NULL > x is not true, so it drops out. The query cost is one sort, and the sequence numbers are usually already in order.

The count formula has a second flaw: duplicates hide holes. On the rows 1, 2, 2, 3, 6, it gives 1, where the truth is 2, because the extra row cancels one of the missing. COUNT(DISTINCT seq) repairs the count. The LEAD query still gives the right range there, because a difference of 0 is not above 1.

Classic wrong answer. Generating every number from the smallest to the largest, and anti-joining. It works, but it costs memory proportional to the range, and on a feed with a billion messages that matters.

Follow-up. How do you find a hole at the end? From outside the table: the feed’s heartbeat or the end-of-session message states the last sequence number sent. Compare with that.

Question 7.15 ★★ QD, Dev, DS, QR • market maker, crypto, hedge fund • junior, experienced

The table quotes below holds one symbol, in arrival order seq. Write one query that returns each suspect row and one reason for it, checking in this order: bid at or above ask; a size of zero or less; a time that does not increase; and a mid-price that moved more than 5% from the previous row’s. Which rows come back? Which of them is a good row? How would you stop one bad print from flagging two rows?

seqtsbidaskbid_size
11100.0100.2500
22100.1100.3400
33100.4100.2300
44100.1100.30
54100.1100.3200
66110.0110.2100
77100.2100.4300
Answer

Answer to Question 7.15.

Rows 3 to 7 come back, for the reasons in the table below. Row 7 is a good quote, flagged only because it returns from the bad print in row 6. To stop that, compare each mid with the median of the preceding rows rather than with the previous row.

WITH q AS (
    SELECT seq, ts, bid, ask, bid_size,
           LAG(ts) OVER (ORDER BY seq) AS prev_ts,
           LAG((bid + ask) / 2) OVER (ORDER BY seq) AS prev_mid
    FROM quotes
)
SELECT seq,
       CASE WHEN bid >= ask THEN 'crossed'
            WHEN bid_size <= 0 THEN 'size'
            WHEN ts <= prev_ts THEN 'time'
            WHEN ABS((bid + ask) / 2 - prev_mid) > 0.05 * prev_mid THEN 'jump'
       END AS problem
FROM q
WHERE bid >= ask OR bid_size <= 0 OR ts <= prev_ts
   OR ABS((bid + ask) / 2 - prev_mid) > 0.05 * prev_mid
ORDER BY seq;
Listing 7.23. One reason per suspect row. code/interviews-3/07-sql-and-data/sql/bad_ticks.sql
seqproblem
3crossed
4size
5time
6jump
7jump

CASE returns the first branch that holds, which gives the checking order. LAG needs a subquery because a window cannot appear in WHERE. A NULL from LAG on the first row makes those comparisons unknown, so it passes: the first row has nothing to be compared with. Row 5 is flagged on time, as its 4 is not above row 4’s 4.

The rows are compared with whatever came before, good or bad. Row 3 is crossed, yet its mid still enters the comparison for row 4, which can matter if a crossed quote is far away. The median cures both: a single print cannot move a median of five, so on DuckDB, where a window median exists, only row 6 is flagged for a jump, while rows 3, 4 and 5 keep their other reasons. SQLite 3.37 has no such function.

Classic wrong answer. Dropping every flagged row automatically. Row 7 is real, and a genuine gap on news can look like row 6: so review jumps, or check them against another source, before you delete them.

Why it matters. A strategy trained on data with one crossed quote or one wrong print can learn to trade on it, and the backtest will show a profit no one can earn.

Question 7.16 ★★ QD, Dev, LLE, DS • any • junior, experienced

A table latency has one integer column us, holding 10, 12, 13, 15, 20, 22, 25, 30, 80 and 200. On DuckDB, what do quantile_disc(us, 0.9) and quantile_cont(us, 0.9) return, and why do they differ? SQLite 3.37 has neither, so write the 90th percentile there by the nearest-rank rule: the smallest value with at least 90% of the rows at or below it. Which definition would you report for latency?

Answer

Answer to Question 7.16.

quantile_disc returns 80 and quantile_cont returns 92, up to rounding: DuckDB 1.5.5 prints 91.99999999999996. Listing 7.24 gives the nearest-rank value on SQLite: 80. For latency, report a value that really occurred, by a stated rule, because an interpolated number can land where no request ever was.

SELECT MIN(us) AS p90
FROM (SELECT us,
             ROW_NUMBER() OVER (ORDER BY us) AS pos,
             COUNT(*) OVER () AS n
      FROM latency)
WHERE pos * 10 >= n * 9;
Listing 7.24. The nearest-rank rule. code/interviews-3/07-sql-and-data/sql/nearest_rank.sql

The discrete form returns an actual value: the smallest with at least 90% of the rows at or below it, which is the 9th of 10. The continuous form treats sorted values as points to interpolate between. Its position is 0.9×(10−1)=8.10.9 \times (10 - 1) = 8.1, counting from 0, which is a tenth of the way from 80 to 200, so 80+0.1×120=9280 + 0.1 \times 120 = 92. No request took 92 microseconds: the gap between 80 and 200 is where the distribution has nothing.

In the listing, the test pos * 10 >= n * 9 keeps the arithmetic in integers, which is exact, and it avoids CEIL, which not every SQLite build provides. It gives the same 80 on both engines.

The tail is where this matters most. With ten rows, the 90th percentile and the maximum are separated by one observation. A sample that small cannot say anything sure about the tail, and the 99th percentile of ten rows is simply the maximum.

Classic wrong answer. Averaging percentiles across venues or days. A percentile does not average. To combine, merge the raw data or the histograms, then read off the percentile.

Follow-up. NTILE(10)? It splits rows into ten nearly equal groups, and it can place equal values in different groups, so the boundary is not a clean percentile.

Question 7.17 ★★ QD, Dev, DS • any • junior, experienced

On SQLite, a table trades has the index in Listing 7.10, on (symbol, ts). For each of the five queries, does the plan search the index or scan the table, and does it need a separate sort? Explain the rule behind your answers.

CREATE TABLE trades (symbol TEXT, ts BIGINT, price DOUBLE);
CREATE INDEX by_symbol_ts ON trades (symbol, ts);

-- Query A
SELECT price FROM trades WHERE symbol = 'AAA' AND ts >= 100;
-- Query B
SELECT price FROM trades WHERE ts >= 100;
-- Query C
SELECT price FROM trades WHERE symbol = 'AAA' ORDER BY ts;
-- Query D
SELECT price FROM trades WHERE symbol = 'AAA' ORDER BY price;
-- Query E
SELECT price FROM trades WHERE substr(symbol, 1, 1) = 'A';
Listing 7.10. One index, five queries. code/interviews-3/07-sql-and-data/sql/index_plans.sql
Answer

Answer to Question 7.17.

Query A searches on both columns, with no sort. Query B scans. Query C searches on symbol, and the index supplies the order, so it needs no sort. Query D searches on symbol but needs a sort. Query E scans. The rule is that an index on (symbol, ts) is sorted by symbol and then by time, so it helps only from its leftmost column on, and only on the columns themselves.

SQLite’s EXPLAIN QUERY PLAN, read by the test, says:

  • A: SEARCH trades USING INDEX by_symbol_ts (symbol=? AND ts>?).
  • B: SCAN trades. Times are sorted only within each symbol, so a condition on ts alone has no one place to start.
  • C: SEARCH with (symbol=?), and no sort step, as the rows for one symbol come out in time order.
  • D: the same search, then USE TEMP B-TREE FOR ORDER BY.
  • E: SCAN trades. A function of a column hides it from the index, even when the prefix is fixed. symbol >= 'A' AND symbol < 'B' can use it.

So write the index with the equality column first and the range or sort column next. SQLite can sometimes use an index without its first column, by a skip-scan, but only with statistics from ANALYZE and few distinct first values, so do not plan on it.

Follow-up. The query reads only ts and price, and it is hot. What else can you do? Add price to the index, as (symbol, ts, price): the plan then says COVERING INDEX, as the table is never visited. Each extra index slows every write.

Follow-up. Would you add one to a column store like DuckDB? Usually not at first. It reads column chunks and skips them using each chunk’s minimum and maximum, so sorting the data on load often achieves what the index would.

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

The table daily holds one row per trading day, in which day increases but need not be consecutive. Write a query that returns the first day, the last day and the length of the longest run of consecutive rows with positive profit. On ties, take the earliest. What does it return here? Use no loop or recursion.

daypnl
15
23
3−2-2
61
72
84
9−1-1
107
Answer

Answer to Question 7.18.

Listing 7.25 returns days 6 to 8, a run of 3. This is the gaps-and-islands method: a row’s position in the whole table, minus its position among the winning rows, is the same for every row of a run.

WITH numbered AS (
    SELECT day, pnl, ROW_NUMBER() OVER (ORDER BY day) AS pos
    FROM daily
),
wins AS (
    SELECT day, pos - ROW_NUMBER() OVER (ORDER BY day) AS island
    FROM numbered
    WHERE pnl > 0
)
SELECT MIN(day) AS first_day, MAX(day) AS last_day, COUNT(*) AS days
FROM wins
GROUP BY island
ORDER BY days DESC, first_day
LIMIT 1;
Listing 7.25. Islands, from a difference of row numbers. code/interviews-3/07-sql-and-data/sql/islands.sql

Number all rows by day to get pos. Then keep the winners and number them again. Along a run of winners both numbers rise by 1 per row, so their difference stays fixed. Every losing row skipped raises pos and not the second number, so the difference jumps between runs. Here the winners are at positions 1, 2, 4, 5, 6, 8, the second numbering is 1 to 6, and the differences are 0, 0, 1, 1, 1, 2: three islands, of sizes 2, 3 and 1.

pos has to come from a first step on all rows. Using day itself would count a missing day as a break, whereas the question is about consecutive rows, and days 4 and 5 simply do not exist in this table. The test compares with a plain loop in Python on 100 random tables with holes in the days.

The tie rule goes in the final ORDER BY, and LIMIT 1 takes the top. Note that LIMIT is not in every dialect, as some engines use FETCH FIRST.

Classic wrong answer. Using LAG alone. It tells you where a run starts, but not how long it is. You still need a running count of starts, which is the method of marking starts and summing them, in another form.

Follow-up. The longest run of losses? Change the filter. The same numbering of the whole table applies.

Question 7.19 ★★★ QD, Dev, DS • any • junior, experienced

The table events holds a trader’s actions, with ts in seconds. A session is a run of one trader’s events in which each follows the last by 300 seconds or less, and a longer gap starts a new session. Write a query that returns, for each session, the trader, the session number, the first and last times and the number of events. What does it return here? Why is a gap of exactly 300 the case to test?

traderts
ann100
ann200
ann700
ann800
ann1500
bob150
bob460
bob760
Answer

Answer to Question 7.19.

Listing 7.26 marks each event that starts a session, then takes a running sum of the marks as the session number. It returns three sessions for ann and two for bob. A gap of exactly 300 is the case to test because the rule says “300 or less” continues a session, and bob’s last two events test exactly that.

WITH marked AS (
    SELECT trader, ts,
           CASE WHEN ts - LAG(ts) OVER (PARTITION BY trader ORDER BY ts) <= 300
                THEN 0 ELSE 1 END AS starts
    FROM events
),
numbered AS (
    SELECT trader, ts,
           SUM(starts) OVER (PARTITION BY trader ORDER BY ts) AS session
    FROM marked
)
SELECT trader, session, MIN(ts) AS first_ts, MAX(ts) AS last_ts, COUNT(*) AS n
FROM numbered
GROUP BY trader, session
ORDER BY trader, session;
Listing 7.26. Mark the starts, then sum them. code/interviews-3/07-sql-and-data/sql/sessions.sql
tradersessionfirst_tslast_tsn
ann11002002
ann27008002
ann3150015001
bob11501501
bob24607602

This pattern is worth knowing: turn a condition on neighbouring rows into a 0 or 1 column, and a running sum makes a group number. Partitions keep the two traders apart, so ann’s last event has no effect on bob’s first. A first event has no predecessor, so LAG gives NULL, the comparison is unknown, and CASE falls to ELSE 1, which correctly starts a session.

Bob’s gaps are 310 and 300. The first starts a new session, and the second continues one. Writing < 300 would split that last pair, and only a test on the boundary would notice.

Classic wrong answer. Grouping on ts / 300. Fixed buckets are simple, but two events a second apart can fall in different buckets, and a long session is cut at every multiple of 300.

Follow-up. Can this be a query you run every hour on new data? Yes, but a session may span the boundary, so you must reprocess the last open session of each trader rather than start fresh.

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

In the table closes, a NULL price means no trade that day. Fill each NULL with the last known price, and leave a NULL with nothing before it as it is. Write it for SQLite 3.37, which does not support IGNORE NULLS, and say how DuckDB can do it in one line. What do you return here? Why would you not fill backwards as well?

dayprice
1NULL
210.0
3NULL
4NULL
512.0
6NULL
Answer

Answer to Question 7.20.

Listing 7.27 makes a group for each known price and spreads that price over its group. It returns NULL, 10, 10, 10, 12, 12. On DuckDB one line is enough: LAST_VALUE(price IGNORE NULLS) OVER (ORDER BY day). Filling backwards would put a price from the future into the past, which in a backtest is look-ahead.

WITH groups AS (
    SELECT day, price, COUNT(price) OVER (ORDER BY day) AS grp
    FROM closes
)
SELECT day, MAX(price) OVER (PARTITION BY grp) AS filled
FROM groups
ORDER BY day;
Listing 7.27. Forward fill with a running count. code/interviews-3/07-sql-and-data/sql/ffill.sql
dayfilled
1NULL
210.0
310.0
410.0
512.0
612.0

COUNT(price) skips NULLs, so its running value rises only at a known price. The groups by day are 0, 1, 1, 1, 2, 2. Each group holds at most one non-NULL price, so MAX over the group returns exactly that price. Group 0 has none, so day 1 stays NULL, as the question asks.

SQLite rejects the IGNORE NULLS syntax, and the test confirms it, so you need this trick there.

Classic wrong answer. COALESCE(price, LAG(price) OVER (ORDER BY day)). It fills only one step: day 4’s previous value is itself NULL, so it stays NULL.

Follow-up. Fill at most one day, so that a stale price is not carried for ever? Also keep MAX(CASE WHEN price IS NOT NULL THEN day END) OVER (ORDER BY day), the last day with a price, and fill only when day minus that is at most 1. Here day 4 then stays NULL.

Why it matters. A filled price is a stale price, so a return computed across a fill is 0 and then a jump. For volatility and correlation, that can matter more than the gap itself.

Question 7.21 ★★★ QD, Dev, DS, QR • market maker, hedge fund, crypto • experienced

The left table is trades and the right table is quotes, each with millions of rows per symbol, and neither has an index. For each trade, return the mid of the latest quote of the same symbol at or before the trade, or NULL if none. A quote at the same time as a trade counts. Give the query on DuckDB, using its special join. Then give a query with no such join and no correlated subquery, which runs on SQLite 3.37. What does each cost, and what do you return here?

symbolts
AAA5
AAA12
BBB1
BBB7
symboltsmid
AAA3100.0
AAA12101.0
BBB250.0
BBB951.0
Answer

Answer to Question 7.21.

On DuckDB, use ASOF LEFT JOIN (Listing 7.28). The other query merges both tables and uses the running-count trick (Listing 7.29). Each sorts, for about O((n+m)log⁡(n+m))O((n + m) \log(n + m)), where a correlated lookup with no index costs O(nm)O(nm).

SELECT t.symbol, t.ts, q.mid
FROM trades t ASOF LEFT JOIN quotes q
  ON t.symbol = q.symbol AND t.ts >= q.ts
ORDER BY t.symbol, t.ts;
Listing 7.28. An as-of join on DuckDB. code/interviews-3/07-sql-and-data/sql/asof_duckdb.sql
WITH merged AS (
    SELECT symbol, ts, 0 AS kind, mid FROM quotes
    UNION ALL
    SELECT symbol, ts, 1 AS kind, NULL FROM trades
),
grouped AS (
    SELECT symbol, ts, kind, mid,
           COUNT(mid) OVER (PARTITION BY symbol ORDER BY ts, kind
                            ROWS UNBOUNDED PRECEDING) AS grp
    FROM merged
)
SELECT symbol, ts, last_mid AS mid
FROM (SELECT symbol, ts, kind,
             MAX(mid) OVER (PARTITION BY symbol, grp) AS last_mid
      FROM grouped)
WHERE kind = 1
ORDER BY symbol, ts;
Listing 7.29. The same with a merged stream. code/interviews-3/07-sql-and-data/sql/asof_union.sql
symboltsmid
AAA5100.0
AAA12101.0
BBB1NULL
BBB750.0

Put quotes and trades in one stream, sorted by symbol, time and kind. Within a symbol, COUNT(mid) rises at each quote and holds at each trade. So a trade shares a group with exactly the latest quote before it, and MAX(mid) over that group is that quote’s mid. The last step keeps only the trades, by kind = 1.

kind is where the tie rule lives. Quotes are 0 and trades 1, so at equal times a quote comes first, as the question wants. To exclude a quote at the same moment, swap them.

The tests compare both queries with a brute-force search over 100 random tables. Quotes there have unique times per symbol. With duplicates, this query needs a further sort key, since nothing then says which of the two quotes counts last, and the result depends on the order in which the engine happens to read them.

Classic wrong answer. “An index would solve it.” It would cut each lookup to O(log⁡m)O(\log m), but the question has none, and the merge does not need one.

Follow-up. Which side is the outer? In the DuckDB listing, trades are on the left, so a trade with no earlier quote stays, with NULL. A plain ASOF JOIN would drop it.

Question 7.22 ★★★ QD, Dev, DS, QR • hedge fund, bank • experienced

A ticker can pass from one company to another. The left table, master, gives each instrument’s ticker and the dates it held it, from valid_from up to but not including valid_to, and the right table is trades. Dates are ISO text. Listing 7.11 is a join someone wrote. What does it return, and what are the two separate problems with this result? Fix the query, and write a data-quality query that finds the bad rows in master.

inst_idtickervalid_fromvalid_to
1ABC2020-01-012024-05-01
2ABC2024-05-019999-12-31
3XYZ2020-01-012023-01-01
4XYZ2022-06-019999-12-31
tickerdayqty
ABC2024-04-3010
ABC2024-05-0120
XYZ2022-07-0130
SELECT t.ticker, t.day, t.qty, m.inst_id
FROM trades t JOIN master m
  ON t.ticker = m.ticker AND t.day BETWEEN m.valid_from AND m.valid_to
ORDER BY t.day, m.inst_id;
Listing 7.11. A join on validity dates. code/interviews-3/07-sql-and-data/sql/pit_between.sql
Answer

Answer to Question 7.22.

It returns five rows for three trades. The ABC trade on 2024-05-01 appears twice, because BETWEEN includes both ends, which is a fault in the query. The XYZ trade appears twice, because instruments 3 and 4 overlap in master, which is a fault in the data. Listing 7.30 fixes the first, and Listing 7.31 finds the second.

tickerdayqtyinst_id
XYZ2022-07-01303
XYZ2022-07-01304
ABC2024-04-30101
ABC2024-05-01201
ABC2024-05-01202
SELECT t.ticker, t.day, t.qty, m.inst_id
FROM trades t JOIN master m
  ON t.ticker = m.ticker AND m.valid_from <= t.day AND t.day < m.valid_to
ORDER BY t.day, m.inst_id;
Listing 7.30. A half-open join. code/interviews-3/07-sql-and-data/sql/pit_fixed.sql
SELECT a.inst_id AS first_id, b.inst_id AS second_id, a.ticker
FROM master a JOIN master b
  ON a.ticker = b.ticker AND a.inst_id < b.inst_id
 AND a.valid_from < b.valid_to AND b.valid_from < a.valid_to;
Listing 7.31. Rows of the master that overlap. code/interviews-3/07-sql-and-data/sql/pit_overlaps.sql

The dates mean a half-open span, from valid_from up to but not including valid_to, and BETWEEN is closed at both ends. On the handover day both ABC rows match, so an inner join multiplies the trade, and a volume report doubles it. The fixed query assigns that trade to instrument 2 only.

No query fix can settle XYZ, as the master itself claims two owners for June to December 2022. Two half-open spans overlap exactly when each starts before the other ends, and that one line is the check. It returns (3, 4), and it should run as a test whenever master is loaded.

ISO dates in text compare correctly as strings, which is why the comparisons work here, on SQLite and DuckDB. Other date formats do not.

Classic wrong answer. “Use DISTINCT to remove the duplicates.” That hides the double match, and it leaves one wrong instrument in the result.

Follow-up. Why not join on today’s ticker for each instrument? History then gets the wrong company, or none at all for a delisted one, which leaves out failed firms. This survivorship is why master data holds dates.

Question 7.23 ★★★ QD, Dev, DS, QR • market maker, hedge fund, crypto • experienced

The table trades has an integer time ts in seconds, a double price and an integer qty. For each trade, give the volume-weighted average price over the five minutes ending at that trade, that is, over trades with t−300<ts≤tt - 300 < \text{ts} \le t. Use a window function with a frame in time, not in rows. What do you return here? Two trades share the time 420: do they see each other? What must change if ts were a double?

tspriceqty
0100.010
120101.010
299102.020
300103.010
420100.010
42099.030
Answer

Answer to Question 7.23.

Listing 7.32 uses a RANGE frame on ts, returning the table below. The two trades at 420 do see each other, as RANGE includes peers, so they get the same value. With a double ts, the integer shortcut for the open end no longer works.

SELECT ts, SUM(price * qty) OVER w / SUM(qty) OVER w AS vwap
FROM trades
WINDOW w AS (ORDER BY ts RANGE BETWEEN 299 PRECEDING AND CURRENT ROW)
ORDER BY ts, price;
Listing 7.32. A five-minute window, in time. code/interviews-3/07-sql-and-data/sql/rolling_vwap.sql
tsvwap
0100.000
120100.500
299101.250
300102.000
420100.571
420100.571

The window must be open at t−300t - 300 and closed at tt, and a RANGE frame is closed at both ends. With integer seconds, 299 preceding is exactly the same as the open end at 300: a trade at 0 is out of the window for the trade at 300, as the 102.000 shows. It is (1010+2040+1030)/40(1010 + 2040 + 1030) / 40, from the trades at 120, 299 and 300. At 420 the sum is over the trades at 299, 300 and both at 420: 7040/70≈100.5717040 / 70 \approx 100.571.

A running window means one pass over sorted rows, adding and removing as the frame moves, so a good engine does this in about O(nlog⁡n)O(n \log n), mostly for the sort. Compare the self-join, which scales with the number of pairs inside the window.

For double times, no finite offset gives an open end exactly, and subtracting a tiny tolerance is a fudge. Convert the time to integer microseconds or nanoseconds first, and the same query is exact. That is also how you should store times.

Classic wrong answer. ROWS BETWEEN 299 PRECEDING AND CURRENT ROW. It counts 300 trades, not 300 seconds. On a quiet day it reaches back for hours, and on a busy one it covers a fraction of a second.

Follow-up. Why divide two window sums, rather than average? The average of price over a window weights each trade equally, where VWAP weights by quantity. A WINDOW clause shows that both sums use the same frame.

Question 7.24 ★★★★ QD, Dev, DS, QT • any • experienced

The table fills holds one symbol’s buys and sells, in order seq, and the position never goes negative. Sales are matched to buys first in, first out: each sale uses up the oldest shares still held. In one SQL statement, with window functions but no loop and no recursion, return one row per matched pair of buy and sell, with the quantity and the profit, on SQLite 3.37 and on DuckDB. What is the total profit here? Does your query still hold if the position can go short?

seqsidepriceqty
1B100100
2B11050
3S120120
4B9030
5S13060
Answer

Answer to Question 7.24.

Lay out all bought shares on one number line, and all sold shares on another: first in, first out means the kk-th share sold is the kk-th share bought. So each fill is a span, and the matched quantity is the overlap, as in Listing 7.33. Total profit here is 4000, and the query stays right when the position goes short.

WITH spans AS (
    SELECT seq, side, price,
           SUM(qty) OVER (PARTITION BY side ORDER BY seq) - qty AS lo,
           SUM(qty) OVER (PARTITION BY side ORDER BY seq) AS hi
    FROM fills
),
lots AS (
    SELECT b.seq AS buy_seq, s.seq AS sell_seq, b.price AS buy_price, s.price AS sell_price,
           CASE WHEN b.hi < s.hi THEN b.hi ELSE s.hi END
         - CASE WHEN b.lo > s.lo THEN b.lo ELSE s.lo END AS qty
    FROM spans b JOIN spans s
      ON b.side = 'B' AND s.side = 'S' AND b.lo < s.hi AND s.lo < b.hi
)
SELECT buy_seq, sell_seq, qty, (sell_price - buy_price) * qty AS pnl
FROM lots
ORDER BY sell_seq, buy_seq;
Listing 7.33. First-in first-out as overlapping spans. code/interviews-3/07-sql-and-data/sql/fifo.sql
buy_seqsell_seqqtypnl
131002000
2320200
2530600
45301200

A running sum per side gives each fill its span: buys cover [0,100)[0, 100), [100,150)[100, 150) and [150,180)[150, 180), and sales cover [0,120)[0, 120) and [120,180)[120, 180). Two half-open spans overlap when each starts before the other ends, and the shared length is the smaller end minus the larger start. CASE does that, as SQLite’s two-argument MIN means something else on DuckDB, where it returns a list, and LEAST is not in SQLite 3.37. The profit per pair is (sell−buy)×qty(\text{sell} - \text{buy}) \times \text{qty}. The test compares with a queue-based loop in Python on 150 random histories.

For shorts, the same reasoning holds. Whichever way the position crosses zero, first-in first-out on both sides still pairs the kk-th share bought with the kk-th share sold. The test confirms this on random histories that do go short. All that changes is how you read a row: when sell_seq comes before buy_seq, it is a short sale and its later cover.

Shares bought but not yet sold have no matching span, so they appear in no row. That is the open position, valued by marking to market, not here. With many symbols, add the symbol to the partition and to the join.

Classic wrong answer. “SQL needs a cursor or recursion, because lots are consumed one by one.” Thinking of positions on a number line removes the state altogether. The same trick does not work for last-in first-out, where the matching really depends on the path.

Follow-up. And the cost? The self-join on inequalities can touch many pairs without an engine that handles such joins well. A two-pointer walk in code is O(n)O(n), so use this for a report rather than on a hot path.

Question 7.25 ★★★★ QD, Dev, DS • market maker, crypto • experienced

The table events is the history of the buy side of one book: at step seq, the resting quantity at price changes by delta. In one SQL statement, on SQLite 3.37 and on DuckDB, return for each step the best bid after that step, meaning the highest price with a positive quantity, and the quantity there, or NULL if the side is empty. What do you return here? What does your query cost, and would you run it this way on a day of data?

seqpricedelta
110050
210120
310030
4101−20-20
59940
6100−80-80
Answer

Answer to Question 7.25.

Listing 7.34 turns the history into versions of each price level, each valid from one step until the next change at that price, and takes the highest live level at each step. It returns the table below. On SQLite it costs O(n2)O(n^2) for nn events, so I would not run it on a day of data: replay in code instead.

WITH levels AS (
    SELECT price, seq AS from_seq,
           LEAD(seq) OVER (PARTITION BY price ORDER BY seq) AS to_seq,
           SUM(delta) OVER (PARTITION BY price ORDER BY seq) AS qty
    FROM events
),
ranked AS (
    SELECT e.seq, l.price, l.qty,
           ROW_NUMBER() OVER (PARTITION BY e.seq ORDER BY l.price DESC) AS rn
    FROM events e JOIN levels l
      ON l.from_seq <= e.seq AND (l.to_seq IS NULL OR e.seq < l.to_seq) AND l.qty > 0
)
SELECT e.seq, r.price, r.qty
FROM events e LEFT JOIN ranked r ON r.seq = e.seq AND r.rn = 1
ORDER BY e.seq;
Listing 7.34. The best bid at every step. code/interviews-3/07-sql-and-data/sql/best_bid.sql
seqpriceqty
110050
210120
310120
410080
510080
69940

Per price, the running SUM(delta) is the level’s quantity after each of its changes, and LEAD(seq) tells when that version ends. So a version is live over the half-open span from its seq to the next, or to the end if the next is NULL. Joining each step to the versions live then, with positive quantity, gives the book at that step. ROW_NUMBER by descending price picks the best. The outer left join brings back a step at which the side is empty, with NULLs. The tests compare with a dictionary replay on 100 random histories.

The join is the problem. Its output has about n×Ln \times L rows, for LL live levels, and on SQLite, with no index for the inequality, finding them compares each step with every version, which is O(n2)O(n^2). Another engine may plan it better, but a replay in code with an ordered map costs O(nlog⁡L)O(n \log L) and streams from disk. On a busy instrument with millions of events, and hundreds of levels, one is a query you run for a morning’s sample and the other is what you ship.

Classic wrong answer. Taking MAX(price) of events with positive delta so far. It forgets that a level can empty out, so after step 4 it would still say 101.

Follow-up. How can you shrink the join? A level only matters while it is near the top, so filter to prices within a few ticks of the best so far. Or run the query on a small time slice, per batch. Both help, but a replay is simpler.