From 623a0c6976cc2019132548d9a934bd7b6cbf8a43 Mon Sep 17 00:00:00 2001 From: Khizar Imran <75162994+KhizarImran@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:08:05 +0100 Subject: [PATCH] Reset broker state before independent native engine runs --- CHANGELOG.md | 2 + README.md | 8 +++ src/engine.rs | 85 +++++++++++++++++++++++++++++++ tests/test_native_engine_reuse.py | 34 +++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 tests/test_native_engine_reuse.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 19644f8..bc20598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ API/platform compatibility policy, and a prioritized FX roadmap. ### Fixed +- Native engine runs reset cash, positions, trade history, and position IDs before + each independent simulation, including repeated `run_native_sma()` calls. - `spread` now means the full bid–ask width around midpoint OHLC prices. Each fill applies half the width, so a one-pip EURUSD spread costs $10 per standard lot on a flat round trip, before commission, instead of $20. diff --git a/README.md b/README.md index 1682fed..355576a 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,14 @@ open, high, low, close, volume The index should be a `DatetimeIndex`, or include a `timestamp` column. Volume is optional (defaults to 0). +### Reusing a native engine + +Native Rust `Engine::run()` and Python `Engine.run_native_sma()` start each run +with fresh broker state using the current initial cash and cost configuration. +Cash, positions, completed trades, position IDs, and equity history do not carry +between runs. For native strategies with mutable internal state, reset that state +in `Strategy::init()` or construct a fresh strategy for each run. + ## Why Rust? The backtesting engine is written in Rust and compiled as a native Python extension via [PyO3](https://pyo3.rs). This means the event loop, broker simulation, and stats computation run at native speed while your strategy stays in plain Python. diff --git a/src/engine.rs b/src/engine.rs index 4ba1a56..5b2869a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -12,7 +12,16 @@ pub struct Engine { } impl Engine { + /// Run an independent simulation using the broker's current configuration. + /// Strategies with their own mutable state should reset it in `init`. pub fn run(&mut self, strategy: &mut dyn Strategy) -> Stats { + self.broker = Broker::new( + self.broker.initial_cash, + self.broker.commission, + self.broker.spread, + self.broker.contract_size, + self.broker.quote_to_account, + ); self.equity_curve.clear(); self.equity_curve.push(self.broker.initial_cash); @@ -160,4 +169,80 @@ mod tests { assert_eq!(stats.final_cash, 9_986.0); assert_eq!(engine.equity_curve.last(), Some(&stats.final_cash)); } + + #[test] + fn repeated_native_runs_reset_cash_trades_and_position_ids() { + let data = vec![Bar::new(0, 1.1, 1.1, 1.1, 1.1, 0.0)]; + let mut engine = Engine::new(data, 10_000.0, 7.0, 0.0001, 100_000.0, 1.27); + let mut strategy = BuyAndHold; + let first = engine.run(&mut strategy); + let second = engine.run(&mut strategy); + + assert_eq!(second.final_cash, first.final_cash); + assert_eq!(second.equity_curve, first.equity_curve); + assert_eq!(second.num_trades, 1); + assert_eq!(engine.broker.trade_history.len(), 1); + assert_eq!(second.trades[0].pnl, first.trades[0].pnl); + assert!((second.final_cash - 9_973.3).abs() < 1e-8); + assert!(engine.broker.positions.is_empty()); + + // Probe the next run from inside the strategy, before final liquidation. + struct Probe; + impl Strategy for Probe { + fn next(&mut self, bar: &Bar, broker: &mut Broker) { + assert_eq!(broker.cash, 10_000.0); + assert!(broker.positions.is_empty()); + assert!(broker.trade_history.is_empty()); + broker.buy(bar.close, 1.0, bar.timestamp, None, None); + assert_eq!(broker.positions[0].id, 0); + } + } + engine.run(&mut Probe); + } + + #[test] + fn a_different_strategy_matches_a_fresh_engine() { + struct SellEveryBar; + impl Strategy for SellEveryBar { + fn next(&mut self, bar: &Bar, broker: &mut Broker) { + broker.sell(bar.close, 0.5, bar.timestamp, None, None); + } + } + let data = vec![ + Bar::new(0, 1.1, 1.1, 1.1, 1.1, 0.0), + Bar::new(1, 1.2, 1.2, 1.2, 1.2, 0.0), + ]; + let mut reused = Engine::new(data.clone(), 10_000.0, 7.0, 0.0001, 100_000.0, 1.27); + let mut fresh = Engine::new(data, 10_000.0, 7.0, 0.0001, 100_000.0, 1.27); + reused.run(&mut BuyAndHold); + let actual = reused.run(&mut SellEveryBar); + let expected = fresh.run(&mut SellEveryBar); + + assert_eq!(actual.final_cash, expected.final_cash); + assert_eq!(actual.equity_curve, expected.equity_curve); + assert_eq!(actual.num_trades, expected.num_trades); + for (actual, expected) in actual.trades.iter().zip(&expected.trades) { + assert_eq!(actual.pnl, expected.pnl); + assert_eq!(actual.entry_price, expected.entry_price); + assert_eq!(actual.exit_price, expected.exit_price); + assert!(!actual.is_long); + } + } + + #[test] + fn empty_run_discards_existing_positions_and_history() { + let mut engine = Engine::new(Vec::new(), 10_000.0, 7.0, 0.0001, 100_000.0, 1.0); + engine.broker.buy(1.1, 1.0, 0, None, None); + engine.broker.close_all(1.2, 1); + engine.broker.buy(1.2, 1.0, 1, None, None); + engine.equity_curve.push(123.0); + + let stats = engine.run(&mut BuyAndHold); + + assert_eq!(stats.final_cash, 10_000.0); + assert_eq!(stats.num_trades, 0); + assert_eq!(stats.equity_curve, vec![10_000.0]); + assert!(engine.broker.positions.is_empty()); + assert!(engine.broker.trade_history.is_empty()); + } } diff --git a/tests/test_native_engine_reuse.py b/tests/test_native_engine_reuse.py new file mode 100644 index 0000000..6c34e38 --- /dev/null +++ b/tests/test_native_engine_reuse.py @@ -0,0 +1,34 @@ +import unittest + +from backtestingfx import Bar, Engine + + +class NativeEngineReuseTest(unittest.TestCase): + def engine(self): + bars = [Bar(i, price, price, price, price, 0.0) + for i, price in enumerate([1.1, 1.2, 1.3, 1.4, 1.5])] + return Engine(bars, 10_000.0, 7.0, 0.0001, 100_000.0, 1.27) + + def test_native_sma_repeated_runs_match(self): + engine = self.engine() + first = engine.run_native_sma(1, 2, 0.1) + second = engine.run_native_sma(1, 2, 0.1) + self.assertGreater(first.num_trades, 0) + self.assertEqual(second.final_cash, first.final_cash) + self.assertEqual(second.equity_curve, first.equity_curve) + self.assertEqual(second.num_trades, first.num_trades) + self.assertEqual([t.pnl for t in second.trades], [t.pnl for t in first.trades]) + + def test_new_native_parameters_match_a_fresh_engine(self): + engine = self.engine() + engine.run_native_sma(1, 2, 0.1) + actual = engine.run_native_sma(2, 3, 0.2) + expected = self.engine().run_native_sma(2, 3, 0.2) + self.assertEqual(actual.final_cash, expected.final_cash) + self.assertEqual(actual.equity_curve, expected.equity_curve) + self.assertEqual(actual.num_trades, expected.num_trades) + self.assertEqual([t.pnl for t in actual.trades], [t.pnl for t in expected.trades]) + + +if __name__ == "__main__": + unittest.main()