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)wheretotal_costis 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_taccumulates 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 viaTransactionCosts.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
Traderecords 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
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_yearargument 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
+infif 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
+infif 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
+infif there are no returns belowmar.- 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
tradesis 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
BacktestEnginefor both the validation and test periods. Thesplitfield 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) –
Signalobjects 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.
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 remainder1 − train_frac − val_fracforms 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
Noneto disable. Defaults to 120. Has no effect ifsignal_genis already aRollingADFFilter.adf_p_threshold (float) – Maximum ADF p-value to allow an entry (default 0.05). Ignored when
adf_windowisNone.adf_min_confirm (int) – Number of consecutive bars the p-value must stay above
adf_p_thresholdto 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:
Align
yandxand split into train / val / test.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.
Fit the signal generator on the training spread.
Evaluate on val (if
val_frac > 0) and on test, calling_run_split()for each.
- Parameters:
y (PriceTimeSeries) – Dependent-leg price series.
x (PriceTimeSeries) – Independent-leg price series.
- Returns:
(val_result, test_result)whereval_resultisNonewhenval_frac = 0.0.- Return type:
Tuple[Optional[BacktestResult], BacktestResult]