Skip to content

Latest commit

 

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nest QuickFIX

npm version license

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.

Features

  • 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 FIXSessionStore contract, 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 configurationFIXModule.register() and FIXModule.registerAsync()

Requirements

  • Node.js 20 or 22
  • NestJS 11 (@nestjs/common and @nestjs/core are peer dependencies)

Installation

# 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).

Quick Start

1. Register the module

Acceptor (listen for incoming FIX connections)

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 {}

Initiator (connect out to an acceptor)

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,
    },
  },
});

Async configuration

FIXModule.registerAsync({
  inject: [ConfigService],
  useFactory: async (config: ConfigService) => ({
    config: config.get('fix'),
    auth: {/* ... */},
  }),
});

2. Handle FIX events in a controller

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) {}
}

3. Send messages from anywhere via FixService

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.

Configuration reference

FIXModuleOptions

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

FIXConfig

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

Decorators

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.

Sessions and rooms

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 only

FixService.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 and secure logging

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.

Resource defaults

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

Message API

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 JSON

The 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.

Events

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) => {
  /* ... */
});

Architecture

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
Loading

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: FixServiceRoomManagerSession → required header fields + in-memory replay store + checksum → socket.

Performance benchmark

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 build

Representative 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.

Sample application

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:9876

Point 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.

Development

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 + mutation

prepublishOnly 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.

Roadmap

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)

Contributing

  1. Fork and clone the repo
  2. pnpm install, create a feature branch
  3. Make sure pnpm build and pnpm lint pass
  4. Open a pull request

Please report issues at github.com/sotarak/nest-quickfix/issues.

License

MIT

About

A NestJS implementation of the FIX protocol for financial trading. High-performance TCP messaging with session management, message validation, recovery, and Redis-backed stores.

Topics

Resources

Stars

4 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages