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