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},
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/// A native instrument with the full CoinAPI `symbol_id` for its transport requests and
47/// subscriptions.
48#[derive(Debug, Clone, PartialEq)]
49pub struct CoinApiInstrument {
50    pub instrument: InstrumentAny,
51    pub symbol_id: Ustr,
52}
53
54/// A CoinAPI symbol that cannot become a complete native instrument.
55#[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/// Parses the spot, perpetual, and futures symbols in a metadata response.
71///
72/// Other CoinAPI symbol types are skipped. Symbols of a parsed type that cannot be mapped or
73/// validated are returned as errors alongside the instruments. Valid symbols that resolve to the
74/// same native instrument ID are all rejected, because their transport symbol would be ambiguous.
75#[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
117/// Parses one CoinAPI symbol into a native spot, perpetual, or futures instrument.
118///
119/// # Errors
120///
121/// Returns an error if the symbol type is unsupported, the native mapping is unsupported or
122/// unresolved, or a required identity, precision, contract, or date field is missing or invalid.
123pub 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
301/// Converts a current order book into an L2 market-by-price book stamped with `time_exchange`.
302///
303/// # Errors
304///
305/// Returns an error if a level is finer than the instrument precision or the timestamp is invalid;
306/// a single invalid level rejects the whole snapshot.
307pub 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
326/// Converts a historical best bid and ask, taking both `ts_event` and `ts_init` from
327/// `time_exchange`.
328///
329/// # Errors
330///
331/// Returns an error if a value is finer than the instrument precision or a timestamp is invalid.
332pub 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
345/// Converts a historical trade, taking both `ts_event` and `ts_init` from `time_exchange`.
346///
347/// Historical records are initialized at their event time so the data engine, which bounds
348/// responses by `ts_init`, keeps every record the exchange-time window selected.
349///
350/// # Errors
351///
352/// Returns an error if a value is finer than the instrument precision, the size is not positive, or
353/// a timestamp or the trade identifier is invalid.
354pub 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
367/// Converts a historical OHLCV period into a bar of the requested type, taking both `ts_event`
368/// and `ts_init` from `time_period_end`.
369///
370/// Rounds volume to the instrument's size precision when the adjustment is at most both
371/// `1e-15` times the reported volume and `1e-6` times one unit at that precision.
372///
373/// # Errors
374///
375/// Returns an error if a price is finer than the instrument precision, volume needs rounding beyond
376/// the noise tolerance, the prices are inconsistent, or a timestamp is invalid.
377pub 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
402// Binance USD-M lists commodity and equity perpetuals beside crypto perpetuals under the same
403// CoinAPI symbol type, and only the forwarded venue contract type distinguishes them.
404fn 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
433// CoinAPI pads resolutions with trailing zeros beyond the domain precision limit
434fn 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}