nautilus_portfolio/
manager.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
21use nautilus_common::{cache::Cache, clock::Clock};
22use nautilus_core::{UUID4, UnixNanos};
23use nautilus_model::{
24    accounts::{Account, AccountAny, CashAccount, MarginAccount},
25    enums::{AccountType, OrderSide, OrderSideSpecified, PriceType},
26    events::{AccountState, OrderFilled},
27    instruments::{Instrument, InstrumentAny},
28    orders::{Order, OrderAny},
29    position::Position,
30    types::{AccountBalance, Currency, Money},
31};
32use rust_decimal::{Decimal, prelude::ToPrimitive};
33/// Manages account balance updates and calculations for portfolio management.
34///
35/// The accounts manager handles balance updates for different account types,
36/// including cash and margin accounts, based on order fills and position changes.
37pub struct AccountsManager {
38    clock: Rc<RefCell<dyn Clock>>,
39    cache: Rc<RefCell<Cache>>,
40}
41
42impl Debug for AccountsManager {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct(stringify!(AccountsManager)).finish()
45    }
46}
47
48impl AccountsManager {
49    /// Creates a new [`AccountsManager`] instance.
50    pub fn new(clock: Rc<RefCell<dyn Clock>>, cache: Rc<RefCell<Cache>>) -> Self {
51        Self { clock, cache }
52    }
53
54    /// Updates the given account state based on a filled order.
55    ///
56    /// # Panics
57    ///
58    /// Panics if the position list for the filled instrument is empty.
59    #[must_use]
60    pub fn update_balances(
61        &self,
62        account: AccountAny,
63        instrument: InstrumentAny,
64        fill: OrderFilled,
65    ) -> AccountState {
66        let cache = self.cache.borrow();
67        let position_id = if let Some(position_id) = fill.position_id {
68            position_id
69        } else {
70            let positions_open = cache.positions_open(None, Some(&fill.instrument_id), None, None);
71            positions_open
72                .first()
73                .unwrap_or_else(|| panic!("List of Positions is empty"))
74                .id
75        };
76
77        let position = cache.position(&position_id);
78
79        let pnls = account.calculate_pnls(instrument, fill, position.cloned());
80
81        // Calculate final PnL including commissions
82        match account.base_currency() {
83            Some(base_currency) => {
84                let pnl = pnls.map_or_else(
85                    |_| Money::new(0.0, base_currency),
86                    |pnl_list| {
87                        pnl_list
88                            .first()
89                            .copied()
90                            .unwrap_or_else(|| Money::new(0.0, base_currency))
91                    },
92                );
93
94                self.update_balance_single_currency(account.clone(), &fill, pnl);
95            }
96            None => {
97                if let Ok(mut pnl_list) = pnls {
98                    self.update_balance_multi_currency(account.clone(), fill, &mut pnl_list);
99                }
100            }
101        }
102
103        // Generate and return account state
104        self.generate_account_state(account, fill.ts_event)
105    }
106
107    /// Updates account balances based on open orders.
108    ///
109    /// For cash accounts, updates the balance locked by open orders.
110    /// For margin accounts, updates the initial margin requirements.
111    #[must_use]
112    pub fn update_orders(
113        &self,
114        account: &AccountAny,
115        instrument: InstrumentAny,
116        orders_open: Vec<&OrderAny>,
117        ts_event: UnixNanos,
118    ) -> Option<(AccountAny, AccountState)> {
119        match account.clone() {
120            AccountAny::Cash(cash_account) => self
121                .update_balance_locked(&cash_account, instrument, orders_open, ts_event)
122                .map(|(updated_cash_account, state)| {
123                    (AccountAny::Cash(updated_cash_account), state)
124                }),
125            AccountAny::Margin(margin_account) => self
126                .update_margin_init(&margin_account, instrument, orders_open, ts_event)
127                .map(|(updated_margin_account, state)| {
128                    (AccountAny::Margin(updated_margin_account), state)
129                }),
130        }
131    }
132
133    /// Updates the account based on current open positions.
134    ///
135    /// # Panics
136    ///
137    /// Panics if any position's `instrument_id` does not match the provided `instrument`.
138    #[must_use]
139    pub fn update_positions(
140        &self,
141        account: &MarginAccount,
142        instrument: InstrumentAny,
143        positions: Vec<&Position>,
144        ts_event: UnixNanos,
145    ) -> Option<(MarginAccount, AccountState)> {
146        let mut total_margin_maint = 0.0;
147        let mut base_xrate: Option<f64> = None;
148        let mut currency = instrument.settlement_currency();
149        let mut account = account.clone();
150
151        for position in positions {
152            assert_eq!(
153                position.instrument_id,
154                instrument.id(),
155                "Position not for instrument {}",
156                instrument.id()
157            );
158
159            if !position.is_open() {
160                continue;
161            }
162
163            let margin_maint = match instrument {
164                InstrumentAny::Betting(i) => account
165                    .calculate_maintenance_margin(
166                        i,
167                        position.quantity,
168                        instrument.make_price(position.avg_px_open),
169                        None,
170                    )
171                    .ok()?,
172                InstrumentAny::BinaryOption(i) => account
173                    .calculate_maintenance_margin(
174                        i,
175                        position.quantity,
176                        instrument.make_price(position.avg_px_open),
177                        None,
178                    )
179                    .ok()?,
180                InstrumentAny::CryptoFuture(i) => account
181                    .calculate_maintenance_margin(
182                        i,
183                        position.quantity,
184                        instrument.make_price(position.avg_px_open),
185                        None,
186                    )
187                    .ok()?,
188                InstrumentAny::CryptoOption(i) => account
189                    .calculate_maintenance_margin(
190                        i,
191                        position.quantity,
192                        instrument.make_price(position.avg_px_open),
193                        None,
194                    )
195                    .ok()?,
196                InstrumentAny::CryptoPerpetual(i) => account
197                    .calculate_maintenance_margin(
198                        i,
199                        position.quantity,
200                        instrument.make_price(position.avg_px_open),
201                        None,
202                    )
203                    .ok()?,
204                InstrumentAny::CurrencyPair(i) => account
205                    .calculate_maintenance_margin(
206                        i,
207                        position.quantity,
208                        instrument.make_price(position.avg_px_open),
209                        None,
210                    )
211                    .ok()?,
212                InstrumentAny::Equity(i) => account
213                    .calculate_maintenance_margin(
214                        i,
215                        position.quantity,
216                        instrument.make_price(position.avg_px_open),
217                        None,
218                    )
219                    .ok()?,
220                InstrumentAny::FuturesContract(i) => account
221                    .calculate_maintenance_margin(
222                        i,
223                        position.quantity,
224                        instrument.make_price(position.avg_px_open),
225                        None,
226                    )
227                    .ok()?,
228                InstrumentAny::FuturesSpread(i) => account
229                    .calculate_maintenance_margin(
230                        i,
231                        position.quantity,
232                        instrument.make_price(position.avg_px_open),
233                        None,
234                    )
235                    .ok()?,
236                InstrumentAny::OptionContract(i) => account
237                    .calculate_maintenance_margin(
238                        i,
239                        position.quantity,
240                        instrument.make_price(position.avg_px_open),
241                        None,
242                    )
243                    .ok()?,
244                InstrumentAny::OptionSpread(i) => account
245                    .calculate_maintenance_margin(
246                        i,
247                        position.quantity,
248                        instrument.make_price(position.avg_px_open),
249                        None,
250                    )
251                    .ok()?,
252            };
253
254            let mut margin_maint = margin_maint.as_f64();
255
256            if let Some(base_currency) = account.base_currency {
257                if base_xrate.is_none() {
258                    currency = base_currency;
259                    base_xrate = self.calculate_xrate_to_base(
260                        AccountAny::Margin(account.clone()),
261                        instrument.clone(),
262                        position.entry.as_specified(),
263                    );
264                }
265
266                if let Some(xrate) = base_xrate {
267                    margin_maint *= xrate;
268                } else {
269                    log::debug!(
270                        "Cannot calculate maintenance (position) margin: insufficient data for {}/{}",
271                        instrument.settlement_currency(),
272                        base_currency
273                    );
274                    return None;
275                }
276            }
277
278            total_margin_maint += margin_maint;
279        }
280
281        let margin_maint = Money::new(total_margin_maint, currency);
282        account.update_maintenance_margin(instrument.id(), margin_maint);
283
284        log::info!("{} margin_maint={margin_maint}", instrument.id());
285
286        // Generate and return account state
287        Some((
288            account.clone(),
289            self.generate_account_state(AccountAny::Margin(account), ts_event),
290        ))
291    }
292
293    fn update_balance_locked(
294        &self,
295        account: &CashAccount,
296        instrument: InstrumentAny,
297        orders_open: Vec<&OrderAny>,
298        ts_event: UnixNanos,
299    ) -> Option<(CashAccount, AccountState)> {
300        let mut account = account.clone();
301        if orders_open.is_empty() {
302            let balance = account.balances.remove(&instrument.quote_currency());
303            if let Some(balance) = balance {
304                account.recalculate_balance(balance.currency);
305            }
306            return Some((
307                account.clone(),
308                self.generate_account_state(AccountAny::Cash(account), ts_event),
309            ));
310        }
311
312        let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
313        let mut base_xrate: Option<f64> = None;
314
315        let mut currency = instrument.settlement_currency();
316
317        for order in orders_open {
318            assert_eq!(
319                order.instrument_id(),
320                instrument.id(),
321                "Order not for instrument {}",
322                instrument.id()
323            );
324            assert!(order.is_open(), "Order is not open");
325
326            if order.price().is_none() && order.trigger_price().is_none() {
327                continue;
328            }
329
330            if order.is_reduce_only() {
331                continue; // Does not contribute to locked balance
332            }
333
334            let price = if order.price().is_some() {
335                order.price()
336            } else {
337                order.trigger_price()
338            };
339
340            let mut locked = account
341                .calculate_balance_locked(
342                    instrument.clone(),
343                    order.order_side(),
344                    order.quantity(),
345                    price?,
346                    None,
347                )
348                .unwrap();
349
350            if let Some(base_curr) = account.base_currency() {
351                if base_xrate.is_none() {
352                    currency = base_curr;
353                    base_xrate = self.calculate_xrate_to_base(
354                        AccountAny::Cash(account.clone()),
355                        instrument.clone(),
356                        order.order_side_specified(),
357                    );
358                }
359
360                if let Some(xrate) = base_xrate {
361                    locked = Money::new(locked.as_f64() * xrate, currency);
362                } else {
363                    log::error!(
364                        "Cannot calculate balance locked: insufficient data for {}/{}",
365                        instrument.settlement_currency(),
366                        base_curr
367                    );
368                    return None;
369                }
370            }
371
372            total_locked
373                .entry(locked.currency)
374                .and_modify(|total| *total += locked)
375                .or_insert(locked);
376        }
377
378        for (_, balance_locked) in total_locked {
379            if let Some(balance) = account.balances.get_mut(&balance_locked.currency) {
380                balance.locked = balance_locked;
381                let currency = balance.currency;
382                account.recalculate_balance(currency);
383            }
384
385            log::info!("{} balance_locked={balance_locked}", instrument.id());
386        }
387
388        Some((
389            account.clone(),
390            self.generate_account_state(AccountAny::Cash(account), ts_event),
391        ))
392    }
393
394    fn update_margin_init(
395        &self,
396        account: &MarginAccount,
397        instrument: InstrumentAny,
398        orders_open: Vec<&OrderAny>,
399        ts_event: UnixNanos,
400    ) -> Option<(MarginAccount, AccountState)> {
401        let mut total_margin_init = 0.0;
402        let mut base_xrate: Option<f64> = None;
403        let mut currency = instrument.settlement_currency();
404        let mut account = account.clone();
405
406        for order in orders_open {
407            assert_eq!(
408                order.instrument_id(),
409                instrument.id(),
410                "Order not for instrument {}",
411                instrument.id()
412            );
413
414            if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) {
415                continue;
416            }
417
418            if order.is_reduce_only() {
419                continue; // Does not contribute to margin
420            }
421
422            let price = if order.price().is_some() {
423                order.price()
424            } else {
425                order.trigger_price()
426            };
427
428            let margin_init = match instrument {
429                InstrumentAny::Betting(i) => account
430                    .calculate_initial_margin(i, order.quantity(), price?, None)
431                    .ok()?,
432                InstrumentAny::BinaryOption(i) => account
433                    .calculate_initial_margin(i, order.quantity(), price?, None)
434                    .ok()?,
435                InstrumentAny::CryptoFuture(i) => account
436                    .calculate_initial_margin(i, order.quantity(), price?, None)
437                    .ok()?,
438                InstrumentAny::CryptoOption(i) => account
439                    .calculate_initial_margin(i, order.quantity(), price?, None)
440                    .ok()?,
441                InstrumentAny::CryptoPerpetual(i) => account
442                    .calculate_initial_margin(i, order.quantity(), price?, None)
443                    .ok()?,
444                InstrumentAny::CurrencyPair(i) => account
445                    .calculate_initial_margin(i, order.quantity(), price?, None)
446                    .ok()?,
447                InstrumentAny::Equity(i) => account
448                    .calculate_initial_margin(i, order.quantity(), price?, None)
449                    .ok()?,
450                InstrumentAny::FuturesContract(i) => account
451                    .calculate_initial_margin(i, order.quantity(), price?, None)
452                    .ok()?,
453                InstrumentAny::FuturesSpread(i) => account
454                    .calculate_initial_margin(i, order.quantity(), price?, None)
455                    .ok()?,
456                InstrumentAny::OptionContract(i) => account
457                    .calculate_initial_margin(i, order.quantity(), price?, None)
458                    .ok()?,
459                InstrumentAny::OptionSpread(i) => account
460                    .calculate_initial_margin(i, order.quantity(), price?, None)
461                    .ok()?,
462            };
463
464            let mut margin_init = margin_init.as_f64();
465
466            if let Some(base_currency) = account.base_currency {
467                if base_xrate.is_none() {
468                    currency = base_currency;
469                    base_xrate = self.calculate_xrate_to_base(
470                        AccountAny::Margin(account.clone()),
471                        instrument.clone(),
472                        order.order_side_specified(),
473                    );
474                }
475
476                if let Some(xrate) = base_xrate {
477                    margin_init *= xrate;
478                } else {
479                    log::debug!(
480                        "Cannot calculate initial margin: insufficient data for {}/{}",
481                        instrument.settlement_currency(),
482                        base_currency
483                    );
484                    continue;
485                }
486            }
487
488            total_margin_init += margin_init;
489        }
490
491        let money = Money::new(total_margin_init, currency);
492        let margin_init = {
493            account.update_initial_margin(instrument.id(), money);
494            money
495        };
496
497        log::info!("{} margin_init={margin_init}", instrument.id());
498
499        Some((
500            account.clone(),
501            self.generate_account_state(AccountAny::Margin(account), ts_event),
502        ))
503    }
504
505    fn update_balance_single_currency(
506        &self,
507        account: AccountAny,
508        fill: &OrderFilled,
509        mut pnl: Money,
510    ) {
511        let base_currency = if let Some(currency) = account.base_currency() {
512            currency
513        } else {
514            log::error!("Account has no base currency set");
515            return;
516        };
517
518        let mut balances = Vec::new();
519        let mut commission = fill.commission;
520
521        if let Some(ref mut comm) = commission
522            && comm.currency != base_currency
523        {
524            let xrate = self.cache.borrow().get_xrate(
525                fill.instrument_id.venue,
526                comm.currency,
527                base_currency,
528                if fill.order_side == OrderSide::Sell {
529                    PriceType::Bid
530                } else {
531                    PriceType::Ask
532                },
533            );
534
535            if let Some(xrate) = xrate {
536                *comm = Money::new(comm.as_f64() * xrate, base_currency);
537            } else {
538                log::error!(
539                    "Cannot calculate account state: insufficient data for {}/{}",
540                    comm.currency,
541                    base_currency
542                );
543                return;
544            }
545        }
546
547        if pnl.currency != base_currency {
548            let xrate = self.cache.borrow().get_xrate(
549                fill.instrument_id.venue,
550                pnl.currency,
551                base_currency,
552                if fill.order_side == OrderSide::Sell {
553                    PriceType::Bid
554                } else {
555                    PriceType::Ask
556                },
557            );
558
559            if let Some(xrate) = xrate {
560                pnl = Money::new(pnl.as_f64() * xrate, base_currency);
561            } else {
562                log::error!(
563                    "Cannot calculate account state: insufficient data for {}/{}",
564                    pnl.currency,
565                    base_currency
566                );
567                return;
568            }
569        }
570
571        if let Some(comm) = commission {
572            pnl -= comm;
573        }
574
575        if pnl.is_zero() {
576            return;
577        }
578
579        let existing_balances = account.balances();
580        let balance = if let Some(b) = existing_balances.get(&pnl.currency) {
581            b
582        } else {
583            log::error!(
584                "Cannot complete transaction: no balance for {}",
585                pnl.currency
586            );
587            return;
588        };
589
590        let new_balance =
591            AccountBalance::new(balance.total + pnl, balance.locked, balance.free + pnl);
592        balances.push(new_balance);
593
594        match account {
595            AccountAny::Cash(mut cash) => {
596                cash.update_balances(balances);
597                if let Some(comm) = commission {
598                    cash.update_commissions(comm);
599                }
600            }
601            AccountAny::Margin(mut margin) => {
602                margin.update_balances(balances);
603                if let Some(comm) = commission {
604                    margin.update_commissions(comm);
605                }
606            }
607        }
608    }
609
610    fn update_balance_multi_currency(
611        &self,
612        account: AccountAny,
613        fill: OrderFilled,
614        pnls: &mut [Money],
615    ) {
616        let mut new_balances = Vec::new();
617        let commission = fill.commission;
618        let mut apply_commission = commission.is_some_and(|c| !c.is_zero());
619
620        for pnl in pnls.iter_mut() {
621            if apply_commission && pnl.currency == commission.unwrap().currency {
622                *pnl -= commission.unwrap();
623                apply_commission = false;
624            }
625
626            if pnl.is_zero() {
627                continue; // No Adjustment
628            }
629
630            let currency = pnl.currency;
631            let balances = account.balances();
632
633            let new_balance = if let Some(balance) = balances.get(&currency) {
634                let new_total = balance.total.as_f64() + pnl.as_f64();
635                let new_free = balance.free.as_f64() + pnl.as_f64();
636                let total = Money::new(new_total, currency);
637                let free = Money::new(new_free, currency);
638
639                if new_total < 0.0 {
640                    log::error!(
641                        "AccountBalanceNegative: balance = {}, currency = {}",
642                        total.as_decimal(),
643                        currency
644                    );
645                    return;
646                }
647                if new_free < 0.0 {
648                    log::error!(
649                        "AccountMarginExceeded: balance = {}, margin = {}, currency = {}",
650                        total.as_decimal(),
651                        balance.locked.as_decimal(),
652                        currency
653                    );
654                    return;
655                }
656
657                AccountBalance::new(total, balance.locked, free)
658            } else {
659                if pnl.as_decimal() < Decimal::ZERO {
660                    log::error!(
661                        "Cannot complete transaction: no {currency} to deduct a {pnl} realized PnL from"
662                    );
663                    return;
664                }
665                AccountBalance::new(*pnl, Money::new(0.0, currency), *pnl)
666            };
667
668            new_balances.push(new_balance);
669        }
670
671        if apply_commission {
672            let commission = commission.unwrap();
673            let currency = commission.currency;
674            let balances = account.balances();
675
676            let commission_balance = if let Some(balance) = balances.get(&currency) {
677                let new_total = balance.total.as_decimal() - commission.as_decimal();
678                let new_free = balance.free.as_decimal() - commission.as_decimal();
679                AccountBalance::new(
680                    Money::new(new_total.to_f64().unwrap(), currency),
681                    balance.locked,
682                    Money::new(new_free.to_f64().unwrap(), currency),
683                )
684            } else {
685                if commission.as_decimal() > Decimal::ZERO {
686                    log::error!(
687                        "Cannot complete transaction: no {currency} balance to deduct a {commission} commission from"
688                    );
689                    return;
690                }
691                AccountBalance::new(
692                    Money::new(0.0, currency),
693                    Money::new(0.0, currency),
694                    Money::new(0.0, currency),
695                )
696            };
697            new_balances.push(commission_balance);
698        }
699
700        if new_balances.is_empty() {
701            return;
702        }
703
704        match account {
705            AccountAny::Cash(mut cash) => {
706                cash.update_balances(new_balances);
707                if let Some(commission) = commission {
708                    cash.update_commissions(commission);
709                }
710            }
711            AccountAny::Margin(mut margin) => {
712                margin.update_balances(new_balances);
713                if let Some(commission) = commission {
714                    margin.update_commissions(commission);
715                }
716            }
717        }
718    }
719
720    fn generate_account_state(&self, account: AccountAny, ts_event: UnixNanos) -> AccountState {
721        match account {
722            AccountAny::Cash(cash_account) => AccountState::new(
723                cash_account.id,
724                AccountType::Cash,
725                cash_account.balances.clone().into_values().collect(),
726                vec![],
727                false,
728                UUID4::new(),
729                ts_event,
730                self.clock.borrow().timestamp_ns(),
731                cash_account.base_currency(),
732            ),
733            AccountAny::Margin(margin_account) => AccountState::new(
734                margin_account.id,
735                AccountType::Margin,
736                vec![],
737                margin_account.margins.clone().into_values().collect(),
738                false,
739                UUID4::new(),
740                ts_event,
741                self.clock.borrow().timestamp_ns(),
742                margin_account.base_currency(),
743            ),
744        }
745    }
746
747    fn calculate_xrate_to_base(
748        &self,
749        account: AccountAny,
750        instrument: InstrumentAny,
751        side: OrderSideSpecified,
752    ) -> Option<f64> {
753        match account.base_currency() {
754            None => Some(1.0),
755            Some(base_curr) => self.cache.borrow().get_xrate(
756                instrument.id().venue,
757                instrument.settlement_currency(),
758                base_curr,
759                match side {
760                    OrderSideSpecified::Sell => PriceType::Bid,
761                    OrderSideSpecified::Buy => PriceType::Ask,
762                },
763            ),
764        }
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use std::{cell::RefCell, rc::Rc};
771
772    use nautilus_common::{cache::Cache, clock::TestClock};
773    use nautilus_model::{
774        accounts::CashAccount,
775        enums::{AccountType, OrderSide, OrderType},
776        events::{AccountState, OrderAccepted, OrderEventAny, OrderSubmitted},
777        identifiers::{AccountId, VenueOrderId},
778        instruments::{InstrumentAny, stubs::audusd_sim},
779        orders::{OrderAny, OrderTestBuilder},
780        types::{AccountBalance, Currency, Money, Price, Quantity},
781    };
782    use rstest::rstest;
783
784    use super::*;
785
786    #[rstest]
787    fn test_update_balance_locked_with_base_currency_multiple_orders() {
788        // Create account with USD base currency
789        let usd = Currency::USD();
790        let account_state = AccountState::new(
791            AccountId::new("SIM-001"),
792            AccountType::Cash,
793            vec![AccountBalance::new(
794                Money::new(1_000_000.0, usd),
795                Money::new(0.0, usd),
796                Money::new(1_000_000.0, usd),
797            )],
798            Vec::new(),
799            true,
800            UUID4::new(),
801            UnixNanos::default(),
802            UnixNanos::default(),
803            Some(usd), // Base currency set to USD
804        );
805
806        let account = CashAccount::new(account_state, true, false);
807
808        // Create cache and manager
809        let clock = Rc::new(RefCell::new(TestClock::new()));
810        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
811        cache
812            .borrow_mut()
813            .add_account(AccountAny::Cash(account.clone()))
814            .unwrap();
815
816        let manager = AccountsManager::new(clock, cache);
817
818        // Create instrument
819        let instrument = audusd_sim();
820
821        // Create multiple orders for the same instrument
822        let order1 = OrderTestBuilder::new(OrderType::Limit)
823            .instrument_id(instrument.id())
824            .side(OrderSide::Buy)
825            .quantity(Quantity::from("100000"))
826            .price(Price::from("0.75000"))
827            .build();
828
829        let order2 = OrderTestBuilder::new(OrderType::Limit)
830            .instrument_id(instrument.id())
831            .side(OrderSide::Buy)
832            .quantity(Quantity::from("50000"))
833            .price(Price::from("0.74500"))
834            .build();
835
836        let order3 = OrderTestBuilder::new(OrderType::Limit)
837            .instrument_id(instrument.id())
838            .side(OrderSide::Buy)
839            .quantity(Quantity::from("75000"))
840            .price(Price::from("0.74000"))
841            .build();
842
843        // Submit and accept orders to mark them as open
844        let mut order1 = order1;
845        let mut order2 = order2;
846        let mut order3 = order3;
847
848        let submitted1 = OrderSubmitted::new(
849            order1.trader_id(),
850            order1.strategy_id(),
851            order1.instrument_id(),
852            order1.client_order_id(),
853            AccountId::new("SIM-001"),
854            UUID4::new(),
855            UnixNanos::default(),
856            UnixNanos::default(),
857        );
858
859        let accepted1 = OrderAccepted::new(
860            order1.trader_id(),
861            order1.strategy_id(),
862            order1.instrument_id(),
863            order1.client_order_id(),
864            order1.venue_order_id().unwrap_or(VenueOrderId::new("1")),
865            AccountId::new("SIM-001"),
866            UUID4::new(),
867            UnixNanos::default(),
868            UnixNanos::default(),
869            false,
870        );
871
872        order1.apply(OrderEventAny::Submitted(submitted1)).unwrap();
873        order1.apply(OrderEventAny::Accepted(accepted1)).unwrap();
874
875        let submitted2 = OrderSubmitted::new(
876            order2.trader_id(),
877            order2.strategy_id(),
878            order2.instrument_id(),
879            order2.client_order_id(),
880            AccountId::new("SIM-001"),
881            UUID4::new(),
882            UnixNanos::default(),
883            UnixNanos::default(),
884        );
885
886        let accepted2 = OrderAccepted::new(
887            order2.trader_id(),
888            order2.strategy_id(),
889            order2.instrument_id(),
890            order2.client_order_id(),
891            order2.venue_order_id().unwrap_or(VenueOrderId::new("2")),
892            AccountId::new("SIM-001"),
893            UUID4::new(),
894            UnixNanos::default(),
895            UnixNanos::default(),
896            false,
897        );
898
899        order2.apply(OrderEventAny::Submitted(submitted2)).unwrap();
900        order2.apply(OrderEventAny::Accepted(accepted2)).unwrap();
901
902        let submitted3 = OrderSubmitted::new(
903            order3.trader_id(),
904            order3.strategy_id(),
905            order3.instrument_id(),
906            order3.client_order_id(),
907            AccountId::new("SIM-001"),
908            UUID4::new(),
909            UnixNanos::default(),
910            UnixNanos::default(),
911        );
912
913        let accepted3 = OrderAccepted::new(
914            order3.trader_id(),
915            order3.strategy_id(),
916            order3.instrument_id(),
917            order3.client_order_id(),
918            order3.venue_order_id().unwrap_or(VenueOrderId::new("3")),
919            AccountId::new("SIM-001"),
920            UUID4::new(),
921            UnixNanos::default(),
922            UnixNanos::default(),
923            false,
924        );
925
926        order3.apply(OrderEventAny::Submitted(submitted3)).unwrap();
927        order3.apply(OrderEventAny::Accepted(accepted3)).unwrap();
928
929        let orders: Vec<&OrderAny> = vec![&order1, &order2, &order3];
930
931        let result = manager.update_orders(
932            &AccountAny::Cash(account),
933            InstrumentAny::CurrencyPair(instrument),
934            orders,
935            UnixNanos::default(),
936        );
937
938        assert!(result.is_some());
939        let (updated_account, _state) = result.unwrap();
940
941        if let AccountAny::Cash(cash_account) = updated_account {
942            let locked_balance = cash_account.balance_locked(Some(usd));
943
944            // Calculate expected locked balance in USD
945            // Order 1: 100,000 * 0.75000 = 75,000 USD
946            // Order 2: 50,000 * 0.74500 = 37,250 USD
947            // Order 3: 75,000 * 0.74000 = 55,500 USD
948            // Total: 167,750 USD
949            let expected_locked = Money::new(167_750.0, usd);
950
951            assert_eq!(locked_balance, Some(expected_locked));
952
953            // Verify no locked balance in AUD (should all be converted to base USD)
954            let aud = Currency::AUD();
955            assert_eq!(cash_account.balance_locked(Some(aud)), None);
956        } else {
957            panic!("Expected CashAccount");
958        }
959    }
960}