Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
4ce3c35
feat(order): roll up realized quote and fee onto OrderRecord
gregorydemay Jun 23, 2026
a86e05d
test(order): label worked-example fixture as ckBTC, note spec uses ck…
gregorydemay Jun 23, 2026
17109c5
perf(state): settle fills in a single pass without an intermediate Vec
gregorydemay Jun 23, 2026
e71fe50
chore(canister): refresh canbench baseline for order-level scalars
gregorydemay Jun 23, 2026
0efe8e5
test(canister): derive arb_order_record notional via base_scale
gregorydemay Jun 23, 2026
1128a86
test(canister): make realized_scalars a literal ICP/ckUSDT example
gregorydemay Jun 23, 2026
2198311
docs(candid): simplify filled_quote/filled_fee field docs
gregorydemay Jun 23, 2026
6b4a9dd
refactor(state): wrap Fill in FillSettlement, single write gate, rena…
gregorydemay Jun 23, 2026
c730732
test(state): fold realized-scalar coverage into existing fee tests
gregorydemay Jun 23, 2026
e97d66c
test(int): exercise non-zero fees in partial-fill cancel test
gregorydemay Jun 23, 2026
61abf99
chore(canister): refresh canbench baseline for renamed scope
gregorydemay Jun 23, 2026
d08b235
Merge remote-tracking branch 'origin/main' into gdemay/DEFI-2901-orde…
gregorydemay Jun 23, 2026
ccd8246
Merge remote-tracking branch 'origin/main' into gdemay/DEFI-2901-orde…
gregorydemay Jun 24, 2026
f90f243
chore(canister): refresh canbench baseline after main merge
gregorydemay Jun 24, 2026
df627ae
test(state): rename proptest to reflect fill_settlement + push_balanc…
gregorydemay Jun 24, 2026
4abdc5c
docs(did): express order VWAP as filled_quote / filled_quantity
gregorydemay Jun 24, 2026
15e624e
refactor(state): have FillSettlement own its Fill
gregorydemay Jun 24, 2026
9767bfe
docs(types): align filled_quote VWAP docstring with the .did (drop ba…
gregorydemay Jun 24, 2026
e632896
refactor(order): promote FillSettlement to a first-class type
gregorydemay Jun 25, 2026
8860030
Merge remote-tracking branch 'origin/main' into gdemay/DEFI-2901-orde…
gregorydemay Jun 25, 2026
b1b9d56
DEFI-2901: refactor accrue_fill
gregorydemay Jun 25, 2026
20e5b7e
DEFI-2901: refactor FillSettlement
gregorydemay Jun 25, 2026
67700b9
DEFI-2901: remove dead code
gregorydemay Jun 25, 2026
a23bf6e
DEFI-2901: simplify comments
gregorydemay Jun 25, 2026
738a76e
DEFI-2901: remove unnecessary clone
gregorydemay Jun 25, 2026
01ec74c
refactor(canister): encapsulate balance-op construction in a settle()…
gregorydemay Jun 25, 2026
3b664d3
chore(canister): regenerate canbench results after settle() refactor
gregorydemay Jun 25, 2026
c2e8354
perf(canister): gate full settlement under the write gate during replay
gregorydemay Jun 25, 2026
c427980
test(canister): drop review-flagged comments in state tests
gregorydemay Jun 25, 2026
3099210
test(canister): drop duplicate ckUSDT metadata fixture
gregorydemay Jun 25, 2026
c2fa150
test(canister): drive realized-scalar examples from a TestCase table
gregorydemay Jun 25, 2026
f676083
DEFI-2901: complete expectations
gregorydemay Jun 25, 2026
d1d23b4
test(canister): move record_of helper into test_fixtures
gregorydemay Jun 25, 2026
f0e21a0
test(canister): assert filled records via assert_eq_ignoring_timestamp
gregorydemay Jun 25, 2026
933e131
test(canister): drive worked-example matching with mock_runtime_for_t…
gregorydemay Jun 25, 2026
772edb2
perf(canister): gate cancel settlement under the write gate during re…
gregorydemay Jun 25, 2026
3fc7aed
test(canister): build token-id fixtures via SupportedTokens::token_id
gregorydemay Jun 26, 2026
e28d073
test(int): drop redundant comment in cancel partial-fill refund test
gregorydemay Jun 26, 2026
034d957
chore(canister): merge main, adopt collapsed place_* order builder
gregorydemay Jun 26, 2026
0b2fb0b
Merge branch 'main' into gdemay/DEFI-2901-order-level-scalars
gregorydemay Jun 29, 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
346 changes: 173 additions & 173 deletions canister/canbench_results.yml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions canister/oisy_trade.did
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,14 @@ type OrderRecord = record {
last_updated_at : opt nat64;
/// Time-in-force policy the order was placed with.
time_in_force : TimeInForce;
/// Cumulative realized quote notional transacted across the order's fills,
/// in quote-token smallest units. The average execution price is
/// `filled_quote / filled_quantity` (VWAP), a ratio of the two tokens'
/// smallest units.
Comment thread
gregorydemay marked this conversation as resolved.
filled_quote : nat;
/// Cumulative realized fee charged across the order's fills, in the order's
/// receive token — base for a buy, quote for a sell.
filled_fee : nat;
};

/// Request for `get_my_orders`.
Expand Down
42 changes: 2 additions & 40 deletions canister/src/order/book.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use super::plan::{FillPlan, FillPlanBuilder, PlanOutcome};
use super::queue::{OrderQueue, OrderQueueIter};
use super::{
FeeRates, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side, TickSize,
FeeRates, Fill, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side,
TickSize,
};
use minicbor::{Decode, Encode};
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::num::NonZeroU64;

/// Central limit order book for a single trading pair.
///
Expand Down Expand Up @@ -579,44 +579,6 @@ impl MatchResult {
}
}

/// A single fill produced when an incoming order matches a resting order.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct Fill {
/// The sequence of the incoming (taker) order.
#[n(0)]
pub taker_order_seq: OrderSeq,
/// The side of the taker order.
#[n(1)]
pub taker_side: Side,
/// The limit price of the taker order.
#[n(2)]
pub taker_price: Price,
/// The sequence of the resting (maker) order that was matched.
#[n(3)]
pub maker_order_seq: OrderSeq,
/// The price at which the fill occurred (always the maker's price).
#[n(4)]
pub maker_price: Price,
/// The quantity filled.
#[n(5)]
pub quantity: Quantity,
}

impl Fill {
/// The amount of quote tokens exchanged:
/// `maker_price × quantity / base_scale` (`base_scale = 10^base_decimals`).
pub fn quote_amount(&self, base_scale: NonZeroU64) -> Quantity {
self.maker_price
.checked_mul_quantity_scaled(&self.quantity, base_scale)
.expect("BUG: validation of order should prevent overflow")
}

/// The amount of base tokens exchanged (same as quantity).
pub fn base_amount(&self) -> &Quantity {
&self.quantity
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MatchOrderError {
/// Price is not a positive multiple of the tick size.
Expand Down
203 changes: 203 additions & 0 deletions canister/src/order/fill.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use super::{FeeRates, OrderSeq, OrderUpdate, PairToken, Price, Quantity, RemovedOrder, Side};
use crate::state::event;
use minicbor::{Decode, Encode};
use std::collections::BTreeMap;
use std::num::NonZeroU64;

/// A single fill produced when an incoming order matches a resting order.
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct Fill {
/// The sequence of the incoming (taker) order.
#[n(0)]
pub taker_order_seq: OrderSeq,
/// The side of the taker order.
#[n(1)]
pub taker_side: Side,
/// The limit price of the taker order.
#[n(2)]
pub taker_price: Price,
/// The sequence of the resting (maker) order that was matched.
#[n(3)]
pub maker_order_seq: OrderSeq,
/// The price at which the fill occurred (always the maker's price).
#[n(4)]
pub maker_price: Price,
/// The quantity filled.
#[n(5)]
pub quantity: Quantity,
}

impl Fill {
/// The amount of quote tokens exchanged:
/// `maker_price × quantity / base_scale` (`base_scale = 10^base_decimals`).
pub fn quote_amount(&self, base_scale: NonZeroU64) -> Quantity {
self.maker_price
.checked_mul_quantity_scaled(&self.quantity, base_scale)
.expect("BUG: validation of order should prevent overflow")
}

/// The amount of base tokens exchanged (same as quantity).
pub fn base_amount(&self) -> &Quantity {
&self.quantity
}
}

/// A single [`Fill`] together with the realized values derived from it, computed
/// once in settlement (the only point where both `fee_rates` and `base_scale`
/// are in scope) and reused to build both the [`event::BalanceOperation`]s and
/// the per-order scalar deltas, so the two can never diverge.
pub struct FillSettlement {
fill: Fill,
/// Quote notional `maker_price × quantity / base_scale` (the executed
/// price; a buy taker's reservation surplus is excluded).
notional: Quantity,
/// Fee charged to the taker order, in its receive token (base if the taker
/// bought, quote if it sold).
taker_fee: Quantity,
/// Fee charged to the maker order, in its receive token.
maker_fee: Quantity,
/// Quote surplus released back to a buy taker that crossed below its limit;
/// Zero for a sell taker or an exact-price fill.
surplus: Quantity,
}

impl FillSettlement {
/// Compute the realized values of a single fill once.
pub fn new(fill: Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> Self {
// Receive-side convention: buyer pays fee in base (the asset they
// receive), seller in quote. Each side's rate is `taker` if they
// were the taker, else `maker`.
let (buyer_rate, seller_rate) = match fill.taker_side {
Side::Buy => (fee_rates.taker, fee_rates.maker),
Side::Sell => (fee_rates.maker, fee_rates.taker),
};
let notional = fill.quote_amount(base_scale);
let quote_fee = seller_rate.mul_ceil(notional);
let base_fee = buyer_rate.mul_ceil(fill.quantity);
// The taker pays on the side it traded: base if it bought, quote if
// it sold. The maker pays on the opposite side.
let (taker_fee, maker_fee) = match fill.taker_side {
Side::Buy => (base_fee, quote_fee),
Side::Sell => (quote_fee, base_fee),
};
let surplus = if fill.taker_side == Side::Buy
&& let Some(diff) = fill.taker_price.checked_sub(fill.maker_price)
&& !diff.is_zero()
{
diff.checked_mul_quantity_scaled(&fill.quantity, base_scale)
.expect("BUG: price_diff * quantity overflow — validated in validate_limit_order")
} else {
Quantity::ZERO
};
Self {
fill,
notional,
taker_fee,
maker_fee,
surplus,
}
}

/// Push the (up to three) balance operations a single fill settles into `ops`.
pub fn push_balance_operations(&self, ops: &mut Vec<event::BalanceOperation>) {
let fill = &self.fill;
let (buyer_seq, seller_seq) = match fill.taker_side {
Side::Buy => (fill.taker_order_seq, fill.maker_order_seq),
Side::Sell => (fill.maker_order_seq, fill.taker_order_seq),
};
let (quote_fee, base_fee) = match fill.taker_side {
Side::Buy => (self.maker_fee, self.taker_fee),
Side::Sell => (self.taker_fee, self.maker_fee),
};
ops.push(event::BalanceOperation::Transfer {
from_order: buyer_seq,
to_order: seller_seq,
token: PairToken::Quote,
amount: self.notional,
fee: nonzero(quote_fee),
});
if !self.surplus.is_zero() {
ops.push(event::BalanceOperation::Unreserve {
order: fill.taker_order_seq,
token: PairToken::Quote,
amount: self.surplus,
});
}
ops.push(event::BalanceOperation::Transfer {
from_order: seller_seq,
to_order: buyer_seq,
token: PairToken::Base,
amount: fill.quantity,
fee: nonzero(base_fee),
});
}

/// Update maker and taker orders based on this fill.
pub fn accrue_fill(&self, updates: &mut BTreeMap<OrderSeq, OrderUpdate>) {
for (order_seq, fee) in [
(self.fill.maker_order_seq, self.maker_fee),
(self.fill.taker_order_seq, self.taker_fee),
] {
let update = updates.entry(order_seq).or_default();
update.filled_delta = update
.filled_delta
.checked_add(self.fill.quantity)
.expect("BUG: filled_delta overflow");
update.quote_delta = update
.quote_delta
.checked_add(self.notional)
.expect("BUG: quote_delta overflow");
update.fee_delta = update
.fee_delta
.checked_add(fee)
.expect("BUG: fee_delta overflow");
}
}
}

/// The settlement of a removed order (canceled or killed): the placement
/// reservation released back to its owner, computed where `base_scale` is in
/// scope so the matcher stays scale-agnostic.
pub struct RemovedOrderSettlement {
order_seq: OrderSeq,
token: PairToken,
amount: Quantity,
}

impl RemovedOrderSettlement {
/// Compute the reservation released by removing an order.
pub fn new(order_seq: OrderSeq, removed: &RemovedOrder, base_scale: NonZeroU64) -> Self {
let (token, amount) = match removed.side {
Side::Buy => (
PairToken::Quote,
removed
.price
.checked_mul_quantity_scaled(&removed.remaining_quantity, base_scale)
.expect("BUG: price * remaining overflow — validated at placement"),
),
Side::Sell => (PairToken::Base, removed.remaining_quantity),
};
Self {
order_seq,
token,
amount,
}
}

/// Push the single unreserve operation that releases the reservation.
pub fn push_balance_operations(&self, ops: &mut Vec<event::BalanceOperation>) {
ops.push(event::BalanceOperation::Unreserve {
order: self.order_seq,
token: self.token,
amount: self.amount,
});
}
}

/// Collapse a zero-quantity fee to `None`. Keeps `Some(_)` reserved for
/// "fee was actually charged" so callers (audit log, apply path,
/// `/metrics`) can distinguish "no fee on this fill" from "fee of zero
/// charged".
fn nonzero(q: Quantity) -> Option<Quantity> {
if q.is_zero() { None } else { Some(q) }
}
45 changes: 41 additions & 4 deletions canister/src/order/history/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ pub struct OrderRecord {
/// Time-in-force policy the order was placed with.
#[n(8)]
pub time_in_force: TimeInForce,
/// Cumulative realized quote notional transacted across the order's fills,
/// `Σ (maker_price × fill_quantity / base_scale)`. Always quote-denominated;
/// a buy taker's released reservation surplus is excluded.
#[n(9)]
pub filled_quote: Quantity,
Comment thread
gregorydemay marked this conversation as resolved.
/// Cumulative realized fee charged across the order's fills, denominated in
/// the order's receive token — base for a buy, quote for a sell.
#[n(10)]
pub filled_fee: Quantity,
Comment thread
gregorydemay marked this conversation as resolved.
}

impl From<OrderRecord> for oisy_trade_types::OrderRecord {
Expand All @@ -59,17 +68,22 @@ impl From<OrderRecord> for oisy_trade_types::OrderRecord {
created_at: record.created_at.as_nanos(),
last_updated_at: record.last_updated_at.map(|t| t.as_nanos()),
time_in_force: record.time_in_force.into(),
filled_quote: record.filled_quote.into(),
filled_fee: record.filled_fee.into(),
}
}
}

/// A combined update to an order record, applied in a single read-modify-write
/// by [`OrderHistory::apply_update`]: an optional status transition plus a
/// fill delta to add to `filled_quantity`.
/// by [`OrderHistory::apply_update`]: an optional status transition plus the
/// fill, quote, and fee deltas to add to `filled_quantity`, `filled_quote`, and
/// `filled_fee`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct OrderUpdate {
pub status: Option<OrderStatus>,
pub filled_delta: Quantity,
pub quote_delta: Quantity,
pub fee_delta: Quantity,
}

impl OrderUpdate {
Expand All @@ -78,6 +92,8 @@ impl OrderUpdate {
Self {
status: Some(status),
filled_delta: Quantity::ZERO,
quote_delta: Quantity::ZERO,
fee_delta: Quantity::ZERO,
}
}

Expand All @@ -86,6 +102,8 @@ impl OrderUpdate {
Self {
status: None,
filled_delta,
quote_delta: Quantity::ZERO,
fee_delta: Quantity::ZERO,
}
}

Expand All @@ -94,13 +112,16 @@ impl OrderUpdate {
/// # Panics
///
/// `filled_quantity` is monotonic non-decreasing and must never exceed
/// `quantity`; this invariant is enforced by an always-on check that traps
/// on violation.
/// `quantity`; `filled_quote` and `filled_fee` are monotonic
/// non-decreasing. These invariants are enforced by always-on checks that
/// trap on violation.
pub fn apply(self, order: &mut OrderRecord) -> bool {
let mut changed = false;
let OrderUpdate {
status,
filled_delta,
quote_delta,
fee_delta,
} = self;

if let Some(new_status) = status
Expand All @@ -125,6 +146,22 @@ impl OrderUpdate {
order.created_at,
);
}

if quote_delta != Quantity::ZERO {
changed = true;
order.filled_quote = order
.filled_quote
.checked_add(quote_delta)
.expect("BUG: filled_quote overflow");
}

if fee_delta != Quantity::ZERO {
changed = true;
order.filled_fee = order
.filled_fee
.checked_add(fee_delta)
.expect("BUG: filled_fee overflow");
}
changed
}
}
Expand Down
Loading
Loading