1use std::{collections::HashMap, fmt::Display, str::FromStr};
24
25use jiff::{Timestamp, civil::Date, tz::TimeZone};
26use nautilus_core::{UnixNanos, correctness::check_valid_string_utf8};
27use nautilus_model::{
28 data::{Bar, BarType, BookOrder, QuoteTick, TradeTick},
29 enums::{AssetClass, BookType, OrderSide},
30 identifiers::{InstrumentId, Symbol as RawSymbol, TradeId},
31 instruments::{
32 CryptoFuture, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny, PerpetualContract,
33 },
34 orderbook::OrderBook,
35 types::{Currency, Price, Quantity},
36};
37use rust_decimal::Decimal;
38use ustr::Ustr;
39
40use super::models::{Book, Ohlcv, Quote, Symbol, Trade};
41use crate::{
42 common::symbol::{CoinApiSymbolType, Contract, SymbolAssets, SymbolError, VenueSymbol},
43 websocket::parse::{parse_aggressor_side, price, quantity, timestamp, volume},
44};
45
46#[derive(Debug, Clone, PartialEq)]
49pub struct CoinApiInstrument {
50 pub instrument: InstrumentAny,
51 pub symbol_id: Ustr,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56pub enum InstrumentError {
57 #[error("CoinAPI symbol {symbol_id}: {source}")]
58 Symbol {
59 symbol_id: Ustr,
60 source: SymbolError,
61 },
62 #[error("CoinAPI symbol {symbol_id}: invalid {field}: {reason}")]
63 Field {
64 symbol_id: Ustr,
65 field: &'static str,
66 reason: String,
67 },
68}
69
70#[must_use]
76pub fn parse_instruments(
77 symbols: &[Symbol],
78 ts_init: UnixNanos,
79) -> (Vec<CoinApiInstrument>, Vec<InstrumentError>) {
80 let mut parsed = Vec::new();
81 let mut errors = Vec::new();
82
83 for symbol in symbols {
84 if CoinApiSymbolType::from_str(&symbol.symbol_type).is_err() {
85 continue;
86 }
87
88 match parse_instrument(symbol, ts_init) {
89 Ok(instrument) => parsed.push(instrument),
90 Err(e) => errors.push(e),
91 }
92 }
93
94 let mut counts = HashMap::<InstrumentId, usize>::new();
95 for instrument in &parsed {
96 *counts.entry(instrument.instrument.id()).or_default() += 1;
97 }
98
99 let mut instruments = Vec::with_capacity(parsed.len());
100 for instrument in parsed {
101 let instrument_id = instrument.instrument.id();
102 if counts[&instrument_id] > 1 {
103 errors.push(InstrumentError::Symbol {
104 symbol_id: instrument.symbol_id,
105 source: SymbolError::Ambiguous(instrument_id),
106 });
107
108 continue;
109 }
110
111 instruments.push(instrument);
112 }
113
114 (instruments, errors)
115}
116
117pub fn parse_instrument(
124 symbol: &Symbol,
125 ts_init: UnixNanos,
126) -> Result<CoinApiInstrument, InstrumentError> {
127 let symbol_id = symbol.symbol_id;
128
129 let field = |field: &'static str, reason: &dyn Display| InstrumentError::Field {
130 symbol_id,
131 field,
132 reason: reason.to_string(),
133 };
134
135 let required = |name: &'static str, value: Option<Ustr>| {
136 let value = value
137 .filter(|value| !value.is_empty())
138 .ok_or_else(|| field(name, &"missing"))?;
139 check_valid_string_utf8(value, name).map_err(|e| field(name, &e))?;
140 Ok(value)
141 };
142
143 let symbol_type = CoinApiSymbolType::from_str(&symbol.symbol_type).map_err(|_| {
144 field(
145 "symbol_type",
146 &format!("unsupported {}", symbol.symbol_type),
147 )
148 })?;
149
150 let venue_symbol = VenueSymbol {
151 exchange_id: symbol.exchange_id,
152 symbol_type,
153 symbol: required("symbol_id_exchange", symbol.symbol_id_exchange)?,
154 };
155
156 let assets = SymbolAssets {
157 base: required("asset_id_base", symbol.asset_id_base)?,
158 quote: required("asset_id_quote", symbol.asset_id_quote)?,
159 base_exchange: required("asset_id_base_exchange", symbol.asset_id_base_exchange)?,
160 quote_exchange: required("asset_id_quote_exchange", symbol.asset_id_quote_exchange)?,
161 };
162
163 let delivery = match symbol_type {
164 CoinApiSymbolType::Futures => Some(
165 symbol
166 .future_delivery_time
167 .ok_or_else(|| field("future_delivery_time", &"missing"))?,
168 ),
169 CoinApiSymbolType::Spot | CoinApiSymbolType::Perpetual => None,
170 };
171
172 let native = venue_symbol
173 .to_native(
174 &assets,
175 delivery.map(|time| time.to_zoned(TimeZone::UTC).date()),
176 )
177 .map_err(|source| InstrumentError::Symbol { symbol_id, source })?;
178
179 let price_increment = decimal(symbol.price_precision)
180 .and_then(|value| Price::from_decimal(value).map_err(|e| e.to_string()))
181 .map_err(|reason| field("price_precision", &reason))?;
182 let size_increment = decimal(symbol.size_precision)
183 .and_then(|value| Quantity::from_decimal(value).map_err(|e| e.to_string()))
184 .map_err(|reason| field("size_precision", &reason))?;
185
186 let instrument_id = native.instrument_id;
187 let raw_symbol = RawSymbol::new(venue_symbol.symbol);
188 let quote = Currency::get_or_create_crypto(assets.quote);
189
190 let instrument = match (symbol_type, native.contract) {
191 (CoinApiSymbolType::Spot, _) => CurrencyPair::builder()
192 .instrument_id(instrument_id)
193 .raw_symbol(raw_symbol)
194 .base_currency(Currency::get_or_create_crypto(assets.base))
195 .quote_currency(quote)
196 .price_precision(price_increment.precision)
197 .size_precision(size_increment.precision)
198 .price_increment(price_increment)
199 .size_increment(size_increment)
200 .ts_event(ts_init)
201 .ts_init(ts_init)
202 .build()
203 .map(InstrumentAny::CurrencyPair),
204 (CoinApiSymbolType::Perpetual, contract) => {
205 let multiplier =
206 multiplier(symbol).map_err(|reason| field("future_contract_unit", &reason))?;
207
208 match perpetual_asset_class(symbol).map_err(|(name, reason)| field(name, &reason))? {
209 Some(asset_class) => PerpetualContract::builder()
210 .instrument_id(instrument_id)
211 .raw_symbol(raw_symbol)
212 .underlying(assets.base)
213 .asset_class(asset_class)
214 .quote_currency(quote)
215 .settlement_currency(quote)
216 .is_inverse(false)
217 .price_precision(price_increment.precision)
218 .size_precision(size_increment.precision)
219 .price_increment(price_increment)
220 .size_increment(size_increment)
221 .multiplier(multiplier)
222 .ts_event(ts_init)
223 .ts_init(ts_init)
224 .build()
225 .map(InstrumentAny::PerpetualContract),
226 None => {
227 let base = Currency::get_or_create_crypto(assets.base);
228
229 CryptoPerpetual::builder()
230 .instrument_id(instrument_id)
231 .raw_symbol(raw_symbol)
232 .base_currency(base)
233 .quote_currency(quote)
234 .settlement_currency(settlement(contract, base, quote))
235 .is_inverse(contract == Contract::Inverse)
236 .price_precision(price_increment.precision)
237 .size_precision(size_increment.precision)
238 .price_increment(price_increment)
239 .size_increment(size_increment)
240 .multiplier(multiplier)
241 .ts_event(ts_init)
242 .ts_init(ts_init)
243 .build()
244 .map(InstrumentAny::CryptoPerpetual)
245 }
246 }
247 }
248 (CoinApiSymbolType::Futures, contract) => {
249 let activation = symbol
250 .data_start
251 .as_deref()
252 .ok_or_else(|| "missing".to_string())
253 .and_then(|value| Date::from_str(value).map_err(|e| e.to_string()))
254 .and_then(|date| {
255 date.to_zoned(TimeZone::UTC)
256 .map_err(|e| e.to_string())
257 .and_then(|zoned| unix_nanos(zoned.timestamp()))
258 })
259 .map_err(|reason| field("data_start", &reason))?;
260
261 let expiration = delivery
262 .map_or_else(|| Err("missing".to_string()), unix_nanos)
263 .map_err(|reason| field("future_delivery_time", &reason))?;
264
265 if activation >= expiration {
266 return Err(field("data_start", &"not before future_delivery_time"));
267 }
268
269 let base = Currency::get_or_create_crypto(assets.base);
270
271 CryptoFuture::builder()
272 .instrument_id(instrument_id)
273 .raw_symbol(raw_symbol)
274 .underlying(base)
275 .quote_currency(quote)
276 .settlement_currency(settlement(contract, base, quote))
277 .is_inverse(contract == Contract::Inverse)
278 .activation_ns(activation)
279 .expiration_ns(expiration)
280 .price_precision(price_increment.precision)
281 .size_precision(size_increment.precision)
282 .price_increment(price_increment)
283 .size_increment(size_increment)
284 .multiplier(
285 multiplier(symbol).map_err(|reason| field("future_contract_unit", &reason))?,
286 )
287 .ts_event(ts_init)
288 .ts_init(ts_init)
289 .build()
290 .map(InstrumentAny::CryptoFuture)
291 }
292 }
293 .map_err(|e| field("definition", &e))?;
294
295 Ok(CoinApiInstrument {
296 instrument,
297 symbol_id,
298 })
299}
300
301pub fn parse_order_book(book: &Book, instrument: &InstrumentAny) -> anyhow::Result<OrderBook> {
308 let ts_event = timestamp(book.time_exchange, "time_exchange")?;
309 let mut order_book = OrderBook::new(instrument.id(), BookType::L2_MBP);
310
311 for (side, levels) in [(OrderSide::Buy, &book.bids), (OrderSide::Sell, &book.asks)] {
312 for level in levels {
313 let order = BookOrder::new(
314 side,
315 price(level.price, instrument, "price")?,
316 quantity(level.size, instrument, "size")?,
317 0,
318 );
319 order_book.add(order, 0, 0, ts_event);
320 }
321 }
322
323 Ok(order_book)
324}
325
326pub fn parse_quote_tick(quote: &Quote, instrument: &InstrumentAny) -> anyhow::Result<QuoteTick> {
333 let ts_event = timestamp(quote.time_exchange, "time_exchange")?;
334 QuoteTick::new_checked(
335 instrument.id(),
336 price(quote.bid_price, instrument, "bid_price")?,
337 price(quote.ask_price, instrument, "ask_price")?,
338 quantity(quote.bid_size, instrument, "bid_size")?,
339 quantity(quote.ask_size, instrument, "ask_size")?,
340 ts_event,
341 ts_event,
342 )
343}
344
345pub fn parse_trade_tick(trade: &Trade, instrument: &InstrumentAny) -> anyhow::Result<TradeTick> {
355 let ts_event = timestamp(trade.time_exchange, "time_exchange")?;
356 TradeTick::new_checked(
357 instrument.id(),
358 price(trade.price, instrument, "price")?,
359 quantity(trade.size, instrument, "size")?,
360 parse_aggressor_side(trade.taker_side),
361 TradeId::new_checked(&trade.uuid)?,
362 ts_event,
363 ts_event,
364 )
365}
366
367pub fn parse_bar(
378 ohlcv: &Ohlcv,
379 bar_type: BarType,
380 instrument: &InstrumentAny,
381) -> anyhow::Result<Bar> {
382 let ts_event = timestamp(ohlcv.time_period_end, "time_period_end")?;
383 Bar::new_checked(
384 bar_type,
385 price(ohlcv.price_open, instrument, "price_open")?,
386 price(ohlcv.price_high, instrument, "price_high")?,
387 price(ohlcv.price_low, instrument, "price_low")?,
388 price(ohlcv.price_close, instrument, "price_close")?,
389 volume(ohlcv.volume_traded, instrument)?,
390 ts_event,
391 ts_event,
392 )
393}
394
395fn settlement(contract: Contract, base: Currency, quote: Currency) -> Currency {
396 match contract {
397 Contract::Inverse => base,
398 Contract::Linear | Contract::Spot => quote,
399 }
400}
401
402fn perpetual_asset_class(symbol: &Symbol) -> Result<Option<AssetClass>, (&'static str, String)> {
405 if symbol.exchange_id != "BINANCEFTS" {
406 return Ok(None);
407 }
408
409 let raw_kvp = symbol.raw_kvp.as_ref();
410 match raw_kvp.and_then(|raw_kvp| raw_kvp.contract_type).as_deref() {
411 Some("PERPETUAL") => Ok(None),
412 Some("TRADIFI_PERPETUAL") => {
413 match raw_kvp
414 .and_then(|raw_kvp| raw_kvp.underlying_type)
415 .as_deref()
416 {
417 Some("EQUITY" | "CN_EQUITY" | "KR_EQUITY" | "HK_EQUITY" | "PREMARKET") => {
418 Ok(Some(AssetClass::Equity))
419 }
420 Some("COMMODITY") => Ok(Some(AssetClass::Commodity)),
421 other => Err(("raw_kvp.underlyingType", format!("unsupported {other:?}"))),
422 }
423 }
424 other => Err(("raw_kvp.contractType", format!("unsupported {other:?}"))),
425 }
426}
427
428fn multiplier(symbol: &Symbol) -> Result<Quantity, String> {
429 decimal(symbol.future_contract_unit)
430 .and_then(|value| Quantity::from_decimal(value).map_err(|e| e.to_string()))
431}
432
433fn decimal(value: Option<Decimal>) -> Result<Decimal, String> {
435 match value {
436 None => Err("missing".to_string()),
437 Some(value) if value <= Decimal::ZERO => Err(format!("{value} is not positive")),
438 Some(value) => Ok(value.normalize()),
439 }
440}
441
442pub(crate) fn unix_nanos(timestamp: Timestamp) -> Result<UnixNanos, String> {
443 u64::try_from(timestamp.as_nanosecond())
444 .map(UnixNanos::from)
445 .map_err(|_| format!("{timestamp} precedes the UNIX epoch"))
446}
447
448#[cfg(test)]
449mod tests {
450 use rstest::rstest;
451
452 use super::*;
453 use crate::http::models::BookLevel;
454
455 fn btc_usd() -> InstrumentAny {
456 let symbols: Vec<Symbol> =
457 serde_json::from_str(include_str!("../../test_data/symbols.json")).unwrap();
458 let (instruments, _) = parse_instruments(&symbols, UnixNanos::default());
459 instruments
460 .into_iter()
461 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
462 .unwrap()
463 .instrument
464 }
465
466 fn book() -> Book {
467 serde_json::from_str(include_str!("../../test_data/book.json")).unwrap()
468 }
469
470 #[rstest]
471 #[case("0.9707556800000001", "0.97075568")]
472 #[case("7.087936829999999", "7.08793683")]
473 #[case("1.2582318399999999", "1.25823184")]
474 fn bar_normalizes_volume_noise(#[case] volume: &str, #[case] expected_volume: &str) {
475 let mut records: Vec<Ohlcv> =
476 serde_json::from_str(include_str!("../../test_data/ohlcv.json")).unwrap();
477 let ohlcv = &mut records[0];
478 ohlcv.price_close = Decimal::from_str_exact("85998.69").unwrap();
479 ohlcv.volume_traded = Decimal::from_str_exact(volume).unwrap();
480 let bar_type = BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL");
481
482 let bar = parse_bar(ohlcv, bar_type, &btc_usd()).unwrap();
483
484 assert_eq!(
485 bar,
486 Bar::new(
487 bar_type,
488 Price::from("85988.49"),
489 Price::from("86007.23"),
490 Price::from("85986.91"),
491 Price::from("85998.69"),
492 Quantity::from(expected_volume),
493 UnixNanos::from(1_790_154_600_000_000_000),
494 UnixNanos::from(1_790_154_600_000_000_000),
495 )
496 );
497 assert_eq!(bar.volume.precision, 8);
498 }
499
500 #[rstest]
501 fn bar_rejects_finer_price_despite_volume_noise() {
502 let records: Vec<Ohlcv> =
503 serde_json::from_str(include_str!("../../test_data/ohlcv.json")).unwrap();
504 let bar_type = BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL");
505
506 let error = parse_bar(&records[0], bar_type, &btc_usd()).unwrap_err();
507
508 assert_eq!(
509 error.to_string(),
510 "price_close 85998.695 is finer than instrument precision 2"
511 );
512 }
513
514 #[rstest]
515 fn order_book_rejects_whole_snapshot_for_one_finer_level() {
516 let mut book = book();
517 book.asks.push(BookLevel {
518 id: None,
519 price: Decimal::from_str_exact("83556.605").unwrap(),
520 size: Decimal::ONE,
521 });
522
523 let error = parse_order_book(&book, &btc_usd()).unwrap_err();
524
525 assert_eq!(
526 error.to_string(),
527 "price 83556.605 is finer than instrument precision 2"
528 );
529 }
530}