1use 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#[derive(Debug, Clone, PartialEq, Default)]
55#[non_exhaustive]
56pub struct InstrumentExt {
57 pub status: Option<SymbolStatus>,
59 pub permission_sets: Option<Vec<Vec<String>>>,
61 pub order_types: Option<Vec<String>>,
63 pub is_spot_trading_allowed: Option<bool>,
65 pub is_margin_trading_allowed: Option<bool>,
67 pub filters: Option<Vec<Filter>>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74#[non_exhaustive]
75pub struct BookExt {
76 pub last_update_id: Option<u64>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82#[non_exhaustive]
83pub struct TradeExt {
84 pub first_trade_id: Option<u64>,
86 pub last_trade_id: Option<u64>,
88 pub is_best_match: Option<bool>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[non_exhaustive]
95pub struct CandleExt {
96 pub close_time: Option<UnixMillis>,
98 pub taker_buy_volume: Option<Decimal>,
100 pub taker_buy_notional: Option<Decimal>,
102}
103
104fn level(level: &types::Level) -> Level {
105 Level::new(level.price, level.quantity)
106}
107
108fn instrument(info: &SymbolInfo) -> Result<Instrument, ClassifiedError> {
110 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
141fn 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 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 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 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}