feat: derivatives open-interest, flow & liquidation indicators (part 2 of 3) (#127)

* feat(derivatives): OIPriceDivergence indicator (core)

* feat(derivatives): OIWeighted indicator (core)

* feat(derivatives): LongShortRatio indicator (core)

* feat(derivatives): TakerBuySellRatio indicator (core)

* feat(derivatives): LiquidationFeatures multi-output indicator (core)

* feat(derivatives): Python, Node and WASM bindings for OI, flow & liquidation indicators

* test(derivatives): Python and Node tests for OI, flow & liquidation indicators

* fuzz(derivatives): drive OI, flow & liquidation indicators in derivatives target

* docs(derivatives): README row + counter 237->242, CHANGELOG part 2
This commit is contained in:
kingchenc
2026-06-01 21:50:35 +02:00
committed by GitHub
parent 5eb820a9c7
commit 8e5bfd07ce
20 changed files with 2078 additions and 27 deletions
@@ -0,0 +1,179 @@
//! Liquidation Features — per-tick long/short liquidation breakdown.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// The liquidation feature vector emitted by [`LiquidationFeatures`] for one
/// tick.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct LiquidationFeaturesOutput {
/// Long-side liquidation notional on this tick.
pub long: f64,
/// Short-side liquidation notional on this tick.
pub short: f64,
/// Net liquidation `long short` (positive = longs being liquidated).
pub net: f64,
/// Total liquidation `long + short`.
pub total: f64,
/// Liquidation imbalance `(long short) / (long + short)`, in `[1, +1]`;
/// `0.0` when there is no liquidation.
pub imbalance: f64,
}
/// Liquidation Features — decomposes the long- and short-side liquidation
/// notional carried by each tick into a small feature vector.
///
/// ```text
/// net = longLiquidation shortLiquidation
/// total = longLiquidation + shortLiquidation
/// imbalance = net / total (0 when total == 0)
/// ```
///
/// Liquidation cascades are a perpetual-market-specific tail risk: a wave of
/// long liquidations forces market sells that beget more liquidations. Splitting
/// the flow into net, total and a bounded imbalance turns the raw venue feed
/// into model-ready features — `total` sizes the stress, `imbalance` (and its
/// sign) says which side is being flushed. A positive imbalance means longs are
/// being liquidated (downside cascade), a negative one shorts (upside squeeze).
///
/// `Input = DerivativesTick`, `Output = LiquidationFeaturesOutput`. Stateless;
/// ready after the first tick.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, LiquidationFeatures};
///
/// fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick {
/// DerivativesTick::new(
/// 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0,
/// )
/// .unwrap()
/// }
///
/// let mut liq = LiquidationFeatures::new();
/// // 30 long vs 10 short liquidated: net 20, total 40, imbalance 0.5.
/// let out = liq.update(tick(30.0, 10.0)).unwrap();
/// assert_eq!(out.net, 20.0);
/// assert_eq!(out.total, 40.0);
/// assert_eq!(out.imbalance, 0.5);
/// ```
#[derive(Debug, Clone, Default)]
pub struct LiquidationFeatures {
has_emitted: bool,
}
impl LiquidationFeatures {
/// Construct a new liquidation-features indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for LiquidationFeatures {
type Input = DerivativesTick;
type Output = LiquidationFeaturesOutput;
fn update(&mut self, tick: DerivativesTick) -> Option<LiquidationFeaturesOutput> {
self.has_emitted = true;
let long = tick.long_liquidation;
let short = tick.short_liquidation;
let net = long - short;
let total = long + short;
let imbalance = if total == 0.0 { 0.0 } else { net / total };
Some(LiquidationFeaturesOutput {
long,
short,
net,
total,
imbalance,
})
}
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 {
"LiquidationFeatures"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(long_liq: f64, short_liq: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, long_liq, short_liq, 0,
)
}
#[test]
fn accessors_and_metadata() {
let liq = LiquidationFeatures::new();
assert_eq!(liq.name(), "LiquidationFeatures");
assert_eq!(liq.warmup_period(), 1);
assert!(!liq.is_ready());
}
#[test]
fn decomposes_liquidations() {
let mut liq = LiquidationFeatures::new();
let out = liq.update(tick(30.0, 10.0)).unwrap();
assert_eq!(out.long, 30.0);
assert_eq!(out.short, 10.0);
assert_eq!(out.net, 20.0);
assert_eq!(out.total, 40.0);
assert_eq!(out.imbalance, 0.5);
assert!(liq.is_ready());
}
#[test]
fn short_cascade_is_negative_imbalance() {
let mut liq = LiquidationFeatures::new();
let out = liq.update(tick(0.0, 50.0)).unwrap();
assert_eq!(out.net, -50.0);
assert_eq!(out.imbalance, -1.0);
}
#[test]
fn no_liquidation_is_zero_imbalance() {
let mut liq = LiquidationFeatures::new();
let out = liq.update(tick(0.0, 0.0)).unwrap();
assert_eq!(out.total, 0.0);
assert_eq!(out.imbalance, 0.0);
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| tick(f64::from(i % 5) * 10.0, f64::from(i % 3) * 10.0))
.collect();
let mut a = LiquidationFeatures::new();
let mut b = LiquidationFeatures::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut liq = LiquidationFeatures::new();
liq.update(tick(30.0, 10.0));
assert!(liq.is_ready());
liq.reset();
assert!(!liq.is_ready());
}
}
@@ -0,0 +1,138 @@
//! Long/Short Ratio — aggregate long size relative to short size.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Long/Short Ratio — the aggregate long size divided by the aggregate short
/// size carried by each tick.
///
/// ```text
/// longShortRatio = longSize / shortSize
/// ```
///
/// Exchanges publish the long/short account (or position) ratio as a crowd
/// positioning gauge: a ratio above `1` means longs outweigh shorts, below `1`
/// the reverse. Extremes are a contrarian signal — an overwhelmingly long crowd
/// is fuel for a long squeeze. When the short side is zero the ratio is
/// undefined and the indicator reports `0.0`.
///
/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first
/// tick.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, LongShortRatio};
///
/// fn tick(long: f64, short: f64) -> DerivativesTick {
/// DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, long, short, 0.0, 0.0, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut lsr = LongShortRatio::new();
/// // 600 longs vs 400 shorts -> 1.5.
/// assert_eq!(lsr.update(tick(600.0, 400.0)), Some(1.5));
/// ```
#[derive(Debug, Clone, Default)]
pub struct LongShortRatio {
has_emitted: bool,
}
impl LongShortRatio {
/// Construct a new long/short ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for LongShortRatio {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.has_emitted = true;
if tick.short_size == 0.0 {
// No short side to divide by: the ratio is undefined.
return Some(0.0);
}
Some(tick.long_size / tick.short_size)
}
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 {
"LongShortRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(long: f64, short: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
0.0, 100.0, 100.0, 100.0, 0.0, long, short, 0.0, 0.0, 0.0, 0.0, 0,
)
}
#[test]
fn accessors_and_metadata() {
let lsr = LongShortRatio::new();
assert_eq!(lsr.name(), "LongShortRatio");
assert_eq!(lsr.warmup_period(), 1);
assert!(!lsr.is_ready());
}
#[test]
fn divides_long_by_short() {
let mut lsr = LongShortRatio::new();
assert_eq!(lsr.update(tick(600.0, 400.0)), Some(1.5));
assert_eq!(lsr.update(tick(400.0, 800.0)), Some(0.5));
assert!(lsr.is_ready());
}
#[test]
fn zero_short_is_zero() {
let mut lsr = LongShortRatio::new();
assert_eq!(lsr.update(tick(600.0, 0.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| {
tick(
500.0 + f64::from(i % 5) * 10.0,
400.0 + f64::from(i % 3) * 10.0,
)
})
.collect();
let mut a = LongShortRatio::new();
let mut b = LongShortRatio::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut lsr = LongShortRatio::new();
lsr.update(tick(600.0, 400.0));
assert!(lsr.is_ready());
lsr.reset();
assert!(!lsr.is_ready());
}
}
+16 -1
View File
@@ -114,6 +114,8 @@ mod linreg;
mod linreg_angle;
mod linreg_channel;
mod linreg_slope;
mod liquidation_features;
mod long_short_ratio;
mod ma_envelope;
mod macd;
mod mama;
@@ -135,6 +137,8 @@ mod ob_imbalance_top1;
mod ob_imbalance_topn;
mod obv;
mod oi_delta;
mod oi_price_divergence;
mod oi_weighted;
mod omega_ratio;
mod opening_range;
mod pain_index;
@@ -186,6 +190,7 @@ mod stochastic;
mod super_smoother;
mod super_trend;
mod t3;
mod taker_buy_sell_ratio;
mod td_combo;
mod td_countdown;
mod td_demarker;
@@ -351,6 +356,8 @@ pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use long_short_ratio::LongShortRatio;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use mama::{Mama, MamaOutput};
@@ -372,6 +379,8 @@ pub use ob_imbalance_top1::OrderBookImbalanceTop1;
pub use ob_imbalance_topn::OrderBookImbalanceTopN;
pub use obv::Obv;
pub use oi_delta::OpenInterestDelta;
pub use oi_price_divergence::OIPriceDivergence;
pub use oi_weighted::OIWeighted;
pub use omega_ratio::OmegaRatio;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use pain_index::PainIndex;
@@ -423,6 +432,7 @@ pub use stochastic::{Stochastic, StochasticOutput};
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use taker_buy_sell_ratio::TakerBuySellRatio;
pub use td_combo::TdCombo;
pub use td_countdown::TdCountdown;
pub use td_demarker::TdDeMarker;
@@ -769,6 +779,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"FundingRateZScore",
"FundingBasis",
"OpenInterestDelta",
"OIPriceDivergence",
"OIWeighted",
"LongShortRatio",
"TakerBuySellRatio",
"LiquidationFeatures",
],
),
(
@@ -825,6 +840,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, 232, "FAMILIES total drifted from indicator count");
assert_eq!(total, 237, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,193 @@
//! Open-Interest / Price Divergence — relative OI change minus relative price
//! change over a window.
use std::collections::VecDeque;
use crate::derivatives::DerivativesTick;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Open-Interest / Price Divergence — the gap between how fast open interest and
/// the mark price have moved over the trailing window of `window` ticks.
///
/// ```text
/// oiChange = (openInterestₜ openInterestₜ₋ₙ) / openInterestₜ₋ₙ
/// priceChange = (markPriceₜ markPriceₜ₋ₙ) / markPriceₜ₋ₙ
/// divergence = oiChange priceChange (n = window)
/// ```
///
/// Reading the two together is a classic positioning signal: open interest
/// rising while price falls (a positive divergence) marks fresh shorts piling
/// in; open interest falling while price rises marks a short squeeze / unwind.
/// A value near zero means OI and price moved in step. If the reference open
/// interest is zero, the OI term contributes zero (no base to grow from).
///
/// The indicator warms up for `window + 1` ticks — `update` returns `None` until
/// the window spans a full `window`-tick lookback — then emits the divergence,
/// maintained in O(1) per tick via a ring buffer.
///
/// `Input = DerivativesTick`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, OIPriceDivergence};
///
/// fn tick(oi: f64, mark: f64) -> DerivativesTick {
/// DerivativesTick::new(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut div = OIPriceDivergence::new(1).unwrap();
/// assert_eq!(div.update(tick(1_000.0, 100.0)), None);
/// // OI +10% while price flat -> divergence +0.1.
/// assert!((div.update(tick(1_100.0, 100.0)).unwrap() - 0.1).abs() < 1e-12);
/// ```
#[derive(Debug, Clone)]
pub struct OIPriceDivergence {
window: usize,
history: VecDeque<(f64, f64)>,
}
impl OIPriceDivergence {
/// Construct an OI / price divergence over a window of `window` ticks.
///
/// # 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 + 1),
})
}
/// The configured window length, in ticks.
#[must_use]
pub fn window(&self) -> usize {
self.window
}
}
impl Indicator for OIPriceDivergence {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.history
.push_back((tick.open_interest, tick.mark_price));
if self.history.len() > self.window + 1 {
self.history.pop_front();
}
if self.history.len() < self.window + 1 {
return None;
}
let (old_oi, old_mark) = *self.history.front().expect("len == window + 1");
let (cur_oi, cur_mark) = *self.history.back().expect("len == window + 1");
// Open interest can legitimately be zero; with no base there is no
// relative change to report from it.
let oi_change = if old_oi == 0.0 {
0.0
} else {
(cur_oi - old_oi) / old_oi
};
// The mark price is finite and positive by `DerivativesTick`
// construction, so the denominator is always well-defined.
let price_change = (cur_mark - old_mark) / old_mark;
Some(oi_change - price_change)
}
fn reset(&mut self) {
self.history.clear();
}
fn warmup_period(&self) -> usize {
self.window + 1
}
fn is_ready(&self) -> bool {
self.history.len() == self.window + 1
}
fn name(&self) -> &'static str {
"OIPriceDivergence"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(oi: f64, mark: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
}
#[test]
fn rejects_zero_window() {
assert!(matches!(OIPriceDivergence::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let div = OIPriceDivergence::new(5).unwrap();
assert_eq!(div.name(), "OIPriceDivergence");
assert_eq!(div.warmup_period(), 6);
assert_eq!(div.window(), 5);
assert!(!div.is_ready());
}
#[test]
fn oi_up_price_flat_is_positive() {
let mut div = OIPriceDivergence::new(1).unwrap();
assert_eq!(div.update(tick(1_000.0, 100.0)), None);
let out = div.update(tick(1_100.0, 100.0)).unwrap();
assert!((out - 0.1).abs() < 1e-12);
assert!(div.is_ready());
}
#[test]
fn oi_flat_price_up_is_negative() {
let mut div = OIPriceDivergence::new(1).unwrap();
div.update(tick(1_000.0, 100.0));
// OI flat, price +10% -> divergence -0.1.
let out = div.update(tick(1_000.0, 110.0)).unwrap();
assert!((out + 0.1).abs() < 1e-12);
}
#[test]
fn zero_reference_oi_drops_oi_term() {
let mut div = OIPriceDivergence::new(1).unwrap();
div.update(tick(0.0, 100.0));
// Reference OI is zero -> only the price term contributes: -(110-100)/100.
let out = div.update(tick(500.0, 110.0)).unwrap();
assert!((out + 0.1).abs() < 1e-12);
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..30)
.map(|i| tick(1_000.0 + f64::from(i % 7) * 10.0, 100.0 + f64::from(i % 5)))
.collect();
let mut a = OIPriceDivergence::new(4).unwrap();
let mut b = OIPriceDivergence::new(4).unwrap();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut div = OIPriceDivergence::new(1).unwrap();
div.update(tick(1_000.0, 100.0));
div.update(tick(1_100.0, 100.0));
assert!(div.is_ready());
div.reset();
assert!(!div.is_ready());
assert_eq!(div.update(tick(1_000.0, 100.0)), None);
}
}
@@ -0,0 +1,151 @@
//! Open-Interest-Weighted Price — cumulative mark price weighted by open
//! interest.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Open-Interest-Weighted Price — the running mean mark price, weighting each
/// tick by its open interest.
///
/// ```text
/// oiWeighted = Σ(markPrice · openInterest) / Σ openInterest
/// ```
///
/// Where a plain mean treats every tick equally, the OI-weighted price pulls
/// toward the levels at which the most contracts were actually outstanding — the
/// price the bulk of open positioning sits around, a fair-value anchor for
/// liquidations and mean-reversion. The accumulation runs from construction;
/// call [`reset`] at each session boundary to re-anchor. Until any open interest
/// has accrued the indicator returns the current mark price.
///
/// `Input = DerivativesTick`, `Output = f64`. Ready after the first tick.
///
/// [`reset`]: crate::Indicator::reset
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, OIWeighted};
///
/// fn tick(mark: f64, oi: f64) -> DerivativesTick {
/// DerivativesTick::new(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut oiw = OIWeighted::new();
/// assert_eq!(oiw.update(tick(100.0, 10.0)), Some(100.0));
/// // (100·10 + 110·30) / (10 + 30) = 4300 / 40 = 107.5.
/// assert_eq!(oiw.update(tick(110.0, 30.0)), Some(107.5));
/// ```
#[derive(Debug, Clone, Default)]
pub struct OIWeighted {
sum_weighted: f64,
sum_oi: f64,
has_emitted: bool,
}
impl OIWeighted {
/// Construct a new OI-weighted price indicator.
#[must_use]
pub const fn new() -> Self {
Self {
sum_weighted: 0.0,
sum_oi: 0.0,
has_emitted: false,
}
}
}
impl Indicator for OIWeighted {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.has_emitted = true;
self.sum_weighted += tick.mark_price * tick.open_interest;
self.sum_oi += tick.open_interest;
if self.sum_oi == 0.0 {
// No open interest has accrued yet: fall back to the mark price.
return Some(tick.mark_price);
}
Some(self.sum_weighted / self.sum_oi)
}
fn reset(&mut self) {
self.sum_weighted = 0.0;
self.sum_oi = 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 {
"OIWeighted"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(mark: f64, oi: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(0.0, mark, mark, mark, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let oiw = OIWeighted::new();
assert_eq!(oiw.name(), "OIWeighted");
assert_eq!(oiw.warmup_period(), 1);
assert!(!oiw.is_ready());
}
#[test]
fn weights_by_open_interest() {
let mut oiw = OIWeighted::new();
assert_eq!(oiw.update(tick(100.0, 10.0)), Some(100.0));
// (100·10 + 110·30) / 40 = 107.5.
assert_eq!(oiw.update(tick(110.0, 30.0)), Some(107.5));
assert!(oiw.is_ready());
}
#[test]
fn zero_open_interest_falls_back_to_mark() {
let mut oiw = OIWeighted::new();
assert_eq!(oiw.update(tick(123.0, 0.0)), Some(123.0));
// Still no OI on the second zero-OI tick.
assert_eq!(oiw.update(tick(125.0, 0.0)), Some(125.0));
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| tick(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4)))
.collect();
let mut a = OIWeighted::new();
let mut b = OIWeighted::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_re_anchors() {
let mut oiw = OIWeighted::new();
oiw.update(tick(100.0, 10.0));
oiw.update(tick(110.0, 30.0));
assert!(oiw.is_ready());
oiw.reset();
assert!(!oiw.is_ready());
// After reset the accumulation starts again from the next tick.
assert_eq!(oiw.update(tick(200.0, 5.0)), Some(200.0));
}
}
@@ -0,0 +1,137 @@
//! Taker Buy/Sell Ratio — aggressive buy volume relative to aggressive sell
//! volume.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Taker Buy/Sell Ratio — the taker (market-order) buy volume divided by the
/// taker sell volume carried by each tick.
///
/// ```text
/// takerBuySellRatio = takerBuyVolume / takerSellVolume
/// ```
///
/// Taker volume is the volume that crossed the spread — the aggressive flow that
/// moves price. A ratio above `1` means buyers are lifting offers faster than
/// sellers are hitting bids (net aggressive buying); below `1` the reverse. It
/// is the perpetual-feed analogue of [trade imbalance], read straight off the
/// venue's taker-volume fields. When taker sell volume is zero the ratio is
/// undefined and the indicator reports `0.0`.
///
/// `Input = DerivativesTick`, `Output = f64`. Stateless; ready after the first
/// tick.
///
/// [trade imbalance]: crate::TradeImbalance
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, TakerBuySellRatio};
///
/// fn tick(buy: f64, sell: f64) -> DerivativesTick {
/// DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, buy, sell, 0.0, 0.0, 0)
/// .unwrap()
/// }
///
/// let mut tbs = TakerBuySellRatio::new();
/// // 60 taker buys vs 40 taker sells -> 1.5.
/// assert_eq!(tbs.update(tick(60.0, 40.0)), Some(1.5));
/// ```
#[derive(Debug, Clone, Default)]
pub struct TakerBuySellRatio {
has_emitted: bool,
}
impl TakerBuySellRatio {
/// Construct a new taker buy/sell ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for TakerBuySellRatio {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
self.has_emitted = true;
if tick.taker_sell_volume == 0.0 {
// No taker sell volume to divide by: the ratio is undefined.
return Some(0.0);
}
Some(tick.taker_buy_volume / tick.taker_sell_volume)
}
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 {
"TakerBuySellRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn tick(buy: f64, sell: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, buy, sell, 0.0, 0.0, 0,
)
}
#[test]
fn accessors_and_metadata() {
let tbs = TakerBuySellRatio::new();
assert_eq!(tbs.name(), "TakerBuySellRatio");
assert_eq!(tbs.warmup_period(), 1);
assert!(!tbs.is_ready());
}
#[test]
fn divides_buy_by_sell() {
let mut tbs = TakerBuySellRatio::new();
assert_eq!(tbs.update(tick(60.0, 40.0)), Some(1.5));
assert_eq!(tbs.update(tick(20.0, 80.0)), Some(0.25));
assert!(tbs.is_ready());
}
#[test]
fn zero_sell_is_zero() {
let mut tbs = TakerBuySellRatio::new();
assert_eq!(tbs.update(tick(60.0, 0.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| tick(50.0 + f64::from(i % 5) * 5.0, 40.0 + f64::from(i % 3) * 5.0))
.collect();
let mut a = TakerBuySellRatio::new();
let mut b = TakerBuySellRatio::new();
assert_eq!(
a.batch(&ticks),
ticks.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut tbs = TakerBuySellRatio::new();
tbs.update(tick(60.0, 40.0));
assert!(tbs.is_ready());
tbs.reset();
assert!(!tbs.is_ready());
}
}