Merge pull request #4 from KhizarImran/feat/partial-close-trailing-stop

feat: partial close and in-place stop loss updates
This commit is contained in:
Khizar Imran
2026-08-30 11:18:41 +01:00
committed by GitHub
5 changed files with 266 additions and 84 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [Unreleased]
### Added
- `Strategy.update_sl(id, stop_loss)` — move the stop on an open position in place,
so a trailing stop doesn't pay spread and commission to close and reopen
- `Strategy.close_partial(id, lot_size)` — scale out of a position, leaving the
remainder open under the same id
### Changed
- `Broker` closes positions through one internal `settle` helper instead of four
copies of the same PnL and commission arithmetic
## [0.1.2] - 2026-08-13
### Added
+33
View File
@@ -100,6 +100,39 @@ class MyStrategy(Strategy):
| `self.sell(lot_size, stop_loss=None, take_profit=None)` | Open a short position |
| `self.close_all()` | Close all open positions |
| `self.close_position(id)` | Close a specific position by ID |
| `self.close_partial(id, lot_size)` | Close part of a position, leaving the rest open |
| `self.update_sl(id, stop_loss)` | Move a position's stop loss (returns `False` if the id is gone) |
### Trailing stops and scaling out
`update_sl` moves the stop on an open position in place, so a trailing stop costs
nothing — closing and reopening would pay spread and commission again. `close_partial`
banks part of a position and leaves the remainder running under the same id, so you can
keep trailing it.
```python
class TrailingBreakout(Strategy):
def next(self):
if not self.positions:
self.buy(1.0, stop_loss=self._bar.close - 0.0050)
return
position = self.positions[0]
# take half off once the trade is 50 pips up
if self._bar.close > position.entry_price + 0.0050 and position.lot_size > 0.5:
self.close_partial(position.id, 0.5)
# trail the stop 50 pips behind price, never backwards
trail = self._bar.close - 0.0050
if position.stop_loss is None or trail > position.stop_loss:
self.update_sl(position.id, trail)
```
`update_sl` does not check which way the stop moves — widening a stop is a legitimate
thing to do, so that call is yours to make (the `trail > position.stop_loss` line above).
`close_partial` with a size at or above the position just closes it outright, and ignores
zero or negative sizes.
### Bar fields
+8
View File
@@ -87,6 +87,14 @@ class Strategy:
def close_position(self, id):
self._broker.close_position(id, self._bar.close, self._bar.timestamp)
def close_partial(self, id, lot_size):
"""Close `lot_size` lots of a position, leaving the rest open."""
self._broker.close_partial(id, lot_size, self._bar.close, self._bar.timestamp)
def update_sl(self, id, stop_loss):
"""Move a position's stop loss. Returns False if there is no such position."""
return self._broker.update_sl(id, stop_loss)
class _Adapter:
def __init__(self, strategy):
+179 -84
View File
@@ -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);
+34
View File
@@ -76,6 +76,40 @@ class BacktestTest(unittest.TestCase):
self.assertIn("2026-01-01 01:00", contents)
class TrailAndScaleOut(Strategy):
"""Buy once, trail the stop up each bar, and scale half out on the third bar."""
def next(self):
if not self.positions:
self.buy(1.0, stop_loss=self._bar.close - 0.0100)
return
position = self.positions[0]
self.update_sl(position.id, self._bar.close - 0.0100)
if self.index == 3:
self.close_partial(position.id, 0.5)
class PartialAndTrailingStopTest(unittest.TestCase):
def test_scaling_out_and_trailing_the_stop_through_the_python_api(self):
backtest = Backtest(rising_market(bars=6), TrailAndScaleOut, cash=10_000.0)
stats = backtest.run()
# two trades from one position: the 0.5 scaled out, then the 0.5 liquidated
self.assertEqual(stats.num_trades, 2)
self.assertEqual([trade.lot_size for trade in stats.trades], [0.5, 0.5])
self.assertEqual(stats.trades[0].entry_price, stats.trades[1].entry_price)
def test_update_sl_reports_a_missing_position(self):
recorded = []
class Probe(Strategy):
def next(self):
recorded.append(self.update_sl(999, 1.0))
Backtest(rising_market(bars=2), Probe, cash=10_000.0).run()
self.assertEqual(recorded, [False, False])
def rising_market(bars=20):
closes = [1.1000 + 0.0010 * i for i in range(bars)]
return pd.DataFrame(