mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 11:36:31 +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
|
# 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
|
## [0.2.0] - 2026-08-30
|
||||||
|
|
||||||
First release to reach PyPI since 0.1.1 — the `v0.1.2` tag predated the release
|
First release to reach PyPI since 0.1.1 — the `v0.1.2` tag predated the release
|
||||||
|
|||||||
@@ -189,12 +189,37 @@ Backtest(
|
|||||||
StrategyClass,
|
StrategyClass,
|
||||||
cash=10000.0, # starting account balance in USD
|
cash=10000.0, # starting account balance in USD
|
||||||
commission=0.0, # commission per lot (e.g. 7.0 = $7/lot)
|
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
|
contract_size=100000.0, # standard FX lot size, don't change this
|
||||||
quote_to_account=1.0, # conversion rate from quote currency to USD
|
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
|
### Trading non-USD pairs
|
||||||
|
|
||||||
By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD).
|
By default `quote_to_account=1.0` which is correct for USD-quoted pairs (EURUSD, GBPUSD).
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ class _Adapter:
|
|||||||
|
|
||||||
|
|
||||||
class Backtest:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
df,
|
df,
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ def render_report(
|
|||||||
{chart_html}
|
{chart_html}
|
||||||
<div class="assumptions">
|
<div class="assumptions">
|
||||||
<div class="assumption"><span>Commission / lot / side</span><strong>{commission:,.4f}</strong></div>
|
<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>Contract size</span><strong>{contract_size:,.0f}</strong></div>
|
||||||
<div class="assumption"><span>Quote conversion</span><strong>{quote_to_account:,.5f}</strong></div>
|
<div class="assumption"><span>Quote conversion</span><strong>{quote_to_account:,.5f}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+90
-6
@@ -10,6 +10,7 @@ pub struct Broker {
|
|||||||
pub positions: Vec<Position>,
|
pub positions: Vec<Position>,
|
||||||
pub trade_history: Vec<Trade>,
|
pub trade_history: Vec<Trade>,
|
||||||
pub commission: f64,
|
pub commission: f64,
|
||||||
|
/// Full bid–ask width in price units; supplied prices are midpoints.
|
||||||
pub spread: f64,
|
pub spread: f64,
|
||||||
pub contract_size: f64,
|
pub contract_size: f64,
|
||||||
pub quote_to_account: f64,
|
pub quote_to_account: f64,
|
||||||
@@ -17,15 +18,23 @@ pub struct Broker {
|
|||||||
|
|
||||||
// Rust-internal only, not exposed to Python
|
// Rust-internal only, not exposed to Python
|
||||||
impl Broker {
|
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:
|
// 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.
|
// 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
|
// `lots` is how much of the position to close, so a partial close is the same code
|
||||||
// with a smaller number.
|
// with a smaller number.
|
||||||
fn settle(&mut self, position: &Position, lots: f64, price: f64, timestamp: i64) {
|
fn settle(&mut self, position: &Position, lots: f64, price: f64, timestamp: i64) {
|
||||||
let close_price = if position.is_long {
|
let close_price = if position.is_long {
|
||||||
price - self.spread
|
self.bid(price)
|
||||||
} else {
|
} else {
|
||||||
price + self.spread
|
self.ask(price)
|
||||||
};
|
};
|
||||||
|
|
||||||
let pnl = if position.is_long {
|
let pnl = if position.is_long {
|
||||||
@@ -123,7 +132,7 @@ impl Broker {
|
|||||||
stop_loss: Option<f64>,
|
stop_loss: Option<f64>,
|
||||||
take_profit: Option<f64>,
|
take_profit: Option<f64>,
|
||||||
) {
|
) {
|
||||||
let fill_price = price + self.spread;
|
let fill_price = self.ask(price);
|
||||||
self.cash -= self.commission * lot_size;
|
self.cash -= self.commission * lot_size;
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
@@ -146,7 +155,7 @@ impl Broker {
|
|||||||
stop_loss: Option<f64>,
|
stop_loss: Option<f64>,
|
||||||
take_profit: Option<f64>,
|
take_profit: Option<f64>,
|
||||||
) {
|
) {
|
||||||
let fill_price = price - self.spread;
|
let fill_price = self.bid(price);
|
||||||
self.cash -= self.commission * lot_size;
|
self.cash -= self.commission * lot_size;
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
@@ -224,12 +233,12 @@ impl Broker {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
if p.is_long {
|
if p.is_long {
|
||||||
(current_price - p.entry_price)
|
(self.bid(current_price) - p.entry_price)
|
||||||
* p.lot_size
|
* p.lot_size
|
||||||
* self.contract_size
|
* self.contract_size
|
||||||
* self.quote_to_account
|
* self.quote_to_account
|
||||||
} else {
|
} else {
|
||||||
(p.entry_price - current_price)
|
(p.entry_price - self.ask(current_price))
|
||||||
* p.lot_size
|
* p.lot_size
|
||||||
* self.contract_size
|
* self.contract_size
|
||||||
* self.quote_to_account
|
* 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]
|
#[test]
|
||||||
fn long_position_closes_at_stop_loss_not_bar_close() {
|
fn long_position_closes_at_stop_loss_not_bar_close() {
|
||||||
let mut broker = test_broker(0.0, 0.0);
|
let mut broker = test_broker(0.0, 0.0);
|
||||||
|
|||||||
@@ -89,6 +89,63 @@ class TrailAndScaleOut(Strategy):
|
|||||||
self.close_partial(position.id, 0.5)
|
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):
|
class PartialAndTrailingStopTest(unittest.TestCase):
|
||||||
def test_scaling_out_and_trailing_the_stop_through_the_python_api(self):
|
def test_scaling_out_and_trailing_the_stop_through_the_python_api(self):
|
||||||
backtest = Backtest(rising_market(bars=6), TrailAndScaleOut, cash=10_000.0)
|
backtest = Backtest(rising_market(bars=6), TrailAndScaleOut, cash=10_000.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user