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