Skip to main content

atomr_fix/
message.rs

1//! FIX message representation, wire encoding, and parsing.
2//!
3//! A FIX message is an ordered list of `tag=value` pairs separated by the SOH
4//! control byte (`0x01`). Every message begins with the standard header fields
5//! `BeginString(8)`, `BodyLength(9)`, `MsgType(35)` and ends with the
6//! `CheckSum(10)` trailer.
7//!
8//! [`FixMessage`] keeps fields in insertion order in a flat `Vec<FixField>`.
9//! The header ordering (8, 9, 35) and the trailer checksum are materialised
10//! only at [`FixMessage::to_wire`] time, so callers can [`FixMessage::set`]
11//! fields in any order.
12
13use bytes::Bytes;
14use thiserror::Error;
15
16/// The SOH (Start Of Header) delimiter that separates FIX fields on the wire.
17pub const SOH: u8 = 0x01;
18
19// Standard tag numbers used by the session layer.
20pub mod tags {
21    pub const BEGIN_STRING: u32 = 8;
22    pub const BODY_LENGTH: u32 = 9;
23    pub const MSG_TYPE: u32 = 35;
24    pub const SENDER_COMP_ID: u32 = 49;
25    pub const TARGET_COMP_ID: u32 = 56;
26    pub const MSG_SEQ_NUM: u32 = 34;
27    pub const SENDING_TIME: u32 = 52;
28    pub const CHECK_SUM: u32 = 10;
29
30    // Session-message-specific tags.
31    pub const HEART_BT_INT: u32 = 108;
32    pub const TEST_REQ_ID: u32 = 112;
33    pub const BEGIN_SEQ_NO: u32 = 7;
34    pub const END_SEQ_NO: u32 = 16;
35    pub const RESET_SEQ_NUM_FLAG: u32 = 141;
36    pub const GAP_FILL_FLAG: u32 = 123;
37    pub const NEW_SEQ_NO: u32 = 36;
38    pub const POSS_DUP_FLAG: u32 = 43;
39    pub const ENCRYPT_METHOD: u32 = 98;
40    pub const TEXT: u32 = 58;
41    pub const DEFAULT_APPL_VER_ID: u32 = 1137;
42
43    // A couple of application tags so the NewOrderSingle / ExecutionReport
44    // round-trip test has something realistic to carry.
45    pub const CL_ORD_ID: u32 = 11;
46    pub const SYMBOL: u32 = 55;
47    pub const SIDE: u32 = 54;
48    pub const ORDER_QTY: u32 = 38;
49    pub const ORD_STATUS: u32 = 39;
50    pub const EXEC_TYPE: u32 = 150;
51}
52
53/// A single `tag=value` field.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct FixField {
56    pub tag: u32,
57    pub value: String,
58}
59
60impl FixField {
61    pub fn new(tag: u32, value: impl Into<String>) -> Self {
62        FixField { tag, value: value.into() }
63    }
64}
65
66/// A FIX administrative or application message type (tag 35).
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum MsgType {
69    Logon,
70    Heartbeat,
71    TestRequest,
72    ResendRequest,
73    SequenceReset,
74    Logout,
75    NewOrderSingle,
76    ExecutionReport,
77    Other(String),
78}
79
80impl MsgType {
81    /// The FIX tag-35 code for this message type.
82    pub fn code(&self) -> &str {
83        match self {
84            MsgType::Logon => "A",
85            MsgType::Heartbeat => "0",
86            MsgType::TestRequest => "1",
87            MsgType::ResendRequest => "2",
88            MsgType::SequenceReset => "4",
89            MsgType::Logout => "5",
90            MsgType::NewOrderSingle => "D",
91            MsgType::ExecutionReport => "8",
92            MsgType::Other(s) => s,
93        }
94    }
95
96    /// Parse a FIX tag-35 code into a [`MsgType`].
97    pub fn from_code(code: &str) -> MsgType {
98        match code {
99            "A" => MsgType::Logon,
100            "0" => MsgType::Heartbeat,
101            "1" => MsgType::TestRequest,
102            "2" => MsgType::ResendRequest,
103            "4" => MsgType::SequenceReset,
104            "5" => MsgType::Logout,
105            "D" => MsgType::NewOrderSingle,
106            "8" => MsgType::ExecutionReport,
107            other => MsgType::Other(other.to_string()),
108        }
109    }
110
111    /// Whether this is a session-layer (administrative) message rather than an
112    /// application message. Used by the FSM to decide what to surface to the
113    /// application and what to replay during a resend.
114    pub fn is_admin(&self) -> bool {
115        matches!(
116            self,
117            MsgType::Logon
118                | MsgType::Heartbeat
119                | MsgType::TestRequest
120                | MsgType::ResendRequest
121                | MsgType::SequenceReset
122                | MsgType::Logout
123        )
124    }
125}
126
127/// Errors produced while parsing a FIX frame.
128#[derive(Debug, Error, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum FixParseError {
131    #[error("empty frame")]
132    Empty,
133    #[error("field {0:?} is not valid `tag=value`")]
134    MalformedField(String),
135    #[error("tag {0:?} is not a valid integer")]
136    InvalidTag(String),
137    #[error("field bytes are not valid UTF-8")]
138    NotUtf8,
139    #[error("missing required field, tag {0}")]
140    MissingField(u32),
141    #[error("checksum mismatch: computed {computed:03}, frame carried {found:03}")]
142    ChecksumMismatch { computed: u8, found: u8 },
143    #[error("CheckSum(10) value {0:?} is not a 3-digit number")]
144    InvalidCheckSum(String),
145}
146
147/// An ordered collection of FIX fields.
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
149pub struct FixMessage {
150    fields: Vec<FixField>,
151}
152
153impl FixMessage {
154    /// A new, empty message.
155    pub fn new() -> Self {
156        FixMessage { fields: Vec::new() }
157    }
158
159    /// Build a message of a given type (tag 35). The `MsgType` field is the
160    /// first body field; header framing fields (8/9) are added at encode time.
161    pub fn of_type(msg_type: MsgType) -> Self {
162        let mut m = FixMessage::new();
163        m.set(tags::MSG_TYPE, msg_type.code());
164        m
165    }
166
167    /// First value for `tag`, if present.
168    pub fn get(&self, tag: u32) -> Option<&str> {
169        self.fields.iter().find(|f| f.tag == tag).map(|f| f.value.as_str())
170    }
171
172    /// Convenience: parse a field value as a `u64`.
173    pub fn get_u64(&self, tag: u32) -> Option<u64> {
174        self.get(tag).and_then(|v| v.parse().ok())
175    }
176
177    /// Set `tag` to `value`, replacing the first existing occurrence or
178    /// appending if absent. Returns `&mut self` for chaining.
179    pub fn set(&mut self, tag: u32, value: impl Into<String>) -> &mut Self {
180        let value = value.into();
181        if let Some(f) = self.fields.iter_mut().find(|f| f.tag == tag) {
182            f.value = value;
183        } else {
184            self.fields.push(FixField { tag, value });
185        }
186        self
187    }
188
189    /// All fields in insertion order (header framing fields excluded — they are
190    /// only materialised by [`to_wire`](Self::to_wire)).
191    pub fn fields(&self) -> &[FixField] {
192        &self.fields
193    }
194
195    /// The message type from tag 35, if present.
196    pub fn msg_type(&self) -> Option<MsgType> {
197        self.get(tags::MSG_TYPE).map(MsgType::from_code)
198    }
199
200    /// The `MsgSeqNum` (tag 34), if present and numeric.
201    pub fn seq_num(&self) -> Option<u64> {
202        self.get_u64(tags::MSG_SEQ_NUM)
203    }
204
205    /// Encode the message to wire bytes.
206    ///
207    /// Ordering: `8=BeginString | 9=BodyLength | 35=MsgType | <body…> | 10=CheckSum`.
208    /// `BodyLength(9)` counts every byte after the SOH that terminates field 9
209    /// up to and including the SOH that terminates the last field before
210    /// `CheckSum(10)`. `CheckSum(10)` is the sum of all bytes up to and
211    /// including the SOH before the `10=` field, taken mod 256 and rendered as
212    /// three decimal digits.
213    pub fn to_wire(&self) -> Bytes {
214        let begin_string = self.get(tags::BEGIN_STRING).unwrap_or("FIX.4.4").to_string();
215        let msg_type = self.get(tags::MSG_TYPE).unwrap_or("").to_string();
216
217        // Body = MsgType(35) first, then every other field except the framing
218        // fields (8/9) and the trailer (10).
219        let mut body = Vec::new();
220        push_field(&mut body, tags::MSG_TYPE, &msg_type);
221        for f in &self.fields {
222            if f.tag == tags::BEGIN_STRING
223                || f.tag == tags::BODY_LENGTH
224                || f.tag == tags::MSG_TYPE
225                || f.tag == tags::CHECK_SUM
226            {
227                continue;
228            }
229            push_field(&mut body, f.tag, &f.value);
230        }
231
232        let mut out = Vec::with_capacity(body.len() + 32);
233        push_field(&mut out, tags::BEGIN_STRING, &begin_string);
234        push_field(&mut out, tags::BODY_LENGTH, &body.len().to_string());
235        out.extend_from_slice(&body);
236
237        // Checksum covers everything emitted so far (8, 9, and the body, each
238        // including its terminating SOH).
239        let sum: u32 = out.iter().map(|b| *b as u32).sum();
240        let checksum = (sum % 256) as u8;
241        push_field(&mut out, tags::CHECK_SUM, &format!("{checksum:03}"));
242
243        Bytes::from(out)
244    }
245
246    /// Parse a single SOH-delimited FIX frame.
247    ///
248    /// The frame may or may not include the trailing SOH after `10=NNN`; both
249    /// are accepted. When a `CheckSum(10)` field is present it is verified
250    /// against the recomputed checksum.
251    pub fn parse(input: &[u8]) -> Result<FixMessage, FixParseError> {
252        if input.is_empty() {
253            return Err(FixParseError::Empty);
254        }
255
256        // Split into fields on SOH, ignoring a trailing empty segment caused by
257        // a terminal SOH.
258        let mut fields = Vec::new();
259        let mut checksum_boundary: Option<usize> = None; // byte offset where 10= field begins
260        let mut found_checksum: Option<u8> = None;
261
262        let mut offset = 0usize;
263        for raw in input.split(|b| *b == SOH) {
264            if raw.is_empty() {
265                // trailing SOH or doubled SOH — skip, but still advance offset.
266                offset += 1;
267                continue;
268            }
269            let s = std::str::from_utf8(raw).map_err(|_| FixParseError::NotUtf8)?;
270            let eq = s.find('=').ok_or_else(|| FixParseError::MalformedField(s.to_string()))?;
271            let (tag_str, val_str) = s.split_at(eq);
272            let value = &val_str[1..]; // skip '='
273            let tag: u32 = tag_str.parse().map_err(|_| FixParseError::InvalidTag(tag_str.to_string()))?;
274
275            if tag == tags::CHECK_SUM {
276                checksum_boundary = Some(offset);
277                if value.len() != 3 || !value.bytes().all(|b| b.is_ascii_digit()) {
278                    return Err(FixParseError::InvalidCheckSum(value.to_string()));
279                }
280                // Validated above as exactly 3 ASCII digits, so the parse is
281                // infallible; map the error anyway to keep the path panic-free.
282                let parsed =
283                    value.parse::<u32>().map_err(|_| FixParseError::InvalidCheckSum(value.to_string()))?;
284                found_checksum = Some(parsed as u8);
285            }
286
287            fields.push(FixField { tag, value: value.to_string() });
288            offset += raw.len() + 1; // +1 for the SOH
289        }
290
291        if fields.is_empty() {
292            return Err(FixParseError::Empty);
293        }
294
295        // Verify checksum if present.
296        if let (Some(boundary), Some(found)) = (checksum_boundary, found_checksum) {
297            let sum: u32 = input[..boundary].iter().map(|b| *b as u32).sum();
298            let computed = (sum % 256) as u8;
299            if computed != found {
300                return Err(FixParseError::ChecksumMismatch { computed, found });
301            }
302        }
303
304        Ok(FixMessage { fields })
305    }
306}
307
308fn push_field(buf: &mut Vec<u8>, tag: u32, value: &str) {
309    buf.extend_from_slice(tag.to_string().as_bytes());
310    buf.push(b'=');
311    buf.extend_from_slice(value.as_bytes());
312    buf.push(SOH);
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn msg_type_codes_round_trip() {
321        for mt in [
322            MsgType::Logon,
323            MsgType::Heartbeat,
324            MsgType::TestRequest,
325            MsgType::ResendRequest,
326            MsgType::SequenceReset,
327            MsgType::Logout,
328            MsgType::NewOrderSingle,
329            MsgType::ExecutionReport,
330        ] {
331            assert_eq!(MsgType::from_code(mt.code()), mt);
332        }
333        assert_eq!(MsgType::from_code("XY"), MsgType::Other("XY".to_string()));
334    }
335
336    #[test]
337    fn to_wire_then_parse_round_trips() {
338        let mut m = FixMessage::of_type(MsgType::Heartbeat);
339        m.set(tags::BEGIN_STRING, "FIX.4.4");
340        m.set(tags::SENDER_COMP_ID, "CLIENT");
341        m.set(tags::TARGET_COMP_ID, "SERVER");
342        m.set(tags::MSG_SEQ_NUM, "1");
343        m.set(tags::TEST_REQ_ID, "ABC");
344
345        let wire = m.to_wire();
346        let parsed = FixMessage::parse(&wire).expect("parse");
347
348        assert_eq!(parsed.msg_type(), Some(MsgType::Heartbeat));
349        assert_eq!(parsed.get(tags::SENDER_COMP_ID), Some("CLIENT"));
350        assert_eq!(parsed.get(tags::TARGET_COMP_ID), Some("SERVER"));
351        assert_eq!(parsed.get(tags::TEST_REQ_ID), Some("ABC"));
352        assert_eq!(parsed.seq_num(), Some(1));
353        // BeginString / BodyLength / CheckSum materialised on the wire.
354        assert_eq!(parsed.get(tags::BEGIN_STRING), Some("FIX.4.4"));
355        assert!(parsed.get(tags::BODY_LENGTH).is_some());
356        assert!(parsed.get(tags::CHECK_SUM).is_some());
357    }
358
359    #[test]
360    fn checksum_matches_known_vector() {
361        // A well-known QuickFIX logon example:
362        // 8=FIX.4.2|9=65|35=A|49=SERVER|56=CLIENT|34=177|52=20090107-18:15:16|98=0|108=30|
363        // has CheckSum 062. Build the same body and verify our encoder agrees.
364        let mut m = FixMessage::of_type(MsgType::Logon);
365        m.set(tags::BEGIN_STRING, "FIX.4.2");
366        m.set(tags::SENDER_COMP_ID, "SERVER");
367        m.set(tags::TARGET_COMP_ID, "CLIENT");
368        m.set(tags::MSG_SEQ_NUM, "177");
369        m.set(tags::SENDING_TIME, "20090107-18:15:16");
370        m.set(tags::ENCRYPT_METHOD, "0");
371        m.set(tags::HEART_BT_INT, "30");
372
373        let wire = m.to_wire();
374        let s = String::from_utf8(wire.to_vec()).unwrap().replace(SOH as char, "|");
375        assert_eq!(
376            s,
377            "8=FIX.4.2|9=65|35=A|49=SERVER|56=CLIENT|34=177|52=20090107-18:15:16|98=0|108=30|10=062|"
378        );
379
380        // And the recomputed checksum verifies on parse.
381        let parsed = FixMessage::parse(&wire).expect("parse");
382        assert_eq!(parsed.get(tags::CHECK_SUM), Some("062"));
383    }
384
385    #[test]
386    fn parse_rejects_bad_checksum() {
387        // Tamper with a valid frame's checksum.
388        let mut m = FixMessage::of_type(MsgType::Heartbeat);
389        m.set(tags::BEGIN_STRING, "FIX.4.4");
390        m.set(tags::MSG_SEQ_NUM, "1");
391        let wire = m.to_wire();
392        let mut tampered = wire.to_vec();
393        // The checksum is the last "10=NNN\x01"; flip a digit.
394        let pos = tampered.len() - 2; // last digit before trailing SOH
395        tampered[pos] = if tampered[pos] == b'0' { b'1' } else { b'0' };
396        let err = FixMessage::parse(&tampered).unwrap_err();
397        assert!(matches!(err, FixParseError::ChecksumMismatch { .. }));
398    }
399
400    #[test]
401    fn parse_rejects_malformed_field() {
402        let bad = b"8=FIX.4.4\x01nonsense\x0135=0\x01";
403        let err = FixMessage::parse(bad).unwrap_err();
404        assert!(matches!(err, FixParseError::MalformedField(_)));
405    }
406}