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