Skip to main content

brokapi_runtime/
price_grid.rs

1//! The price steps and lot sizes a connector knows, and the check of an order price and quantity against them.
2
3use std::collections::HashMap;
4use std::sync::{Arc, RwLock};
5
6use brokapi_core::{Error, InstrumentKey, Price, Qty, TradingParams, check_price_step};
7
8/// The price step of each instrument the connector was told about.
9///
10/// A connector that sends orders refuses a price that is not a whole number of steps **before it
11/// sends anything**: the error is [`Error::Rejected`] with the code `price_off_step`, no quota is spent
12/// and the cid stays unused. An instrument whose step is not known passes unchecked: the broker
13/// decides then. The grid starts empty. Fill it from [`TradingParams`], which you read once per
14/// instrument with
15/// [`InstrumentSource::trading_params`](brokapi_core::InstrumentSource::trading_params).
16///
17/// The grid also keeps the lot size of an instrument. Quantities are in units of the instrument at every
18/// broker (see [`Qty`]), and an exchange trades whole lots, so a quantity that is not a whole number of
19/// lots is refused the same way, before anything is sent: [`Error::Rejected`] with the code
20/// `invalid_qty`. An instrument whose lot is not known passes unchecked (the T-Bank connector looks the
21/// lot up itself, it does not need the grid for that).
22///
23/// The handle is cheap to clone, and all clones are one grid: share it between the connectors of one
24/// broker.
25///
26/// # Examples
27///
28/// ```
29/// use brokapi_core::{Decimal, InstrumentKey, Price, dec};
30/// use brokapi_runtime::PriceGrid;
31///
32/// let grid = PriceGrid::default();
33/// let sber = InstrumentKey::new("SBER", "TQBR");
34///
35/// // The step is not known yet: the check passes.
36/// assert!(grid.check(&sber, Price::new(dec!(244.307))).is_ok());
37///
38/// grid.set(sber.clone(), Price::new(dec!(0.01)));
39/// assert!(grid.check(&sber, Price::new(dec!(244.30))).is_ok());
40/// assert!(grid.check(&sber, Price::new(dec!(244.307))).is_err());
41/// ```
42#[derive(Debug, Clone, Default)]
43pub struct PriceGrid {
44    steps: Arc<RwLock<HashMap<InstrumentKey, Price>>>,
45    lots: Arc<RwLock<HashMap<InstrumentKey, Qty>>>,
46}
47
48impl PriceGrid {
49    /// Remembers the price step of an instrument. A step of zero or below forgets it.
50    pub fn set(&self, key: InstrumentKey, step: Price) {
51        let mut steps = self.steps.write().unwrap_or_else(|e| e.into_inner());
52        if step.is_positive() {
53            steps.insert(key, step);
54        } else {
55            steps.remove(&key);
56        }
57    }
58
59    /// Remembers the price step and the lot size from the trading parameters of an instrument.
60    pub fn learn(&self, params: &TradingParams) {
61        self.set(params.instrument.key.clone(), params.price_step);
62        self.set_lot(params.instrument.key.clone(), params.lot_size);
63    }
64
65    /// Remembers the lot size (units of the instrument in one lot). A size of zero or below forgets it.
66    pub fn set_lot(&self, key: InstrumentKey, lot: Qty) {
67        let mut lots = self.lots.write().unwrap_or_else(|e| e.into_inner());
68        if lot.is_positive() {
69            lots.insert(key, lot);
70        } else {
71            lots.remove(&key);
72        }
73    }
74
75    /// The lot size of an instrument, if it is known.
76    pub fn lot(&self, key: &InstrumentKey) -> Option<Qty> {
77        self.lots
78            .read()
79            .unwrap_or_else(|e| e.into_inner())
80            .get(key)
81            .copied()
82    }
83
84    /// Checks an order quantity against the lot size of the instrument.
85    ///
86    /// # Errors
87    ///
88    /// [`Error::Rejected`] with the code `invalid_qty` when the quantity is not positive, or when the
89    /// lot size is known and the quantity is not a whole number of lots.
90    pub fn check_qty(&self, key: &InstrumentKey, qty: Qty) -> Result<(), Error> {
91        if !qty.is_positive() {
92            return Err(Error::Rejected {
93                code: "invalid_qty".into(),
94                message: format!("quantity must be positive, got {qty}"),
95            });
96        }
97        match self.lot(key) {
98            Some(lot) if !qty.is_on_step(lot) => Err(Error::Rejected {
99                code: "invalid_qty".into(),
100                message: format!("quantity {qty} is not a whole number of lots of {lot}"),
101            }),
102            _ => Ok(()),
103        }
104    }
105
106    /// The step of an instrument, if it is known.
107    pub fn step(&self, key: &InstrumentKey) -> Option<Price> {
108        self.steps
109            .read()
110            .unwrap_or_else(|e| e.into_inner())
111            .get(key)
112            .copied()
113    }
114
115    /// Checks a price against the step of the instrument.
116    ///
117    /// # Errors
118    ///
119    /// [`Error::Rejected`] with the code `price_off_step` when the step is known and the price is not a
120    /// whole number of steps.
121    pub fn check(&self, key: &InstrumentKey, price: Price) -> Result<(), Error> {
122        match self.step(key) {
123            Some(step) => check_price_step(price, step),
124            None => Ok(()),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use brokapi_core::{Decimal, dec};
133
134    #[test]
135    fn unknown_step_passes_and_zero_forgets() {
136        let grid = PriceGrid::default();
137        let k = InstrumentKey::new("SBER", "TQBR");
138        assert!(grid.check(&k, Price::new(dec!(1.234))).is_ok());
139        grid.set(k.clone(), Price::new(dec!(0.05)));
140        assert!(grid.check(&k, Price::new(dec!(1.23))).is_err());
141        grid.set(k.clone(), Price::ZERO);
142        assert!(grid.check(&k, Price::new(dec!(1.23))).is_ok());
143    }
144
145    #[test]
146    fn quantity_is_checked_against_the_lot_when_it_is_known() {
147        let grid = PriceGrid::default();
148        let k = InstrumentKey::new("SBER", "TQBR");
149        // No lot known: any positive quantity passes, a non-positive one never does.
150        assert!(grid.check_qty(&k, Qty::from(3)).is_ok());
151        assert!(grid.check_qty(&k, Qty::ZERO).is_err());
152        assert!(grid.check_qty(&k, Qty::from(-1)).is_err());
153        grid.set_lot(k.clone(), Qty::from(10));
154        assert_eq!(grid.lot(&k), Some(Qty::from(10)));
155        assert!(grid.check_qty(&k, Qty::from(30)).is_ok());
156        let err = grid.check_qty(&k, Qty::from(25)).unwrap_err();
157        assert!(matches!(err, Error::Rejected { ref code, .. } if code == "invalid_qty"));
158        assert!(grid.check_qty(&k, Qty::new(dec!(0.5))).is_err());
159        grid.set_lot(k.clone(), Qty::ZERO);
160        assert_eq!(grid.lot(&k), None);
161        assert!(grid.check_qty(&k, Qty::from(25)).is_ok());
162    }
163
164    #[test]
165    fn learn_takes_step_and_lot_from_the_trading_params() {
166        use brokapi_core::{InstrumentId, Money, NativeId, TradingParams};
167        let k = InstrumentKey::new("SBER", "TQBR");
168        let params = TradingParams {
169            instrument: InstrumentId::new(
170                k.clone(),
171                NativeId::TickerClass {
172                    ticker: "SBER".into(),
173                    class_code: "TQBR".into(),
174                },
175            ),
176            lot_size: Qty::from(10),
177            min_qty: Qty::from(10),
178            price_step: Price::new(dec!(0.01)),
179            price_step_value: Money::new(dec!(0.01)),
180            currency: "RUB".into(),
181        };
182        let grid = PriceGrid::default();
183        grid.learn(&params);
184        assert_eq!(grid.step(&k), Some(Price::new(dec!(0.01))));
185        assert_eq!(grid.lot(&k), Some(Qty::from(10)));
186    }
187
188    #[test]
189    fn clones_share_one_grid() {
190        let a = PriceGrid::default();
191        let b = a.clone();
192        let k = InstrumentKey::new("GAZP", "TQBR");
193        b.set(k.clone(), Price::new(dec!(0.01)));
194        assert_eq!(a.step(&k), Some(Price::new(dec!(0.01))));
195    }
196}