The Trap of Naive Pandas Backtests
The most common introductory quantitative script involves loading daily closing prices into pandas, computing a 20-period moving average, and executing:
df['signal'] = np.where(df['close'] > df['sma_20'], 1, 0)
df['strategy_returns'] = df['signal'] * df['returns']
This code produces glowing Sharpe ratios on historical data. When deployed to a live exchange, however, it loses capital almost immediately. Why?
- Look-Ahead Bias (Future Leakage): The moving average and signal at index
tare calculated using thecloseprice of candlet. In reality, you cannot execute at the close of candletbecause the close is not known until the candle has ended. You must execute at theopenof candlet+1. - Execution Friction Neglect: Real-world execution suffers from bid-ask spread crossing, broker commissions, exchange fees, and market impact slippage. In high-frequency or short-timeframe strategies, friction wipes out theoretical alpha entirely.
Vectorized vs. Event-Driven Architecture
When building backtesters, quants must choose between two distinct engineering paradigms:
| Attribute | Vectorized Backtester | Event-Driven Backtester |
|---|---|---|
| Execution Speed | Ultra-Fast (Millions of bars in milliseconds via NumPy/Polars) | Slower (Object-oriented event loop tick-by-tick) |
| Order Types | Primarily Market on Open / Close | Complex Limit Orders, Stop Orders, Icebergs, Partial Fills |
| Ideal Use Case | Rapid hypothesis screening & parameter sweeps | Final production pre-flight testing matching broker API |
Mathematical Modeling of Slippage and Commission
In our custom engine, we model transaction friction deterministically:
import numpy as np
def run_vectorized_backtest(
prices: np.ndarray,
signals: np.ndarray,
commission_bps: float = 5.0, # 5 basis points (0.05%)
slippage_bps: float = 2.0 # 2 basis points
) -> dict:
# Vectorized simulation doc
Vectorized simulation incorporating lagged execution and transaction costs.
Signals: 1 (Long), 0 (Flat), -1 (Short)
# Signals: 1 (Long), 0 (Flat), -1 (Short)
n_bars = len(prices)
# 1. Enforce 1-bar execution lag to eliminate look-ahead bias
executed_positions = np.zeros(n_bars)
executed_positions[1:] = signals[:-1]
# 2. Calculate raw price returns
price_returns = np.zeros(n_bars)
price_returns[1:] = (prices[1:] / prices[:-1]) - 1.0
# 3. Strategy raw returns
strategy_returns = executed_positions * price_returns
# 4. Detect position transitions (trades executed)
trades = np.zeros(n_bars)
trades[1:] = np.abs(executed_positions[1:] - executed_positions[:-1])
# 5. Apply total friction costs (Commission + Slippage)
total_friction_rate = (commission_bps + slippage_bps) / 10000.0
friction_penalties = trades * total_friction_rate
# 6. Net strategy returns
net_returns = strategy_returns - friction_penalties
cumulative_curve = np.cumprod(1.0 + net_returns)
# 7. Compute Peak-to-Trough Drawdown
running_max = np.maximum.accumulate(cumulative_curve)
drawdowns = (cumulative_curve - running_max) / running_max
max_drawdown = np.min(drawdowns)
# 8. Annualized Sharpe Ratio (Assuming 252 daily bars)
mean_ret = np.mean(net_returns)
std_ret = np.std(net_returns) + 1e-9
sharpe = (mean_ret / std_ret) * np.sqrt(252)
return {
"total_return": float(cumulative_curve[-1] - 1.0),
"max_drawdown": float(max_drawdown),
"sharpe_ratio": float(sharpe),
"trades_count": int(np.sum(trades > 0))
}