Skip to content
Draft
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
6ad0096
refactor(matching): split order matching into plan and execute (1/3)
gregorydemay Jun 18, 2026
3576638
perf(matching): make plan_fills allocation-free on the no-fill path
gregorydemay Jun 18, 2026
dea5921
fix(matching): trap with explicit message on plan/apply divergence
gregorydemay Jun 18, 2026
4cf0ad8
test(matching): regenerate canbench baseline for plan/execute split
gregorydemay Jun 18, 2026
ae5b339
refactor(order): move plan/execute matching into order/plan.rs
gregorydemay Jun 18, 2026
1d0dfc7
refactor(order): inline the two side-walkers into plan_fills
gregorydemay Jun 18, 2026
de45ee7
test(order): compare filled_orders in plan_execute property test
gregorydemay Jun 18, 2026
2638622
refactor(order): explicit divergence message on index removal
gregorydemay Jun 18, 2026
99491a9
refactor(order): move plan/execute methods back onto OrderBook
gregorydemay Jun 18, 2026
9bf1425
refactor(order): introduce OrderIter and use it for best_bid/best_ask
gregorydemay Jun 19, 2026
fa79f5f
refactor(order): pass &Order to plan_fills
gregorydemay Jun 19, 2026
fcc811f
DEFI-2853: refactor plan
gregorydemay Jun 19, 2026
6510752
DEFI-2853: refactor apply plan
gregorydemay Jun 19, 2026
0849006
DEFI-2853: refactor apply plan
gregorydemay Jun 19, 2026
2333a69
DEFI-2853: update canbench
gregorydemay Jun 19, 2026
c8fa885
test(order): drop redundant plan_fills read-only assertion
gregorydemay Jun 19, 2026
98ae9b6
perf(matching): drop redundant per-fill checked_sub in FillPlan::add_…
gregorydemay Jun 19, 2026
cd01e9d
test(order): remove redundant plan_execute property test
gregorydemay Jun 19, 2026
e08d0ef
test(order): add parameterized pop_front tests asserting popped order…
gregorydemay Jun 19, 2026
e623a91
refactor(order): keep order-book iter/pop helpers out of the public API
gregorydemay Jun 19, 2026
a226708
docs(order): fix FillPlan::add_fill panic doc to match enforced invar…
gregorydemay Jun 19, 2026
1043c3a
refactor(order): extract OrderQueue wrapper for per-side book storage
gregorydemay Jun 19, 2026
af8a634
refactor(order): extract OrderQueue wrapper for per-side book storage
gregorydemay Jun 19, 2026
0cbf0b2
merge(order): rebase plan/execute onto extracted OrderQueue base
gregorydemay Jun 19, 2026
8d95d44
refactor(order): drop empty levels in OrderQueue::from_levels
gregorydemay Jun 19, 2026
c2a9625
merge(order): pull OrderQueue::from_levels empty-level fix into plan/…
gregorydemay Jun 19, 2026
f04aad1
test(order): group OrderQueue tests into per-method submodules
gregorydemay Jun 19, 2026
0a33d34
merge(order): pull OrderQueue test reorganization into plan/execute
gregorydemay Jun 19, 2026
481fbc5
test(order): drop pop_front tests redundant with OrderQueue's
gregorydemay Jun 19, 2026
18029b1
refactor(order): use levels().next() for best-level peek in match_order
gregorydemay Jun 19, 2026
d44fb93
merge(order): pull match_order levels().next() cleanup into plan/execute
gregorydemay Jun 19, 2026
b584c41
feat(order): add FOK data model — TimeInForce, Expired status (2/3)
gregorydemay Jun 19, 2026
b89efbf
fix(order): add time_in_force to PendingOrder inits in benchmarks
gregorydemay Jun 19, 2026
057a1d7
chore(order): regenerate canbench baseline for time_in_force field
gregorydemay Jun 19, 2026
925b844
refactor(order)!: collapse cancel terminal-state errors into OrderNot…
gregorydemay Jun 22, 2026
c364849
test(order): replace manual Order minicbor roundtrip with proptest
gregorydemay Jun 22, 2026
bfc351b
test(order): use explicit LegacyOrder struct for legacy-decode test
gregorydemay Jun 22, 2026
d038765
refactor(order)!: rename OrderNotCancelable to OrderAlreadyTerminal
gregorydemay Jun 22, 2026
191f514
refactor(order): hide btree_map::Iter behind impl Iterator in OrderQu…
gregorydemay Jun 22, 2026
1c17b41
merge(order): pull OrderQueue::levels impl-Iterator cleanup into plan…
gregorydemay Jun 22, 2026
f79ccfc
merge(order): pull OrderQueue::levels impl-Iterator cleanup into FOK …
gregorydemay Jun 22, 2026
72a0863
merge(order): merge main into FOK data model, reconcile with disposit…
gregorydemay Jun 22, 2026
0fb8272
feat(matching): gate FOK orders on require_full and release the reser…
gregorydemay Jun 22, 2026
005196a
test(matching): cover FOK fill, kill, no-partial, fee, and terminal o…
gregorydemay Jun 22, 2026
fca97f4
test(matching): add worst-case FOK sweep benchmark
gregorydemay Jun 22, 2026
018444f
docs(matching): document FOK time-in-force and Canceled vs Expired
gregorydemay Jun 22, 2026
b399d6d
refactor(state): de-duplicate refund computation into refund_for
gregorydemay Jun 22, 2026
b92a81d
refactor(order): drop dead Encode/Decode on MatchingOutput and Remove…
gregorydemay Jun 22, 2026
d747d24
docs(design): show direct Pending to Filled path in lifecycle diagram
gregorydemay Jun 22, 2026
bc4ac29
fix(order): preserve time_in_force for resting orders
gregorydemay Jun 22, 2026
4b8d6b4
merge: pull RestingOrder time_in_force fix from #164 into matching-gate
gregorydemay Jun 22, 2026
55d6851
test(order): remove redundant time_in_force resting-order test
gregorydemay Jun 22, 2026
a66bf98
merge: pull redundant-test removal from #164 into matching-gate
gregorydemay Jun 23, 2026
f6bde59
test(fok): cover user-cancel of a FOK order (Canceled vs Expired)
gregorydemay Jun 23, 2026
23010b2
merge: bring in latest main (FOK data model #164 merged)
gregorydemay Jun 23, 2026
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
277 changes: 197 additions & 80 deletions canister/canbench_results.yml

Large diffs are not rendered by default.

25 changes: 21 additions & 4 deletions canister/oisy_trade.did
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ type TradingPair = record {
};

// Request to place a new limit order.
// Time-in-force policy governing how long a limit order stays active in the
// order book.
type TimeInForce = variant {
// Rests in the book until filled or canceled; may fill partially over time.
GoodTilCanceled;
// Must fill in full when the engine processes it, otherwise the whole order
// is killed with zero execution. Never rests.
FillOrKill;
};

type LimitOrderRequest = record {
// The trading pair to place the order on.
pair : TradingPair;
Expand All @@ -88,6 +98,8 @@ type LimitOrderRequest = record {
// Order quantity in base-token smallest units. Must be a positive
// multiple of the pair's `lot_size`.
quantity : nat;
// Time-in-force policy. Absent defaults to GoodTilCanceled.
time_in_force : opt TimeInForce;
};


Expand Down Expand Up @@ -131,10 +143,9 @@ type CancelLimitOrderError = record {
OrderNotFound;
// The caller does not own the order.
NotOrderOwner;
// The order has already been fully filled and cannot be canceled.
OrderAlreadyFilled;
// The order has already been canceled.
OrderAlreadyCanceled;
// The order has reached a terminal state (Filled, Canceled, or
// Expired) and can no longer be canceled.
OrderAlreadyTerminal;
};
TemporaryError : opt variant {};
InternalError : opt variant {};
Expand Down Expand Up @@ -242,6 +253,9 @@ type OrderStatus = variant {
Filled;
/// The order has been canceled.
Canceled;
// The order was terminated by the engine because its time-in-force could
// not be honored (a Fill-or-Kill that could not fully fill).
Expired;
};

/// Full view of an order as stored by the canister.
Expand All @@ -265,6 +279,8 @@ type OrderRecord = record {
/// Time of the most recent modifying event (fill, status transition, or
/// cancel) in nanoseconds since the Unix epoch; null until first modified.
last_updated_at : opt nat64;
/// Time-in-force policy the order was placed with.
time_in_force : TimeInForce;
};

/// Request for `get_my_orders`.
Expand Down Expand Up @@ -596,6 +612,7 @@ type AddLimitOrderEvent = record {
side : Side;
price : nat;
quantity : nat;
time_in_force : TimeInForce;
};

// Event payload for a deposit.
Expand Down
62 changes: 61 additions & 1 deletion canister/src/benchmarks.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::order::{
BasisPoint, FeeRates, LotSize, OrderBookId, PendingOrder, Price, Quantity, Side, TickSize,
TokenId, TokenMetadata, TradingPair,
TimeInForce, TokenId, TokenMetadata, TradingPair,
};

use crate::EXECUTOR;
Expand Down Expand Up @@ -40,6 +40,7 @@ fn bench_process_pending_orders_1_large() -> canbench_rs::BenchResult {
side: Side::Sell,
price: Price::new(TICK_SIZE.get()), // 0.001 USDT — crosses all bids
quantity: Quantity::from(100_000_000_000_000u64), // 1,000,000 ICP
time_in_force: TimeInForce::GoodTilCanceled,
},
);

Expand All @@ -59,6 +60,59 @@ fn bench_process_pending_orders_1_large() -> canbench_rs::BenchResult {
res
}

/// Benchmark a worst-case fill-or-kill order that sweeps the entire ask side
/// (5000 fragmented levels from the Binance depth snapshot) in a single
/// message. The FOK is priced at the worst ask and sized to the total ask
/// depth, so it fully fills — exercising the plan pass plus an `apply_plan`
/// replay and one settlement step per level, all atomically. Asserts the FOK
/// reaches a terminal state and the ask side is emptied (R10).
#[bench(raw)]
fn bench_fill_or_kill_sweep_full_ask_side() -> canbench_rs::BenchResult {
let depth = load_depth();
let mut state = new_state();

populate_state(&mut state, &depth);

let pair = trading_pair();
let (worst_ask_price, total_ask_qty) = depth.asks.iter().fold(
(0u128, 0u128),
|(max_price, total_qty), (price_str, qty_str)| {
(
max_price.max(parse_decimal_8(price_str)),
total_qty + parse_decimal_8(qty_str),
)
},
);

let taker = user((depth.bids.len() + depth.asks.len()) as u64);
fund_user(&mut state, taker);
place_order(
&mut state,
taker,
PendingOrder {
side: Side::Buy,
price: Price::new(worst_ask_price),
quantity: Quantity::from_u128(total_ask_qty),
time_in_force: TimeInForce::FillOrKill,
},
);

let book = state.get_order_book(&pair).unwrap();
assert_eq!(book.pending_orders_len(), 1);
assert_eq!(book.asks_len(), depth.asks.len());

state.set_execution_policy(ExecutionPolicy::MAX);
let res = canbench_rs::bench_fn(|| {
EXECUTOR.run_once(&mut state, &crate::IC_RUNTIME);
});

let book = state.get_order_book(&pair).unwrap();
assert_eq!(book.pending_orders_len(), 0);
assert_eq!(book.asks_len(), 0);
Comment on lines +109 to +111

res
}

/// Benchmark processing 1000 incoming orders against a fully populated order book
/// using real Binance ICP/USDT data (697 bid levels + 5000 ask levels).
/// Each order is placed by a different user (worst case for balance lookups).
Expand Down Expand Up @@ -99,6 +153,7 @@ fn bench_process_pending_orders_1000_with(fee_rates: FeeRates) -> canbench_rs::B
side: if trade.m { Side::Sell } else { Side::Buy },
price: Price::new(parse_decimal_8(&trade.p)),
quantity: Quantity::from_u128(parse_decimal_8(&trade.q)),
time_in_force: TimeInForce::GoodTilCanceled,
},
);
}
Expand Down Expand Up @@ -243,6 +298,7 @@ fn bench_get_my_orders() -> canbench_rs::BenchResult {
side: if trade.m { Side::Sell } else { Side::Buy },
price: Price::new(parse_decimal_8(&trade.p)),
quantity: Quantity::from_u128(parse_decimal_8(&trade.q)),
time_in_force: TimeInForce::GoodTilCanceled,
},
);
}
Expand Down Expand Up @@ -409,6 +465,7 @@ fn populate_state(state: &mut State<storage::VMem, storage::VMem>, depth: &Depth
side: Side::Buy,
price: Price::new(parse_decimal_8(price_str)),
quantity: Quantity::from_u128(parse_decimal_8(qty_str)),
time_in_force: TimeInForce::GoodTilCanceled,
},
);
}
Expand All @@ -422,6 +479,7 @@ fn populate_state(state: &mut State<storage::VMem, storage::VMem>, depth: &Depth
side: Side::Sell,
price: Price::new(parse_decimal_8(price_str)),
quantity: Quantity::from_u128(parse_decimal_8(qty_str)),
time_in_force: TimeInForce::GoodTilCanceled,
},
);
}
Expand Down Expand Up @@ -454,6 +512,7 @@ fn place_1000_non_crossing_orders(state: &mut State<storage::VMem, storage::VMem
side: Side::Buy,
price: Price::new(200_000_000), // 2.000 USDT
quantity: Quantity::from((i + 1) * LOT_SIZE.get()),
time_in_force: TimeInForce::GoodTilCanceled,
},
);
}
Expand All @@ -467,6 +526,7 @@ fn place_1000_non_crossing_orders(state: &mut State<storage::VMem, storage::VMem
side: Side::Sell,
price: Price::new(300_000_000), // 3.000 USDT
quantity: Quantity::from((i + 1) * LOT_SIZE.get()),
time_in_force: TimeInForce::GoodTilCanceled,
},
);
}
Expand Down
4 changes: 3 additions & 1 deletion canister/src/dashboard/tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::{DashboardTemplate, bar_width_percent, saturating_to_u128};
use crate::order::{
BasisPoint, FeeRates, OrderBookId, OrderId, PendingOrder, Price, Quantity, Side, TradingPair,
BasisPoint, FeeRates, OrderBookId, OrderId, PendingOrder, Price, Quantity, Side, TimeInForce,
TradingPair,
};
use crate::state::{StableMemoryOptions, State};
use crate::test_fixtures::mocks::mock_runtime_for;
Expand Down Expand Up @@ -249,6 +250,7 @@ fn place(
side,
price: Price::new(price * PRICE_SCALE),
quantity: Quantity::from(quantity),
time_in_force: TimeInForce::GoodTilCanceled,
};
let (token, required) = match pending.side {
Side::Buy => (
Expand Down
1 change: 1 addition & 0 deletions canister/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub fn add_limit_order(
side: order.side(),
price: order.price(),
quantity: *order.remaining_quantity(),
time_in_force: Some(order.time_in_force()),
};
state::audit::process_event(
s,
Expand Down
2 changes: 2 additions & 0 deletions canister/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ fn get_events(
side,
price,
quantity,
time_in_force,
},
) => event::EventType::AddLimitOrder(event::AddLimitOrderEvent {
user,
Expand All @@ -305,6 +306,7 @@ fn get_events(
side: oisy_trade_types::Side::from(side),
price: candid::Nat::from(price),
quantity: quantity.into(),
time_in_force: time_in_force.unwrap_or_default().into(),
}),
EventType::CancelLimitOrder(
oisy_trade_canister::state::event::CancelLimitOrderEvent { order_id },
Expand Down
63 changes: 53 additions & 10 deletions canister/src/order/book.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::plan::FillPlan;
use super::queue::{OrderQueue, OrderQueueIter};
use super::{
FeeRates, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side, TickSize,
TimeInForce,
};
use minicbor::{Decode, Encode};
use std::cmp::Reverse;
Expand Down Expand Up @@ -151,19 +152,43 @@ impl OrderBook {
.map(|(&price, resting)| resting.to_order(Side::Sell, price))
}

/// Match an incoming order against the book.
/// Match an incoming GTC order against the book.
///
/// Validates tick size, lot size, and rejects zero price/quantity, then attempts
/// to fill the order against the opposite side. Returns:
/// - [`MatchResult::Filled`] if the order is fully filled.
/// - [`MatchResult::PartiallyFilled`] if partially filled with the remainder resting.
/// - [`MatchResult::Resting`] if no match was found and the order rests as-is.
pub(crate) fn match_order(&mut self, mut order: Order) -> Result<MatchResult, MatchOrderError> {
#[cfg(test)]
pub(crate) fn match_order(&mut self, order: Order) -> Result<MatchResult, MatchOrderError> {
self.execute(order, false)
}

/// Plan-then-apply matching for an incoming order, gated by `require_full`.
///
/// Validates the order, then plans the fills it would make against the
/// crossing side (read-only). When `require_full` is set (FOK) and the plan
/// does not fully fill the order, returns [`MatchResult::Killed`] **before**
/// any mutation, so the book is provably untouched. Otherwise applies the
/// plan and either reports [`MatchResult::Filled`] or rests the remainder
/// ([`MatchResult::PartiallyFilled`] / [`MatchResult::Resting`]).
pub(crate) fn execute(
&mut self,
mut order: Order,
require_full: bool,
) -> Result<MatchResult, MatchOrderError> {
#[cfg(feature = "canbench-rs")]
let _p = canbench_rs::bench_scope("book::match_order");
self.validate_order(order.price(), order.remaining_quantity())?;

let plan = self.plan_fills(&order);

if require_full && !plan.taker_remaining().is_zero() {
return Ok(MatchResult::Killed {
killed_order_seq: order.id(),
});
}

let fills = self.apply_plan(plan, &mut order);

if order.remaining_quantity().is_zero() {
Expand Down Expand Up @@ -337,6 +362,7 @@ impl OrderBook {
pub(crate) fn process_pending_orders(&mut self, expected_seqs: &[OrderSeq]) -> MatchingOutput {
let mut all_fills = Vec::new();
let mut resting_order_seqs = BTreeSet::new();
let mut expired_orders = BTreeMap::new();
for expected_seq in expected_seqs {
let order = self
.pending_orders
Expand All @@ -347,7 +373,19 @@ impl OrderBook {
*expected_seq,
"BUG: pending order seq mismatch at the head of the queue"
);
match self.match_order(order) {
let require_full = matches!(order.time_in_force(), TimeInForce::FillOrKill);
let killed = require_full.then(|| RemovedOrder {
side: order.side(),
price: order.price(),
remaining_quantity: *order.remaining_quantity(),
});
match self.execute(order, require_full) {
Ok(MatchResult::Killed { killed_order_seq }) => {
expired_orders.insert(
killed_order_seq,
killed.expect("BUG: Killed outcome only when require_full"),
);
}
Ok(result) => {
if let Some(resting_order_seq) = result.resting_order_seq() {
resting_order_seqs.insert(resting_order_seq);
Expand Down Expand Up @@ -379,6 +417,7 @@ impl OrderBook {
fills: all_fills,
resting_orders,
filled_orders,
expired_orders,
}
}

Expand Down Expand Up @@ -489,19 +528,20 @@ pub struct RemovedOrder {

/// Output of [`OrderBook::process_pending_orders`]: the fills produced,
/// orders that began resting in the book, and orders that were fully filled.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use = "MatchingOutput must be applied to order_history via `record_matching_event`; \
dropping it leaves the order book and order_history out of sync"]
pub struct MatchingOutput {
/// Fills executed during this matching round, in execution order.
#[n(0)]
pub fills: Vec<Fill>,
/// Orders that were not fully filled and are now resting in the book.
#[n(1)]
pub resting_orders: BTreeSet<OrderSeq>,
/// Orders that were fully filled and removed from the book.
#[n(2)]
pub filled_orders: BTreeSet<OrderSeq>,
/// Fill-or-kill orders that could not fully fill and were killed without
/// resting or producing a fill. Each maps to the killed order's side,
/// price, and full quantity so the placement reservation can be released.
pub expired_orders: BTreeMap<OrderSeq, RemovedOrder>,
}

/// The result of matching an incoming order against the book.
Expand All @@ -516,13 +556,16 @@ pub enum MatchResult {
},
/// No match was found; the order is resting in the book.
Resting { resting_order_seq: OrderSeq },
/// A fill-or-kill order that could not fully fill. It was not applied to the
/// book and produced no fill (R4, R5).
Killed { killed_order_seq: OrderSeq },
}

impl MatchResult {
pub fn fills(&self) -> &[Fill] {
match self {
MatchResult::Filled { fills } | MatchResult::PartiallyFilled { fills, .. } => fills,
MatchResult::Resting { .. } => &[],
MatchResult::Resting { .. } | MatchResult::Killed { .. } => &[],
}
}

Expand All @@ -532,14 +575,14 @@ impl MatchResult {
resting_order_seq, ..
}
| MatchResult::Resting { resting_order_seq } => Some(*resting_order_seq),
MatchResult::Filled { .. } => None,
MatchResult::Filled { .. } | MatchResult::Killed { .. } => None,
}
}

pub fn into_fills(self) -> Vec<Fill> {
match self {
MatchResult::Filled { fills } | MatchResult::PartiallyFilled { fills, .. } => fills,
MatchResult::Resting { .. } => Vec::new(),
MatchResult::Resting { .. } | MatchResult::Killed { .. } => Vec::new(),
}
}
}
Expand Down
Loading
Loading