use std::collections::VecDeque;
use crate::bar_indicators::hybrid_tick_book_consumer::HybridTickBookConsumer;
use crate::bar_indicators::indicator_value::IndicatorValue;
use crate::core::types::{OrderBook, Tick};
#[derive(Debug, Clone)]
pub struct TradeBookAbsorption {
rolling_window: usize,
ratio: f64,
events: VecDeque<(f64, i8)>,
last_absorbed: f64,
last_side: i8,
}
impl TradeBookAbsorption {
pub fn new(window: usize) -> Self {
Self::with_ratio(window, 0.5)
}
pub fn with_ratio(window: usize, ratio: f64) -> Self {
let w = window.max(1);
Self {
rolling_window: w,
ratio: ratio.max(0.0),
events: VecDeque::with_capacity(w),
last_absorbed: 0.0,
last_side: 0,
}
}
}
impl HybridTickBookConsumer for TradeBookAbsorption {
fn update_tick_with_book(&mut self, tick: &Tick, book: &OrderBook) -> IndicatorValue {
let (target_level, side) = if tick.is_buy {
(book.best_ask(), 1i8)
} else {
(book.best_bid(), -1i8)
};
let visible_size = target_level.map(|l| l.size).unwrap_or(0.0);
let level_price = target_level.map(|l| l.price).unwrap_or(tick.price);
let price_at_level = (tick.price - level_price).abs() < 1e-9;
let threshold = visible_size * self.ratio;
let absorbed = if price_at_level && visible_size > 0.0 && tick.size > threshold {
(tick.size - threshold).max(0.0)
} else {
0.0
};
self.events
.push_back((absorbed, if absorbed > 0.0 { side } else { 0 }));
if self.events.len() > self.rolling_window {
self.events.pop_front();
}
self.last_absorbed = absorbed;
self.last_side = if absorbed > 0.0 { side } else { 0 };
let cumulative: f64 = self.events.iter().map(|&(v, _)| v).sum();
IndicatorValue::Triple(self.last_side as f64, self.last_absorbed, cumulative)
}
fn update_book_only(&mut self, _book: &OrderBook) {}
fn value(&self) -> IndicatorValue {
let cumulative: f64 = self.events.iter().map(|&(v, _)| v).sum();
IndicatorValue::Triple(self.last_side as f64, self.last_absorbed, cumulative)
}
fn reset(&mut self) {
self.events.clear();
self.last_absorbed = 0.0;
self.last_side = 0;
}
fn is_ready(&self) -> bool {
!self.events.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{OrderBook, Tick};
fn tick(price: f64, size: f64, is_buy: bool) -> Tick {
Tick::new(0, price, size, is_buy)
}
fn book_ask(price: f64, size: f64) -> OrderBook {
OrderBook::from_tuples(&[(price - 1.0, 10.0)], &[(price, size)], 0)
}
fn book_bid(price: f64, size: f64) -> OrderBook {
OrderBook::from_tuples(&[(price, size)], &[(price + 1.0, 10.0)], 0)
}
#[test]
fn no_absorption_when_tick_below_ratio() {
let mut det = TradeBookAbsorption::new(10);
let book = book_ask(100.0, 20.0);
let v = det.update_tick_with_book(&tick(100.0, 8.0, true), &book);
match v {
IndicatorValue::Triple(side, absorbed, _) => {
assert_eq!(side, 0.0);
assert_eq!(absorbed, 0.0);
}
_ => panic!("expected Triple"),
}
}
#[test]
fn absorption_detected_when_trade_exceeds_visible_ask() {
let mut det = TradeBookAbsorption::new(10);
let book = book_ask(100.0, 5.0);
let v = det.update_tick_with_book(&tick(100.0, 20.0, true), &book);
match v {
IndicatorValue::Triple(side, absorbed, _) => {
assert!((side - 1.0).abs() < 1e-9, "expected +1 side");
assert!((absorbed - 17.5).abs() < 1e-9, "absorbed should be 17.5");
}
_ => panic!("expected Triple"),
}
}
#[test]
fn absorption_detected_on_sell_side() {
let mut det = TradeBookAbsorption::new(10);
let book = book_bid(100.0, 3.0);
let v = det.update_tick_with_book(&tick(100.0, 12.0, false), &book);
match v {
IndicatorValue::Triple(side, absorbed, _) => {
assert!((side - (-1.0)).abs() < 1e-9, "expected -1 side");
assert!((absorbed - 10.5).abs() < 1e-9, "absorbed should be 10.5");
}
_ => panic!("expected Triple"),
}
}
#[test]
fn strict_ratio_matches_legacy_semantics() {
let mut det = TradeBookAbsorption::with_ratio(10, 1.0);
let book = book_ask(100.0, 5.0);
let v = det.update_tick_with_book(&tick(100.0, 20.0, true), &book);
match v {
IndicatorValue::Triple(side, absorbed, _) => {
assert!((side - 1.0).abs() < 1e-9);
assert!((absorbed - 15.0).abs() < 1e-9, "ratio 1.0 → absorbed = tick - visible");
}
_ => panic!("expected Triple"),
}
}
#[test]
fn no_absorption_when_tick_price_not_at_best_level() {
let mut det = TradeBookAbsorption::new(10);
let book = book_ask(101.0, 2.0);
let v = det.update_tick_with_book(&tick(100.0, 20.0, true), &book);
match v {
IndicatorValue::Triple(side, absorbed, _) => {
assert_eq!(side, 0.0);
assert_eq!(absorbed, 0.0);
}
_ => panic!("expected Triple"),
}
}
#[test]
fn cumulative_tracks_window() {
let mut det = TradeBookAbsorption::new(3);
let book = book_ask(100.0, 1.0);
for _ in 0..3 {
det.update_tick_with_book(&tick(100.0, 6.0, true), &book);
}
match det.value() {
IndicatorValue::Triple(_, _, cum) => {
assert!((cum - 16.5).abs() < 1e-9, "cum should be 3 × 5.5 = 16.5");
}
_ => panic!("expected Triple"),
}
}
#[test]
fn reset_clears_state() {
let mut det = TradeBookAbsorption::new(5);
let book = book_ask(100.0, 1.0);
det.update_tick_with_book(&tick(100.0, 10.0, true), &book);
assert!(det.is_ready());
det.reset();
assert!(!det.is_ready());
assert_eq!(det.value(), IndicatorValue::Triple(0.0, 0.0, 0.0));
}
}