nautilus_coinbase_intx/fix/
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 chrono::{DateTime, Utc};
17use nautilus_core::{UnixNanos, time::get_atomic_clock_realtime};
18use nautilus_model::{
19    enums::{LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
20    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
21    reports::{FillReport, OrderStatusReport},
22    types::{Currency, Money, Price, Quantity},
23};
24use ustr::Ustr;
25
26use super::messages::{FixMessage, fix_tag};
27use crate::common::{consts::COINBASE_INTX_VENUE, parse::parse_instrument_id};
28
29// Reasonable default precision for now, as reports will be converted in the clients.
30const DEFAULT_PRECISION: u8 = 8;
31
32/// Parse a FIX execution report message to create a Nautilus `OrderStatusReport`.
33///
34/// # Errors
35///
36/// Returns an error if a required FIX tag is missing or cannot be parsed.
37pub fn convert_to_order_status_report(
38    message: &FixMessage,
39    account_id: AccountId,
40    ts_init: UnixNanos,
41) -> anyhow::Result<OrderStatusReport> {
42    let venue_order_id = VenueOrderId::new(message.get_field_checked(fix_tag::ORDER_ID)?);
43    let client_order_id = message
44        .get_field(fix_tag::CL_ORD_ID)
45        .map(ClientOrderId::new); // Can be missing
46
47    let symbol = message.get_field_checked(fix_tag::SYMBOL)?;
48    let instrument_id = parse_instrument_id(Ustr::from(symbol));
49
50    let side = message.get_field_checked(fix_tag::SIDE)?;
51    let order_side = match side {
52        "1" => OrderSide::Buy,
53        "2" => OrderSide::Sell,
54        _ => anyhow::bail!("Unknown order side: {side}"),
55    };
56
57    let ord_type = message.get_field_checked(fix_tag::ORD_TYPE)?;
58    let order_type = match ord_type {
59        "1" => OrderType::Market,
60        "2" => OrderType::Limit,
61        "3" => OrderType::StopLimit,
62        "4" => OrderType::StopMarket,
63        _ => anyhow::bail!("Unknown order type: {ord_type}"),
64    };
65
66    let tif = message.get_field_checked(fix_tag::TIME_IN_FORCE)?;
67    let time_in_force = match tif {
68        "1" => TimeInForce::Gtc, // Good Till Cancel
69        "3" => TimeInForce::Ioc, // Immediate or Cancel
70        "4" => TimeInForce::Fok, // Fill or Kill
71        "6" => TimeInForce::Gtd, // Good Till Date
72        _ => anyhow::bail!("Unknown time in force: {tif}"),
73    };
74
75    let status = message.get_field_checked(fix_tag::ORD_STATUS)?;
76    let order_status = match status {
77        "0" => OrderStatus::Accepted, // New
78        "1" => OrderStatus::PartiallyFilled,
79        "2" => OrderStatus::Filled,
80        "4" => OrderStatus::Canceled,
81        "5" => OrderStatus::Rejected,
82        "6" => OrderStatus::PendingCancel,
83        "8" => OrderStatus::Rejected,
84        "A" => OrderStatus::Submitted,     // Pending New
85        "E" => OrderStatus::PendingUpdate, // Pending Replace
86        "C" => OrderStatus::Expired,
87        _ => anyhow::bail!("Unknown order status: {status}"),
88    };
89
90    let order_qty = message.get_field_checked(fix_tag::ORDER_QTY)?;
91    let quantity = Quantity::new(order_qty.parse::<f64>()?, DEFAULT_PRECISION);
92
93    let _leaves_qty = message.get_field_checked(fix_tag::LEAVES_QTY)?;
94    let cum_qty = message.get_field_checked(fix_tag::CUM_QTY)?;
95    let filled_qty = Quantity::new(cum_qty.parse::<f64>()?, DEFAULT_PRECISION);
96
97    // Use TransactTime as the event time if provided
98    // Use TransactTime as the event time if provided, error on invalid format
99    let ts_last = if let Some(transact_time) = message.get_field(fix_tag::TRANSACT_TIME) {
100        parse_fix_timestamp(transact_time)?
101    } else {
102        ts_init
103    };
104
105    // For ts_accepted, we can only estimate based on available data
106    // In practice, this might be tracked in your order management system
107    let ts_accepted = ts_last;
108
109    // Create the basic report
110    let mut report = OrderStatusReport::new(
111        account_id,
112        instrument_id,
113        client_order_id,
114        venue_order_id,
115        order_side,
116        order_type,
117        time_in_force,
118        order_status,
119        quantity,
120        filled_qty,
121        ts_accepted,
122        ts_last,
123        ts_init,
124        None, // Report ID will be generated
125    );
126
127    if let Some(price_str) = message.get_field(fix_tag::PRICE)
128        && let Ok(price_val) = price_str.parse::<f64>()
129    {
130        report = report.with_price(Price::new(price_val, DEFAULT_PRECISION));
131    }
132
133    if let Some(stop_px) = message.get_field(fix_tag::STOP_PX)
134        && let Ok(stop_val) = stop_px.parse::<f64>()
135    {
136        report = report.with_trigger_price(Price::new(stop_val, DEFAULT_PRECISION));
137        report = report.with_trigger_type(TriggerType::LastPrice);
138    }
139
140    if let Some(avg_px) = message.get_field(fix_tag::AVG_PX)
141        && let Ok(avg_val) = avg_px.parse::<f64>()
142        && avg_val > 0.0
143    {
144        report = report.with_avg_px(avg_val);
145    }
146
147    // Execution instructions
148    if let Some(exec_inst) = message.get_field(fix_tag::EXEC_INST) {
149        // Parse space-delimited flags
150        let flags: Vec<&str> = exec_inst.split(' ').collect();
151        for flag in flags {
152            match flag {
153                "6" => report = report.with_post_only(true), // Post only
154                "E" => report = report.with_reduce_only(true), // Close only
155                _ => {}                                      // Ignore other flags
156            }
157        }
158    }
159
160    if let Some(expire_time) = message.get_field(fix_tag::EXPIRE_TIME)
161        && let Ok(dt) = parse_fix_timestamp(expire_time)
162    {
163        report = report.with_expire_time(dt);
164    }
165
166    if let Some(text) = message.get_field(fix_tag::TEXT)
167        && !text.is_empty()
168    {
169        report = report.with_cancel_reason(text.to_string());
170    }
171
172    Ok(report)
173}
174
175/// Parse a FIX execution report to a Nautilus `FillReport`.
176///
177/// # Errors
178///
179/// Returns an error if a required FIX tag is missing or cannot be parsed.
180pub fn convert_to_fill_report(
181    message: &FixMessage,
182    account_id: AccountId,
183    ts_init: UnixNanos,
184) -> anyhow::Result<FillReport> {
185    let client_order_id = message.get_field_checked(fix_tag::CL_ORD_ID)?;
186    let venue_order_id = message.get_field_checked(fix_tag::ORDER_ID)?;
187    let trade_id = message.get_field_checked(fix_tag::TRD_MATCH_ID)?;
188    let symbol = message.get_field_checked(fix_tag::SYMBOL)?;
189    let side_str = message.get_field_checked(fix_tag::SIDE)?;
190    let last_qty_str = message.get_field_checked(fix_tag::LAST_QTY)?;
191    let last_px_str = message.get_field_checked(fix_tag::LAST_PX)?;
192    let currency = message.get_field_checked(fix_tag::CURRENCY)?.parse()?;
193    let liquidity_indicator = message.get_field(fix_tag::LAST_LIQUIDITY_IND);
194
195    let mut commission = Money::new(0.0, currency);
196
197    if let Some(num_fees) = message.get_field(fix_tag::NO_MISC_FEES)
198        && let Ok(n) = num_fees.parse::<usize>()
199    {
200        // For simplicity, we'll just use the first fee
201        if n > 0
202            && let (Some(fee_amt), Some(fee_curr)) = (
203                message.get_field(fix_tag::MISC_FEE_AMT),
204                message.get_field(fix_tag::MISC_FEE_CURR),
205            )
206            && let Ok(amt) = fee_amt.parse::<f64>()
207        {
208            // Parse fee currency, error on invalid code
209            let fee_currency = fee_curr
210                .parse::<Currency>()
211                .map_err(|e| anyhow::anyhow!("Invalid fee currency '{fee_curr}': {e}"))?;
212            commission = Money::new(amt, fee_currency);
213        }
214    }
215
216    let client_order_id = ClientOrderId::new(client_order_id);
217    let venue_order_id = VenueOrderId::new(venue_order_id);
218    let trade_id = TradeId::new(trade_id);
219
220    let order_side = match side_str {
221        "1" => OrderSide::Buy,
222        "2" => OrderSide::Sell,
223        _ => anyhow::bail!("Unknown order side: {side_str}"),
224    };
225
226    let last_qty = match last_qty_str.parse::<f64>() {
227        Ok(qty) => Quantity::new(qty, DEFAULT_PRECISION),
228        Err(e) => anyhow::bail!(format!("Invalid last quantity: {e}")),
229    };
230
231    let last_px = match last_px_str.parse::<f64>() {
232        Ok(px) => Price::new(px, DEFAULT_PRECISION),
233        Err(e) => anyhow::bail!(format!("Invalid last price: {e}")),
234    };
235
236    let liquidity_side = match liquidity_indicator {
237        Some("1") => LiquiditySide::Maker,
238        Some("2") => LiquiditySide::Taker,
239        _ => LiquiditySide::NoLiquiditySide,
240    };
241
242    // Parse transaction time if available
243    let ts_event = if let Some(transact_time) = message.get_field(fix_tag::TRANSACT_TIME) {
244        if let Ok(dt) = DateTime::parse_from_str(transact_time, "%Y%m%d-%H:%M:%S%.3f") {
245            UnixNanos::from(dt.with_timezone(&Utc))
246        } else {
247            ts_init
248        }
249    } else {
250        ts_init
251    };
252
253    let instrument_id = InstrumentId::new(Symbol::from_str_unchecked(symbol), *COINBASE_INTX_VENUE);
254
255    let report = FillReport::new(
256        account_id,
257        instrument_id,
258        venue_order_id,
259        trade_id,
260        order_side,
261        last_qty,
262        last_px,
263        commission,
264        liquidity_side,
265        Some(client_order_id),
266        None, // Position ID not applicable
267        ts_event,
268        get_atomic_clock_realtime().get_time_ns(),
269        None, // UUID will be generated
270    );
271
272    Ok(report)
273}
274
275/// Parse a FIX timestamp in format YYYYMMDDd-HH:MM:SS.sss
276fn parse_fix_timestamp(timestamp: &str) -> Result<UnixNanos, anyhow::Error> {
277    let dt = DateTime::parse_from_str(timestamp, "%Y%m%d-%H:%M:%S%.3f")?;
278    Ok(UnixNanos::from(dt.with_timezone(&Utc)))
279}