Skip to main content

nautilus_portfolio/
manager.rs

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