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, fixed::check_fixed_precision},
36};
37use rust_decimal::Decimal;
38use ustr::Ustr;
39
40use super::{
41 error::Error,
42 models::{Book, Ohlcv, Quote, Symbol, Trade},
43};
44use crate::{
45 common::symbol::{CoinApiSymbolType, Contract, SymbolAssets, SymbolError, VenueSymbol},
46 websocket::parse::{parse_aggressor_side, price, quantity, timestamp, volume},
47};
48
49#[derive(Debug, Clone, PartialEq)]
52pub struct CoinApiInstrument {
53 pub instrument: InstrumentAny,
54 pub symbol_id: Ustr,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum InstrumentError {
60 #[error("CoinAPI symbol {symbol_id}: {source}")]
61 Symbol {
62 symbol_id: Ustr,
63 source: SymbolError,
64 },
65 #[error("CoinAPI symbol {symbol_id}: invalid {field}: {reason}")]
66 Field {
67 symbol_id: Ustr,
68 field: &'static str,
69 reason: String,
70 },
71}
72
73#[must_use]
79pub fn parse_instruments(
80 symbols: &[Symbol],
81 ts_init: UnixNanos,
82) -> (Vec<CoinApiInstrument>, Vec<InstrumentError>) {
83 let mut parsed = Vec::new();
84 let mut errors = Vec::new();
85
86 for symbol in symbols {
87 if CoinApiSymbolType::from_str(symbol.symbol_type.as_ref()).is_err() {
88 continue;
89 }
90
91 match parse_instrument(symbol, ts_init) {
92 Ok(instrument) => parsed.push(instrument),
93 Err(e) => errors.push(e),
94 }
95 }
96
97 let mut counts = HashMap::<InstrumentId, usize>::new();
98 for instrument in &parsed {
99 *counts.entry(instrument.instrument.id()).or_default() += 1;
100 }
101
102 let mut instruments = Vec::with_capacity(parsed.len());
103 for instrument in parsed {
104 let instrument_id = instrument.instrument.id();
105 if counts[&instrument_id] > 1 {
106 errors.push(InstrumentError::Symbol {
107 symbol_id: instrument.symbol_id,
108 source: SymbolError::Ambiguous(instrument_id),
109 });
110
111 continue;
112 }
113
114 instruments.push(instrument);
115 }
116
117 (instruments, errors)
118}
119
120pub(crate) fn validate_price_precisions(
121 price_precisions: &HashMap<InstrumentId, u8>,
122) -> Result<(), Error> {
123 for (instrument_id, precision) in price_precisions {
124 check_fixed_precision(*precision)
125 .map_err(|e| Error::Validation(format!("price precision for {instrument_id}: {e}")))?;
126 }
127
128 Ok(())
129}
130
131pub(crate) fn parse_instruments_with_precisions(
132 symbols: &[Symbol],
133 price_precisions: &HashMap<InstrumentId, u8>,
134 ts_init: UnixNanos,
135) -> Vec<CoinApiInstrument> {
136 let (mut instruments, errors) = parse_instruments(symbols, ts_init);
137
138 for e in errors {
139 log::warn!("Skipping CoinAPI instrument: {e}");
140 }
141
142 for loaded in &mut instruments {
143 let Some(&precision) = price_precisions.get(&loaded.instrument.id()) else {
144 continue;
145 };
146
147 let Some(symbol) = symbols.iter().find(|s| s.symbol_id == loaded.symbol_id) else {
148 continue;
149 };
150
151 let symbol = Symbol {
152 price_precision: Some(Decimal::new(1, u32::from(precision))),
153 ..symbol.clone()
154 };
155
156 match parse_instrument(&symbol, ts_init) {
157 Ok(overridden) => *loaded = overridden,
158 Err(e) => log::warn!("Keeping CoinAPI price resolution: {e}"),
159 }
160 }
161
162 instruments
163}
164
165pub fn parse_instrument(
172 symbol: &Symbol,
173 ts_init: UnixNanos,
174) -> Result<CoinApiInstrument, InstrumentError> {
175 let symbol_id = symbol.symbol_id;
176
177 let field = |field: &'static str, reason: &dyn Display| InstrumentError::Field {
178 symbol_id,
179 field,
180 reason: reason.to_string(),
181 };
182
183 let required = |name: &'static str, value: Option<Ustr>| {
184 let value = value
185 .filter(|value| !value.is_empty())
186 .ok_or_else(|| field(name, &"missing"))?;
187 check_valid_string_utf8(value, name).map_err(|e| field(name, &e))?;
188 Ok(value)
189 };
190
191 let symbol_type = CoinApiSymbolType::from_str(symbol.symbol_type.as_ref()).map_err(|_| {
192 field(
193 "symbol_type",
194 &format!("unsupported {}", symbol.symbol_type),
195 )
196 })?;
197
198 let venue_symbol = VenueSymbol {
199 exchange_id: symbol.exchange_id,
200 symbol_type,
201 symbol: required("symbol_id_exchange", symbol.symbol_id_exchange)?,
202 };
203
204 let assets = SymbolAssets {
205 base: required("asset_id_base", symbol.asset_id_base)?,
206 quote: required("asset_id_quote", symbol.asset_id_quote)?,
207 base_exchange: required("asset_id_base_exchange", symbol.asset_id_base_exchange)?,
208 quote_exchange: required("asset_id_quote_exchange", symbol.asset_id_quote_exchange)?,
209 };
210
211 let delivery = match symbol_type {
212 CoinApiSymbolType::Futures => Some(
213 symbol
214 .future_delivery_time
215 .ok_or_else(|| field("future_delivery_time", &"missing"))?,
216 ),
217 CoinApiSymbolType::Spot | CoinApiSymbolType::Perpetual => None,
218 };
219
220 let native = venue_symbol
221 .to_native(
222 &assets,
223 delivery.map(|time| time.to_zoned(TimeZone::UTC).date()),
224 )
225 .map_err(|source| InstrumentError::Symbol { symbol_id, source })?;
226
227 let price_increment = decimal(symbol.price_precision)
228 .and_then(|value| {
229 Price::from_decimal_dp(value, value.scale() as u8).map_err(|e| e.to_string())
230 })
231 .map_err(|reason| field("price_precision", &reason))?;
232
233 let size_increment = decimal(symbol.size_precision)
234 .and_then(|value| {
235 Quantity::from_decimal_dp(value, value.scale() as u8).map_err(|e| e.to_string())
236 })
237 .map_err(|reason| field("size_precision", &reason))?;
238
239 let instrument_id = native.instrument_id;
240 let raw_symbol = RawSymbol::new(venue_symbol.symbol);
241 let quote = Currency::get_or_create_crypto(assets.quote);
242
243 let instrument = match (symbol_type, native.contract) {
244 (CoinApiSymbolType::Spot, _) => CurrencyPair::builder()
245 .instrument_id(instrument_id)
246 .raw_symbol(raw_symbol)
247 .base_currency(Currency::get_or_create_crypto(assets.base))
248 .quote_currency(quote)
249 .price_precision(price_increment.precision)
250 .size_precision(size_increment.precision)
251 .price_increment(price_increment)
252 .size_increment(size_increment)
253 .ts_event(ts_init)
254 .ts_init(ts_init)
255 .build()
256 .map(InstrumentAny::CurrencyPair),
257 (CoinApiSymbolType::Perpetual, contract) => {
258 let multiplier =
259 multiplier(symbol).map_err(|reason| field("future_contract_unit", &reason))?;
260
261 match perpetual_asset_class(symbol).map_err(|(name, reason)| field(name, &reason))? {
262 Some(asset_class) => PerpetualContract::builder()
263 .instrument_id(instrument_id)
264 .raw_symbol(raw_symbol)
265 .underlying(assets.base)
266 .asset_class(asset_class)
267 .quote_currency(quote)
268 .settlement_currency(quote)
269 .is_inverse(false)
270 .price_precision(price_increment.precision)
271 .size_precision(size_increment.precision)
272 .price_increment(price_increment)
273 .size_increment(size_increment)
274 .multiplier(multiplier)
275 .ts_event(ts_init)
276 .ts_init(ts_init)
277 .build()
278 .map(InstrumentAny::PerpetualContract),
279 None => {
280 let base = Currency::get_or_create_crypto(assets.base);
281
282 CryptoPerpetual::builder()
283 .instrument_id(instrument_id)
284 .raw_symbol(raw_symbol)
285 .base_currency(base)
286 .quote_currency(quote)
287 .settlement_currency(settlement(contract, base, quote))
288 .is_inverse(contract == Contract::Inverse)
289 .price_precision(price_increment.precision)
290 .size_precision(size_increment.precision)
291 .price_increment(price_increment)
292 .size_increment(size_increment)
293 .multiplier(multiplier)
294 .ts_event(ts_init)
295 .ts_init(ts_init)
296 .build()
297 .map(InstrumentAny::CryptoPerpetual)
298 }
299 }
300 }
301 (CoinApiSymbolType::Futures, contract) => {
302 let activation = symbol
303 .data_start
304 .as_deref()
305 .ok_or_else(|| "missing".to_string())
306 .and_then(|value| Date::from_str(value).map_err(|e| e.to_string()))
307 .and_then(|date| {
308 date.to_zoned(TimeZone::UTC)
309 .map_err(|e| e.to_string())
310 .and_then(|zoned| unix_nanos(zoned.timestamp()))
311 })
312 .map_err(|reason| field("data_start", &reason))?;
313
314 let expiration = delivery
315 .map_or_else(|| Err("missing".to_string()), unix_nanos)
316 .map_err(|reason| field("future_delivery_time", &reason))?;
317
318 if activation >= expiration {
319 return Err(field("data_start", &"not before future_delivery_time"));
320 }
321
322 let base = Currency::get_or_create_crypto(assets.base);
323
324 CryptoFuture::builder()
325 .instrument_id(instrument_id)
326 .raw_symbol(raw_symbol)
327 .underlying(base)
328 .quote_currency(quote)
329 .settlement_currency(settlement(contract, base, quote))
330 .is_inverse(contract == Contract::Inverse)
331 .activation_ns(activation)
332 .expiration_ns(expiration)
333 .price_precision(price_increment.precision)
334 .size_precision(size_increment.precision)
335 .price_increment(price_increment)
336 .size_increment(size_increment)
337 .multiplier(
338 multiplier(symbol).map_err(|reason| field("future_contract_unit", &reason))?,
339 )
340 .ts_event(ts_init)
341 .ts_init(ts_init)
342 .build()
343 .map(InstrumentAny::CryptoFuture)
344 }
345 }
346 .map_err(|e| field("definition", &e))?;
347
348 Ok(CoinApiInstrument {
349 instrument,
350 symbol_id,
351 })
352}
353
354pub fn parse_order_book(book: &Book, instrument: &InstrumentAny) -> anyhow::Result<OrderBook> {
361 let ts_event = timestamp(book.time_exchange, "time_exchange")?;
362 let mut order_book = OrderBook::new(instrument.id(), BookType::L2_MBP);
363
364 for (side, levels) in [(OrderSide::Buy, &book.bids), (OrderSide::Sell, &book.asks)] {
365 for level in levels {
366 let order = BookOrder::new(
367 side,
368 price(level.price, instrument, "price")?,
369 quantity(level.size, instrument, "size")?,
370 0,
371 );
372 order_book.add(order, 0, 0, ts_event);
373 }
374 }
375
376 Ok(order_book)
377}
378
379pub fn parse_quote_tick(quote: &Quote, instrument: &InstrumentAny) -> anyhow::Result<QuoteTick> {
386 let ts_event = timestamp(quote.time_exchange, "time_exchange")?;
387 QuoteTick::new_checked(
388 instrument.id(),
389 price(quote.bid_price, instrument, "bid_price")?,
390 price(quote.ask_price, instrument, "ask_price")?,
391 quantity(quote.bid_size, instrument, "bid_size")?,
392 quantity(quote.ask_size, instrument, "ask_size")?,
393 ts_event,
394 ts_event,
395 )
396}
397
398pub fn parse_trade_tick(trade: &Trade, instrument: &InstrumentAny) -> anyhow::Result<TradeTick> {
408 let ts_event = timestamp(trade.time_exchange, "time_exchange")?;
409 TradeTick::new_checked(
410 instrument.id(),
411 price(trade.price, instrument, "price")?,
412 quantity(trade.size, instrument, "size")?,
413 parse_aggressor_side(trade.taker_side),
414 TradeId::new_checked(&trade.uuid)?,
415 ts_event,
416 ts_event,
417 )
418}
419
420pub fn parse_bar(
431 ohlcv: &Ohlcv,
432 bar_type: BarType,
433 instrument: &InstrumentAny,
434) -> anyhow::Result<Bar> {
435 let ts_event = timestamp(ohlcv.time_period_end, "time_period_end")?;
436 Bar::new_checked(
437 bar_type,
438 price(ohlcv.price_open, instrument, "price_open")?,
439 price(ohlcv.price_high, instrument, "price_high")?,
440 price(ohlcv.price_low, instrument, "price_low")?,
441 price(ohlcv.price_close, instrument, "price_close")?,
442 volume(ohlcv.volume_traded, instrument)?,
443 ts_event,
444 ts_event,
445 )
446}
447
448fn settlement(contract: Contract, base: Currency, quote: Currency) -> Currency {
449 match contract {
450 Contract::Inverse => base,
451 Contract::Linear | Contract::Spot => quote,
452 }
453}
454
455fn perpetual_asset_class(symbol: &Symbol) -> Result<Option<AssetClass>, (&'static str, String)> {
458 if symbol.exchange_id != "BINANCEFTS" {
459 return Ok(None);
460 }
461
462 let raw_kvp = symbol.raw_kvp.as_ref();
463 match raw_kvp.and_then(|raw_kvp| raw_kvp.contract_type).as_deref() {
464 Some("PERPETUAL") => Ok(None),
465 Some("TRADIFI_PERPETUAL") => {
466 match raw_kvp
467 .and_then(|raw_kvp| raw_kvp.underlying_type)
468 .as_deref()
469 {
470 Some("EQUITY" | "CN_EQUITY" | "KR_EQUITY" | "HK_EQUITY" | "PREMARKET") => {
471 Ok(Some(AssetClass::Equity))
472 }
473 Some("COMMODITY") => Ok(Some(AssetClass::Commodity)),
474 other => Err(("raw_kvp.underlyingType", format!("unsupported {other:?}"))),
475 }
476 }
477 other => Err(("raw_kvp.contractType", format!("unsupported {other:?}"))),
478 }
479}
480
481fn multiplier(symbol: &Symbol) -> Result<Quantity, String> {
482 decimal(symbol.future_contract_unit).and_then(|value| {
483 Quantity::from_decimal_dp(value, value.scale() as u8).map_err(|e| e.to_string())
484 })
485}
486
487fn decimal(value: Option<Decimal>) -> Result<Decimal, String> {
489 match value {
490 None => Err("missing".to_string()),
491 Some(value) if value <= Decimal::ZERO => Err(format!("{value} is not positive")),
492 Some(value) => Ok(value.normalize()),
493 }
494}
495
496pub(crate) fn unix_nanos(timestamp: Timestamp) -> Result<UnixNanos, String> {
497 u64::try_from(timestamp.as_nanosecond())
498 .map(UnixNanos::from)
499 .map_err(|_| format!("{timestamp} precedes the UNIX epoch"))
500}
501
502#[cfg(test)]
503mod tests {
504 use rstest::rstest;
505
506 use super::*;
507 use crate::http::models::BookLevel;
508
509 fn btc_usd() -> InstrumentAny {
510 let symbols: Vec<Symbol> =
511 serde_json::from_str(include_str!("../../test_data/symbols.json")).unwrap();
512 let (instruments, _) = parse_instruments(&symbols, UnixNanos::default());
513 instruments
514 .into_iter()
515 .find(|loaded| loaded.symbol_id == "COINBASE_SPOT_BTC_USD")
516 .unwrap()
517 .instrument
518 }
519
520 fn book() -> Book {
521 serde_json::from_str(include_str!("../../test_data/book.json")).unwrap()
522 }
523
524 #[rstest]
525 #[case("0.9707556800000001", "0.97075568")]
526 #[case("7.087936829999999", "7.08793683")]
527 #[case("1.2582318399999999", "1.25823184")]
528 fn bar_normalizes_volume_noise(#[case] volume: &str, #[case] expected_volume: &str) {
529 let mut records: Vec<Ohlcv> =
530 serde_json::from_str(include_str!("../../test_data/ohlcv.json")).unwrap();
531 let ohlcv = &mut records[0];
532 ohlcv.price_close = Decimal::from_str_exact("85998.69").unwrap();
533 ohlcv.volume_traded = Decimal::from_str_exact(volume).unwrap();
534 let bar_type = BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL");
535
536 let bar = parse_bar(ohlcv, bar_type, &btc_usd()).unwrap();
537
538 assert_eq!(
539 bar,
540 Bar::new(
541 bar_type,
542 Price::from("85988.49"),
543 Price::from("86007.23"),
544 Price::from("85986.91"),
545 Price::from("85998.69"),
546 Quantity::from(expected_volume),
547 UnixNanos::from(1_790_154_600_000_000_000),
548 UnixNanos::from(1_790_154_600_000_000_000),
549 )
550 );
551 assert_eq!(bar.volume.precision, 8);
552 }
553
554 #[rstest]
555 fn bar_rejects_finer_price_despite_volume_noise() {
556 let records: Vec<Ohlcv> =
557 serde_json::from_str(include_str!("../../test_data/ohlcv.json")).unwrap();
558 let bar_type = BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL");
559
560 let error = parse_bar(&records[0], bar_type, &btc_usd()).unwrap_err();
561
562 assert_eq!(
563 error.to_string(),
564 "price_close 85998.695 is finer than instrument precision 2"
565 );
566 }
567
568 #[rstest]
569 fn order_book_rejects_whole_snapshot_for_one_finer_level() {
570 let mut book = book();
571 book.asks.push(BookLevel {
572 id: None,
573 price: Decimal::from_str_exact("83556.605").unwrap(),
574 size: Decimal::ONE,
575 });
576
577 let error = parse_order_book(&book, &btc_usd()).unwrap_err();
578
579 assert_eq!(
580 error.to_string(),
581 "price 83556.605 is finer than instrument precision 2"
582 );
583 }
584}