Skip to main content

nautilus_backtest/modules/
mod.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//! Simulation module trait for extending backtesting with custom venue behaviors.
17
18pub mod cfd_swap;
19pub mod fx_rollover;
20
21use std::{
22    fmt::{Debug, Display},
23    rc::Rc,
24};
25
26use ahash::AHashMap;
27pub use cfd_swap::{CfdSwapModule, CfdSwapRate};
28pub use fx_rollover::FXRolloverInterestModule;
29use indexmap::IndexMap;
30use nautilus_common::cache::Cache;
31use nautilus_core::UnixNanos;
32use nautilus_execution::matching_engine::OrderMatchingEngine;
33use nautilus_model::{
34    data::Data,
35    identifiers::{InstrumentId, Venue},
36    instruments::InstrumentAny,
37    types::{Currency, Money},
38};
39
40/// Read-only view of exchange state passed to simulation modules during processing.
41#[derive(Debug)]
42pub struct ExchangeContext<'a> {
43    /// The venue identifier.
44    pub venue: Venue,
45    /// The optional base currency for single-currency accounts.
46    pub base_currency: Option<Currency>,
47    /// All instruments registered on the exchange.
48    pub instruments: &'a AHashMap<InstrumentId, InstrumentAny>,
49    /// All matching engines, providing order book access.
50    pub matching_engines: &'a IndexMap<InstrumentId, OrderMatchingEngine>,
51    /// Read-only cache access for querying positions and other state.
52    pub cache: &'a Cache,
53}
54
55#[derive(Debug, Clone)]
56pub enum SimulationModuleAny {
57    CfdSwap(CfdSwapModule),
58    FXRolloverInterest(FXRolloverInterestModule),
59    #[cfg(feature = "python")]
60    Python(crate::python::modules::PythonSimulationModule),
61}
62
63impl SimulationModule for SimulationModuleAny {
64    fn pre_process(&self, data: &Data) -> anyhow::Result<()> {
65        match self {
66            Self::CfdSwap(module) => module.pre_process(data),
67            Self::FXRolloverInterest(module) => module.pre_process(data),
68            #[cfg(feature = "python")]
69            Self::Python(module) => module.pre_process(data),
70        }
71    }
72
73    fn process(
74        &self,
75        ts_now: UnixNanos,
76        ctx: &ExchangeContext,
77    ) -> anyhow::Result<SimulationModuleResult> {
78        match self {
79            Self::CfdSwap(module) => module.process(ts_now, ctx),
80            Self::FXRolloverInterest(module) => module.process(ts_now, ctx),
81            #[cfg(feature = "python")]
82            Self::Python(module) => module.process(ts_now, ctx),
83        }
84    }
85
86    fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
87        match self {
88            Self::CfdSwap(module) => module.acknowledge(outcomes),
89            Self::FXRolloverInterest(module) => module.acknowledge(outcomes),
90            #[cfg(feature = "python")]
91            Self::Python(module) => module.acknowledge(outcomes),
92        }
93    }
94
95    fn log_diagnostics(&self) -> anyhow::Result<()> {
96        match self {
97            Self::CfdSwap(module) => module.log_diagnostics(),
98            Self::FXRolloverInterest(module) => module.log_diagnostics(),
99            #[cfg(feature = "python")]
100            Self::Python(module) => module.log_diagnostics(),
101        }
102    }
103
104    fn reset(&self) -> anyhow::Result<()> {
105        match self {
106            Self::CfdSwap(module) => module.reset(),
107            Self::FXRolloverInterest(module) => module.reset(),
108            #[cfg(feature = "python")]
109            Self::Python(module) => module.reset(),
110        }
111    }
112}
113
114/// Shared runtime handle for a simulation module.
115///
116/// Clones share the same module instance and state. Create a separate module for each venue or
117/// run that requires isolated state.
118#[derive(Clone)]
119pub struct SimulationModuleHandle(Rc<dyn SimulationModule>);
120
121impl SimulationModuleHandle {
122    /// Creates a new [`SimulationModuleHandle`] from a simulation module.
123    #[must_use]
124    pub fn new<T>(module: T) -> Self
125    where
126        T: SimulationModule + 'static,
127    {
128        Self(Rc::new(module))
129    }
130
131    /// Creates a new [`SimulationModuleHandle`] from an existing reference-counted module.
132    #[must_use]
133    pub fn from_rc(module: Rc<dyn SimulationModule>) -> Self {
134        Self(module)
135    }
136}
137
138impl Debug for SimulationModuleHandle {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_tuple(stringify!(SimulationModuleHandle))
141            .field(&"<dyn SimulationModule>")
142            .finish()
143    }
144}
145
146impl SimulationModule for SimulationModuleHandle {
147    fn pre_process(&self, data: &Data) -> anyhow::Result<()> {
148        self.0.pre_process(data)
149    }
150
151    fn process(
152        &self,
153        ts_now: UnixNanos,
154        ctx: &ExchangeContext,
155    ) -> anyhow::Result<SimulationModuleResult> {
156        self.0.process(ts_now, ctx)
157    }
158
159    fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
160        self.0.acknowledge(outcomes)
161    }
162
163    fn log_diagnostics(&self) -> anyhow::Result<()> {
164        self.0.log_diagnostics()
165    }
166
167    fn reset(&self) -> anyhow::Result<()> {
168        self.0.reset()
169    }
170}
171
172impl From<SimulationModuleAny> for SimulationModuleHandle {
173    fn from(module: SimulationModuleAny) -> Self {
174        Self::new(module)
175    }
176}
177
178/// Result of processing a simulation module.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum SimulationModuleResult {
181    /// The module does not yet have a complete batch of adjustments.
182    NotReady,
183    /// The module produced a complete batch, which may be empty.
184    Completed(Vec<Money>),
185}
186
187/// Failure applying an account adjustment produced by a simulation module.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum AccountAdjustmentError {
190    /// The adjusted total balance would exceed [`Money`] bounds.
191    TotalOverflow(Currency),
192    /// The adjusted free balance would exceed [`Money`] bounds.
193    FreeBalanceOverflow(Currency),
194    /// The account has no balance for the adjustment currency.
195    MissingBalance(Currency),
196    /// The exchange has no account for the venue.
197    MissingAccount(Venue),
198    /// Generating the updated account state failed.
199    AccountStateGeneration(String),
200}
201
202impl Display for AccountAdjustmentError {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            Self::TotalOverflow(currency) => {
206                write!(
207                    f,
208                    "Cannot adjust account: {currency} total exceeds Money bounds"
209                )
210            }
211            Self::FreeBalanceOverflow(currency) => write!(
212                f,
213                "Cannot adjust account: {currency} free balance exceeds Money bounds"
214            ),
215            Self::MissingBalance(currency) => {
216                write!(
217                    f,
218                    "Cannot adjust account: no balance for currency {currency}"
219                )
220            }
221            Self::MissingAccount(venue) => {
222                write!(f, "Cannot adjust account: no account for venue {venue}")
223            }
224            Self::AccountStateGeneration(error) => {
225                write!(
226                    f,
227                    "Cannot adjust account: failed to generate account state: {error}"
228                )
229            }
230        }
231    }
232}
233
234impl std::error::Error for AccountAdjustmentError {}
235
236impl AccountAdjustmentError {
237    pub(crate) const fn is_retryable(&self) -> bool {
238        matches!(
239            self,
240            Self::TotalOverflow(_) | Self::FreeBalanceOverflow(_) | Self::AccountStateGeneration(_)
241        )
242    }
243}
244
245/// Outcome of applying an account adjustment produced by a simulation module.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum AccountAdjustmentOutcome {
248    /// The adjustment was applied successfully.
249    Applied,
250    /// The adjustment could not be applied.
251    Failed(AccountAdjustmentError),
252}
253
254/// Trait for custom simulation modules that extend backtesting functionality.
255///
256/// Implementations can add specialized behavior such as rollover interest,
257/// market makers, price impact models, or other venue-specific simulation
258/// logic that runs alongside the core backtesting engine.
259///
260/// Modules use interior mutability (`Cell`/`RefCell`) for state since they
261/// are stored inside `SimulatedExchange` and invoked through shared references.
262pub trait SimulationModule {
263    /// Pre-processes market data before matching engine processing.
264    ///
265    /// # Errors
266    ///
267    /// Returns an error if the module cannot accept the data.
268    fn pre_process(&self, data: &Data) -> anyhow::Result<()>;
269
270    /// Processes simulation logic at the given timestamp.
271    ///
272    /// Returns a complete batch of account balance adjustments, or indicates
273    /// that the module is not ready.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if the module cannot process the exchange state.
278    fn process(
279        &self,
280        ts_now: UnixNanos,
281        ctx: &ExchangeContext,
282    ) -> anyhow::Result<SimulationModuleResult>;
283
284    /// Acknowledges the ordered application outcomes for a completed batch.
285    ///
286    /// This is called exactly once for every [`SimulationModuleResult::Completed`],
287    /// including an empty batch.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if the outcomes do not match the pending completed batch or the module
292    /// cannot record them. The exchange treats an acknowledgement failure as terminal until reset
293    /// because account adjustments may already have been applied.
294    fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()>;
295
296    /// Logs diagnostic information about the module's state.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the module cannot produce its diagnostics.
301    fn log_diagnostics(&self) -> anyhow::Result<()>;
302
303    /// Resets the module to its initial state.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if the module cannot reset its state.
308    fn reset(&self) -> anyhow::Result<()>;
309}
310
311#[cfg(test)]
312mod tests {
313    use std::{cell::Cell, rc::Rc};
314
315    use rstest::rstest;
316
317    use super::*;
318
319    #[derive(Debug)]
320    struct CountingModule {
321        resets: Rc<Cell<u32>>,
322    }
323
324    impl SimulationModule for CountingModule {
325        fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
326            Ok(())
327        }
328
329        fn process(
330            &self,
331            _ts_now: UnixNanos,
332            _ctx: &ExchangeContext,
333        ) -> anyhow::Result<SimulationModuleResult> {
334            Ok(SimulationModuleResult::NotReady)
335        }
336
337        fn acknowledge(&self, _outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
338            Ok(())
339        }
340
341        fn log_diagnostics(&self) -> anyhow::Result<()> {
342            Ok(())
343        }
344
345        fn reset(&self) -> anyhow::Result<()> {
346            self.resets.set(self.resets.get() + 1);
347            Ok(())
348        }
349    }
350
351    #[rstest]
352    fn simulation_module_handle_from_rc_clones_shared_module() {
353        let resets = Rc::new(Cell::new(0));
354        let module: Rc<dyn SimulationModule> = Rc::new(CountingModule {
355            resets: resets.clone(),
356        });
357        let handle = SimulationModuleHandle::from_rc(module);
358        let cloned = handle.clone();
359
360        handle.reset().unwrap();
361        cloned.reset().unwrap();
362
363        assert_eq!(resets.get(), 2);
364        assert_eq!(
365            format!("{handle:?}"),
366            "SimulationModuleHandle(\"<dyn SimulationModule>\")"
367        );
368    }
369}