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'' + ] + for tick in ticks: + y = y_of(tick) + parts.append( + f'' + f'{tick}' + ) + parts.append( + f'ns' + ) + + for parameter in widths: + x = x_of(parameter) + parts.append( + f'' + f"{parameter}" + ) + parts.append( + f'' + f"{escape(axis)}" + ) + + for name in IMPLEMENTATIONS: + curve = series.get(name) + if not curve: + continue + path = " ".join( + f"{'M' if index == 0 else 'L'}{x_of(p):.1f},{y_of(n):.1f}" + for index, (p, n) in enumerate(curve) + ) + parts.append(f'') + for parameter, nanos in curve: + parts.append( + f'{escape(name)} at ' + f"{parameter} digits: {nanos:.2f} ns" + ) + # the series is named at the end of its own line, so identity never + # rests on colour alone + last_p, last_n = curve[-1] + parts.append( + f'{escape(name)}' + ) + + parts.append("") + 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('—') + elif record["nanos"] == best: + cells.append(f'{record["nanos"]:.2f}') + else: + cells.append(f"{record['nanos']:.2f}") + body.append(f"{escape(operation)}{''.join(cells)}") + + return ( + '
' + f"{head}" + f"{''.join(body)}" + "" + "
operation
Nanoseconds per operation, median of criterion's samples. " + "★ marks the fastest decimal. Lower is better.
" + ) + + +def render_html(snapshot: dict) -> str: + table = timings(snapshot) + info = snapshot["machine"] + + chips = "".join( + f'{escape(label)} {escape(info[key])}' + for key, label in ( + ("cpu", "cpu"), + ("arch", "arch"), + ("rustc", "rustc"), + ("commit", "commit"), + ("measured", "measured"), + ) + if info.get(key) + ) + + legend = "".join( + f'{escape(name)}' + f"{' — inexact reference' if name == 'f64' else ''}" + for name in IMPLEMENTATIONS + ) + + cards = "".join( + render_card(operation, caption, table) + for operation, caption in present(snapshot) + ) + + sweeps = "".join( + f"

{escape(heading)}

{escape(note)}

" + f'
{render_sweep(snapshot, operation, axis)}
' + for operation, heading, axis, note in present_sweeps(snapshot) + ) + + return f""" + + + + +troy benchmarks + + + +
+
+

troy benchmarks

+

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 [![build](https://github.com/quantmind/troy/actions/workflows/build.yml/badge.svg)](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()))