Skip to content
Runner · ExamplesLadderrung 16

Pick the MARKET with a real chance of winning

selection: market "with a chance of winning" by edge vs external reference

Mirrors runner/src/plugins/16-exampleEdgeScanner

What this teaches

  • Breadth: Market.list() to enumerate candidates, priced in parallel
  • argmax over a candidate set instead of "trade the first market found"
  • A +EV gate: gross edge must beat the vault's round-trip fee (entry+exit)
  • sviSigmaRaw as a yardstick — normalize the gap by the market's own expected move so different expiries are comparable (see edge.ts)
  • Clean modular split: edge.ts (PURE math) | refs.ts (swappable IO) | here (glue)

New vs 07-exampleConsensus

  • Consensus votes external SIGNAL SOURCES on ONE pre-chosen market. This votes across MARKETS, using forward-vs-reference divergence. Strategy honesty: the basis-vs-external-reference signal is a TOY edge for illustration (a real desk would model the term structure, funding, and the SVI smile). The point is the SHAPE — scan, score, rank, gate, pick one.
import { noop, type Action, type StrategyPlugin } from "@automark/runtime-core";
import { Market } from "@automark/sdk/market";
import { bigintMin } from "@automark/sdk/math";
import { hours, minutes, seconds } from "@automark/sdk/duration";
import { rankAndSelect, scoreMarket, type Candidate } from "./edge";
import { fetchReferences } from "./refs";

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

  const assets = (process.env.SCAN_ASSETS ?? "BTC,ETH")
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);
  const expiryWindowMs = Number(process.env.EXPIRY_WINDOW_MS ?? hours(6));
  const minNetEdgeBps = Number(process.env.EDGE_THRESHOLD_BPS ?? 150);
  const strikeOffsetBps = Number(process.env.STRIKE_OFFSET_BPS ?? 100);
  const refTtlMs = Number(process.env.REF_TTL_MS ?? seconds(30));

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

    async decide(ctx) {
      if (ctx.vault.isFrozen) return [noop("frozen")];
      if (!ctx.vault.canMintBinary) return [noop("no MINT_BINARY permission")];

      // +EV bar: a trade has to clear the vault's entry + exit fee to be worth it.
      const fp = ctx.vault.feeParams;
      const roundTripFeeBps = Number(fp.entry_fee_bps + fp.exit_fee_bps);

      // 1) Breadth — list every active market per asset in the window, flatten.
      const lists = await Promise.all(
        assets.map((asset) =>
          Market.list({
            asset,
            expiryAfterMs: ctx.now + minutes(5),
            expiringWithinMs: expiryWindowMs,
            client: ctx.suiClient,
          }).catch(() => [] as Market[]),
        ),
      );
      const markets = lists.flat();
      if (markets.length === 0) return [noop("no active markets in window")];

      // 2) Price every candidate + fetch references — all in parallel.
      const [refs, priced] = await Promise.all([
        fetchReferences(assets, ctx, refTtlMs),
        ctx.logger.timing("price candidates", () =>
          Promise.all(markets.map(async (m) => ({ market: m, price: await m.price() }))),
        ),
      ]);

      // 3) Score each market for which we have a reference (PURE — see edge.ts).
      const candidates: Candidate<Market>[] = [];
      for (const { market, price } of priced) {
        const referenceRaw = refs.get(market.asset.toUpperCase());
        if (referenceRaw == null) continue;
        candidates.push({
          market,
          score: scoreMarket({
            forwardRaw: price.forwardRaw,
            sviSigmaRaw: price.sviSigmaRaw,
            expiresAtMs: market.expiresAtMs,
            nowMs: ctx.now,
            referenceRaw,
            roundTripFeeBps,
          }),
        });
      }

      // 4) Rank + gate. argmax over |edgeSigma| among the fee-net-positive set.
      const winner = rankAndSelect(candidates, minNetEdgeBps);
      if (!winner) {
        return [noop(`no market cleared ${minNetEdgeBps}bps net edge (${candidates.length} scored)`)];
      }

      const chosen = priced.find((x) => x.market.id === winner.market.id)!;
      const { score } = winner;
      const isUp = score.side === "up";
      const strike = isUp
        ? chosen.market.strikeAbove(chosen.price.forwardRaw, { pctBps: strikeOffsetBps })
        : chosen.market.strikeBelow(chosen.price.forwardRaw, { pctBps: strikeOffsetBps });

      const quantity = bigintMin(ctx.vault.maxSinglePosition, ctx.vault.exposureHeadroom);
      if (quantity === 0n) return [noop("no headroom")];

      ctx.logger.info("edge winner", {
        asset: chosen.market.asset,
        marketId: chosen.market.id,
        side: score.side,
        edgeSigma: score.edgeSigma.toFixed(3),
        netEdgeBps: Math.round(score.netEdgeBps),
        consideredMarkets: markets.length,
      });

      const action: Action = {
        kind: "vault.mintBinary",
        params: { marketId: chosen.market.id, strike, isUp, quantity },
      };
      return [action];
    },
  };
}

Environment variables

  • VAULT_ID
  • SCAN_ASSETS csv, default "BTC,ETH"
  • EXPIRY_WINDOW_MS only markets expiring within this window (default 6h)
  • EDGE_THRESHOLD_BPS min fee-net edge to trade (default 150)
  • STRIKE_OFFSET_BPS how far off the forward to place the binary (default 100)
  • REF_TTL_MS reference price cache TTL (default 30s)