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.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user