use crate::error::{Error, Result};
use crate::symbol::Symbol;
use rust_decimal::Decimal;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstrumentFilters {
pub min_quantity: Decimal,
pub max_quantity: Decimal,
pub step_size: Decimal,
pub min_price: Decimal,
pub max_price: Decimal,
pub tick_size: Decimal,
pub min_notional: Decimal,
}
fn round_down_to(value: Decimal, increment: Decimal) -> Decimal {
if increment <= Decimal::ZERO {
return value;
}
match value
.checked_div(increment)
.map(|steps| steps.floor())
.and_then(|steps| steps.checked_mul(increment))
{
Some(rounded) => rounded,
None => value,
}
}
impl InstrumentFilters {
#[must_use]
pub fn round_quantity(&self, quantity: Decimal) -> Decimal {
round_down_to(quantity, self.step_size)
}
#[must_use]
pub fn round_price(&self, price: Decimal) -> Decimal {
round_down_to(price, self.tick_size)
}
pub fn validate(&self, quantity: Decimal, price: Option<Decimal>) -> Result<()> {
if quantity < self.min_quantity {
return Err(Error::Filter(format!(
"LOT_SIZE: quantity {quantity} below minimum {}",
self.min_quantity
)));
}
if self.max_quantity > Decimal::ZERO && quantity > self.max_quantity {
return Err(Error::Filter(format!(
"LOT_SIZE: quantity {quantity} above maximum {}",
self.max_quantity
)));
}
if self.step_size > Decimal::ZERO && (quantity % self.step_size) != Decimal::ZERO {
return Err(Error::Filter(format!(
"LOT_SIZE: quantity {quantity} not a multiple of step {}",
self.step_size
)));
}
if let Some(price) = price {
if price < self.min_price {
return Err(Error::Filter(format!(
"PRICE_FILTER: price {price} below minimum {}",
self.min_price
)));
}
if self.max_price > Decimal::ZERO && price > self.max_price {
return Err(Error::Filter(format!(
"PRICE_FILTER: price {price} above maximum {}",
self.max_price
)));
}
if self.tick_size > Decimal::ZERO && (price % self.tick_size) != Decimal::ZERO {
return Err(Error::Filter(format!(
"PRICE_FILTER: price {price} not a multiple of tick {}",
self.tick_size
)));
}
if self.min_notional > Decimal::ZERO && quantity * price < self.min_notional {
return Err(Error::Filter(format!(
"MIN_NOTIONAL: notional {} below minimum {}",
quantity * price,
self.min_notional
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Instrument {
pub symbol: Symbol,
pub base_precision: u32,
pub quote_precision: u32,
pub filters: InstrumentFilters,
}
#[derive(Debug, Default)]
pub struct InstrumentCache {
by_symbol: HashMap<Symbol, Instrument>,
fetched_at_ms: Option<i64>,
}
impl InstrumentCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn replace(&mut self, instruments: impl IntoIterator<Item = Instrument>, now_ms: i64) {
self.by_symbol = instruments
.into_iter()
.map(|inst| (inst.symbol.clone(), inst))
.collect();
self.fetched_at_ms = Some(now_ms);
}
#[must_use]
pub fn get(&self, symbol: &Symbol) -> Option<&Instrument> {
self.by_symbol.get(symbol)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_symbol.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_symbol.is_empty()
}
#[must_use]
pub fn needs_refresh(&self, now_ms: i64, ttl_ms: i64) -> bool {
match self.fetched_at_ms {
None => true,
Some(fetched) => now_ms - fetched >= ttl_ms,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn filters() -> InstrumentFilters {
InstrumentFilters {
min_quantity: dec!(0.001),
max_quantity: dec!(1000),
step_size: dec!(0.001),
min_price: dec!(0.01),
max_price: dec!(1000000),
tick_size: dec!(0.01),
min_notional: dec!(10),
}
}
#[test]
fn rounds_quantity_and_price_down_to_grid() {
let f = filters();
assert_eq!(f.round_quantity(dec!(0.0012345)), dec!(0.001));
assert_eq!(f.round_quantity(dec!(1.2399)), dec!(1.239));
assert_eq!(f.round_price(dec!(20000.017)), dec!(20000.01));
assert_eq!(f.round_price(dec!(20000.00)), dec!(20000.00));
}
#[test]
fn zero_increment_is_a_no_op() {
let f = InstrumentFilters {
step_size: Decimal::ZERO,
tick_size: Decimal::ZERO,
..filters()
};
assert_eq!(f.round_quantity(dec!(1.23456789)), dec!(1.23456789));
assert_eq!(f.round_price(dec!(1.23456789)), dec!(1.23456789));
}
#[test]
fn tiny_increment_does_not_panic_on_overflow() {
let f = InstrumentFilters {
step_size: dec!(0.0000000000000000000000001), tick_size: dec!(0.0000000000000000000000001),
..filters()
};
let value = dec!(1000000000); assert_eq!(f.round_quantity(value), value);
assert_eq!(f.round_price(value), value);
}
#[test]
fn validate_accepts_a_clean_order() {
let f = filters();
assert!(f.validate(dec!(0.5), Some(dec!(20000.00))).is_ok());
assert!(f.validate(dec!(0.5), None).is_ok());
}
#[test]
fn validate_rejects_quantity_violations() {
let f = filters();
assert!(matches!(
f.validate(dec!(0.0005), Some(dec!(20000))).unwrap_err(),
Error::Filter(m) if m.contains("below minimum")
));
assert!(matches!(
f.validate(dec!(2000), Some(dec!(20000))).unwrap_err(),
Error::Filter(m) if m.contains("above maximum")
));
assert!(matches!(
f.validate(dec!(0.0015), Some(dec!(20000))).unwrap_err(),
Error::Filter(m) if m.contains("multiple of step")
));
}
#[test]
fn validate_rejects_price_and_notional_violations() {
let f = filters();
assert!(matches!(
f.validate(dec!(0.5), Some(dec!(0.001))).unwrap_err(),
Error::Filter(m) if m.contains("price") && m.contains("below minimum")
));
assert!(matches!(
f.validate(dec!(0.5), Some(dec!(2000000))).unwrap_err(),
Error::Filter(m) if m.contains("price") && m.contains("above maximum")
));
assert!(matches!(
f.validate(dec!(0.5), Some(dec!(20000.017))).unwrap_err(),
Error::Filter(m) if m.contains("multiple of tick")
));
assert!(matches!(
f.validate(dec!(0.001), Some(dec!(5000))).unwrap_err(),
Error::Filter(m) if m.contains("MIN_NOTIONAL")
));
}
#[test]
fn cache_stores_and_refreshes_deterministically() {
let mut cache = InstrumentCache::new();
assert!(cache.is_empty());
assert!(cache.needs_refresh(1_000, 60_000));
let inst = Instrument {
symbol: Symbol::new("BTC", "USDT"),
base_precision: 8,
quote_precision: 2,
filters: filters(),
};
cache.replace([inst.clone()], 1_000);
assert_eq!(cache.len(), 1);
assert!(!cache.is_empty());
assert_eq!(cache.get(&Symbol::new("BTC", "USDT")), Some(&inst));
assert!(cache.get(&Symbol::new("ETH", "USDT")).is_none());
assert!(!cache.needs_refresh(30_000, 60_000));
assert!(cache.needs_refresh(61_000, 60_000));
}
}