use crate::data::{
BarPoint, BasisPoint, FootprintPoint, FundingRatePoint, FundingSettlementPoint,
MarkPricePoint, IndexPricePoint, TradePoint,
};
use crate::series::{DataPoint, Kind};
use crate::series::SeriesKey;
use crate::subscription::{Event, Stream};
pub(crate) trait DerivedStream: Send + 'static {
type Output: DataPoint;
fn deps() -> &'static [Stream];
fn new_for_key(key: &SeriesKey) -> Self;
fn on_upstream_event(&mut self, ev: &Event, dep_idx: usize) -> Option<Self::Output>;
}
pub(crate) struct BasisDerived {
last_mark: Option<(i64, f64)>,
last_index: Option<(i64, f64)>,
max_skew_ms: i64,
}
impl DerivedStream for BasisDerived {
type Output = BasisPoint;
fn deps() -> &'static [Stream] {
&[Stream::MarkPrice, Stream::IndexPrice]
}
fn new_for_key(_key: &SeriesKey) -> Self {
Self {
last_mark: None,
last_index: None,
max_skew_ms: 2_000,
}
}
fn on_upstream_event(&mut self, ev: &Event, dep_idx: usize) -> Option<BasisPoint> {
match dep_idx {
0 => {
if let Event::MarkPrice { point, .. } = ev {
self.last_mark = Some((point.ts_ms, point.mark));
}
}
1 => {
if let Event::IndexPrice { point, .. } = ev {
self.last_index = Some((point.ts_ms, point.price));
}
}
_ => return None,
}
let (mark_ts, mark) = self.last_mark?;
let (idx_ts, idx) = self.last_index?;
if (mark_ts - idx_ts).abs() > self.max_skew_ms {
return None;
}
let now_ms = mark_ts.max(idx_ts);
Some(BasisPoint {
ts_ms: now_ms,
value: mark - idx,
mark,
index: idx,
})
}
}
pub(crate) fn interval_to_ms(interval: &str) -> Option<i64> {
let bytes = interval.as_bytes();
if bytes.is_empty() {
return None;
}
let unit = *bytes.last()?;
let n_str = std::str::from_utf8(&bytes[..bytes.len().saturating_sub(1)]).ok()?;
let n: i64 = n_str.parse().ok()?;
if n <= 0 {
return None;
}
const SEC: i64 = 1_000;
const MIN: i64 = 60 * SEC;
const HOUR: i64 = 60 * MIN;
const DAY: i64 = 24 * HOUR;
match unit {
b's' => Some(n * SEC),
b'm' => Some(n * MIN),
b'h' | b'H' => Some(n * HOUR),
b'd' | b'D' => Some(n * DAY),
b'w' | b'W' => Some(n * 7 * DAY),
_ => None,
}
}
pub(crate) struct TradeToBarDerived {
interval_ms: i64,
current: Option<BarPoint>,
current_bucket_start: i64,
}
impl DerivedStream for TradeToBarDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] {
&[Stream::Trade]
}
fn new_for_key(key: &SeriesKey) -> Self {
let interval_ms = match &key.kind {
Kind::Kline(iv) => interval_to_ms(iv.as_str()).unwrap_or(0),
_ => 0,
};
Self { interval_ms, current: None, current_bucket_start: 0 }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.interval_ms == 0 {
return None;
}
let Event::Trade { point, .. } = ev else { return None };
let bucket_start = (point.ts_ms / self.interval_ms) * self.interval_ms;
if self.current.is_none() || bucket_start > self.current_bucket_start {
let bar = BarPoint {
open_time: bucket_start,
open: point.price,
high: point.price,
low: point.price,
close: point.price,
volume: point.quantity,
quote_volume: point.price * point.quantity,
trades_count: 1,
};
self.current = Some(bar.clone());
self.current_bucket_start = bucket_start;
Some(bar)
} else {
let bar = self.current.as_mut()?;
if point.price > bar.high { bar.high = point.price; }
if point.price < bar.low { bar.low = point.price; }
bar.close = point.price;
bar.volume += point.quantity;
bar.quote_volume += point.price * point.quantity;
bar.trades_count += 1;
Some(bar.clone())
}
}
}
pub(crate) struct TradeToRangeBarDerived {
range: f64,
current: Option<BarPoint>,
last_emitted_open_time: i64,
}
impl DerivedStream for TradeToRangeBarDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let range = match &key.kind {
Kind::RangeBar(r) if *r > 0 => *r as f64 / 1e8,
_ => 0.0,
};
Self { range, current: None, last_emitted_open_time: 0 }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.range == 0.0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if let Some(ref mut bar) = self.current {
if (point.price - bar.open).abs() >= self.range {
let closed = bar.clone();
let new_open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = new_open_time;
self.current = Some(BarPoint {
open_time: new_open_time,
open: point.price,
high: point.price,
low: point.price,
close: point.price,
volume: point.quantity,
quote_volume: point.price * point.quantity,
trades_count: 1,
});
let _ = closed; return Some(self.current.clone().unwrap());
}
if point.price > bar.high { bar.high = point.price; }
if point.price < bar.low { bar.low = point.price; }
bar.close = point.price;
bar.volume += point.quantity;
bar.quote_volume += point.price * point.quantity;
bar.trades_count += 1;
Some(bar.clone())
} else {
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
let bar = BarPoint {
open_time,
open: point.price,
high: point.price,
low: point.price,
close: point.price,
volume: point.quantity,
quote_volume: point.price * point.quantity,
trades_count: 1,
};
self.current = Some(bar.clone());
Some(bar)
}
}
}
pub(crate) struct TradeToTickBarDerived {
n: u32,
current: Option<BarPoint>,
count: u32,
last_emitted_open_time: i64,
}
impl DerivedStream for TradeToTickBarDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let n = match &key.kind {
Kind::TickBar(n) if *n > 0 => *n,
_ => 0,
};
Self { n, current: None, count: 0, last_emitted_open_time: 0 }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.n == 0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if self.current.is_none() {
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
self.current = Some(BarPoint {
open_time,
open: point.price,
high: point.price,
low: point.price,
close: point.price,
volume: point.quantity,
quote_volume: point.price * point.quantity,
trades_count: 1,
});
self.count = 1;
} else {
let bar = self.current.as_mut()?;
if point.price > bar.high { bar.high = point.price; }
if point.price < bar.low { bar.low = point.price; }
bar.close = point.price;
bar.volume += point.quantity;
bar.quote_volume += point.price * point.quantity;
bar.trades_count += 1;
self.count += 1;
}
let bar = self.current.clone()?;
if self.count >= self.n {
self.current = None;
self.count = 0;
}
Some(bar)
}
}
pub(crate) struct TradeToVolumeBarDerived {
threshold: f64,
current: Option<BarPoint>,
last_emitted_open_time: i64,
}
impl DerivedStream for TradeToVolumeBarDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let threshold = match &key.kind {
Kind::VolumeBar(v) if *v > 0 => *v as f64 / 1e8,
_ => 0.0,
};
Self { threshold, current: None, last_emitted_open_time: 0 }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.threshold == 0.0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if self.current.is_none() {
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
self.current = Some(BarPoint {
open_time,
open: point.price,
high: point.price,
low: point.price,
close: point.price,
volume: point.quantity,
quote_volume: point.price * point.quantity,
trades_count: 1,
});
} else {
let bar = self.current.as_mut()?;
if point.price > bar.high { bar.high = point.price; }
if point.price < bar.low { bar.low = point.price; }
bar.close = point.price;
bar.volume += point.quantity;
bar.quote_volume += point.price * point.quantity;
bar.trades_count += 1;
}
let bar = self.current.clone()?;
if bar.volume >= self.threshold {
self.current = None;
}
Some(bar)
}
}
pub(crate) struct TradeToFootprintDerived {
interval_ms: i64,
current_bucket_start: i64,
current_ohlcv: Option<(f64, f64, f64, f64, f64)>, levels: std::collections::BTreeMap<u64, (f64, f64)>,
}
impl TradeToFootprintDerived {
fn price_bits(price: f64) -> u64 {
u64::from_le_bytes(price.to_le_bytes())
}
fn build_point(&self, open_time: i64) -> FootprintPoint {
let (open, high, low, close, volume) = self.current_ohlcv.unwrap_or((0.0, 0.0, 0.0, 0.0, 0.0));
let levels: Vec<(f64, f64, f64)> = self.levels.iter().map(|(bits, (buy, sell))| {
let price = f64::from_le_bytes(bits.to_le_bytes());
(price, *buy, *sell)
}).collect();
FootprintPoint { open_time, open, high, low, close, volume, levels }
}
}
impl DerivedStream for TradeToFootprintDerived {
type Output = FootprintPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let interval_ms = match &key.kind {
Kind::Footprint(iv) => interval_to_ms(iv.as_str()).unwrap_or(0),
_ => 0,
};
Self {
interval_ms,
current_bucket_start: 0,
current_ohlcv: None,
levels: std::collections::BTreeMap::new(),
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<FootprintPoint> {
if self.interval_ms == 0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
let bucket_start = (point.ts_ms / self.interval_ms) * self.interval_ms;
if self.current_ohlcv.is_none() || bucket_start > self.current_bucket_start {
self.current_bucket_start = bucket_start;
self.current_ohlcv = Some((point.price, point.price, point.price, point.price, point.quantity));
self.levels.clear();
let bits = Self::price_bits(point.price);
let entry = self.levels.entry(bits).or_insert((0.0, 0.0));
if point.side == 0 { entry.0 += point.quantity; } else { entry.1 += point.quantity; }
} else {
let ohlcv = self.current_ohlcv.as_mut()?;
if point.price > ohlcv.1 { ohlcv.1 = point.price; } if point.price < ohlcv.2 { ohlcv.2 = point.price; } ohlcv.3 = point.price; ohlcv.4 += point.quantity; let bits = Self::price_bits(point.price);
let entry = self.levels.entry(bits).or_insert((0.0, 0.0));
if point.side == 0 { entry.0 += point.quantity; } else { entry.1 += point.quantity; }
}
Some(self.build_point(self.current_bucket_start))
}
}
pub(crate) struct FundingSettlementDerived {
last_next_funding_time: i64,
last_rate: f64,
}
impl DerivedStream for FundingSettlementDerived {
type Output = FundingSettlementPoint;
fn deps() -> &'static [Stream] {
&[Stream::FundingRate]
}
fn new_for_key(_key: &SeriesKey) -> Self {
Self {
last_next_funding_time: 0,
last_rate: 0.0,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<FundingSettlementPoint> {
let Event::FundingRate { point, .. } = ev else { return None };
let new_nft = point.next_funding_time_ms;
let new_rate = point.rate;
let now_ms = point.ts_ms;
if new_nft == 0 {
self.last_rate = new_rate;
return None;
}
if self.last_next_funding_time == 0 {
self.last_next_funding_time = new_nft;
self.last_rate = new_rate;
return None;
}
let output = if now_ms >= self.last_next_funding_time
&& new_nft != self.last_next_funding_time
{
Some(FundingSettlementPoint {
ts_ms: now_ms,
settled_rate: self.last_rate,
settlement_time: self.last_next_funding_time,
})
} else {
None
};
self.last_next_funding_time = new_nft;
self.last_rate = new_rate;
output
}
}
const _: fn() = || {
let _ = std::mem::size_of::<MarkPricePoint>();
let _ = std::mem::size_of::<IndexPricePoint>();
let _ = std::mem::size_of::<FundingRatePoint>();
let _ = std::mem::size_of::<TradePoint>();
let _ = std::mem::size_of::<BarPoint>();
let _ = std::mem::size_of::<FootprintPoint>();
};
#[cfg(test)]
mod tests {
use super::*;
use digdigdig3::core::types::ExchangeId;
use digdigdig3::core::types::AccountType;
use digdigdig3::core::websocket::KlineInterval;
fn mark_price_event(ts_ms: i64, mark: f64) -> Event {
Event::MarkPrice {
exchange: ExchangeId::Binance,
symbol: "BTCUSDT".to_string(),
point: MarkPricePoint { ts_ms, mark, index: f64::NAN },
}
}
fn index_price_event(ts_ms: i64, price: f64) -> Event {
Event::IndexPrice {
exchange: ExchangeId::Binance,
symbol: "BTCUSDT".to_string(),
point: IndexPricePoint { ts_ms, price },
}
}
fn funding_rate_event(ts_ms: i64, rate: f64, next_funding_time_ms: i64) -> Event {
Event::FundingRate {
exchange: ExchangeId::Binance,
symbol: "BTCUSDT".to_string(),
point: FundingRatePoint { ts_ms, rate, next_funding_time_ms },
}
}
fn test_key() -> SeriesKey {
SeriesKey::new(ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT", crate::series::Kind::Basis)
}
#[test]
fn basis_no_emit_until_both_sides_seen() {
let mut d = BasisDerived::new_for_key(&test_key());
let r = d.on_upstream_event(&mark_price_event(1000, 50_000.0), 0);
assert!(r.is_none(), "MarkPrice alone must not emit");
let mut d2 = BasisDerived::new_for_key(&test_key());
let r2 = d2.on_upstream_event(&index_price_event(1000, 49_990.0), 1);
assert!(r2.is_none(), "IndexPrice alone must not emit");
}
#[test]
fn basis_emits_on_paired_events() {
let mut d = BasisDerived::new_for_key(&test_key());
let r1 = d.on_upstream_event(&mark_price_event(1000, 50_000.0), 0);
assert!(r1.is_none());
let r2 = d.on_upstream_event(&index_price_event(1200, 49_990.0), 1);
let p = r2.expect("should emit after both sides seen");
assert!((p.value - 10.0).abs() < 1e-9, "value = mark - index = 10.0");
assert_eq!(p.mark, 50_000.0);
assert_eq!(p.index, 49_990.0);
assert_eq!(p.ts_ms, 1200); }
#[test]
fn basis_skew_rejection() {
let mut d = BasisDerived::new_for_key(&test_key());
d.on_upstream_event(&mark_price_event(0, 50_000.0), 0);
let r = d.on_upstream_event(&index_price_event(3000, 49_990.0), 1);
assert!(r.is_none(), "stale pair must be rejected (skew > 2000 ms)");
}
#[test]
fn basis_emits_on_each_update_once_seeded() {
let mut d = BasisDerived::new_for_key(&test_key());
d.on_upstream_event(&mark_price_event(1000, 50_000.0), 0);
d.on_upstream_event(&index_price_event(1001, 49_990.0), 1);
let r = d.on_upstream_event(&mark_price_event(1002, 50_010.0), 0);
let p = r.expect("should emit after update when both seeded");
assert!((p.value - 20.0).abs() < 1e-9, "updated mark=50010, index=49990 → 20.0");
}
#[test]
fn basis_value_correct() {
let mut d = BasisDerived::new_for_key(&test_key());
d.on_upstream_event(&mark_price_event(100, 50_000.0), 0);
let p = d.on_upstream_event(&index_price_event(100, 49_990.0), 1).unwrap();
assert!((p.value - 10.0).abs() < 1e-9);
assert_eq!(p.mark, 50_000.0);
assert_eq!(p.index, 49_990.0);
}
fn fs_key() -> SeriesKey {
SeriesKey::new(ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT", crate::series::Kind::FundingSettlement)
}
#[test]
fn settlement_no_emit_on_first_event() {
let mut d = FundingSettlementDerived::new_for_key(&fs_key());
let r = d.on_upstream_event(&funding_rate_event(500, 0.0001, 1000), 0);
assert!(r.is_none(), "first event must only initialize state");
}
#[test]
fn settlement_no_emit_if_nft_unchanged() {
let mut d = FundingSettlementDerived::new_for_key(&fs_key());
d.on_upstream_event(&funding_rate_event(500, 0.0001, 1000), 0);
let r = d.on_upstream_event(&funding_rate_event(800, 0.0001, 1000), 0);
assert!(r.is_none(), "no crossing: nft unchanged and ts < nft");
}
#[test]
fn settlement_emit_on_crossing() {
let mut d = FundingSettlementDerived::new_for_key(&fs_key());
d.on_upstream_event(&funding_rate_event(500, 0.0001, 1000), 0);
let r = d.on_upstream_event(&funding_rate_event(1001, 0.0002, 2000), 0);
let p = r.expect("must emit on crossing");
assert_eq!(p.ts_ms, 1001);
assert!((p.settled_rate - 0.0001).abs() < 1e-12, "settled_rate must be from PREVIOUS event");
assert_eq!(p.settlement_time, 1000);
}
#[test]
fn settlement_no_emit_when_nft_zero() {
let mut d = FundingSettlementDerived::new_for_key(&fs_key());
let r = d.on_upstream_event(&funding_rate_event(1000, 0.0001, 0), 0);
assert!(r.is_none(), "nft=0 must be silently absorbed");
}
#[test]
fn settlement_rate_is_from_previous_event() {
let mut d = FundingSettlementDerived::new_for_key(&fs_key());
d.on_upstream_event(&funding_rate_event(500, 0.05, 1000), 0);
let p = d.on_upstream_event(&funding_rate_event(1001, 0.03, 2000), 0).unwrap();
assert!((p.settled_rate - 0.05).abs() < 1e-12, "settled_rate must be 0.05 (from prior event), not 0.03");
}
#[test]
fn interval_to_ms_known_intervals() {
assert_eq!(interval_to_ms("1s"), Some(1_000));
assert_eq!(interval_to_ms("3s"), Some(3_000));
assert_eq!(interval_to_ms("5s"), Some(5_000));
assert_eq!(interval_to_ms("10s"), Some(10_000));
assert_eq!(interval_to_ms("15s"), Some(15_000));
assert_eq!(interval_to_ms("30s"), Some(30_000));
assert_eq!(interval_to_ms("1m"), Some(60_000));
assert_eq!(interval_to_ms("3m"), Some(3 * 60_000));
assert_eq!(interval_to_ms("5m"), Some(5 * 60_000));
assert_eq!(interval_to_ms("15m"), Some(15 * 60_000));
assert_eq!(interval_to_ms("30m"), Some(30 * 60_000));
assert_eq!(interval_to_ms("1h"), Some(3_600_000));
assert_eq!(interval_to_ms("2h"), Some(2 * 3_600_000));
assert_eq!(interval_to_ms("4h"), Some(4 * 3_600_000));
assert_eq!(interval_to_ms("6h"), Some(6 * 3_600_000));
assert_eq!(interval_to_ms("8h"), Some(8 * 3_600_000));
assert_eq!(interval_to_ms("12h"), Some(12 * 3_600_000));
assert_eq!(interval_to_ms("1d"), Some(86_400_000));
assert_eq!(interval_to_ms("3d"), Some(3 * 86_400_000));
assert_eq!(interval_to_ms("1w"), Some(7 * 86_400_000));
}
#[test]
fn interval_to_ms_unknown() {
assert!(interval_to_ms("").is_none());
assert!(interval_to_ms("1x").is_none());
assert!(interval_to_ms("abc").is_none());
assert!(interval_to_ms("0m").is_none());
assert!(interval_to_ms("-1m").is_none());
}
fn kline_key(interval: &str) -> SeriesKey {
SeriesKey::new(
ExchangeId::Binance,
AccountType::FuturesCross,
"BTCUSDT",
crate::series::Kind::Kline(KlineInterval::new(interval)),
)
}
fn trade_event(ts_ms: i64, price: f64, quantity: f64) -> Event {
Event::Trade {
exchange: ExchangeId::Binance,
symbol: "BTCUSDT".to_string(),
point: crate::data::TradePoint {
ts_ms,
price,
quantity,
side: 0,
trade_id_hash: 0,
},
}
}
#[test]
fn trade_to_bar_bucketing_1m() {
let key = kline_key("1m");
let interval_ms = 60_000_i64;
let mut d = TradeToBarDerived::new_for_key(&key);
let p1 = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0)
.expect("first trade must emit bar");
assert_eq!(p1.open_time, 0);
assert_eq!(p1.open, 100.0);
assert_eq!(p1.high, 100.0);
assert_eq!(p1.low, 100.0);
assert_eq!(p1.close, 100.0);
assert!((p1.volume - 1.0).abs() < 1e-12);
assert_eq!(p1.trades_count, 1);
let p2 = d.on_upstream_event(&trade_event(30_000, 120.0, 2.0), 0)
.expect("second trade must emit updated bar");
assert_eq!(p2.open_time, 0, "same bucket — open_time must not change");
assert_eq!(p2.open, 100.0, "open must be first trade price");
assert_eq!(p2.high, 120.0, "high must update to 120");
assert_eq!(p2.low, 100.0, "low stays at 100");
assert_eq!(p2.close, 120.0, "close is most recent price");
assert!((p2.volume - 3.0).abs() < 1e-12);
assert_eq!(p2.trades_count, 2);
let p3 = d.on_upstream_event(&trade_event(59_999, 90.0, 0.5), 0)
.expect("third trade must emit");
assert_eq!(p3.open_time, 0);
assert_eq!(p3.low, 90.0, "new minimum");
assert_eq!(p3.close, 90.0);
assert_eq!(p3.trades_count, 3);
let p4 = d.on_upstream_event(&trade_event(interval_ms, 200.0, 5.0), 0)
.expect("trade in new bucket must emit fresh bar");
assert_eq!(p4.open_time, interval_ms, "new bar starts at next bucket boundary");
assert_eq!(p4.open, 200.0);
assert_eq!(p4.high, 200.0);
assert_eq!(p4.low, 200.0);
assert_eq!(p4.close, 200.0);
assert!((p4.volume - 5.0).abs() < 1e-12);
assert_eq!(p4.trades_count, 1);
}
#[test]
fn trade_to_bar_sub_second_1s() {
let key = kline_key("1s");
let mut d = TradeToBarDerived::new_for_key(&key);
let p1 = d.on_upstream_event(&trade_event(0, 50.0, 1.0), 0).unwrap();
assert_eq!(p1.open_time, 0);
let p2 = d.on_upstream_event(&trade_event(500, 60.0, 1.0), 0).unwrap();
assert_eq!(p2.open_time, 0, "same 1s bucket");
assert_eq!(p2.high, 60.0);
let p3 = d.on_upstream_event(&trade_event(1_000, 55.0, 1.0), 0).unwrap();
assert_eq!(p3.open_time, 1_000, "second 1s bucket starts at 1000ms");
assert_eq!(p3.open, 55.0);
}
#[test]
fn trade_to_bar_ohlc_correctness() {
let key = kline_key("5m");
let mut d = TradeToBarDerived::new_for_key(&key);
let bucket = 0_i64;
let prices = [300.0_f64, 100.0, 500.0, 200.0, 400.0];
let qty = [1.0_f64; 5];
let ts = [0_i64, 10_000, 20_000, 30_000, 40_000];
let mut last = None;
for i in 0..5 {
last = d.on_upstream_event(&trade_event(ts[i], prices[i], qty[i]), 0);
}
let bar = last.unwrap();
assert_eq!(bar.open_time, bucket);
assert_eq!(bar.open, 300.0, "open = first price");
assert_eq!(bar.high, 500.0, "high = max");
assert_eq!(bar.low, 100.0, "low = min");
assert_eq!(bar.close, 400.0, "close = last price");
let expected_vol: f64 = qty.iter().sum();
assert!((bar.volume - expected_vol).abs() < 1e-9, "volume = sum of quantities");
let expected_qvol: f64 = prices.iter().zip(qty.iter()).map(|(p, q)| p * q).sum();
assert!((bar.quote_volume - expected_qvol).abs() < 1e-9);
assert_eq!(bar.trades_count, 5);
}
#[test]
fn trade_to_bar_non_kline_key_safe() {
let key = SeriesKey::new(
ExchangeId::Binance,
AccountType::FuturesCross,
"BTCUSDT",
crate::series::Kind::Trade,
);
let mut d = TradeToBarDerived::new_for_key(&key);
let r = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0);
assert!(r.is_none(), "non-Kline key → interval_ms=0 → no emission");
}
#[test]
fn trade_to_bar_unknown_interval_safe() {
let key = kline_key("99x"); let mut d = TradeToBarDerived::new_for_key(&key);
let r = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0);
assert!(r.is_none(), "unknown interval → interval_ms=0 → no emission");
}
fn range_bar_key(range_fixed: u64) -> SeriesKey {
SeriesKey::new(ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT",
crate::series::Kind::RangeBar(range_fixed))
}
fn tick_bar_key(n: u32) -> SeriesKey {
SeriesKey::new(ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT",
crate::series::Kind::TickBar(n))
}
fn volume_bar_key(vol_fixed: u64) -> SeriesKey {
SeriesKey::new(ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT",
crate::series::Kind::VolumeBar(vol_fixed))
}
fn footprint_key(interval: &str) -> SeriesKey {
SeriesKey::new(ExchangeId::Binance, AccountType::FuturesCross, "BTCUSDT",
crate::series::Kind::Footprint(KlineInterval::new(interval)))
}
fn trade_event_side(ts_ms: i64, price: f64, quantity: f64, side: u8) -> Event {
Event::Trade {
exchange: ExchangeId::Binance,
symbol: "BTCUSDT".to_string(),
point: crate::data::TradePoint { ts_ms, price, quantity, side, trade_id_hash: 0 },
}
}
#[test]
fn range_bar_stays_in_bar_while_within_range() {
let key = range_bar_key(100_000_000);
let mut d = TradeToRangeBarDerived::new_for_key(&key);
let p1 = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
assert_eq!(p1.open, 100.0);
let p2 = d.on_upstream_event(&trade_event(1, 100.5, 1.0), 0).unwrap();
assert_eq!(p2.open_time, p1.open_time, "same bar");
assert_eq!(p2.open, 100.0, "open unchanged");
assert_eq!(p2.high, 100.5, "high updated");
assert_eq!(p2.close, 100.5);
assert_eq!(p2.trades_count, 2);
}
#[test]
fn range_bar_rolls_on_crossing() {
let key = range_bar_key(100_000_000);
let mut d = TradeToRangeBarDerived::new_for_key(&key);
d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
let p = d.on_upstream_event(&trade_event(10, 101.0, 2.0), 0).unwrap();
assert_eq!(p.open, 101.0, "new bar opens at crossing price");
assert_eq!(p.trades_count, 1, "first trade in new bar");
}
#[test]
fn range_bar_ohlc_correct() {
let key = range_bar_key(100_000_000); let mut d = TradeToRangeBarDerived::new_for_key(&key);
d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
d.on_upstream_event(&trade_event(1, 100.9, 1.0), 0).unwrap();
let bar1_last = d.on_upstream_event(&trade_event(2, 100.4, 0.5), 0).unwrap();
assert_eq!(bar1_last.open, 100.0);
assert_eq!(bar1_last.high, 100.9);
assert_eq!(bar1_last.low, 100.0);
assert_eq!(bar1_last.close, 100.4);
}
#[test]
fn range_bar_monotonic_open_time_collision() {
let key = range_bar_key(100_000_000); let mut d = TradeToRangeBarDerived::new_for_key(&key);
let p1 = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
let ot1 = p1.open_time;
let p2 = d.on_upstream_event(&trade_event(0, 101.0, 1.0), 0).unwrap();
assert_ne!(p2.open_time, ot1, "bar2 must not share open_time with bar1");
assert!(p2.open_time > ot1, "bar2 open_time must be strictly greater");
}
#[test]
fn range_bar_zero_range_safe() {
let key = range_bar_key(0);
let mut d = TradeToRangeBarDerived::new_for_key(&key);
assert!(d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).is_none());
}
#[test]
fn tick_bar_rolls_every_n() {
let n = 3u32;
let key = tick_bar_key(n);
let mut d = TradeToTickBarDerived::new_for_key(&key);
let p1 = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
assert_eq!(p1.trades_count, 1);
let p2 = d.on_upstream_event(&trade_event(1, 101.0, 1.0), 0).unwrap();
assert_eq!(p2.trades_count, 2);
let p3 = d.on_upstream_event(&trade_event(2, 99.0, 1.0), 0).unwrap();
assert_eq!(p3.trades_count, 3, "3rd trade completes bar");
let p4 = d.on_upstream_event(&trade_event(3, 102.0, 2.0), 0).unwrap();
assert_eq!(p4.trades_count, 1, "first trade in new bar");
assert_eq!(p4.open, 102.0, "new bar open = 4th trade price");
}
#[test]
fn tick_bar_ohlc_correct() {
let key = tick_bar_key(3);
let mut d = TradeToTickBarDerived::new_for_key(&key);
d.on_upstream_event(&trade_event(0, 200.0, 1.0), 0).unwrap();
d.on_upstream_event(&trade_event(1, 50.0, 1.0), 0).unwrap();
let last = d.on_upstream_event(&trade_event(2, 150.0, 1.0), 0).unwrap();
assert_eq!(last.open, 200.0);
assert_eq!(last.high, 200.0);
assert_eq!(last.low, 50.0);
assert_eq!(last.close, 150.0);
assert!((last.volume - 3.0).abs() < 1e-12);
}
#[test]
fn tick_bar_zero_n_safe() {
let key = tick_bar_key(0);
let mut d = TradeToTickBarDerived::new_for_key(&key);
assert!(d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).is_none());
}
#[test]
fn volume_bar_rolls_on_threshold() {
let key = volume_bar_key(200_000_000);
let mut d = TradeToVolumeBarDerived::new_for_key(&key);
let p1 = d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
assert!((p1.volume - 1.0).abs() < 1e-12);
let p2 = d.on_upstream_event(&trade_event(1, 101.0, 1.0), 0).unwrap();
assert!((p2.volume - 2.0).abs() < 1e-12, "crossing trade in closing bar");
assert_eq!(p2.close, 101.0, "close = crossing trade price");
let p3 = d.on_upstream_event(&trade_event(2, 102.0, 0.5), 0).unwrap();
assert_eq!(p3.open, 102.0, "new bar");
assert_ne!(p3.open_time, p2.open_time);
}
#[test]
fn volume_bar_ohlc_correct() {
let key = volume_bar_key(300_000_000); let mut d = TradeToVolumeBarDerived::new_for_key(&key);
d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).unwrap();
d.on_upstream_event(&trade_event(1, 200.0, 1.0), 0).unwrap();
let last = d.on_upstream_event(&trade_event(2, 50.0, 1.0), 0).unwrap();
assert_eq!(last.open, 100.0);
assert_eq!(last.high, 200.0);
assert_eq!(last.low, 50.0);
assert_eq!(last.close, 50.0);
assert!((last.volume - 3.0).abs() < 1e-12);
}
#[test]
fn volume_bar_zero_threshold_safe() {
let key = volume_bar_key(0);
let mut d = TradeToVolumeBarDerived::new_for_key(&key);
assert!(d.on_upstream_event(&trade_event(0, 100.0, 1.0), 0).is_none());
}
#[test]
fn footprint_per_level_buy_sell() {
let key = footprint_key("1m");
let mut d = TradeToFootprintDerived::new_for_key(&key);
d.on_upstream_event(&trade_event_side(0, 100.0, 1.5, 0), 0); d.on_upstream_event(&trade_event_side(1, 100.0, 0.5, 1), 0); let p = d.on_upstream_event(&trade_event_side(2, 100.0, 1.0, 0), 0).unwrap();
assert_eq!(p.levels.len(), 1, "one unique price level");
let (price, buy, sell) = p.levels[0];
assert!((price - 100.0).abs() < 1e-12);
assert!((buy - 2.5 ).abs() < 1e-12, "buy = 1.5 + 1.0");
assert!((sell - 0.5 ).abs() < 1e-12);
}
#[test]
fn footprint_bucket_roll_resets() {
let key = footprint_key("1m");
let interval_ms = 60_000_i64;
let mut d = TradeToFootprintDerived::new_for_key(&key);
d.on_upstream_event(&trade_event_side(0, 100.0, 1.0, 0), 0);
let p = d.on_upstream_event(&trade_event_side(interval_ms, 200.0, 2.0, 1), 0).unwrap();
assert_eq!(p.open_time, interval_ms, "new bucket");
assert_eq!(p.open, 200.0, "reset to new bucket open");
assert_eq!(p.levels.len(), 1, "only new bucket level");
let (_, buy, sell) = p.levels[0];
assert!((buy - 0.0).abs() < 1e-12);
assert!((sell - 2.0).abs() < 1e-12);
}
#[test]
fn footprint_ohlc_correct() {
let key = footprint_key("1m");
let mut d = TradeToFootprintDerived::new_for_key(&key);
d.on_upstream_event(&trade_event_side(0, 100.0, 1.0, 0), 0);
d.on_upstream_event(&trade_event_side(1, 200.0, 1.0, 1), 0);
let p = d.on_upstream_event(&trade_event_side(2, 50.0, 1.0, 0), 0).unwrap();
assert_eq!(p.open, 100.0);
assert_eq!(p.high, 200.0);
assert_eq!(p.low, 50.0);
assert_eq!(p.close, 50.0);
assert!((p.volume - 3.0).abs() < 1e-12);
}
#[test]
fn footprint_levels_sorted_by_price() {
let key = footprint_key("1m");
let mut d = TradeToFootprintDerived::new_for_key(&key);
d.on_upstream_event(&trade_event_side(0, 300.0, 1.0, 0), 0);
d.on_upstream_event(&trade_event_side(1, 100.0, 1.0, 0), 0);
let p = d.on_upstream_event(&trade_event_side(2, 200.0, 1.0, 1), 0).unwrap();
assert_eq!(p.levels.len(), 3);
let prices: Vec<f64> = p.levels.iter().map(|(pr, _, _)| *pr).collect();
assert!(prices[0] < prices[1] && prices[1] < prices[2],
"levels must be sorted ascending: {:?}", prices);
}
#[test]
fn footprint_unknown_interval_safe() {
let key = footprint_key("99x");
let mut d = TradeToFootprintDerived::new_for_key(&key);
assert!(d.on_upstream_event(&trade_event_side(0, 100.0, 1.0, 0), 0).is_none());
}
}