bench: add raptorbt as a third engine, and a 10M-bar point (#6)

The harness compared two engines everywhere; it now compares N against a
reference. manifoldbt is the reference: every parity check and every ratio is
a challenger against it, never two challengers against each other.

raptorbt 0.9.0 joins on three of the four workloads. Its sma_cross comes back
bit-identical to the reference's final equity, and its rsi matches to the last
bit; its ema seeds on a different warmup and it has no fixed-quantity sizing,
so the fee workload records it as unsupported with the reason rather than
leaving a blank cell. On the bracket it diverges in its own documented way: it
never re-arms while the entry level holds, so it books exactly the reference's
round-trips minus the ones that re-enter on the exit bar.

Python moves to 3.12, which raptorbt pins rather than we do: it is built
against pyo3 0.20.3, whose maximum supported CPython is 3.12. Timings from runs
before this change are therefore not directly comparable.

The bar matrix gains 10M and the repetition default drops from 7 to 2. Measured,
those two almost cancel: the job stays around 16 minutes. macOS keeps its old
ceiling, since 10M bars adds 1.55 GB on vectorbt's side alone and that runner
has 7 GB.
This commit is contained in:
Exocet92
2026-08-20 16:16:33 +02:00
committed by GitHub
parent 48c1067bd0
commit 52cbe1ba54
13 changed files with 2028 additions and 334 deletions
+26 -8
View File
@@ -4,9 +4,10 @@
# published wheel from PyPI, exactly like any user would. That is what makes the
# result independent rather than self-reported, and it is why anyone can fork
# this repository and press "Run workflow" to reproduce the numbers on their own
# runner.
# runner. Same for the engines it is compared against: they come from PyPI at
# pinned versions, and the run prints the versions it resolved.
name: Benchmark vs vectorbt
name: Benchmark vs vectorbt and raptorbt
on:
workflow_dispatch:
@@ -18,7 +19,14 @@ on:
reps:
description: "Interleaved repetitions per point"
required: false
default: "7"
# 2, not 7. The budget went into a longer series instead: at 10M bars
# one vectorbt call costs 99 s, so seven of them would put the job past
# its timeout on Windows. Two repetitions still bracket the number (min,
# median and max are all published, and the median of two is their mean)
# but the noise flag gets cruder, since an interquartile range wants at
# least four samples to mean anything. A point that looks surprising is
# worth re-running at a higher `reps` before it is quoted anywhere.
default: "2"
release:
types: [published]
schedule:
@@ -42,13 +50,15 @@ jobs:
matrix:
include:
- os: ubuntu-latest
bars: "10000 100000 1000000 5000000"
bars: "10000 100000 1000000 5000000 10000000"
- os: windows-latest
bars: "10000 100000 1000000 5000000"
bars: "10000 100000 1000000 5000000 10000000"
# macOS runners ship 7 GB of RAM against 16 GB elsewhere, and vectorbt
# materialises the simulation in memory (roughly 150 MB per million
# bars, measured). The top size is trimmed so a point is never lost to
# swapping, which would time the disk instead of the engine.
# swapping, which would time the disk instead of the engine. The same
# arithmetic is why 10M bars is added on the other two and not here:
# measured, that point adds 1.55 GB on vectorbt's side alone.
- os: macos-latest
bars: "10000 100000 1000000"
@@ -60,7 +70,15 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.13"
# 3.12, not 3.13, and it is raptorbt that pins it: it is built against
# pyo3 0.20.3, whose maximum supported CPython is 3.12. No release up
# to 0.9.0 publishes a cp313 wheel and a source build refuses outright
# ("the configured Python interpreter version (3.13) is newer than
# PyO3's maximum supported version (3.12)"). Comparing engines means
# running them in one environment, and the environment has to be one
# they all support. Runs before 2026-08-20 used 3.13 with two engines,
# so their absolute timings are not directly comparable with these.
python-version: "3.12"
- name: Install engines from PyPI
shell: bash
@@ -81,7 +99,7 @@ jobs:
- name: Record the resolved environment
shell: bash
run: pip freeze | grep -iE '^(manifoldbt|vectorbt|numpy|numba|pandas|psutil)=' || true
run: pip freeze | grep -iE '^(manifoldbt|vectorbt|raptorbt|numpy|numba|pandas|psutil)=' || true
- name: Run the benchmark
shell: bash
+124 -40
View File
@@ -1,11 +1,11 @@
# manifoldbt vs vectorbt
# manifoldbt vs vectorbt vs raptorbt
An engine-to-engine benchmark you can re-run yourself. It installs both engines
from PyPI, generates its own data, checks that the two engines produced the
**same result**, and only then reports how long each took.
An engine-to-engine benchmark you can re-run yourself. It installs every engine
from PyPI, generates its own data, checks that they produced the **same
result**, and only then reports how long each took.
```bash
pip install manifoldbt vectorbt
pip install manifoldbt
pip install -r requirements-lock.txt
python bench.py --bars 10000 100000 1000000 --reps 7 --cold-start-reps 3 --memory-bars 2000000 --out results.json
python report.py results.json
@@ -15,22 +15,41 @@ No dataset to download, no API key, no configuration. The same command runs in
GitHub Actions on a public runner, so every published number has a run URL
behind it.
**Python 3.12, and not by preference.** raptorbt is built against pyo3 0.20.3,
whose maximum supported CPython is 3.12: no release up to 0.9.0 publishes a
cp313 wheel, and a source build refuses outright. Comparing engines means
running them in one environment, and the environment has to be one all of them
support. Add or drop a challenger with `--engines`; an engine that is not
installed is skipped with a printed line rather than crashing the run.
**manifoldbt is the reference.** Every parity check and every ratio is a
challenger against it, never two challengers against each other: three engines
make three pairs, and a table of pairs is a matrix, not a benchmark. The
reference gets no advantage from the position, it is simply the one every
timing is divided by.
## The rule this harness is built around
A speed comparison between two backtesters is worthless unless both engines did
the same work. So parity comes first:
A speed comparison between backtesters is worthless unless they did the same
work. So parity comes first:
1. each workload runs once per engine;
2. total return, round-trip count and fees are compared;
3. **a workload the engines disagree on gets no published timing.**
2. total return, round-trip count and fees are compared against the reference;
3. **a workload an engine disagrees on gets no published timing for it.**
Three verdicts come out of that gate:
| Verdict | Meaning | What gets published |
|---|---|---|
| `exact` | agreement down to float-reordering noise (relative tolerance 1e-9) | the timing, in the headline table |
| `documented` | the engines disagree, the workload declared it in advance, and the cause is written down | the timing, in an annex, with the cause and its measured size |
| `failed` | the engines disagree and nobody predicted it | nothing. The run exits non-zero |
| `documented` | that engine disagrees, the workload declared it in advance *for that engine*, and the cause is written down | the timing, in an annex, with the cause and its measured size |
| `failed` | it disagrees and nobody predicted it | nothing. The run exits non-zero |
A fourth outcome sits beside the gate rather than inside it. `unsupported`
means an engine cannot express the workload at all: it is not run, and the
report prints the reason instead of an empty cell. A blank in a speed table
reads as a defeat, and "this engine has no fixed-quantity sizing" is not a
defeat, it is a different fact.
The `failed` path is not decoration. It is the reason the other numbers can be
trusted, and it makes the benchmark fail loudly if a future release of either
@@ -64,12 +83,12 @@ store)`, the documented entry point, not through an internal fast path.
## What is compared
| Workload | What it exercises | Parity |
|---|---|---|
| `sma_cross` | SMA 10/50 crossover, long-only, no cost | exact |
| `ema_rsi_fees` | EMA 12/26 crossover with an RSI(14) filter and a 5 bps taker fee | exact |
| `sma_cross_metrics` | the same simulation, plus max drawdown, Sharpe, Sortino and volatility | exact |
| `bracket_sl_tp` | the same entry with a 15 bps stop and a 30 bps target | documented divergence |
| Workload | What it exercises | vectorbt | raptorbt |
|---|---|---|---|
| `sma_cross` | SMA 10/50 crossover, long-only, no cost | exact | exact |
| `ema_rsi_fees` | EMA 12/26 crossover with an RSI(14) filter and a 5 bps taker fee | exact | unsupported |
| `sma_cross_metrics` | the same simulation, plus max drawdown, Sharpe, Sortino and volatility | exact | exact |
| `bracket_sl_tp` | the same entry with a 15 bps stop and a 30 bps target | documented | documented |
Each of those runs across a range of series lengths. Two further axes, cold
start and memory, are measured in their own processes because they cannot be
@@ -77,15 +96,25 @@ measured honestly inside the main one.
**Scope, stated twice on purpose.** `sma_cross` and `sma_cross_metrics` run the
identical simulation; only the second one also produces a performance summary.
manifoldbt computes that summary inside `run()` whether or not you read it,
while vectorbt defers the equity curve until a risk metric asks for it and then
pays to materialise it. Reporting both scopes is the only honest way to present
the result: a reader who only wants a total return should look at the first
number, and a reader who wants a Sharpe should look at the second. Parity on the
summary workload is gated on total return, round-trip count and max drawdown,
which match exactly; the ratios agree to about 3e-4, because manifoldbt buckets
its daily returns slightly differently, and that residual is reported rather
than smoothed over.
manifoldbt and raptorbt both compute that summary inside the run whether or not
you read it, while vectorbt defers the equity curve until a risk metric asks for
it and then pays to materialise it. Reporting both scopes is the only honest way
to present the result: a reader who only wants a total return should look at the
first number, and a reader who wants a Sharpe should look at the second. Parity
on the summary workload is gated on total return, round-trip count and max
drawdown, which match exactly across all three; the vectorbt ratios agree to
about 3e-4, because manifoldbt buckets its daily returns slightly differently,
and that residual is reported rather than smoothed over.
raptorbt annualises its ratios on its own basis and moves that basis between
releases (the same run reads Sharpe 0.21 on 0.4.1 and 3.43 on 0.9.0, against
manifoldbt's 8.14), so its Sharpe and Sortino are recorded but not compared:
subtracting them from the reference would publish a units mismatch as a
disagreement. It reports no volatility at all, and the harness leaves that cell
empty rather than recomputing it from the equity curve, which would credit
raptorbt with the harness's own arithmetic. None of this touches the gate, which
runs on money, round-trips and drawdown, all three basis-free. Its drawdown
matches the reference to 4e-15.
The vectorbt side of the summary is written out in pandas rather than through
`pf.sharpe_ratio()` for two reasons, both in vectorbt's favour or neutral.
@@ -110,6 +139,26 @@ data: building each engine's data representation is a different question and is
excluded on both sides, in manifoldbt's case a deliberately unflattering choice
since its one-off ingest is the memory-hungry part.
### Why raptorbt sits out the fee workload
Two blockers, either one sufficient, both measured rather than assumed.
*Sizing.* raptorbt has no fixed-quantity mode. `position_sizes` is a fraction of
equity (a constant 0.5 buys exactly half the equity of the bar before the entry),
`lot_size` rounds a computed size down to a multiple of itself, and
`alloted_capital` fixes the notional rather than the quantity. Reproducing
`units=5` would mean feeding a fraction derived from an equity curve that does
not exist until the run is over.
*Indicator.* `raptorbt.ema` seeds on a simple mean of the first `period` bars and
emits from bar `period-1`; manifoldbt seeds on the first observation and emits
from bar 0. Same recursion, different warmup, so the signal differs early and the
round-trip count with it. Its `sma` matches the reference to 3.3e-13 and its
`rsi` matches to the last bit, which is why the other three workloads run.
Running it anyway with a different size and a different indicator would produce
a number, and the number would not mean anything.
### Why the fee workload sizes in units
With `FractionOfEquity` sizing and a non-zero fee the engines size differently:
@@ -121,21 +170,30 @@ isolates the fee arithmetic, which is the thing both engines must agree on.
### The documented divergence, in full
When a bracket fires intrabar and the entry condition still holds at that bar's
close, manifoldbt books two orders on that bar: the stop or target exit, then a
fresh entry at the close. vectorbt processes one order per bar and re-enters on
the next bar instead. Neither is wrong. On controlled bars the bracket fills
themselves match exactly, which the cross-engine parity suite shipped with the
library pins test by test; the divergence is purely about *when* a re-entry is
allowed.
close, the three engines take three different roads:
The harness counts the affected round-trips rather than hand-waving at them, so
the report states what share of the trades the difference touches.
- **manifoldbt** books two orders on that bar: the stop or target exit, then a
fresh entry at the close.
- **vectorbt** processes one order per bar and re-enters on the next bar.
- **raptorbt** does not re-arm at all. The level still being true is not enough;
it waits for the level to go false and true again.
None of them is wrong. On controlled bars the bracket fills themselves match
exactly, which the cross-engine parity suite shipped with the library pins test
by test; the divergence is purely about *when* a re-entry is allowed.
The harness counts the affected round-trips rather than hand-waving at them, and
it is one population, not three: at 10,000 bars manifoldbt books 214 round-trips
of which 82 re-enter on the exit bar, and raptorbt books exactly 214 - 82 = 132.
The report states the count and the share, so the size of the difference is
measured instead of asserted.
## Alignment choices, and why each one exists
Two engines only produce identical numbers if they are told to do the same
thing. These are the conventions the harness sets, all of them visible in
[engine_mbt.py](engine_mbt.py) and [engine_vbt.py](engine_vbt.py):
Engines only produce identical numbers if they are told to do the same thing.
These are the conventions the harness sets, all of them visible in
[engine_mbt.py](engine_mbt.py), [engine_vbt.py](engine_vbt.py) and
[engine_rbt.py](engine_rbt.py):
- `signal_delay=0` with `execution_price="AtClose"`, matching what
`Portfolio.from_signals` does by default: a signal fills at the close of the
@@ -157,6 +215,21 @@ thing. These are the conventions the harness sets, all of them visible in
different fill prices while both being correct, so that class of false
failures is removed from the data rather than argued about in the report.
On the raptorbt side specifically:
- `upon_bar_close=True`, which fills at the close of the signal bar. Turning it
off does not add a one-bar delay, it moves the fill to that same bar's *open*,
so there is no setting on that side matching a delayed execution.
- Sizing is left at its default, which takes the whole equity of the bar before
the entry. Since the account is flat at that point, that is the same number as
the equity at the fill, and the three engines size identically: `sma_cross`
comes back with manifoldbt's final equity to the last bit.
- The bracket is set on the config, in fractions rather than percent: 0.15 means
a 15% stop, so the workload's 0.15% is `0.0015`. Passing the percent number is
not an error, it is a stop so wide it never triggers.
- Indicators come from raptorbt's own Rust `sma` and `rsi`, not from numpy: that
is what a user would write, and it is what deserves to be timed.
## Reading the numbers honestly
- On a shared runner with 4 vCPUs, an engine that parallelises is understated.
@@ -164,14 +237,25 @@ thing. These are the conventions the harness sets, all of them visible in
- The published wheels are CPU-only, and GitHub-hosted runners have no GPU, so
nothing here says anything about GPU performance.
- vectorbt is the open-source package (`pip install vectorbt`), not vectorbtpro.
- raptorbt has no fan-out API for a parameter grid on one instrument, so its
sweep column is a Python loop over `run_single_backtest`. That is not a
handicap the harness imposed, it is the only spelling available, and the
moving averages are hoisted out of the loop so it gets the same courtesy
vectorbt gets on its own grid path.
## Files
- `data.py` - deterministic OHLCV generator and its content digest
- `probe_child.py` - the cold-start and memory probes, each in a fresh process
- `workloads.py` - the parameters both engines read, and the declared parity status
- `engine_mbt.py` / `engine_vbt.py` - one adapter per engine
- `workloads.py` - the parameters every engine reads, and the per-engine notes
- `engines.py` - the registry: who is in the comparison, and who is the reference
- `engine_mbt.py` / `engine_vbt.py` / `engine_rbt.py` - one adapter per engine
- `parity.py` - the gate
- `bench.py` - the runner
- `sweep_child.py` - one parameter-grid point, in its own process
- `report.py` - JSON to Markdown, and to the GitHub job summary
- [`.github/workflows/bench-vs-vectorbt.yml`](../../.github/workflows/bench-vs-vectorbt.yml) - the workflow that runs all of the above on a GitHub-hosted runner
- `ci/bench-vs-vectorbt.yml` - the workflow, deployed to the public repository
The directory is still named `vs_vectorbt` and the workflow file still
`bench-vs-vectorbt.yml`: renaming either would break the path the public
repository runs and start a fresh, empty run history.
+377 -154
View File
@@ -3,21 +3,26 @@
Design decisions that are the whole point of this harness
---------------------------------------------------------
*Parity first.* Every workload runs once per engine before any measurement, and
the results are compared. A workload the engines disagree on gets no published
timing (see ``parity.py``).
each challenger's result is compared against the reference. A workload an engine
disagrees on gets no published timing for that engine (see ``parity.py``).
*Interleaved repetitions.* The engines alternate within each repetition rather
than running in two blocks. A cloud runner that slows down halfway through
penalises both engines equally instead of whichever one happened to be second.
than running in blocks. A cloud runner that slows down halfway through penalises
all of them equally instead of whichever one happened to be last.
*Ratios are the headline, milliseconds are context.* The per-repetition ratio is
computed from two measurements taken seconds apart on the same machine, so it
computed from measurements taken seconds apart on the same machine, so it
survives the noise that absolute timings on shared hardware do not.
*Dispersion is published.* Every point carries min, median, max and IQR. A point
whose IQR exceeds 15% of its median is flagged noisy, and a flagged point is not
headline material no matter how good it looks.
*An engine that cannot run a workload says so.* It is dropped from that workload
with its reason recorded, never left as a blank cell: a missing number in a
speed table reads as a loss, and "this engine has no fixed-quantity sizing" is
not a loss, it is a different fact that deserves its own sentence.
Usage
-----
python bench.py --bars 10000 100000 1000000 --reps 7 --out results.json
@@ -39,12 +44,21 @@ from typing import Any, Callable, Dict, List
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import data as data_mod # noqa: E402
import engine_mbt # noqa: E402
import engine_vbt # noqa: E402
import engines as engines_mod # noqa: E402
import parity as parity_mod # noqa: E402
from workloads import DEFAULT_KEYS, SCOPE_PAIR, WORKLOADS # noqa: E402
from engines import CHALLENGERS, ENGINES, REFERENCE # noqa: E402
from workloads import ( # noqa: E402
DEFAULT_KEYS,
SCOPE_PAIR,
WORKLOADS,
supported,
unsupported_by,
)
SCHEMA_VERSION = 1
# 2: timings, parity and speedups became per-engine maps when the harness grew
# past two engines. `report.py` reads version 1 files as well, so the results
# archived under results/ stay readable.
SCHEMA_VERSION = 2
NOISE_THRESHOLD = 0.15
@@ -88,10 +102,18 @@ def _ram_gb():
def _versions() -> Dict[str, str]:
"""Distribution versions, engines first then the stack underneath them.
Read from the installed metadata rather than from each package's own
``__version__``, which is a hand-maintained constant and can lag: raptorbt
0.4.1 still declares 0.4.0 in its ``__init__``.
"""
import importlib.metadata as md
names = [ENGINES[n].distribution for n in ENGINES]
names += ["numpy", "numba", "pandas"]
out = {}
for name in ("manifoldbt", "vectorbt", "numpy", "numba", "pandas"):
for name in names:
try:
out[name] = md.version(name)
except Exception:
@@ -201,129 +223,161 @@ def _summarise(samples: List[float]) -> Dict[str, Any]:
}
def measure_pair(keys: List[str], bars: int, reps: int, workdir: str) -> List[Dict[str, Any]]:
"""Measure two workloads inside ONE interleaved loop.
def _roll_up(verdicts: Dict[str, Dict[str, Any]]) -> str:
"""One status for the whole entry: the worst any engine came back with."""
statuses = {v["status"] for v in verdicts.values()}
for worst in ("failed", "documented"):
if worst in statuses:
return worst
return "exact"
The report puts these two side by side to show what a performance summary
costs each engine. That subtraction is only legitimate if all four timings
come from the same stretch of machine time: measured in separate blocks, the
drift in absolute timings is larger than the difference being reported, and
the table ends up claiming the version doing more work is the faster one.
"""
frame = data_mod.make_ohlcv(bars)
runners = {}
entries = {}
for key in keys:
run_mbt = engine_mbt.prepare(key, frame, workdir)
run_vbt = engine_vbt.prepare(key, frame, workdir)
_, warm_mbt = _time_once(run_mbt)
_, warm_vbt = _time_once(run_vbt)
verdict = parity_mod.compare(warm_mbt, warm_vbt, key)
runners[key] = (run_mbt, run_vbt)
entries[key] = {
"workload": key,
"title": WORKLOADS[key].title,
"bars": bars,
"data_digest": data_mod.digest(frame),
"parity": verdict,
"paired_with": [k for k in keys if k != key],
}
samples: Dict[str, Dict[str, List[float]]] = {
key: {"manifoldbt": [], "vectorbt": [], "ratio": []} for key in keys
def _prepare_all(key: str, frame, workdir: str, active: List[str]):
"""Build one timed closure per engine that can run this workload."""
return {
name: engines_mod.adapter(name).prepare(key, frame, workdir)
for name in active
if supported(key, name)
}
threading_use = {key: {"manifoldbt": _Parallelism(), "vectorbt": _Parallelism()}
for key in keys}
for _ in range(reps):
for key in keys:
run_mbt, run_vbt = runners[key]
t_mbt, _ = threading_use[key]["manifoldbt"].record(run_mbt)
t_vbt, _ = threading_use[key]["vectorbt"].record(run_vbt)
samples[key]["manifoldbt"].append(t_mbt)
samples[key]["vectorbt"].append(t_vbt)
samples[key]["ratio"].append(t_vbt / t_mbt if t_mbt > 0 else float("nan"))
out = []
for key in keys:
entry = entries[key]
if entry["parity"]["status"] == "failed":
entry["timings"] = None
entry["note"] = "timing withheld: unexplained disagreement between engines"
out.append(entry)
continue
mbt_stats = _summarise(samples[key]["manifoldbt"])
vbt_stats = _summarise(samples[key]["vectorbt"])
ratios = samples[key]["ratio"]
entry["timings"] = {"manifoldbt": mbt_stats, "vectorbt": vbt_stats}
entry["speedup"] = {
"median_of_ratios": statistics.median(ratios),
"min": min(ratios),
"max": max(ratios),
}
entry["noisy"] = (
mbt_stats["iqr_over_median"] > NOISE_THRESHOLD
or vbt_stats["iqr_over_median"] > NOISE_THRESHOLD
)
entry["cpu_over_wall"] = {
engine: threading_use[key][engine].ratio for engine in ("manifoldbt", "vectorbt")
}
out.append(entry)
return out
def measure(key: str, bars: int, reps: int, workdir: str) -> Dict[str, Any]:
frame = data_mod.make_ohlcv(bars)
run_mbt = engine_mbt.prepare(key, frame, workdir)
run_vbt = engine_vbt.prepare(key, frame, workdir)
def _warm_and_gate(key: str, runners: Dict[str, Callable]) -> Dict[str, Any]:
"""One discarded call per engine, then the parity verdicts it feeds.
# Warmup, discarded: numba compiles on vectorbt's first call, and the engine
# warms its own caches. Both are one-off costs, reported separately rather
# than smeared across every repetition.
_, warm_mbt = _time_once(run_mbt)
_, warm_vbt = _time_once(run_vbt)
The warmup is not only there to be thrown away: it is the run the gate
reads. numba compiles on vectorbt's first call and the engines warm their
own caches, so the same call cannot be both the first measurement and a fair
one, but it is a perfectly good sample of what each engine *computed*.
"""
warm = {name: _time_once(run)[1] for name, run in runners.items()}
reference = warm[REFERENCE]
return {
name: parity_mod.compare(reference, metrics, key, name)
for name, metrics in warm.items()
if name != REFERENCE
}
verdict = parity_mod.compare(warm_mbt, warm_vbt, key)
def _entry(key: str, bars: int, frame, verdicts: Dict[str, Any], engines_run: List[str]):
entry: Dict[str, Any] = {
"workload": key,
"title": WORKLOADS[key].title,
"bars": bars,
"data_digest": data_mod.digest(frame),
"parity": verdict,
"engines": engines_run,
"parity": verdicts,
"status": _roll_up(verdicts),
}
if verdict["status"] == "documented":
entry["divergence_scale"] = engine_mbt.diagnose(key, frame, workdir)
skipped = unsupported_by(key)
if skipped:
entry["unsupported"] = skipped
return entry
if verdict["status"] == "failed":
def _collect(entry: Dict[str, Any], runners: Dict[str, Callable],
samples: Dict[str, List[float]], threading_use: Dict[str, _Parallelism]) -> None:
"""Turn raw per-engine samples into the published shape, in place."""
published = [
name for name in runners
if name == REFERENCE or entry["parity"][name]["status"] != "failed"
]
# A solo run has nothing to compare and nothing to withhold: the gate exists
# to stop a *comparison* being published on mismatched work. When challengers
# were present and the gate dropped them all, though, the reference's timing
# is the leftover of a comparison, and printing it alone would read as one.
solo = len(runners) == 1
if REFERENCE not in published or (len(published) == 1 and not solo):
entry["timings"] = None
entry["note"] = "timing withheld: unexplained disagreement between engines"
return entry
entry["note"] = "timing withheld: unexplained disagreement with the reference"
return
mbt_samples: List[float] = []
vbt_samples: List[float] = []
ratios: List[float] = []
threading_use = {"manifoldbt": _Parallelism(), "vectorbt": _Parallelism()}
stats = {name: _summarise(samples[name]) for name in published}
reference = samples[REFERENCE]
entry["timings"] = stats
entry["speedup"] = {}
for name in published:
if name == REFERENCE:
continue
ratios = [
other / ref if ref > 0 else float("nan")
for ref, other in zip(reference, samples[name])
]
entry["speedup"][name] = {
"median_of_ratios": statistics.median(ratios),
"min": min(ratios),
"max": max(ratios),
}
entry["noisy"] = any(s["iqr_over_median"] > NOISE_THRESHOLD for s in stats.values())
entry["cpu_over_wall"] = {name: threading_use[name].ratio for name in published}
withheld = [name for name in runners if name not in published]
if withheld:
entry["withheld"] = withheld
def measure_pair(keys: List[str], bars: int, reps: int, workdir: str,
active: List[str]) -> List[Dict[str, Any]]:
"""Measure two workloads inside ONE interleaved loop.
The report puts these two side by side to show what a performance summary
costs each engine. That subtraction is only legitimate if every timing comes
from the same stretch of machine time: measured in separate blocks, the drift
in absolute timings is larger than the difference being reported, and the
table ends up claiming the version doing more work is the faster one.
"""
frame = data_mod.make_ohlcv(bars)
runners: Dict[str, Dict[str, Callable]] = {}
entries: Dict[str, Dict[str, Any]] = {}
for key in keys:
runners[key] = _prepare_all(key, frame, workdir, active)
verdicts = _warm_and_gate(key, runners[key])
entries[key] = _entry(key, bars, frame, verdicts, list(runners[key]))
entries[key]["paired_with"] = [k for k in keys if k != key]
samples = {key: {name: [] for name in runners[key]} for key in keys}
threading_use = {key: {name: _Parallelism() for name in runners[key]} for key in keys}
for _ in range(reps):
t_mbt, _ = threading_use["manifoldbt"].record(run_mbt)
t_vbt, _ = threading_use["vectorbt"].record(run_vbt)
mbt_samples.append(t_mbt)
vbt_samples.append(t_vbt)
ratios.append(t_vbt / t_mbt if t_mbt > 0 else float("nan"))
for key in keys:
for name, run in runners[key].items():
elapsed, _ = threading_use[key][name].record(run)
samples[key][name].append(elapsed)
mbt_stats = _summarise(mbt_samples)
vbt_stats = _summarise(vbt_samples)
entry["timings"] = {"manifoldbt": mbt_stats, "vectorbt": vbt_stats}
entry["speedup"] = {
"median_of_ratios": statistics.median(ratios),
"min": min(ratios),
"max": max(ratios),
}
entry["noisy"] = (
mbt_stats["iqr_over_median"] > NOISE_THRESHOLD
or vbt_stats["iqr_over_median"] > NOISE_THRESHOLD
)
entry["cpu_over_wall"] = {
engine: threading_use[engine].ratio for engine in ("manifoldbt", "vectorbt")
}
for key in keys:
_collect(entries[key], runners[key], samples[key], threading_use[key])
return [entries[key] for key in keys]
def measure(key: str, bars: int, reps: int, workdir: str, active: List[str]) -> Dict[str, Any]:
frame = data_mod.make_ohlcv(bars)
runners = _prepare_all(key, frame, workdir, active)
verdicts = _warm_and_gate(key, runners)
entry = _entry(key, bars, frame, verdicts, list(runners))
scales = {}
for name, verdict in verdicts.items():
if verdict["status"] != "documented":
continue
adapter = engines_mod.adapter(name)
measured = getattr(adapter, "diagnose", lambda *a, **k: {})(key, frame, workdir)
if measured:
scales[name] = measured
# The reference's own view of the divergence: which of its round-trips are
# the ones the others do not take. Recorded under the reference so the two
# sides of the subtraction sit next to each other.
if scales:
reference_view = engines_mod.adapter(REFERENCE).diagnose(key, frame, workdir)
if reference_view:
scales[REFERENCE] = reference_view
entry["divergence_scale"] = scales
samples = {name: [] for name in runners}
threading_use = {name: _Parallelism() for name in runners}
for _ in range(reps):
for name, run in runners.items():
elapsed, _ = threading_use[name].record(run)
samples[name].append(elapsed)
_collect(entry, runners, samples, threading_use)
return entry
@@ -344,60 +398,185 @@ def _probe(mode: str, engine: str, workload: str, bars: int) -> Dict[str, Any]:
raise RuntimeError("probe produced no result: " + out[-500:])
def cold_start(workload: str, bars: int, reps: int) -> Dict[str, Any]:
"""Time to a first backtest in a process that has never seen either engine.
def cold_start(workload: str, bars: int, reps: int, active: List[str]) -> Dict[str, Any]:
"""Time to a first backtest in a process that has never seen any engine.
This is the cost the steady-state benchmark throws away as warmup, and the
one a user actually waits through every time they open a notebook. The
engines alternate here too, and the interpreter/numpy/pandas baseline is
measured alongside so the engine's own share is visible.
measured alongside so each engine's own share is visible.
"""
samples: Dict[str, List[float]] = {"manifoldbt": [], "vectorbt": [], "baseline": []}
order = [name for name in active if supported(workload, name)]
samples: Dict[str, List[float]] = {name: [] for name in order}
samples["baseline"] = []
for _ in range(reps):
samples["manifoldbt"].append(_probe("coldstart", "mbt", workload, bars)["seconds"])
samples["vectorbt"].append(_probe("coldstart", "vbt", workload, bars)["seconds"])
for name in order:
samples[name].append(
_probe("coldstart", ENGINES[name].code, workload, bars)["seconds"])
samples["baseline"].append(_probe("baseline", "none", workload, bars)["seconds"])
medians = {k: statistics.median(v) for k, v in samples.items()}
base = medians["baseline"]
reference = medians[REFERENCE]
return {
"workload": workload,
"bars": bars,
"engines": order,
"samples_s": samples,
"median_s": medians,
"engine_share_s": {
"manifoldbt": medians["manifoldbt"] - base,
"vectorbt": medians["vectorbt"] - base,
"engine_share_s": {name: medians[name] - base for name in order},
"ratio": {
name: (medians[name] / reference if reference else None)
for name in order if name != REFERENCE
},
"ratio": medians["vectorbt"] / medians["manifoldbt"] if medians["manifoldbt"] else None,
}
def memory(workload: str, bars: int) -> Dict[str, Any]:
def memory(workload: str, bars: int, active: List[str]) -> Dict[str, Any]:
"""Resident memory each engine adds while running one backtest.
vectorbt materialises the simulation as arrays, so its footprint grows with
the series; manifoldbt streams bars out of its store. The number reported is
what the *run* adds, measured after a warmup, not the process total: the
one-off cost of building each engine's data representation is a different
question and is excluded on both sides.
question and is excluded on every side.
"""
mbt = _probe("memory", "mbt", workload, bars)
vbt = _probe("memory", "vbt", workload, bars)
return {
"workload": workload,
"bars": bars,
"manifoldbt": mbt,
"vectorbt": vbt,
}
order = [name for name in active if supported(workload, name)]
out: Dict[str, Any] = {"workload": workload, "bars": bars, "engines": order}
for name in order:
out[name] = _probe("memory", ENGINES[name].code, workload, bars)
return out
SWEEP_CHILD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sweep_child.py")
def parse_point(spec: str) -> tuple:
"""`bars:combos[:oos]`, e.g. `20000:5000` or `20000:100000:oos`.
The `oos` suffix declares the array-materialising engines out of scope for
that point: it is for grids whose vectorbt side needs tens of gigabytes,
where running it anyway would time the swap file rather than the engine.
"""
parts = spec.split(":")
if len(parts) not in (2, 3) or not parts[1]:
raise argparse.ArgumentTypeError(
f"expected bars:combos or bars:combos:oos, got {spec!r}")
mode = "run"
if len(parts) == 3:
if parts[2] != "oos":
raise argparse.ArgumentTypeError(
f"third field must be 'oos', got {parts[2]!r}")
mode = "oos"
return int(parts[0]), int(parts[1]), mode
def sweep(points: List[tuple], reps: int, active: List[str]) -> List[Dict[str, Any]]:
"""Parameter-grid comparison, one point per process.
Each point is spawned rather than run inline. A large grid is the one thing
in this harness that can exhaust the machine, and a point that dies must
cost its own result and nothing else: run inline, an out-of-memory kill
would take the whole benchmark down and lose every number measured before
it. A dead child is recorded as a crashed point and the run continues.
"""
out: List[Dict[str, Any]] = []
challengers = [n for n in active if n != REFERENCE]
for bars, combos, mode in points:
proc = subprocess.run(
[sys.executable, SWEEP_CHILD, "--bars", str(bars),
"--combos", str(combos), "--reps", str(reps),
"--engines", *active, "--vectorbt", mode],
capture_output=True, text=True,
)
payload = None
for line in proc.stdout.splitlines():
if line.startswith("{"):
payload = json.loads(line)
if payload is None:
# No JSON at all: the child died before it could report. Almost
# always the allocator, on the biggest grid of the matrix.
payload = {
"bars": bars,
"combos": combos,
"vectorbt_mode": mode,
"status": "crashed",
"reason": (proc.stderr or proc.stdout or "no output")[-400:].strip(),
"exit_code": proc.returncode,
}
# Memory comes from dedicated single-engine processes, never from the
# interleaved timing run above. Timing has to interleave the engines to
# be fair, and interleaving is exactly what makes a memory reading
# worthless: from the second repetition on, the peak sampled during one
# engine's call is the whole process, the other engines' allocations
# included. Measured both ways, manifoldbt read 6.7 GB interleaved
# against 94 MB alone, at 5000 combinations.
payload["memory"] = _sweep_memory(bars, combos, mode, active)
out.append(payload)
return out
def _sweep_memory(bars: int, combos: int, mode: str, active: List[str]) -> Dict[str, Any]:
"""Peak each engine adds running the grid once, one engine per process."""
names = [REFERENCE] if mode == "oos" else list(active)
added: Dict[str, Any] = {name: None for name in active}
for name in names:
proc = subprocess.run(
[sys.executable, SWEEP_CHILD, "--bars", str(bars),
"--combos", str(combos), "--memory-only", ENGINES[name].code],
capture_output=True, text=True,
)
for line in proc.stdout.splitlines():
if line.startswith("{"):
added[name] = json.loads(line)["added_mb"]
return added
def run_sweeps(points: List[tuple], reps: int, active: List[str]) -> tuple:
"""Drive the sweep matrix and print one line per point. Returns (entries, failures).
Three outcomes count as failures, and the distinction matters:
* ``failed`` - the engines disagreed on a grid nobody predicted. The gate.
* ``crashed`` - the point could not be measured at all.
* ``skipped`` - the point asked for a timing the process was not licensed to
produce. Silent here would be the worst outcome of all: the matrix would
simply come back short, and a table missing its largest grid reads like a
choice rather than a failure.
"""
entries = sweep(points, reps, active)
failures = 0
for e in entries:
head = " sweep {b:>9,} bars x {c:>6,} combos ... ".format(
b=e["bars"], c=e["combos"])
status = e.get("status") or e.get("parity", {}).get("status", "?")
ratios = (e.get("timings") or {}).get("ratio") or {}
if ratios:
print(head + "{s:10s} ".format(s=status) + ", ".join(
"{n} x{r:.1f}".format(n=name, r=ratio) for name, ratio in ratios.items()))
elif e.get("timings"):
print(head + "{s:10s} {ref} {t:.2f} s, challengers out of scope".format(
s=status, ref=REFERENCE, t=e["timings"]["seconds"][REFERENCE]))
else:
print(head + "{s:10s} {why}".format(
s=status, why=e.get("reason") or e.get("note") or "no timing"))
failures += 1
return entries, failures
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
def main() -> int:
parser = argparse.ArgumentParser(description="manifoldbt vs vectorbt")
parser = argparse.ArgumentParser(description="manifoldbt against other engines")
parser.add_argument("--bars", type=int, nargs="+", default=[10_000, 100_000, 1_000_000])
parser.add_argument("--workloads", nargs="+", default=DEFAULT_KEYS, choices=DEFAULT_KEYS)
parser.add_argument("--engines", nargs="*", default=CHALLENGERS, choices=CHALLENGERS,
help="challengers to run against " + REFERENCE + ". Pass it "
"with no value for a solo run: no comparison, no "
"ratios, just what the engine costs. That is a "
"regression tracker rather than a benchmark, and it "
"is the only shape whose wall time is the engine's "
"own rather than the slowest challenger's.")
parser.add_argument("--reps", type=int, default=7)
parser.add_argument("--out", default="results.json")
parser.add_argument("--workdir", default=None, help="where the engine store is built")
@@ -405,12 +584,33 @@ def main() -> int:
help="0 disables the cold-start probe")
parser.add_argument("--memory-bars", type=int, default=0,
help="bars for the memory probe; 0 disables it")
parser.add_argument("--sweep", type=parse_point, nargs="*", default=[],
metavar="BARS:COMBOS",
help="parameter-grid points, e.g. 20000:5000. Needs a "
"licence: an unlicensed fan-out call waits out a "
"fixed interval, so the stopwatch would be timing "
"the wait rather than the engine")
parser.add_argument("--sweep-reps", type=int, default=3,
help="repetitions per sweep point; fewer than --reps "
"because a large grid costs seconds, not milliseconds")
parser.add_argument("--pin-cores", type=int, default=0,
help="restrict the process to N logical cores, so a big "
"workstation can reproduce what a small cloud runner "
"sees; 0 leaves the machine alone")
args = parser.parse_args()
if not engines_mod.installed(REFERENCE):
print("cannot run: the reference engine ({}) is not installed".format(REFERENCE))
return 2
active = [REFERENCE] + engines_mod.present(args.engines)
for name in args.engines:
if name not in active:
# Not fatal: somebody benchmarking on their own machine should not
# have to install every competitor to read their own numbers. It is
# loud, though, because on a runner it means the lock file did not
# do its job.
print("skipping {}: not installed".format(name))
pinned = None
if args.pin_cores:
try:
@@ -422,11 +622,13 @@ def main() -> int:
except Exception as exc: # macOS has no affinity API
print("could not pin to {} cores: {}".format(args.pin_cores, exc))
workdir = args.workdir or tempfile.mkdtemp(prefix="mbt_vs_vbt_")
workdir = args.workdir or tempfile.mkdtemp(prefix="mbt_bench_")
env = environment()
env["pinned_cores"] = pinned
env["engines"] = active
versions = env["versions"]
print("manifoldbt " + versions["manifoldbt"] + " vs vectorbt " + versions["vectorbt"])
print(" vs ".join(
"{} {}".format(name, versions[ENGINES[name].distribution]) for name in active))
print("{cpu} | {cores} logical cores | {ram} GB | {os} {arch}".format(
cpu=env["cpu"], cores=env["logical_cores"], ram=env["ram_gb"],
os=env["os"], arch=env["arch"]))
@@ -435,16 +637,19 @@ def main() -> int:
print(str(args.reps) + " interleaved repetitions per point\n")
def announce(entry: Dict[str, Any]) -> bool:
status = entry["parity"]["status"]
print(" {k:18s} {b:>9,} bars ... ".format(k=entry["workload"], b=entry["bars"]),
end="", flush=True)
if entry.get("timings"):
print("{s:10s} manifoldbt x{v:.1f}{m}".format(
s=status, v=entry["speedup"]["median_of_ratios"],
m=" NOISY" if entry.get("noisy") else ""))
return False
print("{s:10s} timing withheld".format(s=status))
return True
if not entry.get("timings"):
print("{s:10s} timing withheld".format(s=entry["status"]))
return True
speeds = ", ".join(
"{n} x{v:.1f}".format(n=name, v=s["median_of_ratios"])
for name, s in entry["speedup"].items()
) or "no challenger"
print("{s:10s} {speeds}{m}".format(
s=entry["status"], speeds=speeds,
m=" NOISY" if entry.get("noisy") else ""))
return entry["status"] == "failed"
# The scope pair is measured together; everything else one workload at a time.
paired = [k for k in SCOPE_PAIR if k in args.workloads]
@@ -454,38 +659,56 @@ def main() -> int:
failures = 0
for bars in args.bars:
if len(paired) > 1:
for entry in measure_pair(paired, bars, args.reps, workdir):
for entry in measure_pair(paired, bars, args.reps, workdir, active):
results.append(entry)
failures += announce(entry)
for key in singles + (paired if len(paired) == 1 else []):
for bars in args.bars:
entry = measure(key, bars, args.reps, workdir)
entry = measure(key, bars, args.reps, workdir, active)
results.append(entry)
failures += announce(entry)
# The side probes run on the first workload every active engine supports, so
# a cold-start table cannot come back missing a column because the workload
# happened to be one somebody sits out.
probe_workload = next(
(k for k in args.workloads if all(supported(k, n) for n in active)),
args.workloads[0],
)
cold = None
if args.cold_start_reps:
print("")
print(" cold start ... ", end="", flush=True)
cold = cold_start(args.workloads[0], 20_000, args.cold_start_reps)
print("manifoldbt {:.2f} s vs vectorbt {:.2f} s (x{:.1f})".format(
cold["median_s"]["manifoldbt"], cold["median_s"]["vectorbt"], cold["ratio"]))
cold = cold_start(probe_workload, 20_000, args.cold_start_reps, active)
print(", ".join("{n} {t:.2f} s".format(n=name, t=cold["median_s"][name])
for name in cold["engines"]))
mem = None
if args.memory_bars:
print(" memory ... ", end="", flush=True)
mem = memory(args.workloads[0], args.memory_bars)
print("manifoldbt +{:.0f} MB vs vectorbt +{:.0f} MB at {:,} bars".format(
mem["manifoldbt"]["added_mb"], mem["vectorbt"]["added_mb"], args.memory_bars))
mem = memory(probe_workload, args.memory_bars, active)
print(", ".join("{n} +{v:.0f} MB".format(n=name, v=mem[name]["added_mb"])
for name in mem["engines"]))
sweeps = None
if args.sweep:
print("")
sweeps, sweep_failures = run_sweeps(args.sweep, args.sweep_reps, active)
failures += sweep_failures
payload = {
"schema_version": SCHEMA_VERSION,
"generated_at": datetime.now(timezone.utc).isoformat(),
"environment": env,
"reference": REFERENCE,
"engines": active,
"reps": args.reps,
"results": results,
"cold_start": cold,
"memory": mem,
"sweeps": sweeps,
"sweep_reps": args.sweep_reps if args.sweep else None,
}
with open(args.out, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
+57
View File
@@ -0,0 +1,57 @@
"""Activate a licence from the environment, then assert the tier out loud.
Used by the sweep workflow. A sweep benchmark run without a licence does not
fail, it produces a wrong number: every unlicensed fan-out call waits out a
fixed interval before doing any work, so the stopwatch measures the wait. On a
100-cell grid that read 5.00 s against vectorbt's 0.17 s, which would publish
"vectorbt is 29x faster" from a run where the engine barely ran.
So this exits non-zero rather than let the benchmark continue unlicensed.
The key is written to disk by `activate`, so every child process the harness
spawns afterwards picks it up without seeing the secret itself.
MANIFOLDBT_CI_LICENSE=<signed key> python ci_activate.py
"""
from __future__ import annotations
import os
import sys
import manifoldbt as mbt
# The American spelling, because that is how the repository secret is actually
# named. Both are accepted: the two spellings are one typo apart, and the cost
# of getting it wrong is a benchmark job that dies at the first step.
VARS = ("MANIFOLDBT_CI_LICENSE", "MANIFOLDBT_CI_LICENCE")
def main() -> int:
key = ""
for var in VARS:
key = (os.environ.get(var) or "").strip()
if key:
break
if not key:
print(" and ".join(VARS) + " are empty or unset: "
"refusing to benchmark sweeps unlicensed.")
return 1
# Never print the key or the activation message: the message carries the
# licensee's address, and a public run log is not the place for it.
try:
mbt.activate(key)
except Exception as exc: # noqa: BLE001 - reported, not raised
print(f"activation failed: {type(exc).__name__}")
return 1
_used, _limit, is_pro = mbt._native._combo_budget()
if not is_pro:
print("activation did not yield a licensed tier: refusing to continue.")
return 1
print("licensed tier active")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -4
View File
@@ -152,11 +152,12 @@ def diagnose(key: str, df, workdir: str) -> Dict[str, Any]:
"""Untimed measurement of *how much* a documented divergence actually bites.
For the bracket workload this counts the round-trips whose entry lands on the
same bar as the previous exit, which is precisely the population where
vectorbt takes the next bar instead. Reporting the count turns "the engines
differ" into a number a reader can weigh.
same bar as the previous exit, which is precisely the population the other
engines handle differently: vectorbt takes the next bar, raptorbt does not
re-enter at all. Reporting the count turns "the engines differ" into a number
a reader can weigh, and it is the same population for both of them.
"""
if WORKLOADS[key].parity != "documented":
if not any(note.status == "documented" for note in WORKLOADS[key].notes.values()):
return {}
p = WORKLOADS[key].params
+192
View File
@@ -0,0 +1,192 @@
"""raptorbt adapter.
Public API only, and the same shape as the other two: everything that can be
prepared once is prepared before timing, and the timed closure is what a
raptorbt user writes - indicators, entry and exit arrays, one call to
``run_single_backtest``, then reading the headline numbers off the result.
Execution conventions, chosen to line up with the reference
-----------------------------------------------------------
* ``upon_bar_close=True`` fills at the close of the signal bar, which is
manifoldbt's ``signal_delay=0`` plus ``execution_price="AtClose"``. Measured:
with it off, the fill lands on that same bar's *open* rather than on the next
bar, so it is not a delay switch, and there is no setting on this side that
reproduces a one-bar delay.
* Sizing is left at the default, which takes the whole equity of the bar before
the entry. Measured against ``position_sizes``: a constant 0.5 buys exactly
half that equity, so the default is the full-equity fraction manifoldbt calls
``FractionOfEquity`` and vectorbt calls ``size_type="percent"``. The account is
flat at that point, so the previous bar's equity and the equity at the fill
are the same number and the three engines size identically. None of that is
taken on trust: ``sma_cross`` comes back bit-identical to manifoldbt's final
equity, which is what the gate actually checks.
* ``fees`` is a fraction of notional charged on each side, so 5 bps taker on
both legs is ``fee_bps / 10_000`` (measured against a trade's own ``fees``
field, which equals ``size * (entry + exit) * fee``).
Signals are levels, not transitions, exactly as on the vectorbt side: entries is
"the condition holds", exits is "it no longer holds". raptorbt opens when flat
and entries is true, which is the target-position semantics of the reference,
with one documented exception after a bracket exit (see ``workloads.py``).
Indicators come from raptorbt's own Rust implementations rather than from numpy,
because that is both what a user would write and what deserves to be timed.
``sma`` and ``rsi`` reproduce the reference to 3.3e-13 and to the last bit
respectively. ``ema`` does not, which is why the workload that needs one is
declared unsupported instead of being quietly run on different numbers.
Brackets, and the unit trap in them
-----------------------------------
``set_fixed_stop`` and ``set_fixed_target`` exist on ``BacktestConfig`` and on
``InstrumentConfig`` both. On 0.9.0 the two spellings agree to the last bit
(verified: same final equity, same round-trips, whether the bracket is set on
one, the other, or both), so the config-level one is used here as the simpler of
the two.
Their unit is a fraction, not a percent: 0.15 is a 15% stop, and the workload's
0.15% is ``0.0015``. Passing the percent number is not an error, it is a stop so
wide that it never triggers, which is a benchmark that quietly measures a
different strategy.
Version floor
-------------
0.9.0 renamed the config and result classes, dropping the ``Py`` prefix they
carried through 0.4.x. Rather than support both, the import fails loudly on an
older install: a silent fallback here would mean publishing a number from an
engine five minor versions behind the one named in the report.
"""
from __future__ import annotations
from typing import Any, Callable, Dict
import numpy as np
import raptorbt as rbt
from workloads import CAPITAL, WORKLOADS
NAME = "raptorbt"
if not hasattr(rbt, "BacktestConfig"): # 0.4.x and earlier
raise ImportError(
"raptorbt is too old for this harness: no BacktestConfig, which means "
"a release before 0.9.0. Install the pinned version from "
"requirements-lock.txt."
)
def probe() -> Dict[str, Any]:
# From the installed metadata, not from ``rbt.__version__``: that constant
# is hand-maintained and has been wrong before (0.4.1 shipped declaring
# 0.4.0), and a benchmark that misreports which version it measured is worse
# than one that does not say.
import importlib.metadata as md
try:
version = md.version("raptorbt")
except Exception:
version = getattr(rbt, "__version__", "unknown")
return {"engine": NAME, "version": version}
def _level(key: str, close: np.ndarray) -> np.ndarray:
"""The entry condition, as a level, from raptorbt's own indicators."""
p = WORKLOADS[key].params
if key in ("sma_cross", "sma_cross_metrics", "bracket_sl_tp"):
fast = np.asarray(rbt.sma(close, p["fast"]), dtype=np.float64)
slow = np.asarray(rbt.sma(close, p["slow"]), dtype=np.float64)
# A NaN comparison is already False; saying so explicitly keeps the
# warmup out of the signal by construction rather than by numpy detail.
return (fast > slow) & ~np.isnan(fast) & ~np.isnan(slow)
raise KeyError("workload {!r} is not supported by raptorbt".format(key))
def _config(key: str):
"""Everything the engine is told about the run, including the bracket.
Percentages are handed over as fractions (see the module docstring).
"""
p = WORKLOADS[key].params
config = rbt.BacktestConfig(
initial_capital=CAPITAL,
fees=float(p.get("fee_bps", 0.0)) / 10_000.0,
slippage=0.0,
upon_bar_close=True,
)
if "sl_pct" in p:
config.set_fixed_stop(p["sl_pct"] / 100.0)
if "tp_pct" in p:
config.set_fixed_target(p["tp_pct"] / 100.0)
return config
def prepare(key: str, df, workdir: str | None = None) -> Callable[[], Dict[str, Any]]:
"""Untimed setup; returns the closure the harness times."""
p = WORKLOADS[key].params
config = _config(key)
wants_metrics = bool(p.get("metrics"))
# Contiguous float64 columns, built once. The other adapters are handed
# their data ready to use too, so nobody pays for marshalling inside the
# measurement.
timestamps = df["timestamp"].astype("int64").to_numpy()
open_ = np.ascontiguousarray(df["open"].to_numpy(dtype=np.float64))
high = np.ascontiguousarray(df["high"].to_numpy(dtype=np.float64))
low = np.ascontiguousarray(df["low"].to_numpy(dtype=np.float64))
close = np.ascontiguousarray(df["close"].to_numpy(dtype=np.float64))
volume = np.ascontiguousarray(df["volume"].to_numpy(dtype=np.float64))
def run() -> Dict[str, Any]:
level = _level(key, close)
result = rbt.run_single_backtest(
timestamps, open_, high, low, close, volume,
level, ~level,
config=config,
)
m = result.metrics
out = {
"total_return": float(m.total_return_pct) / 100.0,
"final_equity": float(m.end_value),
"round_trips": int(m.total_closed_trades),
# raptorbt books round-trips; ``total_trades`` is that same count
# rather than a fill count, so there is no honest number to report.
"fills": None,
"total_fees": float(m.total_fees_paid),
}
if wants_metrics:
# Computed inside the run whether or not anyone reads them, like the
# reference and unlike vectorbt. That is the point of the workload
# pair, and it means reading them costs nothing measurable here.
out.update({
# Handed over as a positive percentage; the harness works in
# signed fractions of the account, as every other engine does.
"max_drawdown": -float(m.max_drawdown_pct) / 100.0,
"sharpe": float(m.sharpe_ratio),
"sortino": float(m.sortino_ratio),
# No volatility in the metrics object. Left absent rather than
# recomputed off the equity curve: this column is meant to show
# what the engine hands a user, and filling the gap in would
# publish the harness's arithmetic as raptorbt's.
"volatility": None,
})
return out
return run
def diagnose(key: str, df, workdir: str | None = None) -> Dict[str, Any]:
"""Untimed measurement of how far the bracket divergence goes.
The reference counts the round-trips it opens on an exit bar. The mirror
image on this side is how many round-trips are missing: raptorbt never
re-arms while the level holds, so its count is the reference's minus that
same population. Reporting both makes the claim checkable instead of asking
a reader to take the subtraction on faith.
"""
note = WORKLOADS[key].notes.get(NAME)
if note is None or note.status != "documented":
return {}
metrics = prepare(key, df, workdir)()
return {
"round_trips": metrics["round_trips"],
"final_equity": metrics["final_equity"],
}
+81
View File
@@ -0,0 +1,81 @@
"""The engine registry: who is in the comparison, and what each one can do.
One backtester is the *reference* and the others are *challengers*. Every parity
check joins a challenger to the reference, never two challengers to each other:
with three engines there are three pairs, and reporting all of them turns a
speed benchmark into a matrix nobody reads. manifoldbt is the reference because
this harness ships with it, which is a statement about the plumbing and not a
claim about the engine: the reference gets no advantage from the position, it is
simply the one every timing is divided by.
A challenger that is not installed is skipped with a printed line rather than
crashing the run. The CI lock file pins all of them, so a skip on a runner is a
finding; a skip on a laptop is somebody who did not want to install a competitor
to read their own numbers.
Ratio basis
-----------
``ratio_basis`` records how an engine annualises Sharpe and Sortino. manifoldbt
and the vectorbt adapter both compute them on daily returns annualised by
sqrt(365), so their ratios are directly comparable and small differences are
worth reporting. raptorbt returns its own, on its own basis, and comparing those
numbers to manifoldbt's would report a units mismatch as a disagreement. The
parity gate never depends on this either way: it gates on money, round-trips and
drawdown, all three basis-free.
"""
from __future__ import annotations
import importlib
from dataclasses import dataclass
from typing import Dict, List
@dataclass(frozen=True)
class Engine:
name: str
# Short code used by the out-of-process probes, which take argv strings.
code: str
module: str
# "daily_365" ratios are comparable with the reference's; "native" are not.
ratio_basis: str
# PyPI distribution name, for the version stamp in the result envelope.
distribution: str
REFERENCE = "manifoldbt"
ENGINES: Dict[str, Engine] = {
e.name: e
for e in (
Engine("manifoldbt", "mbt", "engine_mbt", "daily_365", "manifoldbt"),
Engine("vectorbt", "vbt", "engine_vbt", "daily_365", "vectorbt"),
Engine("raptorbt", "rbt", "engine_rbt", "native", "raptorbt"),
)
}
CHALLENGERS: List[str] = [n for n in ENGINES if n != REFERENCE]
BY_CODE: Dict[str, Engine] = {e.code: e for e in ENGINES.values()}
def adapter(name: str):
"""Import an adapter module on demand.
Lazy on purpose: the cold-start probe measures a process that has imported
exactly one engine, and a registry that imported all three at module scope
would make that measurement impossible to take.
"""
return importlib.import_module(ENGINES[name].module)
def installed(name: str) -> bool:
try:
adapter(name)
return True
except ImportError:
return False
def present(names: List[str]) -> List[str]:
"""Filter to the engines actually importable here, preserving order."""
return [n for n in names if installed(n)]
+43 -27
View File
@@ -1,26 +1,34 @@
"""The gate: no speed number is published for a workload the engines disagree on.
A benchmark between two backtesters is only a benchmark if both engines did the
same work. This module compares what each engine produced and classifies the
result, and ``bench.py`` refuses to report a timing for anything it classifies
as a failure.
A benchmark between backtesters is only a benchmark if they did the same work.
This module compares what a challenger produced against the reference and
classifies the result, and ``bench.py`` refuses to report a timing for anything
it classifies as a failure.
Every comparison is one challenger against the reference. Challengers are never
joined to each other: three engines make three pairs, and a table of pairs is a
matrix, not a benchmark. It also would not add anything, since agreement with
the reference is transitive enough for the only question being asked here, which
is whether a published timing describes the same simulation.
Three verdicts:
``exact``
Agreement down to float-reordering noise. The timing is publishable.
``documented``
The engines disagree, the workload declared it in advance, and the reason is
written down. The timing goes to the annex with the reason attached.
The two disagree, the workload declared it in advance for this specific
engine, and the reason is written down. The timing goes to the annex with
the reason attached.
``failed``
The engines disagree and nobody predicted it. That is a finding about the
engines, not about their speed: the timing is withheld.
They disagree and nobody predicted it. That is a finding about the engines,
not about their speed: the timing is withheld.
"""
from __future__ import annotations
from typing import Any, Dict
from workloads import CAPITAL, WORKLOADS
from engines import ENGINES, REFERENCE
from workloads import CAPITAL, expectation, why
# Float reordering across two implementations of the same arithmetic lands
# around 1e-13 of the account on a million bars. Anything above this is a
@@ -43,13 +51,13 @@ def _vs_capital(a: float, b: float) -> float:
return abs(a - b) / CAPITAL
def compare(mbt: Dict[str, Any], vbt: Dict[str, Any], key: str) -> Dict[str, Any]:
expected = WORKLOADS[key].parity
def compare(ref: Dict[str, Any], other: Dict[str, Any], key: str, engine: str) -> Dict[str, Any]:
expected = expectation(key, engine)
diffs = {
"final_equity_vs_capital": _vs_capital(mbt["final_equity"], vbt["final_equity"]),
"round_trips_delta": mbt["round_trips"] - vbt["round_trips"],
"total_fees_vs_capital": _vs_capital(mbt["total_fees"], vbt["total_fees"]),
"final_equity_vs_capital": _vs_capital(ref["final_equity"], other["final_equity"]),
"round_trips_delta": ref["round_trips"] - other["round_trips"],
"total_fees_vs_capital": _vs_capital(ref["total_fees"], other["total_fees"]),
}
agrees = (
diffs["final_equity_vs_capital"] <= REL_TOL
@@ -58,18 +66,26 @@ def compare(mbt: Dict[str, Any], vbt: Dict[str, Any], key: str) -> Dict[str, Any
)
# Workloads that also produce a performance summary are gated on the
# drawdown, which both engines compute at full bar resolution and which must
# match. The ratios are reported but not gated: manifoldbt buckets its daily
# returns slightly differently, a difference worth stating rather than
# hiding, and worth nothing at all as an argument about speed.
if "max_drawdown" in mbt and "max_drawdown" in vbt:
diffs["max_drawdown_rel"] = _rel(mbt["max_drawdown"], vbt["max_drawdown"])
# drawdown, which every engine here computes at full bar resolution and
# which must match. The ratios are reported but not gated: the reference
# buckets its daily returns slightly differently from vectorbt, a difference
# worth stating rather than hiding, and worth nothing at all as an argument
# about speed.
if other.get("max_drawdown") is not None and ref.get("max_drawdown") is not None:
diffs["max_drawdown_rel"] = _rel(ref["max_drawdown"], other["max_drawdown"])
agrees = agrees and diffs["max_drawdown_rel"] <= REL_TOL
diffs["advisory_ratio_rel"] = {
name: _rel(mbt[name], vbt[name])
for name in ("sharpe", "sortino", "volatility")
if name in mbt and name in vbt
}
# Only between engines that annualise the same way. raptorbt returns its
# ratios on its own basis, and subtracting those from the reference's
# would publish a units mismatch as a disagreement (measured: Sharpe
# 0.21 against 8.14 on a run whose equity curve is bit-identical).
if ENGINES[engine].ratio_basis == ENGINES[REFERENCE].ratio_basis:
diffs["advisory_ratio_rel"] = {
name: _rel(ref[name], other[name])
for name in ("sharpe", "sortino", "volatility")
if ref.get(name) is not None and other.get(name) is not None
}
else:
diffs["ratio_basis"] = ENGINES[engine].ratio_basis
if agrees:
status = "exact"
@@ -83,6 +99,6 @@ def compare(mbt: Dict[str, Any], vbt: Dict[str, Any], key: str) -> Dict[str, Any
"expected": expected,
"publishable": status == "exact",
"diffs": diffs,
"metrics": {"manifoldbt": mbt, "vectorbt": vbt},
"note": WORKLOADS[key].divergence if status == "documented" else "",
"metrics": {REFERENCE: ref, engine: other},
"note": why(key, engine) if status == "documented" else "",
}
+17 -10
View File
@@ -6,11 +6,11 @@ Two things cannot be measured honestly inside the main harness process:
compiles its numba kernels on the first call, which is a real cost a user pays
in every new notebook or script, and which the steady-state benchmark
deliberately discards. Measuring it requires a process that has never imported
either engine.
any of the engines.
*Memory* - peak resident memory attributable to the run. Once one engine has
run in a process, the allocator has already grown and the other engine's
measurement is meaningless.
run in a process, the allocator has already grown and any other engine's
measurement in it is meaningless.
The ``baseline`` mode measures the same process doing everything except calling
an engine (interpreter start, numpy and pandas import, data generation) so the
@@ -18,6 +18,7 @@ engine's own share can be read off rather than argued about.
python probe_child.py coldstart mbt sma_cross 20000
python probe_child.py memory vbt sma_cross 5000000
python probe_child.py coldstart rbt sma_cross 20000
python probe_child.py baseline none sma_cross 20000
"""
from __future__ import annotations
@@ -42,18 +43,24 @@ def _rss_mb() -> float:
def _build(engine: str, workload: str, bars: int):
"""Import exactly one adapter and hand back its timed closure.
The import happens here, inside the measurement, because on the cold-start
path it *is* part of what is being measured. Which is also why the engine is
named by its short code rather than passed as a module: this process must
have imported one engine and no others by the time it runs.
"""
import data as data_mod
frame = data_mod.make_ohlcv(bars)
if engine == "mbt":
import engine_mbt
if engine == "none":
return lambda: {}
return engine_mbt.prepare(workload, frame, tempfile.mkdtemp(prefix="mbt_probe_"))
if engine == "vbt":
import engine_vbt
import engines as engines_mod
return engine_vbt.prepare(workload, frame, None)
return lambda: {}
name = engines_mod.BY_CODE[engine].name
workdir = tempfile.mkdtemp(prefix=engine + "_probe_")
return engines_mod.adapter(name).prepare(workload, frame, workdir)
def cold_start(engine: str, workload: str, bars: int) -> dict:
+315 -62
View File
@@ -8,6 +8,11 @@ to the job summary so the numbers are visible without downloading an artifact.
This is a run output, not an article: tables, and only the glue needed to read
them. Every "why" belongs in README.md, which is written once instead of being
reprinted underneath every single run.
Two result schemas are accepted. Version 2 keys everything by engine name;
version 1, written while the harness compared exactly two engines, is normalised
into that shape on load so the results archived under ``results/`` keep
rendering after the third engine arrived.
"""
from __future__ import annotations
@@ -19,7 +24,74 @@ from typing import Any, Dict, List
METHOD_LINK = "benchmarks/vs_vectorbt/README.md"
# What version 1 files were, before the shape became a map.
V1_REFERENCE = "manifoldbt"
V1_CHALLENGER = "vectorbt"
# --------------------------------------------------------------------------- #
# Reading
# --------------------------------------------------------------------------- #
def normalise(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Bring a version 1 payload up to the per-engine shape used below."""
if payload.get("schema_version", 1) >= 2:
return payload
payload["reference"] = V1_REFERENCE
payload["engines"] = [V1_REFERENCE, V1_CHALLENGER]
for row in payload.get("results", []):
verdict = row.get("parity") or {}
row["status"] = verdict.get("status", "exact")
row["parity"] = {V1_CHALLENGER: verdict}
row["engines"] = [V1_REFERENCE, V1_CHALLENGER]
if row.get("speedup"):
row["speedup"] = {V1_CHALLENGER: row["speedup"]}
if row.get("divergence_scale"):
row["divergence_scale"] = {V1_REFERENCE: row["divergence_scale"]}
cold = payload.get("cold_start")
if cold:
cold["engines"] = [V1_REFERENCE, V1_CHALLENGER]
if not isinstance(cold.get("ratio"), dict):
cold["ratio"] = {V1_CHALLENGER: cold.get("ratio")}
mem = payload.get("memory")
if mem:
mem["engines"] = [V1_REFERENCE, V1_CHALLENGER]
for point in payload.get("sweeps") or []:
timings = point.get("timings")
if timings and "seconds" not in timings:
point["timings"] = {
"seconds": {
V1_REFERENCE: timings.get("manifoldbt_s"),
V1_CHALLENGER: timings.get("vectorbt_s"),
},
"ratio": ({V1_CHALLENGER: timings["ratio"]}
if timings.get("ratio") is not None else {}),
}
memory = point.get("memory")
if memory and "manifoldbt_added_mb" in memory:
point["memory"] = {
V1_REFERENCE: memory.get("manifoldbt_added_mb"),
V1_CHALLENGER: memory.get("vectorbt_added_mb"),
}
if point.get(V1_CHALLENGER) and "status" in point[V1_CHALLENGER]:
point["out_of_scope"] = {V1_CHALLENGER: point[V1_CHALLENGER]}
if point.get("parity") and "status" in point["parity"]:
point["parity"] = {V1_CHALLENGER: point["parity"]}
return payload
def _engines(payload: Dict[str, Any]) -> List[str]:
return payload.get("engines") or [V1_REFERENCE, V1_CHALLENGER]
def _reference(payload: Dict[str, Any]) -> str:
return payload.get("reference", V1_REFERENCE)
# --------------------------------------------------------------------------- #
# Rendering
# --------------------------------------------------------------------------- #
def _ms(seconds: float) -> str:
if seconds < 1.0:
return "{:.1f} ms".format(seconds * 1e3)
@@ -33,7 +105,8 @@ def _header(payload: Dict[str, Any]) -> List[str]:
if env.get("pinned_cores"):
cores += " (pinned to {})".format(env["pinned_cores"])
lines = [
"# manifoldbt {} vs vectorbt {}".format(versions["manifoldbt"], versions["vectorbt"]),
"# " + " vs ".join(
"{} {}".format(name, versions.get(name, "?")) for name in _engines(payload)),
"",
"`{os} {arch}` | {cpu} | {cores} cores | {ram} GB | python {py} | "
"numpy {np} / numba {nb} / pandas {pd} | {reps} interleaved reps | {when}".format(
@@ -48,31 +121,53 @@ def _header(payload: Dict[str, Any]) -> List[str]:
return lines
def _speed_table(rows: List[Dict[str, Any]], title: str) -> List[str]:
def _speed_table(rows: List[Dict[str, Any]], title: str, payload: Dict[str, Any]) -> List[str]:
"""One row per workload and size, one column per engine, then the ratios.
Columns are taken from the run rather than hardcoded, and a cell is only
empty when that engine was withheld or sat the workload out. Those cases get
a marker and a sentence of their own further down, because a blank in a
speed table reads as a defeat.
"""
if not rows:
return []
reference = _reference(payload)
engines = _engines(payload)
challengers = [e for e in engines if e != reference]
head = "| Workload | Bars | " + " | ".join(engines) + " | "
head += " | ".join("vs " + c for c in challengers) + " |"
lines = [
"## " + title,
"",
"| Workload | Bars | manifoldbt | vectorbt | Ratio |",
"|---|---:|---:|---:|---:|",
head,
"|---|---:|" + "---:|" * (len(engines) + len(challengers)),
]
for row in rows:
timings = row["timings"]
lines.append(
"| {w} | {b:,} | {m} | {v} | **x{s:.1f}**{f} |".format(
w=row["workload"], b=row["bars"],
m=_ms(timings["manifoldbt"]["median_s"]),
v=_ms(timings["vectorbt"]["median_s"]),
s=row["speedup"]["median_of_ratios"],
f=" ~" if row.get("noisy") else "",
)
)
cells = [
_ms(timings[e]["median_s"]) if e in timings else "-" for e in engines
]
speedup = row.get("speedup") or {}
# The noise flag rides on the last ratio, where a reader's eye already
# is when deciding whether to believe the number.
mark = " ~" if row.get("noisy") else ""
ratios = [
"**x{:.1f}**".format(speedup[c]["median_of_ratios"]) if c in speedup else "-"
for c in challengers
]
# On the last ratio that actually has a number: hung on a "-" it would
# look like a comment on the engine that did not run.
present = [i for i, cell in enumerate(ratios) if cell != "-"]
if mark and present:
ratios[present[-1]] += mark
lines.append("| {w} | {b:,} | {cells} |".format(
w=row["workload"], b=row["bars"], cells=" | ".join(cells + ratios)))
lines.append("")
return lines
def _summary_cost(exact: List[Dict[str, Any]]) -> List[str]:
def _summary_cost(exact: List[Dict[str, Any]], payload: Dict[str, Any]) -> List[str]:
plain = {r["bars"]: r for r in exact if r["workload"] == "sma_cross"}
summarised = {r["bars"]: r for r in exact if r["workload"] == "sma_cross_metrics"}
# Only subtract timings that were measured in the same interleaved loop.
@@ -92,21 +187,45 @@ def _summary_cost(exact: List[Dict[str, Any]]) -> List[str]:
"|---:|---|---:|---:|---:|",
]
for bars in shared:
for engine in ("manifoldbt", "vectorbt"):
without = plain[bars]["timings"][engine]["median_s"]
with_ = summarised[bars]["timings"][engine]["median_s"]
for engine in _engines(payload):
without = (plain[bars]["timings"] or {}).get(engine)
with_ = (summarised[bars]["timings"] or {}).get(engine)
if not (without and with_):
continue
a, c = without["median_s"], with_["median_s"]
lines.append(
"| {b:,} | {e} | {a} | {c} | {d} |".format(
b=bars, e=engine, a=_ms(without), c=_ms(with_),
d=("+" + _ms(with_ - without)) if with_ > without else "none measurable",
b=bars, e=engine, a=_ms(a), c=_ms(c),
d=("+" + _ms(c - a)) if c > a else "none measurable",
)
)
lines.append("")
advisory = summarised[shared[-1]]["parity"]["diffs"].get("advisory_ratio_rel")
top = summarised[shared[-1]]
advisory = {}
basis_differs = {}
for engine, verdict in top["parity"].items():
diffs = verdict.get("diffs", {})
if diffs.get("advisory_ratio_rel"):
advisory[engine] = max(diffs["advisory_ratio_rel"].values())
elif diffs.get("ratio_basis"):
basis_differs[engine] = diffs.get("max_drawdown_rel")
if advisory:
lines += [
"Gated on total return, round-trips and max drawdown (exact). Sharpe, Sortino "
"and volatility agree to {:.1e}, from daily bucketing.".format(max(advisory.values())),
"Gated on total return, round-trips and max drawdown (exact). Sharpe, "
"Sortino and volatility agree to {:.1e}.".format(max(advisory.values())),
"",
]
for engine, drawdown_rel in basis_differs.items():
if drawdown_rel is None:
agreement = "was not compared"
elif drawdown_rel == 0.0:
agreement = "is identical"
else:
agreement = "agrees to {:.1e}".format(drawdown_rel)
lines += [
"{e} annualises its ratios on its own basis, so only the drawdown is "
"compared there: it {a}.".format(e=engine, a=agreement),
"",
]
return lines
@@ -115,82 +234,216 @@ def _summary_cost(exact: List[Dict[str, Any]]) -> List[str]:
def _side_measures(payload: Dict[str, Any], results: List[Dict[str, Any]]) -> List[str]:
cold = payload.get("cold_start")
mem = payload.get("memory")
engines = _engines(payload)
threading_rows = [
r for r in results
if r.get("cpu_over_wall")
and all(r["cpu_over_wall"].get(e) is not None for e in ("manifoldbt", "vectorbt"))
and all(r["cpu_over_wall"].get(e) is not None for e in engines)
]
if not (cold or mem or threading_rows):
return []
lines = ["## Cold start, memory, threads", "", "| | manifoldbt | vectorbt |", "|---|---:|---:|"]
lines = [
"## Cold start, memory, threads",
"",
"| | " + " | ".join(engines) + " |",
"|---|" + "---:|" * len(engines),
]
if cold:
medians, share = cold["median_s"], cold["engine_share_s"]
lines.append("| Fresh process to first backtest | {:.2f} s | {:.2f} s |".format(
medians["manifoldbt"], medians["vectorbt"]))
lines.append("| ... minus the {:.2f} s python baseline | {:.2f} s | {:.2f} s |".format(
medians["baseline"], share["manifoldbt"], share["vectorbt"]))
lines.append("| Fresh process to first backtest | " + " | ".join(
"{:.2f} s".format(medians[e]) if e in medians else "-" for e in engines) + " |")
lines.append("| ... minus the {:.2f} s python baseline | ".format(medians["baseline"])
+ " | ".join("{:.2f} s".format(share[e]) if e in share else "-"
for e in engines) + " |")
if mem:
lines.append("| RAM added by the run, per 1M bars | {:.0f} MB | {:.0f} MB |".format(
mem["manifoldbt"]["added_mb_per_million_bars"],
mem["vectorbt"]["added_mb_per_million_bars"]))
lines.append("| RAM added by the run, per 1M bars | " + " | ".join(
"{:.0f} MB".format(mem[e]["added_mb_per_million_bars"]) if e in mem else "-"
for e in engines) + " |")
if threading_rows:
biggest = max(threading_rows, key=lambda r: r["bars"])
ratio = biggest["cpu_over_wall"]
lines.append("| CPU over wall time at {:,} bars | {:.2f} | {:.2f} |".format(
biggest["bars"], ratio["manifoldbt"], ratio["vectorbt"]))
lines.append("| CPU over wall time at {:,} bars | ".format(biggest["bars"])
+ " | ".join("{:.2f}".format(ratio[e]) for e in engines) + " |")
lines.append("")
return lines
def _not_run(payload: Dict[str, Any], results: List[Dict[str, Any]]) -> List[str]:
"""Workloads an engine sits out, and why.
Kept as prose rather than a table because the reason is the content. An
engine that cannot express a workload has told you something about itself,
and compressing that into an empty cell would throw away the only part worth
reading.
"""
seen: Dict[str, Dict[str, str]] = {}
for row in results:
for engine, why in (row.get("unsupported") or {}).items():
if engine in _engines(payload):
seen.setdefault(engine, {})[row["workload"]] = why
if not seen:
return []
lines = ["## Not run, and why", ""]
for engine, entries in seen.items():
for workload, why in entries.items():
lines += ["- **{e} on `{w}`.** {why}".format(e=engine, w=workload, why=why), ""]
return lines
def _gb(mb: float) -> str:
return "{:.1f} GB".format(mb / 1024) if mb >= 1024 else "{:.0f} MB".format(mb)
def _sweep_section(payload: Dict[str, Any]) -> List[str]:
"""The parameter-grid table, plus the memory that decides what is runnable.
Memory is reported next to the timings rather than in its own annex because
on a grid it is not a footnote: it is the first thing to run out. A machine
that cannot hold the grid does not produce a slow number, it produces no
number, and a reader sizing a job needs both columns side by side.
"""
sweeps = payload.get("sweeps")
if not sweeps:
return []
reference = _reference(payload)
engines = _engines(payload)
challengers = [e for e in engines if e != reference]
timed = [s for s in sweeps if s.get("timings")]
untimed = [s for s in sweeps if not s.get("timings")]
oos = [s for s in timed if s.get("out_of_scope")]
lines = ["## Parameter sweeps", ""]
if timed:
header = "| Bars | Combinations | " + " | ".join(engines) + " | "
header += " | ".join("vs " + c for c in challengers) + " | "
header += " | ".join("RAM " + e for e in engines) + " |"
lines += [
header,
# bars, combinations, one column per engine, one ratio per
# challenger, then one RAM column per engine.
"|---:|---:|" + "---:|" * (2 * len(engines) + len(challengers)),
]
for s in timed:
mem = s.get("memory") or {}
seconds = s["timings"]["seconds"]
ratios = s["timings"].get("ratio") or {}
cells = [_ms(seconds[e]) if seconds.get(e) is not None else "not run"
for e in engines]
cells += ["**x{:.1f}**".format(ratios[c]) if ratios.get(c) is not None else "-"
for c in challengers]
cells += [_gb(mem[e]) if mem.get(e) is not None else "-" for e in engines]
lines.append("| {b:,} | {c:,} | {cells} |".format(
b=s["bars"], c=s["combos"], cells=" | ".join(cells)))
lines += [
"",
"Each grid is checked cell by cell before any of it is timed: the "
"engines are joined on the parameter pair they actually ran, not on "
"position, and the worst disagreement in the grid is what the gate "
"sees. A single wrong cell among thousands is exactly the failure a "
"sweep can have and a single backtest cannot.",
"",
]
if oos:
lines += ["### Where a challenger was not run", ""]
for s in oos:
for engine, detail in s["out_of_scope"].items():
verdict = (s.get("parity") or {}).get(engine) or {}
lines.append(
"- **{e}, {c:,} combinations at {b:,} bars.** {why} The "
"cross-engine check for this point therefore covers the same "
"code path at {a:,} combinations, not this grid: agreement "
"was `{st}`.".format(
e=engine, c=s["combos"], b=s["bars"], why=detail["reason"],
a=verdict.get("checked_at_combos", 0),
st=verdict.get("status", "?"),
)
)
lines.append("")
if untimed:
lines += ["No timing for these points:", ""]
for s in untimed:
lines.append("- {b:,} bars x {c:,} combinations: {why}".format(
b=s["bars"], c=s["combos"],
why=s.get("reason") or s.get("note") or "no result",
))
lines.append("")
return lines
def _divergence_note(row: Dict[str, Any], payload: Dict[str, Any]) -> List[str]:
"""The measured size of a documented divergence, engine by engine."""
scales = row.get("divergence_scale") or {}
reference = _reference(payload)
view = scales.get(reference)
if not view:
return []
lines = [
"`{w}` at {b:,} bars: {n} of {t} {ref} round-trips re-enter on the exit "
"bar ({p:.0%}), from {sl} stop and {tp} target exits.".format(
w=row["workload"], b=row["bars"], n=view["reentries_on_exit_bar"],
t=view["round_trips"], p=view["share_of_round_trips"],
sl=view["sl_exits"], tp=view["tp_exits"], ref=reference,
),
]
for engine, scale in scales.items():
if engine == reference or "round_trips" not in scale:
continue
lines[-1] += " {e} books {n}.".format(e=engine, n=scale["round_trips"])
lines += ["", "Cause in {}.".format(METHOD_LINK), ""]
return lines
def render(payload: Dict[str, Any]) -> str:
payload = normalise(payload)
results = payload["results"]
exact = [r for r in results if r["parity"]["status"] == "exact" and r.get("timings")]
documented = [r for r in results if r["parity"]["status"] == "documented"]
failed = [r for r in results if r["parity"]["status"] == "failed"]
exact = [r for r in results if r.get("status") == "exact" and r.get("timings")]
documented = [r for r in results if r.get("status") == "documented"]
failed = [r for r in results if r.get("status") == "failed"]
lines = _header(payload)
lines += _speed_table(exact, "Same results, both engines")
lines += _summary_cost(exact)
lines += _speed_table(exact, "Same results, every engine", payload)
lines += _summary_cost(exact, payload)
lines += _side_measures(payload, results)
lines += _not_run(payload, results)
lines += _sweep_section(payload)
timed = [r for r in documented if r.get("timings")]
if timed:
lines += _speed_table(timed, "Results differ, kept out of the headline")
scales = [r for r in documented if r.get("divergence_scale")]
if scales:
row = scales[0]
scale = row["divergence_scale"]
lines += [
"`{w}`: {n} of {t} round-trips re-enter on the exit bar ({p:.0%}) at {b:,} "
"bars, from {sl} stop and {tp} target exits. Cause in {link}.".format(
w=row["workload"], n=scale["reentries_on_exit_bar"],
t=scale["round_trips"], p=scale["share_of_round_trips"],
b=row["bars"], sl=scale["sl_exits"], tp=scale["tp_exits"],
link=METHOD_LINK,
),
"",
]
lines += _speed_table(timed, "Results differ, kept out of the headline", payload)
for row in documented:
if row.get("divergence_scale"):
lines += _divergence_note(row, payload)
break
if failed:
lines += ["## Timing withheld", ""]
for row in failed:
diffs = row["parity"]["diffs"]
lines.append(
"- `{w}` at {b:,} bars: final equity differs by {d:.2e} of capital, "
"round-trips by {t}.".format(
w=row["workload"], b=row["bars"],
d=diffs["final_equity_vs_capital"], t=diffs["round_trips_delta"],
for engine, verdict in row["parity"].items():
if verdict["status"] != "failed":
continue
diffs = verdict["diffs"]
lines.append(
"- `{w}` at {b:,} bars, {e}: final equity differs by {d:.2e} of "
"capital, round-trips by {t}.".format(
w=row["workload"], b=row["bars"], e=engine,
d=diffs["final_equity_vs_capital"],
t=diffs["round_trips_delta"],
)
)
)
lines.append("")
lines += [
"---",
"",
"`~` = IQR above 15% of the median, indicative only. Ratios are medians of "
"per-repetition ratios with the engines interleaved; data loading excluded, warmup "
"discarded. Method and caveats: {}".format(METHOD_LINK),
"per-repetition ratios against {ref}, with the engines interleaved; data loading "
"excluded, warmup discarded. Method and caveats: {link}".format(
ref=_reference(payload), link=METHOD_LINK),
"",
]
return "\n".join(lines)
@@ -5,7 +5,17 @@
#
# manifoldbt itself is NOT pinned here: the workflow installs the version under
# test (`pip install manifoldbt==X`), so the same lock file serves every release.
#
# raptorbt has no dependency of its own, so pinning it moves nothing else in the
# set. It does pin the *interpreter*, though: it is built against pyo3 0.20.3,
# whose maximum supported CPython is 3.12, so there is no cp313 wheel in any
# release up to 0.9.0 and a source build refuses outright ("the configured
# Python interpreter version (3.13) is newer than PyO3's maximum supported
# version (3.12)"). That is why the workflow runs 3.12: comparing engines means
# running them in one environment, and the environment has to be one they all
# support.
vectorbt==0.28.4
raptorbt==0.9.0
numpy==2.4.3
numba==0.64.0
pandas==2.3.3
+670
View File
@@ -0,0 +1,670 @@
"""One sweep point, one fresh process, one JSON line on stdout.
A parameter sweep is where vectorbt is supposed to be at its strongest: it
broadcasts the whole grid into one vectorised simulation. So it is the workload
where a speed claim needs the tightest gate, and where engines are the easiest
to compare *wrongly*.
Its own process, for three reasons:
* memory. The peak of a large grid is the number that decides what a machine can
run at all, and it cannot be read once another engine has already grown the
allocator in the same process.
* tier. The engine's fan-out allowance is counted per process, so two points
sharing one would interfere.
* isolation. A 100k-combination grid that runs out of memory takes its process
down with it; one point dying should not cost the whole benchmark.
python sweep_child.py --bars 100000 --combos 250
The grid alignment trap
-----------------------
Every engine is asked for the same set of parameter pairs, but nothing forces
them to return the results in the same ORDER. manifoldbt enumerates its grid in
alphabetical key order (``fast`` outer, ``slow`` inner); vectorbt returns one
column per combination in the order its parameter product was built. Zip two of
them together wrongly and every number still looks plausible: the arrays have the
same length, the same distribution, even the same best value. Only the mapping
is scrambled, and the comparison silently becomes meaningless.
So the pairing is not assumed. Each engine returns its results keyed by the
``(fast, slow)`` pair it actually ran, and the comparison joins on that key.
Not every engine has a grid to broadcast
----------------------------------------
raptorbt has no fan-out API for a parameter grid on one instrument, so its
column here is a Python loop over ``run_single_backtest``. That is not a handicap
imposed by the harness, it is the only spelling available, and it is the one a
raptorbt user would write. It gets the same courtesy vectorbt gets on its own
path: each distinct moving average is computed once and reused across every
combination it appears in, rather than recomputed per cell.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from engines import BY_CODE, ENGINES, REFERENCE # noqa: E402
CAPITAL_TOL = 1e-9 # same yardstick as parity.py: difference over capital
def _mem_mb() -> float:
import psutil
info = psutil.Process().memory_info()
# RSS under-reports under memory pressure, which is exactly the regime a
# large grid puts the machine in. Prefer the private working set where the
# platform exposes it.
return (getattr(info, "private", None) or info.rss) / (1024 * 1024)
class _Peak(threading.Thread):
# `_halt`, not `_stop`: Thread already has a private `_stop()` method, and
# shadowing it with an Event makes `join()` raise "'Event' object is not
# callable" on CPython 3.12, where `_wait_for_tstate_lock` calls it. The
# sampler is the last thing anyone suspects when a sweep dies in join().
def __init__(self, interval: float = 0.002):
super().__init__(daemon=True)
self.interval = interval
self.peak = 0.0
self._halt = threading.Event()
def run(self):
while not self._halt.is_set():
self.peak = max(self.peak, _mem_mb())
time.sleep(self.interval)
def stop(self) -> float:
self._halt.set()
self.join(timeout=2.0)
self.peak = max(self.peak, _mem_mb())
return self.peak
def grid(combos: int) -> tuple[list[int], list[int]]:
"""Split `combos` into a near-square fast x slow grid.
Near-square rather than a long strip: a 1 x N grid would sweep a single fast
period and measure indicator reuse rather than fan-out. The fast and slow
ranges are kept disjoint so no cell has fast >= slow, which would never trade
and would pad the grid with empty simulations.
"""
n_fast = int(combos ** 0.5)
while combos % n_fast:
n_fast -= 1
n_slow = combos // n_fast
fast_vals = [5 + 2 * i for i in range(n_fast)]
slow_vals = [max(fast_vals) + 10 + 5 * i for i in range(n_slow)]
return fast_vals, slow_vals
def tier() -> dict:
"""What the engine thinks it is allowed to do, right now.
Read before and after the sweep. The downgrade that follows a refused
licence ping lands from a background thread, so a run can legitimately start
Pro and finish Community: a number measured across that boundary is not a
measurement of anything.
"""
import manifoldbt as mbt
used, limit, is_pro = mbt._native._combo_budget()
return {"pro": bool(is_pro), "budget_used": int(used), "budget_limit": int(limit)}
def run_mbt(df, fast_vals, slow_vals, workdir, metrics=False):
import manifoldbt as mbt
from manifoldbt.expr import col, lit, when
from manifoldbt.helpers import Interval, Slippage
from manifoldbt.indicators import close as close_px, sma
from workloads import CAPITAL
store = mbt.import_dataframe(
df,
symbol="BENCH",
symbol_id=1,
interval="1m",
data_root=os.path.join(workdir, "data"),
metadata_db=os.path.join(workdir, "metadata.sqlite"),
)
last_ns = int(df["timestamp"].iloc[-1].value)
config = mbt.BacktestConfig(
universe=[1],
time_range_start=0,
time_range_end=last_ns + 86_400_000_000_000,
bar_interval=Interval.minutes(1),
initial_capital=CAPITAL,
execution=mbt.ExecutionConfig(
signal_delay=0,
execution_price="AtClose",
max_position_pct=1.0,
allow_short=False,
position_sizing_mode="FractionOfEquity",
),
fees=mbt.FeeConfig.zero(),
slippage=Slippage.none(),
warmup_bars=0,
)
strategy = (
mbt.Strategy.create("sma_sweep")
.signal("fast", sma(close_px, mbt.param("fast")))
.signal("slow", sma(close_px, mbt.param("slow")))
.size(when(col("fast") > col("slow"), lit(1.0), lit(0.0)))
)
def call():
batch = mbt.run_sweep_lite(
strategy, {"fast": fast_vals, "slow": slow_vals}, config, store
)
# Alphabetical key order: "fast" is the outer loop, "slow" the inner one.
# Keyed by the pair rather than by position, so a change to that order
# breaks the join loudly instead of scrambling the comparison.
out = {}
i = 0
for f in fast_vals:
for s in slow_vals:
m = batch[i].metrics
out[(f, s)] = (
(float(m["total_return"]), float(m["max_drawdown"]))
if metrics else float(m["total_return"])
)
i += 1
if i != len(batch):
raise RuntimeError(f"grid decode consumed {i} of {len(batch)} results")
return out
return call
def run_vbt(df, fast_vals, slow_vals, metrics=False):
"""vectorbt's grid path, taking the faster of its two ways of building it.
The obvious spelling is to hand `MA.run` one window per combination and let
`ma_above` line the two up. It is also the wrong one to benchmark: it
recomputes every moving average once per combination it appears in, so a
10 x 25 grid computes 500 averages instead of 35. Measured on 20k bars and
250 combinations, that spelling takes 8.6x longer to build the same signal
matrix, bit for bit.
So the averages are computed once each and the product is formed by indexing
columns. vectorbt is credited with its quicker path, exactly as the fee
workload credits it with the quicker of its two metric paths.
"""
import itertools
import numpy as np
import pandas as pd
import vectorbt as vbt
from workloads import CAPITAL, FREQ
index = pd.DatetimeIndex(df["timestamp"])
close = pd.Series(df["close"].to_numpy(dtype=np.float64), index=index)
pairs = list(itertools.product(fast_vals, slow_vals))
columns = pd.MultiIndex.from_tuples(pairs, names=["fast_window", "slow_window"])
fast_idx = np.repeat(np.arange(len(fast_vals)), len(slow_vals))
slow_idx = np.tile(np.arange(len(slow_vals)), len(fast_vals))
def call():
fast_ma = vbt.MA.run(close, window=fast_vals, short_name="fast").ma.to_numpy()
slow_ma = vbt.MA.run(close, window=slow_vals, short_name="slow").ma.to_numpy()
entries = pd.DataFrame(
fast_ma[:, fast_idx] > slow_ma[:, slow_idx], index=index, columns=columns
)
portfolio = vbt.Portfolio.from_signals(
close,
entries=entries,
exits=~entries,
init_cash=CAPITAL,
size=1.0,
size_type="percent",
fees=0.0,
slippage=0.0,
direction="longonly",
accumulate=False,
freq=FREQ,
)
returns = portfolio.total_return()
# Columns carry a MultiIndex of the two window levels. Read the pair off
# the index rather than trusting positional order.
names = list(returns.index.names)
fi = names.index("fast_window")
si = names.index("slow_window")
if not metrics:
return {
(int(kv[fi]), int(kv[si])): float(v)
for kv, v in zip(returns.index, returns.to_numpy())
}
# The drawdown is what makes this workload different: it cannot be read
# off the trade records, it needs the equity curve of every column, and
# `from_signals` defers building that until something asks. Written out
# rather than through `portfolio.max_drawdown()` because the accessor is
# measurably slower on the same data, and vectorbt is credited with the
# quicker of its two paths here exactly as it is everywhere else.
equity = portfolio.value().to_numpy()
drawdown = (equity / np.maximum.accumulate(equity, axis=0) - 1.0).min(axis=0)
return {
(int(kv[fi]), int(kv[si])): (float(r), float(d))
for kv, r, d in zip(returns.index, returns.to_numpy(), drawdown)
}
return call
def run_rbt(df, fast_vals, slow_vals, metrics=False):
"""raptorbt's grid path: one backtest per cell, in Python.
There is no fan-out entry point to call. ``run_multi_backtest`` broadcasts
over *instruments*, not over parameters, so a parameter grid on one symbol is
a loop, and the loop is what a user would write.
The moving averages are hoisted out of it. Computing them per cell would
recompute the same average once per combination it appears in, which is the
spelling explicitly rejected on the vectorbt side; rejecting it there and
accepting it here would be scoring the two engines with different rulers.
"""
import numpy as np
import raptorbt as rbt
from workloads import CAPITAL
timestamps = df["timestamp"].astype("int64").to_numpy()
open_ = np.ascontiguousarray(df["open"].to_numpy(dtype=np.float64))
high = np.ascontiguousarray(df["high"].to_numpy(dtype=np.float64))
low = np.ascontiguousarray(df["low"].to_numpy(dtype=np.float64))
close = np.ascontiguousarray(df["close"].to_numpy(dtype=np.float64))
volume = np.ascontiguousarray(df["volume"].to_numpy(dtype=np.float64))
config = rbt.BacktestConfig(
initial_capital=CAPITAL, fees=0.0, slippage=0.0, upon_bar_close=True
)
def call():
periods = sorted(set(fast_vals) | set(slow_vals))
averages = {p: np.asarray(rbt.sma(close, p), dtype=np.float64) for p in periods}
finite = {p: ~np.isnan(a) for p, a in averages.items()}
out = {}
for f in fast_vals:
fast, fast_ok = averages[f], finite[f]
for s in slow_vals:
level = (fast > averages[s]) & fast_ok & finite[s]
result = rbt.run_single_backtest(
timestamps, open_, high, low, close, volume,
level, ~level, config=config,
)
m = result.metrics
ret = float(m.total_return_pct) / 100.0
# Already computed inside the run, like the reference: asking
# for it costs raptorbt nothing, and that is a result, not a
# concession.
out[(f, s)] = ((ret, -float(m.max_drawdown_pct) / 100.0)
if metrics else ret)
return out
return call
def builder(name: str, df, fast_vals, slow_vals, workdir: str, metrics: bool = False):
"""The grid closure for one engine, ready to be timed."""
if name == "manifoldbt":
return run_mbt(df, fast_vals, slow_vals, workdir, metrics)
if name == "vectorbt":
return run_vbt(df, fast_vals, slow_vals, metrics)
if name == "raptorbt":
return run_rbt(df, fast_vals, slow_vals, metrics)
raise KeyError("no sweep path for engine {!r}".format(name))
def compare(reference_out: dict, other_out: dict) -> dict:
"""Join on the parameter pair, then gate on the worst cell, not the average.
An average would hide a single badly wrong combination in a grid of
thousands, which is precisely the failure a sweep can have and a single
backtest cannot.
"""
from workloads import CAPITAL
missing = sorted(set(reference_out) ^ set(other_out))
if missing:
return {
"status": "failed",
"reason": f"{len(missing)} parameter pairs present in one engine only",
"examples": [list(p) for p in missing[:5]],
}
# Both sides report a total return, a fraction of the account. Comparing
# those directly is the same yardstick parity.py uses for a single backtest
# (a difference in final equity over capital), without the round trip
# through money.
_ = CAPITAL
# A cell is either a total return, or a (return, drawdown) pair when the
# sweep was asked for a performance summary. Both components are gated: a
# drawdown that disagrees is the same class of finding as a return that
# does, and letting it through would publish a timing for work that was not
# the same work.
worst_pair, worst, worst_component = None, 0.0, None
for pair, mv in reference_out.items():
ov = other_out[pair]
components = zip(mv, ov) if isinstance(mv, tuple) else ((mv, ov),)
for index, (a, b) in enumerate(components):
d = abs(a - b)
if d > worst:
worst, worst_pair = d, pair
worst_component = ("return", "max_drawdown")[index]
return {
"status": "exact" if worst <= CAPITAL_TOL else "failed",
"combos_compared": len(reference_out),
"worst_abs_return_delta": worst,
"worst_component": worst_component,
"worst_pair": list(worst_pair) if worst_pair else None,
}
# vectorbt is the only engine here whose grid footprint grows with the number of
# combinations, so it is the only one a point can declare out of scope. The
# others are looped or streamed and cost the same memory at any grid size.
OOS_ENGINE = "vectorbt"
OOS_REASON = (
"vectorbt materialises the simulation per combination (measured: 1.57 MB "
"per combination at 20k bars), so this grid would need tens of gigabytes. "
"Running it would measure the swap file."
)
def main() -> int:
"""Entry point. The scratch store is removed on the way out, always.
One point leaves one mkdtemp behind, and a matrix run spawns a child per
point per engine per memory probe. A day of measurements left 87 of them and
14.7 GB on the volume TEMP points at, which on a full system drive is not
housekeeping, it is the benchmark failing with "Espace insuffisant sur le
disque" in the middle of a run.
"""
try:
return _main(ap_parse())
finally:
_cleanup()
_SCRATCH: list = []
def _cleanup() -> None:
import shutil
for path in _SCRATCH:
shutil.rmtree(path, ignore_errors=True)
def _scratch(prefix: str) -> str:
"""A temporary directory that will be removed when this process exits."""
path = tempfile.mkdtemp(prefix=prefix)
_SCRATCH.append(path)
return path
def ap_parse():
ap = argparse.ArgumentParser()
ap.add_argument("--bars", type=int, required=True)
ap.add_argument("--combos", type=int, required=True)
ap.add_argument("--reps", type=int, default=1)
ap.add_argument(
"--metrics",
action="store_true",
help="ask every cell for a performance summary, not just a total "
"return. This is what a user sweeping thousands of combinations "
"actually reads, and it is the one axis where the engines differ "
"in kind rather than in speed: manifoldbt and raptorbt compute the "
"drawdown inside the run whether it is read or not, while vectorbt "
"defers the equity curve until something asks for it and then has "
"to build one per column.",
)
ap.add_argument("--engines", nargs="+", default=list(ENGINES),
choices=list(ENGINES),
help="engines to run at this point; the reference is always "
"included whether it is named or not")
ap.add_argument(
"--parity-only",
action="store_true",
help="check that the engines agree, and skip timing. The only mode that "
"says anything useful without a licence.",
)
ap.add_argument(
"--vectorbt",
choices=("run", "oos"),
default="run",
help="'oos' declares vectorbt out of scope for this point and runs the "
"others. For grids whose vectorbt side does not fit in memory on "
"any ordinary machine: not running it is the honest reading, since "
"the alternative is a number produced by swapping.",
)
ap.add_argument(
"--parity-anchor",
type=int,
default=250,
help="with --vectorbt oos, the grid size vectorbt DOES run, so the point "
"still carries a cross-engine check of the same code path.",
)
ap.add_argument(
"--memory-only",
choices=sorted(BY_CODE),
help="run ONE engine and report the memory its grid added. Memory has to "
"be measured this way: once any engine has run, the allocator has "
"grown and whatever runs second is measured against a heap it can "
"reuse. Measured in the same process, the second engine reads about "
"half its true peak.",
)
return ap.parse_args()
def _main(args) -> int:
import data as data_mod
fast_vals, slow_vals = grid(args.combos)
actual = len(fast_vals) * len(slow_vals)
df = data_mod.make_ohlcv(args.bars)
workdir = _scratch("mbt-sweep-")
tier_before = tier()
if args.memory_only:
# What is wanted here is the PEAK of running this grid once, because that
# is what a machine has to survive: a box that cannot hold the grid does
# not return a slow number, it returns no number. Running the grid twice
# and measuring the second call would answer a different question (what a
# repeat costs in a warm process: 4.9 MB against 1.4 GB, measured), and
# that number tells a reader nothing about whether the job fits.
#
# But a cold first call would also charge vectorbt its numba compilation,
# a one-off that has nothing to do with grid size. So the engine is warmed
# on a 2x2 grid -- enough to compile, too small to pre-allocate the real
# one -- and the baseline is taken after that.
name = BY_CODE[args.memory_only].name
warm = builder(name, df, fast_vals[:2], slow_vals[:2],
os.path.join(workdir, "warm"), args.metrics)
call = builder(name, df, fast_vals, slow_vals, workdir, args.metrics)
warm()
base = _mem_mb()
watch = _Peak()
watch.start()
call()
peak = watch.stop()
print(json.dumps({
"bars": args.bars,
"combos": actual,
"engine": name,
"baseline_mb": round(base, 1),
"added_mb": round(peak - base, 1),
"tier_before": tier_before,
"tier_after": tier(),
}))
return 0
# An unlicensed sweep CANNOT be timed, and this is not a matter of degree.
# Every accepted fan-out call waits out a fixed interval before any work
# starts, so the stopwatch measures the pause, not the engine: a 100-cell
# grid on 20k bars measured 5.00 s against vectorbt's 0.17 s here, which
# would publish "vectorbt is 29x faster" from a run where the engine did
# almost nothing. Timing is therefore refused outright without a licence,
# rather than gated on grid size.
#
# Checking tier_before against tier_after is not enough on its own: a run
# that starts AND finishes unlicensed shows no change at all, and would sail
# through that comparison carrying a meaningless ratio.
if not tier_before["pro"] and not args.parity_only:
print(json.dumps({
"bars": args.bars,
"combos": actual,
"status": "skipped",
"reason": (
"sweep timing requires a licence: unlicensed fan-out calls wait "
"out a fixed interval, so the measurement would be of that wait. "
"Re-run with --parity-only to check agreement without timing."
),
"tier_before": tier_before,
}))
return 2
# Parity-only still has to fit the unlicensed allowance, which is spent
# across the whole process: one warmup call per engine and nothing more.
if args.parity_only and not tier_before["pro"] and actual > tier_before["budget_limit"]:
print(json.dumps({
"bars": args.bars,
"combos": actual,
"status": "skipped",
"reason": (
f"{actual} combinations exceeds the unlicensed allowance of "
f"{tier_before['budget_limit']} for a single call"
),
"tier_before": tier_before,
}))
return 2
running = [REFERENCE] + [n for n in args.engines if n != REFERENCE]
out_of_scope = {}
if args.vectorbt == "oos" and OOS_ENGINE in running:
running.remove(OOS_ENGINE)
out_of_scope[OOS_ENGINE] = {"status": "out of scope", "reason": OOS_REASON}
calls = {
name: builder(name, df, fast_vals, slow_vals,
os.path.join(workdir, name), args.metrics)
for name in running
}
result = {
"bars": args.bars,
"combos": actual,
"requested_combos": args.combos,
"grid": {"fast": len(fast_vals), "slow": len(slow_vals)},
"data_digest": data_mod.digest(df),
"engines": running,
"metrics": args.metrics,
"tier_before": tier_before,
}
if out_of_scope:
result["out_of_scope"] = out_of_scope
# Warmup, discarded: it is where vectorbt compiles its numba kernels, and
# charging that to every repetition would inflate the result. The warm call
# is what the gate reads, so nothing is computed twice for it.
warm = {name: call() for name, call in calls.items()}
verdicts = {
name: compare(warm[REFERENCE], out)
for name, out in warm.items() if name != REFERENCE
}
for name, detail in out_of_scope.items():
# The engine is not run at this size, so the point cannot carry a
# cross-engine check against it on its own grid. It carries one of the
# same code path at a size that engine can hold: same data, same
# strategy, same adapters, fewer cells. That is weaker than checking the
# grid itself, and the report says so rather than implying otherwise.
a_fast, a_slow = grid(args.parity_anchor)
anchor = compare(
builder(REFERENCE, df, a_fast, a_slow,
os.path.join(workdir, "anchor"), args.metrics)(),
builder(name, df, a_fast, a_slow, workdir, args.metrics)(),
)
verdicts[name] = {
**anchor,
"checked_at_combos": len(a_fast) * len(a_slow),
"scope": "anchor grid, not this grid",
}
detail["parity_scope"] = "anchor grid"
result["parity"] = verdicts
result["status"] = "failed" if any(
v["status"] == "failed" for v in verdicts.values()) else "exact"
if result["status"] == "failed":
# No timing for a grid the engines disagree on. That is the whole point
# of the gate, and a sweep is where it earns its keep.
result["timings"] = None
result["note"] = "timing withheld: unexplained disagreement between engines"
result["tier_after"] = tier()
print(json.dumps(result))
return 1
samples = {name: [] for name in running}
peaks = {name: 0.0 for name in running}
base_mb = _mem_mb()
for _ in range(args.reps):
# Interleaved, so a runner that slows down mid-point penalises everyone.
# Sampled per call rather than across the whole loop: a single peak over
# every engine is the max of them, attributed to none, and memory is the
# number that decides which grid sizes a machine can run at all.
for name, call in calls.items():
watch = _Peak()
watch.start()
t0 = time.perf_counter()
call()
elapsed = time.perf_counter() - t0
peaks[name] = max(peaks[name], watch.stop())
samples[name].append(elapsed)
tier_after = tier()
def med(xs):
return sorted(xs)[len(xs) // 2]
seconds = {name: med(samples[name]) for name in running}
reference_s = max(1e-12, seconds[REFERENCE])
result["timings"] = {
"seconds": {**seconds, **{name: None for name in out_of_scope}},
"ratio": {name: seconds[name] / reference_s
for name in running if name != REFERENCE},
"reps": args.reps,
"per_combo_us": seconds[REFERENCE] / actual * 1e6,
}
# Added by the call, on top of an already-built store / already-prepared
# Series: what running the grid costs, not what holding the data costs.
result["memory"] = {name: round(peaks[name] - base_mb, 1) for name in running}
result["memory_baseline_mb"] = round(base_mb, 1)
result["tier_after"] = tier_after
# A sweep measured across a tier change is not a measurement. The downgrade
# arrives from a background thread after a refused licence ping, so this can
# only be checked after the fact.
if tier_before["pro"] != tier_after["pro"]:
result["timings"] = None
result["note"] = (
f"timing withheld: tier changed mid-point "
f"(pro={tier_before['pro']} -> {tier_after['pro']})"
)
print(json.dumps(result))
return 1
print(json.dumps(result))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+111 -29
View File
@@ -1,18 +1,25 @@
"""Workload definitions: the numbers both engines read, in one place.
"""Workload definitions: the numbers every engine reads, in one place.
Nothing here is engine-specific. Each adapter (``engine_mbt``, ``engine_vbt``)
reads the same constants, so a workload cannot drift between the two sides by
someone editing one file and forgetting the other.
Nothing here is engine-specific. Each adapter (``engine_mbt``, ``engine_vbt``,
``engine_rbt``) reads the same constants, so a workload cannot drift between two
sides by someone editing one file and forgetting another.
What a workload *does* carry per engine is a note: the places where an engine is
known in advance to disagree, or is unable to run the workload at all. Those are
written down here, next to the parameters they apply to, rather than in the
adapters, so a reader can see the whole map of "who runs what, and where they
part ways" without opening three files.
Sizing policy, and why it changes when fees are on
--------------------------------------------------
With ``FractionOfEquity`` sizing and a non-zero fee, the two engines size a
position differently: manifoldbt charges the fee on top of a full-equity
notional, vectorbt reserves it out of cash first. Both are defensible product
decisions, and comparing them would compare *policy*, not speed or correctness.
The fee workload therefore sizes in fixed units, which isolates the fee
arithmetic itself. This mirrors the choice already made in the cross-engine
parity suite shipped with the library.
With ``FractionOfEquity`` sizing and a non-zero fee, engines size a position
differently: manifoldbt charges the fee on top of a full-equity notional,
vectorbt reserves it out of cash first. Both are defensible product decisions,
and comparing them would compare *policy*, not speed or correctness. The fee
workload therefore sizes in fixed units, which isolates the fee arithmetic
itself. This mirrors the choice already made in the cross-engine parity suite
shipped with the library. It is also the reason raptorbt sits that workload out:
it has no fixed-quantity sizing to offer (see its note below).
"""
from __future__ import annotations
@@ -27,18 +34,35 @@ CAPITAL = 100_000.0
FREQ = "1min"
@dataclass(frozen=True)
class Note:
"""What is known in advance about one engine on one workload.
``documented``
The engine is known to disagree with the reference, and ``why`` says on
what. It still gets timed, but in the annex, never in the headline
table, and always with the reason printed next to the number.
``unsupported``
The engine cannot express this workload at all. It is not run, and the
report says so rather than leaving a blank a reader would read as a
loss. An unsupported entry is a finding about the engine's API, so
``why`` has to be specific enough to be checked.
"""
status: str
why: str
@dataclass(frozen=True)
class Workload:
key: str
title: str
why: str
params: Dict[str, Any] = field(default_factory=dict)
# "exact": the engines must agree to float-reordering noise, or the timing is
# not published. "documented": they are known to disagree for a reason
# written down in `divergence`; the timing goes to the annex, never to the
# headline table.
parity: str = "exact"
divergence: str = ""
# Engine name -> Note. An engine with no entry here is expected to agree
# with the reference down to float-reordering noise, and a disagreement is
# a failure that withholds the timing.
notes: Dict[str, Note] = field(default_factory=dict)
WORKLOADS: Dict[str, Workload] = {
@@ -63,6 +87,26 @@ WORKLOADS: Dict[str, Workload] = {
"engines on a wiped-out account compares rounding noise.",
params=dict(fast=12, slow=26, rsi_period=14, rsi_lo=30.0, rsi_hi=70.0,
units=5.0, fee_bps=5.0),
notes={
"raptorbt": Note(
"unsupported",
"Two blockers, either one sufficient. Sizing: raptorbt has no "
"fixed-quantity mode. `position_sizes` is a fraction of equity "
"(measured: 0.5 buys exactly half the equity of the bar before "
"the entry), `lot_size` rounds a computed size down to a "
"multiple, and `alloted_capital` fixes the notional, not the "
"quantity. Reproducing `units=5` would mean feeding a fraction "
"derived from an equity curve that does not exist until the run "
"is over. Indicator: `raptorbt.ema` seeds on a simple mean of "
"the first `period` bars and emits from bar `period-1`, while "
"manifoldbt seeds on the first observation and emits from bar 0 "
"(measured: 11 leading NaN for span 12, and ema[11] equal to "
"sma(12)[11] to the last bit). The two are the same recursion "
"with a different warmup, so the signal differs early and the "
"round-trip count with it. Its `sma` and `rsi` do match, which "
"is why the other three workloads run.",
),
},
),
Workload(
key="sma_cross_metrics",
@@ -85,22 +129,60 @@ WORKLOADS: Dict[str, Workload] = {
"level, the fill on the triggering bar, and whether a re-entry "
"is allowed on the bar after an exit.",
params=dict(fast=10, slow=50, alloc=1.0, sl_pct=0.15, tp_pct=0.30),
parity="documented",
divergence=(
"Re-entry on the exit bar. When a bracket fires intrabar and the "
"entry condition still holds at that bar's close, manifoldbt books "
"two orders on that bar (the stop or target exit, then a fresh "
"entry at the close); vectorbt processes one order per bar and "
"re-enters on the next bar instead. Neither is wrong, and on "
"controlled bars the bracket fills themselves match exactly (see "
"the cross-engine parity suite shipped with the library). The "
"harness counts the affected round-trips so the size of the "
"divergence is measured, not asserted."
),
notes={
"vectorbt": Note(
"documented",
"Re-entry on the exit bar. When a bracket fires intrabar and "
"the entry condition still holds at that bar's close, "
"manifoldbt books two orders on that bar (the stop or target "
"exit, then a fresh entry at the close); vectorbt processes one "
"order per bar and re-enters on the next bar instead. Neither "
"is wrong, and on controlled bars the bracket fills themselves "
"match exactly (see the cross-engine parity suite shipped with "
"the library). The harness counts the affected round-trips so "
"the size of the divergence is measured, not asserted.",
),
"raptorbt": Note(
"documented",
"Same fork in the road, taken further: raptorbt does not "
"re-arm at all. Once a bracket closes a position, the entry "
"level being still true is not enough to open another one; it "
"waits for the level to go false and true again. So on the same "
"bars the three engines book a different number of round-trips "
"from the same signal, manifoldbt re-entering on the exit bar, "
"vectorbt on the next bar, raptorbt not until the next crossing. "
"The count of affected round-trips is measured rather than "
"asserted, and it is the same population for both challengers.",
),
},
),
)
}
def expectation(key: str, engine: str) -> str:
"""What this engine is expected to do on this workload, before it runs."""
note = WORKLOADS[key].notes.get(engine)
return note.status if note else "exact"
def why(key: str, engine: str) -> str:
note = WORKLOADS[key].notes.get(engine)
return note.why if note else ""
def supported(key: str, engine: str) -> bool:
return expectation(key, engine) != "unsupported"
def unsupported_by(key: str) -> Dict[str, str]:
"""Engines that sit this workload out, and the reason each one gives."""
return {
name: note.why
for name, note in WORKLOADS[key].notes.items()
if note.status == "unsupported"
}
DEFAULT_KEYS = list(WORKLOADS)
# Two workloads that the report compares directly against each other, so they