Progress · 0/5 phases
💻 Phase 2 — Programming & Data Infrastructure
7 min read · Notion
Core insight: A quant who can derive Black-Scholes but can't pull, clean, and process real market data is unemployable. This phase builds the engineering muscle: Pandas at speed, SQL for market data, and the performance habits that separate a 2-second backtest from a 2-hour one.
📚 Topics in order
Days 1–6 — NumPy & vectorization
The golden rule: never write a Python for loop over array data. Vectorized NumPy operations are 10-100x faster because they run in compiled C, not the Python interpreter.
import numpy as np
import time
prices = np.random.uniform(90, 110, 1_000_000)
# SLOW: Python loop
start = time.time()
returns_loop = []
for i in range(1, len(prices)):
returns_loop.append((prices[i] - prices[i-1]) / prices[i-1])
print(f"Loop: {time.time() - start:.4f}s")
# FAST: vectorized
start = time.time()
returns_vec = np.diff(prices) / prices[:-1]
print(f"Vectorized: {time.time() - start:.4f}s")
# Vectorized is typically 50-100x fasterEssential NumPy patterns for quant work:
- Broadcasting — operations on arrays of different shapes without explicit loops
np.where, boolean masking — conditional logic without loops (e.g., "long if signal > 0")np.cumsum,np.cumprod— cumulative returns, running P&L- Rolling window operations via
np.lib.stride_tricksor pandas.rolling() - Random number generation with seeds — reproducible Monte Carlo simulations
Days 7–14 — Pandas mastery for time series
import pandas as pd
import yfinance as yf
# Download and structure data
data = yf.download(['AAPL', 'MSFT', 'GOOGL'], start='2019-01-01', end='2024-01-01')
close_prices = data['Close']
# Resampling: daily -> weekly/monthly
weekly = close_prices.resample('W').last()
monthly_returns = close_prices.resample('M').last().pct_change()
# Rolling statistics — the bread and butter of quant signals
rolling_vol = close_prices.pct_change().rolling(window=21).std() * np.sqrt(252) # annualized
rolling_corr = close_prices['AAPL'].pct_change().rolling(60).corr(close_prices['MSFT'].pct_change())
# Multi-index for panel data (multiple assets x multiple dates)
returns = close_prices.pct_change().dropna()
stacked = returns.stack() # MultiIndex: (date, ticker) -> return
stacked.index.names = ['date', 'ticker']
# Groupby for cross-sectional operations (critical for factor models)
daily_rank = returns.rank(axis=1, pct=True) # percentile rank of each stock, each day
# merge_asof: the CORRECT way to join data with different timestamps
# (e.g., joining trade data with the most recent quote BEFORE the trade)
trades = pd.DataFrame({'time': pd.to_datetime(['09:30:01', '09:30:05']), 'price': [100.1, 100.3]})
quotes = pd.DataFrame({'time': pd.to_datetime(['09:30:00', '09:30:03']), 'bid': [100.0, 100.2], 'ask': [100.2, 100.4]})
merged = pd.merge_asof(trades, quotes, on='time', direction='backward')
print(merged)Common pandas pitfalls:
.locvs.ilocvs chained indexing (df['a']['b']creates copies and triggersSettingWithCopyWarning)- Timezone handling — market data often spans multiple exchanges with different timezones; always work in UTC internally
pct_change()vsdiff()vsnp.log().diff()(simple vs log returns) — log returns are additive across time, simple returns are additive across assets
Days 15–20 — Market data sources & cleaning
# Free data sources for learning
import yfinance as yf
# OHLCV data
data = yf.download('SPY', start='2020-01-01', interval='1d')
# Common data quality issues to handle:
# 1. Stock splits and dividends -> use adjusted close, not raw close
# 2. Survivorship bias -> delisted stocks disappear from "current" universe data
# (a backtest using only TODAY's S&P 500 constituents overstates historical returns)
# 3. Missing data / holidays -> different exchanges have different holiday calendars
# 4. Outliers / bad ticks -> a price of $0.01 or $10,000 for a $100 stock is a data error
def clean_returns(prices: pd.Series, max_daily_move: float = 0.5) -> pd.Series:
"""Remove obviously erroneous price moves (likely data errors)."""
returns = prices.pct_change()
bad_ticks = returns.abs() > max_daily_move
if bad_ticks.any():
print(f"Removing {bad_ticks.sum()} suspected bad ticks")
prices = prices.copy()
prices[bad_ticks] = np.nan
prices = prices.ffill()
return prices.pct_change()Days 21–24 — Time series databases for market data
-- PostgreSQL + TimescaleDB for tick/OHLCV data
CREATE TABLE ohlcv (
time TIMESTAMPTZ NOT NULL,
symbol TEXT NOT NULL,
open DOUBLE PRECISION,
high DOUBLE PRECISION,
low DOUBLE PRECISION,
close DOUBLE PRECISION,
volume BIGINT,
PRIMARY KEY (time, symbol)
);
-- Convert to TimescaleDB hypertable for performance at scale
SELECT create_hypertable('ohlcv', 'time');
-- Index for fast symbol lookups
CREATE INDEX idx_ohlcv_symbol_time ON ohlcv (symbol, time DESC);
-- Query: 20-day rolling average close price per symbol
SELECT
time,
symbol,
close,
AVG(close) OVER (
PARTITION BY symbol
ORDER BY time
ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
) AS sma_20
FROM ohlcv
WHERE symbol = 'AAPL'
ORDER BY time;
-- Window functions for cross-sectional ranking (factor models)
SELECT
time,
symbol,
close,
PERCENT_RANK() OVER (PARTITION BY time ORDER BY close) AS price_percentile
FROM ohlcv
WHERE time = '2024-01-15';# Go: high-performance market data ingestion service
# pkg/marketdata/ingest.go
package marketdata
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type OHLCVBar struct {
Time time.Time
Symbol string
Open, High, Low, Close float64
Volume int64
}
func BatchInsertBars(ctx context.Context, pool *pgxpool.Pool, bars []OHLCVBar) error {
batch := &pgx.Batch{}
for _, bar := range bars {
batch.Queue(
`INSERT INTO ohlcv (time, symbol, open, high, low, close, volume)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (time, symbol) DO NOTHING`,
bar.Time, bar.Symbol, bar.Open, bar.High, bar.Low, bar.Close, bar.Volume,
)
}
return pool.SendBatch(ctx, batch).Close()
}Days 25–28 — Performance: Numba & vectorized backtesting prep
# When even vectorized NumPy isn't enough: Numba JIT compilation
from numba import njit
import numpy as np
@njit
def compute_ema(prices, span):
"""EMA requires sequential dependence -> can't fully vectorize, but Numba compiles to machine code."""
alpha = 2 / (span + 1)
ema = np.empty_like(prices)
ema[0] = prices[0]
for i in range(1, len(prices)):
ema[i] = alpha * prices[i] + (1 - alpha) * ema[i-1]
return ema
# First call compiles (~slow), subsequent calls are C-speed
prices = np.random.uniform(90, 110, 1_000_000)
ema = compute_ema(prices, span=20) # ~100x faster than pure Python loopDays 29–30 — Phase 2 Project: Market Data Pipeline
Deliverable: A production-grade data pipeline
- Python script that downloads daily OHLCV for 100 S&P 500 stocks, handles missing data and adjusts for splits/dividends
- Loads into a TimescaleDB hypertable with proper indexing
- SQL views computing: 20/50/200-day moving averages, 20-day realised volatility, daily cross-sectional return rank — all via window functions
- Python data access layer with a clean API:
get_returns(symbols, start, end) -> pd.DataFrame - Benchmark: compare loop vs vectorized vs Numba for computing rolling Sharpe ratio across 100 stocks × 5 years of data. Report the speedup.
- Document known data quality issues found (gaps, outliers, survivorship considerations)
⚠️ Common mistakes
Mistake 1
❌ Using raw Close price instead of Adj Close for return calculations.
A stock that does a 2-for-1 split shows a 50% "crash" in raw close price that never actually happened to an investor's wealth.
✅ Correct approach: Always use dividend- and split-adjusted prices for return calculations. yfinance's Close with auto_adjust=True (default in recent versions) or explicitly using Adj Close handles this. Verify by checking that a known split date doesn't show a spurious return spike.
Mistake 2
❌ Iterating over DataFrame rows with .iterrows()****.
.iterrows() is one of the slowest operations in pandas — it boxes every value into a Python object and creates a new Series per row. On 1M rows this can take minutes.
✅ Correct approach: Vectorize with column operations, or use .itertuples() (faster but still avoid if possible), or .apply() only as a last resort, or Numba/Cython for genuinely sequential logic (like EMA).
Mistake 3
❌ Look-ahead bias in data joins. Using pd.merge (not merge_asof) to join "current" fundamental data (earnings, P/E ratios) with historical prices — this leaks future information into the past, because the fundamental data wasn't KNOWN at that historical date (reporting lag).
✅ Correct approach: Always use merge_asof with direction='backward' and account for reporting lag (e.g., Q4 earnings aren't public until ~6 weeks after quarter-end). This single bug invalidates more backtests than any other data issue.
Mistake 4
❌ Not setting random seeds, making "reproducible" research irreproducible.
A Monte Carlo simulation or train/test split without a fixed seed gives different results every run — impossible to debug or peer-review.
✅ Correct approach: np.random.seed(42) (or use np.random.default_rng(42) for the modern Generator API) at the start of every script that uses randomness. Document the seed in your research notes.