1use std::collections::HashMap;
19
20use nautilus_common::{actor::data_actor::ImportableActorConfig, python::cache::PyCache};
21#[cfg(feature = "examples")]
22use nautilus_core::python::to_pytype_err;
23use nautilus_core::python::{to_pyruntime_err, to_pyvalue_err};
24use nautilus_model::identifiers::{AccountId, ActorId, Venue};
25use nautilus_portfolio::python::PyPortfolio;
26#[cfg(feature = "examples")]
27use nautilus_trading::examples::strategies::{
28 CompositeMarketMaker, CompositeMarketMakerConfig, DeltaNeutralVol, DeltaNeutralVolConfig,
29 EmaCross, EmaCrossConfig, GridMarketMaker, GridMarketMakerConfig, HurstVpinDirectional,
30 HurstVpinDirectionalConfig,
31};
32use nautilus_trading::{ImportableExecutionAlgorithmConfig, ImportableStrategyConfig};
33use pyo3::{prelude::*, types::PyDict};
34
35use super::engine::{
36 PyBacktestEngine, engine_cache, engine_portfolio, generate_account_report,
37 generate_fills_report, generate_order_fills_report, generate_orders_report,
38 generate_positions_report,
39};
40use crate::{
41 config::BacktestRunConfig, engine::BacktestEngine, node::BacktestNode, result::BacktestResult,
42};
43
44#[pyo3_stub_gen::derive::gen_stub_pymethods]
45#[pymethods]
46impl BacktestNode {
47 #[new]
52 fn py_new(configs: Vec<BacktestRunConfig>) -> PyResult<Self> {
53 Self::new(configs).map_err(to_pyruntime_err)
54 }
55
56 #[getter]
58 #[pyo3(name = "configs")]
59 fn py_configs(&self) -> Vec<BacktestRunConfig> {
60 self.configs().to_vec()
61 }
62
63 #[pyo3(name = "build")]
75 fn py_build(&mut self) -> PyResult<()> {
76 self.build().map_err(to_pyruntime_err)
77 }
78
79 #[pyo3(name = "run")]
93 fn py_run(&mut self) -> PyResult<Vec<BacktestResult>> {
94 self.run().map_err(to_pyruntime_err)
95 }
96
97 #[pyo3(name = "dispose")]
99 fn py_dispose(&mut self) {
100 self.dispose();
101 }
102
103 #[pyo3(name = "get_engine_cache")]
109 fn py_get_engine_cache(&self, run_config_id: &str) -> PyResult<PyCache> {
110 Ok(engine_cache(self.require_engine(run_config_id)?))
111 }
112
113 #[pyo3(name = "get_engine_portfolio")]
119 fn py_get_engine_portfolio(&self, run_config_id: &str) -> PyResult<PyPortfolio> {
120 Ok(engine_portfolio(self.require_engine(run_config_id)?))
121 }
122
123 #[pyo3(name = "generate_orders_report")]
129 fn py_generate_orders_report<'py>(
130 &self,
131 py: Python<'py>,
132 run_config_id: &str,
133 ) -> PyResult<Bound<'py, PyAny>> {
134 generate_orders_report(self.require_engine(run_config_id)?, py)
135 }
136
137 #[pyo3(name = "generate_order_fills_report")]
143 fn py_generate_order_fills_report<'py>(
144 &self,
145 py: Python<'py>,
146 run_config_id: &str,
147 ) -> PyResult<Bound<'py, PyAny>> {
148 generate_order_fills_report(self.require_engine(run_config_id)?, py)
149 }
150
151 #[pyo3(name = "generate_fills_report")]
157 fn py_generate_fills_report<'py>(
158 &self,
159 py: Python<'py>,
160 run_config_id: &str,
161 ) -> PyResult<Bound<'py, PyAny>> {
162 generate_fills_report(self.require_engine(run_config_id)?, py)
163 }
164
165 #[pyo3(name = "generate_positions_report")]
171 fn py_generate_positions_report<'py>(
172 &self,
173 py: Python<'py>,
174 run_config_id: &str,
175 ) -> PyResult<Bound<'py, PyAny>> {
176 generate_positions_report(self.require_engine(run_config_id)?, py)
177 }
178
179 #[pyo3(
188 name = "generate_account_report",
189 signature = (run_config_id, venue=None, account_id=None)
190 )]
191 fn py_generate_account_report<'py>(
192 &self,
193 py: Python<'py>,
194 run_config_id: &str,
195 venue: Option<Venue>,
196 account_id: Option<AccountId>,
197 ) -> PyResult<Bound<'py, PyAny>> {
198 generate_account_report(self.require_engine(run_config_id)?, py, venue, account_id)
199 }
200
201 #[pyo3(name = "add_actor")]
203 fn py_add_actor(&mut self, run_config_id: &str, actor: &Bound<'_, PyAny>) -> PyResult<()> {
204 let engine = self.require_engine_mut(run_config_id)?;
205 PyBacktestEngine::add_python_actor(engine, &actor.clone().unbind())
206 }
207
208 #[pyo3(name = "add_actor_from_config")]
210 #[expect(clippy::needless_pass_by_value)]
211 fn py_add_actor_from_config(
212 &mut self,
213 _py: Python,
214 run_config_id: &str,
215 config: ImportableActorConfig,
216 ) -> PyResult<()> {
217 log::debug!("`add_actor_from_config` with: {config:?}");
218 let engine = self.require_engine_mut(run_config_id)?;
219 let actor = create_importable_component(
220 &config.actor_path,
221 "actor_path",
222 &config.config_path,
223 &config.config,
224 "actor",
225 )?;
226 PyBacktestEngine::add_python_actor(engine, &actor)
227 }
228
229 #[pyo3(name = "add_strategy")]
231 fn py_add_strategy(
232 &mut self,
233 run_config_id: &str,
234 strategy: &Bound<'_, PyAny>,
235 ) -> PyResult<()> {
236 let engine = self.require_engine_mut(run_config_id)?;
237 PyBacktestEngine::add_python_strategy(engine, &strategy.clone().unbind())
238 }
239
240 #[pyo3(name = "add_strategy_from_config")]
242 #[expect(clippy::needless_pass_by_value)]
243 fn py_add_strategy_from_config(
244 &mut self,
245 _py: Python,
246 run_config_id: &str,
247 config: ImportableStrategyConfig,
248 ) -> PyResult<()> {
249 log::debug!("`add_strategy_from_config` with: {config:?}");
250 let engine = self.require_engine_mut(run_config_id)?;
251 let strategy = create_importable_component(
252 &config.strategy_path,
253 "strategy_path",
254 &config.config_path,
255 &config.config,
256 "strategy",
257 )?;
258 PyBacktestEngine::add_python_strategy(engine, &strategy)
259 }
260
261 #[pyo3(name = "add_exec_algorithm")]
263 fn py_add_exec_algorithm(
264 &mut self,
265 run_config_id: &str,
266 exec_algorithm: &Bound<'_, PyAny>,
267 ) -> PyResult<()> {
268 let engine = self.require_engine_mut(run_config_id)?;
269 PyBacktestEngine::add_python_exec_algorithm(engine, &exec_algorithm.clone().unbind())
270 }
271
272 #[pyo3(name = "add_exec_algorithm_from_config")]
274 #[expect(clippy::needless_pass_by_value)]
275 fn py_add_exec_algorithm_from_config(
276 &mut self,
277 _py: Python,
278 run_config_id: &str,
279 config: ImportableExecutionAlgorithmConfig,
280 ) -> PyResult<()> {
281 log::debug!("`add_exec_algorithm_from_config` with: {config:?}");
282 let engine = self.require_engine_mut(run_config_id)?;
283 PyBacktestEngine::ensure_can_add_exec_algorithm(engine)?;
284 let exec_algorithm = create_importable_component(
285 &config.exec_algorithm_path,
286 "exec_algorithm_path",
287 &config.config_path,
288 &config.config,
289 "exec algorithm",
290 )?;
291 PyBacktestEngine::add_python_exec_algorithm(engine, &exec_algorithm)
292 }
293
294 #[pyo3(name = "add_builtin_strategy")]
300 #[cfg_attr(
301 not(feature = "examples"),
302 expect(
303 clippy::unused_self,
304 reason = "PyO3 method keeps the instance API when examples are disabled"
305 )
306 )]
307 fn py_add_builtin_strategy(
308 &mut self,
309 run_config_id: &str,
310 type_name: &str,
311 config: &Bound<'_, PyAny>,
312 ) -> PyResult<()> {
313 #[cfg(feature = "examples")]
314 {
315 let engine = self.get_engine_mut(run_config_id).ok_or_else(|| {
316 to_pyruntime_err(format!("No engine for run config '{run_config_id}'"))
317 })?;
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(|| to_pyruntime_err(format!("No engine for run config '{run_config_id}'")))
343 }
344
345 fn require_engine_mut(&mut self, run_config_id: &str) -> PyResult<&mut BacktestEngine> {
346 self.get_engine_mut(run_config_id)
347 .ok_or_else(|| to_pyruntime_err(format!("No engine for run config '{run_config_id}'")))
348 }
349}
350
351#[cfg(feature = "examples")]
352type BuiltinStrategyRegister = for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
353
354#[cfg(feature = "examples")]
355fn builtin_strategy_register(type_name: &str) -> Option<BuiltinStrategyRegister> {
356 match type_name {
357 "CompositeMarketMaker" => Some(register_composite_market_maker),
358 "DeltaNeutralVol" => Some(register_delta_neutral_vol),
359 "EmaCross" => Some(register_ema_cross),
360 "GridMarketMaker" => Some(register_grid_market_maker),
361 "HurstVpinDirectional" => Some(register_hurst_vpin_directional),
362 _ => None,
363 }
364}
365
366#[cfg(feature = "examples")]
367fn register_composite_market_maker(
368 engine: &mut BacktestEngine,
369 config: &Bound<'_, PyAny>,
370) -> PyResult<()> {
371 let config = config.extract::<CompositeMarketMakerConfig>()?;
372 engine
373 .add_strategy(CompositeMarketMaker::new(config))
374 .map_err(to_pyruntime_err)
375}
376
377#[cfg(feature = "examples")]
378fn register_delta_neutral_vol(
379 engine: &mut BacktestEngine,
380 config: &Bound<'_, PyAny>,
381) -> PyResult<()> {
382 let config = config.extract::<DeltaNeutralVolConfig>()?;
383 engine
384 .add_strategy(DeltaNeutralVol::new(config))
385 .map_err(to_pyruntime_err)
386}
387
388#[cfg(feature = "examples")]
389fn register_ema_cross(engine: &mut BacktestEngine, config: &Bound<'_, PyAny>) -> PyResult<()> {
390 let config = config.extract::<EmaCrossConfig>()?;
391 engine
392 .add_strategy(EmaCross::from_config(config))
393 .map_err(to_pyruntime_err)
394}
395
396#[cfg(feature = "examples")]
397fn register_grid_market_maker(
398 engine: &mut BacktestEngine,
399 config: &Bound<'_, PyAny>,
400) -> PyResult<()> {
401 let config = config.extract::<GridMarketMakerConfig>()?;
402 engine
403 .add_strategy(GridMarketMaker::new(config))
404 .map_err(to_pyruntime_err)
405}
406
407#[cfg(feature = "examples")]
408fn register_hurst_vpin_directional(
409 engine: &mut BacktestEngine,
410 config: &Bound<'_, PyAny>,
411) -> PyResult<()> {
412 let config = config.extract::<HurstVpinDirectionalConfig>()?;
413 engine
414 .add_strategy(HurstVpinDirectional::new(config))
415 .map_err(to_pyruntime_err)
416}
417
418#[cfg(all(test, feature = "examples"))]
419mod tests {
420 use pyo3::{Python, types::PyDict};
421 use rstest::rstest;
422
423 use crate::{config::BacktestEngineConfig, engine::BacktestEngine};
424
425 #[rstest]
426 #[case("CompositeMarketMaker")]
427 #[case("DeltaNeutralVol")]
428 #[case("EmaCross")]
429 #[case("GridMarketMaker")]
430 #[case("HurstVpinDirectional")]
431 fn test_builtin_strategy_register_accepts_supported_names(#[case] type_name: &str) {
432 assert!(super::builtin_strategy_register(type_name).is_some());
433 }
434
435 #[rstest]
436 fn test_builtin_strategy_register_rejects_unknown_name() {
437 assert!(super::builtin_strategy_register("UnknownStrategy").is_none());
438 }
439
440 #[rstest]
441 fn test_builtin_strategy_register_rejects_mismatched_config() {
442 Python::initialize();
443
444 let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
445 Python::attach(|py| {
446 let register = super::builtin_strategy_register("EmaCross").unwrap();
447 let config = PyDict::new(py);
448 let error = register(&mut engine, config.as_any()).unwrap_err();
449
450 assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
451 });
452 }
453}
454
455pub(crate) fn create_importable_component(
456 component_path: &str,
457 path_field: &str,
458 config_path: &str,
459 config: &HashMap<String, serde_json::Value>,
460 component_name: &str,
461) -> PyResult<Py<PyAny>> {
462 let Some((module_name, class_name)) = component_path.split_once(':') else {
463 return Err(to_pyvalue_err(format!(
464 "{path_field} must be in format 'module.path:ClassName'",
465 )));
466 };
467
468 if module_name.is_empty() || class_name.is_empty() || class_name.contains(':') {
469 return Err(to_pyvalue_err(format!(
470 "{path_field} must be in format 'module.path:ClassName'",
471 )));
472 }
473
474 log::info!("Importing {component_name} from module: {module_name} class: {class_name}");
475
476 Python::attach(|py| -> anyhow::Result<Py<PyAny>> {
477 let module = py
478 .import(module_name)
479 .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
480 let class = module
481 .getattr(class_name)
482 .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
483 let config_instance = create_config_instance(py, config_path, config)?;
484 let component = if let Some(config_obj) = config_instance {
485 class.call1((config_obj,))?
486 } else {
487 class.call0()?
488 };
489 Ok(component.unbind())
490 })
491 .map_err(to_pyruntime_err)
492}
493
494pub(crate) fn create_config_instance<'py>(
495 py: Python<'py>,
496 config_path: &str,
497 config: &HashMap<String, serde_json::Value>,
498) -> anyhow::Result<Option<Bound<'py, PyAny>>> {
499 if config_path.is_empty() && config.is_empty() {
500 log::debug!("No config_path or empty config, using None");
501 return Ok(None);
502 }
503
504 let config_parts: Vec<&str> = config_path.split(':').collect();
505 if config_parts.len() != 2 {
506 anyhow::bail!("config_path must be in format 'module.path:ClassName', was {config_path}");
507 }
508 let (config_module_name, config_class_name) = (config_parts[0], config_parts[1]);
509
510 log::debug!(
511 "Importing config class from module: {config_module_name} class: {config_class_name}"
512 );
513
514 let config_module = py
515 .import(config_module_name)
516 .map_err(|e| anyhow::anyhow!("Failed to import config module {config_module_name}: {e}"))?;
517 let config_class = config_module
518 .getattr(config_class_name)
519 .map_err(|e| anyhow::anyhow!("Failed to get config class {config_class_name}: {e}"))?;
520
521 let py_dict = PyDict::new(py);
523
524 for (key, value) in config {
525 let py_value = config_value_to_py(py, key, value)?;
526 py_dict.set_item(key, py_value)?;
527 }
528
529 log::debug!("Created config dict: {py_dict:?}");
530
531 let config_instance = match config_class.call((), Some(&py_dict)) {
533 Ok(instance) => {
534 log::debug!("Created config instance with kwargs");
535 instance
536 }
537 Err(kwargs_err) => {
538 log::debug!("Failed to create config with kwargs: {kwargs_err}");
539
540 match config_class.call0() {
541 Ok(instance) => {
542 log::debug!("Created default config instance, setting attributes");
543 for (key, value) in config {
544 let py_value = config_value_to_py(py, key, value)?;
545
546 if let Err(setattr_err) = instance.setattr(key, py_value) {
547 log::warn!("Failed to set attribute {key}: {setattr_err}");
548 }
549 }
550
551 if instance.hasattr("__post_init__")? {
554 instance.call_method0("__post_init__")?;
555 }
556
557 instance
558 }
559 Err(default_err) => {
560 anyhow::bail!(
561 "Failed to create config instance. \
562 Tried kwargs: {kwargs_err}, default: {default_err}"
563 );
564 }
565 }
566 }
567 };
568
569 log::debug!("Created config instance: {config_instance:?}");
570
571 Ok(Some(config_instance))
572}
573
574fn config_value_to_py<'py>(
575 py: Python<'py>,
576 key: &str,
577 value: &serde_json::Value,
578) -> anyhow::Result<Bound<'py, PyAny>> {
579 if key == "actor_id"
580 && let Some(actor_id) = value.as_str()
581 {
582 return Ok(ActorId::new_checked(actor_id)?
583 .into_pyobject(py)?
584 .into_any());
585 }
586
587 let json_str = serde_json::to_string(value)
588 .map_err(|e| anyhow::anyhow!("Failed to serialize config value: {e}"))?;
589 Ok(PyModule::import(py, "json")?
590 .call_method("loads", (json_str,), None)?
591 .into_any())
592}