1use crate::error::{Error, Result};
11use crate::symbol::Symbol;
12use rust_decimal::Decimal;
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct InstrumentFilters {
18 pub min_quantity: Decimal,
20 pub max_quantity: Decimal,
22 pub step_size: Decimal,
24 pub min_price: Decimal,
26 pub max_price: Decimal,
28 pub tick_size: Decimal,
30 pub min_notional: Decimal,
32}
33
34fn round_down_to(value: Decimal, increment: Decimal) -> Decimal {
37 if increment <= Decimal::ZERO {
38 return value;
39 }
40 match value
45 .checked_div(increment)
46 .map(|steps| steps.floor())
47 .and_then(|steps| steps.checked_mul(increment))
48 {
49 Some(rounded) => rounded,
50 None => value,
51 }
52}
53
54impl InstrumentFilters {
55 #[must_use]
58 pub fn round_quantity(&self, quantity: Decimal) -> Decimal {
59 round_down_to(quantity, self.step_size)
60 }
61
62 #[must_use]
65 pub fn round_price(&self, price: Decimal) -> Decimal {
66 round_down_to(price, self.tick_size)
67 }
68
69 pub fn validate(&self, quantity: Decimal, price: Option<Decimal>) -> Result<()> {
77 if quantity < self.min_quantity {
78 return Err(Error::Filter(format!(
79 "LOT_SIZE: quantity {quantity} below minimum {}",
80 self.min_quantity
81 )));
82 }
83 if self.max_quantity > Decimal::ZERO && quantity > self.max_quantity {
84 return Err(Error::Filter(format!(
85 "LOT_SIZE: quantity {quantity} above maximum {}",
86 self.max_quantity
87 )));
88 }
89 if self.step_size > Decimal::ZERO && (quantity % self.step_size) != Decimal::ZERO {
90 return Err(Error::Filter(format!(
91 "LOT_SIZE: quantity {quantity} not a multiple of step {}",
92 self.step_size
93 )));
94 }
95 if let Some(price) = price {
96 if price < self.min_price {
97 return Err(Error::Filter(format!(
98 "PRICE_FILTER: price {price} below minimum {}",
99 self.min_price
100 )));
101 }
102 if self.max_price > Decimal::ZERO && price > self.max_price {
103 return Err(Error::Filter(format!(
104 "PRICE_FILTER: price {price} above maximum {}",
105 self.max_price
106 )));
107 }
108 if self.tick_size > Decimal::ZERO && (price % self.tick_size) != Decimal::ZERO {
109 return Err(Error::Filter(format!(
110 "PRICE_FILTER: price {price} not a multiple of tick {}",
111 self.tick_size
112 )));
113 }
114 if self.min_notional > Decimal::ZERO && quantity * price < self.min_notional {
115 return Err(Error::Filter(format!(
116 "MIN_NOTIONAL: notional {} below minimum {}",
117 quantity * price,
118 self.min_notional
119 )));
120 }
121 }
122 Ok(())
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Instrument {
129 pub symbol: Symbol,
131 pub base_precision: u32,
133 pub quote_precision: u32,
135 pub filters: InstrumentFilters,
137}
138
139#[derive(Debug, Default)]
144pub struct InstrumentCache {
145 by_symbol: HashMap<Symbol, Instrument>,
146 fetched_at_ms: Option<i64>,
147}
148
149impl InstrumentCache {
150 #[must_use]
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 pub fn replace(&mut self, instruments: impl IntoIterator<Item = Instrument>, now_ms: i64) {
159 self.by_symbol = instruments
160 .into_iter()
161 .map(|inst| (inst.symbol.clone(), inst))
162 .collect();
163 self.fetched_at_ms = Some(now_ms);
164 }
165
166 #[must_use]
168 pub fn get(&self, symbol: &Symbol) -> Option<&Instrument> {
169 self.by_symbol.get(symbol)
170 }
171
172 #[must_use]
174 pub fn len(&self) -> usize {
175 self.by_symbol.len()
176 }
177
178 #[must_use]
180 pub fn is_empty(&self) -> bool {
181 self.by_symbol.is_empty()
182 }
183
184 #[must_use]
187 pub fn needs_refresh(&self, now_ms: i64, ttl_ms: i64) -> bool {
188 match self.fetched_at_ms {
189 None => true,
190 Some(fetched) => now_ms - fetched >= ttl_ms,
191 }
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use rust_decimal_macros::dec;
199
200 fn filters() -> InstrumentFilters {
201 InstrumentFilters {
202 min_quantity: dec!(0.001),
203 max_quantity: dec!(1000),
204 step_size: dec!(0.001),
205 min_price: dec!(0.01),
206 max_price: dec!(1000000),
207 tick_size: dec!(0.01),
208 min_notional: dec!(10),
209 }
210 }
211
212 #[test]
213 fn rounds_quantity_and_price_down_to_grid() {
214 let f = filters();
215 assert_eq!(f.round_quantity(dec!(0.0012345)), dec!(0.001));
216 assert_eq!(f.round_quantity(dec!(1.2399)), dec!(1.239));
217 assert_eq!(f.round_price(dec!(20000.017)), dec!(20000.01));
218 assert_eq!(f.round_price(dec!(20000.00)), dec!(20000.00));
219 }
220
221 #[test]
222 fn zero_increment_is_a_no_op() {
223 let f = InstrumentFilters {
224 step_size: Decimal::ZERO,
225 tick_size: Decimal::ZERO,
226 ..filters()
227 };
228 assert_eq!(f.round_quantity(dec!(1.23456789)), dec!(1.23456789));
229 assert_eq!(f.round_price(dec!(1.23456789)), dec!(1.23456789));
230 }
231
232 #[test]
233 fn tiny_increment_does_not_panic_on_overflow() {
234 let f = InstrumentFilters {
238 step_size: dec!(0.0000000000000000000000001), tick_size: dec!(0.0000000000000000000000001),
240 ..filters()
241 };
242 let value = dec!(1000000000); assert_eq!(f.round_quantity(value), value);
244 assert_eq!(f.round_price(value), value);
245 }
246
247 #[test]
248 fn validate_accepts_a_clean_order() {
249 let f = filters();
250 assert!(f.validate(dec!(0.5), Some(dec!(20000.00))).is_ok());
251 assert!(f.validate(dec!(0.5), None).is_ok());
253 }
254
255 #[test]
256 fn validate_rejects_quantity_violations() {
257 let f = filters();
258 assert!(matches!(
259 f.validate(dec!(0.0005), Some(dec!(20000))).unwrap_err(),
260 Error::Filter(m) if m.contains("below minimum")
261 ));
262 assert!(matches!(
263 f.validate(dec!(2000), Some(dec!(20000))).unwrap_err(),
264 Error::Filter(m) if m.contains("above maximum")
265 ));
266 assert!(matches!(
267 f.validate(dec!(0.0015), Some(dec!(20000))).unwrap_err(),
268 Error::Filter(m) if m.contains("multiple of step")
269 ));
270 }
271
272 #[test]
273 fn validate_rejects_price_and_notional_violations() {
274 let f = filters();
275 assert!(matches!(
276 f.validate(dec!(0.5), Some(dec!(0.001))).unwrap_err(),
277 Error::Filter(m) if m.contains("price") && m.contains("below minimum")
278 ));
279 assert!(matches!(
280 f.validate(dec!(0.5), Some(dec!(2000000))).unwrap_err(),
281 Error::Filter(m) if m.contains("price") && m.contains("above maximum")
282 ));
283 assert!(matches!(
284 f.validate(dec!(0.5), Some(dec!(20000.017))).unwrap_err(),
285 Error::Filter(m) if m.contains("multiple of tick")
286 ));
287 assert!(matches!(
289 f.validate(dec!(0.001), Some(dec!(5000))).unwrap_err(),
290 Error::Filter(m) if m.contains("MIN_NOTIONAL")
291 ));
292 }
293
294 #[test]
295 fn cache_stores_and_refreshes_deterministically() {
296 let mut cache = InstrumentCache::new();
297 assert!(cache.is_empty());
298 assert!(cache.needs_refresh(1_000, 60_000));
299
300 let inst = Instrument {
301 symbol: Symbol::new("BTC", "USDT"),
302 base_precision: 8,
303 quote_precision: 2,
304 filters: filters(),
305 };
306 cache.replace([inst.clone()], 1_000);
307 assert_eq!(cache.len(), 1);
308 assert!(!cache.is_empty());
309 assert_eq!(cache.get(&Symbol::new("BTC", "USDT")), Some(&inst));
310 assert!(cache.get(&Symbol::new("ETH", "USDT")).is_none());
311
312 assert!(!cache.needs_refresh(30_000, 60_000));
314 assert!(cache.needs_refresh(61_000, 60_000));
315 }
316}