Skip to main content

rustrade_execution/exchange/mock/
account.rs

1use crate::{
2    UnindexedAccountSnapshot,
3    balance::{AssetBalance, Balance},
4    exchange::mock::orders::{
5        AlreadyResting, OpenOrder, OpenOrders, Reservation, RestingOrder, as_open,
6    },
7    order::{
8        Order, UnindexedInactiveOrder,
9        id::ClientOrderId,
10        state::{Cancelled, Expired, Filled, InactiveOrderState, Open, OrderState},
11    },
12    trade::Trade,
13};
14use chrono::{DateTime, Utc};
15use fnv::FnvHashMap;
16use rust_decimal::Decimal;
17use rustrade_instrument::{
18    asset::name::AssetNameExchange, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
19};
20use thiserror::Error;
21
22#[derive(Debug)]
23pub struct AccountState {
24    balances: FnvHashMap<AssetNameExchange, AssetBalance<AssetNameExchange>>,
25    orders_open: OpenOrders,
26    orders_cancelled:
27        FnvHashMap<ClientOrderId, Order<ExchangeId, InstrumentNameExchange, Cancelled>>,
28    /// Orders that filled, kept so a cancel arriving after one can say *why* it failed, and so an
29    /// order-state lookup can report how it ended.
30    ///
31    /// Whole orders, like `orders_cancelled`: the fill's total and average price are not
32    /// recoverable from [`trades`](Self::trades) alone, which records each cross separately. Grows
33    /// with the run, like `trades` and `orders_cancelled` — a simulated venue's ledgers are bounded
34    /// by the dataset, not reaped.
35    orders_filled: FnvHashMap<ClientOrderId, Order<ExchangeId, InstrumentNameExchange, Filled>>,
36    /// Orders retired by their own deadline, kept so they reach a later account snapshot and so a
37    /// cancel arriving after one can say *why* it failed.
38    ///
39    /// Whole orders rather than ids alone, unlike [`orders_filled`](Self::orders_filled): nothing
40    /// else records an expiry, so a snapshot has no other source for it — whereas a fill is already
41    /// in [`trades`](Self::trades). Grows with the run, like `trades` and `orders_cancelled`.
42    orders_expired: FnvHashMap<ClientOrderId, Order<ExchangeId, InstrumentNameExchange, Expired>>,
43    trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
44}
45
46impl AccountState {
47    pub fn new(
48        balances: FnvHashMap<AssetNameExchange, AssetBalance<AssetNameExchange>>,
49        orders_open: OpenOrders,
50        orders_cancelled: FnvHashMap<
51            ClientOrderId,
52            Order<ExchangeId, InstrumentNameExchange, Cancelled>,
53        >,
54        trades: Vec<Trade<AssetNameExchange, InstrumentNameExchange>>,
55    ) -> Self {
56        Self {
57            balances,
58            orders_open,
59            orders_cancelled,
60            orders_filled: FnvHashMap::default(),
61            orders_expired: FnvHashMap::default(),
62            trades,
63        }
64    }
65
66    /// This account's open orders, indexed for lookup and ordered for matching.
67    pub fn orders(&self) -> &OpenOrders {
68        &self.orders_open
69    }
70
71    /// Takes the order resting under `cid` off the book, with whatever is held against it.
72    ///
73    /// The caller records how it ended, with [`ack_cancelled`](Self::ack_cancelled),
74    /// [`ack_filled`](Self::ack_filled) or [`ack_expired`](Self::ack_expired). Booking is only
75    /// through [`book`](Self::book), which keeps an id in at most one place.
76    pub fn remove_order(&mut self, cid: &ClientOrderId) -> Option<RestingOrder> {
77        self.orders_open.remove(cid)
78    }
79
80    /// Puts `order` on the book holding `reservation`, unless an order is already resting under
81    /// its [`ClientOrderId`].
82    ///
83    /// A client order id names one order at a time, not one for good: once an order has ended its
84    /// id may name a new one. So booking forgets how an earlier order under the same id ended,
85    /// which keeps an id in at most one place, the book or one ledger, and a snapshot from listing
86    /// it twice.
87    ///
88    /// # Errors
89    /// [`AlreadyResting`], with `order` and `reservation` handed back and nothing changed, if an
90    /// order is already resting under its id. See [`OpenOrders::insert`].
91    pub fn book(
92        &mut self,
93        order: OpenOrder,
94        reservation: Option<Reservation>,
95    ) -> Result<(), AlreadyResting> {
96        let cid = order.key.cid.clone();
97        self.orders_open.insert(order, reservation)?;
98        self.forget_ended(&cid);
99        Ok(())
100    }
101
102    /// Drops what the ledgers remember of how an order under `cid` ended, so that the order about
103    /// to be recorded under it is the only one they hold.
104    fn forget_ended(&mut self, cid: &ClientOrderId) {
105        self.orders_cancelled.remove(cid);
106        self.orders_filled.remove(cid);
107        self.orders_expired.remove(cid);
108    }
109
110    /// Records that `order` was cancelled, so it is reported by a later account snapshot and a
111    /// second cancel for it can be told apart from a cancel for an order that never existed.
112    ///
113    /// Replaces whatever the ledgers held for an earlier order under the same id.
114    pub fn ack_cancelled(&mut self, order: Order<ExchangeId, InstrumentNameExchange, Cancelled>) {
115        self.forget_ended(&order.key.cid);
116        self.orders_cancelled.insert(order.key.cid.clone(), order);
117    }
118
119    /// Whether `cid` names an order this account cancelled.
120    pub fn is_cancelled(&self, cid: &ClientOrderId) -> bool {
121        self.orders_cancelled.contains_key(cid)
122    }
123
124    /// Records that `order` filled, so a cancel that loses the race to it can say so and an
125    /// order-state lookup can report it.
126    ///
127    /// Replaces whatever the ledgers held for an earlier order under the same id.
128    pub fn ack_filled(&mut self, order: Order<ExchangeId, InstrumentNameExchange, Filled>) {
129        self.forget_ended(&order.key.cid);
130        self.orders_filled.insert(order.key.cid.clone(), order);
131    }
132
133    /// Whether `cid` names an order this account filled.
134    pub fn is_filled(&self, cid: &ClientOrderId) -> bool {
135        self.orders_filled.contains_key(cid)
136    }
137
138    /// How the order `cid` ended, or `None` if it is still open or this account never held it.
139    ///
140    /// An order a configured `initial_state` reported as cancelled is found too; one it reported
141    /// in any other inactive state is not, because a snapshot seeds only open and cancelled orders.
142    ///
143    /// An id names at most one order at a time, and an id is in at most one place: the book, or
144    /// one of the ledgers an order can end in, holding the latest order under it (see
145    /// [`book`](Self::book)). So the answer is how the latest order under `cid` ended.
146    pub fn order_ended(&self, cid: &ClientOrderId) -> Option<UnindexedInactiveOrder> {
147        if self.orders_open.contains(cid) {
148            None
149        } else if let Some(filled) = self.orders_filled.get(cid) {
150            Some(filled.clone().map_state(InactiveOrderState::FullyFilled))
151        } else if let Some(cancelled) = self.orders_cancelled.get(cid) {
152            Some(cancelled.clone().map_state(InactiveOrderState::Cancelled))
153        } else {
154            self.orders_expired
155                .get(cid)
156                .map(|expired| expired.clone().map_state(InactiveOrderState::Expired))
157        }
158    }
159
160    /// Records that `order` reached its own deadline, so it is reported by a later account snapshot
161    /// and a cancel arriving after it can be told apart from a cancel for an unknown order.
162    ///
163    /// Replaces whatever the ledgers held for an earlier order under the same id.
164    pub fn ack_expired(&mut self, order: Order<ExchangeId, InstrumentNameExchange, Expired>) {
165        self.forget_ended(&order.key.cid);
166        self.orders_expired.insert(order.key.cid.clone(), order);
167    }
168
169    /// Whether `cid` names an order that reached its deadline while this account held it.
170    pub fn is_expired(&self, cid: &ClientOrderId) -> bool {
171        self.orders_expired.contains_key(cid)
172    }
173
174    /// Restates every balance as of `time_exchange`.
175    ///
176    /// # Open orders keep their own stamps
177    /// [`Open::time_exchange`](crate::order::state::Open::time_exchange) is the instant the venue
178    /// accepted the order, and an order does not become a different order because time passed. This
179    /// used to rewrite it on every advance, which was invisible only because nothing rested: the
180    /// venue filled every order on arrival, so `orders_open` held at most what an `initial_state`
181    /// seeded.
182    ///
183    /// It is not invisible once orders rest. Arrival order is half of price-time priority, so
184    /// rewriting it would make a matching engine's tie-break depend on when the clock last moved
185    /// rather than on when each order arrived — and since every order would be rewritten to the
186    /// same instant, there would be no tie-break left at all. It would also make every open order
187    /// look newer than the engine's copy on each advance, which is exactly what the `rustrade`
188    /// engine's `OrderManager` recency guard reads to decide whether an update is stale.
189    pub fn update_time_exchange(&mut self, time_exchange: DateTime<Utc>) {
190        for balance in self.balances.values_mut() {
191            balance.time_exchange = time_exchange;
192        }
193    }
194
195    pub fn balances(&self) -> impl Iterator<Item = &AssetBalance<AssetNameExchange>> + '_ {
196        self.balances.values()
197    }
198
199    /// Every open order, in no particular order — see [`OpenOrders::resting`] for the order a
200    /// venue matches in.
201    pub fn orders_open(
202        &self,
203    ) -> impl Iterator<Item = &Order<ExchangeId, InstrumentNameExchange, Open>> + '_ {
204        self.orders_open.iter()
205    }
206
207    pub fn orders_cancelled(
208        &self,
209    ) -> impl Iterator<Item = &Order<ExchangeId, InstrumentNameExchange, Cancelled>> + '_ {
210        self.orders_cancelled.values()
211    }
212
213    pub fn orders_expired(
214        &self,
215    ) -> impl Iterator<Item = &Order<ExchangeId, InstrumentNameExchange, Expired>> + '_ {
216        self.orders_expired.values()
217    }
218
219    pub fn trades(
220        &self,
221        time_since: DateTime<Utc>,
222    ) -> impl Iterator<Item = &Trade<AssetNameExchange, InstrumentNameExchange>> + '_ {
223        self.trades
224            .iter()
225            .filter(move |trade| trade.time_exchange >= time_since)
226    }
227
228    pub fn balance_mut(
229        &mut self,
230        asset: &AssetNameExchange,
231    ) -> Option<&mut AssetBalance<AssetNameExchange>> {
232        self.balances.get_mut(asset)
233    }
234
235    pub fn ack_trade(&mut self, trade: Trade<AssetNameExchange, InstrumentNameExchange>) {
236        self.trades.push(trade);
237    }
238
239    /// Holds `amount` of `asset` against an order, moving it out of `free`.
240    ///
241    /// `total` is untouched: the asset is still held, it is merely no longer spendable. The held
242    /// portion is `total - free`, which is what [`Balance::used`](crate::balance::Balance::used)
243    /// reports and what a venue restates to its client.
244    ///
245    /// # Errors
246    /// [`BalanceInsufficient`] if `free` does not cover `amount`. The ledger is left untouched, so
247    /// a caller may reject the order and carry on.
248    ///
249    /// # Panics
250    /// Panics if `asset` has no balance. The balances are the venue's own fixture, so an absent one
251    /// is a mis-specified account rather than a runtime condition — see [`SimulatedVenue`]'s
252    /// caller obligations.
253    ///
254    /// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
255    pub fn reserve(
256        &mut self,
257        asset: &AssetNameExchange,
258        amount: Decimal,
259        time_exchange: DateTime<Utc>,
260    ) -> Result<AssetBalance<AssetNameExchange>, BalanceInsufficient> {
261        let balance = self.balance_expect(asset);
262
263        if balance.balance.free < amount {
264            return Err(BalanceInsufficient {
265                asset: asset.clone(),
266                free: balance.balance.free,
267                required: amount,
268            });
269        }
270
271        balance.balance.free -= amount;
272        balance.time_exchange = time_exchange;
273
274        Ok(balance.clone())
275    }
276
277    /// Settles `amount` of `asset` already held by [`reserve`](Self::reserve) out of the account.
278    ///
279    /// Only `total` moves: `free` was reduced when the amount was reserved. Reserving and then
280    /// settling the same amount therefore lowers both by it, which is what an order that fills on
281    /// arrival does.
282    ///
283    /// # Panics
284    /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). Settling more than is
285    /// held would take `total` below `free` and break the ledger's invariant, so it panics in debug
286    /// builds; callers settle an amount they reserved.
287    pub fn settle(
288        &mut self,
289        asset: &AssetNameExchange,
290        amount: Decimal,
291        time_exchange: DateTime<Utc>,
292    ) -> AssetBalance<AssetNameExchange> {
293        let balance = self.balance_expect(asset);
294
295        debug_assert!(
296            balance.balance.total - amount >= balance.balance.free,
297            "settling {amount} of {asset} would take total {} below free {}: only a reserved \
298             amount may be settled",
299            balance.balance.total,
300            balance.balance.free
301        );
302
303        balance.balance.total -= amount;
304        balance.time_exchange = time_exchange;
305
306        balance.clone()
307    }
308
309    /// Gives `amount` of `asset` back to `free`, undoing a [`reserve`](Self::reserve) that will
310    /// never be settled.
311    ///
312    /// Only `free` moves: nothing left the account, so `total` is unchanged. This is what a
313    /// cancelled, expired or rejected order does to the balance held against it.
314    ///
315    /// # Panics
316    /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). Releasing more than is
317    /// held would take `free` above `total` and break the ledger's invariant, so it panics in debug
318    /// builds; callers release an amount they reserved.
319    pub fn release(
320        &mut self,
321        asset: &AssetNameExchange,
322        amount: Decimal,
323        time_exchange: DateTime<Utc>,
324    ) -> AssetBalance<AssetNameExchange> {
325        let balance = self.balance_expect(asset);
326
327        debug_assert!(
328            balance.balance.free + amount <= balance.balance.total,
329            "releasing {amount} of {asset} would take free {} above total {}: only a reserved \
330             amount may be released",
331            balance.balance.free,
332            balance.balance.total
333        );
334
335        balance.balance.free += amount;
336        balance.time_exchange = time_exchange;
337
338        balance.clone()
339    }
340
341    /// Moves `amount` of `asset` into the account, or out of it when negative, whatever `free`
342    /// holds.
343    ///
344    /// For a charge the account cannot refuse, such as a holding cost: a venue posts carry whether
345    /// or not the account can cover it. `total` and `free` move together, so what is held stays
346    /// held, and a debit larger than `free` takes `free` below zero rather than being refused. The
347    /// caller reports that, since only it knows what the charge was for.
348    ///
349    /// # Panics
350    /// Panics if `asset` has no balance — see [`reserve`](Self::reserve).
351    pub fn charge(
352        &mut self,
353        asset: &AssetNameExchange,
354        amount: Decimal,
355        time_exchange: DateTime<Utc>,
356    ) -> AssetBalance<AssetNameExchange> {
357        let balance = self.balance_expect(asset);
358
359        balance.balance.total += amount;
360        balance.balance.free += amount;
361        balance.time_exchange = time_exchange;
362
363        balance.clone()
364    }
365
366    /// Moves a [`Delivery`] into the account, or out of it when negative, creating the balance at
367    /// zero if the account has never held its asset.
368    ///
369    /// What a fill delivers: the base a spot buy receives, or the proceeds of a spot sell. A
370    /// venue credits the asset an order receives whether or not the account held it before, so
371    /// unlike every debit, an absent balance here is not a mis-specified fixture. `total` and
372    /// `free` move together, as for [`charge`](Self::charge).
373    ///
374    /// **Unchecked**, like [`charge`](Self::charge): a negative delivery larger than `free` takes
375    /// `free` below zero rather than being refused. [`commit`](Self::commit) is the checked path,
376    /// and a fill that can be refused goes through it.
377    pub fn deliver(
378        &mut self,
379        delivery: &Delivery,
380        time_exchange: DateTime<Utc>,
381    ) -> AssetBalance<AssetNameExchange> {
382        let balance = match self.balances.get_mut(&delivery.asset) {
383            Some(balance) => balance,
384            None => self
385                .balances
386                .entry(delivery.asset.clone())
387                .or_insert_with(|| AssetBalance {
388                    asset: delivery.asset.clone(),
389                    balance: Balance::new(Decimal::ZERO, Decimal::ZERO),
390                    time_exchange,
391                }),
392        };
393
394        balance.balance.total += delivery.amount;
395        balance.balance.free += delivery.amount;
396        balance.time_exchange = time_exchange;
397
398        balance.clone()
399    }
400
401    /// Commits one arriving order's whole ledger effect, or none of it.
402    ///
403    /// One order's arrival can both settle a fill and take a hold: a taker that the book could
404    /// only partly fill settles what traded and reserves against the remainder it leaves resting.
405    /// Those are one event at the venue, so they are one operation here — and because every
406    /// requirement is tested before anything moves, an arrival this account cannot afford leaves
407    /// the ledger exactly as it found it.
408    ///
409    /// # Why this is not two calls
410    /// Settling first and reserving second is the same two moves in the order that can fail
411    /// halfway: the settle commits, the reserve is refused, and nothing puts back what `total`
412    /// has already lost — [`release`](Self::release) moves only `free`, and its own assertion
413    /// fires if it is used as a rollback. The venue would then hold a balance its client never
414    /// hears about, silently and for the rest of the run. Asking for the whole requirement up
415    /// front is what makes that state unreachable rather than merely avoided.
416    ///
417    /// # One restatement per asset moved
418    /// A balance is an absolute restatement rather than a delta, and a fill is atomic at the
419    /// venue, so emitting the state between the settle and the hold would report a balance the
420    /// account never held — for the same reason a conservative reservation that had to be released
421    /// and re-debited would. [`Committed`] therefore carries one balance per asset the arrival
422    /// moved: the one it paid with, and the one it received when that is another asset.
423    ///
424    /// # What the fill pays back
425    /// `credit` is what the fill gives the account, and which [`Credit`] it is decides what it
426    /// does:
427    /// - **[`PaidBack`](Credit::PaidBack)**, into the paying asset — the margin and realised PnL
428    ///   of the part of a CFD fill that closes a position. It counts towards what `free` must
429    ///   cover, because one fill can carry both: a CFD flip, closing a long and opening a short in
430    ///   one order, is paid back for the long and posts margin for the short, so the flip is
431    ///   funded by the long it closes.
432    /// - **[`Delivery`](Credit::Delivery)**, into another asset — what a spot fill receives: base
433    ///   for a buy, the proceeds for a sell, each net of any fee charged in it. It funds nothing,
434    ///   since it is not the asset paid with, and it is [delivered](Self::deliver) whether or not
435    ///   the account held that asset before. It is negative only for a fill whose fee exceeds
436    ///   what it receives, and then that asset's `free` must cover the excess too.
437    ///
438    /// Nothing is credited until every requirement is known to be covered, so a refusal still
439    /// leaves the ledger exactly as it was. `None` is an arrival with nothing to pay back: an
440    /// order that rests having traded nothing.
441    ///
442    /// # Errors
443    /// [`BalanceInsufficient`], naming the asset short, if `free` plus a credit into the paying
444    /// asset does not cover [`settled`](Debit::settled) + [`reserved`](Debit::reserved), or if a
445    /// negative credit into another asset exceeds that asset's `free`. The ledger is left
446    /// untouched.
447    ///
448    /// # Panics
449    /// Panics if the paying asset has no balance — see [`reserve`](Self::reserve). Debug-asserts
450    /// that a [`PaidBack`](Credit::PaidBack) credit is not negative.
451    pub fn commit(
452        &mut self,
453        debit: &Debit,
454        credit: Option<&Credit>,
455        time_exchange: DateTime<Utc>,
456    ) -> Result<Committed, BalanceInsufficient> {
457        let (funding, delivery) = match credit {
458            Some(Credit::PaidBack(amount)) => {
459                debug_assert!(
460                    !amount.is_sign_negative(),
461                    "a credit of {amount} {} is negative: a loss beyond the margin is debited",
462                    debit.asset
463                );
464                (*amount, None)
465            }
466            Some(Credit::Delivery(delivery)) => (Decimal::ZERO, Some(delivery)),
467            None => (Decimal::ZERO, None),
468        };
469
470        // Asked for as one requirement, so a refusal refuses the arrival rather than half of it.
471        let required = debit.settled + debit.reserved;
472        let free = self.balance_expect(&debit.asset).balance.free;
473        if free + funding < required {
474            return Err(BalanceInsufficient {
475                asset: debit.asset.clone(),
476                free: free + funding,
477                required,
478            });
479        }
480
481        // A delivery that takes rather than gives -- a fill whose fee exceeds what it receives --
482        // must be covered before anything moves too. An asset never held has nothing free.
483        if let Some(delivery) = delivery.filter(|delivery| delivery.amount.is_sign_negative()) {
484            let free = self
485                .balances
486                .get(&delivery.asset)
487                .map_or(Decimal::ZERO, |balance| balance.balance.free);
488            if free + delivery.amount < Decimal::ZERO {
489                return Err(BalanceInsufficient {
490                    asset: delivery.asset.clone(),
491                    free,
492                    required: -delivery.amount,
493                });
494            }
495        }
496
497        // Applied only now that every requirement is covered, so a refusal moves nothing.
498        if !funding.is_zero() {
499            let balance = self.balance_expect(&debit.asset);
500            balance.balance.total += funding;
501            balance.balance.free += funding;
502        }
503
504        // Infallible: `free` now covers the whole requirement. `total` cannot fall below `free`,
505        // which the reserve takes the whole requirement out of, and whatever was reserved and not
506        // settled stays held.
507        self.reserve(&debit.asset, required, time_exchange)?;
508        let paid = self.settle(&debit.asset, debit.settled, time_exchange);
509        let received = delivery.map(|delivery| self.deliver(delivery, time_exchange));
510
511        Ok(Committed { paid, received })
512    }
513
514    #[allow(clippy::expect_used)] // Documented panic: an absent balance is a mis-specified fixture.
515    fn balance_expect(
516        &mut self,
517        asset: &AssetNameExchange,
518    ) -> &mut AssetBalance<AssetNameExchange> {
519        self.balances
520            .get_mut(asset)
521            .expect("SimulatedVenue has Balance for all configured Instrument assets")
522    }
523}
524
525/// What one arriving order does to one asset's balance, as a single commitment.
526///
527/// Both fields are denominated in [`asset`](Self::asset) and inclusive of fees. They are one
528/// asset, not two, because they come from one order: which asset an order pays with is decided by
529/// its instrument and its side, and an order has one of each. That is what makes an arrival a
530/// single ledger operation rather than two that must be kept in step.
531///
532/// The three arrivals a [`SimulatedVenue`] can have are the three shapes of this type:
533///
534/// | arrival | `settled` | `reserved` |
535/// |---|---|---|
536/// | fills in full | the fill | zero |
537/// | rests, having traded nothing | zero | the reservation |
538/// | fills in part and rests the remainder | the fill | the remainder's reservation |
539///
540/// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct Debit {
543    /// Which asset the order pays with: quote for a buy or a CFD, base for a spot sell.
544    pub asset: AssetNameExchange,
545
546    /// Left the account outright, because it traded. Lowers `free` and `total` alike.
547    pub settled: Decimal,
548
549    /// Held against a remainder that is still working. Lowers `free` and leaves `total`, so it is
550    /// still the account's until whatever it is held against settles or is released.
551    pub reserved: Decimal,
552}
553
554/// What one fill gives the account. See [`AccountState::commit`].
555///
556/// Two kinds, because where a credit goes decides what it does: one paid back into the asset the
557/// order paid with funds that order, and one delivered in another asset does not.
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub enum Credit {
560    /// Paid back into the asset the order paid with: the margin and realised PnL of the part of a
561    /// CFD fill that closes a position. Zero for a fill that closes nothing, and never negative,
562    /// since a loss beyond the margin is part of the debit.
563    PaidBack(Decimal),
564    /// Delivered in another asset: what a spot fill receives.
565    Delivery(Delivery),
566}
567
568/// What a spot fill delivers, in the asset it receives.
569#[derive(Debug, Clone, PartialEq, Eq)]
570pub struct Delivery {
571    /// The asset received: base for a buy, quote for a sell.
572    pub asset: AssetNameExchange,
573    /// Denominated in [`asset`](Self::asset), net of any fee charged in it. Negative only for a
574    /// fill whose fee exceeds what it receives.
575    pub amount: Decimal,
576}
577
578/// The balances one [`AccountState::commit`] restated, one per asset it moved.
579#[derive(Debug, Clone, PartialEq, Eq)]
580pub struct Committed {
581    /// The asset the order paid with, or held against it.
582    pub paid: AssetBalance<AssetNameExchange>,
583    /// The asset a spot fill delivered, which is never the one paid with. `None` for an arrival
584    /// that moved one asset: a CFD fill, or an order that rests having traded nothing.
585    pub received: Option<AssetBalance<AssetNameExchange>>,
586}
587
588/// A ledger operation asked for more of an asset than `free` covers.
589///
590/// Carries the asset and both sides of the comparison so the caller can report them without
591/// re-reading the ledger it has just been refused by.
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct BalanceInsufficient {
594    /// The asset short of what was asked for.
595    pub asset: AssetNameExchange,
596    /// Freely-spendable amount at the moment of refusal.
597    pub free: Decimal,
598    /// Amount the operation asked for.
599    pub required: Decimal,
600}
601
602/// An account snapshot lists one [`ClientOrderId`] more than once among the orders it seeds.
603///
604/// A snapshot seeds open and cancelled orders, and a client order id names one order at a time,
605/// so it can be listed once among them. Two entries under one id would leave the venue to choose
606/// which order it holds, silently.
607#[derive(Debug, Clone, PartialEq, Eq, Error)]
608#[error("account snapshot lists {0} more than once among its open and cancelled orders")]
609pub struct DuplicateSeededOrder(pub ClientOrderId);
610
611/// Seeds an account from a snapshot, as a [`SimulatedVenue`] does from its `initial_state`.
612///
613/// # Errors
614/// [`DuplicateSeededOrder`] if the snapshot lists one [`ClientOrderId`] more than once among its
615/// open and cancelled orders.
616///
617/// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
618impl TryFrom<UnindexedAccountSnapshot> for AccountState {
619    type Error = DuplicateSeededOrder;
620
621    fn try_from(value: UnindexedAccountSnapshot) -> Result<Self, Self::Error> {
622        let UnindexedAccountSnapshot {
623            exchange: _,
624            balances,
625            instruments,
626        } = value;
627
628        let balances = balances
629            .into_iter()
630            .map(|asset_balance| (asset_balance.asset.clone(), asset_balance))
631            .collect();
632
633        let mut orders_open = OpenOrders::default();
634        let mut orders_cancelled = FnvHashMap::default();
635        for order in instruments.into_iter().flat_map(|snapshot| snapshot.orders) {
636            match &order.state {
637                OrderState::Active(_) => {
638                    // `as_open` yields `None` for an active order that is not yet open — an
639                    // `OpenInFlight`, which has no venue-side existence to record.
640                    let Some(open) = as_open(order) else {
641                        continue;
642                    };
643                    let cid = open.key.cid.clone();
644                    if orders_cancelled.contains_key(&cid) {
645                        return Err(DuplicateSeededOrder(cid));
646                    }
647                    // Seeded, not booked here: the venue holds nothing against it.
648                    orders_open
649                        .insert(open, None)
650                        .map_err(|_| DuplicateSeededOrder(cid))?;
651                }
652                OrderState::Inactive(InactiveOrderState::Cancelled(cancelled)) => {
653                    let cid = order.key.cid.clone();
654                    if orders_open.contains(&cid) || orders_cancelled.contains_key(&cid) {
655                        return Err(DuplicateSeededOrder(cid));
656                    }
657                    let cancelled = cancelled.clone();
658                    orders_cancelled.insert(
659                        cid,
660                        Order {
661                            key: order.key,
662                            side: order.side,
663                            price: order.price,
664                            quantity: order.quantity,
665                            kind: order.kind,
666                            time_in_force: order.time_in_force,
667                            state: cancelled,
668                        },
669                    );
670                }
671                // Not seeded: see `order_ended`.
672                OrderState::Inactive(_) => {}
673            }
674        }
675
676        Ok(Self {
677            balances,
678            orders_open,
679            orders_cancelled,
680            orders_filled: FnvHashMap::default(),
681            orders_expired: FnvHashMap::default(),
682            trades: vec![],
683        })
684    }
685}
686
687#[cfg(test)]
688#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
689mod tests {
690    use super::*;
691    use crate::{
692        balance::Balance,
693        exchange::mock::fixtures::{funded, seeded_gtd, spot_config_holding},
694        order::{
695            UnindexedOrder,
696            id::OrderId,
697            state::{Cancelled, Filled},
698        },
699    };
700    use rust_decimal_macros::dec;
701
702    fn usd() -> AssetNameExchange {
703        AssetNameExchange::new("usd")
704    }
705
706    fn account_with_usd(amount: Decimal) -> AccountState {
707        AccountState::new(
708            FnvHashMap::from_iter([(usd(), funded(usd(), amount))]),
709            OpenOrders::default(),
710            FnvHashMap::default(),
711            vec![],
712        )
713    }
714
715    fn usd_balance(account: &AccountState) -> Balance {
716        let Some(balance) = account.balances().find(|balance| balance.asset == usd()) else {
717            panic!("the account holds usd");
718        };
719        balance.balance
720    }
721
722    /// A CFD flip: what closing the old position pays back funds the margin the new one posts,
723    /// so a requirement above `free` alone is met, and the remainder's hold stays held.
724    #[test]
725    fn a_credit_counts_towards_the_requirement_it_arrives_with() {
726        let mut account = account_with_usd(dec!(100));
727        let debit = Debit {
728            asset: usd(),
729            settled: dec!(250),
730            reserved: dec!(40),
731        };
732
733        let credit = Credit::PaidBack(dec!(200));
734
735        let Ok(committed) = account.commit(&debit, Some(&credit), DateTime::<Utc>::MIN_UTC) else {
736            panic!("100 free plus a 200 credit covers 290");
737        };
738
739        // total: 100 + 200 credited - 250 settled; free: that, less the 40 still held.
740        assert_eq!(committed.paid.balance, Balance::new(dec!(50), dec!(10)));
741        assert_eq!(
742            committed.received, None,
743            "a CFD credit restates the asset it paid with"
744        );
745        assert_eq!(usd_balance(&account), Balance::new(dec!(50), dec!(10)));
746    }
747
748    #[test]
749    fn a_refused_commit_applies_none_of_its_credit() {
750        let mut account = account_with_usd(dec!(100));
751        let debit = Debit {
752            asset: usd(),
753            settled: dec!(250),
754            reserved: dec!(60),
755        };
756
757        let credit = Credit::PaidBack(dec!(200));
758
759        let Err(insufficient) = account.commit(&debit, Some(&credit), DateTime::<Utc>::MIN_UTC)
760        else {
761            panic!("100 free plus a 200 credit does not cover 310");
762        };
763
764        assert_eq!(
765            insufficient,
766            BalanceInsufficient {
767                asset: usd(),
768                free: dec!(300),
769                required: dec!(310),
770            }
771        );
772        assert_eq!(usd_balance(&account), Balance::new(dec!(100), dec!(100)));
773    }
774
775    fn btc() -> AssetNameExchange {
776        AssetNameExchange::new("btc")
777    }
778
779    fn balance_of(account: &AccountState, asset: &AssetNameExchange) -> Option<Balance> {
780        account
781            .balances()
782            .find(|balance| &balance.asset == asset)
783            .map(|balance| balance.balance)
784    }
785
786    /// A spot buy pays quote and receives base: the base funds nothing, is restated as its own
787    /// balance, and is created at zero when the account never held it.
788    #[test]
789    fn a_credit_into_another_asset_is_delivered_and_restated_separately() {
790        let mut account = account_with_usd(dec!(100));
791        let debit = Debit {
792            asset: usd(),
793            settled: dec!(100),
794            reserved: dec!(0),
795        };
796        let credit = Credit::Delivery(Delivery {
797            asset: btc(),
798            amount: dec!(2),
799        });
800
801        let Ok(committed) = account.commit(&debit, Some(&credit), DateTime::<Utc>::MIN_UTC) else {
802            panic!("100 free covers 100");
803        };
804
805        assert_eq!(committed.paid.balance, Balance::new(dec!(0), dec!(0)));
806        let Some(received) = committed.received else {
807            panic!("a credit into another asset restates that asset");
808        };
809        assert_eq!(received.asset, btc());
810        assert_eq!(received.balance, Balance::new(dec!(2), dec!(2)));
811        assert_eq!(
812            balance_of(&account, &btc()),
813            Some(Balance::new(dec!(2), dec!(2)))
814        );
815    }
816
817    /// A credit into another asset is not the asset paid with, so it cannot fund the debit.
818    #[test]
819    fn a_credit_into_another_asset_does_not_fund_the_debit() {
820        let mut account = account_with_usd(dec!(100));
821        let debit = Debit {
822            asset: usd(),
823            settled: dec!(101),
824            reserved: dec!(0),
825        };
826        let credit = Credit::Delivery(Delivery {
827            asset: btc(),
828            amount: dec!(1000),
829        });
830
831        let Err(insufficient) = account.commit(&debit, Some(&credit), DateTime::<Utc>::MIN_UTC)
832        else {
833            panic!("100 free does not cover 101, whatever another asset is credited");
834        };
835
836        assert_eq!(
837            insufficient,
838            BalanceInsufficient {
839                asset: usd(),
840                free: dec!(100),
841                required: dec!(101),
842            }
843        );
844        assert_eq!(usd_balance(&account), Balance::new(dec!(100), dec!(100)));
845        assert_eq!(
846            balance_of(&account, &btc()),
847            None,
848            "a refusal creates nothing"
849        );
850    }
851
852    /// A fill whose fee exceeds what it receives takes the excess from the asset it receives, and
853    /// is refused, moving nothing, when that asset cannot cover it.
854    #[test]
855    fn a_negative_delivery_must_be_covered_by_its_own_asset() {
856        let mut account = AccountState::new(
857            FnvHashMap::from_iter([
858                (usd(), funded(usd(), dec!(3))),
859                (btc(), funded(btc(), dec!(1))),
860            ]),
861            OpenOrders::default(),
862            FnvHashMap::default(),
863            vec![],
864        );
865        let debit = Debit {
866            asset: btc(),
867            settled: dec!(1),
868            reserved: dec!(0),
869        };
870        let short = Credit::Delivery(Delivery {
871            asset: usd(),
872            amount: dec!(-4),
873        });
874
875        let Err(insufficient) = account.commit(&debit, Some(&short), DateTime::<Utc>::MIN_UTC)
876        else {
877            panic!("3 usd free does not cover a 4 usd excess fee");
878        };
879        assert_eq!(
880            insufficient,
881            BalanceInsufficient {
882                asset: usd(),
883                free: dec!(3),
884                required: dec!(4),
885            }
886        );
887        assert_eq!(
888            balance_of(&account, &btc()),
889            Some(Balance::new(dec!(1), dec!(1)))
890        );
891        assert_eq!(usd_balance(&account), Balance::new(dec!(3), dec!(3)));
892
893        let covered = Credit::Delivery(Delivery {
894            asset: usd(),
895            amount: dec!(-2),
896        });
897        let Ok(committed) = account.commit(&debit, Some(&covered), DateTime::<Utc>::MIN_UTC) else {
898            panic!("3 usd free covers a 2 usd excess fee");
899        };
900        assert_eq!(committed.paid.balance, Balance::new(dec!(0), dec!(0)));
901        assert_eq!(
902            committed.received.map(|balance| balance.balance),
903            Some(Balance::new(dec!(1), dec!(1)))
904        );
905    }
906
907    #[test]
908    fn a_charge_moves_total_and_free_together_and_may_take_free_negative() {
909        let mut account = account_with_usd(dec!(100));
910        account
911            .reserve(&usd(), dec!(60), DateTime::<Utc>::MIN_UTC)
912            .unwrap();
913
914        let charged = account.charge(&usd(), dec!(-50), DateTime::<Utc>::MIN_UTC);
915        assert_eq!(charged.balance, Balance::new(dec!(50), dec!(-10)));
916        assert_eq!(usd_balance(&account), Balance::new(dec!(50), dec!(-10)));
917
918        let credited = account.charge(&usd(), dec!(15), DateTime::<Utc>::MIN_UTC);
919        assert_eq!(credited.balance, Balance::new(dec!(65), dec!(5)));
920    }
921
922    /// A snapshot holding `orders`, as a configured `initial_state` would.
923    fn snapshot_of(orders: impl IntoIterator<Item = UnindexedOrder>) -> UnindexedAccountSnapshot {
924        let mut orders = orders.into_iter();
925        let first = orders.next().unwrap();
926        let mut snapshot = spot_config_holding("1", "1000", first).initial_state;
927        snapshot.instruments[0].orders.extend(orders);
928        snapshot
929    }
930
931    fn open(cid: &str) -> UnindexedOrder {
932        let at = DateTime::<Utc>::MIN_UTC;
933        seeded_gtd(cid, "100", at, at + chrono::TimeDelta::days(1))
934    }
935
936    fn ended(
937        cid: &str,
938        state: OrderState<AssetNameExchange, InstrumentNameExchange>,
939    ) -> UnindexedOrder {
940        UnindexedOrder { state, ..open(cid) }
941    }
942
943    fn cancelled(cid: &str) -> UnindexedOrder {
944        let at = DateTime::<Utc>::MIN_UTC;
945        ended(
946            cid,
947            OrderState::inactive(Cancelled::new(OrderId::new(cid), at, None)),
948        )
949    }
950
951    /// One id listed twice among the orders a snapshot seeds is refused, rather than one of them
952    /// silently winning, whichever two of open and cancelled they are.
953    #[test]
954    fn a_snapshot_seeding_one_id_twice_is_refused() {
955        for (orders, case) in [
956            ([open("x"), open("x")], "open twice"),
957            ([open("x"), cancelled("x")], "open, then cancelled"),
958            ([cancelled("x"), open("x")], "cancelled, then open"),
959            ([cancelled("x"), cancelled("x")], "cancelled twice"),
960        ] {
961            assert_eq!(
962                AccountState::try_from(snapshot_of(orders)).err(),
963                Some(DuplicateSeededOrder(ClientOrderId::new("x"))),
964                "{case}"
965            );
966        }
967    }
968
969    /// An order the snapshot does not seed holds no id, so it cannot clash with one it does.
970    #[test]
971    fn an_order_a_snapshot_does_not_seed_does_not_clash() {
972        let at = DateTime::<Utc>::MIN_UTC;
973        let filled = ended(
974            "x",
975            OrderState::fully_filled(Filled::new(OrderId::new("x"), at, dec!(1), None)),
976        );
977
978        let Ok(account) = AccountState::try_from(snapshot_of([filled, open("x")])) else {
979            panic!("a filled order is not seeded");
980        };
981        assert!(account.orders().contains(&ClientOrderId::new("x")));
982    }
983}