Report an average over an empty set as undefined (0.10.4)

A backtest where every trade won still reported "average losing trade: 0.00"
and "average losing trade duration: 0.00". Zero is a measurement -- it says
the losers broke even, in no time at all -- and it was describing trades that
were never taken. Measured on a two-leg straddle that closed two winners and
no losers, beside a 100% win rate on the same panel.

avg_win_pct, avg_loss_pct, avg_winning_duration and avg_losing_duration are
now Option<f64> (Python: float | None) and report None when their population
is empty. This is the rule 0.10.3 applied to calmar_ratio and that
profit_factor already followed: a quantity with no denominator, or no
population, is undefined rather than zero. The four were the last fields in
BacktestMetrics still answering "cannot say" with a number.

payoff_ratio is unaffected. It reads both averages through unwrap_or(0.0),
which reproduces its previous branch exactly -- a None unwraps to the same 0.0
the old code already saw there, so its output is bit-identical.

Verified on the built wheel: a monotonically rising series closes two winners
and no losers, and reports avg_loss_pct = None, avg_losing_duration = None,
profit_factor = None while the winning-side fields keep real values. A mixed
run still computes all four (avg_win_pct 4.80, avg_loss_pct -7.75,
durations 95.0 and 100.0), so only genuinely empty populations null out.

Pinned by a unit test in metrics::drawdown, where the "undefined is not zero"
convention already lives.

cargo test --lib: 511 passed. clippy and fmt clean.

Co-authored-by: Pier Code <no-reply@piercode.com>
This commit is contained in:
porcelaincode
2026-08-30 17:33:50 +05:30
co-authored by Pier Code
parent 4619577e22
commit 49427cf4c2
10 changed files with 147 additions and 61 deletions
+36
View File
@@ -5,6 +5,42 @@ All notable changes to raptorbt are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.10.4] - 2026-08-30
Averages over an empty set now report "undefined" instead of zero.
**In plain words: a backtest where every trade won still reported "average
losing trade: 0.00" and "average losing trade duration: 0.00" -- figures
describing trades that do not exist. Zero is a measurement; it says the losers
broke even. The honest answer is that there were no losers, so there is
nothing to average. These four now come back empty, the way profit factor
already did.**
### Changed
- **`avg_win_pct`, `avg_loss_pct`, `avg_winning_duration` and
`avg_losing_duration` are now `Option<f64>`** (Python: `float | None`), and
are `None` when their population is empty -- no winning trade, or no losing
trade. Previously each returned `0.0`, which is indistinguishable from a real
measurement of zero and reads as a claim about trades that were never taken.
Measured on a two-leg straddle that closed two winners and no losers: the
stored row carried `avg_loss_pct = 0.00` and `avg_losing_duration = 0.00`
beside a 100% win rate.
This is the same rule 0.10.3 applied to `calmar_ratio` and that
`profit_factor` already followed: a quantity with no denominator, or no
population, is undefined rather than zero.
`payoff_ratio` is unaffected -- it reads both averages through
`unwrap_or(0.0)`, reproducing its previous branch exactly.
### Migration
Callers reading these four fields must handle `None`. In Python they arrive as
`None` and can be formatted like any other optional metric; a caller that
previously relied on the `0.0` fallback for arithmetic should decide
explicitly whether "no such trades" should score as zero or be skipped.
## [0.10.3] - 2026-08-30
Calmar is no longer annualized, and every runner now computes it the same way.
Generated
+1 -1
View File
@@ -636,7 +636,7 @@ dependencies = [
[[package]]
name = "raptorbt"
version = "0.10.3"
version = "0.10.4"
dependencies = [
"approx",
"clarabel",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "raptorbt"
version = "0.10.3"
version = "0.10.4"
edition = "2021"
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
authors = ["Alphabench <contact@alphabench.in>"]
+12 -12
View File
@@ -1265,16 +1265,16 @@ access all 33. The most useful are grouped below.
### Trade Performance
| Metric | Description |
| ---------------------- | --------------------------------- |
| `profit_factor` | Gross profit / Gross loss |
| `expectancy` | Average expected profit per trade |
| `sqn` | System Quality Number |
| `avg_trade_return_pct` | Average trade return |
| `avg_win_pct` | Average winning trade return |
| `avg_loss_pct` | Average losing trade return |
| `best_trade_pct` | Best single trade return |
| `worst_trade_pct` | Worst single trade return |
| Metric | Description |
| ---------------------- | ---------------------------------------------------- |
| `profit_factor` | Gross profit / Gross loss; `None` when nothing lost |
| `expectancy` | Average expected profit per trade |
| `sqn` | System Quality Number |
| `avg_trade_return_pct` | Average trade return |
| `avg_win_pct` | Average winning trade return; `None` without winners |
| `avg_loss_pct` | Average losing trade return; `None` without losers |
| `best_trade_pct` | Best single trade return |
| `worst_trade_pct` | Worst single trade return |
### Duration
@@ -1282,8 +1282,8 @@ access all 33. The most useful are grouped below.
| ------------------------- | ---------------------------------------------------- |
| `avg_holding_period` | Average trade duration (bars) |
| `avg_holding_period_secs` | The same average in seconds; `None` without timestamps |
| `avg_winning_duration` | Average winning trade duration |
| `avg_losing_duration` | Average losing trade duration |
| `avg_winning_duration` | Average winning trade duration; `None` without winners |
| `avg_losing_duration` | Average losing trade duration; `None` without losers |
### Streaks
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "raptorbt"
version = "0.10.3"
version = "0.10.4"
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
readme = "README.md"
requires-python = ">=3.10"
+10 -8
View File
@@ -630,14 +630,16 @@ pub struct BacktestMetrics {
pub worst_trade_pct: f64,
/// Average trade return percentage.
pub avg_trade_return_pct: f64,
/// Average winning trade return percentage.
pub avg_win_pct: f64,
/// Average losing trade return percentage.
pub avg_loss_pct: f64,
/// Average winning trade duration in bars.
pub avg_winning_duration: f64,
/// Average losing trade duration in bars.
pub avg_losing_duration: f64,
/// Average winning trade return percentage. `None` when no trade won:
/// an average over an empty set is undefined, and 0.0 there reads as
/// "the winners averaged nothing" rather than "there were no winners".
pub avg_win_pct: Option<f64>,
/// Average losing trade return percentage. `None` when no trade lost.
pub avg_loss_pct: Option<f64>,
/// Average winning trade duration in bars. `None` when no trade won.
pub avg_winning_duration: Option<f64>,
/// Average losing trade duration in bars. `None` when no trade lost.
pub avg_losing_duration: Option<f64>,
/// Maximum consecutive wins.
pub max_consecutive_wins: usize,
/// Maximum consecutive losses.
+22
View File
@@ -349,6 +349,28 @@ mod tests {
assert!(calmar < 5.0, "a plausible Calmar cannot be in the thousands");
}
#[test]
fn an_average_over_an_empty_population_is_undefined() {
// Not a drawdown test, but the same rule, and this module is where the
// "undefined is not zero" convention is pinned.
//
// Measured 2026-08-30: a two-leg straddle closed two winning trades and
// no losing ones, and the stored row read avg_losing_duration = 0.00
// and avg_loss_pct = 0.00 -- figures describing trades that do not
// exist. BacktestMetrics now carries Option for all four, so an empty
// population reports "cannot say" instead of a number.
let no_trades: Vec<f64> = Vec::new();
let mean = |xs: &[f64]| -> Option<f64> {
if xs.is_empty() {
None
} else {
Some(xs.iter().sum::<f64>() / xs.len() as f64)
}
};
assert_eq!(mean(&no_trades), None, "an average of nothing is not zero");
assert_eq!(mean(&[2.0, 4.0]), Some(3.0), "a real population still averages");
}
#[test]
fn calmar_is_undefined_rather_than_zero_without_drawdown() {
// A profitable run that never drew down has no defined Calmar. Returning
+16 -11
View File
@@ -437,28 +437,30 @@ impl StreamingMetrics {
0.0
};
// Undefined over an empty population -- see the note in
// `portfolio::engine`. None, not 0.0.
let avg_win_pct = if self.winning_trades > 0 {
self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0
Some(self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0)
} else {
0.0
None
};
let avg_loss_pct = if self.losing_trades > 0 {
-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0)
Some(-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0))
} else {
0.0
None
};
let avg_winning_duration = if self.winning_trades > 0 {
self.sum_winning_duration as f64 / self.winning_trades as f64
Some(self.sum_winning_duration as f64 / self.winning_trades as f64)
} else {
0.0
None
};
let avg_losing_duration = if self.losing_trades > 0 {
self.sum_losing_duration as f64 / self.losing_trades as f64
Some(self.sum_losing_duration as f64 / self.losing_trades as f64)
} else {
0.0
None
};
let avg_holding_period = if self.trade_count > 0 {
@@ -510,9 +512,12 @@ impl StreamingMetrics {
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
// Payoff ratio: average win / average loss (absolute value)
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
avg_win_pct / avg_loss_pct.abs()
} else if avg_win_pct > 0.0 {
// unwrap_or(0.0) reproduces the pre-Option branch exactly.
let avg_win_for_payoff = avg_win_pct.unwrap_or(0.0);
let avg_loss_for_payoff = avg_loss_pct.unwrap_or(0.0);
let payoff_ratio = if avg_loss_for_payoff.abs() > 0.0 {
avg_win_for_payoff / avg_loss_for_payoff.abs()
} else if avg_win_for_payoff > 0.0 {
f64::INFINITY
} else {
0.0
+40 -23
View File
@@ -274,41 +274,54 @@ impl PortfolioEngine {
0.0
};
// An average over an empty set is undefined, not zero. Reporting 0.0
// for "the average losing trade" on a run where nothing lost money
// states that the losers broke even, which is a claim about trades
// that do not exist -- the same defect that had profit_factor showing
// 0.00 beside a 100% win rate.
let avg_win_pct = if winning_trades > 0 {
closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.return_pct).sum::<f64>()
/ winning_trades as f64
Some(
closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.return_pct).sum::<f64>()
/ winning_trades as f64,
)
} else {
0.0
None
};
let avg_loss_pct = if losing_trades > 0 {
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::<f64>()
/ losing_trades as f64
Some(
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::<f64>()
/ losing_trades as f64,
)
} else {
0.0
None
};
// Average winning/losing trade duration
let avg_winning_duration = if winning_trades > 0 {
closed_trades
.iter()
.filter(|t| t.pnl > 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ winning_trades as f64
Some(
closed_trades
.iter()
.filter(|t| t.pnl > 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ winning_trades as f64,
)
} else {
0.0
None
};
let avg_losing_duration = if losing_trades > 0 {
closed_trades
.iter()
.filter(|t| t.pnl < 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ losing_trades as f64
Some(
closed_trades
.iter()
.filter(|t| t.pnl < 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ losing_trades as f64,
)
} else {
0.0
None
};
// Consecutive wins/losses
@@ -411,9 +424,13 @@ impl PortfolioEngine {
crate::metrics::drawdown::calmar_ratio(total_return_pct, max_drawdown_pct);
// Payoff ratio: average win / average loss (absolute value)
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
avg_win_pct / avg_loss_pct.abs()
} else if avg_win_pct > 0.0 {
// Undefined without both halves: no loss to divide by, or no win to
// divide. unwrap_or(0.0) reproduces the pre-Option branch exactly.
let avg_win_for_payoff = avg_win_pct.unwrap_or(0.0);
let avg_loss_for_payoff = avg_loss_pct.unwrap_or(0.0);
let payoff_ratio = if avg_loss_for_payoff.abs() > 0.0 {
avg_win_for_payoff / avg_loss_for_payoff.abs()
} else if avg_win_for_payoff > 0.0 {
f64::INFINITY
} else {
0.0
+8 -4
View File
@@ -638,14 +638,18 @@ pub struct PyBacktestMetrics {
pub worst_trade_pct: f64,
#[pyo3(get)]
pub avg_trade_return_pct: f64,
/// None when no trade won -- an average over an empty set is undefined.
#[pyo3(get)]
pub avg_win_pct: f64,
pub avg_win_pct: Option<f64>,
/// None when no trade lost.
#[pyo3(get)]
pub avg_loss_pct: f64,
pub avg_loss_pct: Option<f64>,
/// None when no trade won.
#[pyo3(get)]
pub avg_winning_duration: f64,
pub avg_winning_duration: Option<f64>,
/// None when no trade lost.
#[pyo3(get)]
pub avg_losing_duration: f64,
pub avg_losing_duration: Option<f64>,
#[pyo3(get)]
pub max_consecutive_wins: usize,
#[pyo3(get)]