mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 03:26:11 +00:00
Fix full bid-ask spread semantics and equity marking
Codex 🤖, On behalf of Khizar
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user