nautilus_model/accounts/
margin.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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    /// Calculates the initial margin amount for the specified instrument and quantity.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the margin calculation produces a value that cannot be represented as `Money`.
201    ///
202    /// # Panics
203    ///
204    /// Panics if `instrument.base_currency()` is `None` for inverse instruments.
205    pub fn calculate_initial_margin<T: Instrument>(
206        &mut self,
207        instrument: T,
208        quantity: Quantity,
209        price: Price,
210        use_quote_for_inverse: Option<bool>,
211    ) -> anyhow::Result<Money> {
212        let notional = instrument.calculate_notional_value(quantity, price, use_quote_for_inverse);
213        let mut leverage = self.get_leverage(&instrument.id());
214        if leverage == Decimal::ZERO {
215            self.leverages
216                .insert(instrument.id(), self.default_leverage);
217            leverage = self.default_leverage;
218        }
219        let notional_decimal = notional.as_decimal();
220        let adjusted_notional = notional_decimal / leverage;
221        let margin_decimal = adjusted_notional * instrument.margin_init();
222
223        let use_quote_for_inverse = use_quote_for_inverse.unwrap_or(false);
224        let currency = if instrument.is_inverse() && !use_quote_for_inverse {
225            instrument.base_currency().unwrap()
226        } else {
227            instrument.quote_currency()
228        };
229
230        Money::from_decimal(margin_decimal, currency)
231    }
232
233    /// Calculates the maintenance margin amount for the specified instrument and quantity.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the margin calculation produces a value that cannot be represented as `Money`.
238    ///
239    /// # Panics
240    ///
241    /// Panics if `instrument.base_currency()` is `None` for inverse instruments.
242    pub fn calculate_maintenance_margin<T: Instrument>(
243        &mut self,
244        instrument: T,
245        quantity: Quantity,
246        price: Price,
247        use_quote_for_inverse: Option<bool>,
248    ) -> anyhow::Result<Money> {
249        let notional = instrument.calculate_notional_value(quantity, price, use_quote_for_inverse);
250        let mut leverage = self.get_leverage(&instrument.id());
251        if leverage == Decimal::ZERO {
252            self.leverages
253                .insert(instrument.id(), self.default_leverage);
254            leverage = self.default_leverage;
255        }
256        let notional_decimal = notional.as_decimal();
257        let adjusted_notional = notional_decimal / leverage;
258        let margin_decimal = adjusted_notional * instrument.margin_maint();
259
260        let use_quote_for_inverse = use_quote_for_inverse.unwrap_or(false);
261        let currency = if instrument.is_inverse() && !use_quote_for_inverse {
262            instrument.base_currency().unwrap()
263        } else {
264            instrument.quote_currency()
265        };
266
267        Money::from_decimal(margin_decimal, currency)
268    }
269
270    /// Recalculates the account balance for the specified currency based on current margins.
271    ///
272    /// # Panics
273    ///
274    /// This function panics if:
275    /// - No starting balance exists for the given `currency`.
276    /// - Total free margin would be negative.
277    /// - Margin calculation overflows.
278    pub fn recalculate_balance(&mut self, currency: Currency) {
279        let current_balance = match self.balances.get(&currency) {
280            Some(balance) => balance,
281            None => panic!("Cannot recalculate balance when no starting balance"),
282        };
283
284        let mut total_margin: MoneyRaw = 0;
285        for margin in self.margins.values() {
286            if margin.currency == currency {
287                total_margin = total_margin
288                    .checked_add(margin.initial.raw)
289                    .and_then(|sum| sum.checked_add(margin.maintenance.raw))
290                    .unwrap_or_else(|| {
291                        panic!(
292                            "Margin calculation overflow for currency {}: total would exceed maximum",
293                            currency.code
294                        )
295                    });
296            }
297        }
298
299        let total_free = current_balance.total.raw - total_margin;
300        // TODO error handle this with AccountMarginExceeded
301        assert!(
302            total_free >= 0,
303            "Cannot recalculate balance when total_free is less than 0.0"
304        );
305        let new_balance = AccountBalance::new(
306            current_balance.total,
307            Money::from_raw(total_margin, currency),
308            Money::from_raw(total_free, currency),
309        );
310        self.balances.insert(currency, new_balance);
311    }
312}
313
314impl Deref for MarginAccount {
315    type Target = BaseAccount;
316
317    fn deref(&self) -> &Self::Target {
318        &self.base
319    }
320}
321
322impl DerefMut for MarginAccount {
323    fn deref_mut(&mut self) -> &mut Self::Target {
324        &mut self.base
325    }
326}
327
328impl Account for MarginAccount {
329    fn id(&self) -> AccountId {
330        self.id
331    }
332
333    fn account_type(&self) -> AccountType {
334        self.account_type
335    }
336
337    fn base_currency(&self) -> Option<Currency> {
338        self.base_currency
339    }
340
341    fn is_cash_account(&self) -> bool {
342        self.account_type == AccountType::Cash
343    }
344
345    fn is_margin_account(&self) -> bool {
346        self.account_type == AccountType::Margin
347    }
348
349    fn calculated_account_state(&self) -> bool {
350        false // TODO (implement this logic)
351    }
352
353    fn balance_total(&self, currency: Option<Currency>) -> Option<Money> {
354        self.base_balance_total(currency)
355    }
356
357    fn balances_total(&self) -> AHashMap<Currency, Money> {
358        self.base_balances_total()
359    }
360
361    fn balance_free(&self, currency: Option<Currency>) -> Option<Money> {
362        self.base_balance_free(currency)
363    }
364
365    fn balances_free(&self) -> AHashMap<Currency, Money> {
366        self.base_balances_free()
367    }
368
369    fn balance_locked(&self, currency: Option<Currency>) -> Option<Money> {
370        self.base_balance_locked(currency)
371    }
372
373    fn balances_locked(&self) -> AHashMap<Currency, Money> {
374        self.base_balances_locked()
375    }
376
377    fn balance(&self, currency: Option<Currency>) -> Option<&AccountBalance> {
378        self.base_balance(currency)
379    }
380
381    fn last_event(&self) -> Option<AccountState> {
382        self.base_last_event()
383    }
384
385    fn events(&self) -> Vec<AccountState> {
386        self.events.clone()
387    }
388
389    fn event_count(&self) -> usize {
390        self.events.len()
391    }
392
393    fn currencies(&self) -> Vec<Currency> {
394        self.balances.keys().copied().collect()
395    }
396
397    fn starting_balances(&self) -> AHashMap<Currency, Money> {
398        self.balances_starting.clone()
399    }
400
401    fn balances(&self) -> AHashMap<Currency, AccountBalance> {
402        self.balances.clone()
403    }
404
405    fn apply(&mut self, event: AccountState) {
406        self.base_apply(event);
407    }
408
409    fn purge_account_events(&mut self, ts_now: nautilus_core::UnixNanos, lookback_secs: u64) {
410        self.base.base_purge_account_events(ts_now, lookback_secs);
411    }
412
413    fn calculate_balance_locked(
414        &mut self,
415        instrument: InstrumentAny,
416        side: OrderSide,
417        quantity: Quantity,
418        price: Price,
419        use_quote_for_inverse: Option<bool>,
420    ) -> anyhow::Result<Money> {
421        self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
422    }
423
424    fn calculate_pnls(
425        &self,
426        _instrument: InstrumentAny, // TBD if this should be removed
427        fill: OrderFilled,
428        position: Option<Position>,
429    ) -> anyhow::Result<Vec<Money>> {
430        let mut pnls: Vec<Money> = Vec::new();
431
432        if let Some(ref pos) = position
433            && pos.quantity.is_positive()
434            && pos.entry != fill.order_side
435        {
436            // Calculate and add PnL using the minimum of fill quantity and position quantity
437            // to avoid double-limiting that occurs in position.calculate_pnl()
438            let pnl_quantity = Quantity::from_raw(
439                fill.last_qty.raw.min(pos.quantity.raw),
440                fill.last_qty.precision,
441            );
442            let pnl = pos.calculate_pnl(pos.avg_px_open, fill.last_px.as_f64(), pnl_quantity);
443            pnls.push(pnl);
444        }
445
446        Ok(pnls)
447    }
448
449    fn calculate_commission(
450        &self,
451        instrument: InstrumentAny,
452        last_qty: Quantity,
453        last_px: Price,
454        liquidity_side: LiquiditySide,
455        use_quote_for_inverse: Option<bool>,
456    ) -> anyhow::Result<Money> {
457        self.base_calculate_commission(
458            instrument,
459            last_qty,
460            last_px,
461            liquidity_side,
462            use_quote_for_inverse,
463        )
464    }
465}
466
467impl PartialEq for MarginAccount {
468    fn eq(&self, other: &Self) -> bool {
469        self.id == other.id
470    }
471}
472
473impl Eq for MarginAccount {}
474
475impl Display for MarginAccount {
476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477        write!(
478            f,
479            "MarginAccount(id={}, type={}, base={})",
480            self.id,
481            self.account_type,
482            self.base_currency.map_or_else(
483                || "None".to_string(),
484                |base_currency| format!("{}", base_currency.code)
485            ),
486        )
487    }
488}
489
490impl Hash for MarginAccount {
491    fn hash<H: Hasher>(&self, state: &mut H) {
492        self.id.hash(state);
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use ahash::AHashMap;
499    use nautilus_core::UnixNanos;
500    use rstest::rstest;
501    use rust_decimal::Decimal;
502
503    use crate::{
504        accounts::{Account, MarginAccount, stubs::*},
505        enums::{LiquiditySide, OrderSide, OrderType},
506        events::{AccountState, OrderFilled, account::stubs::*},
507        identifiers::{
508            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, TraderId,
509            VenueOrderId,
510            stubs::{uuid4, *},
511        },
512        instruments::{CryptoPerpetual, CurrencyPair, InstrumentAny, stubs::*},
513        position::Position,
514        types::{Currency, Money, Price, Quantity},
515    };
516
517    #[rstest]
518    fn test_display(margin_account: MarginAccount) {
519        assert_eq!(
520            margin_account.to_string(),
521            "MarginAccount(id=SIM-001, type=MARGIN, base=USD)"
522        );
523    }
524
525    #[rstest]
526    fn test_base_account_properties(
527        margin_account: MarginAccount,
528        margin_account_state: AccountState,
529    ) {
530        assert_eq!(margin_account.base_currency, Some(Currency::from("USD")));
531        assert_eq!(
532            margin_account.last_event(),
533            Some(margin_account_state.clone())
534        );
535        assert_eq!(margin_account.events(), vec![margin_account_state]);
536        assert_eq!(margin_account.event_count(), 1);
537        assert_eq!(
538            margin_account.balance_total(None),
539            Some(Money::from("1525000 USD"))
540        );
541        assert_eq!(
542            margin_account.balance_free(None),
543            Some(Money::from("1500000 USD"))
544        );
545        assert_eq!(
546            margin_account.balance_locked(None),
547            Some(Money::from("25000 USD"))
548        );
549        let mut balances_total_expected = AHashMap::new();
550        balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
551        assert_eq!(margin_account.balances_total(), balances_total_expected);
552        let mut balances_free_expected = AHashMap::new();
553        balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
554        assert_eq!(margin_account.balances_free(), balances_free_expected);
555        let mut balances_locked_expected = AHashMap::new();
556        balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
557        assert_eq!(margin_account.balances_locked(), balances_locked_expected);
558    }
559
560    #[rstest]
561    fn test_set_default_leverage(mut margin_account: MarginAccount) {
562        assert_eq!(margin_account.default_leverage, Decimal::ONE);
563        margin_account.set_default_leverage(Decimal::from(10));
564        assert_eq!(margin_account.default_leverage, Decimal::from(10));
565    }
566
567    #[rstest]
568    fn test_get_leverage_default_leverage(
569        margin_account: MarginAccount,
570        instrument_id_aud_usd_sim: InstrumentId,
571    ) {
572        assert_eq!(
573            margin_account.get_leverage(&instrument_id_aud_usd_sim),
574            Decimal::ONE
575        );
576    }
577
578    #[rstest]
579    fn test_set_leverage(
580        mut margin_account: MarginAccount,
581        instrument_id_aud_usd_sim: InstrumentId,
582    ) {
583        assert_eq!(margin_account.leverages.len(), 0);
584        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
585        assert_eq!(margin_account.leverages.len(), 1);
586        assert_eq!(
587            margin_account.get_leverage(&instrument_id_aud_usd_sim),
588            Decimal::from(10)
589        );
590    }
591
592    #[rstest]
593    fn test_is_unleveraged_with_leverage_returns_false(
594        mut margin_account: MarginAccount,
595        instrument_id_aud_usd_sim: InstrumentId,
596    ) {
597        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
598        assert!(!margin_account.is_unleveraged(instrument_id_aud_usd_sim));
599    }
600
601    #[rstest]
602    fn test_is_unleveraged_with_no_leverage_returns_true(
603        mut margin_account: MarginAccount,
604        instrument_id_aud_usd_sim: InstrumentId,
605    ) {
606        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::ONE);
607        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
608    }
609
610    #[rstest]
611    fn test_is_unleveraged_with_default_leverage_of_1_returns_true(
612        margin_account: MarginAccount,
613        instrument_id_aud_usd_sim: InstrumentId,
614    ) {
615        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
616    }
617
618    #[rstest]
619    fn test_update_margin_init(
620        mut margin_account: MarginAccount,
621        instrument_id_aud_usd_sim: InstrumentId,
622    ) {
623        assert_eq!(margin_account.margins.len(), 0);
624        let margin = Money::from("10000 USD");
625        margin_account.update_initial_margin(instrument_id_aud_usd_sim, margin);
626        assert_eq!(
627            margin_account.initial_margin(instrument_id_aud_usd_sim),
628            margin
629        );
630        let margins: Vec<Money> = margin_account
631            .margins
632            .values()
633            .map(|margin_balance| margin_balance.initial)
634            .collect();
635        assert_eq!(margins, vec![margin]);
636    }
637
638    #[rstest]
639    fn test_update_margin_maintenance(
640        mut margin_account: MarginAccount,
641        instrument_id_aud_usd_sim: InstrumentId,
642    ) {
643        let margin = Money::from("10000 USD");
644        margin_account.update_maintenance_margin(instrument_id_aud_usd_sim, margin);
645        assert_eq!(
646            margin_account.maintenance_margin(instrument_id_aud_usd_sim),
647            margin
648        );
649        let margins: Vec<Money> = margin_account
650            .margins
651            .values()
652            .map(|margin_balance| margin_balance.maintenance)
653            .collect();
654        assert_eq!(margins, vec![margin]);
655    }
656
657    #[rstest]
658    fn test_calculate_margin_init_with_leverage(
659        mut margin_account: MarginAccount,
660        audusd_sim: CurrencyPair,
661    ) {
662        margin_account.set_leverage(audusd_sim.id, Decimal::from(50));
663        let result = margin_account
664            .calculate_initial_margin(
665                audusd_sim,
666                Quantity::from(100_000),
667                Price::from("0.8000"),
668                None,
669            )
670            .unwrap();
671        assert_eq!(result, Money::from("48.00 USD"));
672    }
673
674    #[rstest]
675    fn test_calculate_margin_init_with_default_leverage(
676        mut margin_account: MarginAccount,
677        audusd_sim: CurrencyPair,
678    ) {
679        margin_account.set_default_leverage(Decimal::from(10));
680        let result = margin_account
681            .calculate_initial_margin(
682                audusd_sim,
683                Quantity::from(100_000),
684                Price::from("0.8"),
685                None,
686            )
687            .unwrap();
688        assert_eq!(result, Money::from("240.00 USD"));
689    }
690
691    #[rstest]
692    fn test_calculate_margin_init_with_no_leverage_for_inverse(
693        mut margin_account: MarginAccount,
694        xbtusd_bitmex: CryptoPerpetual,
695    ) {
696        let result_use_quote_inverse_true = margin_account
697            .calculate_initial_margin(
698                xbtusd_bitmex,
699                Quantity::from(100_000),
700                Price::from("11493.60"),
701                Some(false),
702            )
703            .unwrap();
704        assert_eq!(result_use_quote_inverse_true, Money::from("0.08700494 BTC"));
705        let result_use_quote_inverse_false = margin_account
706            .calculate_initial_margin(
707                xbtusd_bitmex,
708                Quantity::from(100_000),
709                Price::from("11493.60"),
710                Some(true),
711            )
712            .unwrap();
713        assert_eq!(result_use_quote_inverse_false, Money::from("1000 USD"));
714    }
715
716    #[rstest]
717    fn test_calculate_margin_maintenance_with_no_leverage(
718        mut margin_account: MarginAccount,
719        xbtusd_bitmex: CryptoPerpetual,
720    ) {
721        let result = margin_account
722            .calculate_maintenance_margin(
723                xbtusd_bitmex,
724                Quantity::from(100_000),
725                Price::from("11493.60"),
726                None,
727            )
728            .unwrap();
729        assert_eq!(result, Money::from("0.03045173 BTC"));
730    }
731
732    #[rstest]
733    fn test_calculate_margin_maintenance_with_leverage_fx_instrument(
734        mut margin_account: MarginAccount,
735        audusd_sim: CurrencyPair,
736    ) {
737        margin_account.set_default_leverage(Decimal::from(50));
738        let result = margin_account
739            .calculate_maintenance_margin(
740                audusd_sim,
741                Quantity::from(1_000_000),
742                Price::from("1"),
743                None,
744            )
745            .unwrap();
746        assert_eq!(result, Money::from("600.00 USD"));
747    }
748
749    #[rstest]
750    fn test_calculate_margin_maintenance_with_leverage_inverse_instrument(
751        mut margin_account: MarginAccount,
752        xbtusd_bitmex: CryptoPerpetual,
753    ) {
754        margin_account.set_default_leverage(Decimal::from(10));
755        let result = margin_account
756            .calculate_maintenance_margin(
757                xbtusd_bitmex,
758                Quantity::from(100_000),
759                Price::from("100000.00"),
760                None,
761            )
762            .unwrap();
763        assert_eq!(result, Money::from("0.00035000 BTC"));
764    }
765
766    #[rstest]
767    fn test_calculate_pnls_github_issue_2657() {
768        // Create a margin account
769        let account_state = margin_account_state();
770        let account = MarginAccount::new(account_state, false);
771
772        // Create BTCUSDT instrument
773        let btcusdt = currency_pair_btcusdt();
774        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt);
775
776        // Create initial position with BUY 0.001 BTC at 50000.00
777        let fill1 = OrderFilled::new(
778            TraderId::from("TRADER-001"),
779            StrategyId::from("S-001"),
780            btcusdt.id,
781            ClientOrderId::from("O-1"),
782            VenueOrderId::from("V-1"),
783            AccountId::from("SIM-001"),
784            TradeId::from("T-1"),
785            OrderSide::Buy,
786            OrderType::Market,
787            Quantity::from("0.001"),
788            Price::from("50000.00"),
789            btcusdt.quote_currency,
790            LiquiditySide::Taker,
791            uuid4(),
792            UnixNanos::from(1_000_000_000),
793            UnixNanos::default(),
794            false,
795            Some(PositionId::from("P-GITHUB-2657")),
796            None,
797        );
798
799        let position = Position::new(&btcusdt_any, fill1);
800
801        // Create second fill that sells MORE than position size (0.002 > 0.001)
802        let fill2 = OrderFilled::new(
803            TraderId::from("TRADER-001"),
804            StrategyId::from("S-001"),
805            btcusdt.id,
806            ClientOrderId::from("O-2"),
807            VenueOrderId::from("V-2"),
808            AccountId::from("SIM-001"),
809            TradeId::from("T-2"),
810            OrderSide::Sell,
811            OrderType::Market,
812            Quantity::from("0.002"), // This is larger than position quantity!
813            Price::from("50075.00"),
814            btcusdt.quote_currency,
815            LiquiditySide::Taker,
816            uuid4(),
817            UnixNanos::from(2_000_000_000),
818            UnixNanos::default(),
819            false,
820            Some(PositionId::from("P-GITHUB-2657")),
821            None,
822        );
823
824        // Test the fix - should only calculate PnL for position quantity (0.001), not fill quantity (0.002)
825        let pnls = account
826            .calculate_pnls(btcusdt_any, fill2, Some(position))
827            .unwrap();
828
829        // Should have exactly one PnL entry
830        assert_eq!(pnls.len(), 1);
831
832        // Expected PnL should be for 0.001 BTC, not 0.002 BTC
833        // PnL = (50075.00 - 50000.00) * 0.001 = 75.0 * 0.001 = 0.075 USDT
834        let expected_pnl = Money::from("0.075 USDT");
835        assert_eq!(pnls[0], expected_pnl);
836    }
837
838    #[rstest]
839    fn test_calculate_initial_margin_with_zero_leverage_falls_back_to_default(
840        mut margin_account: MarginAccount,
841        audusd_sim: CurrencyPair,
842    ) {
843        // Set default leverage
844        margin_account.set_default_leverage(Decimal::from(10));
845
846        // Set instrument-specific leverage to 0.0 (invalid)
847        margin_account.set_leverage(audusd_sim.id, Decimal::ZERO);
848
849        // Should not panic, should use default leverage instead
850        let result = margin_account
851            .calculate_initial_margin(
852                audusd_sim,
853                Quantity::from(100_000),
854                Price::from("0.8"),
855                None,
856            )
857            .unwrap();
858
859        // With default leverage of 10.0, notional of 80,000 / 10 = 8,000
860        // Initial margin rate is 0.03, so 8,000 * 0.03 = 240.00
861        assert_eq!(result, Money::from("240.00 USD"));
862
863        // Verify that the hashmap was updated with default leverage
864        assert_eq!(
865            margin_account.get_leverage(&audusd_sim.id),
866            Decimal::from(10)
867        );
868    }
869
870    #[rstest]
871    fn test_calculate_maintenance_margin_with_zero_leverage_falls_back_to_default(
872        mut margin_account: MarginAccount,
873        audusd_sim: CurrencyPair,
874    ) {
875        // Set default leverage
876        margin_account.set_default_leverage(Decimal::from(50));
877
878        // Set instrument-specific leverage to 0.0 (invalid)
879        margin_account.set_leverage(audusd_sim.id, Decimal::ZERO);
880
881        // Should not panic, should use default leverage instead
882        let result = margin_account
883            .calculate_maintenance_margin(
884                audusd_sim,
885                Quantity::from(1_000_000),
886                Price::from("1"),
887                None,
888            )
889            .unwrap();
890
891        // With default leverage of 50.0, notional of 1,000,000 / 50 = 20,000
892        // Maintenance margin rate is 0.03, so 20,000 * 0.03 = 600.00
893        assert_eq!(result, Money::from("600.00 USD"));
894
895        // Verify that the hashmap was updated with default leverage
896        assert_eq!(
897            margin_account.get_leverage(&audusd_sim.id),
898            Decimal::from(50)
899        );
900    }
901
902    #[rstest]
903    fn test_calculate_pnls_with_same_side_fill_returns_empty() {
904        use nautilus_core::UnixNanos;
905
906        use crate::{
907            enums::{LiquiditySide, OrderSide, OrderType},
908            events::OrderFilled,
909            identifiers::{
910                AccountId, ClientOrderId, PositionId, StrategyId, TradeId, TraderId, VenueOrderId,
911                stubs::uuid4,
912            },
913            instruments::InstrumentAny,
914            position::Position,
915            types::{Price, Quantity},
916        };
917
918        // Create a margin account
919        let account_state = margin_account_state();
920        let account = MarginAccount::new(account_state, false);
921
922        // Create BTCUSDT instrument
923        let btcusdt = currency_pair_btcusdt();
924        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt);
925
926        // Create initial position with BUY 1.0 BTC at 50000.00
927        let fill1 = OrderFilled::new(
928            TraderId::from("TRADER-001"),
929            StrategyId::from("S-001"),
930            btcusdt.id,
931            ClientOrderId::from("O-1"),
932            VenueOrderId::from("V-1"),
933            AccountId::from("SIM-001"),
934            TradeId::from("T-1"),
935            OrderSide::Buy,
936            OrderType::Market,
937            Quantity::from("1.0"),
938            Price::from("50000.00"),
939            btcusdt.quote_currency,
940            LiquiditySide::Taker,
941            uuid4(),
942            UnixNanos::from(1_000_000_000),
943            UnixNanos::default(),
944            false,
945            Some(PositionId::from("P-123456")),
946            None,
947        );
948
949        let position = Position::new(&btcusdt_any, fill1);
950
951        // Create second fill that also BUYS (same side as position entry)
952        let fill2 = OrderFilled::new(
953            TraderId::from("TRADER-001"),
954            StrategyId::from("S-001"),
955            btcusdt.id,
956            ClientOrderId::from("O-2"),
957            VenueOrderId::from("V-2"),
958            AccountId::from("SIM-001"),
959            TradeId::from("T-2"),
960            OrderSide::Buy, // Same side as position entry
961            OrderType::Market,
962            Quantity::from("0.5"),
963            Price::from("51000.00"),
964            btcusdt.quote_currency,
965            LiquiditySide::Taker,
966            uuid4(),
967            UnixNanos::from(2_000_000_000),
968            UnixNanos::default(),
969            false,
970            Some(PositionId::from("P-123456")),
971            None,
972        );
973
974        // Test that no PnL is calculated for same-side fills
975        let pnls = account
976            .calculate_pnls(btcusdt_any, fill2, Some(position))
977            .unwrap();
978
979        // Should return empty PnL list
980        assert_eq!(pnls.len(), 0);
981    }
982}