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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user