Skip to main content

nautilus_portfolio/
manager.rs

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