1use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::domain::Money;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum InstrumentType {
13 Stock,
15 Etf,
17 Bond,
19 Currency,
21 Futures,
23 Options,
25 Crypto,
27 Other,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum MarketStatus {
35 Open,
37 Closed,
39 PreMarket,
41 AfterHours,
43 Halted,
45 Unknown,
47}
48
49impl MarketStatus {
50 #[must_use]
52 pub const fn is_tradeable(&self) -> bool {
53 matches!(self, Self::Open | Self::PreMarket | Self::AfterHours)
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct Instrument {
60 pub symbol: String,
62 pub name: String,
64 pub exchange_id: String,
66 pub exchange: String,
68 pub instrument_type: InstrumentType,
70 pub currency: String,
72 pub lot_size: u32,
74 pub min_price_increment: Decimal,
76 pub min_quantity: u32,
78 pub tradeable: bool,
80 pub margin_available: bool,
82 pub short_available: bool,
84}
85
86impl Instrument {
87 #[must_use]
89 pub fn lots_to_units(&self, lots: u32) -> Decimal {
90 Decimal::from(lots) * Decimal::from(self.lot_size)
91 }
92
93 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct MarketData {
117 pub symbol: String,
119 pub last_price: Money,
121 pub bid: Option<Money>,
123 pub ask: Option<Money>,
125 pub volume_24h: Option<Decimal>,
127 pub high_24h: Option<Money>,
129 pub low_24h: Option<Money>,
131 pub change_24h: Option<Money>,
133 pub change_percent_24h: Option<Decimal>,
135 pub market_status: MarketStatus,
137 pub timestamp: DateTime<Utc>,
139}
140
141impl MarketData {
142 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct Ticker {
174 pub symbol: String,
176 pub price: Money,
178 pub timestamp: DateTime<Utc>,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct OrderBookEntry {
185 pub price: Money,
187 pub quantity: Decimal,
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct OrderBook {
194 pub symbol: String,
196 pub bids: Vec<OrderBookEntry>,
198 pub asks: Vec<OrderBookEntry>,
200 pub timestamp: DateTime<Utc>,
202}
203
204impl OrderBook {
205 #[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 #[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 #[must_use]
233 pub fn best_bid(&self) -> Option<&OrderBookEntry> {
234 self.bids.first()
235 }
236
237 #[must_use]
239 pub fn best_ask(&self) -> Option<&OrderBookEntry> {
240 self.asks.first()
241 }
242
243 #[must_use]
245 pub fn best_bid_price(&self) -> Option<Decimal> {
246 self.bids.first().map(|e| e.price.amount())
247 }
248
249 #[must_use]
251 pub fn best_ask_price(&self) -> Option<Decimal> {
252 self.asks.first().map(|e| e.price.amount())
253 }
254
255 #[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 #[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 #[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 #[must_use]
285 pub fn is_empty(&self) -> bool {
286 self.bids.is_empty() && self.asks.is_empty()
287 }
288
289 #[must_use]
291 pub fn bid_levels(&self) -> usize {
292 self.bids.len()
293 }
294
295 #[must_use]
297 pub fn ask_levels(&self) -> usize {
298 self.asks.len()
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct ExchangeInfo {
305 pub id: String,
307 pub name: String,
309 pub url: String,
311 pub supported_types: Vec<InstrumentType>,
313}
314
315impl ExchangeInfo {
316 #[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); }
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 assert_eq!(instrument.round_price(dec!(150.125)), dec!(150.12));
383 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}