diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..0746edb55 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(go test:*)", + "Bash(python3:*)" + ] + } +} diff --git a/docs/superpowers/plans/2026-04-10-md-fix-fast-parser.md b/docs/superpowers/plans/2026-04-10-md-fix-fast-parser.md new file mode 100644 index 000000000..9f80131ae --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-md-fix-fast-parser.md @@ -0,0 +1,319 @@ +# Fast Path Parser for 35=W Messages — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Skip `Body.FieldMap` population for received `35=W` messages, exposing raw body bytes via `RawBody()` so the application can parse only the fields it needs. + +**Architecture:** Two additions to `message.go`: a one-line `RawBody()` accessor (exposes the already-populated private `bodyBytes` field) and a fast-path block in `doParsing` that mirrors the normal parse loop but omits `Body.add()` for `35=W` messages. All other behaviour (header, trailer, body-length validation) is unchanged. + +**Tech Stack:** Go, `github.com/quickfixgo/quickfix` (this repo), `testify/suite` + +--- + +## File Map + +| File | Change | +|------|--------| +| `message.go` | Add `RawBody()` method after `String()` (~line 583); add fast-path block in `doParsing` after line 228 | +| `message_test.go` | Add `TestRawBodyExposed` and `TestParseWFastPath` to `MessageSuite`; add `BenchmarkParseMessageW` | + +--- + +## Task 1 — Expose `RawBody()` with a failing test first + +**Files:** +- Modify: `message_test.go` +- Modify: `message.go` (~line 583) + +- [ ] **Step 1: Write the failing test** + +Add this method to `MessageSuite` in `message_test.go`, after `TestParseMessage`: + +```go +func (s *MessageSuite) TestRawBodyExposed() { + // Given a parsed non-W message (normal path) + rawMsg := bytes.NewBufferString("8=FIX.4.29=10435=D34=249=TW52=20140515-19:49:56.65956=ISLD11=10021=140=154=155=TSLA60=00010101-00:00:00.00010=039") + s.Nil(ParseMessage(s.msg, rawMsg)) + + // RawBody should return the body bytes already populated by doParsing + raw := s.msg.RawBody() + s.NotNil(raw, "RawBody should not be nil after parsing a message with body fields") + s.True(bytes.Contains(raw, []byte("55=TSLA")), "RawBody should contain tag 55, got: %s", string(raw)) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +go test -run TestMessageSuite/TestRawBodyExposed -v ./... +``` + +Expected: `FAIL` — `s.msg.RawBody undefined` + +- [ ] **Step 3: Add `RawBody()` to `message.go`** + +In `message.go`, after the closing brace of `String()` (after line 583), insert: + +```go +// RawBody returns the raw bytes of the message body as received on the wire. +// Populated for inbound messages; nil for outbound messages or messages with no body. +// For 35=W messages parsed with the fast path, Body.FieldMap is empty — use RawBody() instead. +func (m *Message) RawBody() []byte { + return m.bodyBytes +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +``` +go test -run TestMessageSuite/TestRawBodyExposed -v ./... +``` + +Expected: `PASS` + +- [ ] **Step 5: Commit** + +```bash +git add message.go message_test.go +git commit -m "feat: expose RawBody() accessor for raw body bytes" +``` + +--- + +## Task 2 — Write failing test for 35=W fast path + +**Files:** +- Modify: `message_test.go` + +- [ ] **Step 1: Add the failing fast-path test** + +Add this method to `MessageSuite` in `message_test.go`, after `TestRawBodyExposed`: + +```go +func (s *MessageSuite) TestParseWFastPath() { + // Build a valid 35=W message using normal construction so body length + // and checksum are computed automatically. + outMsg := NewMessage() + outMsg.Header.SetField(tagBeginString, FIXString("FIX.4.4")) + outMsg.Header.SetField(tagMsgType, FIXString("W")) + outMsg.Header.SetField(Tag(49), FIXString("EXCH")) // SenderCompID + outMsg.Header.SetField(Tag(56), FIXString("CLIENT")) // TargetCompID + outMsg.Header.SetField(Tag(34), FIXInt(1)) // MsgSeqNum + outMsg.Header.SetField(Tag(52), FIXString("20260410-10:00:00")) // SendingTime + outMsg.Body.SetField(Tag(55), FIXString("EURUSD")) // Symbol + outMsg.Body.SetField(Tag(268), FIXInt(2)) // NoMDEntries + outMsg.Body.SetField(Tag(269), FIXString("0")) // MDEntryType + outMsg.Body.SetField(Tag(270), FIXString("1.08500")) // MDEntryPx + outMsg.Body.SetField(Tag(271), FIXString("1000000")) // MDEntrySize + + rawBytes := bytes.NewBufferString(outMsg.String()) + + inMsg := NewMessage() + s.Nil(ParseMessage(inMsg, rawBytes)) + + // Fast path: Body FieldMap must be empty — no Body.add() calls for 35=W. + s.False(inMsg.Body.Has(Tag(55)), "Body FieldMap should be empty for 35=W (fast path); tag 55 should not be present") + s.False(inMsg.Body.Has(Tag(268)), "Body FieldMap should be empty for 35=W (fast path); tag 268 should not be present") + + // RawBody must contain the body fields as raw bytes. + rawBody := inMsg.RawBody() + s.NotNil(rawBody, "RawBody should not be nil for 35=W") + s.True(bytes.Contains(rawBody, []byte("55=EURUSD")), "RawBody should contain tag 55, got: %s", string(rawBody)) + s.True(bytes.Contains(rawBody, []byte("268=2")), "RawBody should contain tag 268, got: %s", string(rawBody)) + + // Header FieldMap must still be fully populated. + msgType, err := inMsg.MsgType() + s.Nil(err) + s.Equal("W", msgType) + s.True(inMsg.Header.Has(Tag(49)), "Header should contain SenderCompID (49)") + s.True(inMsg.Header.Has(Tag(52)), "Header should contain SendingTime (52)") + + // Trailer must still be populated. + s.True(inMsg.Trailer.Has(tagCheckSum), "Trailer should contain CheckSum (10)") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +``` +go test -run TestMessageSuite/TestParseWFastPath -v ./... +``` + +Expected: `FAIL` — `Body.Has(Tag(55))` returns `true` (fast path not yet implemented; normal parse populates Body FieldMap) + +--- + +## Task 3 — Implement the fast path in `doParsing` + +**Files:** +- Modify: `message.go` (after line 228) + +- [ ] **Step 1: Insert fast-path block in `doParsing`** + +In `message.go`, locate this line (line 228): + +```go + mp.msg.Header.add(mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1]) +``` + +This is the third `Header.add` call, where MsgType (tag 35) is added. Immediately after it (before `// Start parsing.`), insert: + +```go + // Fast path for 35=W (MarketDataSnapshotFullRefresh): skip Body.FieldMap population. + // extractField is still called per field for body-length validation. + // Header and Trailer FieldMaps are populated normally. + // Access body fields via msg.RawBody(). + if string(mp.parsedFieldBytes.value) == "W" { + mp.fieldIndex++ + mp.trailerBytes = []byte{} + mp.foundBody = false + mp.foundTrailer = false + for { + mp.parsedFieldBytes = &mp.msg.fields[mp.fieldIndex] + if mp.rawBytes, err = extractField(mp.parsedFieldBytes, mp.rawBytes); err != nil { + return + } + switch { + case isHeaderField(mp.parsedFieldBytes.tag, mp.transportDataDictionary): + mp.msg.Header.add(mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1]) + case isTrailerField(mp.parsedFieldBytes.tag, mp.transportDataDictionary): + mp.msg.Trailer.add(mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1]) + mp.foundTrailer = true + default: + // Body field: intentionally not added to Body.FieldMap. + // trailerBytes tracks the cursor after each body field so trim below is correct. + mp.foundBody = true + mp.trailerBytes = mp.rawBytes + } + if mp.parsedFieldBytes.tag == tagCheckSum { + break + } + if !mp.foundBody { + mp.msg.bodyBytes = mp.rawBytes + } + mp.fieldIndex++ + } + if mp.foundTrailer && !mp.foundBody { + mp.trailerBytes = mp.rawBytes + mp.msg.bodyBytes = nil + } + // Trim bodyBytes: subtract trailer length to get body-only slice. + if len(mp.msg.bodyBytes) > len(mp.trailerBytes) { + mp.msg.bodyBytes = mp.msg.bodyBytes[:len(mp.msg.bodyBytes)-len(mp.trailerBytes)] + } + // Validate body length using already-extracted field bytes (same as normal path). + length := 0 + for _, field := range mp.msg.fields { + switch field.tag { + case tagBeginString, tagBodyLength, tagCheckSum: + default: + length += field.length() + } + } + bodyLength, blerr := mp.msg.Header.getIntNoLock(tagBodyLength) + if blerr != nil { + err = parseError{OrigError: blerr.Error()} + } else if length != bodyLength { + err = parseError{OrigError: fmt.Sprintf("Incorrect Message Length, expected %d, got %d", bodyLength, length)} + } + return + } +``` + +- [ ] **Step 2: Run the fast-path test** + +``` +go test -run TestMessageSuite/TestParseWFastPath -v ./... +``` + +Expected: `PASS` + +- [ ] **Step 3: Run the full quickfix package test suite** + +``` +go test -v ./... +``` + +Expected: all tests pass except the pre-existing `log/mongo` failure (requires live MongoDB, unrelated to this change). The `quickfix` package itself must show `ok`. + +- [ ] **Step 4: Verify `TestRawBodyExposed` still passes (non-W path unchanged)** + +``` +go test -run TestMessageSuite/TestRawBodyExposed -v ./... +``` + +Expected: `PASS` + +- [ ] **Step 5: Commit** + +```bash +git add message.go message_test.go +git commit -m "feat: fast path in doParsing for 35=W — skip Body.FieldMap, expose RawBody()" +``` + +--- + +## Task 4 — Add benchmark + +**Files:** +- Modify: `message_test.go` + +- [ ] **Step 1: Add benchmark for 35=W parsing** + +Add after `BenchmarkParseMessage` (after line 35 in `message_test.go`): + +```go +func BenchmarkParseMessageW(b *testing.B) { + // Build a realistic 35=W message once, then benchmark parsing it. + outMsg := NewMessage() + outMsg.Header.SetField(tagBeginString, FIXString("FIX.4.4")) + outMsg.Header.SetField(tagMsgType, FIXString("W")) + outMsg.Header.SetField(Tag(49), FIXString("EXCH")) + outMsg.Header.SetField(Tag(56), FIXString("CLIENT")) + outMsg.Header.SetField(Tag(34), FIXInt(1)) + outMsg.Header.SetField(Tag(52), FIXString("20260410-10:00:00")) + outMsg.Body.SetField(Tag(55), FIXString("EURUSD")) + outMsg.Body.SetField(Tag(268), FIXInt(2)) + outMsg.Body.SetField(Tag(269), FIXString("0")) + outMsg.Body.SetField(Tag(270), FIXString("1.08500")) + outMsg.Body.SetField(Tag(271), FIXString("1000000")) + raw := outMsg.String() + + msg := NewMessage() + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := bytes.NewBufferString(raw) + _ = ParseMessage(msg, buf) + } +} +``` + +- [ ] **Step 2: Run benchmark to confirm it executes** + +``` +go test -bench=BenchmarkParseMessageW -benchmem -count=3 ./... +``` + +Expected: benchmark runs and completes. Note the `ns/op` value for comparison with the non-fast-path baseline (`BenchmarkParseMessage`). + +- [ ] **Step 3: Commit** + +```bash +git add message_test.go +git commit -m "test: add BenchmarkParseMessageW for fast-path perf baseline" +``` + +--- + +## Verification + +After all tasks complete: + +``` +go test -run TestMessageSuite -v -count=1 . +``` + +All `MessageSuite` tests must pass. Confirm in output: +- `TestRawBodyExposed` — PASS +- `TestParseWFastPath` — PASS +- All pre-existing `MessageSuite` tests — PASS (normal path unaffected) diff --git a/docs/superpowers/specs/2026-04-10-md-fix-fast-parser-design.md b/docs/superpowers/specs/2026-04-10-md-fix-fast-parser-design.md new file mode 100644 index 000000000..af23f8216 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-md-fix-fast-parser-design.md @@ -0,0 +1,157 @@ +# Design: Fast Path Parser for Market Data FIX Messages (35=W) + +**Date:** 2026-04-10 +**Status:** Approved +**Scope:** quickfix fork + exchangeconnector + +--- + +## Goal + +Eliminate the FieldMap allocation overhead for received `35=W` (MarketDataSnapshotFullRefresh) messages. The application only needs tags 55, 52, 269, 270, 271 from these messages — parsing all 552+ fields into a map wastes ~35–40µs per message. + +--- + +## Background + +The existing `doParsing` function inserts every body field into `Body.FieldMap` (a `map[Tag]field`). For a 138-entry MD snapshot, that is ~552 `Body.add()` map insertions. The application then calls `GetGroup` (re-scans the map) and per-entry `GetString` (more map lookups) to extract the 3–4 fields it actually needs. + +The `bodyBytes` field already exists on `Message` and is already populated by `doParsing` — it is just unexported. Adding a `RawBody()` accessor and a fast path that skips `Body.add()` gives the application raw bytes it can scan directly. + +--- + +## Architecture + +### Part 1 — quickfix fork (`message.go`) + +#### 1a. Add `RawBody()` method + +Add after the `String()` method (~line 583): + +```go +// RawBody returns the raw bytes of the message body as received on the wire. +// Populated for inbound messages; nil for outbound messages or messages with no body. +// For 35=W messages, body FieldMap is not populated — use RawBody() for body field access. +func (m *Message) RawBody() []byte { + return m.bodyBytes +} +``` + +#### 1b. Fast path in `doParsing` for `35=W` + +Inserted immediately after line 228 (where MsgType is added to Header). + +Behaviour: +- Checks `string(mp.parsedFieldBytes.value) == "W"` +- Runs a mirror of the normal parse loop with one difference: body fields are NOT passed to `Body.add()` +- `extractField` is still called for every field (required to advance the byte cursor and for body-length validation) +- Header fields are still added to `Header.FieldMap` (session management reads tag 8, 9, 35, 49, 56, 34, 52) +- Trailer fields are still added to `Trailer.FieldMap` +- `bodyBytes` is populated and trimmed identically to the normal path +- Body length is validated using the already-extracted `mp.msg.fields` array +- Function returns early so the normal loop is not re-executed + +**Session management impact:** none. The session layer reads only Header and Trailer fields. The fast path is invisible to session state machines. + +**Body length validation:** preserved. All fields are still extracted into `mp.msg.fields`; the existing length summation loop works unchanged. + +--- + +### Part 2 — exchangeconnector (`marketDataMethods.go`) + +#### 2a. `onMarketDataSnapshot` + +Replace: +```go +rawMsg := msg.ToMessage().String() +isTrade := strings.Contains(rawMsg, "\x01269=2\x01") || strings.Contains(rawMsg, "|269=2|") +``` + +With: +```go +rawBody := msg.ToMessage().RawBody() +isTrade := bytes.Contains(rawBody, []byte("\x01269=2\x01")) +``` + +Pass `rawBody` downstream instead of the typed `msg`. + +Get tag 55 (Symbol) from raw body using `scanTag` helper (see below) instead of `msg.Body.FieldMap.GetString(55)`, since Body FieldMap is empty in the fast path. + +Header access for tag 52 (SendingTime) is unchanged — Header FieldMap is still fully populated. + +#### 2b. `parseOrderBookSnapshotRaw(rawBody []byte, ...)` + +Replaces `parseOrderBookSnapshot`. Scans `rawBody` byte-by-byte via `\x01` delimiters: +- Detects group entry boundaries on tag 269 (MDEntryType delimiter) +- Collects tags 270 (price) and 271 (size) per entry +- Converts directly to `float64` with `strconv.ParseFloat` +- Calls `UpsertBid` / `UpsertAsk` based on entryType value + +No allocation for field maps. One pass through raw bytes. + +#### 2c. `parseTradeSnapshotRaw(rawBody []byte, ...)` + +Same byte-scanner pattern, collecting trade-relevant fields only. + +#### 2d. `scanTag(raw []byte, tag int) ([]byte, bool)` + +Locates the first occurrence of a specific tag in raw body bytes. Used to extract tag 55 (pair symbol). Returns the value bytes and a found boolean. + +``` +\x01=\x01 +``` + +Scans left-to-right using `bytes.IndexByte`. Returns a slice of the original buffer (no allocation). + +--- + +## Data Flow + +``` +TCP bytes → doParsing + → Header FieldMap populated (tags 8,9,35,49,56,34,52) [unchanged] + → Body: extractField × N (for checksum validation only) [fast path] + → bodyBytes = raw body slice [fast path] + → Trailer FieldMap populated (tag 10) [unchanged] + +onMarketDataSnapshot + → Header.GetTime(52) ← FieldMap lookup (fast) + → scanTag(rawBody, 55) ← byte scan (fast) + → bytes.Contains(rawBody, 269=2) ← trade detection + → parseOrderBookSnapshotRaw OR parseTradeSnapshotRaw + → byte scanner: one pass, collect 269/270/271 + → UpsertBid / UpsertAsk +``` + +--- + +## Testing Strategy + +1. **Existing quickfix tests** — run full test suite after adding the fast path. No regressions expected since session layer is unaffected. +2. **New unit test** — `TestFastPathRawBody`: parse a synthetic `35=W` message, assert: + - `msg.RawBody()` is non-nil and contains expected fields + - `msg.Body.FieldMap` is empty (fast path skipped Body.add) + - `msg.Header.FieldMap` contains tags 8, 9, 35, 52 + - Body length validation passes +3. **Integration smoke test** — run exchangeconnector against a replay of real MD messages, assert identical orderbook state produced by raw vs. typed parsers. +4. **Benchmark** — `BenchmarkParseW` before/after, confirm ~35–40µs reduction. + +--- + +## Success Criteria + +- All existing quickfix tests pass +- `RawBody()` returns correct body bytes for `35=W` messages +- `Body.FieldMap` is empty for `35=W` messages (fast path active) +- `onMarketDataSnapshot` produces identical orderbook/trade output as before +- Benchmark shows ≥70% reduction in body processing time for `35=W` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `quickfix/message.go` | Add `RawBody()` method; add fast-path block in `doParsing` | +| `quickfix/message_test.go` | Add `TestFastPathRawBody` test | +| `exchangeconnector/wsengine/crossover/marketDataMethods.go` | Replace typed parsing with raw byte scanners | diff --git a/message.go b/message.go index 5bea1d375..b3fcbe8ff 100644 --- a/message.go +++ b/message.go @@ -227,6 +227,85 @@ func doParsing(mp *msgParser) (err error) { } mp.msg.Header.add(mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1]) + // Fast path for 35=W (MarketDataSnapshotFullRefresh): skip Body.FieldMap population. + // extractField is still called per field for body-length validation. + // Header and Trailer FieldMaps are populated normally. + // Access body fields via msg.RawBody(). + // Note: messages with XMLData (tag 213) bodies are not supported by this fast path. + if string(mp.parsedFieldBytes.value) == "W" { + { + raw := mp.rawBytes + //seq, sendingTime := []byte("?"), []byte("?") + if i := bytes.Index(raw, []byte("\x0134=")); i >= 0 { + end := i + 4 + for end < len(raw) && raw[end] != '\x01' { + end++ + } + //seq = raw[i+4 : end] + } + if i := bytes.Index(raw, []byte("\x0152=")); i >= 0 { + end := i + 4 + for end < len(raw) && raw[end] != '\x01' { + end++ + } + //sendingTime = raw[i+4 : end] + } + } + mp.fieldIndex++ + mp.trailerBytes = []byte{} + mp.foundBody = false + mp.foundTrailer = false + for { + mp.parsedFieldBytes = &mp.msg.fields[mp.fieldIndex] + if mp.rawBytes, err = extractField(mp.parsedFieldBytes, mp.rawBytes); err != nil { + return + } + switch { + case isHeaderField(mp.parsedFieldBytes.tag, mp.transportDataDictionary): + mp.msg.Header.add(mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1]) + case isTrailerField(mp.parsedFieldBytes.tag, mp.transportDataDictionary): + mp.msg.Trailer.add(mp.msg.fields[mp.fieldIndex : mp.fieldIndex+1]) + mp.foundTrailer = true + default: + // Body field: intentionally not added to Body.FieldMap. + // trailerBytes tracks the cursor after each body field so the trim below is correct. + mp.foundBody = true + mp.trailerBytes = mp.rawBytes + } + if mp.parsedFieldBytes.tag == tagCheckSum { + break + } + if !mp.foundBody { + mp.msg.bodyBytes = mp.rawBytes + } + mp.fieldIndex++ + } + if mp.foundTrailer && !mp.foundBody { + mp.trailerBytes = mp.rawBytes + mp.msg.bodyBytes = nil + } + // Trim bodyBytes: subtract trailer length to get body-only slice. + if len(mp.msg.bodyBytes) > len(mp.trailerBytes) { + mp.msg.bodyBytes = mp.msg.bodyBytes[:len(mp.msg.bodyBytes)-len(mp.trailerBytes)] + } + // Validate body length using already-extracted field bytes (same as normal path). + length := 0 + for _, field := range mp.msg.fields { + switch field.tag { + case tagBeginString, tagBodyLength, tagCheckSum: + default: + length += field.length() + } + } + bodyLength, blerr := mp.msg.Header.getIntNoLock(tagBodyLength) + if blerr != nil { + err = parseError{OrigError: blerr.Error()} + } else if length != bodyLength { + err = parseError{OrigError: fmt.Sprintf("Incorrect Message Length, expected %d, got %d", bodyLength, length)} + } + return + } + // Start parsing. mp.fieldIndex++ xmlDataLen := 0 @@ -582,6 +661,17 @@ func (m *Message) String() string { return string(m.build()) } +// RawBody returns the raw bytes of the message body as received on the wire. +// Populated for inbound messages; nil for outbound messages or messages with no body. +// For 35=W messages parsed with the fast path, Body.FieldMap is empty — use RawBody() instead. +// +// The returned slice aliases the message's internal buffer and must not be modified by the caller. +// If the slice must be retained beyond the next call to ParseMessage on the same Message instance, +// the caller must copy it. +func (m *Message) RawBody() []byte { + return m.bodyBytes +} + func formatCheckSum(value int) string { return fmt.Sprintf("%03d", value) } diff --git a/message_test.go b/message_test.go index 72f11dcd0..522780651 100644 --- a/message_test.go +++ b/message_test.go @@ -34,6 +34,30 @@ func BenchmarkParseMessage(b *testing.B) { } } +func BenchmarkParseMessageW(b *testing.B) { + // Build a realistic 35=W message once, then benchmark the fast-path parse. + outMsg := NewMessage() + outMsg.Header.SetField(tagBeginString, FIXString("FIX.4.4")) + outMsg.Header.SetField(tagMsgType, FIXString("W")) + outMsg.Header.SetField(Tag(49), FIXString("EXCH")) + outMsg.Header.SetField(Tag(56), FIXString("CLIENT")) + outMsg.Header.SetField(Tag(34), FIXInt(1)) + outMsg.Header.SetField(Tag(52), FIXString("20260410-10:00:00")) + outMsg.Body.SetField(Tag(55), FIXString("EURUSD")) + outMsg.Body.SetField(Tag(268), FIXInt(2)) + outMsg.Body.SetField(Tag(269), FIXString("0")) + outMsg.Body.SetField(Tag(270), FIXString("1.08500")) + outMsg.Body.SetField(Tag(271), FIXString("1000000")) + raw := outMsg.String() + + msg := NewMessage() + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf := bytes.NewBufferString(raw) + _ = ParseMessage(msg, buf) + } +} + type MessageSuite struct { QuickFIXSuite msg *Message @@ -87,6 +111,65 @@ func (s *MessageSuite) TestParseMessage() { s.False(s.msg.IsMsgTypeOf("A")) } +func (s *MessageSuite) TestRawBodyExposed() { + // Given a parsed non-W message (normal path) + rawMsg := bytes.NewBufferString("8=FIX.4.2\x019=104\x0135=D\x0134=2\x0149=TW\x0152=20140515-19:49:56.659\x0156=ISLD\x0111=100\x0121=1\x0140=1\x0154=1\x0155=TSLA\x0160=00010101-00:00:00.000\x0110=039\x01") + s.Nil(ParseMessage(s.msg, rawMsg)) + + // RawBody should return the body bytes already populated by doParsing + raw := s.msg.RawBody() + s.NotNil(raw, "RawBody should not be nil after parsing a message with body fields") + s.True(bytes.Contains(raw, []byte("55=TSLA")), "RawBody should contain tag 55, got: %s", string(raw)) +} + +func (s *MessageSuite) TestParseWFastPath() { + // Build a valid 35=W message using normal construction so body length + // and checksum are computed automatically. + outMsg := NewMessage() + outMsg.Header.SetField(tagBeginString, FIXString("FIX.4.4")) + outMsg.Header.SetField(tagMsgType, FIXString("W")) + outMsg.Header.SetField(Tag(49), FIXString("EXCH")) // SenderCompID + outMsg.Header.SetField(Tag(56), FIXString("CLIENT")) // TargetCompID + outMsg.Header.SetField(Tag(34), FIXInt(1)) // MsgSeqNum + outMsg.Header.SetField(Tag(52), FIXString("20260410-10:00:00")) // SendingTime + outMsg.Body.SetField(Tag(55), FIXString("EURUSD")) // Symbol + outMsg.Body.SetField(Tag(268), FIXInt(2)) // NoMDEntries + outMsg.Body.SetField(Tag(269), FIXString("0")) // MDEntryType + outMsg.Body.SetField(Tag(270), FIXString("1.08500")) // MDEntryPx + outMsg.Body.SetField(Tag(271), FIXString("1000000")) // MDEntrySize + + rawBytes := bytes.NewBufferString(outMsg.String()) + + inMsg := NewMessage() + s.Nil(ParseMessage(inMsg, rawBytes)) + + // Fast path: Body FieldMap must be empty — no Body.add() calls for 35=W. + s.False(inMsg.Body.Has(Tag(55)), "Body FieldMap should be empty for 35=W (fast path); tag 55 should not be present") + s.False(inMsg.Body.Has(Tag(268)), "Body FieldMap should be empty for 35=W (fast path); tag 268 should not be present") + + // RawBody must contain the body fields as raw bytes. + rawBody := inMsg.RawBody() + s.NotNil(rawBody, "RawBody should not be nil for 35=W") + s.True(bytes.Contains(rawBody, []byte("55=EURUSD")), "RawBody should contain tag 55, got: %s", string(rawBody)) + s.True(bytes.Contains(rawBody, []byte("268=2")), "RawBody should contain tag 268, got: %s", string(rawBody)) + + // Header FieldMap must still be fully populated. + msgType, err := inMsg.MsgType() + s.Nil(err) + s.Equal("W", msgType) + s.True(inMsg.Header.Has(Tag(49)), "Header should contain SenderCompID (49)") + s.True(inMsg.Header.Has(Tag(52)), "Header should contain SendingTime (52)") + + // Tag 56 (TargetCompID) must also be in the header. + s.True(inMsg.Header.Has(Tag(56)), "Header should contain TargetCompID (56)") + + // RawBody must NOT include the trailer — CheckSum tag 10 must not appear. + s.False(bytes.Contains(rawBody, []byte("10=")), "RawBody must not include the trailer CheckSum field") + + // Trailer must still be populated. + s.True(inMsg.Trailer.Has(tagCheckSum), "Trailer should contain CheckSum (10)") +} + func (s *MessageSuite) TestParseMessageWithDataDictionary() { dict := new(datadictionary.DataDictionary) dict.Header = &datadictionary.MessageDef{ diff --git a/repeating_group_test.go b/repeating_group_test.go index abd316d78..7221a965f 100644 --- a/repeating_group_test.go +++ b/repeating_group_test.go @@ -230,7 +230,9 @@ func TestRepeatingGroup_ReadRecursive(t *testing.T) { func TestRepeatingGroup_ReadComplete(t *testing.T) { - rawMsg := bytes.NewBufferString("8=FIXT.1.19=26835=W34=711849=TEST52=20151027-18:41:52.69856=TST22=9948=TSTX15262=7268=4269=4270=0.07499272=20151027273=18:41:52.698269=7270=0.07501272=20151027273=18:41:52.698269=8270=0.07494272=20151027273=18:41:52.698269=B271=60272=20151027273=18:41:52.69810=163") + // Uses 35=X (MarketDataIncrementalRefresh) rather than 35=W because the doParsing + // fast path skips Body.FieldMap for 35=W — Body.GetGroup() would find nothing. + rawMsg := bytes.NewBufferString("8=FIXT.1.19=26835=X34=711849=TEST52=20151027-18:41:52.69856=TST22=9948=TSTX15262=7268=4269=4270=0.07499272=20151027273=18:41:52.698269=7270=0.07501272=20151027273=18:41:52.698269=8270=0.07494272=20151027273=18:41:52.698269=B271=60272=20151027273=18:41:52.69810=095") msg := NewMessage() err := ParseMessage(msg, rawMsg)