# Writing a Tide Arena bot

A bot is one TypeScript file that default-exports a Tide `Strategy`. Arena feeds it closed bars from a frozen data pack, simulates every trade it asks for with fixed fees and slippage, and scores the result. This page is the whole contract.

```ts
import type { Strategy } from "@chest-tide/arena";

const bot: Strategy = {
  name: "my-first-bot",
  markets: ["BTC 4h"],  // optional: the only pairs it runs on
  lookback: 50,            // bars handed to decide, 2 to 300
  decide({ candles, state }) {
    const atr = state.volatility.atr14;
    if (atr === null) return null;            // null = no trade
    const close = candles[candles.length - 1].close;
    return { side: "long", stop: close - 2 * atr, target: close + 4 * atr, maxBars: 12 };
  },
};

export default bot;
```

The starter bots (`*-bot.ts`) are working examples of everything below. Each is short and commented. They are teaching examples, not good strategies.

## The strategy

| Field | Meaning |
| --- | --- |
| `name` | Unique, shown in results. |
| `lookback` | How many closed bars `decide` receives, and the window `ctx.state` is built from. An integer from 2 to the pack's warmup (300). |
| `markets` | Optional. The pairs the bot runs on, written `"SYMBOL INTERVAL"` exactly as the pack names them: `markets: ["BTC 4h", "BTC 1d"]`. Arena never runs it anywhere else. Write the list out in the file as strings; Arena reads it from your source before anything runs. Leave it out to run on every compatible pair in the pack (all 5 Binance BTC pairs). |
| `interval` | Optional. `"15m"`, `"1h"`, `"4h"`, `"1d"` or `"1w"`: the bot is only asked on pairs of that interval. Without `markets`, it then runs on every pair at that interval; with both, every entry in `markets` must be at that interval. Leave both out to be asked on every pair in the pack and route on `state.symbol` / `state.interval` yourself. |
| `decide(ctx)` | Called at the close of every bar while the bot has no open position on that pair. Returns an `Intent` to trade, or `null`. |

`decide` must be **pure**. Given the same bars it must return the same answer. Do not use `Date.now()`, `Math.random()` or variables that survive between calls. Arena runs every bot twice, in fresh processes, and rejects one whose decisions differ. Each pair also runs in its own process, so nothing carries over from one pair to the next.

### What `decide` receives

`ctx.candles` holds the last `lookback` closed bars, oldest first. The last one is the bar that just closed. Each bar has `openTime`, `closeTime` (UTC epoch milliseconds), `open`, `high`, `low`, `close` and `volume`. The Binance BTC pack carries OHLCV only; no trade count is invented. The bar after the last one has not happened yet, and nothing in the context can reach it.

`ctx.state` is Tide's reading of those same bars, computed for you:

| Path | What it is |
| --- | --- |
| `symbol`, `interval`, `venue` | Which pair this is. On the TypeScript pack that is Binance BTC at `15m`, `1h`, `4h`, `1d` or `1w`. The exact pair names are in the pack. |
| `structure.trend` | Trend read from the swing sequence: `"uptrend"`, `"downtrend"` or `"range"`. |
| `structure.pivots`, `structure.sequence` | Recent swing highs and lows (most recent first), and their labels in order: `HH`, `HL`, `LH`, `LL`, `EQH`, `EQL`. |
| `volatility.atr14` | 14-bar average true range, in price. |
| `volatility.compression` | Recent range ÷ its trailing median. Below 1 means the market is quieter than usual. |
| `levels.rangeHigh`, `levels.rangeLow` | Highest high and lowest low of the lookback window. |
| `levels.vwap`, `levels.poc`, `levels.vah`, `levels.val` | Volume-weighted average price, and the volume profile: point of control, value-area high and low. |
| `position.vsVwapAtr`, `vsPocAtr`, `vsVahAtr`, `vsValAtr` | Distance of the close from each level, in ATRs. For example, `vsValAtr = -1.5` is 1.5 ATR below the value-area low. |
| `position.insideValueArea` | Whether the close is between VAL and VAH. |
| `volume.current`, `volume.medianTrailing`, `volume.expanding` | This bar's volume, the trailing median, and whether volume is expanding. |

Any value can be `null` early in the window, so check before you use it. The full types are in the `@chest-tide/arena` package, in `src/market/types.ts` (`node_modules/@chest-tide/arena/` in the kit).

### What `decide` returns: an `Intent`

| Field | Required | Meaning |
| --- | --- | --- |
| `side` | yes | `"long"` or `"short"`. |
| `stop` | yes | Stop price, on the losing side of the last close. Risk (R) is measured against it, so every trade needs one. |
| `target` | yes | Take-profit price on the winning side, or `null` for none. |
| `maxBars` | no | Leave at the close of this many bars, counting the entry bar. |
| `breakevenAtR` | no | Move the stop to the entry once price has gone this many R in your favour. Applied after the bar that reached it. |
| `partial` | no | `{ price, share, keepStop? }`: take `share` (0 to 1) off at `price`, a resting limit before the target. Once it fills, the stop moves to the entry, unless `keepStop` is true, when it stays where it was. |
| `reverseAfterPartial` | no | After the partial fills, the bot is asked again at each close, and an intent facing the other way closes the rest and reverses. |
| `exitOnVolumeContraction` | no | Leave at the close of a bar where `state.volume.expanding` is `false`. |
| `exitWhenSignalEnds` | no | The trade lasts only while the signal does: the bot is asked again at each close, and anything but an intent on the same side closes it at the next open (exit reason `signal`). Not with `reverseAfterPartial`. |
| `tag` | no | A label carried onto the trade, so you can slice results by setup. Reports keep its first 60 characters. |

### Importing helpers

Everything a bot may use comes from one import, the [`@chest-tide/arena`](https://www.npmjs.com/package/@chest-tide/arena) package: Tide's engine, which the kit installs.

```ts
import { atr, rsi, type Strategy } from "@chest-tide/arena";
```

Useful helpers:

- Momentum and bands: `rsi(candles, 14)`, `bollinger(candles, 20, 2)`, `adx(candles, 14)`
- Volatility: `atr(candles, 14)`, `trueRange(candles, i)`
- Structure: `findPivots(candles, strength)`
- Volume: `vwap(candles)`, `volumeProfile(candles, bucketSize)`

Arena accepts no other import: no other npm packages, no network and no file access. Arena uploads are one file of at most 64 KB.

## How a trade is simulated

1. `decide` runs at a bar's close. The intent fills at the **next bar's open**, plus 1 bp adverse slippage.
2. If that open is already past the stop, or past the first target, the trade is skipped and counted as skipped.
3. Each later bar is checked in this order: stop (an open past it fills at the open), partial, target, volume exit, time exit. **When one bar touches both stop and target, the stop counts.**
4. Only one position per pair. `decide` is not asked again until it closes, except with `reverseAfterPartial` or `exitWhenSignalEnds`.
5. A trade still open at the end of the window closes at the last bar's close.
6. Fees are charged on notional at entry and exit: the taker fee for market fills (entry, stop, time exit) and the maker fee for targets. Pack costs: 5.5 bp taker, 2 bp maker. Funding is not modelled.

Fees are measured in R, which punishes tight stops. A trade with a stop 0.3% away pays fifteen times the fee R of one with a stop 4.5% away. On 15-minute bars most simple ideas lose to fees.

## Score and rules

- **Where it runs.** Leave `markets` out and the bot is tested on every pair it can be: all 5 public BTC pairs, or every pair at its `interval` if it sets one. List pairs in `markets`, for example `markets: ["BTC 4h", "BTC 1d"]`, and it runs on those alone. The list is fixed by your file before anything runs.
- Each pair it runs on is its own account, starting at equity 1.0. Every trade risks **1% of that pair's current equity**, so a 1R loss costs 1% and a 2R win gains 2%, compounded.
- **Your book.** The pairs your bot actually traded, an equal stake in each. Pairs it ran on without trading aren't in it: they show in your report with 0 trades, marked not scored. The book is valued at every UTC midnight in the window, with any open trade marked at the last close before midnight (net of the fees it has paid so far), which gives one return per day.
- **Score: the Sharpe ratio** of those daily returns: their mean divided by their standard deviation, times √365 (these markets trade every day). It is the standard measure of return per unit of risk. Raw return can be bought with risk: trade more, or bet on one pair, and the best and worst outcomes both get bigger. The Sharpe ratio cannot: a bot with no edge scores around zero however it trades, so the bots on top are the ones whose returns were steady, not the ones that got lucky once.
- **Return** is shown beside it: the equal-weight geometric mean of the final equities of the pairs your bot traded, minus 1, as a percentage. It breaks a tie on Sharpe. A bot that makes no trades scores 0 and fails the rules.
- Every result also shows the **PSR** (probabilistic Sharpe ratio: the probability that the true Sharpe is above zero, given how many days were measured and how lumpy the returns were), the Sortino ratio, the annualised volatility and the book's drawdown, and lists the daily returns so you can check the arithmetic.
- The Donchian starter trades BTC 4h on the public pack: a return of about **+42.3%** and a **Sharpe ratio of about 0.64** across 2022–2026 (it still clears the rules; the other teaching bots do not on this longer window).
- To pass the rules (be *eligible*), a bot needs, over the pairs it runs on:
  - at least **30 trades** in total;
  - at least **5 trades on every pair it trades** (a pair with no trades has no minimum);
  - no trade above **5× leverage**. Leverage is 1% ÷ the stop distance as a fraction of entry, so **the stop must be at least 0.2% from the entry**;
  - no pair above **20% drawdown** on closed trades. That is about 23 net losses in a row from a peak.
- Ranking: eligible bots first, then higher Sharpe ratio, then higher return, then lower worst-pair drawdown, then more trades, then name. In competition judging, eligible bots that clear their **luck line** rank before the other eligible bots (see below).
- **One pair is enough.** A bot that trades a single pair, or a single timeframe, competes on equal terms with one that trades many. Nobody's score is diluted by pairs it chose not to trade, and its luck line is drawn on its own pairs. It does need 30 trades in all, and a specialist with few trades a month earns its place over more months.

### Your luck line

A short record can look good by chance, and how good depends on how a bot trades: one pair, or a few trades, gives luck a wider spread than many. So every bot is measured against **no-skill twins** of itself. A twin trades the same pairs in the same windows and makes as many trades on each as the bot did. It takes the bot's own trades, shuffled: their stops (sized in average true ranges, so each fits the market the twin enters), targets, time limits and directions, so a bot that only buys is compared with twins that only buy. Only when each trade opens is left to chance. The twins pay the same fees, so costs cancel out. Your **luck line** is the Sharpe ratio that 95% of 200 twins stay below. A bot above it, with a Sharpe ratio above zero, did better than a bot trading its way could by chance, 19 times in 20: it **clears** its line. The kit shows it: `npm run score -- bots/my-bot.ts --luck`. The twins are drawn from a seed (in judging, your source's SHA-256), so the line repeats exactly.

The engine is **`@chest-tide/arena@0.5.6`**. This release adds daily and weekly BTC pairs so TypeScript matches Pine's candle timeframes; pin this exact version (`npm install @chest-tide/arena@0.5.6`). You need **Node 22.13 or newer**. Tradathon 2027 fixes the version its judging uses when it opens on 1 February 2027.

### Public development markets

The TypeScript pack is **Binance BTCUSDT perpetual** at **15m, 1h, 4h, 1d and 1w** (Monday-UTC weeks), scored from **21 February 2022** through **27 September 2026** (up to 300 warmup bars before the start; daily and weekly carry shorter warmup from the shared 15m history). It is the same Binance BTC history the Pine track uses. Its SHA-256 is `3c9bfbb26273420b4341f8197f4151ac3e6727cf1bdcbef40db74d356f282d73`. A bot may declare any of those five pairs in `markets`, or leave `markets` out to enter every pair at its `interval` (or every pair when it sets no interval).

**Tradathon 2027 judges each entry on exactly that pack** — the same bytes builders preview and submit on. Official ranking is a Docker re-run of the entered source on this one window. An entry must **make money on the pack** (its book ends above where it started) and its Sharpe ratio must be **above zero**. Eligible bots that clear their **luck line** rank ahead of other eligible bots.

**Forward testing is phase 2, outside hackathon judging and prizes.** Older private Bybit multi-window packs are historical artifacts only and do not decide hackathon prizes.

## The checks before you submit

The Arena page checks your file before it lets you submit, and the kit's `npm run score` and `npm run submit` run the same checks. Tide repeats the source checks on every submission, however it arrives.

| Group | Check | Required |
| --- | --- | --- |
| File | Under 64 KB; valid TypeScript | yes |
| Structure | Default-exports the strategy; imports only Tide's engine (`@chest-tide/arena`); has a name, a lookback and a `decide` function; its interval, if set, is in the pack; its `markets`, if set, are written out in the file as strings and are pairs in the pack; no `requiresIndicatorMarket` or `decidesEvery` | yes |
| Safety | No `import()`, `require`, `eval` or `Function`; no `fetch`, `XMLHttpRequest`, `WebSocket`, `process`, `document`, `window` or similar | yes |
| Repeatable | No `Math.random`, `Date.now`, `new Date()`, `performance` or `crypto`; no `async`, `await`, promises or timers | yes |
| Repeatable | No top-level `let` or `var` | advice |
| Fair play | No hard-coded dates or timestamps (epoch numbers for 2000–2100, or dates in text) | yes |
| Fair play | No tables of 50 or more numbers in one list | advice |
| Run | Runs on every pair it enters without an error; every intent is valid; the same decisions on a second run | yes |
| Rules | The benchmark's rules (trades, leverage, drawdown) | no: a bot that fails them can be submitted, but will not rank |

Your own variable called `window` or `process` is fine: the checks only object to the globals. Once submitted, a bot cannot be changed or viewed; Arena shows its size and SHA-256 so you can tell which file it was. To change a bot, submit a new file.

## Entering a competition

Any of your scored submissions can be your entry in a competition such as Tradathon 2027: open it on the Arena page and choose **Enter this bot**, or call `arena_set_entry` over MCP. One entry per person; entering another replaces it, and you can withdraw. Entries can change until the deadline, then they are fixed, and the judges run exactly that source on the same development pack.

The judged pack is public, so a bot must not encode its prices or trades. Hard-coded dates and timestamps are refused, large tables of numbers are flagged, and the top entries' source is read by the judges: a bot that encodes a period's prices or trades is disqualified, however it hides them. The judges run your entered file on its `markets` (every pair if it has none) and score the pairs it trades there.

## How your score is worked out

Your bot runs on your side; Tide does the scoring. On the website it runs in your browser, sealed off from the page and the network. With the starter kit (`npx @chest-tide/arena init my-bots`) it runs on your machine (`npm run submit`). Either way, what reaches Tide is the bot's **decisions**, where it went long or short with its stop and target, and the `markets` it declares. Tide replays those decisions on those pairs through its own engine and stores the score that gives, counting the pairs it traded. A report whose markets differ from the ones your file declares is refused. That stored number is a **development-pack preview**, not the contest ranking.

Each result says how it was scored: **your browser**, **your machine (kit)**, or **Tide's runner** (Tide ran the source itself). Browser and kit scores prove the fills for the decisions you sent; they do not prove the uploaded source made those decisions. Official Tradathon ranking is a Docker re-run of the entered source on the same development pack (`npm run arena -- judge`). Tide's public host does not run that isolated runner today: a source-only paste or an MCP submit without the kit's report is **refused** (`RUNNER_OFFLINE`), not queued. Use the Arena page or `npm run submit`, then enter that submission. If the Docker run and the report disagree, the Docker run wins.

## The forward test

A backtest on a window anyone can download can be tuned to it, knowingly or not. So Arena also runs every bot that passes the rules **forward**: once a calendar month closes, Tide builds a pack for that month alone (the same pairs, costs and rules) and runs the bot again, from its source, in its isolated runner. Only months that begin after you submitted the bot count, so nothing in them could have shaped it.

Each month's daily returns are added to the bot's forward record, and the record is scored the same way: one Sharpe ratio over every forward day. A forward place needs **2 months**; before that the bot is listed as provisional. The rules apply to the whole record: the leverage and drawdown limits in every month, and the trade minimums over all of them together once there are 2. A month with no trades is a flat month; a month the bot fails to run in counts against it. Your Arena page shows your bots' forward months and their place among every bot that has one; nobody else's bots or names are shown to you.

A bot whose book ended up in at least **9 of its last 12 forward months** is marked **consistent**, whatever it trades: one pair at one timeframe counts as much as every pair in the pack. A bot with no edge manages that about 1 time in 25.

## Reading your report

Every Arena result has:

- **the Sharpe ratio, PSR, return, and each pair's return, trades, drawdown, fees and highest leverage**: pairs without trades are listed as not scored, and pairs outside its `markets` as not run;
- **a hint for each rule the bot broke**, in the pack's own numbers;
- **every trade**, as a table and a CSV download: entry and exit times and prices, stop, target, bars held, net R, fees in R, exit reason, and the pair's equity after the trade;
- **cumulative net R per pair**, as a chart;
- **what your bot printed**: anything written with `console.log` (or `console.error`), the last 2,000 characters for each pair. Use it to check what your bot sees;
- **the starter bots' scores on the same pack**, for comparison.

## Submitting from a coding agent

Arena has an MCP server, so Claude Code, Cursor, Codex and similar agents can read this contract, submit bots and read the results without the website. The fastest way to submit is the starter kit: it scores the bot on your machine and sends its decisions.

```bash
TIDE_ARENA_KEY=<your Arena key> npm run submit -- bots/my-bot.ts
```

1. Sign in at [tide.chest.chat](https://tide.chest.chat/arena), open **MCP keys**, and create an Arena key (`tide:arena`). An Arena key can only submit and read your own bots.
2. Connect it. For Claude Code:

   ```bash
   claude mcp add --transport http tide https://api.tide.chest.chat/mcp --header "Authorization: Bearer <your key>"
   ```

   Other clients take the same URL and header as a Streamable HTTP server.
3. Ask your agent to write a bot. It has six tools:

| Tool | What it does |
| --- | --- |
| `arena_get_guide` | This guide, the current pack's markets and window, the starter bots' scores, and the limits. |
| `arena_get_starter_bot` | One starter bot's source. |
| `arena_submit_bot` | Submit a bot. With the kit's report (what `npm run submit` sends) Tide replays its decisions and stores the unofficial preview at once. Without a report the isolated runner would have to run the source; that runner is offline on the public host, so the call is refused (`RUNNER_OFFLINE`) — use `npm run submit` instead. |
| `arena_list_submissions` | Your submissions and their scores. |
| `arena_get_result` | One result: score, broken rules with hints, each pair, trades, and what the bot printed. |
| `arena_set_entry` | Make a scored submission your competition entry, or withdraw it, until the deadline. |

The same limits apply as on the website: 100 submissions in any 5 hours and 3 waiting at once. Read the code your agent writes before you rely on it, and have it submit with `npm run submit` rather than pasting a report into a tool call.

## Common errors

| Message | Fix |
| --- | --- |
| `bot did not start: the module must default-export a Strategy` | End the file with `export default bot;`. |
| `bot lookback must be a whole number from 2 to 300` | Set `lookback` in that range. |
| `bot interval 12h is not in this pack` | Use `15m`, `1h`, `4h`, `1d` or `1w`, or leave `interval` out. |
| `markets entry "ETH 4h" is not a pair in this pack` | Write each pair as `"SYMBOL INTERVAL"` exactly as the pack names it, such as `"BTC 4h"` or `"BTC 1d"`. Pairs at another `interval`, an empty list or a pair listed twice are refused too. |
| `long stop … is on the wrong side of price` | A long's stop must be below the last close, and a short's above it. The same applies to targets and partials, on the other side. |
| `non-deterministic decisions` | Remove `Date.now()`, `Math.random()`, and any state kept between calls. |
| `bot response timed out` | Each decision has 5 seconds, and a full run has 10 minutes. Avoid recomputing over the whole window on every bar if you can help it. |
| `used 7.10x leverage (limit 5x)` | Widen the stop. One to two ATR is a common choice. |

## Before you submit

- Score the bot locally. You get the same score as Arena, faster.
- Compare it with the coin flip. If your idea scores close to random entries, the edge is in the exits and the fees, not in the idea.
- Check every pair you trade, not just the total. One great pair can hide four that fail the rules.
- Choose your `markets` from what the bot is built for, not from which pairs did best on this pack: the judged months are different, and every pair the bot trades there counts.
- Look at when the money was made in the trades CSV. A result that comes from one week is luck until another period shows otherwise.
