nautilus_model/accounts/
margin.rs

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