Skip to content
Runner · ExamplesLadderrung 12

Dynamic sizing from external conviction + audit log + error counter

dynamic sizing + audit log

Mirrors runner/src/plugins/12-exampleAuditLog

What this teaches

  • Trade size is a function of an external signal's conviction (0..1)
  • onExecuted persists every successful trade into ctx.state as a trade ledger you can later query/export
  • state.keys() pattern to summarize the ledger
  • onError increments a per-error-kind counter to feed dashboards or trigger external alerts when one kind of failure spikes

New vs exampleMultiTrigger

  • Conviction-weighted sizing (was halving constant)
  • Persistent trade audit log via onExecuted + state.keys()
  • onError counters split by phase (client errors vs on-chain aborts)
import { noop, type StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { bigintMin } from "@automark/sdk/math";
import { hours, minutes } from "@automark/sdk/duration";

interface Signal {
  asset: string;
  direction: "up" | "down";
  conviction: number;       // 0..1 — higher = bigger trade
}

interface TradeLog {
  digest: string;
  recordedAtMs: number;
  marketId: string;
  strike: string;
  quantity: string;
  isUp: boolean;
  gasMist: string;
}

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

  const signalUrl =
    process.env.SIGNAL_URL ?? "https://your-backend.example/signal";

  return {
    name: "exampleAuditLog",
    vaultId,
    triggers: [{ kind: "cron", everySeconds: 90 }],

    async decide(ctx) {
      if (ctx.vault.isFrozen) return [noop("frozen")];

      // `logger.timing` measures + logs the fetch duration. Useful when the
      // signal endpoint is the slowest step of the tick — you'll see it
      // immediately if latency creeps up.
      const signal = await ctx.logger.timing(
        "fetch signal",
        async () => (await (await fetch(signalUrl)).json()) as Signal,
      );
      const conviction = Math.max(0, Math.min(1, signal.conviction));
      if (conviction < 0.3) {
        return [noop(`low conviction ${conviction}`)];
      }

      const { maxSinglePosition, exposureHeadroom } = ctx.vault;
      const ceiling = bigintMin(maxSinglePosition, exposureHeadroom);
      const convictionBps = BigInt(Math.round(conviction * 10_000));
      const quantity = (ceiling * convictionBps) / 10_000n;
      if (quantity === 0n) return [noop("no size")];

      // Conviction-weighted trades pick from a mid-range rolling window
      // (15min to 1h) — far enough out to let conviction signals matter,
      // not so far that pricing is noisy.
      const market = await Market.find({
        asset: signal.asset,
        expiryAfterMs: ctx.now + minutes(15),
        expiringWithinMs: hours(1),
        client: ctx.suiClient,
      });
      const p = await market.price();
      const isUp = signal.direction === "up";

      return [
        {
          kind: "vault.mintBinary",
          params: {
            marketId: market.id,
            strike: isUp
              ? market.strikeAbove(p.forwardRaw, { pctBps: 300 })
              : market.strikeBelow(p.forwardRaw, { pctBps: 300 }),
            isUp,
            quantity,
          },
        },
      ];
    },

    async onExecuted(ctx, result) {
      if (result.outcome !== "submitted") return;

      // Persist each mint into the ledger
      for (const action of result.actions) {
        if (action.kind !== "vault.mintBinary") continue;
        const entry: TradeLog = {
          digest: result.digest!,
          recordedAtMs: ctx.now,
          marketId: action.params.marketId,
          strike: action.params.strike.toString(),
          quantity: action.params.quantity.toString(),
          isUp: action.params.isUp,
          gasMist: result.gasUsed?.toString() ?? "0",
        };
        await ctx.state.set(`trade:${result.digest}`, entry);
      }

      // Periodic ledger summary (every ~10 trades)
      const tradeKeys = (await ctx.state.keys()).filter((k) => k.startsWith("trade:"));
      if (tradeKeys.length % 10 === 0) {
        ctx.logger.info("ledger summary", { tradeCount: tradeKeys.length });
      }
    },

    // Error counter keyed by phase — lets ops see at a glance whether the
    // recent failures are RPC issues (transient) or on-chain rejections
    // (logic problem). In a real plugin, push these counters to your
    // metrics pipeline or trigger a webhook when one phase spikes.
    async onError(ctx, err) {
      const key = `errCount:${err.phase}`;
      const current = await ctx.state.getOrDefault<number>(key, 0);
      await ctx.state.set(key, current + 1);
      ctx.logger.warn("execution error counted", {
        phase: err.phase,
        digest: err.digest,
        error: err.error,
        totalForPhase: current + 1,
      });
    },
  };
}
Go deeperLifecycle hooksonExecuted and onError react to the result of each tick without breaking the next one.

Environment variables

  • VAULT_ID
  • SIGNAL_URL