Skip to main content

rustrade_core/
instrument.rs

1//! Instrument metadata — the exchange/asset-specific knobs the framework needs
2//! to size and round orders correctly, and to apply asset-class-aware rules.
3//!
4//! [`InstrumentSpec`] is returned by
5//! [`ExchangeClient::instrument_spec`](crate::ExchangeClient::instrument_spec).
6//! It generalises the older single `contract_value` hook into the full set a
7//! multi-asset bot needs: contract size, price tick, quantity lot, minimum
8//! order notional, and a broad [`AssetClass`]. The defaults are permissive so
9//! adapters that expose no metadata keep working unchanged.
10
11use serde::{Deserialize, Serialize};
12
13/// Broad asset class an instrument belongs to. Drives class-aware risk presets
14/// (different leverage/stop/size conventions per class) and sizing semantics.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum AssetClass {
19    /// Perpetual / dated crypto futures — contract-based (the default for the
20    /// venues the framework targets first).
21    #[default]
22    CryptoPerp,
23    /// Crypto spot — one unit traded equals one base-asset unit.
24    CryptoSpot,
25    /// Foreign exchange.
26    Fx,
27    /// Traditional dated futures (CME, etc.).
28    Future,
29    /// Cash equities.
30    Equity,
31    /// Anything else / unknown.
32    Other,
33}
34
35/// Exchange/asset metadata for one instrument.
36///
37/// Used by the framework to round prices/quantities to the venue's increments,
38/// enforce a minimum order notional, and apply class-aware rules. Adapters that
39/// expose no metadata get a permissive default (see
40/// [`ExchangeClient::instrument_spec`](crate::ExchangeClient::instrument_spec)),
41/// which preserves today's behaviour.
42#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
43pub struct InstrumentSpec {
44    /// Broad asset class.
45    pub asset_class: AssetClass,
46    /// Base-asset units per one contract (e.g. `0.001` BTC for `XBTUSDTM`);
47    /// `1.0` for spot. Subsumes the older `ExchangeClient::contract_value`.
48    pub contract_value: f64,
49    /// Minimum price increment (tick). `0.0` ⇒ unknown / unconstrained.
50    pub tick_size: f64,
51    /// Minimum quantity increment (lot), in contracts. `0.0` ⇒ unknown;
52    /// futures are typically whole contracts (`1.0`).
53    pub lot_size: f64,
54    /// Minimum order notional in quote currency. `0.0` ⇒ no minimum.
55    pub min_notional: f64,
56}
57
58impl Default for InstrumentSpec {
59    fn default() -> Self {
60        Self::spot_default()
61    }
62}
63
64impl InstrumentSpec {
65    /// A permissive spot default: 1:1 contract value, no tick/lot/min-notional
66    /// constraints. Appropriate when an adapter exposes no metadata.
67    #[must_use]
68    pub const fn spot_default() -> Self {
69        Self {
70            asset_class: AssetClass::CryptoSpot,
71            contract_value: 1.0,
72            tick_size: 0.0,
73            lot_size: 0.0,
74            min_notional: 0.0,
75        }
76    }
77
78    /// A futures-contract spec from a known `contract_value`, with no other
79    /// constraints. Backs the blanket [`ExchangeClient::instrument_spec`](crate::ExchangeClient::instrument_spec)
80    /// default so an adapter that only overrides `contract_value` still gets a
81    /// correct (if minimal) spec.
82    #[must_use]
83    pub const fn from_contract_value(contract_value: f64) -> Self {
84        Self {
85            asset_class: AssetClass::CryptoPerp,
86            contract_value,
87            tick_size: 0.0,
88            lot_size: 0.0,
89            min_notional: 0.0,
90        }
91    }
92
93    /// Round a price to the nearest valid tick. No-op when `tick_size <= 0`.
94    #[must_use]
95    pub fn round_price(&self, price: f64) -> f64 {
96        round_to_increment(price, self.tick_size)
97    }
98
99    /// Round a quantity **down** to the nearest valid lot — down so a sized
100    /// order never exceeds the intended risk. No-op when `lot_size <= 0`.
101    #[must_use]
102    pub fn round_qty_down(&self, qty: f64) -> f64 {
103        if self.lot_size <= 0.0 || !self.lot_size.is_finite() {
104            return qty;
105        }
106        (qty / self.lot_size).floor() * self.lot_size
107    }
108
109    /// Does `notional` (quote currency) meet the minimum order notional?
110    /// Always `true` when `min_notional` is `0.0`.
111    #[must_use]
112    pub fn meets_min_notional(&self, notional: f64) -> bool {
113        notional >= self.min_notional
114    }
115}
116
117/// Round `value` to the nearest multiple of `increment` (no-op if `increment <= 0`).
118fn round_to_increment(value: f64, increment: f64) -> f64 {
119    if increment <= 0.0 || !increment.is_finite() {
120        return value;
121    }
122    (value / increment).round() * increment
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn defaults_are_permissive() {
131        let s = InstrumentSpec::default();
132        assert_eq!(s.contract_value, 1.0);
133        assert_eq!(s.asset_class, AssetClass::CryptoSpot);
134        // No constraints → every round is a no-op, every notional passes.
135        assert_eq!(s.round_price(1234.5678), 1234.5678);
136        assert_eq!(s.round_qty_down(3.7), 3.7);
137        assert!(s.meets_min_notional(0.0));
138    }
139
140    #[test]
141    fn from_contract_value_keeps_value_and_is_perp() {
142        let s = InstrumentSpec::from_contract_value(0.001);
143        assert_eq!(s.contract_value, 0.001);
144        assert_eq!(s.asset_class, AssetClass::CryptoPerp);
145    }
146
147    #[test]
148    fn round_price_snaps_to_tick() {
149        let s = InstrumentSpec {
150            tick_size: 0.5,
151            ..InstrumentSpec::default()
152        };
153        assert_eq!(s.round_price(100.24), 100.0);
154        assert_eq!(s.round_price(100.25), 100.5); // .round() rounds half up
155        assert_eq!(s.round_price(100.74), 100.5);
156        assert_eq!(s.round_price(100.75), 101.0);
157    }
158
159    #[test]
160    fn round_qty_rounds_down_to_lot() {
161        let s = InstrumentSpec {
162            lot_size: 0.1,
163            ..InstrumentSpec::default()
164        };
165        assert!((s.round_qty_down(3.79) - 3.7).abs() < 1e-9);
166        assert!((s.round_qty_down(3.70) - 3.7).abs() < 1e-9);
167        // Whole-contract lot.
168        let c = InstrumentSpec {
169            lot_size: 1.0,
170            ..InstrumentSpec::default()
171        };
172        assert_eq!(c.round_qty_down(4.9), 4.0);
173    }
174
175    #[test]
176    fn min_notional_gate() {
177        let s = InstrumentSpec {
178            min_notional: 10.0,
179            ..InstrumentSpec::default()
180        };
181        assert!(!s.meets_min_notional(9.99));
182        assert!(s.meets_min_notional(10.0));
183        assert!(s.meets_min_notional(25.0));
184    }
185
186    #[test]
187    fn asset_class_serdes_snake_case() {
188        let json = serde_json::to_string(&AssetClass::CryptoPerp).unwrap();
189        assert_eq!(json, "\"crypto_perp\"");
190        let back: AssetClass = serde_json::from_str("\"fx\"").unwrap();
191        assert_eq!(back, AssetClass::Fx);
192    }
193}