diff --git a/tools/simulator/archive.spec.ts b/tools/simulator/archive.spec.ts new file mode 100644 index 00000000..2b07b1b1 --- /dev/null +++ b/tools/simulator/archive.spec.ts @@ -0,0 +1,119 @@ +/** + * Data-layer tests for the simulation harness (issue #452): archive + * parsing, the synthetic generator, price lookups, and the seeded PRNG. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive"; +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; + +const FIXTURE_DIR = join(__dirname, "fixtures"); + +function fixture(name: string): string { + return readFileSync(join(FIXTURE_DIR, name), "utf8"); +} + +describe("parseIntentEvents (issue #452)", () => { + it("parses the sample fixture, preserving order and dispatch kinds", () => { + const events = parseIntentEvents(fixture("sample-intents.jsonl")); + expect(events).toHaveLength(6); + for (let i = 1; i < events.length; i += 1) { + expect(events[i].ts).toBeGreaterThanOrEqual(events[i - 1].ts); + } + expect(events[0].intent.intentId).toBe("sample-1"); + expect(events[5].intent.event).toBe("quote_request"); + expect(events[2].intent.auction?.decayEnd).toBe(1759248360); + expect(events[1].intent.usdValueAtCreate).toBe(1000); + }); + + it("ignores blank lines and # comments", () => { + const events = parseIntentEvents('# header\n\n{"intentId":"a","createdAt":10,"deadline":20,"srcChain":"base","srcAmount":"1","minDstAmount":"1"}\n'); + expect(events).toHaveLength(1); + }); + + it("reports the offending line for invalid JSON", () => { + expect(() => parseIntentEvents('{"intentId":"a"}\n{oops\n')).toThrow(/line 2: invalid JSON/); + }); + + it("reports the missing field for incomplete rows", () => { + expect(() => parseIntentEvents('{"intentId":"a","deadline":20}\n')).toThrow(/line 1: "createdAt" must be a finite number/); + }); +}); + +describe("generateSyntheticArchive (issue #452)", () => { + it("is deterministic for a given seed and sorted by time", () => { + const a = generateSyntheticArchive(500, 7); + const b = generateSyntheticArchive(500, 7); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + for (let i = 1; i < a.length; i += 1) { + expect(a[i].ts).toBeGreaterThanOrEqual(a[i - 1].ts); + } + expect(a[0].intent.intentId).toBe("syn-0"); + expect(a[499].intent.intentId).toBe("syn-499"); + }); + + it("rejects negative counts", () => { + expect(() => generateSyntheticArchive(-1, 0)).toThrow(/non-negative integer/); + }); +}); + +describe("PriceBook (issue #452)", () => { + const book = new PriceBook([ + { ts: 100, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 200, chain: "ethereum", symbol: "USDC", priceUsd: 1.5, decimals: 6 }, + { ts: 50, chain: "stellar", symbol: "USDC", priceUsd: 1 }, + ]); + + it("returns the latest snapshot at or before the requested time", () => { + expect(book.lookup("ethereum", "USDC", 99)).toBeNull(); + expect(book.lookup("ethereum", "USDC", 100)).toEqual({ priceUsd: 1, decimals: 6 }); + expect(book.lookup("ethereum", "USDC", 199)).toEqual({ priceUsd: 1, decimals: 6 }); + expect(book.lookup("ethereum", "USDC", 1_000)).toEqual({ priceUsd: 1.5, decimals: 6 }); + }); + + it("returns null for unknown pairs", () => { + expect(book.lookup("ethereum", "WETH", 500)).toBeNull(); + expect(new PriceBook().lookup("ethereum", "USDC", 500)).toBeNull(); + }); + + it("converts base units with per-chain default decimals", () => { + // ethereum row has explicit decimals: 6 → 2_500_000_000 = 2500. + expect(book.usdValue("ethereum", "USDC", "2500000000", 150)).toBe(2500); + // stellar row has no decimals → default 7 → 100000000 = 10. + expect(book.usdValue("stellar", "USDC", "100000000", 60)).toBe(10); + // Non-numeric amounts are unknown, never zero. + expect(book.usdValue("stellar", "USDC", "not-a-number", 60)).toBeNull(); + }); +}); + +describe("parsePriceBook (issue #452)", () => { + it("builds a working book from JSONL", () => { + const book = parsePriceBook(fixture("sample-prices.jsonl")); + expect(book.lookup("stellar", "USDC", 1759248000)).toEqual({ priceUsd: 1, decimals: 7 }); + expect(book.lookup("base", "USDC", 1759248000)).toEqual({ priceUsd: 1, decimals: 6 }); + }); + + it("validates required fields", () => { + expect(() => parsePriceBook('{"ts":1,"chain":"ethereum"}\n')).toThrow(/"symbol" must be a non-empty string/); + }); +}); + +describe("createPrng (issue #452)", () => { + it("reproduces the same sequence for the same seed", () => { + const a = createPrng(123); + const b = createPrng(123); + const seqA = Array.from({ length: 32 }, () => a()); + const seqB = Array.from({ length: 32 }, () => b()); + expect(seqA).toEqual(seqB); + }); + + it("stays in [0, 1)", () => { + const random = createPrng(9); + for (let i = 0; i < 1_000; i += 1) { + const value = random(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); +}); diff --git a/tools/simulator/archive.ts b/tools/simulator/archive.ts new file mode 100644 index 00000000..e3893170 --- /dev/null +++ b/tools/simulator/archive.ts @@ -0,0 +1,139 @@ +/** + * Archive I/O for the simulation harness (issue #452). + * + * Input is JSON Lines: one archived intent event (or price snapshot) per + * line. Rows in the public `intents` dataset schema replay directly; rows + * without an explicit `event` field default to `"intent"`. `#` comment + * lines and blank lines are ignored, which keeps fixtures hand-editable. + */ +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; +import type { PriceRow } from "./prices"; +import type { ArchivedIntent, SimEvent } from "./types"; + +function parseJsonl(text: string, label: string): Array<{ lineNo: number; value: unknown }> { + const out: Array<{ lineNo: number; value: unknown }> = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (line === "" || line.startsWith("#")) continue; + try { + out.push({ lineNo: i + 1, value: JSON.parse(line) }); + } catch (err) { + throw new Error(`${label} line ${i + 1}: invalid JSON (${(err as Error).message})`); + } + } + return out; +} + +function requireNumber(row: Record, key: string, lineNo: number, label: string): number { + const value = row[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} line ${lineNo}: "${key}" must be a finite number`); + } + return value; +} + +function requireString(row: Record, key: string, lineNo: number, label: string): string { + const value = row[key]; + if (typeof value !== "string" || value === "") { + throw new Error(`${label} line ${lineNo}: "${key}" must be a non-empty string`); + } + return value; +} + +/** + * Parse an archived intent stream (JSONL) into timestamped events. + * + * @param text Raw file contents. + * @throws When a row is not valid JSON or lacks the required fields + * (`intentId`, `createdAt`, `deadline`, `srcChain`, `srcAmount`, `minDstAmount`). + * @returns Events sorted by timestamp (stable for equal timestamps). + */ +export function parseIntentEvents(text: string): SimEvent[] { + const events: SimEvent[] = []; + for (const { lineNo, value } of parseJsonl(text, "intents archive")) { + const row = value as Record; + const intent: ArchivedIntent = { + intentId: requireString(row, "intentId", lineNo, "intents archive"), + createdAt: requireNumber(row, "createdAt", lineNo, "intents archive"), + deadline: requireNumber(row, "deadline", lineNo, "intents archive"), + srcChain: requireString(row, "srcChain", lineNo, "intents archive"), + srcAmount: requireString(row, "srcAmount", lineNo, "intents archive"), + minDstAmount: requireString(row, "minDstAmount", lineNo, "intents archive"), + srcToken: typeof row.srcToken === "string" ? row.srcToken : undefined, + srcTokenSymbol: typeof row.srcTokenSymbol === "string" ? row.srcTokenSymbol : undefined, + dstToken: typeof row.dstToken === "string" ? row.dstToken : undefined, + dstTokenSymbol: typeof row.dstTokenSymbol === "string" ? row.dstTokenSymbol : undefined, + dstChain: typeof row.dstChain === "string" ? row.dstChain : undefined, + state: typeof row.state === "string" ? row.state : undefined, + usdValueAtCreate: typeof row.usdValueAtCreate === "number" ? row.usdValueAtCreate : undefined, + auction: (row.auction as ArchivedIntent["auction"]) ?? undefined, + event: row.event === "quote_request" ? "quote_request" : "intent", + }; + events.push({ ts: intent.createdAt, intent }); + } + return events.sort((a, b) => a.ts - b.ts); +} + +/** + * Parse archived price snapshots (JSONL) into a {@link PriceBook}. + * + * @throws When a row lacks `ts`/`chain`/`symbol`/`priceUsd`. + */ +export function parsePriceBook(text: string): PriceBook { + const rows: PriceRow[] = []; + for (const { lineNo, value } of parseJsonl(text, "prices archive")) { + const row = value as Record; + rows.push({ + ts: requireNumber(row, "ts", lineNo, "prices archive"), + chain: requireString(row, "chain", lineNo, "prices archive"), + symbol: requireString(row, "symbol", lineNo, "prices archive"), + priceUsd: requireNumber(row, "priceUsd", lineNo, "prices archive"), + decimals: typeof row.decimals === "number" ? row.decimals : undefined, + }); + } + return new PriceBook(rows); +} + +const SYNTHETIC_SRC_CHAINS = ["ethereum", "base", "polygon", "arbitrum", "optimism", "avalanche"]; + +/** + * Generate a deterministic synthetic archive — used by benchmarks, the + * determinism tests, and `cli.ts --generate` for smoke runs without data. + * + * The stream mixes zero-width bursts (several intents in the same second) + * with one-second gaps, so equal-timestamp ordering and clock advancement + * are both exercised. + * + * @param count Number of intents to generate. + * @param seed PRNG seed; identical inputs yield identical archives. + * @returns Events already sorted by timestamp. + */ +export function generateSyntheticArchive(count: number, seed: number): SimEvent[] { + if (!Number.isInteger(count) || count < 0) { + throw new Error(`count must be a non-negative integer, got ${count}`); + } + const random = createPrng(seed); + const events: SimEvent[] = new Array(count); + let ts = 1_700_000_000; + for (let i = 0; i < count; i += 1) { + if (random() < 0.3) ts += 1; // ~70% of events share the previous second + const intent: ArchivedIntent = { + intentId: `syn-${i}`, + createdAt: ts, + deadline: ts + 60 + Math.floor(random() * 120), + srcChain: SYNTHETIC_SRC_CHAINS[i % SYNTHETIC_SRC_CHAINS.length], + srcToken: "0xsimulated-src", + srcTokenSymbol: "TOK", + srcAmount: "1000000000000000000", + dstToken: "CSIMDST", + dstTokenSymbol: "USDC", + minDstAmount: String(1_000_000 + (i % 100) * 1_000), + state: "open", + event: "intent", + }; + events[i] = { ts, intent }; + } + return events; +} diff --git a/tools/simulator/cli.spec.ts b/tools/simulator/cli.spec.ts new file mode 100644 index 00000000..39ef21c1 --- /dev/null +++ b/tools/simulator/cli.spec.ts @@ -0,0 +1,165 @@ +/** + * CLI tests (issue #452): argument handling, fixture replay, sweep mode, + * synthetic generation, and the `--json` report dump. + */ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli, USAGE } from "./cli"; + +const FIXTURES = join(__dirname, "fixtures"); +const INTENTS = join(FIXTURES, "sample-intents.jsonl"); +const PRICES = join(FIXTURES, "sample-prices.jsonl"); + +describe("runCli (issue #452)", () => { + it("prints usage and exits 0 for --help", () => { + const result = runCli(["--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(USAGE); + expect(result.stdout).toContain("--sweep"); + }); + + it("requires an input source", () => { + const result = runCli([]); + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain("--input or --generate is required"); + }); + + it("rejects unknown flags with usage", () => { + const result = runCli(["--nope"]); + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain("Usage:"); + }); + + it("replays the fixture with the default margin strategy", () => { + const result = runCli(["--input", INTENTS, "--prices", PRICES]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Strategy: margin-threshold"); + expect(result.stdout).toContain("fill rate 100.0%"); + expect(result.stdout).toContain("Slash risk:"); + expect(result.stdout).toContain("PnL:"); + expect(result.stdout).toContain("unpriced fills: 0"); + }); + + it("fills everything with the always-fill strategy", () => { + const result = runCli(["--input", INTENTS, "--prices", PRICES, "--strategy", "always"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Strategy: always-fill"); + expect(result.stdout).toContain("6 submitted, 0 declined"); + }); + + it("declines everything above a hostile margin threshold", () => { + const result = runCli([ + "--input", + INTENTS, + "--prices", + PRICES, + "--strategy", + "margin", + "--min-margin-bps", + "1000", + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Filled: 0"); + expect(result.stdout).toContain("6 declined"); + }); + + it("rejects unknown strategies", () => { + const result = runCli(["--input", INTENTS, "--strategy", "yolo"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('unknown strategy "yolo"'); + }); + + it("runs a fill-window × fee-bps sweep as a markdown table", () => { + const result = runCli(["--input", INTENTS, "--prices", PRICES, "--sweep"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("| fill window (s) | fee bps |"); + expect(result.stdout).toContain("| 60 | 0 |"); + expect(result.stdout).toContain("| 300 | 10 |"); + // Default grid: 3 windows × 3 fee levels. + const dataRows = result.stdout.split("\n").filter((line) => /^\| \d/.test(line)); + expect(dataRows).toHaveLength(9); + }); + + it("honours explicit sweep axes", () => { + const result = runCli([ + "--input", + INTENTS, + "--sweep", + "--fill-windows", + "0,60", + "--fee-bps-list", + "5", + ]); + expect(result.exitCode).toBe(0); + const dataRows = result.stdout.split("\n").filter((line) => /^\| \d/.test(line)); + expect(dataRows).toHaveLength(2); + }); + + it("rejects a malformed sweep axis", () => { + const result = runCli(["--input", INTENTS, "--sweep", "--fill-windows", "abc"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("--fill-windows: not a number: abc"); + }); + + it("generates a synthetic archive with --generate", () => { + const result = runCli(["--generate", "500", "--strategy", "always"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Events: 500"); + expect(result.stdout).toContain("fill rate 100.0%"); + }); + + it("validates --generate input", () => { + // "--generate -5" is rejected by parseArgs as ambiguous; the "=" form + // reaches our own non-negative-integer validation. + const result = runCli(["--generate=-5"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("--generate needs a non-negative integer"); + }); + + it("reports missing input files", () => { + const result = runCli(["--input", join(FIXTURES, "does-not-exist.jsonl")]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("ENOENT"); + }); + + it("writes the report JSON when --json is given", () => { + const dir = mkdtempSync(join(tmpdir(), "sim-harness-")); + const outPath = join(dir, "report.json"); + try { + const result = runCli(["--input", INTENTS, "--prices", PRICES, "--json", outPath]); + expect(result.exitCode).toBe(0); + expect(result.jsonPath).toBe(outPath); + expect(result.stdout).toContain(`report JSON written to ${outPath}`); + const report = JSON.parse(readFileSync(outPath, "utf8")); + expect(report.strategy).toBe("margin-threshold"); + expect(report.totals.filled).toBe(6); + expect(report.params.seed).toBe(42); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes sweep JSON as a comparative report", () => { + const dir = mkdtempSync(join(tmpdir(), "sim-harness-")); + const outPath = join(dir, "sweep.json"); + try { + const result = runCli([ + "--input", + INTENTS, + "--prices", + PRICES, + "--sweep", + "--json", + outPath, + ]); + expect(result.exitCode).toBe(0); + const sweep = JSON.parse(readFileSync(outPath, "utf8")); + expect(sweep.rows).toHaveLength(9); + expect(sweep.strategy).toBe("margin-threshold"); + expect(sweep.seed).toBe(42); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/simulator/cli.ts b/tools/simulator/cli.ts new file mode 100644 index 00000000..a3acc861 --- /dev/null +++ b/tools/simulator/cli.ts @@ -0,0 +1,224 @@ +/** + * CLI for the solver simulation harness (issue #452). + * + * tsx tools/simulator/cli.ts --input archive.jsonl --strategy margin + * tsx tools/simulator/cli.ts --input archive.jsonl --sweep \ + * --fill-windows 60,120,300 --fee-bps-list 0,5,10 + * tsx tools/simulator/cli.ts --generate 100000 --strategy always + * + * Node's built-in `parseArgs` keeps the tool dependency-free. Nothing + * here touches the network or the wall clock; reports are pure functions + * of (archive, prices, strategy, params, seed). + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; +import { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive"; +import { ReplayEngine } from "./engine"; +import { formatReport, formatSweep } from "./report"; +import { runSweep } from "./sweep"; +import type { SweepGrid } from "./sweep"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +import type { SimEvent, SimParams, SimulatorStrategy } from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; + +/** Usage text printed for `--help` and on argument errors. */ +export const USAGE = [ + "Usage: tsx tools/simulator/cli.ts --input [options]", + "", + "Inputs", + " --input archived intent events (JSONL, required unless --generate)", + " --prices archived price snapshots (JSONL)", + " --generate build a synthetic archive of n intents instead", + "", + "Strategy & params", + " --strategy always | margin (default: margin)", + " --min-margin-bps margin threshold (default: 0)", + " --auction let the margin strategy wait for Dutch-auction decay", + " --seed PRNG seed (default: 42)", + " --fee-bps protocol fee bps (default: 5)", + " --fill-window fill window from creation (default: deadline only)", + " --chains chain allowlist (default: all supported chains)", + " --slash-usd USD penalty per unfilled acceptance (default: 100)", + " --gas-usd USD cost per fill (default: 0)", + "", + "Sweep mode", + " --sweep replay across the parameter grid below", + " --fill-windows fill-window axis (default: 60,120,300)", + " --fee-bps-list fee-bps axis (default: 0,5,10)", + "", + "Output", + " --json also write the raw report JSON to a file", + " --help show this help", +].join("\n"); + +/** Outcome of a CLI invocation — pure data so tests can assert on it. */ +export interface CliResult { + stdout: string; + exitCode: number; + /** Destination of the `--json` dump, when requested. */ + jsonPath?: string; +} + +/** Loose view of `parseArgs` values (string options + booleans). */ +type CliValues = Record; + +function asString(value: string | boolean | undefined): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: string | boolean | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new Error(`not a number: ${String(value)}`); + return parsed; +} + +function numberList(raw: string, flag: string): number[] { + const parts = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); + if (parts.length === 0) throw new Error(`${flag} needs at least one number`); + return parts.map((part) => { + const value = Number(part); + if (!Number.isFinite(value)) throw new Error(`${flag}: not a number: ${part}`); + return value; + }); +} + +function buildStrategy(name: string, params: SimParams, useAuction: boolean): SimulatorStrategy { + if (name === "always") return new AlwaysFillStrategy({ chains: params.chains }); + if (name === "margin") { + return new MarginThresholdStrategy({ + minMarginBps: params.minMarginBps, + chains: params.chains, + useAuction, + }); + } + throw new Error(`unknown strategy "${name}" (expected: always | margin)`); +} + +/** + * Run the CLI against an argv slice (without `node`/`script`). + * + * Never throws: argument, input and runtime errors become a message on + * stdout with a non-zero exit code, so shells and tests can assert on the + * result uniformly. + */ +export function runCli(argv: readonly string[]): CliResult { + let values: CliValues; + try { + const parsed = parseArgs({ + args: [...argv], + strict: true, + options: { + input: { type: "string" }, + prices: { type: "string" }, + generate: { type: "string" }, + strategy: { type: "string" }, + "min-margin-bps": { type: "string" }, + auction: { type: "boolean" }, + seed: { type: "string" }, + "fee-bps": { type: "string" }, + "fill-window": { type: "string" }, + chains: { type: "string" }, + "slash-usd": { type: "string" }, + "gas-usd": { type: "string" }, + sweep: { type: "boolean" }, + "fill-windows": { type: "string" }, + "fee-bps-list": { type: "string" }, + json: { type: "string" }, + help: { type: "boolean" }, + }, + }); + values = parsed.values as unknown as CliValues; + } catch (err) { + return { stdout: `${(err as Error).message}\n\n${USAGE}`, exitCode: 2 }; + } + + if (values.help === true) return { stdout: USAGE, exitCode: 0 }; + + try { + const inputPath = asString(values.input); + const pricesPath = asString(values.prices); + const generate = asString(values.generate); + const jsonPath = asString(values.json); + const chainsRaw = asString(values.chains); + const fillWindowsRaw = asString(values["fill-windows"]); + const feeListRaw = asString(values["fee-bps-list"]); + const strategyName = asString(values.strategy) ?? "margin"; + + const params: SimParams = { + ...DEFAULT_SIM_PARAMS, + seed: asNumber(values.seed, DEFAULT_SIM_PARAMS.seed), + feeBps: asNumber(values["fee-bps"], DEFAULT_SIM_PARAMS.feeBps), + fillWindowSec: asNumber(values["fill-window"], DEFAULT_SIM_PARAMS.fillWindowSec), + minMarginBps: asNumber(values["min-margin-bps"], DEFAULT_SIM_PARAMS.minMarginBps), + slashPenaltyUsd: asNumber(values["slash-usd"], DEFAULT_SIM_PARAMS.slashPenaltyUsd), + gasUsdPerFill: asNumber(values["gas-usd"], DEFAULT_SIM_PARAMS.gasUsdPerFill), + chains: + chainsRaw === undefined + ? DEFAULT_SIM_PARAMS.chains + : chainsRaw + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""), + }; + + let events: SimEvent[]; + if (generate !== undefined) { + const count = Number(generate); + if (!Number.isInteger(count) || count < 0) { + throw new Error("--generate needs a non-negative integer"); + } + events = generateSyntheticArchive(count, params.seed); + } else if (inputPath !== undefined) { + events = parseIntentEvents(readFileSync(inputPath, "utf8")); + } else { + return { stdout: `--input or --generate is required\n\n${USAGE}`, exitCode: 2 }; + } + + const prices = pricesPath === undefined ? undefined : parsePriceBook(readFileSync(pricesPath, "utf8")); + const useAuction = values.auction === true; + + let stdout: string; + let jsonValue: unknown; + if (values.sweep === true) { + const grid: SweepGrid = { + fillWindowSecs: fillWindowsRaw === undefined ? [60, 120, 300] : numberList(fillWindowsRaw, "--fill-windows"), + feeBps: feeListRaw === undefined ? [0, 5, 10] : numberList(feeListRaw, "--fee-bps-list"), + }; + const sweep = runSweep(events, { + strategyFactory: (cellParams) => buildStrategy(strategyName, cellParams, useAuction), + params, + prices, + grid, + }); + stdout = formatSweep(sweep); + jsonValue = sweep; + } else { + const strategy = buildStrategy(strategyName, params, useAuction); + const report = new ReplayEngine({ strategy, params, prices }).run(events); + stdout = formatReport(report); + jsonValue = report; + } + + const result: CliResult = { stdout, exitCode: 0 }; + if (jsonPath !== undefined) { + writeFileSync(jsonPath, `${JSON.stringify(jsonValue, null, 2)}\n`, "utf8"); + result.jsonPath = jsonPath; + result.stdout += `\n\nreport JSON written to ${jsonPath}`; + } + return result; + } catch (err) { + return { stdout: `${(err as Error).message}\n\n${USAGE}`, exitCode: 1 }; + } +} + +/* istanbul ignore next -- process bootstrap, exercised by CLI runs */ +if (require.main === module) { + const result = runCli(process.argv.slice(2)); + process.stdout.write(`${result.stdout}\n`); + process.exitCode = result.exitCode; +} diff --git a/tools/simulator/engine.spec.ts b/tools/simulator/engine.spec.ts new file mode 100644 index 00000000..9979029d --- /dev/null +++ b/tools/simulator/engine.spec.ts @@ -0,0 +1,244 @@ +/** + * Replay-engine tests (issue #452): dispatch, fills, fees/PnL, failures + * and slashes, the simulated clock, and seed determinism. + */ +import { PriceBook } from "./prices"; +import { ReplayEngine } from "./engine"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +import type { + ArchivedIntent, + QuoteDecision, + SimEvent, + SimulatorStrategy, + StrategyContext, +} from "./types"; + +/** Priced world: 1 USDC on both legs (EVM 6 decimals, Stellar 7). */ +function makePrices(): PriceBook { + return new PriceBook([ + { ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }, + ]); +} + +function makeIntent(over: Partial = {}): ArchivedIntent { + return { + intentId: "e-1", + createdAt: 1_000, + deadline: 1_600, + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000000", // 1000 USDC + dstTokenSymbol: "USDC", + dstChain: "stellar", + minDstAmount: "9960000000", // 996 USDC + state: "open", + ...over, + }; +} + +function event(ts: number, over: Partial = {}): SimEvent { + return { ts, intent: makeIntent({ createdAt: ts, ...over }) }; +} + +/** Strategy driven by a decision function, recording every hook call. */ +function recordingStrategy( + decide: (ctx: StrategyContext, intent: ArchivedIntent) => QuoteDecision | null, +): SimulatorStrategy & { calls: string[] } { + const calls: string[] = []; + return { + name: "recording", + calls, + onIntent(ctx, intent) { + calls.push(`intent:${intent.intentId}@${ctx.nowSec}`); + return decide(ctx, intent); + }, + onQuoteRequest(ctx, intent) { + calls.push(`quote:${intent.intentId}@${ctx.nowSec}`); + return decide(ctx, intent); + }, + onTick(_ctx, nowSec) { + calls.push(`tick@${nowSec}`); + }, + }; +} + +describe("ReplayEngine (issue #452)", () => { + it("fills every intent through the always-fill strategy and prices PnL via the fee engine", () => { + const events = [event(1_000), event(1_010, { intentId: "e-2" }), event(1_020, { intentId: "e-3" })]; + const report = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { seed: 1, gasUsdPerFill: 0.1 }, + prices: makePrices(), + }).run(events); + + // Per fill: 1000 − 996 − fee(0.498) − gas(0.1) = 3.402. + expect(report.totals.events).toBe(3); + expect(report.totals.quotesSubmitted).toBe(3); + expect(report.totals.filled).toBe(3); + expect(report.totals.fillRate).toBe(1); + expect(report.totals.captureRate).toBe(1); + expect(report.totals.volumeUsd).toBeCloseTo(3_000, 9); + expect(report.totals.feesPaidUsd).toBeCloseTo(3 * 0.498, 9); + expect(report.totals.gasPaidUsd).toBeCloseTo(0.3, 9); + expect(report.totals.pnlUsd).toBeCloseTo(3 * 3.402, 9); + expect(report.totals.slashEvents).toBe(0); + expect(report.totals.unknownPriceFills).toBe(0); + expect(report.strategy).toBe("always-fill"); + expect(report.params.seed).toBe(1); + }); + + it("applies the configured fee bps to every fill", () => { + const events = [event(1_000)]; + const free = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { feeBps: 0 }, + prices: makePrices(), + }).run(events); + const expensive = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { feeBps: 100 }, + prices: makePrices(), + }).run(events); + expect(free.totals.feesPaidUsd).toBe(0); + expect(free.totals.pnlUsd).toBeCloseTo(4, 9); // 1000 − 996 + expect(expensive.totals.feesPaidUsd).toBeCloseTo(9.96, 9); // 1% of 996 + expect(expensive.totals.pnlUsd).toBeCloseTo(-5.96, 9); + expect(expensive.totals.pnlUsd).toBeLessThan(free.totals.pnlUsd); + }); + + it("declines below the margin threshold and fills above it", () => { + const events = [event(1_000)]; + const pass = new ReplayEngine({ + strategy: new MarginThresholdStrategy(), + params: { minMarginBps: 35 }, + prices: makePrices(), + }).run(events); + const fail = new ReplayEngine({ + strategy: new MarginThresholdStrategy(), + params: { minMarginBps: 36 }, + prices: makePrices(), + }).run(events); + expect(pass.totals.filled).toBe(1); + expect(fail.totals.filled).toBe(0); + expect(fail.totals.quotesDeclined).toBe(1); + }); + + it("charges a below-minimum quote as a failed fill and a slash", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "1" })); + const report = new ReplayEngine({ strategy, params: { slashPenaltyUsd: 25 } }).run([event(1_000)]); + expect(report.totals.quotesSubmitted).toBe(1); + expect(report.totals.filled).toBe(0); + expect(report.totals.failedFills).toBe(1); + expect(report.totals.failedBelowMin).toBe(1); + expect(report.totals.slashEvents).toBe(1); + expect(report.totals.slashPenaltyUsd).toBe(25); + }); + + it("fails and slashes fills scheduled past the intent deadline", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000", fillDelayMs: 601_000 })); + const report = new ReplayEngine({ strategy }).run([event(1_000)]); + expect(report.totals.filled).toBe(0); + expect(report.totals.failedLate).toBe(1); + expect(report.totals.slashEvents).toBe(1); + }); + + it("tightens lateness with the fill-window protocol parameter", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000", fillDelayMs: 200_000 })); + const events = [event(1_000)]; // deadline 1600 → delay 200 s fits the deadline + const noWindow = new ReplayEngine({ strategy, params: { fillWindowSec: 0 } }).run(events); + expect(noWindow.totals.filled).toBe(1); + const windowed = new ReplayEngine({ strategy, params: { fillWindowSec: 100 } }).run(events); + expect(windowed.totals.filled).toBe(0); + expect(windowed.totals.failedLate).toBe(1); + expect(windowed.totals.slashEvents).toBe(1); + }); + + it("dispatches intents and quote requests to their own hooks", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000" })); + const events: SimEvent[] = [ + { ts: 1_000, intent: makeIntent({ createdAt: 1_000 }) }, + { ts: 1_010, intent: makeIntent({ createdAt: 1_010, intentId: "rfq-1", event: "quote_request" }) }, + ]; + const report = new ReplayEngine({ strategy }).run(events); + expect(report.totals.intentEvents).toBe(1); + expect(report.totals.quoteRequests).toBe(1); + expect(strategy.calls.filter((c) => c.startsWith("intent:"))).toEqual(["intent:e-1@1000"]); + expect(strategy.calls.filter((c) => c.startsWith("quote:"))).toEqual(["quote:rfq-1@1010"]); + }); + + it("ticks once per clock advance, before the event dispatch", () => { + const strategy = recordingStrategy(() => null); + const events: SimEvent[] = [ + { ts: 1_000, intent: makeIntent({ createdAt: 1_000, intentId: "a" }) }, + { ts: 1_000, intent: makeIntent({ createdAt: 1_000, intentId: "b" }) }, + { ts: 1_010, intent: makeIntent({ createdAt: 1_010, intentId: "c" }) }, + { ts: 1_050, intent: makeIntent({ createdAt: 1_050, intentId: "d" }) }, + ]; + new ReplayEngine({ strategy }).run(events); + expect(strategy.calls).toEqual([ + "tick@1000", + "intent:a@1000", + "intent:b@1000", + "tick@1010", + "intent:c@1010", + "tick@1050", + "intent:d@1050", + ]); + }); + + it("counts fills with unknown prices but excludes them from USD totals", () => { + const report = new ReplayEngine({ strategy: new AlwaysFillStrategy() }).run([event(1_000)]); + expect(report.totals.filled).toBe(1); + expect(report.totals.unknownPriceFills).toBe(1); + expect(report.totals.pnlUsd).toBe(0); + expect(report.totals.volumeUsd).toBe(0); + expect(report.totals.feesPaidUsd).toBe(0); + }); + + it("prices the source leg from usdValueAtCreate when the book lacks it", () => { + const stellarOnly = new PriceBook([{ ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }]); + const report = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + prices: stellarOnly, + }).run([event(1_000, { usdValueAtCreate: 1_000 })]); + expect(report.totals.unknownPriceFills).toBe(0); + expect(report.totals.pnlUsd).toBeCloseTo(1_000 - 996 - 0.498, 9); + }); + + it("sorts events by time without mutating the input array", () => { + const strategy = recordingStrategy(() => null); + const events = [event(2_000, { intentId: "second" }), event(1_000, { intentId: "first" })]; + const before = JSON.stringify(events); + new ReplayEngine({ strategy }).run(events); + expect(JSON.stringify(events)).toBe(before); + expect(strategy.calls).toEqual(["tick@1000", "intent:first@1000", "tick@2000", "intent:second@2000"]); + }); + + it("produces an identical report for the same seed, and a different one otherwise", () => { + const randomStrategy: SimulatorStrategy = { + name: "random", + onIntent: (ctx, intent) => + ctx.random() < 0.5 + ? { dstAmount: intent.minDstAmount, fillDelayMs: Math.floor(ctx.random() * 600) * 1_000 } + : null, + onQuoteRequest: () => null, + onTick: () => undefined, + }; + const events = Array.from({ length: 16 }, (_, i) => event(1_000 + i, { intentId: `r-${i}` })); + + const run = (seed: number) => + JSON.stringify(new ReplayEngine({ strategy: randomStrategy, params: { seed } }).run(events)); + + expect(run(42)).toBe(run(42)); + expect(run(42)).not.toBe(run(43)); + }); + + it("computes average fill latency across settled fills", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000", fillDelayMs: 30_000 })); + const report = new ReplayEngine({ strategy }).run([event(1_000), event(1_010, { intentId: "e-2" })]); + expect(report.totals.filled).toBe(2); + expect(report.totals.avgFillLatencyMs).toBe(30_000); + }); +}); diff --git a/tools/simulator/engine.ts b/tools/simulator/engine.ts new file mode 100644 index 00000000..37180ee0 --- /dev/null +++ b/tools/simulator/engine.ts @@ -0,0 +1,327 @@ +/** + * Deterministic replay engine for the solver simulation harness (issue #452). + * + * The engine walks an archived intent stream on a simulated clock — event + * timestamps only, never `Date.now()` — completing scheduled fills, + * dispatching to the strategy's `onIntent` / `onQuoteRequest` / `onTick` + * hooks, and pricing fills through the pure domain modules: + * + * - `src/fees` — protocol fee per fill (`quoteFee`) + * - `src/routing` — settlement-time feasibility (`RoutingService.buildRoute`) + * - `src/auctions` — Dutch-auction decay (used by the margin strategy) + * + * No Nest bootstrap: everything is plain construction over pure modules. + * A fill whose simulated time passes the effective deadline (`intent + * deadline`, tightened by `fillWindowSec`) becomes a failed fill and a + * slash-risk event; a quote below `minDstAmount` fails the same way. + */ +import { RoutingService } from "../../src/routing/routing.service"; +import { DEFAULT_FEE_RULE, quoteFee } from "../../src/fees/fee-engine"; +import type { FeeRule } from "../../src/fees/fee-engine"; +import type { Route, SupportedChain } from "../../src/intents/intents.types"; +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; +import type { + ArchivedIntent, + QuoteDecision, + SimEvent, + SimParams, + SimReport, + SimulationTotals, + StrategyContext, + SimulatorStrategy, +} from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; +import { effectiveDeadline } from "./strategies/gate"; + +/** Options for one replay. */ +export interface EngineOptions { + strategy: SimulatorStrategy; + /** Partial parameters; merged over {@link DEFAULT_SIM_PARAMS}. */ + params?: Partial; + /** Archived prices; an empty book makes all price-dependent economics "unknown". */ + prices?: PriceBook; +} + +/** A scheduled fill awaiting settlement. */ +interface PendingFill { + fillAtSec: number; + quotedAtSec: number; + intent: ArchivedIntent; + dstAmount: string; +} + +/** + * Binary min-heap over `fillAtSec`, so scheduled fills settle in time + * order without rescanning the pending set on every event (keeps the + * 1M-intent budget comfortably inside 5 minutes). + */ +class FillHeap { + private readonly items: PendingFill[] = []; + + push(item: PendingFill): void { + const items = this.items; + items.push(item); + let i = items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (items[parent].fillAtSec <= items[i].fillAtSec) break; + [items[parent], items[i]] = [items[i], items[parent]]; + i = parent; + } + } + + peek(): PendingFill | undefined { + return this.items[0]; + } + + pop(): PendingFill | undefined { + const items = this.items; + if (items.length === 0) return undefined; + const top = items[0]; + const last = items.pop() as PendingFill; + if (items.length > 0) { + items[0] = last; + let i = 0; + for (;;) { + const left = 2 * i + 1; + const right = left + 1; + let smallest = i; + if (left < items.length && items[left].fillAtSec < items[smallest].fillAtSec) smallest = left; + if (right < items.length && items[right].fillAtSec < items[smallest].fillAtSec) smallest = right; + if (smallest === i) break; + [items[smallest], items[i]] = [items[i], items[smallest]]; + i = smallest; + } + } + return top; + } +} + +/** + * Replays archived events against one strategy. + * + * @example + * ```ts + * const engine = new ReplayEngine({ strategy: new AlwaysFillStrategy(), params: { seed: 7 } }); + * const report = engine.run(parseIntentEvents(archiveText)); + * ``` + */ +export class ReplayEngine { + private readonly strategy: SimulatorStrategy; + private readonly params: SimParams; + private readonly prices: PriceBook; + private readonly feeRules: readonly FeeRule[]; + private readonly routing = new RoutingService(); + private readonly random: () => number; + + constructor(options: EngineOptions) { + this.strategy = options.strategy; + this.params = { ...DEFAULT_SIM_PARAMS, ...options.params }; + this.prices = options.prices ?? new PriceBook(); + this.feeRules = [{ ...DEFAULT_FEE_RULE, id: "sim-default", bps: this.params.feeBps }]; + this.random = createPrng(this.params.seed); + } + + /** + * Replay `events` in timestamp order and return the aggregated report. + * + * The input array is not mutated; events with equal timestamps keep + * their relative input order (stable sort), which keeps replays + * deterministic. + */ + run(events: readonly SimEvent[]): SimReport { + const sorted: SimEvent[] = [...events].sort((a, b) => a.ts - b.ts); + const heap = new FillHeap(); + const ctx: StrategyContext = { + nowSec: 0, + params: this.params, + prices: this.prices, + feeRules: this.feeRules, + random: this.random, + route: (intent) => this.routeFor(intent), + fee: (amount, intent) => + quoteFee(this.feeRules, [], { + amount, + srcChain: intent.srcChain, + dstChain: intent.dstChain ?? "stellar", + }), + valueUsd: (chain, symbol, amountBase, ts) => this.prices.usdValue(chain, symbol, amountBase, ts), + }; + + const totals = emptyTotals(); + let lastTs = Number.NEGATIVE_INFINITY; + + for (const event of sorted) { + // 1. Settle everything due at or before this moment. + while (heap.peek() && (heap.peek() as PendingFill).fillAtSec <= event.ts) { + this.settle(heap.pop() as PendingFill, totals); + } + // 2. The clock advanced → tick, then dispatch. + ctx.nowSec = event.ts; + if (event.ts > lastTs) this.strategy.onTick(ctx, event.ts); + lastTs = event.ts; + this.dispatch(event, ctx, heap, totals); + } + // 3. Stream over: settle the remainder (late vs on-time is decided per fill). + for (;;) { + const pending = heap.pop(); + if (!pending) break; + this.settle(pending, totals); + } + + return finalizeReport(this.strategy.name, this.params, totals); + } + + private dispatch( + event: SimEvent, + ctx: StrategyContext, + heap: FillHeap, + totals: SimulationTotals, + ): void { + const intent = event.intent; + totals.events += 1; + const kind = intent.event ?? "intent"; + let decision: QuoteDecision | null; + if (kind === "quote_request") { + totals.quoteRequests += 1; + decision = this.strategy.onQuoteRequest(ctx, intent); + } else { + totals.intentEvents += 1; + decision = this.strategy.onIntent(ctx, intent); + } + if (decision === null) { + totals.quotesDeclined += 1; + return; + } + totals.quotesSubmitted += 1; + const dstUnits = safeBigint(decision.dstAmount); + const minUnits = safeBigint(intent.minDstAmount); + if (dstUnits === null || minUnits === null || dstUnits < minUnits) { + // A quote below minDstAmount can never fill — failed + slash now. + totals.failedFills += 1; + totals.failedBelowMin += 1; + this.chargeSlash(totals); + return; + } + const delayMs = Math.max(0, decision.fillDelayMs ?? 0); + heap.push({ + fillAtSec: event.ts + delayMs / 1_000, + quotedAtSec: event.ts, + intent, + dstAmount: decision.dstAmount, + }); + } + + private settle(pending: PendingFill, totals: SimulationTotals): void { + const deadline = effectiveDeadline(pending.intent, this.params.fillWindowSec); + if (pending.fillAtSec > deadline) { + totals.failedFills += 1; + totals.failedLate += 1; + this.chargeSlash(totals); + return; + } + + totals.filled += 1; + totals.avgFillLatencyMs += (pending.fillAtSec - pending.quotedAtSec) * 1_000; + + const intent = pending.intent; + const fee = quoteFee(this.feeRules, [], { + amount: pending.dstAmount, + srcChain: intent.srcChain, + dstChain: intent.dstChain ?? "stellar", + }); + + // USD legs: captured-at-creation value first, archived price second. + const srcUsd = + intent.usdValueAtCreate !== undefined + ? intent.usdValueAtCreate + : this.prices.usdValue(intent.srcChain, intent.srcTokenSymbol ?? "", intent.srcAmount, intent.createdAt); + const dstUsd = this.prices.usdValue( + intent.dstChain ?? "stellar", + intent.dstTokenSymbol ?? "", + pending.dstAmount, + intent.createdAt, + ); + + if (srcUsd === null || dstUsd === null) { + // Unknown economics: count the fill, contribute no USD figures. + totals.unknownPriceFills += 1; + return; + } + + const dstUnits = Number(pending.dstAmount); + const feeUsd = Number.isFinite(dstUnits) && dstUnits > 0 ? dstUsd * (Number(fee.fee) / dstUnits) : 0; + totals.volumeUsd += srcUsd; + totals.feesPaidUsd += feeUsd; + totals.gasPaidUsd += this.params.gasUsdPerFill; + totals.pnlUsd += srcUsd - dstUsd - feeUsd - this.params.gasUsdPerFill; + } + + private chargeSlash(totals: SimulationTotals): void { + totals.slashEvents += 1; + totals.slashPenaltyUsd += this.params.slashPenaltyUsd; + } + + /** Direct two-hop-or-single route estimate from the pure routing module. */ + private routeFor(intent: ArchivedIntent): Route { + return this.routing.buildRoute( + { + address: intent.srcToken ?? "", + symbol: intent.srcTokenSymbol ?? "", + name: intent.srcTokenSymbol ?? "", + decimals: 18, + chain: intent.srcChain as SupportedChain, + }, + { + address: intent.dstToken ?? "", + symbol: intent.dstTokenSymbol ?? "", + name: intent.dstTokenSymbol ?? "", + decimals: 7, + chain: "stellar", + }, + "SIM_SOLVER", + { totalFeesUSD: 0, priceImpact: 0, estimatedFillTime: 60 }, + ); + } +} + +function emptyTotals(): SimulationTotals { + return { + events: 0, + intentEvents: 0, + quoteRequests: 0, + quotesDeclined: 0, + quotesSubmitted: 0, + filled: 0, + failedFills: 0, + failedLate: 0, + failedBelowMin: 0, + slashEvents: 0, + slashPenaltyUsd: 0, + fillRate: 0, + captureRate: 0, + volumeUsd: 0, + pnlUsd: 0, + feesPaidUsd: 0, + gasPaidUsd: 0, + unknownPriceFills: 0, + avgFillLatencyMs: 0, + }; +} + +function finalizeReport(strategy: string, params: SimParams, totals: SimulationTotals): SimReport { + totals.fillRate = totals.quotesSubmitted > 0 ? totals.filled / totals.quotesSubmitted : 0; + totals.captureRate = totals.intentEvents > 0 ? totals.filled / totals.intentEvents : 0; + totals.avgFillLatencyMs = totals.filled > 0 ? totals.avgFillLatencyMs / totals.filled : 0; + return { strategy, params, totals }; +} + +function safeBigint(value: string): bigint | null { + if (!/^\d+$/.test(value)) return null; + try { + return BigInt(value); + } catch { + return null; + } +} diff --git a/tools/simulator/fixtures/sample-intents.jsonl b/tools/simulator/fixtures/sample-intents.jsonl new file mode 100644 index 00000000..04a25a67 --- /dev/null +++ b/tools/simulator/fixtures/sample-intents.jsonl @@ -0,0 +1,11 @@ +# Sample archived intent stream for the simulation harness (issue #452). +# One intent per line, JSONL; field names mirror the public `intents` +# dataset schema. Unix timestamps are seconds. EVM USDC = 6 decimals, +# Stellar USDC = 7 decimals — dst amounts are ~0.4% below src so the +# default margin strategy quotes them. +{"intentId":"sample-1","createdAt":1759248000,"deadline":1759248120,"srcChain":"ethereum","srcToken":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","srcTokenSymbol":"USDC","srcAmount":"2500000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"24900000000","state":"open"} +{"intentId":"sample-2","createdAt":1759248030,"deadline":1759248330,"srcChain":"base","srcToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","srcTokenSymbol":"USDC","srcAmount":"1000000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"9960000000","state":"open","usdValueAtCreate":1000} +{"intentId":"sample-3","createdAt":1759248060,"deadline":1759248660,"srcChain":"polygon","srcToken":"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359","srcTokenSymbol":"USDC","srcAmount":"5000000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"49800000000","state":"open","auction":{"startDstAmount":"50000000000","decayStart":1759248060,"decayEnd":1759248360}} +{"intentId":"sample-4","createdAt":1759248120,"deadline":1759248420,"srcChain":"arbitrum","srcToken":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","srcTokenSymbol":"USDC","srcAmount":"750000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"7470000000","state":"open"} +{"intentId":"sample-5","createdAt":1759248180,"deadline":1759248240,"srcChain":"optimism","srcToken":"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85","srcTokenSymbol":"USDC","srcAmount":"400000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"3984000000","state":"open"} +{"intentId":"sample-6","createdAt":1759248240,"deadline":1759248840,"srcChain":"avalanche","srcToken":"0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E","srcTokenSymbol":"USDC","srcAmount":"3000000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"29880000000","state":"open","event":"quote_request"} diff --git a/tools/simulator/fixtures/sample-prices.jsonl b/tools/simulator/fixtures/sample-prices.jsonl new file mode 100644 index 00000000..fa6ed726 --- /dev/null +++ b/tools/simulator/fixtures/sample-prices.jsonl @@ -0,0 +1,9 @@ +# Sample archived price snapshots (JSONL) for the simulation harness. +# `decimals` is optional (default: 7 on stellar, 18 elsewhere). +{"ts":1759247000,"chain":"ethereum","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"base","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"polygon","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"arbitrum","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"optimism","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"avalanche","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"stellar","symbol":"USDC","priceUsd":1,"decimals":7} diff --git a/tools/simulator/index.ts b/tools/simulator/index.ts new file mode 100644 index 00000000..818ada0f --- /dev/null +++ b/tools/simulator/index.ts @@ -0,0 +1,46 @@ +/** + * Solver simulation & backtesting harness (issue #452). + * + * Replays archived intent/price streams against pluggable solver + * strategies on a deterministic simulated clock, and reports PnL, + * fill-rate and slash-risk — optionally across a fill-window × fee-bps + * parameter sweep. Pure domain modules only (`src/auctions`, `src/fees`, + * `src/routing`): no Nest bootstrap, no network, no wall clock. + * + * @example + * ```ts + * import { parseIntentEvents, ReplayEngine, AlwaysFillStrategy } from "./tools/simulator"; + * + * const events = parseIntentEvents(archiveText); + * const report = new ReplayEngine({ strategy: new AlwaysFillStrategy(), params: { seed: 7 } }).run(events); + * ``` + */ +export type { + ArchivedIntent, + QuoteDecision, + SimEvent, + SimEventKind, + SimParams, + SimReport, + SimulatorStrategy, + SimulationTotals, + StrategyContext, +} from "./types"; +export { DEFAULT_SIM_PARAMS } from "./types"; +export { createPrng } from "./prng"; +export { PriceBook } from "./prices"; +export type { PricePoint, PriceRow } from "./prices"; +export { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive"; +export { ReplayEngine } from "./engine"; +export type { EngineOptions } from "./engine"; +export { runSweep } from "./sweep"; +export type { SweepGrid, SweepOptions, SweepReport, SweepRow } from "./sweep"; +export { formatReport, formatSweep } from "./report"; +export { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +export type { AlwaysFillOptions } from "./strategies/always-fill.strategy"; +export { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +export type { MarginThresholdOptions } from "./strategies/margin-threshold.strategy"; +export { attemptGateReason, effectiveDeadline } from "./strategies/gate"; +export type { AttemptGate, GateReason } from "./strategies/gate"; +export { runCli, USAGE } from "./cli"; +export type { CliResult } from "./cli"; diff --git a/tools/simulator/perf.spec.ts b/tools/simulator/perf.spec.ts new file mode 100644 index 00000000..988729a8 --- /dev/null +++ b/tools/simulator/perf.spec.ts @@ -0,0 +1,36 @@ +/** + * Performance budget for the simulation harness (issue #452): + * replay 1M archived intents in under 5 minutes. + * + * The bound is asserted generously (300 s) because the issue's requirement + * is the contract; local/CI runs are expected to land well below it. The + * test measures `ReplayEngine.run` only — archive generation happens + * before the clock starts. + */ +import { generateSyntheticArchive } from "./archive"; +import { ReplayEngine } from "./engine"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; + +describe("replay performance (issue #452)", () => { + it( + "replays 1M intents in under 5 minutes", + () => { + const events = generateSyntheticArchive(1_000_000, 7); + const engine = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { seed: 7 }, + }); + + const started = Date.now(); + const report = engine.run(events); + const elapsedMs = Date.now() - started; + + expect(report.totals.events).toBe(1_000_000); + expect(report.totals.quotesSubmitted).toBe(1_000_000); + expect(report.totals.filled).toBe(1_000_000); + expect(report.totals.slashEvents).toBe(0); + expect(elapsedMs).toBeLessThan(300_000); + }, + 300_000, + ); +}); diff --git a/tools/simulator/prices.ts b/tools/simulator/prices.ts new file mode 100644 index 00000000..dc828bac --- /dev/null +++ b/tools/simulator/prices.ts @@ -0,0 +1,88 @@ +/** + * Archived price snapshots for USD valuation during replay (issue #452). + * + * Prices arrive as JSONL rows (`{"ts","chain","symbol","priceUsd","decimals?"}`) + * and are looked up as-of a simulated timestamp: the latest snapshot at or + * before `ts`. Unknown pairs/timestamps return `null` — the harness never + * fabricates a price, mirroring `usdValueAtCreate` semantics in + * `src/intents/intents.types.ts`. + */ + +/** One price snapshot row from the archive. */ +export interface PriceRow { + /** Unix epoch seconds. */ + ts: number; + chain: string; + symbol: string; + priceUsd: number; + /** Token decimals for base-unit conversion; defaults per chain. */ + decimals?: number; +} + +/** A resolved price point. */ +export interface PricePoint { + priceUsd: number; + decimals: number; +} + +function defaultDecimals(chain: string): number { + return chain === "stellar" ? 7 : 18; +} + +/** + * Immutable, time-indexed price store. + * + * Rows are bucketed per `chain:symbol` and sorted once at construction; + * lookups binary-search the bucket, keeping replays O(log n) per valuation. + */ +export class PriceBook { + private readonly buckets = new Map(); + + constructor(rows: readonly PriceRow[] = []) { + for (const row of rows) { + const key = `${row.chain}:${row.symbol}`; + const bucket = this.buckets.get(key); + if (bucket) bucket.push(row); + else this.buckets.set(key, [row]); + } + for (const bucket of this.buckets.values()) { + bucket.sort((a, b) => a.ts - b.ts); + } + } + + /** Latest snapshot at or before `ts`, or null when the pair is unknown. */ + lookup(chain: string, symbol: string, ts: number): PricePoint | null { + const bucket = this.buckets.get(`${chain}:${symbol}`); + if (!bucket || bucket.length === 0) return null; + // Binary search for the last row with row.ts <= ts. + let lo = 0; + let hi = bucket.length - 1; + let best = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (bucket[mid].ts <= ts) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + if (best < 0) return null; + const row = bucket[best]; + return { priceUsd: row.priceUsd, decimals: row.decimals ?? defaultDecimals(chain) }; + } + + /** + * USD value of `amountBase` base units at `ts`. + * + * Returns null when no snapshot exists — callers must treat unknown + * economics as "cannot evaluate", never as zero. + */ + usdValue(chain: string, symbol: string, amountBase: string, ts: number): number | null { + const point = this.lookup(chain, symbol, ts); + if (!point) return null; + const amount = Number(amountBase); + if (!Number.isFinite(amount)) return null; + return (amount / 10 ** point.decimals) * point.priceUsd; + } +} diff --git a/tools/simulator/prng.ts b/tools/simulator/prng.ts new file mode 100644 index 00000000..d69e958a --- /dev/null +++ b/tools/simulator/prng.ts @@ -0,0 +1,25 @@ +/** + * Deterministic, seedable PRNG for the simulation harness (issue #452). + * + * mulberry32: 32-bit state, uniform in `[0, 1)`. Chosen over + * `Math.random()` because replays must be bit-for-bit reproducible from a + * seed — including strategies that inject their own randomness through + * `StrategyContext.random`. + */ + +/** + * Create a deterministic generator from a 32-bit seed. + * + * @param seed Any number; coerced to uint32 so negative seeds are stable. + * @returns A function producing floats in `[0, 1)`. + */ +export function createPrng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/tools/simulator/report.ts b/tools/simulator/report.ts new file mode 100644 index 00000000..239b2cfe --- /dev/null +++ b/tools/simulator/report.ts @@ -0,0 +1,69 @@ +/** + * Human-readable report formatting for the simulation harness (issue #452). + * + * Pure string building over {@link SimReport} / {@link SweepReport} — no + * clock, no I/O — so formatted output is deterministic and diffable. + */ +import type { SimReport } from "./types"; +import type { SweepReport } from "./sweep"; + +function pct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function usd(value: number): string { + const sign = value < 0 ? "-" : ""; + return `${sign}$${Math.abs(value).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +function num(value: number): string { + return value.toLocaleString("en-US"); +} + +/** + * Format a single replay as an aligned plain-text report covering PnL, + * fill-rate and slash-risk (the three views the issue calls for). + */ +export function formatReport(report: SimReport): string { + const t = report.totals; + const p = report.params; + const lines = [ + `Strategy: ${report.strategy}`, + `Params: seed=${p.seed} feeBps=${p.feeBps} fillWindowSec=${p.fillWindowSec} minMarginBps=${p.minMarginBps} slashPenaltyUsd=${p.slashPenaltyUsd} gasUsdPerFill=${p.gasUsdPerFill}`, + `Events: ${num(t.events)} (${num(t.intentEvents)} intents, ${num(t.quoteRequests)} quote requests)`, + `Quotes: ${num(t.quotesSubmitted)} submitted, ${num(t.quotesDeclined)} declined`, + `Filled: ${num(t.filled)} fill rate ${pct(t.fillRate)} capture ${pct(t.captureRate)}`, + `Failed fills: ${num(t.failedFills)} (late ${num(t.failedLate)}, below min ${num(t.failedBelowMin)})`, + `Slash risk: ${num(t.slashEvents)} event(s), penalty ${usd(t.slashPenaltyUsd)}`, + `Volume: ${usd(t.volumeUsd)}`, + `Fees paid: ${usd(t.feesPaidUsd)} gas: ${usd(t.gasPaidUsd)}`, + `PnL: ${usd(t.pnlUsd)} (unpriced fills: ${num(t.unknownPriceFills)})`, + `Avg fill latency: ${num(Math.round(t.avgFillLatencyMs))} ms`, + ]; + return lines.join("\n"); +} + +/** + * Format a sweep as a GitHub-flavored Markdown table, one row per grid + * cell, ordered window-major then fee bps. + */ +export function formatSweep(sweep: SweepReport): string { + const lines = [ + `Strategy: ${sweep.strategy} — seed ${sweep.seed}`, + "", + "| fill window (s) | fee bps | fill rate | capture | filled | slashes | PnL (USD) | fees (USD) | volume (USD) |", + "|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ]; + for (const row of sweep.rows) { + const t = row.report.totals; + lines.push( + `| ${row.fillWindowSec} | ${row.feeBps} | ${pct(t.fillRate)} | ${pct(t.captureRate)} | ` + + `${num(t.filled)} | ${num(t.slashEvents)} | ${t.pnlUsd.toFixed(2)} | ` + + `${t.feesPaidUsd.toFixed(2)} | ${t.volumeUsd.toFixed(2)} |`, + ); + } + return lines.join("\n"); +} diff --git a/tools/simulator/strategies.spec.ts b/tools/simulator/strategies.spec.ts new file mode 100644 index 00000000..3828d064 --- /dev/null +++ b/tools/simulator/strategies.spec.ts @@ -0,0 +1,205 @@ +/** + * Unit tests for the strategy hooks, the shared accept gate, and the + * fill-window deadline (issue #452). + */ +import { DEFAULT_FEE_RULE, quoteFee } from "../../src/fees/fee-engine"; +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { attemptGateReason, effectiveDeadline } from "./strategies/gate"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +import type { ArchivedIntent, StrategyContext } from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; + +/** Priced world: 1 USDC on both legs (EVM 6 decimals, Stellar 7). */ +function makePrices(): PriceBook { + return new PriceBook([ + { ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }, + ]); +} + +function makeIntent(over: Partial = {}): ArchivedIntent { + return { + intentId: "u-1", + createdAt: 1_000, + deadline: 1_600, + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000000", // 1000 USDC + dstTokenSymbol: "USDC", + dstChain: "stellar", + minDstAmount: "9960000000", // 996 USDC + state: "open", + ...over, + }; +} + +function makeCtx(over: Partial = {}): StrategyContext { + const feeRules = [{ ...DEFAULT_FEE_RULE, id: "sim", bps: 5 }]; + const ctx: StrategyContext = { + nowSec: 1_000, + params: { ...DEFAULT_SIM_PARAMS }, + prices: makePrices(), + feeRules, + random: createPrng(1), + route: () => ({ steps: [], totalTime: 60, totalFeesUSD: 0, priceImpact: 0 }), + fee: (amount, intent) => + quoteFee(feeRules, [], { + amount, + srcChain: intent.srcChain, + dstChain: intent.dstChain ?? "stellar", + }), + // Reads through the final object so a `prices` override below applies here too. + valueUsd: (chain, symbol, amountBase, ts) => ctx.prices.usdValue(chain, symbol, amountBase, ts), + ...over, + }; + return ctx; +} + +describe("attemptGateReason (issue #452)", () => { + const base = { chains: ["ethereum"], minMarginBps: 0, nowSec: 1_000 }; + + it("accepts an open, on-chain, in-deadline intent", () => { + expect(attemptGateReason(makeIntent(), base)).toBeNull(); + }); + + it("applies precedence: state, deadline, chain, margin", () => { + expect(attemptGateReason(makeIntent({ state: "filled" }), base)).toBe("state"); + expect(attemptGateReason(makeIntent({ deadline: 1_000 }), base)).toBe("deadline"); + expect(attemptGateReason(makeIntent({ srcChain: "base" }), base)).toBe("chain"); + expect( + attemptGateReason(makeIntent({ minDstAmount: "1" }), { ...base, minMarginBps: 10 }), + ).toBe("margin"); + }); + + it("ignores state only when the caller opts in (replay rows)", () => { + const intent = makeIntent({ state: "filled" }); + expect(attemptGateReason(intent, { ...base, ignoreState: true })).toBeNull(); + expect(attemptGateReason(intent, base)).toBe("state"); + // A missing state is treated as open either way (creation-moment rows). + expect(attemptGateReason(makeIntent({ state: undefined }), base)).toBeNull(); + }); + + it("uses the bot's absolute margin heuristic", () => { + // 1_000_000 * (bps / 10_000) base units of minDstAmount. + expect(attemptGateReason(makeIntent({ minDstAmount: "999" }), { ...base, minMarginBps: 10 })).toBe("margin"); + expect(attemptGateReason(makeIntent({ minDstAmount: "1000" }), { ...base, minMarginBps: 10 })).toBeNull(); + }); +}); + +describe("effectiveDeadline (issue #452)", () => { + const intent = makeIntent({ createdAt: 1_000, deadline: 1_600 }); + + it("returns the intent deadline when no window is configured", () => { + expect(effectiveDeadline(intent, 0)).toBe(1_600); + }); + + it("tightens to createdAt + window when a window is configured", () => { + expect(effectiveDeadline(intent, 100)).toBe(1_100); + expect(effectiveDeadline(intent, 600)).toBe(1_600); + expect(effectiveDeadline(intent, 900)).toBe(1_600); + }); +}); + +describe("AlwaysFillStrategy (issue #452)", () => { + const strategy = new AlwaysFillStrategy(); + + it("quotes minDstAmount immediately for gate-passing intents", () => { + const decision = strategy.onIntent(makeCtx(), makeIntent()); + expect(decision).toEqual({ dstAmount: "9960000000", fillDelayMs: 0 }); + }); + + it("gives RFQ rounds the same answer as broadcast intents", () => { + expect(strategy.onQuoteRequest(makeCtx(), makeIntent())).toEqual( + strategy.onIntent(makeCtx(), makeIntent()), + ); + }); + + it("declines intents outside the chain allowlist", () => { + expect(strategy.onIntent(makeCtx(), makeIntent({ srcChain: "dogechain" }))).toBeNull(); + expect(new AlwaysFillStrategy({ chains: ["base"] }).onIntent(makeCtx(), makeIntent())).toBeNull(); + expect(new AlwaysFillStrategy({ chains: ["ethereum"] }).onIntent(makeCtx(), makeIntent())).not.toBeNull(); + }); + + it("has a no-op tick", () => { + expect(() => strategy.onTick(makeCtx(), 1_100)).not.toThrow(); + }); +}); + +describe("MarginThresholdStrategy (issue #452)", () => { + const strategy = new MarginThresholdStrategy(); + + it("quotes when the fee-adjusted margin clears the threshold", () => { + // margin = (1000 − 996 − 0.498) / 1000 → 35.02 bps. + expect(strategy.onIntent(makeCtx({ nowSec: 1_000 }), makeIntent())).not.toBeNull(); + const higher = new MarginThresholdStrategy({ minMarginBps: 36 }); + expect(higher.onIntent(makeCtx({ nowSec: 1_000 }), makeIntent())).toBeNull(); + }); + + it("declines when the destination leg cannot be priced", () => { + const noDstPrices = makeCtx({ + prices: new PriceBook([{ ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }]), + }); + expect(strategy.onIntent(noDstPrices, makeIntent())).toBeNull(); + }); + + it("declines when the source leg cannot be valued (never guesses)", () => { + const noSrcPrices = makeCtx({ + prices: new PriceBook([{ ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }]), + }); + expect(strategy.onIntent(noSrcPrices, makeIntent())).toBeNull(); + }); + + it("prefers usdValueAtCreate over the price book for the source leg", () => { + // Source price missing from the book; captured value makes it workable. + const ctx = makeCtx({ + prices: new PriceBook([{ ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }]), + }); + expect(strategy.onIntent(ctx, makeIntent({ usdValueAtCreate: 1_000 }))).not.toBeNull(); + }); + + it("declines when routing says settlement cannot fit the window", () => { + const slowRoute = makeCtx({ route: () => ({ steps: [], totalTime: 10_000, totalFeesUSD: 0, priceImpact: 0 }) }); + expect(strategy.onIntent(slowRoute, makeIntent())).toBeNull(); + }); + + describe("Dutch-auction waiting", () => { + const sniper = new MarginThresholdStrategy({ minMarginBps: 50, useAuction: true }); + const auctionIntent = makeIntent({ + minDstAmount: "9900000000", // $990 floor + deadline: 1_600, + usdValueAtCreate: 1_000, // source leg: $1000 + auction: { + startDstAmount: "10100000000", // $1010 start → negative margin at t=1000 + decayStart: 1_000, + decayEnd: 1_100, + }, + }); + + it("waits for decay to clear the threshold, then fills at the decayed price", () => { + const decision = sniper.onIntent(makeCtx({ nowSec: 1_000 }), auctionIntent); + // price(t) = 1010 − 20·(t−1000)/100; margin crosses 50 bps at t=1078. + expect(decision).toEqual({ dstAmount: "9944000000", fillDelayMs: 78_000 }); + }); + + it("declines when even full decay misses the threshold", () => { + const greedy = new MarginThresholdStrategy({ minMarginBps: 5_000, useAuction: true }); + expect(greedy.onIntent(makeCtx({ nowSec: 1_000 }), auctionIntent)).toBeNull(); + }); + + it("fills immediately when the start price already clears the threshold", () => { + const early = new MarginThresholdStrategy({ minMarginBps: -10_000, useAuction: true }); + const decision = early.onIntent(makeCtx({ nowSec: 1_000 }), auctionIntent); + expect(decision?.fillDelayMs).toBe(0); + expect(decision?.dstAmount).toBe("10100000000"); + }); + + it("falls back to the plain quote when the intent has no auction", () => { + const plain = new MarginThresholdStrategy({ minMarginBps: 0, useAuction: true }); + const decision = plain.onIntent(makeCtx({ nowSec: 1_000 }), makeIntent({ auction: undefined })); + expect(decision?.dstAmount).toBe("9960000000"); + expect(decision?.fillDelayMs).toBe(0); + }); + }); +}); diff --git a/tools/simulator/strategies/always-fill.strategy.ts b/tools/simulator/strategies/always-fill.strategy.ts new file mode 100644 index 00000000..42b3c97d --- /dev/null +++ b/tools/simulator/strategies/always-fill.strategy.ts @@ -0,0 +1,61 @@ +/** + * Reference strategy #1 for the simulation harness (issue #452): + * accept-everything — the exact behaviour `scripts/solver-bot.ts` runs in + * live demos, extracted so it can be replayed offline. + * + * Quotes `minDstAmount` and fills immediately for every intent that clears + * the shared gate (chains + optional absolute margin + live deadline). + */ +import type { + ArchivedIntent, + QuoteDecision, + SimulatorStrategy, + StrategyContext, +} from "../types"; +import { attemptGateReason } from "./gate"; + +/** Tuning for {@link AlwaysFillStrategy}. */ +export interface AlwaysFillOptions { + /** Override the chain allowlist (defaults to `ctx.params.chains`). */ + chains?: readonly string[]; + /** Absolute-margin heuristic in bps; `0` disables it (the bot's default). */ + minMarginBps?: number; +} + +/** + * The naive "fill every open intent at `minDstAmount`" strategy. + * + * Deterministic: no prices, no fees, no randomness — the harness's + * baseline for comparing smarter strategies and for parameter sweeps. + */ +export class AlwaysFillStrategy implements SimulatorStrategy { + readonly name = "always-fill"; + + constructor(private readonly options: AlwaysFillOptions = {}) {} + + /** @inheritdoc */ + onIntent(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc — RFQ rounds get the same answer as broadcast intents. */ + onQuoteRequest(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc — this strategy observes no time-based signal. */ + onTick(_ctx: StrategyContext, _nowSec: number): void { + /* no time-dependent behaviour */ + } + + private decide(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + const reason = attemptGateReason(intent, { + chains: this.options.chains ?? ctx.params.chains, + minMarginBps: this.options.minMarginBps ?? 0, + nowSec: ctx.nowSec, + ignoreState: true, + }); + if (reason !== null) return null; + return { dstAmount: intent.minDstAmount, fillDelayMs: 0 }; + } +} diff --git a/tools/simulator/strategies/gate.ts b/tools/simulator/strategies/gate.ts new file mode 100644 index 00000000..e1734687 --- /dev/null +++ b/tools/simulator/strategies/gate.ts @@ -0,0 +1,64 @@ +/** + * Shared decision gate for strategies — and the exact accept gate the live + * reference bot runs (issue #452). + * + * `scripts/solver-bot.ts` imports {@link attemptGateReason} so the live + * bot and the `always-fill` reference strategy literally share one + * decision path: whatever passes here passes in a replay, and vice versa. + */ +import type { ArchivedIntent } from "../types"; + +/** Why a gate declined an intent; `null` means "attempt it". */ +export type GateReason = "state" | "deadline" | "chain" | "margin"; + +/** Inputs to {@link attemptGateReason}. */ +export interface AttemptGate { + /** Chains the solver trades. */ + chains: readonly string[]; + /** Minimum margin in bps using the bot's absolute-base-unit heuristic. */ + minMarginBps: number; + /** Current time (simulated clock in replay, wall clock in the bot). */ + nowSec: number; + /** + * Skip the `state === "open"` check. Replay rows describe the creation + * moment (or carry the intent's final state when exported from the + * public dataset), so strategies ignore state while the live bot — which + * sees real state transitions — does not. + */ + ignoreState?: boolean; +} + +/** + * Evaluate the shared accept gate. + * + * Precedence matches the reference bot: state, deadline, chain, margin. + * + * @param intent Intent (or bot-local view of one) to evaluate. + * @param gate Gate inputs. + * @returns The decline reason, or `null` when the intent should be attempted. + */ +export function attemptGateReason( + intent: Pick, + gate: AttemptGate, +): GateReason | null { + if (!gate.ignoreState && intent.state !== undefined && intent.state !== "open") return "state"; + if (intent.deadline <= gate.nowSec) return "deadline"; + if (!gate.chains.includes(intent.srcChain)) return "chain"; + if (gate.minMarginBps > 0 && Number(intent.minDstAmount) < 1_000_000 * (gate.minMarginBps / 10_000)) { + return "margin"; + } + return null; +} + +/** + * The moment after which a fill for `intent` is late under the current + * fill-window parameter: the intent deadline, tightened to + * `createdAt + fillWindowSec` when a window is configured. + * + * @param intent Intent being filled. + * @param fillWindowSec Protocol fill window in seconds; `0` disables it. + */ +export function effectiveDeadline(intent: ArchivedIntent, fillWindowSec: number): number { + if (fillWindowSec > 0) return Math.min(intent.deadline, intent.createdAt + fillWindowSec); + return intent.deadline; +} diff --git a/tools/simulator/strategies/margin-threshold.strategy.ts b/tools/simulator/strategies/margin-threshold.strategy.ts new file mode 100644 index 00000000..117e1c25 --- /dev/null +++ b/tools/simulator/strategies/margin-threshold.strategy.ts @@ -0,0 +1,170 @@ +/** + * Reference strategy #2 for the simulation harness (issue #452): + * price-aware margin thresholding with optional Dutch-auction sniping. + * + * Values both legs in USD (archived prices or `usdValueAtCreate`), quotes + * the protocol fee through `src/fees`, checks settlement feasibility + * through `src/routing`, and — when the intent carries an auction — waits + * for `src/auctions`' decay to push the margin over the threshold before + * filling. Declines whenever economics are unknown: never guess a price. + */ +import { dutchAuctionPrice } from "../../../src/auctions/dutch"; +import type { + ArchivedIntent, + QuoteDecision, + SimulatorStrategy, + StrategyContext, +} from "../types"; +import { attemptGateReason, effectiveDeadline } from "./gate"; + +/** Tuning for {@link MarginThresholdStrategy}. */ +export interface MarginThresholdOptions { + /** Threshold in bps of the source-leg value; defaults to `ctx.params.minMarginBps`. */ + minMarginBps?: number; + /** Override the chain allowlist (defaults to `ctx.params.chains`). */ + chains?: readonly string[]; + /** Wait for Dutch-auction decay to improve the margin before filling. */ + useAuction?: boolean; +} + +/** A chosen fill: when to fill and at what dst amount. */ +interface FillPlan { + delaySec: number; + dstAmount: string; +} + +/** + * Quote only when the trade clears a margin threshold after protocol fees. + * + * All arithmetic is deterministic (no randomness, integer-second auction + * search), so the strategy is directly comparable across sweep cells. + */ +export class MarginThresholdStrategy implements SimulatorStrategy { + readonly name = "margin-threshold"; + + constructor(private readonly options: MarginThresholdOptions = {}) {} + + /** @inheritdoc */ + onIntent(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc */ + onQuoteRequest(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc — this strategy plans waits at decision time, not per tick. */ + onTick(_ctx: StrategyContext, _nowSec: number): void { + /* no time-dependent behaviour */ + } + + private decide(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + const threshold = this.options.minMarginBps ?? ctx.params.minMarginBps; + const reason = attemptGateReason(intent, { + chains: this.options.chains ?? ctx.params.chains, + minMarginBps: 0, + nowSec: ctx.nowSec, + ignoreState: true, + }); + if (reason !== null) return null; + + const srcUsd = this.srcValueUsd(ctx, intent); + if (srcUsd === null || srcUsd <= 0) return null; // unknown economics → decline + + const plan = + this.options.useAuction && intent.auction + ? this.planAuctionFill(ctx, intent, srcUsd, threshold) + : this.planImmediateFill(ctx, intent, srcUsd, threshold); + if (plan === null) return null; + + // Settlement must fit inside the fill window (routing module estimate). + const route = ctx.route(intent); + if (ctx.nowSec + plan.delaySec + route.totalTime > effectiveDeadline(intent, ctx.params.fillWindowSec)) { + return null; + } + return { dstAmount: plan.dstAmount, fillDelayMs: plan.delaySec * 1_000 }; + } + + /** Source-leg USD value: captured-at-creation first, archived prices second. */ + private srcValueUsd(ctx: StrategyContext, intent: ArchivedIntent): number | null { + if (intent.usdValueAtCreate !== undefined) return intent.usdValueAtCreate; + return ctx.valueUsd(intent.srcChain, intent.srcTokenSymbol ?? "", intent.srcAmount, intent.createdAt); + } + + /** + * Margin (bps of the source leg) of delivering `dstAmount`. + * + * `null` when the destination leg cannot be priced. + */ + private marginBps( + ctx: StrategyContext, + intent: ArchivedIntent, + dstAmount: string, + srcUsd: number, + ): number | null { + const dstUsd = ctx.valueUsd(intent.dstChain ?? "stellar", intent.dstTokenSymbol ?? "", dstAmount, intent.createdAt); + if (dstUsd === null) return null; + const dstUnits = Number(dstAmount); + if (!Number.isFinite(dstUnits) || dstUnits <= 0) return null; + const fee = ctx.fee(dstAmount, intent); + const feeUsd = dstUsd * (Number(fee.fee) / dstUnits); + return ((srcUsd - dstUsd - feeUsd) / srcUsd) * 10_000; + } + + private planImmediateFill( + ctx: StrategyContext, + intent: ArchivedIntent, + srcUsd: number, + threshold: number, + ): FillPlan | null { + const margin = this.marginBps(ctx, intent, intent.minDstAmount, srcUsd); + if (margin === null || margin < threshold) return null; + return { delaySec: 0, dstAmount: intent.minDstAmount }; + } + + /** + * Find the earliest integer-second moment (≥ now) where the decaying + * auction price clears the margin threshold. Margin is monotonic + * non-decreasing as the auction decays (the solver delivers less), so a + * binary search over the decay range is exact. + * + * @returns The fill plan, or `null` when even full decay misses the threshold. + */ + private planAuctionFill( + ctx: StrategyContext, + intent: ArchivedIntent, + srcUsd: number, + threshold: number, + ): FillPlan | null { + const auction = intent.auction; + if (!auction) return null; + const loSec = ctx.nowSec; + const hiSec = Math.min(auction.decayEnd, effectiveDeadline(intent, ctx.params.fillWindowSec)); + const priceAt = (ts: number): string => + dutchAuctionPrice(auction, ts, intent.minDstAmount); + const marginAt = (ts: number): number | null => + this.marginBps(ctx, intent, priceAt(ts), srcUsd); + + // Fully decayed must still clear the threshold, else decline. + const endMargin = marginAt(hiSec); + if (endMargin === null || endMargin < threshold) return null; + const startMargin = marginAt(loSec); + if (startMargin === null) return null; + if (startMargin >= threshold) return { delaySec: 0, dstAmount: priceAt(loSec) }; + + let low = loSec; + let high = hiSec; + while (low < high) { + const mid = Math.floor((low + high) / 2); + const margin = marginAt(mid); + if (margin !== null && margin >= threshold) high = mid; + else low = mid + 1; + } + const dstAmount = priceAt(low); + // Re-verify: quantised fees can leave the boundary short in theory. + const finalMargin = marginAt(low); + if (finalMargin === null || finalMargin < threshold) return null; + return { delaySec: low - loSec, dstAmount }; + } +} diff --git a/tools/simulator/sweep.spec.ts b/tools/simulator/sweep.spec.ts new file mode 100644 index 00000000..dbcae6bc --- /dev/null +++ b/tools/simulator/sweep.spec.ts @@ -0,0 +1,159 @@ +/** + * Sweep-mode tests (issue #452): grid construction, parameter plumbing, + * comparative determinism, and report formatting. + */ +import { PriceBook } from "./prices"; +import { formatReport, formatSweep } from "./report"; +import { runSweep } from "./sweep"; +import type { ArchivedIntent, SimEvent, SimulatorStrategy } from "./types"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; + +function makeIntent(over: Partial = {}): ArchivedIntent { + return { + intentId: "s-1", + createdAt: 1_000, + deadline: 1_600, + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000000", + dstTokenSymbol: "USDC", + dstChain: "stellar", + minDstAmount: "9960000000", + state: "open", + ...over, + }; +} + +function makeEvents(): SimEvent[] { + return [ + { ts: 1_000, intent: makeIntent() }, + { ts: 1_010, intent: makeIntent({ intentId: "s-2", createdAt: 1_010 }) }, + { ts: 1_020, intent: makeIntent({ intentId: "s-3", createdAt: 1_020, deadline: 1_050 }) }, + ]; +} + +function makePrices(): PriceBook { + return new PriceBook([ + { ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }, + ]); +} + +describe("runSweep (issue #452)", () => { + const grid = { fillWindowSecs: [0, 100], feeBps: [0, 5, 50] }; + + it("runs the Cartesian product, window-major then fee bps", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + params: { seed: 7 }, + prices: makePrices(), + grid, + }); + expect(sweep.strategy).toBe("always-fill"); + expect(sweep.seed).toBe(7); + expect(sweep.rows).toHaveLength(6); + expect(sweep.rows.map((r) => [r.fillWindowSec, r.feeBps])).toEqual([ + [0, 0], + [0, 5], + [0, 50], + [100, 0], + [100, 5], + [100, 50], + ]); + // Every cell replays the full stream with its own parameters. + for (const row of sweep.rows) { + expect(row.report.totals.events).toBe(3); + expect(row.report.params.fillWindowSec).toBe(row.fillWindowSec); + expect(row.report.params.feeBps).toBe(row.feeBps); + } + }); + + it("varies economics with fee bps: higher fees, lower PnL", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + prices: makePrices(), + grid: { fillWindowSecs: [0], feeBps: [0, 50] }, + }); + const [free, costly] = sweep.rows; + expect(free.report.totals.feesPaidUsd).toBe(0); + expect(costly.report.totals.feesPaidUsd).toBeGreaterThan(0); + expect(costly.report.totals.pnlUsd).toBeLessThan(free.report.totals.pnlUsd); + }); + + it("varies lateness with the fill window: tight windows slash more", () => { + const strategyFactory = () => + new (class implements SimulatorStrategy { + readonly name = "slow"; + onIntent(): { dstAmount: string; fillDelayMs: number } { + return { dstAmount: "9960000000", fillDelayMs: 60_000 }; + } + onQuoteRequest(): { dstAmount: string; fillDelayMs: number } { + return { dstAmount: "9960000000", fillDelayMs: 60_000 }; + } + onTick(): void { + /* no-op */ + } + })(); + const sweep = runSweep(makeEvents(), { + strategyFactory, + prices: makePrices(), + grid: { fillWindowSecs: [0, 60], feeBps: [5] }, + }); + // s-3 has deadline 1050 (createdAt 1020 + 30 s): a 60 s delay misses it + // regardless of window — the window itself adds no extra slashes here, + // but the plumbing must reach the engine either way. + for (const row of sweep.rows) { + expect(row.report.params.fillWindowSec).toBe(row.fillWindowSec); + } + expect(sweep.rows[0].report.totals.filled).toBe(2); + expect(sweep.rows[0].report.totals.failedLate).toBe(1); + expect(sweep.rows[1].report.totals.filled).toBe(2); + expect(sweep.rows[1].report.totals.failedLate).toBe(1); + }); + + it("is deterministic: two identical sweeps produce identical JSON", () => { + const options = { + strategyFactory: (params: { minMarginBps: number }) => + new MarginThresholdStrategy({ minMarginBps: params.minMarginBps }), + params: { seed: 11 }, + prices: makePrices(), + grid, + }; + const first = JSON.stringify(runSweep(makeEvents(), options)); + const second = JSON.stringify(runSweep(makeEvents(), options)); + expect(first).toBe(second); + }); +}); + +describe("report formatting (issue #452)", () => { + it("formatReport covers PnL, fill-rate and slash-risk", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + params: { seed: 3 }, + prices: makePrices(), + grid: { fillWindowSecs: [0], feeBps: [5] }, + }); + const text = formatReport(sweep.rows[0].report); + expect(text).toContain("Strategy: always-fill"); + expect(text).toContain("fill rate 100.0%"); + expect(text).toContain("Slash risk:"); + expect(text).toContain("PnL:"); + expect(text).toContain("seed=3 feeBps=5"); + }); + + it("formatSweep renders one markdown row per grid cell", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + prices: makePrices(), + grid: { fillWindowSecs: [0, 100], feeBps: [5, 50] }, + }); + const table = formatSweep(sweep); + // Header + one data row per cell (the |---| separator never starts with "| "). + const rows = table.split("\n").filter((line) => line.startsWith("| ")); + expect(rows).toHaveLength(1 + sweep.rows.length); + expect(table).toContain("| fill window (s) | fee bps |"); + expect(table).toContain("| 0 | 50 |"); + expect(table).toContain("| 100 | 5 |"); + }); +}); diff --git a/tools/simulator/sweep.ts b/tools/simulator/sweep.ts new file mode 100644 index 00000000..916fb836 --- /dev/null +++ b/tools/simulator/sweep.ts @@ -0,0 +1,67 @@ +/** + * Parameter sweep mode for the simulation harness (issue #452): replay the + * same archive across a grid of protocol parameters — fill window × fee + * bps — and produce a comparative report. + */ +import { ReplayEngine } from "./engine"; +import type { EngineOptions } from "./engine"; +import type { PriceBook } from "./prices"; +import type { SimEvent, SimParams, SimReport, SimulatorStrategy } from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; + +/** Sweep axes; the grid is their Cartesian product. */ +export interface SweepGrid { + /** Fill-window values in seconds (`0` = deadline only). */ + fillWindowSecs: readonly number[]; + /** Protocol fee values in basis points. */ + feeBps: readonly number[]; +} + +/** One grid cell's outcome. */ +export interface SweepRow { + fillWindowSec: number; + feeBps: number; + report: SimReport; +} + +/** Comparative result of a sweep, ordered window-major then fee bps. */ +export interface SweepReport { + strategy: string; + seed: number; + rows: SweepRow[]; +} + +/** Options for {@link runSweep}. */ +export interface SweepOptions { + /** Builds a fresh strategy per cell (strategies must not leak state between runs). */ + strategyFactory: (params: SimParams) => SimulatorStrategy; + /** Base parameters; each cell overrides `fillWindowSec` and `feeBps`. */ + params?: Partial; + prices?: PriceBook; + grid: SweepGrid; +} + +/** + * Replay `events` once per grid cell. + * + * Events are parsed/normalized once and reused; each cell gets a fresh + * engine (fresh counters, fresh seeded PRNG), so cells are independent and + * the whole sweep is deterministic for a given seed. + */ +export function runSweep(events: readonly SimEvent[], options: SweepOptions): SweepReport { + const base: SimParams = { ...DEFAULT_SIM_PARAMS, ...options.params }; + const rows: SweepRow[] = []; + for (const fillWindowSec of options.grid.fillWindowSecs) { + for (const feeBps of options.grid.feeBps) { + const params: SimParams = { ...base, fillWindowSec, feeBps }; + const engineOptions: EngineOptions = { + strategy: options.strategyFactory(params), + params, + prices: options.prices, + }; + const report = new ReplayEngine(engineOptions).run(events); + rows.push({ fillWindowSec, feeBps, report }); + } + } + return { strategy: options.strategyFactory(base).name, seed: base.seed, rows }; +} diff --git a/tools/simulator/types.ts b/tools/simulator/types.ts new file mode 100644 index 00000000..689bdd09 --- /dev/null +++ b/tools/simulator/types.ts @@ -0,0 +1,180 @@ +/** + * Core types for the solver simulation harness (issue #452). + * + * The harness replays an archived intent stream against a pluggable + * strategy on a deterministic, simulated clock — no network, no Nest + * bootstrap, no wall-clock reads. All randomness flows through the seeded + * PRNG exposed on {@link StrategyContext}, so identical inputs and seed + * always produce an identical {@link SimReport}. + */ +import type { DutchAuction } from "../../src/auctions/dutch"; +import type { FeeQuote, FeeRule } from "../../src/fees/fee-engine"; +import { SUPPORTED_CHAINS } from "../../src/intents/intents.types"; +import type { Route } from "../../src/intents/intents.types"; +import type { PriceBook } from "./prices"; + +/** Which strategy hook an archived event is dispatched to. */ +export type SimEventKind = "intent" | "quote_request"; + +/** + * One row of the archived intent stream. Field names intentionally mirror + * the public `intents` dataset schema (`src/datasets/schemas.ts`) so rows + * exported by `npm run export:datasets` can be replayed with minimal + * reshaping; timestamps are Unix epoch seconds. + */ +export interface ArchivedIntent { + intentId: string; + /** Creation time — the simulated clock position of this event. */ + createdAt: number; + /** Unix seconds after which the intent can no longer be filled. */ + deadline: number; + srcChain: string; + /** Source contract / mint address. */ + srcToken?: string; + srcTokenSymbol?: string; + /** Source amount in base units. */ + srcAmount: string; + /** Destination contract / mint address (Stellar). */ + dstToken?: string; + dstTokenSymbol?: string; + /** Minimum the user must receive, in dst-token base units. */ + minDstAmount: string; + /** Destination chain; defaults to `stellar`. */ + dstChain?: string; + /** + * Intent state as-of the event. Strategies may ignore it (see + * `AttemptGate.ignoreState`) — replay rows usually describe the creation + * moment, so the default is `open`. + */ + state?: string; + /** Source-leg USD value captured at creation, when the archive has it. */ + usdValueAtCreate?: number; + /** Dutch auction attached to the intent, if any. */ + auction?: DutchAuction; + /** Dispatch selector; defaults to `"intent"`. */ + event?: SimEventKind; +} + +/** A single archived event: intent delivery at simulated time `ts`. */ +export interface SimEvent { + ts: number; + intent: ArchivedIntent; +} + +/** + * A strategy's answer to a quote. `null` declines. A returned decision + * schedules a fill at `now + fillDelayMs`; the engine enforces the fill + * window and the intent deadline. + */ +export interface QuoteDecision { + /** Dst-token base units the solver commits to deliver. Must be ≥ `minDstAmount`. */ + dstAmount: string; + /** Simulated latency from decision to fill. Defaults to `0`. */ + fillDelayMs?: number; +} + +/** Protocol parameters the harness evaluates (sweepable — see `sweep.ts`). */ +export interface SimParams { + /** PRNG seed for {@link StrategyContext.random}. */ + seed: number; + /** Protocol fee in basis points applied to fills (via `src/fees`). */ + feeBps: number; + /** + * Fill window in seconds from intent creation. `0` disables the window + * and only the intent's own deadline constrains fills. + */ + fillWindowSec: number; + /** Chains the simulated solver trades. */ + chains: readonly string[]; + /** Default margin threshold (bps) handed to margin-style strategies. */ + minMarginBps: number; + /** USD penalty charged per accepted-but-unfilled intent (models a bond slash). */ + slashPenaltyUsd: number; + /** Flat USD settlement cost per fill. */ + gasUsdPerFill: number; +} + +/** Default protocol parameters used when none are supplied. */ +export const DEFAULT_SIM_PARAMS: SimParams = { + seed: 42, + feeBps: 5, + fillWindowSec: 0, + chains: [...SUPPORTED_CHAINS], + minMarginBps: 0, + slashPenaltyUsd: 100, + gasUsdPerFill: 0, +}; + +/** + * Everything a strategy may read while deciding. The engine reuses a + * single context object across the run and mutates `nowSec` — strategies + * must not retain the context after a hook returns. + */ +export interface StrategyContext { + /** Current simulated time (Unix seconds of the event being dispatched). */ + nowSec: number; + params: SimParams; + prices: PriceBook; + feeRules: readonly FeeRule[]; + /** Deterministic PRNG in `[0, 1)` seeded from `params.seed`. */ + random(): number; + /** Build the routing module's estimate for settling this intent. */ + route(intent: ArchivedIntent): Route; + /** Quote the protocol fee for delivering `amount` dst base units. */ + fee(amount: string, intent: ArchivedIntent): FeeQuote; + /** USD value of `amount` of `chain`/`symbol` at time `ts`, or null when unknown. */ + valueUsd(chain: string, symbol: string, amountBase: string, ts: number): number | null; +} + +/** + * A pluggable solver strategy under test (issue #452). The three hooks are + * the contract every strategy — the two reference implementations in + * `strategies/` included — must implement. + */ +export interface SimulatorStrategy { + readonly name: string; + /** A new intent arrived on the feed. Return a quote or `null` to decline. */ + onIntent(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null; + /** The protocol asked this solver for a quote (RFQ round). */ + onQuoteRequest(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null; + /** The simulated clock advanced to `nowSec`. */ + onTick(ctx: StrategyContext, nowSec: number): void; +} + +/** Aggregated outcome of one replay — the report the issue asks for. */ +export interface SimulationTotals { + /** Total events replayed. */ + events: number; + /** Events dispatched to `onIntent`. */ + intentEvents: number; + /** Events dispatched to `onQuoteRequest`. */ + quoteRequests: number; + quotesDeclined: number; + quotesSubmitted: number; + filled: number; + /** Accepted but never filled (counted regardless of reason). */ + failedFills: number; + failedLate: number; + failedBelowMin: number; + /** Unfilled acceptances — the slash-risk event count. */ + slashEvents: number; + slashPenaltyUsd: number; + /** `filled / quotesSubmitted`. */ + fillRate: number; + /** `filled / intentEvents`. */ + captureRate: number; + volumeUsd: number; + pnlUsd: number; + feesPaidUsd: number; + gasPaidUsd: number; + /** Fills whose USD legs could not be priced (excluded from USD totals). */ + unknownPriceFills: number; + avgFillLatencyMs: number; +} + +/** Full replay result: strategy identity + parameters + totals. */ +export interface SimReport { + strategy: string; + params: SimParams; + totals: SimulationTotals; +} diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 00000000..45557aea --- /dev/null +++ b/tsconfig.tools.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-tools", + "declaration": false, + "declarationMap": false, + "lib": ["ES2022", "DOM"] + }, + "include": ["tools/**/*", "src/**/*"] +}