Skip to main content

nautilus_model/python/account/
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//! Python bindings for margin model types.
17
18use std::any::Any;
19
20use nautilus_core::python::{clone_py_object, to_pynotimplemented_err, to_pytype_err};
21use pyo3::{
22    IntoPyObjectExt,
23    prelude::*,
24    types::{PyDict, PyTuple},
25};
26use rust_decimal::Decimal;
27
28use crate::{
29    accounts::margin_model::{
30        LeveragedMarginModel, MarginModel, MarginModelAny, StandardMarginModel,
31    },
32    instruments::{
33        BettingInstrument, BinaryOption, Cfd, Commodity, CryptoFuture, CryptoFuturesSpread,
34        CryptoOption, CryptoOptionSpread, CryptoPerpetual, CurrencyPair, Equity, FuturesContract,
35        FuturesSpread, IndexInstrument, Instrument, InstrumentAny, OptionContract, OptionSpread,
36        PerpetualContract, TokenizedAsset,
37    },
38    python::instruments::instrument_any_to_pyobject,
39    types::{Money, Price, Quantity},
40};
41
42/// Base class for Python-defined margin models.
43#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")]
44#[pyclass(module = "nautilus_trader.model", name = "MarginModel", subclass)]
45#[derive(Debug)]
46pub struct PyMarginModel;
47
48#[pyo3_stub_gen::derive::gen_stub_pymethods]
49#[pymethods]
50impl PyMarginModel {
51    #[new]
52    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
53    #[pyo3(signature = (*_args, **_kwargs))]
54    fn py_new(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> Self {
55        Self
56    }
57
58    /// Returns the initial margin requirement as `Money`.
59    #[pyo3(signature = (instrument, quantity, price, leverage, use_quote_for_inverse=None))]
60    fn calculate_initial_margin(
61        &self,
62        instrument: &Bound<'_, PyAny>,
63        quantity: Quantity,
64        price: Price,
65        leverage: Decimal,
66        use_quote_for_inverse: Option<bool>,
67    ) -> PyResult<Money> {
68        let _ = (instrument, quantity, price, leverage, use_quote_for_inverse);
69        Err(to_pynotimplemented_err(
70            "Method 'calculate_initial_margin' must be implemented in a subclass.",
71        ))
72    }
73
74    /// Returns the maintenance margin requirement as `Money`.
75    #[pyo3(signature = (instrument, quantity, price, leverage, use_quote_for_inverse=None))]
76    fn calculate_maintenance_margin(
77        &self,
78        instrument: &Bound<'_, PyAny>,
79        quantity: Quantity,
80        price: Price,
81        leverage: Decimal,
82        use_quote_for_inverse: Option<bool>,
83    ) -> PyResult<Money> {
84        let _ = (instrument, quantity, price, leverage, use_quote_for_inverse);
85        Err(to_pynotimplemented_err(
86            "Method 'calculate_maintenance_margin' must be implemented in a subclass.",
87        ))
88    }
89}
90
91/// Retains the Python instance used by margin accounts and configuration clones.
92#[derive(Debug)]
93pub struct PythonMarginModel {
94    obj: Py<PyAny>,
95}
96
97impl Clone for PythonMarginModel {
98    fn clone(&self) -> Self {
99        Self {
100            obj: clone_py_object(&self.obj),
101        }
102    }
103}
104
105impl MarginModel for PythonMarginModel {
106    fn name(&self) -> &'static str {
107        "python"
108    }
109
110    fn calculate_initial_margin(
111        &self,
112        instrument: &dyn Instrument,
113        quantity: Quantity,
114        price: Price,
115        leverage: Decimal,
116        use_quote_for_inverse: Option<bool>,
117    ) -> anyhow::Result<Money> {
118        self.calculate(
119            "calculate_initial_margin",
120            instrument,
121            quantity,
122            price,
123            leverage,
124            use_quote_for_inverse,
125        )
126    }
127
128    fn calculate_maintenance_margin(
129        &self,
130        instrument: &dyn Instrument,
131        quantity: Quantity,
132        price: Price,
133        leverage: Decimal,
134        use_quote_for_inverse: Option<bool>,
135    ) -> anyhow::Result<Money> {
136        self.calculate(
137            "calculate_maintenance_margin",
138            instrument,
139            quantity,
140            price,
141            leverage,
142            use_quote_for_inverse,
143        )
144    }
145}
146
147impl PythonMarginModel {
148    fn calculate(
149        &self,
150        method: &str,
151        instrument: &dyn Instrument,
152        quantity: Quantity,
153        price: Price,
154        leverage: Decimal,
155        use_quote_for_inverse: Option<bool>,
156    ) -> anyhow::Result<Money> {
157        Python::attach(|py| -> PyResult<Money> {
158            let instrument = instrument_to_pyobject(py, instrument)?;
159            self.obj
160                .bind(py)
161                .call_method1(
162                    method,
163                    (instrument, quantity, price, leverage, use_quote_for_inverse),
164                )?
165                .extract::<Money>()
166                .map_err(Into::into)
167        })
168        .map_err(|e| anyhow::anyhow!("Python MarginModel.{method} failed: {e}"))
169    }
170}
171
172fn instrument_to_pyobject(py: Python<'_>, instrument: &dyn Instrument) -> PyResult<Py<PyAny>> {
173    let instrument = instrument as &dyn Any;
174    if let Some(instrument) = instrument.downcast_ref::<InstrumentAny>() {
175        return instrument_any_to_pyobject(py, instrument.clone());
176    }
177
178    macro_rules! convert {
179        ($($kind:ty),+ $(,)?) => {
180            $(if let Some(instrument) = instrument.downcast_ref::<$kind>() {
181                return instrument.clone().into_py_any(py);
182            })+
183        };
184    }
185
186    convert!(
187        BettingInstrument,
188        BinaryOption,
189        Cfd,
190        Commodity,
191        CryptoFuture,
192        CryptoFuturesSpread,
193        CryptoOption,
194        CryptoOptionSpread,
195        CryptoPerpetual,
196        CurrencyPair,
197        Equity,
198        FuturesContract,
199        FuturesSpread,
200        IndexInstrument,
201        OptionContract,
202        OptionSpread,
203        PerpetualContract,
204        TokenizedAsset,
205    );
206    Err(to_pytype_err(
207        "Unsupported instrument type for Python MarginModel",
208    ))
209}
210
211/// Extracts a built-in or Python margin model, retaining Python instance state.
212///
213/// # Errors
214///
215/// Returns an error if either margin calculation method is missing or not callable.
216pub fn pyobject_to_margin_model_any(obj: &Bound<'_, PyAny>) -> PyResult<MarginModelAny> {
217    if let Ok(model) = obj.extract::<StandardMarginModel>() {
218        return Ok(MarginModelAny::Standard(model));
219    }
220
221    if let Ok(model) = obj.extract::<LeveragedMarginModel>() {
222        return Ok(MarginModelAny::Leveraged(model));
223    }
224
225    for method in ["calculate_initial_margin", "calculate_maintenance_margin"] {
226        if !obj.hasattr(method)? || !obj.getattr(method)?.is_callable() {
227            let type_name = obj.get_type().name()?;
228            return Err(to_pytype_err(format!(
229                "Cannot convert {type_name} to MarginModel"
230            )));
231        }
232    }
233
234    Ok(MarginModelAny::Python(PythonMarginModel {
235        obj: obj.clone().unbind(),
236    }))
237}
238
239/// Converts a margin model into its Python object without copying Python state.
240///
241/// # Errors
242///
243/// Returns an error if a built-in model cannot be converted to Python.
244pub fn margin_model_any_to_pyobject(py: Python<'_>, model: &MarginModelAny) -> PyResult<Py<PyAny>> {
245    match model {
246        MarginModelAny::Standard(model) => (*model).into_py_any(py),
247        MarginModelAny::Leveraged(model) => (*model).into_py_any(py),
248        MarginModelAny::Python(model) => Ok(model.obj.clone_ref(py)),
249    }
250}
251
252#[pymethods]
253#[pyo3_stub_gen::derive::gen_stub_pymethods]
254impl StandardMarginModel {
255    /// Uses fixed margin percentages without leverage division.
256    ///
257    /// Margin is calculated as `notional_value * margin_rate`, ignoring the
258    /// account leverage. Appropriate for traditional brokers where margin
259    /// requirements are fixed percentages of notional value.
260    #[new]
261    fn py_new() -> Self {
262        Self
263    }
264
265    fn __repr__(&self) -> String {
266        format!("{self:?}")
267    }
268}
269
270#[pymethods]
271#[pyo3_stub_gen::derive::gen_stub_pymethods]
272impl LeveragedMarginModel {
273    /// Divides notional value by leverage before applying margin rates.
274    ///
275    /// Margin is calculated as `(notional_value / leverage) * margin_rate`.
276    /// This is the default model, appropriate for crypto exchanges and venues
277    /// where leverage directly reduces margin requirements.
278    #[new]
279    fn py_new() -> Self {
280        Self
281    }
282
283    fn __repr__(&self) -> String {
284        format!("{self:?}")
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use pyo3::ffi::c_str;
291    use rstest::rstest;
292    use rust_decimal_macros::dec;
293
294    use super::*;
295    use crate::{
296        accounts::{MarginAccount, margin_model::MarginModelHandle},
297        events::account::stubs::margin_account_state,
298        instruments::stubs::*,
299        python::instruments::pyobject_to_instrument_any,
300    };
301
302    #[rstest]
303    #[case(None)]
304    #[case(Some(false))]
305    #[case(Some(true))]
306    fn test_python_margin_model_account_clones_retain_state_and_exact_inputs(
307        #[case] use_quote: Option<bool>,
308    ) {
309        Python::initialize();
310        Python::attach(|py| {
311            let locals = PyDict::new(py);
312            locals
313                .set_item("MarginModel", py.get_type::<PyMarginModel>())
314                .unwrap();
315            py.run(
316                c_str!(
317                    "\
318class CustomMargin(MarginModel):
319    def __init__(self):
320        self.calls = []
321    def calculate_initial_margin(self, *args):
322        self.calls.append(('initial', args))
323        return self.initial
324    def calculate_maintenance_margin(self, *args):
325        self.calls.append(('maintenance', args))
326        return self.maintenance
327model = CustomMargin()
328"
329                ),
330                Some(&locals),
331                None,
332            )
333            .unwrap();
334            let model = locals.get_item("model").unwrap().unwrap();
335            model.setattr("initial", Money::from("123.45 USD")).unwrap();
336            model
337                .setattr("maintenance", Money::from("67.89 USD"))
338                .unwrap();
339            let configured = pyobject_to_margin_model_any(&model).unwrap();
340            let cloned = configured.clone();
341            let retained = margin_model_any_to_pyobject(py, &cloned).unwrap();
342            let instrument = audusd_sim();
343            let mut account = MarginAccount::new(margin_account_state(), true);
344            account.set_default_leverage(dec!(9));
345            account.set_leverage(instrument.id(), dec!(3.25));
346            account.set_margin_model(configured.into());
347            let account = account.clone();
348            let quantity = Quantity::from("12345");
349            let price = Price::from("0.81234");
350            let initial = account
351                .calculate_initial_margin(&instrument, quantity, price, use_quote)
352                .unwrap();
353            let maintenance = account
354                .calculate_maintenance_margin(&instrument, quantity, price, use_quote)
355                .unwrap();
356            let calls = model.getattr("calls").unwrap();
357
358            assert!(retained.bind(py).is(&model));
359            assert_eq!(account.margin_model().name(), "python");
360            assert_eq!(initial, Money::from("123.45 USD"));
361            assert_eq!(maintenance, Money::from("67.89 USD"));
362            assert_eq!(calls.len().unwrap(), 2);
363
364            for (index, method) in ["initial", "maintenance"].iter().enumerate() {
365                let call = calls.get_item(index).unwrap();
366                assert_eq!(
367                    call.get_item(0).unwrap().extract::<String>().unwrap(),
368                    *method
369                );
370                let args = call.get_item(1).unwrap();
371                assert_eq!(args.len().unwrap(), 5);
372                assert_eq!(
373                    args.get_item(0).unwrap().extract::<CurrencyPair>().unwrap(),
374                    instrument
375                );
376                assert_eq!(
377                    args.get_item(1).unwrap().extract::<Quantity>().unwrap(),
378                    quantity
379                );
380                assert_eq!(args.get_item(2).unwrap().extract::<Price>().unwrap(), price);
381                assert_eq!(
382                    args.get_item(3).unwrap().extract::<Decimal>().unwrap(),
383                    dec!(3.25)
384                );
385                assert_eq!(
386                    args.get_item(4).unwrap().extract::<Option<bool>>().unwrap(),
387                    use_quote
388                );
389            }
390        });
391    }
392
393    #[rstest]
394    #[case(InstrumentAny::Betting(betting()))]
395    #[case(InstrumentAny::BinaryOption(binary_option()))]
396    #[case(InstrumentAny::Cfd(cfd_gold()))]
397    #[case(InstrumentAny::Commodity(commodity_gold()))]
398    #[case(InstrumentAny::CryptoFuture(crypto_future_btcusdt(
399        2,
400        6,
401        Price::from("0.01"),
402        Quantity::from("0.000001")
403    )))]
404    #[case(InstrumentAny::CryptoFuturesSpread(crypto_futures_spread_btc_deribit()))]
405    #[case(InstrumentAny::CryptoOption(crypto_option_btc_deribit(
406        3,
407        1,
408        Price::from("0.001"),
409        Quantity::from("0.1")
410    )))]
411    #[case(InstrumentAny::CryptoOptionSpread(crypto_option_spread_btc_deribit()))]
412    #[case(InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt()))]
413    #[case(InstrumentAny::CurrencyPair(audusd_sim()))]
414    #[case(InstrumentAny::Equity(equity_aapl()))]
415    #[case(InstrumentAny::FuturesContract(futures_contract_es(None, None)))]
416    #[case(InstrumentAny::FuturesSpread(futures_spread_es()))]
417    #[case(InstrumentAny::IndexInstrument(index_instrument_spx()))]
418    #[case(InstrumentAny::OptionContract(option_contract_appl()))]
419    #[case(InstrumentAny::OptionSpread(option_spread()))]
420    #[case(InstrumentAny::PerpetualContract(perpetual_contract_eurusd()))]
421    #[case(InstrumentAny::TokenizedAsset(tokenized_asset_aaplx()))]
422    fn test_margin_callback_instrument_conversion_preserves_all_fields(
423        #[case] instrument: InstrumentAny,
424    ) {
425        Python::initialize();
426        Python::attach(|py| {
427            let concrete: &dyn Instrument = match &instrument {
428                InstrumentAny::Betting(i) => i,
429                InstrumentAny::BinaryOption(i) => i,
430                InstrumentAny::Cfd(i) => i,
431                InstrumentAny::Commodity(i) => i,
432                InstrumentAny::CryptoFuture(i) => i,
433                InstrumentAny::CryptoFuturesSpread(i) => i,
434                InstrumentAny::CryptoOption(i) => i,
435                InstrumentAny::CryptoOptionSpread(i) => i,
436                InstrumentAny::CryptoPerpetual(i) => i,
437                InstrumentAny::CurrencyPair(i) => i,
438                InstrumentAny::Equity(i) => i,
439                InstrumentAny::FuturesContract(i) => i,
440                InstrumentAny::FuturesSpread(i) => i,
441                InstrumentAny::IndexInstrument(i) => i,
442                InstrumentAny::OptionContract(i) => i,
443                InstrumentAny::OptionSpread(i) => i,
444                InstrumentAny::PerpetualContract(i) => i,
445                InstrumentAny::TokenizedAsset(i) => i,
446            };
447
448            for borrowed in [&instrument as &dyn Instrument, concrete] {
449                let obj = instrument_to_pyobject(py, borrowed).unwrap();
450                let converted = pyobject_to_instrument_any(py, obj).unwrap();
451                assert_eq!(
452                    serde_json::to_value(&converted).unwrap(),
453                    serde_json::to_value(&instrument).unwrap()
454                );
455            }
456        });
457    }
458
459    #[rstest]
460    fn test_python_margin_model_handle_is_send_sync_and_runs_on_another_thread() {
461        fn assert_send_sync<T: Send + Sync>() {}
462        assert_send_sync::<MarginModelHandle>();
463        assert_send_sync::<MarginModelAny>();
464        assert_send_sync::<PythonMarginModel>();
465        Python::initialize();
466
467        let handle = Python::attach(|py| {
468            let locals = PyDict::new(py);
469            locals.set_item("result", Money::from("45.67 USD")).unwrap();
470            let model = py.eval(c_str!("type('Margin', (), {'calculate_initial_margin': lambda self, *args: result, 'calculate_maintenance_margin': lambda self, *args: result})()"), Some(&locals), None).unwrap();
471            MarginModelHandle::from(pyobject_to_margin_model_any(&model).unwrap())
472        });
473
474        let result = std::thread::spawn(move || {
475            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
476            handle
477                .calculate_initial_margin(
478                    &instrument,
479                    Quantity::from(1),
480                    Price::from("0.8"),
481                    dec!(2.5),
482                    Some(true),
483                )
484                .unwrap()
485        })
486        .join()
487        .unwrap();
488
489        assert_eq!(result, Money::from("45.67 USD"));
490    }
491}