feat: add 19 indicators for external feature-extractor coverage (377 -> 396) (#175)
Adds 19 streaming indicators so an external trading-bot feature extractor can replace its hand-built features with native, batch/streaming-equivalent ones. Each is a real gap (verified against the existing catalogue), production-only, with full Python/Node/WASM bindings, fuzz drivers, and tests. Five commits, one per family group; counter 377 -> 396. ## What's added **Price Statistics (6)** — `LogReturn`, `RealizedVolatility` (raw quadratic variation, the un-annualised counterpart to `HistoricalVolatility`), `RollingQuantile`, `RollingIqr`, `RollingPercentileRank`, `SpreadAr1Coefficient` (pairwise AR(1) rho of the spread; complements `OuHalfLife`). **Price Action (4)** — `CloseVsOpen`, `BodySizePct`, `WickRatio`, `HighLowRange` (stateless per-bar OHLC transforms). **Regime / Trend / Jump labels (3)** — `TrendLabel` (sign of the rolling OLS slope), `JumpIndicator` (return outliers vs trailing volatility, measured as deviation from the trailing mean so steady drift is not flagged), `RegimeLabel` (volatility-quantile regime split). **Risk / Performance (2)** — `WinRate`, `Expectancy` (R-multiple). **Microstructure (4)** — `OrderFlowImbalance` (Cont-Kukanov-Stoikov OFI), `Vpin`, `AmihudIlliquidity`, `RollMeasure`. These reuse the existing `OrderBook` / `Trade` inputs (no new input type). ## Intentionally NOT added (already present, would be duplicates) - **Population skew / kurtosis** — `skewness.rs` / `kurtosis.rs` are already population moments (divisor n). - **Hurst R/S** — `hurst_exponent.rs` already uses rescaled-range (R/S) analysis. - **Queue Imbalance** — exactly `OrderBookImbalanceTop1` ((bidSize - askSize) / (bidSize + askSize)). ## Verification `cargo test -p wickra-core` (lib 3187 + doc 354), `cargo clippy --workspace --all-targets --all-features -D warnings` clean, node `npm run build && npm test` (471), python `pytest` (784). Counter consistent across `mod.rs`, lib block, README, and docs/README at 396.
This commit is contained in:
@@ -0,0 +1,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_{t−period})`.
|
||||
///
|
||||
/// 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_{t−period})
|
||||
/// ```
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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_{t−1})
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user