nautilus_coinbase_intx/http/
parse.rs

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