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