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