A NestJS library that embeds a FIX (Financial Information eXchange) protocol engine directly into your application. Run a FIX acceptor (server) or initiator (client) over raw TCP, handle incoming messages with decorators, and broadcast outbound messages to groups of sessions — all with familiar NestJS patterns.
- FIX acceptor & initiator — TCP server accepting counterparties, or TCP client connecting out to a broker/exchange, from the same module
- Session management — logon/logout handshake, heartbeat supervision, sequence validation, resend/replay and gap-fill recovery
- Decorator-based handlers —
@OnLogon(),@OnFixMessage(MsgType.NewOrderSingle), ... on any controller method - Room-based broadcasting — group sessions into rooms and send a message to all of them with
fixService.to(room).send(msg) - FIX field dictionary — 680+ typed field-tag and value enums (
Fields,MsgType,Side,OrdType,TimeInForce, ...) with full tag/name coverage of FIX 4.4 - Byte-correct message framing — ASCII
BodyLength(9) framing, configured width, checksum and maximum-frame validation across fragmented/coalesced TCP chunks - Bounded reconnection — serialized reconnect attempts, Logon timeout and stale-socket protection for the initiator
- Native TLS — verified initiator connections, custom CA/client certificates and TLS acceptor support
- Durable-store boundary — an async atomic
FIXSessionStorecontract, with a reconnect-safe process-local default - Production-safe defaults — bounded connections, parser/input queues, gap buffers and message-store bytes
- Credential-safe logging — metadata-only message logs by default and unconditional sensitive-tag redaction in full mode
- Sync & async configuration —
FIXModule.register()andFIXModule.registerAsync()
- Node.js 20 or 22
- NestJS 11 (
@nestjs/commonand@nestjs/coreare peer dependencies)
# Using npm
npm install @sotatech/nest-quickfix
# Using yarn
yarn add @sotatech/nest-quickfix
# Using pnpm
pnpm add @sotatech/nest-quickfix@nestjs/common, @nestjs/core, reflect-metadata and rxjs must be present in the host application (they already are in any NestJS app).
import { Module } from '@nestjs/common';
import { FIXModule, Fields, Message } from '@sotatech/nest-quickfix';
@Module({
imports: [
FIXModule.register({
config: {
application: {
name: 'MyFixGateway',
protocol: 'ascii',
tcp: {
host: '0.0.0.0',
port: 9876,
},
type: 'acceptor',
},
BeginString: 'FIX.4.4',
BodyLengthChars: 10,
EncryptMethod: 0,
HeartBtInt: 30,
ResetSeqNumFlag: true,
SenderCompID: '*', // '*' accepts any sender
TargetCompID: 'BROKER',
},
auth: {
// Receives the raw Logon message — extract any field you need
validateCredentials: async (message: Message) => {
const username = message.getField(Fields.Username);
const password = message.getField(Fields.Password);
return username === 'username' && password === 'password';
},
// Optional: allow-list per Account (tag 1) from the Logon message
getAllowedSenderCompIds: async (account: string) => [
'CLIENT1',
'CLIENT2',
],
},
session: {
maxSessions: 0, // 0 = unlimited
maxConnections: 200,
messageStoreCapacity: 10_000,
messageStoreMaxBytes: 67_108_864,
maxMessageBytes: 1_048_576,
logonTimeoutMs: 10_000,
maxQueuedInboundBytes: 8_388_608,
},
logging: { messages: 'metadata' },
}),
],
})
export class AppModule {}FIXModule.register({
config: {
application: {
name: 'MyFixClient',
protocol: 'ascii',
tcp: {
host: 'broker.example.com',
port: 9876,
},
type: 'initiator',
},
BeginString: 'FIX.4.4',
BodyLengthChars: 10,
EncryptMethod: 0,
HeartBtInt: 30,
ResetSeqNumFlag: true,
SenderCompID: 'CLIENT1',
TargetCompID: 'BROKER',
// Optional credentials — sent as tags 553/554 in the Logon message
Username: 'username',
Password: 'password',
reconnect: {
enabled: true,
intervalMs: 5000,
maxAttempts: 5,
},
},
});FIXModule.registerAsync({
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
config: config.get('fix'),
auth: {/* ... */},
}),
});import { Controller } from '@nestjs/common';
import {
Message,
Field,
Fields,
MsgType,
Session,
FixService,
OnLogon,
OnLogout,
OnConnected,
OnDisconnected,
OnFixMessage,
} from '@sotatech/nest-quickfix';
@Controller()
export class TradingController {
constructor(private readonly fixService: FixService) {}
@OnLogon()
async onLogon(session: Session, message: Message) {
// Group the session into a room for targeted broadcasting
session.join('CLIENTS');
// Announce trading session status right after logon
const status = new Message(
new Field(Fields.MsgType, MsgType.TradingSessionStatus),
new Field(Fields.TradingSessionID, '1'),
);
await this.fixService.to(session.getSessionId()).send(status);
}
@OnFixMessage(MsgType.NewOrderSingle)
async onNewOrder(session: Session, message: Message) {
const clOrdId = message.getField(Fields.ClOrdID);
const symbol = message.getField(Fields.Symbol);
const side = message.getField(Fields.Side);
const qty = message.getField(Fields.OrderQty);
// ... route the order to your matching logic
}
@OnFixMessage() // no filter: receives every inbound application message
async onAnyMessage(session: Session, message: Message) {
console.log('IN', message.toFieldNameObject());
}
@OnLogout()
async onLogout(session: Session, message: Message) {
console.log(`Logout: ${session.getSessionId()}`);
}
@OnConnected()
async onConnected(session: Session) {}
@OnDisconnected()
async onDisconnected(session: Session) {}
}import { Injectable } from '@nestjs/common';
import {
FixService,
Message,
Field,
Fields,
MsgType,
Side,
OrdType,
TimeInForce,
RejectMessage,
} from '@sotatech/nest-quickfix';
@Injectable()
export class OrderGateway {
constructor(private readonly fixService: FixService) {}
async sendOrder() {
const order = new Message(
new Field(Fields.MsgType, MsgType.NewOrderSingle),
new Field(Fields.ClOrdID, 'ORDER-123'),
new Field(Fields.Symbol, 'AAPL'),
new Field(Fields.Side, Side.Buy),
new Field(Fields.OrderQty, 100),
new Field(Fields.Price, 150.5),
new Field(Fields.OrdType, OrdType.Limit),
new Field(Fields.TimeInForce, TimeInForce.Day),
);
// Target can be a room name or a session id ("SENDER->TARGET")
await this.fixService.to('CLIENTS').send(order);
}
async reject(refSeqNum: number, reason: string) {
const reject = new RejectMessage(refSeqNum, reason, MsgType.NewOrderSingle);
await this.fixService.to('CLIENT1->BROKER').send(reject);
}
}You never set BeginString, SenderCompID, TargetCompID, MsgSeqNum, SendingTime, BodyLength or CheckSum yourself — the library fills them in per session.
| Option | Type | Description |
|---|---|---|
config |
FIXConfig |
Connection and session configuration (below) |
auth |
object |
Optional. Authentication hooks for the acceptor |
auth.validateCredentials |
(message, context?) => boolean | Promise<boolean> |
Receives Logon plus optional abort context; return false to reject |
auth.getAllowedSenderCompIds (optional) |
(account, context?) => string[] | Promise<string[]> |
Allowed SenderCompIDs for Account (tag 1), with optional abort context |
session.maxSessions |
number |
Optional. Max concurrent sessions, 0 = unlimited |
session.messageStoreCapacity |
number |
Optional. Outbound entries retained per session for replay; default 10_000 |
session.maxMessageBytes |
number |
Optional. Maximum inbound frame/buffer size; default 1_048_576 (1 MiB) |
session.logonTimeoutMs |
number |
Optional. Initiator timeout waiting for peer Logon; default 10_000 ms |
session.authenticationTimeoutMs |
number |
Optional. Acceptor authentication timeout; default 10_000 ms |
session.storeOperationTimeoutMs |
number |
Optional. Atomic store operation timeout; default 10_000 ms |
session.logoutTimeoutMs |
number |
Optional. Logout handshake timeout; default 5_000 ms |
session.writeTimeoutMs |
number |
Optional. Socket write callback timeout; default 10_000 ms. On timeout the session is destroyed |
session.handlerTimeoutMs |
number |
Optional. Handler timeout; default 30_000, 0 disables |
session.shutdownTimeoutMs |
number |
Optional. Graceful engine shutdown budget; default 15_000 ms |
session.replayBatchSize |
number |
Optional. Maximum replay range read per batch; default 1_000 |
| Field | Type | Required | Description |
|---|---|---|---|
application.type |
'acceptor' | 'initiator' |
yes | Run as server or client |
application.name |
string |
yes | Application name |
application.protocol |
'ascii' |
yes | Wire encoding |
application.dictionary |
string |
no | Deprecated metadata only; no business dictionary validation is performed |
application.reconnectSeconds |
number |
no | Deprecated seconds fallback when reconnect.intervalMs is absent |
application.tcp.host |
string |
yes | Bind host (acceptor) or remote host (initiator) |
application.tcp.port |
number |
yes | TCP port |
BeginString |
string |
yes | FIX version, e.g. 'FIX.4.4' |
SenderCompID |
string | '*' |
yes | Your comp ID; '*' = accept any (acceptor) |
TargetCompID |
string | '*' |
yes | Counterparty comp ID |
HeartBtInt |
number |
yes | Heartbeat interval, seconds |
EncryptMethod |
number |
yes | 0 = none |
ResetSeqNumFlag |
boolean |
yes | Reset sequence numbers on logon |
BodyLengthChars |
number |
yes | Exact tag 9 width used by the engine; serialization fails if the value does not fit |
Username / Password |
string |
no | Credentials (acceptor-side validation reference) |
TargetSubID |
string |
no | Target sub ID |
LastSentSeqNum / LastReceivedSeqNum |
number |
no | Restore sequence numbers |
reconnect.enabled / intervalMs / maxAttempts |
no | Initiator reconnect policy; deprecated interval remains a millisecond alias |
| Decorator | Handler signature | Fires when |
|---|---|---|
@OnLogon() |
(session, message) |
Logon handshake completed |
@OnLogout() |
(session, message) |
Logout received |
@OnConnected() |
(session) |
TCP connection established |
@OnDisconnected() |
(session) |
TCP connection closed |
@OnFixMessage(msgType?) |
(session, message, context?) |
Inbound message; optionally filtered by MsgType |
Handlers can be placed on methods of any @Controller() or @Injectable() provider discovered by NestJS.
Every connection is represented by a Session:
session.getSessionId(); // "SENDER->TARGET"
session.join('roomA'); // join a room
session.leave('roomA'); // leave a room
session.getRooms(); // list joined rooms
session.getConfig(); // SessionConfig
await session.sendMessage(message); // application messages; LOGGED_ON onlyFixService.to(target) accepts either a room name (broadcast to all sessions in the room) or a session id (send to one session). If no live session exists, send() rejects with the exported FixTargetNotFoundError. Broadcasts use an independent message clone per recipient; if any write fails, the returned promise rejects.
Sequence counters start at LastSentSeqNum + 1 and LastReceivedSeqNum + 1 only when the store has no state. A peer gap is buffered and requested with ResendRequest; application messages are replayed with PossDupFlag/OrigSendingTime, while admin ranges are represented by SequenceReset-GapFill. The default singleton InMemoryFIXSessionStore retains counters and replay entries across TCP reconnects in the same Node.js process and is bounded by entry count and serialized bytes. If a requested application sequence has been evicted, the engine sends Logout and closes instead of silently gap-filling business data.
For ResetSeqNumFlag=false in production, provide session.store backed by durable storage. Implement the exported FIXSessionStore operations atomically per stable session key. Operations receive an optional AbortSignal; an aborted operation must not commit later. Run the exported assertFIXSessionStoreCompliance(store, isolatedKey) against every adapter. A multi-process adapter must also provide locking/ownership; this package does not provide active-active fencing.
TLS values are PEM contents (string or Buffer), not filenames. An acceptor requires both key and cert; an initiator verifies the certificate by default.
FIXModule.register({
config: {
// ...session fields...
application: {
name: 'secure-fix',
type: 'initiator',
protocol: 'ascii',
tcp: {
host: 'fix.example.com',
port: 9877,
tls: {
ca: readFileSync('/run/secrets/fix-ca.pem'),
servername: 'fix.example.com',
rejectUnauthorized: true,
minVersion: 'TLSv1.2',
handshakeTimeoutMs: 10_000,
},
},
},
},
logging: {
messages: 'metadata', // none | metadata | full
redactTags: [9001],
},
});full logging always redacts tags 89, 91, 96, 553, 554, 925, 1402, 1404, plus redactTags. Metadata mode logs only direction, session id, message type, sequence and byte length.
| Option | Default | 0 behavior |
|---|---|---|
maxSessions |
100 | unlimited |
maxConnections |
200 | unlimited |
messageStoreCapacity |
10,000 entries | not allowed |
messageStoreMaxBytes |
67,108,864/session | unlimited bytes |
maxMessageBytes |
1,048,576 | not allowed |
maxPendingInboundMessages |
1,000 | unlimited count |
maxPendingInboundBytes |
16,777,216 | unlimited bytes |
maxQueuedInboundBytes |
8,388,608 | unlimited bytes |
maxSendingTimeDriftMs |
120,000 | disabled |
connectTimeoutMs / logonTimeoutMs |
10,000 | not allowed |
logoutTimeoutMs |
5,000 | not allowed |
authenticationTimeoutMs / storeOperationTimeoutMs |
10,000 | not allowed |
handlerTimeoutMs |
30,000 | disabled |
shutdownTimeoutMs |
15,000 | not allowed |
replayBatchSize |
1,000 entries | not allowed |
const msg = new Message(
new Field(Fields.MsgType, MsgType.ExecutionReport),
new Field(Fields.OrderID, 'X1'),
);
msg.getField(Fields.OrderID); // typed getter
msg.setField(Fields.Price, 42.5);
msg.addField(448, 'PARTY-1'); // append; repeated body tags are preserved
msg.addFields(new Field(447, 'D'), new Field(452, 1));
msg.getFields(448); // every occurrence in wire order
msg.getFieldEntries(); // defensive ordered Field[] snapshot
msg.hasField(Fields.Price); // true
msg.toString(); // minimal-width BodyLength + CheckSum computed
msg.toString({ bodyLengthChars: 10 }); // exact tag 9 width used by the engine
msg.clone(); // copy preserving ordered/repeated field occurrences
msg.createReverse(); // copy with SenderCompID/TargetCompID swapped
msg.toJSON(); // legacy fields projection + optional stringTags/ordered entries
msg.toFieldNameObject(); // { MsgType: '8', OrderID: 'X1', ... }
Message.fromJSON(json); // rebuild from JSONThe constructor and addField() preserve repeated body tags used by FIX repeating groups. getField() and compatibility object projections return the first occurrence; setField() updates the first occurrence and removeField() removes all occurrences. Serializer, parser, store and replay retain exact body order. Session header/trailer tags remain singletons.
Repeating-group support in 0.12.0 is deliberately wire-level: the engine does not validate NumInGroup, delimiters, nested group structure or business-dictionary rules. The application must keep group counts and ordering valid.
Fields is the tag-number enum (Fields.ClOrdID === 11). Value enums (MsgType, Side, OrdType, TimeInForce, and 600+ more) type the field values.
FIXAcceptor, FIXInitiator and Session are EventEmitters:
import {
SessionEvents,
InitiatorEvents,
AcceptorEvents,
} from '@sotatech/nest-quickfix';
// e.g. injected FIXInitiator
initiator.on(InitiatorEvents.LOGGED_ON, () => {
/* ... */
});
initiator.on(InitiatorEvents.RECONNECT_FAILED, ({ attempt, error }) => {
/* ... */
});
session.on(SessionEvents.MESSAGE_IN, (msg) => {
/* ... */
});flowchart LR
subgraph External["External Counterparties"]
CP["FIX Client / Broker"]
end
subgraph Lib["@sotatech/nest-quickfix"]
Acceptor["FIXAcceptor - TCP server"]
Initiator["FIXInitiator - TCP client"]
SessionMgr["SessionManager"]
SessionObj["Session - heartbeat, seq nums"]
Parser["FIXMessageParser - SOH framing, checksum"]
Explorer["FixMetadataExplorer - decorator discovery"]
Rooms["RoomManager"]
end
subgraph App["Your Application"]
Ctrl["Controller handlers"]
Svc["Services via FixService"]
end
CP -->|"TCP / FIX over SOH"| Acceptor
Initiator -->|"TCP / FIX over SOH"| CP
Acceptor --> SessionMgr
SessionMgr --> SessionObj
SessionObj --> Parser
Parser --> Explorer
Explorer --> Ctrl
Svc --> Rooms
Rooms --> SessionObj
Flow inbound: TCP bytes → FIXMessageParser (frame by tag 9 byte length, validate field order + checksum) → Session (sequence validation/recovery, admin messages) → your decorated handlers. Waiting-gap and duplicate messages are not emitted to application handlers. Flow outbound: FixService → RoomManager → Session → required header fields + in-memory replay store + checksum → socket.
The repository includes a reproducible single-process benchmark inspired by the separate CPU and network measurements commonly published by FIX engines. It uses warmup plus multiple samples and reports the median rather than the fastest run.
pnpm benchmark # build, then run the default benchmark
pnpm benchmark -- --quick # shorter development run
pnpm benchmark:run -- --json # JSON report from the existing buildRepresentative local result on 2026-08-24:
- Apple M3 Pro, 12 cores, 36 GiB RAM, macOS 15.7.3 arm64
- Node.js 22.22.0, V8 12.4
- CPU cases: five 750 ms samples after 300 ms warmup
- TCP case: median of three runs of 50,000 application messages over macOS loopback
| Benchmark | Fields | Wire bytes | Median msg/s | Msg/min | Median µs/msg |
|---|---|---|---|---|---|
| Serialize Heartbeat | 6 | 97 | 645,812 | 38,748,699 | 1.548 |
| Parse Heartbeat | 6 | 97 | 456,362 | 27,381,702 | 2.191 |
| Serialize NewOrderSingle | 14 | 184 | 273,911 | 16,434,645 | 3.651 |
| Parse NewOrderSingle | 14 | 184 | 260,854 | 15,651,251 | 3.834 |
| Serialize NewOrderSingle + Parties(2) | 21 | 241 | 185,311 | 11,118,680 | 5.396 |
| Parse NewOrderSingle + Parties(2) | 21 | 241 | 190,379 | 11,422,750 | 5.253 |
| Stream parser, 32 coalesced orders | 14 | 184 | 225,435 | 13,526,127 | 4.436 |
| TCP loopback, full inbound session | 14 | 179 | 53,183 | 3,190,981 | 18.803 |
CPU rows measure only serialization or parsing. The TCP row includes socket framing, FIX validation, sequence checking, in-memory inbound counter persistence and application-handler dispatch. It excludes application business logic, durable external-store latency, TLS, logging and outbound acknowledgements. These results are not a cross-library comparison or a production capacity guarantee; message shape, Node version, CPU power mode, logging, store adapter and network topology materially affect throughput. Run the benchmark on the intended deployment host before sizing production capacity.
A complete working acceptor lives in samples/nest-quickfix-sample. It consumes the library via a local file: link:
cd samples/nest-quickfix-sample
pnpm install
pnpm start:dev
# FIX acceptor now listens on localhost:9876Point any FIX 4.4 client (e.g. QuickFIX/J's Banzai, or a raw telnet-style script) at it to see logon, heartbeat and message handling in action.
pnpm install # install dependencies
pnpm build # compile to dist/
pnpm lint # eslint (flat config)
pnpm format # prettier
pnpm test # jest unit tests
pnpm test:cov # coverage with enforced thresholds
pnpm test:property # deterministic parser properties (FIX_PROPERTY_SEED supported)
pnpm test:faults # real TCP fault-injection regressions
pnpm test:soak # 50,000 messages + 100 real reconnects
pnpm test:smoke # isolated tarball, Nest TCP/TLS, CJS/ESM and package lint
pnpm test:interop # two-way QuickFIX/J 3.0.2 + repeating groups/replay
pnpm test:mutation # targeted parser/session/store/transport mutation suite
pnpm benchmark # CPU and full-session TCP throughput benchmark
pnpm verify # standard release gate
pnpm verify:release # standard gate + soak + mutationprepublishOnly and scripts/publish.sh intentionally run the complete release gate only. They require a clean working tree whose HEAD is already pushed to its configured upstream. Tagging and npm publish --access=public --tag latest remain explicit maintainer actions.
Potential future extensions:
- Reference durable-store adapters (for example Redis/PostgreSQL with atomic locking)
- Active-active session ownership and fencing
- FIX 5.0 session layer (the field dictionary already covers 5.0 tags)
- Fork and clone the repo
pnpm install, create a feature branch- Make sure
pnpm buildandpnpm lintpass - Open a pull request
Please report issues at github.com/sotarak/nest-quickfix/issues.
MIT