UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

335 lines (297 loc) 14.4 kB
/** * # bus/synthetic — a seeded, deterministic synthetic session generator (public test substrate) * * A publication-safe substrate for grading, replay, and engine tests: given a seed and a few * session parameters, it emits a **full bus** — `META`, `SPOT` ticks along a seeded random * walk with **regime segments** (trend / chop / spike), an option `BOOK` around spot whose * spreads widen on velocity and that occasionally goes one-sided or dark, and `HEARTBEAT` * cadence events. It reveals no strategy and no real market data (ARCHITECTURE §7): tickers * are generic and the tape is pure PRNG. * * **Determinism (RUNTIME §0):** all randomness is a single {@link mulberry32} stream seeded * from the config; all time is derived from `date` + bar index (no wall clock). The same * config therefore yields a byte-identical bus — the same events in the same order with the * same `seq`. Feed the result through {@link ../bus/write.ts serializeBus} to get the bytes. */ import type { BusEvent, NewBusEvent, OptionQuote, SessionInstrument } from "./types.ts"; import { type Mode, type SessionPhase } from "./types.ts"; // ───────────────────────────────────────────────────────────────────────────── // Config // ───────────────────────────────────────────────────────────────────────────── export interface SyntheticConfig { /** Seeds the single PRNG stream — the sole source of randomness. */ readonly seed: number; /** Session calendar date, `YYYY-MM-DD`. Anchors every timestamp deterministically. */ readonly date: string; /** The underlier symbol (generic only: `SPX` / `SPY` / `QQQ` / `AAPL`). */ readonly instrument: string; /** Opening spot. */ readonly spot0: number; /** Per-bar volatility as a return fraction (e.g. `0.0008`). Scaled by the active regime. */ readonly vol: number; /** Session length in minutes. */ readonly sessionMinutes: number; /** Bar cadence in seconds — one SPOT (and one BOOK, if options) per bar. */ readonly barSeconds: number; /** Order mode stamped on META. Default `sim`. */ readonly mode?: Mode; /** Emit an option BOOK each bar. `false` ⇒ an equity-only session (SPOT tape, no legs). * Default `true`. */ readonly withOptions?: boolean; /** Strikes each side of the money (total legs = `(2·strikes + 1)·2` C+P). Default 3. */ readonly strikes?: number; /** Strike grid step. Default 1. */ readonly strikeStep?: number; /** The expiry tag stamped on each BOOK (e.g. `0dte`). Default `0dte`. */ readonly expiry?: string; /** Heartbeat cadence in seconds. Default 60. */ readonly heartbeatEverySeconds?: number; } // ───────────────────────────────────────────────────────────────────────────── // PRNG (mulberry32) + Gaussian // ───────────────────────────────────────────────────────────────────────────── /** A mulberry32 PRNG: 32-bit-seeded, uniform in [0,1). Pure, fast, fully reproducible — * the one randomness source on this deterministic path (no unseeded RNG, RUNTIME §0). */ export function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Standard-normal draw via Box–Muller, consuming two uniforms from `rng`. */ function gaussian(rng: () => number): number { const u1 = Math.max(rng(), 1e-12); const u2 = rng(); return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); } // ───────────────────────────────────────────────────────────────────────────── // Rounding helpers (deterministic) // ───────────────────────────────────────────────────────────────────────────── const r2 = (x: number): number => Math.round(x * 100) / 100; // ───────────────────────────────────────────────────────────────────────────── // Regimes // ───────────────────────────────────────────────────────────────────────────── type Regime = "trend" | "chop" | "spike"; interface RegimeState { regime: Regime; /** Bars remaining in this segment before a new one is drawn. */ left: number; /** Signed drift for the active segment (per bar). */ drift: number; /** Volatility multiplier for the active segment. */ volMult: number; /** Log-price anchor a chop segment mean-reverts toward. */ anchorLog: number; } /** Draw the next regime segment (kind, duration, drift, vol multiplier) from the PRNG. */ function nextRegime(rng: () => number, vol: number, curLog: number): RegimeState { const pick = rng(); const dir = rng() < 0.5 ? -1 : 1; if (pick < 0.5) { // chop: flat drift, damped vol, mean-reverting to where it started return { regime: "chop", left: 8 + Math.floor(rng() * 20), drift: 0, volMult: 0.6, anchorLog: curLog }; } if (pick < 0.9) { // trend: a persistent signed drift, normal vol return { regime: "trend", left: 10 + Math.floor(rng() * 25), drift: dir * vol * (1.2 + rng() * 1.8), volMult: 1, anchorLog: curLog, }; } // spike: brief, violent, directional return { regime: "spike", left: 2 + Math.floor(rng() * 3), drift: dir * vol * (6 + rng() * 8), volMult: 3, anchorLog: curLog, }; } // ───────────────────────────────────────────────────────────────────────────── // Option book synthesis // ───────────────────────────────────────────────────────────────────────────── /** Build the option legs around `spot` for one bar. Spreads widen with `ewmaVel` (velocity) * and with distance from the money; a small PRNG-driven fraction of legs go one-sided or * fully dark (the MM-pull fingerprint). Consumes the PRNG in a fixed per-leg order. */ function buildLegs( rng: () => number, spot: number, ewmaVel: number, remainingBars: number, totalBars: number, strikes: number, step: number, vol: number, ): OptionQuote[] { const legs: OptionQuote[] = []; const center = Math.round(spot / step) * step; const tvScale = step * (strikes + 2); const timeMag = Math.sqrt(Math.max(remainingBars, 1) / totalBars); for (let k = -strikes; k <= strikes; k++) { const strike = r2(center + k * step); for (const right of ["C", "P"] as const) { const intrinsic = right === "C" ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0); const dist = Math.abs(spot - strike); const tv = spot * vol * timeMag * 8 * Math.exp(-0.5 * (dist / tvScale) ** 2); const mid = intrinsic + tv; // half-spread: a floor, widened by velocity and by distance from the money let half = Math.max(0.02, 0.01 * mid); half *= 1 + 40 * ewmaVel; half *= 1 + 0.5 * (dist / (strikes * step + step)); half = Math.min(half, Math.max(mid * 0.5, 0.05)); const bidRaw = Math.max(0, mid - half); const askRaw = mid + half; // darkness: rare full-dark, occasional one-sided const droll = rng(); let bid: number | null; let ask: number | null; let bsz: number | null; let asz: number | null; if (droll < 0.02) { // fully dark bid = null; ask = null; bsz = null; asz = null; // keep PRNG cadence stable across branches: still burn the size/last draws rng(); rng(); rng(); } else if (droll < 0.1) { // one-sided: drop bid or ask const dropBid = rng() < 0.5; const szB = 1 + Math.floor(rng() * 40); const szA = 1 + Math.floor(rng() * 40); if (dropBid) { bid = null; bsz = null; ask = r2(askRaw); asz = szA; } else { bid = r2(bidRaw); bsz = szB; ask = null; asz = null; } } else { // two-sided bid = r2(bidRaw); ask = r2(askRaw); bsz = 1 + Math.floor(rng() * 40); asz = 1 + Math.floor(rng() * 40); rng(); // keep draw count aligned with the one-sided branch (dropBid + 2 sizes) } // last trade prints sometimes (independent of current book side) const lastRoll = rng(); const includeLast = lastRoll < 0.4 && (intrinsic > 0 || tv > 0.03); legs.push({ strike, right, bid, ask, bsz, asz, ...(includeLast ? { last: r2(mid) } : {}), }); } } return legs; } // ───────────────────────────────────────────────────────────────────────────── // Session generation // ───────────────────────────────────────────────────────────────────────────── /** The session phase for bar `i` of `n` bars — open on the first, close on the last. */ function phaseFor(i: number, n: number): SessionPhase { if (i === 0) return "open"; if (i >= n - 1) return "close"; return "regular"; } /** Parse `YYYY-MM-DD` into the session's opening epoch ms. The time-of-day anchor (13:30 UTC) * is a fixed, timezone-free constant chosen only so timestamps are deterministic; the bus is * its own clock (RUNTIME §0), so the absolute wall value is immaterial. */ function sessionStartMs(date: string): number { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); if (m === null) throw new Error(`synthetic: date must be YYYY-MM-DD, got ${JSON.stringify(date)}`); const [, y, mo, d] = m; return Date.UTC(Number(y), Number(mo) - 1, Number(d), 13, 30, 0, 0); } /** * Generate a full synthetic session as an in-order, `seq`-stamped {@link BusEvent} array. * Serialize with {@link ../bus/write.ts serializeBus} (or {@link ../bus/write.ts writeBusFile}). * Same config ⇒ byte-identical output. */ export function generateSession(cfg: SyntheticConfig): readonly BusEvent[] { const mode: Mode = cfg.mode ?? "sim"; const withOptions = cfg.withOptions ?? true; const strikes = cfg.strikes ?? 3; const step = cfg.strikeStep ?? 1; const expiry = cfg.expiry ?? "0dte"; const hbEvery = cfg.heartbeatEverySeconds ?? 60; if (cfg.barSeconds <= 0) throw new Error("synthetic: barSeconds must be positive"); if (cfg.sessionMinutes <= 0) throw new Error("synthetic: sessionMinutes must be positive"); const rng = mulberry32(cfg.seed); const startMs = sessionStartMs(cfg.date); const nBars = Math.max(1, Math.floor((cfg.sessionMinutes * 60) / cfg.barSeconds)); const events: BusEvent[] = []; let seq = 0; const emit = (ev: NewBusEvent): void => { events.push({ seq: seq++, ...ev } as BusEvent); }; // — META (session header) — const assetClass: SessionInstrument["assetClass"] = withOptions ? "option-underlier" : "equity"; const instrument: SessionInstrument = { symbol: cfg.instrument, assetClass, role: "signal" }; emit({ ts: startMs, stream: "META", type: "session", session_date: cfg.date, instruments: [instrument], mode, bus_schema: 5, // INPUT-TAPE PINNED LITERAL (clock-honest wakes §1): an input tape never carries a deliberation record; re-stamping the corpus would re-mint every frozen tape hash (ADR-0018). Only graded OUTPUT buses follow BUS_SCHEMA. }); // — the walk — let spot = cfg.spot0; let ewmaVel = 0; let reg = nextRegime(rng, cfg.vol, Math.log(spot)); for (let i = 0; i < nBars; i++) { const ts = startMs + i * cfg.barSeconds * 1000; if (reg.left <= 0) reg = nextRegime(rng, cfg.vol, Math.log(spot)); reg.left -= 1; const z = gaussian(rng); let ret = reg.drift + cfg.vol * reg.volMult * z; if (reg.regime === "chop") ret += -0.15 * (Math.log(spot) - reg.anchorLog); spot = Math.max(0.01, spot * (1 + ret)); ewmaVel = 0.7 * ewmaVel + 0.3 * Math.abs(ret); // SPOT emit({ ts, stream: "TICK", type: "SPOT", instrument: cfg.instrument, px: r2(spot) }); // BOOK if (withOptions) { const legs = buildLegs(rng, spot, ewmaVel, nBars - i, nBars, strikes, step, cfg.vol); emit({ ts, stream: "TICK", type: "BOOK", instrument: cfg.instrument, underlier_px: r2(spot), expiry, legs, }); } // HEARTBEAT on cadence if ((i * cfg.barSeconds) % hbEvery === 0) { emit({ ts, stream: "TICK", type: "HEARTBEAT", phase: phaseFor(i, nBars) }); } } // — closing heartbeat, if the cadence did not already land on the last bar — if (((nBars - 1) * cfg.barSeconds) % hbEvery !== 0) { const ts = startMs + (nBars - 1) * cfg.barSeconds * 1000; emit({ ts, stream: "TICK", type: "HEARTBEAT", phase: "close" }); } return events; }