Skip to content

Commit c07f350

Browse files
div0rceclaude
andauthored
feat: FIX-like text protocol adapter (#29) (#131)
* docs: sync resume anchors and PMU claims to v0.2.0 (Codex #127/#128 follow-up) Resolve the Codex review findings left on `main` by PRs #127 and #128: - PROGRESS.md: remove the stale "Next action remains" block that still steered /resume to the merged PR #125 on `perf/linux-host-artifact-refresh`; replace with the v0.2.0 between-releases state (no active milestone; #94/#90 gated). - AGENTS.md: bring it into sync with CLAUDE.md's v0.2.0 partial-PMU reframe. The constraints bullet, the "correct claim" block, and the "M29 perf evidence status" subsection no longer label the artifacts "constrained Docker validation"; the stale `perf/linux-host-artifact-refresh` follow-up line is updated (also in CLAUDE.md) to the released state. - docs/perf_analysis.md: narrow the PMU claim so it no longer implies the Apple Blizzard (E-core) PMU carries live counts. The `apple_blizzard_pmu/...` rows read `<not counted>` in results/perf_stat_linux.txt because the single-threaded benchmark stays on the Avalanche P-cores — expected scheduling, not a counter. Docs/memory only; no code or artifacts changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: add flamegraph generator and make target (#32) Add `make flamegraph`, the missing-flamegraph follow-up tracked by issue #32. The perf stat/record text workflow already existed; this renders a perf call-graph flamegraph. - scripts/flamegraph.sh: records `perf record --call-graph dwarf -F 4000 -g -e cpu-clock` on qsl-bench and writes results/flamegraph.svg plus a results/flamegraph.txt provenance/classification companion (top folded stacks). Mirrors perf_record.sh: Linux-only, reuses qsl_common.sh provenance + qsl_publish_artifact, and honours QSL_PERF_ALLOW_PARTIAL for constrained hosts. DWARF call graphs unwind correctly despite the Release `bench` preset omitting frame pointers. - scripts/flamegraph.py: dependency-free (stdlib-only) stackcollapse + SVG renderer, so the artifact is reproducible from the repo without vendoring the Perl FlameGraph toolkit. Deterministic: frames sorted by name, colors a pure function of the name, no RNG/timestamps in the drawn body. - tests/shell/test_flamegraph.sh: CTest-registered (python3-only, skips cleanly if absent) — folding (offset/dso stripping, perf-order reversal, comm-at-base, count aggregation, sortedness), SVG well-formedness, XML escaping, determinism, empty-input handling. - docs (perf_analysis.md, results/README.md), command lists (CLAUDE.md, AGENTS.md), MILESTONES.md backlog, PROGRESS.md log. `make check` 242/242. Full hardware cache-PMU evidence stays in #90. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: add generated flamegraph artifact on bare-metal Fedora Asahi (#32) results/flamegraph.svg + results/flamegraph.txt generated by `make flamegraph` from the clean committed tree on the bare-metal Apple M2 (aarch64) Fedora Asahi host: 397 cpu-clock samples, 171 folded stacks, `Dirty inputs: no`. The hot paths resolve to real engine symbols (OrderBook::modify/cancel/add_limit, the dispatch_storage cancel path, decode_new_order, the gateway Session path, replay::generate_flow). Software cpu-clock sampling hot-symbol profile — not a latency/throughput claim; full hardware cache-PMU evidence stays in #90. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: add FIX-like text protocol adapter (#29) Add a human-readable `tag=value` text protocol alongside the binary codec, mapping the same internal message structs (issue #29, reprioritized by the human from the backlog). - include/qsl/protocol/fix.hpp + src/protocol/fix.cpp: SOH-framed `tag=value` adapter with genuine FIX framing — 8 BeginString / 9 BodyLength / 35 MsgType / ... / 10 mod-256 CheckSum — for the client->gateway order path: NewOrderSingle (35=D) -> NewOrder, OrderCancelRequest (35=F) -> CancelOrder. Decoding is total, deterministic, and noexcept (fixed field table, std::from_chars, std::string_view; no heap on the decode path) and reports every malformed input through a FixError taxonomy mirroring the binary codec's DecodeError. Documented, deliberate simplifications: Symbol (55) carries the numeric SymbolId; Price (44) carries integer ticks and is always present, so NewOrder<->FIX is a lossless bijection like the binary codec (price is never a float). - 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 x OrdType x TIF), a byte-pinned fixture, 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 messages. - docs/fix_protocol.md (+ pointer from docs/binary_protocol.md); MILESTONES.md and PROGRESS.md updated. make check 260/260; make asan 260/260 (the adapter parses untrusted text). Closes #29. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: harden flamegraph collapsed-stack parsing (Codex review) Address two Codex review findings in scripts/flamegraph.py::parse_collapsed: - Prefer a tab separator when present so a tab-separated folded line whose stack contains spaces (C++ signatures) splits on the trailing count instead of an interior space and is silently dropped. - Ignore non-positive sample counts, so hand-crafted --from-collapsed input with 0/negative counts cannot render a misleading SVG (all-non-positive input now fails with exit 1 via the existing empty-folded guard). Adds test coverage in tests/shell/test_flamegraph.sh (19/19). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: regenerate flamegraph artifact after parser hardening flamegraph.py is a provenance input, so regenerate results/flamegraph.svg + .txt from the clean tree to keep the Source digest consistent (423 samples, Dirty inputs: no). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: enforce FIX-required ClOrdID on OrderCancelRequest (Codex review) decode_cancel_order validated only OrigClOrdID (41) and Symbol (55), so a 35=F message missing ClOrdID (tag 11) — which FIX requires and encode() emits — was accepted. Validate tag 11 (present and numeric) on decode without storing it, keeping decode symmetric with encode. Adds a rejection test and clarifies the docs/fix_protocol.md note for tag 11. make check 261/261; make asan 261/261. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: improve flamegraph.py code health (CodeScene gate) CodeScene's delta gate scored scripts/flamegraph.py at 7.81 (render_svg: Large Method + Excess Arguments + complexity; fold_perf_script: Bumpy Road / nested complexity). Restructure without changing output: - fold_perf_script: move per-line state into a small _Folder helper so the parsing loop is a flat if/elif/else instead of a nested block. - render_svg: bundle styling knobs into a FlameOptions dataclass (2 args, was 7) and extract _append_chrome, _frame_svg, _truncate; geometry constants (_SIDE/_PAD_TOP/_PAD_BOTTOM) hoisted to module scope and a _Canvas dataclass carries derived geometry. Emitted SVG/collapsed bytes are unchanged; tests/shell/test_flamegraph.sh 19/19. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: regenerate flamegraph artifact after code-health refactor flamegraph.py is a provenance input; regenerate results/flamegraph.svg + .txt from the clean tree (402 samples, Dirty inputs: no). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: reduce decode_new_order complexity in fix.cpp (CodeScene gate) CodeScene's delta gate scored src/protocol/fix.cpp at 8.02 (decode_new_order: Complex Method / Complex Conditional / Overall Code Complexity). Restructure without changing behavior: - Extract map_side / map_ord_type / map_tif (the enum-code switches) and expect_msg_type (the tag-35 check) as small helpers. - Add a FieldReader accumulator that reads required integer/coded fields and short-circuits on the first error, so decode_new_order and decode_cancel_order become a flat fluent chain plus a single error check instead of a long if-return ladder with three inline switches. make check 261/261; make asan 261/261; FIX tests 19 cases / 140 assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: flatten flamegraph.py remaining complexity (CodeScene) Clear the last two CodeScene flags on scripts/flamegraph.py: - _clean_symbol: replace the balanced-paren dso scan (a deep nested loop) with a flat regex _DSO_RE. perf prints a space before the "(dso)" and dso strings never contain parens, so a non-nested " (...)$" match is exact and won't strip a C++ signature's own parentheses. - _layout: drop the unused `total` parameter (5 args -> 4). Output unchanged; tests/shell/test_flamegraph.sh 19/19. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: regenerate flamegraph artifact after complexity flattening Provenance input changed; regenerate from clean tree (416 samples, Dirty inputs: no). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: remove decoder duplication and split parse_envelope (CodeScene) Clear the remaining CodeScene flags on src/protocol/fix.cpp (Code Duplication, Complex Method, Complex Conditional): - Extract a decode_typed<T> skeleton (validate envelope -> confirm MsgType -> fill body via FieldReader -> error check) so decode_new_order and decode_cancel_order collapse to just their field maps, removing the duplicated prologue/epilogue. - Split parse_envelope into tokenize / check_envelope_shape / verify_length_and_checksum, each a small single-purpose function, and fold the 8/9/10 ordering check into a named bool. Behavior unchanged: make check 261/261, make asan 261/261, FIX tests 19 cases / 140 assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: table-driven enum maps + simpler msg-type check (CodeScene) Clear the last two CodeScene advisory flags on src/protocol/fix.cpp: - Code Duplication: the three near-identical map_side/map_ord_type/map_tif switches are replaced by one generic FieldReader::coded(tag, out, table) that looks a code up in a small constexpr {code, enum} table; the decoder supplies the per-enum tables. No per-enum mapping duplication remains. - Complex Conditional: expect_msg_type's 3-term `||` is split so each branch has a single operator. make check 261/261; make asan 261/261; FIX tests 19 cases / 140 assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: record resume-anchor sync in PROGRESS current-state (Codex #129) The decision-log entry and bottom "Next action remains" block already covered this follow-up, but the top `## Current state` bullets — the first thing `/resume` reads — still presented the v0.2.0 release (PR #127) as the "Last action". A resuming agent could therefore miss that this resume-anchor / PMU sync already happened and duplicate it. Record the sync as the current "Last action" and "Last completed docs sync", demoting the v0.2.0 release detail to a "Prior action" bullet. Docs-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: harden flamegraph.sh classification + sample gating (Codex #130) Address five Codex review findings on the flamegraph driver: 1. Classify `zero-sized data` (perf script's no-sample report) as a perf limitation, matching scripts/perf_record.sh, so the documented QSL_PERF_ALLOW_PARTIAL=1 constrained-host path works instead of tripping the unexpected-failure exit. 2. Remove any prior results/flamegraph.svg when a partial run captures no folded stacks, so a constrained rerun cannot leave a previous host's SVG beside a .txt that says there is no sample report. 3. Accept perf's `(~N samples)` estimate marker (optional `~`), and base the minimum-sample gate on the authoritative folded sample total rather than perf record's self-described estimate. Report both counts. 4. Capture flamegraph.py --collapse-only's exit status instead of `|| true`; a renderer/parser failure now exits 4 (unmaskable) rather than being published as a constrained-environment artifact. 5. Derive the sampling-kind label/caveat from the selected event (software cpu-clock/task-clock vs hardware-PMU) so the artifact type, SVG comment, and text companion stay consistent for QSL_FLAMEGRAPH_EVENT=cycles etc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: regenerate flamegraph artifact after classification hardening Bare-metal Apple M2 (aarch64) Fedora Asahi, cpu-clock @ 4000Hz: 329 folded samples / 159 stacks, classified `flamegraph (software cpu-clock sampling hot-symbol profile)`, `Dirty inputs: no`. Source digest now covers the hardened scripts/flamegraph.sh; the .txt reports both the folded total and perf record's estimate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: enforce FIX envelope MsgType position + reject duplicate tags (Codex #131) Two Codex review findings on the FIX adapter's parser strictness: - check_envelope_shape now requires MsgType (35) as the first body field, immediately after BodyLength, so a non-standard header like 8/9/34/35/.../10 is rejected as Malformed instead of decoding via a first-match scan. - tokenize now rejects any repeated tag. This adapter maps each business tag exactly once (no repeating groups), so a duplicate such as `55=2` then `55=999` is an ambiguous/malformed frame rather than a silently-ignored later value. Adds a deterministic rejection test for each. (The earlier ClOrdID-required finding was already resolved by 3e4c8e3 and is covered by an existing test.) make check 263/263, make asan 263/263. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: drop delivered #29 from open-backlog anchors (Codex #131) The decision log marks #29 (FIX adapter) closed by this PR, but the current-state / resume anchors in PROGRESS.md and HANDOFF.md still listed it as open backlog, so /resume could send the next session to re-implement work this PR just added. Remove #29 from those backlog lists and note it as delivered in this PR. (#32 stays listed here; the v0.2.1 release PR removes it as part of its release sweep.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: scope partial-PMU claim to perf-stat; perf-record is a software profile (Codex #129) The constraints bullet labeled all perf artifacts as partial hardware PMU evidence, but only results/perf_stat_linux.txt carries real PMU counters (cycles/instructions/branches/branch-misses). results/perf_report_linux.txt is a software cpu-clock sampling profile, not PMU evidence. Scope the claim to the perf-stat artifact and call out perf-record separately, identically in AGENTS.md and CLAUDE.md so the two memories stay in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: embed the flamegraph as a visible image in the README The flamegraph artifact, generator, provenance companion, and docs already existed but no page actually displayed the SVG — it was only referenced by filename. Embed the rendered results/flamegraph.svg as a visible image under the Benchmarks section, with a caption that classifies it honestly as a software cpu-clock sampling hot-symbol profile (not PMU evidence), names the hot frames, and links the provenance .txt and docs/perf_analysis.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 31d16f6 commit c07f350

10 files changed

Lines changed: 938 additions & 13 deletions

File tree

CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ include(cmake/ProjectOptions.cmake)
1010
include(cmake/CompilerWarnings.cmake)
1111
include(cmake/Sanitizers.cmake)
1212

13-
add_library(qsl_core src/core/types.cpp src/protocol/codec.cpp src/engine/order_book.cpp
13+
add_library(qsl_core src/core/types.cpp src/protocol/codec.cpp src/protocol/fix.cpp
14+
src/engine/order_book.cpp
1415
src/engine/matching_engine.cpp src/engine/risk.cpp
1516
src/gateway/order_gateway.cpp src/feed/market_data.cpp
1617
src/feed/publisher.cpp src/replay/event_log.cpp src/replay/command.cpp

HANDOFF.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,16 @@ Current state:
8282
provides real cycles/instructions/branches/branch-misses but no cache-reference/cache-miss support
8383
- issues #95, #28, and #26 were closed by PR #112
8484
- open review request issue: #94
85-
- legacy backlog still open: #29 and #32
85+
- legacy backlog still open: #32 (#29 delivered in this PR, `feat/fix-text-protocol-adapter`)
8686

8787
### Next milestone
8888

8989
There is no active milestone. M0–M49, the Linux artifact refresh (PR #125), and the v0.2.0 release
9090
(PR #127) are merged. The highest-value remaining work is non-code and externally gated: issue #94
9191
(independent external review — needs a human reviewer) and issue #90 (full cache-counter PMU
92-
evidence — needs a PMU microarchitecture that exposes cache events). Low-signal backlog: #32
93-
(flamegraph) and #29 (FIX adapter). Do not invent a new milestone without an explicit human request.
92+
evidence — needs a PMU microarchitecture that exposes cache events). #29 (FIX-like text protocol
93+
adapter) is delivered in this PR; low-signal backlog: #32 (flamegraph). Do not invent a new
94+
milestone without an explicit human request.
9495

9596
### Phase III / IV purpose
9697

@@ -107,7 +108,8 @@ Current priority order (post-v0.2.0):
107108
2. Issue #90 — full cache-counter PMU evidence. The bare-metal Apple host gives real
108109
cycles/instructions/branches/branch-misses but no cache-reference/cache-miss counters, so this
109110
needs a PMU microarchitecture that exposes cache events (x86_64, or an ARM server core).
110-
3. Low-signal backlog only after the above: #32 (flamegraph), #29 (FIX adapter).
111+
3. Low-signal backlog only after the above: #32 (flamegraph). #29 (FIX adapter) is delivered in
112+
this PR (`feat/fix-text-protocol-adapter`).
111113

112114
### Forbidden shortcuts
113115

MILESTONES.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,10 +469,11 @@ Sequential, dependency-ordered. **Build them in order.** Each milestone is one f
469469
> perf/flamegraph). M25 (memory-ordering evidence), M30 (socket profiling/hardening), and M31
470470
> (external review) are new milestones with no prior issue. PR #112 closed
471471
> the remaining tractable systems items **#26** (portable TCP serving beyond one-connection-at-a-time
472-
> accept) and **#28** (realistic synthetic order-flow model). The genuinely **deferred** product/API
473-
> items remain **#29** (FIX adapter), **#30** (web dashboard), **#31** (Docker packaging), and
474-
> **#33** (Pages site) — do not start them before the Phase III/IV systems roadmap unless the human
475-
> explicitly reprioritizes.
472+
> accept) and **#28** (realistic synthetic order-flow model). The human later reprioritized two
473+
> backlog items, now **done**: **#32** (perf/flamegraph) and **#29** (FIX-like text protocol
474+
> adapter). The genuinely **deferred** product/API items remain **#30** (web dashboard), **#31**
475+
> (Docker packaging), and **#33** (Pages site) — do not start them before the Phase III/IV systems
476+
> roadmap unless the human explicitly reprioritizes.
476477
477478
Do not pull backlog items into earlier PRs.
478479

@@ -481,7 +482,11 @@ Do not pull backlog items into earlier PRs.
481482
- Multithreaded gateway and market data pipeline, plus portable threaded TCP serving follow-up. (#26)
482483
- ThreadSanitizer coverage. (#27)
483484
- More realistic synthetic order-flow model. (#28)
484-
- FIX-like text protocol adapter. (#29)
485+
- FIX-like text protocol adapter. (#29) — **done**: `tag=value` SOH-framed adapter
486+
(`include/qsl/protocol/fix.hpp`, `src/protocol/fix.cpp`) over the same internal structs as the
487+
binary codec, with genuine FIX BeginString/BodyLength/CheckSum framing for NewOrderSingle (35=D)
488+
and OrderCancelRequest (35=F). Cross-codec equivalence + malformed-input rejection tested in
489+
`tests/unit/test_fix_protocol.cpp`; docs in `docs/fix_protocol.md`.
485490
- Web dashboard for visualization. (#30)
486491
- Docker packaging. (#31)
487492
- Perf/flamegraph docs. (#32) — **done**: `make flamegraph` renders a perf call-graph flamegraph

PROGRESS.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,16 @@ Do not rely on prior chat memory.
5252
- **Next action:** no active milestone. Highest-value remaining work is non-code and gated:
5353
issue #94 (independent external review — needs a human reviewer) and issue #90 (full
5454
cache-counter PMU evidence — needs a PMU microarchitecture that exposes cache events, e.g.
55-
x86_64). Low-signal backlog: #32 (flamegraph), #29 (FIX adapter).
55+
x86_64). #29 (FIX-like text protocol adapter) is delivered in this PR
56+
(`feat/fix-text-protocol-adapter`). Low-signal backlog: #32 (flamegraph).
5657
- **Blockers:** issue #90 is now a *cache-counter* PMU gap, not a host-access gap — this bare-metal
5758
Apple M2 exposes real `cycles`/`instructions`/`branches`/`branch-misses` but its PMU does not
5859
implement `cache-references`/`cache-misses`; closing it needs a PMU microarchitecture that exposes
5960
cache events (x86_64, or an ARM server core). Issue #94 remains open for independent external
6061
review (human-gated). Hardware NIC/offload latency measurement still requires suitable wired NIC
6162
hardware, driver support, timestamping/offload/RSS access, and a measured packet workload; the
6263
current `wld0` Wi-Fi capability observation is not NIC-offload latency evidence. Legacy backlog
63-
still includes #32 and #29. Issues #95, #28, and #26 were closed by PR #112.
64+
still includes #32 (#29 delivered in this PR). Issues #95, #28, and #26 were closed by PR #112.
6465

6566
---
6667

@@ -386,6 +387,24 @@ Lower priority:
386387
the bare-metal Fedora Asahi host (aarch64) from the clean committed tree (`Dirty inputs: no`).
387388
This is a software cpu-clock sampling hot-symbol profile, not a latency/throughput claim; full
388389
hardware cache-PMU evidence stays in #90. Do not merge from automation; human squash-merges.
390+
- [2026-06-21] Issue #29 FIX-like text protocol adapter (`feat/fix-text-protocol-adapter`, stacked
391+
on the flamegraph branch). Added `include/qsl/protocol/fix.hpp` + `src/protocol/fix.cpp`: a
392+
`tag=value` SOH-framed adapter over the SAME internal structs as the binary codec, with genuine
393+
FIX framing (8 BeginString / 9 BodyLength / 35 MsgType / … / 10 mod-256 CheckSum) for the
394+
client→gateway order path — NewOrderSingle (35=D)→`NewOrder` and OrderCancelRequest
395+
(35=F)→`CancelOrder`. Decoding is total/deterministic/`noexcept` (fixed field table,
396+
`std::from_chars`, `string_view`; no heap on decode) and reports every malformed input through a
397+
`FixError` taxonomy mirroring the binary `DecodeError`. Documented, deliberate simplifications:
398+
Symbol (55) carries the numeric SymbolId; Price (44) carries integer ticks and is always present,
399+
making `NewOrder↔FIX` a lossless bijection like the binary codec (never float for price).
400+
`tests/unit/test_fix_protocol.cpp` mirrors the binary required tests and adds a **cross-codec
401+
equivalence** test (binary and FIX decode the same order to identical structs across all
402+
Side×OrdType×TIF), a byte-pinned fixture (checksum 164 / body-length 50), and rejection of
403+
malformed framing / unsupported BeginString / unknown-or-wrong MsgType / BodyLength mismatch /
404+
CheckSum mismatch / missing field / invalid field / invalid enum / out-of-range / oversized. Docs
405+
in `docs/fix_protocol.md` (+ pointer from `docs/binary_protocol.md`). `make check` 260/260 and
406+
`make asan` 260/260 clean (the parser handles untrusted text). Closes #29. Do not merge from
407+
automation; human squash-merges.
389408
- [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.
390409
- [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).
391410
- [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.

docs/binary_protocol.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,9 @@ a byte stream belong to the TCP/session layer (M9), not the codec.
8181

8282
The wire format is pinned by a byte-fixture test (`tests/unit/test_protocol.cpp`) so any
8383
accidental change to field order or byte order fails the build.
84+
85+
## Text alternative
86+
87+
A human-readable, FIX-like `tag=value` adapter over the same internal message structs lives
88+
alongside this binary codec — see [fix_protocol.md](fix_protocol.md). Both decode the same order to
89+
identical structs, which the tests assert directly.

docs/fix_protocol.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# FIX-like Text Protocol Adapter
2+
3+
A human-readable `tag=value` text protocol alongside the [binary protocol](binary_protocol.md),
4+
mapping the **same internal message structs**. Implemented in `include/qsl/protocol/fix.hpp` and
5+
`src/protocol/fix.cpp`; tested in `tests/unit/test_fix_protocol.cpp`. This is the optional FIX
6+
adapter tracked by issue #29.
7+
8+
It is **FIX-like**, not a full FIX engine: it implements the genuine FIX framing and the
9+
client→gateway order path, deliberately scoped to what mirrors the binary codec.
10+
11+
## Why it exists
12+
13+
Real venues speak FIX as well as binary protocols. Showing a second, independently-validated wire
14+
format over one internal model demonstrates a clean protocol boundary: the engine does not care
15+
which encoding produced a `NewOrder`. The strongest invariant the tests assert is that a binary
16+
frame and a FIX message for the same order **decode to identical internal structs**.
17+
18+
## Framing
19+
20+
A message is a sequence of `tag=value` fields, each terminated by the **SOH** byte (`0x01`). The
21+
adapter uses the standard FIX envelope:
22+
23+
```text
24+
8=FIX.4.2 | 9=<BodyLength> | 35=<MsgType> | <business fields...> | 10=<CheckSum> |
25+
```
26+
27+
(`|` denotes SOH.)
28+
29+
- **`8` BeginString** must be `FIX.4.2`; anything else is `UnsupportedBeginString`.
30+
- **`9` BodyLength** is the byte count from the field after tag 9 through the SOH before tag 10.
31+
A mismatch is `BodyLengthMismatch`.
32+
- **`10` CheckSum** is the mod-256 sum of every byte up to the SOH before tag 10, formatted as
33+
exactly three zero-padded digits. A mismatch is `ChecksumMismatch`.
34+
35+
## Messages
36+
37+
### NewOrderSingle (`35=D`) → `NewOrder`
38+
39+
| Tag | FIX name | Internal field | Encoding |
40+
|-----|---------------|----------------|----------|
41+
| 34 | MsgSeqNum | sequence no. | carried like the binary header `seq_no` (validated, not stored in the body struct) |
42+
| 11 | ClOrdID | `order_id` | decimal |
43+
| 55 | Symbol | `symbol` | decimal `SymbolId` (see simplifications) |
44+
| 54 | Side | `side` | `1`=Buy, `2`=Sell |
45+
| 38 | OrderQty | `quantity` | decimal |
46+
| 40 | OrdType | `type` | `1`=Market, `2`=Limit |
47+
| 44 | Price | `price` | integer ticks (see simplifications) |
48+
| 59 | TimeInForce | `tif` | `1`=GTC, `3`=IOC |
49+
50+
### OrderCancelRequest (`35=F`) → `CancelOrder`
51+
52+
| Tag | FIX name | Internal field | Notes |
53+
|-----|--------------|----------------|-------|
54+
| 34 | MsgSeqNum | sequence no. | as above |
55+
| 41 | OrigClOrdID | `order_id` | the order being cancelled |
56+
| 11 | ClOrdID || required by FIX; validated on decode, echoes `order_id` on encode (no separate cancel-request id is modelled) |
57+
| 55 | Symbol | `symbol` | decimal `SymbolId` |
58+
59+
## Deliberate simplifications
60+
61+
These are documented departures from strict FIX, chosen so the adapter stays a deterministic,
62+
lossless map onto the simulator's internal model:
63+
64+
- **Symbol (tag 55) carries the numeric `SymbolId`** in decimal, not a ticker string — the engine
65+
keys on `SymbolId`, so mapping to a string table would only add a lossy layer.
66+
- **Price (tag 44) carries integer ticks and is always present**, including market orders. The
67+
project never represents price as a float, and `NewOrder` always has a `price` field; carrying it
68+
losslessly makes `NewOrder ↔ FIX` a true bijection over the internal struct, exactly like the
69+
binary codec. (Strict FIX uses a decimal price and forbids tag 44 on market orders.)
70+
71+
## Error model
72+
73+
Decoding is total and deterministic: it never throws, allocates nothing on the decode path (a
74+
fixed field table, `std::from_chars`, `std::string_view`), and reports every malformed input via
75+
`FixError` rather than undefined behavior — mirroring the binary codec's `DecodeError` discipline.
76+
77+
`FixError`: `None`, `Malformed`, `UnsupportedBeginString`, `UnknownMsgType`, `MissingField`,
78+
`InvalidField`, `BodyLengthMismatch`, `ChecksumMismatch`, `InvalidEnumValue`, `OutOfRange`.
79+
80+
## Determinism and testing
81+
82+
`tests/unit/test_fix_protocol.cpp` mirrors the binary codec's required tests and adds FIX-specific
83+
ones:
84+
85+
- round-trip for NewOrderSingle and OrderCancelRequest;
86+
- **cross-codec equivalence**: binary and FIX decode the same order to identical structs across all
87+
Side × OrdType × TIF combinations;
88+
- a **byte-pinned fixture** (`8=FIX.4.2|9=50|35=D|…|10=164|`) so any change to field order or the
89+
checksum/body-length computation fails the build;
90+
- rejection of malformed framing, unsupported BeginString, unknown/wrong MsgType, BodyLength
91+
mismatch, CheckSum mismatch, missing required fields, non-numeric fields, invalid enum codes,
92+
out-of-range integers, and oversized messages;
93+
- signed/extreme `int64` price and `uint64` id/seq round-trips.
94+
95+
The adapter is also covered by the ASan/UBSan preset (`make asan`), since it parses untrusted text.

0 commit comments

Comments
 (0)