perf: process batch SL/TP exits in linear time

Codex 🤖, On behalf of Khizar
This commit is contained in:
Khizar Imran
2026-10-20 22:16:46 +00:00
parent c490f274e6
commit c4281671a0
5 changed files with 177 additions and 7 deletions
+1
View File
@@ -4,6 +4,7 @@ Cargo.lock
__pycache__/
### My claude md file
CLAUDE.md
.claude/settings.local.json
data/*
!data/EURUSD_1H.csv
.env
+2
View File
@@ -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
+29
View File
@@ -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.
+37
View File
@@ -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
);
}
}
+108 -7
View File
@@ -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);