Validate
Manifest schema, entry resolution, hook signatures, import allowlist, forbidden syntax. Static, seconds, free — and identical to ot check.
// STRATEGY SDK · v3.1.0
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.
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 lastEach 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.
The solid boxes are the methods you write; the dashed ones are what the runner does between your calls.
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.
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.
# 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.0Two files is a complete submission. Python shown; Node.js has identical semantics with idiomatic names — see the parity table.
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){
"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
}
}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.
# 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-samplesAll 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.
Without a valid outcometick.json a submission is not queued. It is also what the quote is computed from.
| field | required | meaning |
|---|---|---|
| schema | Yes | Manifest version. Currently 1. We will never change the meaning of a field within a version. |
| language | Yes | python@3.14 · nodejs@24. The pinned runtime, not a range. |
| entry | Yes | file:ClassName. The class name has to match exactly — there is no auto-discovery and no decorator to register it. |
| hooks | Yes | Which hooks you implement. Anything not listed is never called; anything listed but missing is a rejection. |
| datasets | Yes | settlement · 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. |
| intervals | No | ["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. |
| latency | No | The 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. |
| reference | No | Outside feeds replayed on the same clock, e.g. binance:btcusdt:spot:1s. |
| series | No | Your own CSV series, aligned to event time and optionally lagged. |
| deps | No | Names only, from the per-language allowlist. Versions are ours; you cannot pin them and there is no install step. |
| params | No | Defaults, reachable as ctx.p. A sweep varies these; nothing else can change between cells. |
| mode | No | market (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_bps | No | Absent 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_latency | No | Milliseconds 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. |
| fok | No | true 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_rules | No | "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_rebate | No | A 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_guard | No | {"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_depth | No | "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_latency | No | Milliseconds 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_model | No | Resting 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_ms | No | The 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. |
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.
| settlement | Resolves per market to the stream that market actually settled on. Recommended. |
| prices | The 1 Hz Chainlink report stream. |
| twap30s | TWAP over a 30-second lookback. |
| twap60s | TWAP over a 60-second lookback. |
| book | Order-book snapshots and deltas. |
| bbo | Unthrottled 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. |
| spot | Chainlink'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. |
| trades | Trade prints as the venue broadcast them. |
| markets | Per-market metadata, strike and settlement outcome. |
| twap60s:derived | Recomputed from prices for dates before capture began — a deterministic function of data we do hold, flagged as derived on every row. |
| twap30s:derived | Recomputed 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.
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.
| hook | fires | returns |
|---|---|---|
| on_market_open | Once per market, before any event. Instance state is reset immediately before this call. | nothing |
| on_tick | Every settlement-feed report, in event-time order. This is the stream the market settles on. | Order | list | None |
| on_book | Each 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_trade | Each trade print in the archive, including other participants'. | Order | list | None |
| on_settle | Once 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_timer | At 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 |
| python | on_tick(ctx, tick) |
| nodejs | onTick(ctx, tick) |
Everything the runner lets you touch is on this object. Anything not listed here is not in the process.
| ctx.p | Your params for this run, already type-checked against the manifest. |
| ctx.now | Event 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. |
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:1s | spot 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:1m | spot 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_ms | Optional 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 available | Live fetches from inside a run, at any URL, for any reason. There is no network in the sandbox and there will not be one. |
"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)Fades the market when it disagrees with Binance spot momentum. The whole thing, so you can see where the feed comes into it.
{
"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 }
}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.
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.
ts_ms,score,confidence
1786536000000,0.62,0.91
1786536060000,0.58,0.88
1786536120000,0.71,0.95{
"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 }
}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.
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. |
| size | Contracts. Collateral is posted in full, as on the venue. Give this OR notional, not both. |
| notional | Spend 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. |
| limit | Price in dollars, 0–1. A CEILING when opening — nothing fills above it — and a FLOOR when reducing. Unfilled remainder is reported, not assumed. |
| hold_s | Seconds until flat, measured from the FILL, or omit to hold to settlement. |
| reduce_only | Closes 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_only | gtc only: refuse the order instead of taking liquidity if it is marketable when it arrives. |
| client_id | gtc only: your handle for ctx.cancel() — 1-64 of A-Z a-z 0-9 _ . : -, not of the form o<digits>. |
| tag | Free string, carried through to every row of trades.csv. |
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.
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.
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)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.
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)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.
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| Resting orders on Predict.fun | The 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 languages | Go 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 testing | This is a replay engine over an archive. It never touches a live venue and never places a real order. |
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.
| files | 6 | text only, no archives, no repository URLs |
| total source | 256 KB | including the manifest |
| per-event budget | 400 µs sustained | sustained breach kills the shard |
| memory | 2 GB | per worker, per market-day |
| cpu | 1 vCPU | no threads of your own; parallelism is across markets |
| wall clock | up to 108 min | sized from the market-days scanned; then killed and refunded |
| range | 35 days max | one run, within the most recent archived days |
| ctx.log output | 2 MB / run | for the whole run, kept in the archive |
| state reset | per market | session 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".
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.
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 polymarketUp 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.
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.
# 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-samplesot 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.
# 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
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).
# 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_...Manifest schema, entry resolution, hook signatures, import allowlist, forbidden syntax. Static, seconds, free — and identical to ot check.
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.
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.
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.
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_MANIFEST | Missing or malformed outcometick.json, or a schema version we do not know. |
| E_ENTRY | entry does not resolve to a class in the named file, or the class does not implement the SDK base. |
| E_HOOK_SIG | A declared hook has the wrong arity or returns a type that is not Order or nothing. |
| E_IMPORT | An import outside the allowlist, transitive ones included. The offending chain is printed. |
| E_FORBIDDEN | Threads, subprocess, eval, dynamic import, reflection or a native extension found at import time. |
| E_NONDETERMINISM | Unseeded randomness or a wall-clock read. Use ctx.random and ctx.now. |
| E_STATE | Instance state is not serialisable, so the market-day cannot be moved between workers. |
| E_BUDGET | Per-event budget exceeded on the smoke run. Nothing was billed. |
| E_COVERAGE | A captured stream was requested outside the window it was captured in. |
| E_LIMIT | A submission limit was exceeded — file count, total source size or series size. |
| E_SCOPE | The 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_RUNTIME | The 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 →