Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> below group changes by feature; everything ships in the same
> 0.7.0 publish.

### Added — feature-gated binary wire protocol (#59)

- **New `wire` feature flag** in `Cargo.toml` plus an optional
dependency on `zerocopy = "0.8"` (with `derive`). Disabled by
default; the crate's existing JSON and bincode paths are
unchanged — the wire protocol is purely additive.
- **Length-prefixed framing** — every frame on the wire is
`[len:u32 LE | kind:u8 | payload]`. `len` covers `kind + payload`
(it does NOT include the 4-byte `len` prefix itself). All
multi-byte integers are little-endian. Implementation in
`src/wire/framing.rs` with `encode_frame` / `decode_frame`.
- **`MessageKind` enum** (`#[repr(u8)]`, `#[non_exhaustive]`) with
stable explicit discriminants documented as stable across
`0.7.x`:

| Code | Direction | Message | Payload size |
|--------|-----------|-----------------|-------------:|
| `0x01` | inbound | `NewOrder` | 48 B |
| `0x02` | inbound | `CancelOrder` | 24 B |
| `0x03` | inbound | `CancelReplace` | 40 B |
| `0x04` | inbound | `MassCancel` | 24 B |
| `0x81` | outbound | `ExecReport` | 44 B |
| `0x82` | outbound | `TradePrint` | 48 B |
| `0x83` | outbound | `BookUpdate` | 32 B |

- **Inbound messages** are `#[repr(C, packed)]` and derive the
`zerocopy` traits (`FromBytes`, `IntoBytes`, `Unaligned`,
`Immutable`, `KnownLayout`). Decoding is safe — the crate keeps
`#![deny(unsafe_code)]` on the lib root. Each struct ships a
compile-time `const _: () = assert!(size_of::<…>() == N)` size
guard. Exposed: `NewOrderWire`, `CancelOrderWire`,
`CancelReplaceWire`, `MassCancelWire` and the matching
`decode_*` helpers.
- **Outbound messages** use explicit byte-cursor encoders
(`Vec<u8>::extend_from_slice`) rather than packed structs.
Outbound is I/O-dominated so the cost of a few dozen bytes of
field-by-field copy is dwarfed by socket overhead, and the
layout is free to evolve. Exposed: `ExecReport` +
`encode_exec_report` + `status_to_wire`,
`TradePrintWire` + `encode_trade_print`,
`BookUpdateWire` + `encode_book_update`.
- **Wire ↔ domain mapping** at the boundary —
`impl TryFrom<&NewOrderWire> for OrderType<()>` performs the
conversion, copies each packed field into a local first
(taking a reference to a packed field is undefined behaviour),
and returns `WireError::InvalidPayload` on unknown
side / TIF / order_type bytes or a negative price.
- **Errors** routed through a manual-`Display`
`#[non_exhaustive] WireError` (no `thiserror`, matches the
crate's existing manual style for the wire surface): variants
`Truncated`, `UnknownKind(u8)`, `InvalidPayload(&'static str)`.
- **`doc/wire-protocol.md`** with per-message offset / size /
field / type / notes tables, the `MessageKind` discriminant
table, the framing rule, and the LE-endianness statement.
- **Round-trip `proptest` tests** in every
`src/wire/{inbound,outbound}/*.rs` module — encode through the
framer, decode back, assert byte-for-byte equality.
- **Crate-root re-exports** under `#[cfg(feature = "wire")]` —
callers reach types via `orderbook_rs::wire::*`.
- **Example** `examples/src/bin/wire_roundtrip.rs` (gated by
`required-features = ["wire"]`) — builds a `NewOrderWire`,
encodes it through the framer, decodes it back, converts to a
domain `OrderType<()>`, and prints every field via
`tracing::info!`.

### Added — HDR-histogram tail-latency bench suite (#56)

- **Six new bench binaries** under `benches/order_book/*_hdr.rs` that
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ bytes = { workspace = true, optional = true }
bincode = { workspace = true, optional = true }
crc32fast = { workspace = true, optional = true }
memmap2 = { workspace = true, optional = true }
zerocopy = { version = "0.8", features = ["derive"], optional = true }

[features]
default = []
special_orders = []
nats = ["dep:async-nats", "dep:bytes"]
bincode = ["dep:bincode"]
journal = ["dep:crc32fast", "dep:memmap2"]
wire = ["dep:zerocopy"]

[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,41 @@ This order book engine is built with the following design principles:

### What's New in Version 0.7.0

#### v0.7.0 — Feature-gated binary wire protocol

- **New `wire` feature flag** behind which a small,
length-prefixed binary protocol lives — every frame is
`[len:u32 LE | kind:u8 | payload]`, `len` covers
`kind + payload`, and all multi-byte integers are
little-endian. Disabled by default; the existing JSON and
bincode paths are unchanged. The protocol is additive.
- **`MessageKind`** — `#[repr(u8)]` enum with stable explicit
discriminants. Inbound: `NewOrder = 0x01`,
`CancelOrder = 0x02`, `CancelReplace = 0x03`,
`MassCancel = 0x04`. Outbound: `ExecReport = 0x81`,
`TradePrint = 0x82`, `BookUpdate = 0x83`.
- **Zero-copy inbound** — `NewOrderWire`, `CancelOrderWire`,
`CancelReplaceWire`, `MassCancelWire` are
`#[repr(C, packed)]` with `zerocopy::{FromBytes, IntoBytes,
Unaligned, Immutable, KnownLayout}` derives. Each ships a
`const _: () = assert!(size_of::<…>() == N)` guard. Decoding
is safe — `#![deny(unsafe_code)]` stays on.
- **Byte-cursor outbound** — `ExecReport`, `TradePrintWire`,
`BookUpdateWire` are encoded via explicit
`extend_from_slice` calls. Outbound is I/O-dominated; this
keeps the layout free to evolve.
- **`TryFrom<&NewOrderWire> for OrderType<()>`** — boundary
mapping that copies each packed field into a stack local
first (taking a reference to a packed field is UB), validates
the side / TIF / order_type discriminants, and rejects
negative prices via `WireError::InvalidPayload`.
- **`doc/wire-protocol.md`** with per-message layout tables,
discriminant table, framing rule, and endianness statement.
- **Round-trip `proptest` coverage** in every
`src/wire/{inbound,outbound}/*.rs` module.
- Example: `examples/src/bin/wire_roundtrip.rs`
(`required-features = ["wire"]`).

#### v0.7.0 — HDR-histogram tail-latency bench suite

- **Six new `*_hdr` bench binaries** under
Expand Down
196 changes: 196 additions & 0 deletions doc/wire-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Binary Wire Protocol (feature `wire`)

> Status: MVP / additive. JSON and bincode paths are unchanged. Enable
> with `--features wire`.

The binary wire protocol is a small, fixed-layout, little-endian framing
used by gateways to talk to the engine without going through
`serde_json`. It is intentionally lean — the MVP covers four inbound
order-entry messages and three outbound execution / market-data
messages. A full TCP gateway is out of scope.

## Framing

Every frame on the wire has the layout:

```
+-------------------+--------+--------------------------+
| len (u32 LE) | kind | payload |
| 4 B | 1 B | len - 1 B |
+-------------------+--------+--------------------------+
```

- `len` is the byte length of `kind + payload`. **It does NOT include
the 4-byte `len` prefix itself.** The minimum legal `len` is `1`
(kind byte present, zero-byte payload).
- All multi-byte integers on the wire are **little-endian**.
- Frames have no separator and no trailer — the next frame begins
immediately after the previous one. Decoders should advance their
read cursor by the `bytes_consumed` value returned from
[`decode_frame`](../src/wire/framing.rs).

## `MessageKind` discriminants

Wire codes are stable across `0.7.x` patch releases. Inbound messages
occupy the low half of the byte (`0x01..=0x7F`); outbound messages
occupy the high half (`0x80..=0xFF`). Code `0x00` is reserved as a
"no-message" sentinel.

| Code | Direction | Message | Fixed payload size |
|--------|-----------|-----------------|-------------------:|
| `0x01` | inbound | `NewOrder` | 48 B |
| `0x02` | inbound | `CancelOrder` | 24 B |
| `0x03` | inbound | `CancelReplace` | 40 B |
| `0x04` | inbound | `MassCancel` | 24 B |
| `0x81` | outbound | `ExecReport` | 44 B |
| `0x82` | outbound | `TradePrint` | 48 B |
| `0x83` | outbound | `BookUpdate` | 32 B |

## Inbound layouts

Inbound messages are `#[repr(C, packed)]` and derive
`zerocopy::{FromBytes, IntoBytes, Unaligned, Immutable, KnownLayout}`,
so the gateway can validate-and-cast `&[u8]` into a typed reference
without copying. Decoding is safe — the crate has
`#![deny(unsafe_code)]` on the lib root.

### `NewOrder` (`0x01`) — 48 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|-----------------|------|--------------------------------------|
| 0 | 8 | `client_ts` | u64 | client-side timestamp (ms) |
| 8 | 8 | `order_id` | u64 | unique order id |
| 16 | 8 | `account_id` | u64 | numeric account id |
| 24 | 8 | `price` | i64 | tick-scaled limit price |
| 32 | 8 | `qty` | u64 | quantity |
| 40 | 1 | `side` | u8 | `0` Buy, `1` Sell |
| 41 | 1 | `time_in_force` | u8 | `0` GTC, `1` IOC, `2` FOK, `3` DAY |
| 42 | 1 | `order_type` | u8 | `0` Standard (only one in MVP) |
| 43 | 5 | `_pad` | u8×5 | reserved, must be zero |
| **48** | | **total** | | |

`TryFrom<&NewOrderWire> for OrderType<()>` performs the wire → domain
conversion. `account_id` is encoded into the high 8 bytes of the
domain `Hash32` `user_id` so the field round-trips across the
boundary; gateways performing STP must use a non-zero `account_id`.

### `CancelOrder` (`0x02`) — 24 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|--------------|------|----------------------------|
| 0 | 8 | `client_ts` | u64 | client-side timestamp (ms) |
| 8 | 8 | `order_id` | u64 | order id to cancel |
| 16 | 8 | `account_id` | u64 | numeric account id |
| **24** | | **total** | | |

### `CancelReplace` (`0x03`) — 40 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|--------------|------|-----------------------------|
| 0 | 8 | `client_ts` | u64 | client-side timestamp (ms) |
| 8 | 8 | `order_id` | u64 | original order id |
| 16 | 8 | `account_id` | u64 | numeric account id |
| 24 | 8 | `new_price` | i64 | replacement limit price |
| 32 | 8 | `new_qty` | u64 | replacement quantity |
| **40** | | **total** | | |

### `MassCancel` (`0x04`) — 24 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|--------------|------|--------------------------------------|
| 0 | 8 | `client_ts` | u64 | client-side timestamp (ms) |
| 8 | 8 | `account_id` | u64 | numeric account id |
| 16 | 1 | `scope` | u8 | `0` All, `1` ByAccount, `2` BySide |
| 17 | 7 | `_pad` | u8×7 | for `BySide`, `_pad[0] & 1` = side |
| **24** | | **total** | | |

For `scope == BySide`, the low bit of `_pad[0]` encodes the side
(`0` = Buy, `1` = Sell). Other padding bits must be zero.

## Outbound layouts

Outbound messages use byte-cursor encoders rather than packed structs.
Outbound is I/O-dominated, so the cost of a few dozen bytes of explicit
field-by-field copying into a `Vec<u8>` is dwarfed by socket overhead,
and the layout stays free to evolve without exposing a packed type to
callers.

### `ExecReport` (`0x81`) — 44 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|------------------|------|----------------------------------|
| 0 | 8 | `engine_seq` | u64 | global engine sequence |
| 8 | 8 | `order_id` | u64 | order id |
| 16 | 1 | `status` | u8 | see `STATUS_*` constants below |
| 17 | 8 | `filled_qty` | u64 | cumulative filled quantity |
| 25 | 8 | `remaining_qty` | u64 | quantity still resting |
| 33 | 8 | `price` | i64 | tick-scaled price |
| 41 | 2 | `reject_reason` | u16 | reject code, `0` if not rejected |
| 43 | 1 | `_pad` | u8 | reserved, must be zero |
| **44** | | **total** | | |

`status` discriminants (mirror of `OrderStatus`):

| Code | `OrderStatus` |
|-----:|---------------------|
| 0 | `Open` |
| 1 | `PartiallyFilled` |
| 2 | `Filled` |
| 3 | `Cancelled` |
| 4 | `Rejected` |

The `reject_reason` field carries the `RejectReason` numeric code
(stable across `0.7.x`); see `src/orderbook/reject_reason.rs`.

### `TradePrint` (`0x82`) — 48 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|---------------|------|------------------------------|
| 0 | 8 | `engine_seq` | u64 | global engine sequence |
| 8 | 8 | `maker_id` | u64 | maker order id (resting) |
| 16 | 8 | `taker_id` | u64 | taker order id (incoming) |
| 24 | 8 | `price` | i64 | tick-scaled fill price |
| 32 | 8 | `qty` | u64 | matched quantity |
| 40 | 8 | `ts` | u64 | engine timestamp (ms) |
| **48** | | **total** | | |

### `BookUpdate` (`0x83`) — 32 B

| Offset | Size | Field | Type | Notes |
|-------:|-----:|--------------|------|------------------------------------|
| 0 | 8 | `engine_seq` | u64 | global engine sequence |
| 8 | 1 | `side` | u8 | `0` Buy, `1` Sell |
| 9 | 8 | `price` | i64 | tick-scaled level price |
| 17 | 8 | `qty` | u64 | new total quantity at level (`0` = wiped) |
| 25 | 7 | `_pad` | u8×7 | reserved, must be zero |
| **32** | | **total** | | (rounded to 32 B; trailing pad) |

The trailing 7-byte pad rounds the message to a comfortable 32 B block
and leaves room for forward-compatible field additions without bumping
the wire code.

## Endianness

All multi-byte integers are little-endian. The packed struct memory
layout matches the on-wire byte order on every supported target
(little-endian only). Consumers on big-endian hosts must explicitly
byte-swap when reading the integers — the `decode_*` helpers in this
crate handle that for you.

## Round-trip tests

Every inbound and outbound message has a `proptest` round-trip test
that builds a representative shape, encodes through the framer, and
decodes back. See:

- `src/wire/inbound/new_order.rs`
- `src/wire/inbound/cancel.rs`
- `src/wire/inbound/cancel_replace.rs`
- `src/wire/inbound/mass_cancel.rs`
- `src/wire/outbound/exec_report.rs`
- `src/wire/outbound/trade_print.rs`
- `src/wire/outbound/book_update.rs`

A runnable end-to-end demo lives in
`examples/src/bin/wire_roundtrip.rs` (gated on
`required-features = ["wire"]`).
5 changes: 5 additions & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition = "2024"
[features]
default = []
special_orders = ["orderbook-rs/special_orders"]
wire = ["orderbook-rs/wire"]

[dependencies]
orderbook-rs = { workspace = true }
Expand All @@ -19,3 +20,7 @@ serde_json = { workspace = true }
[[bin]]
name = "special_orders_demo"
required-features = ["special_orders"]

[[bin]]
name = "wire_roundtrip"
required-features = ["wire"]
Loading
Loading