Files
manifoldbt/benchmarks/vs_vectorbt/report.py
T
Jimmy7892 23421cd5ca bench: report an engine that was held back, or has no drawdown, in words
The report assumed every engine ran on every row. With a ceiling that is
no longer true, so:

- "Not run, and why" prints one reason once for all the workloads it
  covers, instead of once per workload.
- The CPU-over-wall row reads each engine at the longest series it ran,
  where it used to need every engine at one size and would have vanished.
- A memory figure taken at an engine's ceiling carries its bar count.
- An engine with no maximum drawdown is said to have none, rather than
  "it was not compared", and its summary workload is described as gated on
  total return and round-trips.
- A documented divergence where an engine takes the same trades and sizes
  one differently reports how many re-entries and how far apart.
- Probes that died for a challenger are listed under the table.

test_harness.py adds guards for the gate (an engine without a drawdown,
the documented bracket divergence), the ceilings, the sweep scope rules
(including that a recipe saying only `:oos` cannot start Nautilus on a
big grid), the out-of-scope reasons and these report changes. Like
test_report.py it needs no engine installed.
2026-10-23 23:06:58 +02:00

540 lines
22 KiB
Python

"""Turn a results JSON into the Markdown a human reads.
Prints to stdout and, when running under GitHub Actions, appends the same text
to the job summary so the numbers are visible without downloading an artifact.
python report.py results.json
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
import argparse
import json
import os
import sys
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)
return "{:.2f} s".format(seconds)
def _header(payload: Dict[str, Any]) -> List[str]:
env = payload["environment"]
versions = env["versions"]
cores = str(env["logical_cores"])
if env.get("pinned_cores"):
cores += " (pinned to {})".format(env["pinned_cores"])
lines = [
"# " + " 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(
os=env["os"], arch=env["arch"], cpu=env["cpu"], cores=cores, ram=env["ram_gb"],
py=env["python"], np=versions["numpy"], nb=versions["numba"],
pd=versions["pandas"], reps=payload["reps"], when=payload["generated_at"][:19],
),
]
if env.get("run_url"):
lines += ["", env["run_url"]]
lines.append("")
return lines
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,
"",
head,
"|---|---:|" + "---:|" * (len(engines) + len(challengers)),
]
for row in rows:
timings = row["timings"]
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]], 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.
shared = sorted(
b for b in set(plain) & set(summarised)
if "sma_cross_metrics" in (plain[b].get("paired_with") or [])
)
if not shared:
return []
lines = [
"## Cost of the performance summary",
"",
"Same simulation, with and without max drawdown / Sharpe / Sortino / volatility.",
"",
"| Bars | Engine | Without | With | Delta |",
"|---:|---|---:|---:|---:|",
]
for bars in shared:
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(a), c=_ms(c),
d=("+" + _ms(c - a)) if c > a else "none measurable",
)
)
lines.append("")
advisory = {}
basis_differs = {}
no_drawdown = set()
# Each engine is read at the longest series it ran on: one held back by its
# ceiling has no verdict at the longest size, and reading only that row would
# leave it out of the sentence.
for bars in reversed(shared):
for engine, verdict in summarised[bars]["parity"].items():
if engine in advisory or engine in basis_differs:
continue
diffs = verdict.get("diffs", {})
if diffs.get("max_drawdown_absent"):
no_drawdown.add(engine)
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}.".format(max(advisory.values())),
"",
]
for engine, drawdown_rel in basis_differs.items():
if engine in no_drawdown:
# Not "it was not compared": it does not exist. The engine offers
# volatility, Sharpe and Sortino and no drawdown statistic, and the
# harness does not build one for it.
lines += [
"{e} annualises its ratios on its own basis and reports no maximum "
"drawdown, so this workload is gated on total return and "
"round-trips only (exact).".format(e=engine),
"",
]
continue
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
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)
# CPU over wall time, per engine, at the longest series that engine ran and
# produced a reading for. An engine held back by its ceiling has no reading at
# the longest size, and requiring every engine at one size would drop the row
# for all of them.
threading: Dict[str, Any] = {}
for engine in engines:
rows = [r for r in results
if (r.get("cpu_over_wall") or {}).get(engine) is not None]
if rows:
best = max(rows, key=lambda r: r["bars"])
threading[engine] = (best["cpu_over_wall"][engine], best["bars"])
if not (cold or mem or threading):
return []
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 | " + " | ".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:
def memory_cell(engine: str) -> str:
if engine not in mem or not isinstance(mem[engine], dict):
return "-"
probe = mem[engine]
text = "{:.0f} MB".format(probe["added_mb_per_million_bars"])
# An engine held back by its ceiling is measured at the ceiling, and
# the figure is scaled to a million bars like the others. Say so
# beside the number rather than leave a reader to assume the same
# series length.
if probe.get("bars") and probe["bars"] != mem.get("bars"):
text += " (at {:,} bars)".format(probe["bars"])
return text
lines.append("| RAM added by the run, per 1M bars | "
+ " | ".join(memory_cell(e) for e in engines) + " |")
if threading:
sizes = {bars for _, bars in threading.values()}
if len(sizes) == 1:
lines.append("| CPU over wall time at {:,} bars | ".format(sizes.pop())
+ " | ".join("{:.2f}".format(threading[e][0]) if e in threading else "-"
for e in engines) + " |")
else:
lines.append("| CPU over wall time, at the longest series each ran | " + " | ".join(
"{:.2f} (at {:,})".format(*threading[e]) if e in threading else "-"
for e in engines) + " |")
lines.append("")
if mem and mem.get("failed"):
for engine, why in mem["failed"].items():
lines += ["- Memory probe for {}: {}".format(engine, why), ""]
if cold and cold.get("failed"):
for engine, why in cold["failed"].items():
lines += ["- Cold-start probe for {}: {}".format(engine, why), ""]
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. An engine held back by its ceiling gets the same treatment, and the
same reason is printed once with the list of workloads it applies to, not
once per workload.
"""
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():
by_reason: Dict[str, List[str]] = {}
for workload, why in entries.items():
by_reason.setdefault(why, []).append(workload)
for why, workloads in by_reason.items():
lines += ["- **{e} on {w}.** {why}".format(
e=engine, w=", ".join("`{}`".format(w) for w in workloads), 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 one documented divergence, engine by engine.
A list item, so that the notes of several points stay one per line once
rendered; the cause is common to all of them and render() states it once.
"""
scales = row.get("divergence_scale") or {}
reference = _reference(payload)
view = scales.get(reference)
if not view:
return []
note = (
"- `{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
note += " {e} books {n}.".format(e=engine, n=scale["round_trips"])
# An engine that takes the same trades and sizes one of them differently
# says how far: a count and a median, not an adjective.
if scale.get("reentries_on_exit_bar") is not None:
note += (" Of those, {n} re-enter on the exit bar, with a quantity that "
"sits a median of {m:.1e} (at most {x:.1e}), in relative terms, "
"away from the position closed on that bar.".format(
n=scale["reentries_on_exit_bar"],
m=scale.get("reentry_quantity_gap_median", 0.0),
x=scale.get("reentry_quantity_gap_max", 0.0)))
return [note]
def render(payload: Dict[str, Any]) -> str:
payload = normalise(payload)
results = payload["results"]
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, 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", payload)
# Every point carries its own size: a divergence can grow with the
# number of bars, so the first point does not speak for the others.
notes = [note for row in timed for note in _divergence_note(row, payload)]
if notes:
lines += notes + ["", "Cause in {}.".format(METHOD_LINK), ""]
if failed:
lines += ["## Timing withheld", ""]
for row in failed:
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 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)
def main() -> int:
parser = argparse.ArgumentParser(description="render a benchmark result")
parser.add_argument("results", nargs="?", default="results.json")
args = parser.parse_args()
with open(args.results, encoding="utf-8") as fh:
payload = json.load(fh)
text = render(payload)
sys.stdout.write(text + "\n")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a", encoding="utf-8") as fh:
fh.write(text + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())