Connecting to another venue over FIX¶
The venue module answers FIX sessions. This page is the other half: opening
one. lrvx::fix::FixInitiator is the client end of a FIX 4.4 order-entry
session, written against the acceptor described in
Perimeter so that the two ends agree about recovery and not
only about the happy path.
There are two callers. A strategy connects to a counterparty and sends orders. A venue connects to another venue and becomes its client. They get the same session.
Where it lives, and why not next to the codec¶
The initiator is in the core, at include/lrvx/connector/fix/. The venue's
FixCodec stays in the venue module.
The deciding factor was what the arrangement costs a strategy author who will
never run a matching engine. LRVX_BUILD_VENUE=ON pulls in the matching
engine, clearing, venue risk and simdjson, forces LRVX_ENABLE_BACKTEST=ON,
and is off entirely on MSVC and clang-cl because the module needs native
128-bit integers. Putting the initiator there would charge that whole bill for
a FIX client, and would leave Windows with no FIX client at all.
What the two ends share is smaller than a module: field parsing, the
BodyLength/CheckSum framing, the header field order, the SendingTime
format, and the decimal printing. Those moved to
lrvx/connector/fix/fix_wire.h and lrvx/util/decimal_wire.h, where both
sides read them. FixCodec calls into them now instead of keeping its own
copies, so a change to the checksum or the header order lands on both ends at
once.
| Header | What it holds |
|---|---|
lrvx/connector/fix/fix_wire.h |
framing, field parsing, checksum, header, SendingTime |
lrvx/connector/fix/fix_client_codec.h |
35=D/F/G out, 35=8/9/3/j in |
lrvx/connector/fix/fix_initiator.h |
the session: Logon, liveness, sequencing, recovery |
lrvx/connector/fix/fix_tcp_client.h |
the framed TCP client and a session driver |
lrvx/connector/fix/fix_executor.h |
IRoutableExecutor over a session |
A session¶
#include "lrvx/connector/fix/fix_initiator.h"
#include "lrvx/connector/fix/fix_tcp_client.h"
lrvx::fix::FixInitiatorConfig cfg;
cfg.senderCompId = "CLIENT";
cfg.targetCompId = "VENUE";
cfg.heartBtIntSec = 30;
lrvx::fix::FixInitiator initiator{cfg};
initiator.setReportHandler([](const lrvx::fix::InboundReport& r) {
if (const auto* e = std::get_if<lrvx::fix::ExecutionReport>(&r)) {
// e->execType, e->lastQty, e->lastPx, e->leavesQty
}
});
lrvx::fix::FixTcpClient tcp;
lrvx::fix::FixTcpSession session{initiator, tcp};
session.connect("10.0.0.7", 9876, nowNs());
while (session.poll(nowNs())) {
// poll() reads what has arrived and runs the session timers
}
poll() returns false when the session ends, for any of the three reasons a
FIX session ends: a Logout was exchanged, liveness was lost, or the transport
went away. loggedOn() goes false at the same moment, however the session
ended -- a caller that judges reachability by the flag rather than by poll()'s
return value sees the same answer either way, instead of one that stays true
through a transport loss it never got a Logout for.
Sending is typed:
lrvx::fix::NewOrderRequest o;
o.clOrdId = 1001;
o.symbol = 3;
o.side = lrvx::Side::BUY;
o.type = lrvx::OrderType::LIMIT;
o.price = lrvx::Price::fromRaw(priceRaw);
o.quantity = lrvx::Quantity::fromRaw(qtyRaw);
initiator.submit(o, nowNs());
Price and Quantity are fixed-point on the way in and on the way out. A
price of 100.25 is printed as 100.25, not as 100.250000 and not as
100.24999999; the printing runs through lrvx::decwire, the same code the
venue uses on its side.
Logon and the sequence space¶
The outgoing Logon (35=A) carries HeartBtInt (108) and, by default,
ResetSeqNumFlag (141=Y), which restarts both directions at 1 and clears the
retained resend log. Set cancelOnDisconnect and the Logon also carries the
custom tag 20003 the venue reads to negotiate cancel-on-disconnect per session.
The HeartBtInt in the reply governs, not the one the Logon proposed. The
acceptor is the end that will act on the interval.
Turn 141 off, and the Logon continues the sequence space the counterparty already believes in. That is what a reconnect wants, and what the sidecar below makes possible across a restart.
Liveness¶
A Heartbeat goes out after HeartBtInt of outbound silence. No inbound traffic
for 1.2 intervals sends a TestRequest (35=1); silence for another 1.2
intervals ends the session. An inbound TestRequest comes back as a Heartbeat
echoing 112, which is what identifies the probe being answered.
These are the acceptor's numbers, read from the other side. A client that waited longer than the acceptor does would be disconnected while it was still deciding whether to worry.
Recovery¶
Their ResendRequest (35=2)¶
Application messages replay from the retained log, re-encoded with
PossDupFlag (43=Y) and OrigSendingTime (122) and their original 34.
Sequence numbers the log does not hold -- admin traffic, which is sequenced and
not logged, and history trimmed past resendLogCapacity -- collapse into
SequenceReset-GapFill (35=4, 123=Y).
Re-encoding rather than replaying bytes is forced: 43 and 122 change
BodyLength and CheckSum, so a byte replay was never available here.
Our own gap¶
There is no reorder buffer. A message whose 34 sits above what is expected
triggers a ResendRequest and is dropped, along with everything else above the
hole, until the counterparty's PossDup replay closes it. The request repeats at
most once per HeartBtInt while the hole stays open, and a TestRequest is
still answered so liveness survives it.
The acceptor does the same thing for the same reason. An execution report
applied out of order tells a strategy about a fill that has not happened yet,
and the counterparty has to resend regardless, so a buffer would buy nothing
and cost a wrong answer. A PossDup whose 34 was already consumed is dropped
silently.
Sequence violations¶
A 34 below what is expected, with no PossDupFlag, is a corrupted session:
Logout with the reason in 58, then disconnect. A message with no 34 at all
is not a FIX 4.4 message and gets the same treatment. A message whose checksum
is present and wrong is dropped without an answer, since its 34 cannot be
trusted either.
Message types a client session does not speak¶
An in-sequence message outside the set a client handles (admin 0/1/2/3/4/5/A/j,
application 8/9) is answered, never swallowed. Which answer depends on what
is wrong:
35=j(BusinessMessageReject) for a real FIX 4.4 application type that this client does not implement. The session is healthy; the message is declined.35=3(session Reject) for anything else, carrying45=RefSeqNum,372=RefMsgType,373=11and a58text.
The line is drawn by the same list the acceptor uses, in fix_wire.h. Sending
35=3 for a message the counterparty is entitled to send reads as a fault on
this side rather than as a declined capability.
Restart¶
FixInitiatorSidecar persists the two counters that have to survive: the next
outbound 34 and the next inbound 34 expected. It writes through a temporary
file, an fsync and an atomic rename, with a trailing CRC32 -- the journal's
discipline, and the same one the venue's FixSessionSidecar uses. A torn or
corrupt file loads as absent, which costs a 141=Y on the next Logon and never
a wrong sequence space.
lrvx::fix::FixSeqState saved;
if (lrvx::fix::FixInitiatorSidecar::load(path, saved)) {
initiator.restore(saved); // also turns 141=Y off: continue, do not restart
}
// ... later, at a checkpoint boundary
lrvx::fix::FixInitiatorSidecar::write(path, initiator.seqState());
The resend log is deliberately not persisted. A ResendRequest reaching into the pre-restart range is answered with GapFill, the same honest signal the venue gives for history it no longer holds.
Reading a lrvx venue's reports¶
The decoder reads the fields a lrvx venue actually writes, including the ones
FIX 4.4 has no spelling for. A last-look venue reports a fill held pending the
maker's decision as ExecType=U with custom tags 20001 (heldId) and 20002
(makerId), and reports a held fill that will not stand as ExecType=H. Those
arrive as ExecType::FillHeld and ExecType::TradeCancel with the ids intact.
A client that dropped them would read a hold as a plain working order and would never learn how it resolved. See Matching for what the venue means by the two.
OrderCancelReject (35=9) decodes as its own type rather than as an execution
report, because that is what it is: an execution report describes the state of
an order, and a refused cancel changed no order state.
Routing between counterparties¶
FixRoutableExecutor implements IRoutableExecutor, so OrderRouter
registers several FIX counterparties and switches between them on its own
routing strategy:
lrvx::fix::FixRoutableExecutor primary{initiatorA};
lrvx::fix::FixRoutableExecutor backup{initiatorB};
lrvx::OrderRouter<4> router;
router.registerExecutor(0, &primary);
router.registerExecutor(1, &backup);
router.setFailoverPolicy(lrvx::FailoverPolicy::FailoverToBest);
The router's cancel() carries only an OrderId, while FIX wants the symbol
and side of the order being cancelled, so the executor remembers the few bytes
of each working order and drops the entry on the terminal report. Feed inbound
reports to onReport() for that to stay accurate.
Transport and TLS¶
FixTcpClient speaks the framed TCP wire the venue's TcpGateway serves: one
FIX message per length-prefixed frame. Reads go through
lrvx::net::FrameReader, so a receive timeout in the middle of a frame is
resumable and the session timers run without the reader losing its place.
FixTlsChannel is the same thing encrypted: the same connect/send/read/
close, the same framing, the same resumable read. The initiator hands
finished messages to a SendFn and is fed complete messages through
onFrame(), so the channel drops in where FixTcpClient sits and the session
layer does not know which one it is on.
Why it is behind a flag¶
-DLRVX_FIX_TLS=ON finds OpenSSL and builds the channel; the flag is off by
default and the core links no OpenSSL without it. Nobody who does not ask for
TLS acquires the dependency, which is why the initiator is in the core in the
first place: a FIX client should not pay for a matching engine, and it should
not pay for a TLS stack it never opens either.
The alternative placement considered for THIS channel was connectors/, which
already links OpenSSL. It also requires ZLIB and CURL and fetches ixwebsocket
and simdjson -- three system libraries and two fetched projects to get an
encrypted FIX session. One optional dependency behind one flag is the cheaper
of the two, and that is the whole reason for the placement.
(The venue module does not come into this. lrvx-venue links neither
connectors nor OpenSSL: its TLS gateway finds OpenSSL on its own and links
it to that gateway's test alone.)
Verification is on¶
The venue's own tls::clientCtx() sets SSL_VERIFY_NONE, which is correct
where it is used: a test talking to a self-signed certificate it just
generated. It would not be correct here. A counterparty's certificate is the
only thing between a FIX session and whoever happens to answer that port, so
the channel:
- verifies the chain against the system trust store, or a
caFile/caPaththe caller names; - checks the hostname (
SSL_set1_host) -- without it any certificate the trust store accepts would pass, not just this counterparty's; - requires TLS 1.2 or better;
- refuses to connect if the trust store could not be loaded. A verify that cannot load its roots verifies nothing, and failing is the honest outcome.
Options::verifyPeer = false turns it off. It is a named, explicit act, which
is what a test harness against a self-signed venue needs and what production
should never contain.
A failed handshake leaves nothing readable: the SSL* and the socket are
dropped together, so no later read can pick bytes off a half-established
channel. A frame length the channel will not honour closes the session rather
than resynchronising -- past that point the stream is no longer one this side
can follow.
What the numbers look like¶
Measured over loopback against a live venue acceptor, from the submit() call
to the bytes reaching the socket:
| p50 | p99 | |
|---|---|---|
submit() to bytes handed to the socket |
7.6 us | 19.0 us |
submit() to encoded and sequenced, no write |
1.25 us | 1.5 us |
The write dominates. The 1.25 us the initiator owns is string building: the
codec composes tag=value through std::string, the way FixCodec does on
the venue side. That is where to look first if the number matters to you.
Testing¶
venue/tests/test_venue_fix_initiator.cpp runs the initiator against the
venue's own FixSessionHost in one process: logon, an order, the report
decoded back into typed fields, a dropped outbound message recovered through
the counterparty's ResendRequest, a dropped inbound message recovered through
ours, and a reconnect that continues the sequence space across a sidecar
restore. Both ends are real, which is the whole point of that file: a client
and a server that each pass their own tests can still disagree about recovery,
and the disagreement shows up as orders applied in the wrong order rather than
as a failure.
tests/test_fix_initiator.cpp pins the session rules against a scripted
counterparty in memory, and tests/test_fix_client_parser_fuzz.cpp drives
hostile input through the decoder and the session layer under AddressSanitizer
and UndefinedBehaviorSanitizer. An initiator dials out, so the bytes it parses
come from a machine it does not run.