Skip to main content

wickra_exchange_core/
instruments.rs

1//! Instrument metadata, symbol filters and order rounding.
2//!
3//! Every venue constrains orders with per-symbol filters — a quantity step
4//! (`LOT_SIZE`), a price tick (`PRICE_FILTER`), a minimum notional. Getting the
5//! rounding wrong means a rejected or mis-sized order, so it is done here in
6//! exact [`Decimal`] arithmetic and tested at the edges. [`InstrumentCache`]
7//! holds the `exchangeInfo` metadata with a deterministic, caller-driven refresh
8//! clock (no hidden wall-clock, so it is testable).
9
10use crate::error::{Error, Result};
11use crate::symbol::Symbol;
12use rust_decimal::Decimal;
13use std::collections::HashMap;
14
15/// The order constraints for one instrument.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct InstrumentFilters {
18    /// Minimum order quantity (`LOT_SIZE` `minQty`).
19    pub min_quantity: Decimal,
20    /// Maximum order quantity (`LOT_SIZE` `maxQty`); `0` means no maximum.
21    pub max_quantity: Decimal,
22    /// Quantity increment (`LOT_SIZE` `stepSize`); `0` means no stepping.
23    pub step_size: Decimal,
24    /// Minimum price (`PRICE_FILTER` `minPrice`).
25    pub min_price: Decimal,
26    /// Maximum price (`PRICE_FILTER` `maxPrice`); `0` means no maximum.
27    pub max_price: Decimal,
28    /// Price increment (`PRICE_FILTER` `tickSize`); `0` means no stepping.
29    pub tick_size: Decimal,
30    /// Minimum order notional (`price * quantity`); `0` means no minimum.
31    pub min_notional: Decimal,
32}
33
34/// Round `value` down to the nearest multiple of `increment`. A zero or negative
35/// `increment` is a no-op (the value is returned unchanged).
36fn round_down_to(value: Decimal, increment: Decimal) -> Decimal {
37    if increment <= Decimal::ZERO {
38        return value;
39    }
40    // A vanishingly small increment relative to the value (e.g. a malformed
41    // venue filter) makes `value / increment` exceed `Decimal`'s range. Use the
42    // checked ops and fall back to the unrounded value rather than panicking on
43    // untrusted filter data.
44    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    /// Round a quantity **down** to the venue's step size, so the result never
56    /// exceeds the requested amount.
57    #[must_use]
58    pub fn round_quantity(&self, quantity: Decimal) -> Decimal {
59        round_down_to(quantity, self.step_size)
60    }
61
62    /// Round a price **down** to the venue's tick size, putting it on the price
63    /// grid the venue accepts.
64    #[must_use]
65    pub fn round_price(&self, price: Decimal) -> Decimal {
66        round_down_to(price, self.tick_size)
67    }
68
69    /// Validate a quantity and optional price against every filter.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`Error::Filter`] naming the first filter the order violates:
74    /// quantity below `min`/above `max`/off the step grid, price below `min`/
75    /// above `max`/off the tick grid, or notional below the minimum.
76    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/// One tradable instrument and its metadata.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Instrument {
129    /// The canonical symbol.
130    pub symbol: Symbol,
131    /// Base-asset display precision (decimal places).
132    pub base_precision: u32,
133    /// Quote-asset display precision (decimal places).
134    pub quote_precision: u32,
135    /// The order filters.
136    pub filters: InstrumentFilters,
137}
138
139/// A cache of instrument metadata with a caller-driven refresh clock.
140///
141/// The refresh decision takes the current time as an argument rather than
142/// reading a hidden clock, so staleness logic is deterministic and testable.
143#[derive(Debug, Default)]
144pub struct InstrumentCache {
145    by_symbol: HashMap<Symbol, Instrument>,
146    fetched_at_ms: Option<i64>,
147}
148
149impl InstrumentCache {
150    /// An empty cache.
151    #[must_use]
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Replace the cache contents with a freshly fetched set, stamping the fetch
157    /// time (milliseconds since the Unix epoch).
158    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    /// Look up an instrument by symbol.
167    #[must_use]
168    pub fn get(&self, symbol: &Symbol) -> Option<&Instrument> {
169        self.by_symbol.get(symbol)
170    }
171
172    /// The number of cached instruments.
173    #[must_use]
174    pub fn len(&self) -> usize {
175        self.by_symbol.len()
176    }
177
178    /// Whether the cache is empty.
179    #[must_use]
180    pub fn is_empty(&self) -> bool {
181        self.by_symbol.is_empty()
182    }
183
184    /// Whether the cache should be refreshed: never fetched, or older than
185    /// `ttl_ms` relative to `now_ms`.
186    #[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        // A vanishingly small step relative to the value overflows the
235        // intermediate `value / increment`; rounding must fall back to the
236        // value rather than panic (found by the `filter_round` fuzz target).
237        let f = InstrumentFilters {
238            step_size: dec!(0.0000000000000000000000001), // 1e-25
239            tick_size: dec!(0.0000000000000000000000001),
240            ..filters()
241        };
242        let value = dec!(1000000000); // 1e9; 1e9 / 1e-25 = 1e34 > Decimal::MAX
243        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        // No price: notional and price filters are skipped.
252        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        // 0.001 * 5000 = 5 < min_notional 10.
288        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        // Within TTL: fresh. Past TTL: stale.
313        assert!(!cache.needs_refresh(30_000, 60_000));
314        assert!(cache.needs_refresh(61_000, 60_000));
315    }
316}