From c4281671a0a42658e34197e6fe4df84526a78878 Mon Sep 17 00:00:00 2001 From: Khizar Imran Date: Tue, 20 Oct 2026 22:16:46 +0000 Subject: [PATCH] perf: process batch SL/TP exits in linear time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 🤖, On behalf of Khizar --- .gitignore | 1 + CHANGELOG.md | 2 + examples/benchmark_sl_tp.md | 29 +++++++++ examples/benchmark_sl_tp.rs | 37 ++++++++++++ src/broker.rs | 115 +++++++++++++++++++++++++++++++++--- 5 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 examples/benchmark_sl_tp.md create mode 100644 examples/benchmark_sl_tp.rs diff --git a/.gitignore b/.gitignore index 60c742c..190d960 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ Cargo.lock __pycache__/ ### My claude md file CLAUDE.md +.claude/settings.local.json data/* !data/EURUSD_1H.csv .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bee0a1..3b5a863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ API/platform compatibility policy, and a prioritized FX roadmap. ### Fixed +- Batch stop-loss/take-profit processing uses a single linear pass, preserving + survivor and trade order, fills, and accounting when many positions exit together. - 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 diff --git a/examples/benchmark_sl_tp.md b/examples/benchmark_sl_tp.md new file mode 100644 index 0000000..660162d --- /dev/null +++ b/examples/benchmark_sl_tp.md @@ -0,0 +1,29 @@ +# Batch SL/TP benchmark + +Run `cargo run --release --example benchmark_sl_tp` from the repository root. +The example opens positions before timing, then measures one `check_sl_tp` +call with all stops triggered. Each of nine repetitions uses a fresh broker; +results are medians. Assertions check that every position closes and trade +order matches submission order. Trade allocation and settlement are included. + +Measured on an Intel Core i5-8350U (4 cores / 8 threads), Linux x86_64, +Rust 1.98.1, PyO3 0.28.3, CPython 3.14.4, default Cargo release profile. +The benchmark itself executes entirely in Rust on one thread. +Before uses the repeated `Vec::remove` implementation at commit `867eee6`; +after uses stable in-place compaction. Both use the same example and settings. + +| Positions | Before (microseconds) | After (microseconds) | +| ---: | ---: | ---: | +| 1,000 | 521.215 | 7.622 | +| 2,000 | 2,245.135 | 54.041 | +| 4,000 | 10,763.030 | 109.674 | +| 8,000 | 52,533.606 | 222.399 | +| 16,000 | 232,879.797 | 484.124 | +| 32,000 | 966,525.622 | 980.658 | + +From 2,000 through 32,000 positions, the new implementation takes roughly +27–31 ns per position, consistent with linear scaling. The 1,000-position +case fits a smaller working set and is faster per position. At 32,000 positions +the measured batch-close speedup is about 986x. These are local microbenchmark +results, not an end-to-end backtest speedup; strategies with few open positions +will see much smaller gains. Timing varies with hardware and system load. diff --git a/examples/benchmark_sl_tp.rs b/examples/benchmark_sl_tp.rs new file mode 100644 index 0000000..3316d32 --- /dev/null +++ b/examples/benchmark_sl_tp.rs @@ -0,0 +1,37 @@ +//! Run with `cargo run --release --example benchmark_sl_tp`. +//! Position setup is excluded; each repetition uses a fresh broker. +use _backtestingfx::broker::Broker; +use _backtestingfx::types::Bar; +use std::hint::black_box; +use std::time::Instant; + +fn main() { + const REPEATS: usize = 9; + println!("All positions hit SL on one bar; median of {REPEATS} runs"); + for count in [1_000, 2_000, 4_000, 8_000, 16_000, 32_000] { + let mut times = Vec::with_capacity(REPEATS); + for _ in 0..REPEATS { + let mut broker = Broker::new(10_000.0, 7.0, 0.0001, 100_000.0, 1.27); + for i in 0..count { + broker.buy(1.1, 0.01, i as i64, Some(1.09), None); + } + let bar = Bar::new(count as i64, 1.1, 1.11, 1.08, 1.1, 0.0); + let start = Instant::now(); + broker.check_sl_tp(black_box(&bar)); + times.push(start.elapsed()); + assert!(broker.positions.is_empty()); + assert_eq!(broker.trade_history.len(), count); + for (i, trade) in broker.trade_history.iter().enumerate() { + assert_eq!(trade.entry_timestamp, i as i64); + } + black_box(&broker); + } + times.sort_unstable(); + let median = times[REPEATS / 2]; + println!( + "{count:>6} positions: {:>12.3} us ({:.1} ns/position)", + median.as_secs_f64() * 1e6, + median.as_secs_f64() * 1e9 / count as f64 + ); + } +} diff --git a/src/broker.rs b/src/broker.rs index 4f8601f..abf58a7 100644 --- a/src/broker.rs +++ b/src/broker.rs @@ -62,10 +62,12 @@ impl Broker { /// Midpoint OHLC triggers are inclusive; if both levels are touched, stop wins. /// The engine calls this before the strategy, so new entries wait until the next bar. pub fn check_sl_tp(&mut self, bar: &Bar) { - let mut i = 0; - while i < self.positions.len() { + // Move the Vec out so settlement can borrow self while retain compacts + // survivors in place. This preserves capacity and both observable orders, + // without shifting the entire suffix after every triggered position. + let mut positions = std::mem::take(&mut self.positions); + positions.retain(|p| { let fill = { - let p = &self.positions[i]; if p.is_long { let sl_hit = p.stop_loss.is_some_and(|sl| bar.low <= sl); let tp_hit = p.take_profit.is_some_and(|tp| bar.high >= tp); @@ -90,12 +92,13 @@ impl Broker { }; if let Some(fill_price) = fill { - let position = self.positions.remove(i); - self.settle(&position, position.lot_size, fill_price, bar.timestamp); + self.settle(p, p.lot_size, fill_price, bar.timestamp); + false } else { - i += 1; + true } - } + }); + self.positions = positions; } } @@ -265,6 +268,104 @@ mod tests { assert!((a - b).abs() < 1e-6, "expected {b}, got {a}"); } + // 0 = survivor, 1 = stop only, 2 = target only, 3 = both touched. + // Unique timestamps and sizes identify settlement order independently of IDs. + fn check_batch_exits(kinds: &[u8]) { + for long_first in [true, false] { + let mut broker = Broker::new(10_000.0, 7.0, 0.0001, 100_000.0, 1.27); + let mut survivors = Vec::new(); + let mut expected_trades = Vec::new(); + let mut expected_cash = 10_000.0; + for (i, &kind) in kinds.iter().enumerate() { + let is_long = if i % 2 == 0 { long_first } else { !long_first }; + let lots = (i + 1) as f64 * 0.01; + let stop = if is_long { 1.09 } else { 1.11 }; + let target = if is_long { 1.11 } else { 1.09 }; + let sl = if kind == 1 || kind == 3 { + Some(stop) + } else if kind == 0 && i % 2 == 0 { + Some(if is_long { 1.08 } else { 1.12 }) + } else { + None + }; + let tp = if kind == 2 || kind == 3 { + Some(target) + } else if kind == 0 && i % 2 == 0 { + Some(if is_long { 1.12 } else { 1.08 }) + } else { + None + }; + if is_long { + broker.buy(1.1, lots, i as i64, sl, tp); + } else { + broker.sell(1.1, lots, i as i64, sl, tp); + } + expected_cash -= 7.0 * lots; + if kind == 0 { + survivors.push(broker.positions.last().unwrap().clone()); + } else { + let fill = if kind == 2 { target } else { stop }; + let exit = fill + if is_long { -0.00005 } else { 0.00005 }; + let gross_pnl = + (if kind == 2 { 0.0099 } else { -0.0101 }) * lots * 100_000.0 * 1.27; + expected_cash += gross_pnl - 7.0 * lots; + expected_trades.push((i as i64, is_long, lots, exit, gross_pnl - 14.0 * lots)); + } + } + let capacity = broker.positions.capacity(); + let bar = Bar::new(100, 1.1, 1.11, 1.09, 1.1, 0.0); + broker.check_sl_tp(&bar); + assert_eq!(broker.positions.capacity(), capacity); + assert_eq!(broker.positions.len(), survivors.len()); + for (actual, expected) in broker.positions.iter().zip(&survivors) { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.entry_timestamp, expected.entry_timestamp); + assert_eq!(actual.is_long, expected.is_long); + assert_eq!(actual.entry_price, expected.entry_price); + assert_eq!(actual.lot_size, expected.lot_size); + assert_eq!(actual.stop_loss, expected.stop_loss); + assert_eq!(actual.take_profit, expected.take_profit); + } + assert_eq!(broker.trade_history.len(), expected_trades.len()); + for (trade, &(timestamp, is_long, lots, exit, pnl)) in + broker.trade_history.iter().zip(&expected_trades) + { + assert_eq!(trade.entry_timestamp, timestamp); + assert_eq!(trade.exit_timestamp, 100); + assert_eq!(trade.is_long, is_long); + assert_close(trade.lot_size, lots); + assert_close(trade.exit_price, exit); + assert_close(trade.pnl, pnl); + } + assert_close(broker.cash, expected_cash); + // A second scan must not settle a closed position twice. + broker.check_sl_tp(&bar); + assert_eq!(broker.trade_history.len(), expected_trades.len()); + assert_close(broker.cash, expected_cash); + broker.close_all(1.1, 101); + assert_close( + broker.cash - broker.initial_cash, + broker.trade_history.iter().map(|trade| trade.pnl).sum(), + ); + } + } + + #[test] + fn batch_sl_tp_all_triggered_preserves_settlement_and_stop_precedence() { + check_batch_exits(&[1, 2, 3, 1, 2, 3]); + } + + #[test] + fn batch_sl_tp_none_triggered_preserves_positions() { + check_batch_exits(&[0, 0, 0, 0]); + check_batch_exits(&[]); + } + + #[test] + fn batch_sl_tp_interleaved_preserves_survivors_and_settlement_order() { + check_batch_exits(&[0, 1, 0, 2, 3, 0, 1, 0, 2, 0]); + } + #[test] fn buy_then_close_all_computes_pnl() { let mut broker = test_broker(0.0, 0.0);