Skip to main content

trader_bot/exchange/
types.rs

1//! Exchange-related types.
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::domain::Money;
8
9/// Type of financial instrument.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum InstrumentType {
13    /// Stock/equity.
14    Stock,
15    /// Exchange-traded fund.
16    Etf,
17    /// Bond.
18    Bond,
19    /// Currency pair.
20    Currency,
21    /// Futures contract.
22    Futures,
23    /// Options contract.
24    Options,
25    /// Cryptocurrency.
26    Crypto,
27    /// Other/unknown type.
28    Other,
29}
30
31/// Current market status for an instrument.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum MarketStatus {
35    /// Market is open for trading.
36    Open,
37    /// Market is closed.
38    Closed,
39    /// Pre-market session.
40    PreMarket,
41    /// After-hours session.
42    AfterHours,
43    /// Trading is halted.
44    Halted,
45    /// Status is unknown.
46    Unknown,
47}
48
49impl MarketStatus {
50    /// Returns true if trading is possible.
51    #[must_use]
52    pub const fn is_tradeable(&self) -> bool {
53        matches!(self, Self::Open | Self::PreMarket | Self::AfterHours)
54    }
55}
56
57/// Information about a tradeable instrument.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct Instrument {
60    /// Trading symbol (e.g., "AAPL", "BTC-USD").
61    pub symbol: String,
62    /// Full name of the instrument.
63    pub name: String,
64    /// Unique identifier used by the exchange (e.g., FIGI).
65    pub exchange_id: String,
66    /// Exchange where the instrument is traded.
67    pub exchange: String,
68    /// Type of instrument.
69    pub instrument_type: InstrumentType,
70    /// Base currency for the instrument.
71    pub currency: String,
72    /// Minimum lot size.
73    pub lot_size: u32,
74    /// Minimum price increment.
75    pub min_price_increment: Decimal,
76    /// Minimum order quantity in lots.
77    pub min_quantity: u32,
78    /// Whether the instrument is currently tradeable.
79    pub tradeable: bool,
80    /// Whether margin trading is allowed.
81    pub margin_available: bool,
82    /// Whether short selling is allowed.
83    pub short_available: bool,
84}
85
86impl Instrument {
87    /// Returns the quantity in units for the given number of lots.
88    #[must_use]
89    pub fn lots_to_units(&self, lots: u32) -> Decimal {
90        Decimal::from(lots) * Decimal::from(self.lot_size)
91    }
92
93    /// Returns the number of complete lots for the given quantity.
94    #[must_use]
95    pub fn units_to_lots(&self, units: Decimal) -> u32 {
96        (units / Decimal::from(self.lot_size))
97            .floor()
98            .to_string()
99            .parse()
100            .unwrap_or(0)
101    }
102
103    /// Rounds a price to the minimum price increment.
104    #[must_use]
105    pub fn round_price(&self, price: Decimal) -> Decimal {
106        if self.min_price_increment.is_zero() {
107            return price;
108        }
109        let steps = (price / self.min_price_increment).round();
110        steps * self.min_price_increment
111    }
112}
113
114/// Real-time market data for an instrument.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct MarketData {
117    /// Trading symbol.
118    pub symbol: String,
119    /// Last traded price.
120    pub last_price: Money,
121    /// Best bid price.
122    pub bid: Option<Money>,
123    /// Best ask price.
124    pub ask: Option<Money>,
125    /// 24-hour volume.
126    pub volume_24h: Option<Decimal>,
127    /// 24-hour high.
128    pub high_24h: Option<Money>,
129    /// 24-hour low.
130    pub low_24h: Option<Money>,
131    /// Price change in the last 24 hours.
132    pub change_24h: Option<Money>,
133    /// Percentage change in the last 24 hours.
134    pub change_percent_24h: Option<Decimal>,
135    /// Current market status.
136    pub market_status: MarketStatus,
137    /// Timestamp of the data.
138    pub timestamp: DateTime<Utc>,
139}
140
141impl MarketData {
142    /// Returns the mid price (average of bid and ask).
143    #[must_use]
144    pub fn mid_price(&self) -> Option<Money> {
145        let bid = self.bid.as_ref()?;
146        let ask = self.ask.as_ref()?;
147        let mid = (bid.amount() + ask.amount()) / Decimal::from(2);
148        Some(Money::new(mid, bid.currency()))
149    }
150
151    /// Returns the spread (ask - bid).
152    #[must_use]
153    pub fn spread(&self) -> Option<Money> {
154        let bid = self.bid.as_ref()?;
155        let ask = self.ask.as_ref()?;
156        ask.checked_sub(bid)
157    }
158
159    /// Returns the spread as a percentage of the mid price.
160    #[must_use]
161    pub fn spread_percent(&self) -> Option<Decimal> {
162        let mid = self.mid_price()?;
163        let spread = self.spread()?;
164        if mid.is_zero() {
165            return None;
166        }
167        Some(spread.amount() / mid.amount() * Decimal::from(100))
168    }
169}
170
171/// Ticker data (simplified market data).
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct Ticker {
174    /// Trading symbol.
175    pub symbol: String,
176    /// Last traded price.
177    pub price: Money,
178    /// Timestamp.
179    pub timestamp: DateTime<Utc>,
180}
181
182/// Single entry in an order book.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct OrderBookEntry {
185    /// Price level.
186    pub price: Money,
187    /// Quantity available at this price.
188    pub quantity: Decimal,
189}
190
191/// Order book (market depth).
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct OrderBook {
194    /// Trading symbol.
195    pub symbol: String,
196    /// Bid levels (buyers), sorted from highest to lowest price.
197    pub bids: Vec<OrderBookEntry>,
198    /// Ask levels (sellers), sorted from lowest to highest price.
199    pub asks: Vec<OrderBookEntry>,
200    /// Timestamp.
201    pub timestamp: DateTime<Utc>,
202}
203
204impl OrderBook {
205    /// Creates an empty order book.
206    #[must_use]
207    pub fn empty(symbol: impl Into<String>) -> Self {
208        Self {
209            symbol: symbol.into(),
210            bids: Vec::new(),
211            asks: Vec::new(),
212            timestamp: chrono::Utc::now(),
213        }
214    }
215
216    /// Creates an order book with the given levels.
217    #[must_use]
218    pub fn with_levels(
219        symbol: impl Into<String>,
220        bids: Vec<OrderBookEntry>,
221        asks: Vec<OrderBookEntry>,
222    ) -> Self {
223        Self {
224            symbol: symbol.into(),
225            bids,
226            asks,
227            timestamp: chrono::Utc::now(),
228        }
229    }
230
231    /// Returns the best bid (highest buy price).
232    #[must_use]
233    pub fn best_bid(&self) -> Option<&OrderBookEntry> {
234        self.bids.first()
235    }
236
237    /// Returns the best ask (lowest sell price).
238    #[must_use]
239    pub fn best_ask(&self) -> Option<&OrderBookEntry> {
240        self.asks.first()
241    }
242
243    /// Returns the best bid price.
244    #[must_use]
245    pub fn best_bid_price(&self) -> Option<Decimal> {
246        self.bids.first().map(|e| e.price.amount())
247    }
248
249    /// Returns the best ask price.
250    #[must_use]
251    pub fn best_ask_price(&self) -> Option<Decimal> {
252        self.asks.first().map(|e| e.price.amount())
253    }
254
255    /// Returns the spread.
256    #[must_use]
257    pub fn spread(&self) -> Option<Money> {
258        let bid = self.best_bid()?;
259        let ask = self.best_ask()?;
260        ask.price.checked_sub(&bid.price)
261    }
262
263    /// Returns the total bid depth up to the given level count.
264    #[must_use]
265    pub fn bid_depth(&self, levels: usize) -> u64 {
266        self.bids
267            .iter()
268            .take(levels)
269            .map(|e| e.quantity.floor().to_string().parse::<u64>().unwrap_or(0))
270            .sum()
271    }
272
273    /// Returns the total ask depth up to the given level count.
274    #[must_use]
275    pub fn ask_depth(&self, levels: usize) -> u64 {
276        self.asks
277            .iter()
278            .take(levels)
279            .map(|e| e.quantity.floor().to_string().parse::<u64>().unwrap_or(0))
280            .sum()
281    }
282
283    /// Returns true if the order book is empty.
284    #[must_use]
285    pub fn is_empty(&self) -> bool {
286        self.bids.is_empty() && self.asks.is_empty()
287    }
288
289    /// Returns the number of bid levels.
290    #[must_use]
291    pub fn bid_levels(&self) -> usize {
292        self.bids.len()
293    }
294
295    /// Returns the number of ask levels.
296    #[must_use]
297    pub fn ask_levels(&self) -> usize {
298        self.asks.len()
299    }
300}
301
302/// General information about an exchange.
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct ExchangeInfo {
305    /// Exchange identifier (e.g., "MOEX", "BINANCE").
306    pub id: String,
307    /// Human-readable name.
308    pub name: String,
309    /// Exchange website URL.
310    pub url: String,
311    /// Supported instrument types.
312    pub supported_types: Vec<InstrumentType>,
313}
314
315impl ExchangeInfo {
316    /// Creates a new ExchangeInfo.
317    #[must_use]
318    pub fn new(
319        id: impl Into<String>,
320        name: impl Into<String>,
321        url: impl Into<String>,
322        supported_types: Vec<InstrumentType>,
323    ) -> Self {
324        Self {
325            id: id.into(),
326            name: name.into(),
327            url: url.into(),
328            supported_types,
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use rust_decimal_macros::dec;
337
338    fn make_usd(amount: Decimal) -> Money {
339        Money::new(amount, "USD")
340    }
341
342    #[test]
343    fn test_instrument_lot_conversion() {
344        let instrument = Instrument {
345            symbol: "SBER".into(),
346            name: "Sberbank".into(),
347            exchange_id: "BBG004730N88".into(),
348            exchange: "MOEX".into(),
349            instrument_type: InstrumentType::Stock,
350            currency: "RUB".into(),
351            lot_size: 10,
352            min_price_increment: dec!(0.01),
353            min_quantity: 1,
354            tradeable: true,
355            margin_available: true,
356            short_available: false,
357        };
358
359        assert_eq!(instrument.lots_to_units(5), dec!(50));
360        assert_eq!(instrument.units_to_lots(dec!(55)), 5); // 55/10 = 5 complete lots
361    }
362
363    #[test]
364    fn test_instrument_price_rounding() {
365        let instrument = Instrument {
366            symbol: "AAPL".into(),
367            name: "Apple Inc.".into(),
368            exchange_id: "AAPL".into(),
369            exchange: "NASDAQ".into(),
370            instrument_type: InstrumentType::Stock,
371            currency: "USD".into(),
372            lot_size: 1,
373            min_price_increment: dec!(0.01),
374            min_quantity: 1,
375            tradeable: true,
376            margin_available: true,
377            short_available: true,
378        };
379
380        assert_eq!(instrument.round_price(dec!(150.123)), dec!(150.12));
381        // Note: Uses banker's rounding (round half to even) - 15012.5 rounds to 15012
382        assert_eq!(instrument.round_price(dec!(150.125)), dec!(150.12));
383        // A value that clearly rounds up
384        assert_eq!(instrument.round_price(dec!(150.126)), dec!(150.13));
385    }
386
387    #[test]
388    fn test_market_data_spread() {
389        let data = MarketData {
390            symbol: "AAPL".into(),
391            last_price: make_usd(dec!(150)),
392            bid: Some(make_usd(dec!(149.95))),
393            ask: Some(make_usd(dec!(150.05))),
394            volume_24h: None,
395            high_24h: None,
396            low_24h: None,
397            change_24h: None,
398            change_percent_24h: None,
399            market_status: MarketStatus::Open,
400            timestamp: Utc::now(),
401        };
402
403        assert_eq!(data.mid_price().unwrap().amount(), dec!(150));
404        assert_eq!(data.spread().unwrap().amount(), dec!(0.10));
405    }
406
407    #[test]
408    fn test_market_status_tradeable() {
409        assert!(MarketStatus::Open.is_tradeable());
410        assert!(MarketStatus::PreMarket.is_tradeable());
411        assert!(MarketStatus::AfterHours.is_tradeable());
412        assert!(!MarketStatus::Closed.is_tradeable());
413        assert!(!MarketStatus::Halted.is_tradeable());
414    }
415}