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
perfscaleCLI (install instructions); runperfscale self-updateto 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
| Token | Expands 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
| Symptom | Likely cause / fix |
|---|---|
| Test rejected at creation | Starter plan — pro/fix* needs Scale or Enterprise |
| Logon rejected | Wrong sender_comp_id/target_comp_id, or the venue expects username/password (tags 553/554) |
| Sequence number errors on reconnect | reset_seq_num defaults to true; if your venue forbids reset, set it to false and manage sequences |
| TLS handshake failure | Gateway port is encrypted → tls: true; certificate CN mismatch → tls_server_name |
| Session times out mid-run | Raise timeout (default 10000ms covers the whole session) or lower _repeat × _interval_ms per iteration |
| Required-field errors after adding schema | The dictionary enforces the venue's required lists — the error names the missing field; add it to the message |
Next steps
- FIX Protocol reference — every parameter of
pro/fix@v1andpro/fix-config@v1 - Why we built FIX support — the background story
- Load testing with AI agents — let an agent author these YAML files over MCP, schema-validated