1use std::{cell::RefCell, cmp::Ordering, fmt::Debug, rc::Rc};
19
20use ahash::{AHashMap, AHashSet};
21use nautilus_common::{cache::Cache, clock::Clock};
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24 accounts::{
25 Account, AccountAny, BaseAccount, BettingAccount, CashAccount, MarginAccount, WalletAccount,
26 },
27 enums::{AccountType, OrderSide, OrderType, PriceType},
28 events::{AccountState, OrderFilled},
29 identifiers::InstrumentId,
30 instruments::{Instrument, InstrumentAny},
31 orders::{Order, OrderAny},
32 position::{Position, fold_net_position},
33 types::{
34 AccountBalance, Currency, Money, Price, Quantity,
35 fixed::{FIXED_PRECISION, check_fixed_raw_i128, check_fixed_raw_u128},
36 money::MoneyRaw,
37 },
38};
39use rust_decimal::Decimal;
40
41pub struct AccountsManager {
46 clock: Rc<RefCell<dyn Clock>>,
47 cache: Rc<RefCell<Cache>>,
48}
49
50impl Debug for AccountsManager {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct(stringify!(AccountsManager)).finish()
53 }
54}
55
56impl AccountsManager {
57 pub fn new(clock: Rc<RefCell<dyn Clock>>, cache: Rc<RefCell<Cache>>) -> Self {
59 Self { clock, cache }
60 }
61
62 #[must_use]
71 pub fn update_balances(
72 &self,
73 mut account: AccountAny,
74 instrument: &InstrumentAny,
75 fill: &OrderFilled,
76 ) -> (AccountAny, AccountState) {
77 let base = base_account(&account);
80 let original_balances = base.balances.clone();
81 let original_commissions = base.commissions.clone();
82 let position_id = if let Some(position_id) = fill.position_id {
83 position_id
84 } else {
85 let cache = self.cache.borrow();
86 let positions_open = cache.positions_open(
87 None,
88 Some(&fill.instrument_id),
89 None,
90 Some(&fill.account_id),
91 None,
92 );
93 positions_open
94 .first()
95 .unwrap_or_else(|| panic!("List of Positions is empty"))
96 .id
97 };
98
99 let position = self
100 .cache
101 .borrow()
102 .position(&position_id)
103 .map(|position| position.clone_without_events());
104
105 let pnls = match account.calculate_pnls(instrument, fill, position) {
106 Ok(pnls) => pnls,
107 Err(e) => {
108 log::error!(
109 "Cannot update balances for fill {}: failed to calculate PnL: {e}",
110 fill.trade_id
111 );
112 let state = self.generate_account_state(&account, fill.ts_event);
113 return (account, state);
114 }
115 };
116
117 let updated = match account.base_currency() {
119 Some(base_currency) => {
120 let pnl = pnls
121 .first()
122 .copied()
123 .unwrap_or_else(|| Money::zero(base_currency));
124
125 self.update_balance_single_currency(&mut account, fill, pnl)
126 }
127 None => {
128 let mut pnl_list = pnls;
129 self.update_balance_multi_currency(&mut account, fill, &mut pnl_list)
130 }
131 };
132
133 if !updated {
134 let base = base_account_mut(&mut account);
135 base.balances = original_balances;
136 base.commissions = original_commissions;
137
138 let state = self.generate_account_state(&account, fill.ts_event);
139 return (account, state);
140 }
141
142 let state = self.generate_account_state(&account, fill.ts_event);
143 (account, state)
144 }
145
146 #[must_use]
151 pub fn update_orders(
152 &self,
153 account: &AccountAny,
154 instrument: &InstrumentAny,
155 orders_open: &[&OrderAny],
156 ts_event: UnixNanos,
157 ) -> Option<(AccountAny, AccountState)> {
158 let mut account = account.clone();
159 self.update_orders_in_place(&mut account, instrument, orders_open, ts_event)
160 .map(|state| (account, state))
161 }
162
163 #[must_use]
168 pub fn update_orders_in_place(
169 &self,
170 account: &mut AccountAny,
171 instrument: &InstrumentAny,
172 orders_open: &[&OrderAny],
173 ts_event: UnixNanos,
174 ) -> Option<AccountState> {
175 match account {
176 AccountAny::Margin(margin_account) => {
177 self.update_margin_init(margin_account, instrument, orders_open, ts_event)
178 }
179 AccountAny::Cash(cash_account) => {
180 self.update_balance_locked(cash_account, instrument, orders_open, ts_event)
181 }
182 AccountAny::Betting(betting_account) => self.update_balance_locked_betting(
183 betting_account,
184 instrument,
185 orders_open,
186 ts_event,
187 ),
188 AccountAny::Wallet(wallet_account) => {
189 self.update_balance_locked_wallet(wallet_account, instrument, orders_open, ts_event)
190 }
191 }
192 }
193
194 #[must_use]
200 pub fn update_positions(
201 &self,
202 account: &MarginAccount,
203 instrument: &InstrumentAny,
204 positions: Vec<&Position>,
205 ts_event: UnixNanos,
206 ) -> Option<(MarginAccount, AccountState)> {
207 let mut account = account.clone();
208 self.update_positions_in_place(&mut account, instrument, positions, ts_event)
209 .map(|state| (account, state))
210 }
211
212 #[must_use]
222 pub fn update_positions_in_place(
223 &self,
224 account: &mut MarginAccount,
225 instrument: &InstrumentAny,
226 positions: Vec<&Position>,
227 ts_event: UnixNanos,
228 ) -> Option<AccountState> {
229 let mut ordered: Vec<&Position> = positions;
230 ordered.sort_by_key(|p| (p.ts_opened, p.id));
231
232 let legs: Vec<(Decimal, Decimal, u64)> = ordered
233 .iter()
234 .map(|p| {
235 assert_eq!(
236 p.instrument_id,
237 instrument.id(),
238 "Position not for instrument {}",
239 instrument.id()
240 );
241 (
242 p.signed_decimal_qty(),
243 Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO),
244 p.ts_opened.as_u64(),
245 )
246 })
247 .collect();
248
249 let (net_signed_qty, net_avg_px) = fold_net_position(&legs);
250
251 let mut currency = account
252 .base_currency
253 .unwrap_or_else(|| instrument.settlement_currency());
254
255 let mut total_margin_maint = Decimal::ZERO;
256
257 let net_qty =
258 match Quantity::from_decimal_dp(net_signed_qty.abs(), instrument.size_precision()) {
259 Ok(q) if q.is_zero() => None,
260 Ok(q) => Some(q),
261 Err(e) => {
262 log::error!(
263 "Cannot calculate maintenance (position) margin: net quantity \
264 conversion failed for {}: {e}",
265 instrument.id()
266 );
267 return None;
268 }
269 };
270
271 if let Some(quantity) = net_qty {
272 let price = Price::from_decimal_dp(net_avg_px, instrument.price_precision()).ok()?;
273 let net_entry = if net_signed_qty > Decimal::ZERO {
274 OrderSide::Buy
275 } else {
276 OrderSide::Sell
277 };
278
279 let margin_maint = match instrument {
280 InstrumentAny::Betting(i) => account
281 .calculate_maintenance_margin(i, quantity, price, None)
282 .ok()?,
283 InstrumentAny::BinaryOption(i) => account
284 .calculate_maintenance_margin(i, quantity, price, None)
285 .ok()?,
286 InstrumentAny::Cfd(i) => account
287 .calculate_maintenance_margin(i, quantity, price, None)
288 .ok()?,
289 InstrumentAny::Commodity(i) => account
290 .calculate_maintenance_margin(i, quantity, price, None)
291 .ok()?,
292 InstrumentAny::CryptoFuture(i) => account
293 .calculate_maintenance_margin(i, quantity, price, None)
294 .ok()?,
295 InstrumentAny::CryptoFuturesSpread(i) => account
296 .calculate_maintenance_margin(i, quantity, price, None)
297 .ok()?,
298 InstrumentAny::CryptoOption(i) => account
299 .calculate_maintenance_margin(i, quantity, price, None)
300 .ok()?,
301 InstrumentAny::CryptoOptionSpread(i) => account
302 .calculate_maintenance_margin(i, quantity, price, None)
303 .ok()?,
304 InstrumentAny::CryptoPerpetual(i) => account
305 .calculate_maintenance_margin(i, quantity, price, None)
306 .ok()?,
307 InstrumentAny::CurrencyPair(i) => account
308 .calculate_maintenance_margin(i, quantity, price, None)
309 .ok()?,
310 InstrumentAny::Equity(i) => account
311 .calculate_maintenance_margin(i, quantity, price, None)
312 .ok()?,
313 InstrumentAny::FuturesContract(i) => account
314 .calculate_maintenance_margin(i, quantity, price, None)
315 .ok()?,
316 InstrumentAny::FuturesSpread(i) => account
317 .calculate_maintenance_margin(i, quantity, price, None)
318 .ok()?,
319 InstrumentAny::IndexInstrument(i) => account
320 .calculate_maintenance_margin(i, quantity, price, None)
321 .ok()?,
322 InstrumentAny::OptionContract(i) => account
323 .calculate_maintenance_margin(i, quantity, price, None)
324 .ok()?,
325 InstrumentAny::OptionSpread(i) => account
326 .calculate_maintenance_margin(i, quantity, price, None)
327 .ok()?,
328 InstrumentAny::PerpetualContract(i) => account
329 .calculate_maintenance_margin(i, quantity, price, None)
330 .ok()?,
331 InstrumentAny::TokenizedAsset(i) => account
332 .calculate_maintenance_margin(i, quantity, price, None)
333 .ok()?,
334 };
335
336 let source_currency = margin_maint.currency;
337 total_margin_maint = margin_maint.as_decimal();
338
339 if let Some(base_currency) = account.base_currency {
340 if let Some(xrate) = self.calculate_xrate_to_base(
341 account.base_currency,
342 instrument,
343 source_currency,
344 net_entry,
345 ) {
346 total_margin_maint *= xrate;
347 } else {
348 log::debug!(
349 "Cannot calculate maintenance (position) margin: insufficient data for {source_currency}/{base_currency}"
350 );
351 return None;
352 }
353 } else {
354 currency = source_currency;
355 }
356 }
357
358 let margin_maint = Money::from_decimal(total_margin_maint, currency).ok()?;
359 if total_margin_maint.is_zero() {
360 account.clear_maintenance_margin(instrument.id());
361 } else {
362 if let Some(existing) = account.margin(&instrument.id())
363 && existing.currency != margin_maint.currency
364 {
365 log::error!(
366 "Cannot update maintenance margin for {}: existing currency {} differs from calculated currency {}",
367 instrument.id(),
368 existing.currency,
369 margin_maint.currency
370 );
371 return None;
372 }
373 account.update_maintenance_margin(instrument.id(), margin_maint);
374 }
375
376 log::info!("{} margin_maint={margin_maint}", instrument.id());
377
378 Some(self.generate_margin_account_state(account, ts_event))
379 }
380
381 fn update_balance_locked(
382 &self,
383 account: &mut CashAccount,
384 instrument: &InstrumentAny,
385 orders_open: &[&OrderAny],
386 ts_event: UnixNanos,
387 ) -> Option<AccountState> {
388 if orders_open.is_empty() {
389 account.clear_balance_locked(instrument.id());
390 return Some(self.generate_unleveraged_account_state(account, ts_event));
391 }
392
393 let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
394
395 for order in orders_open {
396 assert_eq!(
397 order.instrument_id(),
398 instrument.id(),
399 "Order not for instrument {}",
400 instrument.id()
401 );
402 assert!(order.is_open(), "Order is not open");
403
404 if order.price().is_none() && order.trigger_price().is_none() {
405 continue;
406 }
407
408 if order.is_reduce_only() {
409 continue; }
411
412 let price = if order.price().is_some() {
413 order.price()
414 } else {
415 order.trigger_price()
416 };
417
418 let mut locked = match account.calculate_balance_locked(
419 instrument,
420 order.order_side(),
421 order.leaves_qty(),
422 price?,
423 None,
424 ) {
425 Ok(locked) => locked,
426 Err(e) => {
427 log::error!("Cannot calculate balance locked: {e}");
428 return None;
429 }
430 };
431
432 if let Some(base_curr) = account.base_currency() {
433 if let Some(xrate) = self.calculate_xrate_to_base(
434 account.base_currency(),
435 instrument,
436 locked.currency,
437 order.order_side(),
438 ) {
439 locked = match Money::from_decimal(locked.as_decimal() * xrate, base_curr) {
440 Ok(money) => money,
441 Err(e) => {
442 log::error!("Cannot calculate balance locked: {e}");
443 return None;
444 }
445 };
446 } else {
447 log::error!(
448 "Cannot calculate balance locked: insufficient data for {}/{}",
449 locked.currency,
450 base_curr
451 );
452 return None;
453 }
454 }
455
456 if let Some(total) = total_locked.get_mut(&locked.currency) {
457 let Some(sum) = total.checked_add(locked) else {
458 log::error!(
459 "Cannot calculate balance locked: {} total exceeds Money bounds",
460 locked.currency
461 );
462 return None;
463 };
464 *total = sum;
465 } else {
466 total_locked.insert(locked.currency, locked);
467 }
468 }
469
470 if total_locked.is_empty() {
471 account.clear_balance_locked(instrument.id());
472 return Some(self.generate_unleveraged_account_state(account, ts_event));
473 }
474
475 if !reservation_precisions_match(account, &total_locked) {
476 return None;
477 }
478
479 let balances_before = account.base.balances.clone();
480 let locks_before = account.balances_locked.clone();
481
482 account.clear_balance_locked(instrument.id());
484
485 for (_, balance_locked) in total_locked {
486 if let Err(e) = account.update_balance_locked(instrument.id(), balance_locked) {
487 log::error!("Cannot update balance locked: {e}");
488 account.base.balances = balances_before;
489 account.balances_locked = locks_before;
490 return None;
491 }
492 log::info!("{} balance_locked={balance_locked}", instrument.id());
493 }
494
495 Some(self.generate_unleveraged_account_state(account, ts_event))
496 }
497
498 fn update_balance_locked_wallet(
499 &self,
500 account: &mut WalletAccount,
501 instrument: &InstrumentAny,
502 orders: &[&OrderAny],
503 ts_event: UnixNanos,
504 ) -> Option<AccountState> {
505 let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
506 let mut fully_locked = AHashSet::new();
507
508 for order in orders {
509 if order.instrument_id() != instrument.id() {
510 log::error!(
511 "Cannot calculate wallet balance locked: order {} is for instrument {}, expected {}",
512 order.client_order_id(),
513 order.instrument_id(),
514 instrument.id()
515 );
516 return None;
517 }
518
519 if !(order.is_open() || order.is_inflight()) {
520 continue;
521 }
522
523 if order.is_pending_update() {
524 let source_currency = match order.order_side() {
525 OrderSide::Buy => instrument.quote_currency(),
526 OrderSide::Sell => instrument
527 .base_currency()
528 .unwrap_or_else(|| instrument.quote_currency()),
529 };
530 let Some(total) = account.balance_total(Some(source_currency)) else {
531 log::error!(
532 "Cannot calculate wallet balance locked: no observed balance for {source_currency}"
533 );
534 return None;
535 };
536 total_locked.insert(total.currency, total);
537 fully_locked.insert(total.currency);
538 continue;
539 }
540
541 let quantity = order.leaves_qty();
542 if quantity.is_zero() {
543 continue;
544 }
545
546 let locked = match order.order_side() {
547 OrderSide::Sell if order.is_quote_quantity() => {
548 log::error!(
549 "Cannot calculate wallet balance locked for quote-denominated SELL order {}",
550 order.client_order_id()
551 );
552 return None;
553 }
554 OrderSide::Sell => {
555 let source_currency = instrument
556 .base_currency()
557 .unwrap_or_else(|| instrument.quote_currency());
558 let Some(total) = account.balance_total(Some(source_currency)) else {
559 log::error!(
560 "Cannot calculate wallet balance locked: no observed balance for {source_currency}"
561 );
562 return None;
563 };
564
565 match wallet_money_from_quantity(quantity, total.currency) {
566 Ok(locked) => locked,
567 Err(e) => {
568 log::error!("Cannot calculate wallet balance locked: {e}");
569 return None;
570 }
571 }
572 }
573 OrderSide::Buy if order.is_quote_quantity() => {
574 let source_currency = instrument.quote_currency();
575 let Some(total) = account.balance_total(Some(source_currency)) else {
576 log::error!(
577 "Cannot calculate wallet balance locked: no observed balance for {source_currency}"
578 );
579 return None;
580 };
581
582 match wallet_money_from_quantity(quantity, total.currency) {
583 Ok(locked) => locked,
584 Err(e) => {
585 log::error!("Cannot calculate wallet balance locked: {e}");
586 return None;
587 }
588 }
589 }
590 OrderSide::Buy => {
591 let Some(price) = order.price().or_else(|| order.trigger_price()) else {
592 log::error!(
593 "Cannot calculate wallet balance locked for order {} without a price",
594 order.client_order_id()
595 );
596 return None;
597 };
598
599 match account.calculate_balance_locked(
600 instrument,
601 OrderSide::Buy,
602 quantity,
603 price,
604 None,
605 ) {
606 Ok(locked) => locked,
607 Err(e) => {
608 log::error!("Cannot calculate wallet balance locked: {e}");
609 return None;
610 }
611 }
612 }
613 };
614
615 if account.balance_total(Some(locked.currency)).is_none() {
616 log::error!(
617 "Cannot calculate wallet balance locked: no observed balance for {}",
618 locked.currency
619 );
620 return None;
621 }
622
623 if fully_locked.contains(&locked.currency) {
624 continue;
625 }
626
627 if let Some(total) = total_locked.get_mut(&locked.currency) {
628 let Some(sum) = total.checked_add(locked) else {
629 log::error!(
630 "Cannot calculate wallet balance locked: {} total exceeds Money bounds",
631 locked.currency
632 );
633 return None;
634 };
635 *total = sum;
636 } else {
637 total_locked.insert(locked.currency, locked);
638 }
639 }
640
641 let balances_before = account.base.balances.clone();
642 let locks_before = account.balances_locked.clone();
643 account.clear_balance_locked(instrument.id());
644 if account
645 .balances_locked
646 .keys()
647 .any(|(instrument_id, _)| *instrument_id == instrument.id())
648 {
649 log::error!(
650 "Cannot update wallet balance locked: prior reservations for {} were not cleared",
651 instrument.id()
652 );
653 account.base.balances = balances_before;
654 account.balances_locked = locks_before;
655 return None;
656 }
657
658 for balance_locked in total_locked.into_values() {
659 if let Err(e) = account.update_balance_locked(instrument.id(), balance_locked) {
660 log::error!("Cannot update wallet balance locked: {e}");
661 account.base.balances = balances_before;
662 account.balances_locked = locks_before;
663 return None;
664 }
665 log::info!("{} balance_locked={balance_locked}", instrument.id());
666 }
667
668 Some(self.generate_unleveraged_account_state(account, ts_event))
669 }
670
671 fn update_margin_init(
672 &self,
673 account: &mut MarginAccount,
674 instrument: &InstrumentAny,
675 orders_open: &[&OrderAny],
676 ts_event: UnixNanos,
677 ) -> Option<AccountState> {
678 let mut total_margin_init = Decimal::ZERO;
679 let mut currency = instrument.settlement_currency();
680 let mut source_currency: Option<Currency> = None;
681
682 for order in orders_open {
683 assert_eq!(
684 order.instrument_id(),
685 instrument.id(),
686 "Order not for instrument {}",
687 instrument.id()
688 );
689
690 if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) {
691 continue;
692 }
693
694 if order.is_reduce_only() {
695 continue; }
697
698 let price = if order.price().is_some() {
699 order.price()
700 } else {
701 order.trigger_price()
702 };
703
704 let margin_init = match instrument {
705 InstrumentAny::Betting(i) => account
706 .calculate_initial_margin(i, order.quantity(), price?, None)
707 .ok()?,
708 InstrumentAny::BinaryOption(i) => account
709 .calculate_initial_margin(i, order.quantity(), price?, None)
710 .ok()?,
711 InstrumentAny::Cfd(i) => account
712 .calculate_initial_margin(i, order.quantity(), price?, None)
713 .ok()?,
714 InstrumentAny::Commodity(i) => account
715 .calculate_initial_margin(i, order.quantity(), price?, None)
716 .ok()?,
717 InstrumentAny::CryptoFuture(i) => account
718 .calculate_initial_margin(i, order.quantity(), price?, None)
719 .ok()?,
720 InstrumentAny::CryptoFuturesSpread(i) => account
721 .calculate_initial_margin(i, order.quantity(), price?, None)
722 .ok()?,
723 InstrumentAny::CryptoOption(i) => account
724 .calculate_initial_margin(i, order.quantity(), price?, None)
725 .ok()?,
726 InstrumentAny::CryptoOptionSpread(i) => account
727 .calculate_initial_margin(i, order.quantity(), price?, None)
728 .ok()?,
729 InstrumentAny::CryptoPerpetual(i) => account
730 .calculate_initial_margin(i, order.quantity(), price?, None)
731 .ok()?,
732 InstrumentAny::CurrencyPair(i) => account
733 .calculate_initial_margin(i, order.quantity(), price?, None)
734 .ok()?,
735 InstrumentAny::Equity(i) => account
736 .calculate_initial_margin(i, order.quantity(), price?, None)
737 .ok()?,
738 InstrumentAny::FuturesContract(i) => account
739 .calculate_initial_margin(i, order.quantity(), price?, None)
740 .ok()?,
741 InstrumentAny::FuturesSpread(i) => account
742 .calculate_initial_margin(i, order.quantity(), price?, None)
743 .ok()?,
744 InstrumentAny::IndexInstrument(i) => account
745 .calculate_initial_margin(i, order.quantity(), price?, None)
746 .ok()?,
747 InstrumentAny::OptionContract(i) => account
748 .calculate_initial_margin(i, order.quantity(), price?, None)
749 .ok()?,
750 InstrumentAny::OptionSpread(i) => account
751 .calculate_initial_margin(i, order.quantity(), price?, None)
752 .ok()?,
753 InstrumentAny::PerpetualContract(i) => account
754 .calculate_initial_margin(i, order.quantity(), price?, None)
755 .ok()?,
756 InstrumentAny::TokenizedAsset(i) => account
757 .calculate_initial_margin(i, order.quantity(), price?, None)
758 .ok()?,
759 };
760
761 let margin_currency = margin_init.currency;
762 let mut margin_init = margin_init.as_decimal();
763
764 if let Some(base_currency) = account.base_currency {
765 currency = base_currency;
766 if let Some(xrate) = self.calculate_xrate_to_base(
767 account.base_currency,
768 instrument,
769 margin_currency,
770 order.order_side(),
771 ) {
772 margin_init *= xrate;
773 } else {
774 log::debug!(
775 "Cannot calculate initial margin: insufficient data for {margin_currency}/{base_currency}"
776 );
777 return None;
778 }
779 } else if let Some(source_currency) = source_currency {
780 if source_currency != margin_currency {
781 log::error!(
782 "Cannot calculate initial margin: mixed currencies {source_currency} and {margin_currency}"
783 );
784 return None;
785 }
786 } else {
787 currency = margin_currency;
788 source_currency = Some(margin_currency);
789 }
790
791 total_margin_init += margin_init;
792 }
793
794 let money = match Money::from_decimal(total_margin_init, currency) {
795 Ok(money) => money,
796 Err(e) => {
797 log::error!("Cannot calculate initial margin: {e}");
798 return None;
799 }
800 };
801 let margin_init = if total_margin_init.is_zero() {
802 account.clear_initial_margin(instrument.id());
803 money
804 } else {
805 if let Some(existing) = account.margin(&instrument.id())
806 && existing.currency != money.currency
807 {
808 log::error!(
809 "Cannot update initial margin for {}: existing currency {} differs from calculated currency {}",
810 instrument.id(),
811 existing.currency,
812 money.currency
813 );
814 return None;
815 }
816 account.update_initial_margin(instrument.id(), money);
817 money
818 };
819
820 log::info!("{} margin_init={margin_init}", instrument.id());
821
822 Some(self.generate_margin_account_state(account, ts_event))
823 }
824
825 fn update_balance_locked_betting(
826 &self,
827 account: &mut BettingAccount,
828 instrument: &InstrumentAny,
829 orders_open: &[&OrderAny],
830 ts_event: UnixNanos,
831 ) -> Option<AccountState> {
832 if orders_open.is_empty() {
833 account.clear_balance_locked(instrument.id());
834 return Some(self.generate_betting_account_state(account, ts_event));
835 }
836
837 let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
838
839 for order in orders_open {
840 assert_eq!(
841 order.instrument_id(),
842 instrument.id(),
843 "Order not for instrument {}",
844 instrument.id()
845 );
846 assert!(order.is_open(), "Order is not open");
847
848 if order.price().is_none() && order.trigger_price().is_none() {
849 continue;
850 }
851
852 if order.is_reduce_only() {
853 continue;
854 }
855
856 let price = if order.price().is_some() {
857 order.price()
858 } else {
859 order.trigger_price()
860 };
861
862 let mut locked = match account.calculate_balance_locked(
863 instrument,
864 order.order_side(),
865 order.quantity(),
866 price?,
867 None,
868 ) {
869 Ok(locked) => locked,
870 Err(e) => {
871 log::error!("Cannot calculate betting balance locked: {e}");
872 return None;
873 }
874 };
875
876 if let Some(base_curr) = account.base_currency() {
877 if let Some(xrate) = self.cache.borrow().get_xrate(
878 instrument.id().venue,
879 locked.currency,
880 base_curr,
881 PriceType::Mid,
882 ) {
883 locked = match Money::from_decimal(locked.as_decimal() * xrate, base_curr) {
884 Ok(money) => money,
885 Err(e) => {
886 log::error!("Cannot calculate balance locked: {e}");
887 return None;
888 }
889 };
890 } else {
891 log::error!(
892 "Cannot calculate balance locked: insufficient data for {}/{}",
893 locked.currency,
894 base_curr
895 );
896 return None;
897 }
898 }
899
900 if let Some(total) = total_locked.get_mut(&locked.currency) {
901 let Some(sum) = total.checked_add(locked) else {
902 log::error!(
903 "Cannot calculate betting balance locked: {} total exceeds Money bounds",
904 locked.currency
905 );
906 return None;
907 };
908 *total = sum;
909 } else {
910 total_locked.insert(locked.currency, locked);
911 }
912 }
913
914 if total_locked.is_empty() {
915 account.clear_balance_locked(instrument.id());
916 return Some(self.generate_betting_account_state(account, ts_event));
917 }
918
919 if !reservation_precisions_match(account, &total_locked) {
920 return None;
921 }
922
923 let balances_before = account.base.balances.clone();
924 let locks_before = account.balances_locked.clone();
925
926 account.clear_balance_locked(instrument.id());
927
928 for (_, balance_locked) in total_locked {
929 if let Err(e) = account.update_balance_locked(instrument.id(), balance_locked) {
930 log::error!("Cannot update betting balance locked: {e}");
931 account.base.balances = balances_before;
932 account.balances_locked = locks_before;
933 return None;
934 }
935 log::info!("{} balance_locked={balance_locked}", instrument.id());
936 }
937
938 Some(self.generate_betting_account_state(account, ts_event))
939 }
940
941 fn update_balance_single_currency(
942 &self,
943 account: &mut AccountAny,
944 fill: &OrderFilled,
945 mut pnl: Money,
946 ) -> bool {
947 let base_currency = if let Some(currency) = account.base_currency() {
948 currency
949 } else {
950 log::error!("Account has no base currency set");
951 return false;
952 };
953
954 let mut balances = Vec::new();
955 let mut commission = fill.commission;
956
957 if let Some(ref mut comm) = commission
958 && comm.currency != base_currency
959 {
960 let xrate = self.cache.borrow().get_xrate(
961 fill.instrument_id.venue,
962 comm.currency,
963 base_currency,
964 if fill.order_side == OrderSide::Sell {
965 PriceType::Bid
966 } else {
967 PriceType::Ask
968 },
969 );
970
971 if let Some(xrate) = xrate {
972 let Some(converted) = comm.as_decimal().checked_mul(xrate) else {
973 log::error!("Cannot calculate account state: commission conversion overflow");
974 return false;
975 };
976 *comm = match Money::from_decimal(converted, base_currency) {
977 Ok(money) => money,
978 Err(e) => {
979 log::error!("Cannot calculate account state: {e}");
980 return false;
981 }
982 };
983 } else {
984 log::error!(
985 "Cannot calculate account state: insufficient data for {}/{}",
986 comm.currency,
987 base_currency
988 );
989 return false;
990 }
991 }
992
993 if pnl.currency != base_currency {
994 let xrate = self.cache.borrow().get_xrate(
995 fill.instrument_id.venue,
996 pnl.currency,
997 base_currency,
998 if fill.order_side == OrderSide::Sell {
999 PriceType::Bid
1000 } else {
1001 PriceType::Ask
1002 },
1003 );
1004
1005 if let Some(xrate) = xrate {
1006 let Some(converted) = pnl.as_decimal().checked_mul(xrate) else {
1007 log::error!("Cannot calculate account state: PnL conversion overflow");
1008 return false;
1009 };
1010 pnl = match Money::from_decimal(converted, base_currency) {
1011 Ok(money) => money,
1012 Err(e) => {
1013 log::error!("Cannot calculate account state: {e}");
1014 return false;
1015 }
1016 };
1017 } else {
1018 log::error!(
1019 "Cannot calculate account state: insufficient data for {}/{}",
1020 pnl.currency,
1021 base_currency
1022 );
1023 return false;
1024 }
1025 }
1026
1027 if let Some(comm) = commission {
1028 let Some(net_pnl) = pnl.checked_sub(comm) else {
1029 log::error!("Cannot calculate account state: net PnL exceeds Money bounds");
1030 return false;
1031 };
1032 pnl = net_pnl;
1033 }
1034
1035 if pnl.is_zero() {
1036 return true;
1037 }
1038
1039 let existing_balances = account.balances();
1040 let balance = if let Some(b) = existing_balances.get(&pnl.currency) {
1041 b
1042 } else {
1043 log::error!(
1044 "Cannot complete transaction: no balance for {}",
1045 pnl.currency
1046 );
1047 return false;
1048 };
1049
1050 let Some(new_total) = balance.total.as_decimal().checked_add(pnl.as_decimal()) else {
1051 log::error!("Cannot update {} balance: total overflow", pnl.currency);
1052 return false;
1053 };
1054
1055 let new_balance = match AccountBalance::from_total_and_locked(
1056 new_total,
1057 balance.locked.as_decimal(),
1058 pnl.currency,
1059 ) {
1060 Ok(new_balance) => new_balance,
1061 Err(e) => {
1062 log::error!("Cannot update {} balance: {e}", pnl.currency);
1063 return false;
1064 }
1065 };
1066
1067 balances.push(new_balance);
1068
1069 match account {
1070 AccountAny::Margin(margin) => {
1071 margin.update_balances(&balances);
1072
1073 if let Some(comm) = commission
1074 && let Err(e) = margin.try_update_commissions(comm)
1075 {
1076 log::error!("Cannot update margin account commissions: {e}");
1077 return false;
1078 }
1079 }
1080 AccountAny::Cash(cash) => {
1081 if let Err(e) = cash.update_balances(&balances) {
1082 log::error!("Cannot update cash account balance: {e}");
1083 return false;
1084 }
1085
1086 if let Some(comm) = commission
1087 && let Err(e) = cash.try_update_commissions(comm)
1088 {
1089 log::error!("Cannot update cash account commissions: {e}");
1090 return false;
1091 }
1092 }
1093 AccountAny::Betting(betting) => {
1094 if let Err(e) = betting.update_balances(&balances) {
1095 log::error!("Cannot update betting account balance: {e}");
1096 return false;
1097 }
1098
1099 if let Some(comm) = commission
1100 && let Err(e) = betting.try_update_commissions(comm)
1101 {
1102 log::error!("Cannot update betting account commissions: {e}");
1103 return false;
1104 }
1105 }
1106 AccountAny::Wallet(wallet) => {
1107 if let Err(e) = wallet.update_balances(&balances) {
1108 log::error!("Cannot update wallet account balance: {e}");
1109 return false;
1110 }
1111
1112 if let Some(comm) = commission
1113 && let Err(e) = wallet.try_update_commissions(comm)
1114 {
1115 log::error!("Cannot update wallet account commissions: {e}");
1116 return false;
1117 }
1118 }
1119 }
1120 true
1121 }
1122
1123 fn update_balance_multi_currency(
1124 &self,
1125 account: &mut AccountAny,
1126 fill: &OrderFilled,
1127 pnls: &mut [Money],
1128 ) -> bool {
1129 let mut new_balances = Vec::new();
1130 let commission = fill.commission;
1131 let mut apply_commission = commission.is_some_and(|c| !c.is_zero());
1132
1133 for pnl in pnls.iter_mut() {
1134 if apply_commission && pnl.currency == commission.unwrap().currency {
1135 let Some(net_pnl) = pnl.checked_sub(commission.unwrap()) else {
1136 log::error!("Cannot calculate account state: net PnL exceeds Money bounds");
1137 return false;
1138 };
1139 *pnl = net_pnl;
1140 apply_commission = false;
1141 }
1142
1143 if pnl.is_zero() {
1144 continue; }
1146
1147 let currency = pnl.currency;
1148 let balances = account.balances();
1149
1150 let new_balance = if let Some(balance) = balances.get(¤cy) {
1151 let Some(new_total) = balance.total.as_decimal().checked_add(pnl.as_decimal())
1152 else {
1153 log::error!("Cannot update {currency} balance: total overflow");
1154 return false;
1155 };
1156 let mut new_locked = balance.locked.as_decimal();
1157
1158 if pnl.as_decimal() < Decimal::ZERO
1159 && fill.order_type != OrderType::Market
1160 && !self.is_sports_betting_fill(fill.instrument_id)
1161 {
1162 let Some(updated_locked) = new_locked.checked_add(pnl.as_decimal()) else {
1163 log::error!("Cannot update {currency} balance: locked amount overflow");
1164 return false;
1165 };
1166 new_locked = updated_locked;
1167
1168 if new_locked < Decimal::ZERO {
1169 new_locked = Decimal::ZERO;
1170 }
1171 }
1172
1173 match AccountBalance::from_total_and_locked(new_total, new_locked, currency) {
1174 Ok(new_balance) => new_balance,
1175 Err(e) => {
1176 log::error!("Cannot update {currency} balance: {e}");
1177 return false;
1178 }
1179 }
1180 } else {
1181 if pnl.as_decimal() < Decimal::ZERO {
1189 log::error!(
1190 "Cannot complete transaction: no {currency} to deduct a {pnl} realized PnL from"
1191 );
1192 return false;
1193 }
1194 AccountBalance::new(*pnl, Money::zero(currency), *pnl)
1195 };
1196
1197 new_balances.push(new_balance);
1198 }
1199
1200 if apply_commission {
1201 let commission = commission.unwrap();
1202 let currency = commission.currency;
1203 let balances = account.balances();
1204
1205 let commission_balance = if let Some(balance) = balances.get(¤cy) {
1206 let Some(new_total) = balance
1207 .total
1208 .as_decimal()
1209 .checked_sub(commission.as_decimal())
1210 else {
1211 log::error!("Cannot deduct {currency} commission: total overflow");
1212 return false;
1213 };
1214
1215 match AccountBalance::from_total_and_locked(
1216 new_total,
1217 balance.locked.as_decimal(),
1218 currency,
1219 ) {
1220 Ok(commission_balance) => commission_balance,
1221 Err(e) => {
1222 log::error!("Cannot deduct {currency} commission: {e}");
1223 return false;
1224 }
1225 }
1226 } else {
1227 if commission.as_decimal() > Decimal::ZERO {
1228 log::error!(
1229 "Cannot complete transaction: no {currency} balance to deduct a {commission} commission from"
1230 );
1231 return false;
1232 }
1233 let rebate = -commission.as_decimal();
1234 match AccountBalance::from_total_and_locked(rebate, Decimal::ZERO, currency) {
1235 Ok(commission_balance) => commission_balance,
1236 Err(e) => {
1237 log::error!("Cannot credit {currency} commission rebate: {e}");
1238 return false;
1239 }
1240 }
1241 };
1242 new_balances.push(commission_balance);
1243 }
1244
1245 if new_balances.is_empty() {
1246 return true;
1247 }
1248
1249 match account {
1250 AccountAny::Margin(margin) => {
1251 margin.update_balances(&new_balances);
1252
1253 if let Some(commission) = commission
1254 && let Err(e) = margin.try_update_commissions(commission)
1255 {
1256 log::error!("Cannot update margin account commissions: {e}");
1257 return false;
1258 }
1259 }
1260 AccountAny::Cash(cash) => {
1261 if let Err(e) = cash.update_balances(&new_balances) {
1262 log::error!("Cannot update cash account balance: {e}");
1263 return false;
1264 }
1265
1266 if let Some(commission) = commission
1267 && let Err(e) = cash.try_update_commissions(commission)
1268 {
1269 log::error!("Cannot update cash account commissions: {e}");
1270 return false;
1271 }
1272 }
1273 AccountAny::Betting(betting) => {
1274 if let Err(e) = betting.update_balances(&new_balances) {
1275 log::error!("Cannot update betting account balance: {e}");
1276 return false;
1277 }
1278
1279 if let Some(commission) = commission
1280 && let Err(e) = betting.try_update_commissions(commission)
1281 {
1282 log::error!("Cannot update betting account commissions: {e}");
1283 return false;
1284 }
1285 }
1286 AccountAny::Wallet(wallet) => {
1287 if let Err(e) = wallet.update_balances(&new_balances) {
1288 log::error!("Cannot update wallet account balance: {e}");
1289 return false;
1290 }
1291
1292 if let Some(commission) = commission
1293 && let Err(e) = wallet.try_update_commissions(commission)
1294 {
1295 log::error!("Cannot update wallet account commissions: {e}");
1296 return false;
1297 }
1298 }
1299 }
1300 true
1301 }
1302
1303 fn is_sports_betting_fill(&self, instrument_id: InstrumentId) -> bool {
1304 self.cache
1305 .borrow()
1306 .instrument(&instrument_id)
1307 .is_some_and(|instrument| matches!(instrument, InstrumentAny::Betting(_)))
1308 }
1309
1310 fn generate_account_state(&self, account: &AccountAny, ts_event: UnixNanos) -> AccountState {
1311 match account {
1312 AccountAny::Margin(margin_account) => {
1313 self.generate_margin_account_state(margin_account, ts_event)
1314 }
1315 AccountAny::Cash(cash_account) => {
1316 self.generate_unleveraged_account_state(cash_account, ts_event)
1317 }
1318 AccountAny::Betting(betting_account) => {
1319 self.generate_betting_account_state(betting_account, ts_event)
1320 }
1321 AccountAny::Wallet(wallet_account) => {
1322 self.generate_unleveraged_account_state(wallet_account, ts_event)
1323 }
1324 }
1325 }
1326
1327 fn generate_margin_account_state(
1328 &self,
1329 margin_account: &MarginAccount,
1330 ts_event: UnixNanos,
1331 ) -> AccountState {
1332 let mut margins: Vec<_> = margin_account.margins.values().copied().collect();
1336 margins.extend(margin_account.account_margins.values().copied());
1337 AccountState::new(
1338 margin_account.id,
1339 AccountType::Margin,
1340 margin_account.balances.clone().into_values().collect(),
1341 margins,
1342 false,
1343 UUID4::new(),
1344 ts_event,
1345 self.clock.borrow().timestamp_ns(),
1346 margin_account.base_currency(),
1347 )
1348 }
1349
1350 fn generate_unleveraged_account_state(
1351 &self,
1352 account: &impl Account,
1353 ts_event: UnixNanos,
1354 ) -> AccountState {
1355 AccountState::new(
1356 account.id(),
1357 account.account_type(),
1358 account.balances().into_values().collect(),
1359 vec![],
1360 false,
1361 UUID4::new(),
1362 ts_event,
1363 self.clock.borrow().timestamp_ns(),
1364 account.base_currency(),
1365 )
1366 }
1367
1368 fn generate_betting_account_state(
1369 &self,
1370 betting_account: &BettingAccount,
1371 ts_event: UnixNanos,
1372 ) -> AccountState {
1373 AccountState::new(
1374 betting_account.id,
1375 AccountType::Betting,
1376 betting_account.balances.clone().into_values().collect(),
1377 vec![],
1378 false,
1379 UUID4::new(),
1380 ts_event,
1381 self.clock.borrow().timestamp_ns(),
1382 betting_account.base_currency(),
1383 )
1384 }
1385
1386 fn calculate_xrate_to_base(
1387 &self,
1388 base_currency: Option<Currency>,
1389 instrument: &InstrumentAny,
1390 source_currency: Currency,
1391 side: OrderSide,
1392 ) -> Option<Decimal> {
1393 match base_currency {
1394 None => Some(Decimal::ONE),
1395 Some(base_curr) if source_currency == base_curr => Some(Decimal::ONE),
1396 Some(base_curr) => self.cache.borrow().get_xrate(
1397 instrument.id().venue,
1398 source_currency,
1399 base_curr,
1400 if side == OrderSide::Buy {
1401 PriceType::Bid
1402 } else {
1403 PriceType::Ask
1404 },
1405 ),
1406 }
1407 }
1408}
1409
1410#[allow(
1411 clippy::useless_conversion,
1412 reason = "the raw width differs when high-precision is disabled"
1413)]
1414fn wallet_money_from_quantity(quantity: Quantity, currency: Currency) -> anyhow::Result<Money> {
1415 anyhow::ensure!(!quantity.is_undefined(), "quantity was undefined");
1416 Quantity::from_raw_checked(quantity.raw(), quantity.precision)?;
1417 check_fixed_raw_u128(u128::from(quantity.raw()), quantity.precision)?;
1418
1419 let source_precision = quantity.precision.max(FIXED_PRECISION);
1420 let target_precision = currency.precision.max(FIXED_PRECISION);
1421 let raw = i128::try_from(u128::from(quantity.raw()))
1422 .map_err(|_| anyhow::anyhow!("quantity for {currency} exceeds signed raw bounds"))?;
1423 let raw = match source_precision.cmp(&target_precision) {
1424 Ordering::Less => {
1425 let scale = 10_i128.pow(u32::from(target_precision - source_precision));
1426 raw.checked_mul(scale).ok_or_else(|| {
1427 anyhow::anyhow!("quantity for {currency} overflowed while increasing raw scale")
1428 })?
1429 }
1430 Ordering::Greater => {
1431 let scale = 10_i128.pow(u32::from(source_precision - target_precision));
1432 anyhow::ensure!(
1433 raw % scale == 0,
1434 "quantity for {currency} loses precision when decreasing raw scale"
1435 );
1436 raw / scale
1437 }
1438 Ordering::Equal => raw,
1439 };
1440 check_fixed_raw_i128(raw, currency.precision)?;
1441 let raw: MoneyRaw = raw
1442 .try_into()
1443 .map_err(|_| anyhow::anyhow!("quantity for {currency} exceeds Money raw bounds"))?;
1444
1445 Money::from_raw_checked(raw, currency).map_err(Into::into)
1446}
1447
1448fn reservation_precisions_match(
1449 account: &dyn Account,
1450 reservations: &AHashMap<Currency, Money>,
1451) -> bool {
1452 for reservation in reservations.values() {
1453 let Some(balance) = account.balance(Some(reservation.currency)) else {
1454 continue;
1455 };
1456
1457 if balance.currency.precision != reservation.currency.precision {
1458 log::error!(
1459 "Cannot update {} reservation: precision {} differed from balance precision {}",
1460 reservation.currency,
1461 reservation.currency.precision,
1462 balance.currency.precision
1463 );
1464 return false;
1465 }
1466 }
1467
1468 true
1469}
1470
1471fn base_account(account: &AccountAny) -> &BaseAccount {
1472 match account {
1473 AccountAny::Margin(margin) => margin,
1474 AccountAny::Cash(cash) => cash,
1475 AccountAny::Betting(betting) => betting,
1476 AccountAny::Wallet(wallet) => wallet,
1477 }
1478}
1479
1480fn base_account_mut(account: &mut AccountAny) -> &mut BaseAccount {
1481 match account {
1482 AccountAny::Margin(margin) => margin,
1483 AccountAny::Cash(cash) => cash,
1484 AccountAny::Betting(betting) => betting,
1485 AccountAny::Wallet(wallet) => wallet,
1486 }
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491 use std::{cell::RefCell, rc::Rc};
1492
1493 use nautilus_common::{cache::Cache, clock::VirtualClock};
1494 use nautilus_model::{
1495 accounts::{BettingAccount, CashAccount, MarginAccount},
1496 data::QuoteTick,
1497 enums::{AccountType, CurrencyType, OmsType, OrderSide, OrderType},
1498 events::{
1499 AccountState, OrderAccepted, OrderEventAny, OrderFilled, OrderSubmitted,
1500 account::stubs::wallet_account_state,
1501 order::spec::{
1502 OrderAcceptedSpec, OrderFilledSpec, OrderPendingUpdateSpec, OrderSubmittedSpec,
1503 },
1504 },
1505 identifiers::{
1506 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue,
1507 VenueOrderId,
1508 },
1509 instruments::{
1510 CryptoFuture, CurrencyPair, Instrument, InstrumentAny,
1511 stubs::{
1512 audusd_sim, betting, currency_pair_btcusdt, currency_pair_ethusdt, default_fx_ccy,
1513 },
1514 },
1515 orders::{OrderAny, OrderTestBuilder},
1516 position::Position,
1517 types::{
1518 AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
1519 money::{MONEY_MAX, MONEY_RAW_MAX, MoneyRaw},
1520 },
1521 };
1522 use rstest::rstest;
1523
1524 use super::*;
1525
1526 #[rstest]
1527 fn test_update_balance_locked_with_base_currency_multiple_orders() {
1528 let usd = Currency::USD();
1529 let account_state = AccountState::new(
1530 AccountId::new("SIM-001"),
1531 AccountType::Cash,
1532 vec![AccountBalance::new(
1533 Money::new(1_000_000.0, usd),
1534 Money::zero(usd),
1535 Money::new(1_000_000.0, usd),
1536 )],
1537 Vec::new(),
1538 true,
1539 UUID4::new(),
1540 UnixNanos::default(),
1541 UnixNanos::default(),
1542 Some(usd),
1543 );
1544
1545 let account = CashAccount::new(account_state, true, false);
1546
1547 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1548 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1549 cache
1550 .borrow_mut()
1551 .add_account(AccountAny::Cash(account.clone()))
1552 .unwrap();
1553
1554 let manager = AccountsManager::new(clock, cache);
1555
1556 let instrument = audusd_sim();
1557
1558 let order1 = OrderTestBuilder::new(OrderType::Limit)
1559 .instrument_id(instrument.id())
1560 .side(OrderSide::Buy)
1561 .quantity(Quantity::from("100000"))
1562 .price(Price::from("0.75000"))
1563 .build();
1564
1565 let order2 = OrderTestBuilder::new(OrderType::Limit)
1566 .instrument_id(instrument.id())
1567 .side(OrderSide::Buy)
1568 .quantity(Quantity::from("50000"))
1569 .price(Price::from("0.74500"))
1570 .build();
1571
1572 let order3 = OrderTestBuilder::new(OrderType::Limit)
1573 .instrument_id(instrument.id())
1574 .side(OrderSide::Buy)
1575 .quantity(Quantity::from("75000"))
1576 .price(Price::from("0.74000"))
1577 .build();
1578
1579 let mut order1 = order1;
1580 let mut order2 = order2;
1581 let mut order3 = order3;
1582
1583 let submitted1 = order_submitted_for(&order1);
1584 let accepted1 = order_accepted_for(&order1, VenueOrderId::new("1"));
1585
1586 order1.apply(OrderEventAny::Submitted(submitted1)).unwrap();
1587 order1.apply(OrderEventAny::Accepted(accepted1)).unwrap();
1588
1589 let submitted2 = order_submitted_for(&order2);
1590 let accepted2 = order_accepted_for(&order2, VenueOrderId::new("2"));
1591
1592 order2.apply(OrderEventAny::Submitted(submitted2)).unwrap();
1593 order2.apply(OrderEventAny::Accepted(accepted2)).unwrap();
1594
1595 let submitted3 = order_submitted_for(&order3);
1596 let accepted3 = order_accepted_for(&order3, VenueOrderId::new("3"));
1597
1598 order3.apply(OrderEventAny::Submitted(submitted3)).unwrap();
1599 order3.apply(OrderEventAny::Accepted(accepted3)).unwrap();
1600
1601 let orders: Vec<&OrderAny> = vec![&order1, &order2, &order3];
1602
1603 let result = manager.update_orders(
1604 &AccountAny::Cash(account),
1605 &InstrumentAny::CurrencyPair(instrument),
1606 &orders,
1607 UnixNanos::default(),
1608 );
1609
1610 assert!(result.is_some());
1611 let (updated_account, _state) = result.unwrap();
1612
1613 if let AccountAny::Cash(cash_account) = updated_account {
1614 let locked_balance = cash_account.balance_locked(Some(usd));
1615
1616 let expected_locked = Money::new(167_750.0, usd);
1618
1619 assert_eq!(locked_balance, Some(expected_locked));
1620 let aud = Currency::AUD();
1621 assert_eq!(cash_account.balance_locked(Some(aud)), None);
1622 } else {
1623 panic!("Expected CashAccount");
1624 }
1625 }
1626
1627 #[rstest]
1628 fn test_update_balance_locked_after_partial_fill() {
1629 let usd = Currency::USD();
1630 let account_state = AccountState::new(
1631 AccountId::new("SIM-001"),
1632 AccountType::Cash,
1633 vec![AccountBalance::new(
1634 Money::new(1_000_000.0, usd),
1635 Money::zero(usd),
1636 Money::new(1_000_000.0, usd),
1637 )],
1638 Vec::new(),
1639 true,
1640 UUID4::new(),
1641 UnixNanos::default(),
1642 UnixNanos::default(),
1643 Some(usd),
1644 );
1645 let account = CashAccount::new(account_state, true, false);
1646 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1647 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1648 let manager = AccountsManager::new(clock, cache);
1649 let instrument = audusd_sim();
1650
1651 let mut order = OrderTestBuilder::new(OrderType::Limit)
1652 .instrument_id(instrument.id())
1653 .side(OrderSide::Buy)
1654 .quantity(Quantity::from("100000"))
1655 .price(Price::from("0.80000"))
1656 .build();
1657 order
1658 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
1659 .unwrap();
1660 order
1661 .apply(OrderEventAny::Accepted(order_accepted_for(
1662 &order,
1663 VenueOrderId::new("1"),
1664 )))
1665 .unwrap();
1666
1667 let (account, _) = manager
1668 .update_orders(
1669 &AccountAny::Cash(account),
1670 &InstrumentAny::CurrencyPair(instrument.clone()),
1671 &[&order],
1672 UnixNanos::default(),
1673 )
1674 .unwrap();
1675 let AccountAny::Cash(cash) = &account else {
1676 panic!("Expected CashAccount");
1677 };
1678 assert_eq!(
1679 cash.balance_total(Some(usd)),
1680 Some(Money::new(1_000_000.0, usd))
1681 );
1682 assert_eq!(
1683 cash.balance_locked(Some(usd)),
1684 Some(Money::new(80_000.0, usd))
1685 );
1686 assert_eq!(
1687 cash.balance_free(Some(usd)),
1688 Some(Money::new(920_000.0, usd))
1689 );
1690
1691 let fill = OrderFilledSpec::builder()
1692 .instrument_id(instrument.id())
1693 .client_order_id(order.client_order_id())
1694 .venue_order_id(VenueOrderId::new("1"))
1695 .order_side(OrderSide::Buy)
1696 .order_type(OrderType::Limit)
1697 .last_qty(Quantity::from("40000"))
1698 .last_px(Price::from("0.79000"))
1699 .position_id(PositionId::new("P-001"))
1700 .commission(Money::new(8.0, usd))
1701 .build();
1702 order.apply(OrderEventAny::Filled(fill.clone())).unwrap();
1703 let (account, _) = manager.update_balances(
1704 account,
1705 &InstrumentAny::CurrencyPair(instrument.clone()),
1706 &fill,
1707 );
1708
1709 let (account, _) = manager
1710 .update_orders(
1711 &account,
1712 &InstrumentAny::CurrencyPair(instrument),
1713 &[&order],
1714 UnixNanos::default(),
1715 )
1716 .unwrap();
1717
1718 let AccountAny::Cash(account) = account else {
1719 panic!("Expected CashAccount");
1720 };
1721 assert_eq!(order.leaves_qty(), Quantity::from("60000"));
1722 assert_eq!(
1723 account.balance_total(Some(usd)),
1724 Some(Money::new(968_392.0, usd))
1725 );
1726 assert_eq!(
1727 account.balance_locked(Some(usd)),
1728 Some(Money::new(48_000.0, usd))
1729 );
1730 assert_eq!(
1731 account.balance_free(Some(usd)),
1732 Some(Money::new(920_392.0, usd))
1733 );
1734 assert_eq!(account.commission(&usd), Some(Money::new(8.0, usd)));
1735 }
1736
1737 #[rstest]
1738 fn test_update_orders_cash_precision_mismatch_preserves_state() {
1739 let mut cash = multi_currency_cash_account(false);
1740 let usd = Currency::USD();
1741 let mut instrument = audusd_sim();
1742 let instrument_id = instrument.id();
1743 cash.update_balance_locked(instrument_id, Money::from("10 USD"))
1744 .unwrap();
1745 let balances_before = cash.base.balances.clone();
1746 let locks_before = cash.balances_locked.clone();
1747 let events_before = cash.base.events.clone();
1748 instrument.quote_currency = Currency::new(
1749 "USD",
1750 usd.precision + 1,
1751 840,
1752 "US Dollar",
1753 CurrencyType::Fiat,
1754 );
1755 let mut order = OrderTestBuilder::new(OrderType::Limit)
1756 .instrument_id(instrument_id)
1757 .side(OrderSide::Buy)
1758 .quantity(Quantity::from("1"))
1759 .price(Price::from("0.75"))
1760 .build();
1761 order
1762 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
1763 .unwrap();
1764 order
1765 .apply(OrderEventAny::Accepted(order_accepted_for(
1766 &order,
1767 VenueOrderId::new("1"),
1768 )))
1769 .unwrap();
1770 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1771 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1772 let manager = AccountsManager::new(clock, cache);
1773 let mut account = AccountAny::Cash(cash);
1774
1775 let result = manager.update_orders_in_place(
1776 &mut account,
1777 &InstrumentAny::CurrencyPair(instrument),
1778 &[&order],
1779 UnixNanos::default(),
1780 );
1781
1782 assert_eq!(result, None);
1783 let AccountAny::Cash(cash) = account else {
1784 panic!("Expected CashAccount")
1785 };
1786 assert_eq!(cash.base.balances, balances_before);
1787 assert_eq!(cash.balances_locked, locks_before);
1788 assert_eq!(cash.base.events, events_before);
1789 }
1790
1791 #[rstest]
1794 fn test_update_orders_cash_error_restores_locks() {
1795 let mut cash = multi_currency_cash_account(false);
1796 let instrument = audusd_sim();
1797 let instrument_id = instrument.id();
1798 cash.update_balance_locked(instrument_id, Money::from("10 USD"))
1799 .unwrap();
1800 cash.balances_locked.insert(
1801 (InstrumentId::from("EURUSD.SIM"), Currency::USD()),
1802 Money::from("-1 USD"),
1803 );
1804 let balances_before = cash.base.balances.clone();
1805 let locks_before = cash.balances_locked.clone();
1806 let events_before = cash.base.events.clone();
1807 let mut order = OrderTestBuilder::new(OrderType::Limit)
1808 .instrument_id(instrument_id)
1809 .side(OrderSide::Buy)
1810 .quantity(Quantity::from("1"))
1811 .price(Price::from("0.75"))
1812 .build();
1813 order
1814 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
1815 .unwrap();
1816 order
1817 .apply(OrderEventAny::Accepted(order_accepted_for(
1818 &order,
1819 VenueOrderId::new("1"),
1820 )))
1821 .unwrap();
1822 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1823 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1824 let manager = AccountsManager::new(clock, cache);
1825 let mut account = AccountAny::Cash(cash);
1826
1827 let result = manager.update_orders_in_place(
1828 &mut account,
1829 &InstrumentAny::CurrencyPair(instrument),
1830 &[&order],
1831 UnixNanos::default(),
1832 );
1833
1834 assert_eq!(result, None);
1835 let AccountAny::Cash(cash) = account else {
1836 panic!("Expected CashAccount")
1837 };
1838 assert_eq!(cash.base.balances, balances_before);
1839 assert_eq!(cash.balances_locked, locks_before);
1840 assert_eq!(cash.base.events, events_before);
1841 }
1842
1843 #[rstest]
1844 fn test_update_orders_betting_error_restores_locks() {
1845 let gbp = Currency::GBP();
1846 let account_state = AccountState::new(
1847 AccountId::new("BETTING-001"),
1848 AccountType::Betting,
1849 vec![AccountBalance::new(
1850 Money::from("1000 GBP"),
1851 Money::zero(gbp),
1852 Money::from("1000 GBP"),
1853 )],
1854 Vec::new(),
1855 true,
1856 UUID4::new(),
1857 UnixNanos::default(),
1858 UnixNanos::default(),
1859 None,
1860 );
1861 let mut betting_account = BettingAccount::new(account_state, true);
1862 let instrument = betting();
1863 let instrument_id = instrument.id();
1864 betting_account
1865 .update_balance_locked(instrument_id, Money::from("100 GBP"))
1866 .unwrap();
1867 betting_account.balances_locked.insert(
1868 (
1869 InstrumentId::from("BETFAIR-1.9999999-99999999-0.0.NONE"),
1870 gbp,
1871 ),
1872 Money::from("-1 GBP"),
1873 );
1874 let balances_before = betting_account.base.balances.clone();
1875 let locks_before = betting_account.balances_locked.clone();
1876 let events_before = betting_account.base.events.clone();
1877 let mut order = OrderTestBuilder::new(OrderType::Limit)
1878 .instrument_id(instrument_id)
1879 .side(OrderSide::Sell)
1880 .quantity(Quantity::from("50"))
1881 .price(Price::from("2.0"))
1882 .build();
1883 order
1884 .apply(OrderEventAny::Submitted(order_submitted_for_account(
1885 &order,
1886 AccountId::new("BETTING-001"),
1887 )))
1888 .unwrap();
1889 order
1890 .apply(OrderEventAny::Accepted(order_accepted_for_account(
1891 &order,
1892 VenueOrderId::new("1"),
1893 AccountId::new("BETTING-001"),
1894 )))
1895 .unwrap();
1896 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1897 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1898 let manager = AccountsManager::new(clock, cache);
1899 let mut account = AccountAny::Betting(betting_account);
1900
1901 let result = manager.update_orders_in_place(
1902 &mut account,
1903 &InstrumentAny::Betting(instrument),
1904 &[&order],
1905 UnixNanos::default(),
1906 );
1907
1908 assert_eq!(result, None);
1909 let AccountAny::Betting(betting_account) = account else {
1910 panic!("Expected BettingAccount")
1911 };
1912 assert_eq!(betting_account.base.balances, balances_before);
1913 assert_eq!(betting_account.balances_locked, locks_before);
1914 assert_eq!(betting_account.base.events, events_before);
1915 }
1916
1917 #[rstest]
1918 fn test_update_orders_betting_account_uses_liability_for_locked_balance() {
1919 let gbp = Currency::GBP();
1920 let account_state = AccountState::new(
1921 AccountId::new("BETTING-001"),
1922 AccountType::Betting,
1923 vec![AccountBalance::new(
1924 Money::new(1_000.0, gbp),
1925 Money::zero(gbp),
1926 Money::new(1_000.0, gbp),
1927 )],
1928 Vec::new(),
1929 true,
1930 UUID4::new(),
1931 UnixNanos::default(),
1932 UnixNanos::default(),
1933 Some(gbp),
1934 );
1935
1936 let account = BettingAccount::new(account_state, true);
1937
1938 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1939 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1940 cache
1941 .borrow_mut()
1942 .add_account(AccountAny::Betting(account.clone()))
1943 .unwrap();
1944
1945 let manager = AccountsManager::new(clock, cache);
1946 let instrument = betting();
1947
1948 let mut back_order = OrderTestBuilder::new(OrderType::Limit)
1949 .instrument_id(instrument.id())
1950 .side(OrderSide::Buy)
1951 .quantity(Quantity::from("10"))
1952 .price(Price::from("1.25"))
1953 .build();
1954
1955 let mut lay_order = OrderTestBuilder::new(OrderType::Limit)
1956 .instrument_id(instrument.id())
1957 .side(OrderSide::Sell)
1958 .quantity(Quantity::from("12"))
1959 .price(Price::from("3.00"))
1960 .build();
1961
1962 let submitted_back =
1963 order_submitted_for_account(&back_order, AccountId::new("BETTING-001"));
1964 let accepted_back = order_accepted_for_account(
1965 &back_order,
1966 VenueOrderId::new("B1"),
1967 AccountId::new("BETTING-001"),
1968 );
1969 back_order
1970 .apply(OrderEventAny::Submitted(submitted_back))
1971 .unwrap();
1972 back_order
1973 .apply(OrderEventAny::Accepted(accepted_back))
1974 .unwrap();
1975
1976 let submitted_lay = order_submitted_for_account(&lay_order, AccountId::new("BETTING-001"));
1977 let accepted_lay = order_accepted_for_account(
1978 &lay_order,
1979 VenueOrderId::new("L1"),
1980 AccountId::new("BETTING-001"),
1981 );
1982 lay_order
1983 .apply(OrderEventAny::Submitted(submitted_lay))
1984 .unwrap();
1985 lay_order
1986 .apply(OrderEventAny::Accepted(accepted_lay))
1987 .unwrap();
1988
1989 let orders: Vec<&OrderAny> = vec![&back_order, &lay_order];
1990 let result = manager.update_orders(
1991 &AccountAny::Betting(account),
1992 &InstrumentAny::Betting(instrument),
1993 &orders,
1994 UnixNanos::default(),
1995 );
1996
1997 assert!(result.is_some());
1998 let (updated_account, state) = result.unwrap();
1999
2000 if let AccountAny::Betting(betting_account) = updated_account {
2001 assert_eq!(
2002 betting_account.balance_locked(Some(gbp)),
2003 Some(Money::new(14.5, gbp))
2004 );
2005 assert_eq!(
2006 betting_account.balance_free(Some(gbp)),
2007 Some(Money::new(985.5, gbp))
2008 );
2009 assert_eq!(state.account_type, AccountType::Betting);
2010 } else {
2011 panic!("Expected BettingAccount");
2012 }
2013 }
2014
2015 #[rstest]
2016 fn test_update_orders_betting_precision_mismatch_preserves_state() {
2017 let gbp = Currency::GBP();
2018 let account_state = AccountState::new(
2019 AccountId::new("BETTING-001"),
2020 AccountType::Betting,
2021 vec![AccountBalance::new(
2022 Money::from("1000 GBP"),
2023 Money::zero(gbp),
2024 Money::from("1000 GBP"),
2025 )],
2026 Vec::new(),
2027 true,
2028 UUID4::new(),
2029 UnixNanos::default(),
2030 UnixNanos::default(),
2031 None,
2032 );
2033 let mut betting_account = BettingAccount::new(account_state, true);
2034 let mut instrument = betting();
2035 let instrument_id = instrument.id();
2036 betting_account
2037 .update_balance_locked(instrument_id, Money::from("100 GBP"))
2038 .unwrap();
2039 let balances_before = betting_account.base.balances.clone();
2040 let locks_before = betting_account.balances_locked.clone();
2041 let events_before = betting_account.base.events.clone();
2042 instrument.currency = Currency::new(
2043 "GBP",
2044 gbp.precision + 1,
2045 826,
2046 "Pound Sterling",
2047 CurrencyType::Fiat,
2048 );
2049 let mut order = OrderTestBuilder::new(OrderType::Limit)
2050 .instrument_id(instrument_id)
2051 .side(OrderSide::Sell)
2052 .quantity(Quantity::from("50"))
2053 .price(Price::from("2.0"))
2054 .build();
2055 order
2056 .apply(OrderEventAny::Submitted(order_submitted_for_account(
2057 &order,
2058 AccountId::new("BETTING-001"),
2059 )))
2060 .unwrap();
2061 order
2062 .apply(OrderEventAny::Accepted(order_accepted_for_account(
2063 &order,
2064 VenueOrderId::new("1"),
2065 AccountId::new("BETTING-001"),
2066 )))
2067 .unwrap();
2068 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2069 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2070 let manager = AccountsManager::new(clock, cache);
2071 let mut account = AccountAny::Betting(betting_account);
2072
2073 let result = manager.update_orders_in_place(
2074 &mut account,
2075 &InstrumentAny::Betting(instrument),
2076 &[&order],
2077 UnixNanos::default(),
2078 );
2079
2080 assert_eq!(result, None);
2081 let AccountAny::Betting(betting_account) = account else {
2082 panic!("Expected BettingAccount")
2083 };
2084 assert_eq!(betting_account.base.balances, balances_before);
2085 assert_eq!(betting_account.balances_locked, locks_before);
2086 assert_eq!(betting_account.base.events, events_before);
2087 }
2088
2089 #[rstest]
2090 fn test_betting_order_canceled_releases_locked_balance() {
2091 let gbp = Currency::GBP();
2092 let account_state = AccountState::new(
2093 AccountId::new("BETFAIR-001"),
2094 AccountType::Betting,
2095 vec![AccountBalance::new(
2096 Money::new(1_000.0, gbp),
2097 Money::zero(gbp),
2098 Money::new(1_000.0, gbp),
2099 )],
2100 Vec::new(),
2101 true,
2102 UUID4::new(),
2103 UnixNanos::default(),
2104 UnixNanos::default(),
2105 Some(gbp),
2106 );
2107
2108 let account = BettingAccount::new(account_state, true);
2109
2110 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2111 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2112 cache
2113 .borrow_mut()
2114 .add_account(AccountAny::Betting(account.clone()))
2115 .unwrap();
2116
2117 let manager = AccountsManager::new(clock, cache);
2118 let instrument = betting();
2119
2120 let mut order = OrderTestBuilder::new(OrderType::Limit)
2121 .instrument_id(instrument.id())
2122 .side(OrderSide::Buy)
2123 .quantity(Quantity::from("10"))
2124 .price(Price::from("5.0"))
2125 .build();
2126
2127 let submitted = order_submitted_for_account(&order, AccountId::new("BETFAIR-001"));
2128 let accepted = order_accepted_for_account(
2129 &order,
2130 VenueOrderId::new("B2"),
2131 AccountId::new("BETFAIR-001"),
2132 );
2133
2134 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
2135 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
2136
2137 let result = manager.update_orders(
2138 &AccountAny::Betting(account),
2139 &InstrumentAny::Betting(instrument.clone()),
2140 &[&order],
2141 UnixNanos::default(),
2142 );
2143
2144 assert!(result.is_some());
2145 let (updated_account, _) = result.unwrap();
2146
2147 if let AccountAny::Betting(ref betting_account) = updated_account {
2148 assert_eq!(
2149 betting_account.balance_locked(Some(gbp)),
2150 Some(Money::new(40.0, gbp))
2151 );
2152 assert_eq!(
2153 betting_account.balance_free(Some(gbp)),
2154 Some(Money::new(960.0, gbp))
2155 );
2156 } else {
2157 panic!("Expected BettingAccount");
2158 }
2159
2160 let result = manager.update_orders(
2161 &updated_account,
2162 &InstrumentAny::Betting(instrument),
2163 &[],
2164 UnixNanos::default(),
2165 );
2166
2167 assert!(result.is_some());
2168 let (final_account, _) = result.unwrap();
2169
2170 if let AccountAny::Betting(betting_account) = final_account {
2171 assert_eq!(
2172 betting_account.balance_locked(Some(gbp)),
2173 Some(Money::zero(gbp))
2174 );
2175 assert_eq!(
2176 betting_account.balance_free(Some(gbp)),
2177 Some(Money::new(1_000.0, gbp))
2178 );
2179 assert_eq!(
2180 betting_account.balance_total(Some(gbp)),
2181 Some(Money::new(1_000.0, gbp))
2182 );
2183 } else {
2184 panic!("Expected BettingAccount");
2185 }
2186 }
2187
2188 #[rstest]
2189 fn test_update_orders_clears_stale_currency_locks_when_order_sides_change() {
2190 let usd = Currency::USD();
2191 let aud = Currency::AUD();
2192 let account_state = AccountState::new(
2193 AccountId::new("SIM-001"),
2194 AccountType::Cash,
2195 vec![
2196 AccountBalance::new(
2197 Money::new(1_000_000.0, usd),
2198 Money::zero(usd),
2199 Money::new(1_000_000.0, usd),
2200 ),
2201 AccountBalance::new(
2202 Money::new(1_000_000.0, aud),
2203 Money::zero(aud),
2204 Money::new(1_000_000.0, aud),
2205 ),
2206 ],
2207 Vec::new(),
2208 true,
2209 UUID4::new(),
2210 UnixNanos::default(),
2211 UnixNanos::default(),
2212 None,
2213 );
2214
2215 let account = CashAccount::new(account_state, true, false);
2216
2217 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2218 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2219 cache
2220 .borrow_mut()
2221 .add_account(AccountAny::Cash(account.clone()))
2222 .unwrap();
2223
2224 let manager = AccountsManager::new(clock, cache);
2225 let instrument = audusd_sim();
2226
2227 let mut buy_order = OrderTestBuilder::new(OrderType::Limit)
2228 .instrument_id(instrument.id())
2229 .side(OrderSide::Buy)
2230 .quantity(Quantity::from("100000"))
2231 .price(Price::from("0.80000"))
2232 .build();
2233
2234 let mut sell_order = OrderTestBuilder::new(OrderType::Limit)
2235 .instrument_id(instrument.id())
2236 .side(OrderSide::Sell)
2237 .quantity(Quantity::from("50000"))
2238 .price(Price::from("0.81000"))
2239 .build();
2240
2241 let submitted_buy = order_submitted_for(&buy_order);
2243 let accepted_buy = order_accepted_for(&buy_order, VenueOrderId::new("1"));
2244 buy_order
2245 .apply(OrderEventAny::Submitted(submitted_buy))
2246 .unwrap();
2247 buy_order
2248 .apply(OrderEventAny::Accepted(accepted_buy))
2249 .unwrap();
2250
2251 let submitted_sell = order_submitted_for(&sell_order);
2252 let accepted_sell = order_accepted_for(&sell_order, VenueOrderId::new("2"));
2253 sell_order
2254 .apply(OrderEventAny::Submitted(submitted_sell))
2255 .unwrap();
2256 sell_order
2257 .apply(OrderEventAny::Accepted(accepted_sell))
2258 .unwrap();
2259
2260 let orders_both: Vec<&OrderAny> = vec![&buy_order, &sell_order];
2261 let result = manager.update_orders(
2262 &AccountAny::Cash(account),
2263 &InstrumentAny::CurrencyPair(instrument.clone()),
2264 &orders_both,
2265 UnixNanos::default(),
2266 );
2267
2268 assert!(result.is_some());
2269 let (updated_account, _) = result.unwrap();
2270
2271 if let AccountAny::Cash(cash_account) = &updated_account {
2272 assert_eq!(
2273 cash_account.balance_locked(Some(usd)),
2274 Some(Money::new(80_000.0, usd))
2275 );
2276 assert_eq!(
2277 cash_account.balance_locked(Some(aud)),
2278 Some(Money::new(50_000.0, aud))
2279 );
2280 } else {
2281 panic!("Expected CashAccount");
2282 }
2283
2284 let orders_sell_only: Vec<&OrderAny> = vec![&sell_order];
2286 let result = manager.update_orders(
2287 &updated_account,
2288 &InstrumentAny::CurrencyPair(instrument),
2289 &orders_sell_only,
2290 UnixNanos::default(),
2291 );
2292
2293 assert!(result.is_some());
2294 let (final_account, _) = result.unwrap();
2295
2296 if let AccountAny::Cash(cash_account) = final_account {
2297 assert_eq!(
2298 cash_account.balance_locked(Some(usd)),
2299 Some(Money::zero(usd))
2300 );
2301 assert_eq!(
2302 cash_account.balance_locked(Some(aud)),
2303 Some(Money::new(50_000.0, aud))
2304 );
2305 } else {
2306 panic!("Expected CashAccount");
2307 }
2308 }
2309
2310 #[rstest]
2311 fn test_update_orders_wallet_account_locks_submitted_reduce_only_market_sell() {
2312 let eth = Currency::ETH();
2313 let usdc = Currency::USDC();
2314 let account_state = AccountState::new(
2315 AccountId::new("WALLET-001"),
2316 AccountType::Wallet,
2317 vec![
2318 AccountBalance::new(
2319 Money::new(10.0, eth),
2320 Money::zero(eth),
2321 Money::new(10.0, eth),
2322 ),
2323 AccountBalance::new(
2324 Money::new(25_000.0, usdc),
2325 Money::zero(usdc),
2326 Money::new(25_000.0, usdc),
2327 ),
2328 ],
2329 Vec::new(),
2330 true,
2331 UUID4::new(),
2332 UnixNanos::default(),
2333 UnixNanos::default(),
2334 None,
2335 );
2336
2337 let account = WalletAccount::new(account_state, true);
2338
2339 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2340 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2341 cache
2342 .borrow_mut()
2343 .add_account(AccountAny::Wallet(account.clone()))
2344 .unwrap();
2345
2346 let manager = AccountsManager::new(clock, cache);
2347 let instrument = currency_pair_ethusdt();
2348
2349 let mut sell_order = OrderTestBuilder::new(OrderType::Market)
2350 .instrument_id(instrument.id())
2351 .side(OrderSide::Sell)
2352 .quantity(Quantity::from("2"))
2353 .reduce_only(true)
2354 .build();
2355
2356 let submitted = order_submitted_for(&sell_order);
2357 sell_order
2358 .apply(OrderEventAny::Submitted(submitted))
2359 .unwrap();
2360
2361 let orders: Vec<&OrderAny> = vec![&sell_order];
2362 let result = manager.update_orders(
2363 &AccountAny::Wallet(account),
2364 &InstrumentAny::CurrencyPair(instrument),
2365 &orders,
2366 UnixNanos::default(),
2367 );
2368
2369 assert!(result.is_some());
2370 let (updated_account, state) = result.unwrap();
2371
2372 assert_eq!(state.account_type, AccountType::Wallet);
2373 assert_eq!(state.balances.len(), 2);
2374 let AccountAny::Wallet(wallet_account) = &updated_account else {
2375 panic!("Expected WalletAccount")
2376 };
2377 assert_eq!(
2378 wallet_account.balance_locked(Some(eth)),
2379 Some(Money::new(2.0, eth))
2380 );
2381 assert_eq!(
2382 wallet_account.balance_free(Some(eth)),
2383 Some(Money::new(8.0, eth))
2384 );
2385 assert_eq!(
2386 wallet_account.balance_total(Some(eth)),
2387 Some(Money::new(10.0, eth))
2388 );
2389 assert_eq!(
2390 wallet_account.balance_locked(Some(usdc)),
2391 Some(Money::zero(usdc))
2392 );
2393 }
2394
2395 #[rstest]
2396 fn test_update_orders_wallet_wrong_instrument_preserves_locks() {
2397 let instrument = currency_pair_ethusdt();
2398 let mut wallet = WalletAccount::new(wallet_account_state(), true);
2399 wallet
2400 .update_balance_locked(instrument.id(), Money::from("2 ETH"))
2401 .unwrap();
2402 let mut account = AccountAny::Wallet(wallet);
2403 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2404 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2405 let manager = AccountsManager::new(clock, cache);
2406 let mut order = OrderTestBuilder::new(OrderType::Market)
2407 .instrument_id(currency_pair_btcusdt().id())
2408 .side(OrderSide::Sell)
2409 .quantity(Quantity::from("1"))
2410 .build();
2411 order
2412 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
2413 .unwrap();
2414
2415 let result = manager.update_orders_in_place(
2416 &mut account,
2417 &InstrumentAny::CurrencyPair(instrument),
2418 &[&order],
2419 UnixNanos::default(),
2420 );
2421
2422 assert_eq!(result, None);
2423 let AccountAny::Wallet(wallet) = account else {
2424 panic!("Expected WalletAccount")
2425 };
2426 assert_eq!(
2427 wallet.balance_locked(Some(Currency::ETH())),
2428 Some(Money::from("2 ETH"))
2429 );
2430 assert_eq!(
2431 wallet.balance_free(Some(Currency::ETH())),
2432 Some(Money::from("8 ETH"))
2433 );
2434 }
2435
2436 #[rstest]
2437 fn test_update_orders_wallet_error_restores_locks() {
2438 let instrument = currency_pair_ethusdt();
2439 let mut wallet = WalletAccount::new(wallet_account_state(), true);
2440 wallet
2441 .update_balance_locked(instrument.id(), Money::from("2 ETH"))
2442 .unwrap();
2443 wallet.balances_locked.insert(
2444 (InstrumentId::from("WETHDAI.BLOCKCHAIN"), Currency::ETH()),
2445 Money::from("-1 ETH"),
2446 );
2447 let balances_before = wallet.base.balances.clone();
2448 let locks_before = wallet.balances_locked.clone();
2449 let events_before = wallet.events.clone();
2450 let mut account = AccountAny::Wallet(wallet);
2451 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2452 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2453 let manager = AccountsManager::new(clock, cache);
2454 let mut order = OrderTestBuilder::new(OrderType::Market)
2455 .instrument_id(instrument.id())
2456 .side(OrderSide::Sell)
2457 .quantity(Quantity::from("1"))
2458 .build();
2459 order
2460 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
2461 .unwrap();
2462
2463 let result = manager.update_orders_in_place(
2464 &mut account,
2465 &InstrumentAny::CurrencyPair(instrument),
2466 &[&order],
2467 UnixNanos::default(),
2468 );
2469
2470 assert_eq!(result, None);
2471 let AccountAny::Wallet(wallet) = account else {
2472 panic!("Expected WalletAccount")
2473 };
2474 assert_eq!(wallet.base.balances, balances_before);
2475 assert_eq!(wallet.balances_locked, locks_before);
2476 assert_eq!(wallet.events, events_before);
2477 }
2478
2479 #[rstest]
2480 fn test_update_orders_wallet_account_fully_locks_pending_update_debit_currency() {
2481 let account = WalletAccount::new(wallet_account_state(), true);
2482 let account_id = account.id;
2483 let eth = Currency::ETH();
2484 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2485 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2486 cache
2487 .borrow_mut()
2488 .add_account(AccountAny::Wallet(account.clone()))
2489 .unwrap();
2490
2491 let manager = AccountsManager::new(clock, cache);
2492 let instrument = currency_pair_ethusdt();
2493 let mut order = OrderTestBuilder::new(OrderType::Limit)
2494 .instrument_id(instrument.id())
2495 .side(OrderSide::Sell)
2496 .quantity(Quantity::from("2"))
2497 .price(Price::from("3000"))
2498 .build();
2499 order
2500 .apply(OrderEventAny::Submitted(order_submitted_for_account(
2501 &order, account_id,
2502 )))
2503 .unwrap();
2504 let venue_order_id = VenueOrderId::new("1");
2505 order
2506 .apply(OrderEventAny::Accepted(order_accepted_for_account(
2507 &order,
2508 venue_order_id,
2509 account_id,
2510 )))
2511 .unwrap();
2512 let pending_update = OrderPendingUpdateSpec::builder()
2513 .trader_id(order.trader_id())
2514 .strategy_id(order.strategy_id())
2515 .instrument_id(order.instrument_id())
2516 .client_order_id(order.client_order_id())
2517 .account_id(account_id)
2518 .venue_order_id(venue_order_id)
2519 .build();
2520 order
2521 .apply(OrderEventAny::PendingUpdate(pending_update))
2522 .unwrap();
2523
2524 let result = manager.update_orders(
2525 &AccountAny::Wallet(account),
2526 &InstrumentAny::CurrencyPair(instrument),
2527 &[&order],
2528 UnixNanos::default(),
2529 );
2530
2531 let (updated_account, _) = result.unwrap();
2532 let AccountAny::Wallet(wallet) = updated_account else {
2533 panic!("Expected WalletAccount")
2534 };
2535 assert_eq!(wallet.balance_total(Some(eth)), Some(Money::from("10 ETH")));
2536 assert_eq!(
2537 wallet.balance_locked(Some(eth)),
2538 Some(Money::from("10 ETH"))
2539 );
2540 assert_eq!(wallet.balance_free(Some(eth)), Some(Money::from("0 ETH")));
2541 }
2542
2543 #[rstest]
2544 fn test_update_orders_wallet_preserves_dex_terms_at_observed_precision() {
2545 let Some((wallet, instrument, base, quote)) = wallet_precision_pair(18) else {
2546 return;
2547 };
2548 let mut sell = OrderTestBuilder::new(OrderType::Market)
2549 .instrument_id(instrument.id())
2550 .side(OrderSide::Sell)
2551 .quantity(Quantity::from_raw(1_234_567_890_123_456, 16))
2552 .build();
2553 let mut buy = OrderTestBuilder::new(OrderType::Limit)
2554 .instrument_id(instrument.id())
2555 .side(OrderSide::Buy)
2556 .quantity(Quantity::from_raw(1_000_000_000_000_000, 16))
2557 .price(Price::from_raw(1_234_567_890_123_456, 16))
2558 .build();
2559 let mut buy_quote = OrderTestBuilder::new(OrderType::Market)
2560 .instrument_id(instrument.id())
2561 .side(OrderSide::Buy)
2562 .quantity(Quantity::from_raw(1_234_567_890_123_456, 16))
2563 .quote_quantity(true)
2564 .build();
2565 sell.apply(OrderEventAny::Submitted(order_submitted_for(&sell)))
2566 .unwrap();
2567 buy.apply(OrderEventAny::Submitted(order_submitted_for(&buy)))
2568 .unwrap();
2569 buy_quote
2570 .apply(OrderEventAny::Submitted(order_submitted_for(&buy_quote)))
2571 .unwrap();
2572 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2573 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2574 let manager = AccountsManager::new(clock, cache);
2575
2576 let result = manager.update_orders(
2577 &AccountAny::Wallet(wallet),
2578 &InstrumentAny::CurrencyPair(instrument),
2579 &[&sell, &buy, &buy_quote],
2580 UnixNanos::default(),
2581 );
2582
2583 let (AccountAny::Wallet(wallet), _) = result.unwrap() else {
2584 panic!("Expected WalletAccount")
2585 };
2586 let base_balance = wallet.balance(Some(base)).unwrap();
2587 let quote_balance = wallet.balance(Some(quote)).unwrap();
2588 assert_eq!(base_balance.currency.precision, 18);
2589 assert_eq!(base_balance.total.raw(), 1_000_000_000_000_000_000);
2590 assert_eq!(base_balance.locked.raw(), 123_456_789_012_345_600);
2591 assert_eq!(base_balance.free.raw(), 876_543_210_987_654_400);
2592 assert_eq!(quote_balance.currency.precision, 18);
2593 assert_eq!(quote_balance.total.raw(), 2_000_000_000_000_000_000);
2594 assert_eq!(quote_balance.locked.raw(), 135_802_467_913_580_160);
2595 assert_eq!(quote_balance.free.raw(), 1_864_197_532_086_419_840);
2596 }
2597
2598 #[rstest]
2599 fn test_update_orders_wallet_uses_observed_currency_grid() {
2600 let Some((wallet, instrument, base, quote)) = wallet_precision_pair(6) else {
2601 return;
2602 };
2603 let mut sell = OrderTestBuilder::new(OrderType::Market)
2604 .instrument_id(instrument.id())
2605 .side(OrderSide::Sell)
2606 .quantity(Quantity::from_raw(1_234_560_000_000_000, 16))
2607 .build();
2608 let mut buy = OrderTestBuilder::new(OrderType::Limit)
2609 .instrument_id(instrument.id())
2610 .side(OrderSide::Buy)
2611 .quantity(Quantity::from_raw(1_000_000_000_000_000, 16))
2612 .price(Price::from_raw(12_345_670_000_000_000, 16))
2613 .build();
2614 let mut buy_quote = OrderTestBuilder::new(OrderType::Market)
2615 .instrument_id(instrument.id())
2616 .side(OrderSide::Buy)
2617 .quantity(Quantity::from_raw(2_345_670_000_000_000, 16))
2618 .quote_quantity(true)
2619 .build();
2620 sell.apply(OrderEventAny::Submitted(order_submitted_for(&sell)))
2621 .unwrap();
2622 buy.apply(OrderEventAny::Submitted(order_submitted_for(&buy)))
2623 .unwrap();
2624 buy_quote
2625 .apply(OrderEventAny::Submitted(order_submitted_for(&buy_quote)))
2626 .unwrap();
2627 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2628 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2629 let manager = AccountsManager::new(clock, cache);
2630 let scale = money_raw(10_i128.pow(u32::from(FIXED_PRECISION)));
2631 let grid = money_raw(10_i128.pow(u32::from(FIXED_PRECISION - quote.precision)));
2632
2633 let result = manager.update_orders(
2634 &AccountAny::Wallet(wallet),
2635 &InstrumentAny::CurrencyPair(instrument),
2636 &[&sell, &buy, &buy_quote],
2637 UnixNanos::default(),
2638 );
2639
2640 let (AccountAny::Wallet(wallet), _) = result.unwrap() else {
2641 panic!("Expected WalletAccount")
2642 };
2643 let base_balance = wallet.balance(Some(base)).unwrap();
2644 let quote_balance = wallet.balance(Some(quote)).unwrap();
2645 assert_eq!(base_balance.currency.precision, 6);
2646 assert_eq!(base_balance.total.raw(), scale);
2647 assert_eq!(base_balance.locked.raw(), 123_456 * grid);
2648 assert_eq!(base_balance.free.raw(), scale - 123_456 * grid);
2649 assert_eq!(quote_balance.currency.precision, 6);
2650 assert_eq!(quote_balance.total.raw(), 2 * scale);
2651 assert_eq!(quote_balance.locked.raw(), 358_024 * grid);
2652 assert_eq!(quote_balance.free.raw(), 2 * scale - 358_024 * grid);
2653 }
2654
2655 #[rstest]
2656 #[case::sell(OrderSide::Sell, false, 1, 18, 10_000_000_000_000_000)]
2657 #[case::buy_quote(OrderSide::Buy, true, 1, 18, 10_000_000_000_000_000)]
2658 #[case::sell_currency_grid(OrderSide::Sell, false, 1, 16, 10_000_000_000_000_000)]
2659 #[case::buy_quote_currency_grid(OrderSide::Buy, true, 1, 16, 10_000_000_000_000_000)]
2660 fn test_update_orders_wallet_explicit_quantity_loss_preserves_state(
2661 #[case] side: OrderSide,
2662 #[case] quote_quantity: bool,
2663 #[case] quantity_raw: u128,
2664 #[case] quantity_precision: u8,
2665 #[case] price_raw: i128,
2666 ) {
2667 let wallet_precision = if quantity_precision == 18 { 16 } else { 6 };
2668 let Some((mut wallet, instrument, base, _)) = wallet_precision_pair(wallet_precision)
2669 else {
2670 return;
2671 };
2672 let grid =
2673 money_raw(10_i128.pow(u32::from(FIXED_PRECISION.saturating_sub(wallet_precision))));
2674 wallet
2675 .update_balance_locked(
2676 InstrumentId::from("OTHER.BLOCKCHAIN"),
2677 Money::from_raw(grid, base),
2678 )
2679 .unwrap();
2680 let balances_before = wallet.base.balances.clone();
2681 let locks_before = wallet.balances_locked.clone();
2682 let events_before = wallet.events.clone();
2683 let mut account = AccountAny::Wallet(wallet);
2684 #[allow(
2685 clippy::useless_conversion,
2686 reason = "the test input width differs when high-precision is disabled"
2687 )]
2688 let quantity_raw = quantity_raw.try_into().unwrap();
2689 #[allow(
2690 clippy::useless_conversion,
2691 reason = "the test input width differs when high-precision is disabled"
2692 )]
2693 let price_raw = price_raw.try_into().unwrap();
2694 let mut order = OrderTestBuilder::new(OrderType::Limit)
2695 .instrument_id(instrument.id())
2696 .side(side)
2697 .quantity(Quantity::from_raw(quantity_raw, quantity_precision))
2698 .price(Price::from_raw(price_raw, 16))
2699 .quote_quantity(quote_quantity)
2700 .build();
2701 order
2702 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
2703 .unwrap();
2704 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2705 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2706 let manager = AccountsManager::new(clock, cache);
2707
2708 let result = manager.update_orders_in_place(
2709 &mut account,
2710 &InstrumentAny::CurrencyPair(instrument),
2711 &[&order],
2712 UnixNanos::default(),
2713 );
2714
2715 assert_eq!(result, None);
2716 let AccountAny::Wallet(wallet) = account else {
2717 panic!("Expected WalletAccount")
2718 };
2719 assert_eq!(wallet.base.balances, balances_before);
2720 assert_eq!(wallet.balances_locked, locks_before);
2721 assert_eq!(wallet.events, events_before);
2722 }
2723
2724 #[rstest]
2725 fn test_update_orders_wallet_aggregate_overflow_preserves_state() {
2726 let Some((mut wallet, instrument, base, _)) = wallet_precision_pair(16) else {
2727 return;
2728 };
2729 wallet
2730 .update_balance_locked(
2731 InstrumentId::from("OTHER.BLOCKCHAIN"),
2732 Money::from_raw(1_000, base),
2733 )
2734 .unwrap();
2735 let balances_before = wallet.base.balances.clone();
2736 let locks_before = wallet.balances_locked.clone();
2737 let events_before = wallet.events.clone();
2738 let mut account = AccountAny::Wallet(wallet);
2739 let quantity_raw = MONEY_RAW_MAX.try_into().unwrap();
2740 let mut first = OrderTestBuilder::new(OrderType::Market)
2741 .instrument_id(instrument.id())
2742 .side(OrderSide::Buy)
2743 .quantity(Quantity::from_raw(quantity_raw, 16))
2744 .quote_quantity(true)
2745 .build();
2746 let mut second = OrderTestBuilder::new(OrderType::Market)
2747 .instrument_id(instrument.id())
2748 .side(OrderSide::Buy)
2749 .quantity(Quantity::from_raw(quantity_raw, 16))
2750 .quote_quantity(true)
2751 .build();
2752 first
2753 .apply(OrderEventAny::Submitted(order_submitted_for(&first)))
2754 .unwrap();
2755 second
2756 .apply(OrderEventAny::Submitted(order_submitted_for(&second)))
2757 .unwrap();
2758 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2759 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2760 let manager = AccountsManager::new(clock, cache);
2761
2762 let result = manager.update_orders_in_place(
2763 &mut account,
2764 &InstrumentAny::CurrencyPair(instrument),
2765 &[&first, &second],
2766 UnixNanos::default(),
2767 );
2768
2769 assert_eq!(result, None);
2770 let AccountAny::Wallet(wallet) = account else {
2771 panic!("Expected WalletAccount")
2772 };
2773 assert_eq!(wallet.base.balances, balances_before);
2774 assert_eq!(wallet.balances_locked, locks_before);
2775 assert_eq!(wallet.events, events_before);
2776 }
2777
2778 #[rstest]
2779 fn test_update_orders_margin_init_xrate_unavailable_returns_none() {
2780 let eur = Currency::EUR();
2781 let account_state = AccountState::new(
2782 AccountId::new("SIM-001"),
2783 AccountType::Margin,
2784 vec![AccountBalance::new(
2785 Money::new(1_000_000.0, eur),
2786 Money::zero(eur),
2787 Money::new(1_000_000.0, eur),
2788 )],
2789 Vec::new(),
2790 true,
2791 UUID4::new(),
2792 UnixNanos::default(),
2793 UnixNanos::default(),
2794 Some(eur),
2795 );
2796 let mut account = MarginAccount::new(account_state, true);
2797 let instrument = audusd_sim();
2798 let prior_margin = Money::new(10.0, eur);
2799 account.update_initial_margin(instrument.id(), prior_margin);
2800
2801 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2802 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2803 let manager = AccountsManager::new(clock, cache);
2804
2805 let mut order = OrderTestBuilder::new(OrderType::Limit)
2806 .instrument_id(instrument.id())
2807 .side(OrderSide::Buy)
2808 .quantity(Quantity::from("100000"))
2809 .price(Price::from("0.80000"))
2810 .build();
2811
2812 let submitted = order_submitted_for(&order);
2813 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
2814 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
2815 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
2816
2817 let mut account = AccountAny::Margin(account);
2818 let result = manager.update_orders_in_place(
2819 &mut account,
2820 &InstrumentAny::CurrencyPair(instrument.clone()),
2821 &[&order],
2822 UnixNanos::default(),
2823 );
2824
2825 assert!(result.is_none(), "xrate-unavailable must return None");
2826
2827 match account {
2828 AccountAny::Margin(margin_account) => {
2829 assert_eq!(margin_account.initial_margin(instrument.id()), prior_margin);
2830 assert_eq!(margin_account.balance_locked(Some(eur)), Some(prior_margin));
2831 }
2832 _ => panic!("Expected MarginAccount"),
2833 }
2834 }
2835
2836 #[rstest]
2837 fn test_update_balance_locked_base_xrate_uses_bid_for_buy_order() {
2838 let eur = Currency::EUR();
2839 let account_state = AccountState::new(
2840 AccountId::new("SIM-001"),
2841 AccountType::Cash,
2842 vec![AccountBalance::new(
2843 Money::new(1_000.0, eur),
2844 Money::zero(eur),
2845 Money::new(1_000.0, eur),
2846 )],
2847 Vec::new(),
2848 true,
2849 UUID4::new(),
2850 UnixNanos::default(),
2851 UnixNanos::default(),
2852 Some(eur),
2853 );
2854 let account = CashAccount::new(account_state, true, false);
2855
2856 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2857 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2858 add_usdeur_quote(&cache, "0.90000", "1.10000");
2859 let manager = AccountsManager::new(clock, cache);
2860
2861 let instrument = audusd_sim();
2862 let mut order = OrderTestBuilder::new(OrderType::Limit)
2863 .instrument_id(instrument.id())
2864 .side(OrderSide::Buy)
2865 .quantity(Quantity::from("100"))
2866 .price(Price::from("2.00000"))
2867 .build();
2868
2869 let submitted = order_submitted_for(&order);
2870 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
2871 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
2872 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
2873
2874 let result = manager.update_orders(
2875 &AccountAny::Cash(account),
2876 &InstrumentAny::CurrencyPair(instrument),
2877 &[&order],
2878 UnixNanos::default(),
2879 );
2880
2881 assert!(result.is_some());
2882 let (updated_account, _) = result.unwrap();
2883
2884 match updated_account {
2885 AccountAny::Cash(cash) => {
2886 assert_eq!(cash.balance_locked(Some(eur)), Some(Money::new(180.0, eur)));
2887 }
2888 _ => panic!("Expected CashAccount"),
2889 }
2890 }
2891
2892 #[rstest]
2893 fn test_update_balance_locked_converts_each_calculated_currency() {
2894 let eur = Currency::EUR();
2895 let account_state = AccountState::new(
2896 AccountId::new("SIM-001"),
2897 AccountType::Cash,
2898 vec![AccountBalance::new(
2899 Money::new(1_000.0, eur),
2900 Money::zero(eur),
2901 Money::new(1_000.0, eur),
2902 )],
2903 Vec::new(),
2904 true,
2905 UUID4::new(),
2906 UnixNanos::default(),
2907 UnixNanos::default(),
2908 Some(eur),
2909 );
2910 let account = CashAccount::new(account_state, true, false);
2911 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2912 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2913 add_usdeur_quote(&cache, "0.90000", "1.10000");
2914 let etheur = default_fx_ccy(Symbol::from("ETH/EUR"), Some(Venue::from("SIM")));
2915 cache
2916 .borrow_mut()
2917 .add_instrument(InstrumentAny::CurrencyPair(etheur.clone()))
2918 .unwrap();
2919 cache
2920 .borrow_mut()
2921 .add_quote(QuoteTick::new(
2922 etheur.id(),
2923 Price::from("40.00000"),
2924 Price::from("50.00000"),
2925 Quantity::from("1"),
2926 Quantity::from("1"),
2927 UnixNanos::default(),
2928 UnixNanos::default(),
2929 ))
2930 .unwrap();
2931 let manager = AccountsManager::new(clock, cache);
2932 let instrument = usd_usdt_future();
2933 let mut buy_order = OrderTestBuilder::new(OrderType::Limit)
2934 .instrument_id(instrument.id())
2935 .side(OrderSide::Buy)
2936 .quantity(Quantity::from("2"))
2937 .price(Price::from("100.00"))
2938 .build();
2939 buy_order
2940 .apply(OrderEventAny::Submitted(order_submitted_for(&buy_order)))
2941 .unwrap();
2942 buy_order
2943 .apply(OrderEventAny::Accepted(order_accepted_for(
2944 &buy_order,
2945 VenueOrderId::new("1"),
2946 )))
2947 .unwrap();
2948 let mut sell_order = OrderTestBuilder::new(OrderType::Limit)
2949 .instrument_id(instrument.id())
2950 .side(OrderSide::Sell)
2951 .quantity(Quantity::from("3"))
2952 .price(Price::from("100.00"))
2953 .build();
2954 sell_order
2955 .apply(OrderEventAny::Submitted(order_submitted_for(&sell_order)))
2956 .unwrap();
2957 sell_order
2958 .apply(OrderEventAny::Accepted(order_accepted_for(
2959 &sell_order,
2960 VenueOrderId::new("2"),
2961 )))
2962 .unwrap();
2963
2964 let result = manager.update_orders(
2965 &AccountAny::Cash(account),
2966 &InstrumentAny::CryptoFuture(instrument),
2967 &[&buy_order, &sell_order],
2968 UnixNanos::default(),
2969 );
2970
2971 let (updated_account, _) =
2972 result.expect("USD and ETH locked balances should convert to EUR");
2973 let AccountAny::Cash(cash) = updated_account else {
2974 panic!("Expected CashAccount");
2975 };
2976 assert_eq!(cash.balance_locked(Some(eur)), Some(Money::new(330.0, eur)));
2977 }
2978
2979 #[rstest]
2980 fn test_update_balance_locked_fails_closed_on_money_overflow() {
2981 let usd = Currency::USD();
2982 let account_state = AccountState::new(
2983 AccountId::new("SIM-001"),
2984 AccountType::Cash,
2985 vec![AccountBalance::new(
2986 Money::new(1_000.0, usd),
2987 Money::zero(usd),
2988 Money::new(1_000.0, usd),
2989 )],
2990 Vec::new(),
2991 true,
2992 UUID4::new(),
2993 UnixNanos::default(),
2994 UnixNanos::default(),
2995 None,
2996 );
2997 let mut account = AccountAny::Cash(CashAccount::new(account_state, true, false));
2998 let instrument = audusd_sim();
2999 let open_order = |side: OrderSide, quantity: Quantity, venue_order_id: &str| {
3000 let mut order = OrderTestBuilder::new(OrderType::Limit)
3001 .instrument_id(instrument.id())
3002 .side(side)
3003 .quantity(quantity)
3004 .price(Price::from("1.00000"))
3005 .build();
3006 order
3007 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
3008 .unwrap();
3009 order
3010 .apply(OrderEventAny::Accepted(order_accepted_for(
3011 &order,
3012 VenueOrderId::new(venue_order_id),
3013 )))
3014 .unwrap();
3015 order
3016 };
3017 let half_max = Quantity::new(MONEY_MAX / 2.0 + 1.0, 0);
3018 let first = open_order(OrderSide::Buy, half_max, "1");
3019 let second = open_order(OrderSide::Buy, half_max, "2");
3020 let out_of_range = open_order(OrderSide::Sell, Quantity::new(MONEY_MAX + 1.0, 0), "3");
3021 let manager = AccountsManager::new(
3022 Rc::new(RefCell::new(VirtualClock::new())),
3023 Rc::new(RefCell::new(Cache::new(None, None))),
3024 );
3025
3026 let total_overflow = manager.update_orders_in_place(
3027 &mut account,
3028 &InstrumentAny::CurrencyPair(instrument.clone()),
3029 &[&first, &second],
3030 UnixNanos::default(),
3031 );
3032 let calculation_overflow = manager.update_orders_in_place(
3033 &mut account,
3034 &InstrumentAny::CurrencyPair(instrument),
3035 &[&out_of_range],
3036 UnixNanos::default(),
3037 );
3038
3039 assert!(total_overflow.is_none());
3040 assert!(calculation_overflow.is_none());
3041 let AccountAny::Cash(account) = account else {
3042 panic!("Expected CashAccount");
3043 };
3044 assert_eq!(account.balance_locked(Some(usd)), Some(Money::zero(usd)));
3045 }
3046
3047 #[rstest]
3048 fn test_update_betting_balance_locked_fails_closed_on_money_overflow() {
3049 let gbp = Currency::GBP();
3050 let account_state = AccountState::new(
3051 AccountId::new("BETTING-001"),
3052 AccountType::Betting,
3053 vec![AccountBalance::new(
3054 Money::new(1_000.0, gbp),
3055 Money::zero(gbp),
3056 Money::new(1_000.0, gbp),
3057 )],
3058 Vec::new(),
3059 true,
3060 UUID4::new(),
3061 UnixNanos::default(),
3062 UnixNanos::default(),
3063 Some(gbp),
3064 );
3065 let mut account = AccountAny::Betting(BettingAccount::new(account_state, true));
3066 let instrument = betting();
3067 let open_order = |quantity: Quantity, venue_order_id: &str| {
3068 let mut order = OrderTestBuilder::new(OrderType::Limit)
3069 .instrument_id(instrument.id())
3070 .side(OrderSide::Sell)
3071 .quantity(quantity)
3072 .price(Price::from("2.00"))
3073 .build();
3074 order
3075 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
3076 .unwrap();
3077 order
3078 .apply(OrderEventAny::Accepted(order_accepted_for(
3079 &order,
3080 VenueOrderId::new(venue_order_id),
3081 )))
3082 .unwrap();
3083 order
3084 };
3085 let half_max = Quantity::new(MONEY_MAX / 2.0 + 1.0, 0);
3086 let first = open_order(half_max, "1");
3087 let second = open_order(half_max, "2");
3088 let out_of_range = open_order(Quantity::new(MONEY_MAX + 1.0, 0), "3");
3089 let manager = AccountsManager::new(
3090 Rc::new(RefCell::new(VirtualClock::new())),
3091 Rc::new(RefCell::new(Cache::new(None, None))),
3092 );
3093
3094 let total_overflow = manager.update_orders_in_place(
3095 &mut account,
3096 &InstrumentAny::Betting(instrument.clone()),
3097 &[&first, &second],
3098 UnixNanos::default(),
3099 );
3100 let calculation_overflow = manager.update_orders_in_place(
3101 &mut account,
3102 &InstrumentAny::Betting(instrument),
3103 &[&out_of_range],
3104 UnixNanos::default(),
3105 );
3106
3107 assert!(total_overflow.is_none());
3108 assert!(calculation_overflow.is_none());
3109 let AccountAny::Betting(account) = account else {
3110 panic!("Expected BettingAccount");
3111 };
3112 assert_eq!(account.balance_locked(Some(gbp)), Some(Money::zero(gbp)));
3113 }
3114
3115 #[rstest]
3116 #[case(
3117 Some(Currency::EUR()),
3118 Currency::EUR(),
3119 Money::new(18.0, Currency::EUR())
3120 )]
3121 #[case(None, Currency::USD(), Money::new(20.0, Currency::USD()))]
3122 fn test_update_margins_use_calculated_currency(
3123 #[case] base_currency: Option<Currency>,
3124 #[case] balance_currency: Currency,
3125 #[case] expected_margin: Money,
3126 ) {
3127 let account_state = AccountState::new(
3128 AccountId::new("SIM-001"),
3129 AccountType::Margin,
3130 vec![AccountBalance::new(
3131 Money::new(1_000.0, balance_currency),
3132 Money::zero(balance_currency),
3133 Money::new(1_000.0, balance_currency),
3134 )],
3135 Vec::new(),
3136 true,
3137 UUID4::new(),
3138 UnixNanos::default(),
3139 UnixNanos::default(),
3140 base_currency,
3141 );
3142 let account = MarginAccount::new(account_state, true);
3143 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3144 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3145 add_usdeur_quote(&cache, "0.90000", "1.10000");
3146 let manager = AccountsManager::new(clock, cache);
3147 let instrument = usd_usdt_future();
3148 let instrument_any = InstrumentAny::CryptoFuture(instrument.clone());
3149 let mut order = OrderTestBuilder::new(OrderType::Limit)
3150 .instrument_id(instrument.id())
3151 .side(OrderSide::Buy)
3152 .quantity(Quantity::from("2"))
3153 .price(Price::from("100.00"))
3154 .build();
3155 order
3156 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
3157 .unwrap();
3158 order
3159 .apply(OrderEventAny::Accepted(order_accepted_for(
3160 &order,
3161 VenueOrderId::new("1"),
3162 )))
3163 .unwrap();
3164
3165 let (updated_account, _) = manager
3166 .update_orders(
3167 &AccountAny::Margin(account),
3168 &instrument_any,
3169 &[&order],
3170 UnixNanos::default(),
3171 )
3172 .expect("USD initial margin should convert to EUR");
3173 let AccountAny::Margin(mut account) = updated_account else {
3174 panic!("Expected MarginAccount");
3175 };
3176 let position = build_hedging_position(&instrument_any, OrderSide::Buy, "2", "100.00", "P");
3177
3178 manager
3179 .update_positions_in_place(
3180 &mut account,
3181 &instrument_any,
3182 vec![&position],
3183 UnixNanos::default(),
3184 )
3185 .expect("USD maintenance margin should convert to EUR");
3186
3187 assert_eq!(account.initial_margin(instrument.id()), expected_margin);
3188 assert_eq!(account.maintenance_margin(instrument.id()), expected_margin);
3189 }
3190
3191 #[rstest]
3192 fn test_update_margins_reject_calculated_currency_change() {
3193 let usdt = Currency::USDT();
3194 let instrument = usd_usdt_future();
3195 let prior_margin = MarginBalance::new(
3196 Money::new(10.0, usdt),
3197 Money::new(5.0, usdt),
3198 Some(instrument.id()),
3199 );
3200 let account_state = AccountState::new(
3201 AccountId::new("SIM-001"),
3202 AccountType::Margin,
3203 vec![AccountBalance::new(
3204 Money::new(1_000.0, usdt),
3205 Money::new(15.0, usdt),
3206 Money::new(985.0, usdt),
3207 )],
3208 vec![prior_margin],
3209 true,
3210 UUID4::new(),
3211 UnixNanos::default(),
3212 UnixNanos::default(),
3213 None,
3214 );
3215 let mut account = MarginAccount::new(account_state, true);
3216 let manager = AccountsManager::new(
3217 Rc::new(RefCell::new(VirtualClock::new())),
3218 Rc::new(RefCell::new(Cache::new(None, None))),
3219 );
3220 let instrument_any = InstrumentAny::CryptoFuture(instrument.clone());
3221 let mut order = OrderTestBuilder::new(OrderType::Limit)
3222 .instrument_id(instrument.id())
3223 .side(OrderSide::Buy)
3224 .quantity(Quantity::from("2"))
3225 .price(Price::from("100.00"))
3226 .build();
3227 order
3228 .apply(OrderEventAny::Submitted(order_submitted_for(&order)))
3229 .unwrap();
3230 order
3231 .apply(OrderEventAny::Accepted(order_accepted_for(
3232 &order,
3233 VenueOrderId::new("1"),
3234 )))
3235 .unwrap();
3236 let position = build_hedging_position(&instrument_any, OrderSide::Buy, "2", "100.00", "P");
3237
3238 let initial_result = manager.update_margin_init(
3239 &mut account,
3240 &instrument_any,
3241 &[&order],
3242 UnixNanos::default(),
3243 );
3244 let maintenance_result = manager.update_positions_in_place(
3245 &mut account,
3246 &instrument_any,
3247 vec![&position],
3248 UnixNanos::default(),
3249 );
3250
3251 assert!(initial_result.is_none());
3252 assert!(maintenance_result.is_none());
3253 assert_eq!(account.margin(&instrument.id()), Some(prior_margin));
3254 assert_eq!(
3255 account.balance_locked(Some(usdt)),
3256 Some(Money::new(15.0, usdt))
3257 );
3258 }
3259
3260 #[rstest]
3261 fn test_update_margin_init_base_xrate_uses_ask_for_sell_order() {
3262 let eur = Currency::EUR();
3263 let account_state = AccountState::new(
3264 AccountId::new("SIM-001"),
3265 AccountType::Margin,
3266 vec![AccountBalance::new(
3267 Money::new(1_000.0, eur),
3268 Money::zero(eur),
3269 Money::new(1_000.0, eur),
3270 )],
3271 Vec::new(),
3272 true,
3273 UUID4::new(),
3274 UnixNanos::default(),
3275 UnixNanos::default(),
3276 Some(eur),
3277 );
3278 let account = MarginAccount::new(account_state, true);
3279
3280 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3281 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3282 add_usdeur_quote(&cache, "0.90000", "1.10000");
3283 let manager = AccountsManager::new(clock, cache);
3284
3285 let instrument = audusd_sim();
3286 let mut order = OrderTestBuilder::new(OrderType::Limit)
3287 .instrument_id(instrument.id())
3288 .side(OrderSide::Sell)
3289 .quantity(Quantity::from("100"))
3290 .price(Price::from("2.00000"))
3291 .build();
3292
3293 let submitted = order_submitted_for(&order);
3294 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
3295 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3296 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3297
3298 let result = manager.update_orders(
3299 &AccountAny::Margin(account),
3300 &InstrumentAny::CurrencyPair(instrument.clone()),
3301 &[&order],
3302 UnixNanos::default(),
3303 );
3304
3305 assert!(result.is_some());
3306 let (updated_account, _) = result.unwrap();
3307
3308 match updated_account {
3309 AccountAny::Margin(margin) => {
3310 assert_eq!(
3311 margin.initial_margin(instrument.id()),
3312 Money::new(6.60, eur)
3313 );
3314 }
3315 _ => panic!("Expected MarginAccount"),
3316 }
3317 }
3318
3319 #[rstest]
3320 fn test_update_margin_init_empty_orders_clears_prior_initial_margin() {
3321 let usd = Currency::USD();
3322 let mut account = build_margin_account_usd(1_000_000.0);
3323 let instrument = audusd_sim();
3324 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3325 account.update_margin(MarginBalance::new(
3326 Money::new(25.0, usd),
3327 Money::zero(usd),
3328 Some(instrument.id()),
3329 ));
3330
3331 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3332 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3333 let manager = AccountsManager::new(clock, cache);
3334
3335 let state = manager
3336 .update_margin_init(&mut account, &instrument_any, &[], UnixNanos::default())
3337 .expect("initial margin clear should generate account state");
3338
3339 assert!(account.margin(&instrument.id()).is_none());
3340 assert!(state.margins.is_empty());
3341 }
3342
3343 #[rstest]
3344 fn test_update_margin_init_empty_orders_preserves_prior_maintenance_margin() {
3345 let usd = Currency::USD();
3346 let mut account = build_margin_account_usd(1_000_000.0);
3347 let instrument = audusd_sim();
3348 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3349 let maintenance = Money::new(12.0, usd);
3350 account.update_margin(MarginBalance::new(
3351 Money::new(25.0, usd),
3352 maintenance,
3353 Some(instrument.id()),
3354 ));
3355
3356 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3357 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3358 let manager = AccountsManager::new(clock, cache);
3359
3360 let state = manager
3361 .update_margin_init(&mut account, &instrument_any, &[], UnixNanos::default())
3362 .expect("initial margin clear should generate account state");
3363
3364 let margin = account
3365 .margin(&instrument.id())
3366 .expect("maintenance margin should remain");
3367 assert_eq!(margin.initial, Money::zero(usd));
3368 assert_eq!(margin.maintenance, maintenance);
3369 assert_eq!(state.margins, vec![margin]);
3370 }
3371
3372 #[rstest]
3373 fn test_cash_account_rejects_negative_balance_when_borrowing_disabled() {
3374 let usd = Currency::USD();
3375 let account_state = AccountState::new(
3376 AccountId::new("SIM-001"),
3377 AccountType::Cash,
3378 vec![AccountBalance::new(
3379 Money::new(1_000.0, usd),
3380 Money::zero(usd),
3381 Money::new(1_000.0, usd),
3382 )],
3383 Vec::new(),
3384 true,
3385 UUID4::new(),
3386 UnixNanos::default(),
3387 UnixNanos::default(),
3388 Some(usd),
3389 );
3390
3391 let mut account = CashAccount::new(account_state, true, false);
3392
3393 let negative_balances = vec![AccountBalance::new(
3394 Money::new(-500.0, usd),
3395 Money::zero(usd),
3396 Money::new(-500.0, usd),
3397 )];
3398
3399 let result = account.update_balances(&negative_balances);
3400
3401 assert!(result.is_err());
3402 let err_msg = result.unwrap_err().to_string();
3403 assert!(err_msg.contains("negative"));
3404 assert!(err_msg.contains("borrowing not allowed"));
3405 }
3406
3407 #[rstest]
3408 fn test_manager_update_balances_skips_update_on_negative_balance_error() {
3409 let usd = Currency::USD();
3410 let account_state = AccountState::new(
3411 AccountId::new("SIM-001"),
3412 AccountType::Cash,
3413 vec![AccountBalance::new(
3414 Money::new(100.0, usd),
3415 Money::zero(usd),
3416 Money::new(100.0, usd),
3417 )],
3418 Vec::new(),
3419 true,
3420 UUID4::new(),
3421 UnixNanos::default(),
3422 UnixNanos::default(),
3423 Some(usd),
3424 );
3425
3426 let account = CashAccount::new(account_state, true, false);
3427 let initial_balance = account.balance_total(Some(usd)).unwrap();
3428
3429 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3430 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3431 cache
3432 .borrow_mut()
3433 .add_account(AccountAny::Cash(account.clone()))
3434 .unwrap();
3435
3436 let manager = AccountsManager::new(clock, cache.clone());
3437 let instrument = audusd_sim();
3438
3439 let mut order = OrderTestBuilder::new(OrderType::Market)
3440 .instrument_id(instrument.id())
3441 .side(OrderSide::Buy)
3442 .quantity(Quantity::from("100000"))
3443 .build();
3444
3445 let submitted = order_submitted_for(&order);
3446 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
3447 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3448 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3449
3450 cache
3451 .borrow_mut()
3452 .add_order(order.clone(), None, None, false)
3453 .unwrap();
3454
3455 let fill = OrderFilledSpec::builder()
3457 .instrument_id(instrument.id())
3458 .client_order_id(order.client_order_id())
3459 .venue_order_id(VenueOrderId::new("1"))
3460 .last_qty(Quantity::from("100000"))
3461 .last_px(Price::from("0.80000"))
3462 .ts_event(UnixNanos::from(1))
3463 .ts_init(UnixNanos::from(1))
3464 .position_id(PositionId::new("P-001"))
3465 .commission(Money::new(20.0, usd))
3466 .build();
3467
3468 let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
3469 cache
3470 .borrow_mut()
3471 .add_position(&position, OmsType::Netting)
3472 .unwrap();
3473
3474 let fill2 = OrderFilledSpec::builder()
3475 .instrument_id(instrument.id())
3476 .client_order_id(order.client_order_id())
3477 .venue_order_id(VenueOrderId::new("2"))
3478 .trade_id(TradeId::new("2"))
3479 .last_qty(Quantity::from("100000"))
3480 .last_px(Price::from("0.80000"))
3481 .ts_event(UnixNanos::from(2))
3482 .ts_init(UnixNanos::from(2))
3483 .position_id(PositionId::new("P-001"))
3484 .commission(Money::new(20.0, usd))
3485 .build();
3486 let _state = manager.update_balances(
3487 AccountAny::Cash(account),
3488 &InstrumentAny::CurrencyPair(instrument),
3489 &fill2,
3490 );
3491
3492 let account_after = cache
3493 .borrow()
3494 .account(&AccountId::new("SIM-001"))
3495 .unwrap()
3496 .clone();
3497
3498 if let AccountAny::Cash(cash) = account_after {
3499 assert_eq!(cash.balance_total(Some(usd)), Some(initial_balance));
3500 } else {
3501 panic!("Expected CashAccount");
3502 }
3503 }
3504
3505 #[rstest]
3506 fn test_order_canceled_releases_locked_balance() {
3507 let usd = Currency::USD();
3509 let account_state = AccountState::new(
3510 AccountId::new("SIM-001"),
3511 AccountType::Cash,
3512 vec![AccountBalance::new(
3513 Money::new(100_000.0, usd),
3514 Money::zero(usd),
3515 Money::new(100_000.0, usd),
3516 )],
3517 Vec::new(),
3518 true,
3519 UUID4::new(),
3520 UnixNanos::default(),
3521 UnixNanos::default(),
3522 Some(usd),
3523 );
3524
3525 let account = CashAccount::new(account_state, true, false);
3526
3527 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3528 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3529 cache
3530 .borrow_mut()
3531 .add_account(AccountAny::Cash(account.clone()))
3532 .unwrap();
3533
3534 let manager = AccountsManager::new(clock, cache);
3535 let instrument = audusd_sim();
3536
3537 let mut order = OrderTestBuilder::new(OrderType::Limit)
3538 .instrument_id(instrument.id())
3539 .side(OrderSide::Buy)
3540 .quantity(Quantity::from("100000"))
3541 .price(Price::from("0.80000"))
3542 .build();
3543
3544 let submitted = order_submitted_for(&order);
3545 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
3546
3547 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3548 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3549
3550 let result = manager.update_orders(
3551 &AccountAny::Cash(account),
3552 &InstrumentAny::CurrencyPair(instrument.clone()),
3553 &[&order],
3554 UnixNanos::default(),
3555 );
3556
3557 assert!(result.is_some());
3558 let (updated_account, _) = result.unwrap();
3559
3560 if let AccountAny::Cash(ref cash) = updated_account {
3561 assert_eq!(
3563 cash.balance_locked(Some(usd)),
3564 Some(Money::new(80_000.0, usd))
3565 );
3566 assert_eq!(
3567 cash.balance_free(Some(usd)),
3568 Some(Money::new(20_000.0, usd))
3569 );
3570 } else {
3571 panic!("Expected CashAccount");
3572 }
3573
3574 let result = manager.update_orders(
3575 &updated_account,
3576 &InstrumentAny::CurrencyPair(instrument),
3577 &[],
3578 UnixNanos::default(),
3579 );
3580
3581 assert!(result.is_some());
3582 let (final_account, _) = result.unwrap();
3583
3584 if let AccountAny::Cash(cash) = final_account {
3585 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
3586 assert_eq!(
3587 cash.balance_free(Some(usd)),
3588 Some(Money::new(100_000.0, usd))
3589 );
3590 assert_eq!(
3591 cash.balance_total(Some(usd)),
3592 Some(Money::new(100_000.0, usd))
3593 );
3594 } else {
3595 panic!("Expected CashAccount");
3596 }
3597 }
3598
3599 #[rstest]
3600 fn test_generate_account_state_preserves_per_instrument_and_account_wide_margins() {
3601 let usd = Currency::USD();
3602 let audusd = InstrumentId::from("AUD/USD.SIM");
3603 let account_state = AccountState::new(
3604 AccountId::new("SIM-001"),
3605 AccountType::Margin,
3606 vec![AccountBalance::new(
3607 Money::new(1_000_000.0, usd),
3608 Money::zero(usd),
3609 Money::new(1_000_000.0, usd),
3610 )],
3611 Vec::new(),
3612 true,
3613 UUID4::new(),
3614 UnixNanos::default(),
3615 UnixNanos::default(),
3616 Some(usd),
3617 );
3618 let mut account = MarginAccount::new(account_state, false);
3619 account.update_margin(MarginBalance::new(
3620 Money::new(150.0, usd),
3621 Money::new(75.0, usd),
3622 Some(audusd),
3623 ));
3624 account.update_margin(MarginBalance::new(
3625 Money::new(500.0, usd),
3626 Money::new(250.0, usd),
3627 None,
3628 ));
3629
3630 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3631 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3632 let manager = AccountsManager::new(clock, cache);
3633
3634 let state =
3635 manager.generate_account_state(&AccountAny::Margin(account), UnixNanos::default());
3636
3637 assert_eq!(state.balances.len(), 1);
3638 assert_eq!(state.balances[0].currency, usd);
3639 assert_eq!(state.balances[0].total, Money::new(1_000_000.0, usd));
3640 assert_eq!(state.balances[0].locked, Money::new(975.0, usd));
3641 assert_eq!(state.balances[0].free, Money::new(999_025.0, usd));
3642
3643 assert_eq!(state.margins.len(), 2);
3644 let per_instrument: Vec<_> = state
3645 .margins
3646 .iter()
3647 .filter(|m| m.instrument_id.is_some())
3648 .collect();
3649 let account_wide: Vec<_> = state
3650 .margins
3651 .iter()
3652 .filter(|m| m.instrument_id.is_none())
3653 .collect();
3654 assert_eq!(per_instrument.len(), 1);
3655 assert_eq!(per_instrument[0].instrument_id, Some(audusd));
3656 assert_eq!(per_instrument[0].initial, Money::new(150.0, usd));
3657 assert_eq!(per_instrument[0].maintenance, Money::new(75.0, usd));
3658 assert_eq!(account_wide.len(), 1);
3659 assert_eq!(account_wide[0].currency, usd);
3660 assert_eq!(account_wide[0].initial, Money::new(500.0, usd));
3661 assert_eq!(account_wide[0].maintenance, Money::new(250.0, usd));
3662 }
3663
3664 #[rstest]
3665 fn test_update_balances_returns_recalculated_balance_for_cash_account() {
3666 let usd = Currency::USD();
3667 let account_state = AccountState::new(
3668 AccountId::new("SIM-001"),
3669 AccountType::Cash,
3670 vec![AccountBalance::new(
3671 Money::new(1_000_000.0, usd),
3672 Money::zero(usd),
3673 Money::new(1_000_000.0, usd),
3674 )],
3675 Vec::new(),
3676 true,
3677 UUID4::new(),
3678 UnixNanos::default(),
3679 UnixNanos::default(),
3680 Some(usd),
3681 );
3682
3683 let account = CashAccount::new(account_state, true, false);
3684
3685 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3686 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3687 cache
3688 .borrow_mut()
3689 .add_account(AccountAny::Cash(account.clone()))
3690 .unwrap();
3691
3692 let manager = AccountsManager::new(clock, cache.clone());
3693 let instrument = audusd_sim();
3694
3695 let mut order = OrderTestBuilder::new(OrderType::Market)
3696 .instrument_id(instrument.id())
3697 .side(OrderSide::Buy)
3698 .quantity(Quantity::from("100000"))
3699 .build();
3700 let submitted = order_submitted_for(&order);
3701 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
3702 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
3703 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
3704 cache
3705 .borrow_mut()
3706 .add_order(order.clone(), None, None, false)
3707 .unwrap();
3708
3709 let fill = OrderFilledSpec::builder()
3710 .instrument_id(instrument.id())
3711 .client_order_id(order.client_order_id())
3712 .venue_order_id(VenueOrderId::new("1"))
3713 .last_qty(Quantity::from("100000"))
3714 .last_px(Price::from("0.80000"))
3715 .ts_event(UnixNanos::from(1))
3716 .ts_init(UnixNanos::from(1))
3717 .position_id(PositionId::new("P-001"))
3718 .commission(Money::new(20.0, usd))
3719 .build();
3720 let position = Position::new(
3721 &InstrumentAny::CurrencyPair(instrument.clone()),
3722 fill.clone(),
3723 );
3724 cache
3725 .borrow_mut()
3726 .add_position(&position, OmsType::Netting)
3727 .unwrap();
3728
3729 let (updated, state) = manager.update_balances(
3730 AccountAny::Cash(account),
3731 &InstrumentAny::CurrencyPair(instrument),
3732 &fill,
3733 );
3734
3735 let expected = Money::new(919_980.0, usd);
3737
3738 match updated {
3739 AccountAny::Cash(cash) => {
3740 assert_eq!(cash.balance_total(Some(usd)), Some(expected));
3741 assert_eq!(cash.balance_free(Some(usd)), Some(expected));
3742 }
3743 _ => panic!("Expected CashAccount"),
3744 }
3745 assert_eq!(state.balances.len(), 1);
3746 assert_eq!(state.balances[0].currency, usd);
3747 assert_eq!(state.balances[0].total, expected);
3748 assert_eq!(state.balances[0].free, expected);
3749 }
3750
3751 #[rstest]
3752 fn test_update_balances_preserves_subincrement_base_commission() {
3753 let btc = Currency::BTC();
3754 let usdt = Currency::USDT();
3755 let account_id = AccountId::new("SIM-001");
3756 let account_state = AccountState::new(
3757 account_id,
3758 AccountType::Cash,
3759 vec![
3760 AccountBalance::new(Money::from("1 BTC"), Money::zero(btc), Money::from("1 BTC")),
3761 AccountBalance::new(
3762 Money::from("1000 USDT"),
3763 Money::zero(usdt),
3764 Money::from("1000 USDT"),
3765 ),
3766 ],
3767 Vec::new(),
3768 true,
3769 UUID4::new(),
3770 UnixNanos::default(),
3771 UnixNanos::default(),
3772 None,
3773 );
3774 let account = CashAccount::new(account_state, true, false);
3775 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3776 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3777 let manager = AccountsManager::new(clock, cache.clone());
3778 let mut instrument = currency_pair_btcusdt();
3779 instrument.size_increment = Quantity::from("0.005000");
3780 let instrument = InstrumentAny::CurrencyPair(instrument);
3781 let position_id = PositionId::new("P-001");
3782 let fill = OrderFilledSpec::builder()
3783 .instrument_id(instrument.id())
3784 .account_id(account_id)
3785 .position_id(position_id)
3786 .order_side(OrderSide::Buy)
3787 .order_type(OrderType::Market)
3788 .last_qty(Quantity::from("0.003000"))
3789 .last_px(Price::from("50000.00"))
3790 .currency(usdt)
3791 .commission(Money::from("0.00040000 BTC"))
3792 .build();
3793 let position = Position::new(&instrument, fill.clone());
3794 cache
3795 .borrow_mut()
3796 .add_position(&position, OmsType::Netting)
3797 .unwrap();
3798
3799 let (updated, state) =
3800 manager.update_balances(AccountAny::Cash(account), &instrument, &fill);
3801 let AccountAny::Cash(cash) = updated else {
3802 panic!("Expected CashAccount");
3803 };
3804 let state_btc = state
3805 .balances
3806 .iter()
3807 .find(|balance| balance.currency == btc)
3808 .unwrap();
3809 let state_usdt = state
3810 .balances
3811 .iter()
3812 .find(|balance| balance.currency == usdt)
3813 .unwrap();
3814
3815 assert_eq!(position.quantity, Quantity::from("0.002600"));
3816 assert_eq!(
3817 cash.balance_total(Some(btc)),
3818 Some(Money::from("1.00260000 BTC")),
3819 );
3820 assert_eq!(
3821 cash.balance_total(Some(usdt)),
3822 Some(Money::from("850 USDT")),
3823 );
3824 assert_eq!(cash.commission(&btc), Some(Money::from("0.00040000 BTC")),);
3825 assert_eq!(state_btc.total, Money::from("1.00260000 BTC"));
3826 assert_eq!(state_usdt.total, Money::from("850 USDT"));
3827 }
3828
3829 #[rstest]
3830 fn test_update_balances_rollback_restores_balances_and_commissions() {
3831 let usd = Currency::USD();
3834 let account_state = AccountState::new(
3835 AccountId::new("SIM-001"),
3836 AccountType::Margin,
3837 vec![AccountBalance::new(
3838 Money::new(1_000_000.0, usd),
3839 Money::zero(usd),
3840 Money::new(1_000_000.0, usd),
3841 )],
3842 Vec::new(),
3843 true,
3844 UUID4::new(),
3845 UnixNanos::default(),
3846 UnixNanos::default(),
3847 Some(usd),
3848 );
3849 let mut account = MarginAccount::new(account_state, false);
3850 account.commissions.insert(usd, Money::new(MONEY_MAX, usd));
3851 let original_balances = account.balances.clone();
3852 let original_commissions = account.commissions.clone();
3853
3854 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3855 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3856 let manager = AccountsManager::new(clock, cache.clone());
3857 let instrument = audusd_sim();
3858 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3859
3860 let entry = OrderFilledSpec::builder()
3861 .instrument_id(instrument.id())
3862 .order_side(OrderSide::Buy)
3863 .last_qty(Quantity::from("100000"))
3864 .last_px(Price::from("0.80000"))
3865 .position_id(PositionId::new("P-ROLLBACK"))
3866 .build();
3867 let position = Position::new(&instrument_any, entry);
3868 cache
3869 .borrow_mut()
3870 .add_position(&position, OmsType::Netting)
3871 .unwrap();
3872
3873 let closing = OrderFilledSpec::builder()
3875 .instrument_id(instrument.id())
3876 .order_side(OrderSide::Sell)
3877 .last_qty(Quantity::from("100000"))
3878 .last_px(Price::from("0.81000"))
3879 .trade_id(TradeId::new("2"))
3880 .ts_event(UnixNanos::from(1))
3881 .ts_init(UnixNanos::from(1))
3882 .position_id(PositionId::new("P-ROLLBACK"))
3883 .commission(Money::new(20.0, usd))
3884 .build();
3885
3886 let (updated, state) =
3887 manager.update_balances(AccountAny::Margin(account), &instrument_any, &closing);
3888
3889 let AccountAny::Margin(margin) = updated else {
3890 panic!("Expected MarginAccount");
3891 };
3892 assert_eq!(margin.balances, original_balances);
3893 assert_eq!(margin.commissions, original_commissions);
3894 assert_eq!(state.balances.len(), 1);
3895 assert_eq!(state.balances[0].total, Money::new(1_000_000.0, usd));
3896 assert_eq!(state.balances[0].free, Money::new(1_000_000.0, usd));
3897 }
3898
3899 #[rstest]
3900 fn test_update_balances_notional_error_preserves_cash_balance_and_commission() {
3901 let usd = Currency::USD();
3902 let account_state = AccountState::new(
3903 AccountId::new("SIM-001"),
3904 AccountType::Cash,
3905 vec![AccountBalance::new(
3906 Money::new(1_000_000.0, usd),
3907 Money::zero(usd),
3908 Money::new(1_000_000.0, usd),
3909 )],
3910 Vec::new(),
3911 true,
3912 UUID4::new(),
3913 UnixNanos::default(),
3914 UnixNanos::default(),
3915 Some(usd),
3916 );
3917 let account = CashAccount::new(account_state, true, false);
3918 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3919 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3920 let manager = AccountsManager::new(clock, cache.clone());
3921 let instrument = audusd_sim();
3922 let fill = OrderFilledSpec::builder()
3923 .instrument_id(instrument.id())
3924 .last_qty(Quantity::from("100000000"))
3925 .last_px(Price::from("100000000"))
3926 .ts_event(UnixNanos::from(1))
3927 .ts_init(UnixNanos::from(1))
3928 .position_id(PositionId::new("P-NOTIONAL-ERROR"))
3929 .commission(Money::new(20.0, usd))
3930 .build();
3931 let position = Position::new(
3932 &InstrumentAny::CurrencyPair(instrument.clone()),
3933 fill.clone(),
3934 );
3935 cache
3936 .borrow_mut()
3937 .add_position(&position, OmsType::Netting)
3938 .unwrap();
3939
3940 let (updated, state) = manager.update_balances(
3941 AccountAny::Cash(account),
3942 &InstrumentAny::CurrencyPair(instrument),
3943 &fill,
3944 );
3945
3946 let AccountAny::Cash(cash) = updated else {
3947 panic!("Expected CashAccount");
3948 };
3949 assert_eq!(
3950 cash.balance_total(Some(usd)),
3951 Some(Money::new(1_000_000.0, usd))
3952 );
3953 assert!(cash.commissions().is_empty());
3954 assert_eq!(state.balances[0].total, Money::new(1_000_000.0, usd));
3955 }
3956
3957 fn wallet_precision_pair(
3958 wallet_precision: u8,
3959 ) -> Option<(WalletAccount, CurrencyPair, Currency, Currency)> {
3960 Currency::new_checked("WPREC", 18, 0, "WPREC", CurrencyType::Crypto).ok()?;
3961 let instrument_base = Currency::new("WBASE", 16, 0, "WBASE", CurrencyType::Crypto);
3962 let instrument_quote = Currency::new("WQUOTE", 16, 0, "WQUOTE", CurrencyType::Crypto);
3963 let observed_base =
3964 Currency::new("WBASE", wallet_precision, 0, "WBASE", CurrencyType::Crypto);
3965 let observed_quote = Currency::new(
3966 "WQUOTE",
3967 wallet_precision,
3968 0,
3969 "WQUOTE",
3970 CurrencyType::Crypto,
3971 );
3972 let instrument = CurrencyPair::builder()
3973 .instrument_id(InstrumentId::from("WBASEWQUOTE.BLOCKCHAIN"))
3974 .raw_symbol(Symbol::from("WBASEWQUOTE"))
3975 .base_currency(instrument_base)
3976 .quote_currency(instrument_quote)
3977 .price_precision(16)
3978 .size_precision(16)
3979 .price_increment(Price::from_raw(1, 16))
3980 .size_increment(Quantity::from_raw(1, 16))
3981 .ts_event(UnixNanos::default())
3982 .ts_init(UnixNanos::default())
3983 .build()
3984 .unwrap();
3985 let scale = money_raw(10_i128.pow(u32::from(wallet_precision.max(FIXED_PRECISION))));
3986 let base_total = Money::from_raw(scale, observed_base);
3987 let quote_total = Money::from_raw(2 * scale, observed_quote);
3988 let wallet = WalletAccount::new(
3989 AccountState::new(
3990 AccountId::from("WALLET-PRECISION"),
3991 AccountType::Wallet,
3992 vec![
3993 AccountBalance::new(base_total, Money::zero(observed_base), base_total),
3994 AccountBalance::new(quote_total, Money::zero(observed_quote), quote_total),
3995 ],
3996 vec![],
3997 true,
3998 UUID4::new(),
3999 UnixNanos::default(),
4000 UnixNanos::default(),
4001 None,
4002 ),
4003 true,
4004 );
4005
4006 Some((wallet, instrument, observed_base, observed_quote))
4007 }
4008
4009 #[allow(
4010 clippy::useless_conversion,
4011 reason = "the raw width differs when high-precision is disabled"
4012 )]
4013 fn money_raw(raw: i128) -> MoneyRaw {
4014 raw.try_into().unwrap()
4015 }
4016
4017 fn multi_currency_cash_account(allow_borrowing: bool) -> CashAccount {
4018 let aud = Currency::AUD();
4019 let usd = Currency::USD();
4020 let account_state = AccountState::new(
4021 AccountId::new("SIM-001"),
4022 AccountType::Cash,
4023 vec![
4024 AccountBalance::new(
4025 Money::new(10_000.0, aud),
4026 Money::zero(aud),
4027 Money::new(10_000.0, aud),
4028 ),
4029 AccountBalance::new(
4030 Money::new(100.0, usd),
4031 Money::zero(usd),
4032 Money::new(100.0, usd),
4033 ),
4034 ],
4035 Vec::new(),
4036 true,
4037 UUID4::new(),
4038 UnixNanos::default(),
4039 UnixNanos::default(),
4040 None,
4041 );
4042 CashAccount::new(account_state, true, allow_borrowing)
4043 }
4044
4045 fn buy_audusd_fill(qty: &str, px: &str, commission: f64) -> OrderFilled {
4046 let instrument = audusd_sim();
4047 let usd = Currency::USD();
4048 OrderFilledSpec::builder()
4049 .instrument_id(instrument.id())
4050 .last_qty(Quantity::from(qty))
4051 .last_px(Price::from(px))
4052 .ts_event(UnixNanos::from(1))
4053 .ts_init(UnixNanos::from(1))
4054 .position_id(PositionId::new("P-001"))
4055 .commission(Money::new(commission, usd))
4056 .build()
4057 }
4058
4059 fn multi_currency_cash_account_with_usd_locked(total: f64, locked: f64) -> CashAccount {
4060 multi_currency_cash_account_with_usd_locked_and_borrowing(total, locked, false)
4061 }
4062
4063 fn multi_currency_cash_account_with_usd_locked_and_borrowing(
4064 total: f64,
4065 locked: f64,
4066 allow_borrowing: bool,
4067 ) -> CashAccount {
4068 let usd = Currency::USD();
4069 let account_state = AccountState::new(
4070 AccountId::new("SIM-001"),
4071 AccountType::Cash,
4072 vec![AccountBalance::new(
4073 Money::new(total, usd),
4074 Money::new(locked, usd),
4075 Money::new(total - locked, usd),
4076 )],
4077 Vec::new(),
4078 true,
4079 UUID4::new(),
4080 UnixNanos::default(),
4081 UnixNanos::default(),
4082 None,
4083 );
4084 CashAccount::new(account_state, true, allow_borrowing)
4085 }
4086
4087 fn multi_currency_betting_account_with_gbp_locked(total: f64, locked: f64) -> BettingAccount {
4088 let gbp = Currency::GBP();
4089 let account_state = AccountState::new(
4090 AccountId::new("BETFAIR-001"),
4091 AccountType::Betting,
4092 vec![AccountBalance::new(
4093 Money::new(total, gbp),
4094 Money::new(locked, gbp),
4095 Money::new(total - locked, gbp),
4096 )],
4097 Vec::new(),
4098 true,
4099 UUID4::new(),
4100 UnixNanos::default(),
4101 UnixNanos::default(),
4102 None,
4103 );
4104 BettingAccount::new(account_state, true)
4105 }
4106
4107 fn order_submitted_for(order: &OrderAny) -> OrderSubmitted {
4108 OrderSubmittedSpec::builder()
4109 .trader_id(order.trader_id())
4110 .strategy_id(order.strategy_id())
4111 .instrument_id(order.instrument_id())
4112 .client_order_id(order.client_order_id())
4113 .build()
4114 }
4115
4116 fn order_submitted_for_account(order: &OrderAny, account_id: AccountId) -> OrderSubmitted {
4117 OrderSubmittedSpec::builder()
4118 .trader_id(order.trader_id())
4119 .strategy_id(order.strategy_id())
4120 .instrument_id(order.instrument_id())
4121 .client_order_id(order.client_order_id())
4122 .account_id(account_id)
4123 .build()
4124 }
4125
4126 fn order_accepted_for(order: &OrderAny, venue_order_id: VenueOrderId) -> OrderAccepted {
4127 OrderAcceptedSpec::builder()
4128 .trader_id(order.trader_id())
4129 .strategy_id(order.strategy_id())
4130 .instrument_id(order.instrument_id())
4131 .client_order_id(order.client_order_id())
4132 .venue_order_id(venue_order_id)
4133 .build()
4134 }
4135
4136 fn order_accepted_for_account(
4137 order: &OrderAny,
4138 venue_order_id: VenueOrderId,
4139 account_id: AccountId,
4140 ) -> OrderAccepted {
4141 OrderAcceptedSpec::builder()
4142 .trader_id(order.trader_id())
4143 .strategy_id(order.strategy_id())
4144 .instrument_id(order.instrument_id())
4145 .client_order_id(order.client_order_id())
4146 .venue_order_id(venue_order_id)
4147 .account_id(account_id)
4148 .build()
4149 }
4150
4151 #[rstest]
4152 fn test_update_balance_multi_currency_market_debit_keeps_locked_balance() {
4153 let usd = Currency::USD();
4154 let account = multi_currency_cash_account_with_usd_locked(1_000.0, 200.0);
4155 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4156 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4157 let manager = AccountsManager::new(clock, cache.clone());
4158 let instrument = audusd_sim();
4159 cache
4160 .borrow_mut()
4161 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
4162 .unwrap();
4163
4164 let fill = OrderFilledSpec::builder()
4165 .instrument_id(instrument.id())
4166 .order_type(OrderType::Market)
4167 .commission(Money::new(20.0, usd))
4168 .build();
4169 let mut account = AccountAny::Cash(account);
4170 let mut pnls = vec![Money::new(-100.0, usd)];
4171
4172 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4173
4174 match account {
4175 AccountAny::Cash(cash) => {
4176 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
4177 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(200.0, usd)));
4178 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(680.0, usd)));
4179 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
4180 }
4181 _ => panic!("Expected CashAccount"),
4182 }
4183 }
4184
4185 #[rstest]
4186 fn test_update_balance_multi_currency_limit_debit_reduces_locked_balance() {
4187 let usd = Currency::USD();
4188 let account = multi_currency_cash_account_with_usd_locked(1_000.0, 200.0);
4189 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4190 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4191 let manager = AccountsManager::new(clock, cache.clone());
4192 let instrument = audusd_sim();
4193 cache
4194 .borrow_mut()
4195 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
4196 .unwrap();
4197
4198 let fill = OrderFilledSpec::builder()
4199 .instrument_id(instrument.id())
4200 .order_type(OrderType::Limit)
4201 .commission(Money::new(20.0, usd))
4202 .build();
4203 let mut account = AccountAny::Cash(account);
4204 let mut pnls = vec![Money::new(-100.0, usd)];
4205
4206 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4207
4208 match account {
4209 AccountAny::Cash(cash) => {
4210 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
4211 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(80.0, usd)));
4212 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(800.0, usd)));
4213 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
4214 }
4215 _ => panic!("Expected CashAccount"),
4216 }
4217 }
4218
4219 #[rstest]
4220 fn test_update_balance_multi_currency_limit_debit_spills_from_locked_to_free() {
4221 let usd = Currency::USD();
4222 let account = multi_currency_cash_account_with_usd_locked(1_000.0, 50.0);
4223 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4224 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4225 let manager = AccountsManager::new(clock, cache.clone());
4226 let instrument = audusd_sim();
4227 cache
4228 .borrow_mut()
4229 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
4230 .unwrap();
4231
4232 let fill = OrderFilledSpec::builder()
4233 .instrument_id(instrument.id())
4234 .order_type(OrderType::Limit)
4235 .commission(Money::new(20.0, usd))
4236 .build();
4237 let mut account = AccountAny::Cash(account);
4238 let mut pnls = vec![Money::new(-100.0, usd)];
4239
4240 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4241
4242 match account {
4243 AccountAny::Cash(cash) => {
4244 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
4245 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
4246 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(880.0, usd)));
4247 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
4248 }
4249 _ => panic!("Expected CashAccount"),
4250 }
4251 }
4252
4253 #[rstest]
4254 fn test_update_balance_multi_currency_limit_debit_floors_locked_on_negative_total() {
4255 let usd = Currency::USD();
4256 let account = multi_currency_cash_account_with_usd_locked_and_borrowing(100.0, 50.0, true);
4257 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4258 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4259 let manager = AccountsManager::new(clock, cache.clone());
4260 let instrument = audusd_sim();
4261 cache
4262 .borrow_mut()
4263 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
4264 .unwrap();
4265
4266 let fill = OrderFilledSpec::builder()
4267 .instrument_id(instrument.id())
4268 .order_type(OrderType::Limit)
4269 .commission(Money::new(20.0, usd))
4270 .build();
4271 let mut account = AccountAny::Cash(account);
4272 let mut pnls = vec![Money::new(-200.0, usd)];
4273
4274 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4275
4276 match account {
4277 AccountAny::Cash(cash) => {
4278 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(-120.0, usd)));
4279 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
4280 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(-120.0, usd)));
4281 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
4282 }
4283 _ => panic!("Expected CashAccount"),
4284 }
4285 }
4286
4287 #[rstest]
4288 fn test_update_balance_multi_currency_betting_limit_debit_keeps_locked_balance() {
4289 let gbp = Currency::GBP();
4290 let account = multi_currency_betting_account_with_gbp_locked(1_000.0, 200.0);
4291 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4292 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4293 let manager = AccountsManager::new(clock, cache.clone());
4294 let instrument = betting();
4295 cache
4296 .borrow_mut()
4297 .add_instrument(InstrumentAny::Betting(instrument.clone()))
4298 .unwrap();
4299
4300 let fill = OrderFilledSpec::builder()
4301 .instrument_id(instrument.id())
4302 .order_type(OrderType::Limit)
4303 .commission(Money::new(20.0, gbp))
4304 .build();
4305 let mut account = AccountAny::Betting(account);
4306 let mut pnls = vec![Money::new(-100.0, gbp)];
4307
4308 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4309
4310 match account {
4311 AccountAny::Betting(betting_account) => {
4312 assert_eq!(
4313 betting_account.balance_total(Some(gbp)),
4314 Some(Money::new(880.0, gbp))
4315 );
4316 assert_eq!(
4317 betting_account.balance_locked(Some(gbp)),
4318 Some(Money::new(200.0, gbp))
4319 );
4320 assert_eq!(
4321 betting_account.balance_free(Some(gbp)),
4322 Some(Money::new(680.0, gbp))
4323 );
4324 assert_eq!(
4325 betting_account.commission(&gbp),
4326 Some(Money::new(20.0, gbp))
4327 );
4328 }
4329 _ => panic!("Expected BettingAccount"),
4330 }
4331 }
4332
4333 #[rstest]
4334 fn test_update_balance_multi_currency_persists_negative_balance_with_allow_borrowing() {
4335 let aud = Currency::AUD();
4336 let usd = Currency::USD();
4337 let account = multi_currency_cash_account(true);
4338 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4339 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4340 cache
4341 .borrow_mut()
4342 .add_account(AccountAny::Cash(account.clone()))
4343 .unwrap();
4344 let manager = AccountsManager::new(clock, cache.clone());
4345 let instrument = audusd_sim();
4346 let fill = buy_audusd_fill("10000", "0.80000", 20.0);
4347 let position = Position::new(
4348 &InstrumentAny::CurrencyPair(instrument.clone()),
4349 fill.clone(),
4350 );
4351 cache
4352 .borrow_mut()
4353 .add_position(&position, OmsType::Netting)
4354 .unwrap();
4355
4356 let (updated, _state) = manager.update_balances(
4357 AccountAny::Cash(account),
4358 &InstrumentAny::CurrencyPair(instrument),
4359 &fill,
4360 );
4361
4362 match updated {
4363 AccountAny::Cash(cash) => {
4364 assert_eq!(
4365 cash.balance_total(Some(aud)),
4366 Some(Money::new(20_000.0, aud))
4367 );
4368 assert_eq!(
4369 cash.balance_total(Some(usd)),
4370 Some(Money::new(-7_920.0, usd))
4371 );
4372 }
4373 _ => panic!("Expected CashAccount"),
4374 }
4375 }
4376
4377 #[rstest]
4378 fn test_update_balance_multi_currency_rejects_negative_balance_without_allow_borrowing() {
4379 let aud = Currency::AUD();
4380 let usd = Currency::USD();
4381 let account = multi_currency_cash_account(false);
4382 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4383 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4384 cache
4385 .borrow_mut()
4386 .add_account(AccountAny::Cash(account.clone()))
4387 .unwrap();
4388 let manager = AccountsManager::new(clock, cache.clone());
4389 let instrument = audusd_sim();
4390 let fill = buy_audusd_fill("10000", "0.80000", 20.0);
4391 let position = Position::new(
4392 &InstrumentAny::CurrencyPair(instrument.clone()),
4393 fill.clone(),
4394 );
4395 cache
4396 .borrow_mut()
4397 .add_position(&position, OmsType::Netting)
4398 .unwrap();
4399
4400 let (updated, _state) = manager.update_balances(
4401 AccountAny::Cash(account),
4402 &InstrumentAny::CurrencyPair(instrument),
4403 &fill,
4404 );
4405
4406 match updated {
4408 AccountAny::Cash(cash) => {
4409 assert_eq!(
4410 cash.balance_total(Some(aud)),
4411 Some(Money::new(10_000.0, aud))
4412 );
4413 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(100.0, usd)));
4414 }
4415 _ => panic!("Expected CashAccount"),
4416 }
4417 }
4418
4419 #[rstest]
4420 fn test_update_balance_multi_currency_rejects_new_currency_negative_pnl() {
4421 let aud = Currency::AUD();
4422 let account_state = AccountState::new(
4423 AccountId::new("SIM-001"),
4424 AccountType::Cash,
4425 vec![AccountBalance::new(
4426 Money::new(10_000.0, aud),
4427 Money::zero(aud),
4428 Money::new(10_000.0, aud),
4429 )],
4430 Vec::new(),
4431 true,
4432 UUID4::new(),
4433 UnixNanos::default(),
4434 UnixNanos::default(),
4435 None,
4436 );
4437 let account = CashAccount::new(account_state, true, true);
4438 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4439 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4440 cache
4441 .borrow_mut()
4442 .add_account(AccountAny::Cash(account.clone()))
4443 .unwrap();
4444 let manager = AccountsManager::new(clock, cache.clone());
4445 let instrument = audusd_sim();
4446 let fill = buy_audusd_fill("10000", "0.80000", 0.0);
4449 let position = Position::new(
4450 &InstrumentAny::CurrencyPair(instrument.clone()),
4451 fill.clone(),
4452 );
4453 cache
4454 .borrow_mut()
4455 .add_position(&position, OmsType::Netting)
4456 .unwrap();
4457
4458 let (updated, _state) = manager.update_balances(
4459 AccountAny::Cash(account),
4460 &InstrumentAny::CurrencyPair(instrument),
4461 &fill,
4462 );
4463
4464 match updated {
4466 AccountAny::Cash(cash) => {
4467 assert_eq!(
4468 cash.balance_total(Some(aud)),
4469 Some(Money::new(10_000.0, aud))
4470 );
4471 assert_eq!(cash.balance_total(Some(Currency::USD())), None);
4472 }
4473 _ => panic!("Expected CashAccount"),
4474 }
4475 }
4476
4477 fn large_locked_usdt_margin_account() -> (AccountAny, Money, Money) {
4480 let usdt = Currency::USDT();
4481 let total =
4482 Money::from_decimal(Decimal::from_str_exact("99999997.91829666").unwrap(), usdt)
4483 .unwrap();
4484 let locked =
4485 Money::from_decimal(Decimal::from_str_exact("32.85965").unwrap(), usdt).unwrap();
4486 let free = total - locked;
4487
4488 let account_state = AccountState::new(
4489 AccountId::new("SIM-001"),
4490 AccountType::Margin,
4491 vec![AccountBalance::new(total, locked, free)],
4492 Vec::new(),
4493 true,
4494 UUID4::new(),
4495 UnixNanos::default(),
4496 UnixNanos::default(),
4497 None, );
4499 (
4500 AccountAny::Margin(MarginAccount::new(account_state, false)),
4501 total,
4502 locked,
4503 )
4504 }
4505
4506 #[rstest]
4507 fn test_update_balance_multi_currency_preserves_invariant_with_large_locked() {
4508 let usdt = Currency::USDT();
4512 let (mut account, total, locked) = large_locked_usdt_margin_account();
4513 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4514 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4515 let manager = AccountsManager::new(clock, cache);
4516
4517 let fill = OrderFilledSpec::builder().build();
4520 let pnl =
4521 Money::from_decimal(Decimal::from_str_exact("0.00000064").unwrap(), usdt).unwrap();
4522 let mut pnls = [pnl];
4523 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4524
4525 let balances = account.balances();
4526 let balance = balances.get(&usdt).expect("USDT balance");
4527 assert_eq!(balance.locked, locked, "locked margin preserved");
4528 assert_eq!(balance.total, total + pnl, "total moved by realized PnL");
4529 assert_eq!(
4530 balance.total,
4531 balance.locked + balance.free,
4532 "invariant total == locked + free must hold"
4533 );
4534 }
4535
4536 #[rstest]
4537 fn test_update_balance_multi_currency_commission_preserves_invariant_with_large_locked() {
4538 let usdt = Currency::USDT();
4540 let (mut account, total, locked) = large_locked_usdt_margin_account();
4541 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4542 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4543 let manager = AccountsManager::new(clock, cache);
4544
4545 let commission =
4548 Money::from_decimal(Decimal::from_str_exact("0.00000001").unwrap(), usdt).unwrap();
4549 let fill = OrderFilledSpec::builder().commission(commission).build();
4550
4551 let mut pnls: [Money; 0] = [];
4553 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4554
4555 let balances = account.balances();
4556 let balance = balances.get(&usdt).expect("USDT balance");
4557 assert_eq!(balance.locked, locked, "locked margin preserved");
4558 assert_eq!(
4559 balance.total,
4560 total - commission,
4561 "total reduced by commission"
4562 );
4563 assert_eq!(
4564 balance.total,
4565 balance.locked + balance.free,
4566 "invariant total == locked + free must hold"
4567 );
4568 }
4569
4570 #[rstest]
4571 fn test_update_balance_multi_currency_negative_commission_creates_rebate_balance() {
4572 let usd = Currency::USD();
4573 let account_state = AccountState::new(
4574 AccountId::new("SIM-001"),
4575 AccountType::Cash,
4576 Vec::new(),
4577 Vec::new(),
4578 true,
4579 UUID4::new(),
4580 UnixNanos::default(),
4581 UnixNanos::default(),
4582 None,
4583 );
4584 let mut account = AccountAny::Cash(CashAccount::new(account_state, true, false));
4585 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4586 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4587 let manager = AccountsManager::new(clock, cache);
4588
4589 let fill = OrderFilledSpec::builder()
4590 .commission(Money::new(-1.0, usd))
4591 .build();
4592 let mut pnls: [Money; 0] = [];
4593 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
4594
4595 let AccountAny::Cash(cash) = account else {
4596 panic!("Expected CashAccount");
4597 };
4598 let balance = cash.balance(Some(usd)).expect("USD rebate balance");
4599 assert_eq!(balance.total, Money::new(1.0, usd));
4600 assert_eq!(balance.locked, Money::zero(usd));
4601 assert_eq!(balance.free, Money::new(1.0, usd));
4602 assert_eq!(cash.commission(&usd), Some(Money::new(-1.0, usd)));
4603 }
4604
4605 fn build_margin_account_usd(balance: f64) -> MarginAccount {
4606 let usd = Currency::USD();
4607 let account_state = AccountState::new(
4608 AccountId::new("SIM-001"),
4609 AccountType::Margin,
4610 vec![AccountBalance::new(
4611 Money::new(balance, usd),
4612 Money::zero(usd),
4613 Money::new(balance, usd),
4614 )],
4615 Vec::new(),
4616 true,
4617 UUID4::new(),
4618 UnixNanos::default(),
4619 UnixNanos::default(),
4620 None,
4621 );
4622 MarginAccount::new(account_state, false)
4623 }
4624
4625 fn build_margin_account_usdt(balance: f64) -> MarginAccount {
4626 let usdt = Currency::USDT();
4627 let account_state = AccountState::new(
4628 AccountId::new("SIM-001"),
4629 AccountType::Margin,
4630 vec![AccountBalance::new(
4631 Money::new(balance, usdt),
4632 Money::zero(usdt),
4633 Money::new(balance, usdt),
4634 )],
4635 Vec::new(),
4636 true,
4637 UUID4::new(),
4638 UnixNanos::default(),
4639 UnixNanos::default(),
4640 None,
4641 );
4642 MarginAccount::new(account_state, false)
4643 }
4644
4645 fn build_hedging_position(
4646 instrument: &InstrumentAny,
4647 side: OrderSide,
4648 qty: &str,
4649 price: &str,
4650 id: &str,
4651 ) -> Position {
4652 build_hedging_position_at(instrument, side, qty, price, id, UnixNanos::default())
4653 }
4654
4655 fn build_hedging_position_at(
4656 instrument: &InstrumentAny,
4657 side: OrderSide,
4658 qty: &str,
4659 price: &str,
4660 id: &str,
4661 ts_event: UnixNanos,
4662 ) -> Position {
4663 let fill = OrderFilledSpec::builder()
4664 .instrument_id(instrument.id())
4665 .client_order_id(ClientOrderId::new(id))
4666 .venue_order_id(VenueOrderId::new(id))
4667 .trade_id(TradeId::new(id))
4668 .order_side(side)
4669 .last_qty(Quantity::from(qty))
4670 .last_px(Price::from(price))
4671 .currency(instrument.settlement_currency())
4672 .ts_event(ts_event)
4673 .ts_init(ts_event)
4674 .position_id(PositionId::new(id))
4675 .build();
4676 Position::new(instrument, fill)
4677 }
4678
4679 #[rstest]
4680 fn test_update_positions_in_place_nets_hedging_subpositions() {
4681 let usd = Currency::USD();
4682 let mut account = build_margin_account_usd(1_000_000.0);
4683 let instrument = audusd_sim();
4684 account.set_leverage(instrument.id(), Decimal::ONE);
4685 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4686
4687 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4688 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4689 let manager = AccountsManager::new(clock, cache);
4690
4691 let mut positions: Vec<Position> = Vec::new();
4693 for i in 0..5 {
4694 positions.push(build_hedging_position(
4695 &instrument_any,
4696 OrderSide::Buy,
4697 "50",
4698 "1.00000",
4699 &format!("L{i}"),
4700 ));
4701 }
4702
4703 for i in 0..2 {
4704 positions.push(build_hedging_position(
4705 &instrument_any,
4706 OrderSide::Sell,
4707 "50",
4708 "1.00000",
4709 &format!("S{i}"),
4710 ));
4711 }
4712
4713 let position_refs: Vec<&Position> = positions.iter().collect();
4714 let result = manager.update_positions_in_place(
4715 &mut account,
4716 &instrument_any,
4717 position_refs,
4718 UnixNanos::default(),
4719 );
4720 assert!(result.is_some(), "update_positions_in_place returned None");
4721
4722 let margin_maint = account.maintenance_margin(instrument.id());
4723 assert_eq!(
4724 margin_maint,
4725 Money::new(4.50, usd),
4726 "Maintenance margin must reflect net exposure (150 @ 1.00), not per-position sum",
4727 );
4728 }
4729
4730 #[rstest]
4731 fn test_update_positions_in_place_net_zero_hedge_has_no_margin() {
4732 let usd = Currency::USD();
4733 let mut account = build_margin_account_usd(1_000_000.0);
4734 let instrument = audusd_sim();
4735 account.set_leverage(instrument.id(), Decimal::ONE);
4736 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4737
4738 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4739 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4740 let manager = AccountsManager::new(clock, cache);
4741
4742 let long = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
4744 let short = build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S");
4745
4746 let state = manager
4747 .update_positions_in_place(
4748 &mut account,
4749 &instrument_any,
4750 vec![&long, &short],
4751 UnixNanos::default(),
4752 )
4753 .expect("update_positions_in_place returned None");
4754
4755 assert!(account.margin(&instrument.id()).is_none());
4756 assert!(state.margins.is_empty());
4757 assert_eq!(account.balance_locked(Some(usd)), Some(Money::zero(usd)));
4758 }
4759
4760 #[rstest]
4761 fn test_update_positions_in_place_uses_net_side_avg_open_price() {
4762 let usd = Currency::USD();
4763 let mut account = build_margin_account_usd(1_000_000.0);
4764 let instrument = audusd_sim();
4765 account.set_leverage(instrument.id(), Decimal::ONE);
4766 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4767
4768 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4769 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4770 let manager = AccountsManager::new(clock, cache);
4771
4772 let long = build_hedging_position(&instrument_any, OrderSide::Buy, "300", "0.80000", "L1");
4775 let short =
4776 build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S1");
4777
4778 let result = manager.update_positions_in_place(
4779 &mut account,
4780 &instrument_any,
4781 vec![&long, &short],
4782 UnixNanos::default(),
4783 );
4784 assert!(result.is_some(), "update_positions_in_place returned None");
4785
4786 let margin_maint = account.maintenance_margin(instrument.id());
4787 assert_eq!(margin_maint, Money::new(4.80, usd));
4788 }
4789
4790 #[rstest]
4791 fn test_update_positions_in_place_floating_dust_clears_margin() {
4792 let usdt = Currency::USDT();
4795 let mut account = build_margin_account_usdt(1_000_000.0);
4796 let instrument = currency_pair_btcusdt();
4797 account.set_leverage(instrument.id(), Decimal::ONE);
4798 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4799
4800 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4801 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4802 let manager = AccountsManager::new(clock, cache);
4803
4804 let long =
4806 build_hedging_position(&instrument_any, OrderSide::Buy, "0.300000", "50000.00", "L");
4807 let short_a = build_hedging_position(
4808 &instrument_any,
4809 OrderSide::Sell,
4810 "0.200000",
4811 "50000.00",
4812 "S1",
4813 );
4814 let short_b = build_hedging_position(
4815 &instrument_any,
4816 OrderSide::Sell,
4817 "0.100000",
4818 "50000.00",
4819 "S2",
4820 );
4821
4822 let state = manager
4823 .update_positions_in_place(
4824 &mut account,
4825 &instrument_any,
4826 vec![&long, &short_a, &short_b],
4827 UnixNanos::default(),
4828 )
4829 .expect("update_positions_in_place returned None");
4830
4831 assert!(account.margin(&instrument.id()).is_none());
4832 assert!(state.margins.is_empty());
4833 assert_eq!(account.balance_locked(Some(usdt)), Some(Money::zero(usdt)));
4834 }
4835
4836 #[rstest]
4837 fn test_update_positions_in_place_net_flat_clears_prior_base_currency_margin() {
4838 let usdt = Currency::USDT();
4841 let account_state = AccountState::new(
4842 AccountId::new("SIM-001"),
4843 AccountType::Margin,
4844 vec![AccountBalance::new(
4845 Money::new(1_000_000.0, usdt),
4846 Money::zero(usdt),
4847 Money::new(1_000_000.0, usdt),
4848 )],
4849 Vec::new(),
4850 true,
4851 UUID4::new(),
4852 UnixNanos::default(),
4853 UnixNanos::default(),
4854 Some(usdt),
4855 );
4856 let mut account = MarginAccount::new(account_state, false);
4857 let instrument = currency_pair_btcusdt();
4858 account.set_leverage(instrument.id(), Decimal::ONE);
4859 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4860
4861 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4862 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4863 let manager = AccountsManager::new(clock, cache);
4864
4865 let long =
4867 build_hedging_position(&instrument_any, OrderSide::Buy, "0.500000", "50000.00", "L");
4868 let first = manager.update_positions_in_place(
4869 &mut account,
4870 &instrument_any,
4871 vec![&long],
4872 UnixNanos::default(),
4873 );
4874 assert!(first.is_some());
4875 let prior_margin = account.maintenance_margin(instrument.id());
4876 assert!(prior_margin.as_decimal() > Decimal::ZERO);
4877 assert_eq!(prior_margin.currency, usdt);
4878 let prior_locked = account.balance_locked(Some(usdt)).unwrap();
4879 assert!(prior_locked.as_decimal() > Decimal::ZERO);
4880
4881 let short = build_hedging_position(
4883 &instrument_any,
4884 OrderSide::Sell,
4885 "0.500000",
4886 "50000.00",
4887 "S",
4888 );
4889 let second = manager.update_positions_in_place(
4890 &mut account,
4891 &instrument_any,
4892 vec![&long, &short],
4893 UnixNanos::default(),
4894 );
4895 let second_state = second.expect("net-flat maintenance update should generate state");
4896
4897 assert!(account.margin(&instrument.id()).is_none());
4899 assert!(second_state.margins.is_empty());
4900 assert_eq!(
4901 account.balance_locked(Some(usdt)).unwrap(),
4902 Money::zero(usdt)
4903 );
4904 }
4905
4906 #[rstest]
4907 fn test_update_positions_in_place_net_flat_preserves_prior_initial_margin() {
4908 let usd = Currency::USD();
4909 let mut account = build_margin_account_usd(1_000_000.0);
4910 let instrument = audusd_sim();
4911 account.set_leverage(instrument.id(), Decimal::ONE);
4912 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4913 let initial = Money::new(25.0, usd);
4914 account.update_margin(MarginBalance::new(
4915 initial,
4916 Money::new(5.0, usd),
4917 Some(instrument.id()),
4918 ));
4919
4920 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4921 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4922 let manager = AccountsManager::new(clock, cache);
4923
4924 let long = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
4925 let short = build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S");
4926 let state = manager
4927 .update_positions_in_place(
4928 &mut account,
4929 &instrument_any,
4930 vec![&long, &short],
4931 UnixNanos::default(),
4932 )
4933 .expect("net-flat maintenance update should generate state");
4934
4935 let margin = account
4936 .margin(&instrument.id())
4937 .expect("initial margin should remain");
4938 assert_eq!(margin.initial, initial);
4939 assert_eq!(margin.maintenance, Money::zero(usd));
4940 assert_eq!(state.margins, vec![margin]);
4941 }
4942
4943 #[rstest]
4944 fn test_update_positions_in_place_flip_uses_flipping_fill_price() {
4945 let usd = Currency::USD();
4948 let mut account = build_margin_account_usd(1_000_000.0);
4949 let instrument = audusd_sim();
4950 account.set_leverage(instrument.id(), Decimal::ONE);
4951 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
4952
4953 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4954 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4955 let manager = AccountsManager::new(clock, cache);
4956
4957 let long = build_hedging_position_at(
4960 &instrument_any,
4961 OrderSide::Buy,
4962 "100",
4963 "1.00000",
4964 "L",
4965 UnixNanos::from(1),
4966 );
4967 let short_partial = build_hedging_position_at(
4968 &instrument_any,
4969 OrderSide::Sell,
4970 "50",
4971 "2.00000",
4972 "S1",
4973 UnixNanos::from(2),
4974 );
4975 let short_flip = build_hedging_position_at(
4976 &instrument_any,
4977 OrderSide::Sell,
4978 "100",
4979 "3.00000",
4980 "S2",
4981 UnixNanos::from(3),
4982 );
4983
4984 let result = manager.update_positions_in_place(
4985 &mut account,
4986 &instrument_any,
4987 vec![&long, &short_partial, &short_flip],
4988 UnixNanos::default(),
4989 );
4990 assert!(result.is_some(), "update_positions_in_place returned None");
4991
4992 let margin_maint = account.maintenance_margin(instrument.id());
4993 assert_eq!(margin_maint, Money::new(4.50, usd));
4994 }
4995
4996 #[rstest]
4997 fn test_update_positions_in_place_same_ts_legs_ordering_is_deterministic() {
4998 let usd = Currency::USD();
5001 let instrument = audusd_sim();
5002 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
5003
5004 let same_ts = UnixNanos::from(42);
5007 let l_a = build_hedging_position_at(
5008 &instrument_any,
5009 OrderSide::Buy,
5010 "100",
5011 "1.00000",
5012 "A",
5013 same_ts,
5014 );
5015 let s_b = build_hedging_position_at(
5016 &instrument_any,
5017 OrderSide::Sell,
5018 "50",
5019 "2.00000",
5020 "B",
5021 same_ts,
5022 );
5023 let s_c = build_hedging_position_at(
5024 &instrument_any,
5025 OrderSide::Sell,
5026 "100",
5027 "3.00000",
5028 "C",
5029 same_ts,
5030 );
5031
5032 let permutations: Vec<Vec<&Position>> = vec![
5033 vec![&l_a, &s_b, &s_c],
5034 vec![&s_c, &s_b, &l_a],
5035 vec![&s_b, &l_a, &s_c],
5036 vec![&s_c, &l_a, &s_b],
5037 ];
5038
5039 let mut results: Vec<Money> = Vec::new();
5040
5041 for perm in permutations {
5042 let mut account = build_margin_account_usd(1_000_000.0);
5043 account.set_leverage(instrument.id(), Decimal::ONE);
5044
5045 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5046 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
5047 let manager = AccountsManager::new(clock, cache);
5048
5049 let result = manager.update_positions_in_place(
5050 &mut account,
5051 &instrument_any,
5052 perm,
5053 UnixNanos::default(),
5054 );
5055 assert!(result.is_some());
5056 results.push(account.maintenance_margin(instrument.id()));
5057 }
5058
5059 let first = results[0];
5060 for r in &results[1..] {
5061 assert_eq!(
5062 *r, first,
5063 "maintenance margin must be deterministic across permutations"
5064 );
5065 }
5066 assert_eq!(first, Money::new(4.50, usd));
5068 }
5069
5070 #[rstest]
5071 fn test_update_positions_in_place_xrate_unavailable_returns_none() {
5072 let eur = Currency::EUR();
5075 let account_state = AccountState::new(
5076 AccountId::new("SIM-001"),
5077 AccountType::Margin,
5078 vec![AccountBalance::new(
5079 Money::new(1_000_000.0, eur),
5080 Money::zero(eur),
5081 Money::new(1_000_000.0, eur),
5082 )],
5083 Vec::new(),
5084 true,
5085 UUID4::new(),
5086 UnixNanos::default(),
5087 UnixNanos::default(),
5088 Some(eur),
5089 );
5090 let mut account = MarginAccount::new(account_state, false);
5091 let instrument = audusd_sim();
5092 account.set_leverage(instrument.id(), Decimal::ONE);
5093 let instrument_any = InstrumentAny::CurrencyPair(instrument);
5094
5095 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5096 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
5097 let manager = AccountsManager::new(clock, cache);
5098
5099 let pos = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
5100 let result = manager.update_positions_in_place(
5101 &mut account,
5102 &instrument_any,
5103 vec![&pos],
5104 UnixNanos::default(),
5105 );
5106 assert!(result.is_none(), "xrate-unavailable must return None");
5107 }
5108
5109 #[rstest]
5110 fn test_update_positions_in_place_base_xrate_uses_ask_for_short_net_position() {
5111 let eur = Currency::EUR();
5112 let account_state = AccountState::new(
5113 AccountId::new("SIM-001"),
5114 AccountType::Margin,
5115 vec![AccountBalance::new(
5116 Money::new(1_000.0, eur),
5117 Money::zero(eur),
5118 Money::new(1_000.0, eur),
5119 )],
5120 Vec::new(),
5121 true,
5122 UUID4::new(),
5123 UnixNanos::default(),
5124 UnixNanos::default(),
5125 Some(eur),
5126 );
5127 let mut account = MarginAccount::new(account_state, false);
5128 let instrument = audusd_sim();
5129 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
5130
5131 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5132 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
5133 add_usdeur_quote(&cache, "0.90000", "1.10000");
5134 let manager = AccountsManager::new(clock, cache);
5135
5136 let position =
5137 build_hedging_position(&instrument_any, OrderSide::Sell, "100", "2.00000", "S");
5138 let result = manager.update_positions_in_place(
5139 &mut account,
5140 &instrument_any,
5141 vec![&position],
5142 UnixNanos::default(),
5143 );
5144
5145 assert!(result.is_some());
5146 assert_eq!(
5147 account.maintenance_margin(instrument.id()),
5148 Money::new(6.60, eur)
5149 );
5150 }
5151
5152 #[rstest]
5153 fn test_update_positions_in_place_closed_positions_filtered() {
5154 let usd = Currency::USD();
5156 let mut account = build_margin_account_usd(1_000_000.0);
5157 let instrument = audusd_sim();
5158 account.set_leverage(instrument.id(), Decimal::ONE);
5159 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
5160
5161 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5162 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
5163 let manager = AccountsManager::new(clock, cache);
5164
5165 let open_long = build_hedging_position_at(
5167 &instrument_any,
5168 OrderSide::Buy,
5169 "100",
5170 "1.00000",
5171 "C",
5172 UnixNanos::from(1),
5173 );
5174 let close_fill = OrderFilledSpec::builder()
5175 .instrument_id(instrument.id())
5176 .client_order_id(ClientOrderId::new("Cclose"))
5177 .venue_order_id(VenueOrderId::new("Cclose"))
5178 .trade_id(TradeId::new("Cclose"))
5179 .order_side(OrderSide::Sell)
5180 .last_qty(Quantity::from("100"))
5181 .last_px(Price::from("1.00000"))
5182 .currency(instrument.settlement_currency())
5183 .ts_event(UnixNanos::from(2))
5184 .ts_init(UnixNanos::from(2))
5185 .position_id(PositionId::new("C"))
5186 .build();
5187 let mut closed = open_long;
5188 closed.apply(&close_fill);
5189 assert!(closed.is_closed());
5190
5191 let live = build_hedging_position_at(
5193 &instrument_any,
5194 OrderSide::Buy,
5195 "50",
5196 "1.00000",
5197 "L",
5198 UnixNanos::from(3),
5199 );
5200
5201 let result = manager.update_positions_in_place(
5202 &mut account,
5203 &instrument_any,
5204 vec![&closed, &live],
5205 UnixNanos::default(),
5206 );
5207 assert!(result.is_some());
5208
5209 assert_eq!(
5211 account.maintenance_margin(instrument.id()),
5212 Money::new(1.50, usd)
5213 );
5214 }
5215
5216 fn add_usdeur_quote(cache: &Rc<RefCell<Cache>>, bid: &str, ask: &str) {
5217 let instrument = default_fx_ccy(Symbol::from("USD/EUR"), Some(Venue::from("SIM")));
5218 let quote = QuoteTick::new(
5219 instrument.id(),
5220 Price::from(bid),
5221 Price::from(ask),
5222 Quantity::from("1"),
5223 Quantity::from("1"),
5224 UnixNanos::default(),
5225 UnixNanos::default(),
5226 );
5227 let mut cache = cache.borrow_mut();
5228 cache
5229 .add_instrument(InstrumentAny::CurrencyPair(instrument))
5230 .unwrap();
5231 cache.add_quote(quote).unwrap();
5232 }
5233
5234 fn usd_usdt_future() -> CryptoFuture {
5235 CryptoFuture::builder()
5236 .instrument_id(InstrumentId::from("ETHUSD-123.SIM"))
5237 .raw_symbol(Symbol::from("ETHUSD-123"))
5238 .underlying(Currency::ETH())
5239 .quote_currency(Currency::USD())
5240 .settlement_currency(Currency::USDT())
5241 .is_inverse(false)
5242 .activation_ns(0.into())
5243 .expiration_ns(0.into())
5244 .price_precision(2)
5245 .size_precision(0)
5246 .price_increment(Price::from("0.01"))
5247 .size_increment(Quantity::from("1"))
5248 .margin_init(Decimal::new(1, 1))
5249 .margin_maint(Decimal::new(1, 1))
5250 .ts_event(0.into())
5251 .ts_init(0.into())
5252 .build()
5253 .unwrap()
5254 }
5255}