rustrade_execution/exchange/mock/account.rs
1use crate::{
2 UnindexedAccountSnapshot,
3 balance::{AssetBalance, Balance, MarginDetails},
4 exchange::mock::orders::{
5 AlreadyResting, OpenOrder, OpenOrders, Reservation, RestingOrder, as_open,
6 },
7 margin::MarginEffect,
8 order::{
9 Order, UnindexedInactiveOrder,
10 id::ClientOrderId,
11 state::{Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState},
12 },
13 trade::Trade,
14};
15use chrono::{DateTime, Utc};
16use fnv::FnvHashMap;
17use rust_decimal::Decimal;
18use rustrade_instrument::{
19 asset::name::AssetNameExchange, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
20};
21use thiserror::Error;
22
23#[derive(Debug)]
24pub struct AccountState {
25 balances: FnvHashMap<AssetNameExchange, AssetBalance<AssetNameExchange>>,
26 orders_open: OpenOrders,
27 orders_cancelled:
28 FnvHashMap<ClientOrderId, Order<ExchangeId, InstrumentNameExchange, Cancelled>>,
29 /// Orders that filled, kept so a cancel arriving after one can say *why* it failed, and so an
30 /// order-state lookup can report how it ended.
31 ///
32 /// Whole orders, like `orders_cancelled`: the fill's total and average price are not
33 /// recoverable from [`trades`](Self::trades) alone, which records each cross separately. Grows
34 /// with the run, like `trades` and `orders_cancelled` — a simulated venue's ledgers are bounded
35 /// by the dataset, not reaped.
36 orders_filled: FnvHashMap<ClientOrderId, Order<ExchangeId, InstrumentNameExchange, Filled>>,
37 /// Orders retired by their own deadline, kept so they reach a later account snapshot and so a
38 /// cancel arriving after one can say *why* it failed.
39 ///
40 /// Whole orders rather than ids alone, unlike [`orders_filled`](Self::orders_filled): nothing
41 /// else records an expiry, so a snapshot has no other source for it — whereas a fill is already
42 /// in [`trades`](Self::trades). Grows with the run, like `trades` and `orders_cancelled`.
43 orders_expired: FnvHashMap<ClientOrderId, Order<ExchangeId, InstrumentNameExchange, Expired>>,
44 trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
45 /// Whether this is a margin account, which reports every balance's loan, or a cash account,
46 /// which reports none and borrows nothing. Set by [`enable_margin`](Self::enable_margin).
47 margin: bool,
48 /// Counts each time an asset's principal opened from zero or was repaid to zero. Read through
49 /// [`loans_changed`](Self::loans_changed).
50 loans_changed: u64,
51}
52
53impl AccountState {
54 pub fn new(
55 balances: FnvHashMap<AssetNameExchange, AssetBalance<AssetNameExchange>>,
56 orders_open: OpenOrders,
57 orders_cancelled: FnvHashMap<
58 ClientOrderId,
59 Order<ExchangeId, InstrumentNameExchange, Cancelled>,
60 >,
61 trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
62 ) -> Self {
63 Self {
64 balances,
65 orders_open,
66 orders_cancelled,
67 orders_filled: FnvHashMap::default(),
68 orders_expired: FnvHashMap::default(),
69 trades,
70 margin: false,
71 loans_changed: 0,
72 }
73 }
74
75 /// Makes this a margin account: every balance reports its loan, and an order whose
76 /// [`MarginEffect`] says so borrows what an arrival lacks or repays loans from what it
77 /// receives — see [`SpotMarginConfig`](crate::margin::SpotMarginConfig).
78 ///
79 /// A balance that already reports a loan, as one seeded from a live margin account does, keeps
80 /// it; every other balance starts owing nothing.
81 pub fn enable_margin(&mut self) {
82 self.margin = true;
83 for balance in self.balances.values_mut() {
84 balance
85 .balance
86 .margin
87 .get_or_insert_with(MarginDetails::default);
88 }
89 }
90
91 /// The free balance of `asset`, zero for an asset the account has never held.
92 pub fn free(&self, asset: &AssetNameExchange) -> Decimal {
93 self.balances
94 .get(asset)
95 .map_or(Decimal::ZERO, |balance| balance.balance.free)
96 }
97
98 /// Each asset this account owes principal in, with the principal owed.
99 pub fn loans(&self) -> impl Iterator<Item = (&AssetNameExchange, Decimal)> + '_ {
100 self.balances.iter().filter_map(|(asset, balance)| {
101 let borrowed = balance.balance.margin?.borrowed;
102 (borrowed > Decimal::ZERO).then_some((asset, borrowed))
103 })
104 }
105
106 /// A count that changes whenever the set of assets in [`loans`](Self::loans) may have: each
107 /// time an asset's principal opens from zero or is repaid to zero.
108 ///
109 /// For a caller that caches something derived from that set, such as when interest next falls
110 /// due, and would otherwise recompute it after every operation.
111 pub fn loans_changed(&self) -> u64 {
112 self.loans_changed
113 }
114
115 /// Adds `interest` to the interest `asset`'s loan owes, leaving `total` and `free` as they
116 /// are: interest is owed to the lender, and repaid before principal.
117 ///
118 /// # Panics
119 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). A loan opens its
120 /// balance, so an asset with a loan to charge always has one.
121 pub fn charge_interest(
122 &mut self,
123 asset: &AssetNameExchange,
124 interest: Decimal,
125 time_exchange: DateTime<Utc>,
126 ) -> AssetBalance<AssetNameExchange> {
127 let balance = self.balance_expect(asset);
128 balance
129 .balance
130 .margin
131 .get_or_insert_with(MarginDetails::default)
132 .interest += interest;
133 balance.time_exchange = time_exchange;
134
135 balance.clone()
136 }
137
138 /// This account's open orders, indexed for lookup and ordered for matching.
139 pub fn orders(&self) -> &OpenOrders {
140 &self.orders_open
141 }
142
143 /// Takes the order resting under `cid` off the book, with whatever is held against it.
144 ///
145 /// The caller records how it ended, with [`ack_cancelled`](Self::ack_cancelled),
146 /// [`ack_filled`](Self::ack_filled) or [`ack_expired`](Self::ack_expired). Booking is only
147 /// through [`book`](Self::book), which keeps an id in at most one place.
148 pub fn remove_order(&mut self, cid: &ClientOrderId) -> Option<RestingOrder> {
149 self.orders_open.remove(cid)
150 }
151
152 /// Puts `order` on the book holding `reservation`, unless an order is already resting under
153 /// its [`ClientOrderId`].
154 ///
155 /// A client order id names one order at a time, not one for good: once an order has ended its
156 /// id may name a new one. So booking forgets how an earlier order under the same id ended,
157 /// which keeps an id in at most one place, the book or one ledger, and a snapshot from listing
158 /// it twice.
159 ///
160 /// # Errors
161 /// [`AlreadyResting`], with `order` and `reservation` handed back and nothing changed, if an
162 /// order is already resting under its id. See [`OpenOrders::insert`].
163 pub fn book(
164 &mut self,
165 order: OpenOrder,
166 reservation: Option<Reservation>,
167 ) -> Result<(), AlreadyResting> {
168 let cid = order.key.cid.clone();
169 self.orders_open.insert(order, reservation)?;
170 self.forget_ended(&cid);
171 Ok(())
172 }
173
174 /// Holds `order` until its venue releases it, with `reservation` held against it, unless an
175 /// order is already resting under its [`ClientOrderId`]. Forgets how an earlier order under
176 /// the id ended, as [`book`](Self::book) does.
177 ///
178 /// # Errors
179 /// [`AlreadyResting`], as for [`book`](Self::book). See [`OpenOrders::hold`].
180 pub fn hold(
181 &mut self,
182 order: OpenOrder,
183 reservation: Option<Reservation>,
184 ) -> Result<(), AlreadyResting> {
185 let cid = order.key.cid.clone();
186 self.orders_open.hold(order, reservation)?;
187 self.forget_ended(&cid);
188 Ok(())
189 }
190
191 /// The orders held on `instrument`, earliest arrival first, no longer held. See
192 /// [`OpenOrders::take_held`].
193 pub fn take_held(&mut self, instrument: &InstrumentNameExchange) -> Vec<ClientOrderId> {
194 self.orders_open.take_held(instrument)
195 }
196
197 /// Drops what the ledgers remember of how an order under `cid` ended, so that the order about
198 /// to be recorded under it is the only one they hold.
199 fn forget_ended(&mut self, cid: &ClientOrderId) {
200 self.orders_cancelled.remove(cid);
201 self.orders_filled.remove(cid);
202 self.orders_expired.remove(cid);
203 }
204
205 /// Records that `order` was cancelled, so it is reported by a later account snapshot and a
206 /// second cancel for it can be told apart from a cancel for an order that never existed.
207 ///
208 /// Replaces whatever the ledgers held for an earlier order under the same id.
209 pub fn ack_cancelled(&mut self, order: Order<ExchangeId, InstrumentNameExchange, Cancelled>) {
210 self.forget_ended(&order.key.cid);
211 self.orders_cancelled.insert(order.key.cid.clone(), order);
212 }
213
214 /// Whether `cid` names an order this account cancelled.
215 pub fn is_cancelled(&self, cid: &ClientOrderId) -> bool {
216 self.orders_cancelled.contains_key(cid)
217 }
218
219 /// Records that `order` filled, so a cancel that loses the race to it can say so and an
220 /// order-state lookup can report it.
221 ///
222 /// Replaces whatever the ledgers held for an earlier order under the same id.
223 pub fn ack_filled(&mut self, order: Order<ExchangeId, InstrumentNameExchange, Filled>) {
224 self.forget_ended(&order.key.cid);
225 self.orders_filled.insert(order.key.cid.clone(), order);
226 }
227
228 /// Whether `cid` names an order this account filled.
229 pub fn is_filled(&self, cid: &ClientOrderId) -> bool {
230 self.orders_filled.contains_key(cid)
231 }
232
233 /// How the order `cid` ended, or `None` if it is still open or this account never held it.
234 ///
235 /// An order a configured `initial_state` reported as cancelled is found too; one it reported
236 /// in any other inactive state is not, because a snapshot seeds only open and cancelled orders.
237 ///
238 /// An id names at most one order at a time, and an id is in at most one place: the book, or
239 /// one of the ledgers an order can end in, holding the latest order under it (see
240 /// [`book`](Self::book)). So the answer is how the latest order under `cid` ended.
241 pub fn order_ended(&self, cid: &ClientOrderId) -> Option<UnindexedInactiveOrder> {
242 if self.orders_open.contains(cid) {
243 None
244 } else if let Some(filled) = self.orders_filled.get(cid) {
245 Some(filled.clone().map_state(InactiveOrderState::FullyFilled))
246 } else if let Some(cancelled) = self.orders_cancelled.get(cid) {
247 Some(cancelled.clone().map_state(InactiveOrderState::Cancelled))
248 } else {
249 self.orders_expired
250 .get(cid)
251 .map(|expired| expired.clone().map_state(InactiveOrderState::Expired))
252 }
253 }
254
255 /// Records that `order` reached its own deadline, so it is reported by a later account snapshot
256 /// and a cancel arriving after it can be told apart from a cancel for an unknown order.
257 ///
258 /// Replaces whatever the ledgers held for an earlier order under the same id.
259 pub fn ack_expired(&mut self, order: Order<ExchangeId, InstrumentNameExchange, Expired>) {
260 self.forget_ended(&order.key.cid);
261 self.orders_expired.insert(order.key.cid.clone(), order);
262 }
263
264 /// Whether `cid` names an order that reached its deadline while this account held it.
265 pub fn is_expired(&self, cid: &ClientOrderId) -> bool {
266 self.orders_expired.contains_key(cid)
267 }
268
269 /// Restates every balance as of `time_exchange`.
270 ///
271 /// # Open orders keep their own stamps
272 /// [`Open::time_exchange`](crate::order::state::Open::time_exchange) is the instant the venue
273 /// accepted the order, and an order does not become a different order because time passed. This
274 /// used to rewrite it on every advance, which was invisible only because nothing rested: the
275 /// venue filled every order on arrival, so `orders_open` held at most what an `initial_state`
276 /// seeded.
277 ///
278 /// It is not invisible once orders rest. Arrival order is half of price-time priority, so
279 /// rewriting it would make a matching engine's tie-break depend on when the clock last moved
280 /// rather than on when each order arrived — and since every order would be rewritten to the
281 /// same instant, there would be no tie-break left at all. It would also make every open order
282 /// look newer than the engine's copy on each advance, which is exactly what the `rustrade`
283 /// engine's `OrderManager` recency guard reads to decide whether an update is stale.
284 pub fn update_time_exchange(&mut self, time_exchange: DateTime<Utc>) {
285 for balance in self.balances.values_mut() {
286 balance.time_exchange = time_exchange;
287 }
288 }
289
290 pub fn balances(&self) -> impl Iterator<Item = &AssetBalance<AssetNameExchange>> + '_ {
291 self.balances.values()
292 }
293
294 /// Every open order, in no particular order — see [`OpenOrders::resting`] for the order a
295 /// venue matches in.
296 pub fn orders_open(
297 &self,
298 ) -> impl Iterator<Item = &Order<ExchangeId, InstrumentNameExchange, Open>> + '_ {
299 self.orders_open.iter()
300 }
301
302 pub fn orders_cancelled(
303 &self,
304 ) -> impl Iterator<Item = &Order<ExchangeId, InstrumentNameExchange, Cancelled>> + '_ {
305 self.orders_cancelled.values()
306 }
307
308 pub fn orders_expired(
309 &self,
310 ) -> impl Iterator<Item = &Order<ExchangeId, InstrumentNameExchange, Expired>> + '_ {
311 self.orders_expired.values()
312 }
313
314 pub fn trades(
315 &self,
316 time_since: DateTime<Utc>,
317 ) -> impl Iterator<Item = &Trade<AssetNameExchange, InstrumentNameExchange>> + '_ {
318 self.trades
319 .iter()
320 .filter(move |trade| trade.time_exchange >= time_since)
321 }
322
323 pub fn balance_mut(
324 &mut self,
325 asset: &AssetNameExchange,
326 ) -> Option<&mut AssetBalance<AssetNameExchange>> {
327 self.balances.get_mut(asset)
328 }
329
330 pub fn ack_trade(&mut self, trade: Trade<AssetNameExchange, InstrumentNameExchange>) {
331 self.trades.push(trade);
332 }
333
334 /// Holds `amount` of `asset` against an order, moving it out of `free`.
335 ///
336 /// `total` is untouched: the asset is still held, it is merely no longer spendable. The held
337 /// portion is `total - free`, which is what [`Balance::used`](crate::balance::Balance::used)
338 /// reports and what a venue restates to its client.
339 ///
340 /// # Errors
341 /// [`BalanceInsufficient`] if `free` does not cover `amount`. The ledger is left untouched, so
342 /// a caller may reject the order and carry on.
343 ///
344 /// # Panics
345 /// Panics if `asset` has no balance. The balances are the venue's own fixture, so an absent one
346 /// is a mis-specified account rather than a runtime condition — see [`SimulatedVenue`]'s
347 /// caller obligations.
348 ///
349 /// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
350 pub fn reserve(
351 &mut self,
352 asset: &AssetNameExchange,
353 amount: Decimal,
354 time_exchange: DateTime<Utc>,
355 ) -> Result<AssetBalance<AssetNameExchange>, BalanceInsufficient> {
356 let balance = self.balance_expect(asset);
357
358 if balance.balance.free < amount {
359 return Err(BalanceInsufficient {
360 asset: asset.clone(),
361 free: balance.balance.free,
362 required: amount,
363 borrowable: None,
364 min_borrow: None,
365 });
366 }
367
368 balance.balance.free -= amount;
369 balance.time_exchange = time_exchange;
370
371 Ok(balance.clone())
372 }
373
374 /// Settles `amount` of `asset` already held by [`reserve`](Self::reserve) out of the account.
375 ///
376 /// Only `total` moves: `free` was reduced when the amount was reserved. Reserving and then
377 /// settling the same amount therefore lowers both by it, which is what an order that fills on
378 /// arrival does.
379 ///
380 /// # Panics
381 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). Settling more than is
382 /// held would take `total` below `free` and break the ledger's invariant, so it panics in debug
383 /// builds; callers settle an amount they reserved.
384 pub fn settle(
385 &mut self,
386 asset: &AssetNameExchange,
387 amount: Decimal,
388 time_exchange: DateTime<Utc>,
389 ) -> AssetBalance<AssetNameExchange> {
390 let balance = self.balance_expect(asset);
391
392 debug_assert!(
393 balance.balance.total - amount >= balance.balance.free,
394 "settling {amount} of {asset} would take total {} below free {}: only a reserved \
395 amount may be settled",
396 balance.balance.total,
397 balance.balance.free
398 );
399
400 balance.balance.total -= amount;
401 balance.time_exchange = time_exchange;
402
403 balance.clone()
404 }
405
406 /// Gives `amount` of `asset` back to `free`, undoing a [`reserve`](Self::reserve) that will
407 /// never be settled.
408 ///
409 /// Only `free` moves: nothing left the account, so `total` is unchanged. This is what a
410 /// cancelled, expired or rejected order does to the balance held against it.
411 ///
412 /// # Panics
413 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). Releasing more than is
414 /// held would take `free` above `total` and break the ledger's invariant, so it panics in debug
415 /// builds; callers release an amount they reserved.
416 pub fn release(
417 &mut self,
418 asset: &AssetNameExchange,
419 amount: Decimal,
420 time_exchange: DateTime<Utc>,
421 ) -> AssetBalance<AssetNameExchange> {
422 let balance = self.balance_expect(asset);
423
424 debug_assert!(
425 balance.balance.free + amount <= balance.balance.total,
426 "releasing {amount} of {asset} would take free {} above total {}: only a reserved \
427 amount may be released",
428 balance.balance.free,
429 balance.balance.total
430 );
431
432 balance.balance.free += amount;
433 balance.time_exchange = time_exchange;
434
435 balance.clone()
436 }
437
438 /// Moves `amount` of `asset` into the account, or out of it when negative, whatever `free`
439 /// holds.
440 ///
441 /// For a charge the account cannot refuse, such as a holding cost: a venue posts carry whether
442 /// or not the account can cover it. `total` and `free` move together, so what is held stays
443 /// held, and a debit larger than `free` takes `free` below zero rather than being refused. The
444 /// caller reports that, since only it knows what the charge was for.
445 ///
446 /// # Panics
447 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve).
448 pub fn charge(
449 &mut self,
450 asset: &AssetNameExchange,
451 amount: Decimal,
452 time_exchange: DateTime<Utc>,
453 ) -> AssetBalance<AssetNameExchange> {
454 let balance = self.balance_expect(asset);
455
456 balance.balance.total += amount;
457 balance.balance.free += amount;
458 balance.time_exchange = time_exchange;
459
460 balance.clone()
461 }
462
463 /// Moves a [`Delivery`] into the account, or out of it when negative, creating the balance at
464 /// zero if the account has never held its asset.
465 ///
466 /// What a fill delivers: the base a spot buy receives, or the proceeds of a spot sell. A
467 /// venue credits the asset an order receives whether or not the account held it before, so
468 /// unlike every debit, an absent balance here is not a mis-specified fixture. `total` and
469 /// `free` move together, as for [`charge`](Self::charge).
470 ///
471 /// **Unchecked**, like [`charge`](Self::charge): a negative delivery larger than `free` takes
472 /// `free` below zero rather than being refused. [`commit`](Self::commit) is the checked path,
473 /// and a fill that can be refused goes through it.
474 ///
475 /// On a margin account, for an order whose `effect` is [`Repay`](MarginEffect::Repay), what is
476 /// delivered into an asset the account owes repays that loan first, interest then principal,
477 /// and only the rest is added to `free`. Under any other effect, or on a cash account, all of
478 /// it is added to `free`.
479 pub fn deliver(
480 &mut self,
481 delivery: &Delivery,
482 effect: MarginEffect,
483 time_exchange: DateTime<Utc>,
484 ) -> AssetBalance<AssetNameExchange> {
485 let repays = self.margin && effect == MarginEffect::Repay;
486 let balance = self.balance_or_open(&delivery.asset, time_exchange);
487
488 balance.balance.total += delivery.amount;
489 balance.balance.free += delivery.amount;
490 let cleared = repays && repay(&mut balance.balance, delivery.amount);
491 balance.time_exchange = time_exchange;
492
493 let balance = balance.clone();
494 self.loans_changed += u64::from(cleared);
495 balance
496 }
497
498 /// Gives `reservation` back to `free`, as [`release`](Self::release) does, then, with
499 /// `auto_repay_at_cancel`, repays the loan its order took when it was placed
500 /// ([`Reservation::loan`]) out of the free balance, interest first.
501 ///
502 /// What a cancelled, expired or rejected order does to the balance held against it on a margin
503 /// account, which repays an unused loan when the order it was taken for ends. Without
504 /// `auto_repay_at_cancel` the loan stays owed. A reservation that borrowed nothing is simply
505 /// released.
506 ///
507 /// # Panics
508 /// As [`release`](Self::release).
509 pub fn release_reservation(
510 &mut self,
511 reservation: &Reservation,
512 auto_repay_at_cancel: bool,
513 time_exchange: DateTime<Utc>,
514 ) -> AssetBalance<AssetNameExchange> {
515 let released = self.release(&reservation.asset, reservation.amount, time_exchange);
516 match auto_repay_at_cancel && !reservation.loan().is_zero() {
517 false => released,
518 true => self.repay_loan(&reservation.asset, reservation.loan(), time_exchange),
519 }
520 }
521
522 /// Repays up to `amount` of `asset`'s loan out of its free balance, interest first, and never
523 /// more than `free` holds.
524 ///
525 /// # Panics
526 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve).
527 pub fn repay_loan(
528 &mut self,
529 asset: &AssetNameExchange,
530 amount: Decimal,
531 time_exchange: DateTime<Utc>,
532 ) -> AssetBalance<AssetNameExchange> {
533 let balance = self.balance_expect(asset);
534 let available = amount.min(balance.balance.free);
535 let cleared = repay(&mut balance.balance, available);
536 balance.time_exchange = time_exchange;
537
538 let balance = balance.clone();
539 self.loans_changed += u64::from(cleared);
540 balance
541 }
542
543 /// Lends `amount` of `asset`: `total` and `free` rise by it, and so does the loan. An asset the
544 /// account has never held is opened at zero first, as a delivery opens one.
545 fn borrow(&mut self, asset: &AssetNameExchange, amount: Decimal, time_exchange: DateTime<Utc>) {
546 let balance = &mut self.balance_or_open(asset, time_exchange).balance;
547 balance.total += amount;
548 balance.free += amount;
549 let margin = balance.margin.get_or_insert_with(MarginDetails::default);
550 let opened = margin.borrowed.is_zero();
551 margin.borrowed += amount;
552
553 self.loans_changed += u64::from(opened);
554 }
555
556 /// `asset`'s balance, opened at zero if the account has never held it, reporting a loan from
557 /// the start on a margin account.
558 fn balance_or_open(
559 &mut self,
560 asset: &AssetNameExchange,
561 time_exchange: DateTime<Utc>,
562 ) -> &mut AssetBalance<AssetNameExchange> {
563 let margin = self.margin.then(MarginDetails::default);
564 // `SmolStr` clones without allocating for an asset name, so the entry API costs nothing
565 // on the common path where the balance exists.
566 self.balances
567 .entry(asset.clone())
568 .or_insert_with(|| AssetBalance {
569 asset: asset.clone(),
570 balance: Balance {
571 margin,
572 ..Balance::new(Decimal::ZERO, Decimal::ZERO)
573 },
574 time_exchange,
575 })
576 }
577
578 /// Commits one arriving order's whole ledger effect, or none of it.
579 ///
580 /// One order's arrival can both settle a fill and take a hold: a taker that the book could
581 /// only partly fill settles what traded and reserves against the remainder it leaves resting.
582 /// Those are one event at the venue, so they are one operation here — and because every
583 /// requirement is tested before anything moves, an arrival this account cannot afford leaves
584 /// the ledger exactly as it found it.
585 ///
586 /// # Why this is not two calls
587 /// Settling first and reserving second is the same two moves in the order that can fail
588 /// halfway: the settle commits, the reserve is refused, and nothing puts back what `total`
589 /// has already lost — [`release`](Self::release) moves only `free`, and its own assertion
590 /// fires if it is used as a rollback. The venue would then hold a balance its client never
591 /// hears about, silently and for the rest of the run. Asking for the whole requirement up
592 /// front is what makes that state unreachable rather than merely avoided.
593 ///
594 /// # One restatement per asset moved
595 /// A balance is an absolute restatement rather than a delta, and a fill is atomic at the
596 /// venue, so emitting the state between the settle and the hold would report a balance the
597 /// account never held — for the same reason a conservative reservation that had to be released
598 /// and re-debited would. [`Committed`] therefore carries one balance per asset the arrival
599 /// moved: the one it paid with, and the one it received when that is another asset.
600 ///
601 /// # What the fill pays back
602 /// `credit` is what the fill gives the account, and which [`Credit`] it is decides what it
603 /// does:
604 /// - **[`PaidBack`](Credit::PaidBack)**, into the paying asset — the margin and realised PnL
605 /// of the part of a CFD fill that closes a position. It counts towards what `free` must
606 /// cover, because one fill can carry both: a CFD flip, closing a long and opening a short in
607 /// one order, is paid back for the long and posts margin for the short, so the flip is
608 /// funded by the long it closes.
609 /// - **[`Delivery`](Credit::Delivery)**, into another asset — what a spot fill receives: base
610 /// for a buy, the proceeds for a sell, each net of any fee charged in it. It funds nothing,
611 /// since it is not the asset paid with, and it is [delivered](Self::deliver) whether or not
612 /// the account held that asset before. It is negative only for a fill whose fee exceeds
613 /// what it receives, and then that asset's `free` must cover the excess too.
614 ///
615 /// Nothing is credited until every requirement is known to be covered, so a refusal still
616 /// leaves the ledger exactly as it was. `None` is an arrival with nothing to pay back: an
617 /// order that rests having traded nothing.
618 ///
619 /// # What a margin account lends and repays
620 /// On a margin account, for an order whose `effect` is [`Borrow`](MarginEffect::Borrow), a
621 /// requirement `free` and the credit do not cover borrows the shortfall of the paying asset,
622 /// raised to `min_borrow` if it is less, if that loan is at most `borrowable`, the most the
623 /// venue will lend of it now. The loan is taken with the rest of the arrival, or not at all.
624 /// [`Committed::borrowed`] says how much was lent, and [`Committed::excess_borrowed`] how much
625 /// of it the minimum added, which stays free. A paying asset the account has never held has
626 /// nothing free, and is opened when the loan is taken. A cash account, and an order with
627 /// another effect, ignore `borrowable` and `min_borrow`, and a negative `borrowable` lends
628 /// nothing.
629 ///
630 /// For an order whose `effect` is [`Repay`](MarginEffect::Repay), what the credit delivers
631 /// repays the loan of the asset it is delivered into, as [`deliver`](Self::deliver) does.
632 ///
633 /// # Errors
634 /// [`BalanceInsufficient`], naming the asset short, if `free` plus a credit into the paying
635 /// asset, plus what may be borrowed, does not cover [`settled`](Debit::settled) +
636 /// [`reserved`](Debit::reserved), or if a negative credit into another asset exceeds that
637 /// asset's `free`. The ledger is left untouched.
638 ///
639 /// # Panics
640 /// Panics if the paying asset has no balance and the arrival borrows none of it — see
641 /// [`reserve`](Self::reserve). Debug-asserts that a [`PaidBack`](Credit::PaidBack) credit is
642 /// not negative.
643 pub fn commit(
644 &mut self,
645 debit: &Debit,
646 credit: Option<&Credit>,
647 effect: MarginEffect,
648 borrowable: Decimal,
649 min_borrow: Decimal,
650 time_exchange: DateTime<Utc>,
651 ) -> Result<Committed, BalanceInsufficient> {
652 let (funding, delivery) = match credit {
653 Some(Credit::PaidBack(amount)) => {
654 debug_assert!(
655 !amount.is_sign_negative(),
656 "a credit of {amount} {} is negative: a loss beyond the margin is debited",
657 debit.asset
658 );
659 (*amount, None)
660 }
661 Some(Credit::Delivery(delivery)) => (Decimal::ZERO, Some(delivery)),
662 None => (Decimal::ZERO, None),
663 };
664
665 // Asked for as one requirement, so a refusal refuses the arrival rather than half of it.
666 // A margin account lends the shortfall, or the asset's minimum borrow if that is more,
667 // within what the venue allows. It never lends less than the minimum, so a minimum the
668 // venue cannot lend refuses the arrival.
669 // A margin account borrows even an asset it has never held, which it opens on lending.
670 let required = debit.settled + debit.reserved;
671 let lends = self.margin && effect == MarginEffect::Borrow;
672 let free = match self.margin {
673 true => self.free(&debit.asset),
674 false => self.balance_expect(&debit.asset).balance.free,
675 };
676 let shortfall = required - (free + funding);
677 let loan = shortfall.max(min_borrow);
678 let (borrowed, excess_borrowed) = match shortfall > Decimal::ZERO {
679 false => (Decimal::ZERO, Decimal::ZERO),
680 true if lends && loan <= borrowable => (loan, loan - shortfall),
681 true => {
682 return Err(BalanceInsufficient {
683 asset: debit.asset.clone(),
684 free: free + funding,
685 required,
686 borrowable: lends.then_some(borrowable.max(Decimal::ZERO)),
687 // Named only when it is what was refused: the shortfall alone was lendable, so
688 // the loan refused is the minimum it was raised to.
689 min_borrow: (lends && shortfall <= borrowable).then_some(min_borrow),
690 });
691 }
692 };
693
694 // A delivery that takes rather than gives -- a fill whose fee exceeds what it receives --
695 // must be covered before anything moves too. An asset never held has nothing free.
696 if let Some(delivery) = delivery.filter(|delivery| delivery.amount.is_sign_negative()) {
697 let free = self
698 .balances
699 .get(&delivery.asset)
700 .map_or(Decimal::ZERO, |balance| balance.balance.free);
701 if free + delivery.amount < Decimal::ZERO {
702 return Err(BalanceInsufficient {
703 asset: delivery.asset.clone(),
704 free,
705 required: -delivery.amount,
706 borrowable: None,
707 min_borrow: None,
708 });
709 }
710 }
711
712 // Applied only now that every requirement is covered, so a refusal moves nothing.
713 if !borrowed.is_zero() {
714 self.borrow(&debit.asset, borrowed, time_exchange);
715 }
716 if !funding.is_zero() {
717 let balance = self.balance_expect(&debit.asset);
718 balance.balance.total += funding;
719 balance.balance.free += funding;
720 }
721
722 // Infallible: `free` now covers the whole requirement. `total` cannot fall below `free`,
723 // which the reserve takes the whole requirement out of, and whatever was reserved and not
724 // settled stays held.
725 self.reserve(&debit.asset, required, time_exchange)?;
726 let paid = self.settle(&debit.asset, debit.settled, time_exchange);
727 let received = delivery.map(|delivery| self.deliver(delivery, effect, time_exchange));
728
729 Ok(Committed {
730 paid,
731 received,
732 borrowed,
733 excess_borrowed,
734 })
735 }
736
737 #[allow(clippy::expect_used)] // Documented panic: an absent balance is a mis-specified fixture.
738 fn balance_expect(
739 &mut self,
740 asset: &AssetNameExchange,
741 ) -> &mut AssetBalance<AssetNameExchange> {
742 self.balances
743 .get_mut(asset)
744 .expect("SimulatedVenue has Balance for all configured Instrument assets")
745 }
746}
747
748/// Repays up to `available` of `balance`'s loan, interest first, then principal, out of its
749/// `total` and `free`, and returns whether that repaid the last of its principal. Repays nothing
750/// from a `available` that is not positive, or from a balance that owes nothing.
751fn repay(balance: &mut Balance, available: Decimal) -> bool {
752 let Some(margin) = balance.margin.as_mut() else {
753 return false;
754 };
755 let available = available.max(Decimal::ZERO);
756 let interest = margin.interest.min(available);
757 let principal = margin.borrowed.min(available - interest);
758 margin.interest -= interest;
759 margin.borrowed -= principal;
760
761 let repaid = interest + principal;
762 balance.total -= repaid;
763 balance.free -= repaid;
764 principal > Decimal::ZERO && margin.borrowed.is_zero()
765}
766
767/// What one arriving order does to one asset's balance, as a single commitment.
768///
769/// Both fields are denominated in [`asset`](Self::asset) and inclusive of fees. They are one
770/// asset, not two, because they come from one order: which asset an order pays with is decided by
771/// its instrument and its side, and an order has one of each. That is what makes an arrival a
772/// single ledger operation rather than two that must be kept in step.
773///
774/// The three arrivals a [`SimulatedVenue`] can have are the three shapes of this type:
775///
776/// | arrival | `settled` | `reserved` |
777/// |---|---|---|
778/// | fills in full | the fill | zero |
779/// | rests, having traded nothing | zero | the reservation |
780/// | fills in part and rests the remainder | the fill | the remainder's reservation |
781///
782/// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct Debit {
785 /// Which asset the order pays with: quote for a buy or a CFD, base for a spot sell.
786 pub asset: AssetNameExchange,
787
788 /// Left the account outright, because it traded. Lowers `free` and `total` alike.
789 pub settled: Decimal,
790
791 /// Held against a remainder that is still working. Lowers `free` and leaves `total`, so it is
792 /// still the account's until whatever it is held against settles or is released.
793 pub reserved: Decimal,
794}
795
796/// What one fill gives the account. See [`AccountState::commit`].
797///
798/// Two kinds, because where a credit goes decides what it does: one paid back into the asset the
799/// order paid with funds that order, and one delivered in another asset does not.
800#[derive(Debug, Clone, PartialEq, Eq)]
801pub enum Credit {
802 /// Paid back into the asset the order paid with: the margin and realised PnL of the part of a
803 /// CFD fill that closes a position. Zero for a fill that closes nothing, and never negative,
804 /// since a loss beyond the margin is part of the debit.
805 PaidBack(Decimal),
806 /// Delivered in another asset: what a spot fill receives.
807 Delivery(Delivery),
808}
809
810/// What a spot fill delivers, in the asset it receives.
811#[derive(Debug, Clone, PartialEq, Eq)]
812pub struct Delivery {
813 /// The asset received: base for a buy, quote for a sell.
814 pub asset: AssetNameExchange,
815 /// Denominated in [`asset`](Self::asset), net of any fee charged in it. Negative only for a
816 /// fill whose fee exceeds what it receives.
817 pub amount: Decimal,
818}
819
820/// The balances one [`AccountState::commit`] restated, one per asset it moved.
821#[derive(Debug, Clone, PartialEq, Eq)]
822pub struct Committed {
823 /// The asset the order paid with, or held against it.
824 pub paid: AssetBalance<AssetNameExchange>,
825 /// The asset a spot fill delivered, which is never the one paid with. `None` for an arrival
826 /// that moved one asset: a CFD fill, or an order that rests having traded nothing.
827 pub received: Option<AssetBalance<AssetNameExchange>>,
828 /// How much of the paying asset a margin account lent the arrival. Zero when it lent nothing.
829 pub borrowed: Decimal,
830 /// How much of [`borrowed`](Self::borrowed) the paying asset's minimum borrow lent beyond the
831 /// arrival's shortfall. The arrival does not spend it, so it stays free. Zero when the loan
832 /// was the shortfall.
833 pub excess_borrowed: Decimal,
834}
835
836impl Committed {
837 /// How much of [`borrowed`](Self::borrowed) the arrival's shortfall took: the loan less what
838 /// the minimum borrow added.
839 pub fn shortfall_borrowed(&self) -> Decimal {
840 self.borrowed - self.excess_borrowed
841 }
842}
843
844/// A ledger operation asked for more of an asset than `free` covers.
845///
846/// Carries the asset and both sides of the comparison so the caller can report them without
847/// re-reading the ledger it has just been refused by.
848#[derive(Debug, Clone, PartialEq, Eq)]
849pub struct BalanceInsufficient {
850 /// The asset short of what was asked for.
851 pub asset: AssetNameExchange,
852 /// Freely-spendable amount at the moment of refusal.
853 pub free: Decimal,
854 /// Amount the operation asked for.
855 pub required: Decimal,
856 /// How much of the asset a margin account would have lent, or `None` when the account lends
857 /// nothing, as a cash account does.
858 pub borrowable: Option<Decimal>,
859 /// The asset's minimum borrow, when it is why a margin account refused: the venue would have
860 /// lent the shortfall, but not the minimum the loan was raised to. `None` otherwise.
861 pub min_borrow: Option<Decimal>,
862}
863
864/// An account snapshot lists one [`ClientOrderId`] more than once among the orders it seeds.
865///
866/// A snapshot seeds open and cancelled orders, and a client order id names one order at a time,
867/// so it can be listed once among them. Two entries under one id would leave the venue to choose
868/// which order it holds, silently.
869#[derive(Debug, Clone, PartialEq, Eq, Error)]
870#[error("account snapshot lists {0} more than once among its open and cancelled orders")]
871pub struct DuplicateSeededOrder(pub ClientOrderId);
872
873/// Seeds an account from a snapshot, as a [`SimulatedVenue`] does from its `initial_state`.
874///
875/// # Errors
876/// [`DuplicateSeededOrder`] if the snapshot lists one [`ClientOrderId`] more than once among its
877/// open and cancelled orders.
878///
879/// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
880impl TryFrom<UnindexedAccountSnapshot> for AccountState {
881 type Error = DuplicateSeededOrder;
882
883 fn try_from(value: UnindexedAccountSnapshot) -> Result<Self, Self::Error> {
884 let UnindexedAccountSnapshot {
885 exchange: _,
886 balances,
887 instruments,
888 } = value;
889
890 let balances = balances
891 .into_iter()
892 .map(|asset_balance| (asset_balance.asset.clone(), asset_balance))
893 .collect();
894
895 let mut orders_open = OpenOrders::default();
896 let mut orders_cancelled = FnvHashMap::default();
897 for order in instruments.into_iter().flat_map(|snapshot| snapshot.orders) {
898 match &order.state {
899 OrderState::Active(_) => {
900 // `as_open` yields `None` for an active order that is not yet open — an
901 // `OpenInFlight`, which has no venue-side existence to record.
902 let Some(open) = as_open(order) else {
903 continue;
904 };
905 let cid = open.key.cid.clone();
906 if orders_cancelled.contains_key(&cid) {
907 return Err(DuplicateSeededOrder(cid));
908 }
909 // Seeded, not booked here: the venue holds nothing against it.
910 orders_open
911 .insert(open, None)
912 .map_err(|_| DuplicateSeededOrder(cid))?;
913 }
914 OrderState::Inactive(InactiveOrderState::Cancelled(cancelled)) => {
915 let cid = order.key.cid.clone();
916 if orders_open.contains(&cid) || orders_cancelled.contains_key(&cid) {
917 return Err(DuplicateSeededOrder(cid));
918 }
919 let cancelled = cancelled.clone();
920 orders_cancelled.insert(
921 cid,
922 Order {
923 key: order.key,
924 side: order.side,
925 price: order.price,
926 quantity: order.quantity,
927 kind: order.kind,
928 time_in_force: order.time_in_force,
929 state: cancelled,
930 },
931 );
932 }
933 // Not seeded: see `order_ended`.
934 OrderState::Inactive(_) => {}
935 }
936 }
937
938 Ok(Self {
939 balances,
940 orders_open,
941 orders_cancelled,
942 orders_filled: FnvHashMap::default(),
943 orders_expired: FnvHashMap::default(),
944 trades: vec![],
945 margin: false,
946 loans_changed: 0,
947 })
948 }
949}
950
951#[cfg(test)]
952#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
953mod tests {
954 use super::*;
955 use crate::{
956 balance::Balance,
957 exchange::mock::fixtures::{funded, seeded_gtd, spot_config_holding},
958 order::{
959 UnindexedOrder,
960 id::OrderId,
961 state::{Cancelled, Filled},
962 },
963 };
964 use rust_decimal_macros::dec;
965
966 fn usd() -> AssetNameExchange {
967 AssetNameExchange::new("usd")
968 }
969
970 fn account_with_usd(amount: Decimal) -> AccountState {
971 AccountState::new(
972 FnvHashMap::from_iter([(usd(), funded(usd(), amount))]),
973 OpenOrders::default(),
974 FnvHashMap::default(),
975 vec![],
976 )
977 }
978
979 fn usd_balance(account: &AccountState) -> Balance {
980 let Some(balance) = account.balances().find(|balance| balance.asset == usd()) else {
981 panic!("the account holds usd");
982 };
983 balance.balance
984 }
985
986 /// A CFD flip: what closing the old position pays back funds the margin the new one posts,
987 /// so a requirement above `free` alone is met, and the remainder's hold stays held.
988 #[test]
989 fn a_credit_counts_towards_the_requirement_it_arrives_with() {
990 let mut account = account_with_usd(dec!(100));
991 let debit = Debit {
992 asset: usd(),
993 settled: dec!(250),
994 reserved: dec!(40),
995 };
996
997 let credit = Credit::PaidBack(dec!(200));
998
999 let Ok(committed) = account.commit(
1000 &debit,
1001 Some(&credit),
1002 MarginEffect::NoSideEffect,
1003 Decimal::ZERO,
1004 Decimal::ZERO,
1005 DateTime::<Utc>::MIN_UTC,
1006 ) else {
1007 panic!("100 free plus a 200 credit covers 290");
1008 };
1009
1010 // total: 100 + 200 credited - 250 settled; free: that, less the 40 still held.
1011 assert_eq!(committed.paid.balance, Balance::new(dec!(50), dec!(10)));
1012 assert_eq!(
1013 committed.received, None,
1014 "a CFD credit restates the asset it paid with"
1015 );
1016 assert_eq!(usd_balance(&account), Balance::new(dec!(50), dec!(10)));
1017 }
1018
1019 #[test]
1020 fn a_refused_commit_applies_none_of_its_credit() {
1021 let mut account = account_with_usd(dec!(100));
1022 let debit = Debit {
1023 asset: usd(),
1024 settled: dec!(250),
1025 reserved: dec!(60),
1026 };
1027
1028 let credit = Credit::PaidBack(dec!(200));
1029
1030 let Err(insufficient) = account.commit(
1031 &debit,
1032 Some(&credit),
1033 MarginEffect::NoSideEffect,
1034 Decimal::ZERO,
1035 Decimal::ZERO,
1036 DateTime::<Utc>::MIN_UTC,
1037 ) else {
1038 panic!("100 free plus a 200 credit does not cover 310");
1039 };
1040
1041 assert_eq!(
1042 insufficient,
1043 BalanceInsufficient {
1044 asset: usd(),
1045 free: dec!(300),
1046 required: dec!(310),
1047 borrowable: None,
1048 min_borrow: None,
1049 }
1050 );
1051 assert_eq!(usd_balance(&account), Balance::new(dec!(100), dec!(100)));
1052 }
1053
1054 fn btc() -> AssetNameExchange {
1055 AssetNameExchange::new("btc")
1056 }
1057
1058 fn balance_of(account: &AccountState, asset: &AssetNameExchange) -> Option<Balance> {
1059 account
1060 .balances()
1061 .find(|balance| &balance.asset == asset)
1062 .map(|balance| balance.balance)
1063 }
1064
1065 /// A spot buy pays quote and receives base: the base funds nothing, is restated as its own
1066 /// balance, and is created at zero when the account never held it.
1067 #[test]
1068 fn a_credit_into_another_asset_is_delivered_and_restated_separately() {
1069 let mut account = account_with_usd(dec!(100));
1070 let debit = Debit {
1071 asset: usd(),
1072 settled: dec!(100),
1073 reserved: dec!(0),
1074 };
1075 let credit = Credit::Delivery(Delivery {
1076 asset: btc(),
1077 amount: dec!(2),
1078 });
1079
1080 let Ok(committed) = account.commit(
1081 &debit,
1082 Some(&credit),
1083 MarginEffect::NoSideEffect,
1084 Decimal::ZERO,
1085 Decimal::ZERO,
1086 DateTime::<Utc>::MIN_UTC,
1087 ) else {
1088 panic!("100 free covers 100");
1089 };
1090
1091 assert_eq!(committed.paid.balance, Balance::new(dec!(0), dec!(0)));
1092 let Some(received) = committed.received else {
1093 panic!("a credit into another asset restates that asset");
1094 };
1095 assert_eq!(received.asset, btc());
1096 assert_eq!(received.balance, Balance::new(dec!(2), dec!(2)));
1097 assert_eq!(
1098 balance_of(&account, &btc()),
1099 Some(Balance::new(dec!(2), dec!(2)))
1100 );
1101 }
1102
1103 /// A credit into another asset is not the asset paid with, so it cannot fund the debit.
1104 #[test]
1105 fn a_credit_into_another_asset_does_not_fund_the_debit() {
1106 let mut account = account_with_usd(dec!(100));
1107 let debit = Debit {
1108 asset: usd(),
1109 settled: dec!(101),
1110 reserved: dec!(0),
1111 };
1112 let credit = Credit::Delivery(Delivery {
1113 asset: btc(),
1114 amount: dec!(1000),
1115 });
1116
1117 let Err(insufficient) = account.commit(
1118 &debit,
1119 Some(&credit),
1120 MarginEffect::NoSideEffect,
1121 Decimal::ZERO,
1122 Decimal::ZERO,
1123 DateTime::<Utc>::MIN_UTC,
1124 ) else {
1125 panic!("100 free does not cover 101, whatever another asset is credited");
1126 };
1127
1128 assert_eq!(
1129 insufficient,
1130 BalanceInsufficient {
1131 asset: usd(),
1132 free: dec!(100),
1133 required: dec!(101),
1134 borrowable: None,
1135 min_borrow: None,
1136 }
1137 );
1138 assert_eq!(usd_balance(&account), Balance::new(dec!(100), dec!(100)));
1139 assert_eq!(
1140 balance_of(&account, &btc()),
1141 None,
1142 "a refusal creates nothing"
1143 );
1144 }
1145
1146 /// A fill whose fee exceeds what it receives takes the excess from the asset it receives, and
1147 /// is refused, moving nothing, when that asset cannot cover it.
1148 #[test]
1149 fn a_negative_delivery_must_be_covered_by_its_own_asset() {
1150 let mut account = AccountState::new(
1151 FnvHashMap::from_iter([
1152 (usd(), funded(usd(), dec!(3))),
1153 (btc(), funded(btc(), dec!(1))),
1154 ]),
1155 OpenOrders::default(),
1156 FnvHashMap::default(),
1157 vec![],
1158 );
1159 let debit = Debit {
1160 asset: btc(),
1161 settled: dec!(1),
1162 reserved: dec!(0),
1163 };
1164 let short = Credit::Delivery(Delivery {
1165 asset: usd(),
1166 amount: dec!(-4),
1167 });
1168
1169 let Err(insufficient) = account.commit(
1170 &debit,
1171 Some(&short),
1172 MarginEffect::NoSideEffect,
1173 Decimal::ZERO,
1174 Decimal::ZERO,
1175 DateTime::<Utc>::MIN_UTC,
1176 ) else {
1177 panic!("3 usd free does not cover a 4 usd excess fee");
1178 };
1179 assert_eq!(
1180 insufficient,
1181 BalanceInsufficient {
1182 asset: usd(),
1183 free: dec!(3),
1184 required: dec!(4),
1185 borrowable: None,
1186 min_borrow: None,
1187 }
1188 );
1189 assert_eq!(
1190 balance_of(&account, &btc()),
1191 Some(Balance::new(dec!(1), dec!(1)))
1192 );
1193 assert_eq!(usd_balance(&account), Balance::new(dec!(3), dec!(3)));
1194
1195 let covered = Credit::Delivery(Delivery {
1196 asset: usd(),
1197 amount: dec!(-2),
1198 });
1199 let Ok(committed) = account.commit(
1200 &debit,
1201 Some(&covered),
1202 MarginEffect::NoSideEffect,
1203 Decimal::ZERO,
1204 Decimal::ZERO,
1205 DateTime::<Utc>::MIN_UTC,
1206 ) else {
1207 panic!("3 usd free covers a 2 usd excess fee");
1208 };
1209 assert_eq!(committed.paid.balance, Balance::new(dec!(0), dec!(0)));
1210 assert_eq!(
1211 committed.received.map(|balance| balance.balance),
1212 Some(Balance::new(dec!(1), dec!(1)))
1213 );
1214 }
1215
1216 #[test]
1217 fn a_charge_moves_total_and_free_together_and_may_take_free_negative() {
1218 let mut account = account_with_usd(dec!(100));
1219 account
1220 .reserve(&usd(), dec!(60), DateTime::<Utc>::MIN_UTC)
1221 .unwrap();
1222
1223 let charged = account.charge(&usd(), dec!(-50), DateTime::<Utc>::MIN_UTC);
1224 assert_eq!(charged.balance, Balance::new(dec!(50), dec!(-10)));
1225 assert_eq!(usd_balance(&account), Balance::new(dec!(50), dec!(-10)));
1226
1227 let credited = account.charge(&usd(), dec!(15), DateTime::<Utc>::MIN_UTC);
1228 assert_eq!(credited.balance, Balance::new(dec!(65), dec!(5)));
1229 }
1230
1231 /// A snapshot holding `orders`, as a configured `initial_state` would.
1232 fn snapshot_of(orders: impl IntoIterator<Item = UnindexedOrder>) -> UnindexedAccountSnapshot {
1233 let mut orders = orders.into_iter();
1234 let first = orders.next().unwrap();
1235 let mut snapshot = spot_config_holding("1", "1000", first).initial_state;
1236 snapshot.instruments[0].orders.extend(orders);
1237 snapshot
1238 }
1239
1240 fn open(cid: &str) -> UnindexedOrder {
1241 let at = DateTime::<Utc>::MIN_UTC;
1242 seeded_gtd(cid, "100", at, at + chrono::TimeDelta::days(1))
1243 }
1244
1245 fn ended(
1246 cid: &str,
1247 state: OrderState<AssetNameExchange, InstrumentNameExchange>,
1248 ) -> UnindexedOrder {
1249 UnindexedOrder { state, ..open(cid) }
1250 }
1251
1252 fn cancelled(cid: &str) -> UnindexedOrder {
1253 let at = DateTime::<Utc>::MIN_UTC;
1254 ended(
1255 cid,
1256 OrderState::inactive(Cancelled::new(OrderId::new(cid), at, None)),
1257 )
1258 }
1259
1260 /// One id listed twice among the orders a snapshot seeds is refused, rather than one of them
1261 /// silently winning, whichever two of open and cancelled they are.
1262 #[test]
1263 fn a_snapshot_seeding_one_id_twice_is_refused() {
1264 for (orders, case) in [
1265 ([open("x"), open("x")], "open twice"),
1266 ([open("x"), cancelled("x")], "open, then cancelled"),
1267 ([cancelled("x"), open("x")], "cancelled, then open"),
1268 ([cancelled("x"), cancelled("x")], "cancelled twice"),
1269 ] {
1270 assert_eq!(
1271 AccountState::try_from(snapshot_of(orders)).err(),
1272 Some(DuplicateSeededOrder(ClientOrderId::new("x"))),
1273 "{case}"
1274 );
1275 }
1276 }
1277
1278 /// An order the snapshot does not seed holds no id, so it cannot clash with one it does.
1279 #[test]
1280 fn an_order_a_snapshot_does_not_seed_does_not_clash() {
1281 let at = DateTime::<Utc>::MIN_UTC;
1282 let filled = ended(
1283 "x",
1284 OrderState::fully_filled(Filled::new(OrderId::new("x"), at, dec!(1), None)),
1285 );
1286
1287 let Ok(account) = AccountState::try_from(snapshot_of([filled, open("x")])) else {
1288 panic!("a filled order is not seeded");
1289 };
1290 assert!(account.orders().contains(&ClientOrderId::new("x")));
1291 }
1292
1293 /// A margin account owing 30 usd of principal and 1 of interest out of 100 held, as one seeded
1294 /// from a live margin account does: no loan has opened through it, so its count starts at zero.
1295 fn account_owing_usd() -> AccountState {
1296 let owing = AssetBalance::new(
1297 usd(),
1298 Balance::new_margin(dec!(100), dec!(100), dec!(30), dec!(1)),
1299 DateTime::<Utc>::MIN_UTC,
1300 );
1301 let mut account = AccountState::new(
1302 FnvHashMap::from_iter([(usd(), owing)]),
1303 OpenOrders::default(),
1304 FnvHashMap::default(),
1305 vec![],
1306 );
1307 account.enable_margin();
1308 account
1309 }
1310
1311 #[test]
1312 fn the_loan_count_changes_only_when_a_principal_is_repaid_to_zero() {
1313 let mut account = account_owing_usd();
1314 let at = DateTime::<Utc>::MIN_UTC;
1315
1316 account.deliver(
1317 &Delivery {
1318 asset: btc(),
1319 amount: dec!(1),
1320 },
1321 MarginEffect::Repay,
1322 at,
1323 );
1324 assert_eq!(
1325 account.loans_changed(),
1326 0,
1327 "a delivery into an asset owing nothing repays no loan"
1328 );
1329
1330 // The 1 interest, then 10 of the principal.
1331 account.repay_loan(&usd(), dec!(11), at);
1332 assert_eq!(
1333 account.loans_changed(),
1334 0,
1335 "20 of the principal is still owed"
1336 );
1337
1338 account.repay_loan(&usd(), dec!(20), at);
1339 assert_eq!(account.loans_changed(), 1);
1340 assert_eq!(account.loans().count(), 0);
1341 }
1342}