1use 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
48pub 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
55pub 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#[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#[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
341pub 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
363pub 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
398pub 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 #[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 #[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 #[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 #[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}