Skip to content

Commit d972652

Browse files
committed
docs(exchange): document FOK ghost-id contract + proptest (N7)
FOK rejections return a `SubmitResult` with a valid, monotonically assigned `order_id` and `status == OrderStatus::Cancelled`, but the id is never inserted into the book — `get_order(id)` returns `None`. This is intentional (keeps the id sequence gap-free for deterministic replay) but was previously only hinted at by a single comment in `submit_limit_internal`. Make it a first-class part of the public contract: - Add a "FOK rejection contract (ghost `OrderId`)" block on `Exchange::submit_limit` explaining exactly what callers must check before querying `get_order`. - Add two proptest invariants in `tests/proptest_invariants.rs`: * `fok_rejected_id_is_ghost`: FOK against an empty book — rejected, id is `> 0`, `get_order(id) == None`, quantities and trades match the rejection invariants. * `fok_rejected_against_insufficient_liquidity`: same, but with some liquidity resting that is strictly less than the FOK size. No behavior change.
1 parent 7531e70 commit d972652

2 files changed

Lines changed: 74 additions & 1 deletion

File tree

src/exchange.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ impl Exchange {
6262
/// - **GTC**: Rests on book until filled or cancelled
6363
/// - **IOC**: Cancelled (never rests)
6464
/// - **FOK**: If cannot fill entirely, order is rejected (no trades)
65+
///
66+
/// # FOK rejection contract (ghost `OrderId`)
67+
///
68+
/// An order rejected under `TimeInForce::FOK` (no full fill available)
69+
/// returns a [`SubmitResult`] with a valid, monotonically-assigned
70+
/// `order_id` and `status == OrderStatus::Cancelled`, but that id is
71+
/// **not** retrievable via [`Self::get_order`] — the order is never
72+
/// inserted into the book and is logically non-existent. The id is
73+
/// consumed so the exchange's id sequence stays gap-free for
74+
/// deterministic replay; callers must not treat it as a handle.
75+
///
76+
/// Callers MUST branch on `result.status` (or check
77+
/// `result.filled_quantity`, `result.cancelled_quantity`) before
78+
/// calling `get_order(result.order_id)`. The invariant
79+
/// "`status == Cancelled` with `filled_quantity == 0` ⇒
80+
/// `get_order(order_id) == None`" holds for FOK rejections.
6581
pub fn submit_limit(
6682
&mut self,
6783
side: Side,

tests/proptest_invariants.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//! These tests use proptest to verify that key invariants hold
77
//! across randomly generated scenarios.
88
9-
use nanobook::{Exchange, Price, Side, StopStatus, TimeInForce};
9+
use nanobook::{Exchange, OrderStatus, Price, Side, StopStatus, TimeInForce};
1010
use proptest::prelude::*;
1111

1212
/// Generate a valid price (positive, reasonable range)
@@ -184,6 +184,63 @@ proptest! {
184184
);
185185
}
186186

187+
/// FOK rejection contract: a rejected FOK returns a valid monotonic
188+
/// `order_id` whose status is `Cancelled`, but the id is NOT retrievable
189+
/// via `get_order`. See `Exchange::submit_limit` doc.
190+
#[test]
191+
fn fok_rejected_id_is_ghost(
192+
side in side_strategy(),
193+
price in price_strategy(),
194+
qty in quantity_strategy(),
195+
) {
196+
let mut exchange = Exchange::new();
197+
198+
// Empty book on the opposite side => FOK cannot fill => rejected.
199+
let result = exchange.submit_limit(side, price, qty, TimeInForce::FOK);
200+
201+
// Rejected with nothing filled and nothing resting.
202+
prop_assert_eq!(result.status, OrderStatus::Cancelled);
203+
prop_assert_eq!(result.filled_quantity, 0);
204+
prop_assert_eq!(result.resting_quantity, 0);
205+
prop_assert_eq!(result.cancelled_quantity, qty);
206+
prop_assert!(result.trades.is_empty());
207+
208+
// The id was consumed (monotonic) but not stored.
209+
prop_assert!(result.order_id.0 > 0, "FOK rejection should still burn an id");
210+
prop_assert!(
211+
exchange.get_order(result.order_id).is_none(),
212+
"FOK-rejected id {:?} leaked into the book",
213+
result.order_id
214+
);
215+
}
216+
217+
/// The ghost-id invariant also holds when there IS liquidity on the
218+
/// opposite side but not enough to fully fill the FOK quantity.
219+
#[test]
220+
fn fok_rejected_against_insufficient_liquidity(
221+
price in price_strategy(),
222+
resting_qty in 1u64..100u64,
223+
extra in 1u64..1000u64,
224+
) {
225+
let mut exchange = Exchange::new();
226+
227+
// Resting ask with limited size.
228+
exchange.submit_limit(Side::Sell, price, resting_qty, TimeInForce::GTC);
229+
230+
// FOK buy for strictly more than is available at or below `price`.
231+
let want = resting_qty + extra;
232+
let result = exchange.submit_limit(Side::Buy, price, want, TimeInForce::FOK);
233+
234+
prop_assert_eq!(result.status, OrderStatus::Cancelled);
235+
prop_assert_eq!(result.filled_quantity, 0);
236+
prop_assert_eq!(result.resting_quantity, 0);
237+
prop_assert!(result.trades.is_empty());
238+
prop_assert!(
239+
exchange.get_order(result.order_id).is_none(),
240+
"FOK-rejected id retrievable via get_order"
241+
);
242+
}
243+
187244
// ========================================================================
188245
// DETERMINISM INVARIANTS
189246
// ========================================================================

0 commit comments

Comments
 (0)