Skip to content
Runner · ExamplesLadderrung 15

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")