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    python::{actor::PyDataActor, cache::PyCache},
24};
25use nautilus_core::{
26    UUID4, UnixNanos,
27    python::{to_pyruntime_err, to_pytype_err, to_pyvalue_err},
28};
29use nautilus_execution::models::{
30    fee::{
31        CappedOptionFeeModel, FeeModelAny, FixedFeeModel, MakerTakerFeeModel, PerContractFeeModel,
32        TieredNotionalOptionFeeModel,
33    },
34    fill::{
35        BestPriceFillModel, CompetitionAwareFillModel, DefaultFillModel, FillModelAny,
36        LimitOrderPartialFillModel, MarketHoursFillModel, OneTickSlippageFillModel,
37        ProbabilisticFillModel, SizeAwareFillModel, ThreeTierFillModel, TwoTierFillModel,
38        VolumeSensitiveFillModel,
39    },
40    latency::{LatencyModelAny, StaticLatencyModel},
41};
42#[cfg(feature = "defi")]
43use nautilus_model::defi::DefiData;
44use nautilus_model::{
45    accounts::margin_model::{LeveragedMarginModel, MarginModelAny, StandardMarginModel},
46    data::{
47        Bar, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
48        MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDeltas, OrderBookDeltas_API,
49        OrderBookDepth10, QuoteTick, TradeTick,
50    },
51    enums::{AccountType, BookType, OmsType, OtoTriggerMode},
52    identifiers::{ActorId, ClientId, ComponentId, InstrumentId, StrategyId, TraderId, Venue},
53    python::instruments::pyobject_to_instrument_any,
54    types::{Currency, Money, Price},
55};
56#[cfg(feature = "examples")]
57use nautilus_trading::examples::{
58    actors::{BookImbalanceActor, BookImbalanceActorConfig},
59    strategies::{
60        CompositeMarketMaker, CompositeMarketMakerConfig, DeltaNeutralVol, DeltaNeutralVolConfig,
61        EmaCross, EmaCrossConfig, GridMarketMaker, GridMarketMakerConfig, HurstVpinDirectional,
62        HurstVpinDirectionalConfig,
63    },
64};
65use nautilus_trading::{
66    ImportableStrategyConfig,
67    python::strategy::{PyStrategy, PyStrategyInner},
68};
69use pyo3::prelude::*;
70use rust_decimal::Decimal;
71
72use super::node::create_config_instance;
73use crate::{
74    config::{BacktestEngineConfig, SimulatedVenueConfig},
75    engine::BacktestEngine,
76    modules::{FXRolloverInterestModule, SimulationModuleAny},
77    result::BacktestResult,
78};
79
80/// PyO3 wrapper around [`BacktestEngine`].
81///
82/// Exposes the backtest engine to Python as `BacktestEngine`.
83/// Uses `unsendable` because the inner engine holds `Rc<RefCell<...>>`.
84#[pyo3::pyclass(
85    module = "nautilus_trader.core.nautilus_pyo3.backtest",
86    name = "BacktestEngine",
87    unsendable
88)]
89#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
90#[derive(Debug)]
91pub struct PyBacktestEngine(BacktestEngine);
92
93// DeFi methods live in their own fully gated `#[pymethods]` block (multiple-pymethods is enabled)
94// so the `gen_stub`/pyo3 expansion never references `DefiData` in non-DeFi builds.
95#[cfg(feature = "defi")]
96#[pyo3_stub_gen::derive::gen_stub_pymethods]
97#[pymethods]
98impl PyBacktestEngine {
99    /// Adds DeFi data to the engine.
100    #[pyo3(name = "add_defi_data", signature = (data, client_id=None, sort=true))]
101    fn py_add_defi_data(
102        &mut self,
103        data: Vec<DefiData>,
104        client_id: Option<ClientId>,
105        sort: bool,
106    ) -> PyResult<()> {
107        self.0
108            .add_defi_data(data, client_id, sort)
109            .map_err(to_pyruntime_err)
110    }
111}
112
113#[pyo3_stub_gen::derive::gen_stub_pymethods]
114#[pymethods]
115impl PyBacktestEngine {
116    #[new]
117    fn py_new(config: BacktestEngineConfig) -> PyResult<Self> {
118        let engine = BacktestEngine::new(config).map_err(to_pyruntime_err)?;
119        Ok(Self(engine))
120    }
121
122    /// Adds a simulated exchange with the given parameters to the engine.
123    ///
124    /// # Liquidation parameters
125    ///
126    /// - `liquidation_enabled` (bool, default `False`): if margin liquidation should be
127    ///   triggered when the account's equity falls to or below the maintenance
128    ///   margin threshold scaled by `liquidation_trigger_ratio`.
129    /// - `liquidation_trigger_ratio` (float, optional, default `1.0`): the ratio of
130    ///   maintenance margin used as the liquidation threshold. A value of `1.0`
131    ///   liquidates when equity <= maintenance margin; higher values trigger earlier.
132    /// - `liquidation_cancel_open_orders` (bool, default `True`): if open resting
133    ///   orders for the venue should be cancelled before synthetic close-out fills
134    ///   are emitted for open positions.
135    #[pyo3(
136        name = "add_venue",
137        signature = (
138            venue,
139            oms_type,
140            account_type,
141            starting_balances,
142            base_currency = None,
143            default_leverage = None,
144            leverages = None,
145            margin_model = None,
146            fill_model = None,
147            fee_model = None,
148            latency_model = None,
149            modules = None,
150            book_type = BookType::L1_MBP,
151            routing = false,
152            reject_stop_orders = true,
153            support_gtd_orders = true,
154            support_contingent_orders = true,
155            use_position_ids = true,
156            use_random_ids = false,
157            use_reduce_only = true,
158            use_message_queue = true,
159            use_market_order_acks = false,
160            bar_execution = true,
161            bar_adaptive_high_low_ordering = false,
162            trade_execution = true,
163            liquidity_consumption = false,
164            queue_position = false,
165            allow_cash_borrowing = false,
166            frozen_account = false,
167            oto_trigger_mode = OtoTriggerMode::Partial,
168            price_protection_points = None,
169            settlement_prices = None,
170            liquidation_enabled = false,
171            liquidation_trigger_ratio = None,
172            liquidation_cancel_open_orders = true,
173        )
174    )]
175    #[expect(clippy::too_many_arguments)]
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        settlement_prices: Option<HashMap<InstrumentId, Price>>,
210        liquidation_enabled: bool,
211        liquidation_trigger_ratio: Option<f64>,
212        liquidation_cancel_open_orders: bool,
213    ) -> PyResult<()> {
214        let leverages: AHashMap<InstrumentId, Decimal> = leverages
215            .map(|m| m.into_iter().collect())
216            .unwrap_or_default();
217        let settlement_prices: AHashMap<InstrumentId, Price> = settlement_prices
218            .map(|m| m.into_iter().collect())
219            .unwrap_or_default();
220        let margin_model = margin_model
221            .map(|obj| Python::attach(|py| pyobject_to_margin_model_any(py, obj.bind(py))))
222            .transpose()?;
223        let fill_model = fill_model
224            .map(|obj| Python::attach(|py| pyobject_to_fill_model_any(py, obj.bind(py))))
225            .transpose()?
226            .unwrap_or_default();
227        let fee_model = fee_model
228            .map(|obj| Python::attach(|py| pyobject_to_fee_model_any(py, obj.bind(py))))
229            .transpose()?
230            .unwrap_or_default();
231        let latency_model = latency_model
232            .map(|obj| Python::attach(|py| pyobject_to_latency_model_any(py, obj.bind(py))))
233            .transpose()?
234            .map(Into::into);
235        let modules = modules
236            .map(|objs| {
237                objs.into_iter()
238                    .map(|obj| {
239                        Python::attach(|py| pyobject_to_simulation_module_any(py, obj.bind(py)))
240                    })
241                    .collect::<PyResult<Vec<_>>>()
242            })
243            .transpose()?
244            .unwrap_or_default()
245            .into_iter()
246            .map(Into::into)
247            .collect();
248
249        let sim_config = SimulatedVenueConfig::builder()
250            .venue(venue)
251            .oms_type(oms_type)
252            .account_type(account_type)
253            .book_type(book_type)
254            .starting_balances(starting_balances)
255            .maybe_base_currency(base_currency)
256            .maybe_default_leverage(default_leverage)
257            .leverages(leverages)
258            .maybe_margin_model(margin_model)
259            .modules(modules)
260            .fill_model(fill_model)
261            .fee_model(fee_model)
262            .maybe_latency_model(latency_model)
263            .routing(routing)
264            .reject_stop_orders(reject_stop_orders)
265            .support_gtd_orders(support_gtd_orders)
266            .support_contingent_orders(support_contingent_orders)
267            .use_position_ids(use_position_ids)
268            .use_random_ids(use_random_ids)
269            .use_reduce_only(use_reduce_only)
270            .use_message_queue(use_message_queue)
271            .use_market_order_acks(use_market_order_acks)
272            .bar_execution(bar_execution)
273            .bar_adaptive_high_low_ordering(bar_adaptive_high_low_ordering)
274            .trade_execution(trade_execution)
275            .liquidity_consumption(liquidity_consumption)
276            .allow_cash_borrowing(allow_cash_borrowing)
277            .frozen_account(frozen_account)
278            .queue_position(queue_position)
279            .oto_full_trigger(oto_trigger_mode == OtoTriggerMode::Full)
280            .maybe_price_protection_points(price_protection_points)
281            .liquidation_enabled(liquidation_enabled)
282            .liquidation_trigger_ratio(liquidation_trigger_ratio.unwrap_or(1.0))
283            .liquidation_cancel_open_orders(liquidation_cancel_open_orders)
284            .build();
285
286        self.0.add_venue(sim_config).map_err(to_pyruntime_err)?;
287
288        for (instrument_id, price) in settlement_prices {
289            self.0
290                .set_settlement_price(venue, instrument_id, price)
291                .map_err(to_pyruntime_err)?;
292        }
293
294        Ok(())
295    }
296
297    /// Changes the fill model for a venue.
298    #[pyo3(name = "change_fill_model")]
299    #[expect(clippy::needless_pass_by_value)]
300    fn py_change_fill_model(
301        &mut self,
302        py: Python,
303        venue: Venue,
304        fill_model: Py<PyAny>,
305    ) -> PyResult<()> {
306        let fill_model = pyobject_to_fill_model_any(py, fill_model.bind(py))?;
307        self.0.change_fill_model(venue, fill_model);
308        Ok(())
309    }
310
311    /// Adds data to the engine.
312    #[pyo3(
313        name = "add_data",
314        signature = (data, client_id=None, validate=true, sort=true)
315    )]
316    fn py_add_data(
317        &mut self,
318        py: Python,
319        data: Vec<Py<PyAny>>,
320        client_id: Option<ClientId>,
321        validate: bool,
322        sort: bool,
323    ) -> PyResult<()> {
324        let rust_data: Vec<Data> = data
325            .into_iter()
326            .map(|obj| pyobject_to_data(py, obj.bind(py)))
327            .collect::<PyResult<_>>()?;
328        self.0
329            .add_data(rust_data, client_id, validate, sort)
330            .map_err(to_pyruntime_err)
331    }
332
333    /// Adds an instrument to the engine.
334    #[pyo3(name = "add_instrument")]
335    fn py_add_instrument(&mut self, py: Python, instrument: Py<PyAny>) -> PyResult<()> {
336        let instrument_any = pyobject_to_instrument_any(py, instrument)?;
337        self.0
338            .add_instrument(&instrument_any)
339            .map_err(to_pyruntime_err)
340    }
341
342    /// Adds an actor from an importable config.
343    #[allow(
344        unsafe_code,
345        reason = "Required for Python actor component registration"
346    )]
347    #[pyo3(name = "add_actor_from_config")]
348    #[expect(clippy::needless_pass_by_value)]
349    fn py_add_actor_from_config(
350        &mut self,
351        _py: Python,
352        config: ImportableActorConfig,
353    ) -> PyResult<()> {
354        log::debug!("`add_actor_from_config` with: {config:?}");
355
356        let parts: Vec<&str> = config.actor_path.split(':').collect();
357        if parts.len() != 2 {
358            return Err(to_pyvalue_err(
359                "actor_path must be in format 'module.path:ClassName'",
360            ));
361        }
362        let (module_name, class_name) = (parts[0], parts[1]);
363
364        log::info!("Importing actor from module: {module_name} class: {class_name}");
365
366        let (python_actor, actor_id) =
367            Python::attach(|py| -> anyhow::Result<(Py<PyAny>, ActorId)> {
368                let actor_module = py
369                    .import(module_name)
370                    .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
371                let actor_class = actor_module
372                    .getattr(class_name)
373                    .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
374
375                let config_instance =
376                    create_config_instance(py, &config.config_path, &config.config)?;
377
378                let python_actor = if let Some(config_obj) = config_instance.clone() {
379                    actor_class.call1((config_obj,))?
380                } else {
381                    actor_class.call0()?
382                };
383
384                let mut py_data_actor_ref = python_actor
385                    .extract::<PyRefMut<PyDataActor>>()
386                    .map_err(Into::<PyErr>::into)
387                    .map_err(|e| anyhow::anyhow!("Failed to extract PyDataActor: {e}"))?;
388
389                if let Some(config_obj) = config_instance.as_ref() {
390                    if let Ok(actor_id) = config_obj.getattr("actor_id")
391                        && !actor_id.is_none()
392                    {
393                        let actor_id_val = if let Ok(actor_id_val) = actor_id.extract::<ActorId>() {
394                            actor_id_val
395                        } else if let Ok(actor_id_str) = actor_id.extract::<String>() {
396                            ActorId::new_checked(&actor_id_str)?
397                        } else {
398                            anyhow::bail!("Invalid `actor_id` type");
399                        };
400                        py_data_actor_ref.set_actor_id(actor_id_val);
401                    }
402
403                    if let Ok(log_events) = config_obj.getattr("log_events")
404                        && let Ok(log_events_val) = log_events.extract::<bool>()
405                    {
406                        py_data_actor_ref.set_log_events(log_events_val);
407                    }
408
409                    if let Ok(log_commands) = config_obj.getattr("log_commands")
410                        && let Ok(log_commands_val) = log_commands.extract::<bool>()
411                    {
412                        py_data_actor_ref.set_log_commands(log_commands_val);
413                    }
414                }
415
416                py_data_actor_ref.set_python_instance(python_actor.clone().unbind());
417                let actor_id = py_data_actor_ref.actor_id();
418
419                Ok((python_actor.unbind(), actor_id))
420            })
421            .map_err(to_pyruntime_err)?;
422
423        if self
424            .0
425            .kernel()
426            .trader
427            .borrow()
428            .actor_ids()
429            .contains(&actor_id)
430        {
431            return Err(to_pyruntime_err(format!(
432                "Actor '{actor_id}' is already registered"
433            )));
434        }
435
436        let trader_id = self.0.kernel().config.trader_id();
437        let cache = self.0.kernel().cache.clone();
438        let component_id = ComponentId::new(actor_id.inner().as_str());
439        let clock = self
440            .0
441            .kernel_mut()
442            .trader
443            .borrow_mut()
444            .create_component_clock(component_id);
445
446        Python::attach(|py| -> anyhow::Result<()> {
447            let py_actor = python_actor.bind(py);
448            let mut py_data_actor_ref = py_actor
449                .extract::<PyRefMut<PyDataActor>>()
450                .map_err(Into::<PyErr>::into)
451                .map_err(|e| anyhow::anyhow!("Failed to extract PyDataActor: {e}"))?;
452
453            py_data_actor_ref
454                .register(trader_id, clock, cache)
455                .map_err(|e| anyhow::anyhow!("Failed to register PyDataActor: {e}"))?;
456
457            Ok(())
458        })
459        .map_err(to_pyruntime_err)?;
460
461        Python::attach(|py| -> anyhow::Result<()> {
462            let py_actor = python_actor.bind(py);
463            let py_data_actor_ref = py_actor
464                .cast::<PyDataActor>()
465                .map_err(|e| anyhow::anyhow!("Failed to downcast to PyDataActor: {e}"))?;
466            py_data_actor_ref.borrow().register_in_global_registries();
467            Ok(())
468        })
469        .map_err(to_pyruntime_err)?;
470
471        self.0
472            .kernel_mut()
473            .trader
474            .borrow_mut()
475            .add_actor_id_for_lifecycle(actor_id)
476            .map_err(to_pyruntime_err)?;
477
478        log::info!("Registered Python actor {actor_id}");
479        Ok(())
480    }
481
482    /// Adds a strategy from an importable config.
483    #[allow(
484        unsafe_code,
485        reason = "Required for Python strategy component registration"
486    )]
487    #[pyo3(name = "add_strategy_from_config")]
488    #[expect(clippy::needless_pass_by_value)]
489    fn py_add_strategy_from_config(
490        &mut self,
491        _py: Python,
492        config: ImportableStrategyConfig,
493    ) -> PyResult<()> {
494        log::debug!("`add_strategy_from_config` with: {config:?}");
495
496        let parts: Vec<&str> = config.strategy_path.split(':').collect();
497        if parts.len() != 2 {
498            return Err(to_pyvalue_err(
499                "strategy_path must be in format 'module.path:ClassName'",
500            ));
501        }
502        let (module_name, class_name) = (parts[0], parts[1]);
503
504        log::info!("Importing strategy from module: {module_name} class: {class_name}");
505
506        let (python_strategy, strategy_id) =
507            Python::attach(|py| -> anyhow::Result<(Py<PyAny>, StrategyId)> {
508                let strategy_module = py
509                    .import(module_name)
510                    .map_err(|e| anyhow::anyhow!("Failed to import module {module_name}: {e}"))?;
511                let strategy_class = strategy_module
512                    .getattr(class_name)
513                    .map_err(|e| anyhow::anyhow!("Failed to get class {class_name}: {e}"))?;
514
515                let config_instance =
516                    create_config_instance(py, &config.config_path, &config.config)?;
517
518                let python_strategy = if let Some(config_obj) = config_instance.clone() {
519                    strategy_class.call1((config_obj,))?
520                } else {
521                    strategy_class.call0()?
522                };
523
524                let mut py_strategy_ref = python_strategy
525                    .extract::<PyRefMut<PyStrategy>>()
526                    .map_err(Into::<PyErr>::into)
527                    .map_err(|e| anyhow::anyhow!("Failed to extract PyStrategy: {e}"))?;
528
529                if let Some(config_obj) = config_instance.as_ref() {
530                    if let Ok(strategy_id) = config_obj.getattr("strategy_id")
531                        && !strategy_id.is_none()
532                    {
533                        let strategy_id_val = if let Ok(sid) = strategy_id.extract::<StrategyId>() {
534                            sid
535                        } else if let Ok(sid_str) = strategy_id.extract::<String>() {
536                            StrategyId::new_checked(&sid_str)?
537                        } else {
538                            anyhow::bail!("Invalid `strategy_id` type");
539                        };
540                        py_strategy_ref.set_strategy_id(strategy_id_val)?;
541                    }
542
543                    if let Ok(order_id_tag) = config_obj.getattr("order_id_tag")
544                        && !order_id_tag.is_none()
545                    {
546                        let order_id_tag_val = order_id_tag
547                            .extract::<String>()
548                            .map_err(|e| anyhow::anyhow!("Invalid `order_id_tag` type: {e}"))?;
549                        py_strategy_ref.set_order_id_tag(&order_id_tag_val)?;
550                    }
551
552                    if let Ok(log_events) = config_obj.getattr("log_events")
553                        && let Ok(log_events_val) = log_events.extract::<bool>()
554                    {
555                        py_strategy_ref.set_log_events(log_events_val);
556                    }
557
558                    if let Ok(log_commands) = config_obj.getattr("log_commands")
559                        && let Ok(log_commands_val) = log_commands.extract::<bool>()
560                    {
561                        py_strategy_ref.set_log_commands(log_commands_val);
562                    }
563                }
564
565                py_strategy_ref.set_python_instance(python_strategy.clone().unbind());
566                let strategy_id = py_strategy_ref.strategy_id();
567
568                Ok((python_strategy.unbind(), strategy_id))
569            })
570            .map_err(to_pyruntime_err)?;
571
572        if self
573            .0
574            .kernel()
575            .trader
576            .borrow()
577            .strategy_ids()
578            .contains(&strategy_id)
579        {
580            return Err(to_pyruntime_err(format!(
581                "Strategy '{strategy_id}' is already registered"
582            )));
583        }
584
585        let trader_id = self.0.kernel().config.trader_id();
586        let cache = self.0.kernel().cache.clone();
587        let portfolio = self.0.kernel().portfolio.clone();
588        let component_id = ComponentId::new(strategy_id.inner().as_str());
589        let clock = self
590            .0
591            .kernel_mut()
592            .trader
593            .borrow_mut()
594            .create_component_clock(component_id);
595
596        Python::attach(|py| -> anyhow::Result<()> {
597            let py_strategy = python_strategy.bind(py);
598            let mut py_strategy_ref = py_strategy
599                .extract::<PyRefMut<PyStrategy>>()
600                .map_err(Into::<PyErr>::into)
601                .map_err(|e| anyhow::anyhow!("Failed to extract PyStrategy: {e}"))?;
602
603            py_strategy_ref
604                .register(trader_id, clock, cache, portfolio)
605                .map_err(|e| anyhow::anyhow!("Failed to register PyStrategy: {e}"))?;
606
607            Ok(())
608        })
609        .map_err(to_pyruntime_err)?;
610
611        Python::attach(|py| -> anyhow::Result<()> {
612            let py_strategy = python_strategy.bind(py);
613            let py_strategy_ref = py_strategy
614                .cast::<PyStrategy>()
615                .map_err(|e| anyhow::anyhow!("Failed to downcast to PyStrategy: {e}"))?;
616            py_strategy_ref.borrow().register_in_global_registries();
617            Ok(())
618        })
619        .map_err(to_pyruntime_err)?;
620
621        self.0
622            .kernel_mut()
623            .trader
624            .borrow_mut()
625            .add_strategy_id_with_subscriptions::<PyStrategyInner>(strategy_id)
626            .map_err(to_pyruntime_err)?;
627
628        log::info!("Registered Python strategy {strategy_id}");
629        Ok(())
630    }
631
632    /// Adds a compiled-in native Rust strategy from its type name and config.
633    ///
634    /// The type name determines which built-in strategy is constructed.
635    /// All execution happens in Rust; Python is the configuration layer.
636    #[cfg(feature = "examples")]
637    #[pyo3(name = "add_native_strategy")]
638    fn py_add_native_strategy(
639        &mut self,
640        type_name: &str,
641        config: &Bound<'_, PyAny>,
642    ) -> PyResult<()> {
643        let register = native_strategy_register(type_name).ok_or_else(|| {
644            to_pytype_err(format!("Unsupported native strategy type: {type_name}"))
645        })?;
646        register(&mut self.0, config)
647    }
648
649    /// Adds a compiled-in native Rust actor from its type name and config.
650    ///
651    /// The type name determines which built-in actor is constructed.
652    /// All execution happens in Rust; Python is the configuration layer.
653    #[cfg(feature = "examples")]
654    #[pyo3(name = "add_native_actor")]
655    fn py_add_native_actor(&mut self, type_name: &str, config: &Bound<'_, PyAny>) -> PyResult<()> {
656        let register = native_actor_register(type_name)
657            .ok_or_else(|| to_pytype_err(format!("Unsupported native actor type: {type_name}")))?;
658        register(&mut self.0, config)
659    }
660
661    /// Runs the backtest engine.
662    #[pyo3(
663        name = "run",
664        signature = (start=None, end=None, run_config_id=None, streaming=false)
665    )]
666    fn py_run(
667        &mut self,
668        start: Option<u64>,
669        end: Option<u64>,
670        run_config_id: Option<String>,
671        streaming: bool,
672    ) -> PyResult<()> {
673        self.0
674            .run(
675                start.map(UnixNanos::from),
676                end.map(UnixNanos::from),
677                run_config_id,
678                streaming,
679            )
680            .map_err(to_pyruntime_err)
681    }
682
683    /// Ends the backtest run, finalizing results.
684    #[pyo3(name = "end")]
685    fn py_end(&mut self) {
686        self.0.end();
687    }
688
689    /// Resets the engine state for a new run.
690    #[pyo3(name = "reset")]
691    fn py_reset(&mut self) {
692        self.0.reset();
693    }
694
695    /// Disposes of the engine, releasing all resources.
696    #[pyo3(name = "dispose")]
697    fn py_dispose(&mut self) {
698        self.0.dispose();
699    }
700
701    /// Returns the backtest result from the last run.
702    #[pyo3(name = "get_result")]
703    fn py_get_result(&self) -> BacktestResult {
704        self.0.get_result()
705    }
706
707    /// Clears all data from the engine.
708    #[pyo3(name = "clear_data")]
709    fn py_clear_data(&mut self) {
710        self.0.clear_data();
711    }
712
713    /// Clears all actors from the engine.
714    #[pyo3(name = "clear_actors")]
715    fn py_clear_actors(&mut self) -> PyResult<()> {
716        self.0.clear_actors().map_err(to_pyruntime_err)
717    }
718
719    /// Clears all strategies from the engine.
720    #[pyo3(name = "clear_strategies")]
721    fn py_clear_strategies(&mut self) -> PyResult<()> {
722        self.0.clear_strategies().map_err(to_pyruntime_err)
723    }
724
725    /// Clears all execution algorithms from the engine.
726    #[pyo3(name = "clear_exec_algorithms")]
727    fn py_clear_exec_algorithms(&mut self) -> PyResult<()> {
728        self.0.clear_exec_algorithms().map_err(to_pyruntime_err)
729    }
730
731    /// Adds multiple actors from importable configs. Stops at the first error.
732    #[pyo3(name = "add_actors_from_configs")]
733    fn py_add_actors_from_configs(
734        &mut self,
735        py: Python,
736        configs: Vec<ImportableActorConfig>,
737    ) -> PyResult<()> {
738        for config in configs {
739            self.py_add_actor_from_config(py, config)?;
740        }
741        Ok(())
742    }
743
744    /// Adds multiple strategies from importable configs. Stops at the first error.
745    #[pyo3(name = "add_strategies_from_configs")]
746    fn py_add_strategies_from_configs(
747        &mut self,
748        py: Python,
749        configs: Vec<ImportableStrategyConfig>,
750    ) -> PyResult<()> {
751        for config in configs {
752            self.py_add_strategy_from_config(py, config)?;
753        }
754        Ok(())
755    }
756
757    /// Sorts the engine's internal data stream by timestamp.
758    #[pyo3(name = "sort_data")]
759    fn py_sort_data(&mut self) {
760        self.0.sort_data();
761    }
762
763    /// Returns the trader ID for this engine.
764    #[getter]
765    #[pyo3(name = "trader_id")]
766    fn py_trader_id(&self) -> TraderId {
767        self.0.trader_id()
768    }
769
770    /// Returns the machine ID for this engine.
771    #[getter]
772    #[pyo3(name = "machine_id")]
773    fn py_machine_id(&self) -> String {
774        self.0.machine_id().to_string()
775    }
776
777    /// Returns the unique instance ID for this engine.
778    #[getter]
779    #[pyo3(name = "instance_id")]
780    fn py_instance_id(&self) -> UUID4 {
781        self.0.instance_id()
782    }
783
784    /// Returns the current iteration count.
785    #[getter]
786    #[pyo3(name = "iteration")]
787    fn py_iteration(&self) -> usize {
788        self.0.iteration()
789    }
790
791    /// Returns the last run config ID, if any.
792    #[getter]
793    #[pyo3(name = "run_config_id")]
794    fn py_run_config_id(&self) -> Option<String> {
795        self.0.run_config_id().map(str::to_string)
796    }
797
798    /// Returns the last run ID, if any.
799    #[getter]
800    #[pyo3(name = "run_id")]
801    fn py_run_id(&self) -> Option<UUID4> {
802        self.0.run_id()
803    }
804
805    /// Returns when the last run started, in nanoseconds since the UNIX epoch.
806    #[getter]
807    #[pyo3(name = "run_started")]
808    fn py_run_started(&self) -> Option<u64> {
809        self.0.run_started().map(|n| n.as_u64())
810    }
811
812    /// Returns when the last run finished, in nanoseconds since the UNIX epoch.
813    #[getter]
814    #[pyo3(name = "run_finished")]
815    fn py_run_finished(&self) -> Option<u64> {
816        self.0.run_finished().map(|n| n.as_u64())
817    }
818
819    /// Returns the last backtest range start, in nanoseconds since the UNIX epoch.
820    #[getter]
821    #[pyo3(name = "backtest_start")]
822    fn py_backtest_start(&self) -> Option<u64> {
823        self.0.backtest_start().map(|n| n.as_u64())
824    }
825
826    /// Returns the last backtest range end, in nanoseconds since the UNIX epoch.
827    #[getter]
828    #[pyo3(name = "backtest_end")]
829    fn py_backtest_end(&self) -> Option<u64> {
830        self.0.backtest_end().map(|n| n.as_u64())
831    }
832
833    /// Returns the list of registered venue identifiers.
834    #[pyo3(name = "list_venues")]
835    fn py_list_venues(&self) -> Vec<Venue> {
836        self.0.list_venues()
837    }
838
839    /// Returns the cache shared with the kernel and registered components.
840    #[getter]
841    #[pyo3(name = "cache")]
842    fn py_cache(&self) -> PyCache {
843        PyCache::from_rc(self.0.kernel().cache.clone())
844    }
845
846    fn __repr__(&self) -> String {
847        format!("{:?}", self.0)
848    }
849}
850
851impl PyBacktestEngine {
852    /// Provides access to the inner [`BacktestEngine`].
853    #[must_use]
854    pub fn inner(&self) -> &BacktestEngine {
855        &self.0
856    }
857
858    /// Provides mutable access to the inner [`BacktestEngine`].
859    pub fn inner_mut(&mut self) -> &mut BacktestEngine {
860        &mut self.0
861    }
862}
863
864#[cfg(feature = "examples")]
865type NativeStrategyRegister = for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
866
867#[cfg(feature = "examples")]
868type NativeActorRegister = for<'py> fn(&mut BacktestEngine, &Bound<'py, PyAny>) -> PyResult<()>;
869
870#[cfg(feature = "examples")]
871fn native_strategy_register(type_name: &str) -> Option<NativeStrategyRegister> {
872    match type_name {
873        "CompositeMarketMaker" => Some(register_composite_market_maker),
874        "DeltaNeutralVol" => Some(register_delta_neutral_vol),
875        "EmaCross" => Some(register_ema_cross),
876        "GridMarketMaker" => Some(register_grid_market_maker),
877        "HurstVpinDirectional" => Some(register_hurst_vpin_directional),
878        _ => None,
879    }
880}
881
882#[cfg(feature = "examples")]
883fn native_actor_register(type_name: &str) -> Option<NativeActorRegister> {
884    match type_name {
885        "BookImbalanceActor" => Some(register_book_imbalance_actor),
886        _ => None,
887    }
888}
889
890#[cfg(feature = "examples")]
891fn register_composite_market_maker(
892    engine: &mut BacktestEngine,
893    config: &Bound<'_, PyAny>,
894) -> PyResult<()> {
895    let config = config.extract::<CompositeMarketMakerConfig>()?;
896    engine
897        .add_strategy(CompositeMarketMaker::new(config))
898        .map_err(to_pyruntime_err)
899}
900
901#[cfg(feature = "examples")]
902fn register_delta_neutral_vol(
903    engine: &mut BacktestEngine,
904    config: &Bound<'_, PyAny>,
905) -> PyResult<()> {
906    let config = config.extract::<DeltaNeutralVolConfig>()?;
907    engine
908        .add_strategy(DeltaNeutralVol::new(config))
909        .map_err(to_pyruntime_err)
910}
911
912#[cfg(feature = "examples")]
913fn register_ema_cross(engine: &mut BacktestEngine, config: &Bound<'_, PyAny>) -> PyResult<()> {
914    let config = config.extract::<EmaCrossConfig>()?;
915    engine
916        .add_strategy(EmaCross::from_config(config))
917        .map_err(to_pyruntime_err)
918}
919
920#[cfg(feature = "examples")]
921fn register_grid_market_maker(
922    engine: &mut BacktestEngine,
923    config: &Bound<'_, PyAny>,
924) -> PyResult<()> {
925    let config = config.extract::<GridMarketMakerConfig>()?;
926    engine
927        .add_strategy(GridMarketMaker::new(config))
928        .map_err(to_pyruntime_err)
929}
930
931#[cfg(feature = "examples")]
932fn register_hurst_vpin_directional(
933    engine: &mut BacktestEngine,
934    config: &Bound<'_, PyAny>,
935) -> PyResult<()> {
936    let config = config.extract::<HurstVpinDirectionalConfig>()?;
937    engine
938        .add_strategy(HurstVpinDirectional::new(config))
939        .map_err(to_pyruntime_err)
940}
941
942#[cfg(feature = "examples")]
943fn register_book_imbalance_actor(
944    engine: &mut BacktestEngine,
945    config: &Bound<'_, PyAny>,
946) -> PyResult<()> {
947    let config = config.extract::<BookImbalanceActorConfig>()?;
948    engine
949        .add_actor(BookImbalanceActor::from_config(config))
950        .map_err(to_pyruntime_err)
951}
952
953#[cfg(all(test, feature = "examples"))]
954mod tests {
955    use pyo3::{Python, types::PyDict};
956    use rstest::rstest;
957
958    use crate::{config::BacktestEngineConfig, engine::BacktestEngine};
959
960    #[rstest]
961    #[case("CompositeMarketMaker")]
962    #[case("DeltaNeutralVol")]
963    #[case("EmaCross")]
964    #[case("GridMarketMaker")]
965    #[case("HurstVpinDirectional")]
966    fn test_native_strategy_register_accepts_supported_names(#[case] type_name: &str) {
967        assert!(super::native_strategy_register(type_name).is_some());
968    }
969
970    #[rstest]
971    #[case("BookImbalanceActor")]
972    fn test_native_actor_register_accepts_supported_names(#[case] type_name: &str) {
973        assert!(super::native_actor_register(type_name).is_some());
974    }
975
976    #[rstest]
977    fn test_native_register_rejects_unknown_names() {
978        assert!(super::native_strategy_register("UnknownStrategy").is_none());
979        assert!(super::native_actor_register("UnknownActor").is_none());
980    }
981
982    #[rstest]
983    fn test_native_strategy_register_rejects_mismatched_config() {
984        Python::initialize();
985
986        let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
987        Python::attach(|py| {
988            let register = super::native_strategy_register("EmaCross").unwrap();
989            let config = PyDict::new(py);
990            let error = register(&mut engine, config.as_any()).unwrap_err();
991
992            assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
993        });
994    }
995
996    #[rstest]
997    fn test_native_actor_register_rejects_mismatched_config() {
998        Python::initialize();
999
1000        let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
1001        Python::attach(|py| {
1002            let register = super::native_actor_register("BookImbalanceActor").unwrap();
1003            let config = PyDict::new(py);
1004            let error = register(&mut engine, config.as_any()).unwrap_err();
1005
1006            assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
1007        });
1008    }
1009}
1010
1011pub(crate) fn pyobject_to_fill_model_any(
1012    _py: Python,
1013    obj: &Bound<'_, PyAny>,
1014) -> PyResult<FillModelAny> {
1015    if let Ok(m) = obj.extract::<DefaultFillModel>() {
1016        return Ok(FillModelAny::Default(m));
1017    }
1018
1019    if let Ok(m) = obj.extract::<BestPriceFillModel>() {
1020        return Ok(FillModelAny::BestPrice(m));
1021    }
1022
1023    if let Ok(m) = obj.extract::<OneTickSlippageFillModel>() {
1024        return Ok(FillModelAny::OneTickSlippage(m));
1025    }
1026
1027    if let Ok(m) = obj.extract::<ProbabilisticFillModel>() {
1028        return Ok(FillModelAny::Probabilistic(m));
1029    }
1030
1031    if let Ok(m) = obj.extract::<TwoTierFillModel>() {
1032        return Ok(FillModelAny::TwoTier(m));
1033    }
1034
1035    if let Ok(m) = obj.extract::<ThreeTierFillModel>() {
1036        return Ok(FillModelAny::ThreeTier(m));
1037    }
1038
1039    if let Ok(m) = obj.extract::<LimitOrderPartialFillModel>() {
1040        return Ok(FillModelAny::LimitOrderPartialFill(m));
1041    }
1042
1043    if let Ok(m) = obj.extract::<SizeAwareFillModel>() {
1044        return Ok(FillModelAny::SizeAware(m));
1045    }
1046
1047    if let Ok(m) = obj.extract::<CompetitionAwareFillModel>() {
1048        return Ok(FillModelAny::CompetitionAware(m));
1049    }
1050
1051    if let Ok(m) = obj.extract::<VolumeSensitiveFillModel>() {
1052        return Ok(FillModelAny::VolumeSensitive(m));
1053    }
1054
1055    if let Ok(m) = obj.extract::<MarketHoursFillModel>() {
1056        return Ok(FillModelAny::MarketHours(m));
1057    }
1058
1059    let type_name = obj.get_type().name()?;
1060    Err(to_pytype_err(format!(
1061        "Cannot convert {type_name} to FillModel"
1062    )))
1063}
1064
1065pub(crate) fn pyobject_to_fee_model_any(
1066    _py: Python,
1067    obj: &Bound<'_, PyAny>,
1068) -> PyResult<FeeModelAny> {
1069    if let Ok(m) = obj.extract::<FixedFeeModel>() {
1070        return Ok(FeeModelAny::Fixed(m));
1071    }
1072
1073    if let Ok(m) = obj.extract::<MakerTakerFeeModel>() {
1074        return Ok(FeeModelAny::MakerTaker(m));
1075    }
1076
1077    if let Ok(m) = obj.extract::<PerContractFeeModel>() {
1078        return Ok(FeeModelAny::PerContract(m));
1079    }
1080
1081    if let Ok(m) = obj.extract::<CappedOptionFeeModel>() {
1082        return Ok(FeeModelAny::CappedOption(m));
1083    }
1084
1085    if let Ok(m) = obj.extract::<TieredNotionalOptionFeeModel>() {
1086        return Ok(FeeModelAny::TieredNotionalOption(m));
1087    }
1088
1089    let type_name = obj.get_type().name()?;
1090    Err(to_pytype_err(format!(
1091        "Cannot convert {type_name} to FeeModel"
1092    )))
1093}
1094
1095pub(crate) fn pyobject_to_simulation_module_any(
1096    _py: Python,
1097    obj: &Bound<'_, PyAny>,
1098) -> PyResult<SimulationModuleAny> {
1099    if let Ok(cell) = obj.cast::<FXRolloverInterestModule>() {
1100        let module = cell.borrow().clone();
1101        return Ok(SimulationModuleAny::FXRolloverInterest(module));
1102    }
1103
1104    let type_name = obj.get_type().name()?;
1105    Err(to_pytype_err(format!(
1106        "Cannot convert {type_name} to SimulationModule"
1107    )))
1108}
1109
1110pub(crate) fn pyobject_to_latency_model_any(
1111    _py: Python,
1112    obj: &Bound<'_, PyAny>,
1113) -> PyResult<LatencyModelAny> {
1114    if let Ok(m) = obj.extract::<StaticLatencyModel>() {
1115        return Ok(LatencyModelAny::Static(m));
1116    }
1117
1118    let type_name = obj.get_type().name()?;
1119    Err(to_pytype_err(format!(
1120        "Cannot convert {type_name} to LatencyModel"
1121    )))
1122}
1123
1124pub(crate) fn pyobject_to_margin_model_any(
1125    _py: Python,
1126    obj: &Bound<'_, PyAny>,
1127) -> PyResult<MarginModelAny> {
1128    if let Ok(m) = obj.extract::<StandardMarginModel>() {
1129        return Ok(MarginModelAny::Standard(m));
1130    }
1131
1132    if let Ok(m) = obj.extract::<LeveragedMarginModel>() {
1133        return Ok(MarginModelAny::Leveraged(m));
1134    }
1135
1136    let type_name = obj.get_type().name()?;
1137    Err(to_pytype_err(format!(
1138        "Cannot convert {type_name} to MarginModel"
1139    )))
1140}
1141
1142fn pyobject_to_data(_py: Python, obj: &Bound<'_, PyAny>) -> PyResult<Data> {
1143    if let Ok(delta) = obj.extract::<OrderBookDelta>() {
1144        return Ok(Data::Delta(delta));
1145    }
1146
1147    if let Ok(deltas) = obj.extract::<OrderBookDeltas>() {
1148        return Ok(Data::Deltas(OrderBookDeltas_API::new(deltas)));
1149    }
1150
1151    if let Ok(quote) = obj.extract::<QuoteTick>() {
1152        return Ok(Data::Quote(quote));
1153    }
1154
1155    if let Ok(trade) = obj.extract::<TradeTick>() {
1156        return Ok(Data::Trade(trade));
1157    }
1158
1159    if let Ok(bar) = obj.extract::<Bar>() {
1160        return Ok(Data::Bar(bar));
1161    }
1162
1163    if let Ok(depth) = obj.extract::<OrderBookDepth10>() {
1164        return Ok(Data::Depth10(Box::new(depth)));
1165    }
1166
1167    if let Ok(mark) = obj.extract::<MarkPriceUpdate>() {
1168        return Ok(Data::MarkPriceUpdate(mark));
1169    }
1170
1171    if let Ok(index) = obj.extract::<IndexPriceUpdate>() {
1172        return Ok(Data::IndexPriceUpdate(index));
1173    }
1174
1175    if let Ok(funding_rate) = obj.extract::<FundingRateUpdate>() {
1176        return Ok(Data::FundingRateUpdate(funding_rate));
1177    }
1178
1179    if let Ok(status) = obj.extract::<InstrumentStatus>() {
1180        return Ok(Data::InstrumentStatus(status));
1181    }
1182
1183    if let Ok(greeks) = obj.extract::<OptionGreeks>() {
1184        return Ok(Data::OptionGreeks(greeks));
1185    }
1186
1187    if let Ok(close) = obj.extract::<InstrumentClose>() {
1188        return Ok(Data::InstrumentClose(close));
1189    }
1190
1191    #[cfg(feature = "defi")]
1192    if let Ok(defi) = obj.extract::<DefiData>() {
1193        return Ok(Data::Defi(Box::new(defi)));
1194    }
1195
1196    // Fall back to from_pyobject methods for Cython objects
1197    if let Ok(delta) = OrderBookDelta::from_pyobject(obj) {
1198        return Ok(Data::Delta(delta));
1199    }
1200
1201    if let Ok(quote) = QuoteTick::from_pyobject(obj) {
1202        return Ok(Data::Quote(quote));
1203    }
1204
1205    if let Ok(trade) = TradeTick::from_pyobject(obj) {
1206        return Ok(Data::Trade(trade));
1207    }
1208
1209    if let Ok(bar) = Bar::from_pyobject(obj) {
1210        return Ok(Data::Bar(bar));
1211    }
1212
1213    if let Ok(mark) = MarkPriceUpdate::from_pyobject(obj) {
1214        return Ok(Data::MarkPriceUpdate(mark));
1215    }
1216
1217    if let Ok(index) = IndexPriceUpdate::from_pyobject(obj) {
1218        return Ok(Data::IndexPriceUpdate(index));
1219    }
1220
1221    if let Ok(funding_rate) = FundingRateUpdate::from_pyobject(obj) {
1222        return Ok(Data::FundingRateUpdate(funding_rate));
1223    }
1224
1225    if let Ok(status) = InstrumentStatus::from_pyobject(obj) {
1226        return Ok(Data::InstrumentStatus(status));
1227    }
1228
1229    if let Ok(greeks) = OptionGreeks::from_pyobject(obj) {
1230        return Ok(Data::OptionGreeks(greeks));
1231    }
1232
1233    if let Ok(close) = InstrumentClose::from_pyobject(obj) {
1234        return Ok(Data::InstrumentClose(close));
1235    }
1236
1237    let type_name = obj.get_type().name()?;
1238    Err(to_pytype_err(format!("Cannot convert {type_name} to Data")))
1239}