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