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