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.
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 (), 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
| 1 | import numpy as np |
| 2 | import pandas as pd |
| 3 | |
| 4 | # --- Simulate price data: 252 trading days, 5 stocks --- |
| 5 | np.random.seed(42) |
| 6 | dates = pd.date_range('2023-01-01', periods=252, freq='B') |
| 7 | tickers = ['AAPL', 'MSFT', 'GOOG', 'AMZN', 'META'] |
| 8 | prices = 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 |
| 14 | returns = 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) |
| 18 | mom_raw = returns.shift(21).rolling(231).sum() # sum log returns = log of product |
| 19 | mom_rank = mom_raw.rank(axis=1, pct=True) # cross-sectional rank each date |
| 20 | |
| 21 | # --- Rolling Sharpe Ratio (annualized) --- |
| 22 | def 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 | |
| 27 | sharpe = rolling_sharpe(returns, window=60) |
| 28 | |
| 29 | # --- Rolling Beta vs equal-weight market --- |
| 30 | market = returns.mean(axis=1) |
| 31 | |
| 32 | def 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 | |
| 37 | betas = pd.DataFrame({t: rolling_beta(returns[t], market) for t in tickers}) |
| 38 | |
| 39 | # --- Look-ahead bias: WRONG vs CORRECT --- |
| 40 | signal_wrong = mom_rank # aligned to same date as forward return |
| 41 | signal_correct = mom_rank.shift(1) # only use data known at t-1 |
| 42 | fwd_return_1d = returns.shift(-1) # return from close-t to close-(t+1) |
| 43 | |
| 44 | # IC: Spearman rank correlation between signal and forward return |
| 45 | ic_series = signal_correct.corrwith(fwd_return_1d, axis=1, method='spearman') |
| 46 | |
| 47 | print(f"Mean IC : {ic_series.mean():.4f}") |
| 48 | print(f"IC IR : {ic_series.mean() / ic_series.std():.4f}") |
| 49 | print(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 problemsProblem
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
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.
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.
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.
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.
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.