Skip to main content

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