nautilus_coinbase_intx/fix/
messages.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
16// Some constants not used (retained for completeness)
17#![allow(dead_code)]
18
19use chrono::{DateTime, Utc};
20use indexmap::IndexMap;
21
22/// Common FIX tags used in this implementation.
23pub mod fix_tag {
24    // Standard header fields
25    pub const BEGIN_STRING: u32 = 8; // FIX.4.4
26    pub const BODY_LENGTH: u32 = 9; // Length of message body
27    pub const MSG_TYPE: u32 = 35; // Message type
28    pub const SENDER_COMP_ID: u32 = 49; // Sender's CompID
29    pub const TARGET_COMP_ID: u32 = 56; // Target's CompID
30    pub const MSG_SEQ_NUM: u32 = 34; // Message sequence number
31    pub const SENDING_TIME: u32 = 52; // Message sending time
32    pub const CHECKSUM: u32 = 10; // Checksum of message
33
34    // Logon fields
35    pub const ENCRYPT_METHOD: u32 = 98; // Encryption method (0 = none)
36    pub const HEART_BT_INT: u32 = 108; // Heartbeat interval in seconds
37    pub const RESET_SEQ_NUM_FLAG: u32 = 141; // Reset sequence numbers flag
38    pub const USERNAME: u32 = 553; // Username for authentication
39    pub const PASSWORD: u32 = 554; // Password for authentication
40
41    // Execution report fields
42    pub const CL_ORD_ID: u32 = 11; // Client order ID
43    pub const ORIG_CL_ORD_ID: u32 = 41; // Original client order ID (for cancel/replace)
44    pub const TRD_MATCH_ID: u32 = 880; // Trade match ID
45    pub const EXEC_ID: u32 = 17; // Execution ID
46    pub const EXEC_TRANS_TYPE: u32 = 20; // Execution transaction type
47    pub const ORDER_ID: u32 = 37; // Order ID assigned by exchange
48    pub const EXEC_TYPE: u32 = 150; // Execution type
49    pub const ORD_STATUS: u32 = 39; // Order status
50    pub const ORD_REJ_REASON: u32 = 103; // Order reject reason
51    pub const SYMBOL: u32 = 55; // Symbol
52    pub const SIDE: u32 = 54; // Order side
53    pub const ORDER_QTY: u32 = 38; // Order quantity
54    pub const ORD_TYPE: u32 = 40; // Order type
55    pub const PRICE: u32 = 44; // Order price
56    pub const STOP_PX: u32 = 99; // Stop price
57    pub const STOP_LIMIT_PX: u32 = 3040; // Stop limit price
58    pub const CURRENCY: u32 = 15; // Currency
59    pub const TIME_IN_FORCE: u32 = 59; // Time in force
60    pub const EXPIRE_TIME: u32 = 126; // Expiration time
61    pub const EXEC_INST: u32 = 18; // Execution instructions
62    pub const LAST_QTY: u32 = 32; // Last executed quantity
63    pub const LAST_PX: u32 = 31; // Last executed price
64    pub const LEAVES_QTY: u32 = 151; // Quantity open for further execution
65    pub const CUM_QTY: u32 = 14; // Cumulative executed quantity
66    pub const AVG_PX: u32 = 6; // Average execution price
67    pub const TRANSACT_TIME: u32 = 60; // Transaction time
68    pub const TEXT: u32 = 58; // Text message
69    pub const LAST_LIQUIDITY_IND: u32 = 851; // Last liquidity indicator
70
71    // Party identification fields
72    pub const NO_PARTY_IDS: u32 = 453; // Number of party IDs
73    pub const PARTY_ID: u32 = 448; // Party ID
74    pub const PARTY_ID_SOURCE: u32 = 447; // Party ID source
75    pub const PARTY_ROLE: u32 = 452; // Party role
76
77    // Fee fields
78    pub const NO_MISC_FEES: u32 = 136; // Number of miscellaneous fees
79    pub const MISC_FEE_AMT: u32 = 137; // Miscellaneous fee amount
80    pub const MISC_FEE_CURR: u32 = 138; // Miscellaneous fee currency
81    pub const MISC_FEE_TYPE: u32 = 139; // Miscellaneous fee type
82
83    // Coinbase specific fields
84    pub const SELF_TRADE_PREVENTION_STRATEGY: u32 = 8000; // STP strategy
85    pub const TARGET_STRATEGY: u32 = 847; // Target strategy (e.g., TWAP)
86    pub const DEFAULT_APPL_VER_ID: u32 = 1137; // DefaultApplVerID
87}
88
89/// FIX message types.
90pub(crate) mod fix_message_type {
91    pub const HEARTBEAT: &str = "0";
92    pub const TEST_REQUEST: &str = "1";
93    pub const RESEND_REQUEST: &str = "2";
94    pub const REJECT: &str = "3";
95    pub const SEQUENCE_RESET: &str = "4";
96    pub const LOGOUT: &str = "5";
97    pub const EXECUTION_REPORT: &str = "8";
98    pub const ORDER_CANCEL_REJECT: &str = "9";
99    pub const LOGON: &str = "A";
100    pub const NEWS: &str = "B";
101    pub const EMAIL: &str = "C";
102    pub const NEW_ORDER_SINGLE: &str = "D";
103    pub const ORDER_CANCEL_REQUEST: &str = "F";
104    pub const ORDER_CANCEL_REPLACE_REQUEST: &str = "G";
105    pub const ORDER_STATUS_REQUEST: &str = "H";
106    pub const BUSINESS_MESSAGE_REJECT: &str = "j";
107}
108
109/// Execution types for Execution Reports.
110pub(crate) mod fix_exec_type {
111    pub const NEW: &str = "0";
112    pub const PARTIAL_FILL: &str = "1";
113    pub const FILL: &str = "2";
114    pub const CANCELED: &str = "4";
115    pub const REPLACED: &str = "5";
116    pub const PENDING_CANCEL: &str = "6";
117    pub const REJECTED: &str = "8";
118    pub const PENDING_NEW: &str = "A";
119    pub const EXPIRED: &str = "C";
120    pub const PENDING_REPLACE: &str = "E";
121    pub const TRADE: &str = "F"; // For Trade Capture Reports
122    pub const STOP_TRIGGERED: &str = "L";
123}
124
125pub(crate) const FIX_DELIMITER: u8 = b'\x01';
126
127/// FIX message serialization/deserialization.
128#[derive(Debug, Clone)]
129pub(crate) struct FixMessage {
130    fields: IndexMap<u32, String>,
131}
132
133impl FixMessage {
134    /// Create a new FIX message with standard header.
135    pub(crate) fn new(
136        msg_type: &str,
137        seq_num: usize,
138        sender_comp_id: &str,
139        target_comp_id: &str,
140        now: &DateTime<Utc>,
141    ) -> Self {
142        let mut fields = IndexMap::new();
143
144        // Standard header
145        fields.insert(fix_tag::MSG_TYPE, msg_type.to_string());
146        fields.insert(fix_tag::SENDER_COMP_ID, sender_comp_id.to_string());
147        fields.insert(fix_tag::TARGET_COMP_ID, target_comp_id.to_string());
148        fields.insert(fix_tag::MSG_SEQ_NUM, seq_num.to_string());
149
150        // Add timestamp
151        let timestamp = now.format("%Y%m%d-%H:%M:%S%.6f").to_string();
152        fields.insert(fix_tag::SENDING_TIME, timestamp);
153
154        Self { fields }
155    }
156
157    /// Gets the message type.
158    pub(crate) fn msg_type(&self) -> Option<&str> {
159        self.get_field(fix_tag::MSG_TYPE)
160    }
161
162    /// Gets the message sequence number
163    pub(crate) fn msg_seq_num(&self) -> Option<usize> {
164        self.get_field(fix_tag::MSG_SEQ_NUM)
165            .and_then(|s| s.parse::<usize>().ok())
166    }
167
168    /// Gets a field from the message.
169    pub fn get_field(&self, tag: u32) -> Option<&str> {
170        self.fields.get(&tag).map(|s| s.as_str())
171    }
172
173    /// Adds a field to the message.
174    pub fn add_field(&mut self, tag: u32, value: impl Into<String>) -> &mut Self {
175        self.fields.insert(tag, value.into());
176        self
177    }
178
179    /// Parses a FIX message from a byte slice.
180    pub(crate) fn parse(data: &[u8]) -> Result<Self, String> {
181        const DELIMITER: char = '\x01'; // Standard FIX delimiter (more efficent to define here)
182
183        let data_str = std::str::from_utf8(data).map_err(|e| format!("Invalid UTF-8: {e}"))?;
184
185        let mut fields = IndexMap::new();
186
187        for field_str in data_str.split(DELIMITER) {
188            if field_str.is_empty() {
189                continue;
190            }
191
192            let parts: Vec<&str> = field_str.splitn(2, '=').collect();
193            if parts.len() != 2 {
194                return Err(format!("Invalid field: {field_str}"));
195            }
196
197            let tag = parts[0]
198                .parse::<u32>()
199                .map_err(|e| format!("Invalid tag: {e}"))?;
200            let value = parts[1].to_string();
201
202            fields.insert(tag, value);
203        }
204
205        Ok(Self { fields })
206    }
207
208    /// Gets the value of a field by tag.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the tag is missing.
213    pub(crate) fn get_field_checked(&self, tag: u32) -> anyhow::Result<&str> {
214        self.get_field(tag)
215            .ok_or(anyhow::anyhow!("Missing tag {tag}"))
216    }
217
218    /// Sets the value of a field by tag.
219    fn set_field(&mut self, tag: u32, value: impl Into<String>) -> &mut Self {
220        self.fields.insert(tag, value.into());
221        self
222    }
223
224    /// Calculates body length and checksum, and build the final message bytes.
225    pub(crate) fn to_bytes(&self) -> Vec<u8> {
226        let mut buffer = Vec::new();
227
228        // Add BeginString
229        let begin_string = self.get_field(fix_tag::BEGIN_STRING).unwrap_or("FIXT.1.1");
230        buffer.extend_from_slice(format!("{}={}", fix_tag::BEGIN_STRING, begin_string).as_bytes());
231        buffer.push(FIX_DELIMITER);
232
233        let mut body_buffer = Vec::new();
234
235        // Add all body fields
236        for (&tag, value) in &self.fields {
237            body_buffer.extend_from_slice(format!("{tag}={value}").as_bytes());
238            body_buffer.push(FIX_DELIMITER);
239        }
240
241        // Calculate body length
242        let body_length = body_buffer.len();
243        buffer.extend_from_slice(format!("{}={}", fix_tag::BODY_LENGTH, body_length).as_bytes());
244        buffer.push(FIX_DELIMITER);
245
246        // Add body
247        buffer.extend_from_slice(&body_buffer);
248
249        // Calculate checksum
250        let checksum: u32 = buffer.iter().map(|&b| b as u32).sum::<u32>() % 256;
251        buffer.extend_from_slice(format!("{}={:03}", fix_tag::CHECKSUM, checksum).as_bytes());
252        buffer.push(FIX_DELIMITER);
253
254        buffer
255    }
256
257    /// Creates a logon message.
258    #[allow(clippy::too_many_arguments)]
259    pub(crate) fn create_logon(
260        seq_num: usize,
261        sender_comp_id: &str,
262        target_comp_id: &str,
263        heartbeat_interval: u64,
264        username: &str,
265        password: &str,
266        text: &str,
267        timestamp: &DateTime<Utc>,
268    ) -> Self {
269        let mut msg = Self::new(
270            fix_message_type::LOGON,
271            seq_num,
272            sender_comp_id,
273            target_comp_id,
274            timestamp,
275        );
276
277        msg.add_field(fix_tag::ENCRYPT_METHOD, "0") // No encryption (must be 0)
278            .add_field(fix_tag::HEART_BT_INT, heartbeat_interval.to_string())
279            .add_field(fix_tag::RESET_SEQ_NUM_FLAG, "Y")
280            .add_field(fix_tag::USERNAME, username)
281            .add_field(fix_tag::PASSWORD, password)
282            .add_field(fix_tag::TEXT, text)
283            .add_field(fix_tag::DEFAULT_APPL_VER_ID, "9");
284
285        msg
286    }
287
288    /// Creates a heartbeat message.
289    pub(crate) fn create_heartbeat(
290        seq_num: usize,
291        sender_comp_id: &str,
292        target_comp_id: &str,
293        timestamp: &DateTime<Utc>,
294    ) -> Self {
295        Self::new(
296            fix_message_type::HEARTBEAT,
297            seq_num,
298            sender_comp_id,
299            target_comp_id,
300            timestamp,
301        )
302    }
303
304    /// Creates a logout message.
305    pub(crate) fn create_logout(
306        seq_num: usize,
307        sender_comp_id: &str,
308        target_comp_id: &str,
309        text: Option<&str>,
310        timestamp: &DateTime<Utc>,
311    ) -> Self {
312        let mut msg = Self::new(
313            fix_message_type::LOGOUT,
314            seq_num,
315            sender_comp_id,
316            target_comp_id,
317            timestamp,
318        );
319
320        if let Some(text) = text {
321            msg.add_field(fix_tag::TEXT, text);
322        }
323
324        msg
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use chrono::TimeZone;
331
332    use super::*;
333
334    #[test]
335    fn test_fix_message_to_bytes_simple() {
336        let timestamp = Utc.with_ymd_and_hms(2025, 3, 22, 12, 34, 56).unwrap();
337        let mut msg = FixMessage::new(fix_message_type::LOGON, 1, "SENDER", "TARGET", &timestamp);
338
339        msg.add_field(fix_tag::ENCRYPT_METHOD, "0")
340            .add_field(fix_tag::HEART_BT_INT, "10")
341            .add_field(fix_tag::RESET_SEQ_NUM_FLAG, "Y");
342
343        let bytes = msg.to_bytes();
344        let message = String::from_utf8(bytes).unwrap();
345        let expected = "8=FIXT.1.1\x019=76\x0135=A\x0149=SENDER\x0156=TARGET\x0134=1\x0152=20250322-12:34:56.000000\x0198=0\x01108=10\x01141=Y\x0110=137\x01";
346
347        assert_eq!(message, expected);
348    }
349
350    #[test]
351    fn test_fix_message_to_bytes_complete_logon() {
352        let timestamp = Utc.with_ymd_and_hms(2025, 3, 22, 12, 34, 56).unwrap();
353        let msg = FixMessage::create_logon(
354            1,
355            "SENDER",
356            "TARGET",
357            30,
358            "username",
359            "password",
360            "signature",
361            &timestamp,
362        );
363
364        let bytes = msg.to_bytes();
365        let message = String::from_utf8(bytes).unwrap();
366        let expected = "8=FIXT.1.1\x019=122\x0135=A\x0149=SENDER\x0156=TARGET\x0134=1\x0152=20250322-12:34:56.000000\x0198=0\x01108=30\x01141=Y\x01553=username\x01554=password\x0158=signature\x011137=9\x0110=253\x01";
367
368        assert_eq!(message, expected);
369    }
370}