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