Skip to main content

nautilus_backtest/python/
engine.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 [`BacktestEngine`].
17
18use std::collections::HashMap;
19
20use ahash::AHashMap;
21use nautilus_common::{
22    actor::data_actor::ImportableActorConfig,
23    enums::ComponentState,
24    python::{
25        actor::{PyDataActor, prepare_python_actor},
26        cache::PyCache,
27        config_error_to_pyvalue_err,
28    },
29};
30use nautilus_core::{
31    UUID4, UnixNanos,
32    python::{to_pyruntime_err, to_pytype_err, to_pyvalue_err},
33};
34use nautilus_execution::python::{
35    fee::pyobject_to_fee_model_handle, fill::pyobject_to_fill_model_handle,
36    latency::pyobject_to_latency_model_any,
37};
38#[cfg(feature = "defi")]
39use nautilus_model::defi::DefiData;
40use nautilus_model::{
41    data::{
42        Bar, CustomData, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentClose,
43        InstrumentStatus, MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDeltas,
44        OrderBookDepth, QuoteTick, TradeTick,
45    },
46    enums::{AccountType, BookType, OmsType, OtoTriggerMode},
47    identifiers::{AccountId, ActorId, ClientId, ExecAlgorithmId, InstrumentId, TraderId, Venue},
48    python::{
49        account::margin_model::pyobject_to_margin_model_any,
50        instruments::pyobject_to_instrument_any,
51    },
52    types::{Currency, Money},
53};
54use nautilus_portfolio::python::PyPortfolio;
55#[cfg(feature = "examples")]
56use nautilus_trading::examples::{
57    actors::{BookImbalanceActor, BookImbalanceActorConfig},
58    strategies::{
59        CompositeMarketMaker, CompositeMarketMakerConfig, DeltaNeutralVol, DeltaNeutralVolConfig,
60        EmaCross, EmaCrossConfig, GridMarketMaker, GridMarketMakerConfig, HurstVpinDirectional,
61        HurstVpinDirectionalConfig,
62    },
63};
64use nautilus_trading::{
65    ImportableExecutionAlgorithmConfig, ImportableStrategyConfig,
66    algorithm::{TwapAlgorithm, TwapAlgorithmConfig},
67    python::algorithm::PyExecutionAlgorithm,
68};
69use pyo3::prelude::*;
70use rust_decimal::Decimal;
71
72use super::{modules::pyobject_to_simulation_module_handle, node::create_importable_component};
73use crate::{
74    config::{BacktestEngineConfig, SimulatedVenueConfig},
75    engine::BacktestEngine,
76    result::BacktestResult,
77};
78
79/// PyO3 wrapper around [`BacktestEngine`].
80///
81/// Exposes the backtest engine to Python as `BacktestEngine`.
82/// Uses `unsendable` because the inner engine holds `Rc<RefCell<...>>`.
83#[pyo3::pyclass(
84    module = "nautilus_trader.backtest",
85    name = "BacktestEngine",
86    unsendable
87)]
88#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
89#[derive(Debug)]
90pub struct PyBacktestEngine(BacktestEngine);
91
92// DeFi methods live in their own fully gated `#[pymethods]` block (multiple-pymethods is enabled)
93// so the `gen_stub`/pyo3 expansion never references `DefiData` in non-DeFi builds.
94#[cfg(feature = "defi")]
95#[pyo3_stub_gen::derive::gen_stub_pymethods]
96#[pymethods]
97impl PyBacktestEngine {
98    /// Adds DeFi data to the engine.
99    #[pyo3(name = "add_defi_data", signature = (data, client_id=None, sort=true))]
100    fn py_add_defi_data(
101        &mut self,
102        data: Vec<DefiData>,
103        client_id: Option<ClientId>,
104        sort: bool,
105    ) -> PyResult<()> {
106        self.0
107            .add_defi_data(data, client_id, sort)
108            .map_err(to_pyruntime_err)
109    }
110}
111
112#[pyo3_stub_gen::derive::gen_stub_pymethods]
113#[pymethods]
114impl PyBacktestEngine {
115    #[new]
116    fn py_new(config: BacktestEngineConfig) -> PyResult<Self> {
117        let engine = BacktestEngine::new(config).map_err(to_pyruntime_err)?;
118        Ok(Self(engine))
119    }
120
121    /// Adds a simulated exchange with the given parameters to the engine.
122    ///
123    /// # Liquidation parameters
124    ///
125    /// - `liquidation_enabled` (bool, default `False`): if margin liquidation should be
126    ///   triggered when the account's equity falls to or below the maintenance
127    ///   margin threshold scaled by `liquidation_trigger_ratio`.
128    /// - `liquidation_trigger_ratio` (float, optional, default `1.0`): the ratio of
129    ///   maintenance margin used as the liquidation threshold. A value of `1.0`
130    ///   liquidates when equity <= maintenance margin; higher values trigger earlier.
131    /// - `liquidation_cancel_open_orders` (bool, default `True`): if open resting
132    ///   orders for the venue should be cancelled before synthetic close-out fills
133    ///   are emitted for open positions.
134    #[pyo3(
135        name = "add_venue",
136        signature = (
137            venue,
138            oms_type,
139            account_type,
140            starting_balances,
141            base_currency = None,
142            default_leverage = None,
143            leverages = None,
144            fill_model = None,
145            fee_model = None,
146            margin_model = None,
147            latency_model = None,
148            modules = None,
149            book_type = BookType::L1_MBP,
150            routing = false,
151            reject_stop_orders = true,
152            support_gtd_orders = true,
153            support_contingent_orders = true,
154            use_position_ids = true,
155            use_random_ids = false,
156            use_reduce_only = true,
157            use_message_queue = true,
158            use_market_order_acks = false,
159            bar_execution = true,
160            bar_adaptive_high_low_ordering = false,
161            trade_execution = true,
162            liquidity_consumption = false,
163            queue_position = false,
164            allow_cash_borrowing = false,
165            frozen_account = false,
166            oto_trigger_mode = OtoTriggerMode::Partial,
167            price_protection_points = None,
168            liquidation_enabled = false,
169            liquidation_trigger_ratio = None,
170            liquidation_cancel_open_orders = true,
171        )
172    )]
173    #[expect(
174        clippy::fn_params_excessive_bools,
175        clippy::too_many_arguments,
176        reason = "method mirrors the existing Python keyword API"
177    )]
178    fn py_add_venue(
179        &mut self,
180        venue: Venue,
181        oms_type: OmsType,
182        account_type: AccountType,
183        starting_balances: Vec<Money>,
184        base_currency: Option<Currency>,
185        default_leverage: Option<Decimal>,
186        leverages: Option<HashMap<InstrumentId, Decimal>>,
187        fill_model: Option<Py<PyAny>>,
188        fee_model: Option<Py<PyAny>>,
189        margin_model: Option<Py<PyAny>>,
190        latency_model: Option<Py<PyAny>>,
191        modules: Option<Vec<Py<PyAny>>>,
192        book_type: BookType,
193        routing: bool,
194        reject_stop_orders: bool,
195        support_gtd_orders: bool,
196        support_contingent_orders: bool,
197        use_position_ids: bool,
198        use_random_ids: bool,
199        use_reduce_only: bool,
200        use_message_queue: bool,
201        use_market_order_acks: bool,
202        bar_execution: bool,
203        bar_adaptive_high_low_ordering: bool,
204        trade_execution: bool,
205        liquidity_consumption: bool,
206        queue_position: bool,
207        allow_cash_borrowing: bool,
208        frozen_account: bool,
209        oto_trigger_mode: OtoTriggerMode,
210        price_protection_points: Option<u32>,
211        liquidation_enabled: bool,
212        liquidation_trigger_ratio: Option<f64>,
213        liquidation_cancel_open_orders: bool,
214    ) -> PyResult<()> {
215        let leverages: AHashMap<InstrumentId, Decimal> = leverages
216            .map(|m| m.into_iter().collect())
217            .unwrap_or_default();
218        let fill_model = fill_model
219            .map(|obj| Python::attach(|py| pyobject_to_fill_model_handle(obj.bind(py))))
220            .transpose()?
221            .unwrap_or_default();
222
223        let fee_model = fee_model
224            .map(|obj| Python::attach(|py| pyobject_to_fee_model_handle(obj.bind(py))))
225            .transpose()?
226            .ok_or_else(|| {
227                to_pyvalue_err(
228                    "Backtest venue requires an explicit fee_model, including an explicit zero-fee model",
229                )
230            })?;
231
232        let margin_model = margin_model
233            .map(|obj| Python::attach(|py| pyobject_to_margin_model_any(obj.bind(py))))
234            .transpose()?
235            .map(Into::into);
236
237        let latency_model = latency_model
238            .map(|obj| Python::attach(|py| pyobject_to_latency_model_any(obj.bind(py))))
239            .transpose()?
240            .map(Into::into);
241
242        let modules = modules
243            .map(|objs| {
244                objs.into_iter()
245                    .map(|obj| {
246                        Python::attach(|py| pyobject_to_simulation_module_handle(py, obj.bind(py)))
247                    })
248                    .collect::<PyResult<Vec<_>>>()
249            })
250            .transpose()?
251            .unwrap_or_default();
252
253        let sim_config = SimulatedVenueConfig::builder()
254            .venue(venue)
255            .oms_type(oms_type)
256            .account_type(account_type)
257            .book_type(book_type)
258            .starting_balances(starting_balances)
259            .maybe_base_currency(base_currency)
260            .maybe_default_leverage(default_leverage)
261            .leverages(leverages)
262            .fill_model(fill_model)
263            .fee_model(fee_model)
264            .maybe_margin_model(margin_model)
265            .maybe_latency_model(latency_model)
266            .modules(modules)
267            .routing(routing)
268            .reject_stop_orders(reject_stop_orders)
269            .support_gtd_orders(support_gtd_orders)
270            .support_contingent_orders(support_contingent_orders)
271            .use_position_ids(use_position_ids)
272            .use_random_ids(use_random_ids)
273            .use_reduce_only(use_reduce_only)
274            .use_message_queue(use_message_queue)
275            .use_market_order_acks(use_market_order_acks)
276            .bar_execution(bar_execution)
277            .bar_adaptive_high_low_ordering(bar_adaptive_high_low_ordering)
278            .trade_execution(trade_execution)
279            .liquidity_consumption(liquidity_consumption)
280            .allow_cash_borrowing(allow_cash_borrowing)
281            .frozen_account(frozen_account)
282            .queue_position(queue_position)
283            .oto_full_trigger(oto_trigger_mode == OtoTriggerMode::Full)
284            .maybe_price_protection_points(price_protection_points)
285            .liquidation_enabled(liquidation_enabled)
286            .liquidation_trigger_ratio(liquidation_trigger_ratio.unwrap_or(1.0))
287            .liquidation_cancel_open_orders(liquidation_cancel_open_orders)
288            .build()
289            .map_err(config_error_to_pyvalue_err)?;
290
291        self.0.add_venue(sim_config).map_err(to_pyruntime_err)?;
292
293        Ok(())
294    }
295
296    /// Changes the fill model for a venue.
297    #[pyo3(name = "change_fill_model")]
298    #[expect(clippy::needless_pass_by_value)]
299    fn py_change_fill_model(
300        &mut self,
301        py: Python,
302        venue: Venue,
303        fill_model: Py<PyAny>,
304    ) -> PyResult<()> {
305        let fill_model = pyobject_to_fill_model_handle(fill_model.bind(py))?;
306        self.0.change_fill_model(venue, fill_model);
307        Ok(())
308    }
309
310    /// Adds data to the engine.
311    #[pyo3(
312        name = "add_data",
313        signature = (data, client_id=None, validate=true, sort=true)
314    )]
315    fn py_add_data(
316        &mut self,
317        py: Python,
318        data: Vec<Py<PyAny>>,
319        client_id: Option<ClientId>,
320        validate: bool,
321        sort: bool,
322    ) -> PyResult<()> {
323        let rust_data: Vec<Data> = data
324            .into_iter()
325            .map(|obj| pyobject_to_data(py, obj.bind(py)))
326            .collect::<PyResult<_>>()?;
327        self.0
328            .add_data(rust_data, client_id, validate, sort)
329            .map_err(to_pyruntime_err)
330    }
331
332    /// Adds an instrument to the engine.
333    #[pyo3(name = "add_instrument")]
334    fn py_add_instrument(&mut self, py: Python, instrument: Py<PyAny>) -> PyResult<()> {
335        let instrument_any = pyobject_to_instrument_any(py, instrument)?;
336        self.0
337            .add_instrument(&instrument_any)
338            .map_err(to_pyruntime_err)
339    }
340
341    /// Adds an actor from a constructed Python instance.
342    ///
343    /// The actor ID and logging flags are sourced from the instance's config.
344    #[pyo3(name = "add_actor")]
345    fn py_add_actor(&mut self, actor: &Bound<'_, PyAny>) -> PyResult<()> {
346        log::debug!("`add_actor` with a constructed instance");
347        Self::add_python_actor(&mut self.0, &actor.clone().unbind())
348    }
349
350    /// Adds an actor from an importable config.
351    #[pyo3(name = "add_actor_from_config")]
352    #[expect(clippy::needless_pass_by_value)]
353    fn py_add_actor_from_config(
354        &mut self,
355        _py: Python,
356        config: ImportableActorConfig,
357    ) -> PyResult<()> {
358        log::debug!("`add_actor_from_config` with: {config:?}");
359        let actor = create_importable_component(
360            &config.actor_path,
361            "actor_path",
362            &config.config_path,
363            &config.config,
364            "actor",
365        )?;
366
367        Self::add_python_actor(&mut self.0, &actor)
368    }
369
370    /// Adds a strategy from a constructed Python instance.
371    ///
372    /// The strategy ID, order ID tag, and logging flags are sourced from the instance's
373    /// config.
374    #[pyo3(name = "add_strategy")]
375    fn py_add_strategy(&mut self, strategy: &Bound<'_, PyAny>) -> PyResult<()> {
376        log::debug!("`add_strategy` with a constructed instance");
377        Self::add_python_strategy(&mut self.0, &strategy.clone().unbind())
378    }
379
380    /// Adds a strategy from an importable config.
381    #[pyo3(name = "add_strategy_from_config")]
382    #[expect(clippy::needless_pass_by_value)]
383    fn py_add_strategy_from_config(
384        &mut self,
385        _py: Python,
386        config: ImportableStrategyConfig,
387    ) -> PyResult<()> {
388        log::debug!("`add_strategy_from_config` with: {config:?}");
389        let strategy = create_importable_component(
390            &config.strategy_path,
391            "strategy_path",
392            &config.config_path,
393            &config.config,
394            "strategy",
395        )?;
396
397        Self::add_python_strategy(&mut self.0, &strategy)
398    }
399
400    /// Adds an execution algorithm from a constructed Python instance.
401    ///
402    /// The execution algorithm ID and logging flags are sourced from the instance's
403    /// config.
404    #[pyo3(name = "add_exec_algorithm")]
405    fn py_add_exec_algorithm(&mut self, exec_algorithm: &Bound<'_, PyAny>) -> PyResult<()> {
406        log::debug!("`add_exec_algorithm` with a constructed instance");
407        Self::add_python_exec_algorithm(&mut self.0, &exec_algorithm.clone().unbind())
408    }
409
410    /// Adds an execution algorithm from an importable config.
411    #[pyo3(name = "add_exec_algorithm_from_config")]
412    #[expect(clippy::needless_pass_by_value)]
413    fn py_add_exec_algorithm_from_config(
414        &mut self,
415        _py: Python,
416        config: ImportableExecutionAlgorithmConfig,
417    ) -> PyResult<()> {
418        Self::ensure_can_add_exec_algorithm(&self.0)?;
419
420        log::debug!("`add_exec_algorithm_from_config` with: {config:?}");
421        let exec_algorithm = create_importable_component(
422            &config.exec_algorithm_path,
423            "exec_algorithm_path",
424            &config.config_path,
425            &config.config,
426            "exec algorithm",
427        )?;
428
429        Self::add_python_exec_algorithm(&mut self.0, &exec_algorithm)
430    }
431
432    /// Adds a built-in example actor from its type name and config.
433    ///
434    /// This method exists only to single-source bundled example actor code across
435    /// Rust and Python tests/examples. It is not a first-class extension path for
436    /// adding native actors.
437    #[cfg(feature = "examples")]
438    #[pyo3(name = "add_builtin_actor")]
439    fn py_add_builtin_actor(&mut self, type_name: &str, config: &Bound<'_, PyAny>) -> PyResult<()> {
440        let register = builtin_actor_register(type_name).ok_or_else(|| {
441            to_pytype_err(format!("Unsupported built-in actor type: {type_name}"))
442        })?;
443        register(&mut self.0, config)
444    }
445
446    /// Adds a built-in example strategy from its type name and config.
447    ///
448    /// This method exists only to single-source bundled example strategy code across
449    /// Rust and Python tests/examples. It is not a first-class extension path for
450    /// adding native strategies.
451    #[cfg(feature = "examples")]
452    #[pyo3(name = "add_builtin_strategy")]
453    fn py_add_builtin_strategy(
454        &mut self,
455        type_name: &str,
456        config: &Bound<'_, PyAny>,
457    ) -> PyResult<()> {
458        let register = builtin_strategy_register(type_name).ok_or_else(|| {
459            to_pytype_err(format!("Unsupported built-in strategy type: {type_name}"))
460        })?;
461        register(&mut self.0, config)
462    }
463
464    /// Adds a compiled-in native Rust execution algorithm from its type name and config.
465    ///
466    /// The type name determines which built-in execution algorithm is constructed.
467    /// All execution happens in Rust; Python is the configuration layer.
468    #[pyo3(name = "add_native_exec_algorithm")]
469    fn py_add_native_exec_algorithm(
470        &mut self,
471        type_name: &str,
472        config: &Bound<'_, PyAny>,
473    ) -> PyResult<()> {
474        let register = native_exec_algorithm_register(type_name).ok_or_else(|| {
475            to_pytype_err(format!(
476                "Unsupported native exec algorithm type: {type_name}"
477            ))
478        })?;
479        register(&mut self.0, config)
480    }
481
482    /// Runs the backtest engine.
483    #[pyo3(
484        name = "run",
485        signature = (start=None, end=None, run_config_id=None, streaming=false)
486    )]
487    fn py_run(
488        &mut self,
489        start: Option<u64>,
490        end: Option<u64>,
491        run_config_id: Option<String>,
492        streaming: bool,
493    ) -> PyResult<()> {
494        self.0
495            .run(
496                start.map(UnixNanos::from),
497                end.map(UnixNanos::from),
498                run_config_id,
499                streaming,
500            )
501            .map_err(to_pyruntime_err)
502    }
503
504    /// Ends the backtest run, finalizing results.
505    #[pyo3(name = "end")]
506    fn py_end(&mut self) -> PyResult<()> {
507        self.0.end().map_err(to_pyruntime_err)
508    }
509
510    /// Resets the engine state for a new run.
511    #[pyo3(name = "reset")]
512    fn py_reset(&mut self) -> PyResult<()> {
513        self.0.reset().map_err(to_pyruntime_err)
514    }
515
516    /// Disposes of the engine, releasing all resources.
517    #[pyo3(name = "dispose")]
518    fn py_dispose(&mut self) {
519        self.0.dispose();
520    }
521
522    /// Returns the backtest result from the last run.
523    #[pyo3(name = "get_result")]
524    fn py_get_result(&self) -> BacktestResult {
525        self.0.get_result()
526    }
527
528    /// Clears all data from the engine.
529    #[pyo3(name = "clear_data")]
530    fn py_clear_data(&mut self) {
531        self.0.clear_data();
532    }
533
534    /// Clears all actors from the engine.
535    #[pyo3(name = "clear_actors")]
536    fn py_clear_actors(&mut self) -> PyResult<()> {
537        self.0.clear_actors().map_err(to_pyruntime_err)
538    }
539
540    /// Clears all strategies from the engine.
541    #[pyo3(name = "clear_strategies")]
542    fn py_clear_strategies(&mut self) -> PyResult<()> {
543        self.0.clear_strategies().map_err(to_pyruntime_err)
544    }
545
546    /// Clears all execution algorithms from the engine.
547    #[pyo3(name = "clear_exec_algorithms")]
548    fn py_clear_exec_algorithms(&mut self) -> PyResult<()> {
549        self.0.clear_exec_algorithms().map_err(to_pyruntime_err)
550    }
551
552    /// Adds multiple actors from importable configs. Stops at the first error.
553    #[pyo3(name = "add_actors_from_configs")]
554    fn py_add_actors_from_configs(
555        &mut self,
556        py: Python,
557        configs: Vec<ImportableActorConfig>,
558    ) -> PyResult<()> {
559        for config in configs {
560            self.py_add_actor_from_config(py, config)?;
561        }
562        Ok(())
563    }
564
565    /// Adds multiple strategies from importable configs. Stops at the first error.
566    #[pyo3(name = "add_strategies_from_configs")]
567    fn py_add_strategies_from_configs(
568        &mut self,
569        py: Python,
570        configs: Vec<ImportableStrategyConfig>,
571    ) -> PyResult<()> {
572        for config in configs {
573            self.py_add_strategy_from_config(py, config)?;
574        }
575        Ok(())
576    }
577
578    /// Adds multiple execution algorithms from importable configs. Stops at the first error.
579    #[pyo3(name = "add_exec_algorithms_from_configs")]
580    fn py_add_exec_algorithms_from_configs(
581        &mut self,
582        py: Python,
583        configs: Vec<ImportableExecutionAlgorithmConfig>,
584    ) -> PyResult<()> {
585        for config in configs {
586            self.py_add_exec_algorithm_from_config(py, config)?;
587        }
588        Ok(())
589    }
590
591    /// Adds multiple actors from constructed Python instances. Stops at the first error.
592    #[pyo3(name = "add_actors")]
593    fn py_add_actors(&mut self, actors: Vec<Py<PyAny>>) -> PyResult<()> {
594        for actor in actors {
595            Self::add_python_actor(&mut self.0, &actor)?;
596        }
597        Ok(())
598    }
599
600    /// Adds multiple strategies from constructed Python instances. Stops at the first error.
601    #[pyo3(name = "add_strategies")]
602    fn py_add_strategies(&mut self, strategies: Vec<Py<PyAny>>) -> PyResult<()> {
603        for strategy in strategies {
604            Self::add_python_strategy(&mut self.0, &strategy)?;
605        }
606        Ok(())
607    }
608
609    /// Adds multiple execution algorithms from constructed Python instances. Stops at the first error.
610    #[pyo3(name = "add_exec_algorithms")]
611    fn py_add_exec_algorithms(&mut self, exec_algorithms: Vec<Py<PyAny>>) -> PyResult<()> {
612        for exec_algorithm in exec_algorithms {
613            Self::add_python_exec_algorithm(&mut self.0, &exec_algorithm)?;
614        }
615        Ok(())
616    }
617
618    /// Sorts the engine's internal data stream by timestamp.
619    #[pyo3(name = "sort_data")]
620    fn py_sort_data(&mut self) {
621        self.0.sort_data();
622    }
623
624    /// Returns the trader ID for this engine.
625    #[getter]
626    #[pyo3(name = "trader_id")]
627    fn py_trader_id(&self) -> TraderId {
628        self.0.trader_id()
629    }
630
631    /// Returns the machine ID for this engine.
632    #[getter]
633    #[pyo3(name = "machine_id")]
634    fn py_machine_id(&self) -> String {
635        self.0.machine_id().to_string()
636    }
637
638    /// Returns the unique instance ID for this engine.
639    #[getter]
640    #[pyo3(name = "instance_id")]
641    fn py_instance_id(&self) -> UUID4 {
642        self.0.instance_id()
643    }
644
645    /// Returns the current iteration count.
646    #[getter]
647    #[pyo3(name = "iteration")]
648    fn py_iteration(&self) -> usize {
649        self.0.iteration()
650    }
651
652    /// Returns the last run config ID, if any.
653    #[getter]
654    #[pyo3(name = "run_config_id")]
655    fn py_run_config_id(&self) -> Option<String> {
656        self.0.run_config_id().map(str::to_string)
657    }
658
659    /// Returns the last run ID, if any.
660    #[getter]
661    #[pyo3(name = "run_id")]
662    fn py_run_id(&self) -> Option<UUID4> {
663        self.0.run_id()
664    }
665
666    /// Returns when the last run started, in nanoseconds since the UNIX epoch.
667    #[getter]
668    #[pyo3(name = "run_started")]
669    fn py_run_started(&self) -> Option<u64> {
670        self.0.run_started().map(|n| n.as_u64())
671    }
672
673    /// Returns when the last run finished, in nanoseconds since the UNIX epoch.
674    #[getter]
675    #[pyo3(name = "run_finished")]
676    fn py_run_finished(&self) -> Option<u64> {
677        self.0.run_finished().map(|n| n.as_u64())
678    }
679
680    /// Returns the last backtest range start, in nanoseconds since the UNIX epoch.
681    #[getter]
682    #[pyo3(name = "backtest_start")]
683    fn py_backtest_start(&self) -> Option<u64> {
684        self.0.backtest_start().map(|n| n.as_u64())
685    }
686
687    /// Returns the last backtest range end, in nanoseconds since the UNIX epoch.
688    #[getter]
689    #[pyo3(name = "backtest_end")]
690    fn py_backtest_end(&self) -> Option<u64> {
691        self.0.backtest_end().map(|n| n.as_u64())
692    }
693
694    /// Returns the list of registered venue identifiers.
695    #[pyo3(name = "list_venues")]
696    fn py_list_venues(&self) -> Vec<Venue> {
697        self.0.list_venues()
698    }
699
700    /// Returns the cache shared with the kernel and registered components.
701    #[getter]
702    #[pyo3(name = "cache")]
703    fn py_cache(&self) -> PyCache {
704        engine_cache(&self.0)
705    }
706
707    /// Returns the portfolio shared with the kernel and registered components.
708    #[getter]
709    #[pyo3(name = "portfolio")]
710    fn py_portfolio(&self) -> PyPortfolio {
711        engine_portfolio(&self.0)
712    }
713
714    /// Generates an orders report as a pandas `DataFrame`.
715    ///
716    /// # Errors
717    ///
718    /// Returns an error if the Python `ReportProvider` import or call fails.
719    #[pyo3(name = "generate_orders_report")]
720    fn py_generate_orders_report<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
721        generate_orders_report(&self.0, py)
722    }
723
724    /// Generates an order fills report as a pandas `DataFrame`.
725    ///
726    /// # Errors
727    ///
728    /// Returns an error if the Python `ReportProvider` import or call fails.
729    #[pyo3(name = "generate_order_fills_report")]
730    fn py_generate_order_fills_report<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
731        generate_order_fills_report(&self.0, py)
732    }
733
734    /// Generates a fills report as a pandas `DataFrame`.
735    ///
736    /// # Errors
737    ///
738    /// Returns an error if the Python `ReportProvider` import or call fails.
739    #[pyo3(name = "generate_fills_report")]
740    fn py_generate_fills_report<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
741        generate_fills_report(&self.0, py)
742    }
743
744    /// Generates a positions report as a pandas `DataFrame`.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error if the Python `ReportProvider` import or call fails.
749    #[pyo3(name = "generate_positions_report")]
750    fn py_generate_positions_report<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
751        generate_positions_report(&self.0, py)
752    }
753
754    /// Generates an account report as a pandas `DataFrame`.
755    ///
756    /// At least one of `venue` or `account_id` must be provided.
757    ///
758    /// # Errors
759    ///
760    /// Returns an error if neither `venue` nor `account_id` is provided, or if the Python
761    /// `ReportProvider` import or call fails.
762    #[pyo3(name = "generate_account_report", signature = (venue=None, account_id=None))]
763    fn py_generate_account_report<'py>(
764        &self,
765        py: Python<'py>,
766        venue: Option<Venue>,
767        account_id: Option<AccountId>,
768    ) -> PyResult<Bound<'py, PyAny>> {
769        generate_account_report(&self.0, py, venue, account_id)
770    }
771
772    fn __repr__(&self) -> String {
773        format!("{:?}", self.0)
774    }
775}
776
777pub(super) fn engine_cache(engine: &BacktestEngine) -> PyCache {
778    PyCache::from_rc(engine.kernel().cache.clone())
779}
780
781pub(super) fn engine_portfolio(engine: &BacktestEngine) -> PyPortfolio {
782    PyPortfolio::from_rc(engine.kernel().portfolio.clone())
783}
784
785pub(super) fn generate_orders_report<'py>(
786    engine: &BacktestEngine,
787    py: Python<'py>,
788) -> PyResult<Bound<'py, PyAny>> {
789    let orders = cache_bound(engine, py)?.call_method0("orders")?;
790    report_provider(py)?.call_method1("generate_orders_report", (orders,))
791}
792
793pub(super) fn generate_order_fills_report<'py>(
794    engine: &BacktestEngine,
795    py: Python<'py>,
796) -> PyResult<Bound<'py, PyAny>> {
797    let orders = cache_bound(engine, py)?.call_method0("orders")?;
798    report_provider(py)?.call_method1("generate_order_fills_report", (orders,))
799}
800
801pub(super) fn generate_fills_report<'py>(
802    engine: &BacktestEngine,
803    py: Python<'py>,
804) -> PyResult<Bound<'py, PyAny>> {
805    let orders = cache_bound(engine, py)?.call_method0("orders")?;
806    report_provider(py)?.call_method1("generate_fills_report", (orders,))
807}
808
809pub(super) fn generate_positions_report<'py>(
810    engine: &BacktestEngine,
811    py: Python<'py>,
812) -> PyResult<Bound<'py, PyAny>> {
813    let cache = cache_bound(engine, py)?;
814    let positions = cache.call_method0("positions")?;
815    let snapshots = cache.call_method0("position_snapshots")?;
816    report_provider(py)?.call_method1("generate_positions_report", (positions, snapshots))
817}
818
819pub(super) fn generate_account_report<'py>(
820    engine: &BacktestEngine,
821    py: Python<'py>,
822    venue: Option<Venue>,
823    account_id: Option<AccountId>,
824) -> PyResult<Bound<'py, PyAny>> {
825    let cache = cache_bound(engine, py)?;
826    let account = match (account_id, venue) {
827        (Some(aid), _) => cache.call_method1("account", (aid,))?,
828        (None, Some(v)) => cache.call_method1("account_for_venue", (v,))?,
829        (None, None) => {
830            return Err(to_pyvalue_err(
831                "At least one of 'venue' or 'account_id' must be provided",
832            ));
833        }
834    };
835
836    if account.is_none() {
837        return py.import("pandas")?.call_method0("DataFrame");
838    }
839    report_provider(py)?.call_method1("generate_account_report", (account,))
840}
841
842fn cache_bound<'py>(engine: &BacktestEngine, py: Python<'py>) -> PyResult<Bound<'py, PyCache>> {
843    Ok(Py::new(py, engine_cache(engine))?.into_bound(py))
844}
845
846fn report_provider(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
847    py.import("nautilus_trader.analysis.reporter")?
848        .getattr("ReportProvider")
849}
850
851impl PyBacktestEngine {
852    /// Provides access to the inner [`BacktestEngine`].
853    #[must_use]
854    pub fn inner(&self) -> &BacktestEngine {
855        &self.0
856    }
857
858    /// Provides mutable access to the inner [`BacktestEngine`].
859    pub fn inner_mut(&mut self) -> &mut BacktestEngine {
860        &mut self.0
861    }
862
863    /// Configures and registers a constructed Python strategy instance.
864    ///
865    /// Shared by `add_strategy` (caller-constructed instance) and
866    /// `add_strategy_from_config` (imported and constructed here). The strategy ID,
867    /// order ID tag, and logging flags are sourced from the instance's retained
868    /// `.config`, so both entry points use a single config object.
869    #[allow(
870        unsafe_code,
871        reason = "Required for Python strategy component registration"
872    )]
873    pub(crate) fn add_python_strategy(
874        engine: &mut BacktestEngine,
875        strategy: &Py<PyAny>,
876    ) -> PyResult<()> {
877        let strategy_id = engine
878            .kernel_mut()
879            .trader
880            .borrow_mut()
881            .add_python_strategy_instance(strategy)
882            .map_err(to_pyruntime_err)?;
883
884        let oms_type = Python::attach(|py| -> PyResult<Option<OmsType>> {
885            Ok(strategy
886                .bind(py)
887                .getattr("config")
888                .ok()
889                .filter(|config| !config.is_none())
890                .and_then(|cfg| cfg.getattr("oms_type").ok())
891                .filter(|value| !value.is_none())
892                .and_then(|value| value.extract::<OmsType>().ok()))
893        })?;
894
895        if let Some(oms_type) = oms_type {
896            engine
897                .kernel()
898                .exec_engine
899                .borrow_mut()
900                .register_oms_type(strategy_id, oms_type);
901        }
902
903        Ok(())
904    }
905
906    /// Configures and registers a constructed Python actor instance.
907    ///
908    /// Shared by `add_actor` (caller-constructed instance) and `add_actor_from_config`
909    /// (imported and constructed here). The actor ID and logging flags are sourced from
910    /// the instance's retained `.config`, so both entry points use a single config object.
911    pub(crate) fn add_python_actor(engine: &mut BacktestEngine, actor: &Py<PyAny>) -> PyResult<()> {
912        let actor_id = Python::attach(|py| -> anyhow::Result<ActorId> {
913            let bound = actor.bind(py);
914
915            let config_instance = bound
916                .getattr("config")
917                .ok()
918                .filter(|config| !config.is_none());
919
920            prepare_python_actor(bound, config_instance.as_ref())
921        })
922        .map_err(to_pyruntime_err)?;
923
924        if engine
925            .kernel()
926            .trader
927            .borrow()
928            .actor_ids()
929            .contains(&actor_id)
930        {
931            return Err(to_pyruntime_err(format!(
932                "Actor '{actor_id}' is already registered"
933            )));
934        }
935
936        engine
937            .kernel_mut()
938            .trader
939            .borrow_mut()
940            .add_python_actor_instance(actor, actor_id)
941            .map_err(to_pyruntime_err)?;
942
943        log::info!("Registered Python actor {actor_id}");
944        Ok(())
945    }
946
947    /// Configures and registers a constructed Python execution algorithm instance.
948    ///
949    /// Shared by `add_exec_algorithm` (caller-constructed instance) and
950    /// `add_exec_algorithm_from_config` (imported and constructed here). The execution
951    /// algorithm ID and logging flags are sourced from the instance's retained `.config`.
952    pub(crate) fn add_python_exec_algorithm(
953        engine: &mut BacktestEngine,
954        exec_algorithm: &Py<PyAny>,
955    ) -> PyResult<()> {
956        Self::ensure_can_add_exec_algorithm(engine)?;
957
958        if Self::try_add_py_execution_algorithm(engine, exec_algorithm)? {
959            return Ok(());
960        }
961
962        let actor_id = Python::attach(|py| -> anyhow::Result<ActorId> {
963            let bound = exec_algorithm.bind(py);
964
965            let config_instance = bound
966                .getattr("config")
967                .ok()
968                .filter(|config| !config.is_none());
969
970            let mut py_data_actor_ref = bound
971                .extract::<PyRefMut<PyDataActor>>()
972                .map_err(Into::<PyErr>::into)
973                .map_err(|e| anyhow::anyhow!("Failed to extract PyDataActor: {e}"))?;
974
975            if let Some(config_obj) = config_instance.as_ref() {
976                let id_attr = config_obj
977                    .getattr("exec_algorithm_id")
978                    .ok()
979                    .filter(|v| !v.is_none())
980                    .or_else(|| config_obj.getattr("actor_id").ok().filter(|v| !v.is_none()));
981
982                if let Some(id_value) = id_attr {
983                    let actor_id_val = if let Ok(eaid) = id_value.extract::<ExecAlgorithmId>() {
984                        ActorId::new(eaid.inner())
985                    } else if let Ok(aid) = id_value.extract::<ActorId>() {
986                        aid
987                    } else if let Ok(aid_str) = id_value.extract::<String>() {
988                        ActorId::new_checked(&aid_str)?
989                    } else {
990                        anyhow::bail!("Invalid `exec_algorithm_id`/`actor_id` type");
991                    };
992                    py_data_actor_ref.set_actor_id(actor_id_val);
993                }
994
995                if let Ok(log_events) = config_obj.getattr("log_events")
996                    && let Ok(log_events_val) = log_events.extract::<bool>()
997                {
998                    py_data_actor_ref.set_log_events(log_events_val);
999                }
1000
1001                if let Ok(log_commands) = config_obj.getattr("log_commands")
1002                    && let Ok(log_commands_val) = log_commands.extract::<bool>()
1003                {
1004                    py_data_actor_ref.set_log_commands(log_commands_val);
1005                }
1006            }
1007
1008            py_data_actor_ref.set_python_instance(bound)?;
1009            let actor_id = py_data_actor_ref.actor_id();
1010
1011            Ok(actor_id)
1012        })
1013        .map_err(to_pyruntime_err)?;
1014
1015        let exec_algorithm_id = engine
1016            .kernel_mut()
1017            .trader
1018            .borrow_mut()
1019            .add_python_exec_algorithm_instance(exec_algorithm, actor_id)
1020            .map_err(to_pyruntime_err)?;
1021
1022        log::info!("Registered Python exec algorithm {exec_algorithm_id}");
1023        Ok(())
1024    }
1025
1026    fn try_add_py_execution_algorithm(
1027        engine: &mut BacktestEngine,
1028        exec_algorithm: &Py<PyAny>,
1029    ) -> PyResult<bool> {
1030        let py_exec_algorithm =
1031            Python::attach(|py| -> anyhow::Result<Option<PyExecutionAlgorithm>> {
1032                let bound = exec_algorithm.bind(py);
1033
1034                let config_instance = bound
1035                    .getattr("config")
1036                    .ok()
1037                    .filter(|config| !config.is_none());
1038
1039                let Ok(mut py_exec_algorithm_ref) =
1040                    bound.extract::<PyRefMut<PyExecutionAlgorithm>>()
1041                else {
1042                    return Ok(None);
1043                };
1044
1045                if let Some(config_obj) = config_instance.as_ref() {
1046                    py_exec_algorithm_ref.configure_from_py_config(config_obj)?;
1047                }
1048
1049                py_exec_algorithm_ref.set_python_instance(bound)?;
1050
1051                Ok(Some(py_exec_algorithm_ref.clone()))
1052            })
1053            .map_err(to_pyruntime_err)?;
1054
1055        let Some(py_exec_algorithm) = py_exec_algorithm else {
1056            return Ok(false);
1057        };
1058
1059        let exec_algorithm_id = engine
1060            .kernel_mut()
1061            .trader
1062            .borrow_mut()
1063            .add_py_execution_algorithm_instance(py_exec_algorithm, exec_algorithm)
1064            .map_err(to_pyruntime_err)?;
1065
1066        log::info!("Registered Python exec algorithm {exec_algorithm_id}");
1067        Ok(true)
1068    }
1069
1070    /// Rejects adding an execution algorithm when the trader is running or disposed.
1071    ///
1072    /// Checked before importing and constructing the Python class in the `from_config`
1073    /// path so a rejected addition never runs user constructor code.
1074    pub(crate) fn ensure_can_add_exec_algorithm(engine: &BacktestEngine) -> PyResult<()> {
1075        match engine.kernel().trader.borrow().state() {
1076            ComponentState::PreInitialized | ComponentState::Ready | ComponentState::Stopped => {
1077                Ok(())
1078            }
1079            ComponentState::Running => Err(to_pyruntime_err(
1080                "Cannot add execution algorithms to running trader",
1081            )),
1082            ComponentState::Disposed => {
1083                Err(to_pyruntime_err("Cannot add components to disposed trader"))
1084            }
1085            state => Err(to_pyruntime_err(format!(
1086                "Cannot add execution algorithms in current state: {state}"
1087            ))),
1088        }
1089    }
1090}
1091
1092#[cfg(feature = "examples")]
1093type BuiltinActorRegister = for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
1094
1095#[cfg(feature = "examples")]
1096type BuiltinStrategyRegister = for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
1097
1098#[cfg(feature = "examples")]
1099fn builtin_actor_register(type_name: &str) -> Option<BuiltinActorRegister> {
1100    match type_name {
1101        "BookImbalanceActor" => Some(register_book_imbalance_actor),
1102        _ => None,
1103    }
1104}
1105
1106#[cfg(feature = "examples")]
1107fn builtin_strategy_register(type_name: &str) -> Option<BuiltinStrategyRegister> {
1108    match type_name {
1109        "CompositeMarketMaker" => Some(register_composite_market_maker),
1110        "DeltaNeutralVol" => Some(register_delta_neutral_vol),
1111        "EmaCross" => Some(register_ema_cross),
1112        "GridMarketMaker" => Some(register_grid_market_maker),
1113        "HurstVpinDirectional" => Some(register_hurst_vpin_directional),
1114        _ => None,
1115    }
1116}
1117
1118type NativeExecutionAlgorithmRegister =
1119    for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
1120
1121fn native_exec_algorithm_register(type_name: &str) -> Option<NativeExecutionAlgorithmRegister> {
1122    match type_name {
1123        "TwapAlgorithm" => Some(register_twap_algorithm),
1124        _ => None,
1125    }
1126}
1127
1128fn register_twap_algorithm(engine: &mut BacktestEngine, config: &Bound<'_, PyAny>) -> PyResult<()> {
1129    let config = config.extract::<TwapAlgorithmConfig>()?;
1130    if config.exec_algorithm_id.is_none() {
1131        return Err(to_pyvalue_err(
1132            "TwapAlgorithm config requires `exec_algorithm_id`",
1133        ));
1134    }
1135    engine
1136        .add_exec_algorithm(TwapAlgorithm::new(config))
1137        .map_err(to_pyruntime_err)
1138}
1139
1140#[cfg(feature = "examples")]
1141fn register_composite_market_maker(
1142    engine: &mut BacktestEngine,
1143    config: &Bound<'_, PyAny>,
1144) -> PyResult<()> {
1145    let config = config.extract::<CompositeMarketMakerConfig>()?;
1146    engine
1147        .add_strategy(CompositeMarketMaker::new(config))
1148        .map_err(to_pyruntime_err)
1149}
1150
1151#[cfg(feature = "examples")]
1152fn register_delta_neutral_vol(
1153    engine: &mut BacktestEngine,
1154    config: &Bound<'_, PyAny>,
1155) -> PyResult<()> {
1156    let config = config.extract::<DeltaNeutralVolConfig>()?;
1157    engine
1158        .add_strategy(DeltaNeutralVol::new(config))
1159        .map_err(to_pyruntime_err)
1160}
1161
1162#[cfg(feature = "examples")]
1163fn register_ema_cross(engine: &mut BacktestEngine, config: &Bound<'_, PyAny>) -> PyResult<()> {
1164    let config = config.extract::<EmaCrossConfig>()?;
1165    engine
1166        .add_strategy(EmaCross::from_config(config))
1167        .map_err(to_pyruntime_err)
1168}
1169
1170#[cfg(feature = "examples")]
1171fn register_grid_market_maker(
1172    engine: &mut BacktestEngine,
1173    config: &Bound<'_, PyAny>,
1174) -> PyResult<()> {
1175    let config = config.extract::<GridMarketMakerConfig>()?;
1176    engine
1177        .add_strategy(GridMarketMaker::new(config))
1178        .map_err(to_pyruntime_err)
1179}
1180
1181#[cfg(feature = "examples")]
1182fn register_hurst_vpin_directional(
1183    engine: &mut BacktestEngine,
1184    config: &Bound<'_, PyAny>,
1185) -> PyResult<()> {
1186    let config = config.extract::<HurstVpinDirectionalConfig>()?;
1187    let strategy =
1188        HurstVpinDirectional::new_checked(config).map_err(config_error_to_pyvalue_err)?;
1189    engine.add_strategy(strategy).map_err(to_pyruntime_err)
1190}
1191
1192#[cfg(feature = "examples")]
1193fn register_book_imbalance_actor(
1194    engine: &mut BacktestEngine,
1195    config: &Bound<'_, PyAny>,
1196) -> PyResult<()> {
1197    let config = config.extract::<BookImbalanceActorConfig>()?;
1198    engine
1199        .add_actor(BookImbalanceActor::from_config(config))
1200        .map_err(to_pyruntime_err)
1201}
1202
1203#[cfg(all(test, feature = "examples"))]
1204mod tests {
1205    use pyo3::{Python, types::PyDict};
1206    use rstest::rstest;
1207
1208    use crate::{config::BacktestEngineConfig, engine::BacktestEngine};
1209
1210    #[rstest]
1211    #[case("CompositeMarketMaker")]
1212    #[case("DeltaNeutralVol")]
1213    #[case("EmaCross")]
1214    #[case("GridMarketMaker")]
1215    #[case("HurstVpinDirectional")]
1216    fn test_builtin_strategy_register_accepts_supported_names(#[case] type_name: &str) {
1217        assert!(super::builtin_strategy_register(type_name).is_some());
1218    }
1219
1220    #[rstest]
1221    #[case("BookImbalanceActor")]
1222    fn test_builtin_actor_register_accepts_supported_names(#[case] type_name: &str) {
1223        assert!(super::builtin_actor_register(type_name).is_some());
1224    }
1225
1226    #[rstest]
1227    fn test_builtin_register_rejects_unknown_names() {
1228        assert!(super::builtin_strategy_register("UnknownStrategy").is_none());
1229        assert!(super::builtin_actor_register("UnknownActor").is_none());
1230    }
1231
1232    #[rstest]
1233    fn test_builtin_strategy_register_rejects_mismatched_config() {
1234        Python::initialize();
1235
1236        let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
1237        Python::attach(|py| {
1238            let register = super::builtin_strategy_register("EmaCross").unwrap();
1239            let config = PyDict::new(py);
1240            let error = register(&mut engine, config.as_any()).unwrap_err();
1241
1242            assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
1243        });
1244    }
1245
1246    #[rstest]
1247    fn test_builtin_actor_register_rejects_mismatched_config() {
1248        Python::initialize();
1249
1250        let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
1251        Python::attach(|py| {
1252            let register = super::builtin_actor_register("BookImbalanceActor").unwrap();
1253            let config = PyDict::new(py);
1254            let error = register(&mut engine, config.as_any()).unwrap_err();
1255
1256            assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
1257        });
1258    }
1259
1260    #[rstest]
1261    fn test_add_strategy_registers_python_instance() {
1262        use nautilus_model::identifiers::StrategyId;
1263        use nautilus_trading::python::strategy::PyStrategy;
1264        use pyo3::{ffi::c_str, types::PyAnyMethods};
1265
1266        Python::initialize();
1267
1268        let mut engine =
1269            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1270
1271        Python::attach(|py| {
1272            let config = py
1273                .eval(
1274                    c_str!("type('_Cfg', (), {'strategy_id': 'S-INSTANCE-001'})()"),
1275                    None,
1276                    None,
1277                )
1278                .unwrap();
1279            let instance = py
1280                .get_type::<PyStrategy>()
1281                .as_any()
1282                .call1((config,))
1283                .unwrap();
1284
1285            engine.py_add_strategy(&instance).unwrap();
1286
1287            assert!(
1288                engine
1289                    .0
1290                    .kernel()
1291                    .trader
1292                    .borrow()
1293                    .strategy_ids()
1294                    .contains(&StrategyId::from("S-INSTANCE-001"))
1295            );
1296        });
1297    }
1298
1299    #[rstest]
1300    fn test_add_actor_registers_python_instance() {
1301        use nautilus_common::python::actor::PyDataActor;
1302        use nautilus_model::identifiers::ActorId;
1303        use pyo3::{ffi::c_str, types::PyAnyMethods};
1304
1305        Python::initialize();
1306
1307        let mut engine =
1308            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1309
1310        Python::attach(|py| {
1311            let config = py
1312                .eval(
1313                    c_str!("type('_Cfg', (), {'actor_id': 'A-INSTANCE-001'})()"),
1314                    None,
1315                    None,
1316                )
1317                .unwrap();
1318            let instance = py
1319                .get_type::<PyDataActor>()
1320                .as_any()
1321                .call1((config,))
1322                .unwrap();
1323
1324            engine.py_add_actor(&instance).unwrap();
1325
1326            assert!(
1327                engine
1328                    .0
1329                    .kernel()
1330                    .trader
1331                    .borrow()
1332                    .actor_ids()
1333                    .contains(&ActorId::from("A-INSTANCE-001"))
1334            );
1335        });
1336    }
1337
1338    #[rstest]
1339    fn test_add_exec_algorithm_registers_python_instance() {
1340        use nautilus_common::python::actor::PyDataActor;
1341        use nautilus_model::identifiers::ExecAlgorithmId;
1342        use pyo3::{ffi::c_str, types::PyAnyMethods};
1343
1344        Python::initialize();
1345
1346        let mut engine =
1347            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1348
1349        Python::attach(|py| {
1350            let config = py
1351                .eval(
1352                    c_str!("type('_Cfg', (), {'exec_algorithm_id': 'EXEC-INSTANCE-001'})()"),
1353                    None,
1354                    None,
1355                )
1356                .unwrap();
1357            let instance = py
1358                .get_type::<PyDataActor>()
1359                .as_any()
1360                .call1((config,))
1361                .unwrap();
1362
1363            engine.py_add_exec_algorithm(&instance).unwrap();
1364
1365            assert!(
1366                engine
1367                    .0
1368                    .kernel()
1369                    .trader
1370                    .borrow()
1371                    .exec_algorithm_ids()
1372                    .contains(&ExecAlgorithmId::from("EXEC-INSTANCE-001"))
1373            );
1374        });
1375    }
1376
1377    #[rstest]
1378    fn test_add_exec_algorithm_retains_py_execution_algorithm_wrapper() {
1379        use nautilus_common::python::wrappers::get_python_wrapper;
1380        use nautilus_model::identifiers::{ComponentId, ExecAlgorithmId};
1381        use nautilus_trading::python::algorithm::PyExecutionAlgorithm;
1382        use pyo3::{ffi::c_str, types::PyAnyMethods};
1383
1384        Python::initialize();
1385
1386        let mut engine =
1387            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1388
1389        Python::attach(|py| {
1390            let config = py
1391                .eval(
1392                    c_str!("type('_Cfg', (), {'exec_algorithm_id': 'EXEC-WRAPPED-001'})()"),
1393                    None,
1394                    None,
1395                )
1396                .unwrap();
1397            let instance = py
1398                .get_type::<PyExecutionAlgorithm>()
1399                .as_any()
1400                .call1((config,))
1401                .unwrap();
1402
1403            engine.py_add_exec_algorithm(&instance).unwrap();
1404
1405            assert!(
1406                engine
1407                    .0
1408                    .kernel()
1409                    .trader
1410                    .borrow()
1411                    .exec_algorithm_ids()
1412                    .contains(&ExecAlgorithmId::from("EXEC-WRAPPED-001"))
1413            );
1414            assert!(
1415                get_python_wrapper(ComponentId::from("EXEC-WRAPPED-001"))
1416                    .expect("registering must retain the algorithm's Python wrapper")
1417                    .bind(py)
1418                    .is(&instance)
1419            );
1420        });
1421    }
1422
1423    #[rstest]
1424    fn test_add_exec_algorithm_colliding_with_actor_leaves_the_actor_registered() {
1425        use nautilus_common::python::{actor::PyDataActor, wrappers::get_python_wrapper};
1426        use nautilus_model::identifiers::{ActorId, ComponentId};
1427        use nautilus_trading::python::algorithm::PyExecutionAlgorithm;
1428        use pyo3::{ffi::c_str, types::PyAnyMethods};
1429
1430        Python::initialize();
1431
1432        let mut engine =
1433            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1434
1435        Python::attach(|py| {
1436            let actor_config = py
1437                .eval(
1438                    c_str!("type('_Cfg', (), {'actor_id': 'COLLIDING-ALGO'})()"),
1439                    None,
1440                    None,
1441                )
1442                .unwrap();
1443            let actor = py
1444                .get_type::<PyDataActor>()
1445                .as_any()
1446                .call1((actor_config,))
1447                .unwrap();
1448
1449            engine.py_add_actor(&actor).unwrap();
1450
1451            let algorithm_config = py
1452                .eval(
1453                    c_str!("type('_Cfg', (), {'exec_algorithm_id': 'COLLIDING-ALGO'})()"),
1454                    None,
1455                    None,
1456                )
1457                .unwrap();
1458            let algorithm = py
1459                .get_type::<PyExecutionAlgorithm>()
1460                .as_any()
1461                .call1((algorithm_config,))
1462                .unwrap();
1463
1464            let error = engine
1465                .py_add_exec_algorithm(&algorithm)
1466                .expect_err("an algorithm colliding with a live actor must not register");
1467            assert!(error.to_string().contains("already registered"));
1468
1469            assert_eq!(
1470                engine.0.kernel().trader.borrow().actor_ids(),
1471                vec![ActorId::from("COLLIDING-ALGO")]
1472            );
1473            assert!(
1474                engine
1475                    .0
1476                    .kernel()
1477                    .trader
1478                    .borrow()
1479                    .exec_algorithm_ids()
1480                    .is_empty()
1481            );
1482            assert!(
1483                get_python_wrapper(ComponentId::from("COLLIDING-ALGO"))
1484                    .expect("the actor must still hold its wrapper")
1485                    .bind(py)
1486                    .is(&actor)
1487            );
1488        });
1489    }
1490
1491    #[rstest]
1492    fn test_add_strategies_registers_multiple_python_instances() {
1493        use nautilus_model::identifiers::StrategyId;
1494        use nautilus_trading::python::strategy::PyStrategy;
1495        use pyo3::{ffi::c_str, types::PyAnyMethods};
1496
1497        Python::initialize();
1498
1499        let mut engine =
1500            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1501
1502        Python::attach(|py| {
1503            let strategy_type = py.get_type::<PyStrategy>();
1504            let first_config = py
1505                .eval(
1506                    c_str!("type('_Cfg', (), {'strategy_id': 'S-MULTI-001'})()"),
1507                    None,
1508                    None,
1509                )
1510                .unwrap();
1511            let second_config = py
1512                .eval(
1513                    c_str!("type('_Cfg', (), {'strategy_id': 'S-MULTI-002'})()"),
1514                    None,
1515                    None,
1516                )
1517                .unwrap();
1518            let instances = vec![
1519                strategy_type
1520                    .as_any()
1521                    .call1((first_config,))
1522                    .unwrap()
1523                    .unbind(),
1524                strategy_type
1525                    .as_any()
1526                    .call1((second_config,))
1527                    .unwrap()
1528                    .unbind(),
1529            ];
1530
1531            engine.py_add_strategies(instances).unwrap();
1532
1533            let trader = engine.0.kernel().trader.borrow();
1534            let strategy_ids = trader.strategy_ids();
1535            assert!(strategy_ids.contains(&StrategyId::from("S-MULTI-001")));
1536            assert!(strategy_ids.contains(&StrategyId::from("S-MULTI-002")));
1537        });
1538    }
1539
1540    #[rstest]
1541    fn test_add_exec_algorithms_registers_multiple_python_instances() {
1542        use nautilus_common::python::actor::PyDataActor;
1543        use nautilus_model::identifiers::ExecAlgorithmId;
1544        use pyo3::{ffi::c_str, types::PyAnyMethods};
1545
1546        Python::initialize();
1547
1548        let mut engine =
1549            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1550
1551        Python::attach(|py| {
1552            let algo_type = py.get_type::<PyDataActor>();
1553            let first_config = py
1554                .eval(
1555                    c_str!("type('_Cfg', (), {'exec_algorithm_id': 'EXEC-MULTI-001'})()"),
1556                    None,
1557                    None,
1558                )
1559                .unwrap();
1560            let second_config = py
1561                .eval(
1562                    c_str!("type('_Cfg', (), {'exec_algorithm_id': 'EXEC-MULTI-002'})()"),
1563                    None,
1564                    None,
1565                )
1566                .unwrap();
1567            let instances = vec![
1568                algo_type.as_any().call1((first_config,)).unwrap().unbind(),
1569                algo_type.as_any().call1((second_config,)).unwrap().unbind(),
1570            ];
1571
1572            engine.py_add_exec_algorithms(instances).unwrap();
1573
1574            let trader = engine.0.kernel().trader.borrow();
1575            let exec_algorithm_ids = trader.exec_algorithm_ids();
1576            assert!(exec_algorithm_ids.contains(&ExecAlgorithmId::from("EXEC-MULTI-001")));
1577            assert!(exec_algorithm_ids.contains(&ExecAlgorithmId::from("EXEC-MULTI-002")));
1578        });
1579    }
1580}
1581
1582fn pyobject_to_data(_py: Python, obj: &Bound<'_, PyAny>) -> PyResult<Data> {
1583    if let Ok(delta) = obj.extract::<OrderBookDelta>() {
1584        return Ok(Data::BookDelta(delta));
1585    }
1586
1587    if let Ok(deltas) = obj.extract::<OrderBookDeltas>() {
1588        return Ok(Data::BookDeltas(Box::new(deltas)));
1589    }
1590
1591    if let Ok(quote) = obj.extract::<QuoteTick>() {
1592        return Ok(Data::Quote(quote));
1593    }
1594
1595    if let Ok(trade) = obj.extract::<TradeTick>() {
1596        return Ok(Data::Trade(trade));
1597    }
1598
1599    if let Ok(bar) = obj.extract::<Bar>() {
1600        return Ok(Data::Bar(bar));
1601    }
1602
1603    if let Ok(depth) = obj.extract::<OrderBookDepth>() {
1604        return Ok(Data::BookDepth(Box::new(depth)));
1605    }
1606
1607    if let Ok(mark) = obj.extract::<MarkPriceUpdate>() {
1608        return Ok(Data::MarkPrice(mark));
1609    }
1610
1611    if let Ok(index) = obj.extract::<IndexPriceUpdate>() {
1612        return Ok(Data::IndexPrice(index));
1613    }
1614
1615    if let Ok(funding_rate) = obj.extract::<FundingRateUpdate>() {
1616        return Ok(Data::FundingRate(funding_rate));
1617    }
1618
1619    if let Ok(greeks) = obj.extract::<OptionGreeks>() {
1620        return Ok(Data::OptionGreeks(greeks));
1621    }
1622
1623    if let Ok(status) = obj.extract::<InstrumentStatus>() {
1624        return Ok(Data::InstrumentStatus(status));
1625    }
1626
1627    if let Ok(close) = obj.extract::<InstrumentClose>() {
1628        return Ok(Data::InstrumentClose(close));
1629    }
1630
1631    if let Ok(custom) = obj.extract::<CustomData>() {
1632        return Ok(Data::Custom(custom));
1633    }
1634
1635    #[cfg(feature = "defi")]
1636    if let Ok(defi) = obj.extract::<DefiData>() {
1637        return Ok(Data::Defi(Box::new(defi)));
1638    }
1639
1640    let type_name = obj.get_type().name()?;
1641    Err(to_pytype_err(format!("Cannot convert {type_name} to Data")))
1642}
1643
1644#[cfg(test)]
1645mod model_tests {
1646    use nautilus_execution::{
1647        models::fee::{FeeModelAny, MakerTakerFeeModel},
1648        python::{fee::PyFeeModel, fill::PyFillModel},
1649    };
1650    use nautilus_model::{
1651        accounts::AccountAny,
1652        data::{
1653            Bar, BarType, Data,
1654            stubs::{quote_audusd, stub_custom_data},
1655        },
1656        enums::{AccountType, BookType, OmsType, OtoTriggerMode},
1657        identifiers::Venue,
1658        instruments::{
1659            InstrumentAny,
1660            stubs::{audusd_sim, crypto_perpetual_ethusdt},
1661        },
1662        types::{Currency, Money, Price, Quantity},
1663    };
1664    use pyo3::{
1665        IntoPyObjectExt, Python,
1666        exceptions::PyRuntimeError,
1667        ffi::c_str,
1668        types::{PyAnyMethods, PyDict, PyDictMethods},
1669    };
1670    use rstest::rstest;
1671
1672    use crate::{
1673        config::{BacktestEngineConfig, SimulatedVenueConfig},
1674        engine::BacktestEngine,
1675    };
1676
1677    #[rstest]
1678    #[case::tick("TICK")]
1679    #[case::volume("VOLUME")]
1680    #[case::value("VALUE")]
1681    fn test_run_non_time_bar_raises_runtime_error(#[case] aggregation: &str) {
1682        Python::initialize();
1683
1684        let mut engine =
1685            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1686        let config = SimulatedVenueConfig::builder()
1687            .venue(Venue::from("BINANCE"))
1688            .oms_type(OmsType::Netting)
1689            .account_type(AccountType::Margin)
1690            .book_type(BookType::L1_MBP)
1691            .starting_balances(vec![Money::from("1_000_000 USDT")])
1692            .fee_model(FeeModelAny::MakerTaker(MakerTakerFeeModel::zero()).into())
1693            .build()
1694            .unwrap();
1695        engine.0.add_venue(config).unwrap();
1696        let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
1697        engine.0.add_instrument(&instrument).unwrap();
1698        let bar_type =
1699            BarType::from(format!("ETHUSDT-PERP.BINANCE-200-{aggregation}-LAST-EXTERNAL").as_str());
1700
1701        let bar = Bar::new(
1702            bar_type,
1703            Price::from("1500.00"),
1704            Price::from("1505.00"),
1705            Price::from("1490.00"),
1706            Price::from("1502.00"),
1707            Quantity::from("100.000"),
1708            1_000_000_000.into(),
1709            1_000_000_000.into(),
1710        );
1711        engine
1712            .0
1713            .add_data(vec![Data::Bar(bar)], None, true, true)
1714            .unwrap();
1715
1716        let error = engine.py_run(None, None, None, false).unwrap_err();
1717        engine.0.dispose();
1718
1719        Python::attach(|py| {
1720            assert!(error.is_instance_of::<PyRuntimeError>(py));
1721        });
1722
1723        assert_eq!(
1724            error.to_string(),
1725            format!(
1726                "RuntimeError: Bar execution requires time-aggregated bars, received {bar_type}"
1727            )
1728        );
1729    }
1730
1731    #[rstest]
1732    fn test_pyobject_to_data_accepts_custom_data() {
1733        Python::initialize();
1734
1735        Python::attach(|py| {
1736            let custom = stub_custom_data(2, 42, None, None);
1737            let obj = custom.into_py_any(py).unwrap();
1738            let converted = super::pyobject_to_data(py, obj.bind(py)).unwrap();
1739
1740            let Data::Custom(converted) = converted else {
1741                panic!("Expected Data::Custom");
1742            };
1743            assert_eq!(converted.data_type.type_name(), "StubCustomData");
1744            assert_eq!(converted.data.ts_init().as_u64(), 2);
1745        });
1746    }
1747
1748    #[rstest]
1749    fn test_pyobject_to_data_rejects_duck_typed_object() {
1750        Python::initialize();
1751
1752        Python::attach(|py| {
1753            // Mirrors the attribute shape the removed Cython `from_pyobject` path accepted
1754            let obj = py
1755                .eval(
1756                    c_str!(
1757                        "type('FakeQuote', (), {\
1758                            'instrument_id': type('I', (), {'value': 'AUD/USD.SIM'})(), \
1759                            'bid_price': type('P', (), {'raw': 1, 'precision': 5})(), \
1760                            'ask_price': type('P', (), {'raw': 1, 'precision': 5})(), \
1761                            'bid_size': type('Q', (), {'raw': 1, 'precision': 0})(), \
1762                            'ask_size': type('Q', (), {'raw': 1, 'precision': 0})(), \
1763                            'ts_event': 0, \
1764                            'ts_init': 0\
1765                        })()"
1766                    ),
1767                    None,
1768                    None,
1769                )
1770                .unwrap();
1771
1772            let err = super::pyobject_to_data(py, &obj).unwrap_err();
1773
1774            assert_eq!(
1775                err.to_string(),
1776                "TypeError: Cannot convert FakeQuote to Data"
1777            );
1778        });
1779    }
1780
1781    #[rstest]
1782    fn test_add_venue_accepts_python_defined_fee_fill_and_margin_models() {
1783        Python::initialize();
1784
1785        let mut engine =
1786            super::PyBacktestEngine(BacktestEngine::new(BacktestEngineConfig::default()).unwrap());
1787        Python::attach(|py| {
1788            let locals = PyDict::new(py);
1789            locals
1790                .set_item("FeeModel", py.get_type::<PyFeeModel>())
1791                .unwrap();
1792            locals
1793                .set_item("FillModel", py.get_type::<PyFillModel>())
1794                .unwrap();
1795
1796            let fill_model = py
1797                .eval(
1798                    c_str!(
1799                        "type('CustomFillModel', (FillModel,), {\
1800                            'is_limit_filled': lambda self: True, \
1801                            'is_slipped': lambda self: False\
1802                        })()"
1803                    ),
1804                    None,
1805                    Some(&locals),
1806                )
1807                .unwrap();
1808            let fee_model = py
1809                .eval(
1810                    c_str!(
1811                        "type('CustomFeeModel', (FeeModel,), {\
1812                            'get_commission': \
1813                                lambda self, order, fill_quantity, fill_px, instrument: self.commission\
1814                        })()"
1815                    ),
1816                    None,
1817                    Some(&locals),
1818                )
1819                .unwrap();
1820            fee_model
1821                .setattr("commission", Money::from("0 USD").into_py_any(py).unwrap())
1822                .unwrap();
1823
1824            locals
1825                .set_item("initial", Money::from("123.45 USD"))
1826                .unwrap();
1827            locals
1828                .set_item("maintenance", Money::from("67.89 USD"))
1829                .unwrap();
1830            let margin_model = py.eval(c_str!("type('Margin', (), {'calculate_initial_margin': lambda self, *args: initial, 'calculate_maintenance_margin': lambda self, *args: maintenance})()"), Some(&locals), None).unwrap();
1831
1832            engine
1833                .py_add_venue(
1834                    Venue::from("SIM"),
1835                    OmsType::Netting,
1836                    AccountType::Margin,
1837                    vec![Money::from("1_000_000 USD")],
1838                    None::<Currency>,
1839                    None,
1840                    None,
1841                    Some(fill_model.unbind()),
1842                    Some(fee_model.unbind()),
1843                    Some(margin_model.unbind()),
1844                    None,
1845                    None,
1846                    BookType::L1_MBP,
1847                    false,
1848                    true,
1849                    true,
1850                    true,
1851                    true,
1852                    false,
1853                    true,
1854                    true,
1855                    false,
1856                    true,
1857                    false,
1858                    true,
1859                    false,
1860                    false,
1861                    false,
1862                    false,
1863                    OtoTriggerMode::Partial,
1864                    None,
1865                    false,
1866                    None,
1867                    true,
1868                )
1869                .unwrap();
1870
1871            let instrument = InstrumentAny::CurrencyPair(audusd_sim());
1872            engine.0.add_instrument(&instrument).unwrap();
1873            engine
1874                .0
1875                .add_data(vec![Data::Quote(quote_audusd())], None, true, true)
1876                .unwrap();
1877            engine.py_run(None, None, None, false).unwrap();
1878            let account = engine
1879                .0
1880                .kernel()
1881                .cache
1882                .borrow()
1883                .account_for_venue(&Venue::from("SIM"))
1884                .unwrap()
1885                .clone();
1886
1887            let AccountAny::Margin(account) = account else {
1888                panic!("expected margin account")
1889            };
1890
1891            let quantity = Quantity::from(10000);
1892            let price = Price::from("0.81234");
1893            assert_eq!(
1894                account
1895                    .calculate_initial_margin(&instrument, quantity, price, None)
1896                    .unwrap(),
1897                Money::from("123.45 USD")
1898            );
1899            assert_eq!(
1900                account
1901                    .calculate_maintenance_margin(&instrument, quantity, price, None)
1902                    .unwrap(),
1903                Money::from("67.89 USD")
1904            );
1905            assert_eq!(engine.0.list_venues(), vec![Venue::from("SIM")]);
1906        });
1907    }
1908}
1909
1910#[cfg(test)]
1911mod lifecycle_tests {
1912    use indexmap::IndexMap;
1913    use nautilus_model::identifiers::ActorId;
1914    use nautilus_testkit::{cache::TestCacheDatabaseControl, components::StateActor};
1915    use pyo3::{Python, exceptions::PyRuntimeError};
1916    use rstest::rstest;
1917
1918    use super::PyBacktestEngine;
1919    use crate::{config::BacktestEngineConfig, engine::BacktestEngine};
1920
1921    #[rstest]
1922    fn test_end_reports_state_persistence_error() {
1923        Python::initialize();
1924
1925        let actor_id = ActorId::from("PY-END-FAIL-SAVE-ACTOR");
1926        let (database, control) = TestCacheDatabaseControl::create();
1927        control.set_fail_update_actor(true);
1928        let config = BacktestEngineConfig {
1929            save_state: true,
1930            run_analysis: false,
1931            ..Default::default()
1932        };
1933        let mut engine = PyBacktestEngine(BacktestEngine::new(config).unwrap());
1934        engine
1935            .0
1936            .kernel_mut()
1937            .cache
1938            .borrow_mut()
1939            .set_database(Box::new(database));
1940        engine
1941            .0
1942            .add_actor(StateActor::new(
1943                actor_id,
1944                control.clone(),
1945                IndexMap::from([("state".to_string(), b"value".to_vec())]),
1946            ))
1947            .unwrap();
1948        engine.0.kernel_mut().start();
1949        engine.0.kernel_mut().start_trader().unwrap();
1950        engine.0.kernel_mut().stop_trader();
1951
1952        let error = engine.py_end().unwrap_err();
1953        engine.0.dispose();
1954
1955        Python::attach(|py| {
1956            assert!(error.is_instance_of::<PyRuntimeError>(py));
1957        });
1958        assert_eq!(
1959            error.to_string(),
1960            "RuntimeError: Failed to save component state: actor PY-END-FAIL-SAVE-ACTOR \
1961             persistence: test actor update failure"
1962        );
1963        assert_eq!(
1964            control.events(),
1965            vec![
1966                "actor.on_start",
1967                "actor.on_stop",
1968                "actor.on_save",
1969                "actor.update:PY-END-FAIL-SAVE-ACTOR",
1970                "database.close",
1971            ]
1972        );
1973    }
1974}