nautilus_coinbase_intx/http/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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
16use std::str::FromStr;
17
18use nautilus_core::{UUID4, nanos::UnixNanos};
19use nautilus_model::{
20    enums::{
21        AccountType, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType,
22    },
23    events::AccountState,
24    identifiers::{AccountId, ClientOrderId, Symbol, TradeId, VenueOrderId},
25    instruments::{CryptoPerpetual, CurrencyPair, any::InstrumentAny},
26    reports::{FillReport, OrderStatusReport, PositionStatusReport},
27    types::{AccountBalance, Currency, Money, Price, Quantity},
28};
29use rust_decimal::Decimal;
30
31use super::models::{
32    CoinbaseIntxBalance, CoinbaseIntxFill, CoinbaseIntxInstrument, CoinbaseIntxOrder,
33    CoinbaseIntxPosition,
34};
35use crate::common::{
36    enums::{CoinbaseIntxInstrumentType, CoinbaseIntxOrderEventType, CoinbaseIntxOrderStatus},
37    parse::{get_currency, parse_instrument_id, parse_notional, parse_position_side},
38};
39
40/// Parses a Coinbase International Spot instrument into an `InstrumentAny::CurrencyPair`.
41/// Parses a spot instrument definition into an `InstrumentAny::CurrencyPair`.
42///
43/// # Errors
44///
45/// Returns an error if any numeric field cannot be parsed or required data is missing.
46pub fn parse_spot_instrument(
47    definition: &CoinbaseIntxInstrument,
48    margin_init: Option<Decimal>,
49    margin_maint: Option<Decimal>,
50    maker_fee: Option<Decimal>,
51    taker_fee: Option<Decimal>,
52    ts_init: UnixNanos,
53) -> anyhow::Result<InstrumentAny> {
54    let instrument_id = parse_instrument_id(definition.symbol);
55    let raw_symbol = Symbol::from_ustr_unchecked(definition.symbol);
56
57    let base_currency = get_currency(&definition.base_asset_name);
58    let quote_currency = get_currency(&definition.quote_asset_name);
59
60    let price_increment = Price::from(&definition.quote_increment);
61    let size_increment = Quantity::from(&definition.base_increment);
62    let multiplier = None;
63    let lot_size = None;
64
65    let max_quantity = None;
66    let min_quantity = None;
67    let max_notional = None;
68    let min_notional = parse_notional(&definition.min_notional_value, quote_currency)?;
69    let max_price = None;
70    let min_price = None;
71
72    let instrument = CurrencyPair::new(
73        instrument_id,
74        raw_symbol,
75        base_currency,
76        quote_currency,
77        price_increment.precision,
78        size_increment.precision,
79        price_increment,
80        size_increment,
81        multiplier,
82        lot_size,
83        max_quantity,
84        min_quantity,
85        max_notional,
86        min_notional,
87        max_price,
88        min_price,
89        margin_init,
90        margin_maint,
91        maker_fee,
92        taker_fee,
93        UnixNanos::from(definition.quote.timestamp),
94        ts_init,
95    );
96
97    Ok(InstrumentAny::CurrencyPair(instrument))
98}
99
100/// Parses a Coinbase International perpetual instrument into an `InstrumentAny::CryptoPerpetual`.
101/// Parses a perpetual instrument definition into an `InstrumentAny::CryptoPerpetual`.
102///
103/// # Errors
104///
105/// Returns an error if any numeric field cannot be parsed or required data is missing.
106pub fn parse_perp_instrument(
107    definition: &CoinbaseIntxInstrument,
108    margin_init: Option<Decimal>,
109    margin_maint: Option<Decimal>,
110    maker_fee: Option<Decimal>,
111    taker_fee: Option<Decimal>,
112    ts_init: UnixNanos,
113) -> anyhow::Result<InstrumentAny> {
114    let instrument_id = parse_instrument_id(definition.symbol);
115    let raw_symbol = Symbol::from_ustr_unchecked(definition.symbol);
116
117    let base_currency = get_currency(&definition.base_asset_name);
118    let quote_currency = get_currency(&definition.quote_asset_name);
119    let settlement_currency = quote_currency;
120
121    let price_increment = Price::from(&definition.quote_increment);
122    let size_increment = Quantity::from(&definition.base_increment);
123
124    let multiplier = Some(Quantity::from(&definition.base_asset_multiplier));
125
126    let lot_size = None;
127    let max_quantity = None;
128    let min_quantity = None;
129    let max_notional = None;
130    let min_notional = parse_notional(&definition.min_notional_value, quote_currency)?;
131    let max_price = None;
132    let min_price = None;
133
134    let is_inverse = false;
135
136    let instrument = CryptoPerpetual::new(
137        instrument_id,
138        raw_symbol,
139        base_currency,
140        quote_currency,
141        settlement_currency,
142        is_inverse,
143        price_increment.precision,
144        size_increment.precision,
145        price_increment,
146        size_increment,
147        multiplier,
148        lot_size,
149        max_quantity,
150        min_quantity,
151        max_notional,
152        min_notional,
153        max_price,
154        min_price,
155        margin_init,
156        margin_maint,
157        maker_fee,
158        taker_fee,
159        UnixNanos::from(definition.quote.timestamp),
160        ts_init,
161    );
162
163    Ok(InstrumentAny::CryptoPerpetual(instrument))
164}
165
166#[must_use]
167pub fn parse_instrument_any(
168    instrument: &CoinbaseIntxInstrument,
169    ts_init: UnixNanos,
170) -> Option<InstrumentAny> {
171    let result = match instrument.instrument_type {
172        CoinbaseIntxInstrumentType::Spot => {
173            parse_spot_instrument(instrument, None, None, None, None, ts_init).map(Some)
174        }
175        CoinbaseIntxInstrumentType::Perp => {
176            parse_perp_instrument(instrument, None, None, None, None, ts_init).map(Some)
177        }
178        CoinbaseIntxInstrumentType::Index => Ok(None), // Not yet implemented
179    };
180
181    match result {
182        Ok(instrument) => instrument,
183        Err(e) => {
184            tracing::warn!(
185                "Failed to parse instrument {}: {e}",
186                instrument.instrument_id,
187            );
188            None
189        }
190    }
191}
192
193/// Parses account balances into an `AccountState`.
194///
195/// # Errors
196///
197/// Returns an error if any balance or hold value cannot be parsed into a float.
198pub fn parse_account_state(
199    coinbase_balances: Vec<CoinbaseIntxBalance>,
200    account_id: AccountId,
201    ts_event: UnixNanos,
202) -> anyhow::Result<AccountState> {
203    let mut balances = Vec::new();
204    for b in coinbase_balances {
205        let currency = Currency::from(b.asset_name);
206        let total = Money::new(b.quantity.parse::<f64>()?, currency);
207        let locked = Money::new(b.hold.parse::<f64>()?, currency);
208        let free = total - locked;
209        let balance = AccountBalance::new(total, locked, free);
210        balances.push(balance);
211    }
212    let margins = vec![]; // TBD
213
214    let account_type = AccountType::Margin;
215    let is_reported = true;
216    let event_id = UUID4::new();
217
218    Ok(AccountState::new(
219        account_id,
220        account_type,
221        balances,
222        margins,
223        is_reported,
224        event_id,
225        ts_event,
226        ts_event,
227        None,
228    ))
229}
230
231fn parse_order_status(coinbase_order: &CoinbaseIntxOrder) -> anyhow::Result<OrderStatus> {
232    let exec_qty = coinbase_order
233        .exec_qty
234        .parse::<Decimal>()
235        .map_err(|e| anyhow::anyhow!("Invalid value for `exec_qty`: {e}"))?;
236
237    let status = match coinbase_order.order_status {
238        CoinbaseIntxOrderStatus::Working => {
239            if exec_qty > Decimal::ZERO {
240                return Ok(OrderStatus::PartiallyFilled);
241            }
242
243            match coinbase_order.event_type {
244                CoinbaseIntxOrderEventType::New => OrderStatus::Accepted,
245                CoinbaseIntxOrderEventType::PendingNew => OrderStatus::Submitted,
246                CoinbaseIntxOrderEventType::PendingCancel => OrderStatus::PendingCancel,
247                CoinbaseIntxOrderEventType::PendingReplace => OrderStatus::PendingUpdate,
248                CoinbaseIntxOrderEventType::StopTriggered => OrderStatus::Triggered,
249                CoinbaseIntxOrderEventType::Replaced => OrderStatus::Accepted,
250                // Safety fallback
251                _ => {
252                    tracing::debug!(
253                        "Unexpected order status and last event type: {:?} {:?}",
254                        coinbase_order.order_status,
255                        coinbase_order.event_type
256                    );
257                    OrderStatus::Accepted
258                }
259            }
260        }
261        CoinbaseIntxOrderStatus::Done => {
262            if exec_qty > Decimal::ZERO {
263                return Ok(OrderStatus::Filled);
264            }
265
266            match coinbase_order.event_type {
267                CoinbaseIntxOrderEventType::Canceled => OrderStatus::Canceled,
268                CoinbaseIntxOrderEventType::Rejected => OrderStatus::Rejected,
269                CoinbaseIntxOrderEventType::Expired => OrderStatus::Expired,
270                // Safety fallback
271                _ => {
272                    tracing::debug!(
273                        "Unexpected order status and last event type: {:?} {:?}",
274                        coinbase_order.order_status,
275                        coinbase_order.event_type
276                    );
277                    OrderStatus::Canceled
278                }
279            }
280        }
281    };
282    Ok(status)
283}
284
285fn parse_price(value: &str, precision: u8) -> anyhow::Result<Price> {
286    let v = value
287        .parse::<f64>()
288        .map_err(|e| anyhow::anyhow!("Invalid value for `Price`: {e}"))?;
289    Ok(Price::new(v, precision))
290}
291
292fn parse_quantity(value: &str, precision: u8) -> anyhow::Result<Quantity> {
293    let v = value
294        .parse::<f64>()
295        .map_err(|e| anyhow::anyhow!("Invalid value for `Quantity`: {e}"))?;
296    Ok(Quantity::new(v, precision))
297}
298
299/// Parses an order status report from raw Coinbase REST data.
300///
301/// # Errors
302///
303/// Returns an error if any required field cannot be parsed.
304pub fn parse_order_status_report(
305    coinbase_order: CoinbaseIntxOrder,
306    account_id: AccountId,
307    price_precision: u8,
308    size_precision: u8,
309    ts_init: UnixNanos,
310) -> anyhow::Result<OrderStatusReport> {
311    let filled_qty = parse_quantity(&coinbase_order.exec_qty, size_precision)?;
312    let order_status: OrderStatus = parse_order_status(&coinbase_order)?;
313
314    let instrument_id = parse_instrument_id(coinbase_order.symbol);
315    let client_order_id = ClientOrderId::new(coinbase_order.client_order_id);
316    let venue_order_id = VenueOrderId::new(coinbase_order.order_id);
317    let order_side: OrderSide = coinbase_order.side.into();
318    let order_type: OrderType = coinbase_order.order_type.into();
319    let time_in_force: TimeInForce = coinbase_order.tif.into();
320    let quantity = parse_quantity(&coinbase_order.size, size_precision)?;
321    let ts_accepted = UnixNanos::from(coinbase_order.submit_time.unwrap_or_default());
322    let ts_last = UnixNanos::from(coinbase_order.event_time.unwrap_or_default());
323
324    let mut report = OrderStatusReport::new(
325        account_id,
326        instrument_id,
327        Some(client_order_id),
328        venue_order_id,
329        order_side,
330        order_type,
331        time_in_force,
332        order_status,
333        quantity,
334        filled_qty,
335        ts_accepted,
336        ts_init,
337        ts_last,
338        None, // Will generate a UUID4
339    );
340
341    if let Some(price) = coinbase_order.price {
342        let price = parse_price(&price, price_precision)?;
343        report = report.with_price(price);
344    }
345
346    if let Some(stop_price) = coinbase_order.stop_price {
347        let stop_price = parse_price(&stop_price, price_precision)?;
348        report = report.with_trigger_price(stop_price);
349        report = report.with_trigger_type(TriggerType::Default); // TBD
350    }
351
352    if let Some(expire_time) = coinbase_order.expire_time {
353        report = report.with_expire_time(expire_time.into());
354    }
355
356    if let Some(avg_price) = coinbase_order.avg_price {
357        let avg_px = avg_price
358            .parse::<f64>()
359            .map_err(|e| anyhow::anyhow!("Invalid value for `avg_px`: {e}"))?;
360        report = report.with_avg_px(avg_px);
361    }
362
363    if let Some(text) = coinbase_order.text {
364        report = report.with_cancel_reason(text);
365    }
366
367    report = report.with_post_only(coinbase_order.post_only);
368    report = report.with_reduce_only(coinbase_order.close_only);
369
370    Ok(report)
371}
372
373/// Parses a fill report from raw Coinbase REST data.
374///
375/// # Errors
376///
377/// Returns an error if any required field cannot be parsed.
378pub fn parse_fill_report(
379    coinbase_fill: CoinbaseIntxFill,
380    account_id: AccountId,
381    price_precision: u8,
382    size_precision: u8,
383    ts_init: UnixNanos,
384) -> anyhow::Result<FillReport> {
385    let instrument_id = parse_instrument_id(coinbase_fill.symbol);
386    let client_order_id = ClientOrderId::new(coinbase_fill.client_order_id);
387    let venue_order_id = VenueOrderId::new(coinbase_fill.order_id);
388    let trade_id = TradeId::from(coinbase_fill.fill_id);
389    let order_side: OrderSide = coinbase_fill.side.into();
390    let last_px = parse_price(&coinbase_fill.fill_price, price_precision)?;
391    let last_qty = parse_quantity(&coinbase_fill.fill_qty, size_precision)?;
392    let commission = Money::from(&format!(
393        "{} {}",
394        coinbase_fill.fee, coinbase_fill.fee_asset
395    ));
396    let liquidity = LiquiditySide::Maker; // TBD
397    let ts_event = UnixNanos::from(coinbase_fill.event_time);
398
399    Ok(FillReport::new(
400        account_id,
401        instrument_id,
402        venue_order_id,
403        trade_id,
404        order_side,
405        last_qty,
406        last_px,
407        commission,
408        liquidity,
409        Some(client_order_id),
410        None, // Position ID not applicable on Coinbase Intx
411        ts_event,
412        ts_init,
413        None, // Will generate a UUID4
414    ))
415}
416
417/// Parses a position status report from raw Coinbase REST data.
418///
419/// # Errors
420///
421/// Returns an error if any required field cannot be parsed.
422pub fn parse_position_status_report(
423    coinbase_position: CoinbaseIntxPosition,
424    account_id: AccountId,
425    size_precision: u8,
426    ts_init: UnixNanos,
427) -> anyhow::Result<PositionStatusReport> {
428    let instrument_id = parse_instrument_id(coinbase_position.symbol);
429    let net_size = coinbase_position
430        .net_size
431        .parse::<f64>()
432        .map_err(|e| anyhow::anyhow!("Invalid value for `net_size`: {e}"))?;
433    let position_side = parse_position_side(Some(net_size)).as_specified();
434    let quantity = Quantity::new(net_size.abs(), size_precision);
435    let avg_px_open = Some(Decimal::from_str(&coinbase_position.entry_vwap)?);
436
437    Ok(PositionStatusReport::new(
438        account_id,
439        instrument_id,
440        position_side,
441        quantity,
442        ts_init,
443        ts_init,
444        None, // Will generate a UUID4
445        None, // Position ID not applicable on Coinbase Intx
446        avg_px_open,
447    ))
448}
449
450////////////////////////////////////////////////////////////////////////////////
451// Tests
452////////////////////////////////////////////////////////////////////////////////
453#[cfg(test)]
454mod tests {
455    use nautilus_model::types::Money;
456    use rstest::rstest;
457
458    use super::*;
459    use crate::common::testing::load_test_json;
460
461    #[rstest]
462    fn test_parse_spot_instrument() {
463        let json_data = load_test_json("http_get_instruments_BTC-USDC.json");
464        let parsed: CoinbaseIntxInstrument = serde_json::from_str(&json_data).unwrap();
465
466        let ts_init = UnixNanos::default();
467        let instrument = parse_spot_instrument(&parsed, None, None, None, None, ts_init).unwrap();
468
469        if let InstrumentAny::CurrencyPair(pair) = instrument {
470            assert_eq!(pair.id.to_string(), "BTC-USDC.COINBASE_INTX");
471            assert_eq!(pair.raw_symbol.to_string(), "BTC-USDC");
472            assert_eq!(pair.base_currency.to_string(), "BTC");
473            assert_eq!(pair.quote_currency.to_string(), "USDC");
474            assert_eq!(pair.price_increment.to_string(), "0.01");
475            assert_eq!(pair.size_increment.to_string(), "0.00001");
476            assert_eq!(
477                pair.min_notional,
478                Some(Money::new(10.0, pair.quote_currency))
479            );
480            assert_eq!(pair.ts_event, UnixNanos::from(parsed.quote.timestamp));
481            assert_eq!(pair.ts_init, ts_init);
482            assert_eq!(pair.lot_size, None);
483            assert_eq!(pair.max_quantity, None);
484            assert_eq!(pair.min_quantity, None);
485            assert_eq!(pair.max_notional, None);
486            assert_eq!(pair.max_price, None);
487            assert_eq!(pair.min_price, None);
488            assert_eq!(pair.margin_init, Decimal::ZERO);
489            assert_eq!(pair.margin_maint, Decimal::ZERO);
490            assert_eq!(pair.maker_fee, Decimal::ZERO);
491            assert_eq!(pair.taker_fee, Decimal::ZERO);
492        } else {
493            panic!("Expected `CurrencyPair` variant");
494        }
495    }
496
497    #[rstest]
498    fn test_parse_perp_instrument() {
499        let json_data = load_test_json("http_get_instruments_BTC-PERP.json");
500        let parsed: CoinbaseIntxInstrument = serde_json::from_str(&json_data).unwrap();
501
502        let ts_init = UnixNanos::default();
503        let instrument = parse_perp_instrument(&parsed, None, None, None, None, ts_init).unwrap();
504
505        if let InstrumentAny::CryptoPerpetual(perp) = instrument {
506            assert_eq!(perp.id.to_string(), "BTC-PERP.COINBASE_INTX");
507            assert_eq!(perp.raw_symbol.to_string(), "BTC-PERP");
508            assert_eq!(perp.base_currency.to_string(), "BTC");
509            assert_eq!(perp.quote_currency.to_string(), "USDC");
510            assert_eq!(perp.settlement_currency.to_string(), "USDC");
511            assert!(!perp.is_inverse);
512            assert_eq!(perp.price_increment.to_string(), "0.1");
513            assert_eq!(perp.size_increment.to_string(), "0.0001");
514            assert_eq!(perp.multiplier.to_string(), "1.0");
515            assert_eq!(
516                perp.min_notional,
517                Some(Money::new(10.0, perp.quote_currency))
518            );
519            assert_eq!(perp.ts_event, UnixNanos::from(parsed.quote.timestamp));
520            assert_eq!(perp.ts_init, ts_init);
521            assert_eq!(perp.lot_size, Quantity::from(1));
522            assert_eq!(perp.max_quantity, None);
523            assert_eq!(perp.min_quantity, None);
524            assert_eq!(perp.max_notional, None);
525            assert_eq!(perp.max_price, None);
526            assert_eq!(perp.min_price, None);
527            assert_eq!(perp.margin_init, Decimal::ZERO);
528            assert_eq!(perp.margin_maint, Decimal::ZERO);
529            assert_eq!(perp.maker_fee, Decimal::ZERO);
530            assert_eq!(perp.taker_fee, Decimal::ZERO);
531        } else {
532            panic!("Expected `CryptoPerpetual` variant");
533        }
534    }
535}