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