# outcometick — llms-full.txt > https://outcometick.com/llms.txt followed by the full text of the documentation pages (/docs, /docs/api, /docs/schemas, /docs/settlement, /docs/sdk), generated from the same source the pages render. Chinese versions of every page are under /zh. # outcometick > Historical, tick-level data for cryptocurrency Up/Down prediction markets on > Polymarket and Predict.fun. Sold through a self-serve HTTP API, with a hosted > backtest runner that replays the same archive against a submitted strategy. Every figure below is checkable against the public endpoints listed at the end. Where a number would need qualifying, the qualification is stated rather than dropped — an answer that overstates coverage is worse for a buyer than no answer. ## Settlement audit: the archive checked against the official outcomes Each Polymarket and Predict.fun market in the archive is settled again from the archived settlement stream and compared with the official outcome, and the result is published market by market. The figures below are rendered from the live audit and refreshed hourly. - **Polymarket** (2026-06-08 to 2026-10-14): 312,499 markets could be settled from the archive, and 312,495 of them (99.9987%) agree with the official outcome. The 4 that disagree all closed on 2026-08-17 and were resolved by the venue 31 to 65 minutes after their close; each is listed on the audit page. - **Predict.fun** (2026-06-13 to 2026-10-14): 123,794 markets could be settled from the archive, and all 123,794 agree with the outcome implied by Predict.fun's published start and end prices. Those published prices are the Chainlink stream Predict.fun forwards, read at the boundary seconds; the audit recomputes them from the archived copy of that stream. - Over the last 30 archived days, the archive could not settle 2.8% of Polymarket markets (all because the opening or closing second is missing from the archive) and 2.1% of Predict.fun markets (almost all because the opening or closing second is missing from the archive, the rest for the other reasons listed on its audit page). Each of those markets is listed with its reason on the audit page, and nothing missing is filled in from neighbouring ticks. Not settleable never means a value disagrees: disagreements are counted on their own, above. - Why a second can be missing: the official Polymarket outcome is resolved from Chainlink Data Streams directly, while the archive records the reports as Polymarket's public real-time feed relays them, so a pause in that relay leaves the venue with a report the archive lacks. A second, independent collection point records the same feed and the archive is filled from it before each day ships; Polymarket's gaps have fallen sharply since September. A second can still be missing because of a relay pause, a capture loss, or a fill that did not finish in time; the audit does not tell these apart and never guesses the value. - Predict.fun markets that settle on a Binance price (not archived) are outside this audit and counted separately. - Reproduce it yourself: the free sample repositories carry the audit's own code (verify/verify-settlement.mjs). It settles the sample day again, compares every market with the published audit, and for Polymarket checks a sample of outcomes against Polymarket's own API: https://github.com/outcometick/polymarket-tick-data-samples and https://github.com/outcometick/predict.fun-data-samples - Live figures, per day and per market: https://outcometick.com/polymarket-settlement-audit and https://outcometick.com/predict-fun-settlement-audit ## What the data is - **Settlement feeds.** The Chainlink price streams these markets actually resolve against. Each Polymarket tick carries a decimal value and, whenever the upstream relay publishes it, the full-precision fixed-point string (a 1e18 integer) beside it. Polymarket carries the instantaneous stream plus the 30-second and 60-second TWAP streams, which became the official settlement basis on 2026-08-07. Both 5-minute and 15-minute markets now settle on the 60-second TWAP; the 30-second stream was the 5-minute settlement line earlier on and is archived daily through 2026-10-19 (UTC); collection stops on 2026-10-20. Read the lookback off each market's own cryptoMarketConfig.twapLookbackSeconds, not off the date. - **Per-market metadata.** Strike (the price to beat) and the settled outcome for each market, so a buyer can recompute every win/loss from the raw feed instead of trusting a supplied label. - **Order books.** Full-depth snapshots plus the incremental updates between them, captured at a cadence that has changed over the archive — so a replay reproduces what was captured, not every message the venue sent. Top of book ships as its own unthrottled stream, prices only. Because a dropped delta can have been a removal, a ladder rebuilt from snapshots plus deltas can carry stale levels until the next snapshot; prune it against the latest best bid and ask (the unthrottled top-of-book stream). The SDK's OrderBook (`outcometick/data` on npm, `outcometick.data` on PyPI) applies that rule; the schema page spells it out: https://outcometick.com/docs/schemas - **Trade prints** (Polymarket only). Stored in full, unthrottled, as the venue's WebSocket broadcast them; they are not reconciled against on-chain fills. Predict.fun has no trade data. - **Candles.** OHLC from 1 second to 1 month, derived from the settlement feed. A tick count per candle, not trade volume — the settlement feed is a price feed. - **Timestamps kept apart.** Settlement-feed ticks carry three: event time, upstream server time and our receipt time. Order-book and trade rows carry two: the upstream event time and our receipt time. They are never merged, so the capture path can be measured. ## Coverage - Venues: Polymarket and Predict.fun (the prediction market in the Binance wallet). - Polymarket assets: BNB, BTC, DOGE, ETH, HYPE, SOL, XRP. - Predict.fun assets: BTC, ETH, BNB. - Market types: Polymarket has 5-minute and 15-minute Up/Down only. Predict.fun also has hourly and daily. - Continuous since 2026-06-08 (Polymarket) and 2026-06-13 (Predict.fun); those are the first COMPLETE UTC days. Collection began part-way through the two preceding days, which are held but not counted as coverage. - Each UTC day is checked before release and normally published by 02:00 UTC the next day; a day that fails the checks is held for repair and ships by 13:00 UTC the next day. - The upstream relays pause from time to time, and those gaps are left in the data rather than smoothed over — nothing is interpolated to make a day look whole. The settlement audit above measures the effect, live. ## How it is delivered A self-serve HTTP API. A buyer receives a key, lists files for a date or range, and downloads each one. Every file carries a sha256 that the API also returns, so the download can be verified independently. Downloads redirect to object storage, so bulk transfer does not pass through the API. Bulk tar bundles are available for full-history purchases and for buyers who do not want to script anything. Every file is also available as Parquet, at no extra cost: add format=parquet to /v1/files. Same rows as the archive file, already typed; values wider than int64 (full_accuracy_value, token ids, strike_value) stay text. The Parquet copy has its own sha256, and a day's Parquet is normally published together with the day itself (a file not converted yet is reported as pending). The gzipped CSV / JSONL archive files are unchanged and remain the reference. Both platforms in one shape: with format=parquet, dataset=markets_unified lists a unified markets table per markets file. One row per market, the same columns for Polymarket and Predict.fun: venue, market_id, asset, interval, open_ts_ms and close_ts_ms (milliseconds), open_price and settle_price (DECIMAL(38,18); Polymarket has no settle_price there, its settling value is in the stream settle_feed names), outcome (UP, DOWN or TIE), settle_provider, settle_feed, token_up, token_down. From an AI coding tool: the npm package `outcometick` includes an MCP server (`ot mcp`, stdio, runs on the user's machine, Node 24). Claude Code: `claude mcp add outcometick -e OT_KEY= -- npx -y -p outcometick ot mcp`. Tools: coverage, plans, meta, list_files, download_file (sha256-verified), backtest_capacity, backtest_quote, check_strategy (the queue's validator, run locally), backtest_runs, backtest_status, and for the smart-money lists smart_plans, smart_coverage, smart_days, smart_download (OT_SMART_KEY). It never submits a backtest, because that spends credits. Setup for other clients: https://outcometick.com/docs The same tools are served remotely at https://outcometick.com/v1/mcp (Streamable HTTP, nothing to install): send a key as `Authorization: Bearer ` (more keys as `X-Backtest-Key` / `X-Smart-Key`), or add it to claude.ai / ChatGPT as a custom connector and sign in with your keys (OAuth); connected apps can be listed and disconnected at https://outcometick.com/connections. Remotely, download_file and smart_download return a signed link with its sha256, and check_strategy takes file contents. https://outcometick.com/v1/mcp/public serves the public tools with no key. ## Backtesting A second product on the same archive: a strategy is submitted as text and replayed inside a sealed sandbox, rather than the buyer downloading the files and building a harness first. - Languages: Python 3.14 and Node.js 24. A strategy is one class; hooks are called in event-time order. - The sandbox is a gVisor container with no network, a read-only filesystem, no environment variables, a pinned dependency set, and hard CPU and wall-clock limits. A run that tries to reach the internet fails loudly. - Look-ahead is prevented structurally rather than by filtering. The replay loop pulls one row at a time from the process input, so rows after the current instant are not in the sandbox at all when a hook runs. A market's settled outcome is withheld from the market view until the settlement hook fires. In session mode (one strategy instance across markets) markets that overlap in time are replayed interleaved on one clock. - Fills are matched against the order-book depth that was actually there at that instant, not assumed at the midpoint. A fill can walk several levels. A per-run fill latency can be declared, and every figure in the report is measured at the latency it claims. - Resting limit orders (`tif: "gtc"`, Polymarket only) with a conservative queue model built on how this archive records trading: the book is mirrored (an UP bid at q and a DOWN ask at 1 − q are one queue) and each trade prints once, on the taker's token, so prints on both tokens advance a resting order. An order joins behind the size already visible at its price; only prints that reach that price can fill it, out of one volume budget per print that first covers depth the book removed just before the print. A new order ignores prints for a short lag window and a cancelled one stays exposed for it, because a print can arrive after the book update it caused. Entry and cancel latency are set separately; maker fills pay no fee (rebates not modelled). The report adds fill rate, queue position at entry and at fill, time in queue, and post-fill markouts at 1s, 10s and 60s plus to settlement. Queue position is an estimate from aggregated book levels, and orders have no market impact. - Fees: by default a taker fill pays the venue's taker fee for that market. On Polymarket that is C × rate × p × (1 − p), with the rate read from the market's archived fee schedule, computed per order-book level taken (the venue rounds per maker order matched, which the archive cannot reproduce, so it is an estimate and the report says so). The Predict.fun archive carries no fee schedule, so those runs are fee-free unless a flat rate is set. A flat rate in basis points (`fee_bps`, 0 included) overrides it; the report's fee_model block states which applied. - Backtestable market types are the 5-minute and 15-minute Up/Down markets. Predict.fun's hourly and daily markets are in the dataset but not in the runner. - Backtests cover the most recent 35 archived days only (the window moves forward as new days are archived). Strategies can also read Binance spot reference data (1s and 1m, six symbols) and a buyer's own CSV series. - The report carries an equity curve, every fill, a settlement cross-check against the Chainlink feed the market resolved on, and a coverage section listing any market-day that could not be run and why. - Reports are checked by an auditor that shares no code with the engine: it re-derives every taker fill from the archive independently (it does not yet re-derive resting-order fills, and says so instead of passing them). The two most recent audited runs (15 days, 4321 markets, 2862 fills) reconciled with zero discrepancies. The same replay is installable, and open source (MIT), one repository per language: https://github.com/outcometick/polymarket-backtest (Python) and https://github.com/outcometick/polymarket-backtest-js (JavaScript / TypeScript). Documentation, written for AI coding agents too: https://outcometick.github.io/polymarket-backtest/ (one plain-text file: https://outcometick.github.io/polymarket-backtest/llms-full.txt). `pip install outcometick` includes `outcometick.backtest`, a pure-Python local backtester — the same archive decoder, engine and report as the queue, no Node needed: `run(StrategyClass, load_sample())` downloads the free sample day once and returns the report with the run's full trade and fill rows. Its decoder and report are tested against the JavaScript ones event by event and row by row. It decodes a day on every CPU core, and with `workers=N` replays markets in parallel too, with the single-process result (rows, totals and logs) for strategies that keep their state on the instance as market mode requires; `sweep(StrategyClass, data, grid, holdout=0.3)` runs a parameter grid, ranks it on the earlier days and replays the top cells on the held-out later days, so an in-sample winner that does not survive is visible. The `ot` CLI — which validates a strategy, runs it locally and submits it — ships in the npm package `outcometick` and has to be installed globally to be on PATH. `ot check` runs the SAME manifest and source validator the queue runs, so a strategy it accepts is not later rejected for a validation difference between the two. That is the only guarantee it makes: a submission can still fail for reasons the checker does not see — a date range with no complete archived days, an insufficient credit balance, or an upload that did not land. Starting points: `ot init --template [--lang python|nodejs]` copies a working strategy. Two ship today, each in Python and Node.js: favourite-near-close (in the last minute, buy the side the settlement feed says is winning while the book sells it under a ceiling; runs locally on the free sample) and binance-lead (take the side Binance spot points at before the book catches up; needs the Binance reference feed, so hosted backtest only). They are starting points, not proven edges. Repository: https://github.com/outcometick/polymarket-strategy-templates Billing is per market-day scanned, where a market-day is one asset on one UTC day at one market interval. Credits are bought up front: 120 for $2, 800 for $10, 3000 for $30. Only a run that produces a report is charged — rejected, crashed, timed-out and abandoned runs are refunded in full, and a run that scans fewer market-days than quoted is refunded the difference. ## Pricing Three ways to buy data, each for one venue or both: - **Subscription to the newest 30 days.** $10/month for one venue (Polymarket or Predict.fun), $18/month for both. A key reaches the 30 newest days the archive holds, and the window moves with the archive. A new subscriber reaches those 30 days immediately. Renews monthly. - **Prepaid months of the same subscription.** 1, 3, 6 or 12 months paid at once, no auto-renewal, a month counted as 30 days: one venue $10 / $29 / $54 / $100, both $18 / $52 / $97 / $180. Same newest-30-days window; after it ends the key keeps downloading the days that were paid for, for one more month. Payable by card or stablecoin. - **One-time purchase of any date range in the archive.** Priced per archived day: $0.40 for one venue, $0.70 for both, at least 7 days. The key can download for as many days as were bought. The pricing page quotes any range before checkout. Either way, every data type for the chosen venues is included. Deeper history is not sold as a subscription. The 90-day and full-history tiers were withdrawn on 2026-09-11: depth is a one-time value, and charging for it monthly meant a continuously subscribed customer who kept what they downloaded paid again every month for history they already had. Prices are quoted in USD on the English site and CNY on the Chinese one — the currency follows the language, and there is no currency selector. WeChat Pay is offered on one-time payments (prepaid months and date ranges) only, in CNY. Stablecoins (USDT or USDC) are accepted for one-time purchases and are always priced in USD. ## Smart-money trade history A second, separate product, on sale since 2026-10-09 (https://outcometick.com/v1/public/smart-plans lists the live prices). Built by our partner PMWallets. - What it is: daily files of the on-chain Polymarket fills of the top-ranked traders. Two lists, Top 100 and Top 1000 (the Top 1000 file contains the Top 100). - How the list is chosen: each day, from the 30 days ending at the previous UTC midnight. A trader qualifies with a trade in the window, at least 10 settled markets won or lost, at least $1,000 bought, and a realized profit that stays positive after their single most profitable market is removed; qualifiers are ranked by that remaining profit. This reduces the weight of one lucky bet; it does not detect wash trading. Wallets are merged into one trader only where they can be shown to be linked. - A day's file uses the list as of the end of the previous UTC day, so nobody is selected because of the trades in the file. A trader who drops off the list is not included from the next file on. - Data starts on 2026-10-08 and is not backfilled: until 30 days have accumulated, a subscription reaches only the days published so far (https://outcometick.com/v1/public/smart-coverage counts them). - Pricing, in US dollars on both the English and Chinese sites: Top 100 $19/month, Top 1000 $49/month by card subscription, or 30 days at a time in USDT/USDC. A subscription downloads the last 30 days; a payment for one list never extends the other. - Its own API key and endpoints (/v1/smart/days, /v1/smart/download); a data key does not work there and a smart-money key does not work on the archive. - Free sample (traders ranked 901-1000, three real days 2026-10-08 to 2026-10-10, each from that day's frozen list): https://github.com/outcometick/polymarket-smart-money-data-samples · https://www.kaggle.com/datasets/ligengxin96/polymarket-smart-money-trades-sample · https://huggingface.co/datasets/Ligengxin96/polymarket-smart-money-trades-sample ## Things a buyer should know before asking - Rolling windows move. A 30-day tier covers the 30 newest days the archive holds — counted from the newest archived day, not from today, and not to a fixed date; the window a key sees changes daily. Counting from the archive rather than the calendar is deliberate: data lands a couple of days after it is collected, and counting from today would quietly shorten every window by that lag. - A key stops working when its period ends, apart from a two-day tail during which it can still fetch days that fall inside the period it paid for — those days only reach the archive after the period closes. Nothing archived after the period ends is reachable. Files already downloaded remain the buyer's; access to new ones does not continue. - Predict.fun prices are float64 only — the upstream feed carries no full-precision string — and its order-book data is snapshots without deltas. Polymarket carries both. ## Common questions Answers are written to stand alone, so each one can be quoted without the surrounding context. **Which market intervals have order-book data?** On Polymarket, order-book data (full-depth snapshots, incremental updates, and top-of-book) exists for the 5-minute and 15-minute Up/Down markets only. There is no 1-hour, 4-hour or daily Polymarket order-book data. On Predict.fun, order-book data exists for the 5-minute, 15-minute, hourly and daily markets, for BTC, ETH and BNB, filtered as `interval=5m`, `15m`, `1h` and `1d`. Neither venue has a 4-hour market. The 1s...1mo values that appear as `intervals` in the API are candle periods derived from the settlement feed, not market periods, so `interval=1h` alone selects both the hourly Predict.fun books and the hourly candles; name the dataset as well to separate them. **Is there aggregated trading volume?** No. The archive carries Polymarket's individual trade prints (unthrottled, as the venue broadcast them; Predict.fun has none), but it does not carry a precomputed volume series. A buyer who wants volume per market, per interval or per day aggregates it from the trade prints, which is what makes any definition of it auditable. **Can I test a strategy on this data without downloading it first?** Yes. A strategy is submitted as text and replayed inside a sandbox against the same archive, and the report comes back with an equity curve, every fill priced against the depth that was really there, and a settlement cross-check. Billing is per market-day scanned and starts at $2; a run that produces no report is refunded in full. The same replay engine is installable from npm and PyPI and runs locally against files on disk, at no cost and with no key. **Is there free sample data?** Yes, one complete archived day per venue, in the exact directory layout the paid archive uses, downloadable without an account: https://github.com/outcometick/polymarket-tick-data-samples/releases/latest/download/polymarket-data-samples.tar.gz and https://github.com/outcometick/predict.fun-data-samples/releases/latest/download/predict-fun-data-samples.tar.gz The local runner reads those archives directly, so a strategy can be written and replayed end to end before anything is bought. The same day is also published under CC BY 4.0 on Kaggle, decompressed: https://www.kaggle.com/datasets/ligengxin96/polymarket-predict-fun-tick-data and on Hugging Face, as the original .gz files with one config per file type: https://huggingface.co/datasets/Ligengxin96/polymarket-predict-fun-tick-data **How does outcometick compare to other Polymarket data providers?** It covers the crypto Up/Down markets on Polymarket and Predict.fun, from 2026-06-08 and 2026-06-13. It is sold either as a subscription to the newest 30 days ($10 a month for one venue, $18 for both) or as a one-time purchase of any range in the archive, from $0.40 a day. What it adds is the settlement side: the Chainlink streams those markets resolve on (the TWAP streams from 2026-08-08; they became the settlement basis on 2026-08-07, and that first day has no TWAP archive) and each market's strike and settled outcome sit in the same archive as the order books, so a strategy's wins and losses can be recomputed rather than trusted. Strategy code can also run in a hosted sandbox against that archive, billed per market-day from $2, without downloading anything first. Another provider fits better if you need any of the following. As described on their own sites when checked in September 2026 (not a complete list): - History from before those start dates: Telonex, Marketlens. - On-chain fills with wallet addresses: Telonex (both venues), Resolved Markets. - Sports, esports, weather or other non-crypto markets: Marketlens, Resolved Markets, Telonex. - A free API tier or a live WebSocket feed: Marketlens and Resolved Markets have free tiers, and Resolved Markets has a WebSocket. outcometick sells archived files only, and offers free sample days in their place. - Predict.fun books from before June 2026, or Predict.fun trades: Telonex. Like Telonex, Marketlens and PMData, outcometick also ships Parquet (format=parquet on /v1/files), alongside the gzipped CSV and JSONL archive files. The comparison, with the questions to put to any provider: https://outcometick.com/compare **How accurate is the data? Can the settlement results be trusted?** They are checked publicly. Each market is settled again from the archived settlement stream and compared with the official outcome. Of 312,499 Polymarket markets the archive can settle (2026-06-08 to 2026-10-14), 312,495 agree. The 4 that disagree all closed on 2026-08-17 and were resolved by the venue 31 to 65 minutes after their close; each is listed on the audit page. Of 123,794 Predict.fun markets (2026-06-13 to 2026-10-14), all agree. The per-market verdicts are public: https://outcometick.com/polymarket-settlement-audit and https://outcometick.com/predict-fun-settlement-audit **What can I check without a key?** Current dates, venues, assets and dataset names are public at https://outcometick.com/v1/public/coverage, and current tiers and prices at https://outcometick.com/v1/public/plans. Listing individual files and reading the per-day dimension summary require a key. **How far back does it go, and how often does it update?** Continuously since 2026-06-08 for Polymarket and 2026-06-13 for Predict.fun, growing by one archived UTC day at a time. Each day is normally published by 02:00 UTC the next day (13:00 UTC if it is held for repair). A range request spans at most 92 days per call. ## Endpoints anyone can check without a key - https://outcometick.com/v1/public/coverage — dates, venues, assets, datasets - https://outcometick.com/v1/public/plans — current tiers and prices - https://outcometick.com/docs — full API reference - https://outcometick.com/v1/openapi.json — the HTTP API as OpenAPI 3.1 (operations that spend money or credits are marked x-requires-human-confirmation) - https://outcometick.com/v1/public/smart-plans and /v1/public/smart-coverage — smart-money plans, whether it is on sale, and how many days are published - https://outcometick.com/v1/public/settlement-audit — the settlement audit's per-day counts and disagreements; /v1/public/settlement-audit/ lists that day's markets with their verdicts; add ?venue=predict for Predict.fun - https://outcometick.com/v1/public/asset-stats — how each asset's Up/Down markets settled over the most recent archived days (counts per market period, from the archive's markets files; recomputed several times a day) ## Links - Site: https://outcometick.com - API documentation: https://outcometick.com/docs - All of the documentation pages as one plain-text file, for AI coding tools: https://outcometick.com/llms-full.txt - Endpoint reference (/v1/files, /v1/meta, /v1/dl): https://outcometick.com/docs/api - File schemas, per dataset and per venue: https://outcometick.com/docs/schemas - How a market settles: https://outcometick.com/docs/settlement - Settlement audit — each Polymarket market recomputed from the archive vs the official outcome (agree / disagree / cannot be settled, with reasons): https://outcometick.com/polymarket-settlement-audit - The same for Predict.fun (outcome implied by its published start and end prices, recomputed from the Chainlink stream it forwards): https://outcometick.com/predict-fun-settlement-audit - Backtesting: https://outcometick.com/backtest - Strategy SDK reference: https://outcometick.com/docs/sdk - Polymarket historical data (crypto Up/Down markets only): https://outcometick.com/polymarket-historical-data - Order book data, in detail: https://outcometick.com/polymarket-order-book-data - Settlement data, in detail: https://outcometick.com/chainlink-settlement-data - Predict.fun data, in detail: https://outcometick.com/predict-fun-data - Predict.fun order books (snapshots only, no delta or trade stream): https://outcometick.com/predict-fun-order-book-data - Polymarket smart-money trade history: https://outcometick.com/polymarket-smart-money-data - Compared with other providers: https://outcometick.com/compare - Worked example — fees, queue position and markouts in a Polymarket backtest: https://outcometick.com/guides/polymarket-backtest-fees-queue - Worked example — grading Up/Down markets on the settlement stream each market names, and what the wrong stream flips: https://outcometick.com/guides/polymarket-settlement-price-backtest - Per asset, with settlement statistics: Polymarket https://outcometick.com/data/polymarket/btc · https://outcometick.com/data/polymarket/eth · https://outcometick.com/data/polymarket/sol · https://outcometick.com/data/polymarket/xrp · https://outcometick.com/data/polymarket/doge · https://outcometick.com/data/polymarket/bnb · https://outcometick.com/data/polymarket/hype Predict.fun https://outcometick.com/data/predict-fun/btc · https://outcometick.com/data/predict-fun/eth · https://outcometick.com/data/predict-fun/bnb (each has a Chinese twin under /zh) - SDK source (TypeScript): https://github.com/outcometick/outcometick-sdk-ts - SDK source (Python): https://github.com/outcometick/outcometick-sdk-python - Free sample day on Kaggle: https://www.kaggle.com/datasets/ligengxin96/polymarket-predict-fun-tick-data - Free sample day on Hugging Face: https://huggingface.co/datasets/Ligengxin96/polymarket-predict-fun-tick-data - Chinese: https://outcometick.com/zh - X (Twitter): https://x.com/outcometick - Partner: PMWallets (Polymarket smart-money wallet leaderboard): https://pmwallets.com/ - Support (Telegram): https://t.me/+TcK_hJOzOz5mZTk1 - Community (Discord, English and Chinese): https://discord.gg/ZDMqkptwm5 - Support (email): support@outcometick.com - Manage or cancel a data subscription (Stripe customer portal; sign in with the purchase email; cancellation takes effect at the end of the paid period): https://billing.stripe.com/p/login/eVq4gygMI1ze0RldeOf7i00 --- # Documentation ## Quickstart (https://outcometick.com/docs) With a key in hand, three commands put a file on your disk. The first block below is copy-pasteable as-is. ### Sixty seconds Replace the key on line 1 with your own (shown once on the page right after payment); paste the rest as-is. ```bash # 1. your key, from the page you saw right after paying export OT_KEY="ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" # 2. what can I get? curl -s -H "Authorization: Bearer $OT_KEY" \ https://outcometick.com/v1/meta # 3. find a file — with no date filter you get the newest day your key reaches curl -s -H "Authorization: Bearer $OT_KEY" \ "https://outcometick.com/v1/files?asset=btc&dataset=prices" # 4. download it — every file in that listing carries its own url URL=$(curl -s -H "Authorization: Bearer $OT_KEY" \ "https://outcometick.com/v1/files?asset=btc&dataset=prices" \ | jq -er '.files[0].url') curl -L -H "Authorization: Bearer $OT_KEY" "$URL" -o btc-prices.csv.gz ``` The -L in step 4 matters: the download endpoint answers with a 302 to a short-lived object-storage URL. Files come straight from storage — they never pass through our server. ### One line in Python pandas, DuckDB and wget cannot attach a header to a URL, so the download endpoint also accepts ?api_key=. Be aware it puts the key in browser history and proxy logs — prefer the header wherever you can set one. ```python import pandas as pd, requests KEY = "ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" auth = {"Authorization": f"Bearer {KEY}"} r = requests.get("https://outcometick.com/v1/files", headers=auth, params={"asset": "btc", "dataset": "prices"}) # the download endpoint also takes ?api_key=, for clients that cannot set a header. # The URL no longer ends in .gz, so name the compression; full_accuracy_value is # a 1e18 fixed-point integer wider than int64, so keep it as text. df = pd.read_csv(r.json()["files"][0]["url"] + "?api_key=" + KEY, compression="gzip", dtype={"full_accuracy_value": str}) print(df.head()) ``` The same URL works in polars and DuckDB, and the JSONL datasets (markets, book, trades) load the same way. The arguments are not decoration: without them pandas cannot read the file at all, and pandas and DuckDB silently turn 1e18 fixed-point values and 76-digit token ids into floats. ```python import duckdb, pandas as pd, polars as pl, requests KEY = "ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" auth = {"Authorization": f"Bearer {KEY}"} def newest(dataset): r = requests.get("https://outcometick.com/v1/files", headers=auth, params={"venue": "polymarket", "asset": "btc", "dataset": dataset}) return r.json()["files"][0]["url"] + "?api_key=" + KEY # CSV — settlement prices, here; klines read the same way prices = newest("prices") pl.read_csv(prices) # types= only for prices / twap30s / twap60s: it errors on a file without the column duckdb.sql(f"SELECT * FROM read_csv('{prices}', types={{'full_accuracy_value': 'VARCHAR'}})").df() # JSONL — markets, book, trades: ids and prices are strings, keep them that way markets = newest("markets") pd.read_json(markets, lines=True, compression="gzip", dtype=False) pl.read_ndjson(markets) duckdb.sql(f"SELECT * FROM read_json('{markets}')").df() ``` Every file also comes as Parquet: add format=parquet to the listing. Same rows, already typed, so no compression or precision arguments are needed. ```python import duckdb, polars as pl, requests KEY = "ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" auth = {"Authorization": f"Bearer {KEY}"} # format=parquet lists the Parquet copy of each file: the same rows, already typed r = requests.get("https://outcometick.com/v1/files", headers=auth, params={ "venue": "polymarket", "asset": "btc", "dataset": "book", "format": "parquet"}) url = r.json()["files"][0]["url"] + "?api_key=" + KEY pl.read_parquet(url) duckdb.sql(f"SELECT * FROM read_parquet('{url}')").df() ``` Comparing the two platforms? With format=parquet, dataset=markets_unified lists a second copy of each markets file in one shape for both: one row per market, times in milliseconds, prices as exact decimals and the outcome spelled out, so a single query reads Polymarket and Predict.fun together. The columns are on the schemas page. Would rather not download the files at all? The same archive can be replayed against a strategy in a sealed sandbox, with fills matched against the depth that was really there — see the strategy SDK reference (https://outcometick.com/docs/sdk). ### From an AI coding tool (MCP) The npm package includes an MCP server, so Claude Code, Cursor or any MCP client can look up coverage, list and download files (sha256-verified), quote a backtest, validate a strategy locally, follow your runs and, with a smart-money key (OT_SMART_KEY), download the smart-money lists. It runs on your machine and needs Node 24; your keys stay in its local config. It never submits a backtest, because that spends credits. ```bash # Claude Code — every key is optional; public tools work without any claude mcp add outcometick \ -e OT_KEY="ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" \ -e OT_SMART_KEY="ck_f6e5d4c3b2a1.xxxxxxxxxxxxxxxxxxxxxxxx" \ -e OT_BACKTEST_KEY="bt_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" \ -- npx -y -p outcometick ot mcp ``` Cursor, Claude Desktop and other MCP clients take the same server as JSON: ```json { "mcpServers": { "outcometick": { "command": "npx", "args": ["-y", "-p", "outcometick", "ot", "mcp"], "env": { "OT_KEY": "ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" } } } } ``` Or use the remote server at https://outcometick.com/v1/mcp, with nothing to install. In claude.ai or ChatGPT, add it as a custom connector: you are sent to an outcometick page to paste your keys, and the connector receives a token that stops working when the key is revoked or expires. To see or disconnect the apps connected with your key, use https://outcometick.com/connections. Clients that can send headers can pass the key directly. Remotely, download_file and smart_download return a short-lived signed link together with its sha256, and check_strategy takes the files' contents instead of a directory. ```bash # Remote: nothing to install. The key goes in a header… claude mcp add --transport http outcometick https://outcometick.com/v1/mcp \ --header "Authorization: Bearer ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" # …or leave --header out and sign in with your keys in the browser (OAuth). # Public tools only, no key: https://outcometick.com/v1/mcp/public ``` The HTTP API is also described in OpenAPI 3.1, for code generators and agents: https://outcometick.com/v1/openapi.json. Operations that spend money or credits are marked x-requires-human-confirmation. ### Common tasks #### One day, one asset, one interval Every filter is optional. Use date for a single day, or from / to for a range. None of these examples names a day: they read it out of /v1/meta, which answers with the window your own key reaches. ```bash # the newest day your key reaches DAY=$(curl -s -H "Authorization: Bearer $OT_KEY" \ https://outcometick.com/v1/meta | jq -r .lastDay) # every BTC 5-minute order book for that day curl -s -H "Authorization: Bearer $OT_KEY" \ "https://outcometick.com/v1/files?date=$DAY&asset=btc&interval=5m&dataset=book" ``` #### Everything for 5min markets: books and settlement The settlement streams (prices / twap30s / twap60s) are continuous and carry no interval of their own, so interval=5m leaves them out. Add none to get both in one call — settling a 5min market takes twap60s, and the book alone will not do it. ```bash DAY=$(curl -s -H "Authorization: Bearer $OT_KEY" \ https://outcometick.com/v1/meta | jq -r .lastDay) # everything a 5-minute market needs: the 5m slices AND the settlement streams curl -s -H "Authorization: Bearer $OT_KEY" \ "https://outcometick.com/v1/files?date=$DAY&asset=btc&interval=5m,none" ``` #### A month of settlement prices, verified on the way in Each file carries a sha256 identical to the archive manifest. A match proves you have exactly what we published — not merely that the transfer succeeded. ```bash #!/usr/bin/env bash # a month of BTC settlement prices, downloaded and checksum-verified set -euo pipefail export OT_KEY="ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" # 30 days back from the newest day you can reach, clamped to the oldest — # one call covers at most 92 days, and your window may be shorter than either read -r FROM TO < <(curl -s -H "Authorization: Bearer $OT_KEY" \ https://outcometick.com/v1/meta \ | jq -r '((.lastDay | strptime("%Y-%m-%d") | mktime) - 29*86400 | strftime("%Y-%m-%d")) as $back | "\(if $back < .firstDay then .firstDay else $back end) \(.lastDay)"') curl -s -H "Authorization: Bearer $OT_KEY" \ "https://outcometick.com/v1/files?asset=btc&dataset=prices&from=$FROM&to=$TO" \ | jq -r '.files[] | "\(.url) \(.name) \(.sha256)"' \ | while read -r url name sha; do curl -sL -H "Authorization: Bearer $OT_KEY" "$url" -o "$name" echo "$sha $name" | sha256sum -c - done ``` #### Straight into one DataFrame ```python import pandas as pd, requests from datetime import date, timedelta KEY = "ck_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx" BASE = "https://outcometick.com" auth = {"Authorization": f"Bearer {KEY}"} meta = requests.get(f"{BASE}/v1/meta", headers=auth).json() last = date.fromisoformat(meta["lastDay"]) first = max(date.fromisoformat(meta["firstDay"]), last - timedelta(days=6)) # every 1-minute kline for BTC over the last week your key reaches r = requests.get(f"{BASE}/v1/files", headers=auth, params={ "asset": "btc", "dataset": "klines", "interval": "1m", "from": first.isoformat(), "to": last.isoformat(), }) files = r.json()["files"] df = pd.concat( pd.read_csv(f["url"] + "?api_key=" + KEY, compression="gzip") for f in files ).sort_values("ts_ms") print(len(df), "rows") ``` ### Guides - Fees, queue position and markouts: Two simple strategies replayed on the free sample day, and what taker fees, book depth and queueing do to them. (https://outcometick.com/guides/polymarket-backtest-fees-queue) - Grading markets on the right settlement price: How many outcomes flip when a backtest grades with the wrong Chainlink stream, and how early the result shows up. (https://outcometick.com/guides/polymarket-settlement-price-backtest) ### Cancel your subscription Sign in to the Stripe customer portal with the email you paid with. Cancelling takes effect at the end of the paid period and the key works until then; card changes and invoices are there too. Range purchases and stablecoin payments are one-off, so there is nothing to cancel. ## Polymarket and Predict.fun data download API (https://outcometick.com/docs/api) One bearer key for all three. Everything is GET, everything is rate-limited the same way, and nothing here is paginated — you narrow with filters instead. ### GET /v1/meta — See what you can get The dimensions come back already narrowed to your tier — a Polymarket plan never sees Predict.fun. Note the two venues do not cover the same assets, so read assets rather than assuming one list. assets and intervals hold real values only; filterTokens.appliesTo names the dimensions some files leave empty, which you select with none. ```json { "firstDay": "2026-06-06", "lastDay": "2026-08-12", "days": 68, "venues": ["polymarket"], "assets": ["BNB","BTC","DOGE","ETH","HYPE","SOL","XRP"], "intervals": ["1s","1m","3m","5m","15m","30m","1h","2h","4h","6h","8h","12h","1d","3d","1w","1mo"], "datasets": { "prices": "…", "book": "…", "klines": "…" }, "filterTokens": { "noValue": "none", "appliesTo": ["asset","interval"] } } ``` ### GET /v1/files — Find files Every file in the response carries its own url; hand it straight to your downloader. Filters (all optional): - asset: btc / eth / … comma-separated for several, e.g. asset=btc,eth - dataset: prices / twap30s / twap60s / book / orderbook / best_bid_ask / price_change / last_trade_price / tick_size_change / markets / klines. book is Polymarket full depth, orderbook is the Predict.fun tree; best_bid_ask is unthrottled top of book — prices only, no sizes - interval: 5m and 15m for Polymarket markets; Predict.fun also has 1h and 1d. 1m, 1h, 1d … for klines. The settlement streams (prices / twap30s / twap60s) have no interval of their own — name them with none - none: Any filter takes none for "this dimension is empty". interval=5m,none reads as "5m market data plus every period-less dataset"; on its own, interval=5m returns only files actually sliced at 5m - venue: polymarket / predict - date: a single day — same as from=to=that day - from / to: a range, at most 92 days per call. Omit both for the latest day - format: gz (default) lists the archive files. parquet lists the Parquet copy of each: same rows, already typed, wide values kept as text. Its sha256 is its own. The Parquet copy of a day is normally published together with the day itself; a file not converted yet is counted in pending. With parquet, dataset=markets_unified lists the unified markets tables (see the schemas page) ```json { "from": "2026-08-12", "to": "2026-08-12", "days": 1, "count": 1, "bytes": 1655751, "files": [{ "date": "2026-08-12", "name": "BTCUSD-prices-2026-08-12.csv.gz", "asset": "BTC", "dataset": "prices", "interval": null, "bytes": 1655751, "sha256": "a8b4f866772578e4…", "url": "https://outcometick.com/v1/dl/2026-08-12/BTCUSD-prices-2026-08-12.csv.gz" }] } ``` ### GET /v1/dl/:date/:name — Download one file Answers 302 to a short-lived object-storage URL (valid 15 minutes). Use curl -L, or any client that follows redirects. ### Smart-money trade history A separate subscription with its own key: daily files of the trades made by the top-ranked Polymarket traders. A data key gets 403 on these two endpoints and a smart-money key gets 403 on the ones above. - GET /v1/smart/days: The days your key can download, newest first, with each list's status (published, stopped with a reason, or not yet published), size and sha256. - GET /v1/smart/download?day=&list=: list is top100, or top1000 on the Top 1000 plan. Answers 302 to a short-lived signed URL, with the sha256 in the x-outcometick-sha256 header. How the lists are chosen, the file columns and pricing: https://outcometick.com/polymarket-smart-money-data ### When something goes wrong - 401: Read the body — the two cases mean opposite things. missing API key: the request carried no credential at all. The header must be named Authorization and its value must begin with Bearer (apikey:, X-API-Key:, or the bare key without Bearer all land here). invalid or expired API key: wrong key, revoked key, or a lapsed subscription. Do not mistake the first for the second — re-issuing the key will not change anything. - 403: The body reads date outside subscription coverage or from is outside your coverage: that day is outside your range. The floor field is the earliest date you can request and ceiling the latest — take them from the response rather than working out your tier window. - 404: The body reads file not found in your scope: either the file does not exist for that day, or it is not part of your tier (e.g. a Polymarket plan asking for a Predict.fun file). List /v1/files for the day to see what is actually there. - 200 with an empty files array: Filters too narrow or misspelled. Drop a filter, or check /v1/meta for the valid values. A common one: adding interval makes prices / twap30s / twap60s disappear — they have no interval, so ask for interval=5m,none. - 429: Too many requests — back off and retry. - 5xx: Temporary; retrying is safe. ## File schemas (https://outcometick.com/docs/schemas) Two container formats, and two venues that do not share a field list. Everything below is a column or key you will actually find in a file, listed per dataset. ### The two formats Settlement feeds and klines are gzipped CSV with a header row. Market event streams are gzipped JSONL — one JSON object per line, in the order the frames arrived. Nothing is re-sorted on the way into the archive. Every file also comes as Parquet (format=parquet on /v1/files): the same rows and columns, already typed, with the nested fields kept as nested columns. ### Settlement feeds — CSV (Polymarket side) The same five columns for prices, twap30s and twap60s. This is the only place in the archive where three separate timestamps travel together; from 2026-10-20 (UTC) the upstream feed no longer carries the server send time, so server_ts_ms is empty from that day on. - feed_ts_ms: Event time: the timestamp upstream put on the report (UTC ms) - value: Price as a float, for convenience - full_accuracy_value: Whenever the relay publishes it, the same price as a fixed-point integer string (1e18). Use this to recompute outcomes, not value - server_ts_ms: When the upstream server sent it (UTC ms); empty from 2026-10-20 (UTC) on - recv_ms: When we received it (UTC ms). All three are kept apart, never merged — so you can measure the capture path ### Market events — the JSONL envelope (Polymarket side) Every line in book, price_change, best_bid_ask and last_trade_price carries the same six top-level keys. Note there are TWO timestamps here, not three, and the event one is not called feed_ts_ms. - slug: The market this frame belongs to - asset_id: Which outcome token the frame is about. Null on price_change, because one of those lines carries the changes for both sides at once - event_type: Same name as the dataset - event_ts_ms: Upstream event time (UTC ms) - recv_ms: When we received it (UTC ms) - payload: The upstream message, kept as it arrived ### What is inside payload, per dataset - book: bids and asks, each an array of {price, size} objects, plus hash, market, asset_id and timestamp. One line is a snapshot of ONE outcome token — which one is the envelope asset_id — and carries both of its ladders. An empty array means that ladder holds no orders, not that it went unreported - price_change: price_changes, an array whose items each carry hash, side, size, price, asset_id, best_ask and best_bid. Both outcome tokens appear in the same line, which is why the envelope asset_id is null - best_bid_ask: best_bid, best_ask and spread, for one asset_id. Prices only — there are no sizes on this stream, so depth still comes from book or price_change - last_trade_price: side, size, price, fee_rate_bps and transaction_hash, for one asset_id Rebuilding a ladder from these: book and price_change are captured at a cadence, not frame by frame — snapshots are kept per outcome token and deltas per market, so frames that arrived between two kept ones are not in the archive. Each price_change item sets a level to an absolute size (size 0 removes it), so a removal that fell between kept frames leaves that level in a rebuilt ladder until the next book line for that token replaces the whole ladder. To rebuild: start each token from its latest book line, apply price_change items on top, and drop any bid above or ask below the newest best bid and ask — from best_bid_ask, which is not throttled, on the days the archive has it, otherwise from the best_bid and best_ask carried on the price_change item itself. That removes every level the best prices have moved past. It cannot restore what the dropped frames added or resized: until the next snapshot a level can be missing or carry an old size, at the top as well as further back, because best_bid_ask carries prices only, never sizes. For the latest best bid and ask themselves, read best_bid_ask. The SDK applies this exact rule: OrderBook, in outcometick/data for Node (npm i outcometick) and outcometick.data for Python (pip install outcometick). Feed it the rows of the book, price_change and best_bid_ask files merged by recv_ms, then read ladder() per outcome token. ### markets — one line per market Metadata, the strike, and the settlement once it exists. The raw key holds the upstream market object unchanged, which is where the settlement configuration lives. - slug / asset / interval_sec: Identity and duration. interval_sec is seconds - condition_id / token_ids: On-chain identifiers. token_ids has two entries and the FIRST is Up — established by measurement, not by assumption. For Polymarket Protocol V2 markets (new markets from about 2026-11-02, raw.version "v2") token_ids are V2 position ids, the ids the CLOB trades them under - start_sec / end_sec: Market window, in SECONDS — not milliseconds like the event streams - resolved / outcome_prices: Settlement. ["1","0"] means Up won, ["0","1"] means Down won. Only a complete binary pair is meaningful - strike_value: The price to beat, as a fixed-point integer string (1e18). Null when we cannot establish it honestly — never guessed from a nearby tick - raw: The upstream market object as received, including raw.cryptoMarketConfig — see how a market settles ### klines — CSV Derived from the settlement feed, so these are OHLC and there is no volume column. ticks counts settlement updates in the bucket; it is not traded size. - ts_ms: Bucket start (UTC ms) - open / high / low / close: Prices within the bucket - ticks: How many settlement updates fell in the bucket — an update count, not volume ### Predict.fun does not share the schema above Different column names, a different ladder shape, and different time units. If you subscribe to both platforms, treat them as two formats rather than one with extra fields. - prices (CSV): price_feed_id, symbol, provider, publish_time, server_ts, price, recv_ms. publish_time and server_ts are in SECONDS while recv_ms is milliseconds, and the price column is called price rather than value - orderbook (JSONL): market_id, category_slug, update_ts_ms, recv_ms, payload. Inside payload, bids and asks are arrays of [price, size] PAIRS — not {price, size} objects as on the Polymarket side — alongside version, orderCount, lastOrderSettled and settlementsPending - markets (JSONL): category_slug, asset, interval_label, market_id, price_feed_id, price_feed_symbol, price_feed_provider, condition_id, start_sec, end_sec, start_price, end_price and status. Prices here are ordinary decimal strings, not 1e18 fixed point One thing worth knowing before you write a settlement check against this: status reports what upstream last told us, and a market stops being re-read once its end_price arrives. end_price is therefore the field that tells you a Predict.fun market has settled. A market still open when its day ships (the daily ones, and hourly ones that start just after midnight) has an empty end_price in that day's file; it also appears in the next day's file, which carries the settled values, so read it from there. ### Unified markets (Parquet, both platforms) dataset=markets_unified with format=parquet: one row per market and the same columns for Polymarket and Predict.fun, so the two read in one query. Each is built from the markets file of the same day and gated like it. - venue, market_id, condition_id, slug: polymarket or predict. market_id is the Polymarket condition_id or the Predict.fun market_id; slug is the Polymarket slug or the Predict.fun category_slug - asset, interval: BTC, ETH …; 5m, 15m, 1h, 1d - open_ts_ms, close_ts_ms: Start and end in milliseconds (start_sec and end_sec × 1000) - open_price: DECIMAL(38,18). The price the market is judged against: the Polymarket strike_value divided by 1e18, or the Predict.fun start_price - settle_price: DECIMAL(38,18). The Predict.fun end_price. Empty for Polymarket, whose settling value is in the stream settle_feed names - outcome: UP, DOWN or TIE (Predict.fun settles an unchanged price 50:50). Empty while a market is unsettled in that file, or when its result could not be read - settle_provider, settle_feed: Polymarket: chainlink and the stream it settles on (twap60s, twap30s or prices). Predict.fun: price_feed_provider in lower case and price_feed_symbol - token_up, token_down: The Polymarket token ids, as text. Empty for Predict.fun A market listed twice in one file keeps its last row. A Predict.fun market still open when its day ships settles in the next day's file, so when the same market_id turns up in two days, take the row that has an outcome. ## How a market settles (https://outcometick.com/docs/settlement) A crypto Up/Down market resolves against a Chainlink stream, not against the order book. Which stream is a property of the market, and the archive carries everything you need to recompute the result yourself. ### Two conventions you need #### The settlement rule has changed — read it off the market, not the date Before 2026-08-07(UTC) the instantaneous Chainlink stream decided the outcome; after it, the TWAP streams did — a 30s lookback for 5min markets, 60s for 15min. After 2026-08-14(UTC), 5min markets have since moved to 60s as well, so both durations now settle on twap60s. Take the stream from the market’s own raw.cryptoMarketConfig (twapEnabled, and twapLookbackSeconds of 30 or 60) rather than from the date — markets on different configs coexist on the same day. twap30s is still archived daily; it is simply no longer any market’s settlement line. #### Frames are stored as received Out-of-order included; we do not reorder an archive. Sort at query time if you need chronological order. ### Recomputing an outcome yourself Nothing here needs our word for it — the strike, the window and the deciding stream are all in files you downloaded. 1. Read the market line: start_sec, end_sec, strike_value, and raw.cryptoMarketConfig.twapLookbackSeconds. 2. Pick the stream that lookback names — 60 means twap60s, 30 means twap30s — and take its value at the close of the window. 3. Compare against strike_value. Both are fixed point at 1e18 on the Polymarket side, so parse both to integers and compare those rather than the floats: value is there for convenience and full_accuracy_value is the one to settle on, whenever the relay published it. 4. Check yourself against outcome_prices on the same line. ["1","0"] is Up, ["0","1"] is Down. ### The same four steps, as code Reads the two files you downloaded and prints a verdict per market. It takes the closing tick by timestamp rather than by position in the file, because frames are archived in the order they arrived. ```python import gzip, json, csv MARKETS = "BTC-5m-markets.jsonl.gz" # one line per market, as downloaded STREAMS = { # each market names the one it settles on 60: "BTCUSD-twap60s-prices.csv.gz", 30: "BTCUSD-twap30s-prices.csv.gz", 0: "BTCUSD-prices.csv.gz", # before TWAP, the instantaneous feed } _loaded = {} def stream(lookback): if lookback not in _loaded: with gzip.open(STREAMS[lookback], "rt") as f: _loaded[lookback] = list(csv.DictReader(f)) return _loaded[lookback] for line in gzip.open(MARKETS, "rt"): m = json.loads(line) # strike_value is null when it could not be established honestly if not m.get("resolved") or m.get("strike_value") is None: continue # Read the stream off the MARKET, never off the date: configs coexist on # one day. A MISSING config is a record we could not read — not evidence # of the pre-TWAP era — so it is skipped. Only an explicit null or 0 says # "this one settled on the instantaneous stream". raw = m.get("raw") cfg = raw.get("cryptoMarketConfig") if isinstance(raw, dict) else None if not isinstance(cfg, dict) or "twapLookbackSeconds" not in cfg: continue lookback = cfg["twapLookbackSeconds"] if lookback is None: lookback = 0 if lookback not in STREAMS: continue # unknown config: refuse rather than guess open_ms, close_ms = m["start_sec"] * 1000, m["end_sec"] * 1000 window = [t for t in stream(lookback) if open_ms <= int(t["feed_ts_ms"]) <= close_ms] if not window: continue # by timestamp, not by file order: frames are archived as received last = max(window, key=lambda t: int(t["feed_ts_ms"])) # full_accuracy_value is 1e18 fixed point ONLY when the relay published it; # otherwise the collector wrote str(value) and the archive cannot tell you fixed = last["full_accuracy_value"] if not fixed.isdigit(): continue # a decimal fallback, not fixed point settled = int(fixed) if abs(settled / 1e18 - float(last["value"])) > 1e-6: continue # an integer, but not at the 1e18 scale # only a COMPLETE binary pair is an outcome; anything else is bad metadata prices = m.get("outcome_prices") if prices not in (["1", "0"], ["0", "1"]): continue # the market resolves Up when the close is >= the strike, not > it mine = "UP" if settled >= int(m["strike_value"]) else "DOWN" theirs = "UP" if prices[0] == "1" else "DOWN" print(m["slug"], mine, "matches" if mine == theirs else "DIFFERS") ``` ### Predict.fun settles differently A Predict.fun market line carries start_price and end_price as ordinary decimals, and the outcome follows from comparing them. Read settlement off end_price rather than off status: status is whatever upstream last reported, and a market leaves the re-read queue as soon as its end_price lands, so status can stay OPEN on a market that has settled. A market whose end_price equals its start_price is a tie, which is a third outcome rather than a win for either side. ## Writing a strategy — SDK v3.0.0 (https://outcometick.com/docs/sdk) 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: ```python 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. 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. ```bash # the ot CLI — check / run / submit (node 24, ESM only) npm i -g outcometick@3.0.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.0.0 ``` ### Quickstart Two files is a complete submission. Python shown; Node.js has identical semantics with idiomatic names — see the parity table. strategy.py: ```python 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: ```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. ```bash # 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: https://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 (https://outcometick.com/docs). ### Manifest reference Without a valid outcometick.json a submission is not queued. It is also what the quote is computed from. - 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. #### 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. - 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. ### 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. - on_market_open → nothing: Once per market, before any event. Instance state is reset immediately before this call. - on_tick → Order | list | None: Every settlement-feed report, in event-time order. This is the stream the market settles on. - on_book → Order | list | None: 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(). - on_trade → Order | list | None: Each trade print in the archive, including other participants'. - on_settle → nothing: 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. - on_timer → Order | list | None: 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. NAME PARITY: - python: on_tick(ctx, tick) - nodejs: onTick(ctx, tick) ### ctx 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. ### 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: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) ``` #### 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: ```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: ```python 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: ```csv ts_ms,score,confidence 1786536000000,0.62,0.91 1786536060000,0.58,0.88 1786536120000,0.71,0.95 ``` outcometick.json: ```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: ```python 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. ### 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. - 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. - tag: Free 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. ```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. ```python 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. ```python 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.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. ### 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. - [ABSENT] Network. No sockets, no DNS, no HTTP client in the image. - [ABSENT] Filesystem writes outside a scratch tmpfs, and reads outside your own files. - [ABSENT] Wall clock, timezone, locale, environment variables, process id, hostname. - [REJECTED] threads, subprocess, eval, exec, dynamic import, reflection, native extensions. - [REJECTED] Unseeded randomness. Import-time static analysis catches it before the run starts. - [REJECTED] Imports outside the allowlist, including transitive ones. - [REJECTED] Patching built-ins (Node.js): the standard built-ins (Object, Function, Array, Map, Set, String, Number, Math, Promise, Buffer and the rest) and their prototypes are locked before your code loads, and so are their global names, because the engine keeps its state in them and reads its input through them. Giving your own object its own toString still works. - [REQUIRED] Serialisable instance state. We shard by market-day across workers, so state must survive a move. ### Limits - 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". ### 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. ```bash 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. ```bash # 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: https://github.com/outcometick/polymarket-backtest (Python), https://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): https://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 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= (first_complete_day..last_day). ```bash # 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_... ``` 1. Validate: Manifest schema, entry resolution, hook signatures, import allowlist, forbidden syntax. Static, seconds, free — and identical to ot check. 2. 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. 3. 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. 4. 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_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.