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
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    /// Orchestrates catalog-driven backtests from run configurations.
48    ///
49    /// `BacktestNode` connects the `ParquetDataCatalog` with `BacktestEngine` to load
50    /// historical data and run backtests. Supports both oneshot and streaming modes.
51    #[new]
52    fn py_new(configs: Vec<BacktestRunConfig>) -> PyResult<Self> {
53        Self::new(configs).map_err(to_pyruntime_err)
54    }
55
56    /// Returns the run configurations.
57    #[getter]
58    #[pyo3(name = "configs")]
59    fn py_configs(&self) -> Vec<BacktestRunConfig> {
60        self.configs().to_vec()
61    }
62
63    /// Builds backtest engines from the run configurations.
64    ///
65    /// For each config, creates a `BacktestEngine`, adds venues, and loads
66    /// instruments from the catalog. If building a config fails with
67    /// `BacktestRunConfig.raise_exception` disabled, logs the error and skips that config;
68    /// successful return does not guarantee an engine for every config.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if building an engine from a config fails and
73    /// `BacktestRunConfig.raise_exception` is enabled for that config.
74    #[pyo3(name = "build")]
75    fn py_build(&mut self) -> PyResult<()> {
76        self.build().map_err(to_pyruntime_err)
77    }
78
79    /// Runs all configured backtests and returns results.
80    ///
81    /// Automatically calls `build()` if engines have not been created yet.
82    /// For each run config, loads data from the catalog and runs the engine.
83    /// Supports both oneshot (`chunk_size = None`) and streaming modes.
84    /// Configs without a built engine are skipped. If a run fails with
85    /// `BacktestRunConfig.raise_exception` disabled, logs the error, clears its loaded data,
86    /// leaves the engine undisposed, and omits its result.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if building, data loading, or engine execution fails and
91    /// `BacktestRunConfig.raise_exception` is enabled for the run config.
92    #[pyo3(name = "run")]
93    fn py_run(&mut self) -> PyResult<Vec<BacktestResult>> {
94        self.run().map_err(to_pyruntime_err)
95    }
96
97    /// Disposes all engines and releases resources.
98    #[pyo3(name = "dispose")]
99    fn py_dispose(&mut self) {
100        self.dispose();
101    }
102
103    /// Returns the cache for the given run config engine.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if no engine exists for the run config ID.
108    #[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    /// Returns the portfolio for the given run config engine.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if no engine exists for the run config ID.
118    #[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    /// Generates an orders report for the given run config engine.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if no engine exists or report generation fails.
128    #[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    /// Generates an order fills report for the given run config engine.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if no engine exists or report generation fails.
142    #[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    /// Generates a fills report for the given run config engine.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if no engine exists or report generation fails.
156    #[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    /// Generates a positions report for the given run config engine.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if no engine exists or report generation fails.
170    #[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    /// Generates an account report for the given run config engine.
180    ///
181    /// At least one of `venue` or `account_id` must be provided.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if no engine exists, neither selector is provided, or report generation
186    /// fails.
187    #[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    /// Adds a constructed Python actor to the engine for the given run config.
202    #[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    /// Adds an actor from an importable config to the engine for the given run config.
209    #[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    /// Adds a constructed Python strategy to the engine for the given run config.
230    #[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    /// Adds a strategy from an importable config to the engine for the given run config.
241    #[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    /// Adds a constructed Python execution algorithm to the engine for the given run config.
262    #[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    /// Adds an execution algorithm from an importable config to the engine for the given run config.
273    #[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    /// Adds a built-in example strategy to the engine for the given run config.
295    ///
296    /// This method exists only to single-source bundled example strategy code across
297    /// Rust and Python tests/examples. It is not a first-class extension path for
298    /// adding native strategies.
299    #[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    // Convert config dict to Python dict
522    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    // Try kwargs first, then default constructor with setattr
532    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                    // Only call __post_init__ if it exists (setattr path
552                    // needs it, kwargs path already triggered it via __init__)
553                    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}