Skip to main content

nautilus_coinapi/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Conversion of CoinAPI REST responses into Nautilus instruments and market data.
17//!
18//! Price and size increments come from CoinAPI `price_precision` and `size_precision`. These
19//! are provider data resolutions, not venue trading increments. Market data values finer than
20//! the instrument precision are rejected, except for bounded numeric tails in OHLCV
21//! volume, as on the WebSocket path.
22
23use 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/// A native instrument with the full CoinAPI `symbol_id` for its transport requests and
50/// subscriptions.
51#[derive(Debug, Clone, PartialEq)]
52pub struct CoinApiInstrument {
53    pub instrument: InstrumentAny,
54    pub symbol_id: Ustr,
55}
56
57/// A CoinAPI symbol that cannot become a complete native instrument.
58#[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/// Parses the spot, perpetual, and futures symbols in a metadata response.
74///
75/// Other CoinAPI symbol types are skipped. Symbols of a parsed type that cannot be mapped or
76/// validated are returned as errors alongside the instruments. Valid symbols that resolve to the
77/// same native instrument ID are all rejected, because their transport symbol would be ambiguous.
78#[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
165/// Parses one CoinAPI symbol into a native spot, perpetual, or futures instrument.
166///
167/// # Errors
168///
169/// Returns an error if the symbol type is unsupported, the native mapping is unsupported or
170/// unresolved, or a required identity, precision, contract, or date field is missing or invalid.
171pub 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
354/// Converts a current order book into an L2 market-by-price book stamped with `time_exchange`.
355///
356/// # Errors
357///
358/// Returns an error if a level is finer than the instrument precision or the timestamp is invalid;
359/// a single invalid level rejects the whole snapshot.
360pub 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
379/// Converts a historical best bid and ask, taking both `ts_event` and `ts_init` from
380/// `time_exchange`.
381///
382/// # Errors
383///
384/// Returns an error if a value is finer than the instrument precision or a timestamp is invalid.
385pub 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
398/// Converts a historical trade, taking both `ts_event` and `ts_init` from `time_exchange`.
399///
400/// Historical records are initialized at their event time so the data engine, which bounds
401/// responses by `ts_init`, keeps every record the exchange-time window selected.
402///
403/// # Errors
404///
405/// Returns an error if a value is finer than the instrument precision, the size is not positive, or
406/// a timestamp or the trade identifier is invalid.
407pub 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
420/// Converts a historical OHLCV period into a bar of the requested type, taking both `ts_event`
421/// and `ts_init` from `time_period_end`.
422///
423/// Rounds volume to the instrument's size precision when the adjustment is at most both
424/// `1e-15` times the reported volume and `1e-6` times one unit at that precision.
425///
426/// # Errors
427///
428/// Returns an error if a price is finer than the instrument precision, volume needs rounding beyond
429/// the noise tolerance, the prices are inconsistent, or a timestamp is invalid.
430pub 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
455// Binance USD-M lists commodity and equity perpetuals beside crypto perpetuals under the same
456// CoinAPI symbol type, and only the forwarded venue contract type distinguishes them.
457fn 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
487// CoinAPI pads resolutions with trailing zeros beyond the domain precision limit
488fn 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}