Skip to main content

nautilus_model/accounts/
margin.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! A margin account capable of holding leveraged positions and tracking instrument-specific
17//! leverage ratios.
18//!
19//! # PnL calculation
20//!
21//! The account calculates PnL differently based on instrument type:
22//!
23//! - **Premium instruments** (options, option spreads, binary options, warrants): Realize
24//!   the notional value as a cash flow on every fill. BUY = negative (premium paid),
25//!   SELL = positive (premium received).
26//!
27//! - **Other instruments**: Only realize PnL on position reduction (fill side opposite to
28//!   entry). Use the minimum of fill and position quantity to avoid double-counting.
29
30use std::{
31    fmt::Display,
32    hash::{Hash, Hasher},
33    ops::{Deref, DerefMut},
34};
35
36use ahash::AHashMap;
37use indexmap::IndexMap;
38use nautilus_core::correctness::{
39    CorrectnessResult, CorrectnessResultExt, FAILED, check_positive_decimal,
40};
41use rust_decimal::Decimal;
42use serde::{Deserialize, Serialize};
43
44use crate::{
45    accounts::{
46        Account,
47        base::BaseAccount,
48        fill_position_pnl,
49        margin_model::{MarginModel, MarginModelHandle},
50    },
51    enums::{AccountType, OrderSide},
52    events::{AccountState, OrderFilled},
53    identifiers::InstrumentId,
54    instruments::{Instrument, InstrumentAny},
55    position::{Position, amount, notional},
56    types::{
57        AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
58        money::{MONEY_RAW_MAX, MONEY_RAW_MIN, MoneyRaw},
59    },
60};
61
62/// Represents a margin account that can hold leveraged positions.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[cfg_attr(
65    feature = "python",
66    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
67)]
68#[cfg_attr(
69    feature = "python",
70    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
71)]
72pub struct MarginAccount {
73    /// The account state shared by every account type.
74    pub base: BaseAccount,
75    /// The leverage applied per instrument, overriding `default_leverage`.
76    pub leverages: AHashMap<InstrumentId, Decimal>,
77    /// Per-instrument margin balances (isolated margin, calculated margin in
78    /// backtest mode). Entries here have a concrete `instrument_id`.
79    pub margins: IndexMap<InstrumentId, MarginBalance>,
80    /// Account-wide (cross margin) margin balances keyed by collateral currency.
81    /// Populated from `AccountState.margins` entries where `instrument_id` is
82    /// `None`. Most derivatives venues in cross-margin mode report here.
83    pub account_margins: IndexMap<Currency, MarginBalance>,
84    /// The leverage applied to any instrument absent from `leverages`.
85    pub default_leverage: Decimal,
86    #[serde(skip, default = "MarginModelHandle::default")]
87    margin_model: MarginModelHandle,
88}
89
90impl MarginAccount {
91    /// Creates a new [`MarginAccount`] instance.
92    #[must_use]
93    pub fn new(event: AccountState, calculate_account_state: bool) -> Self {
94        let (margins, account_margins) = split_event_margins(&event);
95
96        Self {
97            base: BaseAccount::new(event, calculate_account_state),
98            leverages: AHashMap::new(),
99            margins,
100            account_margins,
101            default_leverage: Decimal::ONE,
102            margin_model: MarginModelHandle::default(),
103        }
104    }
105
106    #[must_use]
107    pub(crate) fn clone_without_events(&self) -> Self {
108        Self {
109            base: self.base.clone_without_events(),
110            leverages: self.leverages.clone(),
111            margins: self.margins.clone(),
112            account_margins: self.account_margins.clone(),
113            default_leverage: self.default_leverage,
114            margin_model: self.margin_model.clone(),
115        }
116    }
117
118    pub fn set_margin_model(&mut self, model: MarginModelHandle) {
119        self.margin_model = model;
120    }
121
122    #[must_use]
123    pub const fn margin_model(&self) -> &MarginModelHandle {
124        &self.margin_model
125    }
126
127    /// Sets the default leverage for the account.
128    ///
129    /// # Panics
130    ///
131    /// Panics if `leverage` is not positive. See [`Self::try_set_default_leverage`].
132    pub fn set_default_leverage(&mut self, leverage: Decimal) {
133        self.try_set_default_leverage(leverage)
134            .expect_display(FAILED);
135    }
136
137    /// Sets the default leverage for the account.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if `leverage` is not positive, leaving the account unchanged.
142    pub fn try_set_default_leverage(&mut self, leverage: Decimal) -> CorrectnessResult<()> {
143        check_positive_decimal(leverage, "leverage")?;
144        self.default_leverage = leverage;
145        Ok(())
146    }
147
148    /// Sets the leverage for a specific instrument.
149    ///
150    /// # Panics
151    ///
152    /// Panics if `leverage` is not positive. See [`Self::try_set_leverage`].
153    pub fn set_leverage(&mut self, instrument_id: InstrumentId, leverage: Decimal) {
154        self.try_set_leverage(instrument_id, leverage)
155            .expect_display(FAILED);
156    }
157
158    /// Sets the leverage for a specific instrument.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if `leverage` is not positive, leaving the account unchanged.
163    pub fn try_set_leverage(
164        &mut self,
165        instrument_id: InstrumentId,
166        leverage: Decimal,
167    ) -> CorrectnessResult<()> {
168        check_positive_decimal(leverage, "leverage")?;
169        self.leverages.insert(instrument_id, leverage);
170        Ok(())
171    }
172
173    #[must_use]
174    pub fn get_leverage(&self, instrument_id: &InstrumentId) -> Decimal {
175        *self
176            .leverages
177            .get(instrument_id)
178            .unwrap_or(&self.default_leverage)
179    }
180
181    #[must_use]
182    pub fn is_unleveraged(&self, instrument_id: InstrumentId) -> bool {
183        self.get_leverage(&instrument_id) == Decimal::ONE
184    }
185
186    #[must_use]
187    pub fn is_cash_account(&self) -> bool {
188        self.account_type == AccountType::Cash
189    }
190
191    #[must_use]
192    pub fn is_margin_account(&self) -> bool {
193        self.account_type == AccountType::Margin
194    }
195
196    #[must_use]
197    pub fn initial_margins(&self) -> IndexMap<InstrumentId, Money> {
198        self.margins
199            .values()
200            .filter_map(|margin| margin.instrument_id.map(|id| (id, margin.initial)))
201            .collect()
202    }
203
204    #[must_use]
205    pub fn maintenance_margins(&self) -> IndexMap<InstrumentId, Money> {
206        self.margins
207            .values()
208            .filter_map(|margin| margin.instrument_id.map(|id| (id, margin.maintenance)))
209            .collect()
210    }
211
212    /// Returns all account-wide initial margins keyed by currency.
213    #[must_use]
214    pub fn account_initial_margins(&self) -> IndexMap<Currency, Money> {
215        self.account_margins
216            .values()
217            .map(|margin| (margin.currency, margin.initial))
218            .collect()
219    }
220
221    /// Returns all account-wide maintenance margins keyed by currency.
222    #[must_use]
223    pub fn account_maintenance_margins(&self) -> IndexMap<Currency, Money> {
224        self.account_margins
225            .values()
226            .map(|margin| (margin.currency, margin.maintenance))
227            .collect()
228    }
229
230    /// Updates the initial margin for the specified instrument.
231    pub fn update_initial_margin(&mut self, instrument_id: InstrumentId, margin_init: Money) {
232        self.update_margin_component(instrument_id, margin_init, MarginComponent::Initial);
233    }
234
235    /// Clears the initial margin for the specified instrument.
236    pub fn clear_initial_margin(&mut self, instrument_id: InstrumentId) {
237        self.clear_margin_component(instrument_id, MarginComponent::Initial);
238    }
239
240    /// Returns the initial margin amount for the specified instrument.
241    ///
242    /// # Panics
243    ///
244    /// Panics if no margin balance exists for the given `instrument_id`.
245    #[must_use]
246    pub fn initial_margin(&self, instrument_id: InstrumentId) -> Money {
247        let margin_balance = self.margins.get(&instrument_id);
248        assert!(
249            margin_balance.is_some(),
250            "Cannot get margin_init when no margin_balance"
251        );
252        margin_balance.unwrap().initial
253    }
254
255    /// Updates the maintenance margin for the specified instrument.
256    pub fn update_maintenance_margin(
257        &mut self,
258        instrument_id: InstrumentId,
259        margin_maintenance: Money,
260    ) {
261        self.update_margin_component(
262            instrument_id,
263            margin_maintenance,
264            MarginComponent::Maintenance,
265        );
266    }
267
268    /// Clears the maintenance margin for the specified instrument.
269    pub fn clear_maintenance_margin(&mut self, instrument_id: InstrumentId) {
270        self.clear_margin_component(instrument_id, MarginComponent::Maintenance);
271    }
272
273    fn update_margin_component(
274        &mut self,
275        instrument_id: InstrumentId,
276        amount: Money,
277        component: MarginComponent,
278    ) {
279        let mut margin_balance = self
280            .margins
281            .get(&instrument_id)
282            .copied()
283            .unwrap_or_else(|| {
284                let zero = Money::zero(amount.currency);
285                MarginBalance::new(zero, zero, Some(instrument_id))
286            });
287        component.set(&mut margin_balance, amount);
288        self.margins.insert(instrument_id, margin_balance);
289
290        self.recalculate_balance(amount.currency);
291    }
292
293    fn clear_margin_component(&mut self, instrument_id: InstrumentId, component: MarginComponent) {
294        let Some(mut margin_balance) = self.margins.get(&instrument_id).copied() else {
295            return;
296        };
297
298        let currency = margin_balance.currency;
299        if component.sibling(&margin_balance).is_zero() {
300            self.margins.shift_remove(&instrument_id);
301        } else {
302            component.set(&mut margin_balance, Money::zero(currency));
303            self.margins.insert(instrument_id, margin_balance);
304        }
305
306        self.recalculate_balance(currency);
307    }
308
309    /// Returns the maintenance margin amount for the specified instrument.
310    ///
311    /// # Panics
312    ///
313    /// Panics if no margin balance exists for the given `instrument_id`.
314    #[must_use]
315    pub fn maintenance_margin(&self, instrument_id: InstrumentId) -> Money {
316        let margin_balance = self.margins.get(&instrument_id);
317        assert!(
318            margin_balance.is_some(),
319            "Cannot get maintenance_margin when no margin_balance"
320        );
321        margin_balance.unwrap().maintenance
322    }
323
324    /// Returns the margin balance for the specified instrument.
325    #[must_use]
326    pub fn margin(&self, instrument_id: &InstrumentId) -> Option<MarginBalance> {
327        self.margins.get(instrument_id).copied()
328    }
329
330    /// Returns the account-wide margin balance for the specified collateral currency.
331    #[must_use]
332    pub fn account_margin(&self, currency: &Currency) -> Option<MarginBalance> {
333        self.account_margins.get(currency).copied()
334    }
335
336    /// Returns the account-wide initial margin for the specified collateral currency.
337    #[must_use]
338    pub fn account_initial_margin(&self, currency: &Currency) -> Option<Money> {
339        self.account_margins.get(currency).map(|m| m.initial)
340    }
341
342    /// Returns the account-wide maintenance margin for the specified collateral currency.
343    #[must_use]
344    pub fn account_maintenance_margin(&self, currency: &Currency) -> Option<Money> {
345        self.account_margins.get(currency).map(|m| m.maintenance)
346    }
347
348    /// Returns the total initial margin reserved in the specified currency,
349    /// summing per-instrument and account-wide entries.
350    #[must_use]
351    pub fn total_initial_margin(&self, currency: Currency) -> Money {
352        self.total_margin_component(currency, MarginComponent::Initial)
353    }
354
355    /// Returns the total maintenance margin reserved in the specified currency,
356    /// summing per-instrument and account-wide entries.
357    #[must_use]
358    pub fn total_maintenance_margin(&self, currency: Currency) -> Money {
359        self.total_margin_component(currency, MarginComponent::Maintenance)
360    }
361
362    fn total_margin_component(&self, currency: Currency, component: MarginComponent) -> Money {
363        let raw = self
364            .margins_in(currency)
365            .fold(0 as MoneyRaw, |raw, margin| {
366                raw.saturating_add(component.of(margin).raw())
367            });
368
369        Money::from_raw(clamp_money_raw(raw), currency)
370    }
371
372    /// Returns every margin balance reserved in `currency`, per-instrument entries first.
373    fn margins_in(&self, currency: Currency) -> impl Iterator<Item = &MarginBalance> {
374        self.margins
375            .values()
376            .chain(self.account_margins.values())
377            .filter(move |margin| margin.currency == currency)
378    }
379
380    /// Updates the margin balance for the specified instrument or collateral.
381    ///
382    /// When `margin_balance.instrument_id` is `Some`, the entry is stored as a
383    /// per-instrument margin. When `None`, the entry is stored as an
384    /// account-wide margin keyed by `margin_balance.currency`.
385    pub fn update_margin(&mut self, margin_balance: MarginBalance) {
386        match margin_balance.instrument_id {
387            Some(instrument_id) => {
388                self.margins.insert(instrument_id, margin_balance);
389            }
390            None => {
391                self.account_margins
392                    .insert(margin_balance.currency, margin_balance);
393            }
394        }
395        self.recalculate_balance(margin_balance.currency);
396    }
397
398    /// Clears the margin for the specified instrument.
399    pub fn clear_margin(&mut self, instrument_id: InstrumentId) {
400        if let Some(margin_balance) = self.margins.shift_remove(&instrument_id) {
401            self.recalculate_balance(margin_balance.currency);
402        }
403    }
404
405    /// Clears the account-wide margin for the specified collateral currency.
406    pub fn clear_account_margin(&mut self, currency: Currency) {
407        if self.account_margins.shift_remove(&currency).is_some() {
408            self.recalculate_balance(currency);
409        }
410    }
411
412    /// Calculates the initial margin amount for the specified instrument and quantity.
413    ///
414    /// Delegates to the configured [`MarginModel`].
415    ///
416    /// # Errors
417    ///
418    /// Returns an error if leverage is not positive, or if the result cannot be represented
419    /// as `Money`.
420    pub fn calculate_initial_margin<T: Instrument>(
421        &self,
422        instrument: &T,
423        quantity: Quantity,
424        price: Price,
425        use_quote_for_inverse: Option<bool>,
426    ) -> anyhow::Result<Money> {
427        let leverage = self.get_leverage(&instrument.id());
428        self.margin_model.calculate_initial_margin(
429            instrument,
430            quantity,
431            price,
432            leverage,
433            use_quote_for_inverse,
434        )
435    }
436
437    /// Calculates the maintenance margin amount for the specified instrument and quantity.
438    ///
439    /// Delegates to the configured [`MarginModel`].
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if the result cannot be represented as `Money`.
444    pub fn calculate_maintenance_margin<T: Instrument>(
445        &self,
446        instrument: &T,
447        quantity: Quantity,
448        price: Price,
449        use_quote_for_inverse: Option<bool>,
450    ) -> anyhow::Result<Money> {
451        let leverage = self.get_leverage(&instrument.id());
452        self.margin_model.calculate_maintenance_margin(
453            instrument,
454            quantity,
455            price,
456            leverage,
457            use_quote_for_inverse,
458        )
459    }
460
461    /// Recalculates the account balance for the specified currency based on current margins.
462    ///
463    /// If the margins cannot be totaled, the balance is reserved in full so no further orders
464    /// are funded against it.
465    pub fn recalculate_balance(&mut self, currency: Currency) {
466        let current_balance = if let Some(balance) = self.balances.get(&currency) {
467            *balance
468        } else {
469            // Materializing a balance here would assert the venue holds zero of this currency.
470            // On a unified account that collateralizes across assets, absence is not zero, and
471            // the fabricated entry reads as a real venue-reported balance downstream.
472            log::debug!("Cannot recalculate balance when no current balance for {currency}");
473            return;
474        };
475
476        let new_balance = self.balance_locking_margins(current_balance.total);
477        self.balances.insert(currency, new_balance);
478    }
479
480    /// Returns a balance of `total` that locks the margins reserved in its currency, capped at
481    /// the total so the free balance is never negative while the total is positive.
482    #[must_use]
483    pub fn balance_locking_margins(&self, total: Money) -> AccountBalance {
484        let currency = total.currency;
485
486        // An untotalable margin set is venue-reported data, not a local invariant, so the
487        // balance degrades to fully reserved rather than taking the trading node down.
488        let total_margin_raw = self
489            .margins_in(currency)
490            .try_fold(0, |raw: MoneyRaw, margin| {
491                raw.checked_add(margin.initial.raw())?
492                    .checked_add(margin.maintenance.raw())
493            });
494
495        let total_margin = total_margin_raw.map_or_else(
496            || {
497                log::error!(
498                    "Cannot total {currency} margins: the sum exceeded Money bounds; reserving the full balance"
499                );
500                MONEY_RAW_MAX
501            },
502            clamp_money_raw,
503        );
504
505        // Clamp margin to total balance if it would result in negative free balance.
506        // This can occur transiently when venue and client state are out of sync.
507        // Locked margin must never be negative (even if total balance is negative).
508        let mut total_margin = Money::from_raw(total_margin, currency);
509        if total_margin > total {
510            total_margin = total.max(Money::zero(currency));
511        }
512
513        let total_free = total - total_margin;
514
515        AccountBalance::new(total, total_margin, total_free)
516    }
517}
518
519/// Selects one of the two amounts a [`MarginBalance`] carries.
520#[derive(Clone, Copy)]
521enum MarginComponent {
522    Initial,
523    Maintenance,
524}
525
526impl MarginComponent {
527    const fn of(self, margin: &MarginBalance) -> Money {
528        match self {
529            Self::Initial => margin.initial,
530            Self::Maintenance => margin.maintenance,
531        }
532    }
533
534    /// Returns the amount this component is paired with on the same balance.
535    const fn sibling(self, margin: &MarginBalance) -> Money {
536        match self {
537            Self::Initial => margin.maintenance,
538            Self::Maintenance => margin.initial,
539        }
540    }
541
542    const fn set(self, margin: &mut MarginBalance, amount: Money) {
543        match self {
544            Self::Initial => margin.initial = amount,
545            Self::Maintenance => margin.maintenance = amount,
546        }
547    }
548}
549
550fn split_event_margins(
551    event: &AccountState,
552) -> (
553    IndexMap<InstrumentId, MarginBalance>,
554    IndexMap<Currency, MarginBalance>,
555) {
556    let mut per_instrument: IndexMap<InstrumentId, MarginBalance> = IndexMap::new();
557    let mut per_currency: IndexMap<Currency, MarginBalance> = IndexMap::new();
558
559    for margin in &event.margins {
560        match margin.instrument_id {
561            Some(instrument_id) => {
562                per_instrument.insert(instrument_id, *margin);
563            }
564            None => {
565                per_currency.insert(margin.currency, *margin);
566            }
567        }
568    }
569
570    (per_instrument, per_currency)
571}
572
573#[inline]
574fn clamp_money_raw(raw: MoneyRaw) -> MoneyRaw {
575    raw.clamp(MONEY_RAW_MIN, MONEY_RAW_MAX)
576}
577
578impl Deref for MarginAccount {
579    type Target = BaseAccount;
580
581    fn deref(&self) -> &Self::Target {
582        &self.base
583    }
584}
585
586impl DerefMut for MarginAccount {
587    fn deref_mut(&mut self) -> &mut Self::Target {
588        &mut self.base
589    }
590}
591
592impl Account for MarginAccount {
593    impl_account_base_members!();
594
595    fn is_cash_account(&self) -> bool {
596        self.account_type == AccountType::Cash
597    }
598
599    fn is_margin_account(&self) -> bool {
600        self.account_type == AccountType::Margin
601    }
602
603    fn apply(&mut self, event: AccountState) -> anyhow::Result<()> {
604        self.check_event_account_id(&event)?;
605
606        let skip_margin_routing = event.balances.is_empty() && event.margins.is_empty();
607        let (per_instrument, per_currency) = split_event_margins(&event);
608        self.base_apply(event);
609
610        if !skip_margin_routing {
611            self.margins = per_instrument;
612            self.account_margins = per_currency;
613        }
614        Ok(())
615    }
616
617    fn calculate_balance_locked(
618        &self,
619        instrument: &InstrumentAny,
620        side: OrderSide,
621        quantity: Quantity,
622        price: Price,
623        use_quote_for_inverse: Option<bool>,
624    ) -> anyhow::Result<Money> {
625        self.base_calculate_balance_locked(instrument, side, quantity, price, use_quote_for_inverse)
626    }
627
628    fn calculate_pnls(
629        &self,
630        instrument: &InstrumentAny,
631        fill: &OrderFilled,
632        position: Option<Position>,
633    ) -> anyhow::Result<Vec<Money>> {
634        if let Some(commission) = fill.commission {
635            amount(commission)?;
636        }
637
638        let reduces = position.as_ref().is_some_and(|position| {
639            position.quantity.is_positive() && position.entry != fill.order_side
640        });
641
642        let net_pnl = fill_position_pnl(instrument, fill, position.as_ref())?;
643        let mut pnls: Vec<Money> = Vec::new();
644
645        // For premium-based instruments, realize the notional value as a cash flow on every fill
646        if instrument.instrument_class().is_premium_based() {
647            let currency = if instrument.is_inverse() {
648                instrument
649                    .base_currency()
650                    .ok_or_else(|| anyhow::anyhow!("inverse instrument has no base currency"))?
651            } else {
652                instrument.cost_currency()
653            };
654
655            let notional = notional(
656                fill.last_qty,
657                fill.last_px,
658                instrument.multiplier(),
659                instrument
660                    .instrument_class()
661                    .divides_notional_by_price(instrument.is_inverse()),
662                currency,
663            )?;
664
665            let pnl = if fill.order_side == OrderSide::Buy {
666                -notional
667            } else {
668                notional
669            };
670            pnls.push(pnl);
671            return Ok(pnls);
672        }
673
674        if let Some(net_pnl) = net_pnl {
675            // Portfolio deducts commission separately, after the commission-inclusive projection
676            let pnl = match fill
677                .commission
678                .filter(|fee| fee.currency == net_pnl.currency)
679            {
680                Some(fee) => net_pnl
681                    .checked_add(fee)
682                    .ok_or_else(|| anyhow::anyhow!("position gross PnL increment overflow"))?,
683                None => net_pnl,
684            };
685
686            if reduces || !pnl.is_zero() {
687                pnls.push(pnl);
688            }
689        }
690
691        Ok(pnls)
692    }
693}
694
695impl PartialEq for MarginAccount {
696    fn eq(&self, other: &Self) -> bool {
697        self.id == other.id
698    }
699}
700
701impl Eq for MarginAccount {}
702
703impl Display for MarginAccount {
704    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
705        write!(
706            f,
707            "MarginAccount(id={}, type={}, base={})",
708            self.id,
709            self.account_type,
710            self.base_currency.map_or_else(
711                || "None".to_string(),
712                |base_currency| format!("{}", base_currency.code)
713            ),
714        )
715    }
716}
717
718impl Hash for MarginAccount {
719    fn hash<H: Hasher>(&self, state: &mut H) {
720        self.id.hash(state);
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use ahash::AHashMap;
727    use indexmap::IndexMap;
728    use nautilus_core::UnixNanos;
729    use rstest::rstest;
730    use rust_decimal::Decimal;
731    use rust_decimal_macros::dec;
732
733    use crate::{
734        accounts::{
735            Account, MarginAccount,
736            margin_model::{MarginModel, MarginModelHandle},
737            stubs::*,
738        },
739        enums::{AccountType, LiquiditySide, OrderSide, OrderType},
740        events::{AccountState, account::stubs::*, order::spec::OrderFilledSpec},
741        fees::{MakerTakerFeeRates, MakerTakerFeeSchedule},
742        identifiers::{
743            AccountId, ClientOrderId, InstrumentId, PositionId, TradeId, VenueOrderId,
744            stubs::{uuid4, *},
745        },
746        instruments::{
747            CryptoOption, CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny,
748            stubs::{binary_option, option_contract_appl, *},
749        },
750        orders::{OrderTestBuilder, stubs::TestOrderEventStubs},
751        position::Position,
752        types::{
753            AccountBalance, Currency, MarginBalance, Money, Price, Quantity,
754            money::{MONEY_RAW_MAX, MONEY_RAW_MIN},
755        },
756    };
757
758    struct CustomMarginModel;
759
760    impl MarginModel for CustomMarginModel {
761        fn name(&self) -> &'static str {
762            "custom"
763        }
764
765        fn calculate_initial_margin(
766            &self,
767            _instrument: &dyn Instrument,
768            _quantity: Quantity,
769            _price: Price,
770            _leverage: Decimal,
771            _use_quote_for_inverse: Option<bool>,
772        ) -> anyhow::Result<Money> {
773            Ok(Money::from("12.34 USD"))
774        }
775
776        fn calculate_maintenance_margin(
777            &self,
778            _instrument: &dyn Instrument,
779            _quantity: Quantity,
780            _price: Price,
781            _leverage: Decimal,
782            _use_quote_for_inverse: Option<bool>,
783        ) -> anyhow::Result<Money> {
784            Ok(Money::from("5.67 USD"))
785        }
786    }
787
788    #[rstest]
789    fn test_display(margin_account: MarginAccount) {
790        assert_eq!(
791            margin_account.to_string(),
792            "MarginAccount(id=SIM-001, type=MARGIN, base=USD)"
793        );
794    }
795
796    #[rstest]
797    fn test_calculated_account_state_returns_field_value(margin_account_state: AccountState) {
798        assert!(MarginAccount::new(margin_account_state.clone(), true).calculated_account_state());
799        assert!(!MarginAccount::new(margin_account_state, false).calculated_account_state());
800    }
801
802    #[rstest]
803    fn test_base_account_properties(
804        margin_account: MarginAccount,
805        margin_account_state: AccountState,
806    ) {
807        assert_eq!(margin_account.base_currency, Some(Currency::from("USD")));
808        assert_eq!(
809            margin_account.last_event(),
810            Some(margin_account_state.clone())
811        );
812        assert_eq!(margin_account.events(), vec![margin_account_state.clone()]);
813        assert_eq!(margin_account.event_count(), 1);
814        assert_eq!(
815            margin_account.balance_total(None),
816            Some(Money::from("1525000 USD"))
817        );
818        assert_eq!(
819            margin_account.balance_free(None),
820            Some(Money::from("1500000 USD"))
821        );
822        assert_eq!(
823            margin_account.balance_locked(None),
824            Some(Money::from("25000 USD"))
825        );
826        let mut balances_total_expected = IndexMap::new();
827        balances_total_expected.insert(Currency::from("USD"), Money::from("1525000 USD"));
828        assert_eq!(margin_account.balances_total(), balances_total_expected);
829        let mut balances_free_expected = IndexMap::new();
830        balances_free_expected.insert(Currency::from("USD"), Money::from("1500000 USD"));
831        assert_eq!(margin_account.balances_free(), balances_free_expected);
832        let mut balances_locked_expected = IndexMap::new();
833        balances_locked_expected.insert(Currency::from("USD"), Money::from("25000 USD"));
834        assert_eq!(margin_account.balances_locked(), balances_locked_expected);
835        let margin_balance = margin_account_state.margins[0];
836        let instrument_id = margin_balance
837            .instrument_id
838            .expect("stub margin balance carries a concrete instrument_id");
839        let mut initial_margins_expected = IndexMap::new();
840        initial_margins_expected.insert(instrument_id, margin_balance.initial);
841        assert_eq!(margin_account.initial_margins(), initial_margins_expected);
842        let mut maintenance_margins_expected = IndexMap::new();
843        maintenance_margins_expected.insert(instrument_id, margin_balance.maintenance);
844        assert_eq!(
845            margin_account.maintenance_margins(),
846            maintenance_margins_expected
847        );
848    }
849
850    #[rstest]
851    fn test_set_default_leverage(mut margin_account: MarginAccount) {
852        assert_eq!(margin_account.default_leverage, Decimal::ONE);
853        margin_account.set_default_leverage(Decimal::from(10));
854        assert_eq!(margin_account.default_leverage, Decimal::from(10));
855    }
856
857    #[rstest]
858    fn test_get_leverage_default_leverage(
859        margin_account: MarginAccount,
860        instrument_id_aud_usd_sim: InstrumentId,
861    ) {
862        assert_eq!(
863            margin_account.get_leverage(&instrument_id_aud_usd_sim),
864            Decimal::ONE
865        );
866    }
867
868    #[rstest]
869    fn test_set_leverage(
870        mut margin_account: MarginAccount,
871        instrument_id_aud_usd_sim: InstrumentId,
872    ) {
873        assert_eq!(margin_account.leverages.len(), 0);
874        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
875        assert_eq!(margin_account.leverages.len(), 1);
876        assert_eq!(
877            margin_account.get_leverage(&instrument_id_aud_usd_sim),
878            Decimal::from(10)
879        );
880    }
881
882    #[rstest]
883    #[case(dec!(0))]
884    #[case(dec!(-2.5))]
885    fn test_try_leverage_setters_reject_non_positive_without_mutation(
886        mut margin_account: MarginAccount,
887        instrument_id_aud_usd_sim: InstrumentId,
888        #[case] leverage: Decimal,
889    ) {
890        margin_account
891            .try_set_leverage(instrument_id_aud_usd_sim, dec!(10))
892            .unwrap();
893        margin_account.try_set_default_leverage(dec!(5)).unwrap();
894
895        let default_error = margin_account
896            .try_set_default_leverage(leverage)
897            .unwrap_err();
898        let instrument_error = margin_account
899            .try_set_leverage(instrument_id_aud_usd_sim, leverage)
900            .unwrap_err();
901
902        let expected = format!("invalid Decimal for 'leverage' not positive, was {leverage}");
903        assert_eq!(default_error.to_string(), expected);
904        assert_eq!(instrument_error.to_string(), expected);
905        assert_eq!(margin_account.default_leverage, dec!(5));
906        assert_eq!(
907            margin_account.leverages,
908            AHashMap::from([(instrument_id_aud_usd_sim, dec!(10))])
909        );
910    }
911
912    #[rstest]
913    fn test_is_unleveraged_with_leverage_returns_false(
914        mut margin_account: MarginAccount,
915        instrument_id_aud_usd_sim: InstrumentId,
916    ) {
917        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::from(10));
918        assert!(!margin_account.is_unleveraged(instrument_id_aud_usd_sim));
919    }
920
921    #[rstest]
922    fn test_is_unleveraged_with_no_leverage_returns_true(
923        mut margin_account: MarginAccount,
924        instrument_id_aud_usd_sim: InstrumentId,
925    ) {
926        margin_account.set_leverage(instrument_id_aud_usd_sim, Decimal::ONE);
927        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
928    }
929
930    #[rstest]
931    fn test_is_unleveraged_with_default_leverage_of_1_returns_true(
932        margin_account: MarginAccount,
933        instrument_id_aud_usd_sim: InstrumentId,
934    ) {
935        assert!(margin_account.is_unleveraged(instrument_id_aud_usd_sim));
936    }
937
938    #[rstest]
939    fn test_update_margin_init(
940        mut margin_account: MarginAccount,
941        instrument_id_aud_usd_sim: InstrumentId,
942    ) {
943        assert_eq!(margin_account.margins.len(), 1);
944        let margin = Money::from("10000 USD");
945        margin_account.update_initial_margin(instrument_id_aud_usd_sim, margin);
946        assert_eq!(
947            margin_account.initial_margin(instrument_id_aud_usd_sim),
948            margin
949        );
950        assert_eq!(margin_account.margins.len(), 2);
951        assert_eq!(
952            margin_account
953                .margins
954                .get(&instrument_id_aud_usd_sim)
955                .expect("AUD/USD margin should exist")
956                .initial,
957            margin
958        );
959        assert_eq!(
960            margin_account
961                .margins
962                .get(&instrument_id_aud_usd_sim)
963                .expect("AUD/USD margin should exist")
964                .maintenance,
965            Money::zero(margin.currency)
966        );
967    }
968
969    #[rstest]
970    fn test_update_margin_maintenance(
971        mut margin_account: MarginAccount,
972        instrument_id_aud_usd_sim: InstrumentId,
973    ) {
974        let margin = Money::from("10000 USD");
975        margin_account.update_maintenance_margin(instrument_id_aud_usd_sim, margin);
976        assert_eq!(
977            margin_account.maintenance_margin(instrument_id_aud_usd_sim),
978            margin
979        );
980        assert_eq!(margin_account.margins.len(), 2);
981        assert_eq!(
982            margin_account
983                .margins
984                .get(&instrument_id_aud_usd_sim)
985                .expect("AUD/USD margin should exist")
986                .maintenance,
987            margin
988        );
989        assert_eq!(
990            margin_account
991                .margins
992                .get(&instrument_id_aud_usd_sim)
993                .expect("AUD/USD margin should exist")
994                .initial,
995            Money::zero(margin.currency)
996        );
997        // The stub margin reserves 25000 USD, so the new maintenance adds to the locked balance
998        assert_eq!(
999            margin_account.balance_locked(Some(Currency::USD())),
1000            Some(Money::from("35000 USD"))
1001        );
1002        assert_eq!(
1003            margin_account.balance_free(Some(Currency::USD())),
1004            Some(Money::from("1490000 USD"))
1005        );
1006    }
1007
1008    #[rstest]
1009    fn test_clear_initial_margin_preserves_maintenance(
1010        mut margin_account: MarginAccount,
1011        instrument_id_aud_usd_sim: InstrumentId,
1012    ) {
1013        let usd = Currency::USD();
1014        margin_account.update_margin(MarginBalance::new(
1015            Money::from("1000 USD"),
1016            Money::from("500 USD"),
1017            Some(instrument_id_aud_usd_sim),
1018        ));
1019
1020        assert_eq!(
1021            margin_account.balance_locked(Some(usd)),
1022            Some(Money::from("26500 USD"))
1023        );
1024
1025        margin_account.clear_initial_margin(instrument_id_aud_usd_sim);
1026
1027        let margin = margin_account
1028            .margin(&instrument_id_aud_usd_sim)
1029            .expect("margin should retain non-zero maintenance");
1030        assert_eq!(margin.initial, Money::from("0 USD"));
1031        assert_eq!(margin.maintenance, Money::from("500 USD"));
1032        assert_eq!(
1033            margin_account.balance_locked(Some(usd)),
1034            Some(Money::from("25500 USD"))
1035        );
1036        assert_eq!(
1037            margin_account.balance_free(Some(usd)),
1038            Some(Money::from("1499500 USD"))
1039        );
1040        assert_eq!(
1041            margin_account.balance_total(Some(usd)),
1042            Some(Money::from("1525000 USD"))
1043        );
1044    }
1045
1046    #[rstest]
1047    fn test_clear_maintenance_margin_removes_empty_entry(
1048        mut margin_account: MarginAccount,
1049        instrument_id_aud_usd_sim: InstrumentId,
1050    ) {
1051        let usd = Currency::USD();
1052        margin_account.update_margin(MarginBalance::new(
1053            Money::from("0 USD"),
1054            Money::from("500 USD"),
1055            Some(instrument_id_aud_usd_sim),
1056        ));
1057
1058        assert_eq!(
1059            margin_account.balance_locked(Some(usd)),
1060            Some(Money::from("25500 USD"))
1061        );
1062
1063        margin_account.clear_maintenance_margin(instrument_id_aud_usd_sim);
1064
1065        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_none());
1066        assert_eq!(
1067            margin_account.balance_locked(Some(usd)),
1068            Some(Money::from("25000 USD"))
1069        );
1070        assert_eq!(
1071            margin_account.balance_free(Some(usd)),
1072            Some(Money::from("1500000 USD"))
1073        );
1074    }
1075
1076    #[rstest]
1077    fn test_clear_maintenance_margin_preserves_initial(
1078        mut margin_account: MarginAccount,
1079        instrument_id_aud_usd_sim: InstrumentId,
1080    ) {
1081        let usd = Currency::USD();
1082        margin_account.update_margin(MarginBalance::new(
1083            Money::from("1000 USD"),
1084            Money::from("500 USD"),
1085            Some(instrument_id_aud_usd_sim),
1086        ));
1087
1088        assert_eq!(
1089            margin_account.balance_locked(Some(usd)),
1090            Some(Money::from("26500 USD"))
1091        );
1092
1093        margin_account.clear_maintenance_margin(instrument_id_aud_usd_sim);
1094
1095        let margin = margin_account
1096            .margin(&instrument_id_aud_usd_sim)
1097            .expect("margin should retain non-zero initial");
1098        assert_eq!(margin.initial, Money::from("1000 USD"));
1099        assert_eq!(margin.maintenance, Money::from("0 USD"));
1100        assert_eq!(
1101            margin_account.balance_locked(Some(usd)),
1102            Some(Money::from("26000 USD"))
1103        );
1104        assert_eq!(
1105            margin_account.balance_free(Some(usd)),
1106            Some(Money::from("1499000 USD"))
1107        );
1108    }
1109
1110    #[rstest]
1111    fn test_apply_replaces_margin_balances_from_event(
1112        mut margin_account: MarginAccount,
1113        margin_account_state: AccountState,
1114    ) {
1115        let old_instrument_id = margin_account_state.margins[0]
1116            .instrument_id
1117            .expect("stub margin balance carries a concrete instrument_id");
1118        let new_instrument_id = InstrumentId::from("USDJPY.SIM");
1119        let event = AccountState::new(
1120            margin_account_state.account_id,
1121            AccountType::Margin,
1122            margin_account_state.balances.clone(),
1123            vec![MarginBalance::new(
1124                Money::from("12500 USD"),
1125                Money::from("25000 USD"),
1126                Some(new_instrument_id),
1127            )],
1128            true,
1129            uuid4(),
1130            1.into(),
1131            1.into(),
1132            margin_account_state.base_currency,
1133        );
1134
1135        margin_account.apply(event).unwrap();
1136
1137        assert_eq!(
1138            margin_account.initial_margin(new_instrument_id),
1139            Money::from("12500 USD")
1140        );
1141        assert_eq!(
1142            margin_account.maintenance_margin(new_instrument_id),
1143            Money::from("25000 USD")
1144        );
1145        assert!(margin_account.margin(&old_instrument_id).is_none());
1146    }
1147
1148    #[rstest]
1149    fn test_account_type_predicates(margin_account: MarginAccount) {
1150        assert!(!margin_account.is_cash_account());
1151        assert!(margin_account.is_margin_account());
1152        assert!(!Account::is_cash_account(&margin_account));
1153        assert!(Account::is_margin_account(&margin_account));
1154    }
1155
1156    #[rstest]
1157    fn test_equality_compares_account_ids(margin_account_state: AccountState) {
1158        let account = MarginAccount::new(margin_account_state.clone(), true);
1159        let same = MarginAccount::new(margin_account_state.clone(), true);
1160        let mut other_state = margin_account_state;
1161        other_state.account_id = AccountId::from("OTHER-001");
1162        let other = MarginAccount::new(other_state, true);
1163
1164        assert_eq!(account, same);
1165        assert_ne!(account, other);
1166    }
1167
1168    #[rstest]
1169    fn test_apply_routes_account_margins_when_event_has_no_balances(
1170        mut margin_account: MarginAccount,
1171        margin_account_state: AccountState,
1172    ) {
1173        let usd = Currency::USD();
1174
1175        let event = AccountState::new(
1176            margin_account_state.account_id,
1177            AccountType::Margin,
1178            vec![],
1179            vec![MarginBalance::new(
1180                Money::from("12500 USD"),
1181                Money::from("25000 USD"),
1182                None,
1183            )],
1184            true,
1185            uuid4(),
1186            1.into(),
1187            1.into(),
1188            margin_account_state.base_currency,
1189        );
1190
1191        margin_account.apply(event).unwrap();
1192
1193        assert_eq!(
1194            margin_account.account_initial_margins(),
1195            IndexMap::from([(usd, Money::from("12500 USD"))])
1196        );
1197        assert_eq!(
1198            margin_account.account_maintenance_margins(),
1199            IndexMap::from([(usd, Money::from("25000 USD"))])
1200        );
1201    }
1202
1203    #[rstest]
1204    fn test_apply_routes_account_margins_by_currency(
1205        mut margin_account: MarginAccount,
1206        margin_account_state: AccountState,
1207    ) {
1208        let usd = Currency::USD();
1209        let event = AccountState::new(
1210            margin_account_state.account_id,
1211            AccountType::Margin,
1212            margin_account_state.balances.clone(),
1213            vec![MarginBalance::new(
1214                Money::from("12500 USD"),
1215                Money::from("25000 USD"),
1216                None,
1217            )],
1218            true,
1219            uuid4(),
1220            1.into(),
1221            1.into(),
1222            margin_account_state.base_currency,
1223        );
1224
1225        margin_account.apply(event).unwrap();
1226
1227        assert!(margin_account.margins.is_empty());
1228        assert_eq!(margin_account.account_margins.len(), 1);
1229        assert_eq!(
1230            margin_account.account_initial_margin(&usd),
1231            Some(Money::from("12500 USD"))
1232        );
1233        assert_eq!(
1234            margin_account.account_maintenance_margin(&usd),
1235            Some(Money::from("25000 USD"))
1236        );
1237        assert_eq!(
1238            margin_account.total_initial_margin(usd),
1239            Money::from("12500 USD")
1240        );
1241    }
1242
1243    #[rstest]
1244    fn test_apply_empty_event_preserves_margin_balances(
1245        mut margin_account: MarginAccount,
1246        margin_account_state: AccountState,
1247    ) {
1248        let instrument_id = margin_account_state.margins[0]
1249            .instrument_id
1250            .expect("stub margin balance carries a concrete instrument_id");
1251        let initial_margin = margin_account.initial_margin(instrument_id);
1252        let maintenance_margin = margin_account.maintenance_margin(instrument_id);
1253
1254        let empty_event = AccountState::new(
1255            margin_account_state.account_id,
1256            AccountType::Margin,
1257            vec![],
1258            vec![],
1259            true,
1260            uuid4(),
1261            1.into(),
1262            1.into(),
1263            margin_account_state.base_currency,
1264        );
1265
1266        margin_account.apply(empty_event).unwrap();
1267
1268        assert_eq!(margin_account.initial_margin(instrument_id), initial_margin);
1269        assert_eq!(
1270            margin_account.maintenance_margin(instrument_id),
1271            maintenance_margin
1272        );
1273        assert_eq!(margin_account.event_count(), 2);
1274    }
1275
1276    #[rstest]
1277    fn test_calculate_margin_init_with_leverage(
1278        mut margin_account: MarginAccount,
1279        audusd_sim: CurrencyPair,
1280    ) {
1281        margin_account.set_leverage(audusd_sim.id, Decimal::from(50));
1282        let result = margin_account
1283            .calculate_initial_margin(
1284                &audusd_sim,
1285                Quantity::from(100_000),
1286                Price::from("0.8000"),
1287                None,
1288            )
1289            .unwrap();
1290        assert_eq!(result, Money::from("48.00 USD"));
1291    }
1292
1293    #[rstest]
1294    fn test_custom_margin_model_through_account(
1295        mut margin_account: MarginAccount,
1296        audusd_sim: CurrencyPair,
1297    ) {
1298        margin_account.set_margin_model(MarginModelHandle::new(CustomMarginModel));
1299
1300        let initial = margin_account
1301            .calculate_initial_margin(
1302                &audusd_sim,
1303                Quantity::from(100_000),
1304                Price::from("0.8000"),
1305                None,
1306            )
1307            .unwrap();
1308        let maintenance = margin_account
1309            .calculate_maintenance_margin(
1310                &audusd_sim,
1311                Quantity::from(100_000),
1312                Price::from("0.8000"),
1313                None,
1314            )
1315            .unwrap();
1316
1317        assert_eq!(margin_account.margin_model().name(), "custom");
1318        assert_eq!(initial, Money::from("12.34 USD"));
1319        assert_eq!(maintenance, Money::from("5.67 USD"));
1320    }
1321
1322    #[rstest]
1323    fn test_calculate_margin_init_with_default_leverage(
1324        mut margin_account: MarginAccount,
1325        audusd_sim: CurrencyPair,
1326    ) {
1327        margin_account.set_default_leverage(Decimal::from(10));
1328        let result = margin_account
1329            .calculate_initial_margin(
1330                &audusd_sim,
1331                Quantity::from(100_000),
1332                Price::from("0.8"),
1333                None,
1334            )
1335            .unwrap();
1336        assert_eq!(result, Money::from("240.00 USD"));
1337    }
1338
1339    #[rstest]
1340    fn test_calculate_margin_init_with_no_leverage_for_inverse(
1341        margin_account: MarginAccount,
1342        btcusd_bybit: CryptoPerpetual,
1343    ) {
1344        let result_use_quote_inverse_true = margin_account
1345            .calculate_initial_margin(
1346                &btcusd_bybit,
1347                Quantity::from(100_000),
1348                Price::from("11493.60"),
1349                Some(false),
1350            )
1351            .unwrap();
1352        assert_eq!(result_use_quote_inverse_true, Money::from("0.08700494 BTC"));
1353        let result_use_quote_inverse_false = margin_account
1354            .calculate_initial_margin(
1355                &btcusd_bybit,
1356                Quantity::from(100_000),
1357                Price::from("11493.60"),
1358                Some(true),
1359            )
1360            .unwrap();
1361        assert_eq!(result_use_quote_inverse_false, Money::from("1000 USD"));
1362    }
1363
1364    #[rstest]
1365    fn test_calculate_margin_maintenance_with_no_leverage(
1366        margin_account: MarginAccount,
1367        btcusd_bybit: CryptoPerpetual,
1368    ) {
1369        let result = margin_account
1370            .calculate_maintenance_margin(
1371                &btcusd_bybit,
1372                Quantity::from(100_000),
1373                Price::from("11493.60"),
1374                None,
1375            )
1376            .unwrap();
1377        assert_eq!(result, Money::from("0.03045173 BTC"));
1378    }
1379
1380    #[rstest]
1381    fn test_calculate_margin_maintenance_with_leverage_fx_instrument(
1382        mut margin_account: MarginAccount,
1383        audusd_sim: CurrencyPair,
1384    ) {
1385        margin_account.set_default_leverage(Decimal::from(50));
1386        let result = margin_account
1387            .calculate_maintenance_margin(
1388                &audusd_sim,
1389                Quantity::from(1_000_000),
1390                Price::from("1"),
1391                None,
1392            )
1393            .unwrap();
1394        assert_eq!(result, Money::from("600.00 USD"));
1395    }
1396
1397    #[rstest]
1398    fn test_calculate_margin_maintenance_with_leverage_inverse_instrument(
1399        mut margin_account: MarginAccount,
1400        btcusd_bybit: CryptoPerpetual,
1401    ) {
1402        margin_account.set_default_leverage(Decimal::from(10));
1403        let result = margin_account
1404            .calculate_maintenance_margin(
1405                &btcusd_bybit,
1406                Quantity::from(100_000),
1407                Price::from("100000.00"),
1408                None,
1409            )
1410            .unwrap();
1411        assert_eq!(result, Money::from("0.00035000 BTC"));
1412    }
1413
1414    #[rstest]
1415    fn test_calculate_pnls_github_issue_2657() {
1416        // Create a margin account
1417        let account_state = margin_account_state();
1418        let account = MarginAccount::new(account_state, false);
1419
1420        // Create BTCUSDT instrument
1421        let btcusdt = currency_pair_btcusdt();
1422        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt);
1423
1424        // Create initial position with BUY 0.001 BTC at 50000.00
1425        let fill1 = OrderFilledSpec::builder()
1426            .instrument_id(btcusdt_any.id())
1427            .client_order_id(ClientOrderId::from("O-1"))
1428            .venue_order_id(VenueOrderId::from("V-1"))
1429            .trade_id(TradeId::from("T-1"))
1430            .last_qty(Quantity::from("0.001"))
1431            .last_px(Price::from("50000.00"))
1432            .currency(btcusdt_any.quote_currency())
1433            .ts_event(UnixNanos::from(1_000_000_000))
1434            .position_id(PositionId::from("P-GITHUB-2657"))
1435            .build();
1436
1437        let position = Position::new(&btcusdt_any, fill1);
1438
1439        // Create second fill that sells MORE than position size (0.002 > 0.001)
1440        let fill2 = OrderFilledSpec::builder()
1441            .instrument_id(btcusdt_any.id())
1442            .client_order_id(ClientOrderId::from("O-2"))
1443            .venue_order_id(VenueOrderId::from("V-2"))
1444            .trade_id(TradeId::from("T-2"))
1445            .order_side(OrderSide::Sell)
1446            .last_qty(Quantity::from("0.002")) // This is larger than position quantity!
1447            .last_px(Price::from("50075.00"))
1448            .currency(btcusdt_any.quote_currency())
1449            .ts_event(UnixNanos::from(2_000_000_000))
1450            .position_id(PositionId::from("P-GITHUB-2657"))
1451            .build();
1452
1453        // Test the fix - should only calculate PnL for position quantity (0.001), not fill quantity (0.002)
1454        let pnls = account
1455            .calculate_pnls(&btcusdt_any, &fill2, Some(position))
1456            .unwrap();
1457
1458        // Should have exactly one PnL entry
1459        assert_eq!(pnls.len(), 1);
1460
1461        // Expected PnL should be for 0.001 BTC, not 0.002 BTC
1462        // PnL = (50075.00 - 50000.00) * 0.001 = 75.0 * 0.001 = 0.075 USDT
1463        let expected_pnl = Money::from("0.075 USDT");
1464        assert_eq!(pnls[0], expected_pnl);
1465    }
1466
1467    #[rstest]
1468    #[should_panic(expected = "not positive")]
1469    fn test_set_leverage_zero_panics(mut margin_account: MarginAccount, audusd_sim: CurrencyPair) {
1470        margin_account.set_leverage(audusd_sim.id, Decimal::ZERO);
1471    }
1472
1473    #[rstest]
1474    #[should_panic(expected = "not positive")]
1475    fn test_set_default_leverage_zero_panics(mut margin_account: MarginAccount) {
1476        margin_account.set_default_leverage(Decimal::ZERO);
1477    }
1478
1479    #[rstest]
1480    #[should_panic(expected = "not positive")]
1481    fn test_set_leverage_negative_panics(
1482        mut margin_account: MarginAccount,
1483        audusd_sim: CurrencyPair,
1484    ) {
1485        margin_account.set_leverage(audusd_sim.id, Decimal::from(-1));
1486    }
1487
1488    #[rstest]
1489    fn test_calculate_pnls_with_same_side_fill_returns_empty() {
1490        use nautilus_core::UnixNanos;
1491
1492        use crate::{
1493            events::order::spec::OrderFilledSpec,
1494            identifiers::{ClientOrderId, PositionId, TradeId, VenueOrderId},
1495            instruments::InstrumentAny,
1496            position::Position,
1497            types::{Price, Quantity},
1498        };
1499
1500        // Create a margin account
1501        let account_state = margin_account_state();
1502        let account = MarginAccount::new(account_state, false);
1503
1504        // Create BTCUSDT instrument
1505        let btcusdt = currency_pair_btcusdt();
1506        let btcusdt_any = InstrumentAny::CurrencyPair(btcusdt.clone());
1507
1508        // Create initial position with BUY 1.0 BTC at 50000.00
1509        let fill1 = OrderFilledSpec::builder()
1510            .instrument_id(btcusdt.id)
1511            .client_order_id(ClientOrderId::from("O-1"))
1512            .venue_order_id(VenueOrderId::from("V-1"))
1513            .trade_id(TradeId::from("T-1"))
1514            .last_qty(Quantity::from("1.0"))
1515            .last_px(Price::from("50000.00"))
1516            .currency(btcusdt.quote_currency)
1517            .ts_event(UnixNanos::from(1_000_000_000))
1518            .position_id(PositionId::from("P-123456"))
1519            .build();
1520
1521        let position = Position::new(&btcusdt_any, fill1);
1522
1523        // Create second fill that also BUYS (same side as position entry)
1524        let fill2 = OrderFilledSpec::builder()
1525            .instrument_id(btcusdt.id)
1526            .client_order_id(ClientOrderId::from("O-2"))
1527            .venue_order_id(VenueOrderId::from("V-2"))
1528            .trade_id(TradeId::from("T-2"))
1529            .last_qty(Quantity::from("0.5"))
1530            .last_px(Price::from("51000.00"))
1531            .currency(btcusdt.quote_currency)
1532            .ts_event(UnixNanos::from(2_000_000_000))
1533            .position_id(PositionId::from("P-123456"))
1534            .build();
1535
1536        // Test that no PnL is calculated for same-side fills
1537        let pnls = account
1538            .calculate_pnls(&btcusdt_any, &fill2, Some(position))
1539            .unwrap();
1540
1541        // Should return empty PnL list
1542        assert_eq!(pnls.len(), 0);
1543    }
1544
1545    #[rstest]
1546    #[case(OrderSide::Buy, "0.1234", OrderSide::Sell, "0.1235")]
1547    #[case(OrderSide::Sell, "0.1235", OrderSide::Buy, "0.1234")]
1548    fn test_calculate_pnls_preserves_micro_profit(
1549        margin_account: MarginAccount,
1550        #[case] opening_side: OrderSide,
1551        #[case] opening_px: &str,
1552        #[case] closing_side: OrderSide,
1553        #[case] closing_px: &str,
1554    ) {
1555        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1556        let opening = OrderFilledSpec::builder()
1557            .instrument_id(instrument.id())
1558            .position_id(PositionId::from("P-MICRO"))
1559            .order_side(opening_side)
1560            .last_qty(Quantity::from("0.01"))
1561            .last_px(Price::from(opening_px))
1562            .commission(Money::zero(Currency::USD()))
1563            .build();
1564        let mut position = Position::new(&instrument, opening);
1565        let closing = OrderFilledSpec::builder()
1566            .instrument_id(instrument.id())
1567            .position_id(position.id)
1568            .trade_id(TradeId::from("T-CLOSE-MICRO"))
1569            .order_side(closing_side)
1570            .last_qty(Quantity::from("0.01"))
1571            .last_px(Price::from(closing_px))
1572            .commission(Money::zero(Currency::USD()))
1573            .build();
1574
1575        let pnls = margin_account
1576            .calculate_pnls(&instrument, &closing, Some(position.clone()))
1577            .unwrap();
1578        position.try_apply(&closing).unwrap();
1579        let expected = Money::from_decimal_exact(dec!(0.000001), Currency::USD()).unwrap();
1580
1581        assert_eq!(pnls, vec![expected]);
1582        assert_eq!(position.realized_pnl, Some(expected));
1583    }
1584
1585    #[cfg(feature = "high-precision")]
1586    #[rstest]
1587    fn test_calculate_pnls_rejects_opening_product_underflow(margin_account: MarginAccount) {
1588        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1589        let fill = OrderFilledSpec::builder()
1590            .instrument_id(instrument.id())
1591            .last_qty(Quantity::from("0.0000000000000001"))
1592            .last_px(Price::from("0.0000000000000001"))
1593            .commission(Money::from("1 USD"))
1594            .build();
1595
1596        let result = margin_account.calculate_pnls(&instrument, &fill, None);
1597
1598        assert_eq!(
1599            result.unwrap_err().to_string(),
1600            "position proportional arithmetic underflow"
1601        );
1602    }
1603
1604    #[cfg(feature = "defi")]
1605    #[rstest]
1606    #[case::precision_16("50001.0000000000000000")]
1607    #[case::precision_17("50001.00000000000000000")]
1608    #[case::precision_18("50001.000000000000000000")]
1609    fn test_calculate_pnls_preserves_high_precision_reducing_price(
1610        margin_account: MarginAccount,
1611        #[case] last_px: &str,
1612    ) {
1613        let btcusdt = currency_pair_btcusdt();
1614        let instrument = InstrumentAny::CurrencyPair(btcusdt.clone());
1615        let fill_open = OrderFilledSpec::builder()
1616            .instrument_id(btcusdt.id)
1617            .trade_id(TradeId::from("T-1"))
1618            .last_qty(Quantity::from("1.000000"))
1619            .last_px(Price::from("50000.00"))
1620            .currency(btcusdt.quote_currency)
1621            .position_id(PositionId::from("P-1"))
1622            .build();
1623        let position = Position::new(&instrument, fill_open);
1624        let fill_close = OrderFilledSpec::builder()
1625            .instrument_id(btcusdt.id)
1626            .trade_id(TradeId::from("T-2"))
1627            .order_side(OrderSide::Sell)
1628            .last_qty(Quantity::from("2.000000000000000001"))
1629            .last_px(Price::from(last_px))
1630            .currency(btcusdt.quote_currency)
1631            .position_id(PositionId::from("P-1"))
1632            .build();
1633
1634        let result = margin_account.calculate_pnls(&instrument, &fill_close, Some(position));
1635
1636        assert_eq!(result.unwrap(), vec![Money::from("1 USDT")]);
1637    }
1638
1639    #[rstest]
1640    fn test_margin_accessor(
1641        mut margin_account: MarginAccount,
1642        instrument_id_aud_usd_sim: InstrumentId,
1643    ) {
1644        let margin_balance = MarginBalance::new(
1645            Money::from("1000 USD"),
1646            Money::from("500 USD"),
1647            Some(instrument_id_aud_usd_sim),
1648        );
1649
1650        margin_account.update_margin(margin_balance);
1651
1652        let retrieved = margin_account.margin(&instrument_id_aud_usd_sim);
1653        assert!(retrieved.is_some());
1654        let retrieved = retrieved.unwrap();
1655        assert_eq!(retrieved.initial, Money::from("1000 USD"));
1656        assert_eq!(retrieved.maintenance, Money::from("500 USD"));
1657        assert_eq!(retrieved.instrument_id, Some(instrument_id_aud_usd_sim));
1658    }
1659
1660    #[rstest]
1661    fn test_clear_margin(
1662        mut margin_account: MarginAccount,
1663        instrument_id_aud_usd_sim: InstrumentId,
1664    ) {
1665        let usd = Currency::USD();
1666        let margin_balance = MarginBalance::new(
1667            Money::from("1000 USD"),
1668            Money::from("500 USD"),
1669            Some(instrument_id_aud_usd_sim),
1670        );
1671
1672        margin_account.update_margin(margin_balance);
1673        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_some());
1674        assert_eq!(
1675            margin_account.balance_locked(Some(usd)),
1676            Some(Money::from("26500 USD"))
1677        );
1678        assert_eq!(
1679            margin_account.balance_free(Some(usd)),
1680            Some(Money::from("1498500 USD"))
1681        );
1682
1683        margin_account.clear_margin(instrument_id_aud_usd_sim);
1684
1685        assert!(margin_account.margin(&instrument_id_aud_usd_sim).is_none());
1686        assert_eq!(
1687            margin_account.balance_locked(Some(usd)),
1688            Some(Money::from("25000 USD"))
1689        );
1690        assert_eq!(
1691            margin_account.balance_free(Some(usd)),
1692            Some(Money::from("1500000 USD"))
1693        );
1694        assert_eq!(
1695            margin_account.balance_total(Some(usd)),
1696            Some(Money::from("1525000 USD"))
1697        );
1698    }
1699
1700    #[rstest]
1701    fn test_update_margin_routes_account_wide(mut margin_account: MarginAccount) {
1702        let usd = Currency::USD();
1703        let margin_balance =
1704            MarginBalance::new(Money::from("200 USD"), Money::from("100 USD"), None);
1705
1706        margin_account.update_margin(margin_balance);
1707
1708        assert_eq!(margin_account.account_margin(&usd), Some(margin_balance));
1709        assert_eq!(
1710            margin_account.account_initial_margin(&usd),
1711            Some(Money::from("200 USD"))
1712        );
1713        assert_eq!(
1714            margin_account.account_maintenance_margin(&usd),
1715            Some(Money::from("100 USD"))
1716        );
1717        assert_eq!(
1718            margin_account.balance_locked(Some(usd)),
1719            Some(Money::from("25300 USD"))
1720        );
1721        assert_eq!(
1722            margin_account.balance_free(Some(usd)),
1723            Some(Money::from("1499700 USD"))
1724        );
1725
1726        margin_account.clear_account_margin(usd);
1727
1728        assert!(margin_account.account_margin(&usd).is_none());
1729        assert_eq!(
1730            margin_account.balance_locked(Some(usd)),
1731            Some(Money::from("25000 USD"))
1732        );
1733        assert_eq!(
1734            margin_account.balance_free(Some(usd)),
1735            Some(Money::from("1500000 USD"))
1736        );
1737    }
1738
1739    #[rstest]
1740    #[should_panic(expected = "Cannot get margin_init when no margin_balance")]
1741    fn test_initial_margin_panics_when_absent(
1742        margin_account: MarginAccount,
1743        instrument_id_aud_usd_sim: InstrumentId,
1744    ) {
1745        let _ = margin_account.initial_margin(instrument_id_aud_usd_sim);
1746    }
1747
1748    #[rstest]
1749    #[should_panic(expected = "Cannot get maintenance_margin when no margin_balance")]
1750    fn test_maintenance_margin_panics_when_absent(
1751        margin_account: MarginAccount,
1752        instrument_id_aud_usd_sim: InstrumentId,
1753    ) {
1754        let _ = margin_account.maintenance_margin(instrument_id_aud_usd_sim);
1755    }
1756
1757    // A multi-currency margin account (no base currency), as unified venues such as Bybit
1758    // report. Only `reported` carries a venue balance.
1759    fn multi_currency_margin_account(reported: Money) -> MarginAccount {
1760        let state = AccountState::new(
1761            AccountId::from("BYBIT-001"),
1762            AccountType::Margin,
1763            vec![AccountBalance::new(
1764                reported,
1765                Money::zero(reported.currency),
1766                reported,
1767            )],
1768            Vec::new(),
1769            true,
1770            uuid4(),
1771            0.into(),
1772            0.into(),
1773            None,
1774        );
1775        MarginAccount::new(state, true)
1776    }
1777
1778    #[rstest]
1779    fn test_margin_update_leaves_unreported_currency_absent() {
1780        let usdt = Currency::USDT();
1781        let mut account = multi_currency_margin_account(Money::from("1000000 USD"));
1782        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1783
1784        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1785
1786        assert_eq!(account.balance_total(Some(usdt)), None);
1787        assert_eq!(account.balance_locked(Some(usdt)), None);
1788        assert_eq!(account.balance_free(Some(usdt)), None);
1789        assert_eq!(
1790            account.balances.keys().copied().collect::<Vec<_>>(),
1791            vec![Currency::USD()]
1792        );
1793        assert_eq!(
1794            account.initial_margin(instrument_id),
1795            Money::from("5000 USDT")
1796        );
1797        assert_eq!(
1798            account.balance_free(Some(Currency::USD())),
1799            Some(Money::from("1000000 USD"))
1800        );
1801    }
1802
1803    #[rstest]
1804    fn test_margin_update_locks_reported_currency() {
1805        let usdt = Currency::USDT();
1806        let mut account = multi_currency_margin_account(Money::from("100000 USDT"));
1807        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1808
1809        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1810
1811        assert_eq!(
1812            account.balance_total(Some(usdt)),
1813            Some(Money::from("100000 USDT"))
1814        );
1815        assert_eq!(
1816            account.balance_locked(Some(usdt)),
1817            Some(Money::from("5000 USDT"))
1818        );
1819        assert_eq!(
1820            account.balance_free(Some(usdt)),
1821            Some(Money::from("95000 USDT"))
1822        );
1823    }
1824
1825    #[rstest]
1826    fn test_margin_update_locks_currency_reported_after_the_margin() {
1827        let usdt = Currency::USDT();
1828        let mut account = multi_currency_margin_account(Money::from("1000000 USD"));
1829        let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
1830
1831        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1832        account
1833            .apply(AccountState::new(
1834                AccountId::from("BYBIT-001"),
1835                AccountType::Margin,
1836                vec![AccountBalance::new(
1837                    Money::from("100000 USDT"),
1838                    Money::zero(usdt),
1839                    Money::from("100000 USDT"),
1840                )],
1841                Vec::new(),
1842                true,
1843                uuid4(),
1844                0.into(),
1845                0.into(),
1846                None,
1847            ))
1848            .unwrap();
1849        account.update_initial_margin(instrument_id, Money::from("5000 USDT"));
1850
1851        assert_eq!(
1852            account.balance_locked(Some(usdt)),
1853            Some(Money::from("5000 USDT"))
1854        );
1855        assert_eq!(
1856            account.balance_free(Some(usdt)),
1857            Some(Money::from("95000 USDT"))
1858        );
1859    }
1860
1861    #[rstest]
1862    fn test_recalculate_balance_clamps_when_margin_exceeds_total() {
1863        let usdt = Currency::USDT();
1864        let mut account = multi_currency_margin_account(Money::from("1000 USDT"));
1865
1866        account.update_initial_margin(
1867            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
1868            Money::from("1500 USDT"),
1869        );
1870
1871        assert_eq!(
1872            account.balance_total(Some(usdt)),
1873            Some(Money::from("1000 USDT"))
1874        );
1875        assert_eq!(
1876            account.balance_locked(Some(usdt)),
1877            Some(Money::from("1000 USDT"))
1878        );
1879        assert_eq!(
1880            account.balance_free(Some(usdt)),
1881            Some(Money::from("0 USDT"))
1882        );
1883    }
1884
1885    #[rstest]
1886    fn test_recalculate_balance_keeps_locked_non_negative_when_total_negative() {
1887        let usd = Currency::USD();
1888        let mut account = multi_currency_margin_account(Money::from("-1000 USD"));
1889        // Seed a stale non-zero reservation so the reset to zero is observable
1890        account.balances.insert(
1891            usd,
1892            AccountBalance::new(
1893                Money::from("-1000 USD"),
1894                Money::from("250 USD"),
1895                Money::from("-1250 USD"),
1896            ),
1897        );
1898
1899        account.update_initial_margin(InstrumentId::from("EURUSD.SIM"), Money::from("500 USD"));
1900
1901        assert_eq!(
1902            account.balance_total(Some(usd)),
1903            Some(Money::from("-1000 USD"))
1904        );
1905        assert_eq!(
1906            account.balance_locked(Some(usd)),
1907            Some(Money::from("0 USD"))
1908        );
1909        assert_eq!(
1910            account.balance_free(Some(usd)),
1911            Some(Money::from("-1000 USD"))
1912        );
1913    }
1914
1915    #[rstest]
1916    fn test_total_margin_sums_per_instrument_and_account_wide(
1917        mut margin_account: MarginAccount,
1918        instrument_id_aud_usd_sim: InstrumentId,
1919    ) {
1920        let usd = Currency::USD();
1921        let baseline_initial = margin_account.total_initial_margin(usd);
1922        let baseline_maintenance = margin_account.total_maintenance_margin(usd);
1923
1924        margin_account.update_margin(MarginBalance::new(
1925            Money::from("100 USD"),
1926            Money::from("50 USD"),
1927            Some(instrument_id_aud_usd_sim),
1928        ));
1929        margin_account.update_margin(MarginBalance::new(
1930            Money::from("200 USD"),
1931            Money::from("150 USD"),
1932            None,
1933        ));
1934
1935        assert_eq!(
1936            margin_account.total_initial_margin(usd),
1937            baseline_initial + Money::from("300 USD"),
1938        );
1939        assert_eq!(
1940            margin_account.total_maintenance_margin(usd),
1941            baseline_maintenance + Money::from("200 USD"),
1942        );
1943    }
1944
1945    #[rstest]
1946    fn test_recalculate_balance_reserves_full_balance_when_margins_cannot_be_totalled() {
1947        let usdt = Currency::USDT();
1948        let mut account = multi_currency_margin_account(Money::from("1000 USDT"));
1949        let max = Money::from_raw(MONEY_RAW_MAX, usdt);
1950
1951        // Two maximal margins overflow the sum rather than merely exceeding the balance
1952        for symbol in ["ETHUSDT-PERP.BINANCE", "BTCUSDT-PERP.BINANCE"] {
1953            let instrument_id = InstrumentId::from(symbol);
1954            account.margins.insert(
1955                instrument_id,
1956                MarginBalance::new(max, max, Some(instrument_id)),
1957            );
1958        }
1959
1960        account.recalculate_balance(usdt);
1961
1962        assert_eq!(
1963            account.balance_total(Some(usdt)),
1964            Some(Money::from("1000 USDT"))
1965        );
1966        assert_eq!(
1967            account.balance_locked(Some(usdt)),
1968            Some(Money::from("1000 USDT"))
1969        );
1970        assert_eq!(
1971            account.balance_free(Some(usdt)),
1972            Some(Money::from("0 USDT"))
1973        );
1974    }
1975
1976    #[rstest]
1977    fn test_total_margin_clamps_domain_overflow(
1978        mut margin_account: MarginAccount,
1979        instrument_id_aud_usd_sim: InstrumentId,
1980    ) {
1981        let usd = Currency::USD();
1982        let max = Money::from_raw(MONEY_RAW_MAX, usd);
1983        let other_instrument = InstrumentId::from("EUR/USD.SIM");
1984
1985        margin_account.margins.insert(
1986            instrument_id_aud_usd_sim,
1987            MarginBalance::new(max, max, Some(instrument_id_aud_usd_sim)),
1988        );
1989        margin_account.margins.insert(
1990            other_instrument,
1991            MarginBalance::new(max, max, Some(other_instrument)),
1992        );
1993
1994        assert_eq!(margin_account.total_initial_margin(usd), max);
1995        assert_eq!(margin_account.total_maintenance_margin(usd), max);
1996    }
1997
1998    #[rstest]
1999    fn test_total_margin_clamps_negative_domain_overflow(
2000        mut margin_account: MarginAccount,
2001        instrument_id_aud_usd_sim: InstrumentId,
2002    ) {
2003        let usd = Currency::USD();
2004        let min = Money::from_raw(MONEY_RAW_MIN, usd);
2005        let other_instrument = InstrumentId::from("EUR/USD.SIM");
2006
2007        margin_account.margins.insert(
2008            instrument_id_aud_usd_sim,
2009            MarginBalance::new(min, min, Some(instrument_id_aud_usd_sim)),
2010        );
2011        margin_account.margins.insert(
2012            other_instrument,
2013            MarginBalance::new(min, min, Some(other_instrument)),
2014        );
2015
2016        assert_eq!(margin_account.total_initial_margin(usd), min);
2017        assert_eq!(margin_account.total_maintenance_margin(usd), min);
2018    }
2019
2020    #[rstest]
2021    fn test_calculate_pnls_for_option_buy_realizes_premium(margin_account: MarginAccount) {
2022        let option = option_contract_appl();
2023        let option_any = InstrumentAny::OptionContract(option.clone());
2024
2025        let order = OrderTestBuilder::new(OrderType::Market)
2026            .instrument_id(option.id)
2027            .side(OrderSide::Buy)
2028            .quantity(Quantity::from("10"))
2029            .build();
2030
2031        let fill = TestOrderEventStubs::filled(
2032            &order,
2033            &option_any,
2034            None,
2035            Some(PositionId::new("P-OPT-001")),
2036            Some(Price::from("5.50")),
2037            None,
2038            None,
2039            None,
2040            None,
2041            Some(AccountId::from("SIM-001")),
2042        );
2043
2044        let fill_owned: crate::events::OrderFilled = fill.into();
2045        let pnls = margin_account
2046            .calculate_pnls(&option_any, &fill_owned, None)
2047            .unwrap();
2048
2049        // BUY option = pay premium (negative PnL)
2050        // 10 contracts * $5.50 = $55.00 premium paid
2051        assert_eq!(pnls.len(), 1);
2052        assert_eq!(pnls[0], Money::from("-55 USD"));
2053    }
2054
2055    #[rstest]
2056    fn test_calculate_pnls_for_option_sell_realizes_premium(margin_account: MarginAccount) {
2057        let option = option_contract_appl();
2058        let option_any = InstrumentAny::OptionContract(option.clone());
2059
2060        let order = OrderTestBuilder::new(OrderType::Market)
2061            .instrument_id(option.id)
2062            .side(OrderSide::Sell)
2063            .quantity(Quantity::from("10"))
2064            .build();
2065
2066        let fill = TestOrderEventStubs::filled(
2067            &order,
2068            &option_any,
2069            None,
2070            Some(PositionId::new("P-OPT-002")),
2071            Some(Price::from("5.50")),
2072            None,
2073            None,
2074            None,
2075            None,
2076            Some(AccountId::from("SIM-001")),
2077        );
2078
2079        let fill_owned: crate::events::OrderFilled = fill.into();
2080        let pnls = margin_account
2081            .calculate_pnls(&option_any, &fill_owned, None)
2082            .unwrap();
2083
2084        // SELL option = receive premium (positive PnL)
2085        // 10 contracts * $5.50 = $55.00 premium received
2086        assert_eq!(pnls.len(), 1);
2087        assert_eq!(pnls[0], Money::from("55 USD"));
2088    }
2089
2090    #[rstest]
2091    #[case(OrderSide::Buy, "-0.002 BTC")]
2092    #[case(OrderSide::Sell, "0.002 BTC")]
2093    fn test_calculate_pnls_for_inverse_option_realizes_premium_in_base(
2094        margin_account: MarginAccount,
2095        mut crypto_option_btc_deribit: CryptoOption,
2096        #[case] side: OrderSide,
2097        #[case] expected: &str,
2098    ) {
2099        crypto_option_btc_deribit.is_inverse = true;
2100        crypto_option_btc_deribit.multiplier = Quantity::from("0.01");
2101        let option = InstrumentAny::CryptoOption(crypto_option_btc_deribit);
2102        let order = OrderTestBuilder::new(OrderType::Market)
2103            .instrument_id(option.id())
2104            .side(side)
2105            .quantity(Quantity::from("10.0"))
2106            .build();
2107        let fill = TestOrderEventStubs::filled(
2108            &order,
2109            &option,
2110            None,
2111            Some(PositionId::new("P-OPT-003")),
2112            Some(Price::from("0.020")),
2113            None,
2114            None,
2115            None,
2116            None,
2117            Some(AccountId::from("SIM-001")),
2118        );
2119
2120        let pnls = margin_account
2121            .calculate_pnls(&option, &fill.into(), None)
2122            .unwrap();
2123
2124        assert_eq!(pnls, vec![Money::from(expected)]);
2125    }
2126
2127    #[rstest]
2128    fn test_calculate_pnls_for_binary_option(margin_account: MarginAccount) {
2129        let binary = binary_option();
2130        let binary_any = InstrumentAny::BinaryOption(binary);
2131
2132        let order = OrderTestBuilder::new(OrderType::Market)
2133            .instrument_id(binary_any.id())
2134            .side(OrderSide::Buy)
2135            .quantity(Quantity::from("100"))
2136            .build();
2137
2138        let fill = TestOrderEventStubs::filled(
2139            &order,
2140            &binary_any,
2141            None,
2142            Some(PositionId::new("P-BIN-001")),
2143            Some(Price::from("0.65")),
2144            None,
2145            None,
2146            None,
2147            None,
2148            Some(AccountId::from("SIM-001")),
2149        );
2150
2151        let fill_owned: crate::events::OrderFilled = fill.into();
2152        let pnls = margin_account
2153            .calculate_pnls(&binary_any, &fill_owned, None)
2154            .unwrap();
2155
2156        assert_eq!(pnls.len(), 1);
2157        assert!(pnls[0].as_f64() < 0.0);
2158    }
2159
2160    #[rstest]
2161    fn test_commission_explicit_schedule_override(
2162        margin_account: MarginAccount,
2163        currency_pair_btcusdt: CurrencyPair,
2164    ) {
2165        let mut schedule = MakerTakerFeeSchedule::new(dec!(0.002), dec!(0.002));
2166        schedule.set_override(
2167            currency_pair_btcusdt.id,
2168            MakerTakerFeeRates::new(dec!(0.0005), dec!(0.0005)),
2169        );
2170        let fee_rates = schedule.rates_for(currency_pair_btcusdt.id);
2171        let result = margin_account
2172            .calculate_commission(
2173                &currency_pair_btcusdt.into_any(),
2174                Quantity::from("1"),
2175                Price::from("50000.00"),
2176                LiquiditySide::Maker,
2177                fee_rates,
2178                None,
2179            )
2180            .unwrap();
2181        // Override (0.0005) wins over the schedule default (0.002) and the
2182        // instrument's own 0.001 fee: 50000 * 0.0005 = 25 USDT.
2183        assert_eq!(result, Money::from("25 USDT"));
2184    }
2185
2186    #[rstest]
2187    fn test_commission_shared_schedule_mutation(
2188        margin_account: MarginAccount,
2189        currency_pair_btcusdt: CurrencyPair,
2190    ) {
2191        let instrument_id = currency_pair_btcusdt.id;
2192        let mut schedule = MakerTakerFeeSchedule::new(dec!(0.001), dec!(0.001));
2193        let before = margin_account
2194            .calculate_commission(
2195                &currency_pair_btcusdt.clone().into_any(),
2196                Quantity::from("2"),
2197                Price::from("50000.00"),
2198                LiquiditySide::Taker,
2199                schedule.rates_for(instrument_id),
2200                None,
2201            )
2202            .unwrap();
2203        assert_eq!(before, Money::from("100 USDT"));
2204
2205        schedule.default = MakerTakerFeeRates::new(dec!(0.002), dec!(0.002));
2206        let after = margin_account
2207            .calculate_commission(
2208                &currency_pair_btcusdt.into_any(),
2209                Quantity::from("2"),
2210                Price::from("50000.00"),
2211                LiquiditySide::Taker,
2212                schedule.rates_for(instrument_id),
2213                None,
2214            )
2215            .unwrap();
2216        assert_eq!(after, Money::from("200 USDT"));
2217    }
2218}