mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 03:26:11 +00:00
perf: reduce Python callback overhead and verify matched benchmarks
Reuse the Python callback method-name string and expose constant-time position count and presence. Preserve dynamic callbacks, Bar identity, and snapshot behavior with regression tests. Add isolated callback measurements and replace approximate cross-library comparisons with matched trades and equity assertions, separate setup/run timings, and reproducible reports. Track the remaining ingestion bottleneck in #53. Refs #48, #38, #39, #53 Codex 🤖, On behalf of Khizar
This commit is contained in:
@@ -3,12 +3,22 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Constant-time Python `Strategy` and `Broker` properties `has_positions` and
|
||||||
|
`position_count`, avoiding position-list copies for presence and count checks.
|
||||||
|
- A callback benchmark with separate conversion, engine construction,
|
||||||
|
initialization, and simulation timings, native and Python no-op baselines,
|
||||||
|
indicator and position-access workloads, correctness checks, and peak RSS.
|
||||||
- Documented and regression-tested the stop-first intrabar SL/TP policy,
|
- Documented and regression-tested the stop-first intrabar SL/TP policy,
|
||||||
inclusive touches and next-bar checks for new entries.
|
inclusive touches and next-bar checks for new entries.
|
||||||
- Contributor setup and validation guidance, bug/feature issue templates, an
|
- Contributor setup and validation guidance, bug/feature issue templates, an
|
||||||
API/platform compatibility policy, and a prioritized FX roadmap.
|
API/platform compatibility policy, and a prioritized FX roadmap.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Python strategy dispatch reuses the Python method-name string for `next`,
|
||||||
|
while preserving per-bar method lookup, distinct Bar objects, and exceptions.
|
||||||
|
- The existing cross-library comparison and benchmark now use matched fixed-unit
|
||||||
|
next-open strategies, verify trades and full equity curves, and report setup,
|
||||||
|
simulation, and total timings separately. Spread is zero; commission is matched.
|
||||||
- Python constructors reject nonfinite or out-of-range broker settings. Order,
|
- Python constructors reject nonfinite or out-of-range broker settings. Order,
|
||||||
partial-close, stop-update, closing-price, and equity-price inputs are checked
|
partial-close, stop-update, closing-price, and equity-price inputs are checked
|
||||||
before account mutation. Finite nonpositive partial sizes remain no-ops.
|
before account mutation. Finite nonpositive partial sizes remain no-ops.
|
||||||
|
|||||||
+4
-2
@@ -71,8 +71,10 @@ SMA and optimization examples use the included `data/EURUSD_1H.csv`.
|
|||||||
The HTML example generates synthetic data, writes
|
The HTML example generates synthetic data, writes
|
||||||
`backtestingfx-report.html`, and attempts to open a browser. It requires the
|
`backtestingfx-report.html`, and attempts to open a browser. It requires the
|
||||||
`report` extra. Comparison and benchmark scripts additionally require
|
`report` extra. Comparison and benchmark scripts additionally require
|
||||||
`python -m pip install backtesting`; report their versions and execution
|
`python -m pip install backtesting==0.6.6`; report their versions and execution
|
||||||
assumptions when sharing results.
|
assumptions when sharing results. See [the matched comparison](examples/benchmark.md)
|
||||||
|
for correctness checks and separate setup/run timings. Reference-comparison tests
|
||||||
|
skip when the optional dependency is absent.
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,21 @@ class MyStrategy(Strategy):
|
|||||||
| `self.close_partial(id, lot_size)` | Close part of a position, leaving the rest open |
|
| `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) |
|
| `self.update_sl(id, stop_loss)` | Move a position's stop loss (returns `False` if the id is gone) |
|
||||||
|
|
||||||
|
### Position inspection
|
||||||
|
|
||||||
|
Use `self.has_positions` to check whether any positions are open, or
|
||||||
|
`self.position_count` to read their count. Both read the current broker state
|
||||||
|
in constant time without copying positions. Before a run, they return `False`
|
||||||
|
and `0`. The direct Python `Broker` API provides the same read-only properties.
|
||||||
|
|
||||||
|
`self.positions` still returns a list of position snapshots. Use it when you
|
||||||
|
need IDs, sizes, or stop levels. For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not self.has_positions:
|
||||||
|
self.buy(0.1)
|
||||||
|
```
|
||||||
|
|
||||||
### Trailing stops and scaling out
|
### Trailing stops and scaling out
|
||||||
|
|
||||||
`update_sl` moves the stop on an open position in place, so a trailing stop costs
|
`update_sl` moves the stop on an open position in place, so a trailing stop costs
|
||||||
@@ -113,7 +128,7 @@ keep trailing it.
|
|||||||
```python
|
```python
|
||||||
class TrailingBreakout(Strategy):
|
class TrailingBreakout(Strategy):
|
||||||
def next(self):
|
def next(self):
|
||||||
if not self.positions:
|
if not self.has_positions:
|
||||||
self.buy(1.0, stop_loss=self._bar.close - 0.0050)
|
self.buy(1.0, stop_loss=self._bar.close - 0.0050)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -181,6 +196,12 @@ stops, pyramiding, "exit after N bars") still needs `next()` and a plain loop. I
|
|||||||
warmup must come out as `0.0`, not `NaN` — a `NaN` signal is rejected rather than
|
warmup must come out as `0.0`, not `NaN` — a `NaN` signal is rejected rather than
|
||||||
silently treated as "hold".
|
silently treated as "hold".
|
||||||
|
|
||||||
|
Precompute indicators once in `init()` when their values depend only on market
|
||||||
|
data, then read the current value in `next()`. Python callbacks remain useful
|
||||||
|
for decisions that depend on fills, cash, trailing stops, or partial closes.
|
||||||
|
The [callback benchmark](examples/benchmark_callbacks.md) separates conversion,
|
||||||
|
initialization, and simulation costs and compares these workloads.
|
||||||
|
|
||||||
## Backtest Parameters
|
## Backtest Parameters
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -360,6 +381,10 @@ in `Strategy::init()` or construct a fresh strategy for each run.
|
|||||||
|
|
||||||
The backtesting engine is written in Rust and compiled as a native Python extension via [PyO3](https://pyo3.rs). This means the event loop, broker simulation, and stats computation run at native speed while your strategy stays in plain Python.
|
The backtesting engine is written in Rust and compiled as a native Python extension via [PyO3](https://pyo3.rs). This means the event loop, broker simulation, and stats computation run at native speed while your strategy stays in plain Python.
|
||||||
|
|
||||||
|
The [matched backtesting.py comparison](examples/benchmark.md) checks trades and
|
||||||
|
full equity curves before timing both libraries. Current local results show a
|
||||||
|
faster FX run phase but slower total runtime because data conversion dominates.
|
||||||
|
|
||||||
## Contributing and project direction
|
## Contributing and project direction
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, checks, and examples,
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup, checks, and examples,
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ class Strategy:
|
|||||||
def positions(self):
|
def positions(self):
|
||||||
return self._broker.positions() if self._broker else []
|
return self._broker.positions() if self._broker else []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position_count(self):
|
||||||
|
return self._broker.position_count if self._broker is not None else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_positions(self):
|
||||||
|
return self._broker.has_positions if self._broker is not None else False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data(self):
|
def data(self):
|
||||||
if not self._bars:
|
if not self._bars:
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Matched comparison with backtesting.py
|
||||||
|
|
||||||
|
`compare_bt.py` verifies equivalent accounting before `benchmark.py` compares
|
||||||
|
speed. These replace the earlier scripts that used different position sizes,
|
||||||
|
fill timing, and SMA calculations and only printed approximate results.
|
||||||
|
|
||||||
|
## Run the comparison
|
||||||
|
|
||||||
|
From the repository root, with your Python environment activated:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install ".[report]" "backtesting==0.6.6"
|
||||||
|
repo_dir="$PWD"
|
||||||
|
cd /tmp
|
||||||
|
python "$repo_dir/examples/compare_bt.py" --commission 7
|
||||||
|
python "$repo_dir/examples/benchmark.py" \
|
||||||
|
--sizes 2000 10000 50000 --commissions 0 7 --repeats 5 \
|
||||||
|
--output matched-comparison.json
|
||||||
|
python -m unittest discover -s "$repo_dir/tests" -p test_reference_comparison.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The FX extension uses maturin's default release build. Reinstall after source
|
||||||
|
changes and run outside the checkout to avoid loading an old local extension.
|
||||||
|
The reference dependency is optional; the comparison tests skip when it is not
|
||||||
|
installed. The JSON report records library versions, CPU, OS, Python, Rust,
|
||||||
|
source HEAD, every timing sample, medians, and matching trade counts/equity.
|
||||||
|
|
||||||
|
## Shared strategy and execution rules
|
||||||
|
|
||||||
|
Both libraries use identical OHLCV values and UTC timestamps from the bundled
|
||||||
|
EURUSD data. Larger datasets tile that sample and regenerate a monotonic hourly
|
||||||
|
index. They are scaling fixtures, not additional independent historical data.
|
||||||
|
|
||||||
|
- Compute the same trailing SMA 10/50 decisions once with pandas before either
|
||||||
|
library runs. The precomputation uses past and current closes only. The warmup
|
||||||
|
is flat. Both callbacks read this shared array without recalculating indicators.
|
||||||
|
- Start with `$10,000` and use exactly `1,000` units, equivalent to `0.01` FX lots
|
||||||
|
with contract size `100,000`. Keep at most one long position. No shorting,
|
||||||
|
pyramiding, stops, or targets are used.
|
||||||
|
- Enter when the fast SMA is above the slow SMA while flat. Exit when it is
|
||||||
|
below while long. Decisions at bar `i` fill at bar `i+1`'s open.
|
||||||
|
- Queue a final close from the penultimate bar so both libraries finish flat at
|
||||||
|
the final bar's open. Do not open a new position from the final two decisions.
|
||||||
|
- Use USD quote/account currency with conversion rate `1`. Test commission `0`
|
||||||
|
and `$7` per lot per side. The reference commission callable charges
|
||||||
|
`abs(units) * 7 / 100000` per fill, matching the FX commission model.
|
||||||
|
- Set spread to zero. The libraries define nonzero spread differently, so this
|
||||||
|
comparison does not claim equivalence for nonzero spread.
|
||||||
|
|
||||||
|
backtesting.py uses its ordinary queued market orders with
|
||||||
|
`trade_on_close=False`, `hedging=False`, and no automatic finalization. A
|
||||||
|
standalone opposite order flattens the long. Backtestingfx's normal `buy()` and
|
||||||
|
`close_all()` fill immediately at the current close, so the comparison strategy
|
||||||
|
explicitly applies the previous decision through `Broker.buy()` or
|
||||||
|
`Broker.close_all()` at the current open. This is comparison strategy code,
|
||||||
|
not a new next-open engine option. Neither library's broker implementation is
|
||||||
|
patched. Both execute via their normal public `Backtest.run()` entry point.
|
||||||
|
See the [reference execution options](https://kernc.github.io/backtesting.py/doc/backtesting/backtesting.html).
|
||||||
|
|
||||||
|
The reference enforces margin while FX currently does not. Fixed exposure on
|
||||||
|
these datasets remains affordable, and the equivalence checks fail if that
|
||||||
|
difference affects executions. This does not establish margin-model equivalence.
|
||||||
|
|
||||||
|
## What must agree
|
||||||
|
|
||||||
|
Before timing, and again after every measured pair, assertions compare trade
|
||||||
|
count, direction, size, entry/exit timestamps, entry/exit prices, per-trade net
|
||||||
|
PnL, the entire bar-by-bar equity curve, final equity, return, win rate for
|
||||||
|
nonempty trades, and maximum drawdown. Monetary/price comparisons use absolute
|
||||||
|
tolerance `1e-8`, not rounded output. Unit sizes use `1e-10` tolerance.
|
||||||
|
|
||||||
|
FX prepends an initial-cash observation, so its first extra observation is
|
||||||
|
removed when aligning curves. Reference drawdown is negative while FX reports
|
||||||
|
its positive magnitude. No prices, timestamps, or curve observations are shifted
|
||||||
|
to make the assertions pass.
|
||||||
|
|
||||||
|
A seven-bar fixture has distinct opens and closes and two hand-calculated trades.
|
||||||
|
It checks next-open fills, final-open liquidation, commission, and every equity
|
||||||
|
observation. With commission `7`, trades enter at `1.2` and `1.5`, exit at `1.3`
|
||||||
|
and `1.6`, and each nets `$99.86`, ending at `$10,199.72`. A separate test
|
||||||
|
deliberately corrupts an entry price and proves the checker rejects it despite
|
||||||
|
the unchanged trade count.
|
||||||
|
|
||||||
|
This establishes equivalent accounting for this strategy and these settings.
|
||||||
|
It does not establish equivalence for arbitrary strategies, SL/TP policies,
|
||||||
|
leverage, nonzero spread, financing, or all reported statistics. For example,
|
||||||
|
the libraries use different Sharpe/annualization definitions.
|
||||||
|
|
||||||
|
## Timing boundaries
|
||||||
|
|
||||||
|
Load/normalize the input and compute the shared SMA decisions once outside
|
||||||
|
both library timers. Record this common preparation separately in JSON.
|
||||||
|
For each sample, construct a fresh `Backtest` and strategy and call `run()`.
|
||||||
|
Alternate library order between repetitions. There is one untimed warmup and
|
||||||
|
five measured pairs per dataset and commission setting. Report medians.
|
||||||
|
|
||||||
|
- **Setup:** from before the `Backtest` constructor through the end of strategy
|
||||||
|
`init()`. This includes each library's validation and data conversion, and FX's
|
||||||
|
DataFrame-to-Bar and initialization-history conversion.
|
||||||
|
- **Run:** from the end of `init()` until `run()` returns, including simulation,
|
||||||
|
finalization, and that library's normal statistics calculation.
|
||||||
|
- **Total:** the entire constructor plus `run()` call. This is the primary number
|
||||||
|
for deciding which library completes this workload faster.
|
||||||
|
|
||||||
|
The timing marker executes once during initialization. Assertions and printing
|
||||||
|
are outside the timing. The libraries compute different numbers of statistics;
|
||||||
|
the run-phase ratio is not a pure engine-loop speedup. Neither plotting nor
|
||||||
|
optimization is measured. Use [benchmark_callbacks.py](benchmark_callbacks.md)
|
||||||
|
to isolate callback overhead inside backtestingfx.
|
||||||
|
|
||||||
|
## Local results
|
||||||
|
|
||||||
|
Measured on an Intel Core i5-8350U, Linux x86_64, CPython 3.14.4, Rust 1.98.1,
|
||||||
|
PyO3 0.28.3, pandas 3.1.0, NumPy 2.5.4, and backtesting.py 0.6.6. The installed
|
||||||
|
FX release build includes the issue #48 callback optimization and lightweight
|
||||||
|
position properties. All equivalence checks passed for every measured pair.
|
||||||
|
|
||||||
|
| Bars | Commission per lot/side | Matching trades | Final equity |
|
||||||
|
| ---: | ---: | ---: | ---: |
|
||||||
|
| 2,000 | 0 | 34 | 9975.09 |
|
||||||
|
| 2,000 | 7 | 34 | 9970.33 |
|
||||||
|
| 10,000 | 0 | 174 | 9862.73 |
|
||||||
|
| 10,000 | 7 | 174 | 9838.37 |
|
||||||
|
| 50,000 | 0 | 874 | 9300.93 |
|
||||||
|
| 50,000 | 7 | 874 | 9178.57 |
|
||||||
|
|
||||||
|
Times below are milliseconds. Setup, run, and total are independently calculated
|
||||||
|
medians, so rounded setup plus run may not exactly equal the total.
|
||||||
|
|
||||||
|
| Bars | Commission | backtesting.py setup | Run | Total | backtestingfx setup | Run | Total |
|
||||||
|
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 2,000 | 0 | 1.1 | 20.0 | 21.1 | 79.3 | 1.4 | 80.7 |
|
||||||
|
| 2,000 | 7 | 1.2 | 20.7 | 21.9 | 75.5 | 1.4 | 76.9 |
|
||||||
|
| 10,000 | 0 | 1.3 | 56.3 | 57.6 | 393.1 | 7.2 | 400.3 |
|
||||||
|
| 10,000 | 7 | 1.2 | 54.9 | 56.0 | 383.7 | 7.5 | 391.9 |
|
||||||
|
| 50,000 | 0 | 1.5 | 240.0 | 241.5 | 1970.0 | 42.3 | 2011.6 |
|
||||||
|
| 50,000 | 7 | 1.3 | 237.6 | 239.1 | 1925.1 | 40.5 | 1967.8 |
|
||||||
|
|
||||||
|
For 50,000 bars with commission, backtestingfx's run phase was about 5.9 times
|
||||||
|
faster, but its total was about 8.2 times slower. Setup, especially row-by-row
|
||||||
|
DataFrame ingestion, dominated. Optimizing that conversion is tracked in #39.
|
||||||
|
These results do not support a claim that backtestingfx is faster end to end
|
||||||
|
for this workload. Timing varies with versions, hardware, and system load.
|
||||||
+70
-135
@@ -1,146 +1,81 @@
|
|||||||
"""
|
"""Time the matched comparison from compare_bt.py only after equivalence passes.
|
||||||
Speed benchmark: backtesting.py vs backtestingfx, on the same SMA-cross strategy.
|
|
||||||
|
|
||||||
Three paths are timed:
|
Run against installed release packages outside the checkout. See benchmark.md.
|
||||||
1. backtesting.py — everything in Python
|
|
||||||
2. backtestingfx (py) — Rust engine + broker, but next() runs in Python each bar
|
|
||||||
3. backtestingfx (rust) — SMA strategy runs entirely in Rust, no per-bar Python call
|
|
||||||
|
|
||||||
The dataset is small (2000 bars), so it's tiled up to BARS to give the engines
|
|
||||||
real work. Timestamps are regenerated as a clean hourly range so backtesting.py
|
|
||||||
gets the sorted DatetimeIndex it needs.
|
|
||||||
|
|
||||||
Note: paths 1 and 2 both pay the per-bar Python cost (the strategy logic), so the
|
|
||||||
gap between them measures only the engine/broker plumbing. Path 3 shows the
|
|
||||||
engine's ceiling when nothing crosses into Python.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import statistics
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pandas as pd
|
from benchmark_callbacks import metadata
|
||||||
from backtesting import Backtest as BtBacktest, Strategy as BtStrategy
|
from compare_bt import Comparison, load_data
|
||||||
from backtestingfx import Backtest, Strategy
|
|
||||||
from backtestingfx import _backtestingfx as _rust
|
|
||||||
|
|
||||||
FAST = 10
|
|
||||||
SLOW = 50
|
|
||||||
LOT = 0.1
|
|
||||||
BARS = 50_000
|
|
||||||
REPEATS = 2
|
|
||||||
|
|
||||||
|
|
||||||
class SmaCrossBt(BtStrategy):
|
def measure(size, commission, repeats):
|
||||||
def init(self):
|
start = time.perf_counter()
|
||||||
pass
|
comparison = Comparison(load_data(size), commission=commission)
|
||||||
|
common_setup = time.perf_counter() - start
|
||||||
def next(self):
|
# Untimed equivalence check and warmup before collecting any timing samples.
|
||||||
if len(self.data.Close) < SLOW:
|
reference, _ = comparison.run("backtesting.py")
|
||||||
return
|
actual, _ = comparison.run("backtestingfx")
|
||||||
fast_sma = self.data.Close[-FAST:].mean()
|
comparison.verify(reference, actual)
|
||||||
slow_sma = self.data.Close[-SLOW:].mean()
|
samples = {name: [] for name in ("backtesting.py", "backtestingfx")}
|
||||||
if not self.position:
|
for repetition in range(repeats):
|
||||||
if fast_sma > slow_sma:
|
order = list(samples)
|
||||||
self.buy()
|
if repetition % 2:
|
||||||
elif fast_sma < slow_sma:
|
order.reverse()
|
||||||
self.position.close()
|
results = {}
|
||||||
|
for name in order:
|
||||||
|
result, timings = comparison.run(name)
|
||||||
|
results[name] = result
|
||||||
|
samples[name].append(timings)
|
||||||
|
# Every measured pair must also agree. Assertions are outside timing.
|
||||||
|
comparison.verify(results["backtesting.py"], results["backtestingfx"])
|
||||||
|
report = {"bars": size, "commission_per_lot_per_side": commission,
|
||||||
|
"common_preparation_seconds": common_setup,
|
||||||
|
"matching_trades": actual.num_trades, "final_equity": actual.final_cash,
|
||||||
|
"equivalence": "passed", "libraries": {}}
|
||||||
|
for name, times in samples.items():
|
||||||
|
report["libraries"][name] = {
|
||||||
|
**{key: statistics.median(row[key] for row in times) for key in times[0]},
|
||||||
|
"samples": times,
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
class SmaCrossFx(Strategy):
|
def main():
|
||||||
def next(self):
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
if self.index < SLOW:
|
parser.add_argument("--sizes", type=int, nargs="+", default=[2000, 10_000, 50_000])
|
||||||
return
|
parser.add_argument("--commissions", type=float, nargs="+", default=[0.0, 7.0])
|
||||||
closes = [b.close for b in self.data[-SLOW:]]
|
parser.add_argument("--repeats", type=int, default=5)
|
||||||
fast_sma = sum(closes[-FAST:]) / FAST
|
parser.add_argument("--output", type=Path)
|
||||||
slow_sma = sum(closes) / SLOW
|
args = parser.parse_args()
|
||||||
if not self.positions:
|
if args.repeats < 1 or any(size < 3 for size in args.sizes):
|
||||||
if fast_sma > slow_sma:
|
parser.error("sizes must be at least three and repeats must be positive")
|
||||||
self.buy(LOT)
|
report = {"metadata": metadata(), "repeats": args.repeats, "results": []}
|
||||||
elif fast_sma < slow_sma:
|
report["metadata"]["package_versions"]["backtesting"] = importlib.metadata.version("backtesting")
|
||||||
self.close_all()
|
print("Times are medians; run includes simulation, finalization, and each library's statistics.")
|
||||||
|
for size in args.sizes:
|
||||||
|
for commission in args.commissions:
|
||||||
|
result = measure(size, commission, args.repeats)
|
||||||
|
report["results"].append(result)
|
||||||
|
print(f"\nPASS: {size:,} bars, commission {commission}/lot/side, "
|
||||||
|
f"{result['matching_trades']} matching trades, equity {result['final_equity']:.8f}",
|
||||||
|
flush=True)
|
||||||
|
for name, timings in result["libraries"].items():
|
||||||
|
print(f" {name:<15} setup {timings['setup_seconds']:.4f} s "
|
||||||
|
f"run {timings['simulation_seconds']:.4f} s "
|
||||||
|
f"total {timings['total_seconds']:.4f} s", flush=True)
|
||||||
|
bt = result["libraries"]["backtesting.py"]
|
||||||
|
fx = result["libraries"]["backtestingfx"]
|
||||||
|
print(f" backtestingfx speed ratio: run {bt['simulation_seconds'] / fx['simulation_seconds']:.2f}x; "
|
||||||
|
f"total {bt['total_seconds'] / fx['total_seconds']:.2f}x. Above 1 means faster.", flush=True)
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def load_tiled(n):
|
if __name__ == "__main__":
|
||||||
"""Load the EURUSD CSV and tile it up to n rows with a fresh hourly index."""
|
main()
|
||||||
df = pd.read_csv("data/EURUSD_1H.csv")[["open", "high", "low", "close", "volume"]]
|
|
||||||
reps = -(-n // len(df)) # ceil division
|
|
||||||
df = pd.concat([df] * reps, ignore_index=True).iloc[:n].copy()
|
|
||||||
df["timestamp"] = pd.date_range("2000-01-01", periods=n, freq="h")
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def best_of(fn, repeats=REPEATS):
|
|
||||||
"""Run fn repeats times, return (best_seconds, result_of_first_run)."""
|
|
||||||
best = float("inf")
|
|
||||||
result = None
|
|
||||||
for i in range(repeats):
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
r = fn()
|
|
||||||
dt = time.perf_counter() - t0
|
|
||||||
if i == 0:
|
|
||||||
result = r
|
|
||||||
best = min(best, dt)
|
|
||||||
return best, result
|
|
||||||
|
|
||||||
|
|
||||||
df = load_tiled(BARS)
|
|
||||||
|
|
||||||
# backtesting.py wants a DatetimeIndex and capitalised columns
|
|
||||||
df_bt = df.set_index("timestamp").rename(
|
|
||||||
columns={"open": "Open", "high": "High", "low": "Low", "close": "Close", "volume": "Volume"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# native-rust path needs Bar objects built directly
|
|
||||||
bars = [
|
|
||||||
_rust.Bar(
|
|
||||||
timestamp=int(ts.timestamp()),
|
|
||||||
open=float(o), high=float(h), low=float(l), close=float(c), volume=float(v),
|
|
||||||
)
|
|
||||||
for ts, o, h, l, c, v in zip(
|
|
||||||
df["timestamp"], df["open"], df["high"], df["low"], df["close"], df["volume"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def run_bt():
|
|
||||||
return BtBacktest(df_bt, SmaCrossBt, cash=10_000, commission=0).run()
|
|
||||||
|
|
||||||
|
|
||||||
def run_fx_py():
|
|
||||||
return Backtest(df, SmaCrossFx, cash=10_000, commission=0, spread=0).run()
|
|
||||||
|
|
||||||
|
|
||||||
def run_fx_rust():
|
|
||||||
engine = _rust.Engine(bars, 10_000.0, 0.0, 0.0, 100_000.0, 1.0)
|
|
||||||
return engine.run_native_sma(FAST, SLOW, LOT)
|
|
||||||
|
|
||||||
|
|
||||||
print(f"Benchmarking on {BARS:,} bars, best of {REPEATS} runs...\n", flush=True)
|
|
||||||
|
|
||||||
print(" timing backtesting.py ...", flush=True)
|
|
||||||
t_bt, r_bt = best_of(run_bt)
|
|
||||||
print(" timing backtestingfx (py next) ...", flush=True)
|
|
||||||
t_py, r_py = best_of(run_fx_py)
|
|
||||||
print(" timing backtestingfx (rust) ...\n", flush=True)
|
|
||||||
t_rs, r_rs = best_of(run_fx_rust)
|
|
||||||
|
|
||||||
print("=" * 62)
|
|
||||||
print(f"{'Path':<24}{'Time (s)':>10}{'Bars/sec':>14}{'Speedup':>12}")
|
|
||||||
print("=" * 62)
|
|
||||||
for name, t in [
|
|
||||||
("backtesting.py", t_bt),
|
|
||||||
("backtestingfx (py next)", t_py),
|
|
||||||
("backtestingfx (rust)", t_rs),
|
|
||||||
]:
|
|
||||||
print(f"{name:<24}{t:>10.3f}{BARS / t:>14,.0f}{t_bt / t:>11.1f}x")
|
|
||||||
print("=" * 62)
|
|
||||||
|
|
||||||
print("\nCorrectness (should roughly agree):")
|
|
||||||
print(f"{'Path':<24}{'Trades':>10}{'Return %':>12}")
|
|
||||||
print(f"{'backtesting.py':<24}{r_bt['# Trades']:>10}{r_bt['Return [%]']:>12.2f}")
|
|
||||||
print(f"{'backtestingfx (py next)':<24}{r_py.num_trades:>10}{r_py.total_return_pct:>12.2f}")
|
|
||||||
print(f"{'backtestingfx (rust)':<24}{r_rs.num_trades:>10}{r_rs.total_return_pct:>12.2f}")
|
|
||||||
print(
|
|
||||||
"\nNote: py-next vs rust measure the same engine; the gap is the per-bar\n"
|
|
||||||
"Python callback cost. Both use fixed lots, so Return % differs from\n"
|
|
||||||
"backtesting.py's cash-based sizing."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Python callback benchmark
|
||||||
|
|
||||||
|
This benchmark measures the Rust-to-Python callback path separately from data
|
||||||
|
conversion. It also compares indicator work, trading, and position inspection.
|
||||||
|
It uses the real Python `_Adapter` and the normal Rust broker, equity, statistics,
|
||||||
|
and final-liquidation code. It does not compare different backtesting libraries.
|
||||||
|
|
||||||
|
## Run the benchmark
|
||||||
|
|
||||||
|
From the repository root, with your Python environment activated:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install .
|
||||||
|
repo_dir="$PWD"
|
||||||
|
cd /tmp
|
||||||
|
python "$repo_dir/examples/benchmark_callbacks.py" \
|
||||||
|
--sizes 1000 10000 100000 --repeats 7 --conversion-repeats 3 \
|
||||||
|
--label after --output callback-after.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The normal pip build uses maturin's release profile. Reinstall after Rust or
|
||||||
|
Python source changes. Run outside the checkout to use the installed extension.
|
||||||
|
Keep correctness assertions enabled; do not run with `python -O`.
|
||||||
|
Use `--cases native-noop direct-noop adapter-noop` for a narrower comparison.
|
||||||
|
|
||||||
|
Each dataset runs in a fresh subprocess. JSON output records all simulation
|
||||||
|
samples, medians for every phase, peak RSS, CPU, OS, Python, Rust, dependency
|
||||||
|
versions, the Cargo.lock PyO3 version, and the source checkout's HEAD. The HEAD
|
||||||
|
identifies the source checkout, not the installed wheel. Use `--label` to identify
|
||||||
|
working-tree builds, and make sure the installed wheel matches your source.
|
||||||
|
Peak RSS is unavailable on platforms without Python's `resource` module.
|
||||||
|
|
||||||
|
## Timing boundaries
|
||||||
|
|
||||||
|
All paths use the same prepared midpoint bars and cost configuration. Synthetic
|
||||||
|
prices are flat at `1.1`, with a `0.0001` spread, commission `7` per lot per side,
|
||||||
|
contract size `100000`, and quote-to-account conversion `1.27`.
|
||||||
|
|
||||||
|
1. **Conversion:** time `Backtest._to_bars()` separately. DataFrame construction
|
||||||
|
is outside this measurement. Use the last conversion's bars for every case.
|
||||||
|
2. **Engine setup:** construct a fresh `Engine` from those bars for every sample.
|
||||||
|
Record this separately from the run. Construct a fresh strategy and adapter
|
||||||
|
outside the run timer too.
|
||||||
|
3. **Initialization:** start the run timer before `Engine.run()`. A single timestamp
|
||||||
|
at the end of the real adapter's `init()` marks the boundary. This includes
|
||||||
|
Rust-to-Python conversion of the initialization history and any indicator
|
||||||
|
precomputation. Broker creation follows this boundary and is part of the run.
|
||||||
|
4. **Simulation:** measure from that timestamp until `Engine.run()` returns.
|
||||||
|
Include callbacks, SL/TP scans, equity, final liquidation, statistics, and
|
||||||
|
conversion of results back to Python. No timing calls are added per bar.
|
||||||
|
|
||||||
|
The native no-op has no Python initialization. Its measured run includes its
|
||||||
|
empty native `init()` and the normal engine setup inside `Engine::run()`, plus
|
||||||
|
the same broker, equity, finalization, and statistics paths. The private
|
||||||
|
`Engine._run_native_noop()` method is a benchmark hook, not a supported strategy API.
|
||||||
|
|
||||||
|
There is one untimed warmup followed by seven measured runs per case. Reported
|
||||||
|
times are medians. No engines, strategy instances, or brokers carry state between
|
||||||
|
samples. Correctness assertions run after timing. They check trade count, fills,
|
||||||
|
net PnL, final cash, equity length and endpoints, no-op equity, final indicator
|
||||||
|
values, and position inspection. Trading uses fixed `0.001` lots.
|
||||||
|
|
||||||
|
Peak RSS is the whole dataset subprocess's high-water mark after all cases,
|
||||||
|
including imports, conversion, bars, equity, trades, and correctness checks. It is not per-case
|
||||||
|
allocation or the memory cost of a single callback. Native memory is included.
|
||||||
|
|
||||||
|
## Local results
|
||||||
|
|
||||||
|
Measured on an Intel Core i5-8350U, Linux x86_64, CPython 3.14.4, Rust 1.98.1,
|
||||||
|
PyO3 0.28.3, pandas 3.1.0, and NumPy 2.5.4, with default Cargo release settings.
|
||||||
|
Both versions used the same dependency versions, benchmark, data, and costs.
|
||||||
|
The baseline used dispatch from `d9687e7` with the native no-op benchmark hook
|
||||||
|
added. The compatible version reuses the Python method-name string and adds
|
||||||
|
position count/presence properties. It keeps dynamic callback lookup and fresh
|
||||||
|
callback Bar objects.
|
||||||
|
|
||||||
|
| Bars | Adapter no-op before (ns/bar) | After (ns/bar) | Run-time reduction |
|
||||||
|
| ---: | ---: | ---: | ---: |
|
||||||
|
| 1,000 | 290.7 | 201.4 | 30.7% |
|
||||||
|
| 10,000 | 294.8 | 204.4 | 30.7% |
|
||||||
|
| 100,000 | 311.1 | 229.7 | 26.1% |
|
||||||
|
|
||||||
|
At 100,000 bars, the compatible build measured:
|
||||||
|
|
||||||
|
| Workload | Initialization (ms) | Simulation (ms) | Simulation (ns/bar) |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| Native no-op | 0.00 | 1.22 | 12.2 |
|
||||||
|
| Direct Python no-op | 6.63 | 15.77 | 157.7 |
|
||||||
|
| Python adapter no-op | 5.23 | 22.97 | 229.7 |
|
||||||
|
| Rolling 20-bar mean in `next()` | 4.76 | 330.45 | 3304.5 |
|
||||||
|
| Precompute mean in `init()` | 15.80 | 36.60 | 366.0 |
|
||||||
|
| Open and close one trade per bar | 4.98 | 83.81 | 838.1 |
|
||||||
|
| Hold 32 positions without inspection | 3.10 | 38.71 | 387.1 |
|
||||||
|
| Read `len(self.positions)` with 32 positions | 3.00 | 230.73 | 2307.3 |
|
||||||
|
| Read `self.position_count` with 32 positions | 3.01 | 52.87 | 528.7 |
|
||||||
|
| Read `self.has_positions` with 32 positions | 3.09 | 54.81 | 548.1 |
|
||||||
|
|
||||||
|
The direct-to-adapter difference is about 72 ns/bar here. It includes adapter
|
||||||
|
state updates and the extra `Strategy.next()` dispatch. List inspection is much
|
||||||
|
more expensive with 32 positions because it copies the position vector and
|
||||||
|
creates Python position objects. The presence workload takes about 4.2 times
|
||||||
|
less simulation time than list inspection on this fixture. The read workloads
|
||||||
|
share the same subclass dispatch; the hold-only case omits that extra method call.
|
||||||
|
|
||||||
|
At this size, constructing the engine for the adapter no-op took about 4.92 ms,
|
||||||
|
and DataFrame-to-Bar conversion took 3.93 seconds. The 26.1% reduction applies
|
||||||
|
to the measured callback run, not to a complete `Backtest.run()`. Conversion
|
||||||
|
still dominates that workload and is tracked separately in #39.
|
||||||
|
|
||||||
|
| Bars | Peak RSS before (MiB) | After (MiB) |
|
||||||
|
| ---: | ---: | ---: |
|
||||||
|
| 1,000 | 78.53 | 78.37 |
|
||||||
|
| 10,000 | 83.50 | 84.19 |
|
||||||
|
| 100,000 | 135.88 | 135.82 |
|
||||||
|
|
||||||
|
These whole-process high-water marks do not establish a memory improvement.
|
||||||
|
The after run includes the new count/presence cases too. Hardware, allocator
|
||||||
|
state, Python version, and system load affect timings. Differences between rows
|
||||||
|
estimate workload costs; they are not additive profiler measurements.
|
||||||
|
|
||||||
|
## Dispatch and allocation experiments
|
||||||
|
|
||||||
|
Exploratory runs used 100,000 bars, nine repetitions, and one conversion sample,
|
||||||
|
with only `native-noop`, `direct-noop`, and `adapter-noop` selected.
|
||||||
|
|
||||||
|
| Variant | Direct Python (ns/bar) | Adapter (ns/bar) | Decision |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| Reuse method-name string | 167.3 | 214.8 | Keep |
|
||||||
|
| Also cache the bound callable once | 141.1 | 230.7 | Reject |
|
||||||
|
| Reuse initialization Bar objects, dynamic callable | 129.3 | 181.5 | Reject |
|
||||||
|
|
||||||
|
The retained change uses [PyO3's `intern!` macro](https://docs.rs/pyo3/0.28.3/pyo3/macro.intern.html)
|
||||||
|
to reuse the Python string `"next"` instead of constructing it for every call.
|
||||||
|
It still resolves the method on every bar.
|
||||||
|
|
||||||
|
Caching the bound callable broke
|
||||||
|
`test_direct_engine_resolves_callback_replacements_on_later_bars`: the old
|
||||||
|
callback ran three times instead of honoring the replacement on later bars.
|
||||||
|
The adapter measurement also did not show a consistent gain over the interned
|
||||||
|
name alone. Reusing initialization Bars measured lower callback cost, but broke
|
||||||
|
`test_callbacks_keep_distinct_bar_objects_and_retained_values` because the
|
||||||
|
callback received the same object as the initialization history. These changes
|
||||||
|
were removed. Fresh callback objects and existing position snapshot semantics
|
||||||
|
remain intact.
|
||||||
|
|
||||||
|
To reproduce the old dispatch in a separate checkout, keep the benchmark hook
|
||||||
|
and replace the interned-name call in `Engine::run_py` with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
strategy.bind(py).call_method(
|
||||||
|
"next", (bar.clone(), broker_py.clone_ref(py)), None,
|
||||||
|
)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the unused `next_name` declaration, rebuild the installed package, and
|
||||||
|
run the same benchmark command with `--label before`. Do not compare a debug
|
||||||
|
build with a release build.
|
||||||
|
|
||||||
|
## Choose an execution path
|
||||||
|
|
||||||
|
Use Python `next()` for decisions that depend on the broker: fills, current
|
||||||
|
equity, trailing stops, or partial closes. Read `has_positions` or `position_count`
|
||||||
|
when you only need presence or a count; use `positions` when you need snapshots.
|
||||||
|
|
||||||
|
Precompute indicators during initialization when they depend only on market
|
||||||
|
data, then read the current value during callbacks. The indicator rows include
|
||||||
|
the added initialization work and show its separate cost.
|
||||||
|
|
||||||
|
For broker-independent target-lot signals and parameter grids, use `optimize()`.
|
||||||
|
It computes one signal array per combination and runs the simulations in native
|
||||||
|
threads with the GIL released. Python callbacks still execute once per bar under
|
||||||
|
the GIL. No batching, callback removal, or new single-run signal API is introduced
|
||||||
|
by this change.
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Isolate callback, adapter, indicator, and position-inspection costs.
|
||||||
|
|
||||||
|
Run against an installed release build, outside the repository. See
|
||||||
|
benchmark_callbacks.md for commands, timing boundaries, and interpretation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backtestingfx import Backtest, Engine, Strategy
|
||||||
|
from backtestingfx.backtest import _Adapter
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = (10_000.0, 7.0, 0.0001, 100_000.0, 1.27)
|
||||||
|
LOT = 0.001
|
||||||
|
|
||||||
|
|
||||||
|
class Noop(Strategy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DirectNoop:
|
||||||
|
def init(self, bars):
|
||||||
|
self.setup_finished = time.perf_counter_ns()
|
||||||
|
|
||||||
|
def next(self, bar, broker):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TimedAdapter(_Adapter):
|
||||||
|
def init(self, bars):
|
||||||
|
super().init(bars)
|
||||||
|
self.setup_finished = time.perf_counter_ns()
|
||||||
|
|
||||||
|
|
||||||
|
class RollingIndicator(Strategy):
|
||||||
|
def next(self):
|
||||||
|
self.value = sum(bar.close for bar in self.data[-20:]) / min(self.index + 1, 20)
|
||||||
|
|
||||||
|
|
||||||
|
class PrecomputedIndicator(Strategy):
|
||||||
|
def init(self):
|
||||||
|
closes = np.array([bar.close for bar in self._bars])
|
||||||
|
sums = np.concatenate(([0.0], np.cumsum(closes)))
|
||||||
|
ends = np.arange(1, len(closes) + 1)
|
||||||
|
starts = np.maximum(0, ends - 20)
|
||||||
|
self.values = (sums[ends] - sums[starts]) / (ends - starts)
|
||||||
|
|
||||||
|
def next(self):
|
||||||
|
self.value = self.values[self.index]
|
||||||
|
|
||||||
|
|
||||||
|
class Hold(Strategy):
|
||||||
|
count = 1
|
||||||
|
|
||||||
|
def next(self):
|
||||||
|
if self.index == 0:
|
||||||
|
for _ in range(self.count):
|
||||||
|
self.buy(LOT)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadPositions(Hold):
|
||||||
|
def next(self):
|
||||||
|
super().next()
|
||||||
|
self.observed = len(self.positions)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadCount(Hold):
|
||||||
|
def next(self):
|
||||||
|
super().next()
|
||||||
|
self.observed = self.position_count
|
||||||
|
|
||||||
|
|
||||||
|
class ReadPresence(Hold):
|
||||||
|
def next(self):
|
||||||
|
super().next()
|
||||||
|
self.observed = self.has_positions
|
||||||
|
|
||||||
|
|
||||||
|
class TradeEveryBar(Strategy):
|
||||||
|
def next(self):
|
||||||
|
self.buy(LOT)
|
||||||
|
self.close_all()
|
||||||
|
|
||||||
|
|
||||||
|
def cases():
|
||||||
|
result = {
|
||||||
|
"native-noop": (None, 0),
|
||||||
|
"direct-noop": (DirectNoop, 0),
|
||||||
|
"adapter-noop": (Noop, 0),
|
||||||
|
"rolling-indicator": (RollingIndicator, 0),
|
||||||
|
"precomputed-indicator": (PrecomputedIndicator, 0),
|
||||||
|
"trade-every-bar": (TradeEveryBar, None),
|
||||||
|
}
|
||||||
|
for count in (1, 32):
|
||||||
|
for name, base in (("hold", Hold), ("positions", ReadPositions),
|
||||||
|
("count", ReadCount), ("presence", ReadPresence)):
|
||||||
|
if base is ReadCount and not hasattr(Strategy, "position_count"):
|
||||||
|
continue
|
||||||
|
if base is ReadPresence and not hasattr(Strategy, "has_positions"):
|
||||||
|
continue
|
||||||
|
result[f"{name}-{count}"] = (type(f"{name}{count}", (base,), {"count": count}), count)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assert_result(stats, bars, trades):
|
||||||
|
# Flat midpoint: each trade pays one full spread, two commissions, and conversion.
|
||||||
|
pnl = -(0.0001 * 100_000 * 1.27 + 2 * 7.0) * LOT
|
||||||
|
assert stats.num_trades == trades, (stats.num_trades, trades)
|
||||||
|
assert math.isclose(stats.final_cash, 10_000 + trades * pnl, abs_tol=1e-5)
|
||||||
|
assert len(stats.equity_curve) == bars + 1
|
||||||
|
assert stats.equity_curve[0] == 10_000.0
|
||||||
|
assert stats.equity_curve[-1] == stats.final_cash
|
||||||
|
if trades == 0:
|
||||||
|
assert all(value == 10_000.0 for value in stats.equity_curve)
|
||||||
|
assert all(math.isclose(trade.entry_price, 1.10005, abs_tol=1e-12)
|
||||||
|
and math.isclose(trade.exit_price, 1.09995, abs_tol=1e-12)
|
||||||
|
and math.isclose(trade.pnl, pnl, abs_tol=1e-10) for trade in stats.trades)
|
||||||
|
|
||||||
|
|
||||||
|
def run_dataset(size, repeats, conversion_repeats, selected):
|
||||||
|
data = pd.DataFrame(
|
||||||
|
{column: np.full(size, 1.1) for column in ("open", "high", "low", "close")},
|
||||||
|
index=pd.date_range("2026-01-01", periods=size, freq="s", tz="UTC"),
|
||||||
|
)
|
||||||
|
backtest = Backtest(data)
|
||||||
|
conversion = []
|
||||||
|
for _ in range(conversion_repeats):
|
||||||
|
start = time.perf_counter_ns()
|
||||||
|
bars = backtest._to_bars(data)
|
||||||
|
conversion.append((time.perf_counter_ns() - start) / 1e9)
|
||||||
|
# Release the old list before allocating another one in the next repetition.
|
||||||
|
if len(conversion) < conversion_repeats:
|
||||||
|
del bars
|
||||||
|
|
||||||
|
results = []
|
||||||
|
available = cases()
|
||||||
|
for name, (strategy_type, expected_trades) in available.items():
|
||||||
|
if selected and name not in selected:
|
||||||
|
continue
|
||||||
|
samples = []
|
||||||
|
# One untimed warmup, then fresh engines and strategies for every sample.
|
||||||
|
for repetition in range(repeats + 1):
|
||||||
|
start_construction = time.perf_counter_ns()
|
||||||
|
engine = Engine(bars, *CONFIG)
|
||||||
|
constructed = time.perf_counter_ns()
|
||||||
|
strategy = strategy_type() if strategy_type else None
|
||||||
|
if strategy is None:
|
||||||
|
adapter = None
|
||||||
|
elif name == "direct-noop":
|
||||||
|
adapter = strategy
|
||||||
|
else:
|
||||||
|
adapter = TimedAdapter(strategy)
|
||||||
|
start = time.perf_counter_ns()
|
||||||
|
if adapter is None:
|
||||||
|
stats = engine._run_native_noop()
|
||||||
|
else:
|
||||||
|
stats = engine.run(adapter)
|
||||||
|
end = time.perf_counter_ns()
|
||||||
|
setup_finished = adapter.setup_finished if adapter else start
|
||||||
|
if repetition:
|
||||||
|
samples.append({
|
||||||
|
"engine_setup_seconds": (constructed - start_construction) / 1e9,
|
||||||
|
"init_seconds": (setup_finished - start) / 1e9,
|
||||||
|
"simulation_seconds": (end - setup_finished) / 1e9,
|
||||||
|
})
|
||||||
|
assert_result(stats, size, size if expected_trades is None else expected_trades)
|
||||||
|
if isinstance(strategy, (RollingIndicator, PrecomputedIndicator)):
|
||||||
|
assert math.isclose(strategy.value, 1.1, abs_tol=1e-8)
|
||||||
|
if isinstance(strategy, Hold):
|
||||||
|
if isinstance(strategy, ReadPresence):
|
||||||
|
assert strategy.observed is True
|
||||||
|
elif isinstance(strategy, (ReadPositions, ReadCount)):
|
||||||
|
assert strategy.observed == strategy.count
|
||||||
|
del stats, engine, strategy, adapter
|
||||||
|
results.append({"case": name, "bars": size,
|
||||||
|
**{key: statistics.median(sample[key] for sample in samples)
|
||||||
|
for key in samples[0]},
|
||||||
|
"simulation_samples_seconds": [s["simulation_seconds"] for s in samples]})
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||||
|
peak_rss = rss if sys.platform == "darwin" else rss * 1024
|
||||||
|
except ImportError:
|
||||||
|
peak_rss = None
|
||||||
|
return {"bars": size, "conversion_seconds": statistics.median(conversion),
|
||||||
|
"peak_rss_bytes": peak_rss, "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
def command_output(command):
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(command, text=True, stderr=subprocess.DEVNULL).strip()
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def metadata():
|
||||||
|
cpu = platform.processor()
|
||||||
|
if Path("/proc/cpuinfo").exists():
|
||||||
|
cpu = next((line.split(":", 1)[1].strip() for line in
|
||||||
|
Path("/proc/cpuinfo").read_text().splitlines()
|
||||||
|
if line.startswith("model name")), cpu)
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
lock = root / "Cargo.lock"
|
||||||
|
pyo3 = re.search(r'name = "pyo3"\nversion = "([^"]+)"', lock.read_text()) if lock.exists() else None
|
||||||
|
return {"python": sys.version, "platform": platform.platform(), "cpu": cpu,
|
||||||
|
"pyo3_lock_version": pyo3.group(1) if pyo3 else "unavailable",
|
||||||
|
"rust": command_output(["rustc", "--version"]),
|
||||||
|
"git_head": command_output(["git", "-C", str(root), "rev-parse", "HEAD"]),
|
||||||
|
"package_versions": {name: importlib.metadata.version(name)
|
||||||
|
for name in ("backtestingfx", "pandas", "numpy")}}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--sizes", nargs="+", type=int, default=[1000, 10_000, 100_000])
|
||||||
|
parser.add_argument("--repeats", type=int, default=7)
|
||||||
|
parser.add_argument("--conversion-repeats", type=int, default=3)
|
||||||
|
parser.add_argument("--cases", nargs="+")
|
||||||
|
parser.add_argument("--label", default="working-tree")
|
||||||
|
parser.add_argument("--output", type=Path)
|
||||||
|
parser.add_argument("--worker-size", type=int, help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.repeats < 1 or args.conversion_repeats < 1 or any(size < 1 for size in args.sizes):
|
||||||
|
parser.error("sizes and repeats must be positive")
|
||||||
|
if args.cases and set(args.cases) - set(cases()):
|
||||||
|
parser.error(f"available cases: {', '.join(cases())}")
|
||||||
|
if args.worker_size:
|
||||||
|
print(json.dumps(run_dataset(args.worker_size, args.repeats, args.conversion_repeats, args.cases)))
|
||||||
|
return
|
||||||
|
report = {"label": args.label, "metadata": metadata(), "repeats": args.repeats,
|
||||||
|
"conversion_repeats": args.conversion_repeats,
|
||||||
|
"datasets": []}
|
||||||
|
for size in args.sizes:
|
||||||
|
command = [sys.executable, str(Path(__file__).resolve()), "--worker-size", str(size),
|
||||||
|
"--repeats", str(args.repeats), "--conversion-repeats", str(args.conversion_repeats)]
|
||||||
|
if args.cases:
|
||||||
|
command.extend(["--cases", *args.cases])
|
||||||
|
dataset = json.loads(subprocess.check_output(command, text=True))
|
||||||
|
report["datasets"].append(dataset)
|
||||||
|
print(f"{size:,} bars; conversion {dataset['conversion_seconds']:.4f} s; "
|
||||||
|
f"peak RSS {dataset['peak_rss_bytes']} bytes", flush=True)
|
||||||
|
for row in dataset["results"]:
|
||||||
|
print(f" {row['case']:<23} init {row['init_seconds']:.6f} s "
|
||||||
|
f"run {row['simulation_seconds']:.6f} s "
|
||||||
|
f"{row['simulation_seconds'] / size * 1e9:.1f} ns/bar", flush=True)
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+150
-69
@@ -1,90 +1,171 @@
|
|||||||
"""
|
"""Verify matched next-open SMA backtests against backtesting.py.
|
||||||
Compares our SMA crossover logic against backtesting.py using the same data,
|
|
||||||
same SMA periods, and zero commission/spread so only entry/exit logic is tested.
|
|
||||||
|
|
||||||
Trade count and win rate should match between both libraries.
|
Install backtesting==0.6.6, then run this file. See benchmark.md for the shared
|
||||||
Dollar PnL will differ because the sizing models are different:
|
execution policy, supported comparisons, and timing commands.
|
||||||
- backtesting.py: buys fractional units based on available cash
|
|
||||||
- backtestingfx: fixed lot size (0.1 lots = 10,000 units)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from backtesting import Backtest as BtBacktest, Strategy as BtStrategy
|
from backtesting import Backtest as BtBacktest, Strategy as BtStrategy
|
||||||
from backtestingfx import Backtest, Strategy
|
from backtestingfx import Backtest, Strategy
|
||||||
|
|
||||||
|
|
||||||
|
CONTRACT_SIZE = 100_000.0
|
||||||
|
CASH = 10_000.0
|
||||||
|
UNITS = 1000
|
||||||
FAST = 10
|
FAST = 10
|
||||||
SLOW = 50
|
SLOW = 50
|
||||||
|
|
||||||
|
|
||||||
# ── backtesting.py ────────────────────────────────────────────────────────────
|
def load_data(count=None):
|
||||||
|
path = Path(__file__).resolve().parents[1] / "data/EURUSD_1H.csv"
|
||||||
class SmaCrossBt(BtStrategy):
|
data = pd.read_csv(path)
|
||||||
fast = FAST
|
data.index = pd.to_datetime(data.pop("timestamp"), utc=True)
|
||||||
slow = SLOW
|
data = data[["open", "high", "low", "close", "volume"]]
|
||||||
|
if count is not None:
|
||||||
def init(self):
|
if count < 3:
|
||||||
pass
|
raise ValueError("comparison needs at least three bars")
|
||||||
|
data = pd.concat([data] * (-(-count // len(data))), ignore_index=True).iloc[:count].copy()
|
||||||
def next(self):
|
data.index = pd.date_range("2000-01-01", periods=count, freq="h", tz="UTC")
|
||||||
if len(self.data.Close) < self.slow:
|
return data
|
||||||
return
|
|
||||||
fast_sma = self.data.Close[-self.fast:].mean()
|
|
||||||
slow_sma = self.data.Close[-self.slow:].mean()
|
|
||||||
|
|
||||||
if not self.position:
|
|
||||||
if fast_sma > slow_sma:
|
|
||||||
self.buy()
|
|
||||||
else:
|
|
||||||
if fast_sma < slow_sma:
|
|
||||||
self.position.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ── backtestingfx ─────────────────────────────────────────────────────────────
|
def sma_decisions(data, fast=FAST, slow=SLOW):
|
||||||
|
fast_sma = data["close"].rolling(fast).mean()
|
||||||
class SmaCrossFx(Strategy):
|
slow_sma = data["close"].rolling(slow).mean()
|
||||||
fast = FAST
|
return np.where(fast_sma > slow_sma, 1, np.where(fast_sma < slow_sma, -1, 0))
|
||||||
slow = SLOW
|
|
||||||
|
|
||||||
def next(self):
|
|
||||||
if self.index < self.slow:
|
|
||||||
return
|
|
||||||
closes = [b.close for b in self.data[-self.slow:]]
|
|
||||||
fast_sma = sum(closes[-self.fast:]) / self.fast
|
|
||||||
slow_sma = sum(closes) / self.slow
|
|
||||||
|
|
||||||
if not self.positions:
|
|
||||||
if fast_sma > slow_sma:
|
|
||||||
self.buy(0.1)
|
|
||||||
else:
|
|
||||||
if fast_sma < slow_sma:
|
|
||||||
self.close_all()
|
|
||||||
|
|
||||||
|
|
||||||
# ── run both ──────────────────────────────────────────────────────────────────
|
def strategy_types(decisions, units, clocks):
|
||||||
|
last_bar = len(decisions) - 1
|
||||||
|
lots = units / CONTRACT_SIZE
|
||||||
|
|
||||||
df_raw = pd.read_csv("data/EURUSD_1H.csv")
|
class SmaCrossBt(BtStrategy):
|
||||||
|
def init(self):
|
||||||
|
clocks["backtesting.py"] = time.perf_counter_ns()
|
||||||
|
|
||||||
# backtesting.py needs a DatetimeIndex and capitalised column names
|
def next(self):
|
||||||
df_bt = df_raw.copy()
|
index = len(self.data) - 1
|
||||||
df_bt["timestamp"] = pd.to_datetime(df_bt["timestamp"], utc=True)
|
if index >= last_bar - 1:
|
||||||
df_bt = df_bt.set_index("timestamp")
|
if index == last_bar - 1 and self.position:
|
||||||
df_bt.index = df_bt.index.tz_localize(None)
|
self.sell(size=units)
|
||||||
df_bt = df_bt.rename(columns={"open": "Open", "high": "High", "low": "Low", "close": "Close", "volume": "Volume"})
|
return
|
||||||
|
decision = decisions[index]
|
||||||
|
if decision > 0 and not self.position:
|
||||||
|
self.buy(size=units)
|
||||||
|
elif decision < 0 and self.position:
|
||||||
|
self.sell(size=units)
|
||||||
|
|
||||||
bt_result = BtBacktest(df_bt, SmaCrossBt, cash=10_000, commission=0).run()
|
class SmaCrossFx(Strategy):
|
||||||
|
def init(self):
|
||||||
|
clocks["backtestingfx"] = time.perf_counter_ns()
|
||||||
|
|
||||||
fx_result = Backtest(df_raw, SmaCrossFx, cash=10_000, commission=0, spread=0).run()
|
def next(self):
|
||||||
|
if self.index == 0:
|
||||||
|
return
|
||||||
|
price = self._bar.open
|
||||||
|
timestamp = self._bar.timestamp
|
||||||
|
if self.index == last_bar:
|
||||||
|
self._broker.close_all(price, timestamp)
|
||||||
|
return
|
||||||
|
# Broker calls implement next-open fills explicitly. Strategy.buy()
|
||||||
|
# normally fills immediately at the current close in backtestingfx.
|
||||||
|
decision = decisions[self.index - 1]
|
||||||
|
if decision > 0 and not self.has_positions:
|
||||||
|
self._broker.buy(price, lots, timestamp, None, None)
|
||||||
|
elif decision < 0 and self.has_positions:
|
||||||
|
self._broker.close_all(price, timestamp)
|
||||||
|
|
||||||
# ── compare ───────────────────────────────────────────────────────────────────
|
return SmaCrossBt, SmaCrossFx
|
||||||
|
|
||||||
print("=" * 45)
|
|
||||||
print(f"{'Metric':<20} {'backtesting.py':>12} {'backtestingfx':>12}")
|
class Comparison:
|
||||||
print("=" * 45)
|
def __init__(self, data, commission=0.0, units=UNITS, decisions=None):
|
||||||
print(f"{'Trades':<20} {bt_result['# Trades']:>12} {fx_result.num_trades:>12}")
|
if len(data) < 3:
|
||||||
print(f"{'Win Rate %':<20} {bt_result['Win Rate [%]']:>12.1f} {fx_result.win_rate_pct:>12.1f}")
|
raise ValueError("comparison needs at least three bars")
|
||||||
print(f"{'Return %':<20} {bt_result['Return [%]']:>12.2f} {fx_result.total_return_pct:>12.2f}")
|
if units < 1 or units != int(units):
|
||||||
print(f"{'Max Drawdown %':<20} {bt_result['Max. Drawdown [%]']:>12.2f} {fx_result.max_drawdown_pct:>12.2f}")
|
raise ValueError("units must be a positive integer")
|
||||||
print("=" * 45)
|
self.data = data
|
||||||
print()
|
self.commission = commission
|
||||||
print("Note: Return % differs because backtesting.py sizes by available cash,")
|
self.units = int(units)
|
||||||
print(" backtestingfx uses fixed 0.1 lots. Trade count + win rate should match.")
|
self.decisions = sma_decisions(data) if decisions is None else np.asarray(decisions)
|
||||||
|
if self.decisions.shape != (len(data),):
|
||||||
|
raise ValueError("decisions must contain one value per bar")
|
||||||
|
if self.decisions[0] != 0:
|
||||||
|
raise ValueError("the first decision must be flat because backtesting.py starts at bar 1")
|
||||||
|
self.clocks = {}
|
||||||
|
self.bt_strategy, self.fx_strategy = strategy_types(self.decisions, self.units, self.clocks)
|
||||||
|
self.bt_data = data.rename(columns=str.capitalize)
|
||||||
|
|
||||||
|
def new_bt(self):
|
||||||
|
commission = self.commission
|
||||||
|
fee = (lambda size, price: abs(size) * commission / CONTRACT_SIZE) if commission else 0.0
|
||||||
|
return BtBacktest(
|
||||||
|
self.bt_data, self.bt_strategy, cash=CASH, commission=fee, spread=0.0,
|
||||||
|
trade_on_close=False, hedging=False, exclusive_orders=False, finalize_trades=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def new_fx(self):
|
||||||
|
return Backtest(self.data, self.fx_strategy, cash=CASH, commission=self.commission,
|
||||||
|
spread=0.0, contract_size=CONTRACT_SIZE, quote_to_account=1.0)
|
||||||
|
|
||||||
|
def run(self, name):
|
||||||
|
start = time.perf_counter_ns()
|
||||||
|
backtest = self.new_bt() if name == "backtesting.py" else self.new_fx()
|
||||||
|
stats = backtest.run()
|
||||||
|
end = time.perf_counter_ns()
|
||||||
|
initialized = self.clocks[name]
|
||||||
|
timings = {"setup_seconds": (initialized - start) / 1e9,
|
||||||
|
"simulation_seconds": (end - initialized) / 1e9,
|
||||||
|
"total_seconds": (end - start) / 1e9}
|
||||||
|
return stats, timings
|
||||||
|
|
||||||
|
def verify(self, reference, actual):
|
||||||
|
trades = reference["_trades"]
|
||||||
|
fx_trades = actual.trades
|
||||||
|
np.testing.assert_equal(len(trades), len(fx_trades))
|
||||||
|
np.testing.assert_equal(actual.num_trades, len(trades))
|
||||||
|
if len(trades):
|
||||||
|
np.testing.assert_array_equal(trades["Size"], [self.units] * len(fx_trades))
|
||||||
|
np.testing.assert_allclose([trade.lot_size * CONTRACT_SIZE for trade in fx_trades],
|
||||||
|
trades["Size"], rtol=0, atol=1e-10)
|
||||||
|
np.testing.assert_array_equal([trade.is_long for trade in fx_trades], [True] * len(fx_trades))
|
||||||
|
for bt_field, fx_field in (("EntryPrice", "entry_price"), ("ExitPrice", "exit_price"),
|
||||||
|
("PnL", "pnl")):
|
||||||
|
np.testing.assert_allclose(trades[bt_field], [getattr(t, fx_field) for t in fx_trades],
|
||||||
|
rtol=0, atol=1e-8)
|
||||||
|
for bt_field, fx_field in (("EntryTime", "entry_timestamp"), ("ExitTime", "exit_timestamp")):
|
||||||
|
expected = [int(stamp.timestamp()) for stamp in trades[bt_field]]
|
||||||
|
np.testing.assert_array_equal(expected, [getattr(t, fx_field) for t in fx_trades])
|
||||||
|
np.testing.assert_allclose(reference["Win Rate [%]"], actual.win_rate_pct, rtol=0, atol=1e-8)
|
||||||
|
# FX prepends an initial-cash observation before the first bar.
|
||||||
|
np.testing.assert_allclose(reference["_equity_curve"]["Equity"], actual.equity_curve[1:],
|
||||||
|
rtol=0, atol=1e-8)
|
||||||
|
np.testing.assert_allclose(reference["Equity Final [$]"], actual.final_cash, rtol=0, atol=1e-8)
|
||||||
|
np.testing.assert_allclose(reference["Return [%]"], actual.total_return_pct, rtol=0, atol=1e-8)
|
||||||
|
np.testing.assert_allclose(-reference["Max. Drawdown [%]"], actual.max_drawdown_pct,
|
||||||
|
rtol=0, atol=1e-8)
|
||||||
|
np.testing.assert_allclose(actual.final_cash - CASH, sum(t.pnl for t in fx_trades),
|
||||||
|
rtol=0, atol=1e-8)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--bars", type=int)
|
||||||
|
parser.add_argument("--commission", type=float, default=0.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
comparison = Comparison(load_data(args.bars), commission=args.commission)
|
||||||
|
bt_result, _ = comparison.run("backtesting.py")
|
||||||
|
fx_result, _ = comparison.run("backtestingfx")
|
||||||
|
comparison.verify(bt_result, fx_result)
|
||||||
|
print(f"PASS: {len(comparison.data):,} bars, {fx_result.num_trades} matching trades, "
|
||||||
|
f"final equity {fx_result.final_cash:.8f}, commission {args.commission}/lot/side")
|
||||||
|
print("Verified fills, sizes, timestamps, net PnL, full equity curve, return, win rate, and drawdown.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -286,6 +286,16 @@ impl Broker {
|
|||||||
self.positions()
|
self.positions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn position_count(&self) -> usize {
|
||||||
|
self.positions.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn has_positions(&self) -> bool {
|
||||||
|
!self.positions.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
#[pyo3(name = "buy")]
|
#[pyo3(name = "buy")]
|
||||||
fn py_buy(
|
fn py_buy(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
+13
-1
@@ -70,6 +70,12 @@ impl Strategy for SmaCross {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Noop;
|
||||||
|
|
||||||
|
impl Strategy for Noop {
|
||||||
|
fn next(&mut self, _bar: &Bar, _broker: &mut Broker) {}
|
||||||
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
data: Vec<Bar>,
|
data: Vec<Bar>,
|
||||||
@@ -128,6 +134,7 @@ impl Engine {
|
|||||||
),
|
),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let next_name = pyo3::intern!(py, "next");
|
||||||
for bar in &self.data {
|
for bar in &self.data {
|
||||||
{
|
{
|
||||||
let mut b = broker_py.borrow_mut(py);
|
let mut b = broker_py.borrow_mut(py);
|
||||||
@@ -135,7 +142,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
strategy
|
strategy
|
||||||
.bind(py)
|
.bind(py)
|
||||||
.call_method("next", (bar.clone(), broker_py.clone_ref(py)), None)?;
|
.call_method1(next_name, (bar.clone(), broker_py.clone_ref(py)))?;
|
||||||
let equity = broker_py.borrow(py).equity(bar.close);
|
let equity = broker_py.borrow(py).equity(bar.close);
|
||||||
self.equity_curve.push(equity);
|
self.equity_curve.push(equity);
|
||||||
}
|
}
|
||||||
@@ -164,6 +171,11 @@ impl Engine {
|
|||||||
};
|
};
|
||||||
Ok(self.run(&mut strat))
|
Ok(self.run(&mut strat))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Benchmark baseline with the same broker, equity, statistics, and finalization paths.
|
||||||
|
fn _run_native_noop(&mut self) -> Stats {
|
||||||
|
self.run(&mut Noop)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backtestingfx import Backtest, Bar, Broker, Engine, Strategy
|
||||||
|
|
||||||
|
|
||||||
|
def bars():
|
||||||
|
return [Bar(i, price, price, price, price, 0.0)
|
||||||
|
for i, price in enumerate((1.1, 1.2, 1.3))]
|
||||||
|
|
||||||
|
|
||||||
|
def engine():
|
||||||
|
return Engine(bars(), 10_000.0, 7.0, 0.0001, 100_000.0, 1.27)
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackContractTest(unittest.TestCase):
|
||||||
|
def test_empty_engine_does_not_require_a_next_method(self):
|
||||||
|
class InitOnly:
|
||||||
|
def init(self, data):
|
||||||
|
if data:
|
||||||
|
raise AssertionError("expected no bars")
|
||||||
|
|
||||||
|
stats = Engine([], 10_000.0, 0.0, 0.0, 100_000.0, 1.0).run(InitOnly())
|
||||||
|
self.assertEqual(stats.num_trades, 0)
|
||||||
|
self.assertEqual(stats.equity_curve, [10_000.0])
|
||||||
|
|
||||||
|
def test_strategy_position_inspection_before_run_and_after_fills(self):
|
||||||
|
seen = []
|
||||||
|
strategy = Strategy()
|
||||||
|
self.assertFalse(strategy.has_positions)
|
||||||
|
self.assertEqual(strategy.position_count, 0)
|
||||||
|
|
||||||
|
class Probe(Strategy):
|
||||||
|
def next(self):
|
||||||
|
seen.append((self.index, self.position_count, self.has_positions))
|
||||||
|
if self.index == 0:
|
||||||
|
self.buy(0.4, stop_loss=1.09)
|
||||||
|
self.buy(0.6)
|
||||||
|
self.assert_state(2)
|
||||||
|
self.close_partial(self.positions[0].id, 0.2)
|
||||||
|
self.assert_state(2)
|
||||||
|
elif self.index == 1:
|
||||||
|
# The first position stopped out before this callback.
|
||||||
|
self.close_all()
|
||||||
|
self.assert_state(0)
|
||||||
|
|
||||||
|
def assert_state(self, count):
|
||||||
|
if self.position_count != count or self.has_positions != (count > 0):
|
||||||
|
raise AssertionError("position inspection differs from live broker state")
|
||||||
|
|
||||||
|
data = pd.DataFrame(
|
||||||
|
{column: [1.1, 1.08, 1.1] for column in ("open", "high", "low", "close")},
|
||||||
|
index=pd.date_range("2026-01-01", periods=3, freq="h", tz="UTC"),
|
||||||
|
)
|
||||||
|
stats = Backtest(data, Probe).run()
|
||||||
|
self.assertEqual(seen, [(0, 0, False), (1, 1, True), (2, 0, False)])
|
||||||
|
self.assertEqual(stats.num_trades, 3)
|
||||||
|
self.assertAlmostEqual(stats.final_cash - 10_000.0, sum(t.pnl for t in stats.trades))
|
||||||
|
|
||||||
|
def test_broker_count_and_presence_do_not_change_position_snapshots(self):
|
||||||
|
account = Broker(10_000.0, 0.0, 0.0, 100_000.0, 1.0)
|
||||||
|
self.assertEqual(account.position_count, 0)
|
||||||
|
self.assertFalse(account.has_positions)
|
||||||
|
account.buy(1.1, 1.0, 0, None, None)
|
||||||
|
saved = account.positions()
|
||||||
|
account.close_partial(0, 0.4, 1.1, 1)
|
||||||
|
self.assertEqual(account.position_count, 1)
|
||||||
|
self.assertTrue(account.has_positions)
|
||||||
|
self.assertEqual(saved[0].lot_size, 1.0)
|
||||||
|
self.assertAlmostEqual(account.positions()[0].lot_size, 0.6)
|
||||||
|
account.close_all(1.1, 2)
|
||||||
|
self.assertFalse(account.has_positions)
|
||||||
|
self.assertEqual(account.position_count, 0)
|
||||||
|
|
||||||
|
def test_callbacks_keep_distinct_bar_objects_and_retained_values(self):
|
||||||
|
class Retain:
|
||||||
|
def init(self, data):
|
||||||
|
self.data = data
|
||||||
|
self.seen = []
|
||||||
|
|
||||||
|
def next(self, bar, broker):
|
||||||
|
self.seen.append(bar)
|
||||||
|
|
||||||
|
strategy = Retain()
|
||||||
|
stats = engine().run(strategy)
|
||||||
|
self.assertEqual([bar.timestamp for bar in strategy.seen], [0, 1, 2])
|
||||||
|
self.assertEqual([bar.close for bar in strategy.seen], [1.1, 1.2, 1.3])
|
||||||
|
for received, original in zip(strategy.seen, strategy.data):
|
||||||
|
self.assertIsNot(received, original)
|
||||||
|
self.assertEqual(received.close, original.close)
|
||||||
|
self.assertEqual(stats.equity_curve, [10_000.0] * 4)
|
||||||
|
|
||||||
|
def test_direct_engine_resolves_callback_replacements_on_later_bars(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Replace:
|
||||||
|
def init(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def next(self, bar, broker):
|
||||||
|
seen.append(("first", bar.timestamp))
|
||||||
|
self.next = self.replacement
|
||||||
|
|
||||||
|
def replacement(self, bar, broker):
|
||||||
|
seen.append(("replacement", bar.timestamp))
|
||||||
|
|
||||||
|
engine().run(Replace())
|
||||||
|
self.assertEqual(seen, [("first", 0), ("replacement", 1), ("replacement", 2)])
|
||||||
|
|
||||||
|
def test_callback_errors_propagate_and_engine_can_be_reused(self):
|
||||||
|
failure = RuntimeError("callback failure")
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Fail:
|
||||||
|
def init(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def next(self, bar, broker):
|
||||||
|
seen.append(bar.timestamp)
|
||||||
|
raise failure
|
||||||
|
|
||||||
|
simulation = engine()
|
||||||
|
with self.assertRaises(RuntimeError) as raised:
|
||||||
|
simulation.run(Fail())
|
||||||
|
self.assertIs(raised.exception, failure)
|
||||||
|
self.assertEqual(seen, [0])
|
||||||
|
|
||||||
|
class Noop:
|
||||||
|
def init(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def next(self, bar, broker):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(simulation.run(Noop()).equity_curve, [10_000.0] * 4)
|
||||||
|
|
||||||
|
def test_adapter_keeps_index_history_and_dynamic_strategy_methods(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Probe(Strategy):
|
||||||
|
def next(self):
|
||||||
|
seen.append((self.index, len(self.data), self.data[-1].close))
|
||||||
|
self.next = self.replacement
|
||||||
|
|
||||||
|
def replacement(self):
|
||||||
|
seen.append((self.index, len(self.data), self.data[-1].close))
|
||||||
|
with self_check.assertRaises(IndexError):
|
||||||
|
self.data[self.index + 1]
|
||||||
|
|
||||||
|
self_check = self
|
||||||
|
data = pd.DataFrame(
|
||||||
|
{column: [1.1, 1.2, 1.3] for column in ("open", "high", "low", "close")},
|
||||||
|
index=pd.date_range("2026-01-01", periods=3, freq="h", tz="UTC"),
|
||||||
|
)
|
||||||
|
Backtest(data, Probe).run()
|
||||||
|
self.assertEqual(seen, [(0, 1, 1.1), (1, 2, 1.2), (2, 3, 1.3)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
try:
|
||||||
|
import backtesting
|
||||||
|
except ImportError:
|
||||||
|
backtesting = None
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(backtesting is None, "optional comparison requires backtesting==0.6.6")
|
||||||
|
class ReferenceComparisonTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples"))
|
||||||
|
from compare_bt import Comparison
|
||||||
|
cls.comparison_type = Comparison
|
||||||
|
|
||||||
|
def comparison(self, commission=7.0, decisions=None):
|
||||||
|
opens = np.array([1.0, 1.1, 1.2, 1.4, 1.3, 1.5, 1.6])
|
||||||
|
closes = np.array([1.05, 1.15, 1.25, 1.35, 1.4, 1.55, 1.65])
|
||||||
|
data = pd.DataFrame(
|
||||||
|
{"open": opens, "high": np.maximum(opens, closes) + 0.01,
|
||||||
|
"low": np.minimum(opens, closes) - 0.01, "close": closes},
|
||||||
|
index=pd.date_range("2026-01-01", periods=len(opens), freq="h", tz="UTC"),
|
||||||
|
)
|
||||||
|
if decisions is None:
|
||||||
|
decisions = [0, 1, 0, -1, 1, 0, 0]
|
||||||
|
return self.comparison_type(data, commission=commission, decisions=decisions)
|
||||||
|
|
||||||
|
def test_next_open_trades_and_equity_match_hand_calculated_fixture(self):
|
||||||
|
comparison = self.comparison()
|
||||||
|
reference, _ = comparison.run("backtesting.py")
|
||||||
|
actual, _ = comparison.run("backtestingfx")
|
||||||
|
comparison.verify(reference, actual)
|
||||||
|
self.assertEqual(actual.num_trades, 2)
|
||||||
|
self.assertAlmostEqual(actual.final_cash, 10_199.72)
|
||||||
|
np.testing.assert_allclose(actual.equity_curve[1:],
|
||||||
|
[10_000, 10_000, 10_049.93, 10_149.93,
|
||||||
|
10_099.86, 10_149.79, 10_199.72], rtol=0, atol=1e-8)
|
||||||
|
np.testing.assert_allclose([trade.entry_price for trade in actual.trades], [1.2, 1.5])
|
||||||
|
np.testing.assert_allclose([trade.exit_price for trade in actual.trades], [1.3, 1.6])
|
||||||
|
np.testing.assert_allclose([trade.pnl for trade in actual.trades], [99.86, 99.86])
|
||||||
|
|
||||||
|
def test_no_trade_and_zero_commission_cases_match(self):
|
||||||
|
for decisions, expected_trades, equity in (([0] * 7, 0, 10_000),
|
||||||
|
([0, 1, 0, -1, 1, 0, 0], 2, 10_200)):
|
||||||
|
comparison = self.comparison(commission=0.0, decisions=decisions)
|
||||||
|
reference, _ = comparison.run("backtesting.py")
|
||||||
|
actual, _ = comparison.run("backtestingfx")
|
||||||
|
comparison.verify(reference, actual)
|
||||||
|
self.assertEqual(actual.num_trades, expected_trades)
|
||||||
|
self.assertAlmostEqual(actual.final_cash, equity)
|
||||||
|
|
||||||
|
def test_checker_rejects_price_mismatch_even_when_trade_count_matches(self):
|
||||||
|
comparison = self.comparison()
|
||||||
|
reference, _ = comparison.run("backtesting.py")
|
||||||
|
actual, _ = comparison.run("backtestingfx")
|
||||||
|
reference["_trades"] = reference["_trades"].copy()
|
||||||
|
reference["_trades"].loc[0, "EntryPrice"] += 0.01
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
comparison.verify(reference, actual)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user