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, BettingAccount, CashAccount, MarginAccount},
25    enums::{AccountType, OrderSide, OrderType, PriceType},
26    events::{AccountState, OrderFilled},
27    identifiers::InstrumentId,
28    instruments::{Instrument, InstrumentAny},
29    orders::{Order, OrderAny},
30    position::{Position, fold_net_position},
31    types::{AccountBalance, Currency, Money, Price, Quantity},
32};
33use rust_decimal::Decimal;
34
35/// Manages account balance updates and calculations for portfolio management.
36///
37/// The accounts manager handles balance updates for different account types,
38/// including cash and margin accounts, based on order fills and position changes.
39pub struct AccountsManager {
40    clock: Rc<RefCell<dyn Clock>>,
41    cache: Rc<RefCell<Cache>>,
42}
43
44impl Debug for AccountsManager {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct(stringify!(AccountsManager)).finish()
47    }
48}
49
50impl AccountsManager {
51    /// Creates a new [`AccountsManager`] instance.
52    pub fn new(clock: Rc<RefCell<dyn Clock>>, cache: Rc<RefCell<Cache>>) -> Self {
53        Self { clock, cache }
54    }
55
56    /// Updates the given account state based on a filled order.
57    ///
58    /// Mutations are applied to `account` in place so the caller can persist
59    /// the recalculated balances and commissions back to the cache.
60    ///
61    /// # Panics
62    ///
63    /// Panics if the position list for the filled instrument is empty.
64    #[must_use]
65    pub fn update_balances(
66        &self,
67        mut account: AccountAny,
68        instrument: &InstrumentAny,
69        fill: OrderFilled,
70    ) -> (AccountAny, AccountState) {
71        let position_id = if let Some(position_id) = fill.position_id {
72            position_id
73        } else {
74            let cache = self.cache.borrow();
75            let positions_open = cache.positions_open(
76                None,
77                Some(&fill.instrument_id),
78                None,
79                Some(&fill.account_id),
80                None,
81            );
82            positions_open
83                .first()
84                .unwrap_or_else(|| panic!("List of Positions is empty"))
85                .id
86        };
87
88        let position = self.cache.borrow().position_owned(&position_id);
89
90        let pnls = account.calculate_pnls(instrument, &fill, position);
91
92        // Calculate final PnL including commissions
93        match account.base_currency() {
94            Some(base_currency) => {
95                let pnl = pnls.map_or_else(
96                    |_| Money::zero(base_currency),
97                    |pnl_list| {
98                        pnl_list
99                            .first()
100                            .copied()
101                            .unwrap_or_else(|| Money::zero(base_currency))
102                    },
103                );
104
105                self.update_balance_single_currency(&mut account, &fill, pnl);
106            }
107            None => {
108                if let Ok(mut pnl_list) = pnls {
109                    self.update_balance_multi_currency(&mut account, &fill, &mut pnl_list);
110                }
111            }
112        }
113
114        let state = self.generate_account_state(&account, fill.ts_event);
115        (account, state)
116    }
117
118    /// Updates account balances based on open orders.
119    ///
120    /// For cash accounts, updates the balance locked by open orders.
121    /// For margin accounts, updates the initial margin requirements.
122    #[must_use]
123    pub fn update_orders(
124        &self,
125        account: &AccountAny,
126        instrument: &InstrumentAny,
127        orders_open: &[&OrderAny],
128        ts_event: UnixNanos,
129    ) -> Option<(AccountAny, AccountState)> {
130        let mut account = account.clone();
131        self.update_orders_in_place(&mut account, instrument, orders_open, ts_event)
132            .map(|state| (account, state))
133    }
134
135    /// Updates account balances based on open orders in place.
136    ///
137    /// For cash accounts, updates the balance locked by open orders.
138    /// For margin accounts, updates the initial margin requirements.
139    #[must_use]
140    pub fn update_orders_in_place(
141        &self,
142        account: &mut AccountAny,
143        instrument: &InstrumentAny,
144        orders_open: &[&OrderAny],
145        ts_event: UnixNanos,
146    ) -> Option<AccountState> {
147        match account {
148            AccountAny::Margin(margin_account) => {
149                self.update_margin_init(margin_account, instrument, orders_open, ts_event)
150            }
151            AccountAny::Cash(cash_account) => {
152                self.update_balance_locked(cash_account, instrument, orders_open, ts_event)
153            }
154            AccountAny::Betting(betting_account) => self.update_balance_locked_betting(
155                betting_account,
156                instrument,
157                orders_open,
158                ts_event,
159            ),
160        }
161    }
162
163    /// Updates the account based on current open positions.
164    ///
165    /// # Panics
166    ///
167    /// Panics if any position's `instrument_id` does not match the provided `instrument`.
168    #[must_use]
169    pub fn update_positions(
170        &self,
171        account: &MarginAccount,
172        instrument: &InstrumentAny,
173        positions: Vec<&Position>,
174        ts_event: UnixNanos,
175    ) -> Option<(MarginAccount, AccountState)> {
176        let mut account = account.clone();
177        self.update_positions_in_place(&mut account, instrument, positions, ts_event)
178            .map(|state| (account, state))
179    }
180
181    /// Updates the account based on current open positions in place.
182    ///
183    /// Maintenance margin is computed on the net per-instrument exposure: open
184    /// positions are folded into a NETTING-equivalent state and the margin model
185    /// runs once on the result.
186    ///
187    /// # Panics
188    ///
189    /// Panics if any position's `instrument_id` does not match the provided `instrument`.
190    #[must_use]
191    pub fn update_positions_in_place(
192        &self,
193        account: &mut MarginAccount,
194        instrument: &InstrumentAny,
195        positions: Vec<&Position>,
196        ts_event: UnixNanos,
197    ) -> Option<AccountState> {
198        let mut ordered: Vec<&Position> = positions;
199        ordered.sort_by_key(|p| (p.ts_opened, p.id));
200
201        let legs: Vec<(Decimal, Decimal, u64)> = ordered
202            .iter()
203            .map(|p| {
204                assert_eq!(
205                    p.instrument_id,
206                    instrument.id(),
207                    "Position not for instrument {}",
208                    instrument.id()
209                );
210                (
211                    p.signed_decimal_qty(),
212                    Decimal::try_from(p.avg_px_open).unwrap_or(Decimal::ZERO),
213                    p.ts_opened.as_u64(),
214                )
215            })
216            .collect();
217
218        let (net_signed_qty, net_avg_px) = fold_net_position(&legs);
219
220        let currency = account
221            .base_currency
222            .unwrap_or_else(|| instrument.settlement_currency());
223
224        let mut total_margin_maint = Decimal::ZERO;
225
226        let net_qty =
227            match Quantity::from_decimal_dp(net_signed_qty.abs(), instrument.size_precision()) {
228                Ok(q) if q.is_zero() => None,
229                Ok(q) => Some(q),
230                Err(e) => {
231                    log::error!(
232                        "Cannot calculate maintenance (position) margin: net quantity \
233                     conversion failed for {}: {e}",
234                        instrument.id()
235                    );
236                    return None;
237                }
238            };
239
240        if let Some(quantity) = net_qty {
241            let price = Price::from_decimal_dp(net_avg_px, instrument.price_precision()).ok()?;
242            let net_entry = if net_signed_qty > Decimal::ZERO {
243                OrderSide::Buy
244            } else {
245                OrderSide::Sell
246            };
247
248            let margin_maint = match instrument {
249                InstrumentAny::Betting(i) => account
250                    .calculate_maintenance_margin(i, quantity, price, None)
251                    .ok()?,
252                InstrumentAny::BinaryOption(i) => account
253                    .calculate_maintenance_margin(i, quantity, price, None)
254                    .ok()?,
255                InstrumentAny::Cfd(i) => account
256                    .calculate_maintenance_margin(i, quantity, price, None)
257                    .ok()?,
258                InstrumentAny::Commodity(i) => account
259                    .calculate_maintenance_margin(i, quantity, price, None)
260                    .ok()?,
261                InstrumentAny::CryptoFuture(i) => account
262                    .calculate_maintenance_margin(i, quantity, price, None)
263                    .ok()?,
264                InstrumentAny::CryptoFuturesSpread(i) => account
265                    .calculate_maintenance_margin(i, quantity, price, None)
266                    .ok()?,
267                InstrumentAny::CryptoOption(i) => account
268                    .calculate_maintenance_margin(i, quantity, price, None)
269                    .ok()?,
270                InstrumentAny::CryptoOptionSpread(i) => account
271                    .calculate_maintenance_margin(i, quantity, price, None)
272                    .ok()?,
273                InstrumentAny::CryptoPerpetual(i) => account
274                    .calculate_maintenance_margin(i, quantity, price, None)
275                    .ok()?,
276                InstrumentAny::CurrencyPair(i) => account
277                    .calculate_maintenance_margin(i, quantity, price, None)
278                    .ok()?,
279                InstrumentAny::Equity(i) => account
280                    .calculate_maintenance_margin(i, quantity, price, None)
281                    .ok()?,
282                InstrumentAny::FuturesContract(i) => account
283                    .calculate_maintenance_margin(i, quantity, price, None)
284                    .ok()?,
285                InstrumentAny::FuturesSpread(i) => account
286                    .calculate_maintenance_margin(i, quantity, price, None)
287                    .ok()?,
288                InstrumentAny::IndexInstrument(i) => account
289                    .calculate_maintenance_margin(i, quantity, price, None)
290                    .ok()?,
291                InstrumentAny::OptionContract(i) => account
292                    .calculate_maintenance_margin(i, quantity, price, None)
293                    .ok()?,
294                InstrumentAny::OptionSpread(i) => account
295                    .calculate_maintenance_margin(i, quantity, price, None)
296                    .ok()?,
297                InstrumentAny::PerpetualContract(i) => account
298                    .calculate_maintenance_margin(i, quantity, price, None)
299                    .ok()?,
300                InstrumentAny::TokenizedAsset(i) => account
301                    .calculate_maintenance_margin(i, quantity, price, None)
302                    .ok()?,
303            };
304
305            total_margin_maint = margin_maint.as_decimal();
306
307            if let Some(base_currency) = account.base_currency {
308                if let Some(xrate) =
309                    self.calculate_xrate_to_base(account.base_currency, instrument, net_entry)
310                {
311                    total_margin_maint *= xrate;
312                } else {
313                    log::debug!(
314                        "Cannot calculate maintenance (position) margin: insufficient data for {}/{}",
315                        instrument.settlement_currency(),
316                        base_currency
317                    );
318                    return None;
319                }
320            }
321        }
322
323        let margin_maint = Money::from_decimal(total_margin_maint, currency).ok()?;
324        if total_margin_maint.is_zero() {
325            account.clear_maintenance_margin(instrument.id());
326        } else {
327            account.update_maintenance_margin(instrument.id(), margin_maint);
328        }
329
330        log::info!("{} margin_maint={margin_maint}", instrument.id());
331
332        Some(self.generate_margin_account_state(account, ts_event))
333    }
334
335    fn update_balance_locked(
336        &self,
337        account: &mut CashAccount,
338        instrument: &InstrumentAny,
339        orders_open: &[&OrderAny],
340        ts_event: UnixNanos,
341    ) -> Option<AccountState> {
342        if orders_open.is_empty() {
343            account.clear_balance_locked(instrument.id());
344            return Some(self.generate_cash_account_state(account, ts_event));
345        }
346
347        let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
348        let mut base_xrate: Option<Decimal> = None;
349
350        let mut currency = instrument.settlement_currency();
351
352        for order in orders_open {
353            assert_eq!(
354                order.instrument_id(),
355                instrument.id(),
356                "Order not for instrument {}",
357                instrument.id()
358            );
359            assert!(order.is_open(), "Order is not open");
360
361            if order.price().is_none() && order.trigger_price().is_none() {
362                continue;
363            }
364
365            if order.is_reduce_only() {
366                continue; // Does not contribute to locked balance
367            }
368
369            let price = if order.price().is_some() {
370                order.price()
371            } else {
372                order.trigger_price()
373            };
374
375            let mut locked = account
376                .calculate_balance_locked(
377                    instrument,
378                    order.order_side(),
379                    order.quantity(),
380                    price?,
381                    None,
382                )
383                .unwrap();
384
385            if let Some(base_curr) = account.base_currency() {
386                if base_xrate.is_none() {
387                    currency = base_curr;
388                    base_xrate = self.calculate_xrate_to_base(
389                        account.base_currency(),
390                        instrument,
391                        order.order_side(),
392                    );
393                }
394
395                if let Some(xrate) = base_xrate {
396                    locked = match Money::from_decimal(locked.as_decimal() * xrate, currency) {
397                        Ok(money) => money,
398                        Err(e) => {
399                            log::error!("Cannot calculate balance locked: {e}");
400                            return None;
401                        }
402                    };
403                } else {
404                    log::error!(
405                        "Cannot calculate balance locked: insufficient data for {}/{}",
406                        instrument.settlement_currency(),
407                        base_curr
408                    );
409                    return None;
410                }
411            }
412
413            total_locked
414                .entry(locked.currency)
415                .and_modify(|total| *total = *total + locked)
416                .or_insert(locked);
417        }
418
419        if total_locked.is_empty() {
420            account.clear_balance_locked(instrument.id());
421            return Some(self.generate_cash_account_state(account, ts_event));
422        }
423
424        // Clear existing locks before applying new ones to remove stale currency entries
425        account.clear_balance_locked(instrument.id());
426
427        for (_, balance_locked) in total_locked {
428            account.update_balance_locked(instrument.id(), balance_locked);
429            log::info!("{} balance_locked={balance_locked}", instrument.id());
430        }
431
432        Some(self.generate_cash_account_state(account, ts_event))
433    }
434
435    fn update_margin_init(
436        &self,
437        account: &mut MarginAccount,
438        instrument: &InstrumentAny,
439        orders_open: &[&OrderAny],
440        ts_event: UnixNanos,
441    ) -> Option<AccountState> {
442        let mut total_margin_init = Decimal::ZERO;
443        let mut base_xrate: Option<Decimal> = None;
444        let mut currency = instrument.settlement_currency();
445
446        for order in orders_open {
447            assert_eq!(
448                order.instrument_id(),
449                instrument.id(),
450                "Order not for instrument {}",
451                instrument.id()
452            );
453
454            if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) {
455                continue;
456            }
457
458            if order.is_reduce_only() {
459                continue; // Does not contribute to margin
460            }
461
462            let price = if order.price().is_some() {
463                order.price()
464            } else {
465                order.trigger_price()
466            };
467
468            let margin_init = match instrument {
469                InstrumentAny::Betting(i) => account
470                    .calculate_initial_margin(i, order.quantity(), price?, None)
471                    .ok()?,
472                InstrumentAny::BinaryOption(i) => account
473                    .calculate_initial_margin(i, order.quantity(), price?, None)
474                    .ok()?,
475                InstrumentAny::Cfd(i) => account
476                    .calculate_initial_margin(i, order.quantity(), price?, None)
477                    .ok()?,
478                InstrumentAny::Commodity(i) => account
479                    .calculate_initial_margin(i, order.quantity(), price?, None)
480                    .ok()?,
481                InstrumentAny::CryptoFuture(i) => account
482                    .calculate_initial_margin(i, order.quantity(), price?, None)
483                    .ok()?,
484                InstrumentAny::CryptoFuturesSpread(i) => account
485                    .calculate_initial_margin(i, order.quantity(), price?, None)
486                    .ok()?,
487                InstrumentAny::CryptoOption(i) => account
488                    .calculate_initial_margin(i, order.quantity(), price?, None)
489                    .ok()?,
490                InstrumentAny::CryptoOptionSpread(i) => account
491                    .calculate_initial_margin(i, order.quantity(), price?, None)
492                    .ok()?,
493                InstrumentAny::CryptoPerpetual(i) => account
494                    .calculate_initial_margin(i, order.quantity(), price?, None)
495                    .ok()?,
496                InstrumentAny::CurrencyPair(i) => account
497                    .calculate_initial_margin(i, order.quantity(), price?, None)
498                    .ok()?,
499                InstrumentAny::Equity(i) => account
500                    .calculate_initial_margin(i, order.quantity(), price?, None)
501                    .ok()?,
502                InstrumentAny::FuturesContract(i) => account
503                    .calculate_initial_margin(i, order.quantity(), price?, None)
504                    .ok()?,
505                InstrumentAny::FuturesSpread(i) => account
506                    .calculate_initial_margin(i, order.quantity(), price?, None)
507                    .ok()?,
508                InstrumentAny::IndexInstrument(i) => account
509                    .calculate_initial_margin(i, order.quantity(), price?, None)
510                    .ok()?,
511                InstrumentAny::OptionContract(i) => account
512                    .calculate_initial_margin(i, order.quantity(), price?, None)
513                    .ok()?,
514                InstrumentAny::OptionSpread(i) => account
515                    .calculate_initial_margin(i, order.quantity(), price?, None)
516                    .ok()?,
517                InstrumentAny::PerpetualContract(i) => account
518                    .calculate_initial_margin(i, order.quantity(), price?, None)
519                    .ok()?,
520                InstrumentAny::TokenizedAsset(i) => account
521                    .calculate_initial_margin(i, order.quantity(), price?, None)
522                    .ok()?,
523            };
524
525            let mut margin_init = margin_init.as_decimal();
526
527            if let Some(base_currency) = account.base_currency {
528                if base_xrate.is_none() {
529                    currency = base_currency;
530                    base_xrate = self.calculate_xrate_to_base(
531                        account.base_currency,
532                        instrument,
533                        order.order_side(),
534                    );
535                }
536
537                if let Some(xrate) = base_xrate {
538                    margin_init *= xrate;
539                } else {
540                    log::debug!(
541                        "Cannot calculate initial margin: insufficient data for {}/{}",
542                        instrument.settlement_currency(),
543                        base_currency
544                    );
545                    return None;
546                }
547            }
548
549            total_margin_init += margin_init;
550        }
551
552        let money = match Money::from_decimal(total_margin_init, currency) {
553            Ok(money) => money,
554            Err(e) => {
555                log::error!("Cannot calculate initial margin: {e}");
556                return None;
557            }
558        };
559        let margin_init = if total_margin_init.is_zero() {
560            account.clear_initial_margin(instrument.id());
561            money
562        } else {
563            account.update_initial_margin(instrument.id(), money);
564            money
565        };
566
567        log::info!("{} margin_init={margin_init}", instrument.id());
568
569        Some(self.generate_margin_account_state(account, ts_event))
570    }
571
572    fn update_balance_locked_betting(
573        &self,
574        account: &mut BettingAccount,
575        instrument: &InstrumentAny,
576        orders_open: &[&OrderAny],
577        ts_event: UnixNanos,
578    ) -> Option<AccountState> {
579        if orders_open.is_empty() {
580            account.clear_balance_locked(instrument.id());
581            return Some(self.generate_betting_account_state(account, ts_event));
582        }
583
584        let mut total_locked: AHashMap<Currency, Money> = AHashMap::new();
585        let mut base_xrate: Option<Decimal> = None;
586        let mut currency = instrument.settlement_currency();
587
588        for order in orders_open {
589            assert_eq!(
590                order.instrument_id(),
591                instrument.id(),
592                "Order not for instrument {}",
593                instrument.id()
594            );
595            assert!(order.is_open(), "Order is not open");
596
597            if order.price().is_none() && order.trigger_price().is_none() {
598                continue;
599            }
600
601            if order.is_reduce_only() {
602                continue;
603            }
604
605            let price = if order.price().is_some() {
606                order.price()
607            } else {
608                order.trigger_price()
609            };
610
611            let mut locked = account
612                .calculate_balance_locked(
613                    instrument,
614                    order.order_side(),
615                    order.quantity(),
616                    price?,
617                    None,
618                )
619                .unwrap();
620
621            if let Some(base_curr) = account.base_currency() {
622                if base_xrate.is_none() {
623                    currency = base_curr;
624                    base_xrate = self.cache.borrow().get_xrate(
625                        instrument.id().venue,
626                        instrument.settlement_currency(),
627                        base_curr,
628                        PriceType::Mid,
629                    );
630                }
631
632                if let Some(xrate) = base_xrate {
633                    locked = match Money::from_decimal(locked.as_decimal() * xrate, currency) {
634                        Ok(money) => money,
635                        Err(e) => {
636                            log::error!("Cannot calculate balance locked: {e}");
637                            return None;
638                        }
639                    };
640                } else {
641                    log::error!(
642                        "Cannot calculate balance locked: insufficient data for {}/{}",
643                        instrument.settlement_currency(),
644                        base_curr
645                    );
646                    return None;
647                }
648            }
649
650            total_locked
651                .entry(locked.currency)
652                .and_modify(|total| *total = *total + locked)
653                .or_insert(locked);
654        }
655
656        if total_locked.is_empty() {
657            account.clear_balance_locked(instrument.id());
658            return Some(self.generate_betting_account_state(account, ts_event));
659        }
660
661        account.clear_balance_locked(instrument.id());
662
663        for (_, balance_locked) in total_locked {
664            account.update_balance_locked(instrument.id(), balance_locked);
665            log::info!("{} balance_locked={balance_locked}", instrument.id());
666        }
667
668        Some(self.generate_betting_account_state(account, ts_event))
669    }
670
671    fn update_balance_single_currency(
672        &self,
673        account: &mut AccountAny,
674        fill: &OrderFilled,
675        mut pnl: Money,
676    ) {
677        let base_currency = if let Some(currency) = account.base_currency() {
678            currency
679        } else {
680            log::error!("Account has no base currency set");
681            return;
682        };
683
684        let mut balances = Vec::new();
685        let mut commission = fill.commission;
686
687        if let Some(ref mut comm) = commission
688            && comm.currency != base_currency
689        {
690            let xrate = self.cache.borrow().get_xrate(
691                fill.instrument_id.venue,
692                comm.currency,
693                base_currency,
694                if fill.order_side == OrderSide::Sell {
695                    PriceType::Bid
696                } else {
697                    PriceType::Ask
698                },
699            );
700
701            if let Some(xrate) = xrate {
702                *comm = match Money::from_decimal(comm.as_decimal() * xrate, base_currency) {
703                    Ok(money) => money,
704                    Err(e) => {
705                        log::error!("Cannot calculate account state: {e}");
706                        return;
707                    }
708                };
709            } else {
710                log::error!(
711                    "Cannot calculate account state: insufficient data for {}/{}",
712                    comm.currency,
713                    base_currency
714                );
715                return;
716            }
717        }
718
719        if pnl.currency != base_currency {
720            let xrate = self.cache.borrow().get_xrate(
721                fill.instrument_id.venue,
722                pnl.currency,
723                base_currency,
724                if fill.order_side == OrderSide::Sell {
725                    PriceType::Bid
726                } else {
727                    PriceType::Ask
728                },
729            );
730
731            if let Some(xrate) = xrate {
732                pnl = match Money::from_decimal(pnl.as_decimal() * xrate, base_currency) {
733                    Ok(money) => money,
734                    Err(e) => {
735                        log::error!("Cannot calculate account state: {e}");
736                        return;
737                    }
738                };
739            } else {
740                log::error!(
741                    "Cannot calculate account state: insufficient data for {}/{}",
742                    pnl.currency,
743                    base_currency
744                );
745                return;
746            }
747        }
748
749        if let Some(comm) = commission {
750            pnl = pnl - comm;
751        }
752
753        if pnl.is_zero() {
754            return;
755        }
756
757        let existing_balances = account.balances();
758        let balance = if let Some(b) = existing_balances.get(&pnl.currency) {
759            b
760        } else {
761            log::error!(
762                "Cannot complete transaction: no balance for {}",
763                pnl.currency
764            );
765            return;
766        };
767
768        let new_total = balance.total.as_decimal() + pnl.as_decimal();
769
770        let new_balance = match AccountBalance::from_total_and_locked(
771            new_total,
772            balance.locked.as_decimal(),
773            pnl.currency,
774        ) {
775            Ok(new_balance) => new_balance,
776            Err(e) => {
777                log::error!("Cannot update {} balance: {e}", pnl.currency);
778                return;
779            }
780        };
781
782        balances.push(new_balance);
783
784        match account {
785            AccountAny::Margin(margin) => {
786                margin.update_balances(&balances);
787
788                if let Some(comm) = commission {
789                    margin.update_commissions(comm);
790                }
791            }
792            AccountAny::Cash(cash) => {
793                if let Err(e) = cash.update_balances(&balances) {
794                    log::error!("Cannot update cash account balance: {e}");
795                    return;
796                }
797
798                if let Some(comm) = commission {
799                    cash.update_commissions(comm);
800                }
801            }
802            AccountAny::Betting(betting) => {
803                if let Err(e) = betting.update_balances(&balances) {
804                    log::error!("Cannot update betting account balance: {e}");
805                    return;
806                }
807
808                if let Some(comm) = commission {
809                    betting.update_commissions(comm);
810                }
811            }
812        }
813    }
814
815    fn update_balance_multi_currency(
816        &self,
817        account: &mut AccountAny,
818        fill: &OrderFilled,
819        pnls: &mut [Money],
820    ) {
821        let mut new_balances = Vec::new();
822        let commission = fill.commission;
823        let mut apply_commission = commission.is_some_and(|c| !c.is_zero());
824
825        for pnl in pnls.iter_mut() {
826            if apply_commission && pnl.currency == commission.unwrap().currency {
827                *pnl = *pnl - commission.unwrap();
828                apply_commission = false;
829            }
830
831            if pnl.is_zero() {
832                continue; // No Adjustment
833            }
834
835            let currency = pnl.currency;
836            let balances = account.balances();
837
838            let new_balance = if let Some(balance) = balances.get(&currency) {
839                let new_total = balance.total.as_decimal() + pnl.as_decimal();
840                let mut new_locked = balance.locked.as_decimal();
841
842                if pnl.as_decimal() < Decimal::ZERO
843                    && fill.order_type != OrderType::Market
844                    && !self.is_sports_betting_fill(fill.instrument_id)
845                {
846                    new_locked += pnl.as_decimal();
847
848                    if new_locked < Decimal::ZERO {
849                        new_locked = Decimal::ZERO;
850                    }
851                }
852
853                match AccountBalance::from_total_and_locked(new_total, new_locked, currency) {
854                    Ok(new_balance) => new_balance,
855                    Err(e) => {
856                        log::error!("Cannot update {currency} balance: {e}");
857                        return;
858                    }
859                }
860            } else {
861                // Mirrors Python `_update_balance_multi_currency`: a fill that
862                // would open a new debit currency on a non-seeded account is
863                // rejected even when `allow_cash_borrowing=true`. The
864                // existing-currency branch above lets the per-account
865                // `update_balances` enforce the borrowing policy, so the two
866                // branches are intentionally asymmetric until cross-currency
867                // equity tracking lands (see TODO in `manager.pyx`).
868                if pnl.as_decimal() < Decimal::ZERO {
869                    log::error!(
870                        "Cannot complete transaction: no {currency} to deduct a {pnl} realized PnL from"
871                    );
872                    return;
873                }
874                AccountBalance::new(*pnl, Money::zero(currency), *pnl)
875            };
876
877            new_balances.push(new_balance);
878        }
879
880        if apply_commission {
881            let commission = commission.unwrap();
882            let currency = commission.currency;
883            let balances = account.balances();
884
885            let commission_balance = if let Some(balance) = balances.get(&currency) {
886                let new_total = balance.total.as_decimal() - commission.as_decimal();
887
888                match AccountBalance::from_total_and_locked(
889                    new_total,
890                    balance.locked.as_decimal(),
891                    currency,
892                ) {
893                    Ok(commission_balance) => commission_balance,
894                    Err(e) => {
895                        log::error!("Cannot deduct {currency} commission: {e}");
896                        return;
897                    }
898                }
899            } else {
900                if commission.as_decimal() > Decimal::ZERO {
901                    log::error!(
902                        "Cannot complete transaction: no {currency} balance to deduct a {commission} commission from"
903                    );
904                    return;
905                }
906                let rebate = -commission.as_decimal();
907                match AccountBalance::from_total_and_locked(rebate, Decimal::ZERO, currency) {
908                    Ok(commission_balance) => commission_balance,
909                    Err(e) => {
910                        log::error!("Cannot credit {currency} commission rebate: {e}");
911                        return;
912                    }
913                }
914            };
915            new_balances.push(commission_balance);
916        }
917
918        if new_balances.is_empty() {
919            return;
920        }
921
922        match account {
923            AccountAny::Margin(margin) => {
924                margin.update_balances(&new_balances);
925
926                if let Some(commission) = commission {
927                    margin.update_commissions(commission);
928                }
929            }
930            AccountAny::Cash(cash) => {
931                if let Err(e) = cash.update_balances(&new_balances) {
932                    log::error!("Cannot update cash account balance: {e}");
933                    return;
934                }
935
936                if let Some(commission) = commission {
937                    cash.update_commissions(commission);
938                }
939            }
940            AccountAny::Betting(betting) => {
941                if let Err(e) = betting.update_balances(&new_balances) {
942                    log::error!("Cannot update betting account balance: {e}");
943                    return;
944                }
945
946                if let Some(commission) = commission {
947                    betting.update_commissions(commission);
948                }
949            }
950        }
951    }
952
953    fn is_sports_betting_fill(&self, instrument_id: InstrumentId) -> bool {
954        self.cache
955            .borrow()
956            .instrument(&instrument_id)
957            .is_some_and(|instrument| matches!(instrument, InstrumentAny::Betting(_)))
958    }
959
960    fn generate_account_state(&self, account: &AccountAny, ts_event: UnixNanos) -> AccountState {
961        match account {
962            AccountAny::Margin(margin_account) => {
963                self.generate_margin_account_state(margin_account, ts_event)
964            }
965            AccountAny::Cash(cash_account) => {
966                self.generate_cash_account_state(cash_account, ts_event)
967            }
968            AccountAny::Betting(betting_account) => {
969                self.generate_betting_account_state(betting_account, ts_event)
970            }
971        }
972    }
973
974    fn generate_margin_account_state(
975        &self,
976        margin_account: &MarginAccount,
977        ts_event: UnixNanos,
978    ) -> AccountState {
979        // Include both per-instrument (`margins`) and account-wide
980        // (`account_margins`, keyed by collateral currency) entries so
981        // regenerated state events preserve the full margin picture.
982        let mut margins: Vec<_> = margin_account.margins.values().copied().collect();
983        margins.extend(margin_account.account_margins.values().copied());
984        AccountState::new(
985            margin_account.id,
986            AccountType::Margin,
987            margin_account.balances.clone().into_values().collect(),
988            margins,
989            false,
990            UUID4::new(),
991            ts_event,
992            self.clock.borrow().timestamp_ns(),
993            margin_account.base_currency(),
994        )
995    }
996
997    fn generate_cash_account_state(
998        &self,
999        cash_account: &CashAccount,
1000        ts_event: UnixNanos,
1001    ) -> AccountState {
1002        AccountState::new(
1003            cash_account.id,
1004            AccountType::Cash,
1005            cash_account.balances.clone().into_values().collect(),
1006            vec![],
1007            false,
1008            UUID4::new(),
1009            ts_event,
1010            self.clock.borrow().timestamp_ns(),
1011            cash_account.base_currency(),
1012        )
1013    }
1014
1015    fn generate_betting_account_state(
1016        &self,
1017        betting_account: &BettingAccount,
1018        ts_event: UnixNanos,
1019    ) -> AccountState {
1020        AccountState::new(
1021            betting_account.id,
1022            AccountType::Betting,
1023            betting_account.balances.clone().into_values().collect(),
1024            vec![],
1025            false,
1026            UUID4::new(),
1027            ts_event,
1028            self.clock.borrow().timestamp_ns(),
1029            betting_account.base_currency(),
1030        )
1031    }
1032
1033    fn calculate_xrate_to_base(
1034        &self,
1035        base_currency: Option<Currency>,
1036        instrument: &InstrumentAny,
1037        side: OrderSide,
1038    ) -> Option<Decimal> {
1039        match base_currency {
1040            None => Some(Decimal::ONE),
1041            Some(base_curr) => self.cache.borrow().get_xrate(
1042                instrument.id().venue,
1043                instrument.settlement_currency(),
1044                base_curr,
1045                if side == OrderSide::Buy {
1046                    PriceType::Bid
1047                } else {
1048                    PriceType::Ask
1049                },
1050            ),
1051        }
1052    }
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use std::{cell::RefCell, rc::Rc};
1058
1059    use nautilus_common::{cache::Cache, clock::TestClock};
1060    use nautilus_model::{
1061        accounts::{BettingAccount, CashAccount, MarginAccount},
1062        data::QuoteTick,
1063        enums::{AccountType, OmsType, OrderSide, OrderType},
1064        events::{
1065            AccountState, OrderAccepted, OrderEventAny, OrderFilled, OrderSubmitted,
1066            order::spec::{OrderAcceptedSpec, OrderFilledSpec, OrderSubmittedSpec},
1067        },
1068        identifiers::{
1069            AccountId, ClientOrderId, InstrumentId, PositionId, Symbol, TradeId, Venue,
1070            VenueOrderId,
1071        },
1072        instruments::{
1073            Instrument, InstrumentAny,
1074            stubs::{audusd_sim, betting, currency_pair_btcusdt, default_fx_ccy},
1075        },
1076        orders::{OrderAny, OrderTestBuilder},
1077        position::Position,
1078        types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
1079    };
1080    use rstest::rstest;
1081
1082    use super::*;
1083
1084    #[rstest]
1085    fn test_update_balance_locked_with_base_currency_multiple_orders() {
1086        let usd = Currency::USD();
1087        let account_state = AccountState::new(
1088            AccountId::new("SIM-001"),
1089            AccountType::Cash,
1090            vec![AccountBalance::new(
1091                Money::new(1_000_000.0, usd),
1092                Money::zero(usd),
1093                Money::new(1_000_000.0, usd),
1094            )],
1095            Vec::new(),
1096            true,
1097            UUID4::new(),
1098            UnixNanos::default(),
1099            UnixNanos::default(),
1100            Some(usd),
1101        );
1102
1103        let account = CashAccount::new(account_state, true, false);
1104
1105        let clock = Rc::new(RefCell::new(TestClock::new()));
1106        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1107        cache
1108            .borrow_mut()
1109            .add_account(AccountAny::Cash(account.clone()))
1110            .unwrap();
1111
1112        let manager = AccountsManager::new(clock, cache);
1113
1114        let instrument = audusd_sim();
1115
1116        let order1 = OrderTestBuilder::new(OrderType::Limit)
1117            .instrument_id(instrument.id())
1118            .side(OrderSide::Buy)
1119            .quantity(Quantity::from("100000"))
1120            .price(Price::from("0.75000"))
1121            .build();
1122
1123        let order2 = OrderTestBuilder::new(OrderType::Limit)
1124            .instrument_id(instrument.id())
1125            .side(OrderSide::Buy)
1126            .quantity(Quantity::from("50000"))
1127            .price(Price::from("0.74500"))
1128            .build();
1129
1130        let order3 = OrderTestBuilder::new(OrderType::Limit)
1131            .instrument_id(instrument.id())
1132            .side(OrderSide::Buy)
1133            .quantity(Quantity::from("75000"))
1134            .price(Price::from("0.74000"))
1135            .build();
1136
1137        let mut order1 = order1;
1138        let mut order2 = order2;
1139        let mut order3 = order3;
1140
1141        let submitted1 = order_submitted_for(&order1);
1142        let accepted1 = order_accepted_for(&order1, VenueOrderId::new("1"));
1143
1144        order1.apply(OrderEventAny::Submitted(submitted1)).unwrap();
1145        order1.apply(OrderEventAny::Accepted(accepted1)).unwrap();
1146
1147        let submitted2 = order_submitted_for(&order2);
1148        let accepted2 = order_accepted_for(&order2, VenueOrderId::new("2"));
1149
1150        order2.apply(OrderEventAny::Submitted(submitted2)).unwrap();
1151        order2.apply(OrderEventAny::Accepted(accepted2)).unwrap();
1152
1153        let submitted3 = order_submitted_for(&order3);
1154        let accepted3 = order_accepted_for(&order3, VenueOrderId::new("3"));
1155
1156        order3.apply(OrderEventAny::Submitted(submitted3)).unwrap();
1157        order3.apply(OrderEventAny::Accepted(accepted3)).unwrap();
1158
1159        let orders: Vec<&OrderAny> = vec![&order1, &order2, &order3];
1160
1161        let result = manager.update_orders(
1162            &AccountAny::Cash(account),
1163            &InstrumentAny::CurrencyPair(instrument),
1164            &orders,
1165            UnixNanos::default(),
1166        );
1167
1168        assert!(result.is_some());
1169        let (updated_account, _state) = result.unwrap();
1170
1171        if let AccountAny::Cash(cash_account) = updated_account {
1172            let locked_balance = cash_account.balance_locked(Some(usd));
1173
1174            // Order 1: 100k * 0.75 = 75k, Order 2: 50k * 0.745 = 37.25k, Order 3: 75k * 0.74 = 55.5k
1175            let expected_locked = Money::new(167_750.0, usd);
1176
1177            assert_eq!(locked_balance, Some(expected_locked));
1178            let aud = Currency::AUD();
1179            assert_eq!(cash_account.balance_locked(Some(aud)), None);
1180        } else {
1181            panic!("Expected CashAccount");
1182        }
1183    }
1184
1185    #[rstest]
1186    fn test_update_orders_betting_account_uses_liability_for_locked_balance() {
1187        let gbp = Currency::GBP();
1188        let account_state = AccountState::new(
1189            AccountId::new("BETTING-001"),
1190            AccountType::Betting,
1191            vec![AccountBalance::new(
1192                Money::new(1_000.0, gbp),
1193                Money::zero(gbp),
1194                Money::new(1_000.0, gbp),
1195            )],
1196            Vec::new(),
1197            true,
1198            UUID4::new(),
1199            UnixNanos::default(),
1200            UnixNanos::default(),
1201            Some(gbp),
1202        );
1203
1204        let account = BettingAccount::new(account_state, true);
1205
1206        let clock = Rc::new(RefCell::new(TestClock::new()));
1207        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1208        cache
1209            .borrow_mut()
1210            .add_account(AccountAny::Betting(account.clone()))
1211            .unwrap();
1212
1213        let manager = AccountsManager::new(clock, cache);
1214        let instrument = betting();
1215
1216        let mut back_order = OrderTestBuilder::new(OrderType::Limit)
1217            .instrument_id(instrument.id())
1218            .side(OrderSide::Buy)
1219            .quantity(Quantity::from("10"))
1220            .price(Price::from("1.25"))
1221            .build();
1222
1223        let mut lay_order = OrderTestBuilder::new(OrderType::Limit)
1224            .instrument_id(instrument.id())
1225            .side(OrderSide::Sell)
1226            .quantity(Quantity::from("12"))
1227            .price(Price::from("3.00"))
1228            .build();
1229
1230        let submitted_back =
1231            order_submitted_for_account(&back_order, AccountId::new("BETTING-001"));
1232        let accepted_back = order_accepted_for_account(
1233            &back_order,
1234            VenueOrderId::new("B1"),
1235            AccountId::new("BETTING-001"),
1236        );
1237        back_order
1238            .apply(OrderEventAny::Submitted(submitted_back))
1239            .unwrap();
1240        back_order
1241            .apply(OrderEventAny::Accepted(accepted_back))
1242            .unwrap();
1243
1244        let submitted_lay = order_submitted_for_account(&lay_order, AccountId::new("BETTING-001"));
1245        let accepted_lay = order_accepted_for_account(
1246            &lay_order,
1247            VenueOrderId::new("L1"),
1248            AccountId::new("BETTING-001"),
1249        );
1250        lay_order
1251            .apply(OrderEventAny::Submitted(submitted_lay))
1252            .unwrap();
1253        lay_order
1254            .apply(OrderEventAny::Accepted(accepted_lay))
1255            .unwrap();
1256
1257        let orders: Vec<&OrderAny> = vec![&back_order, &lay_order];
1258        let result = manager.update_orders(
1259            &AccountAny::Betting(account),
1260            &InstrumentAny::Betting(instrument),
1261            &orders,
1262            UnixNanos::default(),
1263        );
1264
1265        assert!(result.is_some());
1266        let (updated_account, state) = result.unwrap();
1267
1268        if let AccountAny::Betting(betting_account) = updated_account {
1269            assert_eq!(
1270                betting_account.balance_locked(Some(gbp)),
1271                Some(Money::new(14.5, gbp))
1272            );
1273            assert_eq!(
1274                betting_account.balance_free(Some(gbp)),
1275                Some(Money::new(985.5, gbp))
1276            );
1277            assert_eq!(state.account_type, AccountType::Betting);
1278        } else {
1279            panic!("Expected BettingAccount");
1280        }
1281    }
1282
1283    #[rstest]
1284    fn test_betting_order_canceled_releases_locked_balance() {
1285        let gbp = Currency::GBP();
1286        let account_state = AccountState::new(
1287            AccountId::new("BETFAIR-001"),
1288            AccountType::Betting,
1289            vec![AccountBalance::new(
1290                Money::new(1_000.0, gbp),
1291                Money::zero(gbp),
1292                Money::new(1_000.0, gbp),
1293            )],
1294            Vec::new(),
1295            true,
1296            UUID4::new(),
1297            UnixNanos::default(),
1298            UnixNanos::default(),
1299            Some(gbp),
1300        );
1301
1302        let account = BettingAccount::new(account_state, true);
1303
1304        let clock = Rc::new(RefCell::new(TestClock::new()));
1305        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1306        cache
1307            .borrow_mut()
1308            .add_account(AccountAny::Betting(account.clone()))
1309            .unwrap();
1310
1311        let manager = AccountsManager::new(clock, cache);
1312        let instrument = betting();
1313
1314        let mut order = OrderTestBuilder::new(OrderType::Limit)
1315            .instrument_id(instrument.id())
1316            .side(OrderSide::Buy)
1317            .quantity(Quantity::from("10"))
1318            .price(Price::from("5.0"))
1319            .build();
1320
1321        let submitted = order_submitted_for_account(&order, AccountId::new("BETFAIR-001"));
1322        let accepted = order_accepted_for_account(
1323            &order,
1324            VenueOrderId::new("B2"),
1325            AccountId::new("BETFAIR-001"),
1326        );
1327
1328        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1329        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1330
1331        let result = manager.update_orders(
1332            &AccountAny::Betting(account),
1333            &InstrumentAny::Betting(instrument.clone()),
1334            &[&order],
1335            UnixNanos::default(),
1336        );
1337
1338        assert!(result.is_some());
1339        let (updated_account, _) = result.unwrap();
1340
1341        if let AccountAny::Betting(ref betting_account) = updated_account {
1342            assert_eq!(
1343                betting_account.balance_locked(Some(gbp)),
1344                Some(Money::new(40.0, gbp))
1345            );
1346            assert_eq!(
1347                betting_account.balance_free(Some(gbp)),
1348                Some(Money::new(960.0, gbp))
1349            );
1350        } else {
1351            panic!("Expected BettingAccount");
1352        }
1353
1354        let result = manager.update_orders(
1355            &updated_account,
1356            &InstrumentAny::Betting(instrument),
1357            &[],
1358            UnixNanos::default(),
1359        );
1360
1361        assert!(result.is_some());
1362        let (final_account, _) = result.unwrap();
1363
1364        if let AccountAny::Betting(betting_account) = final_account {
1365            assert_eq!(
1366                betting_account.balance_locked(Some(gbp)),
1367                Some(Money::zero(gbp))
1368            );
1369            assert_eq!(
1370                betting_account.balance_free(Some(gbp)),
1371                Some(Money::new(1_000.0, gbp))
1372            );
1373            assert_eq!(
1374                betting_account.balance_total(Some(gbp)),
1375                Some(Money::new(1_000.0, gbp))
1376            );
1377        } else {
1378            panic!("Expected BettingAccount");
1379        }
1380    }
1381
1382    #[rstest]
1383    fn test_update_orders_clears_stale_currency_locks_when_order_sides_change() {
1384        let usd = Currency::USD();
1385        let aud = Currency::AUD();
1386        let account_state = AccountState::new(
1387            AccountId::new("SIM-001"),
1388            AccountType::Cash,
1389            vec![
1390                AccountBalance::new(
1391                    Money::new(1_000_000.0, usd),
1392                    Money::zero(usd),
1393                    Money::new(1_000_000.0, usd),
1394                ),
1395                AccountBalance::new(
1396                    Money::new(1_000_000.0, aud),
1397                    Money::zero(aud),
1398                    Money::new(1_000_000.0, aud),
1399                ),
1400            ],
1401            Vec::new(),
1402            true,
1403            UUID4::new(),
1404            UnixNanos::default(),
1405            UnixNanos::default(),
1406            None,
1407        );
1408
1409        let account = CashAccount::new(account_state, true, false);
1410
1411        let clock = Rc::new(RefCell::new(TestClock::new()));
1412        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1413        cache
1414            .borrow_mut()
1415            .add_account(AccountAny::Cash(account.clone()))
1416            .unwrap();
1417
1418        let manager = AccountsManager::new(clock, cache);
1419        let instrument = audusd_sim();
1420
1421        let mut buy_order = OrderTestBuilder::new(OrderType::Limit)
1422            .instrument_id(instrument.id())
1423            .side(OrderSide::Buy)
1424            .quantity(Quantity::from("100000"))
1425            .price(Price::from("0.80000"))
1426            .build();
1427
1428        let mut sell_order = OrderTestBuilder::new(OrderType::Limit)
1429            .instrument_id(instrument.id())
1430            .side(OrderSide::Sell)
1431            .quantity(Quantity::from("50000"))
1432            .price(Price::from("0.81000"))
1433            .build();
1434
1435        // Submit and accept orders
1436        let submitted_buy = order_submitted_for(&buy_order);
1437        let accepted_buy = order_accepted_for(&buy_order, VenueOrderId::new("1"));
1438        buy_order
1439            .apply(OrderEventAny::Submitted(submitted_buy))
1440            .unwrap();
1441        buy_order
1442            .apply(OrderEventAny::Accepted(accepted_buy))
1443            .unwrap();
1444
1445        let submitted_sell = order_submitted_for(&sell_order);
1446        let accepted_sell = order_accepted_for(&sell_order, VenueOrderId::new("2"));
1447        sell_order
1448            .apply(OrderEventAny::Submitted(submitted_sell))
1449            .unwrap();
1450        sell_order
1451            .apply(OrderEventAny::Accepted(accepted_sell))
1452            .unwrap();
1453
1454        let orders_both: Vec<&OrderAny> = vec![&buy_order, &sell_order];
1455        let result = manager.update_orders(
1456            &AccountAny::Cash(account),
1457            &InstrumentAny::CurrencyPair(instrument.clone()),
1458            &orders_both,
1459            UnixNanos::default(),
1460        );
1461
1462        assert!(result.is_some());
1463        let (updated_account, _) = result.unwrap();
1464
1465        if let AccountAny::Cash(cash_account) = &updated_account {
1466            assert_eq!(
1467                cash_account.balance_locked(Some(usd)),
1468                Some(Money::new(80_000.0, usd))
1469            );
1470            assert_eq!(
1471                cash_account.balance_locked(Some(aud)),
1472                Some(Money::new(50_000.0, aud))
1473            );
1474        } else {
1475            panic!("Expected CashAccount");
1476        }
1477
1478        // Cancel BUY order, only SELL remains - USD lock should be cleared
1479        let orders_sell_only: Vec<&OrderAny> = vec![&sell_order];
1480        let result = manager.update_orders(
1481            &updated_account,
1482            &InstrumentAny::CurrencyPair(instrument),
1483            &orders_sell_only,
1484            UnixNanos::default(),
1485        );
1486
1487        assert!(result.is_some());
1488        let (final_account, _) = result.unwrap();
1489
1490        if let AccountAny::Cash(cash_account) = final_account {
1491            assert_eq!(
1492                cash_account.balance_locked(Some(usd)),
1493                Some(Money::zero(usd))
1494            );
1495            assert_eq!(
1496                cash_account.balance_locked(Some(aud)),
1497                Some(Money::new(50_000.0, aud))
1498            );
1499        } else {
1500            panic!("Expected CashAccount");
1501        }
1502    }
1503
1504    #[rstest]
1505    fn test_update_orders_margin_init_xrate_unavailable_returns_none() {
1506        let eur = Currency::EUR();
1507        let account_state = AccountState::new(
1508            AccountId::new("SIM-001"),
1509            AccountType::Margin,
1510            vec![AccountBalance::new(
1511                Money::new(1_000_000.0, eur),
1512                Money::zero(eur),
1513                Money::new(1_000_000.0, eur),
1514            )],
1515            Vec::new(),
1516            true,
1517            UUID4::new(),
1518            UnixNanos::default(),
1519            UnixNanos::default(),
1520            Some(eur),
1521        );
1522        let mut account = MarginAccount::new(account_state, true);
1523        let instrument = audusd_sim();
1524        let prior_margin = Money::new(10.0, eur);
1525        account.update_initial_margin(instrument.id(), prior_margin);
1526
1527        let clock = Rc::new(RefCell::new(TestClock::new()));
1528        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1529        let manager = AccountsManager::new(clock, cache);
1530
1531        let mut order = OrderTestBuilder::new(OrderType::Limit)
1532            .instrument_id(instrument.id())
1533            .side(OrderSide::Buy)
1534            .quantity(Quantity::from("100000"))
1535            .price(Price::from("0.80000"))
1536            .build();
1537
1538        let submitted = order_submitted_for(&order);
1539        let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1540        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1541        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1542
1543        let mut account = AccountAny::Margin(account);
1544        let result = manager.update_orders_in_place(
1545            &mut account,
1546            &InstrumentAny::CurrencyPair(instrument.clone()),
1547            &[&order],
1548            UnixNanos::default(),
1549        );
1550
1551        assert!(result.is_none(), "xrate-unavailable must return None");
1552
1553        match account {
1554            AccountAny::Margin(margin_account) => {
1555                assert_eq!(margin_account.initial_margin(instrument.id()), prior_margin);
1556                assert_eq!(margin_account.balance_locked(Some(eur)), Some(prior_margin));
1557            }
1558            _ => panic!("Expected MarginAccount"),
1559        }
1560    }
1561
1562    #[rstest]
1563    fn test_update_balance_locked_base_xrate_uses_bid_for_buy_order() {
1564        let eur = Currency::EUR();
1565        let account_state = AccountState::new(
1566            AccountId::new("SIM-001"),
1567            AccountType::Cash,
1568            vec![AccountBalance::new(
1569                Money::new(1_000.0, eur),
1570                Money::zero(eur),
1571                Money::new(1_000.0, eur),
1572            )],
1573            Vec::new(),
1574            true,
1575            UUID4::new(),
1576            UnixNanos::default(),
1577            UnixNanos::default(),
1578            Some(eur),
1579        );
1580        let account = CashAccount::new(account_state, true, false);
1581
1582        let clock = Rc::new(RefCell::new(TestClock::new()));
1583        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1584        add_usdeur_quote(&cache, "0.90000", "1.10000");
1585        let manager = AccountsManager::new(clock, cache);
1586
1587        let instrument = audusd_sim();
1588        let mut order = OrderTestBuilder::new(OrderType::Limit)
1589            .instrument_id(instrument.id())
1590            .side(OrderSide::Buy)
1591            .quantity(Quantity::from("100"))
1592            .price(Price::from("2.00000"))
1593            .build();
1594
1595        let submitted = order_submitted_for(&order);
1596        let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1597        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1598        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1599
1600        let result = manager.update_orders(
1601            &AccountAny::Cash(account),
1602            &InstrumentAny::CurrencyPair(instrument),
1603            &[&order],
1604            UnixNanos::default(),
1605        );
1606
1607        assert!(result.is_some());
1608        let (updated_account, _) = result.unwrap();
1609
1610        match updated_account {
1611            AccountAny::Cash(cash) => {
1612                assert_eq!(cash.balance_locked(Some(eur)), Some(Money::new(180.0, eur)));
1613            }
1614            _ => panic!("Expected CashAccount"),
1615        }
1616    }
1617
1618    #[rstest]
1619    fn test_update_margin_init_base_xrate_uses_ask_for_sell_order() {
1620        let eur = Currency::EUR();
1621        let account_state = AccountState::new(
1622            AccountId::new("SIM-001"),
1623            AccountType::Margin,
1624            vec![AccountBalance::new(
1625                Money::new(1_000.0, eur),
1626                Money::zero(eur),
1627                Money::new(1_000.0, eur),
1628            )],
1629            Vec::new(),
1630            true,
1631            UUID4::new(),
1632            UnixNanos::default(),
1633            UnixNanos::default(),
1634            Some(eur),
1635        );
1636        let account = MarginAccount::new(account_state, true);
1637
1638        let clock = Rc::new(RefCell::new(TestClock::new()));
1639        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1640        add_usdeur_quote(&cache, "0.90000", "1.10000");
1641        let manager = AccountsManager::new(clock, cache);
1642
1643        let instrument = audusd_sim();
1644        let mut order = OrderTestBuilder::new(OrderType::Limit)
1645            .instrument_id(instrument.id())
1646            .side(OrderSide::Sell)
1647            .quantity(Quantity::from("100"))
1648            .price(Price::from("2.00000"))
1649            .build();
1650
1651        let submitted = order_submitted_for(&order);
1652        let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1653        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1654        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1655
1656        let result = manager.update_orders(
1657            &AccountAny::Margin(account),
1658            &InstrumentAny::CurrencyPair(instrument.clone()),
1659            &[&order],
1660            UnixNanos::default(),
1661        );
1662
1663        assert!(result.is_some());
1664        let (updated_account, _) = result.unwrap();
1665
1666        match updated_account {
1667            AccountAny::Margin(margin) => {
1668                assert_eq!(
1669                    margin.initial_margin(instrument.id()),
1670                    Money::new(6.60, eur)
1671                );
1672            }
1673            _ => panic!("Expected MarginAccount"),
1674        }
1675    }
1676
1677    #[rstest]
1678    fn test_update_margin_init_empty_orders_clears_prior_initial_margin() {
1679        let usd = Currency::USD();
1680        let mut account = build_margin_account_usd(1_000_000.0);
1681        let instrument = audusd_sim();
1682        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
1683        account.update_margin(MarginBalance::new(
1684            Money::new(25.0, usd),
1685            Money::zero(usd),
1686            Some(instrument.id()),
1687        ));
1688
1689        let clock = Rc::new(RefCell::new(TestClock::new()));
1690        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1691        let manager = AccountsManager::new(clock, cache);
1692
1693        let state = manager
1694            .update_margin_init(&mut account, &instrument_any, &[], UnixNanos::default())
1695            .expect("initial margin clear should generate account state");
1696
1697        assert!(account.margin(&instrument.id()).is_none());
1698        assert!(state.margins.is_empty());
1699    }
1700
1701    #[rstest]
1702    fn test_update_margin_init_empty_orders_preserves_prior_maintenance_margin() {
1703        let usd = Currency::USD();
1704        let mut account = build_margin_account_usd(1_000_000.0);
1705        let instrument = audusd_sim();
1706        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
1707        let maintenance = Money::new(12.0, usd);
1708        account.update_margin(MarginBalance::new(
1709            Money::new(25.0, usd),
1710            maintenance,
1711            Some(instrument.id()),
1712        ));
1713
1714        let clock = Rc::new(RefCell::new(TestClock::new()));
1715        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1716        let manager = AccountsManager::new(clock, cache);
1717
1718        let state = manager
1719            .update_margin_init(&mut account, &instrument_any, &[], UnixNanos::default())
1720            .expect("initial margin clear should generate account state");
1721
1722        let margin = account
1723            .margin(&instrument.id())
1724            .expect("maintenance margin should remain");
1725        assert_eq!(margin.initial, Money::zero(usd));
1726        assert_eq!(margin.maintenance, maintenance);
1727        assert_eq!(state.margins, vec![margin]);
1728    }
1729
1730    #[rstest]
1731    fn test_cash_account_rejects_negative_balance_when_borrowing_disabled() {
1732        let usd = Currency::USD();
1733        let account_state = AccountState::new(
1734            AccountId::new("SIM-001"),
1735            AccountType::Cash,
1736            vec![AccountBalance::new(
1737                Money::new(1_000.0, usd),
1738                Money::zero(usd),
1739                Money::new(1_000.0, usd),
1740            )],
1741            Vec::new(),
1742            true,
1743            UUID4::new(),
1744            UnixNanos::default(),
1745            UnixNanos::default(),
1746            Some(usd),
1747        );
1748
1749        let mut account = CashAccount::new(account_state, true, false);
1750
1751        let negative_balances = vec![AccountBalance::new(
1752            Money::new(-500.0, usd),
1753            Money::zero(usd),
1754            Money::new(-500.0, usd),
1755        )];
1756
1757        let result = account.update_balances(&negative_balances);
1758
1759        assert!(result.is_err());
1760        let err_msg = result.unwrap_err().to_string();
1761        assert!(err_msg.contains("negative"));
1762        assert!(err_msg.contains("borrowing not allowed"));
1763    }
1764
1765    #[rstest]
1766    fn test_manager_update_balances_skips_update_on_negative_balance_error() {
1767        let usd = Currency::USD();
1768        let account_state = AccountState::new(
1769            AccountId::new("SIM-001"),
1770            AccountType::Cash,
1771            vec![AccountBalance::new(
1772                Money::new(100.0, usd),
1773                Money::zero(usd),
1774                Money::new(100.0, usd),
1775            )],
1776            Vec::new(),
1777            true,
1778            UUID4::new(),
1779            UnixNanos::default(),
1780            UnixNanos::default(),
1781            Some(usd),
1782        );
1783
1784        let account = CashAccount::new(account_state, true, false);
1785        let initial_balance = account.balance_total(Some(usd)).unwrap();
1786
1787        let clock = Rc::new(RefCell::new(TestClock::new()));
1788        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1789        cache
1790            .borrow_mut()
1791            .add_account(AccountAny::Cash(account.clone()))
1792            .unwrap();
1793
1794        let manager = AccountsManager::new(clock, cache.clone());
1795        let instrument = audusd_sim();
1796
1797        let mut order = OrderTestBuilder::new(OrderType::Market)
1798            .instrument_id(instrument.id())
1799            .side(OrderSide::Buy)
1800            .quantity(Quantity::from("100000"))
1801            .build();
1802
1803        let submitted = order_submitted_for(&order);
1804        let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1805        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1806        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1807
1808        cache
1809            .borrow_mut()
1810            .add_order(order.clone(), None, None, false)
1811            .unwrap();
1812
1813        // Fill with large cost ($80k) that exceeds $100 balance
1814        let fill = OrderFilledSpec::builder()
1815            .instrument_id(instrument.id())
1816            .client_order_id(order.client_order_id())
1817            .venue_order_id(VenueOrderId::new("1"))
1818            .last_qty(Quantity::from("100000"))
1819            .last_px(Price::from("0.80000"))
1820            .ts_event(UnixNanos::from(1))
1821            .ts_init(UnixNanos::from(1))
1822            .position_id(PositionId::new("P-001"))
1823            .commission(Money::new(20.0, usd))
1824            .build();
1825
1826        let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
1827        cache
1828            .borrow_mut()
1829            .add_position(&position, OmsType::Netting)
1830            .unwrap();
1831
1832        let fill2 = OrderFilledSpec::builder()
1833            .instrument_id(instrument.id())
1834            .client_order_id(order.client_order_id())
1835            .venue_order_id(VenueOrderId::new("2"))
1836            .trade_id(TradeId::new("2"))
1837            .last_qty(Quantity::from("100000"))
1838            .last_px(Price::from("0.80000"))
1839            .ts_event(UnixNanos::from(2))
1840            .ts_init(UnixNanos::from(2))
1841            .position_id(PositionId::new("P-001"))
1842            .commission(Money::new(20.0, usd))
1843            .build();
1844        let _state = manager.update_balances(
1845            AccountAny::Cash(account),
1846            &InstrumentAny::CurrencyPair(instrument),
1847            fill2,
1848        );
1849
1850        let account_after = cache
1851            .borrow()
1852            .account(&AccountId::new("SIM-001"))
1853            .unwrap()
1854            .clone();
1855
1856        if let AccountAny::Cash(cash) = account_after {
1857            assert_eq!(cash.balance_total(Some(usd)), Some(initial_balance));
1858        } else {
1859            panic!("Expected CashAccount");
1860        }
1861    }
1862
1863    #[rstest]
1864    fn test_order_canceled_releases_locked_balance() {
1865        // Regression test for https://github.com/nautechsystems/nautilus_trader/issues/3525
1866        let usd = Currency::USD();
1867        let account_state = AccountState::new(
1868            AccountId::new("SIM-001"),
1869            AccountType::Cash,
1870            vec![AccountBalance::new(
1871                Money::new(100_000.0, usd),
1872                Money::zero(usd),
1873                Money::new(100_000.0, usd),
1874            )],
1875            Vec::new(),
1876            true,
1877            UUID4::new(),
1878            UnixNanos::default(),
1879            UnixNanos::default(),
1880            Some(usd),
1881        );
1882
1883        let account = CashAccount::new(account_state, true, false);
1884
1885        let clock = Rc::new(RefCell::new(TestClock::new()));
1886        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1887        cache
1888            .borrow_mut()
1889            .add_account(AccountAny::Cash(account.clone()))
1890            .unwrap();
1891
1892        let manager = AccountsManager::new(clock, cache);
1893        let instrument = audusd_sim();
1894
1895        let mut order = OrderTestBuilder::new(OrderType::Limit)
1896            .instrument_id(instrument.id())
1897            .side(OrderSide::Buy)
1898            .quantity(Quantity::from("100000"))
1899            .price(Price::from("0.80000"))
1900            .build();
1901
1902        let submitted = order_submitted_for(&order);
1903        let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
1904
1905        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
1906        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
1907
1908        let result = manager.update_orders(
1909            &AccountAny::Cash(account),
1910            &InstrumentAny::CurrencyPair(instrument.clone()),
1911            &[&order],
1912            UnixNanos::default(),
1913        );
1914
1915        assert!(result.is_some());
1916        let (updated_account, _) = result.unwrap();
1917
1918        if let AccountAny::Cash(ref cash) = updated_account {
1919            // 100k * 0.80 = 80k USD locked
1920            assert_eq!(
1921                cash.balance_locked(Some(usd)),
1922                Some(Money::new(80_000.0, usd))
1923            );
1924            assert_eq!(
1925                cash.balance_free(Some(usd)),
1926                Some(Money::new(20_000.0, usd))
1927            );
1928        } else {
1929            panic!("Expected CashAccount");
1930        }
1931
1932        let result = manager.update_orders(
1933            &updated_account,
1934            &InstrumentAny::CurrencyPair(instrument),
1935            &[],
1936            UnixNanos::default(),
1937        );
1938
1939        assert!(result.is_some());
1940        let (final_account, _) = result.unwrap();
1941
1942        if let AccountAny::Cash(cash) = final_account {
1943            assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
1944            assert_eq!(
1945                cash.balance_free(Some(usd)),
1946                Some(Money::new(100_000.0, usd))
1947            );
1948            assert_eq!(
1949                cash.balance_total(Some(usd)),
1950                Some(Money::new(100_000.0, usd))
1951            );
1952        } else {
1953            panic!("Expected CashAccount");
1954        }
1955    }
1956
1957    #[rstest]
1958    fn test_generate_account_state_preserves_per_instrument_and_account_wide_margins() {
1959        let usd = Currency::USD();
1960        let audusd = InstrumentId::from("AUD/USD.SIM");
1961        let account_state = AccountState::new(
1962            AccountId::new("SIM-001"),
1963            AccountType::Margin,
1964            vec![AccountBalance::new(
1965                Money::new(1_000_000.0, usd),
1966                Money::zero(usd),
1967                Money::new(1_000_000.0, usd),
1968            )],
1969            Vec::new(),
1970            true,
1971            UUID4::new(),
1972            UnixNanos::default(),
1973            UnixNanos::default(),
1974            Some(usd),
1975        );
1976        let mut account = MarginAccount::new(account_state, false);
1977        account.update_margin(MarginBalance::new(
1978            Money::new(150.0, usd),
1979            Money::new(75.0, usd),
1980            Some(audusd),
1981        ));
1982        account.update_margin(MarginBalance::new(
1983            Money::new(500.0, usd),
1984            Money::new(250.0, usd),
1985            None,
1986        ));
1987
1988        let clock = Rc::new(RefCell::new(TestClock::new()));
1989        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1990        let manager = AccountsManager::new(clock, cache);
1991
1992        let state =
1993            manager.generate_account_state(&AccountAny::Margin(account), UnixNanos::default());
1994
1995        assert_eq!(state.balances.len(), 1);
1996        assert_eq!(state.balances[0].currency, usd);
1997        assert_eq!(state.balances[0].total, Money::new(1_000_000.0, usd));
1998        assert_eq!(state.balances[0].locked, Money::new(975.0, usd));
1999        assert_eq!(state.balances[0].free, Money::new(999_025.0, usd));
2000
2001        assert_eq!(state.margins.len(), 2);
2002        let per_instrument: Vec<_> = state
2003            .margins
2004            .iter()
2005            .filter(|m| m.instrument_id.is_some())
2006            .collect();
2007        let account_wide: Vec<_> = state
2008            .margins
2009            .iter()
2010            .filter(|m| m.instrument_id.is_none())
2011            .collect();
2012        assert_eq!(per_instrument.len(), 1);
2013        assert_eq!(per_instrument[0].instrument_id, Some(audusd));
2014        assert_eq!(per_instrument[0].initial, Money::new(150.0, usd));
2015        assert_eq!(per_instrument[0].maintenance, Money::new(75.0, usd));
2016        assert_eq!(account_wide.len(), 1);
2017        assert_eq!(account_wide[0].currency, usd);
2018        assert_eq!(account_wide[0].initial, Money::new(500.0, usd));
2019        assert_eq!(account_wide[0].maintenance, Money::new(250.0, usd));
2020    }
2021
2022    #[rstest]
2023    fn test_update_balances_returns_recalculated_balance_for_cash_account() {
2024        let usd = Currency::USD();
2025        let account_state = AccountState::new(
2026            AccountId::new("SIM-001"),
2027            AccountType::Cash,
2028            vec![AccountBalance::new(
2029                Money::new(1_000_000.0, usd),
2030                Money::zero(usd),
2031                Money::new(1_000_000.0, usd),
2032            )],
2033            Vec::new(),
2034            true,
2035            UUID4::new(),
2036            UnixNanos::default(),
2037            UnixNanos::default(),
2038            Some(usd),
2039        );
2040
2041        let account = CashAccount::new(account_state, true, false);
2042
2043        let clock = Rc::new(RefCell::new(TestClock::new()));
2044        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2045        cache
2046            .borrow_mut()
2047            .add_account(AccountAny::Cash(account.clone()))
2048            .unwrap();
2049
2050        let manager = AccountsManager::new(clock, cache.clone());
2051        let instrument = audusd_sim();
2052
2053        let mut order = OrderTestBuilder::new(OrderType::Market)
2054            .instrument_id(instrument.id())
2055            .side(OrderSide::Buy)
2056            .quantity(Quantity::from("100000"))
2057            .build();
2058        let submitted = order_submitted_for(&order);
2059        let accepted = order_accepted_for(&order, VenueOrderId::new("1"));
2060        order.apply(OrderEventAny::Submitted(submitted)).unwrap();
2061        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
2062        cache
2063            .borrow_mut()
2064            .add_order(order.clone(), None, None, false)
2065            .unwrap();
2066
2067        let fill = OrderFilledSpec::builder()
2068            .instrument_id(instrument.id())
2069            .client_order_id(order.client_order_id())
2070            .venue_order_id(VenueOrderId::new("1"))
2071            .last_qty(Quantity::from("100000"))
2072            .last_px(Price::from("0.80000"))
2073            .ts_event(UnixNanos::from(1))
2074            .ts_init(UnixNanos::from(1))
2075            .position_id(PositionId::new("P-001"))
2076            .commission(Money::new(20.0, usd))
2077            .build();
2078        let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2079        cache
2080            .borrow_mut()
2081            .add_position(&position, OmsType::Netting)
2082            .unwrap();
2083
2084        let (updated, state) = manager.update_balances(
2085            AccountAny::Cash(account),
2086            &InstrumentAny::CurrencyPair(instrument),
2087            fill,
2088        );
2089
2090        // Buy 100k at 0.80 → 80,000 USD cost, 20 USD commission, expect 919,980 USD
2091        let expected = Money::new(919_980.0, usd);
2092
2093        match updated {
2094            AccountAny::Cash(cash) => {
2095                assert_eq!(cash.balance_total(Some(usd)), Some(expected));
2096                assert_eq!(cash.balance_free(Some(usd)), Some(expected));
2097            }
2098            _ => panic!("Expected CashAccount"),
2099        }
2100        assert_eq!(state.balances.len(), 1);
2101        assert_eq!(state.balances[0].currency, usd);
2102        assert_eq!(state.balances[0].total, expected);
2103        assert_eq!(state.balances[0].free, expected);
2104    }
2105
2106    fn multi_currency_cash_account(allow_borrowing: bool) -> CashAccount {
2107        let aud = Currency::AUD();
2108        let usd = Currency::USD();
2109        let account_state = AccountState::new(
2110            AccountId::new("SIM-001"),
2111            AccountType::Cash,
2112            vec![
2113                AccountBalance::new(
2114                    Money::new(10_000.0, aud),
2115                    Money::zero(aud),
2116                    Money::new(10_000.0, aud),
2117                ),
2118                AccountBalance::new(
2119                    Money::new(100.0, usd),
2120                    Money::zero(usd),
2121                    Money::new(100.0, usd),
2122                ),
2123            ],
2124            Vec::new(),
2125            true,
2126            UUID4::new(),
2127            UnixNanos::default(),
2128            UnixNanos::default(),
2129            None,
2130        );
2131        CashAccount::new(account_state, true, allow_borrowing)
2132    }
2133
2134    fn buy_audusd_fill(qty: &str, px: &str, commission: f64) -> OrderFilled {
2135        let instrument = audusd_sim();
2136        let usd = Currency::USD();
2137        OrderFilledSpec::builder()
2138            .instrument_id(instrument.id())
2139            .last_qty(Quantity::from(qty))
2140            .last_px(Price::from(px))
2141            .ts_event(UnixNanos::from(1))
2142            .ts_init(UnixNanos::from(1))
2143            .position_id(PositionId::new("P-001"))
2144            .commission(Money::new(commission, usd))
2145            .build()
2146    }
2147
2148    fn multi_currency_cash_account_with_usd_locked(total: f64, locked: f64) -> CashAccount {
2149        multi_currency_cash_account_with_usd_locked_and_borrowing(total, locked, false)
2150    }
2151
2152    fn multi_currency_cash_account_with_usd_locked_and_borrowing(
2153        total: f64,
2154        locked: f64,
2155        allow_borrowing: bool,
2156    ) -> CashAccount {
2157        let usd = Currency::USD();
2158        let account_state = AccountState::new(
2159            AccountId::new("SIM-001"),
2160            AccountType::Cash,
2161            vec![AccountBalance::new(
2162                Money::new(total, usd),
2163                Money::new(locked, usd),
2164                Money::new(total - locked, usd),
2165            )],
2166            Vec::new(),
2167            true,
2168            UUID4::new(),
2169            UnixNanos::default(),
2170            UnixNanos::default(),
2171            None,
2172        );
2173        CashAccount::new(account_state, true, allow_borrowing)
2174    }
2175
2176    fn multi_currency_betting_account_with_gbp_locked(total: f64, locked: f64) -> BettingAccount {
2177        let gbp = Currency::GBP();
2178        let account_state = AccountState::new(
2179            AccountId::new("BETFAIR-001"),
2180            AccountType::Betting,
2181            vec![AccountBalance::new(
2182                Money::new(total, gbp),
2183                Money::new(locked, gbp),
2184                Money::new(total - locked, gbp),
2185            )],
2186            Vec::new(),
2187            true,
2188            UUID4::new(),
2189            UnixNanos::default(),
2190            UnixNanos::default(),
2191            None,
2192        );
2193        BettingAccount::new(account_state, true)
2194    }
2195
2196    fn order_submitted_for(order: &OrderAny) -> OrderSubmitted {
2197        OrderSubmittedSpec::builder()
2198            .trader_id(order.trader_id())
2199            .strategy_id(order.strategy_id())
2200            .instrument_id(order.instrument_id())
2201            .client_order_id(order.client_order_id())
2202            .build()
2203    }
2204
2205    fn order_submitted_for_account(order: &OrderAny, account_id: AccountId) -> OrderSubmitted {
2206        OrderSubmittedSpec::builder()
2207            .trader_id(order.trader_id())
2208            .strategy_id(order.strategy_id())
2209            .instrument_id(order.instrument_id())
2210            .client_order_id(order.client_order_id())
2211            .account_id(account_id)
2212            .build()
2213    }
2214
2215    fn order_accepted_for(order: &OrderAny, venue_order_id: VenueOrderId) -> OrderAccepted {
2216        OrderAcceptedSpec::builder()
2217            .trader_id(order.trader_id())
2218            .strategy_id(order.strategy_id())
2219            .instrument_id(order.instrument_id())
2220            .client_order_id(order.client_order_id())
2221            .venue_order_id(venue_order_id)
2222            .build()
2223    }
2224
2225    fn order_accepted_for_account(
2226        order: &OrderAny,
2227        venue_order_id: VenueOrderId,
2228        account_id: AccountId,
2229    ) -> OrderAccepted {
2230        OrderAcceptedSpec::builder()
2231            .trader_id(order.trader_id())
2232            .strategy_id(order.strategy_id())
2233            .instrument_id(order.instrument_id())
2234            .client_order_id(order.client_order_id())
2235            .venue_order_id(venue_order_id)
2236            .account_id(account_id)
2237            .build()
2238    }
2239
2240    #[rstest]
2241    fn test_update_balance_multi_currency_market_debit_keeps_locked_balance() {
2242        let usd = Currency::USD();
2243        let account = multi_currency_cash_account_with_usd_locked(1_000.0, 200.0);
2244        let clock = Rc::new(RefCell::new(TestClock::new()));
2245        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2246        let manager = AccountsManager::new(clock, cache.clone());
2247        let instrument = audusd_sim();
2248        cache
2249            .borrow_mut()
2250            .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2251            .unwrap();
2252
2253        let fill = OrderFilledSpec::builder()
2254            .instrument_id(instrument.id())
2255            .order_type(OrderType::Market)
2256            .commission(Money::new(20.0, usd))
2257            .build();
2258        let mut account = AccountAny::Cash(account);
2259        let mut pnls = vec![Money::new(-100.0, usd)];
2260
2261        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2262
2263        match account {
2264            AccountAny::Cash(cash) => {
2265                assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
2266                assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(200.0, usd)));
2267                assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(680.0, usd)));
2268                assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2269            }
2270            _ => panic!("Expected CashAccount"),
2271        }
2272    }
2273
2274    #[rstest]
2275    fn test_update_balance_multi_currency_limit_debit_reduces_locked_balance() {
2276        let usd = Currency::USD();
2277        let account = multi_currency_cash_account_with_usd_locked(1_000.0, 200.0);
2278        let clock = Rc::new(RefCell::new(TestClock::new()));
2279        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2280        let manager = AccountsManager::new(clock, cache.clone());
2281        let instrument = audusd_sim();
2282        cache
2283            .borrow_mut()
2284            .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2285            .unwrap();
2286
2287        let fill = OrderFilledSpec::builder()
2288            .instrument_id(instrument.id())
2289            .order_type(OrderType::Limit)
2290            .commission(Money::new(20.0, usd))
2291            .build();
2292        let mut account = AccountAny::Cash(account);
2293        let mut pnls = vec![Money::new(-100.0, usd)];
2294
2295        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2296
2297        match account {
2298            AccountAny::Cash(cash) => {
2299                assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
2300                assert_eq!(cash.balance_locked(Some(usd)), Some(Money::new(80.0, usd)));
2301                assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(800.0, usd)));
2302                assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2303            }
2304            _ => panic!("Expected CashAccount"),
2305        }
2306    }
2307
2308    #[rstest]
2309    fn test_update_balance_multi_currency_limit_debit_spills_from_locked_to_free() {
2310        let usd = Currency::USD();
2311        let account = multi_currency_cash_account_with_usd_locked(1_000.0, 50.0);
2312        let clock = Rc::new(RefCell::new(TestClock::new()));
2313        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2314        let manager = AccountsManager::new(clock, cache.clone());
2315        let instrument = audusd_sim();
2316        cache
2317            .borrow_mut()
2318            .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2319            .unwrap();
2320
2321        let fill = OrderFilledSpec::builder()
2322            .instrument_id(instrument.id())
2323            .order_type(OrderType::Limit)
2324            .commission(Money::new(20.0, usd))
2325            .build();
2326        let mut account = AccountAny::Cash(account);
2327        let mut pnls = vec![Money::new(-100.0, usd)];
2328
2329        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2330
2331        match account {
2332            AccountAny::Cash(cash) => {
2333                assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(880.0, usd)));
2334                assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
2335                assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(880.0, usd)));
2336                assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2337            }
2338            _ => panic!("Expected CashAccount"),
2339        }
2340    }
2341
2342    #[rstest]
2343    fn test_update_balance_multi_currency_limit_debit_floors_locked_on_negative_total() {
2344        let usd = Currency::USD();
2345        let account = multi_currency_cash_account_with_usd_locked_and_borrowing(100.0, 50.0, true);
2346        let clock = Rc::new(RefCell::new(TestClock::new()));
2347        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2348        let manager = AccountsManager::new(clock, cache.clone());
2349        let instrument = audusd_sim();
2350        cache
2351            .borrow_mut()
2352            .add_instrument(InstrumentAny::CurrencyPair(instrument.clone()))
2353            .unwrap();
2354
2355        let fill = OrderFilledSpec::builder()
2356            .instrument_id(instrument.id())
2357            .order_type(OrderType::Limit)
2358            .commission(Money::new(20.0, usd))
2359            .build();
2360        let mut account = AccountAny::Cash(account);
2361        let mut pnls = vec![Money::new(-200.0, usd)];
2362
2363        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2364
2365        match account {
2366            AccountAny::Cash(cash) => {
2367                assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(-120.0, usd)));
2368                assert_eq!(cash.balance_locked(Some(usd)), Some(Money::zero(usd)));
2369                assert_eq!(cash.balance_free(Some(usd)), Some(Money::new(-120.0, usd)));
2370                assert_eq!(cash.commission(&usd), Some(Money::new(20.0, usd)));
2371            }
2372            _ => panic!("Expected CashAccount"),
2373        }
2374    }
2375
2376    #[rstest]
2377    fn test_update_balance_multi_currency_betting_limit_debit_keeps_locked_balance() {
2378        let gbp = Currency::GBP();
2379        let account = multi_currency_betting_account_with_gbp_locked(1_000.0, 200.0);
2380        let clock = Rc::new(RefCell::new(TestClock::new()));
2381        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2382        let manager = AccountsManager::new(clock, cache.clone());
2383        let instrument = betting();
2384        cache
2385            .borrow_mut()
2386            .add_instrument(InstrumentAny::Betting(instrument.clone()))
2387            .unwrap();
2388
2389        let fill = OrderFilledSpec::builder()
2390            .instrument_id(instrument.id())
2391            .order_type(OrderType::Limit)
2392            .commission(Money::new(20.0, gbp))
2393            .build();
2394        let mut account = AccountAny::Betting(account);
2395        let mut pnls = vec![Money::new(-100.0, gbp)];
2396
2397        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2398
2399        match account {
2400            AccountAny::Betting(betting_account) => {
2401                assert_eq!(
2402                    betting_account.balance_total(Some(gbp)),
2403                    Some(Money::new(880.0, gbp))
2404                );
2405                assert_eq!(
2406                    betting_account.balance_locked(Some(gbp)),
2407                    Some(Money::new(200.0, gbp))
2408                );
2409                assert_eq!(
2410                    betting_account.balance_free(Some(gbp)),
2411                    Some(Money::new(680.0, gbp))
2412                );
2413                assert_eq!(
2414                    betting_account.commission(&gbp),
2415                    Some(Money::new(20.0, gbp))
2416                );
2417            }
2418            _ => panic!("Expected BettingAccount"),
2419        }
2420    }
2421
2422    #[rstest]
2423    fn test_update_balance_multi_currency_persists_negative_balance_with_allow_borrowing() {
2424        let aud = Currency::AUD();
2425        let usd = Currency::USD();
2426        let account = multi_currency_cash_account(true);
2427        let clock = Rc::new(RefCell::new(TestClock::new()));
2428        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2429        cache
2430            .borrow_mut()
2431            .add_account(AccountAny::Cash(account.clone()))
2432            .unwrap();
2433        let manager = AccountsManager::new(clock, cache.clone());
2434        let instrument = audusd_sim();
2435        let fill = buy_audusd_fill("10000", "0.80000", 20.0);
2436        let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2437        cache
2438            .borrow_mut()
2439            .add_position(&position, OmsType::Netting)
2440            .unwrap();
2441
2442        let (updated, _state) = manager.update_balances(
2443            AccountAny::Cash(account),
2444            &InstrumentAny::CurrencyPair(instrument),
2445            fill,
2446        );
2447
2448        match updated {
2449            AccountAny::Cash(cash) => {
2450                assert_eq!(
2451                    cash.balance_total(Some(aud)),
2452                    Some(Money::new(20_000.0, aud))
2453                );
2454                assert_eq!(
2455                    cash.balance_total(Some(usd)),
2456                    Some(Money::new(-7_920.0, usd))
2457                );
2458            }
2459            _ => panic!("Expected CashAccount"),
2460        }
2461    }
2462
2463    #[rstest]
2464    fn test_update_balance_multi_currency_rejects_negative_balance_without_allow_borrowing() {
2465        let aud = Currency::AUD();
2466        let usd = Currency::USD();
2467        let account = multi_currency_cash_account(false);
2468        let clock = Rc::new(RefCell::new(TestClock::new()));
2469        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2470        cache
2471            .borrow_mut()
2472            .add_account(AccountAny::Cash(account.clone()))
2473            .unwrap();
2474        let manager = AccountsManager::new(clock, cache.clone());
2475        let instrument = audusd_sim();
2476        let fill = buy_audusd_fill("10000", "0.80000", 20.0);
2477        let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2478        cache
2479            .borrow_mut()
2480            .add_position(&position, OmsType::Netting)
2481            .unwrap();
2482
2483        let (updated, _state) = manager.update_balances(
2484            AccountAny::Cash(account),
2485            &InstrumentAny::CurrencyPair(instrument),
2486            fill,
2487        );
2488
2489        // Rejected by `cash.update_balances`: original balances preserved
2490        match updated {
2491            AccountAny::Cash(cash) => {
2492                assert_eq!(
2493                    cash.balance_total(Some(aud)),
2494                    Some(Money::new(10_000.0, aud))
2495                );
2496                assert_eq!(cash.balance_total(Some(usd)), Some(Money::new(100.0, usd)));
2497            }
2498            _ => panic!("Expected CashAccount"),
2499        }
2500    }
2501
2502    #[rstest]
2503    fn test_update_balance_multi_currency_rejects_new_currency_negative_pnl() {
2504        let aud = Currency::AUD();
2505        let account_state = AccountState::new(
2506            AccountId::new("SIM-001"),
2507            AccountType::Cash,
2508            vec![AccountBalance::new(
2509                Money::new(10_000.0, aud),
2510                Money::zero(aud),
2511                Money::new(10_000.0, aud),
2512            )],
2513            Vec::new(),
2514            true,
2515            UUID4::new(),
2516            UnixNanos::default(),
2517            UnixNanos::default(),
2518            None,
2519        );
2520        let account = CashAccount::new(account_state, true, true);
2521        let clock = Rc::new(RefCell::new(TestClock::new()));
2522        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2523        cache
2524            .borrow_mut()
2525            .add_account(AccountAny::Cash(account.clone()))
2526            .unwrap();
2527        let manager = AccountsManager::new(clock, cache.clone());
2528        let instrument = audusd_sim();
2529        // Buy AUD/USD on an AUD-only account: produces negative USD pnl on a missing currency,
2530        // which the documented Python-parity branch rejects even with `allow_borrowing=true`.
2531        let fill = buy_audusd_fill("10000", "0.80000", 0.0);
2532        let position = Position::new(&InstrumentAny::CurrencyPair(instrument.clone()), fill);
2533        cache
2534            .borrow_mut()
2535            .add_position(&position, OmsType::Netting)
2536            .unwrap();
2537
2538        let (updated, _state) = manager.update_balances(
2539            AccountAny::Cash(account),
2540            &InstrumentAny::CurrencyPair(instrument),
2541            fill,
2542        );
2543
2544        // Rejected at the no-existing-balance + negative-pnl branch (Python parity)
2545        match updated {
2546            AccountAny::Cash(cash) => {
2547                assert_eq!(
2548                    cash.balance_total(Some(aud)),
2549                    Some(Money::new(10_000.0, aud))
2550                );
2551                assert_eq!(cash.balance_total(Some(Currency::USD())), None);
2552            }
2553            _ => panic!("Expected CashAccount"),
2554        }
2555    }
2556
2557    // ~100M USDT total with non-zero locked margin: the raw fixed-point value exceeds f64's
2558    // exact-integer range (2^53), which is the condition that triggers issue #4165.
2559    fn large_locked_usdt_margin_account() -> (AccountAny, Money, Money) {
2560        let usdt = Currency::USDT();
2561        let total =
2562            Money::from_decimal(Decimal::from_str_exact("99999997.91829666").unwrap(), usdt)
2563                .unwrap();
2564        let locked =
2565            Money::from_decimal(Decimal::from_str_exact("32.85965").unwrap(), usdt).unwrap();
2566        let free = Money::from_raw(total.raw - locked.raw, usdt);
2567        let account_state = AccountState::new(
2568            AccountId::new("SIM-001"),
2569            AccountType::Margin,
2570            vec![AccountBalance::new(total, locked, free)],
2571            Vec::new(),
2572            true,
2573            UUID4::new(),
2574            UnixNanos::default(),
2575            UnixNanos::default(),
2576            None, // No base currency routes PnL through `update_balance_multi_currency`
2577        );
2578        (
2579            AccountAny::Margin(MarginAccount::new(account_state, false)),
2580            total,
2581            locked,
2582        )
2583    }
2584
2585    #[rstest]
2586    fn test_update_balance_multi_currency_preserves_invariant_with_large_locked() {
2587        // Regression for issue #4165: applying realized PnL to a large multi-currency margin
2588        // balance via independent f64 round-trips drifts `total` and `free` relative to each
2589        // other, breaking `total == locked + free` and panicking `AccountBalance::new`.
2590        let usdt = Currency::USDT();
2591        let (mut account, total, locked) = large_locked_usdt_margin_account();
2592        let clock = Rc::new(RefCell::new(TestClock::new()));
2593        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2594        let manager = AccountsManager::new(clock, cache);
2595
2596        // No commission on the fill: only the realized-PnL branch runs. This PnL lands on an
2597        // 8dp tick where the old independent f64 round-trips drifted by 2e-8.
2598        let fill = OrderFilledSpec::builder().build();
2599        let pnl =
2600            Money::from_decimal(Decimal::from_str_exact("0.00000064").unwrap(), usdt).unwrap();
2601        let mut pnls = [pnl];
2602        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2603
2604        let balances = account.balances();
2605        let balance = balances.get(&usdt).expect("USDT balance");
2606        assert_eq!(balance.locked, locked, "locked margin preserved");
2607        assert_eq!(balance.total, total + pnl, "total moved by realized PnL");
2608        assert_eq!(
2609            balance.total.raw,
2610            balance.locked.raw + balance.free.raw,
2611            "invariant total == locked + free must hold"
2612        );
2613    }
2614
2615    #[rstest]
2616    fn test_update_balance_multi_currency_commission_preserves_invariant_with_large_locked() {
2617        // Regression for issue #4165: the commission branch had the same f64 round-trip drift.
2618        let usdt = Currency::USDT();
2619        let (mut account, total, locked) = large_locked_usdt_margin_account();
2620        let clock = Rc::new(RefCell::new(TestClock::new()));
2621        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2622        let manager = AccountsManager::new(clock, cache);
2623
2624        // This commission reproduces the exact panic values from issue #4165: the old
2625        // Decimal-then-f64 round-trips yielded total=99999997.91829666, free=99999965.05864664.
2626        let commission =
2627            Money::from_decimal(Decimal::from_str_exact("0.00000001").unwrap(), usdt).unwrap();
2628        let fill = OrderFilledSpec::builder().commission(commission).build();
2629
2630        // No PnL entries: only the commission branch runs.
2631        let mut pnls: [Money; 0] = [];
2632        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2633
2634        let balances = account.balances();
2635        let balance = balances.get(&usdt).expect("USDT balance");
2636        assert_eq!(balance.locked, locked, "locked margin preserved");
2637        assert_eq!(
2638            balance.total,
2639            total - commission,
2640            "total reduced by commission"
2641        );
2642        assert_eq!(
2643            balance.total.raw,
2644            balance.locked.raw + balance.free.raw,
2645            "invariant total == locked + free must hold"
2646        );
2647    }
2648
2649    #[rstest]
2650    fn test_update_balance_multi_currency_negative_commission_creates_rebate_balance() {
2651        let usd = Currency::USD();
2652        let account_state = AccountState::new(
2653            AccountId::new("SIM-001"),
2654            AccountType::Cash,
2655            Vec::new(),
2656            Vec::new(),
2657            true,
2658            UUID4::new(),
2659            UnixNanos::default(),
2660            UnixNanos::default(),
2661            None,
2662        );
2663        let mut account = AccountAny::Cash(CashAccount::new(account_state, true, false));
2664        let clock = Rc::new(RefCell::new(TestClock::new()));
2665        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2666        let manager = AccountsManager::new(clock, cache);
2667
2668        let fill = OrderFilledSpec::builder()
2669            .commission(Money::new(-1.0, usd))
2670            .build();
2671        let mut pnls: [Money; 0] = [];
2672        manager.update_balance_multi_currency(&mut account, &fill, &mut pnls);
2673
2674        let AccountAny::Cash(cash) = account else {
2675            panic!("Expected CashAccount");
2676        };
2677        let balance = cash.balance(Some(usd)).expect("USD rebate balance");
2678        assert_eq!(balance.total, Money::new(1.0, usd));
2679        assert_eq!(balance.locked, Money::zero(usd));
2680        assert_eq!(balance.free, Money::new(1.0, usd));
2681        assert_eq!(cash.commission(&usd), Some(Money::new(-1.0, usd)));
2682    }
2683
2684    fn build_margin_account_usd(balance: f64) -> MarginAccount {
2685        let usd = Currency::USD();
2686        let account_state = AccountState::new(
2687            AccountId::new("SIM-001"),
2688            AccountType::Margin,
2689            vec![AccountBalance::new(
2690                Money::new(balance, usd),
2691                Money::zero(usd),
2692                Money::new(balance, usd),
2693            )],
2694            Vec::new(),
2695            true,
2696            UUID4::new(),
2697            UnixNanos::default(),
2698            UnixNanos::default(),
2699            None,
2700        );
2701        MarginAccount::new(account_state, false)
2702    }
2703
2704    fn build_margin_account_usdt(balance: f64) -> MarginAccount {
2705        let usdt = Currency::USDT();
2706        let account_state = AccountState::new(
2707            AccountId::new("SIM-001"),
2708            AccountType::Margin,
2709            vec![AccountBalance::new(
2710                Money::new(balance, usdt),
2711                Money::zero(usdt),
2712                Money::new(balance, usdt),
2713            )],
2714            Vec::new(),
2715            true,
2716            UUID4::new(),
2717            UnixNanos::default(),
2718            UnixNanos::default(),
2719            None,
2720        );
2721        MarginAccount::new(account_state, false)
2722    }
2723
2724    fn build_hedging_position(
2725        instrument: &InstrumentAny,
2726        side: OrderSide,
2727        qty: &str,
2728        price: &str,
2729        id: &str,
2730    ) -> Position {
2731        build_hedging_position_at(instrument, side, qty, price, id, UnixNanos::default())
2732    }
2733
2734    fn build_hedging_position_at(
2735        instrument: &InstrumentAny,
2736        side: OrderSide,
2737        qty: &str,
2738        price: &str,
2739        id: &str,
2740        ts_event: UnixNanos,
2741    ) -> Position {
2742        let fill = OrderFilledSpec::builder()
2743            .instrument_id(instrument.id())
2744            .client_order_id(ClientOrderId::new(id))
2745            .venue_order_id(VenueOrderId::new(id))
2746            .trade_id(TradeId::new(id))
2747            .order_side(side)
2748            .last_qty(Quantity::from(qty))
2749            .last_px(Price::from(price))
2750            .currency(instrument.settlement_currency())
2751            .ts_event(ts_event)
2752            .ts_init(ts_event)
2753            .position_id(PositionId::new(id))
2754            .build();
2755        Position::new(instrument, fill)
2756    }
2757
2758    #[rstest]
2759    fn test_update_positions_in_place_nets_hedging_subpositions() {
2760        let usd = Currency::USD();
2761        let mut account = build_margin_account_usd(1_000_000.0);
2762        let instrument = audusd_sim();
2763        account.set_leverage(instrument.id(), Decimal::ONE);
2764        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2765
2766        let clock = Rc::new(RefCell::new(TestClock::new()));
2767        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2768        let manager = AccountsManager::new(clock, cache);
2769
2770        // 5 long + 2 short, each 50 @ 1.0: net long 150 -> 150 * 1.0 * 0.03 = 4.50 USD
2771        let mut positions: Vec<Position> = Vec::new();
2772        for i in 0..5 {
2773            positions.push(build_hedging_position(
2774                &instrument_any,
2775                OrderSide::Buy,
2776                "50",
2777                "1.00000",
2778                &format!("L{i}"),
2779            ));
2780        }
2781
2782        for i in 0..2 {
2783            positions.push(build_hedging_position(
2784                &instrument_any,
2785                OrderSide::Sell,
2786                "50",
2787                "1.00000",
2788                &format!("S{i}"),
2789            ));
2790        }
2791
2792        let position_refs: Vec<&Position> = positions.iter().collect();
2793        let result = manager.update_positions_in_place(
2794            &mut account,
2795            &instrument_any,
2796            position_refs,
2797            UnixNanos::default(),
2798        );
2799        assert!(result.is_some(), "update_positions_in_place returned None");
2800
2801        let margin_maint = account.maintenance_margin(instrument.id());
2802        assert_eq!(
2803            margin_maint,
2804            Money::new(4.50, usd),
2805            "Maintenance margin must reflect net exposure (150 @ 1.00), not per-position sum",
2806        );
2807    }
2808
2809    #[rstest]
2810    fn test_update_positions_in_place_net_zero_hedge_has_no_margin() {
2811        let usd = Currency::USD();
2812        let mut account = build_margin_account_usd(1_000_000.0);
2813        let instrument = audusd_sim();
2814        account.set_leverage(instrument.id(), Decimal::ONE);
2815        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2816
2817        let clock = Rc::new(RefCell::new(TestClock::new()));
2818        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2819        let manager = AccountsManager::new(clock, cache);
2820
2821        // Long 100 plus short 100 at the same price: net zero, margin zero
2822        let long = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
2823        let short = build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S");
2824
2825        let state = manager
2826            .update_positions_in_place(
2827                &mut account,
2828                &instrument_any,
2829                vec![&long, &short],
2830                UnixNanos::default(),
2831            )
2832            .expect("update_positions_in_place returned None");
2833
2834        assert!(account.margin(&instrument.id()).is_none());
2835        assert!(state.margins.is_empty());
2836        assert_eq!(account.balance_locked(Some(usd)), Some(Money::zero(usd)));
2837    }
2838
2839    #[rstest]
2840    fn test_update_positions_in_place_uses_net_side_avg_open_price() {
2841        let usd = Currency::USD();
2842        let mut account = build_margin_account_usd(1_000_000.0);
2843        let instrument = audusd_sim();
2844        account.set_leverage(instrument.id(), Decimal::ONE);
2845        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2846
2847        let clock = Rc::new(RefCell::new(TestClock::new()));
2848        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2849        let manager = AccountsManager::new(clock, cache);
2850
2851        // Long 300 @ 0.80, short 100 @ 1.00: short closes part of long, residual long 200
2852        // @ 0.80, margin = 200 * 0.80 * 0.03 = 4.80 USD
2853        let long = build_hedging_position(&instrument_any, OrderSide::Buy, "300", "0.80000", "L1");
2854        let short =
2855            build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S1");
2856
2857        let result = manager.update_positions_in_place(
2858            &mut account,
2859            &instrument_any,
2860            vec![&long, &short],
2861            UnixNanos::default(),
2862        );
2863        assert!(result.is_some(), "update_positions_in_place returned None");
2864
2865        let margin_maint = account.maintenance_margin(instrument.id());
2866        assert_eq!(margin_maint, Money::new(4.80, usd));
2867    }
2868
2869    #[rstest]
2870    fn test_update_positions_in_place_floating_dust_clears_margin() {
2871        // Sub-precision dust on a flat hedge (e.g. 0.3 - 0.2 - 0.1) must clear the
2872        // margin instead of feeding a sub-tick quantity into `make_qty`.
2873        let usdt = Currency::USDT();
2874        let mut account = build_margin_account_usdt(1_000_000.0);
2875        let instrument = currency_pair_btcusdt();
2876        account.set_leverage(instrument.id(), Decimal::ONE);
2877        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2878
2879        let clock = Rc::new(RefCell::new(TestClock::new()));
2880        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2881        let manager = AccountsManager::new(clock, cache);
2882
2883        // 0.3 - 0.2 - 0.1 as f64 leaves ~5.55e-17, well below size_precision 6
2884        let long =
2885            build_hedging_position(&instrument_any, OrderSide::Buy, "0.300000", "50000.00", "L");
2886        let short_a = build_hedging_position(
2887            &instrument_any,
2888            OrderSide::Sell,
2889            "0.200000",
2890            "50000.00",
2891            "S1",
2892        );
2893        let short_b = build_hedging_position(
2894            &instrument_any,
2895            OrderSide::Sell,
2896            "0.100000",
2897            "50000.00",
2898            "S2",
2899        );
2900
2901        let state = manager
2902            .update_positions_in_place(
2903                &mut account,
2904                &instrument_any,
2905                vec![&long, &short_a, &short_b],
2906                UnixNanos::default(),
2907            )
2908            .expect("update_positions_in_place returned None");
2909
2910        assert!(account.margin(&instrument.id()).is_none());
2911        assert!(state.margins.is_empty());
2912        assert_eq!(account.balance_locked(Some(usdt)), Some(Money::zero(usdt)));
2913    }
2914
2915    #[rstest]
2916    fn test_update_positions_in_place_net_flat_clears_prior_base_currency_margin() {
2917        // A net-flat snapshot must clear margin in the same currency the prior update
2918        // used, not strand a base-currency lock under a settlement-currency zero.
2919        let usdt = Currency::USDT();
2920        let account_state = AccountState::new(
2921            AccountId::new("SIM-001"),
2922            AccountType::Margin,
2923            vec![AccountBalance::new(
2924                Money::new(1_000_000.0, usdt),
2925                Money::zero(usdt),
2926                Money::new(1_000_000.0, usdt),
2927            )],
2928            Vec::new(),
2929            true,
2930            UUID4::new(),
2931            UnixNanos::default(),
2932            UnixNanos::default(),
2933            Some(usdt),
2934        );
2935        let mut account = MarginAccount::new(account_state, false);
2936        let instrument = currency_pair_btcusdt();
2937        account.set_leverage(instrument.id(), Decimal::ONE);
2938        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2939
2940        let clock = Rc::new(RefCell::new(TestClock::new()));
2941        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2942        let manager = AccountsManager::new(clock, cache);
2943
2944        // First snapshot: net long 0.5 BTC @ 50_000 -> non-zero base-currency margin.
2945        let long =
2946            build_hedging_position(&instrument_any, OrderSide::Buy, "0.500000", "50000.00", "L");
2947        let first = manager.update_positions_in_place(
2948            &mut account,
2949            &instrument_any,
2950            vec![&long],
2951            UnixNanos::default(),
2952        );
2953        assert!(first.is_some());
2954        let prior_margin = account.maintenance_margin(instrument.id());
2955        assert!(prior_margin.as_decimal() > Decimal::ZERO);
2956        assert_eq!(prior_margin.currency, usdt);
2957        let prior_locked = account.balance_locked(Some(usdt)).unwrap();
2958        assert!(prior_locked.as_decimal() > Decimal::ZERO);
2959
2960        // Second snapshot: offsetting short closes the net exposure.
2961        let short = build_hedging_position(
2962            &instrument_any,
2963            OrderSide::Sell,
2964            "0.500000",
2965            "50000.00",
2966            "S",
2967        );
2968        let second = manager.update_positions_in_place(
2969            &mut account,
2970            &instrument_any,
2971            vec![&long, &short],
2972            UnixNanos::default(),
2973        );
2974        let second_state = second.expect("net-flat maintenance update should generate state");
2975
2976        // Net-flat: the per-instrument margin entry and resulting base-currency lock must clear.
2977        assert!(account.margin(&instrument.id()).is_none());
2978        assert!(second_state.margins.is_empty());
2979        assert_eq!(
2980            account.balance_locked(Some(usdt)).unwrap(),
2981            Money::zero(usdt)
2982        );
2983    }
2984
2985    #[rstest]
2986    fn test_update_positions_in_place_net_flat_preserves_prior_initial_margin() {
2987        let usd = Currency::USD();
2988        let mut account = build_margin_account_usd(1_000_000.0);
2989        let instrument = audusd_sim();
2990        account.set_leverage(instrument.id(), Decimal::ONE);
2991        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
2992        let initial = Money::new(25.0, usd);
2993        account.update_margin(MarginBalance::new(
2994            initial,
2995            Money::new(5.0, usd),
2996            Some(instrument.id()),
2997        ));
2998
2999        let clock = Rc::new(RefCell::new(TestClock::new()));
3000        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3001        let manager = AccountsManager::new(clock, cache);
3002
3003        let long = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
3004        let short = build_hedging_position(&instrument_any, OrderSide::Sell, "100", "1.00000", "S");
3005        let state = manager
3006            .update_positions_in_place(
3007                &mut account,
3008                &instrument_any,
3009                vec![&long, &short],
3010                UnixNanos::default(),
3011            )
3012            .expect("net-flat maintenance update should generate state");
3013
3014        let margin = account
3015            .margin(&instrument.id())
3016            .expect("initial margin should remain");
3017        assert_eq!(margin.initial, initial);
3018        assert_eq!(margin.maintenance, Money::zero(usd));
3019        assert_eq!(state.margins, vec![margin]);
3020    }
3021
3022    #[rstest]
3023    fn test_update_positions_in_place_flip_uses_flipping_fill_price() {
3024        // NETTING leaves the residual at the flipping fill's price; the replay must too,
3025        // or a gross net-side average will under-margin reversal cases.
3026        let usd = Currency::USD();
3027        let mut account = build_margin_account_usd(1_000_000.0);
3028        let instrument = audusd_sim();
3029        account.set_leverage(instrument.id(), Decimal::ONE);
3030        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3031
3032        let clock = Rc::new(RefCell::new(TestClock::new()));
3033        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3034        let manager = AccountsManager::new(clock, cache);
3035
3036        // L100@1, S50@2, S100@3: NETTING residual short 50 @ 3 -> 4.50 USD,
3037        // a gross net-side average would give 4.00 USD (under-margin).
3038        let long = build_hedging_position_at(
3039            &instrument_any,
3040            OrderSide::Buy,
3041            "100",
3042            "1.00000",
3043            "L",
3044            UnixNanos::from(1),
3045        );
3046        let short_partial = build_hedging_position_at(
3047            &instrument_any,
3048            OrderSide::Sell,
3049            "50",
3050            "2.00000",
3051            "S1",
3052            UnixNanos::from(2),
3053        );
3054        let short_flip = build_hedging_position_at(
3055            &instrument_any,
3056            OrderSide::Sell,
3057            "100",
3058            "3.00000",
3059            "S2",
3060            UnixNanos::from(3),
3061        );
3062
3063        let result = manager.update_positions_in_place(
3064            &mut account,
3065            &instrument_any,
3066            vec![&long, &short_partial, &short_flip],
3067            UnixNanos::default(),
3068        );
3069        assert!(result.is_some(), "update_positions_in_place returned None");
3070
3071        let margin_maint = account.maintenance_margin(instrument.id());
3072        assert_eq!(margin_maint, Money::new(4.50, usd));
3073    }
3074
3075    #[rstest]
3076    fn test_update_positions_in_place_same_ts_legs_ordering_is_deterministic() {
3077        // positions_open iterates an AHashSet; without a tie-breaker the fold of
3078        // same-ts reversal legs would vary across runs.
3079        let usd = Currency::USD();
3080        let instrument = audusd_sim();
3081        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3082
3083        // Same-ts reversal: long 100 @ 1.0 (A), short 50 @ 2.0 (B), short 100 @ 3.0 (C).
3084        // Ordered by position_id, the fold yields short 50 @ 3.0 -> 4.50 USD.
3085        let same_ts = UnixNanos::from(42);
3086        let l_a = build_hedging_position_at(
3087            &instrument_any,
3088            OrderSide::Buy,
3089            "100",
3090            "1.00000",
3091            "A",
3092            same_ts,
3093        );
3094        let s_b = build_hedging_position_at(
3095            &instrument_any,
3096            OrderSide::Sell,
3097            "50",
3098            "2.00000",
3099            "B",
3100            same_ts,
3101        );
3102        let s_c = build_hedging_position_at(
3103            &instrument_any,
3104            OrderSide::Sell,
3105            "100",
3106            "3.00000",
3107            "C",
3108            same_ts,
3109        );
3110
3111        let permutations: Vec<Vec<&Position>> = vec![
3112            vec![&l_a, &s_b, &s_c],
3113            vec![&s_c, &s_b, &l_a],
3114            vec![&s_b, &l_a, &s_c],
3115            vec![&s_c, &l_a, &s_b],
3116        ];
3117
3118        let mut results: Vec<Money> = Vec::new();
3119
3120        for perm in permutations {
3121            let mut account = build_margin_account_usd(1_000_000.0);
3122            account.set_leverage(instrument.id(), Decimal::ONE);
3123
3124            let clock = Rc::new(RefCell::new(TestClock::new()));
3125            let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3126            let manager = AccountsManager::new(clock, cache);
3127
3128            let result = manager.update_positions_in_place(
3129                &mut account,
3130                &instrument_any,
3131                perm,
3132                UnixNanos::default(),
3133            );
3134            assert!(result.is_some());
3135            results.push(account.maintenance_margin(instrument.id()));
3136        }
3137
3138        let first = results[0];
3139        for r in &results[1..] {
3140            assert_eq!(
3141                *r, first,
3142                "maintenance margin must be deterministic across permutations"
3143            );
3144        }
3145        // Canonical sorted order yields the NETTING residual short 50 @ 3.0
3146        assert_eq!(first, Money::new(4.50, usd));
3147    }
3148
3149    #[rstest]
3150    fn test_update_positions_in_place_xrate_unavailable_returns_none() {
3151        // EUR base account on a USD-settled instrument with no xrate must bail out
3152        // rather than write a stale or zero margin.
3153        let eur = Currency::EUR();
3154        let account_state = AccountState::new(
3155            AccountId::new("SIM-001"),
3156            AccountType::Margin,
3157            vec![AccountBalance::new(
3158                Money::new(1_000_000.0, eur),
3159                Money::zero(eur),
3160                Money::new(1_000_000.0, eur),
3161            )],
3162            Vec::new(),
3163            true,
3164            UUID4::new(),
3165            UnixNanos::default(),
3166            UnixNanos::default(),
3167            Some(eur),
3168        );
3169        let mut account = MarginAccount::new(account_state, false);
3170        let instrument = audusd_sim();
3171        account.set_leverage(instrument.id(), Decimal::ONE);
3172        let instrument_any = InstrumentAny::CurrencyPair(instrument);
3173
3174        let clock = Rc::new(RefCell::new(TestClock::new()));
3175        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3176        let manager = AccountsManager::new(clock, cache);
3177
3178        let pos = build_hedging_position(&instrument_any, OrderSide::Buy, "100", "1.00000", "L");
3179        let result = manager.update_positions_in_place(
3180            &mut account,
3181            &instrument_any,
3182            vec![&pos],
3183            UnixNanos::default(),
3184        );
3185        assert!(result.is_none(), "xrate-unavailable must return None");
3186    }
3187
3188    #[rstest]
3189    fn test_update_positions_in_place_base_xrate_uses_ask_for_short_net_position() {
3190        let eur = Currency::EUR();
3191        let account_state = AccountState::new(
3192            AccountId::new("SIM-001"),
3193            AccountType::Margin,
3194            vec![AccountBalance::new(
3195                Money::new(1_000.0, eur),
3196                Money::zero(eur),
3197                Money::new(1_000.0, eur),
3198            )],
3199            Vec::new(),
3200            true,
3201            UUID4::new(),
3202            UnixNanos::default(),
3203            UnixNanos::default(),
3204            Some(eur),
3205        );
3206        let mut account = MarginAccount::new(account_state, false);
3207        let instrument = audusd_sim();
3208        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3209
3210        let clock = Rc::new(RefCell::new(TestClock::new()));
3211        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3212        add_usdeur_quote(&cache, "0.90000", "1.10000");
3213        let manager = AccountsManager::new(clock, cache);
3214
3215        let position =
3216            build_hedging_position(&instrument_any, OrderSide::Sell, "100", "2.00000", "S");
3217        let result = manager.update_positions_in_place(
3218            &mut account,
3219            &instrument_any,
3220            vec![&position],
3221            UnixNanos::default(),
3222        );
3223
3224        assert!(result.is_some());
3225        assert_eq!(
3226            account.maintenance_margin(instrument.id()),
3227            Money::new(6.60, eur)
3228        );
3229    }
3230
3231    #[rstest]
3232    fn test_update_positions_in_place_closed_positions_filtered() {
3233        // Closed positions in the input must not contribute to net exposure.
3234        let usd = Currency::USD();
3235        let mut account = build_margin_account_usd(1_000_000.0);
3236        let instrument = audusd_sim();
3237        account.set_leverage(instrument.id(), Decimal::ONE);
3238        let instrument_any = InstrumentAny::CurrencyPair(instrument.clone());
3239
3240        let clock = Rc::new(RefCell::new(TestClock::new()));
3241        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3242        let manager = AccountsManager::new(clock, cache);
3243
3244        // Close a position by applying an offsetting fill
3245        let open_long = build_hedging_position_at(
3246            &instrument_any,
3247            OrderSide::Buy,
3248            "100",
3249            "1.00000",
3250            "C",
3251            UnixNanos::from(1),
3252        );
3253        let close_fill = OrderFilledSpec::builder()
3254            .instrument_id(instrument.id())
3255            .client_order_id(ClientOrderId::new("Cclose"))
3256            .venue_order_id(VenueOrderId::new("Cclose"))
3257            .trade_id(TradeId::new("Cclose"))
3258            .order_side(OrderSide::Sell)
3259            .last_qty(Quantity::from("100"))
3260            .last_px(Price::from("1.00000"))
3261            .currency(instrument.settlement_currency())
3262            .ts_event(UnixNanos::from(2))
3263            .ts_init(UnixNanos::from(2))
3264            .position_id(PositionId::new("C"))
3265            .build();
3266        let mut closed = open_long;
3267        closed.apply(&close_fill);
3268        assert!(closed.is_closed());
3269
3270        // Active open position alongside the closed one
3271        let live = build_hedging_position_at(
3272            &instrument_any,
3273            OrderSide::Buy,
3274            "50",
3275            "1.00000",
3276            "L",
3277            UnixNanos::from(3),
3278        );
3279
3280        let result = manager.update_positions_in_place(
3281            &mut account,
3282            &instrument_any,
3283            vec![&closed, &live],
3284            UnixNanos::default(),
3285        );
3286        assert!(result.is_some());
3287
3288        // Only the live 50 long contributes: margin = 50 * 1.0 * 0.03 = 1.50 USD
3289        assert_eq!(
3290            account.maintenance_margin(instrument.id()),
3291            Money::new(1.50, usd)
3292        );
3293    }
3294
3295    fn add_usdeur_quote(cache: &Rc<RefCell<Cache>>, bid: &str, ask: &str) {
3296        let instrument = default_fx_ccy(Symbol::from("USD/EUR"), Some(Venue::from("SIM")));
3297        let quote = QuoteTick::new(
3298            instrument.id(),
3299            Price::from(bid),
3300            Price::from(ask),
3301            Quantity::from("1"),
3302            Quantity::from("1"),
3303            UnixNanos::default(),
3304            UnixNanos::default(),
3305        );
3306        let mut cache = cache.borrow_mut();
3307        cache
3308            .add_instrument(InstrumentAny::CurrencyPair(instrument))
3309            .unwrap();
3310        cache.add_quote(quote).unwrap();
3311    }
3312}