// STRATEGY SDK · v3.1.0

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@3.1.0

# 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==3.1.0

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
  }
}

Start from a template

Two working strategies ship with the CLI, each in Python and Node.js. favourite-near-close buys, in the last minute, the side the settlement feed says is winning, while the book still sells it under a ceiling — it runs locally on the free sample. binance-lead takes the side Binance spot points at before the book catches up; it reads the Binance reference feed, so it runs on the hosted backtest. Both are also in the editor on the backtest page. They are starting points, not proven edges: one sample day says nothing about next month.

SHELL
# list the templates
ot init

# copy one into a new directory (--lang python or nodejs)
ot init my-strategy --template favourite-near-close --lang python
ot check my-strategy

# replay it on the free sample day (download it once — see "Run it locally")
ot run my-strategy --data ./polymarket-data-samples

All templates, with a README per strategy: github.com/outcometick/polymarket-strategy-templates

Working from an AI coding tool? The same npm package includes an MCP server (ot mcp); setup is on the docs page.

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 · spot · 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 instance across the range; markets that overlap in time are replayed interleaved, on one clock). Same price either way.
fee_bpsNoAbsent by default: every taker fill pays the venue's own taker fee, per market (Polymarket: C × rate × p × (1 − p) from the archived schedule; the Predict.fun archive has none, so those runs are fee-free), and resting-order (maker) fills pay nothing, as on the venue — its maker rebate is only estimated when you set maker_rebate. A number replaces it with a flat rate on notional for every fill, in basis points; 0 is a real setting. The report states which was used.
cancel_latencyNoMilliseconds from ctx.cancel() to the cancel reaching the venue, for resting orders. Absent means the same as latency (which is the order-entry delay). 0 is allowed.
fokNotrue lets orders use tif "fok": all of the size within the limit at arrival, or none of it. Off by default; a refused order is a fill row with reject_reason "fok_unfilled".
order_rulesNo"assumed" holds Polymarket orders to a tick of 0.001, a minimum of 5 shares and sizes in 0.01 shares; a notional converts to 0.01 shares instead of whole ones. Archive markets do not carry their own tick, so these are measured values, not read per market — the report says so. Off by default; refused orders are counted by reason.
maker_rebateNoA share of the taker fee credited on each resting fill, e.g. 0.2, as a negative fee. An estimate: the venue pays maker rebates pro rata from a daily pool. Needs the venue fee schedule (not fee_bps).
quote_guardNo{"max_age_ms": 1500, "ask_sum": [0.92, 1.08]}: an opening taker order executes only if both outcomes have an ask whose sum is in range, and all four best quotes were set within max_age_ms. A resting order that fails it is refused whole. Exits are not blocked unless you add "include_exits": true.
mirror_depthNo"shared" (Predict.fun): the book and its mirror are one liquidity, so a fill on one outcome also removes it from the other. Off by default, where each side keeps its own copy.
feed_latencyNoMilliseconds between an event on the venue and your strategy hearing of it, e.g. 50. Hooks fire that much later and ctx.book() shows the book as it was then, while orders still match the venue's current book. Absent means 0.
queue_modelNoResting orders only. Absent: the conservative queue (a size drop with no trade to explain it is assumed to have left from behind your order). {"prob": "power", "n": 2} (power, power2 or power3, n 1 to 10) or {"prob": "log"} (log or log2) instead estimate how much of it left from in front, from the sizes ahead of and behind you. An estimate the archive cannot check: run both and compare.
record_msNoThe account's value every so many milliseconds (1000 to 3600000), every open leg at its best bid (a leg with no bid counts 0): the report adds a drawdown on that value, which sees losses an open position carried before it closed. Market mode only. Absent: off.

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. On whenever book is declared (since SDK 2.13; earlier reports were 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 on the unrefined book, and coverage lists which days had it.
spotChainlink's 1 Hz report stream for each market's asset, fed to every market of that asset whatever stream it settles on. Quiet: it wakes no hook — ctx.spot() returns the latest value and ctx.twap_locked() the part of the settlement window already elapsed, both as the strategy would have seen them (feed_latency applies). twap_locked is an approximation from this per-second stream, not Chainlink's own observations. Polymarket only; name it to opt in.
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_bookEach order-book update the archive holds, 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
on_timerAt the time you asked for with ctx.schedule(at_ms, tag), with {id, at_ms, tag}. Runs on one time line with every market's events, so it never sees anything later than its own time.Order | list | None
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), .levels(n) and .quote_ts(side) (when each best price was last set) — 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.spot()The latest value of this market's asset on Chainlink's 1 Hz stream that has reached you, as {value, ts_ms}, or null. Needs the spot dataset; it wakes no hook, so read it from on_tick, on_book or on_timer.
ctx.twap_locked()The part of the settlement TWAP window [close − 60 s, close) already elapsed, from the spot values seen in it: {value, samples, elapsed_ms, elapsed_fraction, coverage, last_ts_ms, projected}. An approximation — Chainlink averages finer observations than this stream: on one BTC day we measured, recomputing the official 60 s TWAP from it was off by $0.51 at the median and $6.35 at worst. elapsed_fraction is time, not certainty; projected (the final average if the price stayed put) is null below 80% coverage or with a spot value over 5 s old. null outside a TWAP-settled market's window. Needs the spot dataset.
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.orders()Your resting (gtc) orders in this market that are not final yet, as copies: id, client_id, side, limit, remaining, state (pending, live, or cancel_pending — still exposed for the lag window). No queue position, as on a real venue.
ctx.cancel(id)Cancel a resting order by its id ("o3") or your client_id. Lands after cancel_latency; returns false if there is no such order.
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", "spot", "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. If that is 0 (notional below the limit price), Order raises and the run fails — skip the order yourself when the budget is that small. 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 default) takes what is resting at that millisecond and drops the rest. "gtc" rests at limit until filled, cancelled or the market closes, behind the size already queued at that price; any part that is marketable on arrival takes liquidity first. gtc needs a limit on the 0.001 grid, Polymarket, and the "book" and "trades" datasets. "fok" takes all of the size within the limit at that millisecond or none of it; the manifest must set "fok": true.
post_onlygtc only: refuse the order instead of taking liquidity if it is marketable when it arrives.
client_idgtc only: your handle for ctx.cancel() — 1-64 of A-Z a-z 0-9 _ . : -, not of the form o<digits>.
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 book updates as they arrive, 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 on Predict.funThe Predict.fun archive has order-book snapshots and no trade stream, so there is nothing to advance a queue with. gtc orders are refused there; ioc orders run.
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
range35 days maxone run, within the most recent archived days
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 --days 20 --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/outcometick/polymarket-tick-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.

Or with no Node at all: pip install outcometick, and outcometick.backtest runs the same decoder, engine and report in-process — the class straight from your notebook, the sample downloaded once, decoded days cached so the next run starts at once.

PYTHON
# pip install outcometick
from outcometick import Strategy, Order
from outcometick.backtest import run, load_sample

class Favourite(Strategy):
    def on_market_open(self, ctx, market):
        self.close, self.done = market.close_ts_ms, False

    def on_tick(self, ctx, tick):
        if self.done or self.close - ctx.now > 60_000:
            return None
        up, down = ctx.book().best("UP"), ctx.book().best("DOWN")
        if up is None or down is None:
            return None
        self.done = True
        side, px = ("UP", up) if up >= down else ("DOWN", down)
        return Order(side=side, notional=50, limit=0.95) if 0.55 <= px <= 0.95 else None

result = run(Favourite, load_sample("polymarket"))   # or a directory of archive files
print(result.summary())

The backtester is open source, with the tests that compare the Python and JavaScript implementations event by event: github.com/outcometick/polymarket-backtest (Python) · github.com/outcometick/polymarket-backtest-js (JavaScript / TypeScript)

Its documentation — methodology, how it is verified, the Python API, and a page written for AI coding agents (with llms.txt): outcometick.github.io/polymarket-backtest

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.

Breaking in SDK 2.0 — date window. Backtests cover only the most recent 35 archived days, and the window moves forward as new days are archived. A range starting before it, or a day count longer than it, is refused with E_SCOPE, and the message names the current window and what to change. Use --days <n> for the most recent n days; the editor on /backtest only offers dates inside the window. The current dates are at GET /v1/backtest/capacity?venue=<venue> (first_complete_day..last_day).

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: the most recent 10 archived days of two assets
# (backtests cover only the most recent 35; --from/--to must fall inside them,
#  and one run replays at most so many market-days: maxMarketDaysByShape in /v1/backtest/contract)
ot submit . --assets btc,eth --days 10

# 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. Most often the dates: backtests cover only the most recent 35 archived days, and the message names the current window.
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 →