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