Protocols9 min readJuly 13, 2026

Load Testing FIX Venues, Step by Step

From a bare FIX logon to sustained order flow: sessions, data dictionaries, dynamic message templates, and latency assertions with the perfscale native engine.

By Perfscale Team

What you'll build

A load test that opens real FIX (Financial Information eXchange) initiator sessions against a venue, logs on, streams orders or market-data requests, and measures round-trip latency — scaled across concurrent virtual users, with pass/fail assertions you can gate CI on.

We'll go in five steps: a bare logon, reading the output, adding a data dictionary, a reusable connection profile, and finally dynamic order flow at scale. At the end: a live example against CoinAPI's public FIX gateway.

Prerequisites

  • The perfscale CLI (install instructions); run perfscale self-update to be current
  • A workspace on the Scale or Enterprise plan — FIX is a paid capability, and tests using pro/fix* actions are rejected at creation time for Starter tenants
  • A FIX endpoint to test: your staging acceptor, or CoinAPI's gateway for the live example (free API key)

Step 1 — A bare logon session

FIX load testing starts with the session layer: TCP connect, Logon, heartbeats, Logout. The pro/fix@v1 action does the whole exchange and times it. No data dictionary is needed for this — the session layer (standard header/trailer + admin messages) is built in.

Create test.yaml:

name: fix-logon-smoke
steps:
  - uses: pro/fix@v1
    with:
      host: fix-staging.internal
      port: 9823
      sender_comp_id: CLIENT
      target_comp_id: VENUE
      heart_bt_int: 30

Native tests always run with a config. Create config.yaml:

vus: 1
duration: 10s

Run it:

perfscale run -f test.yaml -c config.yaml

The step fails if logon is rejected or a transport/timeout error occurs; otherwise the session round-trip lands in your latency metrics like any other action.


Step 2 — Read the output, assert on it

The step output includes logon, sent, received, duration_ms, the parsed reply messages, and a joined raw body. Assert with a check — here, that the venue answered an order with an ExecutionReport (35=8):

steps:
  - uses: pro/fix@v1
    with:
      host: fix-staging.internal
      port: 9823
      sender_comp_id: CLIENT
      target_comp_id: VENUE
      messages:
        - MsgType: D            # NewOrderSingle, by wire code — no schema yet
          11: order-1           # ClOrdID, numeric tag
          55: EURUSD            # Symbol
          54: 1                 # Side = Buy
          38: 100000            # OrderQty
          40: 1                 # OrdType = Market
    check:
      body_contains: "35=8"

Failed checks show up in the run summary and stderr. Note the run itself still exits 0 — failed checks are load-test feedback, not a CLI error.

Numeric tags work, but they're miserable to review. Fix that next.


Step 3 — Add a data dictionary

A schema lets messages use field names instead of raw tags and fails fast when a required field is missing. Point it at the venue's own QuickFIX spec (FIX44.xml) — the dictionary is loaded from the spec, not hardcoded:

steps:
  - uses: pro/fix@v1
    with:
      host: fix-staging.internal
      port: 9823
      sender_comp_id: CLIENT
      target_comp_id: VENUE
      schema: https://raw.githubusercontent.com/quickfix/quickfix/master/spec/FIX44.xml
      messages:
        - MsgType: NewOrderSingle
          ClOrdID: order-1
          Symbol: EURUSD
          Side: 1
          OrderQty: 100000
          OrdType: 1

Once a dictionary is loaded, message keys may be names or numeric tags, and MsgType accepts a name (NewOrderSingle) or a wire code (D). Custom venue fields layer on top as inline JSON:

schema:
  fields: { AlgoTag: 7001 }
  messages:
    NewOrderSingle: { required: [ClOrdID, Symbol, Side, OrderQty, OrdType] }

Step 4 — A reusable connection profile

Repeating host/credentials/schema in every step doesn't scale. Validate them once with pro/fix-config@v1 in the config's before: block and share the profile:

# config.yaml
vus: 50
duration: 5m
before:
  - uses: std/http@v1
    with: { url: https://raw.githubusercontent.com/quickfix/quickfix/master/spec/FIX44.xml }
    outputs: dict
  - uses: pro/fix-config@v1
    with:
      host: fix-staging.internal
      port: 9823
      sender_comp_id: CLIENT
      target_comp_id: VENUE
      schema: "${{ dict.body }}"
    outputs: venue
# test.yaml — inherits everything via connection
steps:
  - uses: pro/fix@v1
    with:
      connection: "${{ config.venue }}"
      messages:
        - MsgType: NewOrderSingle
          ClOrdID: "${uuid}"
          Symbol: EURUSD
          Side: 1
          OrderQty: 100000
          OrdType: 1

The before: block runs once at config-build time — the dictionary is fetched once, not per VU.


Step 5 — Dynamic order flow at scale

One static order repeated forever isn't load — venues cache, sequence checks trip, and you measure nothing real. Field values may embed ${...} tokens expanded per send, and _repeat + _interval_ms turn one template into a stream of distinct orders:

messages:
  - MsgType: NewOrderSingle
    ClOrdID: "ord-${seq}"
    Symbol: "${choice(EURUSD|GBPUSD|USDJPY)}"
    Side: "${rand(1,2)}"
    OrderQty: "${rand(1000,100000)}"
    Price: "${randf(1.05,1.15,5)}"
    TransactTime: "${now}"
    _repeat: 100          # 100 orders…
    _interval_ms: 50      # …one every 50ms
TokenExpands to
${seq}Monotonic counter, unique per message send
${uuid}Random 32-hex-character id
${now}Current UTC time, FIX format YYYYMMDD-HH:MM:SS.sss
${rand(a,b)}Random integer in [a, b]
${randf(a,b,dp)}Random float, dp decimals (default 2)
${choice(x|y|z)}Random pick among the options

These single-brace tokens are distinct from the engine's ${{ ... }} interpolation, which resolves before the step runs.

The load math: total order rate = vus concurrent sessions × the iteration loop × _repeat per iteration. The config above (50 VUs, each session sending 100 orders at 50ms intervals) sustains ~1,000 orders/sec for the duration — adjust the three knobs independently to shape ramp against your venue's throttle limits.


Live example — CoinAPI market data

CoinAPI's Market Data FIX is a concrete FIX 4.4 target you can hit today: TLS gateway fix.coinapi.io:3303, SenderCompID = your API key, TargetCompID COINAPI_V2. Subscribe to a live BTC/USD trade stream:

# config.yaml
vus: 5
duration: 1m
before:
  - uses: std/http@v1
    with: { url: https://raw.githubusercontent.com/quickfix/quickfix/master/spec/FIX44.xml }
    outputs: dict
  - uses: pro/fix-config@v1
    with:
      host: fix.coinapi.io
      port: 3303
      tls: true
      sender_comp_id: YOUR_COINAPI_KEY   # inject; do not commit
      target_comp_id: COINAPI_V2
      heart_bt_int: 10
      schema: "${{ dict.body }}"
    outputs: coinapi
# test.yaml
steps:
  - uses: pro/fix@v1
    with:
      connection: "${{ config.coinapi }}"
      messages:
        - MsgType: MarketDataRequest
          MDReqID: "perfscale-${uuid}"
          SubscriptionRequestType: 1     # subscribe
          MarketDepth: 1
          MDUpdateType: 1
          NoMDEntryTypes: 1
          MDEntryType: 2                 # trades
          NoRelatedSym: 1
          Symbol: COINBASE_SPOT_BTC_USD
      timeout: 15000

Note tls: true — CoinAPI's gateway is encrypted. For self-signed staging certificates use skipTLSVerify: true (staging only), and tls_server_name when SNI differs from the host.


Gate it in CI

Export the parsed summary and put it in the job output:

perfscale run -f test.yaml -c config.yaml \
  --summary-export results.json \
  --summary-export "$GITHUB_STEP_SUMMARY" --summary-format md

results.json carries p99_ms, error_rate, and requests_per_sec — one jq expression away from a hard gate. The full CI wiring is covered in Continuous load testing with GitHub Actions.


Troubleshooting

SymptomLikely cause / fix
Test rejected at creationStarter plan — pro/fix* needs Scale or Enterprise
Logon rejectedWrong sender_comp_id/target_comp_id, or the venue expects username/password (tags 553/554)
Sequence number errors on reconnectreset_seq_num defaults to true; if your venue forbids reset, set it to false and manage sequences
TLS handshake failureGateway port is encrypted → tls: true; certificate CN mismatch → tls_server_name
Session times out mid-runRaise timeout (default 10000ms covers the whole session) or lower _repeat × _interval_ms per iteration
Required-field errors after adding schemaThe dictionary enforces the venue's required lists — the error names the missing field; add it to the message

Next steps

Comments

Reply on Bluesky