Backtesting (wraquant.backtest)¶
The backtesting module provides event-driven and vectorized backtesting engines, strategy abstractions, position sizing, performance metrics, event tracking, and tearsheet generation.
Key components:
Backtest– event-driven engine with fill simulationVectorizedBacktest– fast vectorized engine for signal-based strategiesStrategy– base class for defining trading strategiesPositionSizer– ATR-based, risk parity, and regime-conditional sizing15+ performance metrics beyond Sharpe: omega, Kelly, profit factor, SQN, recovery factor
Tearsheet generation: equity curves, drawdown tables, monthly heatmaps
Quick Example¶
from wraquant.backtest import Backtest, Strategy, performance_summary
from wraquant.ta import ema, crossover
class MACrossover(Strategy):
def generate_signals(self, prices):
fast = ema(prices, period=10)
slow = ema(prices, period=50)
return crossover(fast, slow).astype(float)
bt = Backtest(MACrossover())
result = bt.run(prices)
perf = performance_summary(result['returns'])
print(f"Sharpe: {perf['sharpe_ratio']:.4f}")
print(f"Max DD: {perf['max_drawdown']:.2%}")
print(f"Win rate: {perf['win_rate']:.2%}")
Tearsheet¶
from wraquant.backtest import generate_tearsheet, monthly_returns_table
tearsheet = generate_tearsheet(result['returns'])
monthly = monthly_returns_table(result['returns'])
print(monthly) # year x month heatmap of returns
Walk-Forward Validation¶
from wraquant.backtest import walk_forward_backtest
wf = walk_forward_backtest(
strategy=MACrossover(),
prices=prices,
train_period=504,
test_period=126,
)
print(f"Walk-forward Sharpe: {wf['sharpe_ratio']:.4f}")
Regime-Conditional Sizing¶
from wraquant.backtest import regime_conditional_sizing
sizing = regime_conditional_sizing(
signal=result['positions'],
states=hmm['states'],
sizing_map={0: 1.0, 1: 0.3}, # full in bull, 30% in bear
)
See also
Backtesting Strategies – Full backtesting tutorial
Risk Management (wraquant.risk) – Metrics module (single source of truth for risk calculations)
Regime Detection (wraquant.regimes) – Regime detection for conditional strategies
API Reference¶
Backtesting framework for trading strategies.
Provides a full-featured backtesting infrastructure for evaluating trading strategies on historical data. Supports both fast vectorized backtesting (for signal-based strategies) and event-driven simulation (for complex order logic), with 30+ performance metrics, position sizing models, regime-aware filtering, and publication-quality tearsheets.
Key sub-modules:
Engine (
engine) –Backtest(event-driven engine with fill simulation),VectorizedBacktest(fast signal-based engine), andwalk_forward_backtest(rolling out-of-sample evaluation).Strategy (
strategy) –Strategybase class for defining entry/exit logic, signal generation, and position management.Metrics (
metrics) – 30+ performance metrics computed from an equity curve:performance_summary(one-call overview),omega_ratio,burke_ratio,ulcer_performance_index,kappa_ratio,tail_ratio,rachev_ratio,gain_to_pain_ratio,kelly_fraction(optimal bet sizing),risk_of_ruin,profit_factor,system_quality_number,expectancy,recovery_factor, and more.Position sizing (
position) –PositionSizerframework,risk_parity_position,regime_conditional_sizing(size based on detected regime),regime_signal_filter(suppress signals in unfavorable regimes),clip_weights,rebalance_threshold.Events (
events) –EventTrackerfor logging trades, rebalances, and drawdown events during simulation.detect_drawdown_eventsanddetect_regime_changesidentify key structural events in the equity curve.Tearsheet (
tearsheet) –generate_tearsheetandcomprehensive_tearsheetproduce multi-panel performance reports.monthly_returns_table,drawdown_table,rolling_metrics_table,strategy_comparison, andtrade_analysisfor detailed diagnostics.Integrations – Wrappers for vectorbt, quantstats, empyrical, pyfolio, and ffn.
Example
>>> from wraquant.backtest import VectorizedBacktest, performance_summary
>>> bt = VectorizedBacktest(signal_fn=my_signal)
>>> result = bt.run(prices)
>>> perf = performance_summary(result["equity_curve"])
>>> print(f"Sharpe: {perf['sharpe']:.2f}, Max DD: {perf['max_drawdown']:.1%}")
Use wraquant.backtest for strategy evaluation and walk-forward
analysis. For risk measurement on the resulting equity curve, see
wraquant.risk. For parallel parameter sweeps, see
wraquant.scale.parallel_backtest. For interactive tearsheet
visualization, see wraquant.viz.plot_backtest_tearsheet.
- class Backtest[source]¶
Bases:
objectVectorized backtesting engine.
- Parameters:
Example
>>> from wraquant.backtest import Backtest >>> from wraquant.backtest.strategy import BuyAndHold >>> bt = Backtest(BuyAndHold(), initial_capital=100_000) >>> result = bt.run(prices_df)
- class VectorizedBacktest[source]¶
Bases:
objectFast vectorized backtesting engine for signal-based strategies.
Unlike the event-driven
Backtestclass (which requires aStrategyobject),VectorizedBacktestoperates directly on a pre-computed signal / weight matrix. This is ideal for research workflows where signals are generated upstream (e.g., from a machine learning model or factor pipeline) and you need fast, repeatable backtest evaluation.The engine computes portfolio returns accounting for:
Transaction costs (fixed commission per unit of turnover).
Slippage (additional cost proportional to turnover).
Rebalancing frequency (skip rebalancing on off-days to reduce turnover).
- Parameters:
initial_capital (
float, default:100000.0) – Starting portfolio value in currency units.commission (
float, default:0.0) – One-way commission as a fraction of turnover (e.g., 0.001 = 10 bps).slippage (
float, default:0.0) – Slippage as a fraction of turnover.rebalance_frequency (
int, default:1) – Rebalance every N periods.1means every period (daily for daily data),5means weekly, etc.
Example
>>> import pandas as pd, numpy as np >>> rng = np.random.default_rng(42) >>> prices = pd.DataFrame( ... 100 * np.exp(np.cumsum(rng.normal(0.0005, 0.02, (100, 2)), axis=0)), ... columns=["A", "B"], ... index=pd.bdate_range("2023-01-01", periods=100), ... ) >>> signals = pd.DataFrame( ... 0.5, index=prices.index, columns=prices.columns ... ) >>> bt = VectorizedBacktest(commission=0.001, slippage=0.0005) >>> result = bt.run(prices, signals) >>> isinstance(result, BacktestResult) True
See also
Backtest: Strategy-object-based backtesting engine. walk_forward_backtest: Walk-forward optimisation + backtest.
- run(prices, signals)[source]¶
Execute the vectorized backtest.
- Parameters:
prices (
DataFrame) – Asset price DataFrame (rows = dates, columns = assets).signals (
DataFrame) – Weight / signal DataFrame with the same shape asprices. Values represent desired portfolio weights (e.g., 0.5 = 50 % allocation to that asset). Signals are shifted by one period internally to avoid look-ahead bias.
- Return type:
- Returns:
BacktestResult with equity curve, returns, positions, and metrics. Additional fields (turnover, costs, net_returns, gross_returns) are available via dict-like access on the metrics dict.
- walk_forward_backtest(prices, strategy_factory, param_grid, train_size=252, test_size=63, step_size=None, metric='sharpe_ratio', initial_capital=100000.0, commission=0.0, slippage=0.0)[source]¶
Walk-forward optimisation and backtesting.
Walk-forward analysis is the gold standard for evaluating parameter-dependent strategies. It splits the data into rolling train/test windows, optimises strategy parameters on the training set, and evaluates on the out-of-sample test set. The result is a true out-of-sample equity curve that avoids look-ahead bias and parameter overfitting.
- Algorithm:
For each window starting at
t: a. Train set:prices[t : t + train_size]b. Test set:prices[t + train_size : t + train_size + test_size]For each parameter combination in
param_grid, backtest on the train set and record the optimisation metric.Select the best parameters and backtest on the test set.
Slide forward by
step_sizeand repeat.Concatenate all out-of-sample test returns.
- Parameters:
prices (
DataFrame) – Asset price DataFrame.strategy_factory (
Callable[...,Strategy]) – Callable that accepts keyword arguments (fromparam_grid) and returns aStrategyinstance.param_grid (
list[dict[str,Any]]) – List of parameter dictionaries to search over.train_size (
int, default:252) – Number of periods in each training window.test_size (
int, default:63) – Number of periods in each test window.step_size (
int|None, default:None) – Number of periods to slide the window forward. Defaults totest_size(non-overlapping test windows).metric (
str, default:'sharpe_ratio') – Performance metric to optimise (key fromperformance_summaryoutput, e.g.,"sharpe_ratio").initial_capital (
float, default:100000.0) – Starting capital for each window backtest.commission (
float, default:0.0) – Commission fraction.slippage (
float, default:0.0) – Slippage fraction.
- Returns:
oos_returns: Concatenated out-of-sample return series.is_returns: Concatenated in-sample return series.oos_equity_curve: Equity curve from OOS returns.params_per_window: List of best params per window.is_metrics_per_window: In-sample metrics per window.oos_metrics: Overall OOS performance summary.stability_ratio: Fraction of windows where OOS Sharpe > 0.
- Return type:
Example
>>> import pandas as pd, numpy as np >>> from wraquant.backtest.strategy import MomentumStrategy >>> rng = np.random.default_rng(42) >>> prices = pd.DataFrame( ... 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.015, (600, 2)), axis=0)), ... columns=["A", "B"], ... index=pd.bdate_range("2020-01-01", periods=600), ... ) >>> grid = [{"lookback": 10}, {"lookback": 20}, {"lookback": 40}] >>> result = walk_forward_backtest( ... prices, ... strategy_factory=lambda **kw: MomentumStrategy(**kw), ... param_grid=grid, ... train_size=200, ... test_size=50, ... ) >>> "oos_returns" in result True
See also
Backtest: Single-pass backtesting engine. VectorizedBacktest: Signal-matrix-based backtesting.
- class Strategy[source]¶
Bases:
ABCAbstract base class for trading strategies.
Subclasses must implement
generate_signalswhich maps prices to position signals (-1, 0, +1 or fractional).Example
>>> class MACrossover(Strategy): ... def __init__(self, fast: int = 10, slow: int = 50): ... self.fast = fast ... self.slow = slow ... ... def generate_signals(self, prices: pd.DataFrame) -> pd.DataFrame: ... fast_ma = prices.rolling(self.fast).mean() ... slow_ma = prices.rolling(self.slow).mean() ... return (fast_ma > slow_ma).astype(float)
- performance_summary(returns, risk_free=0.0, periods_per_year=252)[source]¶
Calculate comprehensive performance metrics.
- omega_ratio(returns, threshold=0.0)[source]¶
Omega ratio: probability-weighted gain/loss ratio above a threshold.
The Omega ratio partitions the return distribution at a threshold L and computes the ratio of the expected gain above L to the expected loss below L. Unlike the Sharpe ratio it uses the entire distribution (all moments, not just the first two), making it more appropriate for non-normal returns.
- Mathematical formulation:
- Omega(L) = E[max(R - L, 0)] / E[max(L - R, 0)]
= sum(max(r_i - L, 0)) / sum(max(L - r_i, 0))
- How to interpret:
Omega = 1.0: strategy breaks even relative to the threshold.
Omega > 1.0: gains outweigh losses (good).
Omega > 2.0: strong risk-adjusted performance.
Omega = inf: no returns below the threshold.
The higher the better; compare strategies at the same threshold.
- When to use:
Use Omega when return distributions are skewed or fat-tailed and you want a single number that captures the full distribution. Prefer Omega over Sharpe for options-based or trend-following strategies whose returns are far from Gaussian.
- Parameters:
- Return type:
- Returns:
Omega ratio as a float. Returns
infif no returns fall below the threshold.
Example
>>> import pandas as pd >>> r = pd.Series([0.01, 0.02, -0.005, 0.015, -0.01]) >>> omega_ratio(r, threshold=0.0) 3.0
See also
kappa_ratio: Generalized lower-partial-moment ratio (Kappa(1) = Omega - 1). sharpe_ratio: Mean/std ratio; only uses first two moments.
- burke_ratio(returns, periods_per_year=252)[source]¶
Burke ratio: return per unit of drawdown severity.
The Burke ratio penalises strategies that experience many deep drawdowns by dividing the annualised excess return by the square root of the sum of squared drawdown depths. Compared to Calmar (which uses only the worst drawdown), Burke considers the entire drawdown history.
- Mathematical formulation:
Burke = annualized_return / sqrt(sum(d_i^2))
where d_i are the individual drawdown depths (negative values).
- How to interpret:
Higher is better.
A strategy with many small drawdowns can have a higher Burke than one with a single large drawdown, even if their Calmar ratios are identical.
There is no universal “good” threshold; use for relative comparison between strategies.
- When to use:
Prefer Burke over Calmar when you want to penalise strategies that repeatedly draw down, not just the single worst case.
- Parameters:
- Return type:
- Returns:
Burke ratio as a float. Returns 0.0 if there are no drawdowns.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> burke_ratio(r) 1.23
See also
ulcer_performance_index: Uses Ulcer Index (RMS of drawdowns) in denominator. recovery_factor: Net profit / max drawdown.
- ulcer_performance_index(returns, periods_per_year=252)[source]¶
Ulcer Performance Index (UPI): return per unit of drawdown pain.
The UPI (also known as Martin ratio) divides the annualised excess return by the Ulcer Index, which is the root-mean-square of percentage drawdowns. The Ulcer Index captures both the depth and duration of drawdowns, making UPI a comprehensive pain-adjusted return measure.
- Mathematical formulation:
Ulcer Index = sqrt(mean(d_i^2)) UPI = annualized_return / Ulcer Index
where d_i is the drawdown at each point in time.
- How to interpret:
Higher is better.
UPI > 2.0 is generally considered very good.
UPI accounts for drawdown duration (not just depth), so a strategy that recovers quickly scores better than one that lingers in drawdown.
- When to use:
Use UPI when you want a risk-adjusted measure that captures the investor’s real experience of pain (deep, prolonged drawdowns hurt more than brief dips).
- Parameters:
- Return type:
- Returns:
UPI as a float. Returns 0.0 if the Ulcer Index is zero.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> ulcer_performance_index(r) 2.5
See also
burke_ratio: Uses sum of squared drawdowns instead of RMS. max_drawdown: Single worst peak-to-trough decline.
- kappa_ratio(returns, order=2, threshold=0.0, periods_per_year=252)[source]¶
Kappa ratio: generalized Sortino using lower partial moments.
The Kappa ratio family generalises the Sortino ratio by using the n-th root of the n-th lower partial moment (LPM) as the risk measure. Special cases:
Kappa(1) is equivalent to (Omega - 1) when annualization is removed (first lower partial moment = expected shortfall below threshold).
Kappa(2) is the Sortino ratio (second lower partial moment = downside deviation).
Kappa(3) penalises large negative returns even more heavily.
- Mathematical formulation:
LPM_n = mean(max(L - r_i, 0)^n) Kappa_n = (annualized_mean_excess) / LPM_n^(1/n)
- How to interpret:
Higher is better (more return per unit of downside risk).
Higher orders penalise tail risk more severely.
Compare strategies using the same order.
- When to use:
Use Kappa(3) when you especially fear large losses. Use Kappa(2) as a drop-in alternative to Sortino. Use Kappa(1) when you want an Omega-style measure in ratio form.
- Parameters:
- Return type:
- Returns:
Kappa ratio as a float. Returns 0.0 if LPM is zero.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> kappa_ratio(r, order=2) 1.8
See also
omega_ratio: Threshold-based gain/loss ratio. sortino_ratio: Equivalent to Kappa(2).
- tail_ratio(returns, upper_pct=95.0, lower_pct=5.0)[source]¶
Tail ratio: magnitude of right tail vs. left tail.
The tail ratio measures how large positive outlier returns are relative to negative outlier returns. A value greater than 1 means the strategy’s best days are proportionally larger than its worst days.
- Mathematical formulation:
Tail ratio = |percentile(R, upper_pct)| / |percentile(R, lower_pct)|
- How to interpret:
Tail ratio > 1.0: right tail is fatter (upside surprises bigger than downside). Desirable.
Tail ratio = 1.0: symmetric tails.
Tail ratio < 1.0: left tail is fatter (downside risk dominates).
- When to use:
Quick diagnostic to check if a strategy has favorable tail behaviour. Combine with
common_sense_ratiofor a sanity check.
- Parameters:
- Return type:
- Returns:
Tail ratio as a float. Returns
infif the lower percentile is zero.
Example
>>> import pandas as pd >>> r = pd.Series([0.03, 0.01, -0.01, 0.02, -0.005]) >>> tail_ratio(r) 2.0
See also
common_sense_ratio: Tail ratio multiplied by (1 + Sharpe). rachev_ratio: CVaR-based tail comparison.
- common_sense_ratio(returns, risk_free=0.0, periods_per_year=252)[source]¶
Common Sense Ratio: quick sanity check combining Sharpe and tails.
The Common Sense Ratio multiplies the tail ratio by (1 + Sharpe). It combines a measure of tail asymmetry with overall risk-adjusted performance into a single number for rapid strategy screening.
- Mathematical formulation:
CSR = tail_ratio * (1 + Sharpe)
- How to interpret:
CSR > 1.0: strategy has both favorable tails and positive risk-adjusted return. Good.
CSR < 1.0: either tails are unfavorable or risk-adjusted return is poor.
Use for fast initial screening; not a substitute for deeper analysis.
- When to use:
Use CSR as a first-pass filter when evaluating many strategies simultaneously.
- Parameters:
- Return type:
- Returns:
Common Sense Ratio as a float.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> common_sense_ratio(r) 1.5
See also
tail_ratio: Upside vs downside tail magnitude. sharpe_ratio: Mean/std risk-adjusted return.
- rachev_ratio(returns, alpha=0.05)[source]¶
Rachev ratio: CVaR of gains over CVaR of losses.
The Rachev ratio (also called the Conditional Tail Ratio) compares the expected size of extreme gains to the expected size of extreme losses, using Conditional Value at Risk (CVaR, a.k.a. Expected Shortfall). Unlike the simple tail ratio, Rachev uses the mean of the tail rather than a single percentile, making it more robust to outliers.
- Mathematical formulation:
CVaR_alpha(gains) = E[R | R > VaR_{1-alpha}(R)] CVaR_alpha(losses) = E[-R | R < VaR_alpha(R)] Rachev = CVaR_alpha(gains) / CVaR_alpha(losses)
- How to interpret:
Rachev > 1.0: expected extreme gains exceed expected extreme losses. The strategy has favorable fat-tail asymmetry.
Rachev = 1.0: symmetric tail risk.
Rachev < 1.0: tail risk is skewed to the downside.
- When to use:
Use Rachev when you need a fat-tail-aware gain/loss comparison. More robust than
tail_ratiobecause it averages over the tail rather than using a single percentile.
- Parameters:
- Return type:
- Returns:
Rachev ratio as a float. Returns
infif the loss CVaR is zero.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 500)) >>> rachev_ratio(r, alpha=0.05) 1.1
See also
tail_ratio: Percentile-based tail comparison. omega_ratio: Full-distribution gain/loss ratio.
- gain_to_pain_ratio(returns)[source]¶
Gain to Pain ratio: total gains over total absolute losses.
The Gain to Pain ratio (GPR) divides the sum of all returns by the absolute sum of all negative returns. It provides a simple measure of how much total return the strategy generates per unit of pain (aggregate losses) experienced.
- Mathematical formulation:
GPR = sum(r_i) / |sum(r_i where r_i < 0)|
- How to interpret:
GPR > 1.0: total gains exceed total losses; strategy is net profitable and then some.
GPR = 0.5: for every dollar lost, the strategy earned 50 cents net.
GPR < 0: strategy loses money overall.
GPR > 1.5 is generally considered good for daily returns.
- When to use:
Quick profitability diagnostic. Simpler and more intuitive than profit factor (which excludes zero returns and looks at gross gains vs. gross losses).
- Parameters:
returns (
Series) – Simple return series.- Return type:
- Returns:
Gain to Pain ratio as a float. Returns
infif there are no negative returns.
Example
>>> import pandas as pd >>> r = pd.Series([0.02, -0.01, 0.015, -0.005, 0.01]) >>> gain_to_pain_ratio(r) 2.67
See also
profit_factor: Gross gains / gross losses (ignores net). omega_ratio: Threshold-based gain/loss comparison.
- risk_of_ruin(win_rate, payoff_ratio, ruin_pct=0.5, n_trades=1000)[source]¶
Probability of losing a given fraction of capital.
Estimates the risk of ruin – the probability that a strategy will lose ruin_pct of its capital – given its win rate and average payoff ratio, assuming fixed fractional position sizing and independent trades.
- Mathematical formulation (simplified):
RoR = ((1 - edge) / (1 + edge)) ^ capital_units
where edge = (win_rate * payoff_ratio) - (1 - win_rate) and capital_units approximates the number of bet-units to exhaust before reaching ruin_pct.
- How to interpret:
RoR close to 0: very unlikely to hit ruin level.
RoR > 0.05 (5 %): meaningful risk; reduce position size.
RoR > 0.20 (20 %): dangerous; the strategy may not survive.
A strategy with high win_rate and high payoff_ratio has very low risk of ruin.
- When to use:
Use risk of ruin to decide whether a strategy is survivable over n_trades. Combine with Kelly fraction to determine appropriate sizing.
- Parameters:
win_rate (
float) – Probability of a winning trade (0 to 1).payoff_ratio (
float) – Average win / average loss (positive number).ruin_pct (
float, default:0.5) – Fraction of capital that constitutes ruin (e.g., 0.5 = losing 50 % of capital).n_trades (
int, default:1000) – Number of trades to consider in the simulation window (affects capital_units approximation).
- Return type:
- Returns:
Estimated probability of ruin (0 to 1).
Example
>>> risk_of_ruin(win_rate=0.55, payoff_ratio=1.5, ruin_pct=0.5) 0.002
See also
kelly_fraction: Optimal position sizing. expectancy: Expected value per trade.
- kelly_fraction(win_rate, avg_win, avg_loss)[source]¶
Kelly fraction: optimal bet sizing for geometric growth.
The Kelly criterion determines the fraction of capital to risk on each trade to maximise the long-run geometric growth rate. The full Kelly is aggressive; practitioners typically use fractional Kelly (e.g., half-Kelly) to reduce variance.
- Mathematical formulation:
b = avg_win / avg_loss (odds ratio) f* = (b * p - q) / b
where p = win_rate, q = 1 - p.
- How to interpret:
f* > 0: strategy has positive edge; bet this fraction.
f* = 0: no edge; do not bet.
f* < 0: negative edge (clamped to 0); avoid this strategy.
f* > 0.25: full Kelly is very aggressive; consider using half or quarter Kelly.
- When to use:
Use Kelly to determine the theoretical maximum position size. In practice, use fractional Kelly (0.25x to 0.5x) because Kelly assumes known and constant edge, which is unrealistic.
- Parameters:
- Return type:
- Returns:
Optimal fraction of capital to risk, clamped to [0, 1].
Example
>>> kelly_fraction(win_rate=0.55, avg_win=1.5, avg_loss=1.0) 0.25
See also
risk_of_ruin: Probability of catastrophic drawdown. expectancy: Expected value per trade.
- expectancy(win_rate, avg_win, avg_loss)[source]¶
Expectancy: expected profit per trade.
Expectancy combines win rate and payoff to give the average expected value of each trade. A strategy with positive expectancy is profitable in the long run (assuming sufficient trades and stable edge).
- Mathematical formulation:
E = (win_rate * avg_win) - ((1 - win_rate) * |avg_loss|)
- How to interpret:
E > 0: each trade is expected to be profitable on average.
E = 0: break-even strategy.
E < 0: losing strategy.
E > 0.10 (if avg_loss is normalised to 1.0): reasonable edge.
- When to use:
Use expectancy alongside system quality number (SQN) and profit factor to evaluate a trading system. Expectancy alone does not account for variability of outcomes.
- Parameters:
- Return type:
- Returns:
Expected value per trade.
Example
>>> expectancy(win_rate=0.6, avg_win=100.0, avg_loss=80.0) 28.0
See also
system_quality_number: Expectancy normalised by trade variability. kelly_fraction: Optimal sizing given expectancy.
- profit_factor(returns)[source]¶
Profit factor: gross profit divided by gross loss.
The profit factor measures how many dollars the strategy earns for every dollar it loses. It is the simplest measure of a strategy’s profitability.
- Mathematical formulation:
PF = sum(r_i where r_i > 0) / |sum(r_i where r_i < 0)|
- How to interpret:
PF > 1.0: strategy is profitable.
PF = 1.0: break even.
PF < 1.0: strategy loses money.
PF > 1.5: good.
PF > 2.0: very good (verify not overfitting).
- When to use:
Use as a quick profitability check. Pair with win rate and payoff ratio for a complete picture.
- Parameters:
returns (
Series) – Return or P&L series.- Return type:
- Returns:
Profit factor as a float. Returns
infif there are no losses.
Example
>>> import pandas as pd >>> r = pd.Series([0.02, -0.01, 0.03, -0.005]) >>> profit_factor(r) ... 3.33
See also
gain_to_pain_ratio: Net return / total losses. expectancy: Expected value per trade.
- payoff_ratio(returns)[source]¶
Payoff ratio: average win divided by average loss.
The payoff ratio (also called reward-to-risk ratio) measures how large the average winning trade is relative to the average losing trade. Combined with win rate, it fully characterises a strategy’s return profile.
- Mathematical formulation:
Payoff = mean(r_i where r_i > 0) / |mean(r_i where r_i < 0)|
- How to interpret:
Payoff > 1.0: average wins are larger than average losses. Common in trend-following strategies.
Payoff = 1.0: wins and losses are the same size.
Payoff < 1.0: average losses exceed average wins. The strategy must have a high win rate to compensate.
Payoff > 2.0 with win rate > 0.40 is a strong system.
- When to use:
Use alongside win rate. A low win rate with high payoff (trend-following) is as viable as high win rate with low payoff (mean-reversion).
- Parameters:
returns (
Series) – Return or P&L series.- Return type:
- Returns:
Payoff ratio as a float. Returns
infif there are no losses, and 0.0 if there are no wins.
Example
>>> import pandas as pd >>> r = pd.Series([0.02, -0.01, 0.03, -0.005]) >>> payoff_ratio(r) ... 3.33
See also
profit_factor: Gross gains / gross losses. expectancy: Combines win rate and payoff into expected value.
- recovery_factor(returns)[source]¶
Recovery factor: net profit relative to max drawdown.
Recovery factor measures how many times over the strategy has recovered from its worst drawdown. A high recovery factor indicates that the strategy generates returns efficiently relative to the drawdown pain it inflicts.
- Mathematical formulation:
RF = total_return / |max_drawdown|
- How to interpret:
RF > 1.0: strategy has earned back more than its worst drawdown.
RF > 3.0: strong.
RF > 5.0: excellent recovery relative to risk.
RF < 1.0: strategy has not yet recovered from its worst drawdown.
- When to use:
Use recovery factor when you want to know if the strategy’s returns justify the drawdown pain. Useful for comparing strategies with different drawdown profiles.
- Parameters:
returns (
Series) – Simple return series.- Return type:
- Returns:
Recovery factor as a float. Returns
infif there is no drawdown.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> recovery_factor(r) 3.5
See also
burke_ratio: Return per sum-of-squared-drawdowns. ulcer_performance_index: Return per Ulcer Index.
- system_quality_number(pnl)[source]¶
System Quality Number (SQN): Van Tharp’s strategy quality metric.
SQN normalises expectancy by the variability of trade outcomes and scales by the square root of the number of trades. It answers the question: “given the consistency and edge of this system, is the positive expectancy statistically significant?”
- Mathematical formulation:
SQN = sqrt(N) * mean(pnl) / std(pnl)
where N is the number of trades (or periods).
- How to interpret:
SQN < 1.6: poor; difficult to trade profitably.
1.6 < SQN < 2.0: below average; marginal edge.
2.0 < SQN < 2.5: average; tradeable with discipline.
2.5 < SQN < 3.0: good; reliable system.
3.0 < SQN < 5.0: excellent.
5.0 < SQN < 7.0: superb.
SQN > 7.0: holy grail (verify not overfitting).
- When to use:
Use SQN to evaluate whether a system’s edge is statistically meaningful given the number of observations. Particularly useful when comparing systems with different numbers of trades.
- Parameters:
pnl (
Series) – Per-trade or per-period P&L series.- Return type:
- Returns:
SQN as a float. Returns 0.0 if the standard deviation is zero.
Example
>>> import pandas as pd >>> pnl = pd.Series([100, -50, 80, -30, 120, -40, 90, -20]) >>> system_quality_number(pnl) 1.7
See also
expectancy: Mean expected value per trade. kelly_fraction: Optimal position sizing from edge.
- class EventTracker[source]¶
Bases:
objectTrack and query portfolio events during a backtest.
Maintains an ordered log of events (trades, rebalances, signals, risk events) and provides query and summary facilities.
Example
>>> tracker = EventTracker() >>> tracker.log_trade(datetime(2024, 1, 2), "AAPL", "buy", 100, 150.0) >>> tracker.summary()["total_events"] 1
- log_trade(timestamp, asset, side, quantity, price, metadata=None)[source]¶
Log a trade event.
- Parameters:
- Returns:
The logged event.
- Return type:
- log_rebalance(timestamp, old_weights, new_weights, reason='')[source]¶
Log a portfolio rebalance event.
- Parameters:
- Returns:
The logged event.
- Return type:
- log_risk_event(timestamp, event_type, details=None)[source]¶
Log a risk event (VaR breach, drawdown threshold, etc.).
- get_events(event_type=None, start=None, end=None)[source]¶
Query events by type and/or time range.
- Parameters:
- Returns:
Matching events in chronological order.
- Return type:
- class EventType[source]¶
-
Enumeration of trackable event types.
- TRADE = 'trade'¶
- REBALANCE = 'rebalance'¶
- SIGNAL = 'signal'¶
- RISK = 'risk'¶
- REGIME_CHANGE = 'regime_change'¶
- DRAWDOWN = 'drawdown'¶
- __new__(value)¶
- detect_drawdown_events(returns, threshold=-0.05)[source]¶
Detect drawdown events that exceed a given threshold.
- Parameters:
- Returns:
DataFrame with columns
start,end,trough_date,depth, anddurationfor each drawdown event.- Return type:
- detect_regime_changes(returns, method='rolling_vol', window=63, threshold=1.5)[source]¶
Detect regime transitions in a return series.
- Parameters:
returns (
Series) – Asset or portfolio returns.method (
str, default:'rolling_vol') – Detection method. Supported:"rolling_vol"(default) uses a ratio of short-term to long-term rolling volatility,"mean_shift"uses a shift in the rolling mean.window (
int, default:63) – Lookback window (number of periods).threshold (
float, default:1.5) – Multiplier above which a regime change is flagged.
- Returns:
DataFrame with columns
timestamp,regime, andindicatorfor each detected transition point.- Return type:
- class PositionSizer[source]¶
Bases:
objectCollection of position-sizing algorithms.
All methods are stateless class methods so the sizer can be used as a lightweight namespace without instantiation.
Example
>>> PositionSizer.fixed_fraction(100_000, 0.02) 2000.0
- static volatility_targeting(returns, target_vol, lookback=20)[source]¶
Volatility-targeting position scalar.
Computes the leverage / de-leverage factor so that the portfolio’s annualised volatility approximates target_vol.
- static risk_parity_weights(cov_matrix)[source]¶
Risk-parity portfolio weights.
Each asset contributes equally to total portfolio risk. Uses an inverse-volatility heuristic that provides a good approximation for portfolios with moderate correlations.
- clip_weights(weights, min_w=0.0, max_w=1.0)[source]¶
Clip portfolio weights and re-normalise to sum to 1.
- Parameters:
- Returns:
Clipped and re-normalised weights.
- Return type:
- rebalance_threshold(current_weights, target_weights, threshold=0.05)[source]¶
Check whether portfolio drift exceeds a rebalance threshold.
- Parameters:
- Returns:
Trueif any weight has drifted beyond threshold and a rebalance is recommended.- Return type:
- risk_parity_position(cov_matrix, target_vol=None)[source]¶
Position sizing using risk parity (equal risk contribution).
Computes portfolio weights such that each asset contributes equally to total portfolio risk, then optionally scales the weights so that the portfolio’s annualised volatility matches
target_vol.This is a convenience wrapper around
PositionSizer.equal_risk_contributionthat adds volatility targeting and returns a clean weight vector.- Mathematical formulation:
- For each asset i, the risk contribution is:
RC_i = w_i * (Cov @ w)_i
We solve for w such that RC_i = RC_j for all i, j, subject to sum(w) = 1.
If
target_volis provided, the weights are scaled bytarget_vol / portfolio_vol.- How to interpret:
Weights will be higher for lower-volatility assets and lower for higher-volatility assets.
In a diagonal covariance matrix, risk parity reduces to inverse volatility weighting.
With non-zero correlations, the optimiser also accounts for diversification benefit.
- When to use:
Use risk parity when you want a balanced portfolio where no single asset dominates the risk budget. Particularly popular for multi-asset and all-weather portfolios.
- Parameters:
cov_matrix (
DataFrame|ndarray[tuple[Any,...],dtype[floating]]) – Covariance matrix of asset returns (n x n). Can be a pandas DataFrame or numpy array.target_vol (
float|None, default:None) – Target annualised portfolio volatility (e.g., 0.10 for 10 %). IfNone, weights sum to 1 without vol scaling.
- Return type:
- Returns:
Weight array. Sums to 1 if
target_volisNone; otherwise scaled to achieve the target volatility.
Example
>>> import numpy as np >>> cov = np.array([[0.04, 0.01], [0.01, 0.09]]) >>> w = risk_parity_position(cov) >>> abs(w.sum() - 1.0) < 1e-6 True >>> w[0] > w[1] # lower-vol asset gets more weight True
See also
PositionSizer.equal_risk_contribution: Core ERC optimiser. PositionSizer.risk_parity_weights: Simpler inverse-vol heuristic. regime_conditional_sizing: Adjust weights based on market regime.
- regime_conditional_sizing(base_weights, regime_probabilities, risk_multipliers)[source]¶
Adjust position sizes based on current regime probabilities.
Scales base portfolio weights by a regime-dependent risk multiplier. The effective multiplier is a probability-weighted average of the per-regime multipliers, ensuring smooth transitions between regimes.
- Mathematical formulation:
effective_multiplier = sum(P(regime_i) * multiplier_i) adjusted_weights = base_weights * effective_multiplier
- How to interpret:
If the current regime is “high_vol” with probability 0.8 and the risk multiplier for “high_vol” is 0.5, the weights will be scaled down significantly.
If the regime is “normal” (multiplier = 1.0), weights remain unchanged.
Multipliers > 1.0 increase exposure (e.g., during low-vol regimes).
- When to use:
Use regime-conditional sizing when your strategy should adjust leverage or exposure based on market conditions. Pair with regime detection (HMM, rolling volatility, etc.) to automatically reduce risk during turbulent markets.
- Parameters:
base_weights (
ndarray[tuple[Any,...],dtype[floating]] |Series) – Base portfolio weights (array or Series).regime_probabilities (
dict[str,float]) – Mapping of regime name to probability (e.g.,{"normal": 0.3, "high_vol": 0.7}). Probabilities should sum to 1 but are not strictly enforced.risk_multipliers (
dict[str,float]) – Mapping of regime name to risk multiplier (e.g.,{"normal": 1.0, "high_vol": 0.5, "low_vol": 1.5}). Regimes inregime_probabilitiesthat are missing fromrisk_multipliersdefault to a multiplier of 1.0.
- Return type:
- Returns:
Adjusted weight array. May not sum to 1 (the multiplier acts as a leverage/de-leverage factor).
Example
>>> import numpy as np >>> base = np.array([0.5, 0.3, 0.2]) >>> probs = {"normal": 0.3, "high_vol": 0.7} >>> mults = {"normal": 1.0, "high_vol": 0.5} >>> adj = regime_conditional_sizing(base, probs, mults) >>> adj.sum() < base.sum() # scaled down due to high_vol True
See also
risk_parity_position: Risk-parity-based weight computation. PositionSizer.volatility_targeting: Vol-targeting scalar.
- regime_signal_filter(signals, regime_probs, active_regime=0, min_prob=0.6)[source]¶
Filter trading signals to only trade in favorable regimes.
Zeros out signals when the probability of the active regime is below min_prob. Useful for avoiding trades during crisis regimes or only trading during trending markets.
- Parameters:
signals (
Series|ndarray) – Raw trading signals (1=long, -1=short, 0=flat).regime_probs (
ndarray) – Regime probability matrix (T, K) from detect_regimes().active_regime (
int, default:0) – Which regime to trade in (0=low vol by convention).min_prob (
float, default:0.6) – Minimum probability threshold to allow trading.
- Return type:
- Returns:
Filtered signals (same shape, zeros where regime inactive).
Example
>>> from wraquant.regimes import detect_regimes >>> from wraquant.backtest.position import regime_signal_filter >>> regimes = detect_regimes(returns, method="hmm", n_regimes=2) >>> filtered = regime_signal_filter(signals, regimes.probabilities, ... active_regime=0, min_prob=0.6)
- comprehensive_tearsheet(returns, benchmark=None, risk_free=0.0, periods_per_year=252, trades_df=None, regime_states=None)[source]¶
Generate a complete performance report combining all available metrics.
This is the “kitchen sink” tearsheet — it computes every metric in the backtesting module and organises them into a nested dictionary that can feed directly into dashboards and visualisation tools.
The returned dictionary contains the following top-level keys:
summary: Core risk/return metrics fromgenerate_tearsheet.extended_metrics: All metrics frombacktest.metrics(Omega, Burke, UPI, Kappa, tail, Rachev, SQN, etc.).monthly_returns: Monthly return table (years x months).yearly_returns: Annual compounded returns.drawdown_analysis: Top 5 drawdowns with dates and durations.rolling_metrics: Rolling 3m/6m/12m Sharpe, vol, and return.trade_analysis: Per-trade statistics (iftrades_dfis provided).regime_performance: Performance broken out by regime state (ifregime_statesis provided).
- Parameters:
returns (
Series) – Simple return series with a DatetimeIndex.benchmark (
Series|None, default:None) – Benchmark return series for relative metrics.risk_free (
float, default:0.0) – Annual risk-free rate.periods_per_year (
int, default:252) – Trading periods per year (252 for daily).trades_df (
DataFrame|None, default:None) – Trade log DataFrame with apnlcolumn. If provided, trade-level analysis is included.regime_states (
Series|None, default:None) – Series of regime labels (e.g., “bull”, “bear”, “normal”) aligned withreturns. If provided, the tearsheet includes per-regime performance breakdowns.
- Return type:
- Returns:
Nested dictionary with all metrics and analysis tables.
Example
>>> import pandas as pd, numpy as np >>> rng = np.random.default_rng(42) >>> rets = pd.Series( ... rng.normal(0.0004, 0.01, 504), ... index=pd.bdate_range("2022-01-03", periods=504), ... ) >>> ts = comprehensive_tearsheet(rets) >>> "summary" in ts and "extended_metrics" in ts True
See also
generate_tearsheet: Core performance metrics only. strategy_comparison: Side-by-side comparison of multiple strategies.
- generate_tearsheet(returns, benchmark=None, risk_free=0.0, periods_per_year=252)[source]¶
Generate a comprehensive performance tearsheet dictionary.
- Parameters:
- Returns:
Dictionary containing absolute and (optionally) relative performance metrics.
- Return type:
- monthly_returns_table(returns)[source]¶
Compute a table of monthly returns suitable for heatmap display.
- rolling_metrics_table(returns, windows=None, periods_per_year=252)[source]¶
Compute rolling Sharpe, volatility, and return at multiple windows.
- Parameters:
- Returns:
MultiIndex columns:
(window, metric)with metricsrolling_return,rolling_vol,rolling_sharpe.- Return type:
- strategy_comparison(strategies, risk_free=0.0, periods_per_year=252)[source]¶
Compare multiple strategies side-by-side on all metrics.
Computes a comprehensive set of performance metrics for each strategy and returns them in a single DataFrame where columns are strategy names and rows are metric names. The best and worst values for each metric are easy to spot by sorting.
- Parameters:
- Return type:
- Returns:
DataFrame with metric names as the index and strategy names as columns. Contains all core and extended metrics.
Example
>>> import pandas as pd, numpy as np >>> rng = np.random.default_rng(42) >>> strats = { ... "momentum": pd.Series(rng.normal(0.0005, 0.012, 252)), ... "mean_rev": pd.Series(rng.normal(0.0003, 0.008, 252)), ... } >>> comp = strategy_comparison(strats) >>> "momentum" in comp.columns and "mean_rev" in comp.columns True
See also
comprehensive_tearsheet: Full report for a single strategy. generate_tearsheet: Core metrics for a single strategy.
- trade_analysis(trades_df)[source]¶
Analyse trade-level performance.
- Parameters:
trades_df (
DataFrame) – Must contain apnlcolumn with per-trade profit/loss values. Optionally includesentry_price,exit_price,side, etc.- Returns:
Dictionary with
win_rate,avg_pnl,avg_win,avg_loss,profit_factor,expectancy,max_win,max_loss,n_trades.- Return type:
- vectorbt_backtest(prices, entries, exits, **kwargs)[source]¶
Run a vectorised backtest using vectorbt.
- Parameters:
prices (
Series|DataFrame) – Price data aligned with the entry/exit signals.entries (
Series|DataFrame) – Boolean series/DataFrame indicating entry signals.exits (
Series|DataFrame) – Boolean series/DataFrame indicating exit signals.**kwargs (
Any) – Additional keyword arguments forwarded tovectorbt.Portfolio.from_signals.
- Returns:
Dictionary containing:
total_return – total portfolio return.
sharpe_ratio – annualised Sharpe ratio.
max_drawdown – maximum drawdown.
total_trades – number of trades executed.
win_rate – fraction of winning trades.
portfolio – the raw
vectorbt.Portfolioobject.
- Return type:
- quantstats_report(returns, benchmark=None, output=None)[source]¶
Generate performance analytics using quantstats.
- Parameters:
- Returns:
Dictionary containing:
sharpe – annualised Sharpe ratio.
sortino – annualised Sortino ratio.
max_drawdown – maximum drawdown.
cagr – compound annual growth rate.
volatility – annualised volatility.
calmar – Calmar ratio.
- Return type:
- empyrical_metrics(returns)[source]¶
Compute a comprehensive set of metrics using empyrical.
- Parameters:
returns (
Series) – Simple return series.- Returns:
Dictionary containing:
annual_return – annualised return.
annual_volatility – annualised volatility.
sharpe_ratio – annualised Sharpe ratio.
sortino_ratio – annualised Sortino ratio.
max_drawdown – maximum drawdown.
calmar_ratio – Calmar ratio.
omega_ratio – Omega ratio.
tail_ratio – tail ratio (95th / 5th percentile).
stability – R-squared of cumulative log returns.
- Return type:
- pyfolio_tearsheet_data(returns, positions=None, transactions=None)[source]¶
Prepare data in the format expected by pyfolio tearsheets.
This function does not render plots but returns the intermediate data structures that pyfolio uses internally, making it possible to inspect results programmatically.
- Parameters:
- Returns:
Dictionary containing:
returns – the input return series.
cum_returns – cumulative return series.
drawdown – drawdown series.
positions – positions DataFrame (or None).
transactions – transactions DataFrame (or None).
- Return type:
- ffn_stats(prices)[source]¶
Compute performance statistics using ffn.
- Parameters:
prices (
Series|DataFrame) – Price series or multi-asset price DataFrame.- Returns:
Dictionary containing key performance metrics:
total_return – total return over the period.
cagr – compound annual growth rate.
daily_sharpe – daily Sharpe ratio.
max_drawdown – maximum drawdown.
avg_drawdown – average drawdown.
monthly_sharpe – monthly Sharpe ratio.
stats_object – the raw
ffn.PerformanceStatsobject.
- Return type:
Engine¶
Vectorized backtesting engine.
- class Backtest[source]¶
Bases:
objectVectorized backtesting engine.
- Parameters:
Example
>>> from wraquant.backtest import Backtest >>> from wraquant.backtest.strategy import BuyAndHold >>> bt = Backtest(BuyAndHold(), initial_capital=100_000) >>> result = bt.run(prices_df)
- class VectorizedBacktest[source]¶
Bases:
objectFast vectorized backtesting engine for signal-based strategies.
Unlike the event-driven
Backtestclass (which requires aStrategyobject),VectorizedBacktestoperates directly on a pre-computed signal / weight matrix. This is ideal for research workflows where signals are generated upstream (e.g., from a machine learning model or factor pipeline) and you need fast, repeatable backtest evaluation.The engine computes portfolio returns accounting for:
Transaction costs (fixed commission per unit of turnover).
Slippage (additional cost proportional to turnover).
Rebalancing frequency (skip rebalancing on off-days to reduce turnover).
- Parameters:
initial_capital (
float, default:100000.0) – Starting portfolio value in currency units.commission (
float, default:0.0) – One-way commission as a fraction of turnover (e.g., 0.001 = 10 bps).slippage (
float, default:0.0) – Slippage as a fraction of turnover.rebalance_frequency (
int, default:1) – Rebalance every N periods.1means every period (daily for daily data),5means weekly, etc.
Example
>>> import pandas as pd, numpy as np >>> rng = np.random.default_rng(42) >>> prices = pd.DataFrame( ... 100 * np.exp(np.cumsum(rng.normal(0.0005, 0.02, (100, 2)), axis=0)), ... columns=["A", "B"], ... index=pd.bdate_range("2023-01-01", periods=100), ... ) >>> signals = pd.DataFrame( ... 0.5, index=prices.index, columns=prices.columns ... ) >>> bt = VectorizedBacktest(commission=0.001, slippage=0.0005) >>> result = bt.run(prices, signals) >>> isinstance(result, BacktestResult) True
See also
Backtest: Strategy-object-based backtesting engine. walk_forward_backtest: Walk-forward optimisation + backtest.
- run(prices, signals)[source]¶
Execute the vectorized backtest.
- Parameters:
prices (
DataFrame) – Asset price DataFrame (rows = dates, columns = assets).signals (
DataFrame) – Weight / signal DataFrame with the same shape asprices. Values represent desired portfolio weights (e.g., 0.5 = 50 % allocation to that asset). Signals are shifted by one period internally to avoid look-ahead bias.
- Return type:
- Returns:
BacktestResult with equity curve, returns, positions, and metrics. Additional fields (turnover, costs, net_returns, gross_returns) are available via dict-like access on the metrics dict.
- walk_forward_backtest(prices, strategy_factory, param_grid, train_size=252, test_size=63, step_size=None, metric='sharpe_ratio', initial_capital=100000.0, commission=0.0, slippage=0.0)[source]¶
Walk-forward optimisation and backtesting.
Walk-forward analysis is the gold standard for evaluating parameter-dependent strategies. It splits the data into rolling train/test windows, optimises strategy parameters on the training set, and evaluates on the out-of-sample test set. The result is a true out-of-sample equity curve that avoids look-ahead bias and parameter overfitting.
- Algorithm:
For each window starting at
t: a. Train set:prices[t : t + train_size]b. Test set:prices[t + train_size : t + train_size + test_size]For each parameter combination in
param_grid, backtest on the train set and record the optimisation metric.Select the best parameters and backtest on the test set.
Slide forward by
step_sizeand repeat.Concatenate all out-of-sample test returns.
- Parameters:
prices (
DataFrame) – Asset price DataFrame.strategy_factory (
Callable[...,Strategy]) – Callable that accepts keyword arguments (fromparam_grid) and returns aStrategyinstance.param_grid (
list[dict[str,Any]]) – List of parameter dictionaries to search over.train_size (
int, default:252) – Number of periods in each training window.test_size (
int, default:63) – Number of periods in each test window.step_size (
int|None, default:None) – Number of periods to slide the window forward. Defaults totest_size(non-overlapping test windows).metric (
str, default:'sharpe_ratio') – Performance metric to optimise (key fromperformance_summaryoutput, e.g.,"sharpe_ratio").initial_capital (
float, default:100000.0) – Starting capital for each window backtest.commission (
float, default:0.0) – Commission fraction.slippage (
float, default:0.0) – Slippage fraction.
- Returns:
oos_returns: Concatenated out-of-sample return series.is_returns: Concatenated in-sample return series.oos_equity_curve: Equity curve from OOS returns.params_per_window: List of best params per window.is_metrics_per_window: In-sample metrics per window.oos_metrics: Overall OOS performance summary.stability_ratio: Fraction of windows where OOS Sharpe > 0.
- Return type:
Example
>>> import pandas as pd, numpy as np >>> from wraquant.backtest.strategy import MomentumStrategy >>> rng = np.random.default_rng(42) >>> prices = pd.DataFrame( ... 100 * np.exp(np.cumsum(rng.normal(0.0003, 0.015, (600, 2)), axis=0)), ... columns=["A", "B"], ... index=pd.bdate_range("2020-01-01", periods=600), ... ) >>> grid = [{"lookback": 10}, {"lookback": 20}, {"lookback": 40}] >>> result = walk_forward_backtest( ... prices, ... strategy_factory=lambda **kw: MomentumStrategy(**kw), ... param_grid=grid, ... train_size=200, ... test_size=50, ... ) >>> "oos_returns" in result True
See also
Backtest: Single-pass backtesting engine. VectorizedBacktest: Signal-matrix-based backtesting.
Strategy¶
Strategy abstract base class and common strategies.
- class Strategy[source]¶
Bases:
ABCAbstract base class for trading strategies.
Subclasses must implement
generate_signalswhich maps prices to position signals (-1, 0, +1 or fractional).Example
>>> class MACrossover(Strategy): ... def __init__(self, fast: int = 10, slow: int = 50): ... self.fast = fast ... self.slow = slow ... ... def generate_signals(self, prices: pd.DataFrame) -> pd.DataFrame: ... fast_ma = prices.rolling(self.fast).mean() ... slow_ma = prices.rolling(self.slow).mean() ... return (fast_ma > slow_ma).astype(float)
- class BuyAndHold[source]¶
Bases:
StrategyBuy and hold strategy — always fully invested.
- class MomentumStrategy[source]¶
Bases:
StrategySimple momentum strategy based on lookback returns.
- Parameters:
Position Sizing¶
Enhanced position sizing and weight management utilities.
Provides position sizing algorithms (fixed-fraction, Kelly, vol-targeting, risk parity, equal risk contribution), signal inversion, weight clipping, and rebalance threshold logic.
- class PositionSizer[source]¶
Bases:
objectCollection of position-sizing algorithms.
All methods are stateless class methods so the sizer can be used as a lightweight namespace without instantiation.
Example
>>> PositionSizer.fixed_fraction(100_000, 0.02) 2000.0
- static volatility_targeting(returns, target_vol, lookback=20)[source]¶
Volatility-targeting position scalar.
Computes the leverage / de-leverage factor so that the portfolio’s annualised volatility approximates target_vol.
- static risk_parity_weights(cov_matrix)[source]¶
Risk-parity portfolio weights.
Each asset contributes equally to total portfolio risk. Uses an inverse-volatility heuristic that provides a good approximation for portfolios with moderate correlations.
- clip_weights(weights, min_w=0.0, max_w=1.0)[source]¶
Clip portfolio weights and re-normalise to sum to 1.
- Parameters:
- Returns:
Clipped and re-normalised weights.
- Return type:
- rebalance_threshold(current_weights, target_weights, threshold=0.05)[source]¶
Check whether portfolio drift exceeds a rebalance threshold.
- Parameters:
- Returns:
Trueif any weight has drifted beyond threshold and a rebalance is recommended.- Return type:
- regime_signal_filter(signals, regime_probs, active_regime=0, min_prob=0.6)[source]¶
Filter trading signals to only trade in favorable regimes.
Zeros out signals when the probability of the active regime is below min_prob. Useful for avoiding trades during crisis regimes or only trading during trending markets.
- Parameters:
signals (
Series|ndarray) – Raw trading signals (1=long, -1=short, 0=flat).regime_probs (
ndarray) – Regime probability matrix (T, K) from detect_regimes().active_regime (
int, default:0) – Which regime to trade in (0=low vol by convention).min_prob (
float, default:0.6) – Minimum probability threshold to allow trading.
- Return type:
- Returns:
Filtered signals (same shape, zeros where regime inactive).
Example
>>> from wraquant.regimes import detect_regimes >>> from wraquant.backtest.position import regime_signal_filter >>> regimes = detect_regimes(returns, method="hmm", n_regimes=2) >>> filtered = regime_signal_filter(signals, regimes.probabilities, ... active_regime=0, min_prob=0.6)
- volatility_target_sizing(returns, target_vol=0.15, method='ewma', span=30)[source]¶
Size positions to target a specific annualised volatility level.
Computes a scalar multiplier so that the portfolio’s expected annualised volatility equals
target_vol. The current volatility is estimated via EWMA (wraquant.vol.models.ewma_volatility), providing a responsive, GARCH-inspired estimate that reacts quickly to changing market conditions.- Mathematical formulation:
scalar = target_vol / current_vol
If
current_volis zero or negative (constant returns), the function returns 1.0 (no scaling).- When to use:
Use volatility targeting when you want your strategy to maintain a consistent risk profile regardless of market conditions. This is the foundation of most institutional risk-parity and managed-futures strategies.
- Parameters:
returns (
Series) – Recent asset return series (at least 10 observations).target_vol (
float, default:0.15) – Desired annualised volatility (e.g., 0.15 for 15%).method (
str, default:'ewma') – Volatility estimation method. Currently only"ewma"is supported.span (
int, default:30) – EWMA span parameter (default 30). Higher values produce smoother estimates; lower values react faster to shocks.
- Return type:
- Returns:
Scalar multiplier for position size. Values > 1 indicate the current vol is below target (increase exposure); values < 1 indicate it is above target (reduce exposure).
Example
>>> import pandas as pd, numpy as np >>> np.random.seed(42) >>> returns = pd.Series(np.random.normal(0, 0.01, 100)) >>> scalar = volatility_target_sizing(returns, target_vol=0.10) >>> scalar > 0 True
See also
PositionSizer.volatility_targeting: Simple std-based vol targeting. regime_conditional_sizing: Regime-aware position scaling.
Events¶
Event tracking system for portfolio backtesting.
Provides structured logging and querying of portfolio events including trades, rebalances, signals, risk events, regime changes, and drawdowns.
- class EventType[source]¶
-
Enumeration of trackable event types.
- TRADE = 'trade'¶
- REBALANCE = 'rebalance'¶
- SIGNAL = 'signal'¶
- RISK = 'risk'¶
- REGIME_CHANGE = 'regime_change'¶
- DRAWDOWN = 'drawdown'¶
- __new__(value)¶
- class EventTracker[source]¶
Bases:
objectTrack and query portfolio events during a backtest.
Maintains an ordered log of events (trades, rebalances, signals, risk events) and provides query and summary facilities.
Example
>>> tracker = EventTracker() >>> tracker.log_trade(datetime(2024, 1, 2), "AAPL", "buy", 100, 150.0) >>> tracker.summary()["total_events"] 1
- log_trade(timestamp, asset, side, quantity, price, metadata=None)[source]¶
Log a trade event.
- Parameters:
- Returns:
The logged event.
- Return type:
- log_rebalance(timestamp, old_weights, new_weights, reason='')[source]¶
Log a portfolio rebalance event.
- Parameters:
- Returns:
The logged event.
- Return type:
- log_risk_event(timestamp, event_type, details=None)[source]¶
Log a risk event (VaR breach, drawdown threshold, etc.).
- get_events(event_type=None, start=None, end=None)[source]¶
Query events by type and/or time range.
- Parameters:
- Returns:
Matching events in chronological order.
- Return type:
- detect_regime_changes(returns, method='rolling_vol', window=63, threshold=1.5)[source]¶
Detect regime transitions in a return series.
- Parameters:
returns (
Series) – Asset or portfolio returns.method (
str, default:'rolling_vol') – Detection method. Supported:"rolling_vol"(default) uses a ratio of short-term to long-term rolling volatility,"mean_shift"uses a shift in the rolling mean.window (
int, default:63) – Lookback window (number of periods).threshold (
float, default:1.5) – Multiplier above which a regime change is flagged.
- Returns:
DataFrame with columns
timestamp,regime, andindicatorfor each detected transition point.- Return type:
Metrics¶
Backtesting performance metrics.
- performance_summary(returns, risk_free=0.0, periods_per_year=252)[source]¶
Calculate comprehensive performance metrics.
- omega_ratio(returns, threshold=0.0)[source]¶
Omega ratio: probability-weighted gain/loss ratio above a threshold.
The Omega ratio partitions the return distribution at a threshold L and computes the ratio of the expected gain above L to the expected loss below L. Unlike the Sharpe ratio it uses the entire distribution (all moments, not just the first two), making it more appropriate for non-normal returns.
- Mathematical formulation:
- Omega(L) = E[max(R - L, 0)] / E[max(L - R, 0)]
= sum(max(r_i - L, 0)) / sum(max(L - r_i, 0))
- How to interpret:
Omega = 1.0: strategy breaks even relative to the threshold.
Omega > 1.0: gains outweigh losses (good).
Omega > 2.0: strong risk-adjusted performance.
Omega = inf: no returns below the threshold.
The higher the better; compare strategies at the same threshold.
- When to use:
Use Omega when return distributions are skewed or fat-tailed and you want a single number that captures the full distribution. Prefer Omega over Sharpe for options-based or trend-following strategies whose returns are far from Gaussian.
- Parameters:
- Return type:
- Returns:
Omega ratio as a float. Returns
infif no returns fall below the threshold.
Example
>>> import pandas as pd >>> r = pd.Series([0.01, 0.02, -0.005, 0.015, -0.01]) >>> omega_ratio(r, threshold=0.0) 3.0
See also
kappa_ratio: Generalized lower-partial-moment ratio (Kappa(1) = Omega - 1). sharpe_ratio: Mean/std ratio; only uses first two moments.
- burke_ratio(returns, periods_per_year=252)[source]¶
Burke ratio: return per unit of drawdown severity.
The Burke ratio penalises strategies that experience many deep drawdowns by dividing the annualised excess return by the square root of the sum of squared drawdown depths. Compared to Calmar (which uses only the worst drawdown), Burke considers the entire drawdown history.
- Mathematical formulation:
Burke = annualized_return / sqrt(sum(d_i^2))
where d_i are the individual drawdown depths (negative values).
- How to interpret:
Higher is better.
A strategy with many small drawdowns can have a higher Burke than one with a single large drawdown, even if their Calmar ratios are identical.
There is no universal “good” threshold; use for relative comparison between strategies.
- When to use:
Prefer Burke over Calmar when you want to penalise strategies that repeatedly draw down, not just the single worst case.
- Parameters:
- Return type:
- Returns:
Burke ratio as a float. Returns 0.0 if there are no drawdowns.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> burke_ratio(r) 1.23
See also
ulcer_performance_index: Uses Ulcer Index (RMS of drawdowns) in denominator. recovery_factor: Net profit / max drawdown.
- ulcer_performance_index(returns, periods_per_year=252)[source]¶
Ulcer Performance Index (UPI): return per unit of drawdown pain.
The UPI (also known as Martin ratio) divides the annualised excess return by the Ulcer Index, which is the root-mean-square of percentage drawdowns. The Ulcer Index captures both the depth and duration of drawdowns, making UPI a comprehensive pain-adjusted return measure.
- Mathematical formulation:
Ulcer Index = sqrt(mean(d_i^2)) UPI = annualized_return / Ulcer Index
where d_i is the drawdown at each point in time.
- How to interpret:
Higher is better.
UPI > 2.0 is generally considered very good.
UPI accounts for drawdown duration (not just depth), so a strategy that recovers quickly scores better than one that lingers in drawdown.
- When to use:
Use UPI when you want a risk-adjusted measure that captures the investor’s real experience of pain (deep, prolonged drawdowns hurt more than brief dips).
- Parameters:
- Return type:
- Returns:
UPI as a float. Returns 0.0 if the Ulcer Index is zero.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> ulcer_performance_index(r) 2.5
See also
burke_ratio: Uses sum of squared drawdowns instead of RMS. max_drawdown: Single worst peak-to-trough decline.
- kappa_ratio(returns, order=2, threshold=0.0, periods_per_year=252)[source]¶
Kappa ratio: generalized Sortino using lower partial moments.
The Kappa ratio family generalises the Sortino ratio by using the n-th root of the n-th lower partial moment (LPM) as the risk measure. Special cases:
Kappa(1) is equivalent to (Omega - 1) when annualization is removed (first lower partial moment = expected shortfall below threshold).
Kappa(2) is the Sortino ratio (second lower partial moment = downside deviation).
Kappa(3) penalises large negative returns even more heavily.
- Mathematical formulation:
LPM_n = mean(max(L - r_i, 0)^n) Kappa_n = (annualized_mean_excess) / LPM_n^(1/n)
- How to interpret:
Higher is better (more return per unit of downside risk).
Higher orders penalise tail risk more severely.
Compare strategies using the same order.
- When to use:
Use Kappa(3) when you especially fear large losses. Use Kappa(2) as a drop-in alternative to Sortino. Use Kappa(1) when you want an Omega-style measure in ratio form.
- Parameters:
- Return type:
- Returns:
Kappa ratio as a float. Returns 0.0 if LPM is zero.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> kappa_ratio(r, order=2) 1.8
See also
omega_ratio: Threshold-based gain/loss ratio. sortino_ratio: Equivalent to Kappa(2).
- tail_ratio(returns, upper_pct=95.0, lower_pct=5.0)[source]¶
Tail ratio: magnitude of right tail vs. left tail.
The tail ratio measures how large positive outlier returns are relative to negative outlier returns. A value greater than 1 means the strategy’s best days are proportionally larger than its worst days.
- Mathematical formulation:
Tail ratio = |percentile(R, upper_pct)| / |percentile(R, lower_pct)|
- How to interpret:
Tail ratio > 1.0: right tail is fatter (upside surprises bigger than downside). Desirable.
Tail ratio = 1.0: symmetric tails.
Tail ratio < 1.0: left tail is fatter (downside risk dominates).
- When to use:
Quick diagnostic to check if a strategy has favorable tail behaviour. Combine with
common_sense_ratiofor a sanity check.
- Parameters:
- Return type:
- Returns:
Tail ratio as a float. Returns
infif the lower percentile is zero.
Example
>>> import pandas as pd >>> r = pd.Series([0.03, 0.01, -0.01, 0.02, -0.005]) >>> tail_ratio(r) 2.0
See also
common_sense_ratio: Tail ratio multiplied by (1 + Sharpe). rachev_ratio: CVaR-based tail comparison.
- common_sense_ratio(returns, risk_free=0.0, periods_per_year=252)[source]¶
Common Sense Ratio: quick sanity check combining Sharpe and tails.
The Common Sense Ratio multiplies the tail ratio by (1 + Sharpe). It combines a measure of tail asymmetry with overall risk-adjusted performance into a single number for rapid strategy screening.
- Mathematical formulation:
CSR = tail_ratio * (1 + Sharpe)
- How to interpret:
CSR > 1.0: strategy has both favorable tails and positive risk-adjusted return. Good.
CSR < 1.0: either tails are unfavorable or risk-adjusted return is poor.
Use for fast initial screening; not a substitute for deeper analysis.
- When to use:
Use CSR as a first-pass filter when evaluating many strategies simultaneously.
- Parameters:
- Return type:
- Returns:
Common Sense Ratio as a float.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> common_sense_ratio(r) 1.5
See also
tail_ratio: Upside vs downside tail magnitude. sharpe_ratio: Mean/std risk-adjusted return.
- rachev_ratio(returns, alpha=0.05)[source]¶
Rachev ratio: CVaR of gains over CVaR of losses.
The Rachev ratio (also called the Conditional Tail Ratio) compares the expected size of extreme gains to the expected size of extreme losses, using Conditional Value at Risk (CVaR, a.k.a. Expected Shortfall). Unlike the simple tail ratio, Rachev uses the mean of the tail rather than a single percentile, making it more robust to outliers.
- Mathematical formulation:
CVaR_alpha(gains) = E[R | R > VaR_{1-alpha}(R)] CVaR_alpha(losses) = E[-R | R < VaR_alpha(R)] Rachev = CVaR_alpha(gains) / CVaR_alpha(losses)
- How to interpret:
Rachev > 1.0: expected extreme gains exceed expected extreme losses. The strategy has favorable fat-tail asymmetry.
Rachev = 1.0: symmetric tail risk.
Rachev < 1.0: tail risk is skewed to the downside.
- When to use:
Use Rachev when you need a fat-tail-aware gain/loss comparison. More robust than
tail_ratiobecause it averages over the tail rather than using a single percentile.
- Parameters:
- Return type:
- Returns:
Rachev ratio as a float. Returns
infif the loss CVaR is zero.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 500)) >>> rachev_ratio(r, alpha=0.05) 1.1
See also
tail_ratio: Percentile-based tail comparison. omega_ratio: Full-distribution gain/loss ratio.
- gain_to_pain_ratio(returns)[source]¶
Gain to Pain ratio: total gains over total absolute losses.
The Gain to Pain ratio (GPR) divides the sum of all returns by the absolute sum of all negative returns. It provides a simple measure of how much total return the strategy generates per unit of pain (aggregate losses) experienced.
- Mathematical formulation:
GPR = sum(r_i) / |sum(r_i where r_i < 0)|
- How to interpret:
GPR > 1.0: total gains exceed total losses; strategy is net profitable and then some.
GPR = 0.5: for every dollar lost, the strategy earned 50 cents net.
GPR < 0: strategy loses money overall.
GPR > 1.5 is generally considered good for daily returns.
- When to use:
Quick profitability diagnostic. Simpler and more intuitive than profit factor (which excludes zero returns and looks at gross gains vs. gross losses).
- Parameters:
returns (
Series) – Simple return series.- Return type:
- Returns:
Gain to Pain ratio as a float. Returns
infif there are no negative returns.
Example
>>> import pandas as pd >>> r = pd.Series([0.02, -0.01, 0.015, -0.005, 0.01]) >>> gain_to_pain_ratio(r) 2.67
See also
profit_factor: Gross gains / gross losses (ignores net). omega_ratio: Threshold-based gain/loss comparison.
- risk_of_ruin(win_rate, payoff_ratio, ruin_pct=0.5, n_trades=1000)[source]¶
Probability of losing a given fraction of capital.
Estimates the risk of ruin – the probability that a strategy will lose ruin_pct of its capital – given its win rate and average payoff ratio, assuming fixed fractional position sizing and independent trades.
- Mathematical formulation (simplified):
RoR = ((1 - edge) / (1 + edge)) ^ capital_units
where edge = (win_rate * payoff_ratio) - (1 - win_rate) and capital_units approximates the number of bet-units to exhaust before reaching ruin_pct.
- How to interpret:
RoR close to 0: very unlikely to hit ruin level.
RoR > 0.05 (5 %): meaningful risk; reduce position size.
RoR > 0.20 (20 %): dangerous; the strategy may not survive.
A strategy with high win_rate and high payoff_ratio has very low risk of ruin.
- When to use:
Use risk of ruin to decide whether a strategy is survivable over n_trades. Combine with Kelly fraction to determine appropriate sizing.
- Parameters:
win_rate (
float) – Probability of a winning trade (0 to 1).payoff_ratio (
float) – Average win / average loss (positive number).ruin_pct (
float, default:0.5) – Fraction of capital that constitutes ruin (e.g., 0.5 = losing 50 % of capital).n_trades (
int, default:1000) – Number of trades to consider in the simulation window (affects capital_units approximation).
- Return type:
- Returns:
Estimated probability of ruin (0 to 1).
Example
>>> risk_of_ruin(win_rate=0.55, payoff_ratio=1.5, ruin_pct=0.5) 0.002
See also
kelly_fraction: Optimal position sizing. expectancy: Expected value per trade.
- kelly_fraction(win_rate, avg_win, avg_loss)[source]¶
Kelly fraction: optimal bet sizing for geometric growth.
The Kelly criterion determines the fraction of capital to risk on each trade to maximise the long-run geometric growth rate. The full Kelly is aggressive; practitioners typically use fractional Kelly (e.g., half-Kelly) to reduce variance.
- Mathematical formulation:
b = avg_win / avg_loss (odds ratio) f* = (b * p - q) / b
where p = win_rate, q = 1 - p.
- How to interpret:
f* > 0: strategy has positive edge; bet this fraction.
f* = 0: no edge; do not bet.
f* < 0: negative edge (clamped to 0); avoid this strategy.
f* > 0.25: full Kelly is very aggressive; consider using half or quarter Kelly.
- When to use:
Use Kelly to determine the theoretical maximum position size. In practice, use fractional Kelly (0.25x to 0.5x) because Kelly assumes known and constant edge, which is unrealistic.
- Parameters:
- Return type:
- Returns:
Optimal fraction of capital to risk, clamped to [0, 1].
Example
>>> kelly_fraction(win_rate=0.55, avg_win=1.5, avg_loss=1.0) 0.25
See also
risk_of_ruin: Probability of catastrophic drawdown. expectancy: Expected value per trade.
- expectancy(win_rate, avg_win, avg_loss)[source]¶
Expectancy: expected profit per trade.
Expectancy combines win rate and payoff to give the average expected value of each trade. A strategy with positive expectancy is profitable in the long run (assuming sufficient trades and stable edge).
- Mathematical formulation:
E = (win_rate * avg_win) - ((1 - win_rate) * |avg_loss|)
- How to interpret:
E > 0: each trade is expected to be profitable on average.
E = 0: break-even strategy.
E < 0: losing strategy.
E > 0.10 (if avg_loss is normalised to 1.0): reasonable edge.
- When to use:
Use expectancy alongside system quality number (SQN) and profit factor to evaluate a trading system. Expectancy alone does not account for variability of outcomes.
- Parameters:
- Return type:
- Returns:
Expected value per trade.
Example
>>> expectancy(win_rate=0.6, avg_win=100.0, avg_loss=80.0) 28.0
See also
system_quality_number: Expectancy normalised by trade variability. kelly_fraction: Optimal sizing given expectancy.
- profit_factor(returns)[source]¶
Profit factor: gross profit divided by gross loss.
The profit factor measures how many dollars the strategy earns for every dollar it loses. It is the simplest measure of a strategy’s profitability.
- Mathematical formulation:
PF = sum(r_i where r_i > 0) / |sum(r_i where r_i < 0)|
- How to interpret:
PF > 1.0: strategy is profitable.
PF = 1.0: break even.
PF < 1.0: strategy loses money.
PF > 1.5: good.
PF > 2.0: very good (verify not overfitting).
- When to use:
Use as a quick profitability check. Pair with win rate and payoff ratio for a complete picture.
- Parameters:
returns (
Series) – Return or P&L series.- Return type:
- Returns:
Profit factor as a float. Returns
infif there are no losses.
Example
>>> import pandas as pd >>> r = pd.Series([0.02, -0.01, 0.03, -0.005]) >>> profit_factor(r) ... 3.33
See also
gain_to_pain_ratio: Net return / total losses. expectancy: Expected value per trade.
- payoff_ratio(returns)[source]¶
Payoff ratio: average win divided by average loss.
The payoff ratio (also called reward-to-risk ratio) measures how large the average winning trade is relative to the average losing trade. Combined with win rate, it fully characterises a strategy’s return profile.
- Mathematical formulation:
Payoff = mean(r_i where r_i > 0) / |mean(r_i where r_i < 0)|
- How to interpret:
Payoff > 1.0: average wins are larger than average losses. Common in trend-following strategies.
Payoff = 1.0: wins and losses are the same size.
Payoff < 1.0: average losses exceed average wins. The strategy must have a high win rate to compensate.
Payoff > 2.0 with win rate > 0.40 is a strong system.
- When to use:
Use alongside win rate. A low win rate with high payoff (trend-following) is as viable as high win rate with low payoff (mean-reversion).
- Parameters:
returns (
Series) – Return or P&L series.- Return type:
- Returns:
Payoff ratio as a float. Returns
infif there are no losses, and 0.0 if there are no wins.
Example
>>> import pandas as pd >>> r = pd.Series([0.02, -0.01, 0.03, -0.005]) >>> payoff_ratio(r) ... 3.33
See also
profit_factor: Gross gains / gross losses. expectancy: Combines win rate and payoff into expected value.
- recovery_factor(returns)[source]¶
Recovery factor: net profit relative to max drawdown.
Recovery factor measures how many times over the strategy has recovered from its worst drawdown. A high recovery factor indicates that the strategy generates returns efficiently relative to the drawdown pain it inflicts.
- Mathematical formulation:
RF = total_return / |max_drawdown|
- How to interpret:
RF > 1.0: strategy has earned back more than its worst drawdown.
RF > 3.0: strong.
RF > 5.0: excellent recovery relative to risk.
RF < 1.0: strategy has not yet recovered from its worst drawdown.
- When to use:
Use recovery factor when you want to know if the strategy’s returns justify the drawdown pain. Useful for comparing strategies with different drawdown profiles.
- Parameters:
returns (
Series) – Simple return series.- Return type:
- Returns:
Recovery factor as a float. Returns
infif there is no drawdown.
Example
>>> import pandas as pd, numpy as np >>> r = pd.Series(np.random.default_rng(42).normal(0.001, 0.01, 252)) >>> recovery_factor(r) 3.5
See also
burke_ratio: Return per sum-of-squared-drawdowns. ulcer_performance_index: Return per Ulcer Index.
- system_quality_number(pnl)[source]¶
System Quality Number (SQN): Van Tharp’s strategy quality metric.
SQN normalises expectancy by the variability of trade outcomes and scales by the square root of the number of trades. It answers the question: “given the consistency and edge of this system, is the positive expectancy statistically significant?”
- Mathematical formulation:
SQN = sqrt(N) * mean(pnl) / std(pnl)
where N is the number of trades (or periods).
- How to interpret:
SQN < 1.6: poor; difficult to trade profitably.
1.6 < SQN < 2.0: below average; marginal edge.
2.0 < SQN < 2.5: average; tradeable with discipline.
2.5 < SQN < 3.0: good; reliable system.
3.0 < SQN < 5.0: excellent.
5.0 < SQN < 7.0: superb.
SQN > 7.0: holy grail (verify not overfitting).
- When to use:
Use SQN to evaluate whether a system’s edge is statistically meaningful given the number of observations. Particularly useful when comparing systems with different numbers of trades.
- Parameters:
pnl (
Series) – Per-trade or per-period P&L series.- Return type:
- Returns:
SQN as a float. Returns 0.0 if the standard deviation is zero.
Example
>>> import pandas as pd >>> pnl = pd.Series([100, -50, 80, -30, 120, -40, 90, -20]) >>> system_quality_number(pnl) 1.7
See also
expectancy: Mean expected value per trade. kelly_fraction: Optimal position sizing from edge.
Tearsheet¶
Enhanced tearsheet / reporting utilities for backtests.
Generates comprehensive performance summaries, monthly return tables, drawdown analysis, rolling metrics, and trade-level analytics.
- generate_tearsheet(returns, benchmark=None, risk_free=0.0, periods_per_year=252)[source]¶
Generate a comprehensive performance tearsheet dictionary.
- Parameters:
- Returns:
Dictionary containing absolute and (optionally) relative performance metrics.
- Return type:
- monthly_returns_table(returns)[source]¶
Compute a table of monthly returns suitable for heatmap display.
- rolling_metrics_table(returns, windows=None, periods_per_year=252)[source]¶
Compute rolling Sharpe, volatility, and return at multiple windows.
- Parameters:
- Returns:
MultiIndex columns:
(window, metric)with metricsrolling_return,rolling_vol,rolling_sharpe.- Return type:
- trade_analysis(trades_df)[source]¶
Analyse trade-level performance.
- Parameters:
trades_df (
DataFrame) – Must contain apnlcolumn with per-trade profit/loss values. Optionally includesentry_price,exit_price,side, etc.- Returns:
Dictionary with
win_rate,avg_pnl,avg_win,avg_loss,profit_factor,expectancy,max_win,max_loss,n_trades.- Return type:
Integrations¶
Advanced backtesting integrations using optional packages.
Provides wrappers around vectorbt, quantstats, empyrical, pyfolio, and ffn for backtesting, reporting, and performance analytics.
- vectorbt_backtest(prices, entries, exits, **kwargs)[source]¶
Run a vectorised backtest using vectorbt.
- Parameters:
prices (
Series|DataFrame) – Price data aligned with the entry/exit signals.entries (
Series|DataFrame) – Boolean series/DataFrame indicating entry signals.exits (
Series|DataFrame) – Boolean series/DataFrame indicating exit signals.**kwargs (
Any) – Additional keyword arguments forwarded tovectorbt.Portfolio.from_signals.
- Returns:
Dictionary containing:
total_return – total portfolio return.
sharpe_ratio – annualised Sharpe ratio.
max_drawdown – maximum drawdown.
total_trades – number of trades executed.
win_rate – fraction of winning trades.
portfolio – the raw
vectorbt.Portfolioobject.
- Return type:
- quantstats_report(returns, benchmark=None, output=None)[source]¶
Generate performance analytics using quantstats.
- Parameters:
- Returns:
Dictionary containing:
sharpe – annualised Sharpe ratio.
sortino – annualised Sortino ratio.
max_drawdown – maximum drawdown.
cagr – compound annual growth rate.
volatility – annualised volatility.
calmar – Calmar ratio.
- Return type:
- empyrical_metrics(returns)[source]¶
Compute a comprehensive set of metrics using empyrical.
- Parameters:
returns (
Series) – Simple return series.- Returns:
Dictionary containing:
annual_return – annualised return.
annual_volatility – annualised volatility.
sharpe_ratio – annualised Sharpe ratio.
sortino_ratio – annualised Sortino ratio.
max_drawdown – maximum drawdown.
calmar_ratio – Calmar ratio.
omega_ratio – Omega ratio.
tail_ratio – tail ratio (95th / 5th percentile).
stability – R-squared of cumulative log returns.
- Return type:
- pyfolio_tearsheet_data(returns, positions=None, transactions=None)[source]¶
Prepare data in the format expected by pyfolio tearsheets.
This function does not render plots but returns the intermediate data structures that pyfolio uses internally, making it possible to inspect results programmatically.
- Parameters:
- Returns:
Dictionary containing:
returns – the input return series.
cum_returns – cumulative return series.
drawdown – drawdown series.
positions – positions DataFrame (or None).
transactions – transactions DataFrame (or None).
- Return type:
- ffn_stats(prices)[source]¶
Compute performance statistics using ffn.
- Parameters:
prices (
Series|DataFrame) – Price series or multi-asset price DataFrame.- Returns:
Dictionary containing key performance metrics:
total_return – total return over the period.
cagr – compound annual growth rate.
daily_sharpe – daily Sharpe ratio.
max_drawdown – maximum drawdown.
avg_drawdown – average drawdown.
monthly_sharpe – monthly Sharpe ratio.
stats_object – the raw
ffn.PerformanceStatsobject.
- Return type: