Chaos / control group (on-chain seeded randomness)
random-but-valid actions; baseline to beat
Mirrors runner/src/plugins/15-exampleChaos
What this teaches
- Auditable randomness: the seed comes from the latest checkpoint digest, not Math.random — so every decision is replayable + reconstructable from public chain data alone
- A counter-based PRNG turns ONE digest into many independent draws
- "Random within the valid action space": sizes against the vault caps, picks real markets, snaps strikes — an apples-to-apples baseline, not garbage that just spams aborts
- MarketKey carries no quantity, so the plugin tracks its own open size (increment from VaultMintBinary events, decrement from lifecycle closures) — the pattern any redeem-capable strategy needs
- onExecuted builds a reproducible coin-flip PnL series to benchmark every real strategy against
- A plugin can target a DIFFERENT vault than the others (OTHER_VAULT_ID) Why you'd run it:
- Benchmark: a real strategy must beat coin-flip trading to claim edge
- Smoke test: exercises sizing → auto-fund → mint/redeem → lifecycle PnL end-to-end with no real signal needed
- Fuzzer: random-but-valid actions surface contract/runtime edge cases
New vs exampleLocalDb
- Randomness as a first-class, auditable input (vs a fixed/external signal)
- Self-tracked position size (MarketKey has no quantity field)
- Runs on its OWN vault (OTHER_VAULT_ID), separate from your strategy's
import { createHash } from "node:crypto";
import { noop, type StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { bigintMin } from "@automark/sdk/math";
import { minutes, hours } from "@automark/sdk/duration";
// One seed → a stream of independent uniform draws in [0, 1).
// draw(i) = uint64(sha256(seed || i)) / 2^64. Deterministic given the seed,
// so anyone holding the checkpoint digest replays the exact same sequence.
function seededStream(seed: string): () => number {
let i = 0;
return () => {
const h = createHash("sha256").update(`${seed}:${i++}`).digest();
return Number(h.readBigUInt64BE(0)) / 2 ** 64;
};
}
// Open-position size lives only in this plugin's state (MarketKey has no
// quantity). Key by the on-chain identity of a binary position.
function qtyKey(oracleId: string, strike: bigint, isUp: boolean): string {
return `qty:${oracleId}:${strike}:${isUp}`;
}
// Shape of the VaultMintBinary event payload (u64 fields arrive as strings).
interface MintBinaryEvent {
oracle_id: string;
strike: string;
is_up: boolean;
quantity: string;
}
export default function createExampleChaos(): StrategyPlugin {
// Plugins are independent: each names its own vaultId, and they only need
// to share the same operator (the key that signs). Here the chaos baseline
// deliberately points at a SEPARATE vault — OTHER_VAULT_ID — so the
// coin-flip benchmark never touches your real strategy's capital.
const vaultId = process.env.OTHER_VAULT_ID;
if (!vaultId) throw new Error("exampleChaos: OTHER_VAULT_ID not set");
const seedMode = process.env.SEED_MODE ?? "onchain";
const actProb = Number(process.env.ACT_PROB ?? "0.2");
const redeemProb = Number(process.env.REDEEM_PROB ?? "0.3");
const maxSizeFrac = Number(process.env.MAX_SIZE_FRAC ?? "0.25");
const assets = (process.env.ASSETS ?? "BTC")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
name: "exampleChaos",
vaultId,
triggers: [{ kind: "cron", everySeconds: 60 }],
async decide(ctx) {
if (ctx.vault.isFrozen) return [noop("frozen")];
// ── Reconcile self-tracked size + accrue realized PnL ──────────────
// closures = what closed since the LAST tick (empty on first tick,
// restarts, and dry-runs — so this never double-counts on a sim).
for (const c of ctx.lifecycle.closures) {
if (c.shape !== "binary" || c.strike === undefined || c.isUp === undefined) continue;
const k = qtyKey(c.marketId, c.strike, c.isUp);
if (c.remainingQuantity === 0n) await ctx.state.delete(k);
else await ctx.state.set(k, c.remainingQuantity.toString());
if (c.pnlBps !== undefined) {
const n = await ctx.state.getOrDefault<number>("closes", 0);
const cum = await ctx.state.getOrDefault<number>("cumPnlBps", 0);
await ctx.state.set("closes", n + 1);
await ctx.state.set("cumPnlBps", cum + c.pnlBps);
ctx.logger.info("chaos pnl", {
pnlBps: c.pnlBps,
closes: n + 1,
avgPnlBps: Math.round((cum + c.pnlBps) / (n + 1)),
});
}
}
// ── Entropy source ─────────────────────────────────────────────────
// onchain: latest checkpoint digest — public, nobody controls it
// precisely, replayable later via the sequence number. local:
// Math.random — fast but NOT reproducible (quick sanity only).
let seed: string;
if (seedMode === "local") {
seed = `local:${Math.random()}`;
ctx.logger.info("chaos seed", { mode: "local" });
} else {
const seq = await ctx.suiClient.getLatestCheckpointSequenceNumber();
const cp = await ctx.suiClient.getCheckpoint({ id: seq });
seed = cp.digest;
ctx.logger.info("chaos seed", { mode: "onchain", checkpoint: seq, digest: cp.digest });
}
const rand = seededStream(seed);
// ── Draw 1: act at all? ────────────────────────────────────────────
if (rand() > actProb) return [noop(`idle (actProb=${actProb})`)];
// ── Draw 2: close an open position instead of opening? ──────────────
const open = ctx.vault.trackedMarketKeys;
if (open.length > 0 && rand() < redeemProb) {
const pos = open[Math.floor(rand() * open.length)];
const tracked = await ctx.state.get<string>(qtyKey(pos.oracle_id, pos.strike, pos.is_up));
const quantity = tracked ? BigInt(tracked) : 0n;
if (quantity > 0n) {
ctx.logger.info("chaos redeem", {
oracle: pos.oracle_id,
strike: pos.strike.toString(),
isUp: pos.is_up,
quantity: quantity.toString(),
});
return [
{
kind: "vault.redeemBinary",
params: { marketId: pos.oracle_id, strike: pos.strike, isUp: pos.is_up, quantity },
},
];
}
// No size we can attribute (opened before this session / by another
// plugin) — fall through and open instead.
}
// ── Draws 3..6: open a random-but-valid binary ─────────────────────
const asset = assets[Math.floor(rand() * assets.length)];
let market: Awaited<ReturnType<typeof Market.find>>;
try {
market = await Market.find({
asset,
expiryAfterMs: ctx.now + minutes(15),
expiringWithinMs: hours(6),
client: ctx.suiClient,
});
} catch {
return [noop(`no market for ${asset}`)];
}
const p = await market.price();
const isUp = rand() < 0.5;
// 0.5%..5% off forward — the floor avoids a degenerate at-the-forward
// strike (EFairPriceAlreadySettled). Drop the floor to use this as a
// fuzzer that intentionally probes that edge.
const offsetBps = 50 + Math.floor(rand() * 450);
const strike = isUp
? market.strikeAbove(p.forwardRaw, { pctBps: offsetBps })
: market.strikeBelow(p.forwardRaw, { pctBps: offsetBps });
const ceiling = bigintMin(ctx.vault.maxSinglePosition, ctx.vault.exposureHeadroom);
const sizeBps = BigInt(Math.round(rand() * maxSizeFrac * 10_000));
const quantity = (ceiling * sizeBps) / 10_000n;
if (quantity === 0n) return [noop("no headroom")];
ctx.logger.info("chaos open", { asset, isUp, offsetBps, quantity: quantity.toString() });
return [
{ kind: "vault.mintBinary", params: { marketId: market.id, strike, isUp, quantity } },
];
},
// Increment self-tracked size from the real minted quantity. Decrement
// happens in decide() from lifecycle closures.
async onExecuted(ctx, result) {
if (result.outcome !== "submitted") return;
for (const ev of result.events ?? []) {
if (!ev.type.endsWith("::vault::VaultMintBinary")) continue;
const m = ev.parsedJson as MintBinaryEvent;
const k = qtyKey(m.oracle_id, BigInt(m.strike), m.is_up);
const prev = await ctx.state.get<string>(k);
const next = (prev ? BigInt(prev) : 0n) + BigInt(m.quantity);
await ctx.state.set(k, next.toString());
}
},
};
}
Environment variables
- OTHER_VAULT_ID the benchmark vault (separate from your strategy's — see below)
- SEED_MODE onchain | local (default onchain)
- ACT_PROB 0..1 (default 0.2)
- REDEEM_PROB 0..1 (default 0.3)
- MAX_SIZE_FRAC 0..1 (default 0.25)
- ASSETS csv (default "BTC")