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    ///
72    /// # Errors
73    ///
74    /// Returns an error if building an engine from a config fails and
75    /// `BacktestRunConfig.raise_exception` is enabled for that config.
76    #[pyo3(name = "build")]
77    fn py_build(&mut self) -> PyResult<()> {
78        self.build().map_err(to_pyruntime_err)
79    }
80
81    /// Runs all configured backtests and returns results.
82    ///
83    /// Automatically calls `build()` if engines have not been created yet.
84    /// For each run config, loads data from the catalog and runs the engine.
85    /// Supports both oneshot (`chunk_size = None`) and streaming modes.
86    /// Configs without a built engine are skipped. If a run fails with
87    /// `BacktestRunConfig.raise_exception` disabled, logs the error, clears its loaded data,
88    /// leaves the engine undisposed, and omits its result.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if building, data loading, or engine execution fails and
93    /// `BacktestRunConfig.raise_exception` is enabled for the run config.
94    #[pyo3(name = "run")]
95    fn py_run(&mut self) -> PyResult<Vec<BacktestResult>> {
96        self.run().map_err(to_pyruntime_err)
97    }
98
99    /// Disposes all engines and releases resources.
100    #[pyo3(name = "dispose")]
101    fn py_dispose(&mut self) {
102        self.dispose();
103    }
104
105    /// Returns the cache for the given run config engine.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if no engine exists for the run config ID.
110    #[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    /// Returns the portfolio 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_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    /// Generates an orders report for the given run config engine.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if no engine exists or report generation fails.
130    #[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    /// Generates an order fills report for the given run config engine.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if no engine exists or report generation fails.
144    #[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    /// Generates a fills report for the given run config engine.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if no engine exists or report generation fails.
158    #[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    /// Generates a positions report for the given run config engine.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if no engine exists or report generation fails.
172    #[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    /// Generates an account report for the given run config engine.
182    ///
183    /// At least one of `venue` or `account_id` must be provided.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if no engine exists, neither selector is provided, or report generation
188    /// fails.
189    #[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    /// Adds a constructed Python actor to the engine for the given run config.
204    #[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    /// Adds an actor from an importable config to the engine for the given run config.
211    #[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    /// Adds a constructed Python strategy to the engine for the given run config.
232    #[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    /// Adds a strategy from an importable config to the engine for the given run config.
243    #[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    /// Adds a constructed Python execution algorithm to the engine for the given run config.
264    #[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    /// Adds an execution algorithm from an importable config to the engine for the given run config.
275    #[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    /// Adds a built-in example strategy to the engine for the given run config.
297    ///
298    /// This method exists only to single-source bundled example strategy code across
299    /// Rust and Python tests/examples. It is not a first-class extension path for
300    /// adding native strategies.
301    #[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    // Convert config dict to Python dict
543    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    // Try kwargs first, then default constructor with setattr
553    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                    // Only call __post_init__ if it exists (setattr path
573                    // needs it, kwargs path already triggered it via __init__)
574                    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}