Skip to main content

nautilus_portfolio/
manager.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides account management functionality.
17
18use std::{cell::RefCell, 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
34/// Manages account balance updates and calculations for portfolio management.
35///
36/// The accounts manager handles balance updates for different account types,
37/// including cash and margin accounts, based on order fills and position changes.
38pub struct AccountsManager {
39    clock: Rc<RefCell<dyn Clock>>,
40    cache: Rc<RefCell<Cache>>,
41}
42
43impl Debug for AccountsManager {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct(stringify!(AccountsManager)).finish()
46    }
47}
48
49impl AccountsManager {
50    /// Creates a new [`AccountsManager`] instance.
51    pub fn new(clock: Rc<RefCell<dyn Clock>>, cache: Rc<RefCell<Cache>>) -> Self {
52        Self { clock, cache }
53    }
54
55    /// Updates the given account state based on a filled order.
56    ///
57    /// # Panics
58    ///
59    /// Panics if the position list for the filled instrument is empty.
60    #[must_use]
61    pub fn update_balances(
62        &self,
63        account: AccountAny,
64        instrument: InstrumentAny,
65        fill: OrderFilled,
66    ) -> AccountState {
67        let cache = self.cache.borrow();
68        let position_id = if let Some(position_id) = fill.position_id {
69            position_id
70        } else {
71            let positions_open =
72                cache.positions_open(None, Some(&fill.instrument_id), None, None, None);
73            positions_open
74                .first()
75                .unwrap_or_else(|| panic!("List of Positions is empty"))
76                .id
77        };
78
79        let position = cache.position(&position_id);
80
81        let pnls = account.calculate_pnls(instrument, fill, position.cloned());
82
83        // Calculate final PnL including commissions
84        match account.base_currency() {
85            Some(base_currency) => {
86                let pnl = pnls.map_or_else(
87                    |_| Money::new(0.0, base_currency),
88                    |pnl_list| {
89                        pnl_list
90                            .first()
91                            .copied()
92                            .unwrap_or_else(|| Money::new(0.0, base_currency))
93                    },
94                );
95
96                self.update_balance_single_currency(account.clone(), &fill, pnl);
97            }
98            None => {
99                if let Ok(mut pnl_list) = pnls {
100                    self.update_balance_multi_currency(account.clone(), fill, &mut pnl_list);
101                }
102            }
103        }
104
105        // Generate and return account state
106        self.generate_account_state(account, fill.ts_event)
107    }
108
109    /// Updates account balances based on open orders.
110    ///
111    /// For cash accounts, updates the balance locked by open orders.
112    /// For margin accounts, updates the initial margin requirements.
113    #[must_use]
114    pub fn update_orders(
115        &self,
116        account: &AccountAny,
117        instrument: InstrumentAny,
118        orders_open: Vec<&OrderAny>,
119        ts_event: UnixNanos,
120    ) -> Option<(AccountAny, AccountState)> {
121        match account.clone() {
122            AccountAny::Cash(cash_account) => self
123                .update_balance_locked(&cash_account, instrument, orders_open, ts_event)
124                .map(|(updated_cash_account, state)| {
125                    (AccountAny::Cash(updated_cash_account), state)
126                }),
127            AccountAny::Margin(margin_account) => self
128                .update_margin_init(&margin_account, instrument, orders_open, ts_event)
129                .map(|(updated_margin_account, state)| {
130                    (AccountAny::Margin(updated_margin_account), state)
131                }),
132        }
133    }
134
135    /// Updates the account based on current open positions.
136    ///
137    /// # Panics
138    ///
139    /// Panics if any position's `instrument_id` does not match the provided `instrument`.
140    #[must_use]
141    pub fn update_positions(
142        &self,
143        account: &MarginAccount,
144        instrument: InstrumentAny,
145        positions: Vec<&Position>,
146        ts_event: UnixNanos,
147    ) -> Option<(MarginAccount, AccountState)> {
148        let mut total_margin_maint = 0.0;
149        let mut base_xrate: Option<f64> = None;
150        let mut currency = instrument.settlement_currency();
151        let mut account = account.clone();
152
153        for position in positions {
154            assert_eq!(
155                position.instrument_id,
156                instrument.id(),
157                "Position not for instrument {}",
158                instrument.id()
159            );
160
161            if !position.is_open() {
162                continue;
163            }
164
165            let margin_maint = match instrument {
166                InstrumentAny::Betting(i) => account
167                    .calculate_maintenance_margin(
168                        i,
169                        position.quantity,
170                        instrument.make_price(position.avg_px_open),
171                        None,
172                    )
173                    .ok()?,
174                InstrumentAny::BinaryOption(i) => account
175                    .calculate_maintenance_margin(
176                        i,
177                        position.quantity,
178                        instrument.make_price(position.avg_px_open),
179                        None,
180                    )
181                    .ok()?,
182                InstrumentAny::CryptoFuture(i) => account
183                    .calculate_maintenance_margin(
184                        i,
185                        position.quantity,
186                        instrument.make_price(position.avg_px_open),
187                        None,
188                    )
189                    .ok()?,
190                InstrumentAny::CryptoOption(i) => account
191                    .calculate_maintenance_margin(
192                        i,
193                        position.quantity,
194                        instrument.make_price(position.avg_px_open),
195                        None,
196                    )
197                    .ok()?,
198                InstrumentAny::CryptoPerpetual(i) => account
199                    .calculate_maintenance_margin(
200                        i,
201                        position.quantity,
202                        instrument.make_price(position.avg_px_open),
203                        None,
204                    )
205                    .ok()?,
206                InstrumentAny::CurrencyPair(i) => account
207                    .calculate_maintenance_margin(
208                        i,
209                        position.quantity,
210                        instrument.make_price(position.avg_px_open),
211                        None,
212                    )
213                    .ok()?,
214                InstrumentAny::Equity(i) => account
215                    .calculate_maintenance_margin(
216                        i,
217                        position.quantity,
218                        instrument.make_price(position.avg_px_open),
219                        None,
220                    )
221                    .ok()?,
222                InstrumentAny::FuturesContract(i) => account
223                    .calculate_maintenance_margin(
224                        i,
225                        position.quantity,
226                        instrument.make_price(position.avg_px_open),
227                        None,
228                    )
229                    .ok()?,
230                InstrumentAny::FuturesSpread(i) => account
231                    .calculate_maintenance_margin(
232                        i,
233                        position.quantity,
234                        instrument.make_price(position.avg_px_open),
235                        None,
236                    )
237                    .ok()?,
238                InstrumentAny::OptionContract(i) => account
239                    .calculate_maintenance_margin(
240                        i,
241                        position.quantity,
242                        instrument.make_price(position.avg_px_open),
243                        None,
244                    )
245                    .ok()?,
246                InstrumentAny::OptionSpread(i) => account
247                    .calculate_maintenance_margin(
248                        i,
249                        position.quantity,
250                        instrument.make_price(position.avg_px_open),
251                        None,
252                    )
253                    .ok()?,
254            };
255
256            let mut margin_maint = margin_maint.as_f64();
257
258            if let Some(base_currency) = account.base_currency {
259                if base_xrate.is_none() {
260                    currency = base_currency;
261                    base_xrate = self.calculate_xrate_to_base(
262                        AccountAny::Margin(account.clone()),
263                        instrument.clone(),
264                        position.entry.as_specified(),
265                    );
266                }
267
268                if let Some(xrate) = base_xrate {
269                    margin_maint *= xrate;
270                } else {
271                    log::debug!(
272                        "Cannot calculate maintenance (position) margin: insufficient data for {}/{}",
273                        instrument.settlement_currency(),
274                        base_currency
275                    );
276                    return None;
277                }
278            }
279
280            total_margin_maint += margin_maint;
281        }
282
283        let margin_maint = Money::new(total_margin_maint, currency);
284        account.update_maintenance_margin(instrument.id(), margin_maint);
285
286        log::info!("{} margin_maint={margin_maint}", instrument.id());
287
288        // Generate and return account state
289        Some((
290            account.clone(),
291            self.generate_account_state(AccountAny::Margin(account), ts_event),
292        ))
293    }
294
295    fn update_balance_locked(
296        &self,
297        account: &CashAccount,
298        instrument: InstrumentAny,
299        orders_open: Vec<&OrderAny>,
300        ts_event: UnixNanos,
301    ) -> Option<(CashAccount, AccountState)> {
302        let mut account = account.clone();
303
304        if orders_open.is_empty() {
305            account.clear_balance_locked(instrument.id());
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 = *total + locked)
375                .or_insert(locked);
376        }
377
378        if total_locked.is_empty() {
379            account.clear_balance_locked(instrument.id());
380            return Some((
381                account.clone(),
382                self.generate_account_state(AccountAny::Cash(account), ts_event),
383            ));
384        }
385
386        // Clear existing locks before applying new ones to remove stale currency entries
387        account.clear_balance_locked(instrument.id());
388
389        for (_, balance_locked) in total_locked {
390            account.update_balance_locked(instrument.id(), balance_locked);
391            log::info!("{} balance_locked={balance_locked}", instrument.id());
392        }
393
394        Some((
395            account.clone(),
396            self.generate_account_state(AccountAny::Cash(account), ts_event),
397        ))
398    }
399
400    fn update_margin_init(
401        &self,
402        account: &MarginAccount,
403        instrument: InstrumentAny,
404        orders_open: Vec<&OrderAny>,
405        ts_event: UnixNanos,
406    ) -> Option<(MarginAccount, AccountState)> {
407        let mut total_margin_init = 0.0;
408        let mut base_xrate: Option<f64> = None;
409        let mut currency = instrument.settlement_currency();
410        let mut account = account.clone();
411
412        for order in orders_open {
413            assert_eq!(
414                order.instrument_id(),
415                instrument.id(),
416                "Order not for instrument {}",
417                instrument.id()
418            );
419
420            if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) {
421                continue;
422            }
423
424            if order.is_reduce_only() {
425                continue; // Does not contribute to margin
426            }
427
428            let price = if order.price().is_some() {
429                order.price()
430            } else {
431                order.trigger_price()
432            };
433
434            let margin_init = match instrument {
435                InstrumentAny::Betting(i) => account
436                    .calculate_initial_margin(i, order.quantity(), price?, None)
437                    .ok()?,
438                InstrumentAny::BinaryOption(i) => account
439                    .calculate_initial_margin(i, order.quantity(), price?, None)
440                    .ok()?,
441                InstrumentAny::CryptoFuture(i) => account
442                    .calculate_initial_margin(i, order.quantity(), price?, None)
443                    .ok()?,
444                InstrumentAny::CryptoOption(i) => account
445                    .calculate_initial_margin(i, order.quantity(), price?, None)
446                    .ok()?,
447                InstrumentAny::CryptoPerpetual(i) => account
448                    .calculate_initial_margin(i, order.quantity(), price?, None)
449                    .ok()?,
450                InstrumentAny::CurrencyPair(i) => account
451                    .calculate_initial_margin(i, order.quantity(), price?, None)
452                    .ok()?,
453                InstrumentAny::Equity(i) => account
454                    .calculate_initial_margin(i, order.quantity(), price?, None)
455                    .ok()?,
456                InstrumentAny::FuturesContract(i) => account
457                    .calculate_initial_margin(i, order.quantity(), price?, None)
458                    .ok()?,
459                InstrumentAny::FuturesSpread(i) => account
460                    .calculate_initial_margin(i, order.quantity(), price?, None)
461                    .ok()?,
462                InstrumentAny::OptionContract(i) => account
463                    .calculate_initial_margin(i, order.quantity(), price?, None)
464                    .ok()?,
465                InstrumentAny::OptionSpread(i) => account
466                    .calculate_initial_margin(i, order.quantity(), price?, None)
467                    .ok()?,
468            };
469
470            let mut margin_init = margin_init.as_f64();
471
472            if let Some(base_currency) = account.base_currency {
473                if base_xrate.is_none() {
474                    currency = base_currency;
475                    base_xrate = self.calculate_xrate_to_base(
476                        AccountAny::Margin(account.clone()),
477                        instrument.clone(),
478                        order.order_side_specified(),
479                    );
480                }
481
482                if let Some(xrate) = base_xrate {
483                    margin_init *= xrate;
484                } else {
485                    log::debug!(
486                        "Cannot calculate initial margin: insufficient data for {}/{}",
487                        instrument.settlement_currency(),
488                        base_currency
489                    );
490                    continue;
491                }
492            }
493
494            total_margin_init += margin_init;
495        }
496
497        let money = Money::new(total_margin_init, currency);
498        let margin_init = {
499            account.update_initial_margin(instrument.id(), money);
500            money
501        };
502
503        log::info!("{} margin_init={margin_init}", instrument.id());
504
505        Some((
506            account.clone(),
507            self.generate_account_state(AccountAny::Margin(account), ts_event),
508        ))
509    }
510
511    fn update_balance_single_currency(
512        &self,
513        account: AccountAny,
514        fill: &OrderFilled,
515        mut pnl: Money,
516    ) {
517        let base_currency = if let Some(currency) = account.base_currency() {
518            currency
519        } else {
520            log::error!("Account has no base currency set");
521            return;
522        };
523
524        let mut balances = Vec::new();
525        let mut commission = fill.commission;
526
527        if let Some(ref mut comm) = commission
528            && comm.currency != base_currency
529        {
530            let xrate = self.cache.borrow().get_xrate(
531                fill.instrument_id.venue,
532                comm.currency,
533                base_currency,
534                if fill.order_side == OrderSide::Sell {
535                    PriceType::Bid
536                } else {
537                    PriceType::Ask
538                },
539            );
540
541            if let Some(xrate) = xrate {
542                *comm = Money::new(comm.as_f64() * xrate, base_currency);
543            } else {
544                log::error!(
545                    "Cannot calculate account state: insufficient data for {}/{}",
546                    comm.currency,
547                    base_currency
548                );
549                return;
550            }
551        }
552
553        if pnl.currency != base_currency {
554            let xrate = self.cache.borrow().get_xrate(
555                fill.instrument_id.venue,
556                pnl.currency,
557                base_currency,
558                if fill.order_side == OrderSide::Sell {
559                    PriceType::Bid
560                } else {
561                    PriceType::Ask
562                },
563            );
564
565            if let Some(xrate) = xrate {
566                pnl = Money::new(pnl.as_f64() * xrate, base_currency);
567            } else {
568                log::error!(
569                    "Cannot calculate account state: insufficient data for {}/{}",
570                    pnl.currency,
571                    base_currency
572                );
573                return;
574            }
575        }
576
577        if let Some(comm) = commission {
578            pnl = pnl - comm;
579        }
580
581        if pnl.is_zero() {
582            return;
583        }
584
585        let existing_balances = account.balances();
586        let balance = if let Some(b) = existing_balances.get(&pnl.currency) {
587            b
588        } else {
589            log::error!(
590                "Cannot complete transaction: no balance for {}",
591                pnl.currency
592            );
593            return;
594        };
595
596        let new_balance =
597            AccountBalance::new(balance.total + pnl, balance.locked, balance.free + pnl);
598        balances.push(new_balance);
599
600        match account {
601            AccountAny::Cash(mut cash) => {
602                if let Err(e) = cash.update_balances(&balances) {
603                    log::error!("Cannot update cash account balance: {e}");
604                    return;
605                }
606                if let Some(comm) = commission {
607                    cash.update_commissions(comm);
608                }
609            }
610            AccountAny::Margin(mut margin) => {
611                margin.update_balances(&balances);
612                if let Some(comm) = commission {
613                    margin.update_commissions(comm);
614                }
615            }
616        }
617    }
618
619    fn update_balance_multi_currency(
620        &self,
621        account: AccountAny,
622        fill: OrderFilled,
623        pnls: &mut [Money],
624    ) {
625        let mut new_balances = Vec::new();
626        let commission = fill.commission;
627        let mut apply_commission = commission.is_some_and(|c| !c.is_zero());
628
629        for pnl in pnls.iter_mut() {
630            if apply_commission && pnl.currency == commission.unwrap().currency {
631                *pnl = *pnl - commission.unwrap();
632                apply_commission = false;
633            }
634
635            if pnl.is_zero() {
636                continue; // No Adjustment
637            }
638
639            let currency = pnl.currency;
640            let balances = account.balances();
641
642            let new_balance = if let Some(balance) = balances.get(&currency) {
643                let new_total = balance.total.as_f64() + pnl.as_f64();
644                let new_free = balance.free.as_f64() + pnl.as_f64();
645                let total = Money::new(new_total, currency);
646                let free = Money::new(new_free, currency);
647
648                if new_total < 0.0 {
649                    log::error!(
650                        "AccountBalanceNegative: balance = {}, currency = {}",
651                        total.as_decimal(),
652                        currency
653                    );
654                    return;
655                }
656                if new_free < 0.0 {
657                    log::error!(
658                        "AccountMarginExceeded: balance = {}, margin = {}, currency = {}",
659                        total.as_decimal(),
660                        balance.locked.as_decimal(),
661                        currency
662                    );
663                    return;
664                }
665
666                AccountBalance::new(total, balance.locked, free)
667            } else {
668                if pnl.as_decimal() < Decimal::ZERO {
669                    log::error!(
670                        "Cannot complete transaction: no {currency} to deduct a {pnl} realized PnL from"
671                    );
672                    return;
673                }
674                AccountBalance::new(*pnl, Money::new(0.0, currency), *pnl)
675            };
676
677            new_balances.push(new_balance);
678        }
679
680        if apply_commission {
681            let commission = commission.unwrap();
682            let currency = commission.currency;
683            let balances = account.balances();
684
685            let commission_balance = if let Some(balance) = balances.get(&currency) {
686                let new_total = balance.total.as_decimal() - commission.as_decimal();
687                let new_free = balance.free.as_decimal() - commission.as_decimal();
688                AccountBalance::new(
689                    Money::new(new_total.to_f64().unwrap(), currency),
690                    balance.locked,
691                    Money::new(new_free.to_f64().unwrap(), currency),
692                )
693            } else {
694                if commission.as_decimal() > Decimal::ZERO {
695                    log::error!(
696                        "Cannot complete transaction: no {currency} balance to deduct a {commission} commission from"
697                    );
698                    return;
699                }
700                AccountBalance::new(
701                    Money::new(0.0, currency),
702                    Money::new(0.0, currency),
703                    Money::new(0.0, currency),
704                )
705            };
706            new_balances.push(commission_balance);
707        }
708
709        if new_balances.is_empty() {
710            return;
711        }
712
713        match account {
714            AccountAny::Cash(mut cash) => {
715                if let Err(e) = cash.update_balances(&new_balances) {
716                    log::error!("Cannot update cash account balance: {e}");
717                    return;
718                }
719                if let Some(commission) = commission {
720                    cash.update_commissions(commission);
721                }
722            }
723            AccountAny::Margin(mut margin) => {
724                margin.update_balances(&new_balances);
725                if let Some(commission) = commission {
726                    margin.update_commissions(commission);
727                }
728            }
729        }
730    }
731
732    fn generate_account_state(&self, account: AccountAny, ts_event: UnixNanos) -> AccountState {
733        match account {
734            AccountAny::Cash(cash_account) => AccountState::new(
735                cash_account.id,
736                AccountType::Cash,
737                cash_account.balances.clone().into_values().collect(),
738                vec![],
739                false,
740                UUID4::new(),
741                ts_event,
742                self.clock.borrow().timestamp_ns(),
743                cash_account.base_currency(),
744            ),
745            AccountAny::Margin(margin_account) => AccountState::new(
746                margin_account.id,
747                AccountType::Margin,
748                vec![],
749                margin_account.margins.clone().into_values().collect(),
750                false,
751                UUID4::new(),
752                ts_event,
753                self.clock.borrow().timestamp_ns(),
754                margin_account.base_currency(),
755            ),
756        }
757    }
758
759    fn calculate_xrate_to_base(
760        &self,
761        account: AccountAny,
762        instrument: InstrumentAny,
763        side: OrderSideSpecified,
764    ) -> Option<f64> {
765        match account.base_currency() {
766            None => Some(1.0),
767            Some(base_curr) => self.cache.borrow().get_xrate(
768                instrument.id().venue,
769                instrument.settlement_currency(),
770                base_curr,
771                match side {
772                    OrderSideSpecified::Sell => PriceType::Bid,
773                    OrderSideSpecified::Buy => PriceType::Ask,
774                },
775            ),
776        }
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use std::{cell::RefCell, rc::Rc};
783
784    use nautilus_common::{cache::Cache, clock::TestClock};
785    use nautilus_model::{
786        accounts::CashAccount,
787        enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderType},
788        events::{AccountState, OrderAccepted, OrderEventAny, OrderFilled, OrderSubmitted},
789        identifiers::{AccountId, PositionId, StrategyId, TradeId, TraderId, VenueOrderId},
790        instruments::{InstrumentAny, stubs::audusd_sim},
791        orders::{OrderAny, OrderTestBuilder},
792        position::Position,
793        stubs::TestDefault,
794        types::{AccountBalance, Currency, Money, Price, Quantity},
795    };
796    use rstest::rstest;
797
798    use super::*;
799
800    #[rstest]
801    fn test_update_balance_locked_with_base_currency_multiple_orders() {
802        let usd = Currency::USD();
803        let account_state = AccountState::new(
804            AccountId::new("SIM-001"),
805            AccountType::Cash,
806            vec![AccountBalance::new(
807                Money::new(1_000_000.0, usd),
808                Money::new(0.0, usd),
809                Money::new(1_000_000.0, usd),
810            )],
811            Vec::new(),
812            true,
813            UUID4::new(),
814            UnixNanos::default(),
815            UnixNanos::default(),
816            Some(usd),
817        );
818
819        let account = CashAccount::new(account_state, true, false);
820
821        let clock = Rc::new(RefCell::new(TestClock::new()));
822        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
823        cache
824            .borrow_mut()
825            .add_account(AccountAny::Cash(account.clone()))
826            .unwrap();
827
828        let manager = AccountsManager::new(clock, cache);
829
830        let instrument = audusd_sim();
831
832        let order1 = OrderTestBuilder::new(OrderType::Limit)
833            .instrument_id(instrument.id())
834            .side(OrderSide::Buy)
835            .quantity(Quantity::from("100000"))
836            .price(Price::from("0.75000"))
837            .build();
838
839        let order2 = OrderTestBuilder::new(OrderType::Limit)
840            .instrument_id(instrument.id())
841            .side(OrderSide::Buy)
842            .quantity(Quantity::from("50000"))
843            .price(Price::from("0.74500"))
844            .build();
845
846        let order3 = OrderTestBuilder::new(OrderType::Limit)
847            .instrument_id(instrument.id())
848            .side(OrderSide::Buy)
849            .quantity(Quantity::from("75000"))
850            .price(Price::from("0.74000"))
851            .build();
852
853        let mut order1 = order1;
854        let mut order2 = order2;
855        let mut order3 = order3;
856
857        let submitted1 = OrderSubmitted::new(
858            order1.trader_id(),
859            order1.strategy_id(),
860            order1.instrument_id(),
861            order1.client_order_id(),
862            AccountId::new("SIM-001"),
863            UUID4::new(),
864            UnixNanos::default(),
865            UnixNanos::default(),
866        );
867
868        let accepted1 = OrderAccepted::new(
869            order1.trader_id(),
870            order1.strategy_id(),
871            order1.instrument_id(),
872            order1.client_order_id(),
873            order1.venue_order_id().unwrap_or(VenueOrderId::new("1")),
874            AccountId::new("SIM-001"),
875            UUID4::new(),
876            UnixNanos::default(),
877            UnixNanos::default(),
878            false,
879        );
880
881        order1.apply(OrderEventAny::Submitted(submitted1)).unwrap();
882        order1.apply(OrderEventAny::Accepted(accepted1)).unwrap();
883
884        let submitted2 = OrderSubmitted::new(
885            order2.trader_id(),
886            order2.strategy_id(),
887            order2.instrument_id(),
888            order2.client_order_id(),
889            AccountId::new("SIM-001"),
890            UUID4::new(),
891            UnixNanos::default(),
892            UnixNanos::default(),
893        );
894
895        let accepted2 = OrderAccepted::new(
896            order2.trader_id(),
897            order2.strategy_id(),
898            order2.instrument_id(),
899            order2.client_order_id(),
900            order2.venue_order_id().unwrap_or(VenueOrderId::new("2")),
901            AccountId::new("SIM-001"),
902            UUID4::new(),
903            UnixNanos::default(),
904            UnixNanos::default(),
905            false,
906        );
907
908        order2.apply(OrderEventAny::Submitted(submitted2)).unwrap();
909        order2.apply(OrderEventAny::Accepted(accepted2)).unwrap();
910
911        let submitted3 = OrderSubmitted::new(
912            order3.trader_id(),
913            order3.strategy_id(),
914            order3.instrument_id(),
915            order3.client_order_id(),
916            AccountId::new("SIM-001"),
917            UUID4::new(),
918            UnixNanos::default(),
919            UnixNanos::default(),
920        );
921
922        let accepted3 = OrderAccepted::new(
923            order3.trader_id(),
924            order3.strategy_id(),
925            order3.instrument_id(),
926            order3.client_order_id(),
927            order3.venue_order_id().unwrap_or(VenueOrderId::new("3")),
928            AccountId::new("SIM-001"),
929            UUID4::new(),
930            UnixNanos::default(),
931            UnixNanos::default(),
932            false,
933        );
934
935        order3.apply(OrderEventAny::Submitted(submitted3)).unwrap();
936        order3.apply(OrderEventAny::Accepted(accepted3)).unwrap();
937
938        let orders: Vec<&OrderAny> = vec![&order1, &order2, &order3];
939
940        let result = manager.update_orders(
941            &AccountAny::Cash(account),
942            InstrumentAny::CurrencyPair(instrument),
943            orders,
944            UnixNanos::default(),
945        );
946
947        assert!(result.is_some());
948        let (updated_account, _state) = result.unwrap();
949
950        if let AccountAny::Cash(cash_account) = updated_account {
951            let locked_balance = cash_account.balance_locked(Some(usd));
952
953            // Order 1: 100k * 0.75 = 75k, Order 2: 50k * 0.745 = 37.25k, Order 3: 75k * 0.74 = 55.5k
954            let expected_locked = Money::new(167_750.0, usd);
955
956            assert_eq!(locked_balance, Some(expected_locked));
957            let aud = Currency::AUD();
958            assert_eq!(cash_account.balance_locked(Some(aud)), None);
959        } else {
960            panic!("Expected CashAccount");
961        }
962    }
963
964    #[rstest]
965    fn test_update_orders_clears_stale_currency_locks_when_order_sides_change() {
966        let usd = Currency::USD();
967        let aud = Currency::AUD();
968        let account_state = AccountState::new(
969            AccountId::new("SIM-001"),
970            AccountType::Cash,
971            vec![
972                AccountBalance::new(
973                    Money::new(1_000_000.0, usd),
974                    Money::new(0.0, usd),
975                    Money::new(1_000_000.0, usd),
976                ),
977                AccountBalance::new(
978                    Money::new(1_000_000.0, aud),
979                    Money::new(0.0, aud),
980                    Money::new(1_000_000.0, aud),
981                ),
982            ],
983            Vec::new(),
984            true,
985            UUID4::new(),
986            UnixNanos::default(),
987            UnixNanos::default(),
988            None,
989        );
990
991        let account = CashAccount::new(account_state, true, false);
992
993        let clock = Rc::new(RefCell::new(TestClock::new()));
994        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
995        cache
996            .borrow_mut()
997            .add_account(AccountAny::Cash(account.clone()))
998            .unwrap();
999
1000        let manager = AccountsManager::new(clock, cache);
1001        let instrument = audusd_sim();
1002
1003        let mut buy_order = OrderTestBuilder::new(OrderType::Limit)
1004            .instrument_id(instrument.id())
1005            .side(OrderSide::Buy)
1006            .quantity(Quantity::from("100000"))
1007            .price(Price::from("0.80000"))
1008            .build();
1009
1010        let mut sell_order = OrderTestBuilder::new(OrderType::Limit)
1011            .instrument_id(instrument.id())
1012            .side(OrderSide::Sell)
1013            .quantity(Quantity::from("50000"))
1014            .price(Price::from("0.81000"))
1015            .build();
1016
1017        // Submit and accept orders
1018        let submitted_buy = OrderSubmitted::new(
1019            buy_order.trader_id(),
1020            buy_order.strategy_id(),
1021            buy_order.instrument_id(),
1022            buy_order.client_order_id(),
1023            AccountId::new("SIM-001"),
1024            UUID4::new(),
1025            UnixNanos::default(),
1026            UnixNanos::default(),
1027        );
1028        let accepted_buy = OrderAccepted::new(
1029            buy_order.trader_id(),
1030            buy_order.strategy_id(),
1031            buy_order.instrument_id(),
1032            buy_order.client_order_id(),
1033            VenueOrderId::new("1"),
1034            AccountId::new("SIM-001"),
1035            UUID4::new(),
1036            UnixNanos::default(),
1037            UnixNanos::default(),
1038            false,
1039        );
1040        buy_order
1041            .apply(OrderEventAny::Submitted(submitted_buy))
1042            .unwrap();
1043        buy_order
1044            .apply(OrderEventAny::Accepted(accepted_buy))
1045            .unwrap();
1046
1047        let submitted_sell = OrderSubmitted::new(
1048            sell_order.trader_id(),
1049            sell_order.strategy_id(),
1050            sell_order.instrument_id(),
1051            sell_order.client_order_id(),
1052            AccountId::new("SIM-001"),
1053            UUID4::new(),
1054            UnixNanos::default(),
1055            UnixNanos::default(),
1056        );
1057        let accepted_sell = OrderAccepted::new(
1058            sell_order.trader_id(),
1059            sell_order.strategy_id(),
1060            sell_order.instrument_id(),
1061            sell_order.client_order_id(),
1062            VenueOrderId::new("2"),
1063            AccountId::new("SIM-001"),
1064            UUID4::new(),
1065            UnixNanos::default(),
1066            UnixNanos::default(),
1067            false,
1068        );
1069        sell_order
1070            .apply(OrderEventAny::Submitted(submitted_sell))
1071            .unwrap();
1072        sell_order
1073            .apply(OrderEventAny::Accepted(accepted_sell))
1074            .unwrap();
1075
1076        let orders_both: Vec<&OrderAny> = vec![&buy_order, &sell_order];
1077        let result = manager.update_orders(
1078            &AccountAny::Cash(account),
1079            InstrumentAny::CurrencyPair(instrument),
1080            orders_both,
1081            UnixNanos::default(),
1082        );
1083
1084        assert!(result.is_some());
1085        let (updated_account, _) = result.unwrap();
1086
1087        if let AccountAny::Cash(cash_account) = &updated_account {
1088            assert_eq!(
1089                cash_account.balance_locked(Some(usd)),
1090                Some(Money::new(80_000.0, usd))
1091            );
1092            assert_eq!(
1093                cash_account.balance_locked(Some(aud)),
1094                Some(Money::new(50_000.0, aud))
1095            );
1096        } else {
1097            panic!("Expected CashAccount");
1098        }
1099
1100        // Cancel BUY order, only SELL remains - USD lock should be cleared
1101        let orders_sell_only: Vec<&OrderAny> = vec![&sell_order];
1102        let result = manager.update_orders(
1103            &updated_account,
1104            InstrumentAny::CurrencyPair(instrument),
1105            orders_sell_only,
1106            UnixNanos::default(),
1107        );
1108
1109        assert!(result.is_some());
1110        let (final_account, _) = result.unwrap();
1111
1112        if let AccountAny::Cash(cash_account) = final_account {
1113            assert_eq!(
1114                cash_account.balance_locked(Some(usd)),
1115                Some(Money::new(0.0, usd))
1116            );
1117            assert_eq!(
1118                cash_account.balance_locked(Some(aud)),
1119                Some(Money::new(50_000.0, aud))
1120            );
1121        } else {
1122            panic!("Expected CashAccount");
1123        }
1124    }
1125
1126    #[rstest]
1127    fn test_cash_account_rejects_negative_balance_when_borrowing_disabled() {
1128        let usd = Currency::USD();
1129        let account_state = AccountState::new(
1130            AccountId::new("SIM-001"),
1131            AccountType::Cash,
1132            vec![AccountBalance::new(
1133                Money::new(1_000.0, usd),
1134                Money::new(0.0, usd),
1135                Money::new(1_000.0, usd),
1136            )],
1137            Vec::new(),
1138            true,
1139            UUID4::new(),
1140            UnixNanos::default(),
1141            UnixNanos::default(),
1142            Some(usd),
1143        );
1144
1145        let mut account = CashAccount::new(account_state, true, false);
1146
1147        let negative_balances = vec![AccountBalance::new(
1148            Money::new(-500.0, usd),
1149            Money::new(0.0, usd),
1150            Money::new(-500.0, usd),
1151        )];
1152
1153        let result = account.update_balances(&negative_balances);
1154
1155        assert!(result.is_err());
1156        let err_msg = result.unwrap_err().to_string();
1157        assert!(err_msg.contains("negative"));
1158        assert!(err_msg.contains("borrowing not allowed"));
1159    }
1160
1161    #[rstest]
1162    fn test_manager_update_balances_skips_update_on_negative_balance_error() {
1163        let usd = Currency::USD();
1164        let account_state = AccountState::new(
1165            AccountId::new("SIM-001"),
1166            AccountType::Cash,
1167            vec![AccountBalance::new(
1168                Money::new(100.0, usd),
1169                Money::new(0.0, usd),
1170                Money::new(100.0, usd),
1171            )],
1172            Vec::new(),
1173            true,
1174            UUID4::new(),
1175            UnixNanos::default(),
1176            UnixNanos::default(),
1177            Some(usd),
1178        );
1179
1180        let account = CashAccount::new(account_state, true, false);
1181        let initial_balance = account.balance_total(Some(usd)).unwrap();
1182
1183        let clock = Rc::new(RefCell::new(TestClock::new()));
1184        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1185        cache
1186            .borrow_mut()
1187            .add_account(AccountAny::Cash(account.clone()))
1188            .unwrap();
1189
1190        let manager = AccountsManager::new(clock, cache.clone());
1191        let instrument = audusd_sim();
1192
1193        let mut order = OrderTestBuilder::new(OrderType::Market)
1194            .instrument_id(instrument.id())
1195            .side(OrderSide::Buy)
1196            .quantity(Quantity::from("100000"))
1197            .build();
1198
1199        let submitted = OrderSubmitted::new(
1200            order.trader_id(),
1201            order.strategy_id(),
1202            order.instrument_id(),
1203            order.client_order_id(),
1204            AccountId::new("SIM-001"),
1205            UUID4::new(),
1206            UnixNanos::default(),
1207            UnixNanos::default(),
1208        );
1209        let accepted = OrderAccepted::new(
1210            order.trader_id(),
1211            order.strategy_id(),
1212            order.instrument_id(),
1213            order.client_order_id(),
1214            VenueOrderId::new("1"),
1215            AccountId::new("SIM-001"),
1216            UUID4::new(),
1217            UnixNanos::default(),
1218            UnixNanos::default(),
1219            false,
1220        );
1221        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1222        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1223
1224        cache
1225            .borrow_mut()
1226            .add_order(order.clone(), None, None, false)
1227            .unwrap();
1228
1229        // Fill with large cost ($80k) that exceeds $100 balance
1230        let fill = OrderFilled::new(
1231            TraderId::test_default(),
1232            StrategyId::test_default(),
1233            instrument.id(),
1234            order.client_order_id(),
1235            VenueOrderId::new("1"),
1236            AccountId::new("SIM-001"),
1237            TradeId::new("1"),
1238            OrderSide::Buy,
1239            order.order_type(),
1240            Quantity::from("100000"),
1241            Price::from("0.80000"),
1242            usd,
1243            LiquiditySide::Taker,
1244            UUID4::new(),
1245            UnixNanos::from(1),
1246            UnixNanos::from(1),
1247            false,
1248            Some(PositionId::new("P-001")),
1249            Some(Money::new(20.0, usd)),
1250        );
1251
1252        let position = Position::new(&InstrumentAny::CurrencyPair(instrument), fill);
1253        cache
1254            .borrow_mut()
1255            .add_position(position, OmsType::Netting)
1256            .unwrap();
1257
1258        let fill2 = OrderFilled::new(
1259            TraderId::test_default(),
1260            StrategyId::test_default(),
1261            instrument.id(),
1262            order.client_order_id(),
1263            VenueOrderId::new("2"),
1264            AccountId::new("SIM-001"),
1265            TradeId::new("2"),
1266            OrderSide::Buy,
1267            order.order_type(),
1268            Quantity::from("100000"),
1269            Price::from("0.80000"),
1270            usd,
1271            LiquiditySide::Taker,
1272            UUID4::new(),
1273            UnixNanos::from(2),
1274            UnixNanos::from(2),
1275            false,
1276            Some(PositionId::new("P-001")),
1277            Some(Money::new(20.0, usd)),
1278        );
1279        let _state = manager.update_balances(
1280            AccountAny::Cash(account),
1281            InstrumentAny::CurrencyPair(instrument),
1282            fill2,
1283        );
1284
1285        let account_after = cache
1286            .borrow()
1287            .account(&AccountId::new("SIM-001"))
1288            .unwrap()
1289            .clone();
1290
1291        if let AccountAny::Cash(cash) = account_after {
1292            assert_eq!(cash.balance_total(Some(usd)), Some(initial_balance));
1293        } else {
1294            panic!("Expected CashAccount");
1295        }
1296    }
1297
1298    #[rstest]
1299    fn test_order_canceled_releases_locked_balance() {
1300        // Regression test for https://github.com/nautechsystems/nautilus_trader/issues/3525
1301        let usd = Currency::USD();
1302        let account_state = AccountState::new(
1303            AccountId::new("SIM-001"),
1304            AccountType::Cash,
1305            vec![AccountBalance::new(
1306                Money::new(100_000.0, usd),
1307                Money::new(0.0, usd),
1308                Money::new(100_000.0, usd),
1309            )],
1310            Vec::new(),
1311            true,
1312            UUID4::new(),
1313            UnixNanos::default(),
1314            UnixNanos::default(),
1315            Some(usd),
1316        );
1317
1318        let account = CashAccount::new(account_state, true, false);
1319
1320        let clock = Rc::new(RefCell::new(TestClock::new()));
1321        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1322        cache
1323            .borrow_mut()
1324            .add_account(AccountAny::Cash(account.clone()))
1325            .unwrap();
1326
1327        let manager = AccountsManager::new(clock, cache);
1328        let instrument = audusd_sim();
1329
1330        let mut order = OrderTestBuilder::new(OrderType::Limit)
1331            .instrument_id(instrument.id())
1332            .side(OrderSide::Buy)
1333            .quantity(Quantity::from("100000"))
1334            .price(Price::from("0.80000"))
1335            .build();
1336
1337        let submitted = OrderSubmitted::new(
1338            order.trader_id(),
1339            order.strategy_id(),
1340            order.instrument_id(),
1341            order.client_order_id(),
1342            AccountId::new("SIM-001"),
1343            UUID4::new(),
1344            UnixNanos::default(),
1345            UnixNanos::default(),
1346        );
1347
1348        let accepted = OrderAccepted::new(
1349            order.trader_id(),
1350            order.strategy_id(),
1351            order.instrument_id(),
1352            order.client_order_id(),
1353            order.venue_order_id().unwrap_or(VenueOrderId::new("1")),
1354            AccountId::new("SIM-001"),
1355            UUID4::new(),
1356            UnixNanos::default(),
1357            UnixNanos::default(),
1358            false,
1359        );
1360
1361        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1362        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1363
1364        let result = manager.update_orders(
1365            &AccountAny::Cash(account),
1366            InstrumentAny::CurrencyPair(instrument),
1367            vec![&order],
1368            UnixNanos::default(),
1369        );
1370
1371        assert!(result.is_some());
1372        let (updated_account, _) = result.unwrap();
1373
1374        if let AccountAny::Cash(ref cash) = updated_account {
1375            // 100k * 0.80 = 80k USD locked
1376            assert_eq!(
1377                cash.balance_locked(Some(usd)),
1378                Some(Money::new(80_000.0, usd))
1379            );
1380            assert_eq!(
1381                cash.balance_free(Some(usd)),
1382                Some(Money::new(20_000.0, usd))
1383            );
1384        } else {
1385            panic!("Expected CashAccount");
1386        }
1387
1388        let result = manager.update_orders(
1389            &updated_account,
1390            InstrumentAny::CurrencyPair(instrument),
1391            vec![],
1392            UnixNanos::default(),
1393        );
1394
1395        assert!(result.is_some());
1396        let (final_account, _) = result.unwrap();
1397
1398        if let AccountAny::Cash(cash) = final_account {
1399            assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(0.0, usd)));
1400            assert_eq!(
1401                cash.balance_free(Some(usd)),
1402                Some(Money::new(100_000.0, usd))
1403            );
1404            assert_eq!(
1405                cash.balance_total(Some(usd)),
1406                Some(Money::new(100_000.0, usd))
1407            );
1408        } else {
1409            panic!("Expected CashAccount");
1410        }
1411    }
1412}