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