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.
251 lines (250 loc) • 12 kB
JavaScript
/**
* # 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 {} from "./types.js";
// ─────────────────────────────────────────────────────────────────────────────
// 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) {
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) {
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) => Math.round(x * 100) / 100;
/** Draw the next regime segment (kind, duration, drift, vol multiplier) from the PRNG. */
function nextRegime(rng, vol, curLog) {
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, spot, ewmaVel, remainingBars, totalBars, strikes, step, vol) {
const legs = [];
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"]) {
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;
let ask;
let bsz;
let asz;
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, n) {
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) {
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) {
const 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 = [];
let seq = 0;
const emit = (ev) => {
events.push({ seq: seq++, ...ev });
};
// — META (session header) —
const assetClass = withOptions ? "option-underlier" : "equity";
const instrument = { 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;
}