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