Validate
Manifest schema, entry resolution, hook signatures, import allowlist, forbidden syntax. Static, seconds, free — and identical to ot check.
// STRATEGY SDK · v1.6.7
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@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.7Two 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
}
}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 · 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 ordered stream across the range). Same price either way. |
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. 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. |
| 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 | Every 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_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 |
| 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) 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. |
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", "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. 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 only value. An order either fills against resting depth at that millisecond or is reported unfilled. |
| 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 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.
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 (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 quotes | Passive 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 replace | Follows 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 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 | 90 days max | one run; ask us for longer |
| 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 --from 2026-07-14 --to 2026-08-12 --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/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-samplesot check runs the exact validator the queue runs. If it passes locally it will not be rejected on submit.
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.
# 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_...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. |
| 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 →