rustrade_execution/exchange/mock/account.rs
1use crate::{
2 UnindexedAccountSnapshot,
3 balance::AssetBalance,
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 free: balance.balance.free,
266 required: amount,
267 });
268 }
269
270 balance.balance.free -= amount;
271 balance.time_exchange = time_exchange;
272
273 Ok(balance.clone())
274 }
275
276 /// Settles `amount` of `asset` already held by [`reserve`](Self::reserve) out of the account.
277 ///
278 /// Only `total` moves: `free` was reduced when the amount was reserved. Reserving and then
279 /// settling the same amount therefore lowers both by it, which is what an order that fills on
280 /// arrival does.
281 ///
282 /// # Panics
283 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). Settling more than is
284 /// held would take `total` below `free` and break the ledger's invariant, so it panics in debug
285 /// builds; callers settle an amount they reserved.
286 pub fn settle(
287 &mut self,
288 asset: &AssetNameExchange,
289 amount: Decimal,
290 time_exchange: DateTime<Utc>,
291 ) -> AssetBalance<AssetNameExchange> {
292 let balance = self.balance_expect(asset);
293
294 debug_assert!(
295 balance.balance.total - amount >= balance.balance.free,
296 "settling {amount} of {asset} would take total {} below free {}: only a reserved \
297 amount may be settled",
298 balance.balance.total,
299 balance.balance.free
300 );
301
302 balance.balance.total -= amount;
303 balance.time_exchange = time_exchange;
304
305 balance.clone()
306 }
307
308 /// Gives `amount` of `asset` back to `free`, undoing a [`reserve`](Self::reserve) that will
309 /// never be settled.
310 ///
311 /// Only `free` moves: nothing left the account, so `total` is unchanged. This is what a
312 /// cancelled, expired or rejected order does to the balance held against it.
313 ///
314 /// # Panics
315 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve). Releasing more than is
316 /// held would take `free` above `total` and break the ledger's invariant, so it panics in debug
317 /// builds; callers release an amount they reserved.
318 pub fn release(
319 &mut self,
320 asset: &AssetNameExchange,
321 amount: Decimal,
322 time_exchange: DateTime<Utc>,
323 ) -> AssetBalance<AssetNameExchange> {
324 let balance = self.balance_expect(asset);
325
326 debug_assert!(
327 balance.balance.free + amount <= balance.balance.total,
328 "releasing {amount} of {asset} would take free {} above total {}: only a reserved \
329 amount may be released",
330 balance.balance.free,
331 balance.balance.total
332 );
333
334 balance.balance.free += amount;
335 balance.time_exchange = time_exchange;
336
337 balance.clone()
338 }
339
340 /// Moves `amount` of `asset` into the account, or out of it when negative, whatever `free`
341 /// holds.
342 ///
343 /// For a charge the account cannot refuse, such as a holding cost: a venue posts carry whether
344 /// or not the account can cover it. `total` and `free` move together, so what is held stays
345 /// held, and a debit larger than `free` takes `free` below zero rather than being refused. The
346 /// caller reports that, since only it knows what the charge was for.
347 ///
348 /// # Panics
349 /// Panics if `asset` has no balance — see [`reserve`](Self::reserve).
350 pub fn charge(
351 &mut self,
352 asset: &AssetNameExchange,
353 amount: Decimal,
354 time_exchange: DateTime<Utc>,
355 ) -> AssetBalance<AssetNameExchange> {
356 let balance = self.balance_expect(asset);
357
358 balance.balance.total += amount;
359 balance.balance.free += amount;
360 balance.time_exchange = time_exchange;
361
362 balance.clone()
363 }
364
365 /// Commits one arriving order's whole ledger effect, or none of it.
366 ///
367 /// One order's arrival can both settle a fill and take a hold: a taker that the book could
368 /// only partly fill settles what traded and reserves against the remainder it leaves resting.
369 /// Those are one event at the venue, so they are one operation here — and because
370 /// [`reserve`](Self::reserve) tests `free` before it moves anything, an arrival this account
371 /// cannot afford leaves the ledger exactly as it found it.
372 ///
373 /// # Why this is not two calls
374 /// Settling first and reserving second is the same two moves in the order that can fail
375 /// halfway: the settle commits, the reserve is refused, and nothing puts back what `total`
376 /// has already lost — [`release`](Self::release) moves only `free`, and its own assertion
377 /// fires if it is used as a rollback. The venue would then hold a balance its client never
378 /// hears about, silently and for the rest of the run. Asking for the whole requirement up
379 /// front is what makes that state unreachable rather than merely avoided.
380 ///
381 /// # One arrival, one restatement
382 /// The returned balance is the only one an arrival owes. A balance is an absolute restatement
383 /// rather than a delta, and a fill is atomic at the venue, so emitting the state between the
384 /// settle and the hold would report a balance the account never held — for the same reason a
385 /// conservative reservation that had to be released and re-debited would.
386 ///
387 /// # A credit funds the debit it arrives with
388 /// A fill that closes a cash-settled position pays proceeds back: `credit`, the margin and
389 /// realised PnL of what it closes, paid into the debit's own asset, since one fill moves one
390 /// asset. A spot fill, or a CFD fill that closes nothing, passes zero. The credit is separate
391 /// from the debit rather than netted into it, because one fill can carry both: a CFD flip,
392 /// closing a long and opening a short in one order, is paid back for the long and posts margin
393 /// for the short. Both are part of the same fill, so the credit counts towards what `free` must
394 /// cover, and the flip is funded by the long it closes. The credit is applied only once the
395 /// whole requirement is known to be covered, so a refusal still leaves the ledger exactly as it
396 /// was.
397 ///
398 /// # Errors
399 /// [`BalanceInsufficient`] if `free` plus the credit does not cover
400 /// [`settled`](Debit::settled) + [`reserved`](Debit::reserved), leaving the ledger untouched.
401 ///
402 /// # Panics
403 /// Panics if the asset has no balance — see [`reserve`](Self::reserve). Debug-asserts that
404 /// `credit` is not negative: a loss beyond the margin is part of the debit, not a credit.
405 pub fn commit(
406 &mut self,
407 debit: &Debit,
408 credit: Decimal,
409 time_exchange: DateTime<Utc>,
410 ) -> Result<AssetBalance<AssetNameExchange>, BalanceInsufficient> {
411 debug_assert!(
412 !credit.is_sign_negative(),
413 "a credit of {credit} {} is negative: a loss beyond the margin is debited",
414 debit.asset
415 );
416
417 // Asked for as one requirement, so a refusal refuses the arrival rather than half of it.
418 let required = debit.settled + debit.reserved;
419 let free = self.balance_expect(&debit.asset).balance.free;
420 if free + credit < required {
421 return Err(BalanceInsufficient {
422 free: free + credit,
423 required,
424 });
425 }
426
427 // Applied only now that the whole requirement is covered, so a refusal moves nothing.
428 if !credit.is_zero() {
429 let balance = self.balance_expect(&debit.asset);
430 balance.balance.total += credit;
431 balance.balance.free += credit;
432 }
433
434 // Infallible: `free` now covers the whole requirement. `total` cannot fall below `free`,
435 // which the reserve takes the whole requirement out of, and whatever was reserved and not
436 // settled stays held.
437 self.reserve(&debit.asset, required, time_exchange)?;
438 Ok(self.settle(&debit.asset, debit.settled, time_exchange))
439 }
440
441 #[allow(clippy::expect_used)] // Documented panic: an absent balance is a mis-specified fixture.
442 fn balance_expect(
443 &mut self,
444 asset: &AssetNameExchange,
445 ) -> &mut AssetBalance<AssetNameExchange> {
446 self.balances
447 .get_mut(asset)
448 .expect("SimulatedVenue has Balance for all configured Instrument assets")
449 }
450}
451
452/// What one arriving order does to one asset's balance, as a single commitment.
453///
454/// Both fields are denominated in [`asset`](Self::asset) and inclusive of fees. They are one
455/// asset, not two, because they come from one order: which asset an order pays with is decided by
456/// its instrument and its side, and an order has one of each. That is what makes an arrival a
457/// single ledger operation rather than two that must be kept in step.
458///
459/// The three arrivals a [`SimulatedVenue`] can have are the three shapes of this type:
460///
461/// | arrival | `settled` | `reserved` |
462/// |---|---|---|
463/// | fills in full | the fill | zero |
464/// | rests, having traded nothing | zero | the reservation |
465/// | fills in part and rests the remainder | the fill | the remainder's reservation |
466///
467/// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct Debit {
470 /// Which asset the order pays with: quote for a buy or a CFD, base for a spot sell.
471 pub asset: AssetNameExchange,
472
473 /// Left the account outright, because it traded. Lowers `free` and `total` alike.
474 pub settled: Decimal,
475
476 /// Held against a remainder that is still working. Lowers `free` and leaves `total`, so it is
477 /// still the account's until whatever it is held against settles or is released.
478 pub reserved: Decimal,
479}
480
481/// A ledger operation asked for more of an asset than `free` covers.
482///
483/// Carries both sides of the comparison so the caller can report them without re-reading the
484/// ledger it has just been refused by.
485#[derive(Debug, Copy, Clone, PartialEq, Eq)]
486pub struct BalanceInsufficient {
487 /// Freely-spendable amount at the moment of refusal.
488 pub free: Decimal,
489 /// Amount the operation asked for.
490 pub required: Decimal,
491}
492
493/// An account snapshot lists one [`ClientOrderId`] more than once among the orders it seeds.
494///
495/// A snapshot seeds open and cancelled orders, and a client order id names one order at a time,
496/// so it can be listed once among them. Two entries under one id would leave the venue to choose
497/// which order it holds, silently.
498#[derive(Debug, Clone, PartialEq, Eq, Error)]
499#[error("account snapshot lists {0} more than once among its open and cancelled orders")]
500pub struct DuplicateSeededOrder(pub ClientOrderId);
501
502/// Seeds an account from a snapshot, as a [`SimulatedVenue`] does from its `initial_state`.
503///
504/// # Errors
505/// [`DuplicateSeededOrder`] if the snapshot lists one [`ClientOrderId`] more than once among its
506/// open and cancelled orders.
507///
508/// [`SimulatedVenue`]: crate::exchange::mock::SimulatedVenue
509impl TryFrom<UnindexedAccountSnapshot> for AccountState {
510 type Error = DuplicateSeededOrder;
511
512 fn try_from(value: UnindexedAccountSnapshot) -> Result<Self, Self::Error> {
513 let UnindexedAccountSnapshot {
514 exchange: _,
515 balances,
516 instruments,
517 } = value;
518
519 let balances = balances
520 .into_iter()
521 .map(|asset_balance| (asset_balance.asset.clone(), asset_balance))
522 .collect();
523
524 let mut orders_open = OpenOrders::default();
525 let mut orders_cancelled = FnvHashMap::default();
526 for order in instruments.into_iter().flat_map(|snapshot| snapshot.orders) {
527 match &order.state {
528 OrderState::Active(_) => {
529 // `as_open` yields `None` for an active order that is not yet open — an
530 // `OpenInFlight`, which has no venue-side existence to record.
531 let Some(open) = as_open(order) else {
532 continue;
533 };
534 let cid = open.key.cid.clone();
535 if orders_cancelled.contains_key(&cid) {
536 return Err(DuplicateSeededOrder(cid));
537 }
538 // Seeded, not booked here: the venue holds nothing against it.
539 orders_open
540 .insert(open, None)
541 .map_err(|_| DuplicateSeededOrder(cid))?;
542 }
543 OrderState::Inactive(InactiveOrderState::Cancelled(cancelled)) => {
544 let cid = order.key.cid.clone();
545 if orders_open.contains(&cid) || orders_cancelled.contains_key(&cid) {
546 return Err(DuplicateSeededOrder(cid));
547 }
548 let cancelled = cancelled.clone();
549 orders_cancelled.insert(
550 cid,
551 Order {
552 key: order.key,
553 side: order.side,
554 price: order.price,
555 quantity: order.quantity,
556 kind: order.kind,
557 time_in_force: order.time_in_force,
558 state: cancelled,
559 },
560 );
561 }
562 // Not seeded: see `order_ended`.
563 OrderState::Inactive(_) => {}
564 }
565 }
566
567 Ok(Self {
568 balances,
569 orders_open,
570 orders_cancelled,
571 orders_filled: FnvHashMap::default(),
572 orders_expired: FnvHashMap::default(),
573 trades: vec![],
574 })
575 }
576}
577
578#[cfg(test)]
579#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
580mod tests {
581 use super::*;
582 use crate::{
583 balance::Balance,
584 exchange::mock::fixtures::{funded, seeded_gtd, spot_config_holding},
585 order::{
586 UnindexedOrder,
587 id::OrderId,
588 state::{Cancelled, Filled},
589 },
590 };
591 use rust_decimal_macros::dec;
592
593 fn usd() -> AssetNameExchange {
594 AssetNameExchange::new("usd")
595 }
596
597 fn account_with_usd(amount: Decimal) -> AccountState {
598 AccountState::new(
599 FnvHashMap::from_iter([(usd(), funded(usd(), amount))]),
600 OpenOrders::default(),
601 FnvHashMap::default(),
602 vec![],
603 )
604 }
605
606 fn usd_balance(account: &AccountState) -> Balance {
607 let Some(balance) = account.balances().find(|balance| balance.asset == usd()) else {
608 panic!("the account holds usd");
609 };
610 balance.balance
611 }
612
613 /// A CFD flip: what closing the old position pays back funds the margin the new one posts,
614 /// so a requirement above `free` alone is met, and the remainder's hold stays held.
615 #[test]
616 fn a_credit_counts_towards_the_requirement_it_arrives_with() {
617 let mut account = account_with_usd(dec!(100));
618 let debit = Debit {
619 asset: usd(),
620 settled: dec!(250),
621 reserved: dec!(40),
622 };
623
624 let Ok(balance) = account.commit(&debit, dec!(200), DateTime::<Utc>::MIN_UTC) else {
625 panic!("100 free plus a 200 credit covers 290");
626 };
627
628 // total: 100 + 200 credited - 250 settled; free: that, less the 40 still held.
629 assert_eq!(balance.balance, Balance::new(dec!(50), dec!(10)));
630 assert_eq!(usd_balance(&account), Balance::new(dec!(50), dec!(10)));
631 }
632
633 #[test]
634 fn a_refused_commit_applies_none_of_its_credit() {
635 let mut account = account_with_usd(dec!(100));
636 let debit = Debit {
637 asset: usd(),
638 settled: dec!(250),
639 reserved: dec!(60),
640 };
641
642 let Err(insufficient) = account.commit(&debit, dec!(200), DateTime::<Utc>::MIN_UTC) else {
643 panic!("100 free plus a 200 credit does not cover 310");
644 };
645
646 assert_eq!(
647 insufficient,
648 BalanceInsufficient {
649 free: dec!(300),
650 required: dec!(310),
651 }
652 );
653 assert_eq!(usd_balance(&account), Balance::new(dec!(100), dec!(100)));
654 }
655
656 #[test]
657 fn a_charge_moves_total_and_free_together_and_may_take_free_negative() {
658 let mut account = account_with_usd(dec!(100));
659 account
660 .reserve(&usd(), dec!(60), DateTime::<Utc>::MIN_UTC)
661 .unwrap();
662
663 let charged = account.charge(&usd(), dec!(-50), DateTime::<Utc>::MIN_UTC);
664 assert_eq!(charged.balance, Balance::new(dec!(50), dec!(-10)));
665 assert_eq!(usd_balance(&account), Balance::new(dec!(50), dec!(-10)));
666
667 let credited = account.charge(&usd(), dec!(15), DateTime::<Utc>::MIN_UTC);
668 assert_eq!(credited.balance, Balance::new(dec!(65), dec!(5)));
669 }
670
671 /// A snapshot holding `orders`, as a configured `initial_state` would.
672 fn snapshot_of(orders: impl IntoIterator<Item = UnindexedOrder>) -> UnindexedAccountSnapshot {
673 let mut orders = orders.into_iter();
674 let first = orders.next().unwrap();
675 let mut snapshot = spot_config_holding("1", "1000", first).initial_state;
676 snapshot.instruments[0].orders.extend(orders);
677 snapshot
678 }
679
680 fn open(cid: &str) -> UnindexedOrder {
681 let at = DateTime::<Utc>::MIN_UTC;
682 seeded_gtd(cid, "100", at, at + chrono::TimeDelta::days(1))
683 }
684
685 fn ended(
686 cid: &str,
687 state: OrderState<AssetNameExchange, InstrumentNameExchange>,
688 ) -> UnindexedOrder {
689 UnindexedOrder { state, ..open(cid) }
690 }
691
692 fn cancelled(cid: &str) -> UnindexedOrder {
693 let at = DateTime::<Utc>::MIN_UTC;
694 ended(
695 cid,
696 OrderState::inactive(Cancelled::new(OrderId::new(cid), at, None)),
697 )
698 }
699
700 /// One id listed twice among the orders a snapshot seeds is refused, rather than one of them
701 /// silently winning, whichever two of open and cancelled they are.
702 #[test]
703 fn a_snapshot_seeding_one_id_twice_is_refused() {
704 for (orders, case) in [
705 ([open("x"), open("x")], "open twice"),
706 ([open("x"), cancelled("x")], "open, then cancelled"),
707 ([cancelled("x"), open("x")], "cancelled, then open"),
708 ([cancelled("x"), cancelled("x")], "cancelled twice"),
709 ] {
710 assert_eq!(
711 AccountState::try_from(snapshot_of(orders)).err(),
712 Some(DuplicateSeededOrder(ClientOrderId::new("x"))),
713 "{case}"
714 );
715 }
716 }
717
718 /// An order the snapshot does not seed holds no id, so it cannot clash with one it does.
719 #[test]
720 fn an_order_a_snapshot_does_not_seed_does_not_clash() {
721 let at = DateTime::<Utc>::MIN_UTC;
722 let filled = ended(
723 "x",
724 OrderState::fully_filled(Filled::new(OrderId::new("x"), at, dec!(1), None)),
725 );
726
727 let Ok(account) = AccountState::try_from(snapshot_of([filled, open("x")])) else {
728 panic!("a filled order is not seeded");
729 };
730 assert!(account.orders().contains(&ClientOrderId::new("x")));
731 }
732}