1use std::collections::HashMap;
19
20#[cfg(feature = "examples")]
21use nautilus_common::python::config_error_to_pyvalue_err;
22use nautilus_common::{actor::data_actor::ImportableActorConfig, python::cache::PyCache};
23#[cfg(feature = "examples")]
24use nautilus_core::python::to_pytype_err;
25use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
26use nautilus_model::identifiers::{AccountId, ActorId, StrategyId, Venue};
27use nautilus_portfolio::python::PyPortfolio;
28#[cfg(feature = "examples")]
29use nautilus_trading::examples::strategies::{
30 CompositeMarketMaker, CompositeMarketMakerConfig, DeltaNeutralVol, DeltaNeutralVolConfig,
31 EmaCross, EmaCrossConfig, GridMarketMaker, GridMarketMakerConfig, HurstVpinDirectional,
32 HurstVpinDirectionalConfig,
33};
34use nautilus_trading::{ImportableExecutionAlgorithmConfig, ImportableStrategyConfig};
35use pyo3::{prelude::*, types::PyDict};
36
37use super::engine::{
38 PyBacktestEngine, engine_cache, engine_portfolio, generate_account_report,
39 generate_fills_report, generate_order_fills_report, generate_orders_report,
40 generate_positions_report,
41};
42use crate::{
43 config::BacktestRunConfig, engine::BacktestEngine, node::BacktestNode, result::BacktestResult,
44};
45
46#[pyo3_stub_gen::derive::gen_stub_pymethods]
47#[pymethods]
48impl BacktestNode {
49 #[new]
54 fn py_new(configs: Vec<BacktestRunConfig>) -> PyResult<Self> {
55 Self::new(configs).map_err(to_pyruntime_err)
56 }
57
58 #[getter]
60 #[pyo3(name = "configs")]
61 fn py_configs(&self) -> Vec<BacktestRunConfig> {
62 self.configs().to_vec()
63 }
64
65 #[pyo3(name = "build")]
80 fn py_build(&mut self) -> PyResult<()> {
81 self.build().map_err(to_pyruntime_err)
82 }
83
84 #[pyo3(name = "run")]
103 fn py_run(&mut self) -> PyResult<Vec<BacktestResult>> {
104 self.run().map_err(to_pyruntime_err)
105 }
106
107 #[pyo3(name = "dispose")]
111 fn py_dispose(&mut self) {
112 self.dispose();
113 }
114
115 #[pyo3(name = "get_engine_cache")]
121 fn py_get_engine_cache(&self, run_config_id: &str) -> PyResult<PyCache> {
122 Ok(engine_cache(self.require_engine(run_config_id)?))
123 }
124
125 #[pyo3(name = "get_engine_portfolio")]
131 fn py_get_engine_portfolio(&self, run_config_id: &str) -> PyResult<PyPortfolio> {
132 Ok(engine_portfolio(self.require_engine(run_config_id)?))
133 }
134
135 #[pyo3(name = "generate_orders_report")]
141 fn py_generate_orders_report<'py>(
142 &self,
143 py: Python<'py>,
144 run_config_id: &str,
145 ) -> PyResult<Bound<'py, PyAny>> {
146 generate_orders_report(self.require_engine(run_config_id)?, py)
147 }
148
149 #[pyo3(name = "generate_order_fills_report")]
155 fn py_generate_order_fills_report<'py>(
156 &self,
157 py: Python<'py>,
158 run_config_id: &str,
159 ) -> PyResult<Bound<'py, PyAny>> {
160 generate_order_fills_report(self.require_engine(run_config_id)?, py)
161 }
162
163 #[pyo3(name = "generate_fills_report")]
169 fn py_generate_fills_report<'py>(
170 &self,
171 py: Python<'py>,
172 run_config_id: &str,
173 ) -> PyResult<Bound<'py, PyAny>> {
174 generate_fills_report(self.require_engine(run_config_id)?, py)
175 }
176
177 #[pyo3(name = "generate_positions_report")]
183 fn py_generate_positions_report<'py>(
184 &self,
185 py: Python<'py>,
186 run_config_id: &str,
187 ) -> PyResult<Bound<'py, PyAny>> {
188 generate_positions_report(self.require_engine(run_config_id)?, py)
189 }
190
191 #[pyo3(
200 name = "generate_account_report",
201 signature = (run_config_id, venue=None, account_id=None)
202 )]
203 fn py_generate_account_report<'py>(
204 &self,
205 py: Python<'py>,
206 run_config_id: &str,
207 venue: Option<Venue>,
208 account_id: Option<AccountId>,
209 ) -> PyResult<Bound<'py, PyAny>> {
210 generate_account_report(self.require_engine(run_config_id)?, py, venue, account_id)
211 }
212
213 #[pyo3(name = "add_actor")]
215 fn py_add_actor(&mut self, run_config_id: &str, actor: &Bound<'_, PyAny>) -> PyResult<()> {
216 let engine = self.require_engine_mut(run_config_id)?;
217 PyBacktestEngine::add_python_actor(engine, &actor.clone().unbind())
218 }
219
220 #[pyo3(name = "add_actor_from_config")]
222 #[expect(clippy::needless_pass_by_value)]
223 fn py_add_actor_from_config(
224 &mut self,
225 _py: Python,
226 run_config_id: &str,
227 config: ImportableActorConfig,
228 ) -> PyResult<()> {
229 log::debug!("`add_actor_from_config` with: {config:?}");
230 let engine = self.require_engine_mut(run_config_id)?;
231 let actor = create_importable_component(
232 &config.actor_path,
233 "actor_path",
234 &config.config_path,
235 &config.config,
236 "actor",
237 )?;
238 PyBacktestEngine::add_python_actor(engine, &actor)
239 }
240
241 #[pyo3(name = "add_strategy")]
243 fn py_add_strategy(
244 &mut self,
245 run_config_id: &str,
246 strategy: &Bound<'_, PyAny>,
247 ) -> PyResult<()> {
248 let engine = self.require_engine_mut(run_config_id)?;
249 PyBacktestEngine::add_python_strategy(engine, &strategy.clone().unbind())
250 }
251
252 #[pyo3(name = "add_strategy_from_config")]
254 #[expect(clippy::needless_pass_by_value)]
255 fn py_add_strategy_from_config(
256 &mut self,
257 _py: Python,
258 run_config_id: &str,
259 config: ImportableStrategyConfig,
260 ) -> PyResult<()> {
261 log::debug!("`add_strategy_from_config` with: {config:?}");
262 let engine = self.require_engine_mut(run_config_id)?;
263 let strategy = create_importable_component(
264 &config.strategy_path,
265 "strategy_path",
266 &config.config_path,
267 &config.config,
268 "strategy",
269 )?;
270 PyBacktestEngine::add_python_strategy(engine, &strategy)
271 }
272
273 #[pyo3(name = "add_exec_algorithm")]
275 fn py_add_exec_algorithm(
276 &mut self,
277 run_config_id: &str,
278 exec_algorithm: &Bound<'_, PyAny>,
279 ) -> PyResult<()> {
280 let engine = self.require_engine_mut(run_config_id)?;
281 PyBacktestEngine::add_python_exec_algorithm(engine, &exec_algorithm.clone().unbind())
282 }
283
284 #[pyo3(name = "add_exec_algorithm_from_config")]
286 #[expect(clippy::needless_pass_by_value)]
287 fn py_add_exec_algorithm_from_config(
288 &mut self,
289 _py: Python,
290 run_config_id: &str,
291 config: ImportableExecutionAlgorithmConfig,
292 ) -> PyResult<()> {
293 log::debug!("`add_exec_algorithm_from_config` with: {config:?}");
294 let engine = self.require_engine_mut(run_config_id)?;
295 PyBacktestEngine::ensure_can_add_exec_algorithm(engine)?;
296 let exec_algorithm = create_importable_component(
297 &config.exec_algorithm_path,
298 "exec_algorithm_path",
299 &config.config_path,
300 &config.config,
301 "exec algorithm",
302 )?;
303 PyBacktestEngine::add_python_exec_algorithm(engine, &exec_algorithm)
304 }
305
306 #[pyo3(name = "add_builtin_strategy")]
312 #[cfg_attr(
313 not(feature = "examples"),
314 expect(
315 clippy::unused_self,
316 reason = "PyO3 method keeps the instance API when examples are disabled"
317 )
318 )]
319 fn py_add_builtin_strategy(
320 &mut self,
321 run_config_id: &str,
322 type_name: &str,
323 config: &Bound<'_, PyAny>,
324 ) -> PyResult<()> {
325 #[cfg(feature = "examples")]
326 {
327 let engine = self.require_engine_mut(run_config_id)?;
328
329 let register = builtin_strategy_register(type_name).ok_or_else(|| {
330 to_pytype_err(format!("Unsupported built-in strategy type: {type_name}"))
331 })?;
332 register(engine, config)
333 }
334
335 #[cfg(not(feature = "examples"))]
336 {
337 let _ = (run_config_id, type_name, config);
338 Err(to_pyruntime_err(
339 "add_builtin_strategy requires the `examples` feature",
340 ))
341 }
342 }
343
344 fn __repr__(&self) -> String {
345 format!("{self:?}")
346 }
347}
348
349impl BacktestNode {
350 fn require_engine(&self, run_config_id: &str) -> PyResult<&BacktestEngine> {
351 self.get_engine(run_config_id)
352 .ok_or_else(|| self.missing_engine_err(run_config_id))
353 }
354
355 fn require_engine_mut(&mut self, run_config_id: &str) -> PyResult<&mut BacktestEngine> {
356 if self.get_engine(run_config_id).is_none() {
357 return Err(self.missing_engine_err(run_config_id));
358 }
359
360 Ok(self.get_engine_mut(run_config_id).expect("checked above"))
361 }
362
363 fn missing_engine_err(&self, run_config_id: &str) -> PyErr {
364 let known = self
365 .configs()
366 .iter()
367 .any(|config| config.id() == run_config_id);
368 let reason = if known {
369 "call build() before accessing the engine; if build() already ran, \
370 it may have failed (check the log) or the engine was disposed"
371 .to_string()
372 } else {
373 let ids: Vec<&str> = self.configs().iter().map(BacktestRunConfig::id).collect();
374 format!("unknown run config ID (known IDs: {ids:?})")
375 };
376 to_pyruntime_err(format!(
377 "No engine for run config '{run_config_id}': {reason}"
378 ))
379 }
380}
381
382#[cfg(feature = "examples")]
383type BuiltinStrategyRegister = for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
384
385#[cfg(feature = "examples")]
386fn builtin_strategy_register(type_name: &str) -> Option<BuiltinStrategyRegister> {
387 match type_name {
388 "CompositeMarketMaker" => Some(register_composite_market_maker),
389 "DeltaNeutralVol" => Some(register_delta_neutral_vol),
390 "EmaCross" => Some(register_ema_cross),
391 "GridMarketMaker" => Some(register_grid_market_maker),
392 "HurstVpinDirectional" => Some(register_hurst_vpin_directional),
393 _ => None,
394 }
395}
396
397#[cfg(feature = "examples")]
398fn register_composite_market_maker(
399 engine: &mut BacktestEngine,
400 config: &Bound<'_, PyAny>,
401) -> PyResult<()> {
402 let config = config.extract::<CompositeMarketMakerConfig>()?;
403 engine
404 .add_strategy(CompositeMarketMaker::new(config))
405 .map_err(to_pyruntime_err)
406}
407
408#[cfg(feature = "examples")]
409fn register_delta_neutral_vol(
410 engine: &mut BacktestEngine,
411 config: &Bound<'_, PyAny>,
412) -> PyResult<()> {
413 let config = config.extract::<DeltaNeutralVolConfig>()?;
414 engine
415 .add_strategy(DeltaNeutralVol::new(config))
416 .map_err(to_pyruntime_err)
417}
418
419#[cfg(feature = "examples")]
420fn register_ema_cross(engine: &mut BacktestEngine, config: &Bound<'_, PyAny>) -> PyResult<()> {
421 let config = config.extract::<EmaCrossConfig>()?;
422 engine
423 .add_strategy(EmaCross::from_config(config))
424 .map_err(to_pyruntime_err)
425}
426
427#[cfg(feature = "examples")]
428fn register_grid_market_maker(
429 engine: &mut BacktestEngine,
430 config: &Bound<'_, PyAny>,
431) -> PyResult<()> {
432 let config = config.extract::<GridMarketMakerConfig>()?;
433 engine
434 .add_strategy(GridMarketMaker::new(config))
435 .map_err(to_pyruntime_err)
436}
437
438#[cfg(feature = "examples")]
439fn register_hurst_vpin_directional(
440 engine: &mut BacktestEngine,
441 config: &Bound<'_, PyAny>,
442) -> PyResult<()> {
443 let config = config.extract::<HurstVpinDirectionalConfig>()?;
444 let strategy =
445 HurstVpinDirectional::new_checked(config).map_err(config_error_to_pyvalue_err)?;
446 engine.add_strategy(strategy).map_err(to_pyruntime_err)
447}
448
449#[cfg(all(test, feature = "examples"))]
450mod tests {
451 use pyo3::{Python, types::PyDict};
452 use rstest::rstest;
453
454 use crate::{config::BacktestEngineConfig, engine::BacktestEngine};
455
456 #[rstest]
457 #[case("CompositeMarketMaker")]
458 #[case("DeltaNeutralVol")]
459 #[case("EmaCross")]
460 #[case("GridMarketMaker")]
461 #[case("HurstVpinDirectional")]
462 fn test_builtin_strategy_register_accepts_supported_names(#[case] type_name: &str) {
463 assert!(super::builtin_strategy_register(type_name).is_some());
464 }
465
466 #[rstest]
467 fn test_builtin_strategy_register_rejects_unknown_name() {
468 assert!(super::builtin_strategy_register("UnknownStrategy").is_none());
469 }
470
471 #[rstest]
472 fn test_builtin_strategy_register_rejects_mismatched_config() {
473 Python::initialize();
474
475 let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
476 Python::attach(|py| {
477 let register = super::builtin_strategy_register("EmaCross").unwrap();
478 let config = PyDict::new(py);
479 let error = register(&mut engine, config.as_any()).unwrap_err();
480
481 assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
482 });
483 }
484}
485
486pub(crate) fn create_importable_component(
487 component_path: &str,
488 path_field: &str,
489 config_path: &str,
490 config: &HashMap<String, serde_json::Value>,
491 component_name: &str,
492) -> PyResult<Py<PyAny>> {
493 let Some((module_name, class_name)) = component_path.split_once(':') else {
494 return Err(to_pyvalue_err(format!(
495 "{path_field} must be in format 'module.path:ClassName'",
496 )));
497 };
498
499 if module_name.is_empty() || class_name.is_empty() || class_name.contains(':') {
500 return Err(to_pyvalue_err(format!(
501 "{path_field} must be in format 'module.path:ClassName'",
502 )));
503 }
504
505 log::info!("Importing {component_name} from module: {module_name} class: {class_name}");
506
507 Python::attach(|py| -> anyhow::Result<Py<PyAny>> {
508 let module = py
509 .import(module_name)
510 .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
511 let class = module
512 .getattr(class_name)
513 .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
514 let config_instance = create_config_instance(py, config_path, config)?;
515 let component = if let Some(config_obj) = config_instance {
516 class.call1((config_obj,))?
517 } else {
518 class.call0()?
519 };
520 Ok(component.unbind())
521 })
522 .map_err(to_pyruntime_err)
523}
524
525pub(crate) fn create_config_instance<'py>(
526 py: Python<'py>,
527 config_path: &str,
528 config: &HashMap<String, serde_json::Value>,
529) -> anyhow::Result<Option<Bound<'py, PyAny>>> {
530 if config_path.is_empty() && config.is_empty() {
531 log::debug!("No config_path or empty config, using None");
532 return Ok(None);
533 }
534
535 let config_parts: Vec<&str> = config_path.split(':').collect();
536 if config_parts.len() != 2 {
537 anyhow::bail!("config_path must be in format 'module.path:ClassName', was {config_path}");
538 }
539 let (config_module_name, config_class_name) = (config_parts[0], config_parts[1]);
540
541 log::debug!(
542 "Importing config class from module: {config_module_name} class: {config_class_name}"
543 );
544
545 let config_module = py
546 .import(config_module_name)
547 .map_err(|e| anyhow::anyhow!("Failed to import config module {config_module_name}: {e}"))?;
548 let config_class = config_module
549 .getattr(config_class_name)
550 .map_err(|e| anyhow::anyhow!("Failed to get config class {config_class_name}: {e}"))?;
551
552 let py_dict = PyDict::new(py);
554
555 for (key, value) in config {
556 let py_value = config_value_to_py(py, key, value)?;
557 py_dict.set_item(key, py_value)?;
558 }
559
560 log::debug!("Created config dict: {py_dict:?}");
561
562 let config_instance = match config_class.call((), Some(&py_dict)) {
564 Ok(instance) => {
565 log::debug!("Created config instance with kwargs");
566 instance
567 }
568 Err(kwargs_err) => {
569 log::debug!("Failed to create config with kwargs: {kwargs_err}");
570
571 match config_class.call0() {
572 Ok(instance) => {
573 log::debug!("Created default config instance, setting attributes");
574 for (key, value) in config {
575 let py_value = config_value_to_py(py, key, value)?;
576
577 if let Err(setattr_err) = instance.setattr(key, py_value) {
578 anyhow::bail!("Failed to set attribute {key}: {setattr_err}");
579 }
580 }
581
582 if instance.hasattr("__post_init__")? {
585 instance.call_method0("__post_init__")?;
586 }
587
588 instance
589 }
590 Err(default_err) => {
591 anyhow::bail!(
592 "Failed to create config instance. \
593 Tried kwargs: {kwargs_err}, default: {default_err}"
594 );
595 }
596 }
597 }
598 };
599
600 log::debug!("Created config instance: {config_instance:?}");
601
602 Ok(Some(config_instance))
603}
604
605fn config_value_to_py<'py>(
606 py: Python<'py>,
607 key: &str,
608 value: &serde_json::Value,
609) -> anyhow::Result<Bound<'py, PyAny>> {
610 if key == "actor_id"
611 && let Some(actor_id) = value.as_str()
612 {
613 return Ok(ActorId::new_checked(actor_id)?
614 .into_pyobject(py)?
615 .into_any());
616 }
617
618 if key == "strategy_id"
619 && let Some(strategy_id) = value.as_str()
620 {
621 return Ok(StrategyId::new_checked(strategy_id)?
622 .into_pyobject(py)?
623 .into_any());
624 }
625
626 let json_str = serde_json::to_string(value)
627 .map_err(|e| anyhow::anyhow!("Failed to serialize config value: {e}"))?;
628 Ok(PyModule::import(py, "json")?
629 .call_method("loads", (json_str,), None)?
630 .into_any())
631}