Files
wickra/crates/wickra-core/src/indicators/expectancy.rs
T
kingchencandGitHub fcb221ec03 feat: add 19 indicators for external feature-extractor coverage (377 -> 396) (#175)
Adds 19 streaming indicators so an external trading-bot feature extractor can replace its hand-built features with native, batch/streaming-equivalent ones. Each is a real gap (verified against the existing catalogue), production-only, with full Python/Node/WASM bindings, fuzz drivers, and tests. Five commits, one per family group; counter 377 -> 396.

## What's added

**Price Statistics (6)** — `LogReturn`, `RealizedVolatility` (raw quadratic variation, the un-annualised counterpart to `HistoricalVolatility`), `RollingQuantile`, `RollingIqr`, `RollingPercentileRank`, `SpreadAr1Coefficient` (pairwise AR(1) rho of the spread; complements `OuHalfLife`).

**Price Action (4)** — `CloseVsOpen`, `BodySizePct`, `WickRatio`, `HighLowRange` (stateless per-bar OHLC transforms).

**Regime / Trend / Jump labels (3)** — `TrendLabel` (sign of the rolling OLS slope), `JumpIndicator` (return outliers vs trailing volatility, measured as deviation from the trailing mean so steady drift is not flagged), `RegimeLabel` (volatility-quantile regime split).

**Risk / Performance (2)** — `WinRate`, `Expectancy` (R-multiple).

**Microstructure (4)** — `OrderFlowImbalance` (Cont-Kukanov-Stoikov OFI), `Vpin`, `AmihudIlliquidity`, `RollMeasure`. These reuse the existing `OrderBook` / `Trade` inputs (no new input type).

## Intentionally NOT added (already present, would be duplicates)

- **Population skew / kurtosis** — `skewness.rs` / `kurtosis.rs` are already population moments (divisor n).
- **Hurst R/S** — `hurst_exponent.rs` already uses rescaled-range (R/S) analysis.
- **Queue Imbalance** — exactly `OrderBookImbalanceTop1` ((bidSize - askSize) / (bidSize + askSize)).

## Verification

`cargo test -p wickra-core` (lib 3187 + doc 354), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (471), python `pytest` (784). Counter consistent across `mod.rs`, lib block, README, and docs/README at 396.
2026-06-04 12:00:35 +02:00

209 lines
6.3 KiB
Rust

//! Expectancy — expected return per unit of average loss (R-multiple).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Expectancy — the expected return per trade expressed in units of average
/// loss (the "R-multiple" expectancy) over the last `period` returns.
///
/// ```text
/// mean = average of the `period` returns
/// avgLoss = average of the absolute losing returns (rᵢ < 0)
/// E = mean / avgLoss (0 when there are no losing returns)
/// ```
///
/// Feed a stream of per-trade or per-bar returns. Expectancy answers "how much
/// do I make per trade for every unit I typically risk": `E = 0.3` means the
/// system nets `0.3R` per trade on average, where `R` is the average loss.
/// Dividing the mean return by the average loss makes the figure comparable
/// across systems with different bet sizes — unlike the raw mean return (which
/// is just an SMA of the series). A positive `E` is a profitable edge, a
/// negative `E` a losing one.
///
/// When the window contains **no** losing returns there is no risk reference to
/// normalise against, so the indicator returns `0` (undefined R-multiple)
/// rather than dividing by zero.
///
/// Each `update` is O(1): the running sum and the loss aggregates are
/// maintained incrementally.
///
/// # Example
///
/// ```
/// use wickra_core::{BatchExt, Indicator, Expectancy};
///
/// let mut indicator = Expectancy::new(4).unwrap();
/// // returns +2, -1, +2, -1: mean 0.5, avg loss 1 -> E = 0.5.
/// let out = indicator.batch(&[2.0, -1.0, 2.0, -1.0]);
/// assert_eq!(out[3], Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct Expectancy {
period: usize,
window: VecDeque<f64>,
sum: f64,
sum_abs_loss: f64,
loss_count: usize,
}
impl Expectancy {
/// Construct a new Expectancy over the given window.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_abs_loss: 0.0,
loss_count: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Expectancy {
type Input = f64;
type Output = f64;
fn update(&mut self, ret: f64) -> Option<f64> {
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
if old < 0.0 {
self.sum_abs_loss -= -old;
self.loss_count -= 1;
}
}
self.window.push_back(ret);
self.sum += ret;
if ret < 0.0 {
self.sum_abs_loss += -ret;
self.loss_count += 1;
}
if self.window.len() < self.period {
return None;
}
if self.loss_count == 0 {
// No losing returns: no risk reference to express the edge in.
return Some(0.0);
}
let mean = self.sum / self.period as f64;
let avg_loss = self.sum_abs_loss / self.loss_count as f64;
Some(mean / avg_loss)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_abs_loss = 0.0;
self.loss_count = 0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"Expectancy"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Expectancy::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let e = Expectancy::new(20).unwrap();
assert_eq!(e.period(), 20);
assert_eq!(e.warmup_period(), 20);
assert_eq!(e.name(), "Expectancy");
assert!(!e.is_ready());
}
#[test]
fn positive_edge() {
// +2, -1, +2, -1: mean 0.5, avgLoss 1 -> 0.5.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn negative_edge() {
// +1, -2, +1, -2: mean -0.5, avgLoss 2 -> -0.25.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[1.0, -2.0, 1.0, -2.0]);
assert_relative_eq!(out[3].unwrap(), -0.25, epsilon = 1e-12);
}
#[test]
fn no_losses_returns_zero() {
// All winning returns: no risk reference -> 0.
let mut e = Expectancy::new(5).unwrap();
for v in e.batch(&[1.0, 2.0, 3.0, 1.0, 2.0]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn flat_returns_are_not_losses() {
// Zeros are not losses: mean (2+0+2+0)/4 = 1, but no losing returns
// -> 0 (undefined R-multiple).
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, 0.0, 2.0, 0.0]);
assert_relative_eq!(out[3].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_old_losses() {
// period 4. Window [+2,-1,+2,-1] -> 0.5; then push +3,+3,+3,+3 to evict
// all losses -> no losses -> 0.
let mut e = Expectancy::new(4).unwrap();
let out = e.batch(&[2.0, -1.0, 2.0, -1.0, 3.0, 3.0, 3.0, 3.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
assert_relative_eq!(out[7].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut e = Expectancy::new(5).unwrap();
e.batch(&[1.0, -1.0, 2.0, -2.0, 1.0]);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.5).sin() * 2.0).collect();
let batch = Expectancy::new(14).unwrap().batch(&rets);
let mut b = Expectancy::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}