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