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