use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::domain::Money;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstrumentType {
Stock,
Etf,
Bond,
Currency,
Futures,
Options,
Crypto,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketStatus {
Open,
Closed,
PreMarket,
AfterHours,
Halted,
Unknown,
}
impl MarketStatus {
#[must_use]
pub const fn is_tradeable(&self) -> bool {
matches!(self, Self::Open | Self::PreMarket | Self::AfterHours)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Instrument {
pub symbol: String,
pub name: String,
pub exchange_id: String,
pub exchange: String,
pub instrument_type: InstrumentType,
pub currency: String,
pub lot_size: u32,
pub min_price_increment: Decimal,
pub min_quantity: u32,
pub tradeable: bool,
pub margin_available: bool,
pub short_available: bool,
}
impl Instrument {
#[must_use]
pub fn lots_to_units(&self, lots: u32) -> Decimal {
Decimal::from(lots) * Decimal::from(self.lot_size)
}
#[must_use]
pub fn units_to_lots(&self, units: Decimal) -> u32 {
(units / Decimal::from(self.lot_size))
.floor()
.to_string()
.parse()
.unwrap_or(0)
}
#[must_use]
pub fn round_price(&self, price: Decimal) -> Decimal {
if self.min_price_increment.is_zero() {
return price;
}
let steps = (price / self.min_price_increment).round();
steps * self.min_price_increment
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MarketData {
pub symbol: String,
pub last_price: Money,
pub bid: Option<Money>,
pub ask: Option<Money>,
pub volume_24h: Option<Decimal>,
pub high_24h: Option<Money>,
pub low_24h: Option<Money>,
pub change_24h: Option<Money>,
pub change_percent_24h: Option<Decimal>,
pub market_status: MarketStatus,
pub timestamp: DateTime<Utc>,
}
impl MarketData {
#[must_use]
pub fn mid_price(&self) -> Option<Money> {
let bid = self.bid.as_ref()?;
let ask = self.ask.as_ref()?;
let mid = (bid.amount() + ask.amount()) / Decimal::from(2);
Some(Money::new(mid, bid.currency()))
}
#[must_use]
pub fn spread(&self) -> Option<Money> {
let bid = self.bid.as_ref()?;
let ask = self.ask.as_ref()?;
ask.checked_sub(bid)
}
#[must_use]
pub fn spread_percent(&self) -> Option<Decimal> {
let mid = self.mid_price()?;
let spread = self.spread()?;
if mid.is_zero() {
return None;
}
Some(spread.amount() / mid.amount() * Decimal::from(100))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Ticker {
pub symbol: String,
pub price: Money,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OrderBookEntry {
pub price: Money,
pub quantity: Decimal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OrderBook {
pub symbol: String,
pub bids: Vec<OrderBookEntry>,
pub asks: Vec<OrderBookEntry>,
pub timestamp: DateTime<Utc>,
}
impl OrderBook {
#[must_use]
pub fn empty(symbol: impl Into<String>) -> Self {
Self {
symbol: symbol.into(),
bids: Vec::new(),
asks: Vec::new(),
timestamp: chrono::Utc::now(),
}
}
#[must_use]
pub fn with_levels(
symbol: impl Into<String>,
bids: Vec<OrderBookEntry>,
asks: Vec<OrderBookEntry>,
) -> Self {
Self {
symbol: symbol.into(),
bids,
asks,
timestamp: chrono::Utc::now(),
}
}
#[must_use]
pub fn best_bid(&self) -> Option<&OrderBookEntry> {
self.bids.first()
}
#[must_use]
pub fn best_ask(&self) -> Option<&OrderBookEntry> {
self.asks.first()
}
#[must_use]
pub fn best_bid_price(&self) -> Option<Decimal> {
self.bids.first().map(|e| e.price.amount())
}
#[must_use]
pub fn best_ask_price(&self) -> Option<Decimal> {
self.asks.first().map(|e| e.price.amount())
}
#[must_use]
pub fn spread(&self) -> Option<Money> {
let bid = self.best_bid()?;
let ask = self.best_ask()?;
ask.price.checked_sub(&bid.price)
}
#[must_use]
pub fn bid_depth(&self, levels: usize) -> u64 {
self.bids
.iter()
.take(levels)
.map(|e| e.quantity.floor().to_string().parse::<u64>().unwrap_or(0))
.sum()
}
#[must_use]
pub fn ask_depth(&self, levels: usize) -> u64 {
self.asks
.iter()
.take(levels)
.map(|e| e.quantity.floor().to_string().parse::<u64>().unwrap_or(0))
.sum()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bids.is_empty() && self.asks.is_empty()
}
#[must_use]
pub fn bid_levels(&self) -> usize {
self.bids.len()
}
#[must_use]
pub fn ask_levels(&self) -> usize {
self.asks.len()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ExchangeInfo {
pub id: String,
pub name: String,
pub url: String,
pub supported_types: Vec<InstrumentType>,
}
impl ExchangeInfo {
#[must_use]
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
url: impl Into<String>,
supported_types: Vec<InstrumentType>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
url: url.into(),
supported_types,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_usd(amount: Decimal) -> Money {
Money::new(amount, "USD")
}
#[test]
fn test_instrument_lot_conversion() {
let instrument = Instrument {
symbol: "SBER".into(),
name: "Sberbank".into(),
exchange_id: "BBG004730N88".into(),
exchange: "MOEX".into(),
instrument_type: InstrumentType::Stock,
currency: "RUB".into(),
lot_size: 10,
min_price_increment: dec!(0.01),
min_quantity: 1,
tradeable: true,
margin_available: true,
short_available: false,
};
assert_eq!(instrument.lots_to_units(5), dec!(50));
assert_eq!(instrument.units_to_lots(dec!(55)), 5); }
#[test]
fn test_instrument_price_rounding() {
let instrument = Instrument {
symbol: "AAPL".into(),
name: "Apple Inc.".into(),
exchange_id: "AAPL".into(),
exchange: "NASDAQ".into(),
instrument_type: InstrumentType::Stock,
currency: "USD".into(),
lot_size: 1,
min_price_increment: dec!(0.01),
min_quantity: 1,
tradeable: true,
margin_available: true,
short_available: true,
};
assert_eq!(instrument.round_price(dec!(150.123)), dec!(150.12));
assert_eq!(instrument.round_price(dec!(150.125)), dec!(150.12));
assert_eq!(instrument.round_price(dec!(150.126)), dec!(150.13));
}
#[test]
fn test_market_data_spread() {
let data = MarketData {
symbol: "AAPL".into(),
last_price: make_usd(dec!(150)),
bid: Some(make_usd(dec!(149.95))),
ask: Some(make_usd(dec!(150.05))),
volume_24h: None,
high_24h: None,
low_24h: None,
change_24h: None,
change_percent_24h: None,
market_status: MarketStatus::Open,
timestamp: Utc::now(),
};
assert_eq!(data.mid_price().unwrap().amount(), dec!(150));
assert_eq!(data.spread().unwrap().amount(), dec!(0.10));
}
#[test]
fn test_market_status_tradeable() {
assert!(MarketStatus::Open.is_tradeable());
assert!(MarketStatus::PreMarket.is_tradeable());
assert!(MarketStatus::AfterHours.is_tradeable());
assert!(!MarketStatus::Closed.is_tradeable());
assert!(!MarketStatus::Halted.is_tradeable());
}
}