Skip to content

Commit a8f9593

Browse files
gregorydemayclaude
andauthored
feat(order): add realized quote and fee onto OrderRecord (#171)
## Purpose `get_my_orders` exposes the original limit price and cumulative base `filled_quantity`, but nothing about the price(s) actually traded — so the realized notional, the volume-weighted average price (VWAP), and the fees paid cannot be recovered from an order. This adds the first, order-level layer of that missing data: two cumulative scalars folded into the write that already updates `filled_quantity`. ## Requirements coverage - **R1** — `filled_quote` accumulates the realized quote notional per fill at the maker price. - **R2** — `filled_fee` accumulates the realized fee in the order's receive token (the amount withheld). - **R6 (order-level)** — both scalars are exposed on `OrderRecord` through `get_my_orders` / `get_my_order`. - **R7** — the scalars are folded into the same per-order write as `filled_quantity` and only under the write gate, so replay does not double-count. - **R9** — every accumulation uses an always-on overflow trap. - **R11** — per-fill notional, fees, and roles are computed once and feed both the balance operations and the per-order deltas, so the two can never diverge. ## Performance impact Measured with canbench (`just bench-check`); deltas are versus the pre-feature baseline. - `matching` scope: ~+10–14% from the inherent per-fill u256 notional and fee rollup. - per-fill `qty` rollup: ~+28–35%, dominated by the u256 `Quantity` arithmetic the realized notional/fee require (this is intrinsic to the feature, not allocation overhead). - `order_history::apply_update`: ~+3–6% for writing the two extra scalar fields. - The settlement path makes a single pass over the fills — no intermediate `Vec<FillSettlement>` is materialized — so the residual cost is the per-fill u256 rollup itself, not allocation. - The whole settlement (per-fill rollup, balance-op construction, per-order writes) now runs only under the write gate, so post-upgrade replay does none of this work; the committed `canbench_results.yml` is unchanged by that gating (`just bench-check` reports no change versus the committed baseline). - The former `status` bench scope is renamed `apply_order_updates` (it wraps the whole per-order apply-update), so its baseline key/value changed accordingly. ## 📚 PR stack 1. **#171 — Order-level scalars** (this PR) — base `main`. 2. **#179 — Fill store (stable-memory persistence)** — base #171. 3. **#186 — Per-order `get_my_trades { ByOrder }` feed** — base #179. 4. **#180 — Account-wide `ByAccount` filter** — base #186. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d395da commit a8f9593

13 files changed

Lines changed: 950 additions & 443 deletions

File tree

canister/canbench_results.yml

Lines changed: 173 additions & 173 deletions
Large diffs are not rendered by default.

canister/oisy_trade.did

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,14 @@ type OrderRecord = record {
297297
last_updated_at : opt nat64;
298298
/// Time-in-force policy the order was placed with.
299299
time_in_force : TimeInForce;
300+
/// Cumulative realized quote notional transacted across the order's fills,
301+
/// in quote-token smallest units. The average execution price is
302+
/// `filled_quote / filled_quantity` (VWAP), a ratio of the two tokens'
303+
/// smallest units.
304+
filled_quote : nat;
305+
/// Cumulative realized fee charged across the order's fills, in the order's
306+
/// receive token — base for a buy, quote for a sell.
307+
filled_fee : nat;
300308
};
301309

302310
/// Request for `get_my_orders`.

canister/src/order/book.rs

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use super::plan::{FillPlan, FillPlanBuilder, PlanOutcome};
22
use super::queue::{OrderQueue, OrderQueueIter};
33
use super::{
4-
FeeRates, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side, TickSize,
4+
FeeRates, Fill, LotSize, Order, OrderBookId, OrderSeq, Price, Quantity, RestingOrder, Side,
5+
TickSize,
56
};
67
use minicbor::{Decode, Encode};
78
use std::cmp::Reverse;
89
use std::collections::{BTreeMap, BTreeSet, VecDeque};
9-
use std::num::NonZeroU64;
1010

1111
/// Central limit order book for a single trading pair.
1212
///
@@ -579,44 +579,6 @@ impl MatchResult {
579579
}
580580
}
581581

582-
/// A single fill produced when an incoming order matches a resting order.
583-
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
584-
pub struct Fill {
585-
/// The sequence of the incoming (taker) order.
586-
#[n(0)]
587-
pub taker_order_seq: OrderSeq,
588-
/// The side of the taker order.
589-
#[n(1)]
590-
pub taker_side: Side,
591-
/// The limit price of the taker order.
592-
#[n(2)]
593-
pub taker_price: Price,
594-
/// The sequence of the resting (maker) order that was matched.
595-
#[n(3)]
596-
pub maker_order_seq: OrderSeq,
597-
/// The price at which the fill occurred (always the maker's price).
598-
#[n(4)]
599-
pub maker_price: Price,
600-
/// The quantity filled.
601-
#[n(5)]
602-
pub quantity: Quantity,
603-
}
604-
605-
impl Fill {
606-
/// The amount of quote tokens exchanged:
607-
/// `maker_price × quantity / base_scale` (`base_scale = 10^base_decimals`).
608-
pub fn quote_amount(&self, base_scale: NonZeroU64) -> Quantity {
609-
self.maker_price
610-
.checked_mul_quantity_scaled(&self.quantity, base_scale)
611-
.expect("BUG: validation of order should prevent overflow")
612-
}
613-
614-
/// The amount of base tokens exchanged (same as quantity).
615-
pub fn base_amount(&self) -> &Quantity {
616-
&self.quantity
617-
}
618-
}
619-
620582
#[derive(Debug, Clone, PartialEq, Eq)]
621583
pub enum MatchOrderError {
622584
/// Price is not a positive multiple of the tick size.

canister/src/order/fill.rs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
use super::{FeeRates, OrderSeq, OrderUpdate, PairToken, Price, Quantity, RemovedOrder, Side};
2+
use crate::state::event;
3+
use minicbor::{Decode, Encode};
4+
use std::collections::BTreeMap;
5+
use std::num::NonZeroU64;
6+
7+
/// A single fill produced when an incoming order matches a resting order.
8+
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
9+
pub struct Fill {
10+
/// The sequence of the incoming (taker) order.
11+
#[n(0)]
12+
pub taker_order_seq: OrderSeq,
13+
/// The side of the taker order.
14+
#[n(1)]
15+
pub taker_side: Side,
16+
/// The limit price of the taker order.
17+
#[n(2)]
18+
pub taker_price: Price,
19+
/// The sequence of the resting (maker) order that was matched.
20+
#[n(3)]
21+
pub maker_order_seq: OrderSeq,
22+
/// The price at which the fill occurred (always the maker's price).
23+
#[n(4)]
24+
pub maker_price: Price,
25+
/// The quantity filled.
26+
#[n(5)]
27+
pub quantity: Quantity,
28+
}
29+
30+
impl Fill {
31+
/// The amount of quote tokens exchanged:
32+
/// `maker_price × quantity / base_scale` (`base_scale = 10^base_decimals`).
33+
pub fn quote_amount(&self, base_scale: NonZeroU64) -> Quantity {
34+
self.maker_price
35+
.checked_mul_quantity_scaled(&self.quantity, base_scale)
36+
.expect("BUG: validation of order should prevent overflow")
37+
}
38+
39+
/// The amount of base tokens exchanged (same as quantity).
40+
pub fn base_amount(&self) -> &Quantity {
41+
&self.quantity
42+
}
43+
}
44+
45+
/// A single [`Fill`] together with the realized values derived from it, computed
46+
/// once in settlement (the only point where both `fee_rates` and `base_scale`
47+
/// are in scope) and reused to build both the [`event::BalanceOperation`]s and
48+
/// the per-order scalar deltas, so the two can never diverge.
49+
pub struct FillSettlement {
50+
fill: Fill,
51+
/// Quote notional `maker_price × quantity / base_scale` (the executed
52+
/// price; a buy taker's reservation surplus is excluded).
53+
notional: Quantity,
54+
/// Fee charged to the taker order, in its receive token (base if the taker
55+
/// bought, quote if it sold).
56+
taker_fee: Quantity,
57+
/// Fee charged to the maker order, in its receive token.
58+
maker_fee: Quantity,
59+
/// Quote surplus released back to a buy taker that crossed below its limit;
60+
/// Zero for a sell taker or an exact-price fill.
61+
surplus: Quantity,
62+
}
63+
64+
impl FillSettlement {
65+
/// Compute the realized values of a single fill once.
66+
pub fn new(fill: Fill, fee_rates: FeeRates, base_scale: NonZeroU64) -> Self {
67+
// Receive-side convention: buyer pays fee in base (the asset they
68+
// receive), seller in quote. Each side's rate is `taker` if they
69+
// were the taker, else `maker`.
70+
let (buyer_rate, seller_rate) = match fill.taker_side {
71+
Side::Buy => (fee_rates.taker, fee_rates.maker),
72+
Side::Sell => (fee_rates.maker, fee_rates.taker),
73+
};
74+
let notional = fill.quote_amount(base_scale);
75+
let quote_fee = seller_rate.mul_ceil(notional);
76+
let base_fee = buyer_rate.mul_ceil(fill.quantity);
77+
// The taker pays on the side it traded: base if it bought, quote if
78+
// it sold. The maker pays on the opposite side.
79+
let (taker_fee, maker_fee) = match fill.taker_side {
80+
Side::Buy => (base_fee, quote_fee),
81+
Side::Sell => (quote_fee, base_fee),
82+
};
83+
let surplus = if fill.taker_side == Side::Buy
84+
&& let Some(diff) = fill.taker_price.checked_sub(fill.maker_price)
85+
&& !diff.is_zero()
86+
{
87+
diff.checked_mul_quantity_scaled(&fill.quantity, base_scale)
88+
.expect("BUG: price_diff * quantity overflow — validated in validate_limit_order")
89+
} else {
90+
Quantity::ZERO
91+
};
92+
Self {
93+
fill,
94+
notional,
95+
taker_fee,
96+
maker_fee,
97+
surplus,
98+
}
99+
}
100+
101+
/// Push the (up to three) balance operations a single fill settles into `ops`.
102+
pub fn push_balance_operations(&self, ops: &mut Vec<event::BalanceOperation>) {
103+
let fill = &self.fill;
104+
let (buyer_seq, seller_seq) = match fill.taker_side {
105+
Side::Buy => (fill.taker_order_seq, fill.maker_order_seq),
106+
Side::Sell => (fill.maker_order_seq, fill.taker_order_seq),
107+
};
108+
let (quote_fee, base_fee) = match fill.taker_side {
109+
Side::Buy => (self.maker_fee, self.taker_fee),
110+
Side::Sell => (self.taker_fee, self.maker_fee),
111+
};
112+
ops.push(event::BalanceOperation::Transfer {
113+
from_order: buyer_seq,
114+
to_order: seller_seq,
115+
token: PairToken::Quote,
116+
amount: self.notional,
117+
fee: nonzero(quote_fee),
118+
});
119+
if !self.surplus.is_zero() {
120+
ops.push(event::BalanceOperation::Unreserve {
121+
order: fill.taker_order_seq,
122+
token: PairToken::Quote,
123+
amount: self.surplus,
124+
});
125+
}
126+
ops.push(event::BalanceOperation::Transfer {
127+
from_order: seller_seq,
128+
to_order: buyer_seq,
129+
token: PairToken::Base,
130+
amount: fill.quantity,
131+
fee: nonzero(base_fee),
132+
});
133+
}
134+
135+
/// Update maker and taker orders based on this fill.
136+
pub fn accrue_fill(&self, updates: &mut BTreeMap<OrderSeq, OrderUpdate>) {
137+
for (order_seq, fee) in [
138+
(self.fill.maker_order_seq, self.maker_fee),
139+
(self.fill.taker_order_seq, self.taker_fee),
140+
] {
141+
let update = updates.entry(order_seq).or_default();
142+
update.filled_delta = update
143+
.filled_delta
144+
.checked_add(self.fill.quantity)
145+
.expect("BUG: filled_delta overflow");
146+
update.quote_delta = update
147+
.quote_delta
148+
.checked_add(self.notional)
149+
.expect("BUG: quote_delta overflow");
150+
update.fee_delta = update
151+
.fee_delta
152+
.checked_add(fee)
153+
.expect("BUG: fee_delta overflow");
154+
}
155+
}
156+
}
157+
158+
/// The settlement of a removed order (canceled or killed): the placement
159+
/// reservation released back to its owner, computed where `base_scale` is in
160+
/// scope so the matcher stays scale-agnostic.
161+
pub struct RemovedOrderSettlement {
162+
order_seq: OrderSeq,
163+
token: PairToken,
164+
amount: Quantity,
165+
}
166+
167+
impl RemovedOrderSettlement {
168+
/// Compute the reservation released by removing an order.
169+
pub fn new(order_seq: OrderSeq, removed: &RemovedOrder, base_scale: NonZeroU64) -> Self {
170+
let (token, amount) = match removed.side {
171+
Side::Buy => (
172+
PairToken::Quote,
173+
removed
174+
.price
175+
.checked_mul_quantity_scaled(&removed.remaining_quantity, base_scale)
176+
.expect("BUG: price * remaining overflow — validated at placement"),
177+
),
178+
Side::Sell => (PairToken::Base, removed.remaining_quantity),
179+
};
180+
Self {
181+
order_seq,
182+
token,
183+
amount,
184+
}
185+
}
186+
187+
/// Push the single unreserve operation that releases the reservation.
188+
pub fn push_balance_operations(&self, ops: &mut Vec<event::BalanceOperation>) {
189+
ops.push(event::BalanceOperation::Unreserve {
190+
order: self.order_seq,
191+
token: self.token,
192+
amount: self.amount,
193+
});
194+
}
195+
}
196+
197+
/// Collapse a zero-quantity fee to `None`. Keeps `Some(_)` reserved for
198+
/// "fee was actually charged" so callers (audit log, apply path,
199+
/// `/metrics`) can distinguish "no fee on this fill" from "fee of zero
200+
/// charged".
201+
fn nonzero(q: Quantity) -> Option<Quantity> {
202+
if q.is_zero() { None } else { Some(q) }
203+
}

canister/src/order/history/mod.rs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ pub struct OrderRecord {
4545
/// Time-in-force policy the order was placed with.
4646
#[n(8)]
4747
pub time_in_force: TimeInForce,
48+
/// Cumulative realized quote notional transacted across the order's fills,
49+
/// `Σ (maker_price × fill_quantity / base_scale)`. Always quote-denominated;
50+
/// a buy taker's released reservation surplus is excluded.
51+
#[n(9)]
52+
pub filled_quote: Quantity,
53+
/// Cumulative realized fee charged across the order's fills, denominated in
54+
/// the order's receive token — base for a buy, quote for a sell.
55+
#[n(10)]
56+
pub filled_fee: Quantity,
4857
}
4958

5059
impl From<OrderRecord> for oisy_trade_types::OrderRecord {
@@ -59,17 +68,22 @@ impl From<OrderRecord> for oisy_trade_types::OrderRecord {
5968
created_at: record.created_at.as_nanos(),
6069
last_updated_at: record.last_updated_at.map(|t| t.as_nanos()),
6170
time_in_force: record.time_in_force.into(),
71+
filled_quote: record.filled_quote.into(),
72+
filled_fee: record.filled_fee.into(),
6273
}
6374
}
6475
}
6576

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

7589
impl OrderUpdate {
@@ -78,6 +92,8 @@ impl OrderUpdate {
7892
Self {
7993
status: Some(status),
8094
filled_delta: Quantity::ZERO,
95+
quote_delta: Quantity::ZERO,
96+
fee_delta: Quantity::ZERO,
8197
}
8298
}
8399

@@ -86,6 +102,8 @@ impl OrderUpdate {
86102
Self {
87103
status: None,
88104
filled_delta,
105+
quote_delta: Quantity::ZERO,
106+
fee_delta: Quantity::ZERO,
89107
}
90108
}
91109

@@ -94,13 +112,16 @@ impl OrderUpdate {
94112
/// # Panics
95113
///
96114
/// `filled_quantity` is monotonic non-decreasing and must never exceed
97-
/// `quantity`; this invariant is enforced by an always-on check that traps
98-
/// on violation.
115+
/// `quantity`; `filled_quote` and `filled_fee` are monotonic
116+
/// non-decreasing. These invariants are enforced by always-on checks that
117+
/// trap on violation.
99118
pub fn apply(self, order: &mut OrderRecord) -> bool {
100119
let mut changed = false;
101120
let OrderUpdate {
102121
status,
103122
filled_delta,
123+
quote_delta,
124+
fee_delta,
104125
} = self;
105126

106127
if let Some(new_status) = status
@@ -125,6 +146,22 @@ impl OrderUpdate {
125146
order.created_at,
126147
);
127148
}
149+
150+
if quote_delta != Quantity::ZERO {
151+
changed = true;
152+
order.filled_quote = order
153+
.filled_quote
154+
.checked_add(quote_delta)
155+
.expect("BUG: filled_quote overflow");
156+
}
157+
158+
if fee_delta != Quantity::ZERO {
159+
changed = true;
160+
order.filled_fee = order
161+
.filled_fee
162+
.checked_add(fee_delta)
163+
.expect("BUG: filled_fee overflow");
164+
}
128165
changed
129166
}
130167
}

0 commit comments

Comments
 (0)