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