Skip to main content

polyoxide_binance/spot/
venue.rs

1//! Spot through the venue-neutral market-data traits.
2//!
3//! [`Spot`] implements [`MarketData`], [`Instruments`], [`Trades`] and
4//! [`Candles`] over its own routes, keyed `binance.spot:<SYMBOL>` by the
5//! constructors in `spot::key`. Spot has no funding, so it implements no
6//! `Funding`. What Binance alone publishes rides in this module's
7//! `<Record>Ext` types, one per record, in each record's
8//! [`Extensions`](polyoxide_venue::Extensions); a quote has none, since
9//! `ticker/bookTicker` carries nothing beyond the record's fields.
10//!
11//! `instruments` skips a pair whose `exchangeInfo` row does not convert, with
12//! a WARN naming the symbol and the error, rather than failing the whole
13//! listing.
14//!
15//! | Method | Route | Weight |
16//! | --- | --- | --- |
17//! | `instrument` | `exchangeInfo?symbol=` | 20 |
18//! | `instruments` | `exchangeInfo` | 20 |
19//! | `quote` | `ticker/bookTicker` | 2 |
20//! | `book` | `depth`, no limit (100 levels) | 5 |
21//! | `trades` | `aggTrades` (the last 1000) | 4 |
22//! | `candles` | `klines`, from `start` (up to 1000) | 2 |
23//!
24//! ```no_run
25//! use std::sync::Arc;
26//!
27//! use polyoxide_binance::spot::{self, types::Symbol, Spot};
28//! use polyoxide_venue::{DynMarketData, MarketData};
29//!
30//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
31//! let venue: Arc<DynMarketData<'static>> = DynMarketData::new_arc(Spot::new()?);
32//! let quote = venue.quote(&spot::key(&Symbol::new("BTCUSDT")?)).await?;
33//! println!("bid {:?} ask {:?}", quote.bid, quote.ask);
34//! # Ok(())
35//! # }
36//! ```
37
38use polyoxide_venue::{
39    Book, Candle, CandleInterval, CandleQuery, Candles, Class, ClassifiedError, Instrument,
40    Instruments, Level, MarketData, MarketKey, NotListed, Quote, Side, SizeUnit, Trade, TradeId,
41    Trades, UnixMillis,
42};
43use rust_decimal::Decimal;
44
45use crate::spot::{
46    key,
47    types::{self, Filter, Interval, Symbol, SymbolInfo, SymbolStatus},
48    Spot,
49};
50
51/// Binance's facts about a trading pair, beside [`Instrument`]'s fields.
52///
53/// It is not `Eq`: [`Filter`] is `PartialEq` alone.
54#[derive(Debug, Clone, PartialEq, Default)]
55#[non_exhaustive]
56pub struct InstrumentExt {
57    /// Trading, halted and so on.
58    pub status: Option<SymbolStatus>,
59    /// The permission groups that may trade the pair.
60    pub permission_sets: Option<Vec<Vec<String>>>,
61    /// The order types the pair accepts.
62    pub order_types: Option<Vec<String>>,
63    /// Whether spot trading is allowed.
64    pub is_spot_trading_allowed: Option<bool>,
65    /// Whether margin trading is allowed.
66    pub is_margin_trading_allowed: Option<bool>,
67    /// Every order filter, the ones [`Instrument`]'s fields come from
68    /// included.
69    pub filters: Option<Vec<Filter>>,
70}
71
72/// Binance's facts about a book, beside [`Book`]'s fields.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74#[non_exhaustive]
75pub struct BookExt {
76    /// The book update id of the snapshot, to align it with the depth stream.
77    pub last_update_id: Option<u64>,
78}
79
80/// Binance's facts about an aggregate trade, beside [`Trade`]'s fields.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82#[non_exhaustive]
83pub struct TradeExt {
84    /// The first trade merged into it.
85    pub first_trade_id: Option<u64>,
86    /// The last trade merged into it.
87    pub last_trade_id: Option<u64>,
88    /// Whether the trade was the best price match.
89    pub is_best_match: Option<bool>,
90}
91
92/// Binance's facts about a candle, beside [`Candle`]'s fields.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[non_exhaustive]
95pub struct CandleExt {
96    /// When the candle closes, inclusive.
97    pub close_time: Option<UnixMillis>,
98    /// The size takers bought.
99    pub taker_buy_volume: Option<Decimal>,
100    /// The quote-currency amount takers bought.
101    pub taker_buy_notional: Option<Decimal>,
102}
103
104fn level(level: &types::Level) -> Level {
105    Level::new(level.price, level.quantity)
106}
107
108/// The instrument a pair's row describes.
109fn instrument(info: &SymbolInfo) -> Result<Instrument, ClassifiedError> {
110    // A row whose symbol no key can carry is a response this client cannot read.
111    let symbol = Symbol::new(info.symbol.as_str())
112        .map_err(|err| ClassifiedError::new(Class::Decode, err))?;
113    let mut instrument = Instrument::new(key::key(&symbol), info.symbol.clone(), SizeUnit::Base);
114    instrument.base_asset = Some(info.base_asset.clone());
115    instrument.quote_asset = Some(info.quote_asset.clone());
116    instrument.tradable = Some(info.status == SymbolStatus::Trading);
117    for filter in &info.filters {
118        match filter {
119            Filter::PriceFilter { tick_size, .. } => instrument.tick_size = Some(*tick_size),
120            Filter::LotSize {
121                min_qty, step_size, ..
122            } => {
123                instrument.min_size = Some(*min_qty);
124                instrument.size_increment = Some(*step_size);
125            }
126            Filter::Notional { min_notional, .. } => instrument.min_notional = Some(*min_notional),
127            _ => {}
128        }
129    }
130    instrument.extensions = polyoxide_venue::Extensions::of(InstrumentExt {
131        status: Some(info.status.clone()),
132        permission_sets: Some(info.permission_sets.clone()),
133        order_types: Some(info.order_types.clone()),
134        is_spot_trading_allowed: Some(info.is_spot_trading_allowed),
135        is_margin_trading_allowed: Some(info.is_margin_trading_allowed),
136        filters: Some(info.filters.clone()),
137    });
138    Ok(instrument)
139}
140
141/// The kline width a candle interval is.
142fn interval(interval: CandleInterval) -> Interval {
143    match interval {
144        CandleInterval::Minute1 => Interval::Minute1,
145        CandleInterval::Minute5 => Interval::Minute5,
146        CandleInterval::Minute15 => Interval::Minute15,
147        CandleInterval::Hour1 => Interval::Hour1,
148        CandleInterval::Hour4 => Interval::Hour4,
149        CandleInterval::Day1 => Interval::Day1,
150        CandleInterval::Week1 => Interval::Week1,
151    }
152}
153
154impl MarketData for Spot {
155    async fn instrument(&self, key: &MarketKey) -> Result<Instrument, ClassifiedError> {
156        let symbol = key::symbol(key)?;
157        let info = self
158            .exchange()
159            .exchange_info()
160            .symbol(&symbol)
161            .send()
162            .await?;
163        match info
164            .symbols
165            .iter()
166            .find(|row| row.symbol == symbol.as_str())
167        {
168            Some(row) => instrument(row),
169            None => Err(NotListed::new(key.clone()).into()),
170        }
171    }
172
173    async fn quote(&self, key: &MarketKey) -> Result<Quote, ClassifiedError> {
174        let symbol = key::symbol(key)?;
175        let ticker = self.market().book_ticker(&symbol).send().await?;
176        let mut quote = Quote::new(key.clone());
177        quote.bid = Some(Level::new(ticker.bid_price, ticker.bid_qty));
178        quote.ask = Some(Level::new(ticker.ask_price, ticker.ask_qty));
179        Ok(quote)
180    }
181
182    async fn book(&self, key: &MarketKey) -> Result<Book, ClassifiedError> {
183        let symbol = key::symbol(key)?;
184        let depth = self.market().depth(&symbol).send().await?;
185        let mut book = Book::new(key.clone());
186        book.bids = depth.bids.iter().map(level).collect();
187        book.asks = depth.asks.iter().map(level).collect();
188        book.extensions = polyoxide_venue::Extensions::of(BookExt {
189            last_update_id: Some(depth.last_update_id),
190        });
191        Ok(book)
192    }
193}
194
195impl Instruments for Spot {
196    /// Every pair `exchangeInfo` lists. A row that does not convert (a symbol
197    /// no key can carry) is logged at WARN with its symbol and the error, and
198    /// skipped: the rest are returned.
199    async fn instruments(&self) -> Result<Vec<Instrument>, ClassifiedError> {
200        let info = self.exchange().exchange_info().send().await?;
201        Ok(info
202            .symbols
203            .iter()
204            .filter_map(|row| match instrument(row) {
205                Ok(instrument) => Some(instrument),
206                Err(err) => {
207                    tracing::warn!(symbol = %row.symbol, error = %err, "skipping an exchangeInfo row that does not convert");
208                    None
209                }
210            })
211            .collect())
212    }
213}
214
215impl Trades for Spot {
216    async fn trades(&self, key: &MarketKey) -> Result<Vec<Trade>, ClassifiedError> {
217        let symbol = key::symbol(key)?;
218        let rows = self.market().agg_trades(&symbol).limit(1000).send().await?;
219        Ok(rows
220            .iter()
221            .map(|row| {
222                let mut trade =
223                    Trade::new(key.clone(), row.price, row.quantity, UnixMillis(row.time));
224                trade.id = Some(TradeId::new(row.id.to_string()));
225                // The buyer made the market, so the taker sold.
226                trade.taker_side = Some(if row.is_buyer_maker {
227                    Side::Sell
228                } else {
229                    Side::Buy
230                });
231                trade.extensions = polyoxide_venue::Extensions::of(TradeExt {
232                    first_trade_id: Some(row.first_trade_id),
233                    last_trade_id: Some(row.last_trade_id),
234                    is_best_match: Some(row.is_best_match),
235                });
236                trade
237            })
238            .collect())
239    }
240}
241
242impl Candles for Spot {
243    async fn candles(
244        &self,
245        key: &MarketKey,
246        query: &CandleQuery,
247    ) -> Result<Vec<Candle>, ClassifiedError> {
248        let symbol = key::symbol(key)?;
249        let mut request = self
250            .market()
251            .klines(&symbol, interval(query.interval))
252            .start_time(query.start.0);
253        if let Some(end) = query.end {
254            request = request.end_time(end.0);
255        }
256        // 1000 candles cost weight 2, as the default 500 does.
257        let rows = request.limit(1000).send().await?;
258        Ok(rows
259            .iter()
260            .map(|row| {
261                let mut candle = Candle::new(
262                    key.clone(),
263                    query.interval,
264                    UnixMillis(row.open_time),
265                    row.close,
266                );
267                candle.open = Some(row.open);
268                candle.high = Some(row.high);
269                candle.low = Some(row.low);
270                candle.volume = Some(row.volume);
271                candle.notional = Some(row.quote_volume);
272                candle.trades = Some(row.trade_count);
273                candle.extensions = polyoxide_venue::Extensions::of(CandleExt {
274                    close_time: Some(UnixMillis(row.close_time)),
275                    taker_buy_volume: Some(row.taker_buy_base_volume),
276                    taker_buy_notional: Some(row.taker_buy_quote_volume),
277                });
278                candle
279            })
280            .collect())
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn every_candle_interval_is_a_kline_width() {
290        let widths: Vec<&str> = CandleInterval::ALL
291            .iter()
292            .map(|each| interval(*each).as_str())
293            .collect();
294        assert_eq!(widths, ["1m", "5m", "15m", "1h", "4h", "1d", "1w"]);
295    }
296
297    #[test]
298    fn a_row_whose_symbol_no_key_carries_is_a_decode_failure() {
299        let info: types::ExchangeInfo = serde_json::from_str(include_str!(
300            "../../tests/fixtures/spot/rest/exchange_info.json"
301        ))
302        .unwrap();
303        let mut row = info.symbols[0].clone();
304        row.symbol = "BTC/USDT".to_owned();
305        let err = instrument(&row).unwrap_err();
306        assert_eq!(err.class, Class::Decode);
307    }
308}