Skip to main content

rustrade_data/exchange/coinbase/
trade.rs

1use super::CoinbaseChannel;
2use crate::{
3    Identifier,
4    event::{MarketEvent, MarketIter},
5    exchange::ExchangeSub,
6    subscription::trade::PublicTrade,
7};
8use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use rustrade_instrument::{Side, exchange::ExchangeId};
11use rustrade_integration::subscription::SubscriptionId;
12use serde::{Deserialize, Serialize};
13use smol_str::format_smolstr;
14
15/// Coinbase real-time trade WebSocket message.
16///
17/// ### Raw Payload Examples
18/// See docs: <https://docs.cloud.coinbase.com/exchange/docs/websocket-channels#match>
19/// ```json
20/// {
21///     "type": "match",
22///     "trade_id": 10,
23///     "sequence": 50,
24///     "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8",
25///     "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1",
26///     "time": "2014-11-07T08:19:27.028459Z",
27///     "product_id": "BTC-USD",
28///     "size": "5.23512",
29///     "price":
30///     "400.23",
31///     "side": "sell"
32/// }
33/// ```
34#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
35pub struct CoinbaseTrade {
36    #[serde(alias = "product_id", deserialize_with = "de_trade_subscription_id")]
37    pub subscription_id: SubscriptionId,
38    #[serde(alias = "trade_id")]
39    pub id: u64,
40    pub time: DateTime<Utc>,
41    #[serde(
42        alias = "size",
43        deserialize_with = "rustrade_integration::serde::de::de_str"
44    )]
45    pub amount: Decimal,
46    #[serde(deserialize_with = "rustrade_integration::serde::de::de_str")]
47    pub price: Decimal,
48    pub side: Side,
49}
50
51impl Identifier<Option<SubscriptionId>> for CoinbaseTrade {
52    fn id(&self) -> Option<SubscriptionId> {
53        Some(self.subscription_id.clone())
54    }
55}
56
57impl<InstrumentKey> From<(ExchangeId, InstrumentKey, CoinbaseTrade)>
58    for MarketIter<InstrumentKey, PublicTrade>
59{
60    fn from((exchange_id, instrument, trade): (ExchangeId, InstrumentKey, CoinbaseTrade)) -> Self {
61        Self(vec![Ok(MarketEvent {
62            time_exchange: trade.time,
63            time_received: Utc::now(),
64            exchange: exchange_id,
65            instrument,
66            kind: PublicTrade {
67                id: format_smolstr!("{}", trade.id),
68                price: trade.price,
69                amount: trade.amount,
70                side: Some(trade.side),
71            },
72        })])
73    }
74}
75
76/// Deserialize a [`CoinbaseTrade`] "product_id" (eg/ "BTC-USD") as the associated [`SubscriptionId`]
77/// (eg/ SubscriptionId("matches|BTC-USD").
78pub fn de_trade_subscription_id<'de, D>(deserializer: D) -> Result<SubscriptionId, D::Error>
79where
80    D: serde::de::Deserializer<'de>,
81{
82    <&str as Deserialize>::deserialize(deserializer)
83        .map(|product_id| ExchangeSub::from((CoinbaseChannel::TRADES, product_id)).id())
84}
85
86#[cfg(test)]
87#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
88mod tests {
89    use super::*;
90    use chrono::NaiveDateTime;
91    use rust_decimal_macros::dec;
92    use rustrade_integration::error::SocketError;
93    use serde::de::Error;
94    use std::str::FromStr;
95
96    #[test]
97    fn test_de_coinbase_trade() {
98        struct TestCase {
99            input: &'static str,
100            expected: Result<CoinbaseTrade, SocketError>,
101        }
102
103        let cases = vec![
104            TestCase {
105                // TC0: invalid Coinbase message w/ unknown tag
106                input: r#"{"type": "unknown", "sequence": 50,"product_id": "BTC-USD"}"#,
107                expected: Err(SocketError::Deserialise {
108                    error: serde_json::Error::custom(""),
109                    payload: "".to_owned(),
110                }),
111            },
112            TestCase {
113                // TC1: valid Spot CoinbaseTrade
114                input: r#"
115                {
116                    "type": "match","trade_id": 10,"sequence": 50,
117                    "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8",
118                    "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1",
119                    "time": "2014-11-07T08:19:27.028459Z",
120                    "product_id": "BTC-USD", "size": "5.23512", "price": "400.23", "side": "sell"
121                }"#,
122                expected: Ok(CoinbaseTrade {
123                    subscription_id: SubscriptionId::from("matches|BTC-USD"),
124                    id: 10,
125                    price: dec!(400.23),
126                    amount: dec!(5.23512),
127                    side: Side::Sell,
128                    time: NaiveDateTime::from_str("2014-11-07T08:19:27.028459")
129                        .unwrap()
130                        .and_utc(),
131                }),
132            },
133        ];
134
135        for (index, test) in cases.into_iter().enumerate() {
136            let actual = serde_json::from_str::<CoinbaseTrade>(test.input);
137            match (actual, test.expected) {
138                (Ok(actual), Ok(expected)) => {
139                    assert_eq!(actual, expected, "TC{} failed", index)
140                }
141                (Err(_), Err(_)) => {
142                    // Test passed
143                }
144                (actual, expected) => {
145                    // Test failed
146                    panic!(
147                        "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
148                    );
149                }
150            }
151        }
152    }
153}