Merge pull request #43 from KhizarImran/fix/7-full-bid-ask-spread

Fix full bid-ask spread semantics and equity marking
This commit is contained in:
Khizar Imran
2026-10-06 22:59:56 +01:00
committed by GitHub
6 changed files with 194 additions and 8 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [Unreleased]
### Fixed
- `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.
- Open equity uses the bid/ask price available to close each position and includes
the exit-side spread (future exit commission is still charged only at closing).
### Migration
- Double the previous `spread` input to preserve pre-change fills and completed
trade PnL. Equity curves can still differ because marking now includes exit
spread. SL/TP levels remain midpoint levels. See README price conventions.
## [0.2.0] - 2026-08-30
First release to reach PyPI since 0.1.1 — the `v0.1.2` tag predated the release
+26 -1
View File
@@ -189,12 +189,37 @@ Backtest(
StrategyClass,
cash=10000.0, # starting account balance in USD
commission=0.0, # commission per lot (e.g. 7.0 = $7/lot)
spread=0.0, # spread in price units (e.g. 0.0001 = 1 pip)
spread=0.0, # full bid–ask width in price units (0.0001 = 1 EURUSD pip)
contract_size=100000.0, # standard FX lot size, don't change this
quote_to_account=1.0, # conversion rate from quote currency to USD
)
```
### Spread and price conventions
OHLC prices are **midpoints** between bid and ask. `spread` is the constant full
bid–ask width in absolute price units, not a percentage or a one-sided offset:
- Bid = midpoint − spread / 2; ask = midpoint + spread / 2.
- Longs enter at ask and exit at bid; shorts enter at bid and exit at ask.
- Open-position equity marks longs at bid and shorts at ask. Entry commission is
already deducted from cash; future exit commission is excluded until closing.
- Stop-loss and take-profit levels are midpoint levels, triggered by midpoint
OHLC highs/lows. Their exits use bid for longs and ask for shorts at that level.
For example, with a flat EURUSD midpoint of `1.1000` and `spread=0.0001`, bid is
`1.09995` and ask is `1.10005`. A one-lot long or short loses `$10` from spread
on a round trip (`0.0001 × 100000`), before commission. Its open equity also
reflects that `$10` spread cost. For USDJPY, one pip is typically `0.01` price
units; spread inputs use the instrument's price units.
**Migration from 0.2.0 and earlier:** those versions applied the entire `spread`
value on each side, charging twice the documented bid–ask width. To preserve
previous entry/exit prices and completed-trade PnL, pass twice your old value
(for example, change `0.0001` to `0.0002`). Open equity now includes the exit-side
spread, so historical equity curves and equity-based decisions can still change.
Supply midpoint data: convert bid-only or ask-only data before running a backtest.
### Trading non-USD pairs
By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD).
+6
View File
@@ -114,6 +114,12 @@ class _Adapter:
class Backtest:
"""Backtest midpoint OHLC data with a full bid–ask spread in price units.
Fills apply half the spread on each side of the midpoint. Open equity marks
positions at their closing bid/ask, excluding future exit commission.
"""
def __init__(
self,
df,
+1 -1
View File
@@ -346,7 +346,7 @@ def render_report(
{chart_html}
<div class="assumptions">
<div class="assumption"><span>Commission / lot / side</span><strong>{commission:,.4f}</strong></div>
<div class="assumption"><span>Spread offset</span><strong>{spread:,.5f}</strong></div>
<div class="assumption"><span>Bid–ask spread (midpoint data)</span><strong>{spread:,.5f}</strong></div>
<div class="assumption"><span>Contract size</span><strong>{contract_size:,.0f}</strong></div>
<div class="assumption"><span>Quote conversion</span><strong>{quote_to_account:,.5f}</strong></div>
</div>
+90 -6
View File
@@ -10,6 +10,7 @@ pub struct Broker {
pub positions: Vec<Position>,
pub trade_history: Vec<Trade>,
pub commission: f64,
/// Full bid–ask width in price units; supplied prices are midpoints.
pub spread: f64,
pub contract_size: f64,
pub quote_to_account: f64,
@@ -17,15 +18,23 @@ pub struct Broker {
// Rust-internal only, not exposed to Python
impl Broker {
fn bid(&self, midpoint: f64) -> f64 {
midpoint - self.spread / 2.0
}
fn ask(&self, midpoint: f64) -> f64 {
midpoint + self.spread / 2.0
}
// 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
self.bid(price)
} else {
price + self.spread
self.ask(price)
};
let pnl = if position.is_long {
@@ -123,7 +132,7 @@ impl Broker {
stop_loss: Option<f64>,
take_profit: Option<f64>,
) {
let fill_price = price + self.spread;
let fill_price = self.ask(price);
self.cash -= self.commission * lot_size;
let id = self.next_id;
self.next_id += 1;
@@ -146,7 +155,7 @@ impl Broker {
stop_loss: Option<f64>,
take_profit: Option<f64>,
) {
let fill_price = price - self.spread;
let fill_price = self.bid(price);
self.cash -= self.commission * lot_size;
let id = self.next_id;
self.next_id += 1;
@@ -224,12 +233,12 @@ impl Broker {
.iter()
.map(|p| {
if p.is_long {
(current_price - p.entry_price)
(self.bid(current_price) - p.entry_price)
* p.lot_size
* self.contract_size
* self.quote_to_account
} else {
(p.entry_price - current_price)
(p.entry_price - self.ask(current_price))
* p.lot_size
* self.contract_size
* self.quote_to_account
@@ -291,6 +300,81 @@ mod tests {
);
}
#[test]
fn flat_round_trips_pay_one_full_spread_and_both_commissions() {
for (is_long, commission) in [(true, 0.0), (false, 0.0), (true, 7.0), (false, 7.0)] {
let mut broker = test_broker(commission, 0.0001);
if is_long {
broker.buy(1.1000, 1.0, 0, None, None);
} else {
broker.sell(1.1000, 1.0, 0, None, None);
}
let expected_entry = if is_long { 1.10005 } else { 1.09995 };
let expected_exit = if is_long { 1.09995 } else { 1.10005 };
assert_close(broker.positions[0].entry_price, expected_entry);
assert_close(broker.cash, 10_000.0 - commission);
// $10 spread + entry commission; exit commission is not yet paid.
assert_close(broker.equity(1.1000), 9_990.0 - commission);
broker.close_all(1.1000, 1);
assert_close(broker.trade_history[0].exit_price, expected_exit);
assert_close(broker.trade_history[0].pnl, -10.0 - 2.0 * commission);
assert_close(broker.cash, 9_990.0 - 2.0 * commission);
assert_close(broker.equity(1.1000), broker.cash);
}
}
#[test]
fn partial_and_individual_closes_reconcile_spread_and_conversion() {
for is_long in [true, false] {
let mut broker = Broker::new(10_000.0, 7.0, 0.0001, 100_000.0, 1.27);
if is_long {
broker.buy(1.1000, 1.0, 0, None, None);
} else {
broker.sell(1.1000, 1.0, 0, None, None);
}
assert_close(broker.equity(1.1000), 9_980.3);
broker.close_partial(0, 0.4, 1.1000, 1);
assert_close(broker.trade_history[0].pnl, -10.68);
broker.close_position(0, 1.1000, 2);
assert_close(broker.trade_history[1].pnl, -16.02);
assert_close(broker.cash, 9_973.3);
assert_close(
broker.cash - broker.initial_cash,
broker.trade_history.iter().map(|trade| trade.pnl).sum(),
);
}
}
#[test]
fn midpoint_stop_and_target_exits_use_closing_bid_or_ask() {
for is_long in [true, false] {
for use_stop in [true, false] {
let mut broker = test_broker(0.0, 0.0001);
let level = if is_long == use_stop { 1.0950 } else { 1.1050 };
let sl = use_stop.then_some(level);
let tp = (!use_stop).then_some(level);
if is_long {
broker.buy(1.1000, 1.0, 0, sl, tp);
} else {
broker.sell(1.1000, 1.0, 0, sl, tp);
}
broker.check_sl_tp(&Bar::new(1, level, level, level, level, 0.0));
assert!(broker.positions.is_empty());
let exit = if is_long {
level - 0.00005
} else {
level + 0.00005
};
assert_close(broker.trade_history[0].exit_price, exit);
assert_close(
broker.trade_history[0].pnl,
if use_stop { -510.0 } else { 490.0 },
);
}
}
}
#[test]
fn long_position_closes_at_stop_loss_not_bar_close() {
let mut broker = test_broker(0.0, 0.0);
+57
View File
@@ -89,6 +89,63 @@ class TrailAndScaleOut(Strategy):
self.close_partial(position.id, 0.5)
class SpreadTest(unittest.TestCase):
def test_midpoint_fills_equity_and_signal_runs_agree(self):
data = pd.DataFrame(
{name: [1.1, 1.1] for name in ("open", "high", "low", "close")},
index=pd.date_range("2026-01-01", periods=2, freq="h", tz="UTC"),
)
for is_long in (True, False):
with self.subTest(is_long=is_long):
equities = []
class Hold(Strategy):
def next(self):
if not self.positions:
if is_long:
self.buy(1.0)
else:
self.sell(1.0)
equities.append(self.equity)
backtest = Backtest(data, Hold, commission=7.0, spread=0.0001)
stats = backtest.run()
self.assertAlmostEqual(stats.final_cash, 9_976.0)
self.assertAlmostEqual(stats.trades[0].pnl, -24.0)
self.assertAlmostEqual(
stats.trades[0].entry_price, 1.10005 if is_long else 1.09995
)
self.assertAlmostEqual(
stats.trades[0].exit_price, 1.09995 if is_long else 1.10005
)
self.assertEqual(len(equities), 2)
for equity in equities:
self.assertAlmostEqual(equity, 9_983.0)
self.assertEqual(len(stats.equity_curve), 3)
for actual, expected in zip(
stats.equity_curve, [10_000.0, 9_983.0, 9_976.0]
):
self.assertAlmostEqual(actual, expected)
results = backtest.optimize(
lambda df, lots: [lots] * len(df),
lots=[1.0 if is_long else -1.0],
)
signal_stats = results[0][1]
self.assertAlmostEqual(signal_stats.final_cash, stats.final_cash)
self.assertEqual(signal_stats.equity_curve, stats.equity_curve)
with tempfile.TemporaryDirectory() as directory:
report = Path(
backtest.plot(
Path(directory) / "spread.html", open_browser=False
)
)
self.assertIn(
"Bid–ask spread (midpoint data)",
report.read_text(encoding="utf-8"),
)
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)