mirror of
https://github.com/alphabench/raptorbt.git
synced 2026-11-02 07:56:12 +00:00
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:
co-authored by
Pier Code
parent
4619577e22
commit
49427cf4c2
@@ -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
@@ -636,7 +636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.10.3"
|
||||
version = "0.10.4"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"clarabel",
|
||||
|
||||
+1
-1
@@ -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>"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user