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