1use std::{cell::RefCell, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
21use nautilus_common::{cache::Cache, clock::Clock};
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24 accounts::{Account, AccountAny, BettingAccount, CashAccount, MarginAccount},
25 enums::{AccountType, OrderSide, OrderType, PriceType},
26 events::{AccountState, OrderFilled},
27 identifiers::InstrumentId,
28 instruments::{Instrument, InstrumentAny},
29 orders::{Order, OrderAny},
30 position::{Position, fold_net_position},
31 types::{AccountBalance, Currency, Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34
35pub struct AccountsManager {
40 clock: Rc<RefCell<dyn Clock>>,
41 cache: Rc<RefCell<Cache>>,
42}
43
44impl Debug for AccountsManager {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct(stringify!(AccountsManager)).finish()
47 }
48}
49
50impl AccountsManager {
51 pub fn new(clock: Rc<RefCell<dyn Clock>>, cache: Rc<RefCell<Cache>>) -> Self {
53 Self { clock, cache }
54 }
55
56 #[must_use]
65 pub fn update_balances(
66 &self,
67 mut account: AccountAny,
68 instrument: &InstrumentAny,
69 fill: OrderFilled,
70 ) -> (AccountAny, AccountState) {
71 let position_id = if let Some(position_id) = fill.position_id {
72 position_id
73 } else {
74 let cache = self.cache.borrow();
75 let positions_open = cache.positions_open(
76 None,
77 Some(&fill.instrument_id),
78 None,
79 Some(&fill.account_id),
80 None,
81 );
82 positions_open
83 .first()
84 .unwrap_or_else(|| panic!("List of Positions is empty"))
85 .id
86 };
87
88 let position = self.cache.borrow().position_owned(&position_id);
89
90 let pnls = account.calculate_pnls(instrument, &fill, position);
91
92 match account.base_currency() {
94 Some(base_currency) => {
95 let pnl = pnls.map_or_else(
96 |_| Money::zero(base_currency),
97 |pnl_list| {
98 pnl_list
99 .first()
100 .copied()
101 .unwrap_or_else(|| Money::zero(base_currency))
102 },
103 );
104
105 self.update_balance_single_currency(&mut account, &fill, pnl);
106 }
107 None => {
108 if let Ok(mut pnl_list) = pnls {
109 self.update_balance_multi_currency(&mut account, &fill, &mut pnl_list);
110 }
111 }
112 }
113
114 let state = self.generate_account_state(&account, fill.ts_event);
115 (account, state)
116 }
117
118 #[must_use]
123 pub fn update_orders(
124 &self,
125 account: &AccountAny,
126 instrument: &InstrumentAny,
127 orders_open: &[&OrderAny],
128 ts_event: UnixNanos,
129 ) -> Option<(AccountAny, AccountState)> {
130 let mut account = account.clone();
131 self.update_orders_in_place(&mut account, instrument, orders_open, ts_event)
132 .map(|state| (account, state))
133 }
134
135 #[must_use]
140 pub fn update_orders_in_place(
141 &self,
142 account: &mut AccountAny,
143 instrument: &InstrumentAny,
144 orders_open: &[&OrderAny],
145 ts_event: UnixNanos,
146 ) -> Option<AccountState> {
147 match account {
148 AccountAny::Margin(margin_account) => {
149 self.update_margin_init(margin_account, instrument, orders_open, ts_event)
150 }
151 AccountAny::Cash(cash_account) => {
152 self.update_balance_locked(cash_account, instrument, orders_open, ts_event)
153 }
154 AccountAny::Betting(betting_account) => self.update_balance_locked_betting(
155 betting_account,
156 instrument,
157 orders_open,
158 ts_event,
159 ),
160 }
161 }
162
163 #[must_use]
169 pub fn update_positions(
170 &self,
171 account: &MarginAccount,
172 instrument: &InstrumentAny,
173 positions: Vec<&Position>,
174 ts_event: UnixNanos,
175 ) -> Option<(MarginAccount, AccountState)> {
176 let mut account = account.clone();
177 self.update_positions_in_place(&mut account, instrument, positions, ts_event)
178 .map(|state| (account, state))
179 }
180
181 #[must_use]
191 pub fn update_positions_in_place(
192 &self,
193 account: &mut MarginAccount,
194 instrument: &InstrumentAny,
195 positions: Vec<&Position>,
196 ts_event: UnixNanos,
197 ) -> Option<AccountState> {
198 let mut ordered: Vec<&Position> = positions;
199 ordered.sort_by_key(|p| (p.ts_opened, p.id));
200
201 let legs: Vec<(Decimal, Decimal, u64)> = ordered
202 .iter()
203 .map(|p| {
204 assert_eq!(
205 p.instrument_id,
206 instrument.id(),
207 "Position not for instrument {}",
208 instrument.id()
209 );
210 (
211 p.signed_decimal_qty(),
212 Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO),
213 p.ts_opened.as_u64(),
214 )
215 })
216 .collect();
217
218 let (net_signed_qty, net_avg_px) = fold_net_position(&legs);
219
220 let currency = account
221 .base_currency
222 .unwrap_or_else(|| instrument.settlement_currency());
223
224 let mut total_margin_maint = Decimal::ZERO;
225
226 let net_qty =
227 match Quantity::from_decimal_dp(net_signed_qty.abs(), instrument.size_precision()) {
228 Ok(q) if q.is_zero() => None,
229 Ok(q) => Some(q),
230 Err(e) => {
231 log::error!(
232 "Cannot calculate maintenance (position) margin: net quantity \
233 conversion failed for {}: {e}",
234 instrument.id()
235 );
236 return None;
237 }
238 };
239
240 if let Some(quantity) = net_qty {
241 let price = Price::from_decimal_dp(net_avg_px, instrument.price_precision()).ok()?;
242 let net_entry = if net_signed_qty > Decimal::ZERO {
243 OrderSide::Buy
244 } else {
245 OrderSide::Sell
246 };
247
248 let margin_maint = match instrument {
249 InstrumentAny::Betting(i) => account
250 .calculate_maintenance_margin(i, quantity, price, None)
251 .ok()?,
252 InstrumentAny::BinaryOption(i) => account
253 .calculate_maintenance_margin(i, quantity, price, None)
254 .ok()?,
255 InstrumentAny::Cfd(i) => account
256 .calculate_maintenance_margin(i, quantity, price, None)
257 .ok()?,
258 InstrumentAny::Commodity(i) => account
259 .calculate_maintenance_margin(i, quantity, price, None)
260 .ok()?,
261 InstrumentAny::CryptoFuture(i) => account
262 .calculate_maintenance_margin(i, quantity, price, None)
263 .ok()?,
264 InstrumentAny::CryptoFuturesSpread(i) => account
265 .calculate_maintenance_margin(i, quantity, price, None)
266 .ok()?,
267 InstrumentAny::CryptoOption(i) => account
268 .calculate_maintenance_margin(i, quantity, price, None)
269 .ok()?,
270 InstrumentAny::CryptoOptionSpread(i) => account
271 .calculate_maintenance_margin(i, quantity, price, None)
272 .ok()?,
273 InstrumentAny::CryptoPerpetual(i) => account
274 .calculate_maintenance_margin(i, quantity, price, None)
275 .ok()?,
276 InstrumentAny::CurrencyPair(i) => account
277 .calculate_maintenance_margin(i, quantity, price, None)
278 .ok()?,
279 InstrumentAny::Equity(i) => account
280 .calculate_maintenance_margin(i, quantity, price, None)
281 .ok()?,
282 InstrumentAny::FuturesContract(i) => account
283 .calculate_maintenance_margin(i, quantity, price, None)
284 .ok()?,
285 InstrumentAny::FuturesSpread(i) => account
286 .calculate_maintenance_margin(i, quantity, price, None)
287 .ok()?,
288 InstrumentAny::IndexInstrument(i) => account
289 .calculate_maintenance_margin(i, quantity, price, None)
290 .ok()?,
291 InstrumentAny::OptionContract(i) => account
292 .calculate_maintenance_margin(i, quantity, price, None)
293 .ok()?,
294 InstrumentAny::OptionSpread(i) => account
295 .calculate_maintenance_margin(i, quantity, price, None)
296 .ok()?,
297 InstrumentAny::PerpetualContract(i) => account
298 .calculate_maintenance_margin(i, quantity, price, None)
299 .ok()?,
300 InstrumentAny::TokenizedAsset(i) => account
301 .calculate_maintenance_margin(i, quantity, price, None)
302 .ok()?,
303 };
304
305 total_margin_maint = margin_maint.as_decimal();
306
307 if let Some(base_currency) = account.base_currency {
308 if let Some(xrate) =
309 self.calculate_xrate_to_base(account.base_currency, instrument, net_entry)
310 {
311 total_margin_maint *= xrate;
312 } else {
313 log::debug!(
314 "Cannot calculate maintenance (position) margin: insufficient data for {}/{}",
315 instrument.settlement_currency(),
316 base_currency
317 );
318 return None;
319 }
320 }
321 }
322
323 let margin_maint = Money::from_decimal(total_margin_maint, currency).ok()?;
324 if total_margin_maint.is_zero() {
325 account.clear_maintenance_margin(instrument.id());
326 } else {
327 account.update_maintenance_margin(instrument.id(), margin_maint);
328 }
329
330 log::info!("{} margin_maint={margin_maint}", instrument.id());
331
332 Some(self.generate_margin_account_state(account, ts_event))
333 }
334
335 fn update_balance_locked(
336 &self,
337 account: &mut CashAccount,
338 instrument: &InstrumentAny,
339 orders_open: &[&OrderAny],
340 ts_event: UnixNanos,
341 ) -> Option<AccountState> {
342 if orders_open.is_empty() {
343 account.clear_balance_locked(instrument.id());
344 return Some(self.generate_cash_account_state(account, ts_event));
345 }
346
347 let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
348 let mut base_xrate: Option<Decimal> = None;
349
350 let mut currency = instrument.settlement_currency();
351
352 for order in orders_open {
353 assert_eq!(
354 order.instrument_id(),
355 instrument.id(),
356 "Order not for instrument {}",
357 instrument.id()
358 );
359 assert!(order.is_open(), "Order is not open");
360
361 if order.price().is_none() && order.trigger_price().is_none() {
362 continue;
363 }
364
365 if order.is_reduce_only() {
366 continue; }
368
369 let price = if order.price().is_some() {
370 order.price()
371 } else {
372 order.trigger_price()
373 };
374
375 let mut locked = account
376 .calculate_balance_locked(
377 instrument,
378 order.order_side(),
379 order.quantity(),
380 price?,
381 None,
382 )
383 .unwrap();
384
385 if let Some(base_curr) = account.base_currency() {
386 if base_xrate.is_none() {
387 currency = base_curr;
388 base_xrate = self.calculate_xrate_to_base(
389 account.base_currency(),
390 instrument,
391 order.order_side(),
392 );
393 }
394
395 if let Some(xrate) = base_xrate {
396 locked = match Money::from_decimal(locked.as_decimal() * xrate, currency) {
397 Ok(money) => money,
398 Err(e) => {
399 log::error!("Cannot calculate balance locked: {e}");
400 return None;
401 }
402 };
403 } else {
404 log::error!(
405 "Cannot calculate balance locked: insufficient data for {}/{}",
406 instrument.settlement_currency(),
407 base_curr
408 );
409 return None;
410 }
411 }
412
413 total_locked
414 .entry(locked.currency)
415 .and_modify(|total| *total = *total + locked)
416 .or_insert(locked);
417 }
418
419 if total_locked.is_empty() {
420 account.clear_balance_locked(instrument.id());
421 return Some(self.generate_cash_account_state(account, ts_event));
422 }
423
424 account.clear_balance_locked(instrument.id());
426
427 for (_, balance_locked) in total_locked {
428 account.update_balance_locked(instrument.id(), balance_locked);
429 log::info!("{} balance_locked={balance_locked}", instrument.id());
430 }
431
432 Some(self.generate_cash_account_state(account, ts_event))
433 }
434
435 fn update_margin_init(
436 &self,
437 account: &mut MarginAccount,
438 instrument: &InstrumentAny,
439 orders_open: &[&OrderAny],
440 ts_event: UnixNanos,
441 ) -> Option<AccountState> {
442 let mut total_margin_init = Decimal::ZERO;
443 let mut base_xrate: Option<Decimal> = None;
444 let mut currency = instrument.settlement_currency();
445
446 for order in orders_open {
447 assert_eq!(
448 order.instrument_id(),
449 instrument.id(),
450 "Order not for instrument {}",
451 instrument.id()
452 );
453
454 if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) {
455 continue;
456 }
457
458 if order.is_reduce_only() {
459 continue; }
461
462 let price = if order.price().is_some() {
463 order.price()
464 } else {
465 order.trigger_price()
466 };
467
468 let margin_init = match instrument {
469 InstrumentAny::Betting(i) => account
470 .calculate_initial_margin(i, order.quantity(), price?, None)
471 .ok()?,
472 InstrumentAny::BinaryOption(i) => account
473 .calculate_initial_margin(i, order.quantity(), price?, None)
474 .ok()?,
475 InstrumentAny::Cfd(i) => account
476 .calculate_initial_margin(i, order.quantity(), price?, None)
477 .ok()?,
478 InstrumentAny::Commodity(i) => account
479 .calculate_initial_margin(i, order.quantity(), price?, None)
480 .ok()?,
481 InstrumentAny::CryptoFuture(i) => account
482 .calculate_initial_margin(i, order.quantity(), price?, None)
483 .ok()?,
484 InstrumentAny::CryptoFuturesSpread(i) => account
485 .calculate_initial_margin(i, order.quantity(), price?, None)
486 .ok()?,
487 InstrumentAny::CryptoOption(i) => account
488 .calculate_initial_margin(i, order.quantity(), price?, None)
489 .ok()?,
490 InstrumentAny::CryptoOptionSpread(i) => account
491 .calculate_initial_margin(i, order.quantity(), price?, None)
492 .ok()?,
493 InstrumentAny::CryptoPerpetual(i) => account
494 .calculate_initial_margin(i, order.quantity(), price?, None)
495 .ok()?,
496 InstrumentAny::CurrencyPair(i) => account
497 .calculate_initial_margin(i, order.quantity(), price?, None)
498 .ok()?,
499 InstrumentAny::Equity(i) => account
500 .calculate_initial_margin(i, order.quantity(), price?, None)
501 .ok()?,
502 InstrumentAny::FuturesContract(i) => account
503 .calculate_initial_margin(i, order.quantity(), price?, None)
504 .ok()?,
505 InstrumentAny::FuturesSpread(i) => account
506 .calculate_initial_margin(i, order.quantity(), price?, None)
507 .ok()?,
508 InstrumentAny::IndexInstrument(i) => account
509 .calculate_initial_margin(i, order.quantity(), price?, None)
510 .ok()?,
511 InstrumentAny::OptionContract(i) => account
512 .calculate_initial_margin(i, order.quantity(), price?, None)
513 .ok()?,
514 InstrumentAny::OptionSpread(i) => account
515 .calculate_initial_margin(i, order.quantity(), price?, None)
516 .ok()?,
517 InstrumentAny::PerpetualContract(i) => account
518 .calculate_initial_margin(i, order.quantity(), price?, None)
519 .ok()?,
520 InstrumentAny::TokenizedAsset(i) => account
521 .calculate_initial_margin(i, order.quantity(), price?, None)
522 .ok()?,
523 };
524
525 let mut margin_init = margin_init.as_decimal();
526
527 if let Some(base_currency) = account.base_currency {
528 if base_xrate.is_none() {
529 currency = base_currency;
530 base_xrate = self.calculate_xrate_to_base(
531 account.base_currency,
532 instrument,
533 order.order_side(),
534 );
535 }
536
537 if let Some(xrate) = base_xrate {
538 margin_init *= xrate;
539 } else {
540 log::debug!(
541 "Cannot calculate initial margin: insufficient data for {}/{}",
542 instrument.settlement_currency(),
543 base_currency
544 );
545 return None;
546 }
547 }
548
549 total_margin_init += margin_init;
550 }
551
552 let money = match Money::from_decimal(total_margin_init, currency) {
553 Ok(money) => money,
554 Err(e) => {
555 log::error!("Cannot calculate initial margin: {e}");
556 return None;
557 }
558 };
559 let margin_init = if total_margin_init.is_zero() {
560 account.clear_initial_margin(instrument.id());
561 money
562 } else {
563 account.update_initial_margin(instrument.id(), money);
564 money
565 };
566
567 log::info!("{} margin_init={margin_init}", instrument.id());
568
569 Some(self.generate_margin_account_state(account, ts_event))
570 }
571
572 fn update_balance_locked_betting(
573 &self,
574 account: &mut BettingAccount,
575 instrument: &InstrumentAny,
576 orders_open: &[&OrderAny],
577 ts_event: UnixNanos,
578 ) -> Option<AccountState> {
579 if orders_open.is_empty() {
580 account.clear_balance_locked(instrument.id());
581 return Some(self.generate_betting_account_state(account, ts_event));
582 }
583
584 let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
585 let mut base_xrate: Option<Decimal> = None;
586 let mut currency = instrument.settlement_currency();
587
588 for order in orders_open {
589 assert_eq!(
590 order.instrument_id(),
591 instrument.id(),
592 "Order not for instrument {}",
593 instrument.id()
594 );
595 assert!(order.is_open(), "Order is not open");
596
597 if order.price().is_none() && order.trigger_price().is_none() {
598 continue;
599 }
600
601 if order.is_reduce_only() {
602 continue;
603 }
604
605 let price = if order.price().is_some() {
606 order.price()
607 } else {
608 order.trigger_price()
609 };
610
611 let mut locked = account
612 .calculate_balance_locked(
613 instrument,
614 order.order_side(),
615 order.quantity(),
616 price?,
617 None,
618 )
619 .unwrap();
620
621 if let Some(base_curr) = account.base_currency() {
622 if base_xrate.is_none() {
623 currency = base_curr;
624 base_xrate = self.cache.borrow().get_xrate(
625 instrument.id().venue,
626 instrument.settlement_currency(),
627 base_curr,
628 PriceType::Mid,
629 );
630 }
631
632 if let Some(xrate) = base_xrate {
633 locked = match Money::from_decimal(locked.as_decimal() * xrate, currency) {
634 Ok(money) => money,
635 Err(e) => {
636 log::error!("Cannot calculate balance locked: {e}");
637 return None;
638 }
639 };
640 } else {
641 log::error!(
642 "Cannot calculate balance locked: insufficient data for {}/{}",
643 instrument.settlement_currency(),
644 base_curr
645 );
646 return None;
647 }
648 }
649
650 total_locked
651 .entry(locked.currency)
652 .and_modify(|total| *total = *total + locked)
653 .or_insert(locked);
654 }
655
656 if total_locked.is_empty() {
657 account.clear_balance_locked(instrument.id());
658 return Some(self.generate_betting_account_state(account, ts_event));
659 }
660
661 account.clear_balance_locked(instrument.id());
662
663 for (_, balance_locked) in total_locked {
664 account.update_balance_locked(instrument.id(), balance_locked);
665 log::info!("{} balance_locked={balance_locked}", instrument.id());
666 }
667
668 Some(self.generate_betting_account_state(account, ts_event))
669 }
670
671 fn update_balance_single_currency(
672 &self,
673 account: &mut AccountAny,
674 fill: &OrderFilled,
675 mut pnl: Money,
676 ) {
677 let base_currency = if let Some(currency) = account.base_currency() {
678 currency
679 } else {
680 log::error!("Account has no base currency set");
681 return;
682 };
683
684 let mut balances = Vec::new();
685 let mut commission = fill.commission;
686
687 if let Some(ref mut comm) = commission
688 && comm.currency != base_currency
689 {
690 let xrate = self.cache.borrow().get_xrate(
691 fill.instrument_id.venue,
692 comm.currency,
693 base_currency,
694 if fill.order_side == OrderSide::Sell {
695 PriceType::Bid
696 } else {
697 PriceType::Ask
698 },
699 );
700
701 if let Some(xrate) = xrate {
702 *comm = match Money::from_decimal(comm.as_decimal() * xrate, base_currency) {
703 Ok(money) => money,
704 Err(e) => {
705 log::error!("Cannot calculate account state: {e}");
706 return;
707 }
708 };
709 } else {
710 log::error!(
711 "Cannot calculate account state: insufficient data for {}/{}",
712 comm.currency,
713 base_currency
714 );
715 return;
716 }
717 }
718
719 if pnl.currency != base_currency {
720 let xrate = self.cache.borrow().get_xrate(
721 fill.instrument_id.venue,
722 pnl.currency,
723 base_currency,
724 if fill.order_side == OrderSide::Sell {
725 PriceType::Bid
726 } else {
727 PriceType::Ask
728 },
729 );
730
731 if let Some(xrate) = xrate {
732 pnl = match Money::from_decimal(pnl.as_decimal() * xrate, base_currency) {
733 Ok(money) => money,
734 Err(e) => {
735 log::error!("Cannot calculate account state: {e}");
736 return;
737 }
738 };
739 } else {
740 log::error!(
741 "Cannot calculate account state: insufficient data for {}/{}",
742 pnl.currency,
743 base_currency
744 );
745 return;
746 }
747 }
748
749 if let Some(comm) = commission {
750 pnl = pnl - comm;
751 }
752
753 if pnl.is_zero() {
754 return;
755 }
756
757 let existing_balances = account.balances();
758 let balance = if let Some(b) = existing_balances.get(&pnl.currency) {
759 b
760 } else {
761 log::error!(
762 "Cannot complete transaction: no balance for {}",
763 pnl.currency
764 );
765 return;
766 };
767
768 let new_total = balance.total.as_decimal() + pnl.as_decimal();
769
770 let new_balance = match AccountBalance::from_total_and_locked(
771 new_total,
772 balance.locked.as_decimal(),
773 pnl.currency,
774 ) {
775 Ok(new_balance) => new_balance,
776 Err(e) => {
777 log::error!("Cannot update {} balance: {e}", pnl.currency);
778 return;
779 }
780 };
781
782 balances.push(new_balance);
783
784 match account {
785 AccountAny::Margin(margin) => {
786 margin.update_balances(&balances);
787
788 if let Some(comm) = commission {
789 margin.update_commissions(comm);
790 }
791 }
792 AccountAny::Cash(cash) => {
793 if let Err(e) = cash.update_balances(&balances) {
794 log::error!("Cannot update cash account balance: {e}");
795 return;
796 }
797
798 if let Some(comm) = commission {
799 cash.update_commissions(comm);
800 }
801 }
802 AccountAny::Betting(betting) => {
803 if let Err(e) = betting.update_balances(&balances) {
804 log::error!("Cannot update betting account balance: {e}");
805 return;
806 }
807
808 if let Some(comm) = commission {
809 betting.update_commissions(comm);
810 }
811 }
812 }
813 }
814
815 fn update_balance_multi_currency(
816 &self,
817 account: &mut AccountAny,
818 fill: &OrderFilled,
819 pnls: &mut [Money],
820 ) {
821 let mut new_balances = Vec::new();
822 let commission = fill.commission;
823 let mut apply_commission = commission.is_some_and(|c| !c.is_zero());
824
825 for pnl in pnls.iter_mut() {
826 if apply_commission && pnl.currency == commission.unwrap().currency {
827 *pnl = *pnl - commission.unwrap();
828 apply_commission = false;
829 }
830
831 if pnl.is_zero() {
832 continue; }
834
835 let currency = pnl.currency;
836 let balances = account.balances();
837
838 let new_balance = if let Some(balance) = balances.get(¤cy) {
839 let new_total = balance.total.as_decimal() + pnl.as_decimal();
840 let mut new_locked = balance.locked.as_decimal();
841
842 if pnl.as_decimal() < Decimal::ZERO
843 && fill.order_type != OrderType::Market
844 && !self.is_sports_betting_fill(fill.instrument_id)
845 {
846 new_locked += pnl.as_decimal();
847
848 if new_locked < Decimal::ZERO {
849 new_locked = Decimal::ZERO;
850 }
851 }
852
853 match AccountBalance::from_total_and_locked(new_total, new_locked, currency) {
854 Ok(new_balance) => new_balance,
855 Err(e) => {
856 log::error!("Cannot update {currency} balance: {e}");
857 return;
858 }
859 }
860 } else {
861 if pnl.as_decimal() < Decimal::ZERO {
869 log::error!(
870 "Cannot complete transaction: no {currency} to deduct a {pnl} realized PnL from"
871 );
872 return;
873 }
874 AccountBalance::new(*pnl, Money::zero(currency), *pnl)
875 };
876
877 new_balances.push(new_balance);
878 }
879
880 if apply_commission {
881 let commission = commission.unwrap();
882 let currency = commission.currency;
883 let balances = account.balances();
884
885 let commission_balance = if let Some(balance) = balances.get(¤cy) {
886 let new_total = balance.total.as_decimal() - commission.as_decimal();
887
888 match AccountBalance::from_total_and_locked(
889 new_total,
890 balance.locked.as_decimal(),
891 currency,
892 ) {
893 Ok(commission_balance) => commission_balance,
894 Err(e) => {
895 log::error!("Cannot deduct {currency} commission: {e}");
896 return;
897 }
898 }
899 } else {
900 if commission.as_decimal() > Decimal::ZERO {
901 log::error!(
902 "Cannot complete transaction: no {currency} balance to deduct a {commission} commission from"
903 );
904 return;
905 }
906 let rebate = -commission.as_decimal();
907 match AccountBalance::from_total_and_locked(rebate, Decimal::ZERO, currency) {
908 Ok(commission_balance) => commission_balance,
909 Err(e) => {
910 log::error!("Cannot credit {currency} commission rebate: {e}");
911 return;
912 }
913 }
914 };
915 new_balances.push(commission_balance);
916 }
917
918 if new_balances.is_empty() {
919 return;
920 }
921
922 match account {
923 AccountAny::Margin(margin) => {
924 margin.update_balances(&new_balances);
925
926 if let Some(commission) = commission {
927 margin.update_commissions(commission);
928 }
929 }
930 AccountAny::Cash(cash) => {
931 if let Err(e) = cash.update_balances(&new_balances) {
932 log::error!("Cannot update cash account balance: {e}");
933 return;
934 }
935
936 if let Some(commission) = commission {
937 cash.update_commissions(commission);
938 }
939 }
940 AccountAny::Betting(betting) => {
941 if let Err(e) = betting.update_balances(&new_balances) {
942 log::error!("Cannot update betting account balance: {e}");
943 return;
944 }
945
946 if let Some(commission) = commission {
947 betting.update_commissions(commission);
948 }
949 }
950 }
951 }
952
953 fn is_sports_betting_fill(&self, instrument_id: InstrumentId) -> bool {
954 self.cache
955 .borrow()
956 .instrument(&instrument_id)
957 .is_some_and(|instrument| matches!(instrument, InstrumentAny::Betting(_)))
958 }
959
960 fn generate_account_state(&self, account: &AccountAny, ts_event: UnixNanos) -> AccountState {
961 match account {
962 AccountAny::Margin(margin_account) => {
963 self.generate_margin_account_state(margin_account, ts_event)
964 }
965 AccountAny::Cash(cash_account) => {
966 self.generate_cash_account_state(cash_account, ts_event)
967 }
968 AccountAny::Betting(betting_account) => {
969 self.generate_betting_account_state(betting_account, ts_event)
970 }
971 }
972 }
973
974 fn generate_margin_account_state(
975 &self,
976 margin_account: &MarginAccount,
977 ts_event: UnixNanos,
978 ) -> AccountState {
979 let mut margins: Vec<_> = margin_account.margins.values().copied().collect();
983 margins.extend(margin_account.account_margins.values().copied());
984 AccountState::new(
985 margin_account.id,
986 AccountType::Margin,
987 margin_account.balances.clone().into_values().collect(),
988 margins,
989 false,
990 UUID4::new(),
991 ts_event,
992 self.clock.borrow().timestamp_ns(),
993 margin_account.base_currency(),
994 )
995 }
996
997 fn generate_cash_account_state(
998 &self,
999 cash_account: &CashAccount,
1000 ts_event: UnixNanos,
1001 ) -> AccountState {
1002 AccountState::new(
1003 cash_account.id,
1004 AccountType::Cash,
1005 cash_account.balances.clone().into_values().collect(),
1006 vec![],
1007 false,
1008 UUID4::new(),
1009 ts_event,
1010 self.clock.borrow().timestamp_ns(),
1011 cash_account.base_currency(),
1012 )
1013 }
1014
1015 fn generate_betting_account_state(
1016 &self,
1017 betting_account: &BettingAccount,
1018 ts_event: UnixNanos,
1019 ) -> AccountState {
1020 AccountState::new(
1021 betting_account.id,
1022 AccountType::Betting,
1023 betting_account.balances.clone().into_values().collect(),
1024 vec![],
1025 false,
1026 UUID4::new(),
1027 ts_event,
1028 self.clock.borrow().timestamp_ns(),
1029 betting_account.base_currency(),
1030 )
1031 }
1032
1033 fn calculate_xrate_to_base(
1034 &self,
1035 base_currency: Option<Currency>,
1036 instrument: &InstrumentAny,
1037 side: OrderSide,
1038 ) -> Option<Decimal> {
1039 match base_currency {
1040 None => Some(Decimal::ONE),
1041 Some(base_curr) => self.cache.borrow().get_xrate(
1042 instrument.id().venue,
1043 instrument.settlement_currency(),
1044 base_curr,
1045 if side == OrderSide::Buy {
1046 PriceType::Bid
1047 } else {
1048 PriceType::Ask
1049 },
1050 ),
1051 }
1052 }
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use std::{cell::RefCell, rc::Rc};
1058
1059 use nautilus_common::{cache::Cache, clock::TestClock};
1060 use nautilus_model::{
1061 accounts::{BettingAccount, CashAccount, MarginAccount},
1062 data::QuoteTick,
1063 enums::{AccountType, OmsType, OrderSide, OrderType},
1064 events::{
1065 AccountState, OrderAccepted, OrderEventAny, OrderFilled, OrderSubmitted,
1066 order::spec::{OrderAcceptedSpec, OrderFilledSpec, OrderSubmittedSpec},
1067 },
1068 identifiers::{
1069 AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue,
1070 VenueOrderId,
1071 },
1072 instruments::{
1073 Instrument, InstrumentAny,
1074 stubs::{audusd_sim, betting, currency_pair_btcusdt, default_fx_ccy},
1075 },
1076 orders::{OrderAny, OrderTestBuilder},
1077 position::Position,
1078 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
1079 };
1080 use rstest::rstest;
1081
1082 use super::*;
1083
1084 #[rstest]
1085 fn test_update_balance_locked_with_base_currency_multiple_orders() {
1086 let usd = Currency::USD();
1087 let account_state = AccountState::new(
1088 AccountId::new("SIM-001"),
1089 AccountType::Cash,
1090 vec![AccountBalance::new(
1091 Money::new(1_000_000.0, usd),
1092 Money::zero(usd),
1093 Money::new(1_000_000.0, usd),
1094 )],
1095 Vec::new(),
1096 true,
1097 UUID4::new(),
1098 UnixNanos::default(),
1099 UnixNanos::default(),
1100 Some(usd),
1101 );
1102
1103 let account = CashAccount::new(account_state, true, false);
1104
1105 let clock = Rc::new(RefCell::new(TestClock::new()));
1106 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1107 cache
1108 .borrow_mut()
1109 .add_account(AccountAny::Cash(account.clone()))
1110 .unwrap();
1111
1112 let manager = AccountsManager::new(clock, cache);
1113
1114 let instrument = audusd_sim();
1115
1116 let order1 = OrderTestBuilder::new(OrderType::Limit)
1117 .instrument_id(instrument.id())
1118 .side(OrderSide::Buy)
1119 .quantity(Quantity::from("100000"))
1120 .price(Price::from("0.75000"))
1121 .build();
1122
1123 let order2 = OrderTestBuilder::new(OrderType::Limit)
1124 .instrument_id(instrument.id())
1125 .side(OrderSide::Buy)
1126 .quantity(Quantity::from("50000"))
1127 .price(Price::from("0.74500"))
1128 .build();
1129
1130 let order3 = OrderTestBuilder::new(OrderType::Limit)
1131 .instrument_id(instrument.id())
1132 .side(OrderSide::Buy)
1133 .quantity(Quantity::from("75000"))
1134 .price(Price::from("0.74000"))
1135 .build();
1136
1137 let mut order1 = order1;
1138 let mut order2 = order2;
1139 let mut order3 = order3;
1140
1141 let submitted1 = order_submitted_for(&order1);
1142 let accepted1 = order_accepted_for(&order1, VenueOrderId::new("1"));
1143
1144 order1.apply(OrderEventAny::Submitted(submitted1)).unwrap();
1145 order1.apply(OrderEventAny::Accepted(accepted1)).unwrap();
1146
1147 let submitted2 = order_submitted_for(&order2);
1148 let accepted2 = order_accepted_for(&order2, VenueOrderId::new("2"));
1149
1150 order2.apply(OrderEventAny::Submitted(submitted2)).unwrap();
1151 order2.apply(OrderEventAny::Accepted(accepted2)).unwrap();
1152
1153 let submitted3 = order_submitted_for(&order3);
1154 let accepted3 = order_accepted_for(&order3, VenueOrderId::new("3"));
1155
1156 order3.apply(OrderEventAny::Submitted(submitted3)).unwrap();
1157 order3.apply(OrderEventAny::Accepted(accepted3)).unwrap();
1158
1159 let orders: Vec<&OrderAny> = vec![&order1, &order2, &order3];
1160
1161 let result = manager.update_orders(
1162 &AccountAny::Cash(account),
1163 &InstrumentAny::CurrencyPair(instrument),
1164 &orders,
1165 UnixNanos::default(),
1166 );
1167
1168 assert!(result.is_some());
1169 let (updated_account, _state) = result.unwrap();
1170
1171 if let AccountAny::Cash(cash_account) = updated_account {
1172 let locked_balance = cash_account.balance_locked(Some(usd));
1173
1174 let expected_locked = Money::new(167_750.0, usd);
1176
1177 assert_eq!(locked_balance, Some(expected_locked));
1178 let aud = Currency::AUD();
1179 assert_eq!(cash_account.balance_locked(Some(aud)), None);
1180 } else {
1181 panic!("Expected CashAccount");
1182 }
1183 }
1184
1185 #[rstest]
1186 fn test_update_orders_betting_account_uses_liability_for_locked_balance() {
1187 let gbp = Currency::GBP();
1188 let account_state = AccountState::new(
1189 AccountId::new("BETTING-001"),
1190 AccountType::Betting,
1191 vec![AccountBalance::new(
1192 Money::new(1_000.0, gbp),
1193 Money::zero(gbp),
1194 Money::new(1_000.0, gbp),
1195 )],
1196 Vec::new(),
1197 true,
1198 UUID4::new(),
1199 UnixNanos::default(),
1200 UnixNanos::default(),
1201 Some(gbp),
1202 );
1203
1204 let account = BettingAccount::new(account_state, true);
1205
1206 let clock = Rc::new(RefCell::new(TestClock::new()));
1207 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1208 cache
1209 .borrow_mut()
1210 .add_account(AccountAny::Betting(account.clone()))
1211 .unwrap();
1212
1213 let manager = AccountsManager::new(clock, cache);
1214 let instrument = betting();
1215
1216 let mut back_order = OrderTestBuilder::new(OrderType::Limit)
1217 .instrument_id(instrument.id())
1218 .side(OrderSide::Buy)
1219 .quantity(Quantity::from("10"))
1220 .price(Price::from("1.25"))
1221 .build();
1222
1223 let mut lay_order = OrderTestBuilder::new(OrderType::Limit)
1224 .instrument_id(instrument.id())
1225 .side(OrderSide::Sell)
1226 .quantity(Quantity::from("12"))
1227 .price(Price::from("3.00"))
1228 .build();
1229
1230 let submitted_back =
1231 order_submitted_for_account(&back_order, AccountId::new("BETTING-001"));
1232 let accepted_back = order_accepted_for_account(
1233 &back_order,
1234 VenueOrderId::new("B1"),
1235 AccountId::new("BETTING-001"),
1236 );
1237 back_order
1238 .apply(OrderEventAny::Submitted(submitted_back))
1239 .unwrap();
1240 back_order
1241 .apply(OrderEventAny::Accepted(accepted_back))
1242 .unwrap();
1243
1244 let submitted_lay = order_submitted_for_account(&lay_order, AccountId::new("BETTING-001"));
1245 let accepted_lay = order_accepted_for_account(
1246 &lay_order,
1247 VenueOrderId::new("L1"),
1248 AccountId::new("BETTING-001"),
1249 );
1250 lay_order
1251 .apply(OrderEventAny::Submitted(submitted_lay))
1252 .unwrap();
1253 lay_order
1254 .apply(OrderEventAny::Accepted(accepted_lay))
1255 .unwrap();
1256
1257 let orders: Vec<&OrderAny> = vec![&back_order, &lay_order];
1258 let result = manager.update_orders(
1259 &AccountAny::Betting(account),
1260 &InstrumentAny::Betting(instrument),
1261 &orders,
1262 UnixNanos::default(),
1263 );
1264
1265 assert!(result.is_some());
1266 let (updated_account, state) = result.unwrap();
1267
1268 if let AccountAny::Betting(betting_account) = updated_account {
1269 assert_eq!(
1270 betting_account.balance_locked(Some(gbp)),
1271 Some(Money::new(14.5, gbp))
1272 );
1273 assert_eq!(
1274 betting_account.balance_free(Some(gbp)),
1275 Some(Money::new(985.5, gbp))
1276 );
1277 assert_eq!(state.account_type, AccountType::Betting);
1278 } else {
1279 panic!("Expected BettingAccount");
1280 }
1281 }
1282
1283 #[rstest]
1284 fn test_betting_order_canceled_releases_locked_balance() {
1285 let gbp = Currency::GBP();
1286 let account_state = AccountState::new(
1287 AccountId::new("BETFAIR-001"),
1288 AccountType::Betting,
1289 vec![AccountBalance::new(
1290 Money::new(1_000.0, gbp),
1291 Money::zero(gbp),
1292 Money::new(1_000.0, gbp),
1293 )],
1294 Vec::new(),
1295 true,
1296 UUID4::new(),
1297 UnixNanos::default(),
1298 UnixNanos::default(),
1299 Some(gbp),
1300 );
1301
1302 let account = BettingAccount::new(account_state, true);
1303
1304 let clock = Rc::new(RefCell::new(TestClock::new()));
1305 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1306 cache
1307 .borrow_mut()
1308 .add_account(AccountAny::Betting(account.clone()))
1309 .unwrap();
1310
1311 let manager = AccountsManager::new(clock, cache);
1312 let instrument = betting();
1313
1314 let mut order = OrderTestBuilder::new(OrderType::Limit)
1315 .instrument_id(instrument.id())
1316 .side(OrderSide::Buy)
1317 .quantity(Quantity::from("10"))
1318 .price(Price::from("5.0"))
1319 .build();
1320
1321 let submitted = order_submitted_for_account(&order, AccountId::new("BETFAIR-001"));
1322 let accepted = order_accepted_for_account(
1323 &order,
1324 VenueOrderId::new("B2"),
1325 AccountId::new("BETFAIR-001"),
1326 );
1327
1328 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1329 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1330
1331 let result = manager.update_orders(
1332 &AccountAny::Betting(account),
1333 &InstrumentAny::Betting(instrument.clone()),
1334 &[&order],
1335 UnixNanos::default(),
1336 );
1337
1338 assert!(result.is_some());
1339 let (updated_account, _) = result.unwrap();
1340
1341 if let AccountAny::Betting(ref betting_account) = updated_account {
1342 assert_eq!(
1343 betting_account.balance_locked(Some(gbp)),
1344 Some(Money::new(40.0, gbp))
1345 );
1346 assert_eq!(
1347 betting_account.balance_free(Some(gbp)),
1348 Some(Money::new(960.0, gbp))
1349 );
1350 } else {
1351 panic!("Expected BettingAccount");
1352 }
1353
1354 let result = manager.update_orders(
1355 &updated_account,
1356 &InstrumentAny::Betting(instrument),
1357 &[],
1358 UnixNanos::default(),
1359 );
1360
1361 assert!(result.is_some());
1362 let (final_account, _) = result.unwrap();
1363
1364 if let AccountAny::Betting(betting_account) = final_account {
1365 assert_eq!(
1366 betting_account.balance_locked(Some(gbp)),
1367 Some(Money::zero(gbp))
1368 );
1369 assert_eq!(
1370 betting_account.balance_free(Some(gbp)),
1371 Some(Money::new(1_000.0, gbp))
1372 );
1373 assert_eq!(
1374 betting_account.balance_total(Some(gbp)),
1375 Some(Money::new(1_000.0, gbp))
1376 );
1377 } else {
1378 panic!("Expected BettingAccount");
1379 }
1380 }
1381
1382 #[rstest]
1383 fn test_update_orders_clears_stale_currency_locks_when_order_sides_change() {
1384 let usd = Currency::USD();
1385 let aud = Currency::AUD();
1386 let account_state = AccountState::new(
1387 AccountId::new("SIM-001"),
1388 AccountType::Cash,
1389 vec![
1390 AccountBalance::new(
1391 Money::new(1_000_000.0, usd),
1392 Money::zero(usd),
1393 Money::new(1_000_000.0, usd),
1394 ),
1395 AccountBalance::new(
1396 Money::new(1_000_000.0, aud),
1397 Money::zero(aud),
1398 Money::new(1_000_000.0, aud),
1399 ),
1400 ],
1401 Vec::new(),
1402 true,
1403 UUID4::new(),
1404 UnixNanos::default(),
1405 UnixNanos::default(),
1406 None,
1407 );
1408
1409 let account = CashAccount::new(account_state, true, false);
1410
1411 let clock = Rc::new(RefCell::new(TestClock::new()));
1412 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1413 cache
1414 .borrow_mut()
1415 .add_account(AccountAny::Cash(account.clone()))
1416 .unwrap();
1417
1418 let manager = AccountsManager::new(clock, cache);
1419 let instrument = audusd_sim();
1420
1421 let mut buy_order = OrderTestBuilder::new(OrderType::Limit)
1422 .instrument_id(instrument.id())
1423 .side(OrderSide::Buy)
1424 .quantity(Quantity::from("100000"))
1425 .price(Price::from("0.80000"))
1426 .build();
1427
1428 let mut sell_order = OrderTestBuilder::new(OrderType::Limit)
1429 .instrument_id(instrument.id())
1430 .side(OrderSide::Sell)
1431 .quantity(Quantity::from("50000"))
1432 .price(Price::from("0.81000"))
1433 .build();
1434
1435 let submitted_buy = order_submitted_for(&buy_order);
1437 let accepted_buy = order_accepted_for(&buy_order, VenueOrderId::new("1"));
1438 buy_order
1439 .apply(OrderEventAny::Submitted(submitted_buy))
1440 .unwrap();
1441 buy_order
1442 .apply(OrderEventAny::Accepted(accepted_buy))
1443 .unwrap();
1444
1445 let submitted_sell = order_submitted_for(&sell_order);
1446 let accepted_sell = order_accepted_for(&sell_order, VenueOrderId::new("2"));
1447 sell_order
1448 .apply(OrderEventAny::Submitted(submitted_sell))
1449 .unwrap();
1450 sell_order
1451 .apply(OrderEventAny::Accepted(accepted_sell))
1452 .unwrap();
1453
1454 let orders_both: Vec<&OrderAny> = vec![&buy_order, &sell_order];
1455 let result = manager.update_orders(
1456 &AccountAny::Cash(account),
1457 &InstrumentAny::CurrencyPair(instrument.clone()),
1458 &orders_both,
1459 UnixNanos::default(),
1460 );
1461
1462 assert!(result.is_some());
1463 let (updated_account, _) = result.unwrap();
1464
1465 if let AccountAny::Cash(cash_account) = &updated_account {
1466 assert_eq!(
1467 cash_account.balance_locked(Some(usd)),
1468 Some(Money::new(80_000.0, usd))
1469 );
1470 assert_eq!(
1471 cash_account.balance_locked(Some(aud)),
1472 Some(Money::new(50_000.0, aud))
1473 );
1474 } else {
1475 panic!("Expected CashAccount");
1476 }
1477
1478 let orders_sell_only: Vec<&OrderAny> = vec![&sell_order];
1480 let result = manager.update_orders(
1481 &updated_account,
1482 &InstrumentAny::CurrencyPair(instrument),
1483 &orders_sell_only,
1484 UnixNanos::default(),
1485 );
1486
1487 assert!(result.is_some());
1488 let (final_account, _) = result.unwrap();
1489
1490 if let AccountAny::Cash(cash_account) = final_account {
1491 assert_eq!(
1492 cash_account.balance_locked(Some(usd)),
1493 Some(Money::zero(usd))
1494 );
1495 assert_eq!(
1496 cash_account.balance_locked(Some(aud)),
1497 Some(Money::new(50_000.0, aud))
1498 );
1499 } else {
1500 panic!("Expected CashAccount");
1501 }
1502 }
1503
1504 #[rstest]
1505 fn test_update_orders_margin_init_xrate_unavailable_returns_none() {
1506 let eur = Currency::EUR();
1507 let account_state = AccountState::new(
1508 AccountId::new("SIM-001"),
1509 AccountType::Margin,
1510 vec![AccountBalance::new(
1511 Money::new(1_000_000.0, eur),
1512 Money::zero(eur),
1513 Money::new(1_000_000.0, eur),
1514 )],
1515 Vec::new(),
1516 true,
1517 UUID4::new(),
1518 UnixNanos::default(),
1519 UnixNanos::default(),
1520 Some(eur),
1521 );
1522 let mut account = MarginAccount::new(account_state, true);
1523 let instrument = audusd_sim();
1524 let prior_margin = Money::new(10.0, eur);
1525 account.update_initial_margin(instrument.id(), prior_margin);
1526
1527 let clock = Rc::new(RefCell::new(TestClock::new()));
1528 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1529 let manager = AccountsManager::new(clock, cache);
1530
1531 let mut order = OrderTestBuilder::new(OrderType::Limit)
1532 .instrument_id(instrument.id())
1533 .side(OrderSide::Buy)
1534 .quantity(Quantity::from("100000"))
1535 .price(Price::from("0.80000"))
1536 .build();
1537
1538 let submitted = order_submitted_for(&order);
1539 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1540 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1541 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1542
1543 let mut account = AccountAny::Margin(account);
1544 let result = manager.update_orders_in_place(
1545 &mut account,
1546 &InstrumentAny::CurrencyPair(instrument.clone()),
1547 &[&order],
1548 UnixNanos::default(),
1549 );
1550
1551 assert!(result.is_none(), "xrate-unavailable must return None");
1552
1553 match account {
1554 AccountAny::Margin(margin_account) => {
1555 assert_eq!(margin_account.initial_margin(instrument.id()), prior_margin);
1556 assert_eq!(margin_account.balance_locked(Some(eur)), Some(prior_margin));
1557 }
1558 _ => panic!("Expected MarginAccount"),
1559 }
1560 }
1561
1562 #[rstest]
1563 fn test_update_balance_locked_base_xrate_uses_bid_for_buy_order() {
1564 let eur = Currency::EUR();
1565 let account_state = AccountState::new(
1566 AccountId::new("SIM-001"),
1567 AccountType::Cash,
1568 vec![AccountBalance::new(
1569 Money::new(1_000.0, eur),
1570 Money::zero(eur),
1571 Money::new(1_000.0, eur),
1572 )],
1573 Vec::new(),
1574 true,
1575 UUID4::new(),
1576 UnixNanos::default(),
1577 UnixNanos::default(),
1578 Some(eur),
1579 );
1580 let account = CashAccount::new(account_state, true, false);
1581
1582 let clock = Rc::new(RefCell::new(TestClock::new()));
1583 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1584 add_usdeur_quote(&cache, "0.90000", "1.10000");
1585 let manager = AccountsManager::new(clock, cache);
1586
1587 let instrument = audusd_sim();
1588 let mut order = OrderTestBuilder::new(OrderType::Limit)
1589 .instrument_id(instrument.id())
1590 .side(OrderSide::Buy)
1591 .quantity(Quantity::from("100"))
1592 .price(Price::from("2.00000"))
1593 .build();
1594
1595 let submitted = order_submitted_for(&order);
1596 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1597 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1598 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1599
1600 let result = manager.update_orders(
1601 &AccountAny::Cash(account),
1602 &InstrumentAny::CurrencyPair(instrument),
1603 &[&order],
1604 UnixNanos::default(),
1605 );
1606
1607 assert!(result.is_some());
1608 let (updated_account, _) = result.unwrap();
1609
1610 match updated_account {
1611 AccountAny::Cash(cash) => {
1612 assert_eq!(cash.balance_locked(Some(eur)), Some(Money::new(180.0, eur)));
1613 }
1614 _ => panic!("Expected CashAccount"),
1615 }
1616 }
1617
1618 #[rstest]
1619 fn test_update_margin_init_base_xrate_uses_ask_for_sell_order() {
1620 let eur = Currency::EUR();
1621 let account_state = AccountState::new(
1622 AccountId::new("SIM-001"),
1623 AccountType::Margin,
1624 vec![AccountBalance::new(
1625 Money::new(1_000.0, eur),
1626 Money::zero(eur),
1627 Money::new(1_000.0, eur),
1628 )],
1629 Vec::new(),
1630 true,
1631 UUID4::new(),
1632 UnixNanos::default(),
1633 UnixNanos::default(),
1634 Some(eur),
1635 );
1636 let account = MarginAccount::new(account_state, true);
1637
1638 let clock = Rc::new(RefCell::new(TestClock::new()));
1639 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1640 add_usdeur_quote(&cache, "0.90000", "1.10000");
1641 let manager = AccountsManager::new(clock, cache);
1642
1643 let instrument = audusd_sim();
1644 let mut order = OrderTestBuilder::new(OrderType::Limit)
1645 .instrument_id(instrument.id())
1646 .side(OrderSide::Sell)
1647 .quantity(Quantity::from("100"))
1648 .price(Price::from("2.00000"))
1649 .build();
1650
1651 let submitted = order_submitted_for(&order);
1652 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1653 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1654 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1655
1656 let result = manager.update_orders(
1657 &AccountAny::Margin(account),
1658 &InstrumentAny::CurrencyPair(instrument.clone()),
1659 &[&order],
1660 UnixNanos::default(),
1661 );
1662
1663 assert!(result.is_some());
1664 let (updated_account, _) = result.unwrap();
1665
1666 match updated_account {
1667 AccountAny::Margin(margin) => {
1668 assert_eq!(
1669 margin.initial_margin(instrument.id()),
1670 Money::new(6.60, eur)
1671 );
1672 }
1673 _ => panic!("Expected MarginAccount"),
1674 }
1675 }
1676
1677 #[rstest]
1678 fn test_update_margin_init_empty_orders_clears_prior_initial_margin() {
1679 let usd = Currency::USD();
1680 let mut account = build_margin_account_usd(1_000_000.0);
1681 let instrument = audusd_sim();
1682 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
1683 account.update_margin(MarginBalance::new(
1684 Money::new(25.0, usd),
1685 Money::zero(usd),
1686 Some(instrument.id()),
1687 ));
1688
1689 let clock = Rc::new(RefCell::new(TestClock::new()));
1690 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1691 let manager = AccountsManager::new(clock, cache);
1692
1693 let state = manager
1694 .update_margin_init(&mut account, &instrument_any, &[], UnixNanos::default())
1695 .expect("initial margin clear should generate account state");
1696
1697 assert!(account.margin(&instrument.id()).is_none());
1698 assert!(state.margins.is_empty());
1699 }
1700
1701 #[rstest]
1702 fn test_update_margin_init_empty_orders_preserves_prior_maintenance_margin() {
1703 let usd = Currency::USD();
1704 let mut account = build_margin_account_usd(1_000_000.0);
1705 let instrument = audusd_sim();
1706 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
1707 let maintenance = Money::new(12.0, usd);
1708 account.update_margin(MarginBalance::new(
1709 Money::new(25.0, usd),
1710 maintenance,
1711 Some(instrument.id()),
1712 ));
1713
1714 let clock = Rc::new(RefCell::new(TestClock::new()));
1715 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1716 let manager = AccountsManager::new(clock, cache);
1717
1718 let state = manager
1719 .update_margin_init(&mut account, &instrument_any, &[], UnixNanos::default())
1720 .expect("initial margin clear should generate account state");
1721
1722 let margin = account
1723 .margin(&instrument.id())
1724 .expect("maintenance margin should remain");
1725 assert_eq!(margin.initial, Money::zero(usd));
1726 assert_eq!(margin.maintenance, maintenance);
1727 assert_eq!(state.margins, vec![margin]);
1728 }
1729
1730 #[rstest]
1731 fn test_cash_account_rejects_negative_balance_when_borrowing_disabled() {
1732 let usd = Currency::USD();
1733 let account_state = AccountState::new(
1734 AccountId::new("SIM-001"),
1735 AccountType::Cash,
1736 vec![AccountBalance::new(
1737 Money::new(1_000.0, usd),
1738 Money::zero(usd),
1739 Money::new(1_000.0, usd),
1740 )],
1741 Vec::new(),
1742 true,
1743 UUID4::new(),
1744 UnixNanos::default(),
1745 UnixNanos::default(),
1746 Some(usd),
1747 );
1748
1749 let mut account = CashAccount::new(account_state, true, false);
1750
1751 let negative_balances = vec![AccountBalance::new(
1752 Money::new(-500.0, usd),
1753 Money::zero(usd),
1754 Money::new(-500.0, usd),
1755 )];
1756
1757 let result = account.update_balances(&negative_balances);
1758
1759 assert!(result.is_err());
1760 let err_msg = result.unwrap_err().to_string();
1761 assert!(err_msg.contains("negative"));
1762 assert!(err_msg.contains("borrowing not allowed"));
1763 }
1764
1765 #[rstest]
1766 fn test_manager_update_balances_skips_update_on_negative_balance_error() {
1767 let usd = Currency::USD();
1768 let account_state = AccountState::new(
1769 AccountId::new("SIM-001"),
1770 AccountType::Cash,
1771 vec![AccountBalance::new(
1772 Money::new(100.0, usd),
1773 Money::zero(usd),
1774 Money::new(100.0, usd),
1775 )],
1776 Vec::new(),
1777 true,
1778 UUID4::new(),
1779 UnixNanos::default(),
1780 UnixNanos::default(),
1781 Some(usd),
1782 );
1783
1784 let account = CashAccount::new(account_state, true, false);
1785 let initial_balance = account.balance_total(Some(usd)).unwrap();
1786
1787 let clock = Rc::new(RefCell::new(TestClock::new()));
1788 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1789 cache
1790 .borrow_mut()
1791 .add_account(AccountAny::Cash(account.clone()))
1792 .unwrap();
1793
1794 let manager = AccountsManager::new(clock, cache.clone());
1795 let instrument = audusd_sim();
1796
1797 let mut order = OrderTestBuilder::new(OrderType::Market)
1798 .instrument_id(instrument.id())
1799 .side(OrderSide::Buy)
1800 .quantity(Quantity::from("100000"))
1801 .build();
1802
1803 let submitted = order_submitted_for(&order);
1804 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1805 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1806 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1807
1808 cache
1809 .borrow_mut()
1810 .add_order(order.clone(), None, None, false)
1811 .unwrap();
1812
1813 let fill = OrderFilledSpec::builder()
1815 .instrument_id(instrument.id())
1816 .client_order_id(order.client_order_id())
1817 .venue_order_id(VenueOrderId::new("1"))
1818 .last_qty(Quantity::from("100000"))
1819 .last_px(Price::from("0.80000"))
1820 .ts_event(UnixNanos::from(1))
1821 .ts_init(UnixNanos::from(1))
1822 .position_id(PositionId::new("P-001"))
1823 .commission(Money::new(20.0, usd))
1824 .build();
1825
1826 let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
1827 cache
1828 .borrow_mut()
1829 .add_position(&position, OmsType::Netting)
1830 .unwrap();
1831
1832 let fill2 = OrderFilledSpec::builder()
1833 .instrument_id(instrument.id())
1834 .client_order_id(order.client_order_id())
1835 .venue_order_id(VenueOrderId::new("2"))
1836 .trade_id(TradeId::new("2"))
1837 .last_qty(Quantity::from("100000"))
1838 .last_px(Price::from("0.80000"))
1839 .ts_event(UnixNanos::from(2))
1840 .ts_init(UnixNanos::from(2))
1841 .position_id(PositionId::new("P-001"))
1842 .commission(Money::new(20.0, usd))
1843 .build();
1844 let _state = manager.update_balances(
1845 AccountAny::Cash(account),
1846 &InstrumentAny::CurrencyPair(instrument),
1847 fill2,
1848 );
1849
1850 let account_after = cache
1851 .borrow()
1852 .account(&AccountId::new("SIM-001"))
1853 .unwrap()
1854 .clone();
1855
1856 if let AccountAny::Cash(cash) = account_after {
1857 assert_eq!(cash.balance_total(Some(usd)), Some(initial_balance));
1858 } else {
1859 panic!("Expected CashAccount");
1860 }
1861 }
1862
1863 #[rstest]
1864 fn test_order_canceled_releases_locked_balance() {
1865 let usd = Currency::USD();
1867 let account_state = AccountState::new(
1868 AccountId::new("SIM-001"),
1869 AccountType::Cash,
1870 vec![AccountBalance::new(
1871 Money::new(100_000.0, usd),
1872 Money::zero(usd),
1873 Money::new(100_000.0, usd),
1874 )],
1875 Vec::new(),
1876 true,
1877 UUID4::new(),
1878 UnixNanos::default(),
1879 UnixNanos::default(),
1880 Some(usd),
1881 );
1882
1883 let account = CashAccount::new(account_state, true, false);
1884
1885 let clock = Rc::new(RefCell::new(TestClock::new()));
1886 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1887 cache
1888 .borrow_mut()
1889 .add_account(AccountAny::Cash(account.clone()))
1890 .unwrap();
1891
1892 let manager = AccountsManager::new(clock, cache);
1893 let instrument = audusd_sim();
1894
1895 let mut order = OrderTestBuilder::new(OrderType::Limit)
1896 .instrument_id(instrument.id())
1897 .side(OrderSide::Buy)
1898 .quantity(Quantity::from("100000"))
1899 .price(Price::from("0.80000"))
1900 .build();
1901
1902 let submitted = order_submitted_for(&order);
1903 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1904
1905 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1906 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1907
1908 let result = manager.update_orders(
1909 &AccountAny::Cash(account),
1910 &InstrumentAny::CurrencyPair(instrument.clone()),
1911 &[&order],
1912 UnixNanos::default(),
1913 );
1914
1915 assert!(result.is_some());
1916 let (updated_account, _) = result.unwrap();
1917
1918 if let AccountAny::Cash(ref cash) = updated_account {
1919 assert_eq!(
1921 cash.balance_locked(Some(usd)),
1922 Some(Money::new(80_000.0, usd))
1923 );
1924 assert_eq!(
1925 cash.balance_free(Some(usd)),
1926 Some(Money::new(20_000.0, usd))
1927 );
1928 } else {
1929 panic!("Expected CashAccount");
1930 }
1931
1932 let result = manager.update_orders(
1933 &updated_account,
1934 &InstrumentAny::CurrencyPair(instrument),
1935 &[],
1936 UnixNanos::default(),
1937 );
1938
1939 assert!(result.is_some());
1940 let (final_account, _) = result.unwrap();
1941
1942 if let AccountAny::Cash(cash) = final_account {
1943 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
1944 assert_eq!(
1945 cash.balance_free(Some(usd)),
1946 Some(Money::new(100_000.0, usd))
1947 );
1948 assert_eq!(
1949 cash.balance_total(Some(usd)),
1950 Some(Money::new(100_000.0, usd))
1951 );
1952 } else {
1953 panic!("Expected CashAccount");
1954 }
1955 }
1956
1957 #[rstest]
1958 fn test_generate_account_state_preserves_per_instrument_and_account_wide_margins() {
1959 let usd = Currency::USD();
1960 let audusd = InstrumentId::from("AUD/USD.SIM");
1961 let account_state = AccountState::new(
1962 AccountId::new("SIM-001"),
1963 AccountType::Margin,
1964 vec![AccountBalance::new(
1965 Money::new(1_000_000.0, usd),
1966 Money::zero(usd),
1967 Money::new(1_000_000.0, usd),
1968 )],
1969 Vec::new(),
1970 true,
1971 UUID4::new(),
1972 UnixNanos::default(),
1973 UnixNanos::default(),
1974 Some(usd),
1975 );
1976 let mut account = MarginAccount::new(account_state, false);
1977 account.update_margin(MarginBalance::new(
1978 Money::new(150.0, usd),
1979 Money::new(75.0, usd),
1980 Some(audusd),
1981 ));
1982 account.update_margin(MarginBalance::new(
1983 Money::new(500.0, usd),
1984 Money::new(250.0, usd),
1985 None,
1986 ));
1987
1988 let clock = Rc::new(RefCell::new(TestClock::new()));
1989 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1990 let manager = AccountsManager::new(clock, cache);
1991
1992 let state =
1993 manager.generate_account_state(&AccountAny::Margin(account), UnixNanos::default());
1994
1995 assert_eq!(state.balances.len(), 1);
1996 assert_eq!(state.balances[0].currency, usd);
1997 assert_eq!(state.balances[0].total, Money::new(1_000_000.0, usd));
1998 assert_eq!(state.balances[0].locked, Money::new(975.0, usd));
1999 assert_eq!(state.balances[0].free, Money::new(999_025.0, usd));
2000
2001 assert_eq!(state.margins.len(), 2);
2002 let per_instrument: Vec<_> = state
2003 .margins
2004 .iter()
2005 .filter(|m| m.instrument_id.is_some())
2006 .collect();
2007 let account_wide: Vec<_> = state
2008 .margins
2009 .iter()
2010 .filter(|m| m.instrument_id.is_none())
2011 .collect();
2012 assert_eq!(per_instrument.len(), 1);
2013 assert_eq!(per_instrument[0].instrument_id, Some(audusd));
2014 assert_eq!(per_instrument[0].initial, Money::new(150.0, usd));
2015 assert_eq!(per_instrument[0].maintenance, Money::new(75.0, usd));
2016 assert_eq!(account_wide.len(), 1);
2017 assert_eq!(account_wide[0].currency, usd);
2018 assert_eq!(account_wide[0].initial, Money::new(500.0, usd));
2019 assert_eq!(account_wide[0].maintenance, Money::new(250.0, usd));
2020 }
2021
2022 #[rstest]
2023 fn test_update_balances_returns_recalculated_balance_for_cash_account() {
2024 let usd = Currency::USD();
2025 let account_state = AccountState::new(
2026 AccountId::new("SIM-001"),
2027 AccountType::Cash,
2028 vec![AccountBalance::new(
2029 Money::new(1_000_000.0, usd),
2030 Money::zero(usd),
2031 Money::new(1_000_000.0, usd),
2032 )],
2033 Vec::new(),
2034 true,
2035 UUID4::new(),
2036 UnixNanos::default(),
2037 UnixNanos::default(),
2038 Some(usd),
2039 );
2040
2041 let account = CashAccount::new(account_state, true, false);
2042
2043 let clock = Rc::new(RefCell::new(TestClock::new()));
2044 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2045 cache
2046 .borrow_mut()
2047 .add_account(AccountAny::Cash(account.clone()))
2048 .unwrap();
2049
2050 let manager = AccountsManager::new(clock, cache.clone());
2051 let instrument = audusd_sim();
2052
2053 let mut order = OrderTestBuilder::new(OrderType::Market)
2054 .instrument_id(instrument.id())
2055 .side(OrderSide::Buy)
2056 .quantity(Quantity::from("100000"))
2057 .build();
2058 let submitted = order_submitted_for(&order);
2059 let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
2060 order.apply(OrderEventAny::Submitted(submitted)).unwrap();
2061 order.apply(OrderEventAny::Accepted(accepted)).unwrap();
2062 cache
2063 .borrow_mut()
2064 .add_order(order.clone(), None, None, false)
2065 .unwrap();
2066
2067 let fill = OrderFilledSpec::builder()
2068 .instrument_id(instrument.id())
2069 .client_order_id(order.client_order_id())
2070 .venue_order_id(VenueOrderId::new("1"))
2071 .last_qty(Quantity::from("100000"))
2072 .last_px(Price::from("0.80000"))
2073 .ts_event(UnixNanos::from(1))
2074 .ts_init(UnixNanos::from(1))
2075 .position_id(PositionId::new("P-001"))
2076 .commission(Money::new(20.0, usd))
2077 .build();
2078 let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2079 cache
2080 .borrow_mut()
2081 .add_position(&position, OmsType::Netting)
2082 .unwrap();
2083
2084 let (updated, state) = manager.update_balances(
2085 AccountAny::Cash(account),
2086 &InstrumentAny::CurrencyPair(instrument),
2087 fill,
2088 );
2089
2090 let expected = Money::new(919_980.0, usd);
2092
2093 match updated {
2094 AccountAny::Cash(cash) => {
2095 assert_eq!(cash.balance_total(Some(usd)), Some(expected));
2096 assert_eq!(cash.balance_free(Some(usd)), Some(expected));
2097 }
2098 _ => panic!("Expected CashAccount"),
2099 }
2100 assert_eq!(state.balances.len(), 1);
2101 assert_eq!(state.balances[0].currency, usd);
2102 assert_eq!(state.balances[0].total, expected);
2103 assert_eq!(state.balances[0].free, expected);
2104 }
2105
2106 fn multi_currency_cash_account(allow_borrowing: bool) -> CashAccount {
2107 let aud = Currency::AUD();
2108 let usd = Currency::USD();
2109 let account_state = AccountState::new(
2110 AccountId::new("SIM-001"),
2111 AccountType::Cash,
2112 vec![
2113 AccountBalance::new(
2114 Money::new(10_000.0, aud),
2115 Money::zero(aud),
2116 Money::new(10_000.0, aud),
2117 ),
2118 AccountBalance::new(
2119 Money::new(100.0, usd),
2120 Money::zero(usd),
2121 Money::new(100.0, usd),
2122 ),
2123 ],
2124 Vec::new(),
2125 true,
2126 UUID4::new(),
2127 UnixNanos::default(),
2128 UnixNanos::default(),
2129 None,
2130 );
2131 CashAccount::new(account_state, true, allow_borrowing)
2132 }
2133
2134 fn buy_audusd_fill(qty: &str, px: &str, commission: f64) -> OrderFilled {
2135 let instrument = audusd_sim();
2136 let usd = Currency::USD();
2137 OrderFilledSpec::builder()
2138 .instrument_id(instrument.id())
2139 .last_qty(Quantity::from(qty))
2140 .last_px(Price::from(px))
2141 .ts_event(UnixNanos::from(1))
2142 .ts_init(UnixNanos::from(1))
2143 .position_id(PositionId::new("P-001"))
2144 .commission(Money::new(commission, usd))
2145 .build()
2146 }
2147
2148 fn multi_currency_cash_account_with_usd_locked(total: f64, locked: f64) -> CashAccount {
2149 multi_currency_cash_account_with_usd_locked_and_borrowing(total, locked, false)
2150 }
2151
2152 fn multi_currency_cash_account_with_usd_locked_and_borrowing(
2153 total: f64,
2154 locked: f64,
2155 allow_borrowing: bool,
2156 ) -> CashAccount {
2157 let usd = Currency::USD();
2158 let account_state = AccountState::new(
2159 AccountId::new("SIM-001"),
2160 AccountType::Cash,
2161 vec![AccountBalance::new(
2162 Money::new(total, usd),
2163 Money::new(locked, usd),
2164 Money::new(total - locked, usd),
2165 )],
2166 Vec::new(),
2167 true,
2168 UUID4::new(),
2169 UnixNanos::default(),
2170 UnixNanos::default(),
2171 None,
2172 );
2173 CashAccount::new(account_state, true, allow_borrowing)
2174 }
2175
2176 fn multi_currency_betting_account_with_gbp_locked(total: f64, locked: f64) -> BettingAccount {
2177 let gbp = Currency::GBP();
2178 let account_state = AccountState::new(
2179 AccountId::new("BETFAIR-001"),
2180 AccountType::Betting,
2181 vec![AccountBalance::new(
2182 Money::new(total, gbp),
2183 Money::new(locked, gbp),
2184 Money::new(total - locked, gbp),
2185 )],
2186 Vec::new(),
2187 true,
2188 UUID4::new(),
2189 UnixNanos::default(),
2190 UnixNanos::default(),
2191 None,
2192 );
2193 BettingAccount::new(account_state, true)
2194 }
2195
2196 fn order_submitted_for(order: &OrderAny) -> OrderSubmitted {
2197 OrderSubmittedSpec::builder()
2198 .trader_id(order.trader_id())
2199 .strategy_id(order.strategy_id())
2200 .instrument_id(order.instrument_id())
2201 .client_order_id(order.client_order_id())
2202 .build()
2203 }
2204
2205 fn order_submitted_for_account(order: &OrderAny, account_id: AccountId) -> OrderSubmitted {
2206 OrderSubmittedSpec::builder()
2207 .trader_id(order.trader_id())
2208 .strategy_id(order.strategy_id())
2209 .instrument_id(order.instrument_id())
2210 .client_order_id(order.client_order_id())
2211 .account_id(account_id)
2212 .build()
2213 }
2214
2215 fn order_accepted_for(order: &OrderAny, venue_order_id: VenueOrderId) -> OrderAccepted {
2216 OrderAcceptedSpec::builder()
2217 .trader_id(order.trader_id())
2218 .strategy_id(order.strategy_id())
2219 .instrument_id(order.instrument_id())
2220 .client_order_id(order.client_order_id())
2221 .venue_order_id(venue_order_id)
2222 .build()
2223 }
2224
2225 fn order_accepted_for_account(
2226 order: &OrderAny,
2227 venue_order_id: VenueOrderId,
2228 account_id: AccountId,
2229 ) -> OrderAccepted {
2230 OrderAcceptedSpec::builder()
2231 .trader_id(order.trader_id())
2232 .strategy_id(order.strategy_id())
2233 .instrument_id(order.instrument_id())
2234 .client_order_id(order.client_order_id())
2235 .venue_order_id(venue_order_id)
2236 .account_id(account_id)
2237 .build()
2238 }
2239
2240 #[rstest]
2241 fn test_update_balance_multi_currency_market_debit_keeps_locked_balance() {
2242 let usd = Currency::USD();
2243 let account = multi_currency_cash_account_with_usd_locked(1_000.0, 200.0);
2244 let clock = Rc::new(RefCell::new(TestClock::new()));
2245 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2246 let manager = AccountsManager::new(clock, cache.clone());
2247 let instrument = audusd_sim();
2248 cache
2249 .borrow_mut()
2250 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2251 .unwrap();
2252
2253 let fill = OrderFilledSpec::builder()
2254 .instrument_id(instrument.id())
2255 .order_type(OrderType::Market)
2256 .commission(Money::new(20.0, usd))
2257 .build();
2258 let mut account = AccountAny::Cash(account);
2259 let mut pnls = vec![Money::new(-100.0, usd)];
2260
2261 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2262
2263 match account {
2264 AccountAny::Cash(cash) => {
2265 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
2266 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(200.0, usd)));
2267 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(680.0, usd)));
2268 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2269 }
2270 _ => panic!("Expected CashAccount"),
2271 }
2272 }
2273
2274 #[rstest]
2275 fn test_update_balance_multi_currency_limit_debit_reduces_locked_balance() {
2276 let usd = Currency::USD();
2277 let account = multi_currency_cash_account_with_usd_locked(1_000.0, 200.0);
2278 let clock = Rc::new(RefCell::new(TestClock::new()));
2279 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2280 let manager = AccountsManager::new(clock, cache.clone());
2281 let instrument = audusd_sim();
2282 cache
2283 .borrow_mut()
2284 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2285 .unwrap();
2286
2287 let fill = OrderFilledSpec::builder()
2288 .instrument_id(instrument.id())
2289 .order_type(OrderType::Limit)
2290 .commission(Money::new(20.0, usd))
2291 .build();
2292 let mut account = AccountAny::Cash(account);
2293 let mut pnls = vec![Money::new(-100.0, usd)];
2294
2295 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2296
2297 match account {
2298 AccountAny::Cash(cash) => {
2299 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
2300 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(80.0, usd)));
2301 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(800.0, usd)));
2302 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2303 }
2304 _ => panic!("Expected CashAccount"),
2305 }
2306 }
2307
2308 #[rstest]
2309 fn test_update_balance_multi_currency_limit_debit_spills_from_locked_to_free() {
2310 let usd = Currency::USD();
2311 let account = multi_currency_cash_account_with_usd_locked(1_000.0, 50.0);
2312 let clock = Rc::new(RefCell::new(TestClock::new()));
2313 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2314 let manager = AccountsManager::new(clock, cache.clone());
2315 let instrument = audusd_sim();
2316 cache
2317 .borrow_mut()
2318 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2319 .unwrap();
2320
2321 let fill = OrderFilledSpec::builder()
2322 .instrument_id(instrument.id())
2323 .order_type(OrderType::Limit)
2324 .commission(Money::new(20.0, usd))
2325 .build();
2326 let mut account = AccountAny::Cash(account);
2327 let mut pnls = vec![Money::new(-100.0, usd)];
2328
2329 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2330
2331 match account {
2332 AccountAny::Cash(cash) => {
2333 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
2334 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
2335 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(880.0, usd)));
2336 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2337 }
2338 _ => panic!("Expected CashAccount"),
2339 }
2340 }
2341
2342 #[rstest]
2343 fn test_update_balance_multi_currency_limit_debit_floors_locked_on_negative_total() {
2344 let usd = Currency::USD();
2345 let account = multi_currency_cash_account_with_usd_locked_and_borrowing(100.0, 50.0, true);
2346 let clock = Rc::new(RefCell::new(TestClock::new()));
2347 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2348 let manager = AccountsManager::new(clock, cache.clone());
2349 let instrument = audusd_sim();
2350 cache
2351 .borrow_mut()
2352 .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2353 .unwrap();
2354
2355 let fill = OrderFilledSpec::builder()
2356 .instrument_id(instrument.id())
2357 .order_type(OrderType::Limit)
2358 .commission(Money::new(20.0, usd))
2359 .build();
2360 let mut account = AccountAny::Cash(account);
2361 let mut pnls = vec![Money::new(-200.0, usd)];
2362
2363 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2364
2365 match account {
2366 AccountAny::Cash(cash) => {
2367 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(-120.0, usd)));
2368 assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
2369 assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(-120.0, usd)));
2370 assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2371 }
2372 _ => panic!("Expected CashAccount"),
2373 }
2374 }
2375
2376 #[rstest]
2377 fn test_update_balance_multi_currency_betting_limit_debit_keeps_locked_balance() {
2378 let gbp = Currency::GBP();
2379 let account = multi_currency_betting_account_with_gbp_locked(1_000.0, 200.0);
2380 let clock = Rc::new(RefCell::new(TestClock::new()));
2381 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2382 let manager = AccountsManager::new(clock, cache.clone());
2383 let instrument = betting();
2384 cache
2385 .borrow_mut()
2386 .add_instrument(InstrumentAny::Betting(instrument.clone()))
2387 .unwrap();
2388
2389 let fill = OrderFilledSpec::builder()
2390 .instrument_id(instrument.id())
2391 .order_type(OrderType::Limit)
2392 .commission(Money::new(20.0, gbp))
2393 .build();
2394 let mut account = AccountAny::Betting(account);
2395 let mut pnls = vec![Money::new(-100.0, gbp)];
2396
2397 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2398
2399 match account {
2400 AccountAny::Betting(betting_account) => {
2401 assert_eq!(
2402 betting_account.balance_total(Some(gbp)),
2403 Some(Money::new(880.0, gbp))
2404 );
2405 assert_eq!(
2406 betting_account.balance_locked(Some(gbp)),
2407 Some(Money::new(200.0, gbp))
2408 );
2409 assert_eq!(
2410 betting_account.balance_free(Some(gbp)),
2411 Some(Money::new(680.0, gbp))
2412 );
2413 assert_eq!(
2414 betting_account.commission(&gbp),
2415 Some(Money::new(20.0, gbp))
2416 );
2417 }
2418 _ => panic!("Expected BettingAccount"),
2419 }
2420 }
2421
2422 #[rstest]
2423 fn test_update_balance_multi_currency_persists_negative_balance_with_allow_borrowing() {
2424 let aud = Currency::AUD();
2425 let usd = Currency::USD();
2426 let account = multi_currency_cash_account(true);
2427 let clock = Rc::new(RefCell::new(TestClock::new()));
2428 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2429 cache
2430 .borrow_mut()
2431 .add_account(AccountAny::Cash(account.clone()))
2432 .unwrap();
2433 let manager = AccountsManager::new(clock, cache.clone());
2434 let instrument = audusd_sim();
2435 let fill = buy_audusd_fill("10000", "0.80000", 20.0);
2436 let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2437 cache
2438 .borrow_mut()
2439 .add_position(&position, OmsType::Netting)
2440 .unwrap();
2441
2442 let (updated, _state) = manager.update_balances(
2443 AccountAny::Cash(account),
2444 &InstrumentAny::CurrencyPair(instrument),
2445 fill,
2446 );
2447
2448 match updated {
2449 AccountAny::Cash(cash) => {
2450 assert_eq!(
2451 cash.balance_total(Some(aud)),
2452 Some(Money::new(20_000.0, aud))
2453 );
2454 assert_eq!(
2455 cash.balance_total(Some(usd)),
2456 Some(Money::new(-7_920.0, usd))
2457 );
2458 }
2459 _ => panic!("Expected CashAccount"),
2460 }
2461 }
2462
2463 #[rstest]
2464 fn test_update_balance_multi_currency_rejects_negative_balance_without_allow_borrowing() {
2465 let aud = Currency::AUD();
2466 let usd = Currency::USD();
2467 let account = multi_currency_cash_account(false);
2468 let clock = Rc::new(RefCell::new(TestClock::new()));
2469 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2470 cache
2471 .borrow_mut()
2472 .add_account(AccountAny::Cash(account.clone()))
2473 .unwrap();
2474 let manager = AccountsManager::new(clock, cache.clone());
2475 let instrument = audusd_sim();
2476 let fill = buy_audusd_fill("10000", "0.80000", 20.0);
2477 let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2478 cache
2479 .borrow_mut()
2480 .add_position(&position, OmsType::Netting)
2481 .unwrap();
2482
2483 let (updated, _state) = manager.update_balances(
2484 AccountAny::Cash(account),
2485 &InstrumentAny::CurrencyPair(instrument),
2486 fill,
2487 );
2488
2489 match updated {
2491 AccountAny::Cash(cash) => {
2492 assert_eq!(
2493 cash.balance_total(Some(aud)),
2494 Some(Money::new(10_000.0, aud))
2495 );
2496 assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(100.0, usd)));
2497 }
2498 _ => panic!("Expected CashAccount"),
2499 }
2500 }
2501
2502 #[rstest]
2503 fn test_update_balance_multi_currency_rejects_new_currency_negative_pnl() {
2504 let aud = Currency::AUD();
2505 let account_state = AccountState::new(
2506 AccountId::new("SIM-001"),
2507 AccountType::Cash,
2508 vec![AccountBalance::new(
2509 Money::new(10_000.0, aud),
2510 Money::zero(aud),
2511 Money::new(10_000.0, aud),
2512 )],
2513 Vec::new(),
2514 true,
2515 UUID4::new(),
2516 UnixNanos::default(),
2517 UnixNanos::default(),
2518 None,
2519 );
2520 let account = CashAccount::new(account_state, true, true);
2521 let clock = Rc::new(RefCell::new(TestClock::new()));
2522 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2523 cache
2524 .borrow_mut()
2525 .add_account(AccountAny::Cash(account.clone()))
2526 .unwrap();
2527 let manager = AccountsManager::new(clock, cache.clone());
2528 let instrument = audusd_sim();
2529 let fill = buy_audusd_fill("10000", "0.80000", 0.0);
2532 let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2533 cache
2534 .borrow_mut()
2535 .add_position(&position, OmsType::Netting)
2536 .unwrap();
2537
2538 let (updated, _state) = manager.update_balances(
2539 AccountAny::Cash(account),
2540 &InstrumentAny::CurrencyPair(instrument),
2541 fill,
2542 );
2543
2544 match updated {
2546 AccountAny::Cash(cash) => {
2547 assert_eq!(
2548 cash.balance_total(Some(aud)),
2549 Some(Money::new(10_000.0, aud))
2550 );
2551 assert_eq!(cash.balance_total(Some(Currency::USD())), None);
2552 }
2553 _ => panic!("Expected CashAccount"),
2554 }
2555 }
2556
2557 fn large_locked_usdt_margin_account() -> (AccountAny, Money, Money) {
2560 let usdt = Currency::USDT();
2561 let total =
2562 Money::from_decimal(Decimal::from_str_exact("99999997.91829666").unwrap(), usdt)
2563 .unwrap();
2564 let locked =
2565 Money::from_decimal(Decimal::from_str_exact("32.85965").unwrap(), usdt).unwrap();
2566 let free = Money::from_raw(total.raw - locked.raw, usdt);
2567 let account_state = AccountState::new(
2568 AccountId::new("SIM-001"),
2569 AccountType::Margin,
2570 vec![AccountBalance::new(total, locked, free)],
2571 Vec::new(),
2572 true,
2573 UUID4::new(),
2574 UnixNanos::default(),
2575 UnixNanos::default(),
2576 None, );
2578 (
2579 AccountAny::Margin(MarginAccount::new(account_state, false)),
2580 total,
2581 locked,
2582 )
2583 }
2584
2585 #[rstest]
2586 fn test_update_balance_multi_currency_preserves_invariant_with_large_locked() {
2587 let usdt = Currency::USDT();
2591 let (mut account, total, locked) = large_locked_usdt_margin_account();
2592 let clock = Rc::new(RefCell::new(TestClock::new()));
2593 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2594 let manager = AccountsManager::new(clock, cache);
2595
2596 let fill = OrderFilledSpec::builder().build();
2599 let pnl =
2600 Money::from_decimal(Decimal::from_str_exact("0.00000064").unwrap(), usdt).unwrap();
2601 let mut pnls = [pnl];
2602 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2603
2604 let balances = account.balances();
2605 let balance = balances.get(&usdt).expect("USDT balance");
2606 assert_eq!(balance.locked, locked, "locked margin preserved");
2607 assert_eq!(balance.total, total + pnl, "total moved by realized PnL");
2608 assert_eq!(
2609 balance.total.raw,
2610 balance.locked.raw + balance.free.raw,
2611 "invariant total == locked + free must hold"
2612 );
2613 }
2614
2615 #[rstest]
2616 fn test_update_balance_multi_currency_commission_preserves_invariant_with_large_locked() {
2617 let usdt = Currency::USDT();
2619 let (mut account, total, locked) = large_locked_usdt_margin_account();
2620 let clock = Rc::new(RefCell::new(TestClock::new()));
2621 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2622 let manager = AccountsManager::new(clock, cache);
2623
2624 let commission =
2627 Money::from_decimal(Decimal::from_str_exact("0.00000001").unwrap(), usdt).unwrap();
2628 let fill = OrderFilledSpec::builder().commission(commission).build();
2629
2630 let mut pnls: [Money; 0] = [];
2632 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2633
2634 let balances = account.balances();
2635 let balance = balances.get(&usdt).expect("USDT balance");
2636 assert_eq!(balance.locked, locked, "locked margin preserved");
2637 assert_eq!(
2638 balance.total,
2639 total - commission,
2640 "total reduced by commission"
2641 );
2642 assert_eq!(
2643 balance.total.raw,
2644 balance.locked.raw + balance.free.raw,
2645 "invariant total == locked + free must hold"
2646 );
2647 }
2648
2649 #[rstest]
2650 fn test_update_balance_multi_currency_negative_commission_creates_rebate_balance() {
2651 let usd = Currency::USD();
2652 let account_state = AccountState::new(
2653 AccountId::new("SIM-001"),
2654 AccountType::Cash,
2655 Vec::new(),
2656 Vec::new(),
2657 true,
2658 UUID4::new(),
2659 UnixNanos::default(),
2660 UnixNanos::default(),
2661 None,
2662 );
2663 let mut account = AccountAny::Cash(CashAccount::new(account_state, true, false));
2664 let clock = Rc::new(RefCell::new(TestClock::new()));
2665 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2666 let manager = AccountsManager::new(clock, cache);
2667
2668 let fill = OrderFilledSpec::builder()
2669 .commission(Money::new(-1.0, usd))
2670 .build();
2671 let mut pnls: [Money; 0] = [];
2672 manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2673
2674 let AccountAny::Cash(cash) = account else {
2675 panic!("Expected CashAccount");
2676 };
2677 let balance = cash.balance(Some(usd)).expect("USD rebate balance");
2678 assert_eq!(balance.total, Money::new(1.0, usd));
2679 assert_eq!(balance.locked, Money::zero(usd));
2680 assert_eq!(balance.free, Money::new(1.0, usd));
2681 assert_eq!(cash.commission(&usd), Some(Money::new(-1.0, usd)));
2682 }
2683
2684 fn build_margin_account_usd(balance: f64) -> MarginAccount {
2685 let usd = Currency::USD();
2686 let account_state = AccountState::new(
2687 AccountId::new("SIM-001"),
2688 AccountType::Margin,
2689 vec![AccountBalance::new(
2690 Money::new(balance, usd),
2691 Money::zero(usd),
2692 Money::new(balance, usd),
2693 )],
2694 Vec::new(),
2695 true,
2696 UUID4::new(),
2697 UnixNanos::default(),
2698 UnixNanos::default(),
2699 None,
2700 );
2701 MarginAccount::new(account_state, false)
2702 }
2703
2704 fn build_margin_account_usdt(balance: f64) -> MarginAccount {
2705 let usdt = Currency::USDT();
2706 let account_state = AccountState::new(
2707 AccountId::new("SIM-001"),
2708 AccountType::Margin,
2709 vec![AccountBalance::new(
2710 Money::new(balance, usdt),
2711 Money::zero(usdt),
2712 Money::new(balance, usdt),
2713 )],
2714 Vec::new(),
2715 true,
2716 UUID4::new(),
2717 UnixNanos::default(),
2718 UnixNanos::default(),
2719 None,
2720 );
2721 MarginAccount::new(account_state, false)
2722 }
2723
2724 fn build_hedging_position(
2725 instrument: &InstrumentAny,
2726 side: OrderSide,
2727 qty: &str,
2728 price: &str,
2729 id: &str,
2730 ) -> Position {
2731 build_hedging_position_at(instrument, side, qty, price, id, UnixNanos::default())
2732 }
2733
2734 fn build_hedging_position_at(
2735 instrument: &InstrumentAny,
2736 side: OrderSide,
2737 qty: &str,
2738 price: &str,
2739 id: &str,
2740 ts_event: UnixNanos,
2741 ) -> Position {
2742 let fill = OrderFilledSpec::builder()
2743 .instrument_id(instrument.id())
2744 .client_order_id(ClientOrderId::new(id))
2745 .venue_order_id(VenueOrderId::new(id))
2746 .trade_id(TradeId::new(id))
2747 .order_side(side)
2748 .last_qty(Quantity::from(qty))
2749 .last_px(Price::from(price))
2750 .currency(instrument.settlement_currency())
2751 .ts_event(ts_event)
2752 .ts_init(ts_event)
2753 .position_id(PositionId::new(id))
2754 .build();
2755 Position::new(instrument, fill)
2756 }
2757
2758 #[rstest]
2759 fn test_update_positions_in_place_nets_hedging_subpositions() {
2760 let usd = Currency::USD();
2761 let mut account = build_margin_account_usd(1_000_000.0);
2762 let instrument = audusd_sim();
2763 account.set_leverage(instrument.id(), Decimal::ONE);
2764 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2765
2766 let clock = Rc::new(RefCell::new(TestClock::new()));
2767 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2768 let manager = AccountsManager::new(clock, cache);
2769
2770 let mut positions: Vec<Position> = Vec::new();
2772 for i in 0..5 {
2773 positions.push(build_hedging_position(
2774 &instrument_any,
2775 OrderSide::Buy,
2776 "50",
2777 "1.00000",
2778 &format!("L{i}"),
2779 ));
2780 }
2781
2782 for i in 0..2 {
2783 positions.push(build_hedging_position(
2784 &instrument_any,
2785 OrderSide::Sell,
2786 "50",
2787 "1.00000",
2788 &format!("S{i}"),
2789 ));
2790 }
2791
2792 let position_refs: Vec<&Position> = positions.iter().collect();
2793 let result = manager.update_positions_in_place(
2794 &mut account,
2795 &instrument_any,
2796 position_refs,
2797 UnixNanos::default(),
2798 );
2799 assert!(result.is_some(), "update_positions_in_place returned None");
2800
2801 let margin_maint = account.maintenance_margin(instrument.id());
2802 assert_eq!(
2803 margin_maint,
2804 Money::new(4.50, usd),
2805 "Maintenance margin must reflect net exposure (150 @ 1.00), not per-position sum",
2806 );
2807 }
2808
2809 #[rstest]
2810 fn test_update_positions_in_place_net_zero_hedge_has_no_margin() {
2811 let usd = Currency::USD();
2812 let mut account = build_margin_account_usd(1_000_000.0);
2813 let instrument = audusd_sim();
2814 account.set_leverage(instrument.id(), Decimal::ONE);
2815 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2816
2817 let clock = Rc::new(RefCell::new(TestClock::new()));
2818 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2819 let manager = AccountsManager::new(clock, cache);
2820
2821 let long = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
2823 let short = build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S");
2824
2825 let state = manager
2826 .update_positions_in_place(
2827 &mut account,
2828 &instrument_any,
2829 vec![&long, &short],
2830 UnixNanos::default(),
2831 )
2832 .expect("update_positions_in_place returned None");
2833
2834 assert!(account.margin(&instrument.id()).is_none());
2835 assert!(state.margins.is_empty());
2836 assert_eq!(account.balance_locked(Some(usd)), Some(Money::zero(usd)));
2837 }
2838
2839 #[rstest]
2840 fn test_update_positions_in_place_uses_net_side_avg_open_price() {
2841 let usd = Currency::USD();
2842 let mut account = build_margin_account_usd(1_000_000.0);
2843 let instrument = audusd_sim();
2844 account.set_leverage(instrument.id(), Decimal::ONE);
2845 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2846
2847 let clock = Rc::new(RefCell::new(TestClock::new()));
2848 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2849 let manager = AccountsManager::new(clock, cache);
2850
2851 let long = build_hedging_position(&instrument_any, OrderSide::Buy, "300", "0.80000", "L1");
2854 let short =
2855 build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S1");
2856
2857 let result = manager.update_positions_in_place(
2858 &mut account,
2859 &instrument_any,
2860 vec![&long, &short],
2861 UnixNanos::default(),
2862 );
2863 assert!(result.is_some(), "update_positions_in_place returned None");
2864
2865 let margin_maint = account.maintenance_margin(instrument.id());
2866 assert_eq!(margin_maint, Money::new(4.80, usd));
2867 }
2868
2869 #[rstest]
2870 fn test_update_positions_in_place_floating_dust_clears_margin() {
2871 let usdt = Currency::USDT();
2874 let mut account = build_margin_account_usdt(1_000_000.0);
2875 let instrument = currency_pair_btcusdt();
2876 account.set_leverage(instrument.id(), Decimal::ONE);
2877 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2878
2879 let clock = Rc::new(RefCell::new(TestClock::new()));
2880 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2881 let manager = AccountsManager::new(clock, cache);
2882
2883 let long =
2885 build_hedging_position(&instrument_any, OrderSide::Buy, "0.300000", "50000.00", "L");
2886 let short_a = build_hedging_position(
2887 &instrument_any,
2888 OrderSide::Sell,
2889 "0.200000",
2890 "50000.00",
2891 "S1",
2892 );
2893 let short_b = build_hedging_position(
2894 &instrument_any,
2895 OrderSide::Sell,
2896 "0.100000",
2897 "50000.00",
2898 "S2",
2899 );
2900
2901 let state = manager
2902 .update_positions_in_place(
2903 &mut account,
2904 &instrument_any,
2905 vec![&long, &short_a, &short_b],
2906 UnixNanos::default(),
2907 )
2908 .expect("update_positions_in_place returned None");
2909
2910 assert!(account.margin(&instrument.id()).is_none());
2911 assert!(state.margins.is_empty());
2912 assert_eq!(account.balance_locked(Some(usdt)), Some(Money::zero(usdt)));
2913 }
2914
2915 #[rstest]
2916 fn test_update_positions_in_place_net_flat_clears_prior_base_currency_margin() {
2917 let usdt = Currency::USDT();
2920 let account_state = AccountState::new(
2921 AccountId::new("SIM-001"),
2922 AccountType::Margin,
2923 vec![AccountBalance::new(
2924 Money::new(1_000_000.0, usdt),
2925 Money::zero(usdt),
2926 Money::new(1_000_000.0, usdt),
2927 )],
2928 Vec::new(),
2929 true,
2930 UUID4::new(),
2931 UnixNanos::default(),
2932 UnixNanos::default(),
2933 Some(usdt),
2934 );
2935 let mut account = MarginAccount::new(account_state, false);
2936 let instrument = currency_pair_btcusdt();
2937 account.set_leverage(instrument.id(), Decimal::ONE);
2938 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2939
2940 let clock = Rc::new(RefCell::new(TestClock::new()));
2941 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2942 let manager = AccountsManager::new(clock, cache);
2943
2944 let long =
2946 build_hedging_position(&instrument_any, OrderSide::Buy, "0.500000", "50000.00", "L");
2947 let first = manager.update_positions_in_place(
2948 &mut account,
2949 &instrument_any,
2950 vec![&long],
2951 UnixNanos::default(),
2952 );
2953 assert!(first.is_some());
2954 let prior_margin = account.maintenance_margin(instrument.id());
2955 assert!(prior_margin.as_decimal() > Decimal::ZERO);
2956 assert_eq!(prior_margin.currency, usdt);
2957 let prior_locked = account.balance_locked(Some(usdt)).unwrap();
2958 assert!(prior_locked.as_decimal() > Decimal::ZERO);
2959
2960 let short = build_hedging_position(
2962 &instrument_any,
2963 OrderSide::Sell,
2964 "0.500000",
2965 "50000.00",
2966 "S",
2967 );
2968 let second = manager.update_positions_in_place(
2969 &mut account,
2970 &instrument_any,
2971 vec![&long, &short],
2972 UnixNanos::default(),
2973 );
2974 let second_state = second.expect("net-flat maintenance update should generate state");
2975
2976 assert!(account.margin(&instrument.id()).is_none());
2978 assert!(second_state.margins.is_empty());
2979 assert_eq!(
2980 account.balance_locked(Some(usdt)).unwrap(),
2981 Money::zero(usdt)
2982 );
2983 }
2984
2985 #[rstest]
2986 fn test_update_positions_in_place_net_flat_preserves_prior_initial_margin() {
2987 let usd = Currency::USD();
2988 let mut account = build_margin_account_usd(1_000_000.0);
2989 let instrument = audusd_sim();
2990 account.set_leverage(instrument.id(), Decimal::ONE);
2991 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2992 let initial = Money::new(25.0, usd);
2993 account.update_margin(MarginBalance::new(
2994 initial,
2995 Money::new(5.0, usd),
2996 Some(instrument.id()),
2997 ));
2998
2999 let clock = Rc::new(RefCell::new(TestClock::new()));
3000 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3001 let manager = AccountsManager::new(clock, cache);
3002
3003 let long = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
3004 let short = build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S");
3005 let state = manager
3006 .update_positions_in_place(
3007 &mut account,
3008 &instrument_any,
3009 vec![&long, &short],
3010 UnixNanos::default(),
3011 )
3012 .expect("net-flat maintenance update should generate state");
3013
3014 let margin = account
3015 .margin(&instrument.id())
3016 .expect("initial margin should remain");
3017 assert_eq!(margin.initial, initial);
3018 assert_eq!(margin.maintenance, Money::zero(usd));
3019 assert_eq!(state.margins, vec![margin]);
3020 }
3021
3022 #[rstest]
3023 fn test_update_positions_in_place_flip_uses_flipping_fill_price() {
3024 let usd = Currency::USD();
3027 let mut account = build_margin_account_usd(1_000_000.0);
3028 let instrument = audusd_sim();
3029 account.set_leverage(instrument.id(), Decimal::ONE);
3030 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3031
3032 let clock = Rc::new(RefCell::new(TestClock::new()));
3033 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3034 let manager = AccountsManager::new(clock, cache);
3035
3036 let long = build_hedging_position_at(
3039 &instrument_any,
3040 OrderSide::Buy,
3041 "100",
3042 "1.00000",
3043 "L",
3044 UnixNanos::from(1),
3045 );
3046 let short_partial = build_hedging_position_at(
3047 &instrument_any,
3048 OrderSide::Sell,
3049 "50",
3050 "2.00000",
3051 "S1",
3052 UnixNanos::from(2),
3053 );
3054 let short_flip = build_hedging_position_at(
3055 &instrument_any,
3056 OrderSide::Sell,
3057 "100",
3058 "3.00000",
3059 "S2",
3060 UnixNanos::from(3),
3061 );
3062
3063 let result = manager.update_positions_in_place(
3064 &mut account,
3065 &instrument_any,
3066 vec![&long, &short_partial, &short_flip],
3067 UnixNanos::default(),
3068 );
3069 assert!(result.is_some(), "update_positions_in_place returned None");
3070
3071 let margin_maint = account.maintenance_margin(instrument.id());
3072 assert_eq!(margin_maint, Money::new(4.50, usd));
3073 }
3074
3075 #[rstest]
3076 fn test_update_positions_in_place_same_ts_legs_ordering_is_deterministic() {
3077 let usd = Currency::USD();
3080 let instrument = audusd_sim();
3081 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3082
3083 let same_ts = UnixNanos::from(42);
3086 let l_a = build_hedging_position_at(
3087 &instrument_any,
3088 OrderSide::Buy,
3089 "100",
3090 "1.00000",
3091 "A",
3092 same_ts,
3093 );
3094 let s_b = build_hedging_position_at(
3095 &instrument_any,
3096 OrderSide::Sell,
3097 "50",
3098 "2.00000",
3099 "B",
3100 same_ts,
3101 );
3102 let s_c = build_hedging_position_at(
3103 &instrument_any,
3104 OrderSide::Sell,
3105 "100",
3106 "3.00000",
3107 "C",
3108 same_ts,
3109 );
3110
3111 let permutations: Vec<Vec<&Position>> = vec![
3112 vec![&l_a, &s_b, &s_c],
3113 vec![&s_c, &s_b, &l_a],
3114 vec![&s_b, &l_a, &s_c],
3115 vec![&s_c, &l_a, &s_b],
3116 ];
3117
3118 let mut results: Vec<Money> = Vec::new();
3119
3120 for perm in permutations {
3121 let mut account = build_margin_account_usd(1_000_000.0);
3122 account.set_leverage(instrument.id(), Decimal::ONE);
3123
3124 let clock = Rc::new(RefCell::new(TestClock::new()));
3125 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3126 let manager = AccountsManager::new(clock, cache);
3127
3128 let result = manager.update_positions_in_place(
3129 &mut account,
3130 &instrument_any,
3131 perm,
3132 UnixNanos::default(),
3133 );
3134 assert!(result.is_some());
3135 results.push(account.maintenance_margin(instrument.id()));
3136 }
3137
3138 let first = results[0];
3139 for r in &results[1..] {
3140 assert_eq!(
3141 *r, first,
3142 "maintenance margin must be deterministic across permutations"
3143 );
3144 }
3145 assert_eq!(first, Money::new(4.50, usd));
3147 }
3148
3149 #[rstest]
3150 fn test_update_positions_in_place_xrate_unavailable_returns_none() {
3151 let eur = Currency::EUR();
3154 let account_state = AccountState::new(
3155 AccountId::new("SIM-001"),
3156 AccountType::Margin,
3157 vec![AccountBalance::new(
3158 Money::new(1_000_000.0, eur),
3159 Money::zero(eur),
3160 Money::new(1_000_000.0, eur),
3161 )],
3162 Vec::new(),
3163 true,
3164 UUID4::new(),
3165 UnixNanos::default(),
3166 UnixNanos::default(),
3167 Some(eur),
3168 );
3169 let mut account = MarginAccount::new(account_state, false);
3170 let instrument = audusd_sim();
3171 account.set_leverage(instrument.id(), Decimal::ONE);
3172 let instrument_any = InstrumentAny::CurrencyPair(instrument);
3173
3174 let clock = Rc::new(RefCell::new(TestClock::new()));
3175 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3176 let manager = AccountsManager::new(clock, cache);
3177
3178 let pos = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
3179 let result = manager.update_positions_in_place(
3180 &mut account,
3181 &instrument_any,
3182 vec![&pos],
3183 UnixNanos::default(),
3184 );
3185 assert!(result.is_none(), "xrate-unavailable must return None");
3186 }
3187
3188 #[rstest]
3189 fn test_update_positions_in_place_base_xrate_uses_ask_for_short_net_position() {
3190 let eur = Currency::EUR();
3191 let account_state = AccountState::new(
3192 AccountId::new("SIM-001"),
3193 AccountType::Margin,
3194 vec![AccountBalance::new(
3195 Money::new(1_000.0, eur),
3196 Money::zero(eur),
3197 Money::new(1_000.0, eur),
3198 )],
3199 Vec::new(),
3200 true,
3201 UUID4::new(),
3202 UnixNanos::default(),
3203 UnixNanos::default(),
3204 Some(eur),
3205 );
3206 let mut account = MarginAccount::new(account_state, false);
3207 let instrument = audusd_sim();
3208 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3209
3210 let clock = Rc::new(RefCell::new(TestClock::new()));
3211 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3212 add_usdeur_quote(&cache, "0.90000", "1.10000");
3213 let manager = AccountsManager::new(clock, cache);
3214
3215 let position =
3216 build_hedging_position(&instrument_any, OrderSide::Sell, "100", "2.00000", "S");
3217 let result = manager.update_positions_in_place(
3218 &mut account,
3219 &instrument_any,
3220 vec![&position],
3221 UnixNanos::default(),
3222 );
3223
3224 assert!(result.is_some());
3225 assert_eq!(
3226 account.maintenance_margin(instrument.id()),
3227 Money::new(6.60, eur)
3228 );
3229 }
3230
3231 #[rstest]
3232 fn test_update_positions_in_place_closed_positions_filtered() {
3233 let usd = Currency::USD();
3235 let mut account = build_margin_account_usd(1_000_000.0);
3236 let instrument = audusd_sim();
3237 account.set_leverage(instrument.id(), Decimal::ONE);
3238 let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3239
3240 let clock = Rc::new(RefCell::new(TestClock::new()));
3241 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3242 let manager = AccountsManager::new(clock, cache);
3243
3244 let open_long = build_hedging_position_at(
3246 &instrument_any,
3247 OrderSide::Buy,
3248 "100",
3249 "1.00000",
3250 "C",
3251 UnixNanos::from(1),
3252 );
3253 let close_fill = OrderFilledSpec::builder()
3254 .instrument_id(instrument.id())
3255 .client_order_id(ClientOrderId::new("Cclose"))
3256 .venue_order_id(VenueOrderId::new("Cclose"))
3257 .trade_id(TradeId::new("Cclose"))
3258 .order_side(OrderSide::Sell)
3259 .last_qty(Quantity::from("100"))
3260 .last_px(Price::from("1.00000"))
3261 .currency(instrument.settlement_currency())
3262 .ts_event(UnixNanos::from(2))
3263 .ts_init(UnixNanos::from(2))
3264 .position_id(PositionId::new("C"))
3265 .build();
3266 let mut closed = open_long;
3267 closed.apply(&close_fill);
3268 assert!(closed.is_closed());
3269
3270 let live = build_hedging_position_at(
3272 &instrument_any,
3273 OrderSide::Buy,
3274 "50",
3275 "1.00000",
3276 "L",
3277 UnixNanos::from(3),
3278 );
3279
3280 let result = manager.update_positions_in_place(
3281 &mut account,
3282 &instrument_any,
3283 vec![&closed, &live],
3284 UnixNanos::default(),
3285 );
3286 assert!(result.is_some());
3287
3288 assert_eq!(
3290 account.maintenance_margin(instrument.id()),
3291 Money::new(1.50, usd)
3292 );
3293 }
3294
3295 fn add_usdeur_quote(cache: &Rc<RefCell<Cache>>, bid: &str, ask: &str) {
3296 let instrument = default_fx_ccy(Symbol::from("USD/EUR"), Some(Venue::from("SIM")));
3297 let quote = QuoteTick::new(
3298 instrument.id(),
3299 Price::from(bid),
3300 Price::from(ask),
3301 Quantity::from("1"),
3302 Quantity::from("1"),
3303 UnixNanos::default(),
3304 UnixNanos::default(),
3305 );
3306 let mut cache = cache.borrow_mut();
3307 cache
3308 .add_instrument(InstrumentAny::CurrencyPair(instrument))
3309 .unwrap();
3310 cache.add_quote(quote).unwrap();
3311 }
3312}