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