feat: trade-flow microstructure indicators (part 2 of 4) (#113)

* feat(core): add 3 trade-flow microstructure indicators

SignedVolume (per-trade size signed by aggressor), CumulativeVolumeDelta
(running signed-volume total), and TradeImbalance (rolling buy/sell volume
imbalance over a trade window). All consume the Trade type, with full unit
coverage. Extends the Microstructure family.

* feat(bindings): expose trade-flow microstructure indicators

Python, Node and WASM bindings for SignedVolume, CumulativeVolumeDelta and
TradeImbalance. Each takes a trade via update(price, size, is_buy); Python and
Node expose a batch over three parallel arrays, WASM exposes per-trade update.
Regenerates node index.d.ts/.js.

* test(bindings,fuzz,bench): cover trade-flow microstructure indicators

Python and Node: reference values, streaming-vs-batch, lifecycle/repr and input
validation (zero window, negative size, non-positive price, mismatched batch
lengths). New indicator_update_trade fuzz target. Synthetic trade-tape benches
(signed_volume cheapest, trade_imbalance windowed/expensive).

* docs: add trade-flow indicators + bump counter to 227

README Microstructure family row gains signed volume / CVD / trade imbalance and
the counter goes 224 -> 227; CHANGELOG records the trade-flow indicators.
This commit is contained in:
kingchenc
2026-06-01 16:38:48 +02:00
committed by GitHub
parent 2be21df803
commit 5867f71450
23 changed files with 1141 additions and 33 deletions
+127
View File
@@ -0,0 +1,127 @@
//! Cumulative Volume Delta — running sum of signed trade volume.
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Cumulative Volume Delta (CVD) — the running sum of [signed volume].
///
/// ```text
/// CVDₜ = CVDₜ₋₁ + sizeₜ · (+1 if buy, 1 if sell)
/// ```
///
/// CVD is an unbounded running total: a rising line signals net buying pressure
/// over the session, a falling line net selling. Divergence between CVD and
/// price is a classic absorption / exhaustion signal. Call [`reset`] at the
/// start of each session to re-anchor the cumulative total at zero.
///
/// `Input = Trade`, `Output = f64`. Ready after the first trade.
///
/// [signed volume]: crate::SignedVolume
/// [`reset`]: crate::Indicator::reset
///
/// # Example
///
/// ```
/// use wickra_core::{CumulativeVolumeDelta, Indicator, Side, Trade};
///
/// let mut cvd = CumulativeVolumeDelta::new();
/// assert_eq!(cvd.update(Trade::new(100.0, 5.0, Side::Buy, 0).unwrap()), Some(5.0));
/// assert_eq!(cvd.update(Trade::new(100.0, 2.0, Side::Sell, 1).unwrap()), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct CumulativeVolumeDelta {
cumulative: f64,
has_emitted: bool,
}
impl CumulativeVolumeDelta {
/// Construct a new CVD indicator with a zero running total.
pub const fn new() -> Self {
Self {
cumulative: 0.0,
has_emitted: false,
}
}
}
impl Indicator for CumulativeVolumeDelta {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
self.has_emitted = true;
self.cumulative += trade.size * trade.side.sign();
Some(self.cumulative)
}
fn reset(&mut self) {
self.cumulative = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"CumulativeVolumeDelta"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let cvd = CumulativeVolumeDelta::new();
assert_eq!(cvd.name(), "CumulativeVolumeDelta");
assert_eq!(cvd.warmup_period(), 1);
assert!(!cvd.is_ready());
}
#[test]
fn accumulates_signed_volume() {
let mut cvd = CumulativeVolumeDelta::new();
assert_eq!(cvd.update(trade(5.0, Side::Buy, 0)), Some(5.0));
assert_eq!(cvd.update(trade(2.0, Side::Sell, 1)), Some(3.0));
assert_eq!(cvd.update(trade(4.0, Side::Sell, 2)), Some(-1.0));
assert!(cvd.is_ready());
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..20)
.map(|i| {
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
trade(1.0 + (i % 4) as f64, side, i)
})
.collect();
let mut a = CumulativeVolumeDelta::new();
let mut b = CumulativeVolumeDelta::new();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_re_anchors_at_zero() {
let mut cvd = CumulativeVolumeDelta::new();
cvd.update(trade(5.0, Side::Buy, 0));
cvd.reset();
assert!(!cvd.is_ready());
// After reset the running total starts again from zero.
assert_eq!(cvd.update(trade(2.0, Side::Buy, 1)), Some(2.0));
}
}
+10 -1
View File
@@ -47,6 +47,7 @@ mod cointegration;
mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod cvd;
mod cybernetic_cycle;
mod decycler;
mod decycler_oscillator;
@@ -155,6 +156,7 @@ mod rvi_volatility;
mod rwi;
mod sharpe_ratio;
mod shooting_star;
mod signed_volume;
mod sine_wave;
mod skewness;
mod sma;
@@ -191,6 +193,7 @@ mod three_inside;
mod three_outside;
mod three_soldiers_or_crows;
mod tii;
mod trade_imbalance;
mod treynor_ratio;
mod trima;
mod trix;
@@ -271,6 +274,7 @@ pub use cointegration::{Cointegration, CointegrationOutput};
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
@@ -379,6 +383,7 @@ pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use skewness::Skewness;
pub use sma::Sma;
@@ -415,6 +420,7 @@ pub use three_inside::ThreeInside;
pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use tii::Tii;
pub use trade_imbalance::TradeImbalance;
pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trix::Trix;
@@ -725,6 +731,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"OrderBookImbalanceFull",
"Microprice",
"QuotedSpread",
"SignedVolume",
"CumulativeVolumeDelta",
"TradeImbalance",
],
),
(
@@ -781,6 +790,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, 219, "FAMILIES total drifted from indicator count");
assert_eq!(total, 222, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,128 @@
//! Signed Volume — per-trade volume signed by aggressor side.
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Signed Volume — the size of each trade signed by its aggressor side.
///
/// ```text
/// signedVolume = size · (+1 if buy, 1 if sell)
/// ```
///
/// A positive value is buyer-initiated flow, a negative value seller-initiated.
/// It is the per-trade building block of [`crate::CumulativeVolumeDelta`] and
/// trade-flow imbalance.
///
/// `Input = Trade`, `Output = f64`. Stateless; ready after the first trade.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SignedVolume, Side, Trade};
///
/// let mut sv = SignedVolume::new();
/// let buy = Trade::new(100.0, 2.0, Side::Buy, 0).unwrap();
/// assert_eq!(sv.update(buy), Some(2.0));
/// let sell = Trade::new(100.0, 3.0, Side::Sell, 1).unwrap();
/// assert_eq!(sv.update(sell), Some(-3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct SignedVolume {
has_emitted: bool,
}
impl SignedVolume {
/// Construct a new signed-volume indicator.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for SignedVolume {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
self.has_emitted = true;
Some(trade.size * trade.side.sign())
}
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 {
"SignedVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn accessors_and_metadata() {
let sv = SignedVolume::new();
assert_eq!(sv.name(), "SignedVolume");
assert_eq!(sv.warmup_period(), 1);
assert!(!sv.is_ready());
}
#[test]
fn buy_is_positive() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(2.0, Side::Buy, 0)), Some(2.0));
assert!(sv.is_ready());
}
#[test]
fn sell_is_negative() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(3.0, Side::Sell, 0)), Some(-3.0));
}
#[test]
fn zero_size_is_zero() {
let mut sv = SignedVolume::new();
assert_eq!(sv.update(trade(0.0, Side::Buy, 0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..20)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + (i % 4) as f64, side, i)
})
.collect();
let mut a = SignedVolume::new();
let mut b = SignedVolume::new();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut sv = SignedVolume::new();
sv.update(trade(1.0, Side::Buy, 0));
assert!(sv.is_ready());
sv.reset();
assert!(!sv.is_ready());
}
}
@@ -0,0 +1,193 @@
//! Trade Imbalance — rolling buy/sell volume imbalance over a trade window.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Trade Imbalance — the signed buy/sell volume imbalance over the trailing
/// window of `window` trades.
///
/// ```text
/// buyVol = Σ size of buyer-initiated trades in the window
/// sellVol = Σ size of seller-initiated trades in the window
/// imbalance = (buyVol sellVol) / (buyVol + sellVol)
/// ```
///
/// The output lies in `[1, +1]`: `+1` means the window was all aggressive
/// buying, `1` all aggressive selling, `0` balanced (or no volume). The
/// indicator warms up for `window` trades — `update` returns `None` until the
/// window is full — then emits the rolling imbalance, maintained in O(1) per
/// trade.
///
/// `Input = Trade`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, TradeImbalance};
///
/// let mut ti = TradeImbalance::new(2).unwrap();
/// assert_eq!(ti.update(Trade::new(100.0, 3.0, Side::Buy, 0).unwrap()), None);
/// // Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
/// let out = ti.update(Trade::new(100.0, 1.0, Side::Sell, 1).unwrap());
/// assert_eq!(out, Some(0.5));
/// ```
#[derive(Debug, Clone)]
pub struct TradeImbalance {
window: usize,
history: VecDeque<(f64, f64)>,
buy_sum: f64,
sell_sum: f64,
}
impl TradeImbalance {
/// Construct a trade-imbalance indicator over a window of `window` trades.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `window` is zero.
pub fn new(window: usize) -> Result<Self> {
if window == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
window,
history: VecDeque::with_capacity(window),
buy_sum: 0.0,
sell_sum: 0.0,
})
}
/// The configured window length, in trades.
pub fn window(&self) -> usize {
self.window
}
}
impl Indicator for TradeImbalance {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let (buy, sell) = if trade.side.sign() > 0.0 {
(trade.size, 0.0)
} else {
(0.0, trade.size)
};
self.history.push_back((buy, sell));
self.buy_sum += buy;
self.sell_sum += sell;
if self.history.len() > self.window {
let (old_buy, old_sell) = self.history.pop_front().expect("window >= 1, len > window");
self.buy_sum -= old_buy;
self.sell_sum -= old_sell;
}
if self.history.len() < self.window {
return None;
}
let total = self.buy_sum + self.sell_sum;
if total <= 0.0 {
return Some(0.0);
}
Some((self.buy_sum - self.sell_sum) / total)
}
fn reset(&mut self) {
self.history.clear();
self.buy_sum = 0.0;
self.sell_sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.window
}
fn is_ready(&self) -> bool {
self.history.len() >= self.window
}
fn name(&self) -> &'static str {
"TradeImbalance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
fn trade(size: f64, side: Side, ts: i64) -> Trade {
Trade::new(100.0, size, side, ts).unwrap()
}
#[test]
fn rejects_zero_window() {
assert!(matches!(TradeImbalance::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let ti = TradeImbalance::new(5).unwrap();
assert_eq!(ti.name(), "TradeImbalance");
assert_eq!(ti.warmup_period(), 5);
assert_eq!(ti.window(), 5);
assert!(!ti.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut ti = TradeImbalance::new(2).unwrap();
assert_eq!(ti.update(trade(3.0, Side::Buy, 0)), None);
assert!(!ti.is_ready());
// Window full: buyVol 3, sellVol 1 -> 0.5.
assert_eq!(ti.update(trade(1.0, Side::Sell, 1)), Some(0.5));
assert!(ti.is_ready());
}
#[test]
fn rolls_off_old_trades() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(3.0, Side::Buy, 0));
ti.update(trade(1.0, Side::Sell, 1)); // [buy 3, sell 1] -> 0.5
// Third trade drops the first: window now [sell 1, buy 5] -> (5-1)/6.
let out = ti.update(trade(5.0, Side::Buy, 2)).unwrap();
assert!((out - (4.0 / 6.0)).abs() < 1e-12);
}
#[test]
fn zero_volume_window_is_zero() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(0.0, Side::Buy, 0));
assert_eq!(ti.update(trade(0.0, Side::Sell, 1)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..30)
.map(|i| {
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
trade(1.0 + (i % 5) as f64, side, i)
})
.collect();
let mut a = TradeImbalance::new(5).unwrap();
let mut b = TradeImbalance::new(5).unwrap();
assert_eq!(
a.batch(&trades),
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ti = TradeImbalance::new(2).unwrap();
ti.update(trade(3.0, Side::Buy, 0));
ti.update(trade(1.0, Side::Sell, 1));
assert!(ti.is_ready());
ti.reset();
assert!(!ti.is_ready());
assert_eq!(ti.update(trade(2.0, Side::Buy, 2)), None);
}
}