1use 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#[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 #[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 #[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#[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
211pub 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
239pub 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 #[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 #[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}