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