Backtest#

Walk-forward backtesting engine with transaction costs and risk metrics.

TransactionCosts#

class spreadpy.backtest.costs.TransactionCosts(slippage_bps: float = 2.0, commission_per_unit: float = 0.0, commission_bps: float = 1.0, min_commission: float = 4.9)[source]#

Transaction cost model applied at each leg fill.

The total cost per fill is computed as:

\[\begin{split}p_{\text{fill}} &= p_{\text{mid}} \cdot (1 + d \cdot s / 10^4) && (d = +1 \text{ for buys, } -1 \text{ for sells}) \\ c_{\text{slip}} &= |p_{\text{fill}} - p_{\text{mid}}| \cdot q \\ c_{\text{comm}} &= \max\!\left(c_{\text{unit}} \cdot q + |p_{\text{fill}} \cdot q| \cdot b / 10^4,\; c_{\min}\right) \\ c_{\text{total}} &= c_{\text{slip}} + c_{\text{comm}}\end{split}\]
Parameters:
  • slippage_bps (float) – One-way adverse price move in basis points.

  • commission_per_unit (float) – Fixed commission charged per unit traded.

  • commission_bps (float) – Ad-valorem commission on notional, in basis points (e.g. 1.0 = 1 bps = 0.01%).

  • min_commission (float) – Minimum commission floor per fill.

apply(price: float, qty: float, direction: int) Tuple[float, float][source]#

Compute the fill price and total cost for a single leg fill.

The fill price incorporates one-way adverse slippage:

\[p_{\text{fill}} = p \cdot (1 + d \cdot s / 10^4)\]

The total monetary cost is:

\[\begin{split}c_{\text{slip}} &= |p_{\text{fill}} - p| \cdot q \\ c_{\text{comm}} &= \max\!\left(c_{\text{unit}} \cdot q + |p_{\text{fill}} \cdot q| \cdot b / 10^4,\; c_{\min}\right) \\ c_{\text{total}} &= c_{\text{slip}} + c_{\text{comm}}\end{split}\]
Parameters:
  • price (float) – Mid price at signal time.

  • qty (float) – Absolute quantity (always positive).

  • direction (int) – Fill direction: +1 (buy) or −1 (sell).

Returns:

(fill_price, total_cost) where total_cost is always positive.

Return type:

Tuple[float, float]

Trade#

class spreadpy.backtest.portfolio.Trade(timestamp: Timestamp, leg: str, direction: int, qty: float, price: float, fill_price: float, cost: float, signal: Signal | None = None)[source]#

Record of a single leg fill.

Parameters:
  • timestamp (pd.Timestamp) – Fill time.

  • leg (str) – Asset leg — 'y' (dependent) or 'x' (independent).

  • direction (int) – Fill direction: +1 (buy) or −1 (sell).

  • qty (float) – Absolute quantity filled.

  • price (float) – Mid price at signal time.

  • fill_price (float) – Execution price after slippage.

  • cost (float) – Total transaction cost in monetary units (always positive).

  • signal (Optional[Signal]) – The Signal that triggered this fill.

Portfolio#

class spreadpy.backtest.portfolio.Portfolio(initial_capital: float = 1000000.0, costs: TransactionCosts | None = None)[source]#

Manages position state, P&L, and equity throughout a backtest.

Tracks open positions per leg (qty and average fill price), realised P&L, and bar-by-bar mark-to-market equity. Position changes use FIFO accounting.

Mark-to-market equity at each bar:

\[\text{equity}_t = \text{cash}_t + \sum_{\ell \in \{y,x\}} q_\ell \cdot (p_\ell - \bar{p}_\ell)\]

where cash_t accumulates realised P&L net of transaction costs.

Parameters:
  • initial_capital (float) – Starting equity in monetary units.

  • costs (Optional[TransactionCosts]) – Cost model applied at each fill. Defaults to TransactionCosts() (2 bps slippage + 1 bps commission).

property current_equity: float#

Current mark-to-market equity using the most recently seen prices.

equity_curve() DataFrame[source]#

Return the full bar-level equity curve as a DataFrame.

Each row corresponds to one mark() call. Columns: equity, cash, unrealised_pnl, realised_pnl, total_costs.

Returns:

Equity curve indexed by timestamp.

Return type:

pd.DataFrame

fill(timestamp: Timestamp, signal: Signal, qty_y: float, qty_x: float, price_y: float, price_x: float) List[Trade][source]#

Execute fills for both legs based on the signal direction.

If the signal is FLAT, all open positions are closed at the current prices. If the signal implies a direction change, the existing position is first closed before the new position is opened. Transaction costs are applied to every fill via TransactionCosts.apply().

Parameters:
  • timestamp (pd.Timestamp) – Current bar timestamp.

  • signal (Signal) – Signal directing the trade.

  • qty_y (float) – Absolute quantity to trade for the y leg (from sizer).

  • qty_x (float) – Absolute quantity to trade for the x leg (from sizer).

  • price_y (float) – Mid price of the y leg at signal time.

  • price_x (float) – Mid price of the x leg at signal time.

Returns:

List of Trade records generated (one per leg filled, two per leg on direction reversal).

Return type:

List[Trade]

mark(timestamp: Timestamp, price_y: float, price_x: float) float[source]#

Mark positions to market and record the equity snapshot.

Computes and stores bar-level equity:

\[\text{equity}_t = \text{cash}_t + \sum_{\ell \in \{y,x\}} q_\ell \cdot (p_\ell - \bar{p}_\ell)\]

Should be called at every bar, whether or not a trade occurred.

Parameters:
  • timestamp (pd.Timestamp) – Current bar timestamp.

  • price_y (float) – Current mid price of the y leg.

  • price_x (float) – Current mid price of the x leg.

Returns:

Current mark-to-market equity in monetary units.

Return type:

float

reset() None[source]#

Reset the portfolio to its initial state.

Clears all positions, trades, equity records, and P&L accumulators, restoring cash to initial_capital. Useful when reusing the same portfolio object across walk-forward folds.

RiskMetrics#

class spreadpy.backtest.metrics.RiskMetrics(equity: Series, risk_free_rate: float = 0.0)[source]#

Stateless risk metric calculator for a backtest equity curve.

All methods operate on the equity series supplied at construction and return scalar values. Annualisation uses the periods_per_year argument passed to each method.

Key metrics:

\[\begin{split}\text{Sharpe} &= \frac{\mathbb{E}[r - r_f]}{\sigma[r]} \cdot \sqrt{T} \\ \text{Sortino} &= \frac{\text{CAGR} - r_f}{\sigma_{\downarrow}} \cdot \sqrt{T} \quad (\text{downside std below MAR}) \\ \text{Calmar} &= \frac{\text{CAGR}}{|\text{MaxDD}|} \\ \text{MaxDD} &= \min_t\!\left(\frac{\text{equity}_t} {\max_{s \leq t}\, \text{equity}_s} - 1\right)\end{split}\]
Parameters:
  • equity (pd.Series) – Bar-level or daily equity curve (NaNs are dropped).

  • risk_free_rate (float) – Annual risk-free rate r_f (e.g. 0.04 for 4%).

annualised_return(periods_per_year: int = 252) float[source]#

Compute the Compound Annual Growth Rate (CAGR).

Defined as:

\[\text{CAGR} = (1 + R)^{T/n} - 1\]

where \(R\) is the total return, \(n\) the number of bars, and \(T\) = periods_per_year.

Parameters:

periods_per_year (int) – Number of bars per year (252 for daily).

Returns:

Annualised return as a fraction. Returns −1 if total equity is non-positive.

Return type:

float

avg_drawdown() float[source]#

Compute the average drawdown over all sub-zero drawdown bars.

Returns:

Mean drawdown as a negative fraction, or 0 if no bar is in drawdown.

Return type:

float

calmar(periods_per_year: int = 252) float[source]#

Compute the Calmar ratio.

Defined as:

\[\text{Calmar} = \frac{\text{CAGR}}{|\text{MDD}|}\]
Parameters:

periods_per_year (int) – Number of bars per year (252 for daily).

Returns:

Calmar ratio. Returns +inf if max drawdown is zero.

Return type:

float

conditional_drawdown(alpha: float = 0.05) float[source]#

Conditional Drawdown at Risk (CDaR) at level alpha.

Average of the worst alpha-fraction of drawdown observations:

\[\text{CDaR}_\alpha = \mathbb{E}[DD_t \mid DD_t \leq \text{VaR}_\alpha(DD)]\]

where \(\text{VaR}_\alpha\) is the \(\alpha\)-quantile of the drawdown distribution. Returns a negative value (same sign convention as max_drawdown()).

Parameters:

alpha (float) – Tail level (default 0.05 = worst 5%).

Returns:

Mean drawdown in the worst alpha fraction of bars.

Return type:

float

drawdown_series() Series[source]#

Compute the full drawdown time series.

At each bar \(t\):

\[DD_t = \frac{\text{equity}_t}{\max_{s \leq t}\, \text{equity}_s} - 1\]
Returns:

Drawdown series (values ≤ 0) aligned with self.equity.index.

Return type:

pd.Series

max_drawdown() float[source]#

Compute the maximum peak-to-trough drawdown.

Defined as:

\[\text{MDD} = \min_t\!\left( \frac{\text{equity}_t}{\max_{s \leq t}\, \text{equity}_s} - 1 \right)\]
Returns:

Maximum drawdown as a negative fraction (e.g. −0.15 for −15%).

Return type:

float

profit_factor(trades: List['Trade']) float[source]#

Compute the profit factor (gross profits divided by gross losses).

Defined as:

\[\text{PF} = \frac{\sum_{\text{winning}} \text{P\&L}} {\left|\sum_{\text{losing}} \text{P\&L}\right|}\]
Parameters:

trades (List[Trade]) – List of leg fills from Portfolio.trades.

Returns:

Profit factor > 1 indicates net profitability. Returns +inf if there are no losing trades.

Return type:

float

sharpe(periods_per_year: int = 252) float[source]#

Compute the annualised Sharpe ratio.

Defined as:

\[\text{Sharpe} = \frac{\mathbb{E}[r_t - r_f/T]}{\sigma_{\text{bar}}} \cdot \sqrt{T}\]

where \(r_t\) is the bar-level return, \(T\) = periods_per_year, and \(\sigma_{\text{bar}}\) the sample standard deviation of bar returns.

Parameters:

periods_per_year (int) – Number of bars per year (252 for daily).

Returns:

Annualised Sharpe ratio. Returns 0 if volatility is zero.

Return type:

float

sortino(periods_per_year: int = 252, mar: float = 0.0) float[source]#

Compute the annualised Sortino ratio.

Uses downside deviation below the minimum acceptable return (MAR) as the risk denominator:

\[\begin{split}\sigma_{\downarrow} &= \sqrt{\mathbb{E}[\min(r_t - \text{mar},\, 0)^2]} \cdot \sqrt{T} \\ \text{Sortino} &= \frac{\text{CAGR} - r_f}{\sigma_{\downarrow}}\end{split}\]
Parameters:
  • periods_per_year (int) – Number of bars per year (252 for daily).

  • mar (float) – Minimum acceptable return per bar (default 0.0).

Returns:

Annualised Sortino ratio. Returns +inf if there are no returns below mar.

Return type:

float

summary(periods_per_year: int = 252, trades: List | None = None) Series[source]#

Return all key risk metrics as a labelled series.

Always includes: total_return, annualised_return, volatility, sharpe, sortino, max_drawdown, avg_drawdown, cdar_5, calmar.

When trades is provided, also includes: win_rate, profit_factor, n_trades.

Parameters:
  • periods_per_year (int) – Number of bars per year (252 for daily).

  • trades (Optional[List[Trade]]) – Leg fills used to compute trade-level metrics.

Returns:

Named series of risk metrics.

Return type:

pd.Series

total_return() float[source]#

Compute the total (cumulative) return over the evaluation period.

Defined as:

\[R = \frac{\text{equity}_T}{\text{equity}_0} - 1\]
Returns:

Total return as a fraction (e.g. 0.12 for +12%).

Return type:

float

turnover(trades: List['Trade'], periods_per_year: int = 252) float[source]#

Compute annualised portfolio turnover.

Defined as:

\[\text{turnover} = \frac{\sum_i |N_i| \cdot T}{n \cdot \bar{\text{equity}}}\]

where \(n\) is the number of bars in the equity series, \(T\) = periods_per_year, and \(\bar{\text{equity}}\) is the mean equity over the period.

Parameters:
  • trades (List[Trade]) – List of leg fills from Portfolio.trades.

  • periods_per_year (int) – Number of bars per year (252 for daily).

Returns:

Annualised turnover as a multiple of average equity.

Return type:

float

volatility(periods_per_year: int = 252) float[source]#

Compute the annualised volatility of bar-level returns.

Defined as \(\sigma_{\text{ann}} = \sigma_{\text{bar}} \cdot \sqrt{T}\) where \(\sigma_{\text{bar}}\) is the sample standard deviation of simple returns and \(T\) = periods_per_year.

Parameters:

periods_per_year (int) – Number of bars per year (252 for daily).

Returns:

Annualised volatility as a fraction.

Return type:

float

win_rate(trades: List['Trade']) float[source]#

Compute the fraction of round-trip trades with positive P&L.

Round trips are matched FIFO per leg via _compute_round_trips().

Parameters:

trades (List[Trade]) – List of leg fills from Portfolio.trades.

Returns:

Win rate ∈ [0, 1], or NaN if no round trips exist.

Return type:

float

BacktestResult#

class spreadpy.backtest.result.BacktestResult(split: str, train_start: Timestamp, train_end: Timestamp, eval_start: Timestamp, eval_end: Timestamp, equity_curve: DataFrame, trades: List[Trade], signals: Series, spread: SpreadSeries, metrics: Series)[source]#

Immutable container for the output of a single backtest split.

Produced by BacktestEngine for both the validation and test periods. The split field identifies which period this result covers.

Parameters:
  • split (str) – Period identifier — 'val' or 'test'.

  • train_start (pd.Timestamp) – Start of the in-sample (fitting) period.

  • train_end (pd.Timestamp) – End of the in-sample (fitting) period.

  • eval_start (pd.Timestamp) – Start of the out-of-sample evaluation period.

  • eval_end (pd.Timestamp) – End of the out-of-sample evaluation period.

  • equity_curve (pd.DataFrame) – Bar-level mark-to-market equity. Columns: equity, cash, unrealised_pnl, realised_pnl, total_costs.

  • trades (List[Trade]) – All leg fills executed during the eval period.

  • signals (pd.Series) – Signal objects indexed by timestamp.

  • spread (SpreadSeries) – Spread series for the eval period.

  • metrics (pd.Series) – Risk metrics summary from RiskMetrics.

print_summary() None[source]#

Print a formatted metrics table to stdout.

Outputs the split label, evaluation date range, and all metrics from summary_df() as an aligned table.

summary_df() DataFrame[source]#

Format all metrics as a single-column DataFrame.

Returns:

DataFrame indexed by metric label with a Value column.

Return type:

pd.DataFrame

BacktestEngine#

class spreadpy.backtest.engine.BacktestEngine(estimator: HedgeRatioEstimator, signal_gen: SignalGenerator, sizer: PositionSizer, costs: TransactionCosts | None = None, initial_capital: float = 1000000.0, train_frac: float = 0.6, val_frac: float = 0.2, periods_per_year: int = 252, log_prices: bool = False, adf_window: int | None = 120, adf_p_threshold: float = 0.05, adf_min_confirm: int = 1)[source]#

Simple train / validation / test backtest engine.

Splits the data once into three consecutive periods and fits the full pipeline — hedge ratio estimator then signal generator — on the training period only:

|←── train_frac ──→|←── val_frac ──→|←── test (remainder) ──→|

Use the validation result to tune hyperparameters; consult the test result only for the final held-out evaluation.

Parameters:
  • estimator (HedgeRatioEstimator) – Hedge ratio estimator (e.g. KalmanFilterWithVelocity).

  • signal_gen (SignalGenerator) – Signal generator (e.g. ZScoreSignal).

  • sizer (LinearSizer) – Position sizing model.

  • costs (Optional[TransactionCosts]) – Transaction cost model. Defaults to 2 bps slippage + 1 bps commission.

  • initial_capital (float) – Starting equity in monetary units.

  • train_frac (float) – Fraction of data reserved for training / fitting (default 0.6).

  • val_frac (float) – Fraction of data used as validation set (default 0.2). Set to 0.0 for a simple train / test split with no validation — run() will then return (None, test_result). The remainder 1 train_frac val_frac forms the test set.

  • periods_per_year (int) – Bars per year for annualisation (252 for daily, 252 × 23 ≈ 5796 for hourly futures).

  • log_prices (bool) – If True, log-transform prices before passing them to the hedge ratio estimator and signal generator. The Kalman filter then operates on log-prices, which better satisfies the constant observation-noise assumption (σ²_ε homoscedastic). Position sizing and P&L accounting always use the original prices.

  • adf_window (Optional[int]) – Rolling window (in bars) for the ADF stationarity gate applied to every entry. Set to None to disable. Defaults to 120. Has no effect if signal_gen is already a RollingADFFilter.

  • adf_p_threshold (float) – Maximum ADF p-value to allow an entry (default 0.05). Ignored when adf_window is None.

  • adf_min_confirm (int) – Number of consecutive bars the p-value must stay above adf_p_threshold to block an entry (default 1). Increase to require sustained non-stationarity before blocking — isolated noisy bars above the threshold will not prevent entry.

run(y: PriceTimeSeries, x: PriceTimeSeries) Tuple[BacktestResult | None, BacktestResult][source]#

Fit the pipeline on the training period and evaluate on validation and test.

Pipeline:

  1. Align y and x and split into train / val / test.

  2. Fit the hedge ratio estimator on the training set, then re-run it over the full train + val + test range for Kalman filter state continuity.

  3. Fit the signal generator on the training spread.

  4. Evaluate on val (if val_frac > 0) and on test, calling _run_split() for each.

Parameters:
Returns:

(val_result, test_result) where val_result is None when val_frac = 0.0.

Return type:

Tuple[Optional[BacktestResult], BacktestResult]