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)