2026-08-20 16:16:33 +02:00
|
|
|
"""Workload definitions: the numbers every engine reads, in one place.
|
2026-08-18 02:49:20 +02:00
|
|
|
|
2026-08-20 16:16:33 +02:00
|
|
|
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.
|
2026-08-18 02:49:20 +02:00
|
|
|
|
|
|
|
|
Sizing policy, and why it changes when fees are on
|
|
|
|
|
--------------------------------------------------
|
2026-08-20 16:16:33 +02:00
|
|
|
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).
|
2026-08-18 02:49:20 +02:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Any, Dict
|
|
|
|
|
|
|
|
|
|
CAPITAL = 100_000.0
|
|
|
|
|
|
|
|
|
|
# Bar interval of the generated data. Both engines are told the same thing:
|
|
|
|
|
# manifoldbt through `Interval.minutes(1)`, vectorbt through `freq="1min"`
|
|
|
|
|
# (annualisation only; it does not touch the simulation).
|
|
|
|
|
FREQ = "1min"
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 16:16:33 +02:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 02:49:20 +02:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Workload:
|
|
|
|
|
key: str
|
|
|
|
|
title: str
|
|
|
|
|
why: str
|
|
|
|
|
params: Dict[str, Any] = field(default_factory=dict)
|
2026-08-20 17:00:41 +02:00
|
|
|
# Longest series this workload is valid on, or None for no ceiling. A
|
|
|
|
|
# workload can stop being a comparison before it stops running: see the fee
|
|
|
|
|
# workload, whose account this exists to keep alive.
|
|
|
|
|
max_bars: int | None = None
|
2026-08-20 16:16:33 +02:00
|
|
|
# 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)
|
2026-08-18 02:49:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
WORKLOADS: Dict[str, Workload] = {
|
|
|
|
|
w.key: w
|
|
|
|
|
for w in (
|
|
|
|
|
Workload(
|
|
|
|
|
key="sma_cross",
|
2026-08-20 17:00:41 +02:00
|
|
|
title="SMA 30/150 crossover, long-only, no cost",
|
2026-08-18 02:49:20 +02:00
|
|
|
why="The canonical baseline. Unambiguous indicator, no fee policy, "
|
|
|
|
|
"no stop semantics: if the engines disagree here, nothing else "
|
|
|
|
|
"in the suite is worth reading.",
|
2026-08-20 17:00:41 +02:00
|
|
|
params=dict(fast=30, slow=150, alloc=1.0),
|
2026-08-18 02:49:20 +02:00
|
|
|
),
|
|
|
|
|
Workload(
|
|
|
|
|
key="ema_rsi_fees",
|
|
|
|
|
title="EMA 12/26 crossover + RSI(14) filter, 5 bps taker fee",
|
|
|
|
|
why="A realistic signal stack with a real cost model, sized in fixed "
|
|
|
|
|
"units so the fee arithmetic is comparable across engines. The "
|
|
|
|
|
"unit count is small relative to capital on purpose: at 1-minute "
|
|
|
|
|
"resolution this strategy turns over often enough that a larger "
|
|
|
|
|
"size would spend the whole account on fees, and comparing two "
|
|
|
|
|
"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),
|
2026-08-20 17:00:41 +02:00
|
|
|
# The account has to survive, or the engines are being compared on
|
|
|
|
|
# rounding noise around zero. Measured: -15% of capital at 1M bars,
|
|
|
|
|
# -74% at 5M, and exactly -100% at 10M, where fees reach 99,611 of
|
|
|
|
|
# the 100,000 started with. Past that the two disagree by thousands
|
|
|
|
|
# of round-trips while both sit at zero equity, which is a fact
|
|
|
|
|
# about a bankrupt strategy and not about either engine. The ceiling
|
|
|
|
|
# is set where the comparison still means something.
|
|
|
|
|
max_bars=1_000_000,
|
2026-08-20 16:16:33 +02:00
|
|
|
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.",
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-08-18 02:49:20 +02:00
|
|
|
),
|
|
|
|
|
Workload(
|
|
|
|
|
key="sma_cross_metrics",
|
2026-08-20 17:00:41 +02:00
|
|
|
title="SMA 30/150 crossover, with a performance summary",
|
2026-08-18 02:49:20 +02:00
|
|
|
why="The same simulation as `sma_cross`, but both engines are asked "
|
|
|
|
|
"for what a user actually reads: max drawdown, Sharpe, Sortino "
|
|
|
|
|
"and volatility alongside the return. manifoldbt computes them "
|
|
|
|
|
"inside run() whether you ask or not; vectorbt defers the equity "
|
|
|
|
|
"curve until a risk metric needs it, and every one of them pays "
|
|
|
|
|
"for materialising it. This is a scope difference, not a trick: "
|
|
|
|
|
"`sma_cross` above is the same work without the summary, and the "
|
|
|
|
|
"two are reported side by side so the reader can see what the "
|
|
|
|
|
"summary costs each engine.",
|
2026-08-20 17:00:41 +02:00
|
|
|
params=dict(fast=30, slow=150, alloc=1.0, metrics=True),
|
2026-08-18 02:49:20 +02:00
|
|
|
),
|
|
|
|
|
Workload(
|
|
|
|
|
key="bracket_sl_tp",
|
|
|
|
|
title="SMA 10/50 entry with a 15 bps stop / 30 bps target bracket",
|
|
|
|
|
why="Brackets are where two engines most easily disagree: the stop "
|
|
|
|
|
"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),
|
2026-08-20 16:16:33 +02:00
|
|
|
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.",
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-08-18 02:49:20 +02:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 16:16:33 +02:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-18 02:49:20 +02:00
|
|
|
DEFAULT_KEYS = list(WORKLOADS)
|
|
|
|
|
|
|
|
|
|
# Two workloads that the report compares directly against each other, so they
|
|
|
|
|
# must be measured inside ONE interleaved loop rather than in two blocks minutes
|
|
|
|
|
# apart. Comparing medians across blocks is exactly the mistake the interleaving
|
|
|
|
|
# exists to prevent: absolute timings drift between blocks, and the drift once
|
|
|
|
|
# produced a table claiming the version doing MORE work was the faster one.
|
|
|
|
|
SCOPE_PAIR = ("sma_cross", "sma_cross_metrics")
|