Skip to content
Runner · ExamplesLadderrung 13

Full pipeline: signal + sizing + cooldown + failure recovery + multi-action + audit log + multi-trigger

full pipeline (everything combined)

Mirrors runner/src/plugins/13-examplePipeline

What this teaches

  • How all the moving pieces fit together in a single "production-shaped" plugin (still a toy strategy — real edge is YOUR job)
  • Pattern: decide() is the brain, onExecuted is the bookkeeper
  • State is used both for the cooldown gate AND the audit log AND failure learning — partitioned by key prefix

New vs exampleAuditLog

  • Combines everything: ban-aware decisions, multi-action tx, conviction sizing, cooldown set on success only, audit log, ledger summary, multi-trigger
import { noop, type StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { bigintMin } from "@automark/sdk/math";
import { minutes } from "@automark/sdk/duration";

interface Signal {
  asset: string;
  direction: "up" | "down";
  conviction: number;
  hedge: boolean;
}

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

const COOLDOWN_MS = minutes(10);
const BAN_MS = minutes(15);
const MIN_CONVICTION = 0.4;

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

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

  const banKey = (marketId: string, strike: bigint) => `ban:${marketId}:${strike}`;

  return {
    name: "examplePipeline",
    vaultId,
    triggers: [
      { kind: "cron", everySeconds: 90 },
      { kind: "event", topic: "deposit_processed" },
    ],

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

      const lastSuccessMs = await ctx.state.getOrDefault<number>("lastSuccessMs", 0);
      if (ctx.now - lastSuccessMs < COOLDOWN_MS) {
        return [noop("cooldown")];
      }

      // Signal
      const signal = (await (await fetch(signalUrl)).json()) as Signal;
      if (signal.conviction < MIN_CONVICTION) {
        return [noop(`conviction ${signal.conviction} < ${MIN_CONVICTION}`)];
      }

      // Sizing — `bigintMin` picks the binding cap. Conviction scales linearly.
      const { maxSinglePosition, exposureHeadroom } = ctx.vault;
      const ceiling = bigintMin(maxSinglePosition, exposureHeadroom);
      const convictionBps = BigInt(Math.round(signal.conviction * 10_000));
      const quantity = (ceiling * convictionBps) / 10_000n;
      if (quantity === 0n) return [noop("no size")];

      // Market + strike (with ban check)
      // Pattern B: ABSOLUTE window — only trade markets expiring before
      // end of UTC day. The window is fixed per tick (recomputed each call
      // because EOD itself moves day-to-day), not sliding minute-by-minute
      // like the rolling pattern. Use this shape when your strategy is
      // calendar-bound: intraday only, event windows, governance schedules.
      const endOfUtcDay = new Date();
      endOfUtcDay.setUTCHours(23, 59, 59, 999);
      const eodMs = endOfUtcDay.getTime();

      const market = await Market.find({
        asset: signal.asset,
        expiringBetweenMs: [ctx.now + minutes(5), eodMs],
        client: ctx.suiClient,
      });
      const p = await market.price();
      const isUp = signal.direction === "up";
      // `strikeAtSigma` places the strike at 1σ from the forward, scaled
      // by √(time-to-expiry). Beats fixed `pctBps` for production: a 3%
      // move 1 day out is huge, 3% 30 days out is small — sigma-aware
      // sizing keeps the probabilistic distance constant.
      const strike = market.strikeAtSigma(p, {
        k: 1,
        direction: isUp ? "up" : "down",
        atMs: ctx.now,
      });

      if (await ctx.state.has(banKey(market.id, strike))) {
        return [noop("strike banned by prior failure (TTL active)")];
      }

      // Multi-action: fund PM + directional + optional hedge
      const actions: Awaited<ReturnType<typeof this.decide>> = [
        { kind: "vault.pmDeposit", params: { amount: quantity } },
        { kind: "vault.mintBinary", params: { marketId: market.id, strike, isUp, quantity } },
      ];
      if (signal.hedge) {
        actions.push({
          kind: "vault.mintRange",
          params: {
            marketId: market.id,
            lowerStrike: market.strikeBelow(p.forwardRaw, { pctBps: 200 }),
            higherStrike: market.strikeAbove(p.forwardRaw, { pctBps: 200 }),
            quantity: quantity / 2n,
          },
        });
      }

      return actions;
    },

    // onExecuted now focuses ONLY on success-path bookkeeping (cooldown,
    // audit log). Failure handling moved to onError — cleaner separation.
    async onExecuted(ctx, result) {
      if (result.outcome !== "submitted") return;

      // Update cooldown only on real success
      await ctx.state.set("lastSuccessMs", ctx.now);

      // Audit log
      for (const action of result.actions) {
        if (action.kind !== "vault.mintBinary") continue;
        const entry: TradeLog = {
          digest: result.digest!,
          marketId: action.params.marketId,
          isUp: action.params.isUp,
          strike: action.params.strike.toString(),
          quantity: action.params.quantity.toString(),
          hadHedge: result.actions.some((a) => a.kind === "vault.mintRange"),
          gasMist: result.gasUsed?.toString() ?? "0",
          recordedAtMs: ctx.now,
        };
        await ctx.state.set(`trade:${result.digest}`, entry);
      }
    },

    // onError handles failure-specific logic: ban degenerate strikes,
    // discriminate transient (client) vs structural (on-chain) failures.
    async onError(ctx, err) {
      if (err.phase === "client") {
        // Transient — log and let scheduler unhealthy backoff handle retry
        ctx.logger.warn("client error (transient)", { error: err.error });
        return;
      }

      // on-chain abort — react if it's degenerate (strike-specific)
      const isDegenerate =
        err.error.includes("EFairPriceAlreadySettled") ||
        err.error.includes("EInvalidStrike");
      if (!isDegenerate) {
        ctx.logger.warn("on-chain failure (non-degenerate)", {
          digest: err.digest,
          error: err.error,
        });
        return;
      }

      for (const action of err.actions) {
        if (action.kind !== "vault.mintBinary") continue;
        await ctx.state.setWithTTL(
          banKey(action.params.marketId, action.params.strike),
          { reason: err.error },
          BAN_MS,
        );
      }
      ctx.logger.warn("degenerate strike banned", {
        digest: err.digest,
        error: err.error,
      });
    },
  };
}

Environment variables

  • VAULT_ID
  • SIGNAL_URL