Home/Quant Research/Python for Quantitative Finance
Back
๐Ÿ

Quant Research Guide

Python for Quantitative Finance

The toolbox every quant researcher needs before touching a single signal. Python proficiency in finance means vectorized cross-sectional operations, point-in-time data handling, and writing production-quality research code that a colleague can run and reproduce six months later.

PythonNumPyPandasVectorizationData Engineering

Core Theory

  • 1NumPy broadcasting: operations between arrays of different shapes follow rules aligned right-to-left โ€” size-1 dimensions expand. A (252,) market return array broadcasts against a (252,5) returns matrix without looping, enabling full cross-sectional computations in one expression.
  • 2Pandas MultiIndex is the standard container for (date, ticker) panels. Use .xs() for cross-sections, .loc[date] for time slices, and groupby(level='date').transform() for cross-sectional operations โ€” all vectorized, no Python loops.
  • 3Look-ahead bias: df.rolling(N).mean() on day t includes the return on day t itself. Signals must only use information available at close of t-1. The fix is exactly one line: signal.shift(1). This is the most common source of false alpha in research code.
  • 4Vectorized cross-sectional transformations: rank(axis=1, pct=True) ranks each row across all tickers simultaneously. This replaces any for-loop over dates and is 100โ€“1000x faster. Use it for momentum ranks, value deciles, and signal normalization.
  • 5Log returns vs simple returns: log returns are additive over time (rlog=ln(Pt/Ptโˆ’1)r_{log} = ln(P_t/P_{t-1})), making them suitable for time-series models and summing over periods. Simple returns are additive across assets in a portfolio. Know when to use each: time-series models use log, portfolio P&L uses simple.
  • 6Resample and time-aware aggregation: returns.resample('ME').apply(lambda x: (1+x).prod()-1) computes monthly returns from daily data respecting calendar boundaries. Never manually aggregate dates โ€” use pandas time-aware methods.
  • 7Point-in-time (PIT) data loading: fundamental data must be loaded as-of the filing/announcement date, not the reporting date. Use pd.merge_asof(price_df, fundamental_df, on='date', by='ticker') to align each price date to the most recently available fundamental โ€” this prevents look-ahead in fundamental signals.
  • 8Memory and performance: float32 halves memory vs float64 for large panels. Use .to_numpy() to extract bare arrays for maximum speed in inner loops. Profile with cProfile before optimizing. Common bottlenecks: df.apply(func, axis=1) (use vectorized methods instead), iterrows() (use itertuples or NumPy), string ops in loops (use .str accessor).

Key Formulas

01Log return: rt=lnโก(Pt/Ptโˆ’1)r_t = \ln(P_t / P_{t-1})
02Simple return: Rt=(Ptโˆ’Ptโˆ’1)/Ptโˆ’1R_t = (P_t - P_{t-1}) / P_{t-1}
03Annualized Sharpe: SR=(rห‰โˆ’rf)/ฯƒrร—252SR = (\bar{r} - r_f) / \sigma_r \times \sqrt{252}
04Rolling beta: ฮฒt=Covt(ri,rm)/Vart(rm)\beta_t = Cov_t(r_i, r_m) / Var_t(r_m)
0512-1 momentum: MOMt=โˆ‘k=22252rtโˆ’kMOM_t = \sum_{k=22}^{252} r_{t-k} (sum log returns, skip last month)
06Cross-sectional z-score: zi,t=(xi,tโˆ’ฮผt)/ฯƒtz_{i,t} = (x_{i,t} - \mu_t) / \sigma_t where stats are across tickers at time t
python
1import numpy as np
2import pandas as pd
3
4# --- Simulate price data: 252 trading days, 5 stocks ---
5np.random.seed(42)
6dates = pd.date_range('2023-01-01', periods=252, freq='B')
7tickers = ['AAPL', 'MSFT', 'GOOG', 'AMZN', 'META']
8prices = pd.DataFrame(
9 100 * np.exp(np.cumsum(np.random.randn(252, 5) * 0.01, axis=0)),
10 index=dates, columns=tickers
11)
12
13# Log returns โ€” vectorized, no loops
14returns = np.log(prices / prices.shift(1))
15
16# --- 12-1 Momentum (no loops) ---
17# Shift 21 days to skip last month; sum 231 more days of log returns (~11 months)
18mom_raw = returns.shift(21).rolling(231).sum() # sum log returns = log of product
19mom_rank = mom_raw.rank(axis=1, pct=True) # cross-sectional rank each date
20
21# --- Rolling Sharpe Ratio (annualized) ---
22def rolling_sharpe(ret: pd.DataFrame, window: int = 60) -> pd.DataFrame:
23 rf_daily = 0.05 / 252
24 excess = ret - rf_daily
25 return (excess.rolling(window).mean() / ret.rolling(window).std()) * np.sqrt(252)
26
27sharpe = rolling_sharpe(returns, window=60)
28
29# --- Rolling Beta vs equal-weight market ---
30market = returns.mean(axis=1)
31
32def rolling_beta(stock_ret: pd.Series, mkt_ret: pd.Series, window: int = 60) -> pd.Series:
33 cov = stock_ret.rolling(window).cov(mkt_ret)
34 var = mkt_ret.rolling(window).var()
35 return cov / var
36
37betas = pd.DataFrame({t: rolling_beta(returns[t], market) for t in tickers})
38
39# --- Look-ahead bias: WRONG vs CORRECT ---
40signal_wrong = mom_rank # aligned to same date as forward return
41signal_correct = mom_rank.shift(1) # only use data known at t-1
42fwd_return_1d = returns.shift(-1) # return from close-t to close-(t+1)
43
44# IC: Spearman rank correlation between signal and forward return
45ic_series = signal_correct.corrwith(fwd_return_1d, axis=1, method='spearman')
46
47print(f"Mean IC : {ic_series.mean():.4f}")
48print(f"IC IR : {ic_series.mean() / ic_series.std():.4f}")
49print(f"Rolling Sharpe (last 3):\n{sharpe.tail(3).round(2)}")

Demonstrates three critical vectorized patterns: 12-1 momentum without loops, rolling Sharpe ratio, and the single most important line in research code โ€” signal.shift(1) to prevent look-ahead bias before computing IC against forward returns.

Worked Interview Problems

3 problems

Problem

A researcher computes a 20-day moving average signal and finds Sharpe = 2.1. After removing look-ahead bias, Sharpe drops to 0.3. Explain exactly where the bias was and how to fix it.

Solution

1

Buggy code: signal = returns.rolling(20).mean(); fwd_ret = returns.shift(-1); IC = signal.corrwith(fwd_ret). The rolling mean on day t uses returns through day t inclusive.

2

The problem: the signal at time t contains r_t. The forward return starting from t also depends on P_t. Correlating a function of r_t with the return starting at r_t is circular โ€” inflating IC artificially.

3

The fix is one line: signal_clean = signal.shift(1). The signal used on day t was computed using data only through day t-1. Signal and forward return are now strictly non-overlapping.

4

Also check: any fundamental data must be shifted to its announcement date. Earnings announced after market close on date t cannot be used until t+1. Use pd.merge_asof with allow_exact_matches=False.

5

Verification: a look-ahead IC of 0.15 that becomes 0.02 after the shift is a classic pattern. A real IC of 0.02 is still usable but requires years of data to be statistically significant.

Answer

signal.shift(1) is the fix. Sharpe dropped from 2.1 to 0.3 because the original signal contained the same return information it was trying to predict.

Common Mistakes

!

Using df.apply(func, axis=1) on large DataFrames โ€” this iterates row-by-row in Python and is 100x slower than vectorized alternatives. Replace with df.rank(axis=1), df.subtract(df.mean(axis=1), axis=0), or NumPy broadcasting.

!

Forgetting signal.shift(1) before computing IC or returns attribution. The single most common error in quant research code and the primary source of spurious alpha.

!

Mixing log returns and simple returns in the same computation. Sum log returns for time-series compounding; sum simple returns (weighted by position) for portfolio P&L. Mixing them introduces a Jensen's inequality error of ~ฯƒยฒ/2 per period.

!

Using df.iterrows() or df.itertuples() for performance-critical operations. For a 1000ร—500 DataFrame, iterrows is ~50,000x slower than rank(axis=1). If you feel the urge to loop over rows, you are doing it wrong.

!

Not setting np.random.seed() in simulations โ€” results become non-reproducible across runs, making debugging and peer review impossible.

!

Treating df.resample('M') and df.resample('ME') as equivalent โ€” resample('M') is deprecated in pandas โ‰ฅ 2.2. Always use 'ME' (month-end) for monthly aggregation.

Only works in the Electron app

<webview> is an Electron-only tag. Run npm run electron:dev to use this.

25:00Focus
0

25 min focus ยท 5 min break ยท long break every 4 sessions

The Ultimate Grind