Skip to main content

nautilus_backtest/python/
modules.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 and native extractor registry for simulation module types.
17
18use std::sync::LazyLock;
19
20use ahash::AHashMap;
21use jiff::civil::{Time, Weekday};
22use nautilus_core::python::{
23    clone_py_object, to_pynotimplemented_err, to_pytype_err, to_pyvalue_err,
24};
25use nautilus_model::{
26    data::Data,
27    identifiers::{InstrumentId, Venue},
28    instruments::{Instrument, InstrumentAny},
29    orderbook::OrderBook,
30    position::Position,
31    python::{data::data_to_pyobject, instruments::instrument_any_to_pyobject},
32    types::{Currency, Money},
33};
34use parking_lot::Mutex;
35use pyo3::{
36    PyClass,
37    prelude::*,
38    types::{PyDict, PyTuple},
39};
40use rust_decimal::Decimal;
41
42use crate::modules::{
43    AccountAdjustmentOutcome, CfdSwapModule, CfdSwapRate, ExchangeContext,
44    FXRolloverInterestModule, SimulationModule, SimulationModuleAny, SimulationModuleHandle,
45    SimulationModuleResult, fx_rollover::InterestRateRecord,
46};
47
48/// Function pointer for extracting a linked native simulation module from Python.
49pub type SimulationModuleExtractor =
50    for<'py> fn(Python<'py>, &Bound<'py, PyAny>) -> PyResult<SimulationModuleHandle>;
51
52static SIMULATION_MODULE_EXTRACTORS: LazyLock<Mutex<AHashMap<usize, SimulationModuleExtractor>>> =
53    LazyLock::new(|| Mutex::new(AHashMap::new()));
54
55/// Registers an extractor for a linked native simulation module Python class.
56///
57/// Registering the same function for the same Python type more than once succeeds without change.
58///
59/// # Errors
60///
61/// Returns an error if a different extractor is already registered for `T`.
62pub fn register_simulation_module_extractor<T: PyClass>(
63    py: Python<'_>,
64    extractor: SimulationModuleExtractor,
65) -> anyhow::Result<()> {
66    let type_object = py.get_type::<T>();
67    let type_id = type_object.as_ptr() as usize;
68    let type_name = type_object.name()?;
69    let mut extractors = SIMULATION_MODULE_EXTRACTORS.lock();
70    if let Some(registered) = extractors.get(&type_id) {
71        if std::ptr::fn_addr_eq(*registered, extractor) {
72            return Ok(());
73        }
74        anyhow::bail!(
75            "A different simulation module extractor is already registered for '{type_name}'"
76        );
77    }
78    extractors.insert(type_id, extractor);
79    Ok(())
80}
81
82#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
83#[pyclass(
84    module = "nautilus_trader.backtest",
85    name = "SimulationModule",
86    subclass
87)]
88#[derive(Debug)]
89pub struct PySimulationModule;
90
91#[pyo3_stub_gen::derive::gen_stub_pymethods]
92#[pymethods]
93#[allow(
94    clippy::unused_self,
95    reason = "PyO3 exposes these hooks as overridable instance methods"
96)]
97impl PySimulationModule {
98    #[new]
99    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
100    #[pyo3(signature = (*_args, **_kwargs))]
101    fn py_new(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> Self {
102        Self
103    }
104
105    fn pre_process(&self, _data: &Bound<'_, PyAny>) {}
106
107    fn process(
108        &self,
109        _ts_now: u64,
110        _context: &PySimulationModuleContext,
111    ) -> PyResult<Option<Vec<Money>>> {
112        Err(to_pynotimplemented_err(
113            "Method 'process' must be implemented in a subclass.",
114        ))
115    }
116
117    fn acknowledge(&self, _outcomes: &Bound<'_, PyAny>) {}
118
119    fn log_diagnostics(&self) {}
120
121    fn reset(&self) {}
122}
123
124/// Read-only owned snapshot of the exchange state exposed to Python simulation modules.
125#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
126#[pyclass(
127    module = "nautilus_trader.backtest",
128    name = "SimulationModuleContext",
129    frozen,
130    unsendable
131)]
132#[derive(Debug)]
133pub struct PySimulationModuleContext {
134    venue: Venue,
135    base_currency: Option<Currency>,
136    instruments: Vec<InstrumentAny>,
137    order_books: Vec<OrderBook>,
138    positions: Vec<Position>,
139}
140
141impl PySimulationModuleContext {
142    fn from_exchange(ctx: &ExchangeContext<'_>) -> Self {
143        let mut instruments = ctx.instruments.values().cloned().collect::<Vec<_>>();
144        instruments.sort_unstable_by_key(Instrument::id);
145
146        let mut order_books = ctx
147            .matching_engines
148            .values()
149            .map(|engine| engine.get_book().clone())
150            .collect::<Vec<_>>();
151        order_books.sort_unstable_by_key(|book| book.instrument_id);
152
153        let mut positions = ctx
154            .cache
155            .positions_open(Some(&ctx.venue), None, None, None, None)
156            .into_iter()
157            .map(|position| (*position).clone())
158            .collect::<Vec<_>>();
159        positions.sort_unstable_by_key(|position| position.id);
160
161        Self {
162            venue: ctx.venue,
163            base_currency: ctx.base_currency,
164            instruments,
165            order_books,
166            positions,
167        }
168    }
169}
170
171#[pyo3_stub_gen::derive::gen_stub_pymethods]
172#[pymethods]
173impl PySimulationModuleContext {
174    #[getter]
175    fn venue(&self) -> Venue {
176        self.venue
177    }
178
179    #[getter]
180    fn base_currency(&self) -> Option<Currency> {
181        self.base_currency
182    }
183
184    #[getter]
185    fn instruments(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
186        self.instruments
187            .iter()
188            .cloned()
189            .map(|instrument| instrument_any_to_pyobject(py, instrument))
190            .collect()
191    }
192
193    #[getter]
194    fn order_books(&self) -> Vec<OrderBook> {
195        self.order_books.clone()
196    }
197
198    #[getter]
199    fn positions(&self) -> Vec<Position> {
200        self.positions.clone()
201    }
202}
203
204/// Read-only account adjustment result passed to Python module acknowledgements.
205#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
206#[pyclass(
207    module = "nautilus_trader.backtest",
208    name = "AccountAdjustmentOutcome",
209    frozen,
210    skip_from_py_object
211)]
212#[derive(Debug, Clone)]
213pub struct PyAccountAdjustmentOutcome {
214    applied: bool,
215    error: Option<String>,
216}
217
218impl From<&AccountAdjustmentOutcome> for PyAccountAdjustmentOutcome {
219    fn from(outcome: &AccountAdjustmentOutcome) -> Self {
220        match outcome {
221            AccountAdjustmentOutcome::Applied => Self {
222                applied: true,
223                error: None,
224            },
225            AccountAdjustmentOutcome::Failed(error) => Self {
226                applied: false,
227                error: Some(error.to_string()),
228            },
229        }
230    }
231}
232
233#[pyo3_stub_gen::derive::gen_stub_pymethods]
234#[pymethods]
235impl PyAccountAdjustmentOutcome {
236    #[getter]
237    const fn applied(&self) -> bool {
238        self.applied
239    }
240
241    #[getter]
242    fn error(&self) -> Option<String> {
243        self.error.clone()
244    }
245}
246
247#[derive(Debug)]
248pub struct PythonSimulationModule {
249    obj: Py<PyAny>,
250}
251
252impl Clone for PythonSimulationModule {
253    fn clone(&self) -> Self {
254        Self::new(clone_py_object(&self.obj))
255    }
256}
257
258impl PythonSimulationModule {
259    #[must_use]
260    pub const fn new(obj: Py<PyAny>) -> Self {
261        Self { obj }
262    }
263
264    pub(crate) fn clone_ref(&self, py: Python<'_>) -> Py<PyAny> {
265        self.obj.clone_ref(py)
266    }
267}
268
269impl SimulationModule for PythonSimulationModule {
270    fn pre_process(&self, data: &Data) -> anyhow::Result<()> {
271        Python::attach(|py| -> anyhow::Result<()> {
272            let data = data_to_pyobject(py, data.clone())?;
273            self.obj.bind(py).call_method1("pre_process", (data,))?;
274            Ok(())
275        })
276        .map_err(|e| anyhow::anyhow!("Python SimulationModule.pre_process failed: {e}"))
277    }
278
279    fn process(
280        &self,
281        ts_now: nautilus_core::UnixNanos,
282        ctx: &ExchangeContext,
283    ) -> anyhow::Result<SimulationModuleResult> {
284        Python::attach(|py| -> anyhow::Result<SimulationModuleResult> {
285            let context = Py::new(py, PySimulationModuleContext::from_exchange(ctx))?;
286            let adjustments = self
287                .obj
288                .bind(py)
289                .call_method1("process", (ts_now.as_u64(), context))?
290                .extract::<Option<Vec<Money>>>()?;
291            Ok(adjustments.map_or(
292                SimulationModuleResult::NotReady,
293                SimulationModuleResult::Completed,
294            ))
295        })
296        .map_err(|e| anyhow::anyhow!("Python SimulationModule.process failed: {e}"))
297    }
298
299    fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
300        Python::attach(|py| -> anyhow::Result<()> {
301            let outcomes = outcomes
302                .iter()
303                .map(PyAccountAdjustmentOutcome::from)
304                .collect::<Vec<_>>();
305            self.obj.bind(py).call_method1("acknowledge", (outcomes,))?;
306            Ok(())
307        })
308        .map_err(|e| anyhow::anyhow!("Python SimulationModule.acknowledge failed: {e}"))
309    }
310
311    fn log_diagnostics(&self) -> anyhow::Result<()> {
312        Python::attach(|py| -> anyhow::Result<()> {
313            self.obj.bind(py).call_method0("log_diagnostics")?;
314            Ok(())
315        })
316        .map_err(|e| anyhow::anyhow!("Python SimulationModule.log_diagnostics failed: {e}"))
317    }
318
319    fn reset(&self) -> anyhow::Result<()> {
320        Python::attach(|py| -> anyhow::Result<()> {
321            self.obj.bind(py).call_method0("reset")?;
322            Ok(())
323        })
324        .map_err(|e| anyhow::anyhow!("Python SimulationModule.reset failed: {e}"))
325    }
326}
327
328fn pyobject_to_builtin_simulation_module_any(
329    obj: &Bound<'_, PyAny>,
330) -> Option<SimulationModuleAny> {
331    if let Ok(module) = obj.extract::<PyRef<'_, CfdSwapModule>>() {
332        return Some(SimulationModuleAny::CfdSwap((*module).clone()));
333    }
334
335    if let Ok(module) = obj.extract::<PyRef<'_, FXRolloverInterestModule>>() {
336        return Some(SimulationModuleAny::FXRolloverInterest((*module).clone()));
337    }
338    None
339}
340
341/// Extracts a Python object into a declarative simulation module.
342///
343/// # Errors
344///
345/// Returns an error if `obj` is neither a built-in nor a Python `SimulationModule` instance.
346pub fn pyobject_to_simulation_module_any(obj: &Bound<'_, PyAny>) -> PyResult<SimulationModuleAny> {
347    if let Some(module) = pyobject_to_builtin_simulation_module_any(obj) {
348        return Ok(module);
349    }
350
351    if obj.is_instance_of::<PySimulationModule>() {
352        return Ok(SimulationModuleAny::Python(PythonSimulationModule::new(
353            obj.clone().unbind(),
354        )));
355    }
356
357    let type_name = obj.get_type().name()?;
358    Err(to_pytype_err(format!(
359        "Cannot convert {type_name} to SimulationModule"
360    )))
361}
362
363/// Extracts a Python object into a runtime simulation module handle.
364///
365/// Built-ins resolve first, followed by linked native extractors, then Python subclasses.
366///
367/// # Errors
368///
369/// Returns an error if the object cannot be resolved or its native extractor fails.
370pub fn pyobject_to_simulation_module_handle(
371    py: Python<'_>,
372    obj: &Bound<'_, PyAny>,
373) -> PyResult<SimulationModuleHandle> {
374    if let Some(module) = pyobject_to_builtin_simulation_module_any(obj) {
375        return Ok(module.into());
376    }
377
378    let type_object = obj.get_type();
379    let type_id = type_object.as_ptr() as usize;
380    let extractor = SIMULATION_MODULE_EXTRACTORS.lock().get(&type_id).copied();
381
382    if let Some(extractor) = extractor {
383        return extractor(py, obj);
384    }
385
386    if obj.is_instance_of::<PySimulationModule>() {
387        return Ok(SimulationModuleHandle::new(PythonSimulationModule::new(
388            obj.clone().unbind(),
389        )));
390    }
391
392    Err(to_pytype_err(format!(
393        "Cannot convert {} to SimulationModule",
394        type_object.name()?
395    )))
396}
397
398/// Converts a declarative simulation module into its Python binding object.
399///
400/// # Errors
401///
402/// Returns an error if the Python object cannot be allocated.
403pub fn simulation_module_any_to_pyobject(
404    py: Python<'_>,
405    module: &SimulationModuleAny,
406) -> PyResult<Py<PyAny>> {
407    match module {
408        SimulationModuleAny::CfdSwap(module) => Ok(Py::new(
409            py,
410            PyClassInitializer::from(PySimulationModule).add_subclass(module.clone()),
411        )?
412        .into_any()),
413        SimulationModuleAny::FXRolloverInterest(module) => Ok(Py::new(
414            py,
415            PyClassInitializer::from(PySimulationModule).add_subclass(module.clone()),
416        )?
417        .into_any()),
418        SimulationModuleAny::Python(module) => Ok(module.clone_ref(py)),
419    }
420}
421
422#[pyo3_stub_gen::derive::gen_stub_pymethods]
423#[pymethods]
424impl InterestRateRecord {
425    /// A single interest rate data entry.
426    #[new]
427    fn py_new(location: String, time: String, value: f64) -> PyResult<Self> {
428        let record = Self {
429            location,
430            time,
431            value,
432        };
433        record.validate().map_err(to_pyvalue_err)?;
434        Ok(record)
435    }
436
437    fn __repr__(&self) -> String {
438        format!("{self:?}")
439    }
440}
441
442#[pyo3_stub_gen::derive::gen_stub_pymethods]
443#[pymethods]
444impl FXRolloverInterestModule {
445    /// Simulates FX rollover (swap) interest applied at 5 PM US/Eastern daily.
446    ///
447    /// When holding FX positions overnight, the interest rate differential
448    /// between the two currencies is credited or debited. Wednesday and Friday
449    /// rollovers are tripled (Wednesday for T+2 settlement, Friday for the weekend).
450    #[new]
451    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
452    fn py_new(records: Vec<InterestRateRecord>) -> PyResult<PyClassInitializer<Self>> {
453        let module = Self::new(records).map_err(to_pyvalue_err)?;
454        Ok(PyClassInitializer::from(PySimulationModule).add_subclass(module))
455    }
456
457    fn __repr__(&self) -> String {
458        format!("{self:?}")
459    }
460}
461
462#[pyo3_stub_gen::derive::gen_stub_pymethods]
463#[pymethods]
464impl CfdSwapRate {
465    /// Daily long and short swap rates for a CFD instrument.
466    #[new]
467    fn py_new(instrument_id: InstrumentId, long_rate: Decimal, short_rate: Decimal) -> Self {
468        Self::new(instrument_id, long_rate, short_rate)
469    }
470
471    #[getter]
472    fn instrument_id(&self) -> InstrumentId {
473        self.instrument_id
474    }
475
476    #[getter]
477    fn long_rate(&self) -> Decimal {
478        self.long_rate
479    }
480
481    #[getter]
482    fn short_rate(&self) -> Decimal {
483        self.short_rate
484    }
485
486    fn __repr__(&self) -> String {
487        format!("{self:?}")
488    }
489}
490
491#[pyo3_stub_gen::derive::gen_stub_pymethods]
492#[pymethods]
493impl CfdSwapModule {
494    /// Simulates daily CFD swap adjustments at a configurable UTC rollover time.
495    #[new]
496    #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
497    #[pyo3(signature = (rates, rollover_hour=17, rollover_minute=0, triple_roll_weekday=5))]
498    fn py_new(
499        rates: Vec<CfdSwapRate>,
500        rollover_hour: i8,
501        rollover_minute: i8,
502        triple_roll_weekday: i8,
503    ) -> PyResult<PyClassInitializer<Self>> {
504        let rollover_time =
505            Time::new(rollover_hour, rollover_minute, 0, 0).map_err(to_pyvalue_err)?;
506        let triple_roll_weekday =
507            Weekday::from_monday_one_offset(triple_roll_weekday).map_err(to_pyvalue_err)?;
508        Ok(
509            PyClassInitializer::from(PySimulationModule).add_subclass(Self::new(
510                rates,
511                rollover_time,
512                triple_roll_weekday,
513            )),
514        )
515    }
516
517    fn __repr__(&self) -> String {
518        format!("{self:?}")
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use std::{cell::Cell, rc::Rc};
525
526    use indexmap::IndexMap;
527    use nautilus_common::cache::Cache;
528    use nautilus_execution::models::fee::{FeeModelAny, MakerTakerFeeModel};
529    use nautilus_model::{
530        data::Data,
531        enums::{AccountType, BookType, OmsType},
532        identifiers::Venue,
533        types::{Currency, Money},
534    };
535    use pyo3::{IntoPyObjectExt, exceptions::PyAttributeError, ffi::c_str, types::PyDict};
536    use rstest::rstest;
537
538    use super::*;
539    use crate::{
540        config::{BacktestEngineConfig, SimulatedVenueConfig},
541        engine::BacktestEngine,
542    };
543
544    fn with_empty_context<T>(f: impl FnOnce(&ExchangeContext<'_>) -> T) -> T {
545        let instruments = AHashMap::new();
546        let matching_engines = IndexMap::new();
547        let cache = Cache::default();
548        f(&ExchangeContext {
549            venue: Venue::new("SIM"),
550            base_currency: Some(Currency::USD()),
551            instruments: &instruments,
552            matching_engines: &matching_engines,
553            cache: &cache,
554        })
555    }
556
557    #[rstest]
558    fn test_pure_python_simulation_module_dispatch() {
559        Python::initialize();
560
561        Python::attach(|py| {
562            let locals = PyDict::new(py);
563            locals
564                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
565                .unwrap();
566            let module = py
567                .eval(
568                    c_str!(
569                        "type('PurePythonSimulationModule', (SimulationModule,), {\
570                            'process': lambda self, ts_now, context: \
571                                (setattr(self, 'context', context), [self.adjustment])[1]\
572                        })()"
573                    ),
574                    None,
575                    Some(&locals),
576                )
577                .unwrap();
578            module
579                .setattr(
580                    "adjustment",
581                    Money::from("1.25 USD").into_py_any(py).unwrap(),
582                )
583                .unwrap();
584
585            assert!(matches!(
586                pyobject_to_simulation_module_any(&module).unwrap(),
587                SimulationModuleAny::Python(_)
588            ));
589            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
590            let result =
591                with_empty_context(|ctx| handle.process(nautilus_core::UnixNanos::from(10), ctx))
592                    .unwrap();
593            handle
594                .acknowledge(&[AccountAdjustmentOutcome::Applied])
595                .unwrap();
596            handle.reset().unwrap();
597
598            let context = module.getattr("context").unwrap();
599
600            assert_eq!(
601                result,
602                SimulationModuleResult::Completed(vec![Money::from("1.25 USD")])
603            );
604            assert_eq!(
605                context
606                    .getattr("venue")
607                    .unwrap()
608                    .extract::<Venue>()
609                    .unwrap(),
610                Venue::new("SIM")
611            );
612            assert_eq!(
613                context
614                    .getattr("base_currency")
615                    .unwrap()
616                    .extract::<Option<Currency>>()
617                    .unwrap(),
618                Some(Currency::USD())
619            );
620            assert_eq!(context.getattr("instruments").unwrap().len().unwrap(), 0);
621            assert_eq!(context.getattr("order_books").unwrap().len().unwrap(), 0);
622            assert_eq!(context.getattr("positions").unwrap().len().unwrap(), 0);
623            let error = context.setattr("venue", Venue::new("OTHER")).unwrap_err();
624            assert!(error.is_instance_of::<PyAttributeError>(py));
625        });
626    }
627
628    #[derive(Debug)]
629    struct NativeRustSimulationModule {
630        calls: Rc<Cell<u32>>,
631    }
632
633    impl SimulationModule for NativeRustSimulationModule {
634        fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
635            Ok(())
636        }
637
638        fn process(
639            &self,
640            _ts_now: nautilus_core::UnixNanos,
641            _ctx: &ExchangeContext,
642        ) -> anyhow::Result<SimulationModuleResult> {
643            self.calls.set(self.calls.get() + 1);
644            Ok(SimulationModuleResult::NotReady)
645        }
646
647        fn acknowledge(&self, _outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
648            Ok(())
649        }
650
651        fn log_diagnostics(&self) -> anyhow::Result<()> {
652            Ok(())
653        }
654
655        fn reset(&self) -> anyhow::Result<()> {
656            Ok(())
657        }
658    }
659
660    #[pyclass(
661        name = "NativeSimulationModuleTest",
662        module = "native_simulation_module_test.registered",
663        unsendable
664    )]
665    #[derive(Debug)]
666    struct NativeSimulationModuleBinding {
667        calls: Rc<Cell<u32>>,
668    }
669
670    #[pyclass(
671        name = "NativeSimulationModuleTest",
672        module = "native_simulation_module_test.unregistered",
673        unsendable
674    )]
675    #[derive(Debug)]
676    struct UnregisteredNativeSimulationModuleBinding;
677
678    fn extract_native_simulation_module(
679        _py: Python<'_>,
680        obj: &Bound<'_, PyAny>,
681    ) -> PyResult<SimulationModuleHandle> {
682        let binding = obj.extract::<PyRef<'_, NativeSimulationModuleBinding>>()?;
683        Ok(SimulationModuleHandle::new(NativeRustSimulationModule {
684            calls: binding.calls.clone(),
685        }))
686    }
687
688    #[rstest]
689    fn test_registered_native_simulation_module_dispatch() {
690        Python::initialize();
691
692        Python::attach(|py| {
693            register_simulation_module_extractor::<NativeSimulationModuleBinding>(
694                py,
695                extract_native_simulation_module,
696            )
697            .unwrap();
698            let calls = Rc::new(Cell::new(0));
699            let binding = Py::new(
700                py,
701                NativeSimulationModuleBinding {
702                    calls: calls.clone(),
703                },
704            )
705            .unwrap();
706            let handle =
707                pyobject_to_simulation_module_handle(py, binding.bind(py).as_any()).unwrap();
708
709            let result =
710                with_empty_context(|ctx| handle.process(nautilus_core::UnixNanos::from(10), ctx))
711                    .unwrap();
712
713            assert_eq!(result, SimulationModuleResult::NotReady);
714            assert_eq!(calls.get(), 1);
715        });
716    }
717
718    #[rstest]
719    fn test_registered_native_simulation_module_uses_exact_python_type() {
720        Python::initialize();
721
722        Python::attach(|py| {
723            register_simulation_module_extractor::<NativeSimulationModuleBinding>(
724                py,
725                extract_native_simulation_module,
726            )
727            .unwrap();
728            let binding = Py::new(py, UnregisteredNativeSimulationModuleBinding).unwrap();
729
730            let error =
731                pyobject_to_simulation_module_handle(py, binding.bind(py).as_any()).unwrap_err();
732
733            assert_eq!(
734                error.to_string(),
735                "TypeError: Cannot convert NativeSimulationModuleTest to SimulationModule"
736            );
737        });
738    }
739
740    fn engine_with_module(module: SimulationModuleHandle) -> BacktestEngine {
741        let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
742        engine
743            .add_venue(
744                SimulatedVenueConfig::builder()
745                    .venue(Venue::new("SIM"))
746                    .oms_type(OmsType::Netting)
747                    .account_type(AccountType::Margin)
748                    .book_type(BookType::L1_MBP)
749                    .starting_balances(vec![Money::from("1000 USD")])
750                    .modules(vec![module])
751                    .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
752                    .build()
753                    .unwrap(),
754            )
755            .unwrap();
756        engine
757    }
758
759    #[rstest]
760    fn test_python_simulation_module_exception_propagates_through_run() {
761        Python::initialize();
762
763        Python::attach(|py| {
764            let locals = PyDict::new(py);
765            locals
766                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
767                .unwrap();
768            let module = py
769                .eval(
770                    c_str!(
771                        "type('FailingSimulationModule', (SimulationModule,), {\
772                            'process': lambda self, ts_now, context: \
773                                (_ for _ in ()).throw(ValueError('module boom'))\
774                        })()"
775                    ),
776                    None,
777                    Some(&locals),
778                )
779                .unwrap();
780            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
781            let mut engine = engine_with_module(handle);
782
783            let error = engine.run(None, None, None, false).unwrap_err();
784            let message = error.to_string();
785            assert!(message.contains("Simulation module 0 process failed"));
786            assert!(message.contains("Python SimulationModule.process failed"));
787            assert!(message.contains("ValueError: module boom"));
788
789            assert_eq!(
790                engine.run(None, None, None, false).unwrap_err().to_string(),
791                format!("Simulation module failure requires exchange reset: {message}")
792            );
793        });
794    }
795
796    #[rstest]
797    fn test_python_simulation_module_diagnostics_exception_propagates_through_run() {
798        Python::initialize();
799
800        Python::attach(|py| {
801            let locals = PyDict::new(py);
802            locals
803                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
804                .unwrap();
805            let module = py
806                .eval(
807                    c_str!(
808                        "type('FailingDiagnosticsSimulationModule', (SimulationModule,), {\
809                            'process': lambda self, ts_now, context: None,\
810                            'log_diagnostics': lambda self: \
811                                (_ for _ in ()).throw(ValueError('diagnostics boom'))\
812                        })()"
813                    ),
814                    None,
815                    Some(&locals),
816                )
817                .unwrap();
818            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
819            let mut engine = engine_with_module(handle);
820
821            let error = engine.run(None, None, None, false).unwrap_err();
822            let message = error.to_string();
823
824            assert!(message.contains("Simulation module 0 log_diagnostics failed"));
825            assert!(message.contains("Python SimulationModule.log_diagnostics failed"));
826            assert!(message.contains("ValueError: diagnostics boom"));
827        });
828    }
829
830    #[rstest]
831    fn test_engine_reset_finishes_after_python_diagnostics_exception() {
832        Python::initialize();
833
834        Python::attach(|py| {
835            let locals = PyDict::new(py);
836            locals
837                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
838                .unwrap();
839            let module = py
840                .eval(
841                    c_str!(
842                        "type('ResetAfterDiagnosticsFailureModule', (SimulationModule,), {\
843                            'process': lambda self, ts_now, context: None,\
844                            'log_diagnostics': lambda self: \
845                                (_ for _ in ()).throw(ValueError('diagnostics boom')) \
846                                if self.fail_diagnostics else None,\
847                            'reset': lambda self: \
848                                setattr(self, 'resets', self.resets + 1)\
849                        })()"
850                    ),
851                    None,
852                    Some(&locals),
853                )
854                .unwrap();
855            module.setattr("fail_diagnostics", true).unwrap();
856            module.setattr("resets", 0).unwrap();
857            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
858            let mut engine = engine_with_module(handle);
859            engine.run(None, None, None, true).unwrap();
860
861            let error = engine.reset().unwrap_err();
862
863            assert!(
864                error
865                    .to_string()
866                    .contains("Simulation module 0 log_diagnostics failed")
867            );
868            assert_eq!(
869                module.getattr("resets").unwrap().extract::<u32>().unwrap(),
870                1
871            );
872            module.setattr("fail_diagnostics", false).unwrap();
873            engine.run(None, None, None, false).unwrap();
874        });
875    }
876}