Skip to content
Runner · ExamplesLadderrung 17

Standing sigma-ladder market-maker (grid + recenter)

market-maker on a ±kσ grid with reconcile + recenter

Mirrors runner/src/plugins/17-exampleLadder

What this teaches

  • Desired-vs-tracked reconciliation => idempotent ticks (see reconcile.ts)
  • Multi-rung ±kσ placement via strikeAtSigma (see ladder.ts)
  • Drift-triggered recenter (redeem stale + mint fresh, atomic)
  • Drawdown-scaled notional from navPerShare vs highWaterMark (see sizing.ts)
  • Self-tracked leg size: MarketKey has no quantity, so we keep our own ledger from VaultMintBinary events (onExecuted) and lifecycle closures

New vs 09-exampleMultiAction

  • MultiAction bundles two UNRELATED opens ONCE and never tracks/redeems/ recenters. This runs a living book that converges to a target each tick. Edge model honesty: a real maker would skew rungs to inventory and quote a spread; this is the mechanical skeleton (placement + reconcile + recenter + risk-off). The alpha is yours.
import { noop, type Action, type StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { hours, minutes } from "@automark/sdk/duration";
import { buildLadder, driftExceeded } from "./ladder";
import { diffLadder, legKey } from "./reconcile";
import { ladderNotional } from "./sizing";

// VaultMintBinary event payload (u64 fields arrive as strings).
interface MintBinaryEvent {
  oracle_id: string;
  strike: string;
  is_up: boolean;
  quantity: string;
}

type QtyMap = Record<string, string>; // legKey -> open qty (bigint as string)

const qtyStateKey = (oracleId: string) => `legqty:${oracleId}`;
const centerStateKey = (oracleId: string) => `center:${oracleId}`;

export default function createExampleLadder(): StrategyPlugin {
  const vaultId = process.env.VAULT_ID;
  if (!vaultId) throw new Error("exampleLadder: VAULT_ID not set");

  const asset = process.env.LADDER_ASSET ?? "BTC";
  const expiryWindowMs = Number(process.env.EXPIRY_WINDOW_MS ?? hours(6));
  const ks = (process.env.LADDER_KS ?? "0.5,1,1.5")
    .split(",")
    .map((s) => Number(s.trim()))
    .filter((n) => Number.isFinite(n) && n > 0);
  const recenterBandBps = Number(process.env.RECENTER_BAND_BPS ?? 100);
  const baseNotionalBps = Number(process.env.BASE_NOTIONAL_BPS ?? 500);

  return {
    name: "exampleLadder",
    vaultId,
    triggers: [{ kind: "cron", everySeconds: 60 }],

    async decide(ctx) {
      if (ctx.vault.isFrozen) return [noop("frozen")];
      if (!ctx.vault.canMintBinary || !ctx.vault.canRedeemBinary) {
        return [noop("needs MINT_BINARY + REDEEM_BINARY permissions")];
      }

      const market = await Market.find({
        asset,
        expiryAfterMs: ctx.now + minutes(5),
        expiringWithinMs: expiryWindowMs,
        client: ctx.suiClient,
      });
      const p = await market.price();

      // 1) Reconcile self-tracked leg size from last tick's closures.
      const qmap = await ctx.state.getOrDefault<QtyMap>(qtyStateKey(market.id), {});
      let mutated = false;
      for (const c of ctx.lifecycle.closures) {
        if (c.shape !== "binary" || c.marketId !== market.id) continue;
        if (c.strike === undefined || c.isUp === undefined) continue;
        const k = legKey(c.strike, c.isUp);
        if (c.remainingQuantity === 0n) delete qmap[k];
        else qmap[k] = c.remainingQuantity.toString();
        mutated = true;
      }
      if (mutated) await ctx.state.set(qtyStateKey(market.id), qmap);
      const selfQty = new Map(
        Object.entries(qmap).map(([k, v]) => [k, BigInt(v)] as const),
      );

      // 2) Anchored center + drift. Establish the anchor on first run so the
      //    grid stays stable between recenters (no churn from forward jitter).
      const storedCenter = await ctx.state.get<string>(centerStateKey(market.id));
      const center = storedCenter ? BigInt(storedCenter) : p.forwardRaw;
      const recenter = storedCenter
        ? driftExceeded(p.forwardRaw, center, recenterBandBps)
        : true;
      const anchorForward = recenter ? p.forwardRaw : center;

      // 3) Size the whole ladder (drawdown-aware, clamped to headroom).
      const notional = ladderNotional({
        nav: ctx.vault.nav,
        navPerShare: ctx.vault.navPerShare,
        highWaterMark: ctx.vault.highWaterMark,
        baseNotionalBps,
        exposureHeadroom: ctx.vault.exposureHeadroom,
      });
      if (notional === 0n) return [noop("no notional (headroom/drawdown)")];

      // 4) Build desired grid, diff against what's open on-chain for this oracle.
      const desired = buildLadder(
        {
          forwardRaw: anchorForward,
          sviSigmaRaw: p.sviSigmaRaw,
          expiresAtMs: market.expiresAtMs,
          nowMs: ctx.now,
          ks,
          notional,
        },
        market,
      );
      const tracked = ctx.vault.trackedMarketKeys.filter((t) => t.oracle_id === market.id);
      const { toMint, toRedeem } = diffLadder(desired, tracked, selfQty);

      if (recenter) await ctx.state.set(centerStateKey(market.id), p.forwardRaw.toString());

      // Redeem stale legs first, then mint missing legs — one atomic PTB.
      const actions: Action[] = [];
      for (const r of toRedeem) {
        if (r.qty > 0n) {
          actions.push({
            kind: "vault.redeemBinary",
            params: { marketId: market.id, strike: r.strike, isUp: r.isUp, quantity: r.qty },
          });
        } else {
          // Orphan: a tracked leg fell off the grid but our self-tracked ledger
          // has no size for it (crash between submit & persist, or it predates
          // this runtime session). We log instead of silently dropping it. A
          // production maker would RESEED the size from on-chain — see
          // 18-exampleExpiryJanitor's readOpenPositions — rather than skip.
          ctx.logger.warn("ladder: off-grid leg with no known size — skipping redeem", {
            strike: r.strike.toString(),
            isUp: r.isUp,
          });
        }
      }
      for (const l of toMint) {
        if (l.qty > 0n) {
          actions.push({
            kind: "vault.mintBinary",
            params: { marketId: market.id, strike: l.strike, isUp: l.isUp, quantity: l.qty },
          });
        }
      }

      if (actions.length === 0) return [noop("ladder in sync")];
      ctx.logger.info("ladder reconcile", {
        marketId: market.id,
        mint: toMint.length,
        redeem: toRedeem.filter((r) => r.qty > 0n).length,
        recenter,
        notional: notional.toString(),
      });
      return actions;
    },

    // Increment self-tracked leg size from the real minted quantities. The
    // decrement side runs 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 key = qtyStateKey(m.oracle_id);
        const qmap = await ctx.state.getOrDefault<QtyMap>(key, {});
        const k = legKey(BigInt(m.strike), m.is_up);
        const prev = qmap[k] ? BigInt(qmap[k]) : 0n;
        qmap[k] = (prev + BigInt(m.quantity)).toString();
        await ctx.state.set(key, qmap);
      }
    },
  };
}

Environment variables

  • VAULT_ID
  • LADDER_ASSET default "BTC"
  • EXPIRY_WINDOW_MS market to make on, expiring within this (default 6h)
  • LADDER_KS csv sigma rungs, default "0.5,1,1.5"
  • RECENTER_BAND_BPS forward drift before recenter (default 100 = 1%)
  • BASE_NOTIONAL_BPS base ladder notional as bps of NAV (default 500 = 5%)