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