diff --git a/CMakeLists.txt b/CMakeLists.txt index 54b3098..383a4e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,8 @@ include(cmake/ProjectOptions.cmake) include(cmake/CompilerWarnings.cmake) include(cmake/Sanitizers.cmake) -add_library(qsl_core src/core/types.cpp src/protocol/codec.cpp src/engine/order_book.cpp +add_library(qsl_core src/core/types.cpp src/protocol/codec.cpp src/protocol/fix.cpp + src/engine/order_book.cpp src/engine/matching_engine.cpp src/engine/risk.cpp src/gateway/order_gateway.cpp src/feed/market_data.cpp src/feed/publisher.cpp src/replay/event_log.cpp src/replay/command.cpp diff --git a/HANDOFF.md b/HANDOFF.md index 0bee945..ba1dafa 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -82,15 +82,16 @@ Current state: provides real cycles/instructions/branches/branch-misses but no cache-reference/cache-miss support - issues #95, #28, and #26 were closed by PR #112 - open review request issue: #94 -- legacy backlog still open: #29 and #32 +- legacy backlog still open: #32 (#29 delivered in this PR, `feat/fix-text-protocol-adapter`) ### Next milestone There is no active milestone. M0–M49, the Linux artifact refresh (PR #125), and the v0.2.0 release (PR #127) are merged. The highest-value remaining work is non-code and externally gated: issue #94 (independent external review — needs a human reviewer) and issue #90 (full cache-counter PMU -evidence — needs a PMU microarchitecture that exposes cache events). Low-signal backlog: #32 -(flamegraph) and #29 (FIX adapter). Do not invent a new milestone without an explicit human request. +evidence — needs a PMU microarchitecture that exposes cache events). #29 (FIX-like text protocol +adapter) is delivered in this PR; low-signal backlog: #32 (flamegraph). Do not invent a new +milestone without an explicit human request. ### Phase III / IV purpose @@ -107,7 +108,8 @@ Current priority order (post-v0.2.0): 2. Issue #90 — full cache-counter PMU evidence. The bare-metal Apple host gives real cycles/instructions/branches/branch-misses but no cache-reference/cache-miss counters, so this needs a PMU microarchitecture that exposes cache events (x86_64, or an ARM server core). -3. Low-signal backlog only after the above: #32 (flamegraph), #29 (FIX adapter). +3. Low-signal backlog only after the above: #32 (flamegraph). #29 (FIX adapter) is delivered in + this PR (`feat/fix-text-protocol-adapter`). ### Forbidden shortcuts diff --git a/MILESTONES.md b/MILESTONES.md index c32aec8..34ac385 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -469,10 +469,11 @@ Sequential, dependency-ordered. **Build them in order.** Each milestone is one f > perf/flamegraph). M25 (memory-ordering evidence), M30 (socket profiling/hardening), and M31 > (external review) are new milestones with no prior issue. PR #112 closed > the remaining tractable systems items **#26** (portable TCP serving beyond one-connection-at-a-time -> accept) and **#28** (realistic synthetic order-flow model). The genuinely **deferred** product/API -> items remain **#29** (FIX adapter), **#30** (web dashboard), **#31** (Docker packaging), and -> **#33** (Pages site) — do not start them before the Phase III/IV systems roadmap unless the human -> explicitly reprioritizes. +> accept) and **#28** (realistic synthetic order-flow model). The human later reprioritized two +> backlog items, now **done**: **#32** (perf/flamegraph) and **#29** (FIX-like text protocol +> adapter). The genuinely **deferred** product/API items remain **#30** (web dashboard), **#31** +> (Docker packaging), and **#33** (Pages site) — do not start them before the Phase III/IV systems +> roadmap unless the human explicitly reprioritizes. Do not pull backlog items into earlier PRs. @@ -481,7 +482,11 @@ Do not pull backlog items into earlier PRs. - Multithreaded gateway and market data pipeline, plus portable threaded TCP serving follow-up. (#26) - ThreadSanitizer coverage. (#27) - More realistic synthetic order-flow model. (#28) -- FIX-like text protocol adapter. (#29) +- FIX-like text protocol adapter. (#29) — **done**: `tag=value` SOH-framed adapter + (`include/qsl/protocol/fix.hpp`, `src/protocol/fix.cpp`) over the same internal structs as the + binary codec, with genuine FIX BeginString/BodyLength/CheckSum framing for NewOrderSingle (35=D) + and OrderCancelRequest (35=F). Cross-codec equivalence + malformed-input rejection tested in + `tests/unit/test_fix_protocol.cpp`; docs in `docs/fix_protocol.md`. - Web dashboard for visualization. (#30) - Docker packaging. (#31) - Perf/flamegraph docs. (#32) — **done**: `make flamegraph` renders a perf call-graph flamegraph diff --git a/PROGRESS.md b/PROGRESS.md index 2919d13..751757e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -52,7 +52,8 @@ Do not rely on prior chat memory. - **Next action:** no active milestone. Highest-value remaining work is non-code and gated: issue #94 (independent external review — needs a human reviewer) and issue #90 (full cache-counter PMU evidence — needs a PMU microarchitecture that exposes cache events, e.g. - x86_64). Low-signal backlog: #32 (flamegraph), #29 (FIX adapter). + x86_64). #29 (FIX-like text protocol adapter) is delivered in this PR + (`feat/fix-text-protocol-adapter`). Low-signal backlog: #32 (flamegraph). - **Blockers:** issue #90 is now a *cache-counter* PMU gap, not a host-access gap — this bare-metal Apple M2 exposes real `cycles`/`instructions`/`branches`/`branch-misses` but its PMU does not implement `cache-references`/`cache-misses`; closing it needs a PMU microarchitecture that exposes @@ -60,7 +61,7 @@ Do not rely on prior chat memory. review (human-gated). Hardware NIC/offload latency measurement still requires suitable wired NIC hardware, driver support, timestamping/offload/RSS access, and a measured packet workload; the current `wld0` Wi-Fi capability observation is not NIC-offload latency evidence. Legacy backlog - still includes #32 and #29. Issues #95, #28, and #26 were closed by PR #112. + still includes #32 (#29 delivered in this PR). Issues #95, #28, and #26 were closed by PR #112. --- @@ -386,6 +387,24 @@ Lower priority: the bare-metal Fedora Asahi host (aarch64) from the clean committed tree (`Dirty inputs: no`). This is a software cpu-clock sampling hot-symbol profile, not a latency/throughput claim; full hardware cache-PMU evidence stays in #90. Do not merge from automation; human squash-merges. +- [2026-06-21] Issue #29 FIX-like text protocol adapter (`feat/fix-text-protocol-adapter`, stacked + on the flamegraph branch). Added `include/qsl/protocol/fix.hpp` + `src/protocol/fix.cpp`: a + `tag=value` SOH-framed adapter over the SAME internal structs as the binary codec, with genuine + FIX framing (8 BeginString / 9 BodyLength / 35 MsgType / … / 10 mod-256 CheckSum) for the + client→gateway order path — NewOrderSingle (35=D)→`NewOrder` and OrderCancelRequest + (35=F)→`CancelOrder`. Decoding is total/deterministic/`noexcept` (fixed field table, + `std::from_chars`, `string_view`; no heap on decode) and reports every malformed input through a + `FixError` taxonomy mirroring the binary `DecodeError`. Documented, deliberate simplifications: + Symbol (55) carries the numeric SymbolId; Price (44) carries integer ticks and is always present, + making `NewOrder↔FIX` a lossless bijection like the binary codec (never float for price). + `tests/unit/test_fix_protocol.cpp` mirrors the binary required tests and adds a **cross-codec + equivalence** test (binary and FIX decode the same order to identical structs across all + Side×OrdType×TIF), a byte-pinned fixture (checksum 164 / body-length 50), and rejection of + malformed framing / unsupported BeginString / unknown-or-wrong MsgType / BodyLength mismatch / + CheckSum mismatch / missing field / invalid field / invalid enum / out-of-range / oversized. Docs + in `docs/fix_protocol.md` (+ pointer from `docs/binary_protocol.md`). `make check` 260/260 and + `make asan` 260/260 clean (the parser handles untrusted text). Closes #29. Do not merge from + automation; human squash-merges. - [2026-06-03] M35: implemented a multi-client TCP connection-scaling load test (`scripts/socket_load.sh`, `make socket-load`, Linux-only) driving N concurrent `qsl-client`s against the portable TCP and epoll (M34) gateways; `results/socket_load_summary.txt` is Docker-generated and constrained. A `/code-review` (3 finder agents) caught and fixed real measurement-integrity bugs before the PR: a failed trial's `wall=0` no longer poisons the reported best (only trials whose gateway served count toward the min); the `completed` column reports the WORST per-trial completion, not the last, so partial/total trial failures are surfaced rather than masked; a per-client `timeout` bounds a hang if the gateway dies; and `QSL_LOAD_TRIALS` is validated. Post-PR hardening uses fresh monotonic ports per gateway start, retries transient startup/serve failures on new ports, and refuses to write a partial artifact unless `QSL_LOAD_ALLOW_PARTIAL=1` is set intentionally; the refreshed artifact records `Dirty tree: no`. The scaling-shape claim remains constrained to loopback connection setup, not a demonstrated production-capacity advantage for either transport. Deferred follow-up: a shared `scripts/lib` to remove the dirty-tree / `wait_ready` / gateway-stop duplication across the three socket scripts. - [2026-06-03] M35: started after M34 (#98) squash-merged (commit 9e3750b). Scope: multi-client load / socket-pressure testing of the gateway/feed path (TCP/UDP stress, socket-buffer pressure, connection scaling, backpressure) building on M34's epoll multi-client path and M30's socket tooling. Constraints: scripts/tests document load shape + environment; results must distinguish kernel/socket pressure from user-space engine cost; no production-capacity claims (honest constrained-environment framing, like M29/M30). - [2026-06-04] M35: PR #100 squash-merged to `main` as a86b701 after all CI jobs and review checks were green. M35 is now landed; original M36 NUMA remains deferred until the repository-health refactor analysis is completed or explicitly skipped by the human. diff --git a/docs/binary_protocol.md b/docs/binary_protocol.md index 2d2a827..1e1c948 100644 --- a/docs/binary_protocol.md +++ b/docs/binary_protocol.md @@ -81,3 +81,9 @@ a byte stream belong to the TCP/session layer (M9), not the codec. The wire format is pinned by a byte-fixture test (`tests/unit/test_protocol.cpp`) so any accidental change to field order or byte order fails the build. + +## Text alternative + +A human-readable, FIX-like `tag=value` adapter over the same internal message structs lives +alongside this binary codec — see [fix_protocol.md](fix_protocol.md). Both decode the same order to +identical structs, which the tests assert directly. diff --git a/docs/fix_protocol.md b/docs/fix_protocol.md new file mode 100644 index 0000000..d25c8ce --- /dev/null +++ b/docs/fix_protocol.md @@ -0,0 +1,95 @@ +# FIX-like Text Protocol Adapter + +A human-readable `tag=value` text protocol alongside the [binary protocol](binary_protocol.md), +mapping the **same internal message structs**. Implemented in `include/qsl/protocol/fix.hpp` and +`src/protocol/fix.cpp`; tested in `tests/unit/test_fix_protocol.cpp`. This is the optional FIX +adapter tracked by issue #29. + +It is **FIX-like**, not a full FIX engine: it implements the genuine FIX framing and the +client→gateway order path, deliberately scoped to what mirrors the binary codec. + +## Why it exists + +Real venues speak FIX as well as binary protocols. Showing a second, independently-validated wire +format over one internal model demonstrates a clean protocol boundary: the engine does not care +which encoding produced a `NewOrder`. The strongest invariant the tests assert is that a binary +frame and a FIX message for the same order **decode to identical internal structs**. + +## Framing + +A message is a sequence of `tag=value` fields, each terminated by the **SOH** byte (`0x01`). The +adapter uses the standard FIX envelope: + +```text +8=FIX.4.2 | 9= | 35= | | 10= | +``` + +(`|` denotes SOH.) + +- **`8` BeginString** must be `FIX.4.2`; anything else is `UnsupportedBeginString`. +- **`9` BodyLength** is the byte count from the field after tag 9 through the SOH before tag 10. + A mismatch is `BodyLengthMismatch`. +- **`10` CheckSum** is the mod-256 sum of every byte up to the SOH before tag 10, formatted as + exactly three zero-padded digits. A mismatch is `ChecksumMismatch`. + +## Messages + +### NewOrderSingle (`35=D`) → `NewOrder` + +| Tag | FIX name | Internal field | Encoding | +|-----|---------------|----------------|----------| +| 34 | MsgSeqNum | sequence no. | carried like the binary header `seq_no` (validated, not stored in the body struct) | +| 11 | ClOrdID | `order_id` | decimal | +| 55 | Symbol | `symbol` | decimal `SymbolId` (see simplifications) | +| 54 | Side | `side` | `1`=Buy, `2`=Sell | +| 38 | OrderQty | `quantity` | decimal | +| 40 | OrdType | `type` | `1`=Market, `2`=Limit | +| 44 | Price | `price` | integer ticks (see simplifications) | +| 59 | TimeInForce | `tif` | `1`=GTC, `3`=IOC | + +### OrderCancelRequest (`35=F`) → `CancelOrder` + +| Tag | FIX name | Internal field | Notes | +|-----|--------------|----------------|-------| +| 34 | MsgSeqNum | sequence no. | as above | +| 41 | OrigClOrdID | `order_id` | the order being cancelled | +| 11 | ClOrdID | — | required by FIX; validated on decode, echoes `order_id` on encode (no separate cancel-request id is modelled) | +| 55 | Symbol | `symbol` | decimal `SymbolId` | + +## Deliberate simplifications + +These are documented departures from strict FIX, chosen so the adapter stays a deterministic, +lossless map onto the simulator's internal model: + +- **Symbol (tag 55) carries the numeric `SymbolId`** in decimal, not a ticker string — the engine + keys on `SymbolId`, so mapping to a string table would only add a lossy layer. +- **Price (tag 44) carries integer ticks and is always present**, including market orders. The + project never represents price as a float, and `NewOrder` always has a `price` field; carrying it + losslessly makes `NewOrder ↔ FIX` a true bijection over the internal struct, exactly like the + binary codec. (Strict FIX uses a decimal price and forbids tag 44 on market orders.) + +## Error model + +Decoding is total and deterministic: it never throws, allocates nothing on the decode path (a +fixed field table, `std::from_chars`, `std::string_view`), and reports every malformed input via +`FixError` rather than undefined behavior — mirroring the binary codec's `DecodeError` discipline. + +`FixError`: `None`, `Malformed`, `UnsupportedBeginString`, `UnknownMsgType`, `MissingField`, +`InvalidField`, `BodyLengthMismatch`, `ChecksumMismatch`, `InvalidEnumValue`, `OutOfRange`. + +## Determinism and testing + +`tests/unit/test_fix_protocol.cpp` mirrors the binary codec's required tests and adds FIX-specific +ones: + +- round-trip for NewOrderSingle and OrderCancelRequest; +- **cross-codec equivalence**: binary and FIX decode the same order to identical structs across all + Side × OrdType × TIF combinations; +- a **byte-pinned fixture** (`8=FIX.4.2|9=50|35=D|…|10=164|`) so any change to field order or the + checksum/body-length computation fails the build; +- rejection of malformed framing, unsupported BeginString, unknown/wrong MsgType, BodyLength + mismatch, CheckSum mismatch, missing required fields, non-numeric fields, invalid enum codes, + out-of-range integers, and oversized messages; +- signed/extreme `int64` price and `uint64` id/seq round-trips. + +The adapter is also covered by the ASan/UBSan preset (`make asan`), since it parses untrusted text. diff --git a/include/qsl/protocol/fix.hpp b/include/qsl/protocol/fix.hpp new file mode 100644 index 0000000..7c15562 --- /dev/null +++ b/include/qsl/protocol/fix.hpp @@ -0,0 +1,110 @@ +#pragma once + +// FIX-like text protocol adapter (issue #29). +// +// A human-readable `tag=value` wire format alongside the binary codec +// (qsl/protocol/codec.hpp), mapping the same internal message structs. It is +// "FIX-like": it uses genuine FIX framing — SOH-delimited tag=value fields, the +// 8/9/35/.../10 envelope, a BodyLength (tag 9) and a mod-256 CheckSum (tag 10) — +// for the client->gateway order path: NewOrderSingle (35=D) -> NewOrder and +// OrderCancelRequest (35=F) -> CancelOrder. +// +// Deliberate, documented simplifications for a deterministic simulator (see +// docs/fix_protocol.md): +// * Symbol (tag 55) carries the numeric SymbolId in decimal, not a ticker +// string — the internal model keys on SymbolId. +// * Price (tag 44) carries integer ticks, never a decimal/float, and is always +// present (including market orders). This keeps NewOrder<->FIX a lossless +// bijection over the internal struct, exactly like the binary codec, so a +// binary frame and a FIX message for the same order decode to identical +// structs. Prices are never floating point. +// +// Decoding is total and deterministic: it never throws, allocates only the +// returned string on encode, and reports every malformed input through FixError +// rather than undefined behavior — mirroring the binary codec's DecodeError +// discipline. + +#include "qsl/protocol/messages.hpp" + +#include +#include + +namespace qsl::protocol::fix { + +// FIX field separator (SOH, 0x01) and the supported BeginString (tag 8). +inline constexpr char kSoh = '\x01'; +inline constexpr std::string_view kBeginString = "FIX.4.2"; +// Defensive upper bound on a single message; order messages are small. +inline constexpr std::size_t kMaxMessageLen = 1024; + +// FIX MsgType (tag 35) values this adapter handles. +inline constexpr char kMsgNewOrderSingle = 'D'; +inline constexpr char kMsgOrderCancelRequest = 'F'; + +// Deterministic decode outcomes for malformed/invalid FIX text. Extends the +// binary codec's error taxonomy with FIX-envelope-specific failures. +enum class FixError : std::uint8_t { + None = 0, + Malformed, // not tag=value/SOH framed, or empty/oversized + UnsupportedBeginString, // tag 8 != kBeginString + UnknownMsgType, // tag 35 absent, or not the expected message type + MissingField, // a required tag is absent + InvalidField, // a value failed integer parsing / is empty + BodyLengthMismatch, // tag 9 (BodyLength) != the actual body byte count + ChecksumMismatch, // tag 10 (CheckSum) != the computed mod-256 sum + InvalidEnumValue, // Side/OrdType/TimeInForce code is not recognized + OutOfRange, // a parsed integer does not fit its target field +}; + +[[nodiscard]] constexpr const char *to_string(FixError e) noexcept { + switch (e) { + case FixError::None: + return "None"; + case FixError::Malformed: + return "Malformed"; + case FixError::UnsupportedBeginString: + return "UnsupportedBeginString"; + case FixError::UnknownMsgType: + return "UnknownMsgType"; + case FixError::MissingField: + return "MissingField"; + case FixError::InvalidField: + return "InvalidField"; + case FixError::BodyLengthMismatch: + return "BodyLengthMismatch"; + case FixError::ChecksumMismatch: + return "ChecksumMismatch"; + case FixError::InvalidEnumValue: + return "InvalidEnumValue"; + case FixError::OutOfRange: + return "OutOfRange"; + } + return "Unknown"; +} + +template struct FixDecodeResult { + FixError error{FixError::None}; + T value{}; + + [[nodiscard]] bool ok() const noexcept { return error == FixError::None; } +}; + +// Encode internal order structs to a complete FIX-like message string (a single +// allocation, framed with BeginString/BodyLength/CheckSum). `seq` is carried in +// MsgSeqNum (tag 34), mirroring the binary frame's header sequence number. +[[nodiscard]] std::string encode(const NewOrder &msg, SeqNo seq); +[[nodiscard]] std::string encode(const CancelOrder &msg, SeqNo seq); + +// Decode and validate a complete FIX-like message into the internal struct. +[[nodiscard]] FixDecodeResult decode_new_order(std::string_view msg) noexcept; +[[nodiscard]] FixDecodeResult decode_cancel_order(std::string_view msg) noexcept; + +// Validate the FIX envelope (8/9/.../10) and return the MsgType (tag 35) so a +// dispatcher can route to the right typed decoder. +[[nodiscard]] FixDecodeResult peek_msg_type(std::string_view msg) noexcept; + +// Validate the envelope and return MsgSeqNum (tag 34). Useful for verifying that +// the sequence number round-trips, since the typed decoders return only the body. +[[nodiscard]] FixDecodeResult peek_seq(std::string_view msg) noexcept; + +} // namespace qsl::protocol::fix diff --git a/src/protocol/fix.cpp b/src/protocol/fix.cpp new file mode 100644 index 0000000..8d05efd --- /dev/null +++ b/src/protocol/fix.cpp @@ -0,0 +1,394 @@ +#include "qsl/protocol/fix.hpp" + +#include "qsl/core/types.hpp" + +#include +#include +#include +#include +#include + +namespace qsl::protocol::fix { + +namespace { + +// FIX tags this adapter reads/writes. +enum Tag : unsigned { + kTagBeginString = 8, + kTagBodyLength = 9, + kTagCheckSum = 10, + kTagClOrdID = 11, + kTagMsgSeqNum = 34, + kTagMsgType = 35, + kTagOrderQty = 38, + kTagOrdType = 40, + kTagOrigClOrdID = 41, + kTagPrice = 44, + kTagSide = 54, + kTagSymbol = 55, + kTagTimeInForce = 59, +}; + +constexpr std::size_t kMaxFields = 32; + +struct Field { + unsigned tag{0}; + std::string_view value{}; + std::size_t start{0}; // byte offset of the field within the message +}; + +struct Parsed { + std::array fields{}; + std::size_t count{0}; +}; + +// Parse an unsigned/signed integer, requiring the whole view to be consumed. +template [[nodiscard]] bool parse_int(std::string_view sv, Int &out) noexcept { + if (sv.empty()) { + return false; + } + const char *first = sv.data(); + const char *last = sv.data() + sv.size(); + const auto res = std::from_chars(first, last, out); + return res.ec == std::errc() && res.ptr == last; +} + +[[nodiscard]] const Field *find_field(const Parsed &p, unsigned tag) noexcept { + for (std::size_t i = 0; i < p.count; ++i) { + if (p.fields[i].tag == tag) { + return &p.fields[i]; + } + } + return nullptr; +} + +// Split the message into SOH-delimited tag=value fields. Malformed framing +// (missing SOH, missing '=', non-numeric tag, too many fields) is rejected. +[[nodiscard]] FixError tokenize(std::string_view msg, Parsed &out) noexcept { + std::size_t pos = 0; + while (pos < msg.size()) { + const std::size_t field_start = pos; + const std::size_t soh = msg.find(kSoh, pos); + if (soh == std::string_view::npos) { + return FixError::Malformed; // field not SOH-terminated + } + const std::size_t eq = msg.find('=', pos); + if (eq == std::string_view::npos || eq >= soh) { + return FixError::Malformed; // no '=' within the field + } + unsigned tag = 0; + if (!parse_int(msg.substr(field_start, eq - field_start), tag)) { + return FixError::Malformed; // non-numeric tag + } + if (out.count >= kMaxFields) { + return FixError::Malformed; // too many fields + } + if (find_field(out, tag) != nullptr) { + // This adapter maps each business tag exactly once (no repeating + // groups), so a repeated tag is an ambiguous/malformed frame rather + // than a silently-ignored later value (e.g. 55=2 then 55=999). + return FixError::Malformed; // duplicate tag + } + out.fields[out.count++] = Field{tag, msg.substr(eq + 1, soh - (eq + 1)), field_start}; + pos = soh + 1; + } + return FixError::None; +} + +// Confirm the standard 8 / 9 / 35 / ... / 10 envelope: BeginString, BodyLength, +// MsgType as the first body field, CheckSum last, and a supported BeginString. +[[nodiscard]] FixError check_envelope_shape(const Parsed &p) noexcept { + if (p.count < 3) { + return FixError::Malformed; + } + const Field &begin = p.fields[0]; + // MsgType (35) must be the first body field, immediately after BodyLength, so + // a frame like 8/9/34/35/.../10 is rejected rather than decoded. + const bool ordered = begin.tag == kTagBeginString && p.fields[1].tag == kTagBodyLength && + p.fields[2].tag == kTagMsgType && + p.fields[p.count - 1].tag == kTagCheckSum; + if (!ordered) { + return FixError::Malformed; + } + return begin.value == kBeginString ? FixError::None : FixError::UnsupportedBeginString; +} + +// Verify BodyLength (tag 9) against the actual body span and the mod-256 +// CheckSum (tag 10) against the sum of every byte before the tag-10 field. +[[nodiscard]] FixError verify_length_and_checksum(std::string_view msg, const Parsed &p) noexcept { + const Field &f_csum = p.fields[p.count - 1]; + // BodyLength spans [fields[2].start, checksum_field.start). + const std::size_t checksum_start = f_csum.start; + std::size_t body_len = 0; + if (!parse_int(p.fields[1].value, body_len)) { + return FixError::InvalidField; + } + if (body_len != checksum_start - p.fields[2].start) { + return FixError::BodyLengthMismatch; + } + unsigned declared = 0; + if (f_csum.value.size() != 3 || !parse_int(f_csum.value, declared)) { + return FixError::InvalidField; + } + unsigned sum = 0; + for (std::size_t i = 0; i < checksum_start; ++i) { + sum += static_cast(msg[i]); + } + return (sum & 0xFFu) == declared ? FixError::None : FixError::ChecksumMismatch; +} + +// Validate the FIX envelope and fill the field table; business fields are then +// looked up by the typed decoders. +[[nodiscard]] FixError parse_envelope(std::string_view msg, Parsed &out) noexcept { + if (msg.empty() || msg.size() > kMaxMessageLen) { + return FixError::Malformed; + } + if (const FixError e = tokenize(msg, out); e != FixError::None) { + return e; + } + if (const FixError e = check_envelope_shape(out); e != FixError::None) { + return e; + } + return verify_length_and_checksum(msg, out); +} + +// Extract a required integer field; map absence/format/overflow to structured +// errors. A value that does not fit the target field is OutOfRange (distinct from +// a non-numeric InvalidField). +template +[[nodiscard]] FixError require_int(const Parsed &p, unsigned tag, Int &out) noexcept { + const Field *f = find_field(p, tag); + if (f == nullptr) { + return FixError::MissingField; + } + if (f->value.empty()) { + return FixError::InvalidField; + } + const char *first = f->value.data(); + const char *last = f->value.data() + f->value.size(); + const auto res = std::from_chars(first, last, out); + if (res.ec == std::errc::result_out_of_range) { + return FixError::OutOfRange; + } + if (res.ec != std::errc() || res.ptr != last) { + return FixError::InvalidField; + } + return FixError::None; +} + +// Require a single-character coded enum field (e.g. Side, OrdType, TIF). +[[nodiscard]] FixError require_code(const Parsed &p, unsigned tag, char &out) noexcept { + const Field *f = find_field(p, tag); + if (f == nullptr) { + return FixError::MissingField; + } + if (f->value.size() != 1) { + return FixError::InvalidEnumValue; + } + out = f->value.front(); + return FixError::None; +} + +// Confirm MsgType (tag 35) is present, single-character, and the expected type. +[[nodiscard]] FixError expect_msg_type(const Parsed &p, char expected) noexcept { + const Field *type = find_field(p, kTagMsgType); + if (type == nullptr || type->value.size() != 1) { + return FixError::UnknownMsgType; + } + return type->value.front() == expected ? FixError::None : FixError::UnknownMsgType; +} + +// Reads required fields and short-circuits on the first error, so the typed +// decoders stay a flat chain instead of a long if-return ladder. +class FieldReader { + public: + explicit FieldReader(const Parsed &p) noexcept : p_(p) {} + + template FieldReader &integer(unsigned tag, Int &out) noexcept { + if (err_ == FixError::None) { + err_ = require_int(p_, tag, out); + } + return *this; + } + + // Read a single-character coded field and map it via a {code, enum} table + // (Side 1/2, OrdType 1/2, TIF 1/3). One generic method covers every enum, so + // there is no per-enum mapping duplication. An unknown code is InvalidEnumValue. + template + FieldReader &coded(unsigned tag, Enum &out, + const std::array, N> &table) noexcept { + if (err_ != FixError::None) { + return *this; + } + char code = 0; + err_ = require_code(p_, tag, code); + if (err_ != FixError::None) { + return *this; + } + for (const auto &entry : table) { + if (entry.first == code) { + out = entry.second; + return *this; + } + } + err_ = FixError::InvalidEnumValue; + return *this; + } + + [[nodiscard]] FixError error() const noexcept { return err_; } + + private: + const Parsed &p_; + FixError err_{FixError::None}; +}; + +void append_field(std::string &dst, unsigned tag, std::string_view value) { + dst += std::to_string(tag); + dst += '='; + dst += value; + dst += kSoh; +} + +void append_field(std::string &dst, unsigned tag, std::uint64_t value) { + append_field(dst, tag, std::to_string(value)); +} + +// Wrap an already-built body (the fields from tag 35 onward) in the FIX +// envelope: prepend 8/9 and append the computed CheckSum (tag 10). +[[nodiscard]] std::string frame(const std::string &body) { + std::string head; + head += "8="; + head += kBeginString; + head += kSoh; + head += "9="; + head += std::to_string(body.size()); + head += kSoh; + head += body; + + unsigned sum = 0; + for (const char c : head) { + sum += static_cast(c); + } + const unsigned cs = sum % 256u; // FIX CheckSum is the mod-256 byte sum... + char csum[4]; + // ...formatted as exactly three zero-padded digits (0..255). + csum[0] = static_cast('0' + ((cs / 100) % 10)); + csum[1] = static_cast('0' + ((cs / 10) % 10)); + csum[2] = static_cast('0' + (cs % 10)); + csum[3] = '\0'; + + head += "10="; + head += csum; + head += kSoh; + return head; +} + +} // namespace + +std::string encode(const NewOrder &msg, SeqNo seq) { + std::string body; + append_field(body, kTagMsgType, std::string_view(&kMsgNewOrderSingle, 1)); + append_field(body, kTagMsgSeqNum, seq); + append_field(body, kTagClOrdID, msg.order_id); + append_field(body, kTagSymbol, static_cast(msg.symbol)); + append_field(body, kTagSide, msg.side == Side::Buy ? "1" : "2"); + append_field(body, kTagOrderQty, static_cast(msg.quantity)); + append_field(body, kTagOrdType, msg.type == OrderType::Market ? "1" : "2"); + // Price is integer ticks (never a float) and is always present, so the FIX + // and binary encodings are both lossless bijections over NewOrder. + append_field(body, kTagPrice, std::to_string(static_cast(msg.price))); + append_field(body, kTagTimeInForce, msg.tif == TimeInForce::GTC ? "1" : "3"); + return frame(body); +} + +std::string encode(const CancelOrder &msg, SeqNo seq) { + std::string body; + append_field(body, kTagMsgType, std::string_view(&kMsgOrderCancelRequest, 1)); + append_field(body, kTagMsgSeqNum, seq); + // OrigClOrdID identifies the order to cancel; ClOrdID is the (required) id of + // the cancel request itself. CancelOrder carries only one id, so both echo it. + append_field(body, kTagOrigClOrdID, msg.order_id); + append_field(body, kTagClOrdID, msg.order_id); + append_field(body, kTagSymbol, static_cast(msg.symbol)); + return frame(body); +} + +// Shared typed-decode skeleton: validate the envelope, confirm MsgType, then let +// `fill` read the body fields through a FieldReader (which short-circuits on the +// first error). Keeps the two public decoders to just their field maps. +template +[[nodiscard]] FixDecodeResult decode_typed(std::string_view msg, char expected, + Fill fill) noexcept { + Parsed p; + if (const FixError e = parse_envelope(msg, p); e != FixError::None) { + return {e, {}}; + } + if (const FixError e = expect_msg_type(p, expected); e != FixError::None) { + return {e, {}}; + } + T out{}; + FieldReader reader(p); + fill(reader, out); + if (reader.error() != FixError::None) { + return {reader.error(), {}}; + } + return {FixError::None, out}; +} + +FixDecodeResult decode_new_order(std::string_view msg) noexcept { + return decode_typed(msg, kMsgNewOrderSingle, [](FieldReader &r, NewOrder &o) { + static constexpr std::array, 2> sides{ + {{'1', Side::Buy}, {'2', Side::Sell}}}; + static constexpr std::array, 2> types{ + {{'1', OrderType::Market}, {'2', OrderType::Limit}}}; + static constexpr std::array, 2> tifs{ + {{'1', TimeInForce::GTC}, {'3', TimeInForce::IOC}}}; + SeqNo seq = 0; // tag 34 (standard header); validated but not stored. + r.integer(kTagMsgSeqNum, seq) + .integer(kTagClOrdID, o.order_id) + .integer(kTagSymbol, o.symbol) + .integer(kTagOrderQty, o.quantity) + .integer(kTagPrice, o.price) + .coded(kTagSide, o.side, sides) + .coded(kTagOrdType, o.type, types) + .coded(kTagTimeInForce, o.tif, tifs); + }); +} + +FixDecodeResult decode_cancel_order(std::string_view msg) noexcept { + return decode_typed( + msg, kMsgOrderCancelRequest, [](FieldReader &r, CancelOrder &o) { + SeqNo seq = 0; // tag 34 + OrderId clord_id = 0; // tag 11 (ClOrdID): required by FIX, validated but not stored. + r.integer(kTagMsgSeqNum, seq) + .integer(kTagOrigClOrdID, o.order_id) + .integer(kTagClOrdID, clord_id) + .integer(kTagSymbol, o.symbol); + }); +} + +FixDecodeResult peek_msg_type(std::string_view msg) noexcept { + Parsed p; + if (const FixError e = parse_envelope(msg, p); e != FixError::None) { + return {e, {}}; + } + const Field *type = find_field(p, kTagMsgType); + if (type == nullptr || type->value.size() != 1) { + return {FixError::UnknownMsgType, {}}; + } + return {FixError::None, type->value.front()}; +} + +FixDecodeResult peek_seq(std::string_view msg) noexcept { + Parsed p; + if (const FixError e = parse_envelope(msg, p); e != FixError::None) { + return {e, {}}; + } + SeqNo seq = 0; + if (const FixError e = require_int(p, kTagMsgSeqNum, seq); e != FixError::None) { + return {e, {}}; + } + return {FixError::None, seq}; +} + +} // namespace qsl::protocol::fix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cb617a9..45669f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,7 +15,7 @@ foreach(t test_smoke test_types test_clock test_protocol test_order_book test_ma test_risk_gateway test_market_data test_event_log test_replay test_session test_tcp_gateway test_epoll_gateway test_md_feed test_invariants test_fuzz_protocol test_fixture_export test_shrink test_oracle_selftest test_reject_coverage test_spsc_ring - test_order_pool) + test_order_pool test_fix_protocol) add_executable(${t} unit/${t}.cpp) target_link_libraries(${t} PRIVATE qsl_core qsl_warnings Catch2::Catch2WithMain Threads::Threads) catch_discover_tests(${t}) diff --git a/tests/unit/test_fix_protocol.cpp b/tests/unit/test_fix_protocol.cpp new file mode 100644 index 0000000..1a6f5d2 --- /dev/null +++ b/tests/unit/test_fix_protocol.cpp @@ -0,0 +1,293 @@ +#include "qsl/protocol/codec.hpp" +#include "qsl/protocol/fix.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace qsl::protocol; + +namespace { + +constexpr char SOH = '\x01'; + +NewOrder sample_new_order() { + return NewOrder{/*order_id=*/1, /*symbol=*/2, /*price=*/12345, /*quantity=*/10, + Side::Buy, OrderType::Limit, TimeInForce::GTC}; +} + +// Build a complete FIX message from a body (the fields from tag 35 onward), +// computing BodyLength (tag 9) and the mod-256 CheckSum (tag 10). Lets a test +// construct messages with missing/invalid body fields that encode() never emits. +std::string wrap(const std::string &body) { + std::string head = "8="; + head += std::string(fix::kBeginString) + SOH; + head += "9=" + std::to_string(body.size()) + SOH; + head += body; + unsigned sum = 0; + for (const char c : head) { + sum += static_cast(c); + } + const unsigned mod = sum % 256u; + char cs[4]; + cs[0] = static_cast('0' + ((mod / 100) % 10)); + cs[1] = static_cast('0' + ((mod / 10) % 10)); + cs[2] = static_cast('0' + (mod % 10)); + cs[3] = '\0'; + head += "10="; + head += cs; + head += SOH; + return head; +} + +std::string field(unsigned tag, std::string_view value) { + return std::to_string(tag) + "=" + std::string(value) + SOH; +} + +void require_same(const NewOrder &a, const NewOrder &b) { + REQUIRE(a.order_id == b.order_id); + REQUIRE(a.symbol == b.symbol); + REQUIRE(a.price == b.price); + REQUIRE(a.quantity == b.quantity); + REQUIRE(a.side == b.side); + REQUIRE(a.type == b.type); + REQUIRE(a.tif == b.tif); +} + +} // namespace + +TEST_CASE("FIX NewOrder encode/decode round-trips", "[fix]") { + const NewOrder in = sample_new_order(); + const std::string msg = fix::encode(in, /*seq=*/7); + + const auto type = fix::peek_msg_type(msg); + REQUIRE(type.ok()); + REQUIRE(type.value == fix::kMsgNewOrderSingle); + + const auto seq = fix::peek_seq(msg); + REQUIRE(seq.ok()); + REQUIRE(seq.value == 7); + + const auto out = fix::decode_new_order(msg); + REQUIRE(out.ok()); + require_same(out.value, in); +} + +TEST_CASE("FIX CancelOrder encode/decode round-trips", "[fix]") { + const CancelOrder in{/*order_id=*/42, /*symbol=*/3}; + const std::string msg = fix::encode(in, /*seq=*/99); + + REQUIRE(fix::peek_msg_type(msg).value == fix::kMsgOrderCancelRequest); + REQUIRE(fix::peek_seq(msg).value == 99); + + const auto out = fix::decode_cancel_order(msg); + REQUIRE(out.ok()); + REQUIRE(out.value.order_id == 42); + REQUIRE(out.value.symbol == 3); +} + +TEST_CASE("FIX and binary codecs decode to identical NewOrder structs", "[fix]") { + // The strong invariant: two independent wire formats, one internal model. + for (const Side side : {Side::Buy, Side::Sell}) { + for (const OrderType type : {OrderType::Limit, OrderType::Market}) { + for (const TimeInForce tif : {TimeInForce::GTC, TimeInForce::IOC}) { + NewOrder in = sample_new_order(); + in.side = side; + in.type = type; + in.tif = tif; + + const std::vector bin = encode(in, /*seq=*/7); + const std::string fixmsg = fix::encode(in, /*seq=*/7); + + const auto bin_out = decode_new_order({bin.data(), bin.size()}); + const auto fix_out = fix::decode_new_order(fixmsg); + REQUIRE(bin_out.ok()); + REQUIRE(fix_out.ok()); + require_same(bin_out.value, fix_out.value); + } + } + } +} + +TEST_CASE("FIX side/ord-type/tif codes map both directions", "[fix]") { + NewOrder in = sample_new_order(); + in.side = Side::Sell; + in.type = OrderType::Market; + in.tif = TimeInForce::IOC; + const std::string msg = fix::encode(in, 1); + // 54=2 (Sell), 40=1 (Market), 59=3 (IOC). + REQUIRE(msg.find(field(54, "2")) != std::string::npos); + REQUIRE(msg.find(field(40, "1")) != std::string::npos); + REQUIRE(msg.find(field(59, "3")) != std::string::npos); + + const auto out = fix::decode_new_order(msg); + REQUIRE(out.ok()); + require_same(out.value, in); +} + +TEST_CASE("FIX deterministic fixture pins the wire format", "[fix]") { + const std::string msg = fix::encode(sample_new_order(), /*seq=*/7); + // Built with explicit SOH so the byte sequence (and the pinned BodyLength 50 + // and CheckSum 164) are unambiguous — a "\x01..." literal would greedily + // swallow the following digits into one hex escape. + const std::string S(1, SOH); + const std::string expected = "8=FIX.4.2" + S + "9=50" + S + "35=D" + S + "34=7" + S + "11=1" + + S + "55=2" + S + "54=1" + S + "38=10" + S + "40=2" + S + + "44=12345" + S + "59=1" + S + "10=164" + S; + REQUIRE(msg == expected); +} + +TEST_CASE("FIX malformed framing rejects deterministically", "[fix]") { + REQUIRE(fix::decode_new_order("").error == fix::FixError::Malformed); + REQUIRE(fix::decode_new_order("not fix at all").error == fix::FixError::Malformed); + // A field with no '=' before its SOH. + REQUIRE(fix::decode_new_order(std::string("8=FIX.4.2") + SOH + "garbage" + SOH).error == + fix::FixError::Malformed); + // A non-numeric tag. + REQUIRE(fix::decode_new_order(std::string("8=FIX.4.2") + SOH + "x=1" + SOH).error == + fix::FixError::Malformed); + // Last field is not the checksum (tag 10). + REQUIRE(fix::decode_new_order(std::string("8=FIX.4.2") + SOH + "9=0" + SOH).error == + fix::FixError::Malformed); +} + +TEST_CASE("FIX MsgType must be the first body field", "[fix]") { + // 8/9/34/35/.../10 — every required NewOrder field is present, but MsgType + // (35) does not immediately follow BodyLength. A first-match scan would still + // decode this; the standard envelope requires 35 first, so it is malformed. + std::string body = field(34, "1") + field(35, "D") + field(11, "1") + field(55, "2") + + field(54, "1") + field(38, "10") + field(40, "2") + field(44, "100") + + field(59, "1"); + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::Malformed); +} + +TEST_CASE("FIX duplicate tag rejects deterministically", "[fix]") { + // Symbol (55) repeated. First-wins parsing would silently take 2 and ignore + // 999; with no repeating groups, the frame is ambiguous and rejected. + std::string body = field(35, "D") + field(34, "1") + field(11, "1") + field(55, "2") + + field(55, "999") + field(54, "1") + field(38, "10") + field(40, "2") + + field(44, "100") + field(59, "1"); + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::Malformed); +} + +TEST_CASE("FIX oversized message rejects", "[fix]") { + std::string body = field(35, "D"); + body += field(34, "1"); + body += "55="; + body += std::string(fix::kMaxMessageLen, '9'); + body += SOH; + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::Malformed); +} + +TEST_CASE("FIX unsupported BeginString rejects", "[fix]") { + std::string msg = fix::encode(sample_new_order(), 1); + const auto pos = msg.find("FIX.4.2"); + REQUIRE(pos != std::string::npos); + msg.replace(pos, 7, "FIX.4.4"); // same width keeps BodyLength valid + const auto out = fix::decode_new_order(msg); + REQUIRE(out.error == fix::FixError::UnsupportedBeginString); +} + +TEST_CASE("FIX unknown / wrong message type rejects", "[fix]") { + // An unknown MsgType. + const std::string unknown = wrap(field(35, "X") + field(34, "1")); + REQUIRE(fix::peek_msg_type(unknown).value == 'X'); + REQUIRE(fix::decode_new_order(unknown).error == fix::FixError::UnknownMsgType); + + // A valid NewOrder decoded as a cancel rejects on type. + const std::string neworder = fix::encode(sample_new_order(), 1); + REQUIRE(fix::decode_cancel_order(neworder).error == fix::FixError::UnknownMsgType); +} + +TEST_CASE("FIX body-length mismatch rejects", "[fix]") { + std::string msg = fix::encode(sample_new_order(), 1); + const auto pos = msg.find("9=50"); + REQUIRE(pos != std::string::npos); + msg[pos + 3] = '1'; // declared 50 -> 51, actual body unchanged + REQUIRE(fix::decode_new_order(msg).error == fix::FixError::BodyLengthMismatch); +} + +TEST_CASE("FIX checksum mismatch rejects", "[fix]") { + std::string msg = fix::encode(sample_new_order(), 1); + REQUIRE(msg.size() >= 4); + char &last_digit = msg[msg.size() - 2]; // the final digit before the trailing SOH + last_digit = (last_digit == '9') ? '0' : static_cast(last_digit + 1); + REQUIRE(fix::decode_new_order(msg).error == fix::FixError::ChecksumMismatch); +} + +TEST_CASE("FIX missing required field rejects", "[fix]") { + // A NewOrder body lacking Symbol (tag 55). + std::string body = field(35, "D") + field(34, "1") + field(11, "1") + field(54, "1") + + field(38, "10") + field(40, "2") + field(44, "100") + field(59, "1"); + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::MissingField); +} + +TEST_CASE("FIX cancel without required ClOrdID rejects", "[fix]") { + // OrderCancelRequest lacking ClOrdID (tag 11), which FIX requires. + std::string body = field(35, "F") + field(34, "1") + field(41, "42") + field(55, "3"); + REQUIRE(fix::decode_cancel_order(wrap(body)).error == fix::FixError::MissingField); +} + +TEST_CASE("FIX invalid integer field rejects", "[fix]") { + std::string body = field(35, "D") + field(34, "1") + field(11, "1") + field(55, "2") + + field(54, "1") + field(38, "abc") + field(40, "2") + field(44, "100") + + field(59, "1"); + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::InvalidField); +} + +TEST_CASE("FIX invalid enum code rejects", "[fix]") { + std::string body = field(35, "D") + field(34, "1") + field(11, "1") + field(55, "2") + + field(54, "9") + field(38, "10") + field(40, "2") + field(44, "100") + + field(59, "1"); + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::InvalidEnumValue); +} + +TEST_CASE("FIX signed price round-trips including int64 extremes", "[fix]") { + for (const Price p : + {Price{-1}, std::numeric_limits::min(), std::numeric_limits::max()}) { + NewOrder in = sample_new_order(); + in.price = p; + const auto out = fix::decode_new_order(fix::encode(in, /*seq=*/5)); + REQUIRE(out.ok()); + REQUIRE(out.value.price == p); + } +} + +TEST_CASE("FIX overflowing a field reports OutOfRange", "[fix]") { + // Symbol (tag 55) is uint32; a value past its max is OutOfRange, not Invalid. + std::string body = field(35, "D") + field(34, "1") + field(11, "1") + field(55, "4294967296") + + field(54, "1") + field(38, "10") + field(40, "2") + field(44, "100") + + field(59, "1"); + REQUIRE(fix::decode_new_order(wrap(body)).error == fix::FixError::OutOfRange); +} + +TEST_CASE("FIX large order id and seq round-trip", "[fix]") { + NewOrder in = sample_new_order(); + in.order_id = std::numeric_limits::max(); + const SeqNo seq = std::numeric_limits::max(); + const std::string msg = fix::encode(in, seq); + REQUIRE(fix::peek_seq(msg).value == seq); + REQUIRE(fix::decode_new_order(msg).value.order_id == in.order_id); +} + +TEST_CASE("FIX errors stringify deterministically", "[fix]") { + using fix::FixError; + using fix::to_string; + REQUIRE(std::string_view{to_string(FixError::None)} == "None"); + REQUIRE(std::string_view{to_string(FixError::Malformed)} == "Malformed"); + REQUIRE(std::string_view{to_string(FixError::UnsupportedBeginString)} == + "UnsupportedBeginString"); + REQUIRE(std::string_view{to_string(FixError::UnknownMsgType)} == "UnknownMsgType"); + REQUIRE(std::string_view{to_string(FixError::MissingField)} == "MissingField"); + REQUIRE(std::string_view{to_string(FixError::InvalidField)} == "InvalidField"); + REQUIRE(std::string_view{to_string(FixError::BodyLengthMismatch)} == "BodyLengthMismatch"); + REQUIRE(std::string_view{to_string(FixError::ChecksumMismatch)} == "ChecksumMismatch"); + REQUIRE(std::string_view{to_string(FixError::InvalidEnumValue)} == "InvalidEnumValue"); + REQUIRE(std::string_view{to_string(FixError::OutOfRange)} == "OutOfRange"); + REQUIRE(std::string_view{to_string(static_cast(255))} == "Unknown"); +}