Skip to main content

nautilus_model/accounts/
margin.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//! A margin account capable of holding leveraged positions and tracking instrument-specific
17//! leverage ratios.
18//!
19//! # PnL calculation
20//!
21//! The account calculates PnL differently based on instrument type:
22//!
23//! - **Premium instruments** (options, option spreads, binary options, warrants): Realize
24//!   the notional value as a cash flow on every fill. BUY = negative (premium paid),
25//!   SELL = positive (premium received).
26//!
27//! - **Other instruments**: Only realize PnL on position reduction (fill side opposite to
28//!   entry). Use the minimum of fill and position quantity to avoid double-counting.
29
30#![allow(dead_code)]
31
32use std::{
33    fmt::Display,
34    hash::{Hash, Hasher},
35    ops::{Deref, DerefMut},
36};
37
38use ahash::AHashMap;
39use indexmap::IndexMap;
40use nautilus_core::correctness::{CorrectnessResultExt, FAILED, check_positive_decimal};
41use rust_decimal::Decimal;
42use serde::{Deserialize, Serialize};
43
44use crate::{
45    accounts::{
46        Account,
47        base::BaseAccount,
48        margin_model::{MarginModel, MarginModelHandle},
49    },
50    enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide},
51    events::{AccountState, OrderFilled},
52    identifiers::{AccountId, InstrumentId},
53    instruments::{Instrument, InstrumentAny},
54    position::Position,
55    types::{
56        AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
57        money::{MONEY_RAW_MAX, MONEY_RAW_MIN, MoneyRaw},
58    },
59};
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[cfg_attr(
63    feature = "python",
64    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
65)]
66#[cfg_attr(
67    feature = "python",
68    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
69)]
70pub struct MarginAccount {
71    pub base: BaseAccount,
72    pub leverages: AHashMap<InstrumentId, Decimal>,
73    /// Per-instrument margin balances (isolated margin, calculated margin in
74    /// backtest mode). Entries here have a concrete `instrument_id`.
75    pub margins: IndexMap<InstrumentId, MarginBalance>,
76    /// Account-wide (cross margin) margin balances keyed by collateral currency.
77    /// Populated from `AccountState.margins` entries where `instrument_id` is
78    /// `None`. Most derivatives venues in cross-margin mode report here.
79    pub account_margins: IndexMap<Currency, MarginBalance>,
80    pub default_leverage: Decimal,
81    #[serde(skip, default = "MarginModelHandle::default")]
82    margin_model: MarginModelHandle,
83}
84
85fn split_event_margins(
86    event: &AccountState,
87) -> (
88    IndexMap<InstrumentId, MarginBalance>,
89    IndexMap<Currency, MarginBalance>,
90) {
91    let mut per_instrument: IndexMap<InstrumentId, MarginBalance> = IndexMap::new();
92    let mut per_currency: IndexMap<Currency, MarginBalance> = IndexMap::new();
93
94    for margin in &event.margins {
95        match margin.instrument_id {
96            Some(instrument_id) => {
97                per_instrument.insert(instrument_id, *margin);
98            }
99            None => {
100                per_currency.insert(margin.currency, *margin);
101            }
102        }
103    }
104    (per_instrument, per_currency)
105}
106
107impl MarginAccount {
108    /// Creates a new [`MarginAccount`] instance.
109    #[must_use]
110    pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
111        let (margins, account_margins) = split_event_margins(&event);
112
113        Self {
114            base: BaseAccount::new(event, calculate_account_state),
115            leverages: AHashMap::new(),
116            margins,
117            account_margins,
118            default_leverage: Decimal::ONE,
119            margin_model: MarginModelHandle::default(),
120        }
121    }
122
123    #[must_use]
124    pub(crate) fn clone_without_events(&self) -> Self {
125        Self {
126            base: self.base.clone_without_events(),
127            leverages: self.leverages.clone(),
128            margins: self.margins.clone(),
129            account_margins: self.account_margins.clone(),
130            default_leverage: self.default_leverage,
131            margin_model: self.margin_model.clone(),
132        }
133    }
134
135    pub fn set_margin_model(&mut self, model: MarginModelHandle) {
136        self.margin_model = model;
137    }
138
139    #[must_use]
140    pub const fn margin_model(&self) -> &MarginModelHandle {
141        &self.margin_model
142    }
143
144    /// Sets the default leverage for the account.
145    ///
146    /// # Panics
147    ///
148    /// Panics if `leverage` is not positive.
149    pub fn set_default_leverage(&mut self, leverage: Decimal) {
150        check_positive_decimal(leverage, "leverage").expect_display(FAILED);
151        self.default_leverage = leverage;
152    }
153
154    /// Sets the leverage for a specific instrument.
155    ///
156    /// # Panics
157    ///
158    /// Panics if `leverage` is not positive.
159    pub fn set_leverage(&mut self, instrument_id: InstrumentId, leverage: Decimal) {
160        check_positive_decimal(leverage, "leverage").expect_display(FAILED);
161        self.leverages.insert(instrument_id, leverage);
162    }
163
164    #[must_use]
165    pub fn get_leverage(&self, instrument_id: &InstrumentId) -> Decimal {
166        *self
167            .leverages
168            .get(instrument_id)
169            .unwrap_or(&self.default_leverage)
170    }
171
172    #[must_use]
173    pub fn is_unleveraged(&self, instrument_id: InstrumentId) -> bool {
174        self.get_leverage(&instrument_id) == Decimal::ONE
175    }
176
177    #[must_use]
178    pub fn is_cash_account(&self) -> bool {
179        self.account_type == AccountType::Cash
180    }
181
182    #[must_use]
183    pub fn is_margin_account(&self) -> bool {
184        self.account_type == AccountType::Margin
185    }
186
187    #[must_use]
188    pub fn initial_margins(&self) -> IndexMap<InstrumentId, Money> {
189        self.margins
190            .values()
191            .filter_map(|margin| margin.instrument_id.map(|id| (id, margin.initial)))
192            .collect()
193    }
194
195    #[must_use]
196    pub fn maintenance_margins(&self) -> IndexMap<InstrumentId, Money> {
197        self.margins
198            .values()
199            .filter_map(|margin| margin.instrument_id.map(|id| (id, margin.maintenance)))
200            .collect()
201    }
202
203    /// Returns all account-wide initial margins keyed by currency.
204    #[must_use]
205    pub fn account_initial_margins(&self) -> IndexMap<Currency, Money> {
206        self.account_margins
207            .values()
208            .map(|margin| (margin.currency, margin.initial))
209            .collect()
210    }
211
212    /// Returns all account-wide maintenance margins keyed by currency.
213    #[must_use]
214    pub fn account_maintenance_margins(&self) -> IndexMap<Currency, Money> {
215        self.account_margins
216            .values()
217            .map(|margin| (margin.currency, margin.maintenance))
218            .collect()
219    }
220
221    /// Updates the initial margin for the specified instrument.
222    pub fn update_initial_margin(&mut self, instrument_id: InstrumentId, margin_init: Money) {
223        let margin_balance = self.margins.get(&instrument_id);
224        if let Some(balance) = margin_balance {
225            // update the margin_balance initial property with margin_init
226            let mut new_margin_balance = *balance;
227            new_margin_balance.initial = margin_init;
228            self.margins.insert(instrument_id, new_margin_balance);
229        } else {
230            self.margins.insert(
231                instrument_id,
232                MarginBalance::new(
233                    margin_init,
234                    Money::zero(margin_init.currency),
235                    Some(instrument_id),
236                ),
237            );
238        }
239        self.recalculate_balance(margin_init.currency);
240    }
241
242    /// Clears the initial margin for the specified instrument.
243    pub fn clear_initial_margin(&mut self, instrument_id: InstrumentId) {
244        let Some(margin_balance) = self.margins.get(&instrument_id).copied() else {
245            return;
246        };
247
248        if margin_balance.maintenance.is_zero() {
249            self.margins.shift_remove(&instrument_id);
250        } else {
251            let mut new_margin_balance = margin_balance;
252            new_margin_balance.initial = Money::zero(margin_balance.currency);
253            self.margins.insert(instrument_id, new_margin_balance);
254        }
255
256        self.recalculate_balance(margin_balance.currency);
257    }
258
259    /// Returns the initial margin amount for the specified instrument.
260    ///
261    /// # Panics
262    ///
263    /// Panics if no margin balance exists for the given `instrument_id`.
264    #[must_use]
265    pub fn initial_margin(&self, instrument_id: InstrumentId) -> Money {
266        let margin_balance = self.margins.get(&instrument_id);
267        assert!(
268            margin_balance.is_some(),
269            "Cannot get margin_init when no margin_balance"
270        );
271        margin_balance.unwrap().initial
272    }
273
274    /// Updates the maintenance margin for the specified instrument.
275    pub fn update_maintenance_margin(
276        &mut self,
277        instrument_id: InstrumentId,
278        margin_maintenance: Money,
279    ) {
280        let margin_balance = self.margins.get(&instrument_id);
281        if let Some(balance) = margin_balance {
282            // update the margin_balance maintenance property with margin_maintenance
283            let mut new_margin_balance = *balance;
284            new_margin_balance.maintenance = margin_maintenance;
285            self.margins.insert(instrument_id, new_margin_balance);
286        } else {
287            self.margins.insert(
288                instrument_id,
289                MarginBalance::new(
290                    Money::zero(margin_maintenance.currency),
291                    margin_maintenance,
292                    Some(instrument_id),
293                ),
294            );
295        }
296        self.recalculate_balance(margin_maintenance.currency);
297    }
298
299    /// Clears the maintenance margin for the specified instrument.
300    pub fn clear_maintenance_margin(&mut self, instrument_id: InstrumentId) {
301        let Some(margin_balance) = self.margins.get(&instrument_id).copied() else {
302            return;
303        };
304
305        if margin_balance.initial.is_zero() {
306            self.margins.shift_remove(&instrument_id);
307        } else {
308            let mut new_margin_balance = margin_balance;
309            new_margin_balance.maintenance = Money::zero(margin_balance.currency);
310            self.margins.insert(instrument_id, new_margin_balance);
311        }
312
313        self.recalculate_balance(margin_balance.currency);
314    }
315
316    /// Returns the maintenance margin amount for the specified instrument.
317    ///
318    /// # Panics
319    ///
320    /// Panics if no margin balance exists for the given `instrument_id`.
321    #[must_use]
322    pub fn maintenance_margin(&self, instrument_id: InstrumentId) -> Money {
323        let margin_balance = self.margins.get(&instrument_id);
324        assert!(
325            margin_balance.is_some(),
326            "Cannot get maintenance_margin when no margin_balance"
327        );
328        margin_balance.unwrap().maintenance
329    }
330
331    /// Returns the margin balance for the specified instrument.
332    #[must_use]
333    pub fn margin(&self, instrument_id: &InstrumentId) -> Option<MarginBalance> {
334        self.margins.get(instrument_id).copied()
335    }
336
337    /// Returns the account-wide margin balance for the specified collateral currency.
338    #[must_use]
339    pub fn account_margin(&self, currency: &Currency) -> Option<MarginBalance> {
340        self.account_margins.get(currency).copied()
341    }
342
343    /// Returns the account-wide initial margin for the specified collateral currency.
344    #[must_use]
345    pub fn account_initial_margin(&self, currency: &Currency) -> Option<Money> {
346        self.account_margins.get(currency).map(|m| m.initial)
347    }
348
349    /// Returns the account-wide maintenance margin for the specified collateral currency.
350    #[must_use]
351    pub fn account_maintenance_margin(&self, currency: &Currency) -> Option<Money> {
352        self.account_margins.get(currency).map(|m| m.maintenance)
353    }
354
355    /// Returns the total initial margin reserved in the specified currency,
356    /// summing per-instrument and account-wide entries.
357    #[must_use]
358    pub fn total_initial_margin(&self, currency: Currency) -> Money {
359        let mut raw: MoneyRaw = 0;
360
361        for margin in self.margins.values() {
362            if margin.currency == currency {
363                raw = raw.saturating_add(margin.initial.raw);
364            }
365        }
366
367        for margin in self.account_margins.values() {
368            if margin.currency == currency {
369                raw = raw.saturating_add(margin.initial.raw);
370            }
371        }
372
373        Money::from_raw(clamp_money_raw(raw), currency)
374    }
375
376    /// Returns the total maintenance margin reserved in the specified currency,
377    /// summing per-instrument and account-wide entries.
378    #[must_use]
379    pub fn total_maintenance_margin(&self, currency: Currency) -> Money {
380        let mut raw: MoneyRaw = 0;
381
382        for margin in self.margins.values() {
383            if margin.currency == currency {
384                raw = raw.saturating_add(margin.maintenance.raw);
385            }
386        }
387
388        for margin in self.account_margins.values() {
389            if margin.currency == currency {
390                raw = raw.saturating_add(margin.maintenance.raw);
391            }
392        }
393
394        Money::from_raw(clamp_money_raw(raw), currency)
395    }
396
397    /// Updates the margin balance for the specified instrument or collateral.
398    ///
399    /// When `margin_balance.instrument_id` is `Some`, the entry is stored as a
400    /// per-instrument margin. When `None`, the entry is stored as an
401    /// account-wide margin keyed by `margin_balance.currency`.
402    pub fn update_margin(&mut self, margin_balance: MarginBalance) {
403        match margin_balance.instrument_id {
404            Some(instrument_id) => {
405                self.margins.insert(instrument_id, margin_balance);
406            }
407            None => {
408                self.account_margins
409                    .insert(margin_balance.currency, margin_balance);
410            }
411        }
412        self.recalculate_balance(margin_balance.currency);
413    }
414
415    /// Clears the margin for the specified instrument.
416    pub fn clear_margin(&mut self, instrument_id: InstrumentId) {
417        if let Some(margin_balance) = self.margins.shift_remove(&instrument_id) {
418            self.recalculate_balance(margin_balance.currency);
419        }
420    }
421
422    /// Clears the account-wide margin for the specified collateral currency.
423    pub fn clear_account_margin(&mut self, currency: Currency) {
424        if self.account_margins.shift_remove(&currency).is_some() {
425            self.recalculate_balance(currency);
426        }
427    }
428
429    /// Calculates the initial margin amount for the specified instrument and quantity.
430    ///
431    /// Delegates to the configured [`MarginModel`].
432    ///
433    /// # Errors
434    ///
435    /// Returns an error if leverage is not positive, or if the result cannot be represented
436    /// as `Money`.
437    pub fn calculate_initial_margin<T: Instrument>(
438        &mut self,
439        instrument: &T,
440        quantity: Quantity,
441        price: Price,
442        use_quote_for_inverse: Option<bool>,
443    ) -> anyhow::Result<Money> {
444        let leverage = self.get_leverage(&instrument.id());
445        self.margin_model.calculate_initial_margin(
446            instrument,
447            quantity,
448            price,
449            leverage,
450            use_quote_for_inverse,
451        )
452    }
453
454    /// Calculates the maintenance margin amount for the specified instrument and quantity.
455    ///
456    /// Delegates to the configured [`MarginModel`].
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the result cannot be represented as `Money`.
461    pub fn calculate_maintenance_margin<T: Instrument>(
462        &mut self,
463        instrument: &T,
464        quantity: Quantity,
465        price: Price,
466        use_quote_for_inverse: Option<bool>,
467    ) -> anyhow::Result<Money> {
468        let leverage = self.get_leverage(&instrument.id());
469        self.margin_model.calculate_maintenance_margin(
470            instrument,
471            quantity,
472            price,
473            leverage,
474            use_quote_for_inverse,
475        )
476    }
477
478    /// Recalculates the account balance for the specified currency based on current margins.
479    ///
480    /// # Panics
481    ///
482    /// This function panics if:
483    /// - Margin calculation overflows.
484    pub fn recalculate_balance(&mut self, currency: Currency) {
485        let current_balance = if let Some(balance) = self.balances.get(&currency) {
486            *balance
487        } else {
488            // Materializing a balance here would assert the venue holds zero of this currency.
489            // On a unified account that collateralizes across assets, absence is not zero, and
490            // the fabricated entry reads as a real venue-reported balance downstream.
491            log::debug!("Cannot recalculate balance when no current balance for {currency}");
492            return;
493        };
494
495        let mut total_margin: MoneyRaw = 0;
496
497        let accumulate = |raw: MoneyRaw, margin: &MarginBalance| -> MoneyRaw {
498            raw.checked_add(margin.initial.raw)
499                .and_then(|sum| sum.checked_add(margin.maintenance.raw))
500                .unwrap_or_else(|| {
501                    panic!(
502                        "Margin calculation overflow for currency {}: total would exceed maximum",
503                        currency.code
504                    )
505                })
506        };
507
508        for margin in self.margins.values() {
509            if margin.currency == currency {
510                total_margin = accumulate(total_margin, margin);
511            }
512        }
513
514        for margin in self.account_margins.values() {
515            if margin.currency == currency {
516                total_margin = accumulate(total_margin, margin);
517            }
518        }
519
520        // Clamp margin to total balance if it would result in negative free balance.
521        // This can occur transiently when venue and client state are out of sync.
522        // Locked margin must never be negative (even if total balance is negative).
523        let total_free = if total_margin > current_balance.total.raw {
524            total_margin = current_balance.total.raw.max(0);
525            current_balance.total.raw - total_margin
526        } else {
527            current_balance.total.raw - total_margin
528        };
529
530        let new_balance = AccountBalance::new(
531            current_balance.total,
532            Money::from_raw(total_margin, currency),
533            Money::from_raw(total_free, currency),
534        );
535        self.balances.insert(currency, new_balance);
536    }
537}
538
539#[inline]
540fn clamp_money_raw(raw: MoneyRaw) -> MoneyRaw {
541    raw.clamp(MONEY_RAW_MIN, MONEY_RAW_MAX)
542}
543
544impl Deref for MarginAccount {
545    type Target = BaseAccount;
546
547    fn deref(&self) -> &Self::Target {
548        &self.base
549    }
550}
551
552impl DerefMut for MarginAccount {
553    fn deref_mut(&mut self) -> &mut Self::Target {
554        &mut self.base
555    }
556}
557
558impl Account for MarginAccount {
559    fn id(&self) -> AccountId {
560        self.id
561    }
562
563    fn account_type(&self) -> AccountType {
564        self.account_type
565    }
566
567    fn base_currency(&self) -> Option<Currency> {
568        self.base_currency
569    }
570
571    fn is_cash_account(&self) -> bool {
572        self.account_type == AccountType::Cash
573    }
574
575    fn is_margin_account(&self) -> bool {
576        self.account_type == AccountType::Margin
577    }
578
579    fn calculated_account_state(&self) -> bool {
580        self.calculate_account_state
581    }
582
583    fn balance_total(&self, currency: Option<Currency>) -> Option<Money> {
584        self.base_balance_total(currency)
585    }
586
587    fn balances_total(&self) -> IndexMap<Currency, Money> {
588        self.base_balances_total()
589    }
590
591    fn balance_free(&self, currency: Option<Currency>) -> Option<Money> {
592        self.base_balance_free(currency)
593    }
594
595    fn balances_free(&self) -> IndexMap<Currency, Money> {
596        self.base_balances_free()
597    }
598
599    fn balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
600        self.base_balance_locked(currency)
601    }
602
603    fn balances_locked(&self) -> IndexMap<Currency, Money> {
604        self.base_balances_locked()
605    }
606
607    fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
608        self.base_balance(currency)
609    }
610
611    fn last_event(&self) -> Option<AccountState> {
612        self.base_last_event()
613    }
614
615    fn events(&self) -> Vec<AccountState> {
616        self.events.clone()
617    }
618
619    fn event_count(&self) -> usize {
620        self.events.len()
621    }
622
623    fn currencies(&self) -> Vec<Currency> {
624        self.balances.keys().copied().collect()
625    }
626
627    fn starting_balances(&self) -> IndexMap<Currency, Money> {
628        self.balances_starting.clone()
629    }
630
631    fn balances(&self) -> IndexMap<Currency, AccountBalance> {
632        self.balances.clone()
633    }
634
635    fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
636        let skip_margin_routing = event.balances.is_empty() && event.margins.is_empty();
637        let (per_instrument, per_currency) = split_event_margins(&event);
638        self.base_apply(event);
639
640        if !skip_margin_routing {
641            self.margins = per_instrument;
642            self.account_margins = per_currency;
643        }
644        Ok(())
645    }
646
647    fn purge_account_events(&mut self, ts_now: nautilus_core::UnixNanos, lookback_secs: u64) {
648        self.base.base_purge_account_events(ts_now, lookback_secs);
649    }
650
651    fn calculate_balance_locked(
652        &mut self,
653        instrument: &InstrumentAny,
654        side: OrderSide,
655        quantity: Quantity,
656        price: Price,
657        use_quote_for_inverse: Option<bool>,
658    ) -> anyhow::Result<Money> {
659        self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
660    }
661
662    fn calculate_pnls(
663        &self,
664        instrument: &InstrumentAny,
665        fill: &OrderFilled,
666        position: Option<Position>,
667    ) -> anyhow::Result<Vec<Money>> {
668        let mut pnls: Vec<Money> = Vec::new();
669
670        // For premium-based instruments, realize the notional value as a cash flow on every fill
671        let instrument_class = instrument.instrument_class();
672
673        if matches!(
674            instrument_class,
675            InstrumentClass::Option
676                | InstrumentClass::OptionSpread
677                | InstrumentClass::BinaryOption
678                | InstrumentClass::Warrant
679        ) {
680            let notional =
681                instrument.try_calculate_notional_value(fill.last_qty, fill.last_px, None)?;
682            let pnl = if fill.order_side == OrderSide::Buy {
683                Money::from_raw(-notional.raw, notional.currency)
684            } else {
685                notional
686            };
687            pnls.push(pnl);
688            return Ok(pnls);
689        }
690
691        // For other instruments, only realize PnL on position reduction
692        if let Some(ref pos) = position
693            && pos.quantity.is_positive()
694            && pos.entry != fill.order_side
695        {
696            // Calculate and add PnL using the minimum of fill quantity and position quantity
697            // to avoid double-limiting that occurs in position.calculate_pnl()
698            let pnl_quantity = Quantity::from_raw(
699                fill.last_qty.raw.min(pos.quantity.raw),
700                fill.last_qty.precision,
701            );
702            let pnl =
703                pos.try_calculate_pnl(pos.avg_px_open, fill.last_px.as_f64(), pnl_quantity)?;
704            pnls.push(pnl);
705        }
706
707        Ok(pnls)
708    }
709
710    fn calculate_commission(
711        &self,
712        instrument: &InstrumentAny,
713        last_qty: Quantity,
714        last_px: Price,
715        liquidity_side: LiquiditySide,
716        use_quote_for_inverse: Option<bool>,
717    ) -> anyhow::Result<Money> {
718        self.base_calculate_commission(
719            instrument,
720            last_qty,
721            last_px,
722            liquidity_side,
723            use_quote_for_inverse,
724        )
725    }
726}
727
728impl PartialEq for MarginAccount {
729    fn eq(&self, other: &Self) -> bool {
730        self.id == other.id
731    }
732}
733
734impl Eq for MarginAccount {}
735
736impl Display for MarginAccount {
737    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
738        write!(
739            f,
740            "MarginAccount(id={}, type={}, base={})",
741            self.id,
742            self.account_type,
743            self.base_currency.map_or_else(
744                || "None".to_string(),
745                |base_currency| format!("{}", base_currency.code)
746            ),
747        )
748    }
749}
750
751impl Hash for MarginAccount {
752    fn hash<H: Hasher>(&self, state: &mut H) {
753        self.id.hash(state);
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use indexmap::IndexMap;
760    use nautilus_core::UnixNanos;
761    use rstest::rstest;
762    use rust_decimal::Decimal;
763
764    use crate::{
765        accounts::{
766            Account, MarginAccount,
767            margin_model::{MarginModel, MarginModelHandle},
768            stubs::*,
769        },
770        enums::{AccountType, OrderSide, OrderType},
771        events::{AccountState, account::stubs::*, order::spec::OrderFilledSpec},
772        identifiers::{
773            AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId,
774            stubs::{uuid4, *},
775        },
776        instruments::{
777            CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny,
778            stubs::{binary_option, option_contract_appl, *},
779        },
780        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
781        position::Position,
782        types::{
783            AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
784            money::{MONEY_RAW_MAX, MONEY_RAW_MIN},
785        },
786    };
787
788    struct CustomMarginModel;
789
790    impl MarginModel for CustomMarginModel {
791        fn name(&self) -> &'static str {
792            "custom"
793        }
794
795        fn calculate_initial_margin(
796            &self,
797            _instrument: &dyn Instrument,
798            _quantity: Quantity,
799            _price: Price,
800            _leverage: Decimal,
801            _use_quote_for_inverse: Option<bool>,
802        ) -> anyhow::Result<Money> {
803            Ok(Money::from("12.34 USD"))
804        }
805
806        fn calculate_maintenance_margin(
807            &self,
808            _instrument: &dyn Instrument,
809            _quantity: Quantity,
810            _price: Price,
811            _leverage: Decimal,
812            _use_quote_for_inverse: Option<bool>,
813        ) -> anyhow::Result<Money> {
814            Ok(Money::from("5.67 USD"))
815        }
816    }
817
818    #[rstest]
819    fn test_display(margin_account: MarginAccount) {
820        assert_eq!(
821            margin_account.to_string(),
822            "MarginAccount(id=SIM-001, type=MARGIN, base=USD)"
823        );
824    }
825
826    #[rstest]
827    fn test_calculated_account_state_returns_field_value(margin_account_state: AccountState) {
828        assert!(MarginAccount::new(margin_account_state.clone(), true).calculated_account_state());
829        assert!(!MarginAccount::new(margin_account_state, false).calculated_account_state());
830    }
831
832    #[rstest]
833    fn test_base_account_properties(
834        margin_account: MarginAccount,
835        margin_account_state: AccountState,
836    ) {
837        assert_eq!(margin_account.base_currency, Some(Currency::from("USD")));
838        assert_eq!(
839            margin_account.last_event(),
840            Some(margin_account_state.clone())
841        );
842        assert_eq!(margin_account.events(), vec![margin_account_state.clone()]);
843        assert_eq!(margin_account.event_count(), 1);
844        assert_eq!(
845            margin_account.balance_total(None),
846            Some(Money::from("1525000 USD"))
847        );
848        assert_eq!(
849            margin_account.balance_free(None),
850            Some(Money::from("1500000 USD"))
851        );
852        assert_eq!(
853            margin_account.balance_locked(None),
854            Some(Money::from("25000 USD"))
855        );
856        let mut balances_total_expected = IndexMap::new();
857        balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
858        assert_eq!(margin_account.balances_total(), balances_total_expected);
859        let mut balances_free_expected = IndexMap::new();
860        balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
861        assert_eq!(margin_account.balances_free(), balances_free_expected);
862        let mut balances_locked_expected = IndexMap::new();
863        balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
864        assert_eq!(margin_account.balances_locked(), balances_locked_expected);
865        let margin_balance = margin_account_state.margins[0];
866        let instrument_id = margin_balance
867            .instrument_id
868            .expect("stub margin balance carries a concrete instrument_id");
869        let mut initial_margins_expected = IndexMap::new();
870        initial_margins_expected.insert(instrument_id, margin_balance.initial);
871        assert_eq!(margin_account.initial_margins(), initial_margins_expected);
872        let mut maintenance_margins_expected = IndexMap::new();
873        maintenance_margins_expected.insert(instrument_id, margin_balance.maintenance);
874        assert_eq!(
875            margin_account.maintenance_margins(),
876            maintenance_margins_expected
877        );
878    }
879
880    #[rstest]
881    fn test_set_default_leverage(mut margin_account: MarginAccount) {
882        assert_eq!(margin_account.default_leverage, Decimal::ONE);
883        margin_account.set_default_leverage(Decimal::from(10));
884        assert_eq!(margin_account.default_leverage, Decimal::from(10));
885    }
886
887    #[rstest]
888    fn test_get_leverage_default_leverage(
889        margin_account: MarginAccount,
890        instrument_id_aud_usd_sim: InstrumentId,
891    ) {
892        assert_eq!(
893            margin_account.get_leverage(&instrument_id_aud_usd_sim),
894            Decimal::ONE
895        );
896    }
897
898    #[rstest]
899    fn test_set_leverage(
900        mut margin_account: MarginAccount,
901        instrument_id_aud_usd_sim: InstrumentId,
902    ) {
903        assert_eq!(margin_account.leverages.len(), 0);
904        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
905        assert_eq!(margin_account.leverages.len(), 1);
906        assert_eq!(
907            margin_account.get_leverage(&instrument_id_aud_usd_sim),
908            Decimal::from(10)
909        );
910    }
911
912    #[rstest]
913    fn test_is_unleveraged_with_leverage_returns_false(
914        mut margin_account: MarginAccount,
915        instrument_id_aud_usd_sim: InstrumentId,
916    ) {
917        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
918        assert!(!margin_account.is_unleveraged(instrument_id_aud_usd_sim));
919    }
920
921    #[rstest]
922    fn test_is_unleveraged_with_no_leverage_returns_true(
923        mut margin_account: MarginAccount,
924        instrument_id_aud_usd_sim: InstrumentId,
925    ) {
926        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::ONE);
927        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
928    }
929
930    #[rstest]
931    fn test_is_unleveraged_with_default_leverage_of_1_returns_true(
932        margin_account: MarginAccount,
933        instrument_id_aud_usd_sim: InstrumentId,
934    ) {
935        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
936    }
937
938    #[rstest]
939    fn test_update_margin_init(
940        mut margin_account: MarginAccount,
941        instrument_id_aud_usd_sim: InstrumentId,
942    ) {
943        assert_eq!(margin_account.margins.len(), 1);
944        let margin = Money::from("10000 USD");
945        margin_account.update_initial_margin(instrument_id_aud_usd_sim, margin);
946        assert_eq!(
947            margin_account.initial_margin(instrument_id_aud_usd_sim),
948            margin
949        );
950        assert_eq!(margin_account.margins.len(), 2);
951        assert_eq!(
952            margin_account
953                .margins
954                .get(&instrument_id_aud_usd_sim)
955                .expect("AUD/USD margin should exist")
956                .initial,
957            margin
958        );
959        assert_eq!(
960            margin_account
961                .margins
962                .get(&instrument_id_aud_usd_sim)
963                .expect("AUD/USD margin should exist")
964                .maintenance,
965            Money::zero(margin.currency)
966        );
967    }
968
969    #[rstest]
970    fn test_update_margin_maintenance(
971        mut margin_account: MarginAccount,
972        instrument_id_aud_usd_sim: InstrumentId,
973    ) {
974        let margin = Money::from("10000 USD");
975        margin_account.update_maintenance_margin(instrument_id_aud_usd_sim, margin);
976        assert_eq!(
977            margin_account.maintenance_margin(instrument_id_aud_usd_sim),
978            margin
979        );
980        assert_eq!(margin_account.margins.len(), 2);
981        assert_eq!(
982            margin_account
983                .margins
984                .get(&instrument_id_aud_usd_sim)
985                .expect("AUD/USD margin should exist")
986                .maintenance,
987            margin
988        );
989        assert_eq!(
990            margin_account
991                .margins
992                .get(&instrument_id_aud_usd_sim)
993                .expect("AUD/USD margin should exist")
994                .initial,
995            Money::zero(margin.currency)
996        );
997    }
998
999    #[rstest]
1000    fn test_clear_initial_margin_preserves_maintenance(
1001        mut margin_account: MarginAccount,
1002        instrument_id_aud_usd_sim: InstrumentId,
1003    ) {
1004        margin_account.update_margin(MarginBalance::new(
1005            Money::from("1000 USD"),
1006            Money::from("500 USD"),
1007            Some(instrument_id_aud_usd_sim),
1008        ));
1009
1010        margin_account.clear_initial_margin(instrument_id_aud_usd_sim);
1011
1012        let margin = margin_account
1013            .margin(&instrument_id_aud_usd_sim)
1014            .expect("margin should retain non-zero maintenance");
1015        assert_eq!(margin.initial, Money::from("0 USD"));
1016        assert_eq!(margin.maintenance, Money::from("500 USD"));
1017    }
1018
1019    #[rstest]
1020    fn test_clear_maintenance_margin_removes_empty_entry(
1021        mut margin_account: MarginAccount,
1022        instrument_id_aud_usd_sim: InstrumentId,
1023    ) {
1024        margin_account.update_margin(MarginBalance::new(
1025            Money::from("0 USD"),
1026            Money::from("500 USD"),
1027            Some(instrument_id_aud_usd_sim),
1028        ));
1029
1030        margin_account.clear_maintenance_margin(instrument_id_aud_usd_sim);
1031
1032        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_none());
1033    }
1034
1035    #[rstest]
1036    fn test_clear_maintenance_margin_preserves_initial(
1037        mut margin_account: MarginAccount,
1038        instrument_id_aud_usd_sim: InstrumentId,
1039    ) {
1040        margin_account.update_margin(MarginBalance::new(
1041            Money::from("1000 USD"),
1042            Money::from("500 USD"),
1043            Some(instrument_id_aud_usd_sim),
1044        ));
1045
1046        margin_account.clear_maintenance_margin(instrument_id_aud_usd_sim);
1047
1048        let margin = margin_account
1049            .margin(&instrument_id_aud_usd_sim)
1050            .expect("margin should retain non-zero initial");
1051        assert_eq!(margin.initial, Money::from("1000 USD"));
1052        assert_eq!(margin.maintenance, Money::from("0 USD"));
1053    }
1054
1055    #[rstest]
1056    fn test_apply_replaces_margin_balances_from_event(
1057        mut margin_account: MarginAccount,
1058        margin_account_state: AccountState,
1059    ) {
1060        let old_instrument_id = margin_account_state.margins[0]
1061            .instrument_id
1062            .expect("stub margin balance carries a concrete instrument_id");
1063        let new_instrument_id = InstrumentId::from("USDJPY.SIM");
1064        let event = AccountState::new(
1065            margin_account_state.account_id,
1066            AccountType::Margin,
1067            margin_account_state.balances.clone(),
1068            vec![MarginBalance::new(
1069                Money::from("12500 USD"),
1070                Money::from("25000 USD"),
1071                Some(new_instrument_id),
1072            )],
1073            true,
1074            uuid4(),
1075            1.into(),
1076            1.into(),
1077            margin_account_state.base_currency,
1078        );
1079
1080        margin_account.apply(event).unwrap();
1081
1082        assert_eq!(
1083            margin_account.initial_margin(new_instrument_id),
1084            Money::from("12500 USD")
1085        );
1086        assert_eq!(
1087            margin_account.maintenance_margin(new_instrument_id),
1088            Money::from("25000 USD")
1089        );
1090        assert!(margin_account.margin(&old_instrument_id).is_none());
1091    }
1092
1093    #[rstest]
1094    fn test_apply_routes_account_margins_by_currency(
1095        mut margin_account: MarginAccount,
1096        margin_account_state: AccountState,
1097    ) {
1098        let usd = Currency::USD();
1099        let event = AccountState::new(
1100            margin_account_state.account_id,
1101            AccountType::Margin,
1102            margin_account_state.balances.clone(),
1103            vec![MarginBalance::new(
1104                Money::from("12500 USD"),
1105                Money::from("25000 USD"),
1106                None,
1107            )],
1108            true,
1109            uuid4(),
1110            1.into(),
1111            1.into(),
1112            margin_account_state.base_currency,
1113        );
1114
1115        margin_account.apply(event).unwrap();
1116
1117        assert!(margin_account.margins.is_empty());
1118        assert_eq!(margin_account.account_margins.len(), 1);
1119        assert_eq!(
1120            margin_account.account_initial_margin(&usd),
1121            Some(Money::from("12500 USD"))
1122        );
1123        assert_eq!(
1124            margin_account.account_maintenance_margin(&usd),
1125            Some(Money::from("25000 USD"))
1126        );
1127        assert_eq!(
1128            margin_account.total_initial_margin(usd),
1129            Money::from("12500 USD")
1130        );
1131    }
1132
1133    #[rstest]
1134    fn test_apply_empty_event_preserves_margin_balances(
1135        mut margin_account: MarginAccount,
1136        margin_account_state: AccountState,
1137    ) {
1138        let instrument_id = margin_account_state.margins[0]
1139            .instrument_id
1140            .expect("stub margin balance carries a concrete instrument_id");
1141        let initial_margin = margin_account.initial_margin(instrument_id);
1142        let maintenance_margin = margin_account.maintenance_margin(instrument_id);
1143
1144        let empty_event = AccountState::new(
1145            margin_account_state.account_id,
1146            AccountType::Margin,
1147            vec![],
1148            vec![],
1149            true,
1150            uuid4(),
1151            1.into(),
1152            1.into(),
1153            margin_account_state.base_currency,
1154        );
1155
1156        margin_account.apply(empty_event).unwrap();
1157
1158        assert_eq!(margin_account.initial_margin(instrument_id), initial_margin);
1159        assert_eq!(
1160            margin_account.maintenance_margin(instrument_id),
1161            maintenance_margin
1162        );
1163        assert_eq!(margin_account.event_count(), 2);
1164    }
1165
1166    #[rstest]
1167    fn test_calculate_margin_init_with_leverage(
1168        mut margin_account: MarginAccount,
1169        audusd_sim: CurrencyPair,
1170    ) {
1171        margin_account.set_leverage(audusd_sim.id, Decimal::from(50));
1172        let result = margin_account
1173            .calculate_initial_margin(
1174                &audusd_sim,
1175                Quantity::from(100_000),
1176                Price::from("0.8000"),
1177                None,
1178            )
1179            .unwrap();
1180        assert_eq!(result, Money::from("48.00 USD"));
1181    }
1182
1183    #[rstest]
1184    fn test_custom_margin_model_through_account(
1185        mut margin_account: MarginAccount,
1186        audusd_sim: CurrencyPair,
1187    ) {
1188        margin_account.set_margin_model(MarginModelHandle::new(CustomMarginModel));
1189
1190        let initial = margin_account
1191            .calculate_initial_margin(
1192                &audusd_sim,
1193                Quantity::from(100_000),
1194                Price::from("0.8000"),
1195                None,
1196            )
1197            .unwrap();
1198        let maintenance = margin_account
1199            .calculate_maintenance_margin(
1200                &audusd_sim,
1201                Quantity::from(100_000),
1202                Price::from("0.8000"),
1203                None,
1204            )
1205            .unwrap();
1206
1207        assert_eq!(margin_account.margin_model().name(), "custom");
1208        assert_eq!(initial, Money::from("12.34 USD"));
1209        assert_eq!(maintenance, Money::from("5.67 USD"));
1210    }
1211
1212    #[rstest]
1213    fn test_calculate_margin_init_with_default_leverage(
1214        mut margin_account: MarginAccount,
1215        audusd_sim: CurrencyPair,
1216    ) {
1217        margin_account.set_default_leverage(Decimal::from(10));
1218        let result = margin_account
1219            .calculate_initial_margin(
1220                &audusd_sim,
1221                Quantity::from(100_000),
1222                Price::from("0.8"),
1223                None,
1224            )
1225            .unwrap();
1226        assert_eq!(result, Money::from("240.00 USD"));
1227    }
1228
1229    #[rstest]
1230    fn test_calculate_margin_init_with_no_leverage_for_inverse(
1231        mut margin_account: MarginAccount,
1232        xbtusd_bitmex: CryptoPerpetual,
1233    ) {
1234        let result_use_quote_inverse_true = margin_account
1235            .calculate_initial_margin(
1236                &xbtusd_bitmex,
1237                Quantity::from(100_000),
1238                Price::from("11493.60"),
1239                Some(false),
1240            )
1241            .unwrap();
1242        assert_eq!(result_use_quote_inverse_true, Money::from("0.08700494 BTC"));
1243        let result_use_quote_inverse_false = margin_account
1244            .calculate_initial_margin(
1245                &xbtusd_bitmex,
1246                Quantity::from(100_000),
1247                Price::from("11493.60"),
1248                Some(true),
1249            )
1250            .unwrap();
1251        assert_eq!(result_use_quote_inverse_false, Money::from("1000 USD"));
1252    }
1253
1254    #[rstest]
1255    fn test_calculate_margin_maintenance_with_no_leverage(
1256        mut margin_account: MarginAccount,
1257        xbtusd_bitmex: CryptoPerpetual,
1258    ) {
1259        let result = margin_account
1260            .calculate_maintenance_margin(
1261                &xbtusd_bitmex,
1262                Quantity::from(100_000),
1263                Price::from("11493.60"),
1264                None,
1265            )
1266            .unwrap();
1267        assert_eq!(result, Money::from("0.03045173 BTC"));
1268    }
1269
1270    #[rstest]
1271    fn test_calculate_margin_maintenance_with_leverage_fx_instrument(
1272        mut margin_account: MarginAccount,
1273        audusd_sim: CurrencyPair,
1274    ) {
1275        margin_account.set_default_leverage(Decimal::from(50));
1276        let result = margin_account
1277            .calculate_maintenance_margin(
1278                &audusd_sim,
1279                Quantity::from(1_000_000),
1280                Price::from("1"),
1281                None,
1282            )
1283            .unwrap();
1284        assert_eq!(result, Money::from("600.00 USD"));
1285    }
1286
1287    #[rstest]
1288    fn test_calculate_margin_maintenance_with_leverage_inverse_instrument(
1289        mut margin_account: MarginAccount,
1290        xbtusd_bitmex: CryptoPerpetual,
1291    ) {
1292        margin_account.set_default_leverage(Decimal::from(10));
1293        let result = margin_account
1294            .calculate_maintenance_margin(
1295                &xbtusd_bitmex,
1296                Quantity::from(100_000),
1297                Price::from("100000.00"),
1298                None,
1299            )
1300            .unwrap();
1301        assert_eq!(result, Money::from("0.00035000 BTC"));
1302    }
1303
1304    #[rstest]
1305    fn test_calculate_pnls_github_issue_2657() {
1306        // Create a margin account
1307        let account_state = margin_account_state();
1308        let account = MarginAccount::new(account_state, false);
1309
1310        // Create BTCUSDT instrument
1311        let btcusdt = currency_pair_btcusdt();
1312        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt);
1313
1314        // Create initial position with BUY 0.001 BTC at 50000.00
1315        let fill1 = OrderFilledSpec::builder()
1316            .instrument_id(btcusdt_any.id())
1317            .client_order_id(ClientOrderId::from("O-1"))
1318            .venue_order_id(VenueOrderId::from("V-1"))
1319            .trade_id(TradeId::from("T-1"))
1320            .last_qty(Quantity::from("0.001"))
1321            .last_px(Price::from("50000.00"))
1322            .currency(btcusdt_any.quote_currency())
1323            .ts_event(UnixNanos::from(1_000_000_000))
1324            .position_id(PositionId::from("P-GITHUB-2657"))
1325            .build();
1326
1327        let position = Position::new(&btcusdt_any, fill1);
1328
1329        // Create second fill that sells MORE than position size (0.002 > 0.001)
1330        let fill2 = OrderFilledSpec::builder()
1331            .instrument_id(btcusdt_any.id())
1332            .client_order_id(ClientOrderId::from("O-2"))
1333            .venue_order_id(VenueOrderId::from("V-2"))
1334            .trade_id(TradeId::from("T-2"))
1335            .order_side(OrderSide::Sell)
1336            .last_qty(Quantity::from("0.002")) // This is larger than position quantity!
1337            .last_px(Price::from("50075.00"))
1338            .currency(btcusdt_any.quote_currency())
1339            .ts_event(UnixNanos::from(2_000_000_000))
1340            .position_id(PositionId::from("P-GITHUB-2657"))
1341            .build();
1342
1343        // Test the fix - should only calculate PnL for position quantity (0.001), not fill quantity (0.002)
1344        let pnls = account
1345            .calculate_pnls(&btcusdt_any, &fill2, Some(position))
1346            .unwrap();
1347
1348        // Should have exactly one PnL entry
1349        assert_eq!(pnls.len(), 1);
1350
1351        // Expected PnL should be for 0.001 BTC, not 0.002 BTC
1352        // PnL = (50075.00 - 50000.00) * 0.001 = 75.0 * 0.001 = 0.075 USDT
1353        let expected_pnl = Money::from("0.075 USDT");
1354        assert_eq!(pnls[0], expected_pnl);
1355    }
1356
1357    #[rstest]
1358    #[should_panic(expected = "not positive")]
1359    fn test_set_leverage_zero_panics(mut margin_account: MarginAccount, audusd_sim: CurrencyPair) {
1360        margin_account.set_leverage(audusd_sim.id, Decimal::ZERO);
1361    }
1362
1363    #[rstest]
1364    #[should_panic(expected = "not positive")]
1365    fn test_set_default_leverage_zero_panics(mut margin_account: MarginAccount) {
1366        margin_account.set_default_leverage(Decimal::ZERO);
1367    }
1368
1369    #[rstest]
1370    #[should_panic(expected = "not positive")]
1371    fn test_set_leverage_negative_panics(
1372        mut margin_account: MarginAccount,
1373        audusd_sim: CurrencyPair,
1374    ) {
1375        margin_account.set_leverage(audusd_sim.id, Decimal::from(-1));
1376    }
1377
1378    #[rstest]
1379    fn test_calculate_pnls_with_same_side_fill_returns_empty() {
1380        use nautilus_core::UnixNanos;
1381
1382        use crate::{
1383            events::order::spec::OrderFilledSpec,
1384            identifiers::{ClientOrderId, PositionId, TradeId, VenueOrderId},
1385            instruments::InstrumentAny,
1386            position::Position,
1387            types::{Price, Quantity},
1388        };
1389
1390        // Create a margin account
1391        let account_state = margin_account_state();
1392        let account = MarginAccount::new(account_state, false);
1393
1394        // Create BTCUSDT instrument
1395        let btcusdt = currency_pair_btcusdt();
1396        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt.clone());
1397
1398        // Create initial position with BUY 1.0 BTC at 50000.00
1399        let fill1 = OrderFilledSpec::builder()
1400            .instrument_id(btcusdt.id)
1401            .client_order_id(ClientOrderId::from("O-1"))
1402            .venue_order_id(VenueOrderId::from("V-1"))
1403            .trade_id(TradeId::from("T-1"))
1404            .last_qty(Quantity::from("1.0"))
1405            .last_px(Price::from("50000.00"))
1406            .currency(btcusdt.quote_currency)
1407            .ts_event(UnixNanos::from(1_000_000_000))
1408            .position_id(PositionId::from("P-123456"))
1409            .build();
1410
1411        let position = Position::new(&btcusdt_any, fill1);
1412
1413        // Create second fill that also BUYS (same side as position entry)
1414        let fill2 = OrderFilledSpec::builder()
1415            .instrument_id(btcusdt.id)
1416            .client_order_id(ClientOrderId::from("O-2"))
1417            .venue_order_id(VenueOrderId::from("V-2"))
1418            .trade_id(TradeId::from("T-2"))
1419            .last_qty(Quantity::from("0.5"))
1420            .last_px(Price::from("51000.00"))
1421            .currency(btcusdt.quote_currency)
1422            .ts_event(UnixNanos::from(2_000_000_000))
1423            .position_id(PositionId::from("P-123456"))
1424            .build();
1425
1426        // Test that no PnL is calculated for same-side fills
1427        let pnls = account
1428            .calculate_pnls(&btcusdt_any, &fill2, Some(position))
1429            .unwrap();
1430
1431        // Should return empty PnL list
1432        assert_eq!(pnls.len(), 0);
1433    }
1434
1435    #[rstest]
1436    fn test_margin_accessor(
1437        mut margin_account: MarginAccount,
1438        instrument_id_aud_usd_sim: InstrumentId,
1439    ) {
1440        let margin_balance = MarginBalance::new(
1441            Money::from("1000 USD"),
1442            Money::from("500 USD"),
1443            Some(instrument_id_aud_usd_sim),
1444        );
1445
1446        margin_account.update_margin(margin_balance);
1447
1448        let retrieved = margin_account.margin(&instrument_id_aud_usd_sim);
1449        assert!(retrieved.is_some());
1450        let retrieved = retrieved.unwrap();
1451        assert_eq!(retrieved.initial, Money::from("1000 USD"));
1452        assert_eq!(retrieved.maintenance, Money::from("500 USD"));
1453        assert_eq!(retrieved.instrument_id, Some(instrument_id_aud_usd_sim));
1454    }
1455
1456    #[rstest]
1457    fn test_clear_margin(
1458        mut margin_account: MarginAccount,
1459        instrument_id_aud_usd_sim: InstrumentId,
1460    ) {
1461        let margin_balance = MarginBalance::new(
1462            Money::from("1000 USD"),
1463            Money::from("500 USD"),
1464            Some(instrument_id_aud_usd_sim),
1465        );
1466
1467        margin_account.update_margin(margin_balance);
1468        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_some());
1469
1470        margin_account.clear_margin(instrument_id_aud_usd_sim);
1471        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_none());
1472    }
1473
1474    #[rstest]
1475    fn test_update_margin_routes_account_wide(mut margin_account: MarginAccount) {
1476        let usd = Currency::USD();
1477        let margin_balance =
1478            MarginBalance::new(Money::from("200 USD"), Money::from("100 USD"), None);
1479
1480        margin_account.update_margin(margin_balance);
1481
1482        assert_eq!(margin_account.account_margin(&usd), Some(margin_balance));
1483        assert_eq!(
1484            margin_account.account_initial_margin(&usd),
1485            Some(Money::from("200 USD"))
1486        );
1487        assert_eq!(
1488            margin_account.account_maintenance_margin(&usd),
1489            Some(Money::from("100 USD"))
1490        );
1491
1492        margin_account.clear_account_margin(usd);
1493        assert!(margin_account.account_margin(&usd).is_none());
1494    }
1495
1496    // A multi-currency margin account (no base currency), as unified venues such as Bybit
1497    // report. Only `reported` carries a venue balance.
1498    fn multi_currency_margin_account(reported: Money) -> MarginAccount {
1499        let state = AccountState::new(
1500            AccountId::from("BYBIT-001"),
1501            AccountType::Margin,
1502            vec![AccountBalance::new(
1503                reported,
1504                Money::zero(reported.currency),
1505                reported,
1506            )],
1507            Vec::new(),
1508            true,
1509            uuid4(),
1510            0.into(),
1511            0.into(),
1512            None,
1513        );
1514        MarginAccount::new(state, true)
1515    }
1516
1517    #[rstest]
1518    fn test_margin_update_leaves_unreported_currency_absent() {
1519        let usdt = Currency::USDT();
1520        let mut account = multi_currency_margin_account(Money::from("1000000 USD"));
1521        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1522
1523        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1524
1525        assert_eq!(account.balance_total(Some(usdt)), None);
1526        assert_eq!(account.balance_locked(Some(usdt)), None);
1527        assert_eq!(account.balance_free(Some(usdt)), None);
1528        assert_eq!(
1529            account.balances.keys().copied().collect::<Vec<_>>(),
1530            vec![Currency::USD()]
1531        );
1532        assert_eq!(
1533            account.initial_margin(instrument_id),
1534            Money::from("5000 USDT")
1535        );
1536        assert_eq!(
1537            account.balance_free(Some(Currency::USD())),
1538            Some(Money::from("1000000 USD"))
1539        );
1540    }
1541
1542    #[rstest]
1543    fn test_margin_update_locks_reported_currency() {
1544        let usdt = Currency::USDT();
1545        let mut account = multi_currency_margin_account(Money::from("100000 USDT"));
1546        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1547
1548        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1549
1550        assert_eq!(
1551            account.balance_total(Some(usdt)),
1552            Some(Money::from("100000 USDT"))
1553        );
1554        assert_eq!(
1555            account.balance_locked(Some(usdt)),
1556            Some(Money::from("5000 USDT"))
1557        );
1558        assert_eq!(
1559            account.balance_free(Some(usdt)),
1560            Some(Money::from("95000 USDT"))
1561        );
1562    }
1563
1564    #[rstest]
1565    fn test_margin_update_locks_currency_reported_after_the_margin() {
1566        let usdt = Currency::USDT();
1567        let mut account = multi_currency_margin_account(Money::from("1000000 USD"));
1568        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1569
1570        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1571        account
1572            .apply(AccountState::new(
1573                AccountId::from("BYBIT-001"),
1574                AccountType::Margin,
1575                vec![AccountBalance::new(
1576                    Money::from("100000 USDT"),
1577                    Money::zero(usdt),
1578                    Money::from("100000 USDT"),
1579                )],
1580                Vec::new(),
1581                true,
1582                uuid4(),
1583                0.into(),
1584                0.into(),
1585                None,
1586            ))
1587            .unwrap();
1588        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1589
1590        assert_eq!(
1591            account.balance_locked(Some(usdt)),
1592            Some(Money::from("5000 USDT"))
1593        );
1594        assert_eq!(
1595            account.balance_free(Some(usdt)),
1596            Some(Money::from("95000 USDT"))
1597        );
1598    }
1599
1600    #[rstest]
1601    fn test_recalculate_balance_clamps_when_margin_exceeds_total() {
1602        let usdt = Currency::USDT();
1603        let mut account = multi_currency_margin_account(Money::from("1000 USDT"));
1604
1605        account.update_initial_margin(
1606            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
1607            Money::from("1500 USDT"),
1608        );
1609
1610        assert_eq!(
1611            account.balance_total(Some(usdt)),
1612            Some(Money::from("1000 USDT"))
1613        );
1614        assert_eq!(
1615            account.balance_locked(Some(usdt)),
1616            Some(Money::from("1000 USDT"))
1617        );
1618        assert_eq!(
1619            account.balance_free(Some(usdt)),
1620            Some(Money::from("0 USDT"))
1621        );
1622    }
1623
1624    #[rstest]
1625    fn test_recalculate_balance_keeps_locked_non_negative_when_total_negative() {
1626        let usd = Currency::USD();
1627        let mut account = multi_currency_margin_account(Money::from("-1000 USD"));
1628
1629        account.update_initial_margin(InstrumentId::from("EURUSD.SIM"), Money::from("500 USD"));
1630
1631        assert_eq!(
1632            account.balance_total(Some(usd)),
1633            Some(Money::from("-1000 USD"))
1634        );
1635        assert_eq!(
1636            account.balance_locked(Some(usd)),
1637            Some(Money::from("0 USD"))
1638        );
1639        assert_eq!(
1640            account.balance_free(Some(usd)),
1641            Some(Money::from("-1000 USD"))
1642        );
1643    }
1644
1645    #[rstest]
1646    fn test_total_margin_sums_per_instrument_and_account_wide(
1647        mut margin_account: MarginAccount,
1648        instrument_id_aud_usd_sim: InstrumentId,
1649    ) {
1650        let usd = Currency::USD();
1651        let baseline_initial = margin_account.total_initial_margin(usd);
1652        let baseline_maintenance = margin_account.total_maintenance_margin(usd);
1653
1654        margin_account.update_margin(MarginBalance::new(
1655            Money::from("100 USD"),
1656            Money::from("50 USD"),
1657            Some(instrument_id_aud_usd_sim),
1658        ));
1659        margin_account.update_margin(MarginBalance::new(
1660            Money::from("200 USD"),
1661            Money::from("150 USD"),
1662            None,
1663        ));
1664
1665        assert_eq!(
1666            margin_account.total_initial_margin(usd).raw,
1667            baseline_initial.raw + Money::from("300 USD").raw,
1668        );
1669        assert_eq!(
1670            margin_account.total_maintenance_margin(usd).raw,
1671            baseline_maintenance.raw + Money::from("200 USD").raw,
1672        );
1673    }
1674
1675    #[rstest]
1676    fn test_total_margin_clamps_domain_overflow(
1677        mut margin_account: MarginAccount,
1678        instrument_id_aud_usd_sim: InstrumentId,
1679    ) {
1680        let usd = Currency::USD();
1681        let max = Money::from_raw(MONEY_RAW_MAX, usd);
1682        let other_instrument = InstrumentId::from("EUR/USD.SIM");
1683
1684        margin_account.margins.insert(
1685            instrument_id_aud_usd_sim,
1686            MarginBalance::new(max, max, Some(instrument_id_aud_usd_sim)),
1687        );
1688        margin_account.margins.insert(
1689            other_instrument,
1690            MarginBalance::new(max, max, Some(other_instrument)),
1691        );
1692
1693        assert_eq!(margin_account.total_initial_margin(usd), max);
1694        assert_eq!(margin_account.total_maintenance_margin(usd), max);
1695    }
1696
1697    #[rstest]
1698    fn test_total_margin_clamps_negative_domain_overflow(
1699        mut margin_account: MarginAccount,
1700        instrument_id_aud_usd_sim: InstrumentId,
1701    ) {
1702        let usd = Currency::USD();
1703        let min = Money::from_raw(MONEY_RAW_MIN, usd);
1704        let other_instrument = InstrumentId::from("EUR/USD.SIM");
1705
1706        margin_account.margins.insert(
1707            instrument_id_aud_usd_sim,
1708            MarginBalance::new(min, min, Some(instrument_id_aud_usd_sim)),
1709        );
1710        margin_account.margins.insert(
1711            other_instrument,
1712            MarginBalance::new(min, min, Some(other_instrument)),
1713        );
1714
1715        assert_eq!(margin_account.total_initial_margin(usd), min);
1716        assert_eq!(margin_account.total_maintenance_margin(usd), min);
1717    }
1718
1719    #[rstest]
1720    fn test_calculate_pnls_for_option_buy_realizes_premium(margin_account: MarginAccount) {
1721        let option = option_contract_appl();
1722        let option_any = InstrumentAny::OptionContract(option.clone());
1723
1724        let order = OrderTestBuilder::new(OrderType::Market)
1725            .instrument_id(option.id)
1726            .side(OrderSide::Buy)
1727            .quantity(Quantity::from("10"))
1728            .build();
1729
1730        let fill = TestOrderEventStubs::filled(
1731            &order,
1732            &option_any,
1733            None,
1734            Some(PositionId::new("P-OPT-001")),
1735            Some(Price::from("5.50")),
1736            None,
1737            None,
1738            None,
1739            None,
1740            Some(AccountId::from("SIM-001")),
1741        );
1742
1743        let fill_owned: crate::events::OrderFilled = fill.into();
1744        let pnls = margin_account
1745            .calculate_pnls(&option_any, &fill_owned, None)
1746            .unwrap();
1747
1748        // BUY option = pay premium (negative PnL)
1749        // 10 contracts * $5.50 = $55.00 premium paid
1750        assert_eq!(pnls.len(), 1);
1751        assert_eq!(pnls[0], Money::from("-55 USD"));
1752    }
1753
1754    #[rstest]
1755    fn test_calculate_pnls_for_option_sell_realizes_premium(margin_account: MarginAccount) {
1756        let option = option_contract_appl();
1757        let option_any = InstrumentAny::OptionContract(option.clone());
1758
1759        let order = OrderTestBuilder::new(OrderType::Market)
1760            .instrument_id(option.id)
1761            .side(OrderSide::Sell)
1762            .quantity(Quantity::from("10"))
1763            .build();
1764
1765        let fill = TestOrderEventStubs::filled(
1766            &order,
1767            &option_any,
1768            None,
1769            Some(PositionId::new("P-OPT-002")),
1770            Some(Price::from("5.50")),
1771            None,
1772            None,
1773            None,
1774            None,
1775            Some(AccountId::from("SIM-001")),
1776        );
1777
1778        let fill_owned: crate::events::OrderFilled = fill.into();
1779        let pnls = margin_account
1780            .calculate_pnls(&option_any, &fill_owned, None)
1781            .unwrap();
1782
1783        // SELL option = receive premium (positive PnL)
1784        // 10 contracts * $5.50 = $55.00 premium received
1785        assert_eq!(pnls.len(), 1);
1786        assert_eq!(pnls[0], Money::from("55 USD"));
1787    }
1788
1789    #[rstest]
1790    fn test_calculate_pnls_for_binary_option(margin_account: MarginAccount) {
1791        let binary = binary_option();
1792        let binary_any = InstrumentAny::BinaryOption(binary);
1793
1794        let order = OrderTestBuilder::new(OrderType::Market)
1795            .instrument_id(binary_any.id())
1796            .side(OrderSide::Buy)
1797            .quantity(Quantity::from("100"))
1798            .build();
1799
1800        let fill = TestOrderEventStubs::filled(
1801            &order,
1802            &binary_any,
1803            None,
1804            Some(PositionId::new("P-BIN-001")),
1805            Some(Price::from("0.65")),
1806            None,
1807            None,
1808            None,
1809            None,
1810            Some(AccountId::from("SIM-001")),
1811        );
1812
1813        let fill_owned: crate::events::OrderFilled = fill.into();
1814        let pnls = margin_account
1815            .calculate_pnls(&binary_any, &fill_owned, None)
1816            .unwrap();
1817
1818        assert_eq!(pnls.len(), 1);
1819        assert!(pnls[0].as_f64() < 0.0);
1820    }
1821}