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 nautilus_core::correctness::{FAILED, check_positive_decimal};
40use rust_decimal::Decimal;
41use serde::{Deserialize, Serialize};
42
43use crate::{
44    accounts::{Account, base::BaseAccount},
45    enums::{AccountType, InstrumentClass, LiquiditySide, OrderSide},
46    events::{AccountState, OrderFilled},
47    identifiers::{AccountId, InstrumentId},
48    instruments::{Instrument, InstrumentAny},
49    position::Position,
50    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity, money::MoneyRaw},
51};
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[cfg_attr(
55    feature = "python",
56    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
57)]
58pub struct MarginAccount {
59    pub base: BaseAccount,
60    pub leverages: AHashMap<InstrumentId, Decimal>,
61    pub margins: AHashMap<InstrumentId, MarginBalance>,
62    pub default_leverage: Decimal,
63}
64
65impl MarginAccount {
66    /// Creates a new [`MarginAccount`] instance.
67    pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
68        Self {
69            base: BaseAccount::new(event, calculate_account_state),
70            leverages: AHashMap::new(),
71            margins: AHashMap::new(),
72            default_leverage: Decimal::ONE,
73        }
74    }
75
76    /// Sets the default leverage for the account.
77    ///
78    /// # Panics
79    ///
80    /// Panics if `leverage` is not positive.
81    pub fn set_default_leverage(&mut self, leverage: Decimal) {
82        check_positive_decimal(leverage, "leverage").expect(FAILED);
83        self.default_leverage = leverage;
84    }
85
86    /// Sets the leverage for a specific instrument.
87    ///
88    /// # Panics
89    ///
90    /// Panics if `leverage` is not positive.
91    pub fn set_leverage(&mut self, instrument_id: InstrumentId, leverage: Decimal) {
92        check_positive_decimal(leverage, "leverage").expect(FAILED);
93        self.leverages.insert(instrument_id, leverage);
94    }
95
96    #[must_use]
97    pub fn get_leverage(&self, instrument_id: &InstrumentId) -> Decimal {
98        *self
99            .leverages
100            .get(instrument_id)
101            .unwrap_or(&self.default_leverage)
102    }
103
104    #[must_use]
105    pub fn is_unleveraged(&self, instrument_id: InstrumentId) -> bool {
106        self.get_leverage(&instrument_id) == Decimal::ONE
107    }
108
109    #[must_use]
110    pub fn is_cash_account(&self) -> bool {
111        self.account_type == AccountType::Cash
112    }
113
114    #[must_use]
115    pub fn is_margin_account(&self) -> bool {
116        self.account_type == AccountType::Margin
117    }
118
119    #[must_use]
120    pub fn initial_margins(&self) -> AHashMap<InstrumentId, Money> {
121        let mut initial_margins: AHashMap<InstrumentId, Money> = AHashMap::new();
122        self.margins.values().for_each(|margin_balance| {
123            initial_margins.insert(margin_balance.instrument_id, margin_balance.initial);
124        });
125        initial_margins
126    }
127
128    #[must_use]
129    pub fn maintenance_margins(&self) -> AHashMap<InstrumentId, Money> {
130        let mut maintenance_margins: AHashMap<InstrumentId, Money> = AHashMap::new();
131        self.margins.values().for_each(|margin_balance| {
132            maintenance_margins.insert(margin_balance.instrument_id, margin_balance.maintenance);
133        });
134        maintenance_margins
135    }
136
137    /// Updates the initial margin for the specified instrument.
138    ///
139    /// # Panics
140    ///
141    /// Panics if an existing margin balance is found but cannot be unwrapped.
142    pub fn update_initial_margin(&mut self, instrument_id: InstrumentId, margin_init: Money) {
143        let margin_balance = self.margins.get(&instrument_id);
144        if let Some(balance) = margin_balance {
145            // update the margin_balance initial property with margin_init
146            let mut new_margin_balance = *balance;
147            new_margin_balance.initial = margin_init;
148            self.margins.insert(instrument_id, new_margin_balance);
149        } else {
150            self.margins.insert(
151                instrument_id,
152                MarginBalance::new(
153                    margin_init,
154                    Money::new(0.0, margin_init.currency),
155                    instrument_id,
156                ),
157            );
158        }
159        self.recalculate_balance(margin_init.currency);
160    }
161
162    /// Returns the initial margin amount for the specified instrument.
163    ///
164    /// # Panics
165    ///
166    /// Panics if no margin balance exists for the given `instrument_id`.
167    #[must_use]
168    pub fn initial_margin(&self, instrument_id: InstrumentId) -> Money {
169        let margin_balance = self.margins.get(&instrument_id);
170        assert!(
171            margin_balance.is_some(),
172            "Cannot get margin_init when no margin_balance"
173        );
174        margin_balance.unwrap().initial
175    }
176
177    /// Updates the maintenance margin for the specified instrument.
178    ///
179    /// # Panics
180    ///
181    /// Panics if an existing margin balance is found but cannot be unwrapped.
182    pub fn update_maintenance_margin(
183        &mut self,
184        instrument_id: InstrumentId,
185        margin_maintenance: Money,
186    ) {
187        let margin_balance = self.margins.get(&instrument_id);
188        if let Some(balance) = margin_balance {
189            // update the margin_balance maintenance property with margin_maintenance
190            let mut new_margin_balance = *balance;
191            new_margin_balance.maintenance = margin_maintenance;
192            self.margins.insert(instrument_id, new_margin_balance);
193        } else {
194            self.margins.insert(
195                instrument_id,
196                MarginBalance::new(
197                    Money::new(0.0, margin_maintenance.currency),
198                    margin_maintenance,
199                    instrument_id,
200                ),
201            );
202        }
203        self.recalculate_balance(margin_maintenance.currency);
204    }
205
206    /// Returns the maintenance margin amount for the specified instrument.
207    ///
208    /// # Panics
209    ///
210    /// Panics if no margin balance exists for the given `instrument_id`.
211    #[must_use]
212    pub fn maintenance_margin(&self, instrument_id: InstrumentId) -> Money {
213        let margin_balance = self.margins.get(&instrument_id);
214        assert!(
215            margin_balance.is_some(),
216            "Cannot get maintenance_margin when no margin_balance"
217        );
218        margin_balance.unwrap().maintenance
219    }
220
221    /// Returns the margin balance for the specified instrument.
222    #[must_use]
223    pub fn margin(&self, instrument_id: &InstrumentId) -> Option<MarginBalance> {
224        self.margins.get(instrument_id).copied()
225    }
226
227    /// Updates the margin balance for the specified instrument with both initial and maintenance.
228    pub fn update_margin(&mut self, margin_balance: MarginBalance) {
229        self.margins
230            .insert(margin_balance.instrument_id, margin_balance);
231        self.recalculate_balance(margin_balance.currency);
232    }
233
234    /// Clears the margin for the specified instrument.
235    pub fn clear_margin(&mut self, instrument_id: InstrumentId) {
236        if let Some(margin_balance) = self.margins.remove(&instrument_id) {
237            self.recalculate_balance(margin_balance.currency);
238        }
239    }
240
241    /// Calculates the initial margin amount for the specified instrument and quantity.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if leverage is not positive, or if the result cannot be represented
246    /// as `Money`.
247    ///
248    /// # Panics
249    ///
250    /// Panics if `instrument.base_currency()` is `None` for inverse instruments.
251    pub fn calculate_initial_margin<T: Instrument>(
252        &mut self,
253        instrument: T,
254        quantity: Quantity,
255        price: Price,
256        use_quote_for_inverse: Option<bool>,
257    ) -> anyhow::Result<Money> {
258        let notional = instrument.calculate_notional_value(quantity, price, use_quote_for_inverse);
259        let leverage = self.get_leverage(&instrument.id());
260        if leverage <= Decimal::ZERO {
261            anyhow::bail!("Invalid leverage {leverage} for {}", instrument.id());
262        }
263        let notional_decimal = notional.as_decimal();
264        let adjusted_notional = notional_decimal / leverage;
265        let margin_decimal = adjusted_notional * instrument.margin_init();
266
267        let use_quote_for_inverse = use_quote_for_inverse.unwrap_or(false);
268        let currency = if instrument.is_inverse() && !use_quote_for_inverse {
269            instrument.base_currency().unwrap()
270        } else {
271            instrument.quote_currency()
272        };
273
274        Money::from_decimal(margin_decimal, currency)
275    }
276
277    /// Calculates the maintenance margin amount for the specified instrument and quantity.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if leverage is not positive, or if the result cannot be represented
282    /// as `Money`.
283    ///
284    /// # Panics
285    ///
286    /// Panics if `instrument.base_currency()` is `None` for inverse instruments.
287    pub fn calculate_maintenance_margin<T: Instrument>(
288        &mut self,
289        instrument: T,
290        quantity: Quantity,
291        price: Price,
292        use_quote_for_inverse: Option<bool>,
293    ) -> anyhow::Result<Money> {
294        let notional = instrument.calculate_notional_value(quantity, price, use_quote_for_inverse);
295        let leverage = self.get_leverage(&instrument.id());
296        if leverage <= Decimal::ZERO {
297            anyhow::bail!("Invalid leverage {leverage} for {}", instrument.id());
298        }
299        let notional_decimal = notional.as_decimal();
300        let adjusted_notional = notional_decimal / leverage;
301        let margin_decimal = adjusted_notional * instrument.margin_maint();
302
303        let use_quote_for_inverse = use_quote_for_inverse.unwrap_or(false);
304        let currency = if instrument.is_inverse() && !use_quote_for_inverse {
305            instrument.base_currency().unwrap()
306        } else {
307            instrument.quote_currency()
308        };
309
310        Money::from_decimal(margin_decimal, currency)
311    }
312
313    /// Recalculates the account balance for the specified currency based on current margins.
314    ///
315    /// # Panics
316    ///
317    /// This function panics if:
318    /// - Margin calculation overflows.
319    pub fn recalculate_balance(&mut self, currency: Currency) {
320        let current_balance = match self.balances.get(&currency) {
321            Some(balance) => *balance,
322            None => {
323                // Initialize zero balance if none exists - can occur when account
324                // state doesn't include a balance for the position's cost currency
325                let zero = Money::from_raw(0, currency);
326                AccountBalance::new(zero, zero, zero)
327            }
328        };
329
330        let mut total_margin: MoneyRaw = 0;
331        for margin in self.margins.values() {
332            if margin.currency == currency {
333                total_margin = total_margin
334                    .checked_add(margin.initial.raw)
335                    .and_then(|sum| sum.checked_add(margin.maintenance.raw))
336                    .unwrap_or_else(|| {
337                        panic!(
338                            "Margin calculation overflow for currency {}: total would exceed maximum",
339                            currency.code
340                        )
341                    });
342            }
343        }
344
345        // Clamp margin to total balance if it would result in negative free balance.
346        // This can occur transiently when venue and client state are out of sync.
347        // Locked margin must never be negative (even if total balance is negative).
348        let total_free = if total_margin > current_balance.total.raw {
349            total_margin = current_balance.total.raw.max(0);
350            current_balance.total.raw - total_margin
351        } else {
352            current_balance.total.raw - total_margin
353        };
354
355        let new_balance = AccountBalance::new(
356            current_balance.total,
357            Money::from_raw(total_margin, currency),
358            Money::from_raw(total_free, currency),
359        );
360        self.balances.insert(currency, new_balance);
361    }
362}
363
364impl Deref for MarginAccount {
365    type Target = BaseAccount;
366
367    fn deref(&self) -> &Self::Target {
368        &self.base
369    }
370}
371
372impl DerefMut for MarginAccount {
373    fn deref_mut(&mut self) -> &mut Self::Target {
374        &mut self.base
375    }
376}
377
378impl Account for MarginAccount {
379    fn id(&self) -> AccountId {
380        self.id
381    }
382
383    fn account_type(&self) -> AccountType {
384        self.account_type
385    }
386
387    fn base_currency(&self) -> Option<Currency> {
388        self.base_currency
389    }
390
391    fn is_cash_account(&self) -> bool {
392        self.account_type == AccountType::Cash
393    }
394
395    fn is_margin_account(&self) -> bool {
396        self.account_type == AccountType::Margin
397    }
398
399    fn calculated_account_state(&self) -> bool {
400        false // TODO (implement this logic)
401    }
402
403    fn balance_total(&self, currency: Option<Currency>) -> Option<Money> {
404        self.base_balance_total(currency)
405    }
406
407    fn balances_total(&self) -> AHashMap<Currency, Money> {
408        self.base_balances_total()
409    }
410
411    fn balance_free(&self, currency: Option<Currency>) -> Option<Money> {
412        self.base_balance_free(currency)
413    }
414
415    fn balances_free(&self) -> AHashMap<Currency, Money> {
416        self.base_balances_free()
417    }
418
419    fn balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
420        self.base_balance_locked(currency)
421    }
422
423    fn balances_locked(&self) -> AHashMap<Currency, Money> {
424        self.base_balances_locked()
425    }
426
427    fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
428        self.base_balance(currency)
429    }
430
431    fn last_event(&self) -> Option<AccountState> {
432        self.base_last_event()
433    }
434
435    fn events(&self) -> Vec<AccountState> {
436        self.events.clone()
437    }
438
439    fn event_count(&self) -> usize {
440        self.events.len()
441    }
442
443    fn currencies(&self) -> Vec<Currency> {
444        self.balances.keys().copied().collect()
445    }
446
447    fn starting_balances(&self) -> AHashMap<Currency, Money> {
448        self.balances_starting.clone()
449    }
450
451    fn balances(&self) -> AHashMap<Currency, AccountBalance> {
452        self.balances.clone()
453    }
454
455    fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
456        self.base_apply(event);
457        Ok(())
458    }
459
460    fn purge_account_events(&mut self, ts_now: nautilus_core::UnixNanos, lookback_secs: u64) {
461        self.base.base_purge_account_events(ts_now, lookback_secs);
462    }
463
464    fn calculate_balance_locked(
465        &mut self,
466        instrument: InstrumentAny,
467        side: OrderSide,
468        quantity: Quantity,
469        price: Price,
470        use_quote_for_inverse: Option<bool>,
471    ) -> anyhow::Result<Money> {
472        self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
473    }
474
475    fn calculate_pnls(
476        &self,
477        instrument: InstrumentAny,
478        fill: OrderFilled,
479        position: Option<Position>,
480    ) -> anyhow::Result<Vec<Money>> {
481        let mut pnls: Vec<Money> = Vec::new();
482
483        // For premium-based instruments, realize the notional value as a cash flow on every fill
484        let instrument_class = instrument.instrument_class();
485        if matches!(
486            instrument_class,
487            InstrumentClass::Option
488                | InstrumentClass::OptionSpread
489                | InstrumentClass::BinaryOption
490                | InstrumentClass::Warrant
491        ) {
492            let notional = instrument.calculate_notional_value(fill.last_qty, fill.last_px, None);
493            let pnl = if fill.order_side == OrderSide::Buy {
494                Money::from_raw(-notional.raw, notional.currency)
495            } else {
496                notional
497            };
498            pnls.push(pnl);
499            return Ok(pnls);
500        }
501
502        // For other instruments, only realize PnL on position reduction
503        if let Some(ref pos) = position
504            && pos.quantity.is_positive()
505            && pos.entry != fill.order_side
506        {
507            // Calculate and add PnL using the minimum of fill quantity and position quantity
508            // to avoid double-limiting that occurs in position.calculate_pnl()
509            let pnl_quantity = Quantity::from_raw(
510                fill.last_qty.raw.min(pos.quantity.raw),
511                fill.last_qty.precision,
512            );
513            let pnl = pos.calculate_pnl(pos.avg_px_open, fill.last_px.as_f64(), pnl_quantity);
514            pnls.push(pnl);
515        }
516
517        Ok(pnls)
518    }
519
520    fn calculate_commission(
521        &self,
522        instrument: InstrumentAny,
523        last_qty: Quantity,
524        last_px: Price,
525        liquidity_side: LiquiditySide,
526        use_quote_for_inverse: Option<bool>,
527    ) -> anyhow::Result<Money> {
528        self.base_calculate_commission(
529            instrument,
530            last_qty,
531            last_px,
532            liquidity_side,
533            use_quote_for_inverse,
534        )
535    }
536}
537
538impl PartialEq for MarginAccount {
539    fn eq(&self, other: &Self) -> bool {
540        self.id == other.id
541    }
542}
543
544impl Eq for MarginAccount {}
545
546impl Display for MarginAccount {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        write!(
549            f,
550            "MarginAccount(id={}, type={}, base={})",
551            self.id,
552            self.account_type,
553            self.base_currency.map_or_else(
554                || "None".to_string(),
555                |base_currency| format!("{}", base_currency.code)
556            ),
557        )
558    }
559}
560
561impl Hash for MarginAccount {
562    fn hash<H: Hasher>(&self, state: &mut H) {
563        self.id.hash(state);
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use ahash::AHashMap;
570    use nautilus_core::UnixNanos;
571    use rstest::rstest;
572    use rust_decimal::Decimal;
573
574    use crate::{
575        accounts::{Account, MarginAccount, stubs::*},
576        enums::{LiquiditySide, OrderSide, OrderType},
577        events::{AccountState, OrderFilled, account::stubs::*},
578        identifiers::{
579            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, TraderId,
580            VenueOrderId,
581            stubs::{uuid4, *},
582        },
583        instruments::{
584            CryptoPerpetual, CurrencyPair, InstrumentAny,
585            stubs::{binary_option, option_contract_appl, *},
586        },
587        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
588        position::Position,
589        types::{Currency, MarginBalance, Money, Price, Quantity},
590    };
591
592    #[rstest]
593    fn test_display(margin_account: MarginAccount) {
594        assert_eq!(
595            margin_account.to_string(),
596            "MarginAccount(id=SIM-001, type=MARGIN, base=USD)"
597        );
598    }
599
600    #[rstest]
601    fn test_base_account_properties(
602        margin_account: MarginAccount,
603        margin_account_state: AccountState,
604    ) {
605        assert_eq!(margin_account.base_currency, Some(Currency::from("USD")));
606        assert_eq!(
607            margin_account.last_event(),
608            Some(margin_account_state.clone())
609        );
610        assert_eq!(margin_account.events(), vec![margin_account_state]);
611        assert_eq!(margin_account.event_count(), 1);
612        assert_eq!(
613            margin_account.balance_total(None),
614            Some(Money::from("1525000 USD"))
615        );
616        assert_eq!(
617            margin_account.balance_free(None),
618            Some(Money::from("1500000 USD"))
619        );
620        assert_eq!(
621            margin_account.balance_locked(None),
622            Some(Money::from("25000 USD"))
623        );
624        let mut balances_total_expected = AHashMap::new();
625        balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
626        assert_eq!(margin_account.balances_total(), balances_total_expected);
627        let mut balances_free_expected = AHashMap::new();
628        balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
629        assert_eq!(margin_account.balances_free(), balances_free_expected);
630        let mut balances_locked_expected = AHashMap::new();
631        balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
632        assert_eq!(margin_account.balances_locked(), balances_locked_expected);
633    }
634
635    #[rstest]
636    fn test_set_default_leverage(mut margin_account: MarginAccount) {
637        assert_eq!(margin_account.default_leverage, Decimal::ONE);
638        margin_account.set_default_leverage(Decimal::from(10));
639        assert_eq!(margin_account.default_leverage, Decimal::from(10));
640    }
641
642    #[rstest]
643    fn test_get_leverage_default_leverage(
644        margin_account: MarginAccount,
645        instrument_id_aud_usd_sim: InstrumentId,
646    ) {
647        assert_eq!(
648            margin_account.get_leverage(&instrument_id_aud_usd_sim),
649            Decimal::ONE
650        );
651    }
652
653    #[rstest]
654    fn test_set_leverage(
655        mut margin_account: MarginAccount,
656        instrument_id_aud_usd_sim: InstrumentId,
657    ) {
658        assert_eq!(margin_account.leverages.len(), 0);
659        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
660        assert_eq!(margin_account.leverages.len(), 1);
661        assert_eq!(
662            margin_account.get_leverage(&instrument_id_aud_usd_sim),
663            Decimal::from(10)
664        );
665    }
666
667    #[rstest]
668    fn test_is_unleveraged_with_leverage_returns_false(
669        mut margin_account: MarginAccount,
670        instrument_id_aud_usd_sim: InstrumentId,
671    ) {
672        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
673        assert!(!margin_account.is_unleveraged(instrument_id_aud_usd_sim));
674    }
675
676    #[rstest]
677    fn test_is_unleveraged_with_no_leverage_returns_true(
678        mut margin_account: MarginAccount,
679        instrument_id_aud_usd_sim: InstrumentId,
680    ) {
681        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::ONE);
682        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
683    }
684
685    #[rstest]
686    fn test_is_unleveraged_with_default_leverage_of_1_returns_true(
687        margin_account: MarginAccount,
688        instrument_id_aud_usd_sim: InstrumentId,
689    ) {
690        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
691    }
692
693    #[rstest]
694    fn test_update_margin_init(
695        mut margin_account: MarginAccount,
696        instrument_id_aud_usd_sim: InstrumentId,
697    ) {
698        assert_eq!(margin_account.margins.len(), 0);
699        let margin = Money::from("10000 USD");
700        margin_account.update_initial_margin(instrument_id_aud_usd_sim, margin);
701        assert_eq!(
702            margin_account.initial_margin(instrument_id_aud_usd_sim),
703            margin
704        );
705        let margins: Vec<Money> = margin_account
706            .margins
707            .values()
708            .map(|margin_balance| margin_balance.initial)
709            .collect();
710        assert_eq!(margins, vec![margin]);
711    }
712
713    #[rstest]
714    fn test_update_margin_maintenance(
715        mut margin_account: MarginAccount,
716        instrument_id_aud_usd_sim: InstrumentId,
717    ) {
718        let margin = Money::from("10000 USD");
719        margin_account.update_maintenance_margin(instrument_id_aud_usd_sim, margin);
720        assert_eq!(
721            margin_account.maintenance_margin(instrument_id_aud_usd_sim),
722            margin
723        );
724        let margins: Vec<Money> = margin_account
725            .margins
726            .values()
727            .map(|margin_balance| margin_balance.maintenance)
728            .collect();
729        assert_eq!(margins, vec![margin]);
730    }
731
732    #[rstest]
733    fn test_calculate_margin_init_with_leverage(
734        mut margin_account: MarginAccount,
735        audusd_sim: CurrencyPair,
736    ) {
737        margin_account.set_leverage(audusd_sim.id, Decimal::from(50));
738        let result = margin_account
739            .calculate_initial_margin(
740                audusd_sim,
741                Quantity::from(100_000),
742                Price::from("0.8000"),
743                None,
744            )
745            .unwrap();
746        assert_eq!(result, Money::from("48.00 USD"));
747    }
748
749    #[rstest]
750    fn test_calculate_margin_init_with_default_leverage(
751        mut margin_account: MarginAccount,
752        audusd_sim: CurrencyPair,
753    ) {
754        margin_account.set_default_leverage(Decimal::from(10));
755        let result = margin_account
756            .calculate_initial_margin(
757                audusd_sim,
758                Quantity::from(100_000),
759                Price::from("0.8"),
760                None,
761            )
762            .unwrap();
763        assert_eq!(result, Money::from("240.00 USD"));
764    }
765
766    #[rstest]
767    fn test_calculate_margin_init_with_no_leverage_for_inverse(
768        mut margin_account: MarginAccount,
769        xbtusd_bitmex: CryptoPerpetual,
770    ) {
771        let result_use_quote_inverse_true = margin_account
772            .calculate_initial_margin(
773                xbtusd_bitmex,
774                Quantity::from(100_000),
775                Price::from("11493.60"),
776                Some(false),
777            )
778            .unwrap();
779        assert_eq!(result_use_quote_inverse_true, Money::from("0.08700494 BTC"));
780        let result_use_quote_inverse_false = margin_account
781            .calculate_initial_margin(
782                xbtusd_bitmex,
783                Quantity::from(100_000),
784                Price::from("11493.60"),
785                Some(true),
786            )
787            .unwrap();
788        assert_eq!(result_use_quote_inverse_false, Money::from("1000 USD"));
789    }
790
791    #[rstest]
792    fn test_calculate_margin_maintenance_with_no_leverage(
793        mut margin_account: MarginAccount,
794        xbtusd_bitmex: CryptoPerpetual,
795    ) {
796        let result = margin_account
797            .calculate_maintenance_margin(
798                xbtusd_bitmex,
799                Quantity::from(100_000),
800                Price::from("11493.60"),
801                None,
802            )
803            .unwrap();
804        assert_eq!(result, Money::from("0.03045173 BTC"));
805    }
806
807    #[rstest]
808    fn test_calculate_margin_maintenance_with_leverage_fx_instrument(
809        mut margin_account: MarginAccount,
810        audusd_sim: CurrencyPair,
811    ) {
812        margin_account.set_default_leverage(Decimal::from(50));
813        let result = margin_account
814            .calculate_maintenance_margin(
815                audusd_sim,
816                Quantity::from(1_000_000),
817                Price::from("1"),
818                None,
819            )
820            .unwrap();
821        assert_eq!(result, Money::from("600.00 USD"));
822    }
823
824    #[rstest]
825    fn test_calculate_margin_maintenance_with_leverage_inverse_instrument(
826        mut margin_account: MarginAccount,
827        xbtusd_bitmex: CryptoPerpetual,
828    ) {
829        margin_account.set_default_leverage(Decimal::from(10));
830        let result = margin_account
831            .calculate_maintenance_margin(
832                xbtusd_bitmex,
833                Quantity::from(100_000),
834                Price::from("100000.00"),
835                None,
836            )
837            .unwrap();
838        assert_eq!(result, Money::from("0.00035000 BTC"));
839    }
840
841    #[rstest]
842    fn test_calculate_pnls_github_issue_2657() {
843        // Create a margin account
844        let account_state = margin_account_state();
845        let account = MarginAccount::new(account_state, false);
846
847        // Create BTCUSDT instrument
848        let btcusdt = currency_pair_btcusdt();
849        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt);
850
851        // Create initial position with BUY 0.001 BTC at 50000.00
852        let fill1 = OrderFilled::new(
853            TraderId::from("TRADER-001"),
854            StrategyId::from("S-001"),
855            btcusdt.id,
856            ClientOrderId::from("O-1"),
857            VenueOrderId::from("V-1"),
858            AccountId::from("SIM-001"),
859            TradeId::from("T-1"),
860            OrderSide::Buy,
861            OrderType::Market,
862            Quantity::from("0.001"),
863            Price::from("50000.00"),
864            btcusdt.quote_currency,
865            LiquiditySide::Taker,
866            uuid4(),
867            UnixNanos::from(1_000_000_000),
868            UnixNanos::default(),
869            false,
870            Some(PositionId::from("P-GITHUB-2657")),
871            None,
872        );
873
874        let position = Position::new(&btcusdt_any, fill1);
875
876        // Create second fill that sells MORE than position size (0.002 > 0.001)
877        let fill2 = OrderFilled::new(
878            TraderId::from("TRADER-001"),
879            StrategyId::from("S-001"),
880            btcusdt.id,
881            ClientOrderId::from("O-2"),
882            VenueOrderId::from("V-2"),
883            AccountId::from("SIM-001"),
884            TradeId::from("T-2"),
885            OrderSide::Sell,
886            OrderType::Market,
887            Quantity::from("0.002"), // This is larger than position quantity!
888            Price::from("50075.00"),
889            btcusdt.quote_currency,
890            LiquiditySide::Taker,
891            uuid4(),
892            UnixNanos::from(2_000_000_000),
893            UnixNanos::default(),
894            false,
895            Some(PositionId::from("P-GITHUB-2657")),
896            None,
897        );
898
899        // Test the fix - should only calculate PnL for position quantity (0.001), not fill quantity (0.002)
900        let pnls = account
901            .calculate_pnls(btcusdt_any, fill2, Some(position))
902            .unwrap();
903
904        // Should have exactly one PnL entry
905        assert_eq!(pnls.len(), 1);
906
907        // Expected PnL should be for 0.001 BTC, not 0.002 BTC
908        // PnL = (50075.00 - 50000.00) * 0.001 = 75.0 * 0.001 = 0.075 USDT
909        let expected_pnl = Money::from("0.075 USDT");
910        assert_eq!(pnls[0], expected_pnl);
911    }
912
913    #[rstest]
914    #[should_panic(expected = "not positive")]
915    fn test_set_leverage_zero_panics(mut margin_account: MarginAccount, audusd_sim: CurrencyPair) {
916        margin_account.set_leverage(audusd_sim.id, Decimal::ZERO);
917    }
918
919    #[rstest]
920    #[should_panic(expected = "not positive")]
921    fn test_set_default_leverage_zero_panics(mut margin_account: MarginAccount) {
922        margin_account.set_default_leverage(Decimal::ZERO);
923    }
924
925    #[rstest]
926    #[should_panic(expected = "not positive")]
927    fn test_set_leverage_negative_panics(
928        mut margin_account: MarginAccount,
929        audusd_sim: CurrencyPair,
930    ) {
931        margin_account.set_leverage(audusd_sim.id, Decimal::from(-1));
932    }
933
934    #[rstest]
935    fn test_calculate_pnls_with_same_side_fill_returns_empty() {
936        use nautilus_core::UnixNanos;
937
938        use crate::{
939            enums::{LiquiditySide, OrderSide, OrderType},
940            events::OrderFilled,
941            identifiers::{
942                AccountId, ClientOrderId, PositionId, StrategyId, TradeId, TraderId, VenueOrderId,
943                stubs::uuid4,
944            },
945            instruments::InstrumentAny,
946            position::Position,
947            types::{Price, Quantity},
948        };
949
950        // Create a margin account
951        let account_state = margin_account_state();
952        let account = MarginAccount::new(account_state, false);
953
954        // Create BTCUSDT instrument
955        let btcusdt = currency_pair_btcusdt();
956        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt);
957
958        // Create initial position with BUY 1.0 BTC at 50000.00
959        let fill1 = OrderFilled::new(
960            TraderId::from("TRADER-001"),
961            StrategyId::from("S-001"),
962            btcusdt.id,
963            ClientOrderId::from("O-1"),
964            VenueOrderId::from("V-1"),
965            AccountId::from("SIM-001"),
966            TradeId::from("T-1"),
967            OrderSide::Buy,
968            OrderType::Market,
969            Quantity::from("1.0"),
970            Price::from("50000.00"),
971            btcusdt.quote_currency,
972            LiquiditySide::Taker,
973            uuid4(),
974            UnixNanos::from(1_000_000_000),
975            UnixNanos::default(),
976            false,
977            Some(PositionId::from("P-123456")),
978            None,
979        );
980
981        let position = Position::new(&btcusdt_any, fill1);
982
983        // Create second fill that also BUYS (same side as position entry)
984        let fill2 = OrderFilled::new(
985            TraderId::from("TRADER-001"),
986            StrategyId::from("S-001"),
987            btcusdt.id,
988            ClientOrderId::from("O-2"),
989            VenueOrderId::from("V-2"),
990            AccountId::from("SIM-001"),
991            TradeId::from("T-2"),
992            OrderSide::Buy, // Same side as position entry
993            OrderType::Market,
994            Quantity::from("0.5"),
995            Price::from("51000.00"),
996            btcusdt.quote_currency,
997            LiquiditySide::Taker,
998            uuid4(),
999            UnixNanos::from(2_000_000_000),
1000            UnixNanos::default(),
1001            false,
1002            Some(PositionId::from("P-123456")),
1003            None,
1004        );
1005
1006        // Test that no PnL is calculated for same-side fills
1007        let pnls = account
1008            .calculate_pnls(btcusdt_any, fill2, Some(position))
1009            .unwrap();
1010
1011        // Should return empty PnL list
1012        assert_eq!(pnls.len(), 0);
1013    }
1014
1015    #[rstest]
1016    fn test_margin_accessor(
1017        mut margin_account: MarginAccount,
1018        instrument_id_aud_usd_sim: InstrumentId,
1019    ) {
1020        let margin_balance = MarginBalance::new(
1021            Money::from("1000 USD"),
1022            Money::from("500 USD"),
1023            instrument_id_aud_usd_sim,
1024        );
1025
1026        margin_account.update_margin(margin_balance);
1027
1028        let retrieved = margin_account.margin(&instrument_id_aud_usd_sim);
1029        assert!(retrieved.is_some());
1030        let retrieved = retrieved.unwrap();
1031        assert_eq!(retrieved.initial, Money::from("1000 USD"));
1032        assert_eq!(retrieved.maintenance, Money::from("500 USD"));
1033        assert_eq!(retrieved.instrument_id, instrument_id_aud_usd_sim);
1034    }
1035
1036    #[rstest]
1037    fn test_clear_margin(
1038        mut margin_account: MarginAccount,
1039        instrument_id_aud_usd_sim: InstrumentId,
1040    ) {
1041        let margin_balance = MarginBalance::new(
1042            Money::from("1000 USD"),
1043            Money::from("500 USD"),
1044            instrument_id_aud_usd_sim,
1045        );
1046
1047        margin_account.update_margin(margin_balance);
1048        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_some());
1049
1050        margin_account.clear_margin(instrument_id_aud_usd_sim);
1051        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_none());
1052    }
1053
1054    #[rstest]
1055    fn test_calculate_pnls_for_option_buy_realizes_premium(margin_account: MarginAccount) {
1056        let option = option_contract_appl();
1057        let option_any = InstrumentAny::OptionContract(option);
1058
1059        let order = OrderTestBuilder::new(OrderType::Market)
1060            .instrument_id(option.id)
1061            .side(OrderSide::Buy)
1062            .quantity(Quantity::from("10"))
1063            .build();
1064
1065        let fill = TestOrderEventStubs::filled(
1066            &order,
1067            &option_any,
1068            None,
1069            Some(PositionId::new("P-OPT-001")),
1070            Some(Price::from("5.50")),
1071            None,
1072            None,
1073            None,
1074            None,
1075            Some(AccountId::from("SIM-001")),
1076        );
1077
1078        let pnls = margin_account
1079            .calculate_pnls(option_any, fill.into(), None)
1080            .unwrap();
1081
1082        // BUY option = pay premium (negative PnL)
1083        // 10 contracts * $5.50 = $55.00 premium paid
1084        assert_eq!(pnls.len(), 1);
1085        assert_eq!(pnls[0], Money::from("-55 USD"));
1086    }
1087
1088    #[rstest]
1089    fn test_calculate_pnls_for_option_sell_realizes_premium(margin_account: MarginAccount) {
1090        let option = option_contract_appl();
1091        let option_any = InstrumentAny::OptionContract(option);
1092
1093        let order = OrderTestBuilder::new(OrderType::Market)
1094            .instrument_id(option.id)
1095            .side(OrderSide::Sell)
1096            .quantity(Quantity::from("10"))
1097            .build();
1098
1099        let fill = TestOrderEventStubs::filled(
1100            &order,
1101            &option_any,
1102            None,
1103            Some(PositionId::new("P-OPT-002")),
1104            Some(Price::from("5.50")),
1105            None,
1106            None,
1107            None,
1108            None,
1109            Some(AccountId::from("SIM-001")),
1110        );
1111
1112        let pnls = margin_account
1113            .calculate_pnls(option_any, fill.into(), None)
1114            .unwrap();
1115
1116        // SELL option = receive premium (positive PnL)
1117        // 10 contracts * $5.50 = $55.00 premium received
1118        assert_eq!(pnls.len(), 1);
1119        assert_eq!(pnls[0], Money::from("55 USD"));
1120    }
1121
1122    #[rstest]
1123    fn test_calculate_pnls_for_binary_option(margin_account: MarginAccount) {
1124        let binary = binary_option();
1125        let binary_any = InstrumentAny::BinaryOption(binary);
1126
1127        let order = OrderTestBuilder::new(OrderType::Market)
1128            .instrument_id(binary.id)
1129            .side(OrderSide::Buy)
1130            .quantity(Quantity::from("100"))
1131            .build();
1132
1133        let fill = TestOrderEventStubs::filled(
1134            &order,
1135            &binary_any,
1136            None,
1137            Some(PositionId::new("P-BIN-001")),
1138            Some(Price::from("0.65")),
1139            None,
1140            None,
1141            None,
1142            None,
1143            Some(AccountId::from("SIM-001")),
1144        );
1145
1146        let pnls = margin_account
1147            .calculate_pnls(binary_any, fill.into(), None)
1148            .unwrap();
1149
1150        assert_eq!(pnls.len(), 1);
1151        assert!(pnls[0].as_f64() < 0.0);
1152    }
1153}