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