mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 11:36:31 +00:00
feat: partial close and in-place stop loss updates
Adds two Broker methods for path-dependent strategies: - close_partial(id, lot_size) scales out of a position and leaves the remainder open under the same id, so it can keep being managed. - update_sl(id, stop_loss) moves the stop in place. A trailing stop no longer has to close and reopen, which paid spread and commission on every step. Both are reachable from Python as Strategy.close_partial and Strategy.update_sl. Closing a position, part of one, or the whole book previously repeated the same PnL and commission arithmetic in four places. That is now one settle() helper taking the number of lots to close, so a partial close is the same code with a smaller number and the feature made broker.rs shorter rather than longer. Commission splits across the legs: the entry was charged on the full size when the position opened, so settle only takes the exit leg for the lots being closed. Two 0.5 closes land on the same cash as one 1.0 close, which is what closing_partially_twice_matches_closing_once pins. update_sl deliberately does not check which way the stop moves, since widening a stop is a legitimate thing for a strategy to do.
This commit is contained in:
+179
-84
@@ -17,6 +17,39 @@ pub struct Broker {
|
||||
|
||||
// Rust-internal only, not exposed to Python
|
||||
impl Broker {
|
||||
// Closing a position, part of a position, or a whole book all do the same four things:
|
||||
// work out the fill price, work out the PnL, charge commission, record the Trade.
|
||||
// `lots` is how much of the position to close, so a partial close is the same code
|
||||
// with a smaller number.
|
||||
fn settle(&mut self, position: &Position, lots: f64, price: f64, timestamp: i64) {
|
||||
let close_price = if position.is_long {
|
||||
price - self.spread
|
||||
} else {
|
||||
price + self.spread
|
||||
};
|
||||
|
||||
let pnl = if position.is_long {
|
||||
(close_price - position.entry_price) * lots * self.contract_size * self.quote_to_account
|
||||
} else {
|
||||
(position.entry_price - close_price) * lots * self.contract_size * self.quote_to_account
|
||||
};
|
||||
|
||||
// entry commission was already taken out of cash when the position opened,
|
||||
// so cash only pays the exit leg here — but the Trade records both, because
|
||||
// a trade's PnL should be what the round trip actually cost.
|
||||
let commission = self.commission * lots;
|
||||
self.cash += pnl - commission;
|
||||
self.trade_history.push(Trade {
|
||||
entry_price: position.entry_price,
|
||||
exit_price: close_price,
|
||||
lot_size: lots,
|
||||
is_long: position.is_long,
|
||||
pnl: pnl - commission * 2.0,
|
||||
entry_timestamp: position.entry_timestamp,
|
||||
exit_timestamp: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn check_sl_tp(&mut self, bar: &Bar) {
|
||||
let mut i = 0;
|
||||
while i < self.positions.len() {
|
||||
@@ -47,34 +80,7 @@ impl Broker {
|
||||
|
||||
if let Some(fill_price) = fill {
|
||||
let position = self.positions.remove(i);
|
||||
let close_price = if position.is_long {
|
||||
fill_price - self.spread
|
||||
} else {
|
||||
fill_price + self.spread
|
||||
};
|
||||
|
||||
let pnl = if position.is_long {
|
||||
(close_price - position.entry_price)
|
||||
* position.lot_size
|
||||
* self.contract_size
|
||||
* self.quote_to_account
|
||||
} else {
|
||||
(position.entry_price - close_price)
|
||||
* position.lot_size
|
||||
* self.contract_size
|
||||
* self.quote_to_account
|
||||
};
|
||||
let commission = self.commission * position.lot_size;
|
||||
self.cash += pnl - commission;
|
||||
self.trade_history.push(Trade {
|
||||
entry_price: position.entry_price,
|
||||
exit_price: close_price,
|
||||
lot_size: position.lot_size,
|
||||
is_long: position.is_long,
|
||||
pnl: pnl - commission * 2.0,
|
||||
entry_timestamp: position.entry_timestamp,
|
||||
exit_timestamp: bar.timestamp,
|
||||
});
|
||||
self.settle(&position, position.lot_size, fill_price, bar.timestamp);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -158,69 +164,57 @@ impl Broker {
|
||||
pub fn close_position(&mut self, id: u64, price: f64, timestamp: i64) {
|
||||
if let Some(index) = self.positions.iter().position(|p| p.id == id) {
|
||||
let position = self.positions.remove(index);
|
||||
self.settle(&position, position.lot_size, price, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
let close_price = if position.is_long {
|
||||
price - self.spread
|
||||
} else {
|
||||
price + self.spread
|
||||
};
|
||||
/// Close part of a position, leaving the rest open. Scaling out of a winner.
|
||||
///
|
||||
/// Asking for at least the full size just closes it outright, so callers don't
|
||||
/// have to check the remaining size before every call.
|
||||
pub fn close_partial(&mut self, id: u64, lot_size: f64, price: f64, timestamp: i64) {
|
||||
if lot_size <= 0.0 {
|
||||
return; // closing zero (or negative) lots would book PnL out of thin air
|
||||
}
|
||||
let Some(index) = self.positions.iter().position(|p| p.id == id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pnl = if position.is_long {
|
||||
(close_price - position.entry_price)
|
||||
* position.lot_size
|
||||
* self.contract_size
|
||||
* self.quote_to_account
|
||||
} else {
|
||||
(position.entry_price - close_price)
|
||||
* position.lot_size
|
||||
* self.contract_size
|
||||
* self.quote_to_account
|
||||
};
|
||||
if lot_size >= self.positions[index].lot_size {
|
||||
let position = self.positions.remove(index);
|
||||
self.settle(&position, position.lot_size, price, timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
let commission = self.commission * position.lot_size;
|
||||
self.cash += pnl - commission;
|
||||
self.trade_history.push(Trade {
|
||||
entry_price: position.entry_price,
|
||||
lot_size: position.lot_size,
|
||||
is_long: position.is_long,
|
||||
pnl: pnl - commission * 2.0,
|
||||
entry_timestamp: position.entry_timestamp,
|
||||
exit_timestamp: timestamp,
|
||||
exit_price: close_price,
|
||||
});
|
||||
// Clone first: settle needs &mut self, so it can't also hold a borrow into
|
||||
// self.positions. A Position is seven numbers, the copy costs nothing.
|
||||
let position = self.positions[index].clone();
|
||||
self.positions[index].lot_size -= lot_size;
|
||||
self.settle(&position, lot_size, price, timestamp);
|
||||
}
|
||||
|
||||
/// Move a position's stop loss. This is how a trailing stop works: the strategy
|
||||
/// calls it each bar as price advances, instead of closing and reopening (which
|
||||
/// would pay spread and commission all over again).
|
||||
///
|
||||
/// No check that the stop only moves in the profitable direction — widening a stop
|
||||
/// is a legitimate thing to do, so that call belongs to the strategy. Returns false
|
||||
/// if no position has that id.
|
||||
pub fn update_sl(&mut self, id: u64, stop_loss: f64) -> bool {
|
||||
match self.positions.iter_mut().find(|p| p.id == id) {
|
||||
Some(position) => {
|
||||
position.stop_loss = Some(stop_loss);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_all(&mut self, price: f64, timestamp: i64) {
|
||||
for position in self.positions.drain(..) {
|
||||
let close_price = if position.is_long {
|
||||
price - self.spread
|
||||
} else {
|
||||
price + self.spread
|
||||
};
|
||||
|
||||
let pnl = if position.is_long {
|
||||
(close_price - position.entry_price)
|
||||
* position.lot_size
|
||||
* self.contract_size
|
||||
* self.quote_to_account
|
||||
} else {
|
||||
(position.entry_price - close_price)
|
||||
* position.lot_size
|
||||
* self.contract_size
|
||||
* self.quote_to_account
|
||||
};
|
||||
let commission = self.commission * position.lot_size;
|
||||
self.cash += pnl - commission;
|
||||
self.trade_history.push(Trade {
|
||||
entry_price: position.entry_price,
|
||||
lot_size: position.lot_size,
|
||||
is_long: position.is_long,
|
||||
pnl: pnl - commission * 2.0,
|
||||
entry_timestamp: position.entry_timestamp,
|
||||
exit_timestamp: timestamp,
|
||||
exit_price: close_price,
|
||||
});
|
||||
// take() hands us the Vec by value, so settle is free to borrow self mutably.
|
||||
// drain() would still be holding a borrow on self.positions here.
|
||||
for position in std::mem::take(&mut self.positions) {
|
||||
self.settle(&position, position.lot_size, price, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +342,107 @@ mod tests {
|
||||
assert_close(broker.trade_history[0].pnl, 500.0); // (1.1050 - 1.1000) * 1.0 * 100_000
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_partial_books_half_and_leaves_the_rest_open() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
broker.buy(1.1000, 1.0, 0, None, None);
|
||||
|
||||
broker.close_partial(0, 0.4, 1.1050, 1);
|
||||
|
||||
// 0.4 lots booked: (1.1050 - 1.1000) * 0.4 * 100_000 = 200.0
|
||||
assert_close(broker.cash, 10_000.0 + 200.0);
|
||||
assert_eq!(broker.trade_history.len(), 1);
|
||||
assert_close(broker.trade_history[0].lot_size, 0.4);
|
||||
// 0.6 lots still riding, at the original entry
|
||||
assert_eq!(broker.positions.len(), 1);
|
||||
assert_close(broker.positions[0].lot_size, 0.6);
|
||||
assert_close(broker.positions[0].entry_price, 1.1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_partially_twice_matches_closing_once() {
|
||||
let mut scaled_out = test_broker(0.0, 0.0);
|
||||
scaled_out.buy(1.1000, 1.0, 0, None, None);
|
||||
scaled_out.close_partial(0, 0.5, 1.1050, 1);
|
||||
scaled_out.close_partial(0, 0.5, 1.1050, 1);
|
||||
|
||||
let mut all_at_once = test_broker(0.0, 0.0);
|
||||
all_at_once.buy(1.1000, 1.0, 0, None, None);
|
||||
all_at_once.close_all(1.1050, 1);
|
||||
|
||||
assert_close(scaled_out.cash, all_at_once.cash);
|
||||
assert_eq!(scaled_out.positions.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_partial_charges_commission_only_on_the_lots_closed() {
|
||||
let mut broker = test_broker(10.0, 0.0); // $10 per lot
|
||||
broker.buy(1.1000, 1.0, 0, None, None);
|
||||
assert_close(broker.cash, 10_000.0 - 10.0); // entry: full 1.0 lot
|
||||
|
||||
broker.close_partial(0, 0.25, 1.1000, 1); // flat price, so cost only
|
||||
assert_close(broker.cash, 10_000.0 - 10.0 - 2.5); // exit: 0.25 lot only
|
||||
assert_close(broker.trade_history[0].pnl, -5.0); // both legs of 0.25 lot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_partial_beyond_the_position_size_closes_it_outright() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
broker.buy(1.1000, 0.5, 0, None, None);
|
||||
|
||||
broker.close_partial(0, 99.0, 1.1050, 1);
|
||||
|
||||
assert_eq!(broker.positions.len(), 0);
|
||||
assert_close(broker.trade_history[0].lot_size, 0.5); // billed for 0.5, not 99
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_partial_ignores_zero_and_unknown_ids() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
broker.buy(1.1000, 1.0, 0, None, None);
|
||||
|
||||
broker.close_partial(0, 0.0, 1.1050, 1); // zero lots
|
||||
broker.close_partial(99, 0.5, 1.1050, 1); // no such position
|
||||
|
||||
assert_eq!(broker.trade_history.len(), 0);
|
||||
assert_close(broker.cash, 10_000.0);
|
||||
assert_close(broker.positions[0].lot_size, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sl_moves_the_stop_and_the_new_one_is_what_fills() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
broker.buy(1.1000, 1.0, 0, Some(1.0950), None);
|
||||
|
||||
// price ran up, so trail the stop to lock in a profit
|
||||
assert!(broker.update_sl(0, 1.1020));
|
||||
assert_eq!(broker.positions[0].stop_loss, Some(1.1020));
|
||||
|
||||
// this bar dips to 1.1010: through the new stop, nowhere near the old one
|
||||
broker.check_sl_tp(&Bar::new(1, 1.1050, 1.1060, 1.1010, 1.1040, 0.0));
|
||||
|
||||
assert_eq!(broker.positions.len(), 0);
|
||||
assert_eq!(broker.trade_history[0].exit_price, 1.1020);
|
||||
assert_close(broker.trade_history[0].pnl, 200.0); // (1.1020 - 1.1000) * 100_000
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sl_reports_an_unknown_id() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
assert!(!broker.update_sl(42, 1.0950));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sl_survives_a_partial_close() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
broker.buy(1.1000, 1.0, 0, Some(1.0950), None);
|
||||
broker.close_partial(0, 0.5, 1.1050, 1);
|
||||
|
||||
// the remainder keeps the same id, so it is still addressable
|
||||
assert!(broker.update_sl(0, 1.1020));
|
||||
assert_eq!(broker.positions[0].stop_loss, Some(1.1020));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equity_includes_unrealized_pnl() {
|
||||
let mut broker = test_broker(0.0, 0.0);
|
||||
|
||||
Reference in New Issue
Block a user