use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
use rust_decimal::Decimal;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Tick {
pub symbol: Symbol,
pub price: Price,
pub quantity: Quantity,
pub side: Side,
pub timestamp: NanoTimestamp,
}
impl Tick {
pub fn new(
symbol: Symbol,
price: Price,
quantity: Quantity,
side: Side,
timestamp: NanoTimestamp,
) -> Self {
Self {
symbol,
price,
quantity,
side,
timestamp,
}
}
pub fn notional(&self) -> Decimal {
self.price.value() * self.quantity.value()
}
pub fn notional_checked(&self) -> Option<Decimal> {
self.price.checked_mul(self.quantity)
}
pub fn is_buy_aggressor(&self) -> bool {
self.side == Side::Bid
}
pub fn is_sell_aggressor(&self) -> bool {
self.side == Side::Ask
}
pub fn is_buy(&self) -> bool {
self.side == Side::Bid
}
pub fn is_sell(&self) -> bool {
self.side == Side::Ask
}
pub fn is_uptick(&self, prev: &Tick) -> bool {
self.price.value() > prev.price.value()
}
pub fn is_downtick(&self, prev: &Tick) -> bool {
self.price.value() < prev.price.value()
}
pub fn delta(ticks: &[Tick]) -> Decimal {
ticks.iter().map(|t| {
match t.side {
Side::Bid => t.quantity.value(),
Side::Ask => -t.quantity.value(),
}
}).sum()
}
pub fn cumulative_delta(ticks: &[Tick]) -> Vec<Decimal> {
let mut running = Decimal::ZERO;
ticks
.iter()
.map(|t| {
match t.side {
Side::Bid => running += t.quantity.value(),
Side::Ask => running -= t.quantity.value(),
}
running
})
.collect()
}
pub fn average_price(ticks: &[Tick]) -> Option<Decimal> {
if ticks.is_empty() {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let sum: Decimal = ticks.iter().map(|t| t.price.value()).sum();
Some(sum / Decimal::from(ticks.len() as u32))
}
pub fn buy_volume(ticks: &[Tick]) -> Decimal {
ticks
.iter()
.filter(|t| t.side == Side::Bid)
.map(|t| t.quantity.value())
.sum()
}
pub fn sell_volume(ticks: &[Tick]) -> Decimal {
ticks
.iter()
.filter(|t| t.side == Side::Ask)
.map(|t| t.quantity.value())
.sum()
}
pub fn vwap_from_slice(ticks: &[Tick]) -> Option<Decimal> {
let total_qty: Decimal = ticks.iter().map(|t| t.quantity.value()).sum();
if total_qty.is_zero() {
return None;
}
let weighted: Decimal = ticks.iter().map(|t| t.price.value() * t.quantity.value()).sum();
Some(weighted / total_qty)
}
pub fn max_price(ticks: &[Tick]) -> Option<Price> {
ticks.iter().map(|t| t.price).max_by(|a, b| a.value().cmp(&b.value()))
}
pub fn min_price(ticks: &[Tick]) -> Option<Price> {
ticks.iter().map(|t| t.price).min_by(|a, b| a.value().cmp(&b.value()))
}
pub fn time_weighted_avg_price(ticks: &[Tick]) -> Option<Decimal> {
if ticks.len() < 2 {
return None;
}
let mut total_weight = 0u128;
let mut weighted_sum = Decimal::ZERO;
for i in 1..ticks.len() {
let elapsed = ticks[i].timestamp.nanos()
.saturating_sub(ticks[i - 1].timestamp.nanos())
.max(0) as u128;
total_weight = total_weight.saturating_add(elapsed);
#[allow(clippy::cast_possible_truncation)]
let w = Decimal::from(elapsed as u64);
weighted_sum += ticks[i].price.value() * w;
}
if total_weight == 0 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
Some(weighted_sum / Decimal::from(total_weight as u64))
}
pub fn largest_trade(ticks: &[Tick]) -> Option<&Tick> {
ticks.iter().max_by(|a, b| {
let na = a.price.value() * a.quantity.value();
let nb = b.price.value() * b.quantity.value();
na.cmp(&nb)
})
}
pub fn classify_aggressor(&self) -> &'static str {
match self.side {
Side::Bid => "market_buy",
Side::Ask => "market_sell",
}
}
pub fn imbalance_ratio(ticks: &[Tick]) -> Option<Decimal> {
let total: Decimal = ticks.iter().map(|t| t.quantity.value()).sum();
if total.is_zero() {
return None;
}
let buy_vol = Self::buy_volume(ticks);
Some(buy_vol / total)
}
pub fn count_by_side(ticks: &[Tick]) -> (usize, usize) {
let buy = ticks.iter().filter(|t| t.side == Side::Bid).count();
let sell = ticks.len() - buy;
(buy, sell)
}
pub fn notional_volume(ticks: &[Tick]) -> Decimal {
ticks.iter().map(|t| t.price.value() * t.quantity.value()).sum()
}
pub fn tick_direction_series(ticks: &[Tick]) -> Vec<i8> {
if ticks.is_empty() {
return vec![];
}
let mut result = Vec::with_capacity(ticks.len());
result.push(0i8);
for w in ticks.windows(2) {
let prev = w[0].price.value();
let curr = w[1].price.value();
result.push(if curr > prev { 1 } else if curr < prev { -1 } else { 0 });
}
result
}
pub fn median_price(ticks: &[Tick]) -> Option<Decimal> {
if ticks.is_empty() {
return None;
}
let mut prices: Vec<Decimal> = ticks.iter().map(|t| t.price.value()).collect();
prices.sort_unstable_by(|a, b| a.cmp(b));
let mid = prices.len() / 2;
if prices.len() % 2 == 0 {
Some((prices[mid - 1] + prices[mid]) / Decimal::TWO)
} else {
Some(prices[mid])
}
}
pub fn price_impact(ticks: &[Tick], ref_price: Decimal) -> Option<Decimal> {
if ticks.is_empty() {
return None;
}
let total_qty: Decimal = ticks.iter().map(|t| t.quantity.value()).sum();
if total_qty.is_zero() {
return None;
}
let weighted_dev: Decimal = ticks
.iter()
.map(|t| (t.price.value() - ref_price) * t.quantity.value())
.sum();
Some(weighted_dev / total_qty)
}
pub fn cluster_count(ticks: &[Tick], gap_ns: u64) -> usize {
if ticks.is_empty() {
return 0;
}
let mut clusters = 1usize;
for w in ticks.windows(2) {
let t0 = w[0].timestamp.nanos() as u64;
let t1 = w[1].timestamp.nanos() as u64;
if t1.saturating_sub(t0) > gap_ns {
clusters += 1;
}
}
clusters
}
}
#[derive(Clone)]
pub struct TickFilter {
symbol: Option<Symbol>,
side: Option<Side>,
min_qty: Option<Quantity>,
max_qty: Option<Quantity>,
min_price: Option<Price>,
max_price: Option<Price>,
min_notional: Option<rust_decimal::Decimal>,
max_notional: Option<rust_decimal::Decimal>,
from_ts: Option<NanoTimestamp>,
to_ts: Option<NanoTimestamp>,
}
impl TickFilter {
pub fn new() -> Self {
Self {
symbol: None,
side: None,
min_qty: None,
max_qty: None,
min_price: None,
max_price: None,
min_notional: None,
max_notional: None,
from_ts: None,
to_ts: None,
}
}
#[must_use]
pub fn symbol(mut self, s: Symbol) -> Self {
self.symbol = Some(s);
self
}
#[must_use]
pub fn side(mut self, s: Side) -> Self {
self.side = Some(s);
self
}
#[must_use]
pub fn min_quantity(mut self, q: Quantity) -> Self {
self.min_qty = Some(q);
self
}
#[must_use]
pub fn max_quantity(mut self, q: Quantity) -> Self {
self.max_qty = Some(q);
self
}
#[must_use]
pub fn min_price(mut self, p: Price) -> Self {
self.min_price = Some(p);
self
}
#[must_use]
pub fn max_price(mut self, p: Price) -> Self {
self.max_price = Some(p);
self
}
#[must_use]
pub fn min_notional(mut self, n: rust_decimal::Decimal) -> Self {
self.min_notional = Some(n);
self
}
#[must_use]
pub fn max_notional(mut self, n: rust_decimal::Decimal) -> Self {
self.max_notional = Some(n);
self
}
#[must_use]
pub fn timestamp_range(mut self, from: NanoTimestamp, to: NanoTimestamp) -> Self {
self.from_ts = Some(from);
self.to_ts = Some(to);
self
}
pub fn has_symbol_filter(&self) -> bool {
self.symbol.is_some()
}
pub fn has_side_filter(&self) -> bool {
self.side.is_some()
}
pub fn has_min_qty_filter(&self) -> bool {
self.min_qty.is_some()
}
pub fn has_price_filter(&self) -> bool {
self.min_price.is_some() || self.max_price.is_some()
}
pub fn has_notional_filter(&self) -> bool {
self.min_notional.is_some() || self.max_notional.is_some()
}
pub fn clear(self) -> Self {
Self::new()
}
pub fn is_empty(&self) -> bool {
self.symbol.is_none()
&& self.side.is_none()
&& self.min_qty.is_none()
&& self.max_qty.is_none()
&& self.min_price.is_none()
&& self.max_price.is_none()
&& self.min_notional.is_none()
&& self.max_notional.is_none()
&& self.from_ts.is_none()
&& self.to_ts.is_none()
}
pub fn matches(&self, tick: &Tick) -> bool {
if let Some(ref sym) = self.symbol {
if tick.symbol != *sym {
return false;
}
}
if let Some(ref side) = self.side {
if tick.side != *side {
return false;
}
}
if let Some(ref min_qty) = self.min_qty {
if tick.quantity < *min_qty {
return false;
}
}
if let Some(ref max_qty) = self.max_qty {
if tick.quantity > *max_qty {
return false;
}
}
if let Some(ref min_p) = self.min_price {
if tick.price < *min_p {
return false;
}
}
if let Some(ref max_p) = self.max_price {
if tick.price > *max_p {
return false;
}
}
if let Some(ref min_n) = self.min_notional {
if tick.notional() < *min_n {
return false;
}
}
if let Some(ref max_n) = self.max_notional {
if tick.notional() > *max_n {
return false;
}
}
if let Some(from) = self.from_ts {
if tick.timestamp.is_before(from) {
return false;
}
}
if let Some(to) = self.to_ts {
if tick.timestamp.is_after(to) {
return false;
}
}
true
}
pub fn count_matches(&self, ticks: &[Tick]) -> usize {
ticks.iter().filter(|t| self.matches(t)).count()
}
}
impl Default for TickFilter {
fn default() -> Self {
Self::new()
}
}
pub struct TickReplayer {
ticks: Vec<Tick>,
index: usize,
}
impl TickReplayer {
pub fn new(mut ticks: Vec<Tick>) -> Self {
ticks.sort_by_key(|t| t.timestamp);
Self { ticks, index: 0 }
}
pub fn next_tick(&mut self) -> Option<&Tick> {
let tick = self.ticks.get(self.index)?;
self.index += 1;
Some(tick)
}
pub fn remaining(&self) -> usize {
self.ticks.len().saturating_sub(self.index)
}
pub fn peek(&self) -> Option<&Tick> {
self.ticks.get(self.index)
}
pub fn ticks(&self) -> &[Tick] {
&self.ticks
}
pub fn reset(&mut self) {
self.index = 0;
}
pub fn count(&self) -> usize {
self.ticks.len()
}
pub fn vwap(&self) -> Option<Decimal> {
let total_vol: Decimal = self.ticks.iter().map(|t| t.quantity.value()).sum();
if total_vol.is_zero() {
return None;
}
let total_notional: Decimal = self.ticks.iter().map(|t| t.notional()).sum();
Some(total_notional / total_vol)
}
pub fn filter_ticks(&self, filter: &TickFilter) -> Vec<Tick> {
self.ticks
.iter()
.filter(|t| filter.matches(t))
.cloned()
.collect()
}
pub fn between(&self, from: NanoTimestamp, to: NanoTimestamp) -> Vec<Tick> {
self.ticks
.iter()
.filter(|t| !t.timestamp.is_before(from) && !t.timestamp.is_after(to))
.cloned()
.collect()
}
pub fn delta(&self) -> Decimal {
self.buy_volume() - self.sell_volume()
}
pub fn time_span_nanos(&self) -> Option<i64> {
if self.ticks.len() < 2 {
return None;
}
let first = self.ticks.first().unwrap().timestamp;
let last = self.ticks.last().unwrap().timestamp;
Some(last.elapsed_since(first))
}
pub fn total_notional(&self) -> Decimal {
self.ticks.iter().map(|t| t.notional()).sum()
}
pub fn buy_volume(&self) -> Decimal {
self.ticks
.iter()
.filter(|t| t.side == Side::Bid)
.map(|t| t.quantity.value())
.sum()
}
pub fn sell_volume(&self) -> Decimal {
self.ticks
.iter()
.filter(|t| t.side == Side::Ask)
.map(|t| t.quantity.value())
.sum()
}
pub fn first(&self) -> Option<&Tick> {
self.ticks.first()
}
pub fn last(&self) -> Option<&Tick> {
self.ticks.last()
}
pub fn vwap_by_side(&self) -> (Option<Decimal>, Option<Decimal>) {
let mut bid_notional = Decimal::ZERO;
let mut bid_vol = Decimal::ZERO;
let mut ask_notional = Decimal::ZERO;
let mut ask_vol = Decimal::ZERO;
for tick in &self.ticks {
let vol = tick.quantity.value();
let notional = tick.notional();
match tick.side {
Side::Bid => {
bid_notional += notional;
bid_vol += vol;
}
Side::Ask => {
ask_notional += notional;
ask_vol += vol;
}
}
}
let bid_vwap = if bid_vol.is_zero() { None } else { Some(bid_notional / bid_vol) };
let ask_vwap = if ask_vol.is_zero() { None } else { Some(ask_notional / ask_vol) };
(bid_vwap, ask_vwap)
}
pub fn collect_by_symbol(&self) -> std::collections::HashMap<Symbol, Vec<Tick>> {
let mut map: std::collections::HashMap<Symbol, Vec<Tick>> = std::collections::HashMap::new();
for tick in &self.ticks {
map.entry(tick.symbol.clone()).or_default().push(tick.clone());
}
map
}
pub fn price_range(&self) -> Option<Decimal> {
let mut max_p = self.ticks.first()?.price.value();
let mut min_p = max_p;
for t in &self.ticks {
let p = t.price.value();
if p > max_p { max_p = p; }
if p < min_p { min_p = p; }
}
Some(max_p - min_p)
}
pub fn tick_count_by_side(&self) -> (usize, usize) {
let bid = self.ticks.iter().filter(|t| t.side == Side::Bid).count();
let ask = self.ticks.iter().filter(|t| t.side == Side::Ask).count();
(bid, ask)
}
pub fn median_trade_size(&self) -> Option<Decimal> {
if self.ticks.is_empty() {
return None;
}
let mut sizes: Vec<Decimal> = self.ticks.iter().map(|t| t.quantity.value()).collect();
sizes.sort();
Some(sizes[sizes.len() / 2])
}
pub fn avg_trade_size(&self) -> Option<Decimal> {
if self.ticks.is_empty() {
return None;
}
let sum: Decimal = self.ticks.iter().map(|t| t.quantity.value()).sum();
#[allow(clippy::cast_possible_truncation)]
Some(sum / Decimal::from(self.ticks.len() as u64))
}
pub fn tick_interval_mean_nanos(&self) -> Option<i64> {
if self.ticks.len() < 2 {
return None;
}
let total = self.ticks.last().unwrap().timestamp.elapsed_since(self.ticks.first().unwrap().timestamp);
Some(total / (self.ticks.len() as i64 - 1))
}
#[allow(clippy::cast_possible_truncation)]
pub fn price_std(&self) -> Option<Decimal> {
if self.ticks.len() < 2 {
return None;
}
let prices: Vec<Decimal> = self.ticks.iter().map(|t| t.price.value()).collect();
let n = prices.len();
let mean = prices.iter().copied().sum::<Decimal>() / Decimal::from(n as u32);
let variance = prices
.iter()
.map(|p| { let d = *p - mean; d * d })
.sum::<Decimal>()
/ Decimal::from((n - 1) as u32);
use rust_decimal::prelude::ToPrimitive;
let std = variance.to_f64()?.sqrt();
Decimal::try_from(std).ok()
}
pub fn bid_ask_imbalance(&self) -> Option<Decimal> {
let total: Decimal = self.ticks.iter().map(|t| t.quantity.value()).sum();
if total.is_zero() {
return None;
}
let bid_vol: Decimal = self
.ticks
.iter()
.filter(|t| t.side == Side::Bid)
.map(|t| t.quantity.value())
.sum();
let ask_vol = total - bid_vol;
Some((bid_vol - ask_vol) / total)
}
pub fn tick_velocity_per_second(&self) -> Option<f64> {
if self.ticks.len() < 2 {
return None;
}
let span_nanos = self
.ticks
.last()
.unwrap()
.timestamp
.elapsed_since(self.ticks.first().unwrap().timestamp);
if span_nanos <= 0 {
return None;
}
Some(self.ticks.len() as f64 / (span_nanos as f64 / 1_000_000_000.0))
}
}
impl Iterator for TickReplayer {
type Item = Tick;
fn next(&mut self) -> Option<Self::Item> {
let tick = self.ticks.get(self.index)?.clone();
self.index += 1;
Some(tick)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_tick(sym: &str, price: &str, qty: &str, side: Side, ts: i64) -> Tick {
Tick::new(
Symbol::new(sym).unwrap(),
Price::new(dec_from_str(price)).unwrap(),
Quantity::new(dec_from_str(qty)).unwrap(),
side,
NanoTimestamp::new(ts),
)
}
fn dec_from_str(s: &str) -> Decimal {
s.parse().unwrap()
}
#[test]
fn test_tick_notional_is_price_times_quantity() {
let t = make_tick("AAPL", "150.00", "10", Side::Ask, 0);
assert_eq!(t.notional(), dec!(1500.00));
}
#[test]
fn test_tick_notional_zero_quantity() {
let t = make_tick("AAPL", "150.00", "0", Side::Ask, 0);
assert_eq!(t.notional(), dec!(0));
}
#[test]
fn test_tick_filter_no_predicates_matches_all() {
let f = TickFilter::new();
let t = make_tick("AAPL", "1", "1", Side::Bid, 0);
assert!(f.matches(&t));
}
#[test]
fn test_tick_filter_by_symbol() {
let sym = Symbol::new("AAPL").unwrap();
let f = TickFilter::new().symbol(sym);
let matching = make_tick("AAPL", "1", "1", Side::Bid, 0);
let non_matching = make_tick("TSLA", "1", "1", Side::Bid, 0);
assert!(f.matches(&matching));
assert!(!f.matches(&non_matching));
}
#[test]
fn test_tick_filter_by_side() {
let f = TickFilter::new().side(Side::Ask);
let ask_tick = make_tick("AAPL", "1", "1", Side::Ask, 0);
let bid_tick = make_tick("AAPL", "1", "1", Side::Bid, 0);
assert!(f.matches(&ask_tick));
assert!(!f.matches(&bid_tick));
}
#[test]
fn test_tick_filter_by_min_quantity() {
let min_qty = Quantity::new(dec!(5)).unwrap();
let f = TickFilter::new().min_quantity(min_qty);
let large = make_tick("AAPL", "1", "10", Side::Bid, 0);
let small = make_tick("AAPL", "1", "2", Side::Bid, 0);
assert!(f.matches(&large));
assert!(!f.matches(&small));
}
#[test]
fn test_tick_filter_by_max_quantity() {
let max_qty = Quantity::new(dec!(5)).unwrap();
let f = TickFilter::new().max_quantity(max_qty);
let small = make_tick("AAPL", "1", "3", Side::Bid, 0);
let large = make_tick("AAPL", "1", "10", Side::Bid, 0);
assert!(f.matches(&small));
assert!(!f.matches(&large));
}
#[test]
fn test_tick_filter_quantity_range() {
let min_qty = Quantity::new(dec!(3)).unwrap();
let max_qty = Quantity::new(dec!(7)).unwrap();
let f = TickFilter::new().min_quantity(min_qty).max_quantity(max_qty);
assert!(f.matches(&make_tick("X", "1", "5", Side::Bid, 0)));
assert!(!f.matches(&make_tick("X", "1", "2", Side::Bid, 0)));
assert!(!f.matches(&make_tick("X", "1", "10", Side::Bid, 0)));
}
#[test]
fn test_tick_filter_by_min_price() {
let min_p = Price::new(dec!(100)).unwrap();
let f = TickFilter::new().min_price(min_p);
let high = make_tick("AAPL", "150", "1", Side::Bid, 0);
let low = make_tick("AAPL", "50", "1", Side::Bid, 0);
assert!(f.matches(&high));
assert!(!f.matches(&low));
}
#[test]
fn test_tick_filter_by_max_price() {
let max_p = Price::new(dec!(100)).unwrap();
let f = TickFilter::new().max_price(max_p);
let low = make_tick("AAPL", "50", "1", Side::Bid, 0);
let high = make_tick("AAPL", "150", "1", Side::Bid, 0);
assert!(f.matches(&low));
assert!(!f.matches(&high));
}
#[test]
fn test_tick_filter_price_range() {
let min_p = Price::new(dec!(90)).unwrap();
let max_p = Price::new(dec!(110)).unwrap();
let f = TickFilter::new().min_price(min_p).max_price(max_p);
assert!(f.matches(&make_tick("X", "100", "1", Side::Bid, 0)));
assert!(!f.matches(&make_tick("X", "80", "1", Side::Bid, 0)));
assert!(!f.matches(&make_tick("X", "120", "1", Side::Bid, 0)));
}
#[test]
fn test_tick_filter_combined_predicates() {
let sym = Symbol::new("AAPL").unwrap();
let min_qty = Quantity::new(dec!(5)).unwrap();
let f = TickFilter::new()
.symbol(sym)
.side(Side::Bid)
.min_quantity(min_qty);
let ok = make_tick("AAPL", "1", "10", Side::Bid, 0);
let wrong_sym = make_tick("TSLA", "1", "10", Side::Bid, 0);
let wrong_side = make_tick("AAPL", "1", "10", Side::Ask, 0);
let wrong_qty = make_tick("AAPL", "1", "1", Side::Bid, 0);
assert!(f.matches(&ok));
assert!(!f.matches(&wrong_sym));
assert!(!f.matches(&wrong_side));
assert!(!f.matches(&wrong_qty));
}
#[test]
fn test_tick_replayer_sorts_by_timestamp() {
let ticks = vec![
make_tick("A", "1", "1", Side::Bid, 300),
make_tick("A", "1", "1", Side::Bid, 100),
make_tick("A", "1", "1", Side::Bid, 200),
];
let mut replayer = TickReplayer::new(ticks);
let t1 = replayer.next_tick().unwrap();
assert_eq!(t1.timestamp.nanos(), 100);
let t2 = replayer.next_tick().unwrap();
assert_eq!(t2.timestamp.nanos(), 200);
let t3 = replayer.next_tick().unwrap();
assert_eq!(t3.timestamp.nanos(), 300);
}
#[test]
fn test_tick_replayer_next_tick_sequential() {
let ticks = vec![
make_tick("A", "1", "1", Side::Bid, 1),
make_tick("A", "1", "1", Side::Bid, 2),
];
let mut replayer = TickReplayer::new(ticks);
assert!(replayer.next_tick().is_some());
assert!(replayer.next_tick().is_some());
assert!(replayer.next_tick().is_none());
}
#[test]
fn test_tick_replayer_reset_restarts() {
let ticks = vec![make_tick("A", "1", "1", Side::Bid, 1)];
let mut replayer = TickReplayer::new(ticks);
let _ = replayer.next_tick();
assert!(replayer.next_tick().is_none());
replayer.reset();
assert!(replayer.next_tick().is_some());
}
#[test]
fn test_tick_replayer_remaining() {
let ticks = vec![
make_tick("A", "1", "1", Side::Bid, 1),
make_tick("A", "1", "1", Side::Bid, 2),
make_tick("A", "1", "1", Side::Bid, 3),
];
let mut replayer = TickReplayer::new(ticks);
assert_eq!(replayer.remaining(), 3);
let _ = replayer.next_tick();
assert_eq!(replayer.remaining(), 2);
}
#[test]
fn test_tick_replayer_iterator() {
let ticks = vec![
make_tick("A", "1", "1", Side::Bid, 1),
make_tick("A", "2", "1", Side::Bid, 2),
make_tick("A", "3", "1", Side::Bid, 3),
];
let mut replayer = TickReplayer::new(ticks);
let prices: Vec<_> = (&mut replayer).map(|t| t.price.value()).collect();
assert_eq!(prices.len(), 3);
assert_eq!(prices[0], dec!(1));
assert_eq!(prices[1], dec!(2));
assert_eq!(prices[2], dec!(3));
}
#[test]
fn test_tick_replayer_peek_does_not_advance() {
let ticks = vec![
make_tick("A", "1", "1", Side::Bid, 1),
make_tick("A", "2", "1", Side::Bid, 2),
];
let mut replayer = TickReplayer::new(ticks);
let p1 = replayer.peek().map(|t| t.timestamp.nanos());
let p2 = replayer.peek().map(|t| t.timestamp.nanos());
assert_eq!(p1, p2, "peek must not advance the position");
assert_eq!(replayer.remaining(), 2);
let _ = replayer.next_tick();
assert_eq!(replayer.remaining(), 1);
}
#[test]
fn test_tick_replayer_peek_none_when_exhausted() {
let replayer = TickReplayer::new(vec![]);
assert!(replayer.peek().is_none());
}
#[test]
fn test_tick_replayer_ticks_slice() {
let ticks = vec![
make_tick("A", "1", "1", Side::Bid, 2),
make_tick("A", "2", "1", Side::Bid, 1),
];
let replayer = TickReplayer::new(ticks);
let slice = replayer.ticks();
assert_eq!(slice.len(), 2);
assert_eq!(slice[0].timestamp.nanos(), 1);
assert_eq!(slice[1].timestamp.nanos(), 2);
}
#[test]
fn test_tick_filter_has_symbol_filter_false_when_unset() {
let f = TickFilter::new();
assert!(!f.has_symbol_filter());
}
#[test]
fn test_tick_filter_has_symbol_filter_true_when_set() {
let f = TickFilter::new().symbol(Symbol::new("AAPL").unwrap());
assert!(f.has_symbol_filter());
}
#[test]
fn test_tick_filter_has_side_filter_false_when_unset() {
let f = TickFilter::new();
assert!(!f.has_side_filter());
}
#[test]
fn test_tick_filter_has_side_filter_true_when_set() {
let f = TickFilter::new().side(Side::Bid);
assert!(f.has_side_filter());
}
#[test]
fn test_tick_filter_has_min_qty_filter() {
let f = TickFilter::new().min_quantity(Quantity::new(dec!(1)).unwrap());
assert!(f.has_min_qty_filter());
}
#[test]
fn test_tick_filter_has_price_filter_min() {
let f = TickFilter::new().min_price(Price::new(dec!(10)).unwrap());
assert!(f.has_price_filter());
}
#[test]
fn test_tick_filter_has_price_filter_max() {
let f = TickFilter::new().max_price(Price::new(dec!(100)).unwrap());
assert!(f.has_price_filter());
}
#[test]
fn test_tick_serde_roundtrip() {
let tick = make_tick("AAPL", "150.50", "25", Side::Bid, 1_000_000_000);
let json = serde_json::to_string(&tick).unwrap();
let back: Tick = serde_json::from_str(&json).unwrap();
assert_eq!(back.symbol, tick.symbol);
assert_eq!(back.price, tick.price);
assert_eq!(back.quantity, tick.quantity);
assert_eq!(back.side, tick.side);
assert_eq!(back.timestamp, tick.timestamp);
}
#[test]
fn test_tick_replayer_count() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "101", "1", Side::Ask, 2),
make_tick("AAPL", "102", "1", Side::Bid, 3),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.count(), 3);
}
#[test]
fn test_tick_replayer_count_empty() {
let replayer = TickReplayer::new(vec![]);
assert_eq!(replayer.count(), 0);
}
#[test]
fn test_tick_replayer_filter_by_side() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "101", "1", Side::Ask, 2),
make_tick("AAPL", "102", "1", Side::Bid, 3),
];
let replayer = TickReplayer::new(ticks);
let filter = TickFilter::new().side(Side::Bid);
let filtered = replayer.filter_ticks(&filter);
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|t| t.side == Side::Bid));
}
#[test]
fn test_tick_replayer_filter_no_matches() {
let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 1)];
let replayer = TickReplayer::new(ticks);
let filter = TickFilter::new().side(Side::Ask);
let filtered = replayer.filter_ticks(&filter);
assert!(filtered.is_empty());
}
#[test]
fn test_tick_filter_min_notional_passes_large() {
let big = make_tick("AAPL", "100", "10", Side::Ask, 1); let filter = TickFilter::new().min_notional(dec_from_str("500"));
assert!(filter.matches(&big));
}
#[test]
fn test_tick_filter_min_notional_rejects_small() {
let small = make_tick("AAPL", "100", "1", Side::Bid, 1); let filter = TickFilter::new().min_notional(dec_from_str("500"));
assert!(!filter.matches(&small));
}
#[test]
fn test_tick_filter_is_empty_when_no_predicates() {
let f = TickFilter::new();
assert!(f.is_empty());
}
#[test]
fn test_tick_filter_not_empty_after_symbol_set() {
let f = TickFilter::new().symbol(Symbol::new("AAPL").unwrap());
assert!(!f.is_empty());
}
#[test]
fn test_tick_filter_not_empty_after_side_set() {
let f = TickFilter::new().side(Side::Ask);
assert!(!f.is_empty());
}
#[test]
fn test_tick_notional_checked_matches_notional() {
let t = make_tick("AAPL", "150.50", "10", Side::Bid, 0);
assert_eq!(t.notional_checked(), Some(t.notional()));
}
#[test]
fn test_tick_notional_checked_zero_qty() {
let t = make_tick("AAPL", "100", "0", Side::Bid, 0);
assert_eq!(t.notional_checked(), Some(dec!(0)));
}
#[test]
fn test_tick_is_buy_bid_side() {
let t = make_tick("AAPL", "100", "1", Side::Bid, 0);
assert!(t.is_buy());
assert!(!t.is_sell());
}
#[test]
fn test_tick_is_sell_ask_side() {
let t = make_tick("AAPL", "100", "1", Side::Ask, 0);
assert!(t.is_sell());
assert!(!t.is_buy());
}
#[test]
fn test_tick_replayer_between_inclusive() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "101", "1", Side::Ask, 5),
make_tick("AAPL", "102", "1", Side::Bid, 10),
];
let replayer = TickReplayer::new(ticks);
let result = replayer.between(NanoTimestamp::new(1), NanoTimestamp::new(5));
assert_eq!(result.len(), 2);
}
#[test]
fn test_tick_replayer_between_no_matches() {
let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 100)];
let replayer = TickReplayer::new(ticks);
let result = replayer.between(NanoTimestamp::new(1), NanoTimestamp::new(50));
assert!(result.is_empty());
}
#[test]
fn test_tick_filter_timestamp_range() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "101", "1", Side::Ask, 5),
make_tick("AAPL", "102", "1", Side::Bid, 10),
];
let filter = TickFilter::new()
.timestamp_range(NanoTimestamp::new(3), NanoTimestamp::new(10));
let matched: Vec<_> = ticks.iter().filter(|t| filter.matches(t)).collect();
assert_eq!(matched.len(), 2);
}
#[test]
fn test_tick_replayer_first_returns_earliest() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 5),
make_tick("AAPL", "101", "1", Side::Ask, 1),
make_tick("AAPL", "102", "1", Side::Bid, 10),
];
let replayer = TickReplayer::new(ticks);
let first = replayer.first().unwrap();
assert_eq!(first.timestamp, NanoTimestamp::new(1));
}
#[test]
fn test_tick_replayer_last_returns_latest() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 5),
make_tick("AAPL", "101", "1", Side::Ask, 1),
make_tick("AAPL", "102", "1", Side::Bid, 10),
];
let replayer = TickReplayer::new(ticks);
let last = replayer.last().unwrap();
assert_eq!(last.timestamp, NanoTimestamp::new(10));
}
#[test]
fn test_tick_replayer_first_none_when_empty() {
let replayer = TickReplayer::new(vec![]);
assert!(replayer.first().is_none());
}
#[test]
fn test_tick_replayer_last_none_when_empty() {
let replayer = TickReplayer::new(vec![]);
assert!(replayer.last().is_none());
}
#[test]
fn test_tick_replayer_vwap_by_side_correct_values() {
let ticks = vec![
make_tick("AAPL", "100", "10", Side::Bid, 1), make_tick("AAPL", "200", "5", Side::Ask, 2), ];
let replayer = TickReplayer::new(ticks);
let (bid_vwap, ask_vwap) = replayer.vwap_by_side();
assert_eq!(bid_vwap, Some(dec_from_str("100")));
assert_eq!(ask_vwap, Some(dec_from_str("200")));
}
#[test]
fn test_tick_replayer_vwap_by_side_no_asks_returns_none_ask() {
let ticks = vec![make_tick("AAPL", "100", "10", Side::Bid, 1)];
let replayer = TickReplayer::new(ticks);
let (bid_vwap, ask_vwap) = replayer.vwap_by_side();
assert!(bid_vwap.is_some());
assert!(ask_vwap.is_none());
}
#[test]
fn test_tick_replayer_vwap_by_side_empty_returns_none_both() {
let replayer = TickReplayer::new(vec![]);
let (bid_vwap, ask_vwap) = replayer.vwap_by_side();
assert!(bid_vwap.is_none());
assert!(ask_vwap.is_none());
}
#[test]
fn test_tick_filter_clear_resets_all_predicates() {
let f = TickFilter::new()
.symbol(Symbol::new("AAPL").unwrap())
.side(Side::Bid)
.min_quantity(Quantity::new(dec!(1)).unwrap());
let cleared = f.clear();
assert!(cleared.is_empty());
}
#[test]
fn test_tick_filter_has_notional_filter_false_when_unset() {
let f = TickFilter::new();
assert!(!f.has_notional_filter());
}
#[test]
fn test_tick_filter_has_notional_filter_true_with_min() {
let f = TickFilter::new().min_notional(dec_from_str("100"));
assert!(f.has_notional_filter());
}
#[test]
fn test_tick_filter_has_notional_filter_true_with_max() {
let f = TickFilter::new().max_notional(dec_from_str("1000"));
assert!(f.has_notional_filter());
}
#[test]
fn test_tick_replayer_total_notional() {
let ticks = vec![
make_tick("AAPL", "100", "10", Side::Bid, 1), make_tick("AAPL", "200", "5", Side::Ask, 2), ];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.total_notional(), dec_from_str("2000"));
}
#[test]
fn test_tick_replayer_total_notional_empty() {
let replayer = TickReplayer::new(vec![]);
assert_eq!(replayer.total_notional(), dec_from_str("0"));
}
#[test]
fn test_tick_replayer_buy_volume() {
let ticks = vec![
make_tick("AAPL", "100", "10", Side::Bid, 1),
make_tick("AAPL", "100", "5", Side::Ask, 2),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.buy_volume(), dec_from_str("10"));
}
#[test]
fn test_tick_replayer_sell_volume() {
let ticks = vec![
make_tick("AAPL", "100", "10", Side::Bid, 1),
make_tick("AAPL", "100", "7", Side::Ask, 2),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.sell_volume(), dec_from_str("7"));
}
#[test]
fn test_tick_replayer_delta_positive_when_more_buys() {
let ticks = vec![
make_tick("AAPL", "100", "10", Side::Bid, 1),
make_tick("AAPL", "100", "3", Side::Ask, 2),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.delta(), dec_from_str("7"));
}
#[test]
fn test_tick_replayer_delta_negative_when_more_sells() {
let ticks = vec![
make_tick("AAPL", "100", "2", Side::Bid, 1),
make_tick("AAPL", "100", "8", Side::Ask, 2),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.delta(), dec_from_str("-6"));
}
#[test]
fn test_tick_replayer_delta_zero_when_balanced() {
let ticks = vec![
make_tick("AAPL", "100", "5", Side::Bid, 1),
make_tick("AAPL", "100", "5", Side::Ask, 2),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.delta(), dec_from_str("0"));
}
#[test]
fn test_tick_replayer_time_span_nanos_correct() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1_000_000),
make_tick("AAPL", "100", "1", Side::Ask, 3_000_000),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.time_span_nanos(), Some(2_000_000));
}
#[test]
fn test_tick_replayer_time_span_nanos_none_for_single_tick() {
let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 1_000_000)];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.time_span_nanos(), None);
}
#[test]
fn test_tick_replayer_time_span_nanos_none_for_empty() {
let replayer = TickReplayer::new(vec![]);
assert_eq!(replayer.time_span_nanos(), None);
}
#[test]
fn test_tick_replayer_price_range_returns_spread() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "105", "1", Side::Ask, 2),
make_tick("AAPL", "98", "1", Side::Bid, 3),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.price_range(), Some(dec_from_str("7")));
}
#[test]
fn test_tick_replayer_price_range_none_for_empty() {
let replayer = TickReplayer::new(vec![]);
assert_eq!(replayer.price_range(), None);
}
#[test]
fn test_tick_replayer_price_range_zero_for_single_price() {
let ticks = vec![make_tick("AAPL", "100", "1", Side::Bid, 1)];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.price_range(), Some(dec_from_str("0")));
}
#[test]
fn test_tick_replayer_tick_count_by_side() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "100", "1", Side::Bid, 2),
make_tick("AAPL", "100", "1", Side::Ask, 3),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.tick_count_by_side(), (2, 1));
}
#[test]
fn test_tick_replayer_tick_count_by_side_empty() {
let replayer = TickReplayer::new(vec![]);
assert_eq!(replayer.tick_count_by_side(), (0, 0));
}
#[test]
fn test_tick_replayer_median_trade_size_single() {
let ticks = vec![make_tick("AAPL", "100", "5", Side::Bid, 1)];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.median_trade_size(), Some(dec_from_str("5")));
}
#[test]
fn test_tick_replayer_median_trade_size_odd_count() {
let ticks = vec![
make_tick("AAPL", "100", "1", Side::Bid, 1),
make_tick("AAPL", "100", "3", Side::Bid, 2),
make_tick("AAPL", "100", "5", Side::Bid, 3),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.median_trade_size(), Some(dec_from_str("3")));
}
#[test]
fn test_tick_replayer_median_trade_size_none_for_empty() {
let replayer = TickReplayer::new(vec![]);
assert_eq!(replayer.median_trade_size(), None);
}
#[test]
fn test_tick_replayer_total_notional_sum_two_trades() {
let ticks = vec![
make_tick("X", "100", "2", Side::Bid, 1),
make_tick("X", "50", "4", Side::Ask, 2),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.total_notional(), dec_from_str("400"));
}
#[test]
fn test_tick_replayer_price_std_none_for_single_tick() {
let ticks = vec![make_tick("X", "100", "1", Side::Bid, 1)];
let replayer = TickReplayer::new(ticks);
assert!(replayer.price_std().is_none());
}
#[test]
fn test_tick_replayer_price_std_zero_for_constant_prices() {
let ticks = vec![
make_tick("X", "100", "1", Side::Bid, 1),
make_tick("X", "100", "2", Side::Bid, 2),
make_tick("X", "100", "3", Side::Bid, 3),
];
let replayer = TickReplayer::new(ticks);
assert_eq!(replayer.price_std(), Some(Decimal::ZERO));
}
#[test]
fn test_tick_replayer_price_std_positive_for_varying_prices() {
let ticks = vec![
make_tick("X", "100", "1", Side::Bid, 1),
make_tick("X", "110", "1", Side::Bid, 2),
make_tick("X", "120", "1", Side::Bid, 3),
];
let replayer = TickReplayer::new(ticks);
let std = replayer.price_std().unwrap();
assert!(std > Decimal::ZERO);
}
}