Backtesting Strategies

This tutorial walks through the full backtesting lifecycle: defining a strategy, running a backtest, analyzing performance metrics, generating tearsheets, and running walk-forward validation.

Step 1: Define a Strategy

wraquant strategies inherit from Strategy and implement generate_signals, which receives price data and returns a signal series (1 for long, -1 for short, 0 for flat).

import wraquant as wq
import pandas as pd
from wraquant.backtest import Strategy
from wraquant.ta import ema, rsi, crossover, crossunder

class MomentumStrategy(Strategy):
    """EMA crossover with RSI filter.

    Go long when fast EMA crosses above slow EMA AND RSI < 70.
    Exit when fast EMA crosses below slow EMA OR RSI > 80.
    """

    def generate_signals(self, prices):
        fast = ema(prices, period=10)
        slow = ema(prices, period=50)
        rsi_values = rsi(prices, period=14)

        # Entry: fast crosses above slow, RSI not overbought
        long_entry = crossover(fast, slow) & (rsi_values < 70)

        # Exit: fast crosses below slow or RSI extremely overbought
        long_exit = crossunder(fast, slow) | (rsi_values > 80)

        # Build position signal: 1 = long, 0 = flat
        signal = pd.Series(0.0, index=prices.index)
        position = 0.0
        for i in range(len(signal)):
            if long_entry.iloc[i]:
                position = 1.0
            elif long_exit.iloc[i]:
                position = 0.0
            signal.iloc[i] = position

        return signal

Step 2: Run the Backtest

The Backtest engine applies your signals to historical data and computes the resulting returns.

from wraquant.backtest import Backtest

prices = pd.read_csv("prices.csv", index_col=0, parse_dates=True)["Close"]

bt = Backtest(MomentumStrategy())
result = bt.run(prices)

print(f"Strategy returns: {len(result['returns'])} observations")
print(f"Total return: {(1 + result['returns']).prod() - 1:.2%}")

# result contains:
# - 'returns': daily strategy returns
# - 'positions': position series over time
# - 'equity_curve': cumulative equity

Step 3: Performance Analysis

Analyze the strategy with 15+ performance metrics beyond the basics.

from wraquant.backtest import (
    performance_summary, omega_ratio, kelly_fraction,
    profit_factor, system_quality_number, recovery_factor,
)

perf = performance_summary(result['returns'])
print(f"Annual return:     {perf['annual_return']:.2%}")
print(f"Annual volatility: {perf['annual_volatility']:.2%}")
print(f"Sharpe ratio:      {perf['sharpe_ratio']:.4f}")
print(f"Sortino ratio:     {perf['sortino_ratio']:.4f}")
print(f"Max drawdown:      {perf['max_drawdown']:.2%}")
print(f"Win rate:          {perf['win_rate']:.2%}")
print(f"Total trades:      {perf['total_trades']}")

# Advanced metrics
omega = omega_ratio(result['returns'], threshold=0.0)
print(f"\nOmega ratio: {omega:.4f}")
# >1.0 means more probability-weighted gains than losses.

kelly = kelly_fraction(result['returns'])
print(f"Kelly fraction: {kelly:.4f}")
# Optimal fraction of capital to bet. In practice, use half-Kelly.

pf = profit_factor(result['returns'])
print(f"Profit factor: {pf:.4f}")
# Gross profit / gross loss. >1.0 is profitable. >2.0 is strong.

sqn = system_quality_number(result['returns'])
print(f"SQN: {sqn:.4f}")
# Van Tharp's quality score. >2.0 is tradable. >3.0 is excellent.

Step 4: Generate a Tearsheet

A tearsheet is a one-page visual summary of strategy performance: equity curve, drawdown chart, monthly returns heatmap, and key metrics.

from wraquant.backtest import (
    generate_tearsheet, monthly_returns_table,
    drawdown_table, rolling_metrics_table,
)

# Full tearsheet
tearsheet = generate_tearsheet(result['returns'])

# Monthly returns table (like a hedge fund factsheet)
monthly = monthly_returns_table(result['returns'])
print(monthly)
# Rows are years, columns are months, values are monthly returns.

# Worst drawdown episodes
dd_table = drawdown_table(result['returns'], top_n=5)
for dd in dd_table:
    print(f"Drawdown: {dd['max_drawdown']:.2%}, "
          f"Start: {dd['start']}, End: {dd['end']}, "
          f"Duration: {dd['duration']} days")

# Rolling Sharpe and volatility
rolling = rolling_metrics_table(result['returns'], window=252)
print(f"\nRolling Sharpe (latest): {rolling['sharpe'].iloc[-1]:.4f}")

Step 5: Compare Strategies

Test multiple strategy variants and compare them side by side.

from wraquant.backtest import strategy_comparison

# Define alternative strategies
class BuyAndHold(Strategy):
    def generate_signals(self, prices):
        return pd.Series(1.0, index=prices.index)

class FastMomentum(Strategy):
    def generate_signals(self, prices):
        fast = ema(prices, period=5)
        slow = ema(prices, period=20)
        return crossover(fast, slow).astype(float)

# Run all backtests
strategies = {
    "Momentum (10/50)": MomentumStrategy(),
    "Fast Momentum (5/20)": FastMomentum(),
    "Buy & Hold": BuyAndHold(),
}

results = {}
for name, strat in strategies.items():
    bt = Backtest(strat)
    results[name] = bt.run(prices)['returns']

# Side-by-side comparison
comparison = strategy_comparison(results)
print(comparison)
# Tabular comparison of Sharpe, return, drawdown, etc.

Step 6: Walk-Forward Validation

Walk-forward is the gold standard for strategy validation. It re-optimizes strategy parameters on a rolling window and tests out-of-sample at each step.

from wraquant.backtest import walk_forward_backtest

# Walk-forward with 2-year train, 6-month test windows
wf_result = walk_forward_backtest(
    strategy=MomentumStrategy(),
    prices=prices,
    train_period=504,     # ~2 years of trading days
    test_period=126,      # ~6 months
    step_size=126,        # advance by one test period
)

print(f"Walk-forward Sharpe: {wf_result['sharpe_ratio']:.4f}")
print(f"Walk-forward MaxDD:  {wf_result['max_drawdown']:.2%}")
print(f"Number of windows:   {wf_result['n_windows']}")

# Compare in-sample vs out-of-sample Sharpe to detect overfitting.
# If IS Sharpe >> OOS Sharpe, the strategy is overfit.

Step 7: Position Sizing

Control risk at the position level with ATR-based sizing, risk parity, or regime-conditional sizing.

from wraquant.backtest import (
    PositionSizer, risk_parity_position,
    regime_conditional_sizing,
)
from wraquant.ta import atr

# ATR-based position sizing: risk a fixed amount per trade
atr_values = atr(ohlcv["High"], ohlcv["Low"], ohlcv["Close"], period=14)
risk_per_trade = 0.02   # 2% of equity
equity = 100_000

position_size = (equity * risk_per_trade) / (2 * atr_values.iloc[-1])
print(f"Position size (shares): {position_size:.0f}")

# Regime-conditional sizing: reduce size in bear regimes
from wraquant.regimes import fit_gaussian_hmm
daily_returns = wq.returns(prices)
hmm = fit_gaussian_hmm(daily_returns, n_states=2)

sizing = regime_conditional_sizing(
    signal=result['positions'],
    states=hmm['states'],
    sizing_map={0: 1.0, 1: 0.3},   # full size in bull, 30% in bear
)
print(f"Current position scale: {sizing.iloc[-1]:.2f}")

Next Steps