diff --git a/.dev/bench-report b/.dev/bench-report
new file mode 100755
index 0000000..796e555
--- /dev/null
+++ b/.dev/bench-report
@@ -0,0 +1,712 @@
+#!/usr/bin/env python3
+"""Turn criterion's JSON output into a benchmark snapshot, a terminal table and
+a standalone HTML page.
+
+criterion writes one directory per benchmark under target/criterion, each
+holding a benchmark.json naming it and an estimates.json carrying the timing
+distribution. Reading those is exact, where scraping the human-readable stdout
+is a guess at a moving format.
+
+ .dev/bench-report save [--run] collect into docs/bench-data.json
+ .dev/bench-report table print the snapshot as a terminal table
+ .dev/bench-report html [-o P] render the snapshot as a web page
+
+Published numbers come from a committed snapshot rather than a CI run, because
+a shared runner's timings vary by more than the differences being measured.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import platform
+import re
+import subprocess
+import sys
+from datetime import datetime, timezone
+from html import escape
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+CRITERION = ROOT / "target" / "criterion"
+SNAPSHOT = ROOT / "docs" / "bench-data.json"
+PAGE = ROOT / "site" / "index.html"
+
+# the order operations are presented in, cheapest kind of work first
+OPERATIONS = [
+ ("cmp", "Compare two values"),
+ ("add", "Add into an accumulator"),
+ ("mul", "Multiply price by size"),
+ ("div", "Divide price by size"),
+ ("round_dp", "Round to 2 decimal places"),
+ ("round_to_step", "Round to a 0.01 step"),
+ ("floor", "Floor to an integer"),
+ ("ceil", "Ceiling to an integer"),
+ ("to_f64", "Convert to f64"),
+ ("from_f64", "Convert from f64"),
+ ("parse", "Parse from a string"),
+ ("format", "Format to a string"),
+ ("collect", "Parse a batch into a fresh Vec"),
+ ("clone", "Allocate and copy a Vec"),
+]
+
+# groups swept over a parameter rather than measured at one width: these are
+# read as a curve, so they get a line chart instead of a row in the table
+SWEEPS = [
+ (
+ "parse_digits",
+ "Parsing by digit width",
+ "Significant digits in the text",
+ "The parser accumulates in a u64 and promotes to u128 once a mantissa "
+ "passes 19 digits, so the cost of that promotion is visible as the "
+ "curve steepens past the boundary.",
+ ),
+ (
+ "format_digits",
+ "Formatting by digit width",
+ "Significant digits in the value",
+ "Rendering walks the digits it has, so the curve is the cost of the "
+ "digits themselves rather than of any one value.",
+ ),
+]
+
+# f64 leads as the inexact reference the decimals are measured against
+IMPLEMENTATIONS = ["f64", "Dec", "rust_decimal", "fastnum"]
+DECIMALS = ["Dec", "rust_decimal", "fastnum"]
+
+
+# --------------------------------------------------------------------------
+# collection
+
+
+def machine() -> dict[str, str]:
+ """Provenance for the numbers: timings without a machine are not a result."""
+
+ def run(*command: str) -> str:
+ try:
+ out = subprocess.run(command, capture_output=True, text=True, cwd=ROOT)
+ except OSError:
+ return "unknown"
+ return out.stdout.strip() if out.returncode == 0 else "unknown"
+
+ cpu = platform.processor() or "unknown"
+ cpuinfo = Path("/proc/cpuinfo")
+ if cpuinfo.exists():
+ found = re.search(r"^model name\s*:\s*(.+)$", cpuinfo.read_text(), re.M)
+ if found:
+ cpu = found.group(1).strip()
+
+ commit = run("git", "rev-parse", "--short", "HEAD")
+ if run("git", "status", "--porcelain"):
+ commit += " (modified)"
+
+ return {
+ "cpu": cpu,
+ "arch": platform.machine(),
+ "os": platform.platform(terse=True),
+ "rustc": run("rustc", "--version"),
+ "commit": commit,
+ "measured": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
+ }
+
+
+def collect(directory: Path) -> list[dict]:
+ """Read every criterion benchmark under `directory`.
+
+ Timings are divided by the sample count baked into the benchmark id, so
+ every number on the page is nanoseconds for a single operation.
+ """
+ results = []
+ for estimates_path in sorted(directory.glob("*/*/*/new/estimates.json")):
+ identity_path = estimates_path.parent / "benchmark.json"
+ if not identity_path.exists():
+ continue
+ identity = json.loads(identity_path.read_text())
+ estimates = json.loads(estimates_path.read_text())
+
+ # criterion records how many values one iteration covers, which is the
+ # only safe divisor: in a swept group the benchmark id is the swept
+ # parameter, not a count, and dividing by it would be nonsense
+ throughput = identity.get("throughput") or {}
+ count = throughput.get("Elements")
+ if count is None:
+ try:
+ count = int(identity["value_str"])
+ except (KeyError, TypeError, ValueError):
+ continue
+ if not isinstance(count, int) or count <= 0:
+ continue
+
+ median = estimates["median"]
+ interval = median["confidence_interval"]
+ results.append(
+ {
+ "operation": identity["group_id"],
+ "implementation": identity["function_id"],
+ "parameter": identity.get("value_str"),
+ "count": count,
+ "nanos": median["point_estimate"] / count,
+ "low": interval["lower_bound"] / count,
+ "high": interval["upper_bound"] / count,
+ }
+ )
+ return results
+
+
+def load(path: Path) -> dict:
+ if not path.exists():
+ sys.exit(f"no snapshot at {path} — run `make bench-save` first")
+ return json.loads(path.read_text())
+
+
+def timings(snapshot: dict) -> dict[tuple[str, str], dict]:
+ return {(r["operation"], r["implementation"]): r for r in snapshot["results"]}
+
+
+def sweep_series(snapshot: dict, operation: str) -> dict[str, list[tuple[int, float]]]:
+ """Per implementation, the curve of (parameter, ns) sorted by parameter."""
+ series: dict[str, list[tuple[int, float]]] = {}
+ for record in snapshot["results"]:
+ if record["operation"] != operation:
+ continue
+ try:
+ parameter = int(record["parameter"])
+ except (TypeError, ValueError):
+ continue
+ series.setdefault(record["implementation"], []).append(
+ (parameter, record["nanos"])
+ )
+ return {name: sorted(points) for name, points in series.items() if points}
+
+
+def fastest_decimal(table: dict, operation: str) -> float | None:
+ candidates = [
+ table[(operation, name)]["nanos"]
+ for name in DECIMALS
+ if (operation, name) in table
+ ]
+ return min(candidates) if candidates else None
+
+
+def present(snapshot: dict) -> list[tuple[str, str]]:
+ """Operations that actually have a measurement, in presentation order."""
+ table = timings(snapshot)
+ return [
+ (operation, caption)
+ for operation, caption in OPERATIONS
+ if any((operation, name) in table for name in IMPLEMENTATIONS)
+ ]
+
+
+def present_sweeps(snapshot: dict) -> list[tuple]:
+ return [sweep for sweep in SWEEPS if sweep_series(snapshot, sweep[0])]
+
+
+# --------------------------------------------------------------------------
+# terminal table
+
+
+def render_table(snapshot: dict) -> str:
+ table = timings(snapshot)
+ rows = present(snapshot)
+ width = max(len(operation) for operation, _ in rows) + 2
+
+ lines = [""]
+ for key, label in (("cpu", "cpu"), ("rustc", "rustc"), ("commit", "commit")):
+ lines.append(f"{label:>7}: {snapshot['machine'][key]}")
+ lines.append("\nns per operation\n")
+
+ header = "op".ljust(width) + "".join(name.rjust(14) for name in IMPLEMENTATIONS)
+ lines += [header, "-" * len(header)]
+
+ for operation, _ in rows:
+ best = fastest_decimal(table, operation)
+ cells = ""
+ for name in IMPLEMENTATIONS:
+ record = table.get((operation, name))
+ if record is None:
+ cells += "-".rjust(14)
+ else:
+ mark = "*" if record["nanos"] == best else " "
+ cells += f"{record['nanos']:>13.2f}" + mark
+ lines.append(operation.ljust(width) + cells)
+
+ lines.append("\n* fastest decimal")
+ return "\n".join(lines)
+
+
+# --------------------------------------------------------------------------
+# page
+#
+# Colors come from the validated categorical palette. f64 is drawn in neutral
+# ink rather than a categorical hue: it is the inexact reference the decimals
+# are measured against, not a fourth competitor. That leaves three series,
+# which clear the CVD and normal-vision floors on every pair in both modes.
+
+STYLE = """
+:root {
+ color-scheme: light;
+ --plane: #f9f9f7;
+ --surface: #fcfcfb;
+ --ink: #0b0b0b;
+ --ink-soft: #52514e;
+ --ink-muted: #898781;
+ --rule: #e1e0d9;
+ --border: rgba(11, 11, 11, 0.1);
+ --reference: #c3c2b7;
+ --reference-ink: #6f6e69;
+ --dec: #2a78d6;
+ --rust-decimal: #eb6834;
+ --fastnum: #1baf7a;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ color-scheme: dark;
+ --plane: #0d0d0d;
+ --surface: #1a1a19;
+ --ink: #ffffff;
+ --ink-soft: #c3c2b7;
+ --ink-muted: #898781;
+ --rule: #2c2c2a;
+ --border: rgba(255, 255, 255, 0.1);
+ --reference: #55554f;
+ --reference-ink: #a3a29b;
+ --dec: #3987e5;
+ --rust-decimal: #d95926;
+ --fastnum: #199e70;
+ }
+}
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --plane: #0d0d0d;
+ --surface: #1a1a19;
+ --ink: #ffffff;
+ --ink-soft: #c3c2b7;
+ --ink-muted: #898781;
+ --rule: #2c2c2a;
+ --border: rgba(255, 255, 255, 0.1);
+ --reference: #55554f;
+ --reference-ink: #a3a29b;
+ --dec: #3987e5;
+ --rust-decimal: #d95926;
+ --fastnum: #199e70;
+}
+
+body {
+ margin: 0;
+ background: var(--plane);
+ color: var(--ink);
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+.wrap { max-width: 1080px; margin: 0 auto; padding: 48px 24px 96px; }
+
+header { border-bottom: 1px solid var(--rule); padding-bottom: 28px; }
+h1 { font-size: 30px; line-height: 1.2; margin: 0 0 8px; letter-spacing: -0.02em; }
+.lede { margin: 0; max-width: 62ch; color: var(--ink-soft); }
+.provenance {
+ display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px;
+ font-size: 12.5px; color: var(--ink-soft);
+}
+.chip {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 999px; padding: 3px 11px;
+}
+.chip b { color: var(--ink-muted); font-weight: 500; }
+
+h2 { font-size: 19px; margin: 48px 0 6px; letter-spacing: -0.01em; }
+h2 + p { margin: 0 0 20px; color: var(--ink-soft); max-width: 62ch; font-size: 14px; }
+
+.legend { display: flex; flex-wrap: wrap; gap: 18px; margin: 0 0 24px; font-size: 13px; }
+.legend span { display: flex; align-items: center; gap: 7px; color: var(--ink-soft); }
+.swatch { width: 11px; height: 11px; border-radius: 3px; flex: none; }
+
+.grid {
+ display: grid; gap: 14px;
+ grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
+}
+.card {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 10px; padding: 15px 16px 13px;
+}
+.card h3 { margin: 0; font-size: 14px; font-weight: 600; }
+.card .caption { margin: 1px 0 13px; font-size: 12px; color: var(--ink-muted); }
+
+.bars { display: flex; flex-direction: column; gap: 8px; }
+.bar-row {
+ display: grid; grid-template-columns: 88px 1fr auto;
+ align-items: center; gap: 10px; font-size: 12px;
+}
+.bar-name { color: var(--ink-soft); text-align: right; }
+/* the track is the full width of the slowest bar in this chart, so every
+ chart is read on its own scale — cmp at 4ns and parse at 100ns do not
+ share an axis */
+.track { height: 13px; border-radius: 3px; position: relative; }
+.bar {
+ height: 100%; border-radius: 0 4px 4px 0; min-width: 2px;
+ transition: filter 0.12s;
+}
+.bar-row:hover .bar { filter: brightness(1.08); }
+.bar-value {
+ font-variant-numeric: tabular-nums; color: var(--ink-soft);
+ min-width: 62px; text-align: right;
+}
+.absent { color: var(--ink-muted); font-style: italic; }
+
+.sweep { width: 100%; height: auto; display: block; overflow: visible; }
+.sweep .grid { stroke: var(--rule); stroke-width: 1; }
+.sweep .tick { fill: var(--ink-muted); font-size: 11px; }
+.sweep .axis { fill: var(--ink-soft); font-size: 12px; }
+.sweep .tag { font-size: 12px; font-weight: 600; }
+.sweep .end { text-anchor: end; }
+.sweep .mid { text-anchor: middle; }
+.sweep .dot { stroke: var(--surface); stroke-width: 2; }
+
+table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
+caption { caption-side: bottom; padding-top: 12px; color: var(--ink-muted); font-size: 12.5px; text-align: left; }
+th, td { padding: 7px 10px; border-bottom: 1px solid var(--rule); }
+thead th { text-align: right; font-weight: 600; color: var(--ink-soft); font-size: 12.5px; }
+thead th:first-child, tbody th { text-align: left; }
+tbody th { font-weight: 500; }
+td { text-align: right; font-variant-numeric: tabular-nums; }
+td.best { font-weight: 650; color: var(--ink); }
+td.best::after { content: " ★"; color: var(--ink-muted); font-size: 10px; }
+td.none { color: var(--ink-muted); }
+.scroll { overflow-x: auto; }
+
+footer {
+ margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--rule);
+ font-size: 13px; color: var(--ink-soft);
+}
+footer a { color: inherit; }
+
+#tip {
+ position: fixed; z-index: 10; pointer-events: none; opacity: 0;
+ transition: opacity 0.1s; background: var(--surface); color: var(--ink);
+ border: 1px solid var(--border); border-radius: 7px; padding: 7px 10px;
+ font-size: 12px; line-height: 1.45; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.13);
+ font-variant-numeric: tabular-nums; max-width: 240px;
+}
+#tip b { display: block; font-size: 12.5px; }
+#tip span { color: var(--ink-muted); }
+"""
+
+SCRIPT = """
+const tip = document.getElementById('tip');
+for (const row of document.querySelectorAll('.bar-row[data-tip]')) {
+ row.addEventListener('pointerenter', () => {
+ tip.innerHTML = row.dataset.tip;
+ tip.style.opacity = '1';
+ });
+ row.addEventListener('pointermove', (event) => {
+ const box = tip.getBoundingClientRect();
+ const x = Math.min(event.clientX + 14, window.innerWidth - box.width - 8);
+ const y = Math.max(event.clientY - box.height - 12, 8);
+ tip.style.left = x + 'px';
+ tip.style.top = y + 'px';
+ });
+ row.addEventListener('pointerleave', () => { tip.style.opacity = '0'; });
+}
+"""
+
+COLOR = {
+ "f64": "var(--reference)",
+ "Dec": "var(--dec)",
+ "rust_decimal": "var(--rust-decimal)",
+ "fastnum": "var(--fastnum)",
+}
+
+
+def nanos(value: float) -> str:
+ if value >= 100:
+ return f"{value:,.0f} ns"
+ if value >= 10:
+ return f"{value:.1f} ns"
+ return f"{value:.2f} ns"
+
+
+def render_card(operation: str, caption: str, table: dict) -> str:
+ records = [
+ (name, table[(operation, name)])
+ for name in IMPLEMENTATIONS
+ if (operation, name) in table
+ ]
+ slowest = max(record["nanos"] for _, record in records)
+ best = fastest_decimal(table, operation)
+
+ rows = []
+ for name in IMPLEMENTATIONS:
+ record = table.get((operation, name))
+ if record is None:
+ # f64 has no arm in the conversion groups because converting it to
+ # itself measures nothing; anywhere else the operation is missing
+ missing = "not applicable" if name == "f64" else "not implemented"
+ rows.append(
+ f'
{escape(name)}
'
+ f'
{missing}
'
+ )
+ continue
+ width = max(record["nanos"] / slowest * 100, 1.2)
+ note = " · fastest decimal" if record["nanos"] == best else ""
+ tip = (
+ f"{escape(name)} — {escape(operation)}"
+ f"{nanos(record['nanos'])} per operation{escape(note)} "
+ f"95% CI {nanos(record['low'])} – {nanos(record['high'])}"
+ )
+ rows.append(
+ f'
'
+ f'
{escape(name)}
'
+ f'
'
+ f'
{nanos(record["nanos"])}
'
+ )
+
+ return (
+ f'
{escape(operation)}
'
+ f'
{escape(caption)}
'
+ f'
{"".join(rows)}
'
+ )
+
+
+# One SVG per sweep. The x axis is linear in the digit count rather than evenly
+# spaced by sample, so the 18-to-19 step reads as the single digit it is.
+def render_sweep(snapshot: dict, operation: str, axis: str) -> str:
+ series = sweep_series(snapshot, operation)
+ points = [value for curve in series.values() for value in curve]
+ widths = sorted({parameter for parameter, _ in points})
+ ceiling = max(nanos for _, nanos in points)
+
+ left, right, top, bottom = 52, 16, 14, 42
+ width, height = 720, 300
+ plot_w, plot_h = width - left - right, height - top - bottom
+
+ def x_of(parameter: int) -> float:
+ span = widths[-1] - widths[0] or 1
+ return left + (parameter - widths[0]) / span * plot_w
+
+ def y_of(nanos: float) -> float:
+ return top + plot_h - (nanos / ceiling) * plot_h
+
+ # round tick values, and never one above the data: a tick past the ceiling
+ # would place its gridline outside the viewBox
+ step = max(10 ** (len(str(int(ceiling))) - 1), 1)
+ if ceiling / step < 3:
+ step = max(step // 2, 1)
+ ticks = list(range(0, int(ceiling) + 1, step))[:8]
+
+ parts = [
+ f'")
+ return "".join(parts)
+
+
+def render_summary(snapshot: dict) -> str:
+ table = timings(snapshot)
+ head = "".join(f"
{escape(name)}
" for name in IMPLEMENTATIONS)
+
+ body = []
+ for operation, _ in present(snapshot):
+ best = fastest_decimal(table, operation)
+ cells = []
+ for name in IMPLEMENTATIONS:
+ record = table.get((operation, name))
+ if record is None:
+ cells.append('
How Dec compares with rust_decimal,
+ fastnum and native f64 across the operations a
+ trading system runs on a hot path. Every number is nanoseconds for one
+ operation, taken as the median over {snapshot["count"]:,} values.
+
{chips}
+
+
+
Per operation
+
Each chart is scaled to its own slowest bar, so a chart shows who wins that
+operation and by how much — not how operations compare with each other.
+f64 is drawn in grey because it is the inexact reference, not a
+decimal competing on correctness.
+
{legend}
+
{cards}
+
+{sweeps}
+
+
Summary
+
The same numbers as one table.
+{render_summary(snapshot)}
+
+
+
+
+
+
+
+"""
+
+
+# --------------------------------------------------------------------------
+
+
+def save(run_first: bool) -> None:
+ if run_first:
+ command = ["cargo", "bench", "--bench", "arithmetic"]
+ print(" ".join(command), file=sys.stderr)
+ if subprocess.run(command, cwd=ROOT).returncode != 0:
+ sys.exit("cargo bench failed")
+
+ if not CRITERION.exists():
+ sys.exit(f"no criterion output at {CRITERION} — run `cargo bench` first")
+
+ results = collect(CRITERION)
+ if not results:
+ sys.exit(f"no benchmarks found under {CRITERION}")
+
+ snapshot = {
+ "machine": machine(),
+ "count": max(record["count"] for record in results),
+ "results": sorted(
+ results, key=lambda r: (r["operation"], r["implementation"])
+ ),
+ }
+ SNAPSHOT.parent.mkdir(parents=True, exist_ok=True)
+ SNAPSHOT.write_text(json.dumps(snapshot, indent=2) + "\n")
+ print(f"wrote {SNAPSHOT.relative_to(ROOT)} ({len(results)} benchmarks)")
+ print(render_table(snapshot))
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ collector = commands.add_parser("save", help="collect into a snapshot")
+ collector.add_argument(
+ "--run", action="store_true", help="run cargo bench before collecting"
+ )
+ commands.add_parser("table", help="print the snapshot as a table")
+ page = commands.add_parser("html", help="render the snapshot as a page")
+ page.add_argument("-o", "--out", type=Path, default=PAGE)
+
+ arguments = parser.parse_args()
+ if arguments.command == "save":
+ save(arguments.run)
+ elif arguments.command == "table":
+ print(render_table(load(SNAPSHOT)))
+ else:
+ arguments.out.parent.mkdir(parents=True, exist_ok=True)
+ arguments.out.write_text(render_html(load(SNAPSHOT)))
+ print(f"wrote {arguments.out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.dev/rs-lint b/.dev/rs-lint
index c29f781..3c4913b 100755
--- a/.dev/rs-lint
+++ b/.dev/rs-lint
@@ -8,5 +8,9 @@ if [ "$1" = "fix" ] ; then
fi
+# without this the script exits with clippy's status alone, so a formatting
+# failure in --check mode passes CI silently
+set -e
+
cargo fmt ${FMT_ARG}
cargo clippy ${CLIPPY_ARG}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 55ba865..8dfa73a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,3 +32,15 @@ jobs:
- name: Test
run: make rs-test
+
+ - name: Benchmarks
+ # --test runs each benchmark once instead of measuring it: enough to
+ # catch a bench that stopped compiling, fast enough for every PR
+ run: cargo bench --bench arithmetic -- --test
+
+ - name: Docs
+ # warnings are errors here, so a broken intra-doc link fails the build
+ # rather than shipping a dead link to docs.rs
+ env:
+ RUSTDOCFLAGS: -Dwarnings
+ run: make docs
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..2e637b6
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,47 @@
+name: pages
+
+# Publishes the benchmark page to GitHub Pages from the committed snapshot in
+# docs/bench-data.json. The numbers are measured on a known machine and
+# committed deliberately — a shared CI runner varies by more than the
+# differences being measured, so nothing here runs `cargo bench`.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - docs/bench-data.json
+ - .dev/bench-report
+ - .github/workflows/pages.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# a queued deploy waits for the running one rather than cancelling it, so the
+# published page always reflects the last commit rather than the last start
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Build the benchmark page
+ run: .dev/bench-report html --out site/index.html
+
+ - uses: actions/configure-pages@v5
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6ef9363..c6283ae 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,10 +1,11 @@
name: release
# Triggered by `v*` tags (push one with `make release`). Checks the tag matches
-# the version in Cargo.toml, runs lint and tests, publishes the crate to
-# crates.io, then extracts the matching section from docs/release-notes.md and
-# publishes it as the GitHub Release body. Re-runs idempotently update an
-# existing release rather than failing.
+# the version in Cargo.toml, runs lint, tests and the doc build, and extracts
+# the matching section from docs/release-notes.md - every step that can fail
+# runs before the crates.io publish, which cannot be taken back for a version.
+# It then publishes the crate and the extracted notes as the GitHub Release
+# body. Re-runs idempotently update an existing release rather than failing.
on:
push:
@@ -55,8 +56,12 @@ jobs:
- name: tests
run: make rs-test
- - name: publish to crates.io
- run: make rs-publish
+ - name: build docs
+ # a rustdoc warning is a broken page on docs.rs, which cannot be
+ # rebuilt for a version once it is published
+ env:
+ RUSTDOCFLAGS: -Dwarnings
+ run: make docs
- name: Extract release notes
run: |
@@ -73,6 +78,9 @@ jobs:
echo "--- extracted notes for ${TAG} ---"
cat /tmp/notes.md
+ - name: publish to crates.io
+ run: make rs-publish
+
- name: Publish GitHub Release
run: |
TAG="${GITHUB_REF_NAME}"
diff --git a/.gitignore b/.gitignore
index f9e7ede..bb97a1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
__pycache__
target/
+site/
diff --git a/CLAUDE.md b/CLAUDE.md
index 46656e1..f8747b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Goal
-Troy provides superfast numeric primitives for High Frequency Trading, where every nanosecond counts. Correctness and performance are the top priorities: the crate is a fixed-scale decimal type for fast, exact arithmetic, written in safe Rust (`unsafe` is forbidden).
+Troy provides superfast primitives and data structures for High Frequency Trading, where every nanosecond counts. Correctness and performance are the top priorities. The crate is written in safe Rust (`unsafe` is forbidden) and holds two things: `Dec`, a fixed-scale decimal for fast exact arithmetic, and `OrderBook`, a level 2 book built on it.
## Build & Test
@@ -29,9 +29,22 @@ make rs-test # test with all features
## Benchmarks
```bash
-cargo bench # run the criterion benchmarks
+make bench # run the criterion benchmarks
+make bench-save # run them and record docs/bench-data.json
+make bench-page # render that snapshot into site/index.html
+make bench-report # open criterion's own report (violin, PDF, sweeps)
```
+criterion's report lives in `target/criterion` and is ~30 MB of SVG, so it
+stays a local tool. The published page carries the curated summary and the
+digit sweeps, both rendered from the snapshot.
+
+`docs/bench-data.json` is a committed snapshot measured on a known machine,
+carrying the CPU, rustc version and commit it came from. `.dev/bench-report`
+reads criterion's JSON and renders both the terminal table and the page that
+`.github/workflows/pages.yml` publishes to GitHub Pages. CI never measures:
+a shared runner varies by more than the differences the page reports.
+
## Alternatives Crates
This crates is design to be fast and efficient for high-frequency trading scenarios, where performance is critical.
diff --git a/Cargo.lock b/Cargo.lock
index a629a61..74e8de0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -114,6 +114,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -166,6 +177,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "criterion"
version = "0.8.2"
@@ -304,10 +324,22 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -367,6 +399,12 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -389,6 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
+ "libm",
]
[[package]]
@@ -489,6 +528,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "rand"
version = "0.8.8"
@@ -510,6 +555,17 @@ dependencies = [
"rand_core 0.9.5",
]
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -548,6 +604,22 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_distr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
+dependencies = [
+ "num-traits",
+ "rand 0.10.2",
+]
+
[[package]]
name = "rayon"
version = "1.12.0"
@@ -752,6 +824,8 @@ version = "0.1.0"
dependencies = [
"criterion",
"fastnum",
+ "rand 0.10.2",
+ "rand_distr",
"rust_decimal",
"serde",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index edefb42..bbe5a01 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,11 +3,11 @@ name = "troy"
version = "0.1.0"
edition = "2024"
rust-version = "1.87"
-description = "A fixed-scale decimal type for fast, exact arithmetic"
+description = "Superfast primitives and data structures for high frequency trading"
repository = "https://github.com/quantmind/troy"
license = "MIT"
readme = "readme.md"
-keywords = ["decimal", "fixed-point", "numeric", "finance"]
+keywords = ["hft", "trading", "orderbook", "decimal", "low-latency"]
categories = ["mathematics", "data-structures"]
publish = true
@@ -15,25 +15,38 @@ publish = true
name = "troy"
path = "src/mod.rs"
+# docs.rs builds one page per published version; build it with serde and
+# rust_decimal on so the optional conversions are documented
+[package.metadata.docs.rs]
+all-features = true
+
[lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
+missing_docs = "warn"
[lints.clippy]
all = { level = "warn", priority = -1 }
[dependencies]
+rand = { version = "0.10.2", optional = true }
+rand_distr = { version = "0.6.0", optional = true }
rust_decimal = { version = "1.43", optional = true }
serde = { version = "1.0", optional = true }
[dev-dependencies]
criterion = "0.8"
+# also optional dependencies of the `testing` feature; repeated here so the
+# crate's own tests can use the harness without enabling it
+rand = "0.10.2"
+rand_distr = "0.6.0"
fastnum = "0.7.5"
rust_decimal = "1.43"
serde_json = "1.0"
[features]
default = []
+testing = ["dep:rand", "dep:rand_distr"]
rust_decimal = ["dep:rust_decimal"]
serde = ["dep:serde"]
diff --git a/Makefile b/Makefile
index f758939..498d373 100644
--- a/Makefile
+++ b/Makefile
@@ -21,6 +21,26 @@ docs: ## build the rust documentation
docs-open: ## build the rust documentation and open it in the browser
@cargo doc --all-features --no-deps --open
+.PHONY: bench
+bench: ## run the criterion benchmarks
+ @cargo bench --bench arithmetic
+
+.PHONY: bench-save
+bench-save: ## run the benchmarks and commit the numbers to docs/bench-data.json
+ @.dev/bench-report save --run
+
+.PHONY: bench-table
+bench-table: ## print the benchmark snapshot as a table
+ @.dev/bench-report table
+
+.PHONY: bench-report
+bench-report: ## open criterion's own report: violin, PDF and per-parameter charts
+ @python3 -c "import webbrowser; webbrowser.open('file://$(PWD)/target/criterion/report/index.html')"
+
+.PHONY: bench-page
+bench-page: ## build the benchmark page into site/index.html
+ @.dev/bench-report html
+
.PHONY: rs-publish
rs-publish: ## publish the crate to crates.io
@cargo publish --token $(CARGO_REGISTRY_TOKEN)
diff --git a/benches/arithmetic.rs b/benches/arithmetic.rs
index eae9a2f..f89ee67 100644
--- a/benches/arithmetic.rs
+++ b/benches/arithmetic.rs
@@ -1,4 +1,7 @@
-use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
+//! `Dec` against `rust_decimal`, `fastnum` and native `f64`, over the
+//! operations a trading system runs on a hot path.
+
+use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use fastnum::D128;
use fastnum::decimal::Context;
use rust_decimal::Decimal;
@@ -56,6 +59,7 @@ fn columns() -> (Column, Column) {
fn bench_add(c: &mut Criterion) {
let (prices, _) = columns();
let mut group = c.benchmark_group("add");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -99,8 +103,8 @@ fn bench_add(c: &mut Criterion) {
fn bench_mul_div(c: &mut Criterion) {
let (prices, sizes) = columns();
- // Dec has no multiply or divide yet; these arms are the target to beat
let mut group = c.benchmark_group("mul");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
for (price, size) in black_box(&prices.floats).iter().zip(&sizes.floats) {
@@ -132,6 +136,7 @@ fn bench_mul_div(c: &mut Criterion) {
group.finish();
let mut group = c.benchmark_group("div");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
for (price, size) in black_box(&prices.floats).iter().zip(&sizes.floats) {
@@ -139,6 +144,13 @@ fn bench_mul_div(c: &mut Criterion) {
}
})
});
+ group.bench_function(BenchmarkId::new("Dec", COUNT), |b| {
+ b.iter(|| {
+ for (price, size) in black_box(&prices.decs).iter().zip(&sizes.decs) {
+ black_box(*price / *size);
+ }
+ })
+ });
group.bench_function(BenchmarkId::new("rust_decimal", COUNT), |b| {
b.iter(|| {
for (price, size) in black_box(&prices.decimals).iter().zip(&sizes.decimals) {
@@ -159,6 +171,7 @@ fn bench_mul_div(c: &mut Criterion) {
fn bench_cmp(c: &mut Criterion) {
let (prices, sizes) = columns();
let mut group = c.benchmark_group("cmp");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -202,6 +215,7 @@ fn bench_cmp(c: &mut Criterion) {
fn bench_f64_boundary(c: &mut Criterion) {
let (prices, _) = columns();
let mut group = c.benchmark_group("to_f64");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("Dec", COUNT), |b| {
b.iter(|| {
@@ -227,6 +241,7 @@ fn bench_f64_boundary(c: &mut Criterion) {
group.finish();
let mut group = c.benchmark_group("from_f64");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("Dec", COUNT), |b| {
b.iter(|| {
for value in black_box(&prices.floats) {
@@ -254,6 +269,7 @@ fn bench_f64_boundary(c: &mut Criterion) {
fn bench_parse(c: &mut Criterion) {
let (prices, _) = columns();
let mut group = c.benchmark_group("parse");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -297,6 +313,7 @@ fn bench_parse(c: &mut Criterion) {
fn bench_format(c: &mut Criterion) {
let (prices, _) = columns();
let mut group = c.benchmark_group("format");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -337,12 +354,14 @@ fn bench_format(c: &mut Criterion) {
group.finish();
}
+// plots stay on: criterion renders the per-parameter line charts that the
+// digit-width groups below exist to produce, and the violin comparisons for
+// the fixed-width ones
fn config() -> Criterion {
Criterion::default()
.warm_up_time(Duration::from_millis(200))
.measurement_time(Duration::from_millis(600))
.sample_size(20)
- .without_plots()
}
fn bench_round_dp(c: &mut Criterion) {
@@ -350,6 +369,7 @@ fn bench_round_dp(c: &mut Criterion) {
// scale-carrying type; the prices column has exactly 2 and would be a no-op
let (_, prices) = columns();
let mut group = c.benchmark_group("round_dp");
+ group.throughput(Throughput::Elements(COUNT as u64));
// f64 has no scale, so the analogue is a scale-round-unscale round trip
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
@@ -391,6 +411,7 @@ fn bench_round_to_step(c: &mut Criterion) {
let step_decimal = Decimal::from_str("0.01").unwrap();
let step_fastnum = D128::from_str("0.01", Context::default()).unwrap();
let mut group = c.benchmark_group("round_to_step");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -426,6 +447,7 @@ fn bench_round_to_step(c: &mut Criterion) {
fn bench_floor(c: &mut Criterion) {
let (prices, _) = columns();
let mut group = c.benchmark_group("floor");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -461,6 +483,7 @@ fn bench_floor(c: &mut Criterion) {
fn bench_ceil(c: &mut Criterion) {
let (prices, _) = columns();
let mut group = c.benchmark_group("ceil");
+ group.throughput(Throughput::Elements(COUNT as u64));
group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
b.iter(|| {
@@ -493,6 +516,197 @@ fn bench_ceil(c: &mut Criterion) {
group.finish();
}
+// the digit widths the parse groups sweep. The parser accumulates in a u64 and
+// promotes to u128 once a mantissa passes 19 digits, so the interesting shape
+// is either side of that crossing rather than a handful of round numbers.
+const WIDTHS: [u32; 8] = [1, 4, 8, 12, 18, 19, 22, 26];
+
+// `digits` significant digits with the point in the middle, which is the shape
+// a price or a size arrives in rather than a bare integer
+fn digit_samples(digits: u32) -> Vec {
+ let mut state = 0x2545_F491_4F6C_DD1D_u64;
+ let integer_digits = digits.div_ceil(2);
+ let fraction_digits = digits - integer_digits;
+ (0..COUNT)
+ .map(|_| {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ let mut text = String::with_capacity(digits as usize + 1);
+ let mut next = state;
+ for position in 0..digits {
+ if position == integer_digits && fraction_digits > 0 {
+ text.push('.');
+ }
+ next = next.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
+ let draw = (next >> 60) as u8;
+ // never a leading zero, so every sample really carries `digits`
+ let digit = match position {
+ 0 => draw % 9 + 1,
+ _ => draw % 10,
+ };
+ text.push((b'0' + digit) as char);
+ }
+ text
+ })
+ .collect()
+}
+
+fn bench_parse_digits(c: &mut Criterion) {
+ let mut group = c.benchmark_group("parse_digits");
+ group.throughput(Throughput::Elements(COUNT as u64));
+
+ for digits in WIDTHS {
+ let texts = digit_samples(digits);
+ group.bench_function(BenchmarkId::new("f64", digits), |b| {
+ b.iter(|| {
+ let mut total = 0.0;
+ for text in black_box(&texts) {
+ total += f64::from_str(text).unwrap_or_default();
+ }
+ total
+ })
+ });
+ group.bench_function(BenchmarkId::new("Dec", digits), |b| {
+ b.iter(|| {
+ let mut total = Dec::ZERO;
+ for text in black_box(&texts) {
+ total += Dec::from_str(text).unwrap_or_default();
+ }
+ total
+ })
+ });
+ group.bench_function(BenchmarkId::new("rust_decimal", digits), |b| {
+ b.iter(|| {
+ let mut total = Decimal::ZERO;
+ for text in black_box(&texts) {
+ total += Decimal::from_str(text).unwrap_or_default();
+ }
+ total
+ })
+ });
+ group.bench_function(BenchmarkId::new("fastnum", digits), |b| {
+ b.iter(|| {
+ let mut total = D128::ZERO;
+ for text in black_box(&texts) {
+ total += D128::from_str(text, Context::default()).unwrap_or(D128::ZERO);
+ }
+ total
+ })
+ });
+ }
+ group.finish();
+}
+
+fn bench_format_digits(c: &mut Criterion) {
+ let mut group = c.benchmark_group("format_digits");
+ group.throughput(Throughput::Elements(COUNT as u64));
+
+ for digits in WIDTHS {
+ let texts = digit_samples(digits);
+ let decs: Vec = texts.iter().filter_map(|t| t.parse().ok()).collect();
+ let decimals: Vec = texts.iter().filter_map(|t| t.parse().ok()).collect();
+ let fastnums: Vec = texts
+ .iter()
+ .filter_map(|t| D128::from_str(t, Context::default()).ok())
+ .collect();
+
+ group.bench_function(BenchmarkId::new("Dec", digits), |b| {
+ b.iter(|| {
+ let mut length = 0;
+ for value in black_box(&decs) {
+ length += value.to_string().len();
+ }
+ length
+ })
+ });
+ group.bench_function(BenchmarkId::new("rust_decimal", digits), |b| {
+ b.iter(|| {
+ let mut length = 0;
+ for value in black_box(&decimals) {
+ length += value.to_string().len();
+ }
+ length
+ })
+ });
+ group.bench_function(BenchmarkId::new("fastnum", digits), |b| {
+ b.iter(|| {
+ let mut length = 0;
+ for value in black_box(&fastnums) {
+ length += value.to_string().len();
+ }
+ length
+ })
+ });
+ }
+ group.finish();
+}
+
+// Ingesting a book or a trade feed means filling a vector, not touching one
+// value: this pays for the allocation and for every byte the element occupies,
+// which is where a 16-byte decimal separates from a wider one.
+fn bench_collect(c: &mut Criterion) {
+ let (prices, _) = columns();
+ let mut group = c.benchmark_group("collect");
+ group.throughput(Throughput::Elements(COUNT as u64));
+
+ group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
+ b.iter(|| {
+ black_box(&prices.texts)
+ .iter()
+ .filter_map(|text| f64::from_str(text).ok())
+ .collect::>()
+ })
+ });
+ group.bench_function(BenchmarkId::new("Dec", COUNT), |b| {
+ b.iter(|| {
+ black_box(&prices.texts)
+ .iter()
+ .filter_map(|text| Dec::from_str(text).ok())
+ .collect::>()
+ })
+ });
+ group.bench_function(BenchmarkId::new("rust_decimal", COUNT), |b| {
+ b.iter(|| {
+ black_box(&prices.texts)
+ .iter()
+ .filter_map(|text| Decimal::from_str(text).ok())
+ .collect::>()
+ })
+ });
+ group.bench_function(BenchmarkId::new("fastnum", COUNT), |b| {
+ b.iter(|| {
+ black_box(&prices.texts)
+ .iter()
+ .filter_map(|text| D128::from_str(text, Context::default()).ok())
+ .collect::>()
+ })
+ });
+ group.finish();
+}
+
+// the same vector without the parsing, so the allocation and the copy are the
+// whole measurement and the element width is the only variable left
+fn bench_clone(c: &mut Criterion) {
+ let (prices, _) = columns();
+ let mut group = c.benchmark_group("clone");
+ group.throughput(Throughput::Elements(COUNT as u64));
+
+ group.bench_function(BenchmarkId::new("f64", COUNT), |b| {
+ b.iter(|| black_box(&prices.floats).clone())
+ });
+ group.bench_function(BenchmarkId::new("Dec", COUNT), |b| {
+ b.iter(|| black_box(&prices.decs).clone())
+ });
+ group.bench_function(BenchmarkId::new("rust_decimal", COUNT), |b| {
+ b.iter(|| black_box(&prices.decimals).clone())
+ });
+ group.bench_function(BenchmarkId::new("fastnum", COUNT), |b| {
+ b.iter(|| black_box(&prices.fastnums).clone())
+ });
+ group.finish();
+}
+
criterion_group! {
name = benches;
config = config();
@@ -507,5 +721,9 @@ criterion_group! {
bench_f64_boundary,
bench_parse,
bench_format,
+ bench_parse_digits,
+ bench_format_digits,
+ bench_collect,
+ bench_clone,
}
criterion_main!(benches);
diff --git a/docs/bench-data.json b/docs/bench-data.json
new file mode 100644
index 0000000..09a88d9
--- /dev/null
+++ b/docs/bench-data.json
@@ -0,0 +1,1003 @@
+{
+ "machine": {
+ "cpu": "Intel(R) Core(TM) Ultra 9 285H",
+ "arch": "x86_64",
+ "os": "Linux-6.6.114.1-microsoft-standard-WSL2-x86_64-with-glibc2.43",
+ "rustc": "rustc 1.98.0 (88d9e12ae 2026-08-18)",
+ "commit": "c9b97e2 (modified)",
+ "measured": "2026-09-04 08:41 UTC"
+ },
+ "count": 1024,
+ "results": [
+ {
+ "operation": "add",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.810931489284759,
+ "low": 0.8055914851756955,
+ "high": 0.8300807558565154
+ },
+ {
+ "operation": "add",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.37271501819053743,
+ "low": 0.3698306819676684,
+ "high": 0.3807562870302071
+ },
+ {
+ "operation": "add",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 7.780702312835345,
+ "low": 7.7632018871038735,
+ "high": 7.822690681851526
+ },
+ {
+ "operation": "add",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 1.7402030337101804,
+ "low": 1.7110731286663714,
+ "high": 1.7760917771692564
+ },
+ {
+ "operation": "ceil",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 3.918198122487862,
+ "low": 3.9114785501358047,
+ "high": 4.010024193706348
+ },
+ {
+ "operation": "ceil",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 1.0120246947164793,
+ "low": 1.0040728260092293,
+ "high": 1.0385637119938798
+ },
+ {
+ "operation": "ceil",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 14.582537698475456,
+ "low": 14.423152038067002,
+ "high": 14.763742683339784
+ },
+ {
+ "operation": "ceil",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 7.245164046690776,
+ "low": 7.234582793846559,
+ "high": 7.3038144153304465
+ },
+ {
+ "operation": "clone",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.11521995154032372,
+ "low": 0.11489785694744631,
+ "high": 0.11748795244754529
+ },
+ {
+ "operation": "clone",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.060215934352269074,
+ "low": 0.05982969254153426,
+ "high": 0.0603766540570153
+ },
+ {
+ "operation": "clone",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.25332156543123896,
+ "low": 0.24958638750609266,
+ "high": 0.25790013632059877
+ },
+ {
+ "operation": "clone",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.10552094749697086,
+ "low": 0.10468864045724241,
+ "high": 0.10658573961928139
+ },
+ {
+ "operation": "cmp",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.2987488442137983,
+ "low": 0.2974095203216811,
+ "high": 0.3057670961355127
+ },
+ {
+ "operation": "cmp",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.10013855563163798,
+ "low": 0.09963888215936947,
+ "high": 0.10099507458814203
+ },
+ {
+ "operation": "cmp",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 4.153628862918875,
+ "low": 4.142806054838617,
+ "high": 4.254163044425981
+ },
+ {
+ "operation": "cmp",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 3.2055426499951887,
+ "low": 3.19113166227627,
+ "high": 3.258905596264454
+ },
+ {
+ "operation": "collect",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 8.040171768748618,
+ "low": 7.985867079664925,
+ "high": 8.127645108721943
+ },
+ {
+ "operation": "collect",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 7.269778182030564,
+ "low": 7.2506649986944804,
+ "high": 7.280063134773203
+ },
+ {
+ "operation": "collect",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 9.386321972544387,
+ "low": 9.354481732627875,
+ "high": 9.597874130825815
+ },
+ {
+ "operation": "collect",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 10.111105665963352,
+ "low": 10.084601765653513,
+ "high": 10.241354646971129
+ },
+ {
+ "operation": "div",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 15.702136888849747,
+ "low": 15.641802487428162,
+ "high": 16.023586697048614
+ },
+ {
+ "operation": "div",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.7220503374598748,
+ "low": 0.7207157617118406,
+ "high": 0.7316663982949496
+ },
+ {
+ "operation": "div",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 79.54098962560877,
+ "low": 78.96205290710034,
+ "high": 80.1256854717548
+ },
+ {
+ "operation": "div",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 26.802558228046983,
+ "low": 25.221693883236757,
+ "high": 27.81539057759405
+ },
+ {
+ "operation": "floor",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 3.5278732108591795,
+ "low": 3.5048262605507157,
+ "high": 3.5779445692622236
+ },
+ {
+ "operation": "floor",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.9977045519071115,
+ "low": 0.9910332622975178,
+ "high": 1.0062922911934784
+ },
+ {
+ "operation": "floor",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 9.935148564091435,
+ "low": 9.897431098090278,
+ "high": 9.996480517818409
+ },
+ {
+ "operation": "floor",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 6.655919295090895,
+ "low": 6.59768817608173,
+ "high": 6.829632232631493
+ },
+ {
+ "operation": "format",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 23.35250476327095,
+ "low": 23.243136338056146,
+ "high": 23.703377771262105
+ },
+ {
+ "operation": "format",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 57.28421518753986,
+ "low": 57.0335842833227,
+ "high": 58.10415946977749
+ },
+ {
+ "operation": "format",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 49.47404039740896,
+ "low": 48.98829675099206,
+ "high": 50.33975109182629
+ },
+ {
+ "operation": "format",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 29.250725054119513,
+ "low": 28.919130204888667,
+ "high": 29.41445978468548
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 12.943788287817718,
+ "low": 12.856543714413837,
+ "high": 13.19890356561019
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 28.01508855495323,
+ "low": 27.77991666768871,
+ "high": 28.08628596091757
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 32.74106253829657,
+ "low": 32.47444422104779,
+ "high": 33.26782016667845
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 33.600391801581324,
+ "low": 33.51714354412844,
+ "high": 34.33594764801393
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 37.43303373235014,
+ "low": 37.088250695764124,
+ "high": 37.868135218086394
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 42.00225782998666,
+ "low": 41.72720263905502,
+ "high": 42.610525050307764
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 21.528623849629994,
+ "low": 21.123290076622595,
+ "high": 21.79995355068109
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "Dec",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 24.593766732379283,
+ "low": 24.521047834382728,
+ "high": 24.895815192184514
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 39.88804004810474,
+ "low": 39.66414612144864,
+ "high": 40.34573135211518
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 70.06654449462891,
+ "low": 68.0629892985026,
+ "high": 70.93485005696616
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 88.56152309841579,
+ "low": 88.17593383789062,
+ "high": 89.5176297472669
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 93.07453002929688,
+ "low": 92.82948296440972,
+ "high": 94.09950033690268
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 88.68450100806452,
+ "low": 88.3917961999568,
+ "high": 89.31261588241648
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 104.49511669067333,
+ "low": 104.15089586046007,
+ "high": 106.28910469714506
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 54.36680620566182,
+ "low": 53.99805565679936,
+ "high": 55.743326859361005
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "fastnum",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 54.10226479717518,
+ "low": 53.70234987241196,
+ "high": 54.67748353247549
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 19.67364827995626,
+ "low": 19.605965430104835,
+ "high": 19.7873042179988
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 45.0991019722465,
+ "low": 44.91319735440341,
+ "high": 45.59860325451203
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 59.474625461785365,
+ "low": 59.0171440521612,
+ "high": 59.73916469840117
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 62.07775978150407,
+ "low": 61.67533901803862,
+ "high": 62.72481320722913
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 68.76258680555554,
+ "low": 68.71132152660473,
+ "high": 70.31830563904897
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 79.97924889238365,
+ "low": 79.18874627976192,
+ "high": 83.29683441264862
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 23.32036683505639,
+ "low": 23.262967529296873,
+ "high": 23.711405164071774
+ },
+ {
+ "operation": "format_digits",
+ "implementation": "rust_decimal",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 34.743904150626506,
+ "low": 34.67215979969171,
+ "high": 35.911243909550535
+ },
+ {
+ "operation": "from_f64",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 8.894955053084935,
+ "low": 8.77884041549813,
+ "high": 8.983404488848825
+ },
+ {
+ "operation": "from_f64",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 75.9350858121305,
+ "low": 75.7207168293594,
+ "high": 76.66020394366406
+ },
+ {
+ "operation": "from_f64",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 81.5115931919643,
+ "low": 80.52812343358396,
+ "high": 82.08429536365327
+ },
+ {
+ "operation": "mul",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 3.1318489108919563,
+ "low": 3.115460042635248,
+ "high": 3.1374292279572766
+ },
+ {
+ "operation": "mul",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.14815806540784837,
+ "low": 0.14642142635080008,
+ "high": 0.1501869251724962
+ },
+ {
+ "operation": "mul",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 4.217815574900129,
+ "low": 4.202824737782807,
+ "high": 4.275198412339261
+ },
+ {
+ "operation": "mul",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 6.035183604691877,
+ "low": 6.003190474076705,
+ "high": 6.06785570673582
+ },
+ {
+ "operation": "parse",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 7.3770380783536815,
+ "low": 7.288148875903058,
+ "high": 7.4034738327419625
+ },
+ {
+ "operation": "parse",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 6.572294920974278,
+ "low": 6.501089510139048,
+ "high": 6.628600990580304
+ },
+ {
+ "operation": "parse",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 15.123069762005308,
+ "low": 15.062186884149774,
+ "high": 15.239417449676239
+ },
+ {
+ "operation": "parse",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 10.809349362574892,
+ "low": 10.750242888621795,
+ "high": 10.878804561346598
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 3.973704460750646,
+ "low": 3.911519083544876,
+ "high": 4.0171548688722645
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 10.187992955317728,
+ "low": 10.099189616490474,
+ "high": 10.278018293990847
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 14.77119568941886,
+ "low": 14.674163754111841,
+ "high": 14.986196546052632
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 15.293298587131677,
+ "low": 15.201797063838052,
+ "high": 15.454502848432373
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 20.616412246436404,
+ "low": 20.4654126484064,
+ "high": 20.81031558388158
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 27.50015888174831,
+ "low": 27.339806121529882,
+ "high": 27.703676037693597
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 5.9238991797119755,
+ "low": 5.875871267932201,
+ "high": 5.951786152800622
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "Dec",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 7.958620280888729,
+ "low": 7.923654202129732,
+ "high": 8.052499430808947
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 3.639145966880342,
+ "low": 3.6147079897593875,
+ "high": 3.7031575630658176
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 9.609196561203156,
+ "low": 9.57904044137874,
+ "high": 9.774511804715008
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 10.225904360428547,
+ "low": 10.172245700362277,
+ "high": 10.44792037746689
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 10.665312082016051,
+ "low": 10.62351669981587,
+ "high": 10.74075989482004
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 28.954348052244416,
+ "low": 28.642628777887403,
+ "high": 29.151331685770483
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 29.94767274247839,
+ "low": 29.84919655042693,
+ "high": 30.12031142962202
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 5.462383404745953,
+ "low": 5.411139236527738,
+ "high": 5.5637494703317145
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "f64",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 7.421092393663194,
+ "low": 7.326332646122685,
+ "high": 7.46765391031901
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 14.19176366196066,
+ "low": 14.124224938291404,
+ "high": 14.3715690875282
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 16.30788741417149,
+ "low": 16.28752830098295,
+ "high": 16.371107149953982
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 18.715001103275306,
+ "low": 18.492278001345777,
+ "high": 18.986518467299355
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 22.013791786245747,
+ "low": 21.845883500180378,
+ "high": 22.08522445436508
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 26.157045894848075,
+ "low": 26.081553878708416,
+ "high": 26.2601584508484
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 27.690372671474737,
+ "low": 27.40867615081821,
+ "high": 27.929853084264742
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 14.790282905028999,
+ "low": 14.701262767180737,
+ "high": 14.83707616679002
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "fastnum",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 15.591594577640013,
+ "low": 15.512218724980855,
+ "high": 15.668203075029696
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "1",
+ "count": 1024,
+ "nanos": 9.613642394718866,
+ "low": 9.549108185165231,
+ "high": 9.891923971737132
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "12",
+ "count": 1024,
+ "nanos": 13.846870422363281,
+ "low": 13.747430284288194,
+ "high": 13.993458147321428
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "18",
+ "count": 1024,
+ "nanos": 17.259494216248605,
+ "low": 17.181432796429984,
+ "high": 17.387575256458028
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "19",
+ "count": 1024,
+ "nanos": 20.730852014878216,
+ "low": 20.484554566455486,
+ "high": 20.988148243071265
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "22",
+ "count": 1024,
+ "nanos": 26.881347127152672,
+ "low": 26.641307476766748,
+ "high": 27.073793099596607
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "26",
+ "count": 1024,
+ "nanos": 30.696209038628474,
+ "low": 30.546802300347224,
+ "high": 31.023532202449843
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "4",
+ "count": 1024,
+ "nanos": 10.522839986491757,
+ "low": 10.442926385566189,
+ "high": 10.695233662923176
+ },
+ {
+ "operation": "parse_digits",
+ "implementation": "rust_decimal",
+ "parameter": "8",
+ "count": 1024,
+ "nanos": 12.79919238231476,
+ "low": 12.72288467257414,
+ "high": 12.877958645201916
+ },
+ {
+ "operation": "round_dp",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 4.131280469137525,
+ "low": 4.099372020384081,
+ "high": 4.204144929584704
+ },
+ {
+ "operation": "round_dp",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 0.9297816489198761,
+ "low": 0.925043445836089,
+ "high": 0.9336740678790596
+ },
+ {
+ "operation": "round_dp",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 7.630197189376877,
+ "low": 7.5479736328125,
+ "high": 7.790826026899731
+ },
+ {
+ "operation": "round_dp",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 13.654069541381723,
+ "low": 13.531894676284034,
+ "high": 13.821047339108912
+ },
+ {
+ "operation": "round_to_step",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 3.319798595000426,
+ "low": 3.2936065031225166,
+ "high": 3.3791715610935738
+ },
+ {
+ "operation": "round_to_step",
+ "implementation": "f64",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 1.0360349246433804,
+ "low": 1.029830833140955,
+ "high": 1.0477665290630682
+ },
+ {
+ "operation": "round_to_step",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 19.796653878348216,
+ "low": 19.6109121681156,
+ "high": 20.098444441253065
+ },
+ {
+ "operation": "round_to_step",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 24.368695122372788,
+ "low": 24.223725252597582,
+ "high": 24.515780855172544
+ },
+ {
+ "operation": "to_f64",
+ "implementation": "Dec",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 1.3140923820450638,
+ "low": 1.3079312607441063,
+ "high": 1.3226729298475464
+ },
+ {
+ "operation": "to_f64",
+ "implementation": "fastnum",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 1.8837632883435287,
+ "low": 1.8811319197974377,
+ "high": 1.8984592480568414
+ },
+ {
+ "operation": "to_f64",
+ "implementation": "rust_decimal",
+ "parameter": "1024",
+ "count": 1024,
+ "nanos": 16.802671514954227,
+ "low": 16.77801916221034,
+ "high": 16.944949265030473
+ }
+ ]
+}
diff --git a/docs/design.md b/docs/design.md
index 80935e8..fce8836 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -195,6 +195,34 @@ would otherwise reconstruct into the finite range and read as
The general rule for new operations: **look for an overflow or exactness check
that a `2^127` magnitude already fails, and let it do the work.**
+## Division reaches 187 bits in u128 steps
+
+The raw quotient is `a * 10^18 / b`, a product needing 187 bits that no `u128`
+holds. Rather than carry a 256-bit intermediate, every path divides first and
+scales the remainder afterwards, which keeps each step inside a `u128`:
+
+- **The divisor carries at most 9 decimals**, so `10^9` divides its raw
+ exactly. Cancelling that against the `10^18` leaves `a * 10^9 / b9`, done as
+ a quotient and a scaled remainder — two divisions and no wide arithmetic.
+ Every price, size and tick takes this path.
+- **Finer than that**, `a` is reduced below `b` first, then the remaining
+ `10^18` is applied to the remainder in two `10^9` steps. The remainder is
+ below `b`, so each step has room. This needs `|b|` at or below `3.4e11`.
+- **A divisor above `3.4e11` with more than 9 decimals** has room for neither
+ step, so the scaling walks the 60 bits of `10^18` one at a time, keeping a
+ window below `b`. Doubling that window or adding the remainder to it stays
+ under twice `b`, so the 187-bit product is never formed. It is slow and
+ effectively unreachable from market data, but it keeps the operation exact
+ over the whole range rather than returning NaN for inputs that have an
+ answer.
+
+The three paths overlap on small divisors, where the tests run them against
+each other: a wrong result would have to be produced identically by three
+independent routines.
+
+Dividing by zero yields NaN. There is no infinity in the type, so it is the
+same class of fault as an overflow and collapses to the same state.
+
## Prefer a branch to a select
Overflow handling should be written so the compiler emits a predicted branch
@@ -214,4 +242,3 @@ operation, moving `Dec` past `rust_decimal` on that benchmark.
cannot read back.
- `checked_*` returns `None` both for an overflowing result and for a NaN
operand, conflating "too big" with "already invalid".
-- `Dec` has no division yet.
diff --git a/readme.md b/readme.md
index 26c41da..8f4e668 100644
--- a/readme.md
+++ b/readme.md
@@ -1,3 +1,101 @@
# Troy
[](https://github.com/quantmind/troy/actions/workflows/build.yml)
+
+Superfast primitives and data structures for high frequency trading, in safe
+Rust — `unsafe` is forbidden crate-wide.
+
+Two things live here: **`Dec`**, a fixed-scale decimal for exact arithmetic,
+and **`OrderBook`**, a level 2 book built on it.
+
+```toml
+[dependencies]
+troy = "0.1"
+```
+
+## `Dec`
+
+An `i128` carrying 18 decimal places. Arithmetic is exact within roughly
+±1.7e20, and anything that leaves that range becomes `Dec::NAN`.
+
+```rust
+use troy::{Dec, dec};
+
+let notional = dec!(104_237.25) * dec!(0.00135);
+assert_eq!(notional, dec!(140.72028750));
+
+// overflow poisons rather than clamping or panicking
+assert!((Dec::MAX + Dec::ONE).is_nan());
+```
+
+NaN propagates through every later operation, so a fault reaches the boundary
+where `is_finite` is checked instead of being clamped to a plausible number or
+raised as a panic on a hot path. The ordering stays total, so `Dec` works as a
+map key and in a sorted book. `checked_*` and `saturating_*` are there for
+callers who would rather decide on the spot.
+
+## `OrderBook`
+
+Each side keeps its levels best first — descending for bids, ascending for
+asks — so the best price, the n-th level and the depth cut are index
+arithmetic rather than searches.
+
+```rust
+use troy::{OrderBook, dec};
+
+let mut book = OrderBook::new(Some(10));
+book.bids.set_price_amount(dec!(104_237.25), dec!(3));
+book.asks.set_price_amount(dec!(104_237.30), dec!(1));
+
+assert_eq!(book.mid_price(), Some(dec!(104_237.275)));
+assert_eq!(book.spread(), Some(dec!(0.05)));
+```
+
+A level enters only if `PriceAmount::is_valid` accepts it. A non-finite price
+would sort against every other level rather than among them, and a non-finite
+amount would poison the side's running total for good, since the subtraction
+that removes the level cannot take a NaN back out again.
+
+## Benchmarks
+
+Nanoseconds per operation against `rust_decimal`, `fastnum` and native `f64`,
+measured on an Intel Core Ultra 9 285H. `f64` is the inexact reference, not a
+competitor; **bold** marks the fastest decimal.
+
+| operation | f64 | `Dec` | `rust_decimal` | `fastnum` |
+|---|---|---|---|---|
+| cmp | 0.10 | **0.30** | 3.21 | 4.15 |
+| add | 0.37 | **0.81** | 1.74 | 7.78 |
+| mul | 0.15 | **3.13** | 6.04 | 4.22 |
+| div | 0.72 | **15.70** | 26.80 | 79.54 |
+| round to step | 1.04 | **3.32** | 24.37 | 19.80 |
+| parse | 6.57 | **7.38** | 10.81 | 15.12 |
+| format | 57.28 | **23.35** | 29.25 | 49.47 |
+| to f64 | — | **1.31** | 16.80 | 1.88 |
+| from f64 | — | **8.89** | 81.51 | 75.94 |
+
+Run them yourself with `make bench`, or `make bench-save` to record a snapshot
+and `make bench-page` to render it. Numbers published from this repo come from
+a committed snapshot rather than a CI run, because a shared runner varies by
+more than the differences being measured.
+
+## Features
+
+| feature | what it adds |
+|---|---|
+| `serde` | `Serialize`/`Deserialize` for `Dec`, as a decimal string |
+| `rust_decimal` | conversions to and from `rust_decimal::Decimal` |
+| `testing` | `OrderBookBuilder` and `RandomWalk` for building and simulating books |
+
+All are off by default.
+
+## Documentation
+
+The `design` module covers the reasoning behind the representation, the NaN
+state, the ordering, and how multiplication and division reach 187 bits in
+`u128` steps. `memory_layout` compares the layout with other decimal crates,
+and `release_notes` carries one section per tagged release.
+
+## License
+
+MIT.
diff --git a/src/dec/core.rs b/src/dec/core.rs
index 95e2c12..e3b479d 100644
--- a/src/dec/core.rs
+++ b/src/dec/core.rs
@@ -1,3 +1,4 @@
+use super::div::div_raw;
use super::mul::{div_exact_1e9, mul_raw};
use super::parse::parse_bytes;
@@ -83,8 +84,11 @@ impl Dec {
/// Number of decimal places every `Dec` carries.
pub const SCALE: u32 = 18;
+ /// Zero.
pub const ZERO: Self = Self(0);
+ /// One.
pub const ONE: Self = Self(ONE_RAW);
+ /// Negative one.
pub const NEG_ONE: Self = Self(-ONE_RAW);
/// The smallest finite value, `-Dec::MAX`. The finite range is symmetric,
@@ -145,21 +149,26 @@ impl Dec {
}
#[inline(always)]
+ /// The underlying scaled integer, the inverse of [`Dec::from_raw`].
pub const fn into_raw(self) -> i128 {
self.0
}
#[inline(always)]
+ /// An exact whole number. Every `i64` fits the finite range.
pub const fn from_int(value: i64) -> Self {
Self(value as i128 * ONE_RAW)
}
#[inline(always)]
+ /// An exact whole number. Every `u64` fits the finite range.
pub const fn from_u64(value: u64) -> Self {
Self(value as i128 * ONE_RAW)
}
#[inline(always)]
+ /// Parse in a `const` context, `None` on malformed or out-of-range text.
+ /// The [`dec!`](crate::dec) macro wraps this.
pub const fn parse_const(value: &str) -> Option {
match parse_bytes(value.as_bytes()) {
Ok(raw) => Some(Self(raw)),
@@ -322,6 +331,30 @@ impl Dec {
mul_raw(self.0, rhs.0).map(Self)
}
+ /// The quotient, or `None` on division by zero, if it leaves the finite
+ /// range, or if either side is [`Dec::NAN`].
+ #[inline(always)]
+ pub fn checked_div(self, rhs: Self) -> Option {
+ div_raw(self.0, rhs.0).map(Self)
+ }
+
+ /// The quotient, clamped to [`Dec::MIN`] or [`Dec::MAX`] on overflow.
+ /// Division by zero has no side to clamp towards and still gives
+ /// [`Dec::NAN`], as does a NaN operand.
+ #[inline(always)]
+ pub fn saturating_div(self, rhs: Self) -> Self {
+ if self.is_nan() || rhs.is_nan() || rhs.is_zero() {
+ return Self::NAN;
+ }
+ match self.checked_div(rhs) {
+ Some(value) => value,
+ None => match (self.0 < 0) != (rhs.0 < 0) {
+ true => Self::MIN,
+ false => Self::MAX,
+ },
+ }
+ }
+
/// The product, clamped to [`Dec::MIN`] or [`Dec::MAX`] on overflow.
/// A [`Dec::NAN`] operand still gives NaN: there is no sign to clamp
/// towards, and clamping an unknown would invent one.
@@ -427,77 +460,11 @@ mod tests {
#![allow(clippy::unwrap_used)]
use crate::Dec;
- use crate::ParseDecError;
use crate::dec;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
- #[test]
- fn test_parse_and_display_round_trip() {
- for text in [
- "0",
- "1",
- "-1",
- "0.5",
- "-0.5",
- "100000.5",
- "0.000000000000000001",
- "-12345.6789",
- ] {
- let value = Dec::from_str(text).unwrap();
- assert_eq!(value.to_string(), text, "round trip of {text}");
- }
- }
-
- #[test]
- fn test_parse_trims_and_rounds_excess_precision() {
- assert_eq!(
- Dec::from_str("1.50").unwrap(),
- Dec::from_str("1.5").unwrap()
- );
- assert_eq!(
- Dec::from_str("0.0000000000000000005").unwrap(),
- Dec::EPSILON,
- "half rounds away from zero at the scale boundary"
- );
- assert_eq!(
- Dec::from_str("0.0000000000000000015").unwrap(),
- Dec::from_raw(2)
- );
- }
-
- #[test]
- fn test_parse_exponent_form() {
- assert_eq!(
- Dec::from_str("1e-8").unwrap(),
- Dec::from_raw(10_000_000_000)
- );
- assert_eq!(
- Dec::from_str("1.5E3").unwrap(),
- Dec::from_str("1500").unwrap()
- );
- assert_eq!(
- Dec::from_str("-2.5e-1").unwrap(),
- Dec::from_str("-0.25").unwrap()
- );
- }
-
- #[test]
- fn test_parse_rejects_malformed_input() {
- assert_eq!(Dec::from_str(""), Err(ParseDecError::Empty));
- assert_eq!(Dec::from_str("1.2.3"), Err(ParseDecError::InvalidDigit));
- assert_eq!(Dec::from_str("abc"), Err(ParseDecError::InvalidDigit));
- }
-
- #[test]
- fn test_macro_is_const() {
- const HALF: Dec = dec!(0.5);
- const NEGATIVE: Dec = dec!(-1.25);
- assert_eq!(HALF, Dec::from_raw(500_000_000_000_000_000));
- assert_eq!(NEGATIVE.to_string(), "-1.25");
- }
-
#[test]
fn test_representation_is_canonical() {
let one = Dec::from_str("1.0").unwrap();
@@ -735,13 +702,6 @@ mod predicates {
// the magnitude of MIN is not representable, so it saturates
assert_eq!(Dec::MIN.abs(), Dec::MAX);
}
-
- #[test]
- fn test_debug_matches_display() {
- for value in [dec!(1.5), dec!(-0.25), Dec::ZERO, Dec::MIN, Dec::MAX] {
- assert_eq!(format!("{value:?}"), format!("{value}"));
- }
- }
}
#[cfg(test)]
diff --git a/src/dec/div.rs b/src/dec/div.rs
new file mode 100644
index 0000000..3d2952f
--- /dev/null
+++ b/src/dec/div.rs
@@ -0,0 +1,301 @@
+use super::core::MAX_RAW;
+
+const CHUNK: u128 = 1_000_000_000;
+const ONE_U: u128 = 1_000_000_000_000_000_000;
+
+// r * CHUNK stays inside a u128 only while r is at or below this
+const CHUNK_LIMIT: u128 = u128::MAX / CHUNK;
+
+// a / b at scale 10^18, so the raw quotient is a * 10^18 / b. That product
+// needs 187 bits, which no u128 holds, so all three paths below reach it by
+// steps that each stay inside one: divide first, then scale the remainder.
+//
+// None on division by zero, on a NaN operand, and when the quotient leaves the
+// finite range, which the operators turn into NaN.
+#[inline(always)]
+pub(crate) fn div_raw(a: i128, b: i128) -> Option {
+ if b == 0 {
+ return None;
+ }
+ let negative = (a < 0) != (b < 0);
+ let (a, b) = (a.unsigned_abs(), b.unsigned_abs());
+ // a NaN operand has magnitude 2^127, one past the finite range, and
+ // nothing finite reaches it, so one bound recognises either side
+ if (a | b) > MAX_RAW as u128 {
+ return None;
+ }
+
+ // a divisor of at most 9 decimal places divides by 10^9 exactly, which
+ // cancels half the scaling before it is ever applied; every price, size
+ // and tick takes this path
+ let magnitude = match b.is_multiple_of(CHUNK) {
+ true => scale_by(a, b / CHUNK, CHUNK)?,
+ false => wide(a, b)?,
+ };
+ signed(negative, magnitude)
+}
+
+// round(x * factor / divisor), where divisor * factor must not exceed a u128.
+// Splitting x into quotient and remainder first keeps the scaling on the
+// remainder, which is below the divisor and so has room for the factor.
+#[inline(always)]
+fn scale_by(x: u128, divisor: u128, factor: u128) -> Option {
+ let (quotient, remainder) = (x / divisor, x % divisor);
+ let scaled = remainder * factor;
+ let total = quotient
+ .checked_mul(factor)?
+ .checked_add(scaled / divisor)?;
+ round_half_away(total, scaled % divisor, divisor)
+}
+
+// the general shape: reduce a below b, then walk the remaining 10^18 up in two
+// 10^9 steps, each of which fits because the remainder it scales is below b
+fn wide(a: u128, b: u128) -> Option {
+ let (quotient, remainder) = (a / b, a % b);
+ let whole = quotient.checked_mul(ONE_U)?;
+
+ // b past this and even one 10^9 step overflows; rare enough to be worth a
+ // slow exact path rather than a wider fast one
+ if b > CHUNK_LIMIT {
+ return long_division(whole, remainder, b);
+ }
+
+ let first = remainder * CHUNK;
+ let second = (first % b) * CHUNK;
+ let fraction = (first / b).checked_mul(CHUNK)?.checked_add(second / b)?;
+ round_half_away(whole.checked_add(fraction)?, second % b, b)
+}
+
+// A divisor above 3.4e11 carrying more than 9 decimal places reaches here: no
+// step of 10^9 fits any more, so the scaling walks the 60 bits of 10^18 one at
+// a time. The window stays below the divisor, so doubling it or adding the
+// remainder to it stays below twice the divisor, and so inside a u128 - the
+// product remainder * 10^18 is never formed.
+fn long_division(whole: u128, remainder: u128, b: u128) -> Option {
+ let mut fraction: u128 = 0;
+ let mut window: u128 = 0;
+
+ for bit in (0..ONE_U.ilog2() + 1).rev() {
+ fraction = fraction.checked_mul(2)?;
+ window *= 2;
+ if window >= b {
+ window -= b;
+ fraction = fraction.checked_add(1)?;
+ }
+ if (ONE_U >> bit) & 1 == 1 {
+ window += remainder;
+ if window >= b {
+ window -= b;
+ fraction = fraction.checked_add(1)?;
+ }
+ }
+ }
+
+ round_half_away(whole.checked_add(fraction)?, window, b)
+}
+
+// the crate rounds half away from zero everywhere: parse, round_dp and mul all
+// do, so a quotient that lands exactly between two raws goes the same way
+#[inline(always)]
+fn round_half_away(total: u128, remainder: u128, divisor: u128) -> Option {
+ // remainder * 2 could overflow, so the comparison moves the halving onto
+ // the divisor and handles its odd case separately
+ let half = divisor / 2;
+ match remainder > half || (remainder == half && divisor.is_multiple_of(2)) {
+ true => total.checked_add(1),
+ false => Some(total),
+ }
+}
+
+#[inline(always)]
+fn signed(negative: bool, magnitude: u128) -> Option {
+ // the range is symmetric, so one limit serves both signs
+ if magnitude > MAX_RAW as u128 {
+ return None;
+ }
+ Some(match negative {
+ true => -(magnitude as i128),
+ false => magnitude as i128,
+ })
+}
+
+#[cfg(test)]
+mod divide {
+ #![allow(clippy::unwrap_used)]
+
+ use super::*;
+ use crate::Dec;
+ use crate::dec;
+ use std::str::FromStr;
+
+ fn parse(text: &str) -> Dec {
+ Dec::from_str(text).unwrap()
+ }
+
+ #[test]
+ fn test_market_precision_takes_the_fast_path() {
+ // every price, size and tick carries at most 9 decimals, so its raw is
+ // a multiple of 10^9 and the divisor cancels before it is scaled
+ for text in ["0.00135", "104237.25", "1", "0.000000001"] {
+ let raw = parse(text).into_raw().unsigned_abs();
+ assert!(raw.is_multiple_of(CHUNK), "{text}");
+ }
+ assert_eq!(
+ dec!(104_237.25) / dec!(0.00135),
+ parse("77212777.777777777777777778")
+ );
+ }
+
+ #[test]
+ fn test_finer_than_nine_decimals_takes_the_wide_path() {
+ let fine = parse("0.0000000001");
+ assert!(!fine.into_raw().unsigned_abs().is_multiple_of(CHUNK));
+ assert_eq!(dec!(1) / fine, parse("10000000000"));
+ assert_eq!(Dec::ONE / Dec::EPSILON, parse("1000000000000000000"));
+ }
+
+ #[test]
+ fn test_a_huge_divisor_with_fine_decimals_takes_the_long_path() {
+ // past this the 10^9 steps overflow and the scaling walks bit by bit
+ let divisor = parse("400000000000.000000000000000001");
+ assert!(divisor.into_raw().unsigned_abs() > CHUNK_LIMIT);
+ assert_eq!(dec!(1) / divisor, parse("0.0000000000025"));
+ assert_eq!(divisor / dec!(3), parse("133333333333.333333333333333334"));
+ }
+
+ #[test]
+ fn test_the_bit_walk_agrees_with_the_chunked_steps() {
+ // the two general paths compute the same function over disjoint
+ // divisor ranges, so run the slow one inside the fast one's range
+ for (left, right) in [
+ ("1", "3"),
+ ("2", "3"),
+ ("104237.25", "0.0000000000007"),
+ ("-7.5", "2.0000000000000001"),
+ ("0.000000000000000001", "1.0000000000000003"),
+ (
+ "170141183460469231731.687303715884105727",
+ "99999999.9999999999999",
+ ),
+ ] {
+ let a = parse(left).into_raw().unsigned_abs();
+ let b = parse(right).into_raw().unsigned_abs();
+ assert!(b <= CHUNK_LIMIT, "{right} is outside the chunked range");
+ let (quotient, remainder) = (a / b, a % b);
+ let whole = quotient.checked_mul(ONE_U).unwrap();
+ assert_eq!(
+ wide(a, b),
+ long_division(whole, remainder, b),
+ "{left} / {right}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_the_paths_agree_over_random_inputs() {
+ // the bit walk is independent of the 10^9 steps and of the fast path's
+ // cancellation, so where their domains overlap each one checks the
+ // others: a mistake would have to be made identically three times
+ let mut state = 0x2545_F491_4F6C_DD1D_u64;
+ let mut checked = 0;
+ for _ in 0..20_000 {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ let a = (state as u128) * (state as u128 | 1);
+ let b = match state % 3 {
+ // a divisor small enough for every path to handle it
+ 0 => (state >> 20) as u128 | 1,
+ 1 => ((state >> 20) as u128 | 1) * CHUNK,
+ _ => (state >> 8) as u128 | 1,
+ };
+ if b > CHUNK_LIMIT || a > MAX_RAW as u128 {
+ continue;
+ }
+ let whole = match (a / b).checked_mul(ONE_U) {
+ Some(whole) => whole,
+ None => continue,
+ };
+ let expected = long_division(whole, a % b, b);
+ assert_eq!(wide(a, b), expected, "wide {a} / {b}");
+ if b.is_multiple_of(CHUNK) {
+ assert_eq!(scale_by(a, b / CHUNK, CHUNK), expected, "fast {a} / {b}");
+ }
+ checked += 1;
+ }
+ assert!(checked > 5_000, "only {checked} cases were in range");
+ }
+
+ #[test]
+ fn test_ties_round_half_away_from_zero() {
+ assert_eq!(dec!(1) / dec!(3), parse("0.333333333333333333"));
+ assert_eq!(dec!(2) / dec!(3), parse("0.666666666666666667"));
+ assert_eq!(dec!(1) / dec!(7), parse("0.142857142857142857"));
+ // an exact half at the last place goes away from zero on both signs
+ assert_eq!(Dec::from_raw(1) / dec!(2), Dec::from_raw(1));
+ assert_eq!(Dec::from_raw(-1) / dec!(2), Dec::from_raw(-1));
+ assert_eq!(Dec::from_raw(3) / dec!(2), Dec::from_raw(2));
+ }
+
+ #[test]
+ fn test_signs_and_identities() {
+ assert_eq!(dec!(10) / dec!(4), dec!(2.5));
+ assert_eq!(dec!(-7.5) / dec!(2), dec!(-3.75));
+ assert_eq!(dec!(7.5) / dec!(-2), dec!(-3.75));
+ assert_eq!(dec!(-7.5) / dec!(-2), dec!(3.75));
+ assert_eq!(dec!(7.5) / Dec::ONE, dec!(7.5));
+ assert_eq!(dec!(7.5) / dec!(7.5), Dec::ONE);
+ assert_eq!(Dec::ZERO / dec!(7.5), Dec::ZERO);
+ assert_eq!(dec!(7.5) / Dec::NEG_ONE, dec!(-7.5));
+ assert_eq!(
+ Dec::MAX / dec!(2),
+ parse("85070591730234615865.843651857942052864")
+ );
+
+ let mut value = dec!(12);
+ value /= dec!(4);
+ assert_eq!(value, dec!(3));
+ }
+
+ #[test]
+ fn test_dividing_by_zero_is_nan_rather_than_infinity() {
+ // the type has no infinity, so this is the same fault as an overflow
+ assert!((dec!(1) / Dec::ZERO).is_nan());
+ assert!((dec!(-1) / Dec::ZERO).is_nan());
+ assert!((Dec::ZERO / Dec::ZERO).is_nan());
+ assert_eq!(dec!(1).checked_div(Dec::ZERO), None);
+ assert!(dec!(1).saturating_div(Dec::ZERO).is_nan());
+ }
+
+ #[test]
+ fn test_nan_propagates_through_division() {
+ assert!((Dec::NAN / dec!(2)).is_nan());
+ assert!((dec!(2) / Dec::NAN).is_nan());
+ assert_eq!(Dec::NAN.checked_div(dec!(2)), None);
+ assert_eq!(dec!(2).checked_div(Dec::NAN), None);
+ assert!(Dec::NAN.saturating_div(dec!(2)).is_nan());
+ }
+
+ #[test]
+ fn test_a_quotient_past_the_range_overflows() {
+ assert_eq!(Dec::MAX.checked_div(dec!(0.5)), None);
+ assert_eq!(Dec::MAX.checked_div(Dec::EPSILON), None);
+ assert!((Dec::MAX / dec!(0.5)).is_nan());
+ assert_eq!(Dec::MAX.saturating_div(dec!(0.5)), Dec::MAX);
+ assert_eq!(Dec::MAX.saturating_div(dec!(-0.5)), Dec::MIN);
+ assert_eq!(Dec::MIN.saturating_div(dec!(0.5)), Dec::MIN);
+ }
+
+ #[test]
+ fn test_dividing_then_multiplying_returns_the_value() {
+ for (left, right) in [
+ ("104237.25", "0.00135"),
+ ("1", "8"),
+ ("-12345.6789", "2.5"),
+ ("0.000000000000000004", "2"),
+ ] {
+ let (a, b) = (parse(left), parse(right));
+ assert_eq!((a / b) * b, a, "{left} / {right}");
+ }
+ }
+}
diff --git a/src/dec/format.rs b/src/dec/format.rs
index 947afd9..cd8f5e9 100644
--- a/src/dec/format.rs
+++ b/src/dec/format.rs
@@ -101,6 +101,39 @@ impl fmt::Debug for Dec {
}
}
+#[cfg(test)]
+mod rendering {
+ #![allow(clippy::unwrap_used)]
+
+ use crate::Dec;
+ use crate::dec;
+ use std::str::FromStr;
+
+ #[test]
+ fn test_parse_and_display_round_trip() {
+ for text in [
+ "0",
+ "1",
+ "-1",
+ "0.5",
+ "-0.5",
+ "100000.5",
+ "0.000000000000000001",
+ "-12345.6789",
+ ] {
+ let value = Dec::from_str(text).unwrap();
+ assert_eq!(value.to_string(), text, "round trip of {text}");
+ }
+ }
+
+ #[test]
+ fn test_debug_matches_display() {
+ for value in [dec!(1.5), dec!(-0.25), Dec::ZERO, Dec::MIN, Dec::MAX] {
+ assert_eq!(format!("{value:?}"), format!("{value}"));
+ }
+ }
+}
+
#[cfg(test)]
mod formatting_bounds {
#![allow(clippy::unwrap_used)]
diff --git a/src/dec/mod.rs b/src/dec/mod.rs
index c867516..d00cf1d 100644
--- a/src/dec/mod.rs
+++ b/src/dec/mod.rs
@@ -1,6 +1,7 @@
mod core;
#[cfg(feature = "rust_decimal")]
mod decimal;
+mod div;
mod format;
mod mul;
mod ops;
@@ -12,6 +13,31 @@ mod serde;
pub use core::Dec;
pub use parse::ParseDecError;
+/// Builds a [`Dec`] from a decimal literal at compile time.
+///
+/// The tokens are stringified and handed to [`Dec::parse_const`] inside a
+/// `const` block, so the value is a constant and a malformed literal fails the
+/// build instead of the trade. The literal accepts everything
+/// [`FromStr`](std::str::FromStr) does: a sign, an exponent, `_` separators,
+/// and more than [`Dec::SCALE`] decimal places, where the excess rounds half
+/// away from zero.
+///
+/// ```
+/// use troy::{Dec, dec};
+///
+/// const TICK: Dec = dec!(0.01);
+/// assert_eq!(dec!(104_237.25) + TICK, dec!(104_237.26));
+/// assert_eq!(dec!(-1.5e3), dec!(-1500));
+/// assert_eq!(dec!(0.0000000000000000005), Dec::EPSILON);
+/// ```
+///
+/// A literal that does not parse, or one outside the finite range, is rejected
+/// by the compiler:
+///
+/// ```compile_fail
+/// # use troy::dec;
+/// let price = dec!(banana);
+/// ```
#[macro_export]
macro_rules! dec {
($($token:tt)+) => {
diff --git a/src/dec/ops.rs b/src/dec/ops.rs
index 032d5e8..db59483 100644
--- a/src/dec/ops.rs
+++ b/src/dec/ops.rs
@@ -1,6 +1,6 @@
use super::core::Dec;
use std::iter::Sum;
-use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign};
+use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign};
/// The product, or [`Dec::NAN`] on overflow or from a NaN operand.
///
@@ -30,6 +30,34 @@ impl MulAssign for Dec {
}
}
+/// The quotient, or [`Dec::NAN`] on division by zero, on overflow, or from a
+/// NaN operand.
+///
+/// There is no infinity in the type, so dividing by zero has no value to
+/// return; it is the same fault as an overflow and collapses to the same NaN,
+/// which then carries to wherever the result is examined rather than panicking
+/// on a hot path. The quotient is exact at [`Dec::SCALE`] places, with a tie
+/// rounding half away from zero. [`Dec::checked_div`] reports the fault,
+/// [`Dec::saturating_div`] clamps an overflow.
+impl Div for Dec {
+ type Output = Self;
+
+ #[inline(always)]
+ fn div(self, rhs: Self) -> Self {
+ match self.checked_div(rhs) {
+ Some(value) => value,
+ None => Self::NAN,
+ }
+ }
+}
+
+impl DivAssign for Dec {
+ #[inline(always)]
+ fn div_assign(&mut self, rhs: Self) {
+ *self = *self / rhs;
+ }
+}
+
/// Negation, which is exact and total: the finite range is symmetric, so every
/// value has a negation, and the NaN pattern is its own.
impl Neg for Dec {
diff --git a/src/dec/parse.rs b/src/dec/parse.rs
index 288369b..b05e60b 100644
--- a/src/dec/parse.rs
+++ b/src/dec/parse.rs
@@ -3,9 +3,13 @@ use std::fmt;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+/// Why a decimal failed to parse.
pub enum ParseDecError {
+ /// No digits in the text.
Empty,
+ /// A character that cannot appear in a decimal.
InvalidDigit,
+ /// The value lies outside the finite range.
Overflow,
}
@@ -163,6 +167,64 @@ impl FromStr for Dec {
}
}
+#[cfg(test)]
+mod parsing {
+ #![allow(clippy::unwrap_used)]
+
+ use crate::Dec;
+ use crate::ParseDecError;
+ use crate::dec;
+ use std::str::FromStr;
+
+ #[test]
+ fn test_parse_trims_and_rounds_excess_precision() {
+ assert_eq!(
+ Dec::from_str("1.50").unwrap(),
+ Dec::from_str("1.5").unwrap()
+ );
+ assert_eq!(
+ Dec::from_str("0.0000000000000000005").unwrap(),
+ Dec::EPSILON,
+ "half rounds away from zero at the scale boundary"
+ );
+ assert_eq!(
+ Dec::from_str("0.0000000000000000015").unwrap(),
+ Dec::from_raw(2)
+ );
+ }
+
+ #[test]
+ fn test_parse_exponent_form() {
+ assert_eq!(
+ Dec::from_str("1e-8").unwrap(),
+ Dec::from_raw(10_000_000_000)
+ );
+ assert_eq!(
+ Dec::from_str("1.5E3").unwrap(),
+ Dec::from_str("1500").unwrap()
+ );
+ assert_eq!(
+ Dec::from_str("-2.5e-1").unwrap(),
+ Dec::from_str("-0.25").unwrap()
+ );
+ }
+
+ #[test]
+ fn test_parse_rejects_malformed_input() {
+ assert_eq!(Dec::from_str(""), Err(ParseDecError::Empty));
+ assert_eq!(Dec::from_str("1.2.3"), Err(ParseDecError::InvalidDigit));
+ assert_eq!(Dec::from_str("abc"), Err(ParseDecError::InvalidDigit));
+ }
+
+ #[test]
+ fn test_macro_is_const() {
+ const HALF: Dec = dec!(0.5);
+ const NEGATIVE: Dec = dec!(-1.25);
+ assert_eq!(HALF, Dec::from_raw(500_000_000_000_000_000));
+ assert_eq!(NEGATIVE.to_string(), "-1.25");
+ }
+}
+
#[cfg(test)]
mod wide_values {
#![allow(clippy::unwrap_used)]
diff --git a/src/market/mod.rs b/src/market/mod.rs
new file mode 100644
index 0000000..a419863
--- /dev/null
+++ b/src/market/mod.rs
@@ -0,0 +1,7 @@
+mod ob;
+mod pa;
+#[cfg(any(test, feature = "testing"))]
+pub mod testing;
+
+pub use ob::*;
+pub use pa::*;
diff --git a/src/market/ob.rs b/src/market/ob.rs
new file mode 100644
index 0000000..3113400
--- /dev/null
+++ b/src/market/ob.rs
@@ -0,0 +1,924 @@
+use super::PriceAmount;
+use crate::Dec;
+
+const DEFAULT_CAPACITY: usize = 64;
+
+/// One side of a level 2 book.
+///
+/// Levels are kept best first — descending for bids, ascending for asks — so
+/// the best level, the n-th level and the depth cut are all index arithmetic.
+#[derive(Clone, Debug)]
+pub struct OrderBookSide {
+ levels: Vec,
+ desc: bool,
+ max_depth: Option,
+ total_amount: Dec,
+}
+
+/// A level 2 order book.
+///
+/// Designed to be efficient for high-frequency trading scenarios.
+#[derive(Clone, Debug)]
+pub struct OrderBook {
+ /// The bids side of the order book.
+ pub bids: OrderBookSide,
+ /// The asks side of the order book.
+ pub asks: OrderBookSide,
+}
+
+/// The best bid and ask: the smallest snapshot of a book that still prices a
+/// trade, and what a top-of-book feed carries.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct OrderBookTop {
+ /// The best bid.
+ pub bid: PriceAmount,
+ /// The best ask.
+ pub ask: PriceAmount,
+}
+
+/// A batch of level updates to apply to a book.
+#[derive(Clone, Debug, Default)]
+pub struct OrderBookDiff {
+ /// Bid levels to set.
+ pub bids: Vec,
+ /// Ask levels to set.
+ pub asks: Vec,
+}
+
+impl OrderBookSide {
+ fn bids(max_depth: Option) -> Self {
+ Self::new(true, max_depth)
+ }
+
+ fn asks(max_depth: Option) -> Self {
+ Self::new(false, max_depth)
+ }
+
+ fn new(desc: bool, max_depth: Option) -> Self {
+ // max_depth is a ceiling, not a forecast: a side capped at ten
+ // thousand levels usually holds a handful, so reserving the cap would
+ // allocate for a book that never arrives — and `usize::MAX`, which is
+ // a legal cap meaning "no limit", aborts the process outright
+ let capacity = max_depth.unwrap_or(DEFAULT_CAPACITY).min(DEFAULT_CAPACITY);
+ Self {
+ levels: Vec::with_capacity(capacity),
+ desc,
+ max_depth,
+ total_amount: Dec::ZERO,
+ }
+ }
+
+ /// The number of levels held.
+ pub fn len(&self) -> usize {
+ self.levels.len()
+ }
+
+ /// Whether the side holds no levels.
+ pub fn is_empty(&self) -> bool {
+ self.levels.is_empty()
+ }
+
+ /// The level cap this side was built with, or `None` when uncapped.
+ pub fn max_depth(&self) -> Option {
+ self.max_depth
+ }
+
+ /// Iterate every level, best first.
+ pub fn iter(&self) -> std::slice::Iter<'_, PriceAmount> {
+ self.levels.iter()
+ }
+
+ /// The level at exactly `price`, if the side holds one.
+ ///
+ /// Keyed by price, where [`OrderBookSide::at`] is keyed by depth.
+ pub fn find(&self, price: Dec) -> Option<&PriceAmount> {
+ self.search(price).ok().map(|index| &self.levels[index])
+ }
+
+ /// The best level: highest bid, lowest ask.
+ pub fn best(&self) -> Option<&PriceAmount> {
+ self.levels.first()
+ }
+
+ /// The price of the best level.
+ pub fn best_price(&self) -> Option {
+ self.best().map(|level| level.price)
+ }
+
+ /// The worst level held, which is the deepest one, not the worst possible.
+ pub fn worst(&self) -> Option<&PriceAmount> {
+ self.levels.last()
+ }
+
+ /// The price of the worst level held.
+ pub fn worst_price(&self) -> Option {
+ self.worst().map(|level| level.price)
+ }
+
+ /// The n-th level from the best, counting from zero.
+ pub fn at(&self, n: usize) -> Option<&PriceAmount> {
+ self.levels.get(n)
+ }
+
+ /// The price of the n-th level from the best.
+ pub fn price_at(&self, n: usize) -> Option {
+ self.at(n).map(|level| level.price)
+ }
+
+ /// Set a level, returning the amount it replaced. A zero amount removes it.
+ ///
+ /// A level failing [`PriceAmount::is_valid`] is rejected and the book left
+ /// untouched. `None` therefore covers four cases: the level was inserted,
+ /// it was rejected as invalid, it was refused for sitting past
+ /// `max_depth`, or a zero amount asked to remove a price that was absent.
+ pub fn set(&mut self, entry: PriceAmount) -> Option {
+ if !entry.is_valid() {
+ return None;
+ }
+ match self.search(entry.price) {
+ Ok(index) => {
+ let previous = self.levels[index].amount;
+ self.discount(previous);
+ if entry.amount.is_zero() {
+ self.levels.remove(index);
+ } else {
+ self.levels[index].amount = entry.amount;
+ self.accrue(entry.amount);
+ }
+ Some(previous)
+ }
+ Err(index) => {
+ if entry.amount.is_zero() || self.max_depth.is_some_and(|depth| index >= depth) {
+ return None;
+ }
+ self.levels.insert(index, entry);
+ self.accrue(entry.amount);
+ if let Some(depth) = self.max_depth {
+ self.trim(depth);
+ }
+ None
+ }
+ }
+ }
+
+ /// [`OrderBookSide::set`] taking the price and amount separately.
+ pub fn set_price_amount(&mut self, price: Dec, amount: Dec) -> Option {
+ self.set(PriceAmount { price, amount })
+ }
+
+ /// Retain at most `depth` levels, dropping the worst ones beyond that.
+ pub fn trim(&mut self, depth: usize) {
+ if self.levels.len() <= depth {
+ return;
+ }
+ let mut amount = Dec::ZERO;
+ for level in self.levels.drain(depth..) {
+ amount += level.amount;
+ }
+ self.total_amount -= amount;
+ }
+
+ /// Iterate levels within `[low, high]` in best-first order.
+ ///
+ /// An empty iterator when no level falls inside, `low` above `high`
+ /// included.
+ pub fn range(&self, low: Dec, high: Dec) -> std::slice::Iter<'_, PriceAmount> {
+ let (start, end) = match self.desc {
+ true => (
+ self.levels.partition_point(|level| level.price > high),
+ self.levels.partition_point(|level| level.price >= low),
+ ),
+ false => (
+ self.levels.partition_point(|level| level.price < low),
+ self.levels.partition_point(|level| level.price <= high),
+ ),
+ };
+ // the two partition points cross rather than meet when `low` sits
+ // above `high`, and the slice would panic on a backwards range
+ self.levels[start..end.max(start)].iter()
+ }
+
+ /// Get the volume up to a given level in the orderbook side
+ pub fn volume_at(&self, level: usize) -> Option {
+ self.levels
+ .iter()
+ .take(level)
+ .map(|entry| entry.amount)
+ .reduce(|acc, x| acc + x)
+ }
+
+ /// Volume weighted price paid to fill `quantity`, or `None` if the side is
+ /// too thin.
+ pub fn price_for_quantity(&self, quantity: f64) -> Option {
+ if quantity <= 0.0 {
+ return None;
+ }
+ let mut accumulated = 0.0;
+ let mut notional = 0.0;
+ for entry in self.levels.iter() {
+ let amount = (quantity - accumulated).min(entry.amount.to_f64());
+ notional += amount * entry.price.to_f64();
+ accumulated += amount;
+ if accumulated >= quantity {
+ return Some(notional / accumulated);
+ }
+ }
+ None
+ }
+
+ /// Mean level amount, or `None` when the side is empty.
+ pub fn amount_mean(&self) -> Option {
+ match self.levels.len() {
+ 0 => None,
+ count => Some(self.total_amount.to_f64() / count as f64),
+ }
+ }
+
+ /// Population standard deviation of the level amounts, or `None` when the
+ /// side is empty.
+ ///
+ /// Computed from the levels rather than from a running sum of squares. A
+ /// running one drifts: adding and removing a level far larger than the
+ /// rest cancels badly enough to report no dispersion at all on a book that
+ /// has some, and the clamp that hid it turned corruption into a plausible
+ /// number. The mean comes from the exact `Dec` total, so only the
+ /// deviations are floating point, and their squares cannot go negative.
+ pub fn amount_std_dev(&self) -> Option {
+ let count = self.levels.len();
+ if count == 0 {
+ return None;
+ }
+ let n = count as f64;
+ let mean = self.total_amount.to_f64() / n;
+ let variance = self
+ .levels
+ .iter()
+ .map(|level| {
+ let deviation = level.amount.to_f64() - mean;
+ deviation * deviation
+ })
+ .sum::()
+ / n;
+ Some(variance.sqrt())
+ }
+
+ #[inline]
+ fn search(&self, price: Dec) -> Result {
+ match self.desc {
+ true => self
+ .levels
+ .binary_search_by(|level| price.cmp(&level.price)),
+ false => self
+ .levels
+ .binary_search_by(|level| level.price.cmp(&price)),
+ }
+ }
+
+ #[inline]
+ fn accrue(&mut self, amount: Dec) {
+ self.total_amount += amount;
+ }
+
+ #[inline]
+ fn discount(&mut self, amount: Dec) {
+ self.total_amount -= amount;
+ }
+}
+
+impl Default for OrderBook {
+ fn default() -> Self {
+ Self::new(None)
+ }
+}
+
+impl OrderBook {
+ /// An empty book, each side capped at `max_depth` levels, or uncapped on
+ /// `None`.
+ pub fn new(max_depth: Option) -> Self {
+ Self {
+ bids: OrderBookSide::bids(max_depth),
+ asks: OrderBookSide::asks(max_depth),
+ }
+ }
+
+ /// The level cap both sides were built with.
+ pub fn max_depth(&self) -> Option {
+ self.bids.max_depth()
+ }
+
+ /// O(1) operation to get the mid price of the order book.
+ ///
+ /// Returns `None` if either the bid or ask side is empty.
+ pub fn mid_price(&self) -> Option {
+ match (self.bids.best_price(), self.asks.best_price()) {
+ (Some(bid), Some(ask)) => Some(bid.midpoint(ask)),
+ _ => None,
+ }
+ }
+
+ /// Best ask less best bid, or `None` if either side is empty.
+ ///
+ /// Negative when the book is crossed, which a feed can produce.
+ pub fn spread(&self) -> Option {
+ match (self.bids.best_price(), self.asks.best_price()) {
+ (Some(bid), Some(ask)) => Some(ask - bid),
+ _ => None,
+ }
+ }
+
+ /// Apply every level in `diff`, each as [`OrderBookSide::set`] does.
+ pub fn apply_diff(&mut self, diff: &OrderBookDiff) {
+ for bid in diff.bids.iter().copied() {
+ self.bids.set(bid);
+ }
+ for ask in diff.asks.iter().copied() {
+ self.asks.set(ask);
+ }
+ }
+}
+
+impl From for OrderBook {
+ fn from(top: OrderBookTop) -> Self {
+ let mut order_book = OrderBook::default();
+ order_book.bids.set(top.bid);
+ order_book.asks.set(top.ask);
+ order_book
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
+
+ use super::*;
+ use crate::dec;
+ use crate::testing::{OrderBookBuilder, RandomWalk, Side};
+
+ fn bids(levels: &[(Dec, Dec)], max_depth: Option) -> OrderBookSide {
+ let mut side = OrderBookSide::bids(max_depth);
+ for (price, amount) in levels.iter().copied() {
+ side.set_price_amount(price, amount);
+ }
+ side
+ }
+
+ fn asks(levels: &[(Dec, Dec)], max_depth: Option) -> OrderBookSide {
+ let mut side = OrderBookSide::asks(max_depth);
+ for (price, amount) in levels.iter().copied() {
+ side.set_price_amount(price, amount);
+ }
+ side
+ }
+
+ #[test]
+ fn test_bids_are_ordered_best_first() {
+ let side = bids(
+ &[
+ (dec!(99), dec!(1)),
+ (dec!(101), dec!(2)),
+ (dec!(100), dec!(3)),
+ ],
+ None,
+ );
+ let prices: Vec = side.iter().map(|level| level.price).collect();
+ assert_eq!(prices, vec![dec!(101), dec!(100), dec!(99)]);
+ assert_eq!(side.best_price(), Some(dec!(101)));
+ assert_eq!(side.worst_price(), Some(dec!(99)));
+ assert_eq!(side.price_at(1), Some(dec!(100)));
+ }
+
+ #[test]
+ fn test_asks_are_ordered_best_first() {
+ let side = asks(
+ &[
+ (dec!(101), dec!(1)),
+ (dec!(99), dec!(2)),
+ (dec!(100), dec!(3)),
+ ],
+ None,
+ );
+ let prices: Vec = side.iter().map(|level| level.price).collect();
+ assert_eq!(prices, vec![dec!(99), dec!(100), dec!(101)]);
+ assert_eq!(side.best_price(), Some(dec!(99)));
+ assert_eq!(side.worst_price(), Some(dec!(101)));
+ }
+
+ #[test]
+ fn test_set_replaces_and_removes() {
+ let mut side = bids(&[(dec!(100), dec!(1)), (dec!(99), dec!(2))], None);
+ assert_eq!(side.set_price_amount(dec!(100), dec!(5)), Some(dec!(1)));
+ assert_eq!(side.volume_at(side.len()), Some(dec!(7)));
+ assert_eq!(side.set_price_amount(dec!(100), dec!(0)), Some(dec!(5)));
+ assert_eq!(side.len(), 1);
+ assert_eq!(side.volume_at(side.len()), Some(dec!(2)));
+ assert_eq!(side.find(dec!(100)), None);
+ assert_eq!(side.set_price_amount(dec!(98), dec!(0)), None);
+ }
+
+ #[test]
+ fn test_is_valid_rejects_what_a_book_cannot_hold() {
+ let valid = PriceAmount {
+ price: dec!(100),
+ amount: dec!(1),
+ };
+ assert!(valid.is_valid());
+ assert!(
+ PriceAmount {
+ price: dec!(100),
+ amount: Dec::ZERO,
+ }
+ .is_valid(),
+ "a zero amount is the removal of a level, not an invalid one"
+ );
+ for bad in [
+ PriceAmount {
+ price: Dec::NAN,
+ amount: dec!(1),
+ },
+ PriceAmount {
+ price: dec!(100),
+ amount: Dec::NAN,
+ },
+ PriceAmount {
+ price: dec!(100),
+ amount: dec!(-1),
+ },
+ ] {
+ assert!(!bad.is_valid(), "{bad:?}");
+ }
+ }
+
+ #[test]
+ fn test_an_invalid_level_leaves_the_book_untouched() {
+ let mut side = bids(&[(dec!(100), dec!(1)), (dec!(99), dec!(2))], None);
+ for bad in [
+ (Dec::NAN, dec!(5)),
+ (dec!(101), Dec::NAN),
+ (dec!(101), dec!(-5)),
+ ] {
+ assert_eq!(side.set_price_amount(bad.0, bad.1), None);
+ }
+ let prices: Vec = side.iter().map(|level| level.price).collect();
+ assert_eq!(prices, vec![dec!(100), dec!(99)]);
+ // a NaN amount can never be taken back out of the total, so the test
+ // that matters is that it never went in
+ assert_eq!(side.volume_at(side.len()), Some(dec!(3)));
+ assert_eq!(side.amount_mean(), Some(1.5));
+ }
+
+ #[test]
+ fn test_range_with_low_above_high_is_empty() {
+ let side = asks(&[(dec!(100), dec!(1)), (dec!(101), dec!(1))], None);
+ assert_eq!(side.range(dec!(101), dec!(100)).count(), 0);
+ let side = bids(&[(dec!(101), dec!(1)), (dec!(100), dec!(1))], None);
+ assert_eq!(side.range(dec!(101), dec!(100)).count(), 0);
+ }
+
+ #[test]
+ fn test_a_large_max_depth_does_not_reserve_it() {
+ // usize::MAX is a legal cap meaning "no limit"; reserving it aborts
+ let mut book = OrderBook::new(Some(usize::MAX));
+ book.asks.set_price_amount(dec!(100), dec!(1));
+ assert_eq!(book.asks.len(), 1);
+ assert_eq!(book.max_depth(), Some(usize::MAX));
+ }
+
+ #[test]
+ fn test_max_depth_drops_worst_levels() {
+ let mut side = bids(
+ &[
+ (dec!(100), dec!(1)),
+ (dec!(99), dec!(2)),
+ (dec!(98), dec!(3)),
+ ],
+ Some(2),
+ );
+ assert_eq!(side.len(), 2);
+ assert_eq!(side.volume_at(side.len()), Some(dec!(3)));
+ // a better price evicts the worst level
+ side.set_price_amount(dec!(101), dec!(4));
+ assert_eq!(side.len(), 2);
+ assert_eq!(side.best_price(), Some(dec!(101)));
+ assert_eq!(side.worst_price(), Some(dec!(100)));
+ assert_eq!(side.volume_at(side.len()), Some(dec!(5)));
+ // a worse price is ignored
+ assert_eq!(side.set_price_amount(dec!(97), dec!(9)), None);
+ assert_eq!(side.len(), 2);
+ }
+
+ #[test]
+ fn test_trim() {
+ let mut side = asks(
+ &[
+ (dec!(100), dec!(1)),
+ (dec!(101), dec!(2)),
+ (dec!(102), dec!(3)),
+ ],
+ None,
+ );
+ side.trim(1);
+ assert_eq!(side.len(), 1);
+ assert_eq!(side.volume_at(side.len()), Some(dec!(1)));
+ assert_eq!(side.amount_std_dev(), Some(0.0));
+ }
+
+ #[test]
+ fn test_volume_and_stats() {
+ let side = asks(
+ &[
+ (dec!(100), dec!(1)),
+ (dec!(101), dec!(2)),
+ (dec!(102), dec!(3)),
+ ],
+ None,
+ );
+ assert_eq!(side.volume_at(2), Some(dec!(3)));
+ assert_eq!(side.volume_at(10), Some(dec!(6)));
+ assert_eq!(side.amount_mean(), Some(2.0));
+ let std_dev = side.amount_std_dev().expect("non empty side");
+ assert!((std_dev - (2.0_f64 / 3.0).sqrt()).abs() < 1e-12);
+ }
+
+ #[test]
+ fn test_price_for_quantity() {
+ let side = asks(&[(dec!(100), dec!(1)), (dec!(102), dec!(1))], None);
+ assert_eq!(side.price_for_quantity(1.0), Some(100.0));
+ assert_eq!(side.price_for_quantity(2.0), Some(101.0));
+ assert_eq!(side.price_for_quantity(3.0), None);
+ assert_eq!(side.price_for_quantity(0.0), None);
+ }
+
+ #[test]
+ fn test_apply_diff() {
+ let mut book = OrderBook::new(None);
+ book.apply_diff(&OrderBookDiff {
+ bids: vec![PriceAmount {
+ price: dec!(99),
+ amount: dec!(1),
+ }],
+ asks: vec![PriceAmount {
+ price: dec!(101),
+ amount: dec!(2),
+ }],
+ });
+ assert_eq!(book.mid_price(), Some(dec!(100)));
+ assert_eq!(book.spread(), Some(dec!(2)));
+ book.apply_diff(&OrderBookDiff {
+ bids: vec![PriceAmount {
+ price: dec!(99),
+ amount: dec!(0),
+ }],
+ ..Default::default()
+ });
+ assert!(book.bids.is_empty());
+ assert_eq!(book.mid_price(), None);
+ }
+
+ #[test]
+ fn test_order_book_from_top() {
+ let top = OrderBookTop {
+ bid: PriceAmount {
+ price: dec!(99),
+ amount: dec!(1),
+ },
+ ask: PriceAmount {
+ price: dec!(101),
+ amount: dec!(2),
+ },
+ };
+ let book = OrderBook::from(top);
+ assert_eq!(book.bids.best_price(), Some(dec!(99)));
+ assert_eq!(book.asks.best_price(), Some(dec!(101)));
+ assert_eq!(book.mid_price(), Some(dec!(100)));
+ }
+
+ /// After a simulation step the book must not be crossed: best bid < best ask,
+ /// and spread() must equal ask - bid.
+ #[test]
+ fn test_simulate_book_never_crossed() {
+ let mut builder = OrderBookBuilder::new()
+ .with_tick_size(dec!(0.01))
+ .with_spread(dec!(0.02))
+ .with_amount(dec!(10));
+ let prices = RandomWalk::new(100).lognormal(100.0, 0.0, 0.2).unwrap();
+ for mid in prices {
+ let (book, _) = builder.simulate(Dec::from_f64(mid).unwrap(), 5);
+ let bid = book.bids.best_price().expect("bids must be non-empty");
+ let ask = book.asks.best_price().expect("asks must be non-empty");
+ assert!(
+ bid < ask,
+ "book crossed after simulate: bid={bid} ask={ask}"
+ );
+ assert_eq!(book.spread(), Some(ask - bid));
+ assert_eq!(
+ book.bids.len(),
+ 5,
+ "expected 5 bid levels, got {}",
+ book.bids.len()
+ );
+ assert_eq!(
+ book.asks.len(),
+ 5,
+ "expected 5 ask levels, got {}",
+ book.asks.len()
+ );
+ }
+ }
+
+ /// All amounts in the book must be strictly positive after every simulate step —
+ /// zero-amount levels are removals and must never persist.
+ #[test]
+ fn test_simulate_all_amounts_positive() {
+ let mut builder = OrderBookBuilder::new()
+ .with_tick_size(dec!(0.01))
+ .with_spread(dec!(0.02))
+ .with_amount(dec!(10));
+ let prices = RandomWalk::new(100).lognormal(100.0, 0.0, 0.2).unwrap();
+ for mid in prices {
+ let (book, _) = builder.simulate(Dec::from_f64(mid).unwrap(), 20);
+ for level in book.bids.iter() {
+ assert!(
+ level.amount > Dec::ZERO,
+ "bid level at {} has non-positive amount {}",
+ level.price,
+ level.amount
+ );
+ }
+ for level in book.asks.iter() {
+ assert!(
+ level.amount > Dec::ZERO,
+ "ask level at {} has non-positive amount {}",
+ level.price,
+ level.amount
+ );
+ }
+ }
+ }
+
+ /// All prices must be multiples of tick_size and all amounts multiples of lot_size.
+ #[test]
+ fn test_simulate_prices_and_amounts_aligned() {
+ let tick = dec!(0.01);
+ let lot = dec!(0.01);
+ let mut builder = OrderBookBuilder::new()
+ .with_tick_size(tick)
+ .with_lot_size(lot)
+ .with_spread(dec!(0.02))
+ .with_amount(dec!(10));
+ let prices = RandomWalk::new(100).lognormal(100.0, 0.0, 0.2).unwrap();
+ for mid in prices {
+ let (book, _) = builder.simulate(Dec::from_f64(mid).unwrap(), 20);
+ for level in book.bids.iter().chain(book.asks.iter()) {
+ assert_eq!(
+ level.price.into_raw() % tick.into_raw(),
+ 0,
+ "price {} is not a multiple of tick {}",
+ level.price,
+ tick
+ );
+ assert_eq!(
+ level.amount.into_raw() % lot.into_raw(),
+ 0,
+ "amount {} is not a multiple of lot {}",
+ level.amount,
+ lot
+ );
+ }
+ }
+ }
+
+ /// Trades returned in events must correspond to levels that crossed the new spread:
+ /// bid-side trades (Side::Sell) have price > new bid, ask-side trades (Side::Buy) have price < new ask.
+ #[test]
+ fn test_simulate_trades_are_crossed_levels() {
+ let mut builder = OrderBookBuilder::new()
+ .with_tick_size(dec!(0.01))
+ .with_spread(dec!(0.02))
+ .with_amount(dec!(10));
+ let prices = RandomWalk::new(100).lognormal(100.0, 0.0, 0.2).unwrap();
+ for mid in prices {
+ let (book, events) = builder.simulate(Dec::from_f64(mid).unwrap(), 20);
+ let bid = book.bids.best_price().expect("bids must be non-empty");
+ let ask = book.asks.best_price().expect("asks must be non-empty");
+ for trade in &events.trades {
+ match trade.side {
+ Side::Sell => assert!(
+ trade.price_amount.price > bid,
+ "bid-side trade at {} is not above new bid {}",
+ trade.price_amount.price,
+ bid
+ ),
+ Side::Buy => assert!(
+ trade.price_amount.price < ask,
+ "ask-side trade at {} is not below new ask {}",
+ trade.price_amount.price,
+ ask
+ ),
+ }
+ }
+ }
+ }
+
+ /// Every new order returned in events must be present in the book after simulate.
+ #[test]
+ fn test_simulate_new_orders_in_book() {
+ let mut builder = OrderBookBuilder::new()
+ .with_tick_size(dec!(0.01))
+ .with_spread(dec!(0.02))
+ .with_amount(dec!(10));
+ let prices = RandomWalk::new(100).lognormal(100.0, 0.0, 0.2).unwrap();
+ for mid in prices {
+ let (book, events) = builder.simulate(Dec::from_f64(mid).unwrap(), 20);
+ for order in &events.new_orders {
+ match order.side {
+ Side::Buy => assert!(
+ book.bids
+ .iter()
+ .any(|level| level.price == order.price_amount.price),
+ "new bid at {} not found in book",
+ order.price_amount.price
+ ),
+ Side::Sell => assert!(
+ book.asks
+ .iter()
+ .any(|level| level.price == order.price_amount.price),
+ "new ask at {} not found in book",
+ order.price_amount.price
+ ),
+ }
+ }
+ }
+ }
+
+ /// volume_at properties: None at 0, equals best amount at 1,
+ /// monotonically non-decreasing, and equals total at full depth.
+ #[test]
+ fn test_simulate_volume_at_properties() {
+ let mut builder = OrderBookBuilder::new()
+ .with_tick_size(dec!(0.01))
+ .with_spread(dec!(0.02))
+ .with_amount(dec!(10));
+ let prices = RandomWalk::new(100).lognormal(100.0, 0.0, 0.2).unwrap();
+ for mid in prices {
+ let (book, _) = builder.simulate(Dec::from_f64(mid).unwrap(), 20);
+ for side in [&book.bids, &book.asks] {
+ // volume_at(0) is always None
+ assert_eq!(side.volume_at(0), None);
+
+ // volume_at(1) equals the best level amount
+ let best_amount = side.best().unwrap().amount;
+ assert_eq!(side.volume_at(1), Some(best_amount));
+
+ // volume_at is monotonically non-decreasing
+ let mut prev = Dec::ZERO;
+ for n in 1..=side.len() {
+ let vol = side.volume_at(n).unwrap();
+ assert!(
+ vol >= prev,
+ "volume_at({n})={vol} < volume_at({})={prev}",
+ n - 1
+ );
+ prev = vol;
+ }
+
+ // volume_at(len) equals sum of all amounts
+ let total: Dec = side.iter().map(|level| level.amount).sum();
+ assert_eq!(side.volume_at(side.len()), Some(total));
+ }
+ }
+ }
+
+ #[test]
+ fn test_range_empty_returns_nothing() {
+ let book = OrderBookBuilder::new()
+ .with_ask(dec!(100), dec!(1))
+ .with_ask(dec!(101), dec!(1))
+ .build();
+ let result: Vec<_> = book.asks.range(dec!(200), dec!(300)).collect();
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn test_range_asks_ascending_best_first() {
+ let book = OrderBookBuilder::new()
+ .with_ask(dec!(100), dec!(1))
+ .with_ask(dec!(101), dec!(2))
+ .with_ask(dec!(102), dec!(3))
+ .with_ask(dec!(103), dec!(4))
+ .build();
+ let prices: Vec<_> = book
+ .asks
+ .range(dec!(100), dec!(102))
+ .map(|l| l.price)
+ .collect();
+ assert_eq!(prices, vec![dec!(100), dec!(101), dec!(102)]);
+ }
+
+ #[test]
+ fn test_range_bids_descending_best_first() {
+ let book = OrderBookBuilder::new()
+ .with_bid(dec!(100), dec!(1))
+ .with_bid(dec!(101), dec!(2))
+ .with_bid(dec!(102), dec!(3))
+ .with_bid(dec!(103), dec!(4))
+ .build();
+ let prices: Vec<_> = book
+ .bids
+ .range(dec!(101), dec!(103))
+ .map(|l| l.price)
+ .collect();
+ assert_eq!(prices, vec![dec!(103), dec!(102), dec!(101)]);
+ }
+
+ #[test]
+ fn test_range_bounds_not_in_book() {
+ let book = OrderBookBuilder::new()
+ .with_ask(dec!(99), dec!(1))
+ .with_ask(dec!(100), dec!(2))
+ .with_ask(dec!(101), dec!(3))
+ .with_ask(dec!(102), dec!(4))
+ .build();
+ let prices: Vec<_> = book
+ .asks
+ .range(dec!(99.5), dec!(101.5))
+ .map(|l| l.price)
+ .collect();
+ assert_eq!(prices, vec![dec!(100), dec!(101)]);
+ }
+
+ #[test]
+ fn test_amount_stats_after_trim() {
+ // amounts 1,2,3,4,5 at bids 101..105 — trim to 3 keeps the 3 best (highest) bids
+ // remaining amounts: 3, 4, 5 → mean=4, variance=2/3, std_dev=sqrt(2/3)
+ let mut book = OrderBookBuilder::new()
+ .with_bid(dec!(101), dec!(1))
+ .with_bid(dec!(102), dec!(2))
+ .with_bid(dec!(103), dec!(3))
+ .with_bid(dec!(104), dec!(4))
+ .with_bid(dec!(105), dec!(5))
+ .build();
+ book.bids.trim(3);
+ assert_eq!(book.bids.len(), 3);
+ assert_eq!(book.bids.amount_mean(), Some(4.0));
+ let std_dev = book.bids.amount_std_dev().unwrap();
+ let expected = (2.0f64 / 3.0).sqrt();
+ let diff = (std_dev - expected).abs();
+ assert!(diff < 1e-7, "std_dev={std_dev} expected={expected}");
+ }
+
+ #[test]
+ fn test_amount_stats_empty() {
+ let book = OrderBookBuilder::new().build();
+ assert_eq!(book.bids.amount_mean(), None);
+ assert_eq!(book.bids.amount_std_dev(), None);
+ }
+
+ #[test]
+ fn test_amount_stats_single_level() {
+ let book = OrderBookBuilder::new().with_bid(dec!(100), dec!(5)).build();
+ assert_eq!(book.bids.amount_mean(), Some(5.0));
+ assert_eq!(book.bids.amount_std_dev(), Some(0.0));
+ }
+
+ #[test]
+ fn test_amount_stats_multiple_levels() {
+ // amounts 1, 2, 3, 4, 5 → mean=3, variance=2, std_dev=sqrt(2)
+ let book = OrderBookBuilder::new()
+ .with_bid(dec!(101), dec!(1))
+ .with_bid(dec!(102), dec!(2))
+ .with_bid(dec!(103), dec!(3))
+ .with_bid(dec!(104), dec!(4))
+ .with_bid(dec!(105), dec!(5))
+ .build();
+ assert_eq!(book.bids.amount_mean(), Some(3.0));
+ let std_dev = book.bids.amount_std_dev().unwrap();
+ let expected = 2.0f64.sqrt();
+ let diff = (std_dev - expected).abs();
+ assert!(diff < 1e-7, "std_dev={std_dev} expected={expected}");
+ }
+
+ #[test]
+ fn test_amount_stats_after_removal() {
+ // amounts 2, 4, 6 → remove 2 → remaining 4, 6 → mean=5, variance=1, std_dev=1
+ let mut book = OrderBookBuilder::new()
+ .with_bid(dec!(100), dec!(2))
+ .with_bid(dec!(101), dec!(4))
+ .with_bid(dec!(102), dec!(6))
+ .build();
+ book.bids.set_price_amount(dec!(100), Dec::ZERO);
+ assert_eq!(book.bids.amount_mean(), Some(5.0));
+ assert_eq!(book.bids.amount_std_dev(), Some(1.0));
+ }
+
+ #[test]
+ fn test_amount_stats_after_update() {
+ // amounts 2, 4 → update 2→6 → amounts 6, 4 → mean=5, variance=1, std_dev=1
+ let mut book = OrderBookBuilder::new()
+ .with_bid(dec!(100), dec!(2))
+ .with_bid(dec!(101), dec!(4))
+ .build();
+ book.bids.set_price_amount(dec!(100), dec!(6));
+ assert_eq!(book.bids.amount_mean(), Some(5.0));
+ assert_eq!(book.bids.amount_std_dev(), Some(1.0));
+ }
+}
diff --git a/src/market/pa.rs b/src/market/pa.rs
new file mode 100644
index 0000000..d5311dd
--- /dev/null
+++ b/src/market/pa.rs
@@ -0,0 +1,23 @@
+use crate::Dec;
+
+/// A struct representing a price and amount pair.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct PriceAmount {
+ /// The price of the level.
+ pub price: Dec,
+ /// The amount of the level.
+ pub amount: Dec,
+}
+
+impl PriceAmount {
+ /// Whether this level can enter a book.
+ ///
+ /// A non-finite price would sort against every other level rather than
+ /// among them, since [`Dec::NAN`] orders below [`Dec::MIN`], and a
+ /// non-finite amount would poison the side's running total for good: the
+ /// subtraction that removes the level cannot take a NaN back out again. A
+ /// negative amount is not a size.
+ pub const fn is_valid(self) -> bool {
+ self.price.is_finite() && self.amount.is_finite() && !self.amount.is_sign_negative()
+ }
+}
diff --git a/src/market/testing.rs b/src/market/testing.rs
new file mode 100644
index 0000000..c1068f3
--- /dev/null
+++ b/src/market/testing.rs
@@ -0,0 +1,392 @@
+//! Building and simulating order books, for tests and for downstream crates
+//! that enable the `testing` feature.
+//!
+//! The crate denies `panic!` and `expect` because a fault on a trading hot
+//! path should reach the caller rather than abort it. Neither applies here: a
+//! builder given a negative tick size is a mistake in the test itself, best
+//! reported loudly and at once, and none of this ships in a default build.
+#![allow(clippy::panic, clippy::expect_used)]
+
+use crate::{Dec, OrderBook, PriceAmount, dec};
+use rand::{RngExt, SeedableRng, rngs::SmallRng};
+use rand_distr::{Distribution, Exp, Normal, NormalError};
+
+/// Which side of the book a level, order or trade sits on.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum Side {
+ /// Buying: the bid side.
+ Buy,
+ /// Selling: the ask side.
+ Sell,
+}
+
+/// A level tagged with the side it came from, which a book event needs and a
+/// level on its own does not carry.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct PriceAmountSide {
+ /// The price and amount.
+ pub price_amount: PriceAmount,
+ /// The side the level sat on.
+ pub side: Side,
+}
+
+#[derive(Debug)]
+#[allow(dead_code)] // stub: the fields are set, the builder methods are not written yet
+/// Builder for an [`OrderBookTop`](crate::OrderBookTop).
+pub struct OrderBookTopBuilder {
+ mid: Dec,
+ bid_spread: Dec,
+ ask_spread: Dec,
+ bid_amount: Dec,
+ ask_amount: Dec,
+ sequence_number: u64,
+}
+
+/// Builder for a full L2 [`OrderBook`] with arbitrary bid and ask levels and
+/// simulated market activity.
+#[derive(Debug)]
+pub struct OrderBookBuilder {
+ book: OrderBook,
+ tick_size: Dec,
+ lot_size: Dec,
+ spread: Dec,
+ amount: Dec,
+ arrival_rate: Dec,
+ rng: SmallRng,
+}
+
+#[derive(Default, Debug, Clone)]
+/// What a simulated step did to the book.
+pub struct OrderBookEvents {
+ /// Levels that crossed the new spread and traded.
+ pub trades: Vec,
+ /// Levels withdrawn before the step.
+ pub cancels: Vec,
+ /// Levels added to refill the book.
+ pub new_orders: Vec,
+}
+
+impl Default for OrderBookBuilder {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl OrderBookBuilder {
+ /// A builder with an empty book and default tick, lot, spread and amount.
+ pub fn new() -> Self {
+ Self {
+ book: OrderBook::default(),
+ tick_size: dec!(0.01),
+ lot_size: dec!(0.01),
+ spread: dec!(0.01),
+ amount: dec!(10),
+ arrival_rate: Dec::ONE,
+ rng: SmallRng::seed_from_u64(0),
+ }
+ }
+
+ /// Seed the generator, so a simulation repeats exactly.
+ pub fn with_seed(mut self, seed: u64) -> Self {
+ self.rng = SmallRng::seed_from_u64(seed);
+ self
+ }
+
+ /// Add a bid level.
+ pub fn with_bid(mut self, price: Dec, amount: Dec) -> Self {
+ self.book.bids.set_price_amount(price, amount);
+ self
+ }
+
+ /// Add an ask level.
+ pub fn with_ask(mut self, price: Dec, amount: Dec) -> Self {
+ self.book.asks.set_price_amount(price, amount);
+ self
+ }
+
+ /// The price increment every level sits on. Panics unless positive.
+ pub fn with_tick_size(mut self, tick_size: Dec) -> Self {
+ if tick_size <= Dec::ZERO {
+ panic!("Tick size must be positive");
+ }
+ self.tick_size = tick_size;
+ self
+ }
+
+ /// The amount increment every level sits on. Panics unless positive.
+ pub fn with_lot_size(mut self, lot_size: Dec) -> Self {
+ if lot_size <= Dec::ZERO {
+ panic!("Lot size must be positive");
+ }
+ self.lot_size = lot_size;
+ self
+ }
+
+ /// The average spread a simulated step aims for. Panics unless positive.
+ pub fn with_spread(mut self, spread: Dec) -> Self {
+ if spread <= Dec::ZERO {
+ panic!("Spread must be positive");
+ }
+ self.spread = spread;
+ self
+ }
+
+ /// The average level amount. Panics unless positive.
+ pub fn with_amount(mut self, amount: Dec) -> Self {
+ if amount <= Dec::ZERO {
+ panic!("Amount must be positive");
+ }
+ self.amount = amount;
+ self
+ }
+
+ /// Arrival rate is used to simulate arrivals of cancel/update orders
+ /// with distance from mid price.
+ /// If the arrival rate is high, more orders will arrive closer to the mid price
+ /// If the arrival is low, orders will be more uniformly distributed across the book.
+ pub fn with_arrival_rate(mut self, arrival_rate: Dec) -> Self {
+ if arrival_rate <= Dec::ZERO {
+ panic!("Arrival rate must be positive");
+ }
+ self.arrival_rate = arrival_rate;
+ self
+ }
+
+ /// The book as built, without simulating anything.
+ pub fn build(self) -> OrderBook {
+ self.book
+ }
+
+ /// Randomize levels around the given mid price,
+ /// using the builder's tick size and spread
+ ///
+ /// Returns the simulated book and the events
+ /// that would have led to it (trades, cancels, new orders)
+ pub fn simulate(&mut self, mid: Dec, levels: usize) -> (OrderBook, OrderBookEvents) {
+ let mut events = self.deplete();
+ let half_spread = self.simulate_half_spread();
+ let bid = (mid - half_spread).round_to_step(self.tick_size);
+ let ask = (mid + half_spread).round_to_step(self.tick_size);
+ events.trades = self.trades(bid, ask);
+ events.new_orders = self.add_levels(bid, ask, levels);
+ (self.book.clone(), events)
+ }
+
+ /// Returns a randomized spread with `self.spread` as the average, sampled
+ /// uniformly in `[spread * 0.5, spread * 1.5]`.
+ ///
+ /// The jitter is drawn in percent and applied to the raw scale so the result
+ /// stays an exact multiple of the tick size.
+ pub fn simulate_spread(&mut self) -> Dec {
+ let jitter: i128 = self.rng.random_range(50..150);
+ ceil_to_step(scale_percent(self.spread, jitter), self.tick_size)
+ }
+
+ /// Half of [`OrderBookBuilder::simulate_spread`], rounded to the raw scale.
+ pub fn simulate_half_spread(&mut self) -> Dec {
+ Dec::from_raw(self.simulate_spread().into_raw() / 2)
+ }
+
+ /// A randomised level amount, at least one lot.
+ pub fn simulate_amount(&mut self) -> Dec {
+ let factor: i128 = self.rng.random_range(10..1_000);
+ ceil_to_step(scale_percent(self.amount, factor), self.lot_size)
+ }
+
+ /// Returns a randomized distance from the best price using an exponential distribution
+ /// with mean `self.spread / self.arrival_rate`.
+ pub fn simulate_distance(&mut self) -> Dec {
+ let rate = self.arrival_rate.to_f64() / self.spread.to_f64();
+ let dist = Exp::new(rate).expect("rate must be positive");
+ let distance = Dec::from_f64(dist.sample(&mut self.rng)).unwrap_or(self.spread);
+ ceil_to_step(distance.max(self.tick_size), self.tick_size)
+ }
+
+ /// Simulate random cancellations of existing levels
+ /// with higher probability for levels closer to the mid price.
+ pub fn deplete(&mut self) -> OrderBookEvents {
+ let mut events = OrderBookEvents::default();
+ let Some(mid) = self.book.mid_price() else {
+ return events;
+ };
+ let Some(spread) = self.book.spread() else {
+ return events;
+ };
+ let lambda = self.arrival_rate.to_f64() / spread.to_f64();
+ let depleted: Vec = self
+ .book
+ .bids
+ .iter()
+ .filter_map(|level| {
+ let distance = (mid - level.price).to_f64();
+ // Deplete the level with higher probability the closer it is to the mid
+ let depletion_chance = (-lambda * distance).exp();
+ let roll: f64 = self.rng.random_range(0.0..1.0);
+ if roll < depletion_chance {
+ events.cancels.push(PriceAmountSide {
+ price_amount: *level,
+ side: Side::Buy,
+ });
+ Some(level.price)
+ } else {
+ None
+ }
+ })
+ .collect();
+ for price in depleted {
+ self.book.bids.set_price_amount(price, Dec::ZERO);
+ }
+ // asks
+ let depleted: Vec = self
+ .book
+ .asks
+ .iter()
+ .filter_map(|level| {
+ let distance = (level.price - mid).to_f64();
+ // Deplete the level with higher probability the closer it is to the mid
+ let depletion_chance = (-lambda * distance).exp();
+ let roll: f64 = self.rng.random_range(0.0..1.0);
+ if roll < depletion_chance {
+ events.cancels.push(PriceAmountSide {
+ price_amount: *level,
+ side: Side::Sell,
+ });
+ Some(level.price)
+ } else {
+ None
+ }
+ })
+ .collect();
+ for price in depleted {
+ self.book.asks.set_price_amount(price, Dec::ZERO);
+ }
+ events
+ }
+
+ fn trades(&mut self, bid: Dec, ask: Dec) -> Vec {
+ let mut trades = Vec::new();
+ loop {
+ if let Some(level) = self.book.bids.best().copied()
+ && level.price > bid
+ {
+ trades.push(PriceAmountSide {
+ price_amount: level,
+ side: Side::Sell,
+ });
+ self.book.bids.set_price_amount(level.price, Dec::ZERO);
+ } else {
+ break;
+ }
+ }
+ loop {
+ if let Some(level) = self.book.asks.best().copied()
+ && level.price < ask
+ {
+ trades.push(PriceAmountSide {
+ price_amount: level,
+ side: Side::Buy,
+ });
+ self.book.asks.set_price_amount(level.price, Dec::ZERO);
+ } else {
+ break;
+ }
+ }
+ trades
+ }
+
+ fn add_levels(&mut self, bid: Dec, ask: Dec, levels: usize) -> Vec {
+ let mut new_orders = Vec::new();
+ if self.book.bids.best_price() < Some(bid) {
+ let amount = self.simulate_amount();
+ self.book.bids.set_price_amount(bid, amount);
+ self.book.bids.trim(levels);
+ }
+ if self.book.asks.best_price() > Some(ask) {
+ let amount = self.simulate_amount();
+ self.book.asks.set_price_amount(ask, amount);
+ self.book.asks.trim(levels);
+ }
+ while self.book.bids.len() < levels {
+ let price = bid - self.simulate_distance();
+ let amount = self.simulate_amount();
+ self.book.bids.set_price_amount(price, amount);
+ new_orders.push(PriceAmountSide {
+ price_amount: PriceAmount { price, amount },
+ side: Side::Buy,
+ });
+ }
+ while self.book.asks.len() < levels {
+ let price = ask + self.simulate_distance();
+ let amount = self.simulate_amount();
+ self.book.asks.set_price_amount(price, amount);
+ new_orders.push(PriceAmountSide {
+ price_amount: PriceAmount { price, amount },
+ side: Side::Sell,
+ });
+ }
+ new_orders
+ }
+}
+
+/// `value * percent / 100`, on the raw scale so no precision is lost
+fn scale_percent(value: Dec, percent: i128) -> Dec {
+ Dec::from_raw(value.into_raw() * percent / 100)
+}
+
+// always up, unlike [`Dec::round_to_step`], so a spread, amount or distance
+// never rounds down to nothing
+fn ceil_to_step(value: Dec, step: Dec) -> Dec {
+ let (value, step) = (value.into_raw(), step.into_raw());
+ let steps = value.div_euclid(step) + i128::from(value.rem_euclid(step) != 0);
+ Dec::from_raw(steps * step)
+}
+
+/// A lognormal random walk, the usual stand-in for a price path.
+#[derive(Debug)]
+pub struct RandomWalk {
+ steps: usize,
+ rng: SmallRng,
+}
+
+impl RandomWalk {
+ /// A walk of `steps` prices, seeded deterministically.
+ pub fn new(steps: usize) -> Self {
+ Self {
+ steps,
+ rng: SmallRng::seed_from_u64(0),
+ }
+ }
+
+ /// Seed the generator, so a walk repeats exactly.
+ pub fn with_seed(mut self, seed: u64) -> Self {
+ self.rng = SmallRng::seed_from_u64(seed);
+ self
+ }
+
+ /// `steps` prices from `start`, each one the last multiplied by
+ /// `exp((drift - volatility^2 / 2) dt + volatility sqrt(dt) Z)`.
+ ///
+ /// `drift` and `volatility` describe the whole path rather than one step,
+ /// so `dt` is `1 / steps` and the shape of the path does not change with
+ /// the number of steps taken through it. A negative volatility is the one
+ /// input that fails.
+ pub fn lognormal(
+ mut self,
+ start: f64,
+ drift: f64,
+ volatility: f64,
+ ) -> Result, NormalError> {
+ let dt = 1.0 / self.steps.max(1) as f64;
+ let step = Normal::new(
+ (drift - volatility * volatility / 2.0) * dt,
+ volatility * dt.sqrt(),
+ )?;
+ let mut price = start;
+ Ok((0..self.steps)
+ .map(|_| {
+ price *= step.sample(&mut self.rng).exp();
+ price
+ })
+ .collect())
+ }
+}
diff --git a/src/mod.rs b/src/mod.rs
index 106f147..b1f7d03 100644
--- a/src/mod.rs
+++ b/src/mod.rs
@@ -1,10 +1,12 @@
-//! Superfast numeric primitives for high frequency trading.
+//! Superfast primitives and data structures for high frequency trading.
//!
-//! The crate is built around [`Dec`], a fixed-scale decimal backed by an
-//! `i128` carrying [`Dec::SCALE`] decimal places. Arithmetic is exact within a
-//! finite range of roughly +/-1.7e20; anything that leaves that range becomes
-//! [`Dec::NAN`], which propagates through every subsequent operation so the
-//! fault reaches the boundary where [`Dec::is_finite`] is checked.
+//! # [`Dec`], the number
+//!
+//! A fixed-scale decimal backed by an `i128` carrying [`Dec::SCALE`] decimal
+//! places. Arithmetic is exact within a finite range of roughly +/-1.7e20;
+//! anything that leaves that range becomes [`Dec::NAN`], which propagates
+//! through every subsequent operation so the fault reaches the boundary where
+//! [`Dec::is_finite`] is checked.
//!
//! ```
//! use troy::{Dec, dec};
@@ -17,6 +19,25 @@
//! assert!((Dec::MAX + Dec::ONE).is_nan());
//! ```
//!
+//! # [`OrderBook`], the structure
+//!
+//! A level 2 book built on [`Dec`]. Each side keeps its levels best first, so
+//! the best price, the n-th level and the depth cut are index arithmetic
+//! rather than searches, and a level enters only if [`PriceAmount::is_valid`]
+//! accepts it — a non-finite price would sort against every other level, and a
+//! non-finite amount would poison the side's running total for good.
+//!
+//! ```
+//! use troy::{OrderBook, dec};
+//!
+//! let mut book = OrderBook::new(Some(10));
+//! book.bids.set_price_amount(dec!(104_237.25), dec!(3));
+//! book.asks.set_price_amount(dec!(104_237.30), dec!(1));
+//!
+//! assert_eq!(book.mid_price(), Some(dec!(104_237.275)));
+//! assert_eq!(book.spread(), Some(dec!(0.05)));
+//! ```
+//!
//! See the [`design`] module for the reasoning behind the representation, the
//! NaN state, and the ordering.
@@ -31,8 +52,10 @@
)]
mod dec;
+mod market;
pub use dec::*;
+pub use market::*;
/// How `Dec` is represented, what the NaN state means, and the rules every
/// operation follows.
diff --git a/table.py b/table.py
deleted file mode 100644
index c2d3301..0000000
--- a/table.py
+++ /dev/null
@@ -1,74 +0,0 @@
-#!/usr/bin/env python3
-"""Run the bpt-dec bench suite and print per-operation timings."""
-
-import re
-import subprocess
-import sys
-
-UNITS = {"ps": 1e-3, "ns": 1.0, "µs": 1e3, "us": 1e3, "ms": 1e6, "s": 1e9}
-ROWS = ["cmp", "add", "mul", "div", "round_dp", "round_to_step", "floor", "ceil", "to_f64", "from_f64", "parse", "format"]
-COLUMNS = ["f64", "Dec", "rust_decimal", "fastnum"]
-
-
-def run() -> str:
- command = ["cargo", "bench", "--bench", "arithmetic"]
- command += sys.argv[1:]
- print(" ".join(command), file=sys.stderr)
- result = subprocess.run(command, capture_output=True, text=True)
- if result.returncode != 0:
- sys.stderr.write(result.stderr)
- sys.exit(result.returncode)
- return result.stdout
-
-
-def parse(output: str) -> dict[tuple[str, str], float]:
- # criterion wraps a long id onto its own line, so track the last id seen
- timings: dict[tuple[str, str], float] = {}
- identifier = None
- for line in output.splitlines():
- text = line.strip()
- head = re.match(r"^(\S+)/(\S+)/(\d+)\s*(time:.*)?$", text)
- if head:
- identifier = (head.group(1), head.group(2), int(head.group(3)))
- rest = head.group(4)
- elif identifier and text.startswith("time:"):
- rest = text
- else:
- continue
- if not rest:
- continue
- # [low median high ] — take the median
- window = re.search(r"\[\S+ \S+ ([\d.]+) (\S+) \S+ \S+\]", rest)
- if not window:
- continue
- group, implementation, count = identifier
- nanos = float(window.group(1)) * UNITS[window.group(2)]
- timings[(group, implementation)] = nanos / count
- identifier = None
- return timings
-
-
-def render(timings: dict[tuple[str, str], float]) -> None:
- width = max(len(row) for row in ROWS + ["op"]) + 2
- header = "op".ljust(width) + "".join(name.rjust(14) for name in COLUMNS)
- print(f"\nns per operation\n\n{header}\n{'-' * len(header)}")
- for row in ROWS:
- if not any((row, column) in timings for column in COLUMNS):
- continue
- best = min(
- (timings[(row, c)] for c in ("Dec", "rust_decimal", "fastnum") if (row, c) in timings),
- default=None,
- )
- cells = ""
- for column in COLUMNS:
- value = timings.get((row, column))
- if value is None:
- cells += "-".rjust(14)
- else:
- cells += f"{value:>13.2f}" + ("*" if value == best else " ")
- print(row.ljust(width) + cells)
- print("\n* fastest decimal")
-
-
-if __name__ == "__main__":
- render(parse(run()))