use crate::data::{
BarPoint, BasisPoint, FootprintPoint, FundingRatePoint, FundingSettlementPoint,
KagiSegmentPoint, MarkPricePoint, IndexPricePoint, PnfColumnPoint,
RenkoBrickPoint, ScalarBarPoint, ThreeLineBreakLinePoint, TpoSessionPoint, TradePoint,
};
use std::collections::VecDeque;
use crate::series::{DataPoint, Kind, TpoSource};
use crate::series::SeriesKey;
use crate::subscription::{Event, Stream};
use digdigdig3::core::websocket::KlineInterval;
pub(crate) trait DerivedStream: Send + 'static {
type Output: DataPoint;
fn deps() -> &'static [Stream];
fn deps_for_key(_key: &SeriesKey) -> Vec<Stream> {
Self::deps().to_vec()
}
fn new_for_key(key: &SeriesKey) -> Self;
fn on_upstream_event(&mut self, ev: &Event, dep_idx: usize) -> Option<Self::Output>;
fn seed_from_events(&mut self, events: &[Event], dep_idx: usize) -> Vec<Self::Output> {
events.iter().filter_map(|e| self.on_upstream_event(e, dep_idx)).collect()
}
}
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 {
eprintln!("[dig3-fp-derived] interval_ms=0 → return None (disabled guard)");
return None;
}
let Event::Trade { point, .. } = ev else {
return None
};
let bucket_start = (point.ts_ms / self.interval_ms) * self.interval_ms;
let rolled = self.current_ohlcv.is_none() || bucket_start > self.current_bucket_start;
if rolled {
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; }
eprintln!(
"[dig3-fp-derived] BUCKET ROLL ts_ms={} bucket_start={} price={:.2} side={} qty={:.6}",
point.ts_ms, bucket_start, point.price, point.side, 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; }
}
let pt = self.build_point(self.current_bucket_start);
if rolled || self.levels.len() % 100 == 0 {
eprintln!(
"[dig3-fp-derived] emit FootprintPoint open_time={} levels.len()={} vol={:.4} ohlc=[o={:.2} h={:.2} l={:.2} c={:.2}]",
pt.open_time, pt.levels.len(), pt.volume, pt.open, pt.high, pt.low, pt.close,
);
}
Some(pt)
}
}
pub(crate) struct TradeToRenkoBarDerived {
box_size: f64,
reversal_count: u8,
anchor: f64,
last_dir: Option<bool>,
vol_acc: f64,
tcount_acc: u64,
last_emitted_open_time: i64,
seeded: bool,
}
impl TradeToRenkoBarDerived {
pub(crate) fn preset_grid_anchor(&mut self, grid_floor: f64) {
self.anchor = grid_floor;
self.seeded = true;
}
}
impl DerivedStream for TradeToRenkoBarDerived {
type Output = RenkoBrickPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let (box_size, reversal_count) = match &key.kind {
Kind::RenkoBar(b, r) if *b > 0 => (*b as f64 / 1e8, (*r).max(1)),
_ => (0.0, 1),
};
Self {
box_size,
reversal_count,
anchor: 0.0,
last_dir: None,
vol_acc: 0.0,
tcount_acc: 0,
last_emitted_open_time: 0,
seeded: false,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<RenkoBrickPoint> {
if self.box_size == 0.0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if !self.seeded {
self.anchor = (point.price / self.box_size).floor() * self.box_size;
self.seeded = true;
}
self.vol_acc += point.quantity;
self.tcount_acc += 1;
let mut last_emitted: Option<RenkoBrickPoint> = None;
loop {
let up_target = self.anchor + self.box_size;
let down_target = self.anchor - self.box_size;
if point.price >= up_target {
if matches!(self.last_dir, Some(false)) {
let needed = self.anchor + self.box_size * self.reversal_count as f64;
if point.price < needed { break; }
self.anchor += self.box_size;
}
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
let brick = RenkoBrickPoint {
open_time,
bottom: self.anchor,
top: self.anchor + self.box_size,
up: true,
volume: self.vol_acc,
trades_count: self.tcount_acc,
};
self.vol_acc = 0.0;
self.tcount_acc = 0;
self.anchor += self.box_size;
self.last_dir = Some(true);
last_emitted = Some(brick);
continue;
}
if point.price <= down_target {
if matches!(self.last_dir, Some(true)) {
let needed = self.anchor - self.box_size * self.reversal_count as f64;
if point.price > needed { break; }
self.anchor -= self.box_size;
}
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
let brick = RenkoBrickPoint {
open_time,
bottom: self.anchor - self.box_size,
top: self.anchor,
up: false,
volume: self.vol_acc,
trades_count: self.tcount_acc,
};
self.vol_acc = 0.0;
self.tcount_acc = 0;
self.anchor -= self.box_size;
self.last_dir = Some(false);
last_emitted = Some(brick);
continue;
}
break;
}
last_emitted
}
}
pub(crate) struct TradeToPnfBarDerived {
box_size: f64,
reversal_count: u8,
cur: Option<PnfColumnPoint>,
next_column_id: u64,
last_emitted_open_time: i64,
}
impl TradeToPnfBarDerived {
pub(crate) fn preset_grid(&mut self, bottom: f64, top: f64, is_x: bool) {
self.cur = Some(PnfColumnPoint {
open_time: 0,
column_id: 0,
is_x,
bottom,
top,
volume: 0.0,
trades_count: 0,
});
}
}
impl DerivedStream for TradeToPnfBarDerived {
type Output = PnfColumnPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let (box_size, reversal_count) = match &key.kind {
Kind::PnfBar(b, r) if *b > 0 => (*b as f64 / 1e8, (*r).max(1)),
_ => (0.0, 3),
};
Self {
box_size,
reversal_count,
cur: None,
next_column_id: 1,
last_emitted_open_time: 0,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<PnfColumnPoint> {
if self.box_size == 0.0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if self.cur.is_none() {
let bottom = (point.price / self.box_size).floor() * self.box_size;
let top = bottom + self.box_size;
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
self.cur = Some(PnfColumnPoint {
open_time,
column_id: self.next_column_id,
is_x: true,
bottom,
top,
volume: point.quantity,
trades_count: 1,
});
self.next_column_id += 1;
return self.cur.clone();
}
let col = self.cur.as_mut().unwrap();
col.volume += point.quantity;
col.trades_count += 1;
if col.is_x {
if point.price >= col.top + self.box_size {
let steps = ((point.price - col.top) / self.box_size).floor() as i64;
col.top += steps as f64 * self.box_size;
return self.cur.clone();
}
if point.price <= col.top - self.box_size * self.reversal_count as f64 {
let new_top = col.top - self.box_size;
let drop_steps = (((col.top - self.box_size) - point.price)
/ self.box_size).floor() as i64 + 1;
let new_bot = new_top - drop_steps.max(1) as f64 * self.box_size;
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
self.cur = Some(PnfColumnPoint {
open_time,
column_id: self.next_column_id,
is_x: false,
bottom: new_bot,
top: new_top,
volume: 0.0,
trades_count: 0,
});
self.next_column_id += 1;
return self.cur.clone();
}
} else {
if point.price <= col.bottom - self.box_size {
let steps = ((col.bottom - point.price) / self.box_size).floor() as i64;
col.bottom -= steps as f64 * self.box_size;
return self.cur.clone();
}
if point.price >= col.bottom + self.box_size * self.reversal_count as f64 {
let new_bot = col.bottom + self.box_size;
let rise_steps = ((point.price - (col.bottom + self.box_size))
/ self.box_size).floor() as i64 + 1;
let new_top = new_bot + rise_steps.max(1) as f64 * self.box_size;
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
self.cur = Some(PnfColumnPoint {
open_time,
column_id: self.next_column_id,
is_x: true,
bottom: new_bot,
top: new_top,
volume: 0.0,
trades_count: 0,
});
self.next_column_id += 1;
return self.cur.clone();
}
}
self.cur.clone()
}
}
pub(crate) struct TradeToKagiBarDerived {
reversal: f64,
anchor: f64,
seeded: bool,
up: bool,
last_shoulder: f64,
last_waist: f64,
last_emitted_open_time: i64,
}
impl DerivedStream for TradeToKagiBarDerived {
type Output = KagiSegmentPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let reversal = match &key.kind {
Kind::KagiBar(r) if *r > 0 => *r as f64 / 1e8,
_ => 0.0,
};
Self {
reversal,
anchor: 0.0,
seeded: false,
up: true,
last_shoulder: f64::NEG_INFINITY,
last_waist: f64::INFINITY,
last_emitted_open_time: 0,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<KagiSegmentPoint> {
if self.reversal == 0.0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if !self.seeded {
if self.anchor == 0.0 {
self.anchor = point.price;
return None;
}
if (point.price - self.anchor).abs() >= self.reversal {
self.up = point.price > self.anchor;
if self.up { self.last_waist = self.anchor; }
else { self.last_shoulder = self.anchor; }
self.seeded = true;
let open_time = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = open_time;
let prev_anchor = self.anchor;
self.anchor = point.price;
return Some(KagiSegmentPoint {
open_time,
start_price: prev_anchor,
end_price: point.price,
up: self.up,
yang: true,
is_connector: false,
});
}
if self.up && point.price > self.anchor { self.anchor = point.price; }
if !self.up && point.price < self.anchor { self.anchor = point.price; }
return None;
}
if self.up && point.price > self.anchor {
self.anchor = point.price;
return None;
}
if !self.up && point.price < self.anchor {
self.anchor = point.price;
return None;
}
let against = if self.up { self.anchor - point.price }
else { point.price - self.anchor };
if against < self.reversal { return None; }
let yang = if self.up {
self.anchor > self.last_shoulder
} else {
self.anchor < self.last_waist
};
if self.up { self.last_shoulder = self.last_shoulder.max(self.anchor); }
else { self.last_waist = self.last_waist.min(self.anchor); }
let close_ts = point.ts_ms.max(self.last_emitted_open_time + 1);
self.last_emitted_open_time = close_ts;
let closed = KagiSegmentPoint {
open_time: close_ts,
start_price: if self.up { self.anchor - against - 0.0 } else { self.anchor + against - 0.0 },
end_price: self.anchor,
up: self.up,
yang,
is_connector: false,
};
self.up = !self.up;
self.anchor = point.price;
Some(closed)
}
}
pub(crate) struct TradeToCvdLineDerived {
enabled: bool,
cvd: f64,
last_emitted_ts: i64,
}
impl DerivedStream for TradeToCvdLineDerived {
type Output = ScalarBarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let enabled = matches!(key.kind, Kind::CvdLine);
Self { enabled, cvd: 0.0, last_emitted_ts: 0 }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<ScalarBarPoint> {
if !self.enabled { return None; }
let Event::Trade { point, .. } = ev else { return None };
let signed = if point.side == 0 { point.quantity } else { -point.quantity };
self.cvd += signed;
let ts_ms = point.ts_ms.max(self.last_emitted_ts + 1);
self.last_emitted_ts = ts_ms;
Some(ScalarBarPoint { ts_ms, value: self.cvd })
}
}
pub(crate) struct TpoSessionState {
freq_minutes: u16,
value_area_pct: f64,
session_date_ms: i64,
buckets: Vec<(f64, f64)>,
samples: Vec<f64>,
tick_size: f64,
session_high: f64,
session_low: f64,
history: VecDeque<TpoSessionPoint>,
max_history_sessions: usize,
}
impl TpoSessionState {
fn new(freq_minutes: u16) -> Self {
Self {
freq_minutes,
value_area_pct: 0.70,
session_date_ms: 0,
buckets: Vec::new(),
samples: Vec::new(),
tick_size: 0.0,
session_high: f64::NEG_INFINITY,
session_low: f64::INFINITY,
history: VecDeque::new(),
max_history_sessions: 10,
}
}
fn day_start_ms(ts_ms: i64) -> i64 {
const MS_PER_DAY: i64 = 86_400_000;
(ts_ms / MS_PER_DAY) * MS_PER_DAY
}
fn bucket_idx(&self, ts_ms: i64) -> usize {
let into_session_ms = ts_ms - self.session_date_ms;
(into_session_ms / (self.freq_minutes as i64 * 60_000)).max(0) as usize
}
fn maybe_roll_session(&mut self, ts_ms: i64) {
let bar_day = Self::day_start_ms(ts_ms);
if bar_day != self.session_date_ms {
let has_data = self.buckets.iter().any(|(h, l)| h.is_finite() && l.is_finite());
if has_data && self.session_date_ms != 0 {
let closed = self.build_point();
self.history.push_back(closed);
if self.history.len() > self.max_history_sessions {
self.history.pop_front();
}
}
self.session_date_ms = bar_day;
self.buckets.clear();
self.samples.clear();
self.tick_size = 0.0;
self.session_high = f64::NEG_INFINITY;
self.session_low = f64::INFINITY;
}
}
fn extend_bucket(&mut self, ts_ms: i64, high: f64, low: f64, price_sample: f64) {
self.maybe_roll_session(ts_ms);
if high > self.session_high { self.session_high = high; }
if low < self.session_low { self.session_low = low; }
self.samples.push(price_sample);
if self.tick_size == 0.0 && self.samples.len() >= 10 {
self.tick_size = self.detect_tick_size();
}
let bi = self.bucket_idx(ts_ms);
while self.buckets.len() <= bi {
self.buckets.push((f64::NEG_INFINITY, f64::INFINITY));
}
let (bhigh, blow) = &mut self.buckets[bi];
if high > *bhigh { *bhigh = high; }
if low < *blow { *blow = low; }
}
fn detect_tick_size(&self) -> f64 {
if self.samples.len() < 2 { return 0.0; }
let mut min_step = f64::INFINITY;
for w in self.samples.windows(2) {
let d = (w[1] - w[0]).abs();
if d > 0.0 && d < min_step { min_step = d; }
}
if min_step.is_finite() && min_step > 0.0 {
min_step
} else {
((self.session_high - self.session_low) / 200.0).max(0.01)
}
}
fn build_point(&self) -> TpoSessionPoint {
let alphabet: Vec<char> = ('A'..='Z').chain('a'..='z').collect();
let tick = if self.tick_size > 0.0 {
self.tick_size
} else {
((self.session_high - self.session_low) / 200.0).max(0.01)
};
let n_rows = (((self.session_high - self.session_low) / tick).ceil() as usize + 1).max(1);
let mut rows: Vec<(f64, Vec<char>)> = (0..n_rows)
.map(|i| (self.session_low + i as f64 * tick, Vec::<char>::new()))
.collect();
for (bi, &(bhigh, blow)) in self.buckets.iter().enumerate() {
if !bhigh.is_finite() || !blow.is_finite() { continue; }
let letter = alphabet[bi % 52];
for (price, letters) in rows.iter_mut() {
if *price >= blow && *price < bhigh {
letters.push(letter);
}
}
}
let max_count = rows.iter().map(|(_, l)| l.len()).max().unwrap_or(0);
let mid = (self.session_high + self.session_low) * 0.5;
let poc_price = rows.iter()
.filter(|(_, l)| l.len() == max_count)
.min_by(|(p1, _), (p2, _)| {
(p1 - mid).abs().partial_cmp(&(p2 - mid).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(p, _)| *p)
.unwrap_or(mid);
let total: usize = rows.iter().map(|(_, l)| l.len()).sum();
let target = (total as f64 * self.value_area_pct).ceil() as usize;
let poc_idx = rows.iter().position(|(p, _)| (*p - poc_price).abs() < tick * 0.5)
.unwrap_or(rows.len() / 2);
let mut acc = rows[poc_idx].1.len();
let mut va_lo_idx = poc_idx;
let mut va_hi_idx = poc_idx;
while acc < target && (va_lo_idx > 0 || va_hi_idx + 1 < rows.len()) {
let above_n = if va_hi_idx + 1 < rows.len() { rows[va_hi_idx + 1].1.len() } else { 0 };
let below_n = if va_lo_idx > 0 { rows[va_lo_idx - 1].1.len() } else { 0 };
if above_n >= below_n && va_hi_idx + 1 < rows.len() {
va_hi_idx += 1;
acc += above_n;
} else if va_lo_idx > 0 {
va_lo_idx -= 1;
acc += below_n;
} else if va_hi_idx + 1 < rows.len() {
va_hi_idx += 1;
acc += above_n;
} else {
break;
}
}
let vah_price = rows[va_hi_idx].0;
let val_price = rows[va_lo_idx].0;
let row_letters: Vec<(f64, Vec<char>)> = rows.into_iter()
.filter(|(_, l)| !l.is_empty())
.collect();
TpoSessionPoint {
open_time: self.session_date_ms,
tick_size: tick,
session_high: self.session_high,
session_low: self.session_low,
poc_price,
vah_price,
val_price,
rows: row_letters,
}
}
}
pub(crate) struct TpoFromKline1mDerived {
state: TpoSessionState,
}
impl DerivedStream for TpoFromKline1mDerived {
type Output = TpoSessionPoint;
fn deps() -> &'static [Stream] { &[] }
fn deps_for_key(_key: &SeriesKey) -> Vec<Stream> {
vec![Stream::Kline(KlineInterval::new("1m"))]
}
fn new_for_key(key: &SeriesKey) -> Self {
let freq_minutes = match &key.kind {
Kind::TpoProfile(f, TpoSource::Kline1m) if *f > 0 => *f,
_ => 30,
};
Self { state: TpoSessionState::new(freq_minutes) }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<TpoSessionPoint> {
let Event::Bar { point, .. } = ev else { return None };
self.state.extend_bucket(point.open_time, point.high, point.low, point.close);
Some(self.state.build_point())
}
}
pub(crate) struct TpoFromTradeDerived {
state: TpoSessionState,
}
impl DerivedStream for TpoFromTradeDerived {
type Output = TpoSessionPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let freq_minutes = match &key.kind {
Kind::TpoProfile(f, TpoSource::TradeBucket) if *f > 0 => *f,
_ => 30,
};
Self { state: TpoSessionState::new(freq_minutes) }
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<TpoSessionPoint> {
let Event::Trade { point, .. } = ev else { return None };
self.state.extend_bucket(point.ts_ms, point.price, point.price, point.price);
Some(self.state.build_point())
}
}
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>();
};
pub(crate) struct TradeToDollarBarDerived {
threshold: f64,
cumulative_dollars: f64,
current: Option<BarPoint>,
last_emitted_open_time: i64,
}
impl DerivedStream for TradeToDollarBarDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let threshold = match &key.kind {
Kind::DollarBar { dollar_threshold } if *dollar_threshold > 0 => *dollar_threshold as f64,
_ => 0.0,
};
Self { threshold, cumulative_dollars: 0.0, 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,
});
self.cumulative_dollars = point.price * point.quantity;
} 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.cumulative_dollars += point.price * point.quantity;
}
let bar = self.current.clone()?;
if self.cumulative_dollars >= self.threshold {
self.current = None;
self.cumulative_dollars = 0.0;
}
Some(bar)
}
}
pub(crate) struct TradeToTickImbalanceDerived {
alpha: f64,
min_ticks: u32,
theta: f64,
tick_count: u32,
expected_theta_abs: f64,
expected_ticks: f64,
current: Option<BarPoint>,
last_emitted_open_time: i64,
}
impl TradeToTickImbalanceDerived {
fn close_threshold(&self) -> f64 {
let dyn_threshold = self.expected_theta_abs * self.expected_ticks;
dyn_threshold.max(self.min_ticks as f64 * 0.5)
}
}
impl DerivedStream for TradeToTickImbalanceDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let (alpha, min_ticks) = match &key.kind {
Kind::TickImbalanceBar { alpha_x100, min_ticks } if *min_ticks > 0 => {
(*alpha_x100 as f64 / 100.0, *min_ticks)
}
_ => (0.0, 0),
};
let expected_ticks = min_ticks as f64;
let expected_theta_abs = min_ticks as f64 * 0.5;
Self {
alpha, min_ticks, theta: 0.0, tick_count: 0,
expected_theta_abs, expected_ticks,
current: None, last_emitted_open_time: 0,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.alpha == 0.0 || self.min_ticks == 0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
let bt = if point.side == 0 { 1.0f64 } else { -1.0f64 };
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.theta = bt;
self.tick_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.theta += bt;
self.tick_count += 1;
}
let bar = self.current.clone()?;
if self.theta.abs() >= self.close_threshold() {
let t = self.tick_count as f64;
let theta_abs = self.theta.abs();
self.expected_ticks = self.alpha * t + (1.0 - self.alpha) * self.expected_ticks;
self.expected_theta_abs = self.alpha * theta_abs + (1.0 - self.alpha) * self.expected_theta_abs;
self.current = None;
self.theta = 0.0;
self.tick_count = 0;
}
Some(bar)
}
}
pub(crate) struct TradeToVolumeImbalanceDerived {
alpha: f64,
min_ticks: u32,
theta: f64,
tick_count: u32,
expected_theta_abs: f64,
expected_ticks: f64,
current: Option<BarPoint>,
last_emitted_open_time: i64,
}
impl TradeToVolumeImbalanceDerived {
fn close_threshold(&self) -> f64 {
let dyn_threshold = self.expected_theta_abs * self.expected_ticks;
dyn_threshold.max(self.min_ticks as f64 * 0.5)
}
}
impl DerivedStream for TradeToVolumeImbalanceDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let (alpha, min_ticks) = match &key.kind {
Kind::VolumeImbalanceBar { alpha_x100, min_ticks } if *min_ticks > 0 => {
(*alpha_x100 as f64 / 100.0, *min_ticks)
}
_ => (0.0, 0),
};
let expected_ticks = min_ticks as f64;
let expected_theta_abs = min_ticks as f64 * 0.5;
Self {
alpha, min_ticks, theta: 0.0, tick_count: 0,
expected_theta_abs, expected_ticks,
current: None, last_emitted_open_time: 0,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.alpha == 0.0 || self.min_ticks == 0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
let bt_vol = if point.side == 0 { point.quantity } else { -point.quantity };
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.theta = bt_vol;
self.tick_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.theta += bt_vol;
self.tick_count += 1;
}
let bar = self.current.clone()?;
if self.theta.abs() >= self.close_threshold() {
let t = self.tick_count as f64;
let theta_abs = self.theta.abs();
self.expected_ticks = self.alpha * t + (1.0 - self.alpha) * self.expected_ticks;
self.expected_theta_abs = self.alpha * theta_abs + (1.0 - self.alpha) * self.expected_theta_abs;
self.current = None;
self.theta = 0.0;
self.tick_count = 0;
}
Some(bar)
}
}
pub(crate) struct TradeToRunBarDerived {
alpha: f64,
min_ticks: u32,
run_buy_len: u32,
run_sell_len: u32,
last_side: u8,
expected_run: f64,
expected_ticks: f64,
tick_count: u32,
current: Option<BarPoint>,
last_emitted_open_time: i64,
}
impl TradeToRunBarDerived {
fn close_threshold(&self) -> f64 {
let dyn_threshold = self.expected_run * self.expected_ticks;
dyn_threshold.max(2.0)
}
}
impl DerivedStream for TradeToRunBarDerived {
type Output = BarPoint;
fn deps() -> &'static [Stream] { &[Stream::Trade] }
fn new_for_key(key: &SeriesKey) -> Self {
let (alpha, min_ticks) = match &key.kind {
Kind::RunBar { alpha_x100, min_ticks } if *min_ticks > 0 => {
(*alpha_x100 as f64 / 100.0, *min_ticks)
}
_ => (0.0, 0),
};
let expected_ticks = min_ticks as f64;
let expected_run = min_ticks as f64 * 0.25;
Self {
alpha, min_ticks, run_buy_len: 0, run_sell_len: 0, last_side: u8::MAX,
expected_run, expected_ticks, tick_count: 0,
current: None, last_emitted_open_time: 0,
}
}
fn on_upstream_event(&mut self, ev: &Event, _dep_idx: usize) -> Option<BarPoint> {
if self.alpha == 0.0 || self.min_ticks == 0 { return None; }
let Event::Trade { point, .. } = ev else { return None };
if point.side == 0 {
if self.last_side == 0 {
self.run_buy_len = self.run_buy_len.saturating_add(1);
} else {
self.run_buy_len = 1;
self.run_sell_len = 0;
}
} else {
if self.last_side == 1 {
self.run_sell_len = self.run_sell_len.saturating_add(1);
} else {
self.run_sell_len = 1;
self.run_buy_len = 0;
}
}
self.last_side = point.side;
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.tick_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.tick_count += 1;
}
let bar = self.current.clone()?;
let max_run = self.run_buy_len.max(self.run_sell_len) as f64;
if max_run >= self.close_threshold() {
let t = self.tick_count as f64;
self.expected_ticks = self.alpha * t + (1.0 - self.alpha) * self.expected_ticks;
self.expected_run = self.alpha * max_run + (1.0 - self.alpha) * self.expected_run;
self.current = None;
self.tick_count = 0;
}
Some(bar)
}
}
pub(crate) struct TradeToThreeLineBreakDerived {
lines_back: usize,
last_lines: std::collections::VecDeque<ThreeLineBreakLinePoint>,
current_open: Option<f64>,
current_open_ts: i64,
current_close: f64,
current_volume: f64,
last_emitted_open_ts: i64,
}
impl TradeToThreeLineBreakDerived {
fn emit_line(&mut self, direction: u8, ts_close: i64) -> ThreeLineBreakLinePoint {
let open = self.current_open.unwrap_or(self.current_close);
let close = self.current_close;
let ts_open = self.current_open_ts.max(self.last_emitted_open_ts + 1);
self.last_emitted_open_ts = ts_open;
let point = ThreeLineBreakLinePoint {
ts_open,
ts_close,
open,
close,
volume: self.current_volume,
direction,
};
self.last_lines.push_back(point.clone());
if self.last_lines.len() > self.lines_back {
self.last_lines.pop_front();
}
self.current_open = Some(close);
self.current_open_ts = ts_close;
self.current_volume = 0.0;
point
}
}
impl DerivedStream for TradeToThreeLineBreakDerived {
type Output = ThreeLineBreakLinePoint;
fn deps() -> &'static [Stream] {
&[Stream::Trade]
}
fn new_for_key(key: &SeriesKey) -> Self {
let lines_back = match &key.kind {
Kind::ThreeLineBreak { lines_back } if *lines_back > 0 => *lines_back as usize,
_ => 3,
};
Self {
lines_back,
last_lines: std::collections::VecDeque::with_capacity(lines_back + 1),
current_open: None,
current_open_ts: 0,
current_close: 0.0,
current_volume: 0.0,
last_emitted_open_ts: 0,
}
}
fn on_upstream_event(
&mut self,
ev: &Event,
_dep_idx: usize,
) -> Option<ThreeLineBreakLinePoint> {
if self.lines_back == 0 {
return None;
}
let Event::Trade { point, .. } = ev else {
return None;
};
if self.current_open.is_none() {
self.current_open = Some(point.price);
self.current_open_ts = point.ts_ms;
self.current_close = point.price;
self.current_volume += point.quantity;
return None;
}
self.current_close = point.price;
self.current_volume += point.quantity;
if self.last_lines.is_empty() {
let seed_open = self.current_open.unwrap_or(self.current_close);
if (self.current_close - seed_open).abs() < f64::EPSILON {
return None; }
let direction = if self.current_close > seed_open { 0u8 } else { 1u8 };
return Some(self.emit_line(direction, point.ts_ms));
}
let max_high = self
.last_lines
.iter()
.map(|l| l.open.max(l.close))
.fold(f64::NEG_INFINITY, f64::max);
let min_low = self
.last_lines
.iter()
.map(|l| l.open.min(l.close))
.fold(f64::INFINITY, f64::min);
let last_dir = self.last_lines.back().map(|l| l.direction).unwrap_or(0);
match last_dir {
0 => {
if self.current_close > max_high {
return Some(self.emit_line(0, point.ts_ms));
}
if self.current_close < min_low {
return Some(self.emit_line(1, point.ts_ms));
}
}
_ => {
if self.current_close < min_low {
return Some(self.emit_line(1, point.ts_ms));
}
if self.current_close > max_high {
return Some(self.emit_line(0, point.ts_ms));
}
}
}
None
}
}
pub(crate) fn kline_to_synthetic_trades(
bar: &BarPoint,
interval_ms: i64,
exchange: digdigdig3::core::types::ExchangeId,
symbol: &str,
) -> Vec<Event> {
if interval_ms <= 0 {
return Vec::new();
}
let bullish = bar.close >= bar.open;
let path: [f64; 4] = if bullish {
[bar.open, bar.low, bar.high, bar.close]
} else {
[bar.open, bar.high, bar.low, bar.close]
};
let leg_volume = bar.volume / 4.0;
let step_ms = interval_ms / 4;
(0..4)
.map(|i| {
let price = path[i];
let side = if i == 0 {
u8::from(!bullish)
} else {
u8::from(path[i] < path[i - 1])
};
Event::Trade {
exchange,
symbol: symbol.to_string(),
point: TradePoint {
ts_ms: bar.open_time + step_ms * i as i64,
price,
quantity: leg_volume,
side,
trade_id_hash: 0,
},
}
})
.collect()
}
#[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");
}
#[test]
fn seed_from_events_primes_state_and_returns_bars() {
let key = kline_key("1m");
let mut d = TradeToBarDerived::new_for_key(&key);
let evs = vec![
trade_event(0, 100.0, 1.0),
trade_event(30_000, 120.0, 2.0),
];
let emitted = d.seed_from_events(&evs, 0);
assert_eq!(emitted.len(), 2, "one bar emission per trade");
let last = &emitted[1];
assert_eq!(last.open, 100.0, "open = first trade");
assert_eq!(last.high, 120.0, "high = second trade");
assert_eq!(last.trades_count, 2);
let cont = d.on_upstream_event(&trade_event(59_000, 110.0, 0.5), 0).unwrap();
assert_eq!(cont.trades_count, 3, "live trade after seed updates state");
}
#[test]
fn seed_from_events_range_bar_state_primed() {
let key = range_bar_key(100_000_000); let mut d = TradeToRangeBarDerived::new_for_key(&key);
let evs = vec![trade_event(0, 100.0, 1.0)];
let emitted = d.seed_from_events(&evs, 0);
assert_eq!(emitted.len(), 1);
let live = d.on_upstream_event(&trade_event(1, 101.0, 1.0), 0).unwrap();
assert_eq!(live.open, 101.0, "new bar at crossing price");
}
#[test]
fn seed_from_events_empty_slice_noop() {
let key = kline_key("1m");
let mut d = TradeToBarDerived::new_for_key(&key);
let emitted = d.seed_from_events(&[], 0);
assert!(emitted.is_empty());
let p = d.on_upstream_event(&trade_event(0, 50.0, 1.0), 0).unwrap();
assert_eq!(p.trades_count, 1);
}
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());
}
fn sample_bar(open: f64, high: f64, low: f64, close: f64, volume: f64) -> BarPoint {
BarPoint {
open_time: 1_000,
open,
high,
low,
close,
volume,
quote_volume: f64::NAN,
trades_count: 0,
}
}
#[test]
fn kline_to_synthetic_trades_bullish_path_is_o_l_h_c() {
let bar = sample_bar(100.0, 120.0, 90.0, 110.0, 40.0);
let events = kline_to_synthetic_trades(&bar, 60_000, ExchangeId::Binance, "BTCUSDT");
assert_eq!(events.len(), 4);
let prices: Vec<f64> = events.iter().map(|e| {
let Event::Trade { point, .. } = e else { panic!("expected Event::Trade") };
point.price
}).collect();
assert_eq!(prices, vec![100.0, 90.0, 120.0, 110.0], "bullish bar must trace O->L->H->C");
}
#[test]
fn kline_to_synthetic_trades_bearish_path_is_o_h_l_c() {
let bar = sample_bar(110.0, 120.0, 90.0, 100.0, 40.0);
let events = kline_to_synthetic_trades(&bar, 60_000, ExchangeId::Binance, "BTCUSDT");
assert_eq!(events.len(), 4);
let prices: Vec<f64> = events.iter().map(|e| {
let Event::Trade { point, .. } = e else { panic!("expected Event::Trade") };
point.price
}).collect();
assert_eq!(prices, vec![110.0, 120.0, 90.0, 100.0], "bearish bar must trace O->H->L->C");
}
#[test]
fn kline_to_synthetic_trades_timestamps_evenly_spaced_within_bar() {
let bar = sample_bar(100.0, 120.0, 90.0, 110.0, 40.0);
let events = kline_to_synthetic_trades(&bar, 60_000, ExchangeId::Binance, "BTCUSDT");
let ts: Vec<i64> = events.iter().map(|e| {
let Event::Trade { point, .. } = e else { panic!("expected Event::Trade") };
point.ts_ms
}).collect();
assert_eq!(ts, vec![1_000, 16_000, 31_000, 46_000]);
assert!(ts.iter().all(|t| *t >= bar.open_time && *t < bar.open_time + 60_000));
}
#[test]
fn kline_to_synthetic_trades_volume_split_evenly() {
let bar = sample_bar(100.0, 120.0, 90.0, 110.0, 40.0);
let events = kline_to_synthetic_trades(&bar, 60_000, ExchangeId::Binance, "BTCUSDT");
for e in &events {
let Event::Trade { point, .. } = e else { panic!("expected Event::Trade") };
assert!((point.quantity - 10.0).abs() < 1e-12, "each leg should carry volume/4");
}
}
#[test]
fn kline_to_synthetic_trades_zero_interval_returns_empty() {
let bar = sample_bar(100.0, 120.0, 90.0, 110.0, 40.0);
assert!(kline_to_synthetic_trades(&bar, 0, ExchangeId::Binance, "BTCUSDT").is_empty());
}
#[test]
fn kline_to_synthetic_trades_carries_exchange_and_symbol() {
let bar = sample_bar(100.0, 120.0, 90.0, 110.0, 40.0);
let events = kline_to_synthetic_trades(&bar, 60_000, ExchangeId::Bybit, "ETHUSDT");
for e in &events {
let Event::Trade { exchange, symbol, .. } = e else { panic!("expected Event::Trade") };
assert_eq!(*exchange, ExchangeId::Bybit);
assert_eq!(symbol, "ETHUSDT");
}
}
}