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_model::{
529        data::Data,
530        enums::{AccountType, BookType, OmsType},
531        identifiers::Venue,
532        types::{Currency, Money},
533    };
534    use pyo3::{IntoPyObjectExt, exceptions::PyAttributeError, ffi::c_str, types::PyDict};
535    use rstest::rstest;
536
537    use super::*;
538    use crate::{
539        config::{BacktestEngineConfig, SimulatedVenueConfig},
540        engine::BacktestEngine,
541    };
542
543    fn with_empty_context<T>(f: impl FnOnce(&ExchangeContext<'_>) -> T) -> T {
544        let instruments = AHashMap::new();
545        let matching_engines = IndexMap::new();
546        let cache = Cache::default();
547        f(&ExchangeContext {
548            venue: Venue::new("SIM"),
549            base_currency: Some(Currency::USD()),
550            instruments: &instruments,
551            matching_engines: &matching_engines,
552            cache: &cache,
553        })
554    }
555
556    #[rstest]
557    fn test_pure_python_simulation_module_dispatch() {
558        Python::initialize();
559
560        Python::attach(|py| {
561            let locals = PyDict::new(py);
562            locals
563                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
564                .unwrap();
565            let module = py
566                .eval(
567                    c_str!(
568                        "type('PurePythonSimulationModule', (SimulationModule,), {\
569                            'process': lambda self, ts_now, context: \
570                                (setattr(self, 'context', context), [self.adjustment])[1]\
571                        })()"
572                    ),
573                    None,
574                    Some(&locals),
575                )
576                .unwrap();
577            module
578                .setattr(
579                    "adjustment",
580                    Money::from("1.25 USD").into_py_any(py).unwrap(),
581                )
582                .unwrap();
583
584            assert!(matches!(
585                pyobject_to_simulation_module_any(&module).unwrap(),
586                SimulationModuleAny::Python(_)
587            ));
588            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
589            let result =
590                with_empty_context(|ctx| handle.process(nautilus_core::UnixNanos::from(10), ctx))
591                    .unwrap();
592            handle
593                .acknowledge(&[AccountAdjustmentOutcome::Applied])
594                .unwrap();
595            handle.reset().unwrap();
596
597            let context = module.getattr("context").unwrap();
598
599            assert_eq!(
600                result,
601                SimulationModuleResult::Completed(vec![Money::from("1.25 USD")])
602            );
603            assert_eq!(
604                context
605                    .getattr("venue")
606                    .unwrap()
607                    .extract::<Venue>()
608                    .unwrap(),
609                Venue::new("SIM")
610            );
611            assert_eq!(
612                context
613                    .getattr("base_currency")
614                    .unwrap()
615                    .extract::<Option<Currency>>()
616                    .unwrap(),
617                Some(Currency::USD())
618            );
619            assert_eq!(context.getattr("instruments").unwrap().len().unwrap(), 0);
620            assert_eq!(context.getattr("order_books").unwrap().len().unwrap(), 0);
621            assert_eq!(context.getattr("positions").unwrap().len().unwrap(), 0);
622            let error = context.setattr("venue", Venue::new("OTHER")).unwrap_err();
623            assert!(error.is_instance_of::<PyAttributeError>(py));
624        });
625    }
626
627    #[derive(Debug)]
628    struct NativeRustSimulationModule {
629        calls: Rc<Cell<u32>>,
630    }
631
632    impl SimulationModule for NativeRustSimulationModule {
633        fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
634            Ok(())
635        }
636
637        fn process(
638            &self,
639            _ts_now: nautilus_core::UnixNanos,
640            _ctx: &ExchangeContext,
641        ) -> anyhow::Result<SimulationModuleResult> {
642            self.calls.set(self.calls.get() + 1);
643            Ok(SimulationModuleResult::NotReady)
644        }
645
646        fn acknowledge(&self, _outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
647            Ok(())
648        }
649
650        fn log_diagnostics(&self) -> anyhow::Result<()> {
651            Ok(())
652        }
653
654        fn reset(&self) -> anyhow::Result<()> {
655            Ok(())
656        }
657    }
658
659    #[pyclass(
660        name = "NativeSimulationModuleTest",
661        module = "native_simulation_module_test.registered",
662        unsendable
663    )]
664    #[derive(Debug)]
665    struct NativeSimulationModuleBinding {
666        calls: Rc<Cell<u32>>,
667    }
668
669    #[pyclass(
670        name = "NativeSimulationModuleTest",
671        module = "native_simulation_module_test.unregistered",
672        unsendable
673    )]
674    #[derive(Debug)]
675    struct UnregisteredNativeSimulationModuleBinding;
676
677    fn extract_native_simulation_module(
678        _py: Python<'_>,
679        obj: &Bound<'_, PyAny>,
680    ) -> PyResult<SimulationModuleHandle> {
681        let binding = obj.extract::<PyRef<'_, NativeSimulationModuleBinding>>()?;
682        Ok(SimulationModuleHandle::new(NativeRustSimulationModule {
683            calls: binding.calls.clone(),
684        }))
685    }
686
687    #[rstest]
688    fn test_registered_native_simulation_module_dispatch() {
689        Python::initialize();
690
691        Python::attach(|py| {
692            register_simulation_module_extractor::<NativeSimulationModuleBinding>(
693                py,
694                extract_native_simulation_module,
695            )
696            .unwrap();
697            let calls = Rc::new(Cell::new(0));
698            let binding = Py::new(
699                py,
700                NativeSimulationModuleBinding {
701                    calls: calls.clone(),
702                },
703            )
704            .unwrap();
705            let handle =
706                pyobject_to_simulation_module_handle(py, binding.bind(py).as_any()).unwrap();
707
708            let result =
709                with_empty_context(|ctx| handle.process(nautilus_core::UnixNanos::from(10), ctx))
710                    .unwrap();
711
712            assert_eq!(result, SimulationModuleResult::NotReady);
713            assert_eq!(calls.get(), 1);
714        });
715    }
716
717    #[rstest]
718    fn test_registered_native_simulation_module_uses_exact_python_type() {
719        Python::initialize();
720
721        Python::attach(|py| {
722            register_simulation_module_extractor::<NativeSimulationModuleBinding>(
723                py,
724                extract_native_simulation_module,
725            )
726            .unwrap();
727            let binding = Py::new(py, UnregisteredNativeSimulationModuleBinding).unwrap();
728
729            let error =
730                pyobject_to_simulation_module_handle(py, binding.bind(py).as_any()).unwrap_err();
731
732            assert_eq!(
733                error.to_string(),
734                "TypeError: Cannot convert NativeSimulationModuleTest to SimulationModule"
735            );
736        });
737    }
738
739    fn engine_with_module(module: SimulationModuleHandle) -> BacktestEngine {
740        let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
741        engine
742            .add_venue(
743                SimulatedVenueConfig::builder()
744                    .venue(Venue::new("SIM"))
745                    .oms_type(OmsType::Netting)
746                    .account_type(AccountType::Margin)
747                    .book_type(BookType::L1_MBP)
748                    .starting_balances(vec![Money::from("1000 USD")])
749                    .modules(vec![module])
750                    .build()
751                    .unwrap(),
752            )
753            .unwrap();
754        engine
755    }
756
757    #[rstest]
758    fn test_python_simulation_module_exception_propagates_through_run() {
759        Python::initialize();
760
761        Python::attach(|py| {
762            let locals = PyDict::new(py);
763            locals
764                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
765                .unwrap();
766            let module = py
767                .eval(
768                    c_str!(
769                        "type('FailingSimulationModule', (SimulationModule,), {\
770                            'process': lambda self, ts_now, context: \
771                                (_ for _ in ()).throw(ValueError('module boom'))\
772                        })()"
773                    ),
774                    None,
775                    Some(&locals),
776                )
777                .unwrap();
778            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
779            let mut engine = engine_with_module(handle);
780
781            let error = engine.run(None, None, None, false).unwrap_err();
782            let message = error.to_string();
783            assert!(message.contains("Simulation module 0 process failed"));
784            assert!(message.contains("Python SimulationModule.process failed"));
785            assert!(message.contains("ValueError: module boom"));
786
787            assert_eq!(
788                engine.run(None, None, None, false).unwrap_err().to_string(),
789                format!("Simulation module failure requires exchange reset: {message}")
790            );
791        });
792    }
793
794    #[rstest]
795    fn test_python_simulation_module_diagnostics_exception_propagates_through_run() {
796        Python::initialize();
797
798        Python::attach(|py| {
799            let locals = PyDict::new(py);
800            locals
801                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
802                .unwrap();
803            let module = py
804                .eval(
805                    c_str!(
806                        "type('FailingDiagnosticsSimulationModule', (SimulationModule,), {\
807                            'process': lambda self, ts_now, context: None,\
808                            'log_diagnostics': lambda self: \
809                                (_ for _ in ()).throw(ValueError('diagnostics boom'))\
810                        })()"
811                    ),
812                    None,
813                    Some(&locals),
814                )
815                .unwrap();
816            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
817            let mut engine = engine_with_module(handle);
818
819            let error = engine.run(None, None, None, false).unwrap_err();
820            let message = error.to_string();
821
822            assert!(message.contains("Simulation module 0 log_diagnostics failed"));
823            assert!(message.contains("Python SimulationModule.log_diagnostics failed"));
824            assert!(message.contains("ValueError: diagnostics boom"));
825        });
826    }
827
828    #[rstest]
829    fn test_engine_reset_finishes_after_python_diagnostics_exception() {
830        Python::initialize();
831
832        Python::attach(|py| {
833            let locals = PyDict::new(py);
834            locals
835                .set_item("SimulationModule", py.get_type::<PySimulationModule>())
836                .unwrap();
837            let module = py
838                .eval(
839                    c_str!(
840                        "type('ResetAfterDiagnosticsFailureModule', (SimulationModule,), {\
841                            'process': lambda self, ts_now, context: None,\
842                            'log_diagnostics': lambda self: \
843                                (_ for _ in ()).throw(ValueError('diagnostics boom')) \
844                                if self.fail_diagnostics else None,\
845                            'reset': lambda self: \
846                                setattr(self, 'resets', self.resets + 1)\
847                        })()"
848                    ),
849                    None,
850                    Some(&locals),
851                )
852                .unwrap();
853            module.setattr("fail_diagnostics", true).unwrap();
854            module.setattr("resets", 0).unwrap();
855            let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
856            let mut engine = engine_with_module(handle);
857            engine.run(None, None, None, true).unwrap();
858
859            let error = engine.reset().unwrap_err();
860
861            assert!(
862                error
863                    .to_string()
864                    .contains("Simulation module 0 log_diagnostics failed")
865            );
866            assert_eq!(
867                module.getattr("resets").unwrap().extract::<u32>().unwrap(),
868                1
869            );
870            module.setattr("fail_diagnostics", false).unwrap();
871            engine.run(None, None, None, false).unwrap();
872        });
873    }
874}