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.
209 lines
6.3 KiB
Rust
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);
|
|
}
|
|
}
|