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:
kingchenc
2026-06-04 12:00:35 +02:00
committed by GitHub
parent a93af60796
commit fcb221ec03
37 changed files with 6697 additions and 84 deletions
@@ -0,0 +1,239 @@
//! Amihud Illiquidity — average price impact per unit traded value.
use std::collections::VecDeque;
use crate::microstructure::Trade;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Amihud Illiquidity — the average absolute log return per unit of traded
/// value over the last `period` trades (Amihud, 2002).
///
/// ```text
/// rₜ = ln(priceₜ / priceₜ₋₁)
/// ILLIQₜ = |rₜ| / (priceₜ · sizeₜ) (return per dollar of volume)
/// Amihud = mean of ILLIQ over the last `period` trades
/// ```
///
/// Amihud's measure captures how much the price moves for a given amount of
/// traded value: a **high** reading means small volume already shifts the price
/// a lot (an illiquid, easily-moved market), a **low** reading means it takes
/// large volume to move the price (a deep, liquid market). It is the workhorse
/// cross-sectional liquidity proxy in market-microstructure research.
///
/// `Input = Trade`. Trades with zero size carry no traded value and are skipped
/// (the ratio is undefined); the last value is returned and state is untouched.
/// The first valid trade only seeds the reference price.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, AmihudIlliquidity};
///
/// let mut amihud = AmihudIlliquidity::new(20).unwrap();
/// assert_eq!(amihud.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), None);
/// ```
#[derive(Debug, Clone)]
pub struct AmihudIlliquidity {
period: usize,
prev_price: Option<f64>,
window: VecDeque<f64>,
sum: f64,
last: Option<f64>,
}
impl AmihudIlliquidity {
/// Construct a new Amihud Illiquidity over the given trade 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,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for AmihudIlliquidity {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
// A zero-size trade has no traded value: the ratio is undefined, so the
// trade is skipped without touching the reference price.
if trade.size == 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(trade.price);
return None;
};
self.prev_price = Some(trade.price);
// `prev` and `trade.price` are both finite and strictly positive
// (enforced by `Trade::new`), so the log return is well-defined and the
// traded value is strictly positive.
let ret = (trade.price / prev).ln().abs();
let illiq = ret / (trade.price * trade.size);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(illiq);
self.sum += illiq;
if self.window.len() < self.period {
return None;
}
let value = self.sum / self.period as f64;
self.last = Some(value);
Some(value)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AmihudIlliquidity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(price: f64, size: f64) -> Trade {
Trade::new(price, size, Side::Buy, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(AmihudIlliquidity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let a = AmihudIlliquidity::new(20).unwrap();
assert_eq!(a.period(), 20);
assert_eq!(a.warmup_period(), 21);
assert_eq!(a.name(), "AmihudIlliquidity");
assert!(!a.is_ready());
}
#[test]
fn known_value() {
// period 1. Seed at 100, then 101 with size 10:
// |ln(101/100)| / (101 * 10).
let mut a = AmihudIlliquidity::new(1).unwrap();
assert_eq!(a.update(trade(100.0, 10.0)), None);
let out = a.update(trade(101.0, 10.0)).unwrap();
let expected = (101.0_f64 / 100.0).ln().abs() / (101.0 * 10.0);
assert_relative_eq!(out, expected, epsilon = 1e-15);
}
#[test]
fn higher_for_thinner_volume() {
// Same price move on smaller volume => larger illiquidity reading.
let thin = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1.0));
a.update(trade(101.0, 1.0)).unwrap()
};
let thick = {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 1000.0));
a.update(trade(101.0, 1000.0)).unwrap()
};
assert!(thin > thick, "thin {thin} should exceed thick {thick}");
}
#[test]
fn flat_price_is_zero() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for v in a.batch(&[trade(100.0, 3.0); 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-15);
}
}
#[test]
fn skips_zero_size_trades() {
let mut a = AmihudIlliquidity::new(1).unwrap();
a.update(trade(100.0, 10.0));
let baseline = a.update(trade(101.0, 10.0)).unwrap();
// A zero-size trade is ignored; the previous reference price is kept.
assert_eq!(a.update(trade(200.0, 0.0)), Some(baseline));
// The next real trade still references price 101, not 200.
let mut control = a.clone();
let after = a.update(trade(102.0, 10.0)).unwrap();
assert_eq!(control.update(trade(102.0, 10.0)).unwrap(), after);
}
#[test]
fn output_is_non_negative() {
let mut a = AmihudIlliquidity::new(10).unwrap();
let trades: Vec<Trade> = (0..100)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1.0 + f64::from(i % 7),
)
})
.collect();
for v in a.batch(&trades).into_iter().flatten() {
assert!(v >= 0.0, "illiquidity must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut a = AmihudIlliquidity::new(5).unwrap();
for i in 0..20 {
a.update(trade(100.0 + f64::from(i), 2.0));
}
assert!(a.is_ready());
a.reset();
assert!(!a.is_ready());
assert_eq!(a.update(trade(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..80)
.map(|i| {
trade(
100.0 + (f64::from(i) * 0.25).sin() * 4.0,
1.0 + f64::from(i % 5),
)
})
.collect();
let batch = AmihudIlliquidity::new(14).unwrap().batch(&trades);
let mut b = AmihudIlliquidity::new(14).unwrap();
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,193 @@
//! Body Size Percent — candle body as a fraction of its range.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Body Size Percent — the absolute body as a fraction of the bar's range.
///
/// ```text
/// BodySizePct = |close open| / (high low)
/// ```
///
/// The result lives in `[0, 1]`: `1` is a full-bodied marubozu (the bar opened
/// at one extreme and closed at the other, no wicks), `0` a doji (open equals
/// close, the bar is all wick). It is the *unsigned* magnitude companion to
/// [`BalanceOfPower`](crate::BalanceOfPower) — where `BoP` keeps the direction,
/// this keeps only the conviction, which is exactly what candlestick body /
/// range filters key on. A zero-range bar carries no information and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BodySizePct};
///
/// let mut indicator = BodySizePct::new();
/// // body |12 - 10| = 2, range 14 - 10 = 4 -> 0.5.
/// let c = Candle::new(10.0, 14.0, 10.0, 12.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.5).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct BodySizePct {
has_emitted: bool,
}
impl BodySizePct {
/// Construct a new Body Size Percent transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BodySizePct {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let out = if range == 0.0 {
// A zero-range bar has no body proportion to speak of.
0.0
} else {
(candle.close - candle.open).abs() / range
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BodySizePct"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// |12 - 10| / (14 - 10) = 0.5.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
0.5,
epsilon = 1e-12
);
}
#[test]
fn marubozu_is_one() {
// open == low, close == high, no wicks -> full body -> 1.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
1.0,
epsilon = 1e-12
);
}
#[test]
fn doji_is_zero() {
// open == close with a real range -> body 0.
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn unsigned_regardless_of_direction() {
// A red bar with the same body magnitude reads identically to a green one.
let mut bsp = BodySizePct::new();
let green = bsp.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap();
let mut bsp2 = BodySizePct::new();
let red = bsp2.update(candle(12.0, 14.0, 10.0, 10.0, 0)).unwrap();
assert_relative_eq!(green, red, epsilon = 1e-12);
}
#[test]
fn zero_range_bar_yields_zero() {
let mut bsp = BodySizePct::new();
assert_relative_eq!(
bsp.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
})
.collect();
let mut bsp = BodySizePct::new();
for v in bsp.batch(&candles).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "BodySizePct {v} outside [0, 1]");
}
}
#[test]
fn name_metadata() {
let bsp = BodySizePct::new();
assert_eq!(bsp.name(), "BodySizePct");
}
#[test]
fn emits_from_first_candle() {
let mut bsp = BodySizePct::new();
assert_eq!(bsp.warmup_period(), 1);
assert!(!bsp.is_ready());
assert!(bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(bsp.is_ready());
}
#[test]
fn reset_clears_state() {
let mut bsp = BodySizePct::new();
bsp.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(bsp.is_ready());
bsp.reset();
assert!(!bsp.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = BodySizePct::new();
let mut b = BodySizePct::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Close vs Open — the signed relative body of a bar.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Close vs Open — the bar's body as a signed fraction of its open price.
///
/// ```text
/// CloseVsOpen = (close open) / open
/// ```
///
/// A scale-free, signed measure of how far price travelled from open to close:
/// `+0.02` is a bar that closed 2% above its open (a green bar), `0.02` the
/// mirror. Unlike [`BalanceOfPower`](crate::BalanceOfPower) — which normalises
/// the body by the bar *range* — this normalises by the *open price*, so it is
/// directly comparable to a return and stays meaningful across instruments of
/// different nominal price. A zero open carries no scale and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, CloseVsOpen};
///
/// let mut indicator = CloseVsOpen::new();
/// // open 100, close 102 -> +0.02.
/// let c = Candle::new(100.0, 103.0, 99.0, 102.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.02).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct CloseVsOpen {
has_emitted: bool,
}
impl CloseVsOpen {
/// Construct a new Close vs Open transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for CloseVsOpen {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let out = if candle.open == 0.0 {
// A zero open price carries no scale to normalise against.
0.0
} else {
(candle.close - candle.open) / candle.open
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CloseVsOpen"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (102 - 100) / 100 = 0.02.
let mut cvo = CloseVsOpen::new();
assert_relative_eq!(
cvo.update(candle(100.0, 103.0, 99.0, 102.0, 0)).unwrap(),
0.02,
epsilon = 1e-12
);
}
#[test]
fn negative_body_is_negative() {
let mut cvo = CloseVsOpen::new();
// close below open -> negative.
assert_relative_eq!(
cvo.update(candle(100.0, 101.0, 97.0, 98.0, 0)).unwrap(),
-0.02,
epsilon = 1e-12
);
}
#[test]
fn zero_open_yields_zero() {
// Candle permits a zero open (only finiteness + OHLC ordering checked).
let mut cvo = CloseVsOpen::new();
assert_relative_eq!(
cvo.update(candle(0.0, 1.0, 0.0, 0.5, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn name_metadata() {
let cvo = CloseVsOpen::new();
assert_eq!(cvo.name(), "CloseVsOpen");
}
#[test]
fn emits_from_first_candle() {
let mut cvo = CloseVsOpen::new();
assert_eq!(cvo.warmup_period(), 1);
assert!(!cvo.is_ready());
assert!(cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(cvo.is_ready());
}
#[test]
fn reset_clears_state() {
let mut cvo = CloseVsOpen::new();
cvo.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(cvo.is_ready());
cvo.reset();
assert!(!cvo.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = CloseVsOpen::new();
let mut b = CloseVsOpen::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -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);
}
}
@@ -0,0 +1,174 @@
//! High-Low Range — the bar range as a fraction of close.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// High-Low Range — the bar's high-low range expressed as a fraction of its
/// close price.
///
/// ```text
/// HighLowRange = (high low) / close
/// ```
///
/// A scale-free, single-bar volatility proxy: the absolute range `high low`
/// grows with the nominal price level, so dividing by the close makes a `2$`
/// range on a `100$` instrument (`0.02`) directly comparable to a `200$` range
/// on a `10000$` one (`0.02`). It is the per-bar cousin of average-true-range
/// style measures without the smoothing — useful as an instant intrabar
/// volatility read or a normaliser for other features. The output is `≥ 0`
/// for positive prices. A zero close carries no scale and yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, HighLowRange};
///
/// let mut indicator = HighLowRange::new();
/// // range 104 - 98 = 6, close 100 -> 0.06.
/// let c = Candle::new(99.0, 104.0, 98.0, 100.0, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 0.06).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct HighLowRange {
has_emitted: bool,
}
impl HighLowRange {
/// Construct a new High-Low Range transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for HighLowRange {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let out = if candle.close == 0.0 {
// A zero close carries no scale to normalise the range against.
0.0
} else {
(candle.high - candle.low) / candle.close
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"HighLowRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn reference_value() {
// (104 - 98) / 100 = 0.06.
let mut hlr = HighLowRange::new();
assert_relative_eq!(
hlr.update(candle(99.0, 104.0, 98.0, 100.0, 0)).unwrap(),
0.06,
epsilon = 1e-12
);
}
#[test]
fn zero_range_bar_yields_zero() {
// high == low -> range 0 -> 0 regardless of close.
let mut hlr = HighLowRange::new();
assert_relative_eq!(
hlr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn zero_close_yields_zero() {
// Candle permits a zero close (only finiteness + OHLC ordering checked):
// open 0, high 1, low 0, close 0 satisfies high >= all, low <= all.
let mut hlr = HighLowRange::new();
assert_relative_eq!(
hlr.update(candle(0.0, 1.0, 0.0, 0.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn output_is_non_negative() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
candle(mid, mid + 3.0, mid - 3.0, mid, i64::from(i))
})
.collect();
let mut hlr = HighLowRange::new();
for v in hlr.batch(&candles).into_iter().flatten() {
assert!(v >= 0.0, "HighLowRange {v} must be non-negative");
}
}
#[test]
fn name_metadata() {
let hlr = HighLowRange::new();
assert_eq!(hlr.name(), "HighLowRange");
}
#[test]
fn emits_from_first_candle() {
let mut hlr = HighLowRange::new();
assert_eq!(hlr.warmup_period(), 1);
assert!(!hlr.is_ready());
assert!(hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(hlr.is_ready());
}
#[test]
fn reset_clears_state() {
let mut hlr = HighLowRange::new();
hlr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(hlr.is_ready());
hlr.reset();
assert!(!hlr.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = HighLowRange::new();
let mut b = HighLowRange::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,291 @@
//! Jump Indicator — detects return outliers relative to trailing volatility.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Jump Indicator — a discrete `{1, 0, +1}` flag for whether the current log
/// return is an outlier relative to the trailing volatility of returns.
///
/// ```text
/// rₜ = ln(priceₜ / priceₜ₋₁)
/// μ, σ = sample mean and stddev of the `period` returns *before* rₜ (trailing)
/// flag = +1 if rₜ μ > threshold · σ
/// 1 if rₜ μ < threshold · σ
/// 0 otherwise
/// ```
///
/// The baseline is the trailing return distribution and **excludes** the current
/// return, so a genuine jump cannot inflate the band it is tested against.
/// Measuring the deviation from the trailing mean `μ` (not the raw return) means
/// a steady drift is *not* flagged — only moves that are large relative to the
/// recent return distribution count. `+1` marks an up jump, `1` a down jump,
/// and `0` an ordinary move. When the trailing window has zero dispersion
/// (`σ = 0`, e.g. a perfectly constant drift) there is no defined baseline and
/// the indicator returns `0` rather than flagging every move.
///
/// This is the generic, threshold-tunable detector; downstream models keep any
/// regime-specific sensitivity by choosing `threshold`. Non-finite and
/// non-positive prices are ignored (the log return is undefined): the tick is
/// dropped and the last value returned.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, JumpIndicator};
///
/// let mut indicator = JumpIndicator::new(20, 3.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
/// }
/// // A calm sinusoid produces no jumps.
/// assert_eq!(last, Some(0.0));
/// ```
#[derive(Debug, Clone)]
pub struct JumpIndicator {
period: usize,
threshold: f64,
prev_price: Option<f64>,
/// Trailing window of the `period` returns preceding the current one.
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
last: Option<f64>,
}
impl JumpIndicator {
/// Construct a new Jump Indicator.
///
/// `threshold` is the number of trailing standard deviations a return must
/// exceed to be flagged.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (the sample standard
/// deviation needs at least two returns), or [`Error::InvalidParameter`] if
/// `threshold` is not finite and positive.
pub fn new(period: usize, threshold: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "jump indicator needs period >= 2",
});
}
if !threshold.is_finite() || threshold <= 0.0 {
return Err(Error::InvalidParameter {
message: "jump indicator threshold must be finite and positive",
});
}
Ok(Self {
period,
threshold,
prev_price: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
last: None,
})
}
/// Configured `(period, threshold)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.threshold)
}
}
impl Indicator for JumpIndicator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
let r = (input / prev).ln();
if self.window.len() < self.period {
// Still filling the trailing window; no baseline yet.
self.window.push_back(r);
self.sum += r;
self.sum_sq += r * r;
return None;
}
// Trailing window is full: classify `r` against the volatility of the
// `period` returns that precede it.
let n = self.period as f64;
let mean = self.sum / n;
let var = ((self.sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
let sd = var.sqrt();
let deviation = r - mean;
let label = if sd == 0.0 {
0.0
} else if deviation > self.threshold * sd {
1.0
} else if deviation < -self.threshold * sd {
-1.0
} else {
0.0
};
// Slide the trailing window forward to include `r`.
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
self.sum_sq -= old * old;
self.window.push_back(r);
self.sum += r;
self.sum_sq += r * r;
self.last = Some(label);
Some(label)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// One price seeds `prev`, `period` returns fill the trailing window,
// then the next return is the first one classified.
self.period + 2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"JumpIndicator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_params() {
assert!(matches!(
JumpIndicator::new(1, 3.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
JumpIndicator::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
JumpIndicator::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let ji = JumpIndicator::new(20, 3.0).unwrap();
assert_eq!(ji.params(), (20, 3.0));
assert_eq!(ji.warmup_period(), 22);
assert_eq!(ji.name(), "JumpIndicator");
assert!(!ji.is_ready());
}
#[test]
fn detects_upward_jump() {
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
// Calm oscillating warmup (small, varied returns), then a +20% spike.
let mut prices: Vec<f64> = (0..20)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
.collect();
let last_calm = *prices.last().unwrap();
prices.push(last_calm * 1.2);
let out = ji.batch(&prices);
assert_eq!(out.last().copied().flatten(), Some(1.0));
}
#[test]
fn detects_downward_jump() {
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
let mut prices: Vec<f64> = (0..20)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
.collect();
let last_calm = *prices.last().unwrap();
prices.push(last_calm * 0.8);
let out = ji.batch(&prices);
assert_eq!(out.last().copied().flatten(), Some(-1.0));
}
#[test]
fn calm_series_has_no_jumps() {
let mut ji = JumpIndicator::new(20, 3.0).unwrap();
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin())
.collect();
for v in ji.batch(&prices).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn zero_trailing_volatility_returns_zero() {
// A constant price has exactly-zero returns => zero trailing dispersion
// => no defined baseline => label 0. (Pins the `sd == 0` branch with an
// exact-zero series; a geometric drift is conceptually zero-vol too but
// floating-point rounding of the log returns leaves ~1e-16 noise.)
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
for v in ji.batch(&[100.0; 30]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn steady_drift_is_not_flagged() {
// A near-constant positive drift (small, equal-ish returns) must not be
// flagged: the deviation from the trailing mean stays well inside the
// band even though the raw return is non-zero every bar.
let mut ji = JumpIndicator::new(10, 3.0).unwrap();
let prices: Vec<f64> = (0..40).map(|i| 100.0 + f64::from(i) * 0.5).collect();
for v in ji.batch(&prices).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn ignores_non_finite_and_non_positive() {
let mut ji = JumpIndicator::new(5, 3.0).unwrap();
let prices: Vec<f64> = (0..20)
.map(|i| 100.0 + (f64::from(i) * 0.6).sin())
.collect();
let out = ji.batch(&prices);
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(ji.update(f64::NAN), last);
assert_eq!(ji.update(-1.0), last);
assert_eq!(ji.update(0.0), last);
}
#[test]
fn reset_clears_state() {
let mut ji = JumpIndicator::new(5, 3.0).unwrap();
ji.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ji.is_ready());
ji.reset();
assert!(!ji.is_ready());
assert_eq!(ji.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 3.0)
.collect();
let batch = JumpIndicator::new(20, 3.0).unwrap().batch(&prices);
let mut b = JumpIndicator::new(20, 3.0).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,218 @@
//! Logarithmic Return over a fixed lag.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Logarithmic return over a `period`-bar lag: `ln(price_t / price_{tperiod})`.
///
/// The natural-log analogue of [`Roc`](crate::Roc) (which reports the simple
/// percentage change). Log returns are the canonical input for volatility and
/// statistical models because they are additive across time — the log return
/// over `k` bars equals the sum of the `k` one-bar log returns — and symmetric
/// around zero (a `+x` move and the reverse `x` move cancel exactly).
///
/// ```text
/// r_t = ln(price_t / price_{tperiod})
/// ```
///
/// Non-finite and non-positive prices are ignored: the input is dropped, state
/// is left untouched, and the last computed value is returned instead. The log
/// of a non-positive price is undefined, so such ticks must not enter the
/// window — mirroring [`HistoricalVolatility`](crate::HistoricalVolatility).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LogReturn};
///
/// let mut indicator = LogReturn::new(1).unwrap();
/// indicator.update(100.0);
/// // ln(110 / 100) ≈ 0.09531
/// let r = indicator.update(110.0).unwrap();
/// assert!((r - (110.0_f64 / 100.0).ln()).abs() < 1e-12);
/// ```
#[derive(Debug, Clone)]
pub struct LogReturn {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl LogReturn {
/// Construct a new log-return indicator with the given lag.
///
/// # 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 + 1),
last: None,
})
}
/// Configured lag.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LogReturn {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite or non-positive prices are ignored: `ln` of a non-positive
// price is undefined, so the tick must not enter the window. Return the
// last value and leave state untouched (SMA / EMA / HV convention).
if !input.is_finite() || input <= 0.0 {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
// `prev` was pushed through the same guard, so it is finite and > 0 and
// `(input / prev).ln()` is always well-defined.
let prev = *self.window.front().expect("non-empty");
let r = (input / prev).ln();
self.last = Some(r);
Some(r)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"LogReturn"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(LogReturn::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let lr = LogReturn::new(5).unwrap();
assert_eq!(lr.period(), 5);
assert_eq!(lr.warmup_period(), 6);
assert_eq!(lr.name(), "LogReturn");
assert!(!lr.is_ready());
}
#[test]
fn known_value() {
// LogReturn(1): ln(110 / 100).
let mut lr = LogReturn::new(1).unwrap();
let out = lr.batch(&[100.0, 110.0]);
assert!(out[0].is_none());
assert_relative_eq!(out[1].unwrap(), (110.0_f64 / 100.0).ln(), epsilon = 1e-12);
}
#[test]
fn multi_bar_lag() {
// LogReturn(3): at index 3, ln(price_3 / price_0).
let mut lr = LogReturn::new(3).unwrap();
let out = lr.batch(&[100.0, 105.0, 108.0, 121.0]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert_relative_eq!(out[3].unwrap(), (121.0_f64 / 100.0).ln(), epsilon = 1e-12);
}
#[test]
fn additive_across_time() {
// The 2-bar log return equals the sum of the two 1-bar log returns.
let prices = [50.0, 55.0, 60.5];
let mut lag2 = LogReturn::new(2).unwrap();
let two_bar = lag2.batch(&prices)[2].unwrap();
let mut lag1 = LogReturn::new(1).unwrap();
let ones = lag1.batch(&prices);
let sum = ones[1].unwrap() + ones[2].unwrap();
assert_relative_eq!(two_bar, sum, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut lr = LogReturn::new(4).unwrap();
for v in lr.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut lr = LogReturn::new(1).unwrap();
let out = lr.batch(&[100.0, 110.0]);
let ready = out[1].expect("ready after two inputs");
assert_eq!(lr.update(f64::NAN), Some(ready));
assert_eq!(lr.update(f64::INFINITY), Some(ready));
// Window untouched: the next finite price still references prev = 110.
assert_relative_eq!(
lr.update(121.0).unwrap(),
(121.0_f64 / 110.0).ln(),
epsilon = 1e-12
);
}
#[test]
fn skips_non_positive_prices() {
let mut lr = LogReturn::new(1).unwrap();
let out = lr.batch(&[100.0, 110.0]);
let baseline = out[1].expect("ready");
// A non-positive tick is ignored and the previous valid price is kept.
assert_eq!(lr.update(-5.0), Some(baseline));
assert_eq!(lr.update(0.0), Some(baseline));
let mut control = lr.clone();
let after = lr.update(121.0).expect("ready");
assert_eq!(control.update(121.0).expect("ready"), after);
assert_relative_eq!(after, (121.0_f64 / 110.0).ln(), epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut lr = LogReturn::new(3).unwrap();
lr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(lr.is_ready());
lr.reset();
assert!(!lr.is_ready());
assert_eq!(lr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = LogReturn::new(5).unwrap().batch(&prices);
let mut b = LogReturn::new(5).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+58 -1
View File
@@ -26,6 +26,7 @@ mod adxr;
mod alligator;
mod alma;
mod alpha;
mod amihud_illiquidity;
mod anchored_rsi;
mod anchored_vwap;
mod apo;
@@ -46,6 +47,7 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
mod breadth_thrust;
@@ -64,6 +66,7 @@ mod chande_kroll_stop;
mod chandelier_exit;
mod choppiness_index;
mod classic_pivots;
mod close_vs_open;
mod closing_marubozu;
mod cmf;
mod cmo;
@@ -109,6 +112,7 @@ mod empirical_mode_decomposition;
mod engulfing;
mod evening_doji_star;
mod evwma;
mod expectancy;
mod falling_three_methods;
mod fama;
mod fib_arcs;
@@ -143,6 +147,7 @@ mod harami;
mod head_and_shoulders;
mod heikin_ashi;
mod high_low_index;
mod high_low_range;
mod high_wave;
mod hikkake;
mod hikkake_modified;
@@ -167,6 +172,7 @@ mod intraday_volatility_profile;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod jump_indicator;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
@@ -187,6 +193,7 @@ mod linreg_channel;
mod linreg_intercept;
mod linreg_slope;
mod liquidation_features;
mod log_return;
mod long_legged_doji;
mod long_line;
mod long_short_ratio;
@@ -229,6 +236,7 @@ mod omega_ratio;
mod on_neck;
mod opening_marubozu;
mod opening_range;
mod order_flow_imbalance;
mod ou_half_life;
mod overnight_gap;
mod overnight_intraday_return;
@@ -253,8 +261,10 @@ mod pvi;
mod quoted_spread;
mod r_squared;
mod realized_spread;
mod realized_volatility;
mod recovery_factor;
mod rectangle_range;
mod regime_label;
mod relative_strength_ab;
mod renko_bars;
mod renko_trailing_stop;
@@ -265,8 +275,12 @@ mod rocp;
mod rocr;
mod rocr100;
mod rogers_satchell;
mod roll_measure;
mod rolling_correlation;
mod rolling_covariance;
mod rolling_iqr;
mod rolling_percentile_rank;
mod rolling_quantile;
mod roofing_filter;
mod rsi;
mod rvi;
@@ -291,6 +305,7 @@ mod smma;
mod sortino_ratio;
mod spearman_correlation;
mod spinning_top;
mod spread_ar1_coefficient;
mod spread_bollinger_bands;
mod spread_hurst;
mod stalled_pattern;
@@ -335,6 +350,7 @@ mod tii;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trend_label;
mod treynor_ratio;
mod triangle;
mod trima;
@@ -367,6 +383,7 @@ mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod vortex;
mod vpin;
mod vpt;
mod vwap;
mod vwap_stddev_bands;
@@ -375,8 +392,10 @@ mod vzo;
mod wave_trend;
mod wedge;
mod weighted_close;
mod wick_ratio;
mod williams_fractals;
mod williams_r;
mod win_rate;
mod wma;
mod woodie_pivots;
mod yang_zhang;
@@ -403,6 +422,7 @@ pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
pub use alma::Alma;
pub use alpha::Alpha;
pub use amihud_illiquidity::AmihudIlliquidity;
pub use anchored_rsi::AnchoredRsi;
pub use anchored_vwap::AnchoredVwap;
pub use apo::Apo;
@@ -423,6 +443,7 @@ pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use breadth_thrust::BreadthThrust;
@@ -441,6 +462,7 @@ pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput};
pub use chandelier_exit::{ChandelierExit, ChandelierExitOutput};
pub use choppiness_index::ChoppinessIndex;
pub use classic_pivots::{ClassicPivots, ClassicPivotsOutput};
pub use close_vs_open::CloseVsOpen;
pub use closing_marubozu::ClosingMarubozu;
pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
@@ -486,6 +508,7 @@ pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
pub use expectancy::Expectancy;
pub use falling_three_methods::FallingThreeMethods;
pub use fama::Fama;
pub use fib_arcs::{FibArcs, FibArcsOutput};
@@ -520,6 +543,7 @@ pub use harami::Harami;
pub use head_and_shoulders::HeadAndShoulders;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_low_index::HighLowIndex;
pub use high_low_range::HighLowRange;
pub use high_wave::HighWave;
pub use hikkake::Hikkake;
pub use hikkake_modified::HikkakeModified;
@@ -544,6 +568,7 @@ pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatil
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
pub use jump_indicator::JumpIndicator;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
pub use kama::Kama;
@@ -564,6 +589,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_intercept::LinRegIntercept;
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use log_return::LogReturn;
pub use long_legged_doji::LongLeggedDoji;
pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
@@ -606,6 +632,7 @@ pub use omega_ratio::OmegaRatio;
pub use on_neck::OnNeck;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use order_flow_imbalance::OrderFlowImbalance;
pub use ou_half_life::OuHalfLife;
pub use overnight_gap::OvernightGap;
pub use overnight_intraday_return::{OvernightIntradayReturn, OvernightIntradayReturnOutput};
@@ -630,8 +657,10 @@ pub use pvi::Pvi;
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
pub use realized_volatility::RealizedVolatility;
pub use recovery_factor::RecoveryFactor;
pub use rectangle_range::RectangleRange;
pub use regime_label::RegimeLabel;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
pub use renko_bars::{RenkoBars, RenkoBrick};
pub use renko_trailing_stop::RenkoTrailingStop;
@@ -642,8 +671,12 @@ pub use rocp::Rocp;
pub use rocr::Rocr;
pub use rocr100::Rocr100;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roll_measure::RollMeasure;
pub use rolling_correlation::RollingCorrelation;
pub use rolling_covariance::RollingCovariance;
pub use rolling_iqr::RollingIqr;
pub use rolling_percentile_rank::RollingPercentileRank;
pub use rolling_quantile::RollingQuantile;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
@@ -668,6 +701,7 @@ pub use smma::Smma;
pub use sortino_ratio::SortinoRatio;
pub use spearman_correlation::SpearmanCorrelation;
pub use spinning_top::SpinningTop;
pub use spread_ar1_coefficient::SpreadAr1Coefficient;
pub use spread_bollinger_bands::{SpreadBollingerBands, SpreadBollingerBandsOutput};
pub use spread_hurst::SpreadHurst;
pub use stalled_pattern::StalledPattern;
@@ -712,6 +746,7 @@ pub use tii::Tii;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trend_label::TrendLabel;
pub use treynor_ratio::TreynorRatio;
pub use triangle::Triangle;
pub use trima::Trima;
@@ -744,6 +779,7 @@ pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput}
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use vortex::{Vortex, VortexOutput};
pub use vpin::Vpin;
pub use vpt::VolumePriceTrend;
pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
@@ -752,8 +788,10 @@ pub use vzo::Vzo;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
pub use weighted_close::WeightedClose;
pub use wick_ratio::WickRatio;
pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput};
pub use williams_r::WilliamsR;
pub use win_rate::WinRate;
pub use wma::Wma;
pub use woodie_pivots::{WoodiePivots, WoodiePivotsOutput};
pub use yang_zhang::YangZhangVolatility;
@@ -846,6 +884,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PlusDi",
"MinusDi",
"Dx",
"TrendLabel",
],
),
(
@@ -884,6 +923,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GarmanKlassVolatility",
"RogersSatchellVolatility",
"YangZhangVolatility",
"JumpIndicator",
"RegimeLabel",
],
),
(
@@ -987,6 +1028,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"GrangerCausality",
"KalmanHedgeRatio",
"SpreadBollingerBands",
"LogReturn",
"RealizedVolatility",
"RollingIqr",
"RollingPercentileRank",
"RollingQuantile",
"SpreadAr1Coefficient",
"CloseVsOpen",
"BodySizePct",
"WickRatio",
"HighLowRange",
],
),
(
@@ -1124,6 +1175,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"RealizedSpread",
"KylesLambda",
"Footprint",
"OrderFlowImbalance",
"Vpin",
"AmihudIlliquidity",
"RollMeasure",
],
),
(
@@ -1173,6 +1228,8 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TreynorRatio",
"InformationRatio",
"Alpha",
"WinRate",
"Expectancy",
],
),
(
@@ -1285,6 +1342,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 377, "FAMILIES total drifted from indicator count");
assert_eq!(total, 396, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,242 @@
//! Order Flow Imbalance (OFI) from best-level order-book changes.
use std::collections::VecDeque;
use crate::microstructure::OrderBook;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Order Flow Imbalance — the rolling sum of best-level order-flow events over
/// the last `period` order-book snapshots.
///
/// Following Cont, Kukanov & Stoikov (2014), each new snapshot contributes a
/// signed event from how the best bid and ask moved versus the previous one:
///
/// ```text
/// Δᵇ = qᵇₙ·1{Pᵇₙ ≥ Pᵇₙ₋₁} − qᵇₙ₋₁·1{Pᵇₙ ≤ Pᵇₙ₋₁} (bid pressure)
/// Δᵃ = qᵃₙ·1{Pᵃₙ ≤ Pᵃₙ₋₁} − qᵃₙ₋₁·1{Pᵃₙ ≥ Pᵃₙ₋₁} (ask pressure)
/// eₙ = Δᵇ Δᵃ
/// OFI = Σ eₙ over the last `period` snapshots
/// ```
///
/// A rising bid (or replenished bid size) and a falling/depleting ask both add
/// positive flow; the mirror subtracts. The rolling sum is a strong
/// short-horizon predictor of price moves: a large positive `OFI` reflects net
/// buying pressure at the top of book, a large negative `OFI` net selling.
///
/// `Input = OrderBook`. Each `update` is O(1) (only the best levels are read).
/// The first snapshot only seeds the reference quotes and emits `None`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Level, OrderBook, OrderFlowImbalance};
///
/// let mut ofi = OrderFlowImbalance::new(20).unwrap();
/// let book = OrderBook::new(
/// vec![Level::new(100.0, 5.0).unwrap()],
/// vec![Level::new(101.0, 4.0).unwrap()],
/// )
/// .unwrap();
/// assert_eq!(ofi.update(book), None); // first snapshot seeds the reference
/// ```
#[derive(Debug, Clone)]
pub struct OrderFlowImbalance {
period: usize,
prev: Option<(f64, f64, f64, f64)>, // (bid_px, bid_sz, ask_px, ask_sz)
window: VecDeque<f64>,
sum: f64,
}
impl OrderFlowImbalance {
/// Construct a new Order Flow Imbalance over the given snapshot 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,
prev: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for OrderFlowImbalance {
type Input = OrderBook;
type Output = f64;
fn update(&mut self, book: OrderBook) -> Option<f64> {
// A book with no levels on a side carries no best-level information.
let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) else {
return None;
};
let curr = (bid.price, bid.size, ask.price, ask.size);
let Some((pb_px, pb_sz, pa_px, pa_sz)) = self.prev else {
self.prev = Some(curr);
return None;
};
self.prev = Some(curr);
let (bid_px, bid_sz, ask_px, ask_sz) = curr;
// Bid pressure: size added when the bid does not retreat, minus size
// removed when the bid does not advance.
let delta_b = f64::from(u8::from(bid_px >= pb_px)) * bid_sz
- f64::from(u8::from(bid_px <= pb_px)) * pb_sz;
// Ask pressure: size added when the ask does not advance, minus size
// removed when the ask does not retreat.
let delta_a = f64::from(u8::from(ask_px <= pa_px)) * ask_sz
- f64::from(u8::from(ask_px >= pa_px)) * pa_sz;
let event = delta_b - delta_a;
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum -= old;
}
self.window.push_back(event);
self.sum += event;
if self.window.len() < self.period {
return None;
}
Some(self.sum)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
// One snapshot seeds the reference quotes, then `period` events fill the
// window.
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"OrderFlowImbalance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Level;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn book(bid_px: f64, bid_sz: f64, ask_px: f64, ask_sz: f64) -> OrderBook {
OrderBook::new(
vec![Level::new(bid_px, bid_sz).unwrap()],
vec![Level::new(ask_px, ask_sz).unwrap()],
)
.unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(OrderFlowImbalance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let ofi = OrderFlowImbalance::new(20).unwrap();
assert_eq!(ofi.period(), 20);
assert_eq!(ofi.warmup_period(), 21);
assert_eq!(ofi.name(), "OrderFlowImbalance");
assert!(!ofi.is_ready());
}
#[test]
fn first_snapshot_is_none() {
let mut ofi = OrderFlowImbalance::new(2).unwrap();
assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None);
}
#[test]
fn empty_book_side_is_none() {
// A book with no levels on a side (only constructible via
// `new_unchecked`, since `OrderBook::new` rejects empty sides) carries
// no best-level information and emits `None` without advancing state.
let mut ofi = OrderFlowImbalance::new(2).unwrap();
let empty = OrderBook::new_unchecked(vec![], vec![]);
assert_eq!(ofi.update(empty), None);
// A real book afterwards still seeds the reference (state untouched).
assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None);
}
#[test]
fn rising_bid_adds_positive_flow() {
// period 1. Reference book, then the bid lifts (price up) with size 6:
// Δᵇ = 6 (bid_px > prev), Δᵃ = (ask unchanged px=) ask_sz - ask_sz = 0
// when ask is identical => e = 6.
let mut ofi = OrderFlowImbalance::new(1).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
let out = ofi.update(book(100.5, 6.0, 101.0, 4.0)).unwrap();
assert_relative_eq!(out, 6.0, epsilon = 1e-12);
}
#[test]
fn falling_bid_adds_negative_flow() {
// The bid drops in price: Δᵇ = prev_bid_sz (bid_px < prev) = 5,
// ask identical => Δᵃ = 0 => e = 5.
let mut ofi = OrderFlowImbalance::new(1).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
let out = ofi.update(book(99.5, 3.0, 101.0, 4.0)).unwrap();
assert_relative_eq!(out, -5.0, epsilon = 1e-12);
}
#[test]
fn rolling_sum_accumulates() {
let mut ofi = OrderFlowImbalance::new(2).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
let a = ofi.update(book(100.5, 6.0, 101.0, 4.0)); // warming (1 event)
assert!(a.is_none());
let b = ofi.update(book(101.0, 2.0, 101.5, 4.0)).unwrap(); // 2 events
// Second event: bid_px 101 > 100.5 => Δᵇ = 2; ask_px 101.5 > 101 =>
// Δᵃ = prev_ask_sz = 4 => e2 = 2 (4) = 6. Sum = 6 + 6 = 12.
assert_relative_eq!(b, 12.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut ofi = OrderFlowImbalance::new(2).unwrap();
ofi.update(book(100.0, 5.0, 101.0, 4.0));
ofi.update(book(100.5, 6.0, 101.0, 4.0));
ofi.update(book(101.0, 2.0, 101.5, 4.0));
assert!(ofi.is_ready());
ofi.reset();
assert!(!ofi.is_ready());
assert_eq!(ofi.update(book(100.0, 5.0, 101.0, 4.0)), None);
}
#[test]
fn batch_equals_streaming() {
let books: Vec<OrderBook> = (0..30)
.map(|i| {
let f = f64::from(i);
book(
100.0 + (f * 0.3).sin(),
5.0 + (f * 0.5).cos().abs(),
101.0 + (f * 0.3).sin(),
4.0 + (f * 0.4).sin().abs(),
)
})
.collect();
let batch = OrderFlowImbalance::new(10).unwrap().batch(&books);
let mut b = OrderFlowImbalance::new(10).unwrap();
let streamed: Vec<_> = books.iter().map(|x| b.update(x.clone())).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,240 @@
//! Realized Volatility from the sum of squared log returns.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Realized Volatility — the square root of the sum of squared log returns over
/// the trailing `period` bars.
///
/// ```text
/// r_t = ln(price_t / price_{t1})
/// RV = √( Σ r_t² over the last `period` returns )
/// ```
///
/// Unlike [`HistoricalVolatility`](crate::HistoricalVolatility) — which reports
/// the *annualised sample standard deviation* of log returns (mean-centred,
/// divided by `n 1`, scaled by `√trading_periods` and ×100) — realized
/// volatility is the **raw, un-centred, un-annualised** quadratic variation
/// estimator used in high-frequency econometrics. It makes no Gaussian
/// assumption and no mean subtraction: it simply accumulates squared returns,
/// which converges to the integrated variance of the price path as the
/// sampling frequency rises. Multiply by `√trading_periods` yourself if an
/// annual figure is wanted.
///
/// Non-finite and non-positive prices are ignored (the log return would be
/// undefined): the tick is dropped, state is left untouched, and the last
/// value is returned.
///
/// Each `update` is O(1): a running sum of squared returns is maintained over
/// the rolling window.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RealizedVolatility};
///
/// let mut indicator = RealizedVolatility::new(20).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RealizedVolatility {
period: usize,
prev_price: Option<f64>,
/// Rolling window of the last `period` log returns.
window: VecDeque<f64>,
sum_sq: f64,
last: Option<f64>,
}
impl RealizedVolatility {
/// Construct a new realized-volatility indicator.
///
/// `period` is the number of squared log returns accumulated in the 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,
prev_price: None,
window: VecDeque::with_capacity(period),
sum_sq: 0.0,
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RealizedVolatility {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Non-finite / non-positive prices are skipped: `ln(input / prev)` is
// undefined, so the tick must not enter the return window.
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
// `prev` came from `self.prev_price`, gated by the guard above, so it is
// finite and positive — the log return is always well-defined.
let r = (input / prev).ln();
if self.window.len() == self.period {
let old = self.window.pop_front().expect("window is non-empty");
self.sum_sq -= old * old;
}
self.window.push_back(r);
self.sum_sq += r * r;
if self.window.len() < self.period {
return None;
}
// Floating-point subtraction in the rolling sum can leave a tiny
// negative residual when every return is ~0; clamp before the sqrt.
let rv = self.sum_sq.max(0.0).sqrt();
self.last = Some(rv);
Some(rv)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
self.sum_sq = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first log return needs a previous price, then the window fills.
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"RealizedVolatility"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(RealizedVolatility::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let rv = RealizedVolatility::new(20).unwrap();
assert_eq!(rv.period(), 20);
assert_eq!(rv.warmup_period(), 21);
assert_eq!(rv.name(), "RealizedVolatility");
assert!(!rv.is_ready());
}
#[test]
fn first_emission_at_warmup_period() {
let mut rv = RealizedVolatility::new(5).unwrap();
let out = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
for v in out.iter().take(5) {
assert!(v.is_none());
}
assert!(out[5].is_some());
}
#[test]
fn known_value() {
// Two equal +10% steps: r = ln(1.1) each. RV = √(2·ln(1.1)²).
let mut rv = RealizedVolatility::new(2).unwrap();
let out = rv.batch(&[100.0, 110.0, 121.0]);
let expected = (2.0 * (1.1_f64).ln().powi(2)).sqrt();
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut rv = RealizedVolatility::new(10).unwrap();
for v in rv.batch(&[100.0; 40]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut rv = RealizedVolatility::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in rv.batch(&prices).into_iter().flatten() {
assert!(
v >= 0.0,
"realized volatility must be non-negative, got {v}"
);
}
}
#[test]
fn ignores_non_finite_input() {
let mut rv = RealizedVolatility::new(5).unwrap();
let out = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(rv.update(f64::NAN), last);
assert_eq!(rv.update(f64::INFINITY), last);
}
#[test]
fn skips_non_positive_prices() {
let mut rv = RealizedVolatility::new(5).unwrap();
let warmup = rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let baseline = warmup.last().copied().flatten().expect("warmed up");
assert_eq!(rv.update(-5.0), Some(baseline));
assert_eq!(rv.update(0.0), Some(baseline));
// State untouched: a clone advanced by the same real tick agrees.
let mut control = rv.clone();
let after = rv.update(21.0).expect("ready");
assert_eq!(control.update(21.0).expect("ready"), after);
}
#[test]
fn reset_clears_state() {
let mut rv = RealizedVolatility::new(5).unwrap();
rv.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(rv.is_ready());
rv.reset();
assert!(!rv.is_ready());
assert_eq!(rv.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
.collect();
let batch = RealizedVolatility::new(20).unwrap().batch(&prices);
let mut b = RealizedVolatility::new(20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,307 @@
//! Regime Label — volatility-quantile classification of the current bar.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Regime Label — a discrete `{1, 0, +1}` classification of the current
/// volatility regime by where the latest rolling volatility falls within its
/// own recent distribution.
///
/// ```text
/// σₜ = sample stddev of the last `vol_period` log returns
/// q1,q3 = 25th / 75th percentile of the last `lookback` σ readings
/// label = 1 if σₜ < q1 (calm regime)
/// +1 if σₜ > q3 (stressed regime)
/// 0 otherwise (normal regime)
/// ```
///
/// This is the canonical rolling-volatility-quantile regime split: rather than
/// thresholding absolute volatility (which is not comparable across instruments
/// or epochs), it asks whether *today's* volatility is unusually low or high
/// **relative to its own recent history**. `1` is a calm regime, `+1` a
/// stressed / high-volatility regime, `0` the normal middle. Because the latest
/// reading is included in its own reference window, a freshly elevated
/// volatility prints `+1` until the window catches up to the new level — it
/// flags the *transition*, not just the absolute level. When the recent
/// volatilities are all equal (`q1 == q3`, e.g. a constant drift) there is no
/// spread to classify against and the label is `0`.
///
/// Each `update` is `O(vol_period + lookback log lookback)`. Non-finite and
/// non-positive prices are ignored.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RegimeLabel};
///
/// let mut indicator = RegimeLabel::new(5, 20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.5).sin());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RegimeLabel {
vol_period: usize,
lookback: usize,
prev_price: Option<f64>,
/// Trailing window of the last `vol_period` log returns.
ret_window: VecDeque<f64>,
ret_sum: f64,
ret_sum_sq: f64,
/// Trailing window of the last `lookback` volatility readings.
vol_window: VecDeque<f64>,
/// Reusable scratch buffer for the quantile sort.
scratch: Vec<f64>,
last: Option<f64>,
}
impl RegimeLabel {
/// Construct a new Regime Label classifier.
///
/// `vol_period` is the window for the rolling volatility; `lookback` is the
/// window of volatility readings whose quartiles set the regime bands.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `vol_period < 2` (the sample standard
/// deviation needs at least two returns) or if `lookback < 2` (the quartile
/// split needs at least two readings).
pub fn new(vol_period: usize, lookback: usize) -> Result<Self> {
if vol_period < 2 {
return Err(Error::InvalidPeriod {
message: "regime label needs vol_period >= 2",
});
}
if lookback < 2 {
return Err(Error::InvalidPeriod {
message: "regime label needs lookback >= 2",
});
}
Ok(Self {
vol_period,
lookback,
prev_price: None,
ret_window: VecDeque::with_capacity(vol_period),
ret_sum: 0.0,
ret_sum_sq: 0.0,
vol_window: VecDeque::with_capacity(lookback),
scratch: Vec::with_capacity(lookback),
last: None,
})
}
/// Configured `(vol_period, lookback)`.
pub const fn params(&self) -> (usize, usize) {
(self.vol_period, self.lookback)
}
}
impl Indicator for RegimeLabel {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.last;
}
let Some(prev) = self.prev_price else {
self.prev_price = Some(input);
return None;
};
self.prev_price = Some(input);
let r = (input / prev).ln();
// Roll the return window and its running moments.
if self.ret_window.len() == self.vol_period {
let old = self.ret_window.pop_front().expect("non-empty");
self.ret_sum -= old;
self.ret_sum_sq -= old * old;
}
self.ret_window.push_back(r);
self.ret_sum += r;
self.ret_sum_sq += r * r;
if self.ret_window.len() < self.vol_period {
return None;
}
let n = self.vol_period as f64;
let mean = self.ret_sum / n;
let var = ((self.ret_sum_sq - n * mean * mean) / (n - 1.0)).max(0.0);
let vol = var.sqrt();
// Roll the volatility window.
if self.vol_window.len() == self.lookback {
self.vol_window.pop_front();
}
self.vol_window.push_back(vol);
if self.vol_window.len() < self.lookback {
return None;
}
// Classify the latest volatility against the quartiles of the window.
self.scratch.clear();
self.scratch.extend(self.vol_window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
let q1 = quantile_sorted(&self.scratch, 0.25);
let q3 = quantile_sorted(&self.scratch, 0.75);
let label = if vol < q1 {
-1.0
} else if vol > q3 {
1.0
} else {
0.0
};
self.last = Some(label);
Some(label)
}
fn reset(&mut self) {
self.prev_price = None;
self.ret_window.clear();
self.ret_sum = 0.0;
self.ret_sum_sq = 0.0;
self.vol_window.clear();
self.scratch.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
// One price seeds `prev`, `vol_period` returns yield the first vol, then
// `lookback` vols fill the regime window.
self.vol_period + self.lookback
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"RegimeLabel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_periods() {
assert!(matches!(
RegimeLabel::new(1, 20),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RegimeLabel::new(5, 1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let rl = RegimeLabel::new(5, 20).unwrap();
assert_eq!(rl.params(), (5, 20));
assert_eq!(rl.warmup_period(), 25);
assert_eq!(rl.name(), "RegimeLabel");
assert!(!rl.is_ready());
}
#[test]
fn detects_stressed_regime_on_volatility_spike() {
// Calm warmup, then a burst of large moves: the elevated volatility
// prints +1 while the lookback window still holds the calm readings.
let mut rl = RegimeLabel::new(4, 8).unwrap();
let mut prices: Vec<f64> = (0..24)
.map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 0.2)
.collect();
let mut base = *prices.last().unwrap();
for i in 0..8 {
base *= if i % 2 == 0 { 1.08 } else { 0.93 };
prices.push(base);
}
let out = rl.batch(&prices);
assert!(
out.iter().flatten().any(|&v| v == 1.0),
"expected a stressed (+1) regime label"
);
}
#[test]
fn detects_calm_regime_after_volatility_drop() {
// Volatile warmup, then a calm tail: the depressed volatility prints -1.
let mut rl = RegimeLabel::new(4, 8).unwrap();
let mut prices: Vec<f64> = Vec::new();
let mut base = 100.0;
for i in 0..24 {
base *= if i % 2 == 0 { 1.05 } else { 0.96 };
prices.push(base);
}
for i in 0..12 {
prices.push(base + (f64::from(i) * 0.7).sin() * 0.05);
}
let out = rl.batch(&prices);
assert!(
out.iter().flatten().any(|&v| v == -1.0),
"expected a calm (-1) regime label"
);
}
#[test]
fn zero_volatility_is_neutral() {
// A constant price has exactly-zero returns => zero volatility on every
// window => q1 == q3 == 0 => neutral 0 throughout. (A geometric drift is
// *conceptually* constant-vol too, but floating-point rounding of the
// log returns leaves ~1e-16 dispersion, so the exactly-flat series is
// the clean way to pin the q1 == q3 branch.)
let mut rl = RegimeLabel::new(4, 8).unwrap();
for v in rl.batch(&[100.0; 40]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn output_is_ternary() {
let mut rl = RegimeLabel::new(5, 20).unwrap();
let prices: Vec<f64> = (0..300)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * (1.0 + (f64::from(i) * 0.05).sin() * 5.0))
.collect();
for v in rl.batch(&prices).into_iter().flatten() {
assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}");
}
}
#[test]
fn ignores_non_finite_and_non_positive() {
let mut rl = RegimeLabel::new(4, 6).unwrap();
let prices: Vec<f64> = (0..40)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 2.0)
.collect();
let out = rl.batch(&prices);
let last = *out.last().unwrap();
assert!(last.is_some());
assert_eq!(rl.update(f64::NAN), last);
assert_eq!(rl.update(-1.0), last);
assert_eq!(rl.update(0.0), last);
}
#[test]
fn reset_clears_state() {
let mut rl = RegimeLabel::new(4, 6).unwrap();
rl.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(rl.is_ready());
rl.reset();
assert!(!rl.is_ready());
assert_eq!(rl.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=160)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 4.0)
.collect();
let batch = RegimeLabel::new(5, 20).unwrap().batch(&prices);
let mut b = RegimeLabel::new(5, 20).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,210 @@
//! Roll Measure — effective spread implied by serial covariance of price changes.
use std::collections::VecDeque;
use crate::microstructure::Trade;
use crate::traits::Indicator;
use crate::{Error, Result};
/// Roll Measure — the effective bid-ask spread implied by the negative
/// first-order serial covariance of trade-price changes (Roll, 1984).
///
/// ```text
/// Δpₜ = priceₜ priceₜ₋₁
/// γ = sample lag-1 autocovariance of Δp over the last `period` changes
/// spread = 2 · √(−γ) if γ < 0, else 0
/// ```
///
/// Roll's insight: in a frictionless market price changes are serially
/// uncorrelated, but the *bid-ask bounce* — trades alternating between buying at
/// the ask and selling at the bid — induces a **negative** autocovariance whose
/// magnitude pins the spread. The measure recovers an effective spread from
/// trade prices alone, with no quote data. When the serial covariance is
/// non-negative (a trending or frictionless tape) the model implies no spread
/// and the indicator returns `0`.
///
/// `Input = Trade` (only the price is used). Each `update` is `O(period)`: the
/// autocovariance is recomputed from the window of price changes.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, RollMeasure};
///
/// let mut roll = RollMeasure::new(20).unwrap();
/// let mut last = None;
/// // A clean bid-ask bounce of ±0.5 around 100 implies a spread near 1.0.
/// for i in 0..40 {
/// let price = if i % 2 == 0 { 100.0 } else { 101.0 };
/// last = roll.update(Trade::new(price, 1.0, Side::Buy, 0).unwrap());
/// }
/// assert!(last.unwrap() > 0.0);
/// ```
#[derive(Debug, Clone)]
pub struct RollMeasure {
period: usize,
prev_price: Option<f64>,
window: VecDeque<f64>,
}
impl RollMeasure {
/// Construct a new Roll Measure over the given window of price changes.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the lag-1
/// autocovariance needs at least two consecutive change pairs.
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "Roll measure needs period >= 3",
});
}
Ok(Self {
period,
prev_price: None,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollMeasure {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let Some(prev) = self.prev_price else {
self.prev_price = Some(trade.price);
return None;
};
let change = trade.price - prev;
self.prev_price = Some(trade.price);
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(change);
if self.window.len() < self.period {
return None;
}
// Sample lag-1 autocovariance of the price changes over the window.
let changes: Vec<f64> = self.window.iter().copied().collect();
let count = changes.len() as f64;
let mean = changes.iter().sum::<f64>() / count;
let pairs = (changes.len() - 1) as f64;
let mut cov = 0.0;
for pair in changes.windows(2) {
cov += (pair[0] - mean) * (pair[1] - mean);
}
cov /= pairs;
let spread = if cov < 0.0 { 2.0 * (-cov).sqrt() } else { 0.0 };
Some(spread)
}
fn reset(&mut self) {
self.prev_price = None;
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollMeasure"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(price: f64) -> Trade {
Trade::new(price, 1.0, Side::Buy, 0).unwrap()
}
#[test]
fn rejects_period_below_three() {
assert!(matches!(
RollMeasure::new(2),
Err(Error::InvalidPeriod { .. })
));
assert!(RollMeasure::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let roll = RollMeasure::new(20).unwrap();
assert_eq!(roll.period(), 20);
assert_eq!(roll.warmup_period(), 21);
assert_eq!(roll.name(), "RollMeasure");
assert!(!roll.is_ready());
}
#[test]
fn bid_ask_bounce_implies_spread() {
// Prices bounce 100/101 => Δp alternates +1/-1 => mean 0, lag-1
// autocov = -5/(6-1) = -1 over a 6-change window => spread = 2.
let mut roll = RollMeasure::new(6).unwrap();
let prices: Vec<Trade> = (0..20)
.map(|i| trade(if i % 2 == 0 { 100.0 } else { 101.0 }))
.collect();
let last = roll.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 2.0, epsilon = 1e-12);
}
#[test]
fn trending_prices_imply_no_spread() {
// Monotone prices => constant Δp => zero-centred deviations => cov 0
// => spread 0.
let mut roll = RollMeasure::new(6).unwrap();
let prices: Vec<Trade> = (0..20).map(|i| trade(100.0 + f64::from(i))).collect();
for v in roll.batch(&prices).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut roll = RollMeasure::new(20).unwrap();
let prices: Vec<Trade> = (0..200)
.map(|i| trade(100.0 + (f64::from(i) * 0.7).sin() * 2.0))
.collect();
for v in roll.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "spread must be non-negative, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut roll = RollMeasure::new(5).unwrap();
for i in 0..20 {
roll.update(trade(100.0 + f64::from(i % 2)));
}
assert!(roll.is_ready());
roll.reset();
assert!(!roll.is_ready());
assert_eq!(roll.update(trade(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<Trade> = (0..80)
.map(|i| trade(100.0 + (f64::from(i) * 0.6).sin() * 3.0))
.collect();
let batch = RollMeasure::new(14).unwrap().batch(&prices);
let mut b = RollMeasure::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,186 @@
//! Rolling Interquartile Range (IQR) over a trailing window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Interquartile Range of the last `period` values: `Q3 Q1`.
///
/// ```text
/// IQR = quantile(0.75) quantile(0.25)
/// ```
///
/// The IQR is the width of the central 50% of the window — the spread between
/// the third and first quartiles. It is a robust dispersion measure: unlike the
/// standard deviation it ignores the extreme tails entirely, so a single spike
/// barely moves it. That makes it the natural scale for outlier rules (the
/// classic *Tukey fence* flags points more than `1.5 · IQR` beyond a quartile)
/// and for volatility-regime splits that must not be dominated by one shock.
///
/// Both quartiles use the type-7 / NumPy-default linearly-interpolated
/// definition, identical to [`RollingQuantile`](crate::RollingQuantile). Each
/// `update` is O(period log period): the window is copied into a scratch buffer
/// and sorted once.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingIqr};
///
/// let mut indicator = RollingIqr::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RollingIqr {
period: usize,
window: VecDeque<f64>,
/// Reusable scratch buffer to avoid allocating per `update`.
scratch: Vec<f64>,
}
impl RollingIqr {
/// Construct a new rolling IQR with the given period.
///
/// # 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),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingIqr {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
let q1 = quantile_sorted(&self.scratch, 0.25);
let q3 = quantile_sorted(&self.scratch, 0.75);
Some(q3 - q1)
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollingIqr"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(RollingIqr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let iqr = RollingIqr::new(14).unwrap();
assert_eq!(iqr.period(), 14);
assert_eq!(iqr.warmup_period(), 14);
assert_eq!(iqr.name(), "RollingIqr");
assert!(!iqr.is_ready());
}
#[test]
fn reference_value() {
// sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20,
// Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20.
let mut iqr = RollingIqr::new(5).unwrap();
let out = iqr.batch(&[50.0, 40.0, 30.0, 20.0, 10.0]);
assert_relative_eq!(out[4].unwrap(), 20.0, epsilon = 1e-12);
}
#[test]
fn constant_series_yields_zero() {
let mut iqr = RollingIqr::new(8).unwrap();
for v in iqr.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_is_non_negative() {
let mut iqr = RollingIqr::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in iqr.batch(&prices).into_iter().flatten() {
assert!(v >= 0.0, "IQR must be non-negative, got {v}");
}
}
#[test]
fn ignores_single_extreme_outlier() {
// 19 tightly-clustered values plus one huge spike: the central 50%
// is unaffected, so the IQR stays small (well below the spike scale).
let mut iqr = RollingIqr::new(20).unwrap();
let mut prices = vec![5.0; 19];
prices.push(10_000.0);
let last = iqr.batch(&prices).into_iter().flatten().last().unwrap();
assert!(last < 1.0, "spike leaked into IQR: {last}");
}
#[test]
fn reset_clears_state() {
let mut iqr = RollingIqr::new(5).unwrap();
iqr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(iqr.is_ready());
iqr.reset();
assert!(!iqr.is_ready());
assert_eq!(iqr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = RollingIqr::new(14).unwrap().batch(&prices);
let mut b = RollingIqr::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,191 @@
//! Rolling Percentile Rank of the latest value within its trailing window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Percentile rank of the most-recent value within the last `period` values,
/// in `[0, 100]`.
///
/// ```text
/// rank = 100 · (#below + 0.5 · #equal) / period
/// ```
///
/// where `#below` counts window values strictly less than the current value and
/// `#equal` counts those equal to it (including the current value itself). This
/// is the "mean" method of `percentileofscore`: ties are split symmetrically,
/// so a flat window scores exactly `50`, the strict window maximum scores just
/// under `100`, and the strict minimum just over `0`.
///
/// Percentile rank turns any series into a bounded, self-normalising oscillator:
/// "where does today sit relative to its own recent history" — high readings
/// mark stretched extremes, mid readings mark the typical range. It is the
/// scale-free cousin of the z-score that makes no distributional assumption.
///
/// Each `update` is O(period): one linear pass tallies the comparisons.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingPercentileRank};
///
/// let mut indicator = RollingPercentileRank::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// // A strictly rising series puts the newest value near the top.
/// assert!(last.unwrap() > 90.0);
/// ```
#[derive(Debug, Clone)]
pub struct RollingPercentileRank {
period: usize,
window: VecDeque<f64>,
}
impl RollingPercentileRank {
/// Construct a new rolling percentile rank with the given period.
///
/// # 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),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingPercentileRank {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let mut below = 0_usize;
let mut equal = 0_usize;
for &x in &self.window {
if x < value {
below += 1;
} else if x == value {
equal += 1;
}
}
let score = (below as f64 + 0.5 * equal as f64) / self.period as f64 * 100.0;
Some(score)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollingPercentileRank"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
RollingPercentileRank::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let pr = RollingPercentileRank::new(14).unwrap();
assert_eq!(pr.period(), 14);
assert_eq!(pr.warmup_period(), 14);
assert_eq!(pr.name(), "RollingPercentileRank");
assert!(!pr.is_ready());
}
#[test]
fn flat_window_scores_fifty() {
// All values equal: #below = 0, #equal = period → 0.5 → 50.
let mut pr = RollingPercentileRank::new(10).unwrap();
for v in pr.batch(&[7.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 50.0, epsilon = 1e-12);
}
}
#[test]
fn current_is_strict_maximum() {
// Window [1,2,3,4,5], current = 5: #below = 4, #equal = 1.
// (4 + 0.5) / 5 * 100 = 90.
let mut pr = RollingPercentileRank::new(5).unwrap();
let out = pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_relative_eq!(out[4].unwrap(), 90.0, epsilon = 1e-12);
}
#[test]
fn current_is_strict_minimum() {
// Window [5,4,3,2,1], current = 1: #below = 0, #equal = 1.
// (0 + 0.5) / 5 * 100 = 10.
let mut pr = RollingPercentileRank::new(5).unwrap();
let out = pr.batch(&[5.0, 4.0, 3.0, 2.0, 1.0]);
assert_relative_eq!(out[4].unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn output_within_bounds() {
let mut pr = RollingPercentileRank::new(20).unwrap();
let prices: Vec<f64> = (1..=200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
.collect();
for v in pr.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "out of bounds: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut pr = RollingPercentileRank::new(5).unwrap();
pr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(pr.is_ready());
pr.reset();
assert!(!pr.is_ready());
assert_eq!(pr.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = RollingPercentileRank::new(14).unwrap().batch(&prices);
let mut b = RollingPercentileRank::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,230 @@
//! Rolling Quantile over a trailing window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// The `quantile`-th quantile of the last `period` values, with linear
/// interpolation between order statistics.
///
/// ```text
/// h = (period 1) · quantile
/// lower = ⌊h⌋
/// result = sorted[lower] + (h lower) · (sorted[lower + 1] sorted[lower])
/// ```
///
/// This is the type-7 / NumPy-default `quantile` definition: `quantile = 0.0`
/// returns the window minimum, `0.5` the median, `1.0` the maximum, and
/// fractional values interpolate linearly between the bracketing order
/// statistics. Rolling quantiles are the building block for distribution-aware
/// thresholds — a price sitting above its rolling 90th-percentile, a volatility
/// regime split at the 25th/75th percentiles, robust band edges that ignore the
/// tails.
///
/// Each `update` is O(period log period): the window is copied into a scratch
/// buffer and sorted with total ordering (NaN-safe).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingQuantile};
///
/// // Rolling median of the last 5 values.
/// let mut indicator = RollingQuantile::new(5, 0.5).unwrap();
/// let out = indicator.update(1.0);
/// assert!(out.is_none()); // warming up
/// ```
#[derive(Debug, Clone)]
pub struct RollingQuantile {
period: usize,
quantile: f64,
window: VecDeque<f64>,
/// Reusable scratch buffer to avoid allocating per `update`.
scratch: Vec<f64>,
}
impl RollingQuantile {
/// Construct a new rolling quantile.
///
/// `quantile` selects the order statistic in `[0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `quantile` is not a finite value in
/// `[0.0, 1.0]`.
pub fn new(period: usize, quantile: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !quantile.is_finite() || !(0.0..=1.0).contains(&quantile) {
return Err(Error::InvalidParameter {
message: "rolling quantile must be a finite value in [0.0, 1.0]",
});
}
Ok(Self {
period,
quantile,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured quantile in `[0.0, 1.0]`.
pub const fn quantile(&self) -> f64 {
self.quantile
}
}
/// Linearly-interpolated quantile of a sorted, non-empty slice (type-7).
pub(crate) fn quantile_sorted(sorted: &[f64], quantile: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let h = (n - 1) as f64 * quantile;
let lower = h.floor();
let idx = lower as usize;
// `idx <= n - 1`: when `quantile == 1.0`, `h == n - 1` and `idx == n - 1`,
// so the interpolation neighbour would be out of bounds — return the top.
if idx >= n - 1 {
return sorted[n - 1];
}
let frac = h - lower;
sorted[idx] + frac * (sorted[idx + 1] - sorted[idx])
}
impl Indicator for RollingQuantile {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
Some(quantile_sorted(&self.scratch, self.quantile))
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"RollingQuantile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
RollingQuantile::new(0, 0.5),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_out_of_range_quantile() {
assert!(matches!(
RollingQuantile::new(5, -0.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
RollingQuantile::new(5, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
RollingQuantile::new(5, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let q = RollingQuantile::new(14, 0.25).unwrap();
assert_eq!(q.period(), 14);
assert_relative_eq!(q.quantile(), 0.25, epsilon = 1e-12);
assert_eq!(q.warmup_period(), 14);
assert_eq!(q.name(), "RollingQuantile");
assert!(!q.is_ready());
}
#[test]
fn median_of_window() {
// Window [5, 1, 3, 2, 4] sorted [1,2,3,4,5] → median 3.
let mut q = RollingQuantile::new(5, 0.5).unwrap();
let out = q.batch(&[5.0, 1.0, 3.0, 2.0, 4.0]);
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn min_and_max_quantiles() {
let prices = [5.0, 1.0, 3.0, 2.0, 4.0];
let lo = RollingQuantile::new(5, 0.0).unwrap().batch(&prices)[4].unwrap();
let hi = RollingQuantile::new(5, 1.0).unwrap().batch(&prices)[4].unwrap();
assert_relative_eq!(lo, 1.0, epsilon = 1e-12);
assert_relative_eq!(hi, 5.0, epsilon = 1e-12);
}
#[test]
fn interpolated_quantile() {
// sorted [10,20,30,40]: q=0.25 → h=(4-1)*0.25=0.75 → 10 + 0.75*(20-10)=17.5.
let mut q = RollingQuantile::new(4, 0.25).unwrap();
let out = q.batch(&[40.0, 30.0, 20.0, 10.0]);
assert_relative_eq!(out[3].unwrap(), 17.5, epsilon = 1e-12);
}
#[test]
fn single_period_returns_value() {
// period 1: window holds one value; quantile of a singleton is itself.
let mut q = RollingQuantile::new(1, 0.3).unwrap();
assert_relative_eq!(q.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut q = RollingQuantile::new(5, 0.5).unwrap();
q.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(q.is_ready());
q.reset();
assert!(!q.is_ready());
assert_eq!(q.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = RollingQuantile::new(14, 0.75).unwrap().batch(&prices);
let mut b = RollingQuantile::new(14, 0.75).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,251 @@
//! AR(1) autoregression coefficient of the spread of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// First-order autoregression coefficient `ρ` of the spread `a b`.
///
/// Each `update` takes one `(a, b)` price pair and forms the spread
/// `sₜ = aₜ bₜ`. Over the trailing window of `period` spreads the indicator
/// fits the discrete AR(1) model by ordinary least squares of the level on its
/// own lag:
///
/// ```text
/// sₜ = ρ · sₜ₋₁ + c + εₜ
/// ρ = cov(sₜ₋₁, sₜ) / var(sₜ₋₁)
/// ```
///
/// `ρ` is the direct measure of cointegration / mean-reversion strength of the
/// pair:
///
/// - `ρ` near `0` — the spread snaps back to its mean almost instantly (very
/// strong mean reversion).
/// - `ρ` near `1` — the spread behaves like a random walk (a unit root: no
/// reliable reversion, the pair is *not* cointegrated).
/// - `ρ > 1` — the spread is explosive (diverging).
///
/// This is the complement of [`OuHalfLife`](crate::OuHalfLife): the OU half-life
/// is `ln(2) / ln(ρ)` for `0 < ρ < 1`, but `ρ` itself is the raw, unbounded
/// stationarity statistic many pairs-trading screens threshold on directly
/// (e.g. "trade only pairs with `ρ < 0.9`"). When the spread is flat over the
/// window (`var(sₜ₋₁) = 0`) the regression slope is undefined and the indicator
/// returns `0`.
///
/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
/// running geometry.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SpreadAr1Coefficient};
///
/// let mut ar1 = SpreadAr1Coefficient::new(40).unwrap();
/// let mut last = None;
/// for t in 0..120 {
/// let b = 100.0 + f64::from(t);
/// // `a` hugs `b` with a fast mean-reverting wobble ⇒ ρ well below 1.
/// let a = b + 2.0 * (f64::from(t) * 0.9).sin();
/// last = ar1.update((a, b));
/// }
/// let rho = last.unwrap();
/// assert!(rho > 0.0 && rho < 1.0);
/// ```
#[derive(Debug, Clone)]
pub struct SpreadAr1Coefficient {
period: usize,
window: VecDeque<f64>,
}
impl SpreadAr1Coefficient {
/// Construct a new AR(1) spread-coefficient estimator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
/// needs at least two `(level, next)` observations (a slope and an
/// intercept).
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "AR(1) spread coefficient needs period >= 3",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window of spreads.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for SpreadAr1Coefficient {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(a - b);
if self.window.len() < self.period {
return None;
}
// OLS slope ρ of the level on its own lag over the window.
let spreads: Vec<f64> = self.window.iter().copied().collect();
let count = (spreads.len() - 1) as f64;
let mut sum_level = 0.0;
let mut sum_next = 0.0;
let mut sum_ll = 0.0;
let mut sum_ln = 0.0;
for pair in spreads.windows(2) {
let level = pair[0];
let next = pair[1];
sum_level += level;
sum_next += next;
sum_ll += level * level;
sum_ln += level * next;
}
let mean_level = sum_level / count;
let mean_next = sum_next / count;
let var_level = sum_ll / count - mean_level * mean_level;
if var_level <= 0.0 {
// Flat spread: the regression has no defined slope.
return Some(0.0);
}
let cov = sum_ln / count - mean_level * mean_next;
Some(cov / var_level)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SpreadAr1Coefficient"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_three() {
assert!(SpreadAr1Coefficient::new(2).is_err());
assert!(SpreadAr1Coefficient::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let ar1 = SpreadAr1Coefficient::new(30).unwrap();
assert_eq!(ar1.period(), 30);
assert_eq!(ar1.warmup_period(), 30);
assert_eq!(ar1.name(), "SpreadAr1Coefficient");
assert!(!ar1.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut ar1 = SpreadAr1Coefficient::new(4).unwrap();
assert_eq!(ar1.update((1.0, 0.0)), None);
assert_eq!(ar1.update((2.0, 0.0)), None);
assert_eq!(ar1.update((3.0, 0.0)), None);
assert!(ar1.update((4.0, 0.0)).is_some());
assert!(ar1.is_ready());
}
#[test]
fn mean_reverting_spread_has_rho_below_one() {
// Fast sinusoidal spread around zero ⇒ stationary ⇒ 0 < ρ < 1.
let pairs: Vec<(f64, f64)> = (0..120)
.map(|t| {
let b = 100.0 + f64::from(t);
let a = b + 2.0 * (f64::from(t) * 0.9).sin();
(a, b)
})
.collect();
let last = SpreadAr1Coefficient::new(40)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 0.0 && last < 1.0, "rho {last}");
}
#[test]
fn random_walk_spread_has_rho_near_one() {
// Spread = a b grows by exactly 1 each bar ⇒ next = level + 1 ⇒
// the OLS slope is exactly 1 (unit root).
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| (2.0 * f64::from(t), f64::from(t)))
.collect();
let last = SpreadAr1Coefficient::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn flat_spread_returns_zero() {
// a b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..30)
.map(|t| (5.0 + f64::from(t), f64::from(t)))
.collect();
let last = SpreadAr1Coefficient::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut ar1 = SpreadAr1Coefficient::new(5).unwrap();
for t in 0..10 {
ar1.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
}
assert!(ar1.is_ready());
ar1.reset();
assert!(!ar1.is_ready());
assert_eq!(ar1.update((1.0, 0.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 50.0 + 0.5 * f64::from(t);
(b + (f64::from(t) * 0.6).sin(), b)
})
.collect();
let batch = SpreadAr1Coefficient::new(25).unwrap().batch(&pairs);
let mut ar1 = SpreadAr1Coefficient::new(25).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| ar1.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,206 @@
//! Trend Label — the sign of the rolling least-squares slope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Trend Label — a discrete `{1, 0, +1}` classification of the local trend from
/// the sign of the ordinary-least-squares slope over the last `period` values.
///
/// ```text
/// slope = Σ (tᵢ t̄)(xᵢ x̄) / Σ (tᵢ t̄)² (regress price on bar index)
/// label = +1 if slope > 0, 1 if slope < 0, 0 if slope == 0
/// ```
///
/// The sign of the regression slope is *scale-invariant* — it does not depend on
/// the nominal price level — which makes it a clean, comparable trend state
/// across instruments. `+1` marks a rising regression line, `1` a falling one,
/// and `0` a perfectly flat window. It is the discrete companion to
/// [`LinRegSlope`](crate::LinRegSlope) (which returns the continuous slope): use
/// the label when a feature pipeline wants a categorical trend direction and
/// keys any magnitude / dead-band tuning on the raw slope itself.
///
/// Each `update` is `O(period)`: the slope numerator is recomputed from the
/// window. The denominator `Σ(tᵢ t̄)²` is strictly positive for `period ≥ 2`,
/// so the sign is always well-defined.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, TrendLabel};
///
/// let mut indicator = TrendLabel::new(10).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// last = indicator.update(100.0 + f64::from(i)); // strictly rising
/// }
/// assert_eq!(last, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct TrendLabel {
period: usize,
window: VecDeque<f64>,
}
impl TrendLabel {
/// Construct a new Trend Label classifier.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a slope needs at least
/// two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "trend label needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for TrendLabel {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let count = self.period as f64;
let mean_t = (count - 1.0) / 2.0;
let mean_x = self.window.iter().sum::<f64>() / count;
// Slope numerator: Σ (t t̄)(x x̄). The denominator Σ(t t̄)² > 0 for
// period >= 2, so the slope sign equals the numerator sign.
let mut numerator = 0.0;
for (t, &x) in self.window.iter().enumerate() {
numerator += (t as f64 - mean_t) * (x - mean_x);
}
let label = if numerator > 0.0 {
1.0
} else if numerator < 0.0 {
-1.0
} else {
0.0
};
Some(label)
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"TrendLabel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_period_below_two() {
assert!(matches!(
TrendLabel::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(TrendLabel::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let tl = TrendLabel::new(10).unwrap();
assert_eq!(tl.period(), 10);
assert_eq!(tl.warmup_period(), 10);
assert_eq!(tl.name(), "TrendLabel");
assert!(!tl.is_ready());
}
#[test]
fn rising_series_is_plus_one() {
let mut tl = TrendLabel::new(10).unwrap();
let prices: Vec<f64> = (0..20).map(f64::from).collect();
assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(1.0));
}
#[test]
fn falling_series_is_minus_one() {
let mut tl = TrendLabel::new(10).unwrap();
let prices: Vec<f64> = (0..20).map(|i| 100.0 - f64::from(i)).collect();
assert_eq!(tl.batch(&prices).into_iter().flatten().last(), Some(-1.0));
}
#[test]
fn flat_series_is_zero() {
let mut tl = TrendLabel::new(8).unwrap();
for v in tl.batch(&[42.0; 16]).into_iter().flatten() {
assert_eq!(v, 0.0);
}
}
#[test]
fn scale_invariant_sign() {
// Multiplying the whole series by a constant cannot change the trend sign.
let prices: Vec<f64> = (0..30)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let small = TrendLabel::new(12).unwrap().batch(&prices);
let scaled: Vec<f64> = prices.iter().map(|p| p * 1000.0).collect();
let large = TrendLabel::new(12).unwrap().batch(&scaled);
assert_eq!(small, large);
}
#[test]
fn output_is_ternary() {
let mut tl = TrendLabel::new(14).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
for v in tl.batch(&prices).into_iter().flatten() {
assert!(v == -1.0 || v == 0.0 || v == 1.0, "non-ternary label {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tl = TrendLabel::new(5).unwrap();
tl.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(tl.is_ready());
tl.reset();
assert!(!tl.is_ready());
assert_eq!(tl.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let batch = TrendLabel::new(14).unwrap().batch(&prices);
let mut b = TrendLabel::new(14).unwrap();
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+262
View File
@@ -0,0 +1,262 @@
//! VPIN — Volume-Synchronised Probability of Informed Trading.
use std::collections::VecDeque;
use crate::microstructure::{Side, Trade};
use crate::traits::Indicator;
use crate::{Error, Result};
/// VPIN — the Volume-Synchronised Probability of Informed Trading
/// (Easley, López de Prado & O'Hara, 2012).
///
/// Trades are bucketed into equal-volume buckets of size `bucket_volume`. For
/// each completed bucket the order-flow imbalance is the absolute difference
/// between buy and sell volume; VPIN is that imbalance averaged over the last
/// `num_buckets` buckets and normalised by the bucket size:
///
/// ```text
/// VPIN = ( Σ |Vᴮ_τ Vˢ_τ| ) / (num_buckets · bucket_volume)
/// ```
///
/// The aggressor [`Side`] of each [`Trade`] classifies its volume directly (no
/// bulk-volume classification needed). A single trade may span several buckets;
/// its volume is split across bucket boundaries. The result lies in `[0, 1]`:
/// values near `1` signal a strongly one-sided, likely-informed flow (a toxic
/// regime), values near `0` a balanced two-sided flow.
///
/// `Input = Trade`. Because bucket completion is driven by cumulative volume,
/// readiness is data-dependent; [`warmup_period`](Indicator::warmup_period)
/// reports `num_buckets` as the minimum number of trades (one per bucket) and
/// [`is_ready`](Indicator::is_ready) reflects the true bucket count.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, Vpin};
///
/// let mut vpin = Vpin::new(10.0, 2).unwrap();
/// // Two buckets of pure buying => imbalance == bucket size => VPIN 1.
/// let mut last = None;
/// for _ in 0..4 {
/// last = vpin.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap());
/// }
/// assert_eq!(last, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct Vpin {
bucket_volume: f64,
num_buckets: usize,
cur_buy: f64,
cur_sell: f64,
cur_total: f64,
window: VecDeque<f64>,
sum_imbalance: f64,
}
impl Vpin {
/// Construct a new VPIN estimator.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `num_buckets == 0`, or
/// [`Error::InvalidParameter`] if `bucket_volume` is not finite and
/// positive.
pub fn new(bucket_volume: f64, num_buckets: usize) -> Result<Self> {
if num_buckets == 0 {
return Err(Error::PeriodZero);
}
if !bucket_volume.is_finite() || bucket_volume <= 0.0 {
return Err(Error::InvalidParameter {
message: "VPIN bucket_volume must be finite and positive",
});
}
Ok(Self {
bucket_volume,
num_buckets,
cur_buy: 0.0,
cur_sell: 0.0,
cur_total: 0.0,
window: VecDeque::with_capacity(num_buckets),
sum_imbalance: 0.0,
})
}
/// Configured `(bucket_volume, num_buckets)`.
pub const fn params(&self) -> (f64, usize) {
(self.bucket_volume, self.num_buckets)
}
fn close_bucket(&mut self) {
let imbalance = (self.cur_buy - self.cur_sell).abs();
if self.window.len() == self.num_buckets {
let old = self.window.pop_front().expect("window is non-empty");
self.sum_imbalance -= old;
}
self.window.push_back(imbalance);
self.sum_imbalance += imbalance;
self.cur_buy = 0.0;
self.cur_sell = 0.0;
self.cur_total = 0.0;
}
}
impl Indicator for Vpin {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let mut remaining = trade.size;
let buy = trade.side == Side::Buy;
// Distribute the trade's volume across one or more buckets.
while remaining > 0.0 {
let capacity = self.bucket_volume - self.cur_total;
let take = remaining.min(capacity);
if buy {
self.cur_buy += take;
} else {
self.cur_sell += take;
}
self.cur_total += take;
remaining -= take;
if self.cur_total >= self.bucket_volume {
self.close_bucket();
}
}
if self.window.len() < self.num_buckets {
return None;
}
Some(self.sum_imbalance / (self.num_buckets as f64 * self.bucket_volume))
}
fn reset(&mut self) {
self.cur_buy = 0.0;
self.cur_sell = 0.0;
self.cur_total = 0.0;
self.window.clear();
self.sum_imbalance = 0.0;
}
fn warmup_period(&self) -> usize {
self.num_buckets
}
fn is_ready(&self) -> bool {
self.window.len() == self.num_buckets
}
fn name(&self) -> &'static str {
"Vpin"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn trade(size: f64, side: Side) -> Trade {
Trade::new(100.0, size, side, 0).unwrap()
}
#[test]
fn rejects_bad_params() {
assert!(matches!(Vpin::new(10.0, 0), Err(Error::PeriodZero)));
assert!(matches!(
Vpin::new(0.0, 5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Vpin::new(f64::NAN, 5),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let vpin = Vpin::new(10.0, 50).unwrap();
assert_eq!(vpin.params(), (10.0, 50));
assert_eq!(vpin.warmup_period(), 50);
assert_eq!(vpin.name(), "Vpin");
assert!(!vpin.is_ready());
}
#[test]
fn one_sided_flow_is_one() {
// Every bucket is pure buying => |buy - sell| == bucket size => VPIN 1.
let mut vpin = Vpin::new(10.0, 2).unwrap();
let mut last = None;
for _ in 0..4 {
last = vpin.update(trade(5.0, Side::Buy));
}
assert_relative_eq!(last.unwrap(), 1.0, epsilon = 1e-12);
assert!(vpin.is_ready());
}
#[test]
fn balanced_flow_is_zero() {
// Each bucket holds equal buy and sell volume => imbalance 0 => VPIN 0.
let mut vpin = Vpin::new(10.0, 2).unwrap();
let mut last = None;
for _ in 0..4 {
vpin.update(trade(5.0, Side::Buy));
last = vpin.update(trade(5.0, Side::Sell));
}
assert_relative_eq!(last.unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn large_trade_spans_multiple_buckets() {
// A single 25-unit buy fills 2 full buckets (size 10) plus 5 into a
// third. Two buckets close => both pure buy => imbalance 10 each.
let mut vpin = Vpin::new(10.0, 2).unwrap();
let out = vpin.update(trade(25.0, Side::Buy));
// After 2 closed buckets the window is full: VPIN = (10+10)/(2*10) = 1.
assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn output_within_bounds() {
let mut vpin = Vpin::new(7.0, 4).unwrap();
for i in 0..200 {
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
if let Some(v) = vpin.update(trade(1.0 + f64::from(i % 5), side)) {
assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
}
}
}
#[test]
fn zero_size_trade_is_noop() {
let mut vpin = Vpin::new(10.0, 1).unwrap();
assert_eq!(vpin.update(trade(0.0, Side::Buy)), None);
// A full bucket of buying then closes it: VPIN 1.
let out = vpin.update(trade(10.0, Side::Buy));
assert_relative_eq!(out.unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut vpin = Vpin::new(10.0, 2).unwrap();
for _ in 0..4 {
vpin.update(trade(5.0, Side::Buy));
}
assert!(vpin.is_ready());
vpin.reset();
assert!(!vpin.is_ready());
assert_eq!(vpin.update(trade(5.0, Side::Buy)), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..120)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + f64::from(i % 4), side)
})
.collect();
let batch = Vpin::new(8.0, 5).unwrap().batch(&trades);
let mut b = Vpin::new(8.0, 5).unwrap();
let streamed: Vec<_> = trades.iter().map(|t| b.update(*t)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,192 @@
//! Wick Ratio — the shadow imbalance of a bar.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wick Ratio — the signed imbalance between the upper and lower shadows as a
/// fraction of the bar's range.
///
/// ```text
/// upper_wick = high max(open, close)
/// lower_wick = min(open, close) low
/// WickRatio = (upper_wick lower_wick) / (high low)
/// ```
///
/// The result lives in `[1, +1]`: `+1` is a bar that is all upper shadow (a
/// long rejection of higher prices, classic shooting-star geometry), `1` all
/// lower shadow (a long rejection of lower prices, hammer geometry), and `0`
/// either a symmetric bar or a wickless one. Where
/// [`BodySizePct`](crate::BodySizePct) measures how much of the range is body,
/// this measures *which side* the wicks fall on — the rejection asymmetry many
/// reversal setups depend on. A zero-range bar yields `0`.
///
/// This is a stateless per-bar transform: every candle produces one value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, WickRatio};
///
/// let mut indicator = WickRatio::new();
/// // upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +0.8333.
/// let c = Candle::new(10.0, 13.0, 10.0, 10.5, 10.0, 0).unwrap();
/// assert!((indicator.update(c).unwrap() - 2.5 / 3.0).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct WickRatio {
has_emitted: bool,
}
impl WickRatio {
/// Construct a new Wick Ratio transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for WickRatio {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
let range = candle.high - candle.low;
let out = if range == 0.0 {
// A zero-range bar has no shadows to compare.
0.0
} else {
let body_top = candle.open.max(candle.close);
let body_bottom = candle.open.min(candle.close);
let upper_wick = candle.high - body_top;
let lower_wick = body_bottom - candle.low;
(upper_wick - lower_wick) / range
};
Some(out)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"WickRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn upper_shadow_dominates_is_positive() {
// upper 13 - 10.5 = 2.5, lower 10 - 10 = 0, range 3 -> +2.5/3.
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(10.0, 13.0, 10.0, 10.5, 0)).unwrap(),
2.5 / 3.0,
epsilon = 1e-12
);
}
#[test]
fn lower_shadow_dominates_is_negative() {
// Hammer: long lower shadow -> negative.
// open 12, close 12.5, high 13, low 9: upper 0.5, lower 3, range 4.
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(12.0, 13.0, 9.0, 12.5, 0)).unwrap(),
(0.5 - 3.0) / 4.0,
epsilon = 1e-12
);
}
#[test]
fn symmetric_wicks_are_zero() {
// Equal upper and lower shadows -> 0.
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(10.0, 12.0, 8.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn zero_range_bar_yields_zero() {
let mut wr = WickRatio::new();
assert_relative_eq!(
wr.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn stays_within_unit_range() {
let candles: Vec<Candle> = (0..100)
.map(|i| {
let mid = 100.0 + (f64::from(i) * 0.2).sin() * 8.0;
let close = mid + (f64::from(i) * 0.5).cos() * 2.0;
candle(mid, mid + 3.0, mid - 3.0, close, i64::from(i))
})
.collect();
let mut wr = WickRatio::new();
for v in wr.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "WickRatio {v} outside [-1, 1]");
}
}
#[test]
fn name_metadata() {
let wr = WickRatio::new();
assert_eq!(wr.name(), "WickRatio");
}
#[test]
fn emits_from_first_candle() {
let mut wr = WickRatio::new();
assert_eq!(wr.warmup_period(), 1);
assert!(!wr.is_ready());
assert!(wr.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
assert!(wr.is_ready());
}
#[test]
fn reset_clears_state() {
let mut wr = WickRatio::new();
wr.update(candle(10.0, 11.0, 9.0, 10.0, 0));
assert!(wr.is_ready());
wr.reset();
assert!(!wr.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + 2.0, base - 2.0, base + 1.0, i64::from(i))
})
.collect();
let mut a = WickRatio::new();
let mut b = WickRatio::new();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,191 @@
//! Win Rate — the fraction of winning returns over a rolling window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Win Rate — the fraction of strictly-positive returns among the last `period`
/// returns, in `[0, 1]`.
///
/// ```text
/// WinRate = #(rᵢ > 0) / period
/// ```
///
/// Feed a stream of per-trade or per-bar returns (or `PnL`); the indicator reports
/// the rolling hit rate. A return of exactly `0` is treated as a non-win (a
/// flat / scratch), so `WinRate` is the share of the window that strictly made
/// money — the most basic performance statistic and a building block for
/// [`Expectancy`](crate::Expectancy), Kelly sizing, and confidence filters.
///
/// Each `update` is O(1): the count of wins in the window is maintained
/// incrementally.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, WinRate};
///
/// let mut indicator = WinRate::new(4).unwrap();
/// // returns: +, -, +, + -> 3 of 4 win -> 0.75.
/// let out = indicator.batch(&[1.0, -1.0, 2.0, 1.0]);
/// # use wickra_core::BatchExt;
/// assert_eq!(out[3], Some(0.75));
/// ```
#[derive(Debug, Clone)]
pub struct WinRate {
period: usize,
window: VecDeque<f64>,
wins: usize,
}
impl WinRate {
/// Construct a new Win Rate 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),
wins: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for WinRate {
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");
if old > 0.0 {
self.wins -= 1;
}
}
self.window.push_back(ret);
if ret > 0.0 {
self.wins += 1;
}
if self.window.len() < self.period {
return None;
}
Some(self.wins as f64 / self.period as f64)
}
fn reset(&mut self) {
self.window.clear();
self.wins = 0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"WinRate"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(WinRate::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let wr = WinRate::new(20).unwrap();
assert_eq!(wr.period(), 20);
assert_eq!(wr.warmup_period(), 20);
assert_eq!(wr.name(), "WinRate");
assert!(!wr.is_ready());
}
#[test]
fn reference_value() {
// +, -, +, + -> 3 wins of 4 -> 0.75.
let mut wr = WinRate::new(4).unwrap();
let out = wr.batch(&[1.0, -1.0, 2.0, 1.0]);
assert_relative_eq!(out[3].unwrap(), 0.75, epsilon = 1e-12);
}
#[test]
fn all_wins_is_one() {
let mut wr = WinRate::new(5).unwrap();
for v in wr.batch(&[1.0; 10]).into_iter().flatten() {
assert_relative_eq!(v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn all_losses_is_zero() {
let mut wr = WinRate::new(5).unwrap();
for v in wr.batch(&[-1.0; 10]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn flat_returns_are_not_wins() {
// Zeros count as non-wins: 2 wins, 2 flats -> 0.5.
let mut wr = WinRate::new(4).unwrap();
let out = wr.batch(&[1.0, 0.0, 2.0, 0.0]);
assert_relative_eq!(out[3].unwrap(), 0.5, epsilon = 1e-12);
}
#[test]
fn rolling_window_drops_old_wins() {
// period 3: after [+,+,+] -> 1.0, then three losses slide the wins out.
let mut wr = WinRate::new(3).unwrap();
let out = wr.batch(&[1.0, 1.0, 1.0, -1.0, -1.0, -1.0]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
assert_relative_eq!(out[5].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn output_within_bounds() {
let mut wr = WinRate::new(20).unwrap();
let rets: Vec<f64> = (0..200).map(|i| (f64::from(i) * 0.7).sin()).collect();
for v in wr.batch(&rets).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v), "out of bounds: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut wr = WinRate::new(5).unwrap();
wr.batch(&[1.0, -1.0, 1.0, -1.0, 1.0]);
assert!(wr.is_ready());
wr.reset();
assert!(!wr.is_ready());
assert_eq!(wr.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 = WinRate::new(14).unwrap().batch(&rets);
let mut b = WinRate::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+64 -62
View File
@@ -59,78 +59,80 @@ pub use indicators::{
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock,
AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands,
AtrBandsOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange,
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat,
BeltHold, Beta, BetaNeutralSpread, BollingerBands, BollingerBandwidth, BollingerOutput,
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, ClosingMarubozu,
Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, ConcealingBabySwallow,
ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab, CumulativeVolumeDelta,
CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile,
DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar, Donchian,
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama,
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput,
Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation,
AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram,
BalanceOfPower, Bat, BeltHold, Beta, BetaNeutralSpread, BodySizePct, BollingerBands,
BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, Butterfly,
CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo,
ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput,
ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput,
CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji,
DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy, FallingThreeMethods,
Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, Gartley,
GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighWave, Hikkake,
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HomingPigeon, HtDcPhase,
HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent,
Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio,
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, KagiBars,
KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput,
Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom,
LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio,
MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar,
MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio,
OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife,
OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
RecoveryFactor, RectangleRange, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars,
RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100,
RogersSatchellVolatility, RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines,
SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap, Shark,
SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadBollingerBands,
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu,
MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared,
RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel,
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness,
Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf,
Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice,
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput,
VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput,
Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and