Reset broker state before independent native engine runs

This commit is contained in:
Khizar Imran
2026-10-08 20:08:05 +01:00
parent 7c48e57af6
commit 623a0c6976
4 changed files with 129 additions and 0 deletions
+2
View File
@@ -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.
+8
View File
@@ -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.
+85
View File
@@ -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());
}
}
+34
View File
@@ -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()