fixlite 0.7.0

fixlite FIX parser core library
Documentation

fixlite

fixlite is a Rust crate for parsing and building FIX (Financial Information eXchange) protocol messages. It provides a procedural macro, FixDeserialize, to automatically generate deserialization implementations for your structs, a macro, fix_tag_registry!, to define registries that map FIX tags to their corresponding Rust types, and a FixBuilder that encodes values via FixValue and tag-bound FixTaggedValue types.

Features

  • Automatic deserialization of FIX messages into Rust structs using #[derive(FixDeserialize)].
  • Support for FIX components and repeating groups via attributes.
  • Customizable tag-to-type mappings through registries defined with fix_tag_registry!.
  • Compile-time validation of tag-type associations to ensure correctness.
  • Zero-copy deserialization for string fields defined as &str, enhancing performance by avoiding unnecessary allocations.
  • Message building with FixBuilder chaining (including field_tagged/field_tagged_ref) and the build_fix! macro.
  • Trait-based field encoding via FixValue and AsFixStr, plus FixTaggedValue for tag-bound types.
  • Common tag constants in the tags module (not exhaustive).
  • Optional BodyLength/CheckSum validation during parsing when the checksum feature is enabled.

Usage

Defining a Registry

Use the fix_tag_registry! macro to define a registry that maps FIX tags to their corresponding Rust types. This registry is used during deserialization to validate and parse tag values correctly.

use fixlite::fix_tag_registry;

fix_tag_registry! {
    MyRegistry {
        35 => [fixlite::enums::MsgType],
        31 => [f64], // LastPx
        8001 => [f64],
    }
}

You can also define an empty registry:

fix_tag_registry!(EmptyRegistry);

Deserializing FIX Messages

Annotate your struct with #[derive(FixDeserialize)] and use the provided attributes to specify how each field corresponds to FIX tags. Enable the derive macro:

fixlite = { version = "...", features = ["derive"] }

If you rename the dependency in Cargo.toml, the derive macro will still resolve it:

fix = { package = "fixlite", version = "...", features = ["derive"] }

No extra use alias is required in your code.

To validate BodyLength and CheckSum during parsing, enable the checksum feature (optionally alongside derive):

fixlite = { version = "...", features = ["derive", "checksum"] }

With checksum enabled, malformed frames return FixError::Malformed(MalformedFix::...) while semantic parse errors return FixError::InvalidValue.

use fixlite::FixDeserialize;

#[derive(FixDeserialize, Debug)]
#[fix_registry(MyRegistry)]
struct TestMessage<'a> {
    #[fix(tag = 35)]
    msg_type: fixlite::enums::MsgType,

    #[fix(tag = 31)]
    last_px: Option<f64>,

    #[fix(component)]
    header: Header<'a>,

    #[fix_group(tag = 453)]
    parties: Vec<Party<'a>>,

    #[fix(tag = 55)]
    symbol: &'a str, // Zero-copy deserialization
}

Decode a FIX message without calling the trait method directly:

let msg: TestMessage = fixlite::decode(bytes)?;

fixlite::decode (and FixDeserialize::from_fix) validate that the input is ASCII before parsing — FIX 4.x is by spec an ASCII protocol, and the check guarantees the parser can safely treat field values as &str internally. Non-ASCII input returns FixError::Malformed(MalformedFix::NonAsciiByte).

If you have already verified your input (for example, because it comes from a trusted upstream that ASCII-validates), use the unsafe variants to skip the check:

// SAFETY: caller guarantees `bytes` contains only valid UTF-8 (ASCII is sufficient).
let msg: TestMessage = unsafe { fixlite::decode_unchecked(bytes) }?;

Violating the safety contract is undefined behavior because the parser uses unchecked UTF-8 conversion internally.

Building FIX Messages

Use FixBuilder directly or via the build_fix! macro. Types that implement FixValue can be encoded, and FIX enums implement AsFixStr and FixTaggedValue automatically. begin_with returns a chainable message builder: use field for owned values, field_ref for borrowed values, field_tagged/field_tagged_ref for tag-bound types, and the str/bytes helpers for string/byte fields. For fallible encoding (currently only f64 rejects NaN/inf), use try_field/try_field_ref/try_field_tagged/try_field_tagged_ref or try_fields, which return Result.

Tagged values (FixTaggedValue) let the type supply its FIX tag so you do not have to:

use fixlite::FixBuilder;
use fixlite::enums::{HandlInst, MsgType, OrdType, Side};

let mut builder = FixBuilder::new("FIX.4.2", "BUYER", "SELLER");
let dt = chrono::Utc::now();

let msg = builder
    .begin_with(&2u64, &dt, &MsgType::NewOrderSingle)
    .field_tagged(HandlInst::Automated)
    .field_tagged(Side::Buy)
    .field_tagged(OrdType::Limit)
    .finish();
use chrono::Utc;
use fixlite::{FixBuilder, tags};
use fixlite::enums::{HandlInst, MsgType, OrdType, Side, TimeInForce};

let mut builder = FixBuilder::new("FIX.4.2", "BUYER", "SELLER");
let dt = Utc::now();

let extras = &[(tags::TEXT, "note"), (tags::EX_DESTINATION, "XNAS")];

let msg = builder
    .begin_with(&2u64, &dt, &MsgType::NewOrderSingle)
    .str(tags::CL_ORD_ID, "123")
    .field_tagged(HandlInst::Automated)
    .str(tags::SYMBOL, "IBM")
    .field_tagged(Side::Buy)
    .field(tags::ORDER_QTY, 100u32)
    .field_tagged(OrdType::Limit)
    .str(tags::PRICE, "150.25")
    .field_tagged(TimeInForce::Day)
    .fields(|m| {
        for &(tag, val) in extras {
            m.str(tag, val);
        }
    })
    .finish();

// The tags module is a convenience list of common tags; it is not exhaustive.

Encoding f64

f64 does not implement FixValue — encoding can fail for NaN/inf, and a silent infallible path would emit a malformed FIX field. Use the fallible builder methods (try_field, try_field_ref) or the ?tag => value macro arm:

let price = 150.25_f64;

let msg = builder
    .begin_with(&2u64, &dt, &MsgType::NewOrderSingle)
    .try_field(44, price)?
    .finish();

When you know finiteness statically (typical for prices), prefer FixedPrice<W, F> (or its alias Price), which encodes without allocation and has no NaN concerns.

Wire contract for f64

The encoder rounds to 15 significant decimal digits using banker's rounding (round-half-to-even), emits no exponential notation, and trims trailing fractional zeros. Examples:

Input f64 Output Notes
0.0 0 integer-only
150.25 150.25 exactly representable
0.1 + 0.2 0.3 15-digit rounding collapses the FP residue
1.0 / 3.0 0.333333333333333 15 significant digits
1e-10 0.0000000001 no exponential notation
f64::NAN / inf error returns FixError::InvalidValue { tag, .. }

This differs from Rust's stdlib Display (and from ryu), which both emit the shortest decimal that round-trips back to the exact f64 bit pattern — e.g., stdlib renders 0.1 + 0.2 as "0.30000000000000004". The choice trades round-trip exactness for a representation that hides the user's floating-point artifacts and runs ~2.65× faster than stdlib on typical FIX-shaped values (see fixlite_example/benches/f64_encode_bench.rs).

When exact precision matters (auditable prices, regulatory reporting, anywhere the last bit needs to round-trip), use FixedPrice<W, F> rather than f64. FixedPrice encodes with bounded scale, no rounding, and no allocation.

build_fix! macro

You can also use the build_fix! macro. It supports four arms:

  • tag => value — infallible field
  • ?tag => value — fallible field (currently f64); expands to try_field_ref(...)? and requires a Result-returning context
  • @value — tagged value (the type provides its FIX tag)
  • legacy tag, value — equivalent to tag => value
use chrono::Utc;
use fixlite::build_fix;
use fixlite::{FixBuilder, tags};
use fixlite::enums::{HandlInst, MsgType, OrdType, Side, TimeInForce};

let mut builder = FixBuilder::new("FIX.4.2", "BUYER", "SELLER");
let dt = Utc::now();

let price: fixlite::fix::Price = "150.25".parse().unwrap();

let msg = build_fix!(
    builder,
    2u64,
    dt,
    MsgType::NewOrderSingle,
    tags::ORDER_QTY => 100u32,
    tags::PRICE => price,
    @HandlInst::Automated,
    @Side::Buy,
    @OrdType::Limit,
    @TimeInForce::Day,
);

Note on string fields in the macro. The tag => value arm expands to field_ref(tag, &value). The unsized str implements FixValue, and so does String, but &str (a reference) does not. Two patterns work:

// (a) Owned String works directly.
let cl_ord_id = String::from("123");
let msg = build_fix!(
    builder, 2u64, dt, MsgType::NewOrderSingle,
    tags::CL_ORD_ID => cl_ord_id,
    @Side::Buy,
);

// (b) For &str, deref so the value position is `str` (unsized) rather than `&str`.
let symbol: &str = "IBM";
let msg = build_fix!(
    builder, 2u64, dt, MsgType::NewOrderSingle,
    tags::SYMBOL => *symbol,
    @Side::Buy,
);

For mixed string handling, the chained FixBuilder syntax with .str(tag, "literal") is often clearer than the macro.

Attributes

  • #[fix(tag = N)]: Maps the field to FIX tag N.
  • #[fix(component)]: Indicates that the field is a nested component.
  • #[fix_group(tag = N)]: Indicates that the field is a repeating group starting with tag N.
  • #[fix_registry(RegistryName)]: Specifies the registry to use for tag-type validation. Defaults to DefaultRegistry if not specified.

Repeating Groups

#[fix_group(tag = N)] declares a repeating-group field of type Vec<T>, where N is the FIX "NoXxx" counter tag (for example, 453 for NoPartyIDs). Element boundaries within the group are detected heuristically rather than from a schema. Two assumptions apply:

  1. All elements start with the same first tag. When the parser sees the same first tag a second time, it treats that as the start of a new element. If your dialect allows an element's leading tag to be omitted, the parser will not detect element boundaries correctly.
  2. A top-level (outer-struct) tag terminates the group. If a tag known to the outer struct appears while parsing group elements, the group is considered finished. If an element's tag overlaps with an outer-struct tag, the group will be truncated at that element.

Nested groups are not supported. Groups whose elements themselves contain #[fix_group] fields will not parse correctly; the inner group's boundaries collide with the outer group's heuristic. For nested-group support you currently need a hand-written FixDeserialize impl.

Components

#[fix(component)] declares that a field is a nested component — a sub-struct whose tags are inlined into the message body. The derive routes incoming tags to the component using its FixDeserialize::is_known_tag set:

if component_field.is_none() && Inner::is_known_tag(tag) {
    component_field = Some(Inner::deserialize_fields(...)?);
}

This implies two constraints:

  1. Component and outer-struct tag sets must be disjoint. A tag known to both the outer struct and the component is consumed by the outer struct first; the component never sees it.
  2. First match wins. If two components share an overlapping tag set, the field declaration order determines which component captures the tag.

Optional components (Option<Component<'a>>) are allowed and reported missing-via-None rather than as MissingComponent.

Zero-Copy Deserialization

For string fields defined as &str, fixlite supports zero-copy deserialization. This means that during deserialization, the string slices in the FIX message are borrowed directly, avoiding unnecessary allocations and enhancing performance.

Ensure that the lifetime annotations are correctly specified to take advantage of this feature.

Example

Given a FIX message:

8=FIX.4.2|9=176|35=D|49=BUYER|56=SELLER|34=2|52=20190605-19:45:32.123|11=123|21=1|55=IBM|54=1|38=100|40=2|44=150.25|59=0|10=128|

You can deserialize using fixlite::decode for the SOH delimiter:

let raw = b"8=FIX.4.2|9=176|35=D|49=BUYER|56=SELLER|34=2|52=20190605-19:45:32.123|11=123|21=1|55=IBM|54=1|38=100|40=2|44=150.25|59=0|10=128|";
let message = raw
    .iter()
    .map(|&b| if b == b'|' { b'\x01' } else { b })
    .collect::<Vec<u8>>();
let parsed: TestMessage = fixlite::decode(&message)?;

Fuzzing

A cargo-fuzz harness for decode lives at the workspace root under fuzz/. It feeds coverage-guided random bytes to the parser and looks for panics, hangs, sanitizer violations, or other crashes. To run:

# One-time setup
rustup install nightly
cargo install cargo-fuzz

# Run the decode target (Ctrl-C to stop)
just fuzz                # equivalent to: cargo +nightly fuzz run decode

# Run for a fixed time (good for CI)
just fuzz-for 60         # 60 seconds, then stop

# Just compile-check the harness without running
just fuzz-build

Crashes land in fuzz/artifacts/decode/ as minimum reproducers. The corpus accumulates in fuzz/corpus/decode/; both directories are gitignored. The harness uses a maximally-permissive Option-everywhere struct so most inputs exercise the parser end-to-end rather than failing the first required-field check.

License

This project is dual-licensed under MIT OR Apache-2.0, at your option. See the LICENSE-MIT and LICENSE-APACHE-2.0 files for details.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.