Skip to main content

nautilus_model/accounts/
margin_model.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//! Pluggable margin calculation models for [`MarginAccount`](super::MarginAccount).
17
18use std::{fmt::Debug, sync::Arc};
19
20use rust_decimal::Decimal;
21
22use crate::{
23    instruments::Instrument,
24    types::{Money, Price, Quantity},
25};
26
27/// Determines how margin requirements are calculated for leveraged positions.
28pub trait MarginModel: Send + Sync {
29    /// Returns the stable model name used in canonical backtest results.
30    #[must_use]
31    fn name(&self) -> &'static str;
32
33    /// Calculates the initial (order) margin requirement.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if margin cannot be computed (e.g. invalid instrument).
38    fn calculate_initial_margin(
39        &self,
40        instrument: &dyn Instrument,
41        quantity: Quantity,
42        price: Price,
43        leverage: Decimal,
44        use_quote_for_inverse: Option<bool>,
45    ) -> anyhow::Result<Money>;
46
47    /// Calculates the maintenance (position) margin requirement.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if margin cannot be computed (e.g. invalid instrument).
52    fn calculate_maintenance_margin(
53        &self,
54        instrument: &dyn Instrument,
55        quantity: Quantity,
56        price: Price,
57        leverage: Decimal,
58        use_quote_for_inverse: Option<bool>,
59    ) -> anyhow::Result<Money>;
60}
61
62/// Shared runtime handle for a margin model.
63#[derive(Clone)]
64pub struct MarginModelHandle(Arc<dyn MarginModel>);
65
66impl MarginModelHandle {
67    /// Creates a new [`MarginModelHandle`] from a margin model.
68    #[must_use]
69    pub fn new<T>(model: T) -> Self
70    where
71        T: MarginModel + 'static,
72    {
73        Self(Arc::new(model))
74    }
75
76    /// Creates a new [`MarginModelHandle`] from an existing atomically reference-counted model.
77    #[must_use]
78    pub fn from_arc(model: Arc<dyn MarginModel>) -> Self {
79        Self(model)
80    }
81}
82
83impl Debug for MarginModelHandle {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_tuple(stringify!(MarginModelHandle))
86            .field(&"<dyn MarginModel>")
87            .finish()
88    }
89}
90
91impl MarginModel for MarginModelHandle {
92    fn name(&self) -> &'static str {
93        self.0.name()
94    }
95
96    fn calculate_initial_margin(
97        &self,
98        instrument: &dyn Instrument,
99        quantity: Quantity,
100        price: Price,
101        leverage: Decimal,
102        use_quote_for_inverse: Option<bool>,
103    ) -> anyhow::Result<Money> {
104        self.0.calculate_initial_margin(
105            instrument,
106            quantity,
107            price,
108            leverage,
109            use_quote_for_inverse,
110        )
111    }
112
113    fn calculate_maintenance_margin(
114        &self,
115        instrument: &dyn Instrument,
116        quantity: Quantity,
117        price: Price,
118        leverage: Decimal,
119        use_quote_for_inverse: Option<bool>,
120    ) -> anyhow::Result<Money> {
121        self.0.calculate_maintenance_margin(
122            instrument,
123            quantity,
124            price,
125            leverage,
126            use_quote_for_inverse,
127        )
128    }
129}
130
131/// Enum dispatch for [`MarginModel`] implementations.
132#[derive(Debug, Clone)]
133pub enum MarginModelAny {
134    Standard(StandardMarginModel),
135    Leveraged(LeveragedMarginModel),
136}
137
138impl MarginModel for MarginModelAny {
139    fn name(&self) -> &'static str {
140        match self {
141            Self::Standard(model) => model.name(),
142            Self::Leveraged(model) => model.name(),
143        }
144    }
145
146    fn calculate_initial_margin(
147        &self,
148        instrument: &dyn Instrument,
149        quantity: Quantity,
150        price: Price,
151        leverage: Decimal,
152        use_quote_for_inverse: Option<bool>,
153    ) -> anyhow::Result<Money> {
154        match self {
155            Self::Standard(m) => m.calculate_initial_margin(
156                instrument,
157                quantity,
158                price,
159                leverage,
160                use_quote_for_inverse,
161            ),
162            Self::Leveraged(m) => m.calculate_initial_margin(
163                instrument,
164                quantity,
165                price,
166                leverage,
167                use_quote_for_inverse,
168            ),
169        }
170    }
171
172    fn calculate_maintenance_margin(
173        &self,
174        instrument: &dyn Instrument,
175        quantity: Quantity,
176        price: Price,
177        leverage: Decimal,
178        use_quote_for_inverse: Option<bool>,
179    ) -> anyhow::Result<Money> {
180        match self {
181            Self::Standard(m) => m.calculate_maintenance_margin(
182                instrument,
183                quantity,
184                price,
185                leverage,
186                use_quote_for_inverse,
187            ),
188            Self::Leveraged(m) => m.calculate_maintenance_margin(
189                instrument,
190                quantity,
191                price,
192                leverage,
193                use_quote_for_inverse,
194            ),
195        }
196    }
197}
198
199impl Default for MarginModelAny {
200    fn default() -> Self {
201        Self::Leveraged(LeveragedMarginModel)
202    }
203}
204
205impl Default for MarginModelHandle {
206    fn default() -> Self {
207        MarginModelAny::default().into()
208    }
209}
210
211impl From<MarginModelAny> for MarginModelHandle {
212    fn from(model: MarginModelAny) -> Self {
213        Self::new(model)
214    }
215}
216
217/// Resolves the margin currency based on instrument properties.
218fn margin_currency(
219    instrument: &dyn Instrument,
220    use_quote_for_inverse: bool,
221) -> anyhow::Result<crate::types::Currency> {
222    if instrument.is_inverse() && !use_quote_for_inverse {
223        instrument.base_currency().ok_or_else(|| {
224            anyhow::anyhow!(
225                "Inverse instrument {} has no base currency",
226                instrument.id()
227            )
228        })
229    } else {
230        Ok(instrument.quote_currency())
231    }
232}
233
234/// Uses fixed margin percentages without leverage division.
235///
236/// Margin is calculated as `notional_value * margin_rate`, ignoring the
237/// account leverage. Appropriate for traditional brokers where margin
238/// requirements are fixed percentages of notional value.
239#[derive(Debug, Clone, Copy)]
240#[cfg_attr(
241    feature = "python",
242    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
243)]
244#[cfg_attr(
245    feature = "python",
246    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
247)]
248pub struct StandardMarginModel;
249
250impl MarginModel for StandardMarginModel {
251    fn name(&self) -> &'static str {
252        "standard"
253    }
254
255    fn calculate_initial_margin(
256        &self,
257        instrument: &dyn Instrument,
258        quantity: Quantity,
259        price: Price,
260        _leverage: Decimal,
261        use_quote_for_inverse: Option<bool>,
262    ) -> anyhow::Result<Money> {
263        let use_quote = use_quote_for_inverse.unwrap_or(false);
264        let notional = instrument.try_calculate_notional_value(quantity, price, Some(use_quote))?;
265        // Spreads and options may quote negative, which carries the sign into the notional.
266        // A requirement is a reserve against exposure magnitude, so take it on `abs`.
267        let margin = notional
268            .as_decimal()
269            .abs()
270            .checked_mul(instrument.margin_init())
271            .ok_or_else(|| anyhow::anyhow!("initial margin calculation overflow"))?;
272        let currency = margin_currency(instrument, use_quote)?;
273        Money::from_decimal(margin, currency).map_err(Into::into)
274    }
275
276    fn calculate_maintenance_margin(
277        &self,
278        instrument: &dyn Instrument,
279        quantity: Quantity,
280        price: Price,
281        _leverage: Decimal,
282        use_quote_for_inverse: Option<bool>,
283    ) -> anyhow::Result<Money> {
284        let use_quote = use_quote_for_inverse.unwrap_or(false);
285        let notional = instrument.try_calculate_notional_value(quantity, price, Some(use_quote))?;
286        let margin = notional
287            .as_decimal()
288            .abs()
289            .checked_mul(instrument.margin_maint())
290            .ok_or_else(|| anyhow::anyhow!("maintenance margin calculation overflow"))?;
291        let currency = margin_currency(instrument, use_quote)?;
292        Money::from_decimal(margin, currency).map_err(Into::into)
293    }
294}
295
296/// Divides notional value by leverage before applying margin rates.
297///
298/// Margin is calculated as `(notional_value / leverage) * margin_rate`.
299/// This is the default model, appropriate for crypto exchanges and venues
300/// where leverage directly reduces margin requirements.
301#[derive(Debug, Clone, Copy)]
302#[cfg_attr(
303    feature = "python",
304    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
305)]
306#[cfg_attr(
307    feature = "python",
308    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
309)]
310pub struct LeveragedMarginModel;
311
312impl MarginModel for LeveragedMarginModel {
313    fn name(&self) -> &'static str {
314        "leveraged"
315    }
316
317    fn calculate_initial_margin(
318        &self,
319        instrument: &dyn Instrument,
320        quantity: Quantity,
321        price: Price,
322        leverage: Decimal,
323        use_quote_for_inverse: Option<bool>,
324    ) -> anyhow::Result<Money> {
325        if leverage <= Decimal::ZERO {
326            anyhow::bail!("Invalid leverage {leverage} for {}", instrument.id());
327        }
328        let use_quote = use_quote_for_inverse.unwrap_or(false);
329        let notional = instrument.try_calculate_notional_value(quantity, price, Some(use_quote))?;
330        let margin = notional
331            .as_decimal()
332            .abs()
333            .checked_div(leverage)
334            .and_then(|adjusted| adjusted.checked_mul(instrument.margin_init()))
335            .ok_or_else(|| anyhow::anyhow!("initial margin calculation overflow"))?;
336        let currency = margin_currency(instrument, use_quote)?;
337        Money::from_decimal(margin, currency).map_err(Into::into)
338    }
339
340    fn calculate_maintenance_margin(
341        &self,
342        instrument: &dyn Instrument,
343        quantity: Quantity,
344        price: Price,
345        leverage: Decimal,
346        use_quote_for_inverse: Option<bool>,
347    ) -> anyhow::Result<Money> {
348        if leverage <= Decimal::ZERO {
349            anyhow::bail!("Invalid leverage {leverage} for {}", instrument.id());
350        }
351        let use_quote = use_quote_for_inverse.unwrap_or(false);
352        let notional = instrument.try_calculate_notional_value(quantity, price, Some(use_quote))?;
353        let margin = notional
354            .as_decimal()
355            .abs()
356            .checked_div(leverage)
357            .and_then(|adjusted| adjusted.checked_mul(instrument.margin_maint()))
358            .ok_or_else(|| anyhow::anyhow!("maintenance margin calculation overflow"))?;
359        let currency = margin_currency(instrument, use_quote)?;
360        Money::from_decimal(margin, currency).map_err(Into::into)
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use rstest::rstest;
367    use rust_decimal::Decimal;
368    use rust_decimal_macros::dec;
369    use ustr::Ustr;
370
371    use super::*;
372    use crate::{
373        enums::AssetClass,
374        identifiers::{InstrumentId, Symbol},
375        instruments::{
376            CryptoPerpetual, FuturesSpread, Instrument, stubs::crypto_perpetual_ethusdt,
377        },
378        types::{Currency, Price, Quantity},
379    };
380
381    struct FixedMarginModel {
382        initial: Money,
383        maintenance: Money,
384    }
385
386    impl MarginModel for FixedMarginModel {
387        fn name(&self) -> &'static str {
388            "fixed"
389        }
390
391        fn calculate_initial_margin(
392            &self,
393            _instrument: &dyn Instrument,
394            _quantity: Quantity,
395            _price: Price,
396            _leverage: Decimal,
397            _use_quote_for_inverse: Option<bool>,
398        ) -> anyhow::Result<Money> {
399            Ok(self.initial)
400        }
401
402        fn calculate_maintenance_margin(
403            &self,
404            _instrument: &dyn Instrument,
405            _quantity: Quantity,
406            _price: Price,
407            _leverage: Decimal,
408            _use_quote_for_inverse: Option<bool>,
409        ) -> anyhow::Result<Money> {
410            Ok(self.maintenance)
411        }
412    }
413
414    fn ethusdt() -> CryptoPerpetual {
415        crypto_perpetual_ethusdt()
416    }
417
418    #[rstest]
419    fn test_leveraged_initial_margin() {
420        let model = LeveragedMarginModel;
421        let instrument = ethusdt();
422        let quantity = Quantity::from("10.000");
423        let price = Price::from("5000.00");
424        let leverage = dec!(10);
425
426        let margin = model
427            .calculate_initial_margin(&instrument, quantity, price, leverage, None)
428            .unwrap();
429
430        // notional = 10 * 5000 = 50000, adjusted = 50000/10 = 5000
431        // margin = 5000 * margin_init
432        let expected = Decimal::from(50000) / leverage * instrument.margin_init();
433        assert_eq!(margin.as_decimal(), expected);
434        assert_eq!(margin.currency, Currency::USDT());
435    }
436
437    #[rstest]
438    fn test_standard_ignores_leverage() {
439        let model = StandardMarginModel;
440        let instrument = ethusdt();
441        let quantity = Quantity::from("10.000");
442        let price = Price::from("5000.00");
443
444        let margin_low = model
445            .calculate_initial_margin(&instrument, quantity, price, dec!(2), None)
446            .unwrap();
447        let margin_high = model
448            .calculate_initial_margin(&instrument, quantity, price, dec!(100), None)
449            .unwrap();
450
451        // StandardMarginModel ignores leverage so both should be equal
452        assert_eq!(margin_low, margin_high);
453    }
454
455    /// A spread carrying non-zero margin rates, so the assertions below cannot pass on a
456    /// zero requirement. `FuturesSpread` is one of the three classes permitting a negative
457    /// price (see `InstrumentClass::allows_negative_price`).
458    fn negative_price_spread() -> FuturesSpread {
459        FuturesSpread::builder()
460            .instrument_id(InstrumentId::from("ESM4-ESU4.GLBX"))
461            .raw_symbol(Symbol::from("ESM4-ESU4"))
462            .asset_class(AssetClass::Index)
463            .underlying(Ustr::from("ES"))
464            .strategy_type(Ustr::from("EQ"))
465            .activation_ns(1_000.into())
466            .expiration_ns(2_000.into())
467            .currency(Currency::USD())
468            .price_precision(2)
469            .price_increment(Price::from("0.01"))
470            .multiplier(Quantity::from(50))
471            .lot_size(Quantity::from(1))
472            .margin_init(dec!(0.01))
473            .margin_maint(dec!(0.02))
474            .ts_event(1.into())
475            .ts_init(2.into())
476            .build()
477            .unwrap()
478    }
479
480    #[rstest]
481    fn test_standard_margin_is_positive_for_a_negative_price() {
482        let model = StandardMarginModel;
483        let instrument = negative_price_spread();
484        let quantity = Quantity::from(2);
485        let positive = Price::from("2.00");
486        let negative = Price::from("-2.00");
487
488        let initial = model
489            .calculate_initial_margin(&instrument, quantity, negative, dec!(1), None)
490            .unwrap();
491        let maintenance = model
492            .calculate_maintenance_margin(&instrument, quantity, negative, dec!(1), None)
493            .unwrap();
494
495        // notional magnitude = 2 * 50 * 2.00 = 200
496        assert_eq!(initial.as_decimal(), dec!(2));
497        assert_eq!(maintenance.as_decimal(), dec!(4));
498        // A negative quote reserves the same as the equivalent positive one.
499        assert_eq!(
500            initial,
501            model
502                .calculate_initial_margin(&instrument, quantity, positive, dec!(1), None)
503                .unwrap()
504        );
505    }
506
507    #[rstest]
508    fn test_leveraged_margin_is_positive_for_a_negative_price() {
509        let model = LeveragedMarginModel;
510        let instrument = negative_price_spread();
511        let quantity = Quantity::from(2);
512        let negative = Price::from("-2.00");
513        let leverage = dec!(10);
514
515        let initial = model
516            .calculate_initial_margin(&instrument, quantity, negative, leverage, None)
517            .unwrap();
518        let maintenance = model
519            .calculate_maintenance_margin(&instrument, quantity, negative, leverage, None)
520            .unwrap();
521
522        // notional magnitude = 200, adjusted = 200 / 10 = 20
523        assert_eq!(initial.as_decimal(), dec!(0.2));
524        assert_eq!(maintenance.as_decimal(), dec!(0.4));
525    }
526
527    #[rstest]
528    fn test_leveraged_zero_leverage_errors() {
529        let model = LeveragedMarginModel;
530        let instrument = ethusdt();
531
532        let result = model.calculate_initial_margin(
533            &instrument,
534            Quantity::from("1.000"),
535            Price::from("5000.00"),
536            Decimal::ZERO,
537            None,
538        );
539
540        assert!(result.is_err());
541    }
542
543    #[rstest]
544    fn test_leveraged_margin_decimal_overflow_returns_error() {
545        let model = LeveragedMarginModel;
546        let instrument = ethusdt();
547
548        let result = model.calculate_initial_margin(
549            &instrument,
550            Quantity::from("1.000"),
551            Price::from("5000.00"),
552            Decimal::new(1, 28),
553            None,
554        );
555
556        assert_eq!(
557            result.unwrap_err().to_string(),
558            "initial margin calculation overflow"
559        );
560    }
561
562    #[rstest]
563    fn test_margin_model_any_default_is_leveraged() {
564        let model = MarginModelAny::default();
565        assert!(matches!(model, MarginModelAny::Leveraged(_)));
566        assert_eq!(model.name(), "leveraged");
567    }
568
569    #[rstest]
570    fn test_margin_model_handle_calls_custom_model() {
571        let initial = Money::from("12.34 USDT");
572        let maintenance = Money::from("5.67 USDT");
573        let model: Arc<dyn MarginModel> = Arc::new(FixedMarginModel {
574            initial,
575            maintenance,
576        });
577        let handle = MarginModelHandle::from_arc(model);
578        let cloned_handle = handle.clone();
579        drop(handle);
580        let instrument = ethusdt();
581
582        let initial_result = cloned_handle
583            .calculate_initial_margin(
584                &instrument,
585                Quantity::from("1.000"),
586                Price::from("5000.00"),
587                dec!(10),
588                None,
589            )
590            .unwrap();
591        let maintenance_result = cloned_handle
592            .calculate_maintenance_margin(
593                &instrument,
594                Quantity::from("1.000"),
595                Price::from("5000.00"),
596                dec!(10),
597                None,
598            )
599            .unwrap();
600
601        assert_eq!(cloned_handle.name(), "fixed");
602        assert_eq!(initial_result, initial);
603        assert_eq!(maintenance_result, maintenance);
604    }
605
606    #[rstest]
607    fn test_maintenance_margin() {
608        let model = LeveragedMarginModel;
609        let instrument = ethusdt();
610        let quantity = Quantity::from("10.000");
611        let price = Price::from("5000.00");
612        let leverage = dec!(10);
613
614        let margin = model
615            .calculate_maintenance_margin(&instrument, quantity, price, leverage, None)
616            .unwrap();
617
618        let expected = Decimal::from(50000) / leverage * instrument.margin_maint();
619        assert_eq!(margin.as_decimal(), expected);
620    }
621}