// STRATEGY SDK · v1.6.7

Writing a strategy

A strategy is one class plus an outcometick.json manifest. The runner calls your hooks tick by tick, in event-time order, inside a sealed container. The SDK is the same locally against the free sample files as it is here against the full archive, so what runs on your local machine runs here unchanged.

Execution model

The runner owns the loop. Your class implements hooks; it does not open files, read a clock, or make network calls. Events arrive in this order:

for each market-day in your scope (sharded across workers)
  on_market_open(ctx, market)     ← state resets here
  for each event in event-time order:
    on_tick / on_book / on_trade  ← you may return an Order
  on_settle(ctx, market, outcome) ← official label arrives last

Each market is independent: your instance state is discarded at every on_market_open, which is what lets us shard hundreds of market-days across workers.

What the runner does with your class

The solid boxes are the methods you write; the dashed ones are what the runner does between your calls.

for each market (a market-day may hold many)on_market_openfresh instance · set up state herefor each event from the datasets you declared, in event-time orderon_tick / on_book / on_tradereturn Ordermatched against the depth really resting↺ next eventevents exhaustedon_settlethe outcome — only herereport.zipequity · fills · slippage · cross-check

You implement at least ONE of on_tick / on_book / on_trade — whichever streams you want to react to. on_market_open and on_settle are optional bookkeeping. The loop belongs to the runner: you never poll, sleep, or ask for the next event, and because the next row has not been read yet, there is nothing to peek at.

Your hook returns an order, and the runner matches it against the book that was actually resting at that millisecond. Fills, partial fills and walked depth are decided by the archive, not by your assumptions.

Install

The ot CLI lives in the npm package — install it globally or the command is not on your PATH. The pip package is the SDK surface for your editor and type checker; it does not carry ot. Versions are pinned per language and the runner uses the same ones: a mismatch is a rejection, not a warning.

SHELL
# the ot CLI — check / run / submit (node 24, ESM only)
npm i -g outcometick@1.6.7

# writing in Python? add this for type hints in your editor.
# it does NOT contain ot — the line above is what gives you the command.
pip install outcometick==1.6.7

Quickstart

Two files is a complete submission. Python shown; Node.js has identical semantics with idiomatic names — see the parity table.

STRATEGY.PY
from outcometick import Strategy, Order


class MeanReversion(Strategy):
    def on_market_open(self, ctx, market):
        self.entered = False

    def on_tick(self, ctx, tick):
        # tick.value is the settlement feed's price, as a float
        z = ctx.zscore(tick.value, window=180)
        if self.entered or abs(z) < ctx.p.entry_z:
            return None

        side = "DOWN" if z > 0 else "UP"
        book = ctx.book(tick.market_id)
        if book.depth(side) < ctx.p.min_depth:
            return None

        self.entered = True
        return Order(side=side, notional=ctx.p.notional,
                     limit=book.best(side),
                     hold_s=ctx.p.hold_minutes * 60)
OUTCOMETICK.JSON
{
  "schema": 1,
  "language": "python@3.14",
  "entry": "strategy.py:MeanReversion",
  "hooks": ["on_market_open", "on_tick"],
  "datasets": ["settlement", "book"],
  "deps": ["numpy"],
  "params": {
    "entry_z": 2.0,
    "hold_minutes": 20,
    "notional": 200,
    "min_depth": 2000
  }
}

Manifest reference

Without a valid outcometick.json a submission is not queued. It is also what the quote is computed from.

fieldrequiredmeaning
schemaYesManifest version. Currently 1. We will never change the meaning of a field within a version.
languageYespython@3.14 · nodejs@24. The pinned runtime, not a range.
entryYesfile:ClassName. The class name has to match exactly — there is no auto-discovery and no decorator to register it.
hooksYesWhich hooks you implement. Anything not listed is never called; anything listed but missing is a rejection.
datasetsYessettlement · prices · twap30s · twap60s · book · bbo · trades · markets. Only what you ask for is fed in, so asking for less gives your handler less to process. It does not change the price: a market-day costs one credit whatever you read of it. Prefer settlement.
intervalsNo["5m"] by default. The archive holds prediction markets at 5m and 15m and at no other length. A credit buys one asset, on one UTC day, at one length — so asking for both is twice the market-days and twice the price, because it is twice the markets.
latencyNoThe fill delay this run is replayed at, in milliseconds, e.g. 250. Absent means 0. Every fill lands that much later than the signal that caused it, and every number in the report is measured in that world — it is not an extra pass, and it costs no extra time.
referenceNoOutside feeds replayed on the same clock, e.g. binance:btcusdt:spot:1s.
seriesNoYour own CSV series, aligned to event time and optionally lagged.
depsNoNames only, from the per-language allowlist. Versions are ours; you cannot pin them and there is no install step.
paramsNoDefaults, reachable as ctx.p. A sweep varies these; nothing else can change between cells.
modeNomarket (default, state resets per market) or session (one ordered stream across the range). Same price either way.

Which settlement stream applies

The venues changed what these markets settle on, and not all at once. Ask for settlement and each market is fed the stream IT actually settled on — read from that market's own config, never inferred from its date. You do not need to know the cut-over, and you cannot get it wrong.

settlementResolves per market to the stream that market actually settled on. Recommended.
pricesThe 1 Hz Chainlink report stream.
twap30sTWAP over a 30-second lookback.
twap60sTWAP over a 60-second lookback.
bookOrder-book snapshots and deltas.
bboUnthrottled top of book: prices only, no sizes. It deletes ladder levels the venue has already moved past and never adds one — the delta stream that maintains the book is thinned to the venue's capture cadence, so the stalest levels are the first an order would hit, and they are stale in your favour. Name it to opt in; it is not in the starter manifest, because switching it on changes which levels are fillable and so changes every report written without it. Captured on Polymarket from 2026-09-03 (the archive also holds 2026-09-02, which is missing its first 42 minutes). Earlier dates and Predict.fun are not refused — they run exactly as they did before this stream existed, and coverage lists which days had it.
tradesTrade prints as the venue broadcast them.
marketsPer-market metadata, strike and settlement outcome.
twap60s:derivedRecomputed from prices for dates before capture began — a deterministic function of data we do hold, flagged as derived on every row.
twap30s:derivedRecomputed from prices for dates before capture began — a deterministic function of data we do hold, flagged as derived on every row.

Naming a captured stream outside the window we actually captured it in is E_COVERAGE, never a silent substitution. Every archive reports how many markets each settlement stream backed, so you can see the mix a run replayed.

Hooks

Implement what you need; declare exactly those in the manifest. An undeclared hook is never called, and a declared-but-missing hook is a rejection.

hookfiresreturns
on_market_openOnce per market, before any event. Instance state is reset immediately before this call.nothing
on_tickEvery settlement-feed report, in event-time order. This is the stream the market settles on.Order | list | None
on_bookEvery order-book change, if you declared the book dataset. The argument is the change itself (a snapshot or one level); read the resulting book with ctx.book().Order | list | None
on_tradeEach trade print in the archive, including other participants'.Order | list | None
on_settleOnce per market, after the last event, carrying the official outcome ("UP", "DOWN", or "TIE" for a 50:50 settlement that pays $0.50 per contract on both sides) and strike.nothing
NAME PARITY
pythonon_tick(ctx, tick)
nodejsonTick(ctx, tick)

ctx

Everything the runner lets you touch is on this object. Anything not listed here is not in the process.

ctx.pYour params for this run, already type-checked against the manifest.
ctx.nowEvent time in ms. The only clock in the process; the wall clock is not reachable.
ctx.book(market_id)The book as of this millisecond. .best(side) is the ask, .best_bid(side) the bid, plus .depth(side) and .levels(n) — no future state.
ctx.history(n)The last n ticks you have already seen. Never more, by construction.
ctx.zscore(v, window)Rolling helpers: zscore, ema, sma, stdev. Numerically identical across languages.
ctx.position()Your open position in this market: side, size, average entry, unrealised.
ctx.random(seed)Seeded generator. The only randomness available, and the seed is recorded in the report.
ctx.ref(name)A declared reference feed as of now: .last, .window(n), .at(ts). Never a row stamped after ctx.now.
ctx.ext(name)One of your own declared series, same point-in-time guarantee and any declared lag already applied.
ctx.log(msg)Up to 512 characters a line and 2 MB for the whole run, kept in the archive. It is a window into your own code, not an export format — past the budget the rest is dropped and the log says so.
ctx.assert_outcome(m, o)Compare your own recompute against the official settlement; results appear in the cross-check panel.

External data

You cannot fetch Binance mid-run. The sandbox has no network, and a live fetch would make the same code produce different reports on different days. Outside data is resolved into a dataset before the run starts and replayed on the same clock as everything else.

binance:*:spot:1sspot klines at 1s, from Binance's own public daily archive — their timestamps, not ours. Pre-downloaded and replayed on event time alongside the settlement stream. BTC ETH SOL XRP BNB DOGE
binance:*:spot:1mspot klines at 1m, from Binance's own public daily archive — their timestamps, not ours. Pre-downloaded and replayed on event time alongside the settlement stream. BTC ETH SOL XRP BNB DOGE
series (your own)One CSV per series, up to 4 MB. A header row, a timestamp column (ts_ms, ts, time, timestamp, date, datetime — or epoch/ISO in the first column), and your own columns beside it. Read as data; nothing in a CSV is unpacked or executed.
lag_msOptional per series. Each row is withheld until ts_ms + lag_ms, which is how you model a signal you could not have had instantly.
not availableLive fetches from inside a run, at any URL, for any reason. There is no network in the sandbox and there will not be one.
MANIFEST + USAGE
  "reference": ["binance:btcusdt:spot:1s"],
  "series": [{ "name": "my_signal", "file": "signal.csv" }]

# both are read-only and aligned to event time
spot = ctx.ref("binance:btcusdt:spot:1s").last
basis = tick.value - spot.close
mine = ctx.ext("my_signal").at(ctx.now)

A strategy that uses one

Fades the market when it disagrees with Binance spot momentum. The whole thing, so you can see where the feed comes into it.

OUTCOMETICK.JSON
{
  "schema": 1,
  "language": "python@3.14",
  "entry": "strategy.py:Basis",
  "hooks": ["on_market_open", "on_tick"],
  "datasets": ["settlement", "book"],
  "reference": ["binance:btcusdt:spot:1s"],
  "params": { "lookback_s": 30, "min_drift": 0.0004, "max_px": 0.6, "notional": 80 }
}
STRATEGY.PY
from outcometick import Strategy, Order


class Basis(Strategy):
    """Fade the market when it disagrees with Binance spot momentum."""

    def on_market_open(self, ctx, market):
        self.done = False

    def on_tick(self, ctx, tick):
        if self.done:
            return None

        spot = ctx.ref("binance:btcusdt:spot:1s")
        # Enough history to measure momentum over. Early in a market-day there
        # is not any, and None is what you get -- not a zero, and not a bar
        # borrowed from after ctx.now.
        bars = spot.window(ctx.p.lookback_s)
        if len(bars) < ctx.p.lookback_s:
            return None

        drift = (bars[-1].close - bars[0].close) / bars[0].close
        if abs(drift) < ctx.p.min_drift:
            return None

        # Spot is going up, so the market saying "down" is the side to take.
        side = "UP" if drift > 0 else "DOWN"
        book = ctx.book(tick.market_id)
        px = book.best(side)
        if px is None or px > ctx.p.max_px:
            return None

        self.done = True
        ctx.log(f"drift {drift:+.4%} over {ctx.p.lookback_s}s -> {side} at {px}")
        return Order(side=side, notional=ctx.p.notional, limit=px)

window(n) returns what has closed by ctx.now and nothing else — early in a market-day there is less than you asked for, which is why the length is checked rather than assumed. Note also: a 1s bar covering [t-1s, t] arrives at t, because it was not knowable at t-1s.

Bringing your own series

Attach a CSV with + outside data in the editor and it is declared for you. It is read on the same clock as everything else: at ctx.now you see the rows stamped at or before it, and nothing after.

SIGNAL.CSV
ts_ms,score,confidence
1786536000000,0.62,0.91
1786536060000,0.58,0.88
1786536120000,0.71,0.95
OUTCOMETICK.JSON
{
  "schema": 1,
  "language": "python@3.14",
  "entry": "strategy.py:Signal",
  "hooks": ["on_tick"],
  "datasets": ["settlement", "prices", "twap30s", "twap60s", "book", "bbo", "trades", "markets"],
  "series": [{ "name": "my_signal", "file": "signal.csv", "lag_ms": 300000 }],
  "params": { "min_score": 0.6, "notional": 80 }
}
STRATEGY.PY
from outcometick import Strategy, Order


class Signal(Strategy):
    def on_tick(self, ctx, tick):
        row = ctx.ext("my_signal").last
        # None until the first row is BOTH stamped and past its lag_ms. The
        # column names are your CSV's own -- ts_ms is ours, the rest is yours.
        if row is None or row.score < ctx.p.min_score:
            return None

        book = ctx.book(tick.market_id)
        px = book.best("UP")
        if px is None:
            return None
        return Order(side="UP", notional=ctx.p.notional, limit=px)

If your signal is computed with a delay — a model that needs five minutes of data before it can score a minute — declare it with lag_ms and each row is held back that long. Without it a backtest reads a signal at a moment it could not have existed.

Both are point-in-time by construction: .at() and .last cannot see a row stamped after ctx.now, so a carelessly built signal file cannot leak the future into your backtest. If your series has a publication lag, declare it with "lag_ms" and we will hold each row back by that much.

Want reference data from an exchange we do not carry yet? Ask — adding a feed is usually quick.

Order

An Order is what a hook returns, not a request you send. The runner matches it against resting depth at that millisecond; an order larger than the visible size is partially filled and walked up the book, and the shortfall appears in the slippage report.

side"UP" or "DOWN". The outcome token you are buying.
sizeContracts. Collateral is posted in full, as on the venue. Give this OR notional, not both.
notionalSpend at most this much, converted at your limit: floor(notional / limit) contracts. Needs a limit — a contract costs whatever it fills at, and a marketable order walks the book, so dividing by the current best price overspends the moment there is any slippage.
limitPrice in dollars, 0–1. A CEILING when opening — nothing fills above it — and a FLOOR when reducing. Unfilled remainder is reported, not assumed.
hold_sSeconds until flat, measured from the FILL, or omit to hold to settlement.
reduce_onlyCloses existing exposure instead of adding to it, clamped to your open size, and sells into the bid.
tif"ioc" — the only value. An order either fills against resting depth at that millisecond or is reported unfilled.
tagFree string, carried through to every row of trades.csv.

Worked examples

Three shapes that cover most of what people actually run on these markets. Every threshold is a param, so each one is a sweep away from being tuned.

1 · Late entry

Buy once, N seconds before settlement, only if the price sits in a band. The whole strategy is four params — and it lives or dies on the slippage and latency pages, because everyone else is doing the same thing at the same second.

LATE ENTRY · PYTHON
def on_market_open(self, ctx, market):
    self.close_ts = market.close_ts_ms
    self.done = False

def on_tick(self, ctx, tick):
    secs_left = (self.close_ts - ctx.now) / 1000
    if self.done or secs_left > ctx.p.enter_at_s:
        return None

    ask = ctx.book(tick.market_id).best(ctx.p.side)
    if not ctx.p.px_min <= ask <= ctx.p.px_max:
        return None

    self.done = True
    return Order(side=ctx.p.side, notional=ctx.p.notional, limit=ask)

2 · Trend from the settlement stream

Same entry window, but the side comes from momentum on the underlying rather than the market's own price. The settlement stream is the Chainlink feed the market settles on, and you are already being fed it, so this needs no outside data and nothing to align. For momentum from an exchange instead, declare a reference feed — see External data.

TREND · PYTHON · "datasets": ["settlement", "book"]
def on_tick(self, ctx, tick):
    secs_left = (self.close_ts - ctx.now) / 1000
    if self.done or secs_left > ctx.p.enter_at_s:
        return None

    # Momentum on the underlying, straight off the settlement stream —
    # the same Chainlink prints this market settles on. No outside feed,
    # and nothing to align: it is the series you are already being fed.
    past = ctx.history(ctx.p.lookback_s + 1)
    if len(past) <= ctx.p.lookback_s:
        return None
    mom = tick.value / past[0].value - 1

    if abs(mom) < ctx.p.mom_threshold:
        return None

    side = "UP" if mom > 0 else "DOWN"
    ask  = ctx.book(tick.market_id).best(side)
    if ask is None or not ctx.p.px_min <= ask <= ctx.p.px_max:
        return None

    self.done = True
    return Order(side=side, notional=ctx.p.notional, limit=ask)

3 · Repeated trading inside one market

Aggressive, in and out of the same market as the signal flips. Drive it from on_book rather than on_tick so you react to every book change, use reduce_only to flatten, and return a list when you want to flip in one event. This is a taker strategy: it crosses the spread.

HIGH FREQUENCY · PYTHON · "hooks": ["on_market_open", "on_book"]
def on_book(self, ctx, event):
    book = ctx.book()  # the event is the change; this is the book it produced
    edge = ctx.p.fair - book.best("UP")   # your signal
    pos  = ctx.position()

    # flat, and the edge is worth crossing for
    if pos.size == 0 and edge > ctx.p.entry_edge:
        return Order(side="UP", size=ctx.p.clip, limit=book.best("UP"))

    # edge gone — flatten
    if pos.size > 0 and edge < ctx.p.exit_edge:
        return Order(side="UP", size=pos.size, reduce_only=True,
                     limit=book.best_bid("UP"), tag="exit")

    # signal flipped — close and reverse in one event
    if pos.size > 0 and edge < -ctx.p.entry_edge:
        return [
            Order(side="UP", size=pos.size, reduce_only=True, limit=book.best_bid("UP")),
            Order(side="DOWN", size=ctx.p.clip, limit=book.best("DOWN")),
        ]

    return None

Not supported yet

Resting orders (tif: "gtc")An order that stays live until the market closes is the same problem as posting a quote: without a queue-position model we would be guessing at where you sat in the line, and the optimistic guess inflates returns by multiples. Submitting it is a rejection rather than a silent one-shot attempt.
Posting quotesPassive orders that rest and wait to be filled. Simulating that honestly needs a queue-position model — where you sat in the line at your price — and the optimistic assumption inflates market-making returns by multiples. Until that is modelled properly it stays unsupported. Taker strategies that cross the spread work today.
Cancel and replaceFollows the same work. Today an order either fills against resting depth at that millisecond or is reported unfilled.
Cross-market state (default mode)Instance state resets per market. Portfolio-level logic needs session mode, where one instance sees the whole range. Same price.
Compiled languagesGo and Rust compile untrusted code, which is itself untrusted execution — build scripts, proc macros — and needs a vendored offline module cache. A different security problem, not a bigger version of this one.
Live or forward testingThis is a replay engine over an archive. It never touches a live venue and never places a real order.

Determinism rules

Two runs of the same code over the same range must produce byte-identical reports, otherwise the number we hand you means nothing. These are enforced statically at import and structurally at run time — the APIs are absent, not merely blocked.

Limits

files6text only, no archives, no repository URLs
total source256 KBincluding the manifest
per-event budget400 µs sustainedsustained breach kills the shard
memory2 GBper worker, per market-day
cpu1 vCPUno threads of your own; parallelism is across markets
wall clockup to 108 minsized from the market-days scanned; then killed and refunded
range90 days maxone run; ask us for longer
ctx.log output2 MB / runfor the whole run, kept in the archive
state resetper marketsession mode lifts this

Session mode lifts the per-market state reset so one instance sees every market in the range in a single ordered stream — portfolio limits, cross-market hedges, capital allocation. It costs the same as market mode: a market-day is a credit whichever mode reads it. Declare it with "mode": "session".

What you submit

A directory. The manifest has to be called outcometick.json and sit at its root; everything else is whatever your entry point imports. The web editor shows you both filenames as tabs — from the command line they are yours to create, so here they are.

SHELL
my-strategy/
  outcometick.json     # the manifest: language, entry, hooks, datasets, params
  strategy.py          # the file `entry` points at, holding the `entry` class
  helpers.py           # split it up if you like
  signal.csv           # your own series, declared under `series`

# python: import your own files RELATIVELY, or the validator rejects them —
#   from .helpers import cheaper      ✓
#   from helpers import cheaper       ✗ E_IMPORT: not on the allowlist
# node:   import { cheaper } from "./helpers.mjs";

# the directory IS the submission — that is what the "." refers to
ot check  ./my-strategy
ot submit ./my-strategy --assets btc --from 2026-07-14 --to 2026-08-12 --venue polymarket

Up to 6 files, of which at most 256 KB may be source: a declared CSV series counts against the file count but has its own byte budget, 4 MB each, and goes straight to storage rather than through the API.

Run it locally first

The SDK ships the same runner we use. Point it at the free sample repository, get the contract right, then spend credits on the full archive.

SHELL
# one day of real files, free, no key needed
curl -L https://github.com/Ligengxin96/polymarket-data-samples/releases/latest/download/polymarket-data-samples.tar.gz | tar xz

# validate the manifest and hook signatures — no data touched, no key needed
ot check .

# replay locally, same runner, same report format
ot run . --data ./polymarket-data-samples

ot check runs the exact validator the queue runs. If it passes locally it will not be rejected on submit.

Submit & billing

From the command line, or from the editor on /backtest — the same queue either way. `ot check` runs the validator the queue runs, so a submission that passes locally will not be rejected here.

SHELL
# the key travels in the environment, never in a flag:
# a flag lands in shell history and in the process list, and this key spends money
export OT_BACKTEST_KEY=bt_...

# submit to the queue against the full archive
ot submit . --assets btc,eth --from 2026-07-14 --to 2026-08-12

# where it got to, and what it actually cost
ot status run_...

# download the archive once it is done
ot fetch run_...
01

Validate

Manifest schema, entry resolution, hook signatures, import allowlist, forbidden syntax. Static, seconds, free — and identical to ot check.

02

Smoke run

One market-day of sample data on the house. It has to produce a valid report; a strategy that crashes here never reaches the queue or your balance.

03

Quote and queue

Market-days are counted from your scope, credits are held, and the run is sharded across workers. A sweep bills per market-day, not per cell.

04

Archive

One zip: CSVs, JSON report, coverage and sha256sums.txt. The report carries the source sha256, so you can tell which version of your strategy produced it. Signed link by email; kept 7 days.

Credits are spent only on a run that produces a report. Rejections, compile errors, timeouts and crashes cost nothing — you get the validator output or stderr and the archive is never created.

Rejection codes

Except for E_RUNTIME, every code listed here comes back from ot check before you spend anything. E_RUNTIME is the one that cannot: it means the run was already executing, so it is refunded in full rather than prevented.

E_MANIFESTMissing or malformed outcometick.json, or a schema version we do not know.
E_ENTRYentry does not resolve to a class in the named file, or the class does not implement the SDK base.
E_HOOK_SIGA declared hook has the wrong arity or returns a type that is not Order or nothing.
E_IMPORTAn import outside the allowlist, transitive ones included. The offending chain is printed.
E_FORBIDDENThreads, subprocess, eval, dynamic import, reflection or a native extension found at import time.
E_NONDETERMINISMUnseeded randomness or a wall-clock read. Use ctx.random and ctx.now.
E_STATEInstance state is not serialisable, so the market-day cannot be moved between workers.
E_BUDGETPer-event budget exceeded on the smoke run. Nothing was billed.
E_COVERAGEA captured stream was requested outside the window it was captured in.
E_LIMITA submission limit was exceeded — file count, total source size or series size.
E_SCOPEThe requested venue, asset or date range is not something we can serve.
E_RUNTIMEThe run started but could not finish — the sandbox crashed, the feed to it was cut short, or the replay ended early. Nothing was billed.

Strategy ready? Paste it in the editor or submit from the CLI.

run a backtest →