Skip to main content

nautilus_backtest/python/
node.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 for backtest node.
17
18use 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    /// Orchestrates catalog-driven backtests from run configurations.
50    ///
51    /// `BacktestNode` connects the catalog with `BacktestEngine` to load
52    /// historical data and run backtests. Supports both oneshot and streaming modes.
53    #[new]
54    fn py_new(configs: Vec<BacktestRunConfig>) -> PyResult<Self> {
55        Self::new(configs).map_err(to_pyruntime_err)
56    }
57
58    /// Returns the run configurations.
59    #[getter]
60    #[pyo3(name = "configs")]
61    fn py_configs(&self) -> Vec<BacktestRunConfig> {
62        self.configs().to_vec()
63    }
64
65    /// Builds backtest engines from the run configurations.
66    ///
67    /// For each config, creates a `BacktestEngine`, adds venues, and loads
68    /// instruments from the catalog. If building a config fails with
69    /// `BacktestRunConfig.raise_exception` disabled, logs the error and skips that config;
70    /// successful return does not guarantee an engine for every config.
71    /// A disposed node cannot be built again; create a new node instead.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if:
76    /// - This node has been disposed.
77    /// - Building an engine from a config fails and
78    ///   `BacktestRunConfig.raise_exception` is enabled for that config.
79    #[pyo3(name = "build")]
80    fn py_build(&mut self) -> PyResult<()> {
81        self.build().map_err(to_pyruntime_err)
82    }
83
84    /// Runs all configured backtests and returns results.
85    ///
86    /// Automatically calls `build()` if engines have not been created yet.
87    /// For each run config, loads data from the catalog and runs the engine.
88    /// Supports both oneshot (`chunk_size = None`) and streaming modes.
89    /// Configs without a built engine are skipped. If a run fails with
90    /// `BacktestRunConfig.raise_exception` disabled, logs the error, clears its loaded data,
91    /// leaves the engine undisposed, and omits its result. A streaming run that fails to load
92    /// data after replaying earlier chunks ends its engine first, stopping the trader and engines.
93    /// A node disposed by a completed run or by `dispose()`
94    /// cannot run again; create a new node instead.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if:
99    /// - This node has been disposed.
100    /// - Building, data loading, or engine execution fails and
101    ///   `BacktestRunConfig.raise_exception` is enabled for the run config.
102    #[pyo3(name = "run")]
103    fn py_run(&mut self) -> PyResult<Vec<BacktestResult>> {
104        self.run().map_err(to_pyruntime_err)
105    }
106
107    /// Disposes all engines and releases resources.
108    /// Subsequent calls to `run()` or `build()`
109    /// return an error; create a new node to run again.
110    #[pyo3(name = "dispose")]
111    fn py_dispose(&mut self) {
112        self.dispose();
113    }
114
115    /// Returns the cache for the given run config engine.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if no engine exists for the run config ID.
120    #[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    /// Returns the portfolio for the given run config engine.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if no engine exists for the run config ID.
130    #[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    /// Generates an orders report for the given run config engine.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if no engine exists or report generation fails.
140    #[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    /// Generates an order fills report for the given run config engine.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if no engine exists or report generation fails.
154    #[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    /// Generates a fills report for the given run config engine.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if no engine exists or report generation fails.
168    #[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    /// Generates a positions report for the given run config engine.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if no engine exists or report generation fails.
182    #[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    /// Generates an account report for the given run config engine.
192    ///
193    /// At least one of `venue` or `account_id` must be provided.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if no engine exists, neither selector is provided, or report generation
198    /// fails.
199    #[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    /// Adds a constructed Python actor to the engine for the given run config.
214    #[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    /// Adds an actor from an importable config to the engine for the given run config.
221    #[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    /// Adds a constructed Python strategy to the engine for the given run config.
242    #[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    /// Adds a strategy from an importable config to the engine for the given run config.
253    #[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    /// Adds a constructed Python execution algorithm to the engine for the given run config.
274    #[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    /// Adds an execution algorithm from an importable config to the engine for the given run config.
285    #[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    /// Adds a built-in example strategy to the engine for the given run config.
307    ///
308    /// This method exists only to single-source bundled example strategy code across
309    /// Rust and Python tests/examples. It is not a first-class extension path for
310    /// adding native strategies.
311    #[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    // Convert config dict to Python dict
553    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    // Try kwargs first, then default constructor with setattr
563    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                    // Only call __post_init__ if it exists (setattr path
583                    // needs it, kwargs path already triggered it via __init__)
584                    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}