use rust_decimal::Decimal;
use crate::types::{Market, Timestamp};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Side {
Buy,
Sell,
}
impl Side {
pub const fn flip(self) -> Self {
match self {
Self::Buy => Self::Sell,
Self::Sell => Self::Buy,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trade {
pub market: Market,
pub timestamp: Timestamp,
pub price: Decimal,
pub quantity: Decimal,
pub taker_side: Side,
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Level {
pub price: Decimal,
pub quantity: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrderBook {
pub market: Market,
pub timestamp: Timestamp,
pub bids: Vec<Level>,
pub asks: Vec<Level>,
}
impl OrderBook {
pub fn best_bid(&self) -> Option<&Level> {
self.bids.first()
}
pub fn best_ask(&self) -> Option<&Level> {
self.asks.first()
}
pub fn spread(&self) -> Option<Decimal> {
Some(self.best_ask()?.price - self.best_bid()?.price)
}
pub fn mid_price(&self) -> Option<Decimal> {
let bid = self.best_bid()?.price;
let ask = self.best_ask()?.price;
Some((bid + ask) / Decimal::TWO)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ticker {
pub market: Market,
pub timestamp: Timestamp,
pub last_trade_time: Option<Timestamp>,
pub last_price: Decimal,
pub change: Option<Decimal>,
pub change_rate: Option<Decimal>,
pub high: Option<Decimal>,
pub low: Option<Decimal>,
pub volume: Option<Decimal>,
pub quote_volume: Option<Decimal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Interval {
Sec1,
Min1,
Min3,
Min5,
Min15,
Min30,
Hour1,
Hour2,
Hour4,
Hour8,
Hour12,
Day1,
Day3,
Week1,
Month1,
}
impl Interval {
pub const fn as_secs(self) -> Option<u64> {
Some(match self {
Self::Sec1 => 1,
Self::Min1 => 60,
Self::Min3 => 180,
Self::Min5 => 300,
Self::Min15 => 900,
Self::Min30 => 1_800,
Self::Hour1 => 3_600,
Self::Hour2 => 7_200,
Self::Hour4 => 14_400,
Self::Hour8 => 28_800,
Self::Hour12 => 43_200,
Self::Day1 => 86_400,
Self::Day3 => 259_200,
Self::Week1 => 604_800,
Self::Month1 => return None,
})
}
pub fn advance(self, at: Timestamp, count: i64) -> Option<Timestamp> {
if let Some(secs) = self.as_secs() {
let span = i64::try_from(secs)
.ok()?
.checked_mul(1_000_000_000)?
.checked_mul(count)?;
return at.as_nanos().checked_add(span).map(Timestamp::from_nanos);
}
let months = chrono::Months::new(u32::try_from(count.unsigned_abs()).ok()?);
let at = chrono::DateTime::from_timestamp_nanos(at.as_nanos());
let moved = if count < 0 {
at.checked_sub_months(months)?
} else {
at.checked_add_months(months)?
};
moved.timestamp_nanos_opt().map(Timestamp::from_nanos)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candle {
pub market: Market,
pub interval: Interval,
pub open_time: Timestamp,
pub open: Decimal,
pub high: Decimal,
pub low: Decimal,
pub close: Decimal,
pub volume: Decimal,
pub quote_volume: Option<Decimal>,
pub closed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Exchange;
fn level(price: i64, quantity: i64) -> Level {
Level {
price: Decimal::from(price),
quantity: Decimal::from(quantity),
}
}
fn book(bids: Vec<Level>, asks: Vec<Level>) -> OrderBook {
OrderBook {
market: Market::spot(Exchange::Upbit, "BTC", "KRW"),
timestamp: Timestamp::from_millis(1_700_000_000_000),
bids,
asks,
}
}
#[test]
fn best_prices_come_off_the_front_of_each_side() {
let book = book(
vec![level(100, 1), level(99, 2)],
vec![level(101, 1), level(102, 2)],
);
assert_eq!(book.best_bid().unwrap().price, Decimal::from(100));
assert_eq!(book.best_ask().unwrap().price, Decimal::from(101));
assert_eq!(book.spread().unwrap(), Decimal::from(1));
assert_eq!(book.mid_price().unwrap(), Decimal::new(1005, 1));
}
#[test]
fn a_one_sided_book_has_no_spread_and_no_mid() {
let bids_only = book(vec![level(100, 1)], vec![]);
let asks_only = book(vec![], vec![level(101, 1)]);
let empty = book(vec![], vec![]);
for book in [&bids_only, &asks_only, &empty] {
assert!(book.spread().is_none());
assert!(book.mid_price().is_none());
}
}
#[test]
fn a_crossed_book_is_reported_rather_than_hidden() {
let crossed = book(vec![level(102, 1)], vec![level(101, 1)]);
assert_eq!(crossed.spread().unwrap(), Decimal::from(-1));
}
#[test]
fn month_is_the_only_interval_without_a_fixed_length() {
assert_eq!(Interval::Min1.as_secs(), Some(60));
assert_eq!(Interval::Week1.as_secs(), Some(604_800));
assert_eq!(Interval::Month1.as_secs(), None);
}
fn month_start(year: i32, month: u32) -> Timestamp {
let date = chrono::NaiveDate::from_ymd_opt(year, month, 1).expect("a first of the month");
Timestamp::from_secs(date.and_time(chrono::NaiveTime::MIN).and_utc().timestamp())
}
#[test]
fn a_month_advances_by_the_calendar_and_not_by_a_fixed_length() {
let mut at = month_start(2024, 1);
for month in 2..=12 {
at = Interval::Month1.advance(at, 1).expect("the next month");
assert_eq!(at, month_start(2024, month), "month {month}");
}
assert_eq!(
Interval::Month1.advance(at, 1),
Some(month_start(2025, 1)),
"the twelfth step should cross the year"
);
for (year, days) in [(2024, 29), (2023, 28)] {
let february = month_start(year, 2);
let march = Interval::Month1.advance(february, 1).expect("March");
assert_eq!(march.as_secs() - february.as_secs(), days * 86_400);
}
}
#[test]
fn a_negative_count_walks_back_the_same_way_it_walked_forward() {
let january = month_start(2024, 1);
let later = Interval::Month1.advance(january, 14).expect("14 months on");
assert_eq!(later, month_start(2025, 3));
assert_eq!(Interval::Month1.advance(later, -14), Some(january));
assert_eq!(Interval::Month1.advance(january, 0), Some(january));
}
#[test]
fn a_fixed_interval_advances_without_losing_sub_second_precision() {
let at = Timestamp::from_nanos(1_700_000_000_123_456_789);
assert_eq!(
Interval::Min1.advance(at, 2),
Some(Timestamp::from_nanos(1_700_000_120_123_456_789))
);
assert_eq!(
Interval::Week1.advance(at, -1),
Some(Timestamp::from_nanos(
1_700_000_000_123_456_789 - 604_800_000_000_000
))
);
}
#[test]
fn a_step_past_the_representable_range_is_reported_rather_than_wrapped() {
let late = Timestamp::from_secs(9_220_000_000);
assert_eq!(
late.as_secs(),
9_220_000_000,
"the starting point itself fits"
);
assert_eq!(Interval::Month1.advance(late, 12), None);
assert_eq!(Interval::Week1.advance(late, i64::MAX), None);
}
#[test]
fn sides_flip_symmetrically() {
assert_eq!(Side::Buy.flip(), Side::Sell);
assert_eq!(Side::Buy.flip().flip(), Side::Buy);
}
}