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}