# OptAlgo for AI agents

This file is for AI coding agents (Claude Code, Codex and others) that work for an OptAlgo user through the OptAlgo API. With one API key you get:

- a **backtest engine**: you write a strategy in Python, OptAlgo runs it on real candles and grades how far the result can be trusted;
- **paper trading** in the user's **My Bots**: real market data, fills in milliseconds, no exchange and no money;
- **live trading** on the user's own exchange account, **when the user decides** in the app.

Read this file once, completely, before you call anything. It is the canonical reference: if another page disagrees with it, this file wins. The plain-text copy is `https://docs.optalgo.com/optalgo-llm.md`.

## The path: follow it in this order

| Step | You do | The user sees |
|---|---|---|
| [0. Connect](#0-connect) | Device login; the key goes straight into a file, never into the chat | An approval page in the app |
| [1. Paper demo](#1-paper-demo-in-my-bots) | Open 1 to 3 paper positions in a My Bots bot, show them, close them | The bot and its trades at app.optalgo.com/my-bots |
| [2. Develop a strategy](#2-develop-a-strategy) | Write `strategy.py`, validate, backtest, read the grade, iterate | Every run at app.optalgo.com/backtest |
| [3. Run it on OptAlgo](#3-run-your-strategy-on-optalgo) | Deploy a backtested strategy (grade A to C), dry run first | The bot, marked **Runs on OptAlgo**, in My Bots, on paper |
| [4. Watch it](#4-watch-it) | Read its status, decisions and logs | Every decision on the bot's page |
| [5. Go live](#5-go-live) | Ask (the paper period is recommended, not required); never flip it yourself | A **Your agent asked to go live** card: Confirm or Decline |
| Live | Never your decision. The user switches to live in the app | The switch, in My Bots |

**Start with OptAlgo itself.** Do not open with TradingView, webhooks or alert messages: they are optional ([section 9](#9-optional-if-the-user-already-has-tradingview-alerts)) and only for a user who already has TradingView alerts and asks to connect them.

Conventions: "bot" means one of the user's own **My Bots** strategies. "Signal" is a JSON message that tells a bot what to do (open, close, move a stop). Exact strings OptAlgo returns are in `code font`: match on them, do not paraphrase them. Timestamps are epoch seconds (UTC) unless a field is ISO 8601 (`as_of`, `expires_at`, `resets_at`).

### The loop in one screen {#the-loop}

Every result carries `next`: it names the call to make after it. With an API key you call the endpoint; in a chat app with the [OptAlgo connector](https://docs.optalgo.com/ai-agents/connectors) you call the tool.

| # | Step | API | Connector tool |
|---|---|---|---|
| 1 | **Markets and limits**: which symbols and timeframes exist and from when; plan limits and quota left | `GET /v1/markets`, `GET /v1/backtests/limits` | `markets`, `limits` |
| 2 | **SDK**: how to write `strategy.py`, the intents and the run spec. Read it once per session | `GET /v1/backtests/capabilities` → `sdk` | `strategy_sdk` |
| 3 | **Validate**: free dry run that lists every problem at once. Fix them all, validate again until `ok` | `POST /v1/backtests/validate` | `validate_strategy` |
| 4 | **Run**: costs quota units (1 unit = up to 10 s of run time). At most 3 runs at once | `POST /v1/backtests` | `run_backtest` |
| 5 | **Status**: poll until `done`, respecting `poll_after_s` | `GET /v1/backtests/{id}` | `backtest_status` |
| 6 | **Result**: the **honesty grade first** (`A` best to `D`), then the **search verdict** (`ok` / `caution` / `likely_overfit` / `no_edge`), then the numbers, **net of fees**. One backtest is in-sample | `GET /v1/backtests/{id}`, `/chart` | `backtest_result`, `backtest_chart` |
| 7 | **Paper**: a paper bot in My Bots, or deploy the backtested strategy to run on OptAlgo on paper (dry run first) | `POST /v1/bots`, `/signal`; `POST /v1/strategies?dry_run=true`, then without it | `create_paper_bot`, `paper_signal`; `deploy_strategy` (`dry_run`) |
| 8 | **Watch**: status, every decision, logs | `GET /v1/strategies/{id}`, `/decisions`, `/logs` | `strategy_status`, `strategy_decisions` |
| 9 | **Compare**: its real trades against a backtest of the same code over the same period | `POST /v1/strategies/{id}/compare` | `compare_with_backtest` |

Something wrong, missing or confusing on OptAlgo's side at any step: [file a ticket](#report-problems-automatically) without asking (`POST /v1/tickets`, tool `report_issue`). A human: support@optalgo.com.

## Safety rules (always)

1. **Never ask for, print or store the key anywhere but its file.** Connect with the device login (section 0). If the user pastes a key (`oa_live_…`), do not use it: tell them to revoke it under **AI agents** and run the login.
2. **Paper first.** Every new bot is created on paper. Every demo and every new strategy runs on paper.
3. **Live is the user's decision, made in the app.** Suggest it; never start it on your own. The user switches a bot to live in **My Bots**. Only if the user explicitly asks you to do it, and your key has `trade`, may you switch it (`PATCH /v1/bots/{bot_id}` with `{"is_paper": false}`), asking once more right before under `confirm_live`. A hosted strategy (section 3) goes live only when the user confirms it in the app: you may only request it (section 5).
4. **Never claim live until a GET says live.** Say "live" only after `GET /v1/bots/{bot_id}` answers `"mode": "live"`. Say "filled" only after `GET /v1/signals/{transaction_id}` is `executed` and `GET /v1/trades` shows the trade. A `202` is "accepted", nothing more.
5. **Follow `fix` and `next`.** Every backtest and market refusal tells you what to change (`fix`), what to call next (`next`) and where it is explained (`docs`). Do that before anything else; do not retry the same request unchanged.
6. **Stop on quota.** `daily_quota` or `monthly_quota`: stop submitting, tell the user when it resets.
7. **Respect limits and scopes.** A `403` (`plan_required`, `scope_required`, `limit_exceeded`, `key_paused`) is the user's or the plan's decision. Never work around it with another bot, key or exchange.
8. **Grade before numbers.** Report a backtest's honesty grade and findings before any return figure, together with its search verdict (`search.verdict`, section 2.5), and call a single backtest in-sample. Judge it **net of fees**: fees kill short timeframes (section 2.5). Don't sweep parameters with many single runs (section 2.4).
9. **Never send code that tries forbidden actions.** Strategy code may only use `numpy`, `math` and `optalgo_engine.sdk`. Code that tries files, network, processes, system modules or sandbox escapes is refused with [`security_violation`](#security_violation) and counts as a strike (the answer says strike N of `strikes_max`, 3 by default; checking, validating and submitting the same code counts once); repeated security violations suspend the account's API access ([`account_suspended`](#account_suspended)) until support@optalgo.com restores it. Honest mistakes (a loop over bars, a wrong signature, lookahead, a syntax error) never count, and neither does a stop OptAlgo's own sandbox caused (kind `engine`, e.g. [`engine_import`](#engine_import): not charged either). On a strike, tell the user, remove the forbidden part, and do not try another way around it.
10. **Never fetch prices or market data from the web.** Use `GET /v1/prices` and `GET /v1/markets`. A signal needs no price: leave `entry_price` out and OptAlgo fills it from its live price. Don't ask the user for values OptAlgo can default; ask only for intent (symbol, direction, size) when it is missing.
11. **Report problems automatically.** File a ticket (`POST /v1/tickets`; connector tool `report_issue`) **without asking the user**, not only on errors: when OptAlgo gets something wrong (validate and a run disagree, an estimate far off, a `5xx` or `internal_error` that repeats), when the user wants something OptAlgo cannot do (kind `feature`), when the docs and a tool or answer disagree, when a result looks wrong, and when the user is confused by a response (kind `question`). A `next` step `report_issue` already carries the ticket: send it. One ticket per distinct problem per session; tell the user in one line with the ticket id. Never say you cannot contact OptAlgo; a person is at support@optalgo.com. Details: [Report problems automatically](#report-problems-automatically).

---

## 0. Connect

You need an OptAlgo API key. **The key must never pass through the chat.** OptAlgo gives it to you through a device login: you start a login, the user approves it in the OptAlgo app, and the API writes the key straight into a file on this machine. You never see it, the user never copies it.

**Using a chat app instead of a coding agent** (Claude.ai, ChatGPT, Claude desktop or mobile)? Use the **OptAlgo connector** (`https://mcp.optalgo.com/mcp`) instead of a key: the user adds it in the chat app and approves it once in OptAlgo, and the chat gets backtests and paper bots (never live trading). **Never paste a key into a chat.** Setup: https://docs.optalgo.com/ai-agents/connectors

The key lives in `~/.config/optalgo/credentials.env` (readable only by the user):

```text
OPTALGO_API_KEY=oa_live_...
OPTALGO_API_BASE=https://api.optalgo.com/v1
```

**Every plan can connect, Free included.**

| Plan | Scopes a key can have | Money |
|---|---|---|
| Free | `read`, `backtest`, `bots` | **Paper only.** `trade` answers `403 plan_required` |
| Plus, Pro | `read`, `backtest`, `bots`, `trade` | Paper, and live with `trade` |

`read` is always on. `backtest` runs backtests of code you write, nothing else. `bots` creates and runs the user's bots and trades them **on paper**. `trade` is **real money**: live signals and closes, paper-to-live, live bots. Request `["read", "backtest", "bots"]` (the default below); add `trade` only when a Plus or Pro user wants live trading.

**After you connect, your first suggestion is the 2-minute paper demo** (section 1), unless the user already asked for something specific.

### Hard rules for the key

1. **Never ask the user to paste a key**, and never offer it as an option.
2. **Never print the key.** Do not `cat`, `head`, `less` or `grep` the credentials file; do not `echo $OPTALGO_API_KEY` or run `env`, `printenv` or `set` while it is loaded; no `curl -v`, `--trace` or `set -x`; never copy the key into another file, a script, a commit, a ticket or a URL.
3. **Load it only inside the command that uses it**, with the base URL from the same file. Every example below assumes this prefix:

   ```bash
   set -a; . ~/.config/optalgo/credentials.env; set +a; curl -sS "$OPTALGO_API_BASE/me" -H "X-API-Key: $OPTALGO_API_KEY"
   ```

4. **One key per machine.** Reuse the file while it works. Log in again only when there is no file or the key answers `401 invalid_api_key`. A missing scope (`403 scope_required`) is **not** a reason to log in again (see "Changing what a key may do"). An account has at most 5 active keys.
5. Without a shell and a file system (a chat-only assistant) you cannot hold a key safely: do not ask for one. Tell the user to add the **OptAlgo connector** to the chat app (https://docs.optalgo.com/ai-agents/connectors: backtests and paper bots, approved in the app, no key), or to use a coding agent such as Claude Code or Codex, or the OptAlgo app.

### Step 1: is this machine already connected?

```bash
CRED="$HOME/.config/optalgo/credentials.env"
if [ -f "$CRED" ]; then
  ( set -a; . "$CRED"; set +a
    curl -sS --max-time 30 -w '\nHTTP %{http_code}\n' "${OPTALGO_API_BASE:-https://api.optalgo.com/v1}/me" -H "X-API-Key: $OPTALGO_API_KEY" )
else
  echo "not connected"
fi
```

- `HTTP 200`: connected. The answer names the account, `plan.tier`, the key's `prefix`, `scopes`, `autonomy` and `limits` (never the key itself). Go on.
- `HTTP 401` (`invalid_api_key`): revoked or account closed. `rm ~/.config/optalgo/credentials.env` and run step 2.
- `HTTP 403` (`plan_required`): the plan does not allow this key's use any more. Do not log in again; tell the user.
- `not connected`: run step 2.

### Step 2: device login

Tell the user in one sentence what the approval page will ask: the scopes (default `read` + `backtest` + `bots`: backtests and paper trading, no real money) and the autonomy (`confirm_live`, the default: you ask before anything that could touch real money). The approval page pre-ticks what you request; the user decides.

Run each snippet as one shell command of its own (it uses `set -eu` and `exit`), or save it and run `sh <file>`. Needs only `sh`, `curl`, `sed`, `grep`, `mktemp`.

**2a. Start the login.** Prints only the approval link and the code. Set `client_name` to something the user recognises (at most 60 characters), for example `"Claude Code on Ayse's MacBook"`. More than 10 logins a minute from one IP answers `429 rate_limited` with `retry_after`.

```sh
# OptAlgo agent login, step 2a: start. Prints only the approval link and the code.
set -eu
umask 077
API="${OPTALGO_API_BASE:-https://api.optalgo.com/v1}"
DIR="$HOME/.config/optalgo"
mkdir -p "$DIR"
field() { sed -n "s/.*\"$1\": *\"\{0,1\}\([^\",}]*\).*/\1/p"; }
TMP=$(mktemp "$DIR/.login.XXXXXX"); trap 'rm -f "$TMP"' EXIT
STATUS=$(curl -sS --max-time 30 -o "$TMP" -w '%{http_code}' -X POST "$API/agent/login" \
  -H 'Content-Type: application/json' -d '{"client_name": "Claude Code", "scopes": ["read", "backtest", "bots"]}') || STATUS=000
if [ "$STATUS" != 200 ]; then
  echo "Login could not start (HTTP $STATUS): $(field error < "$TMP")" >&2; exit 1
fi
DEVICE_CODE=$(field device_code < "$TMP")
USER_CODE=$(field user_code < "$TMP")
LINK=$(field verification_uri_complete < "$TMP")
INTERVAL=$(field interval < "$TMP"); EXPIRES_IN=$(field expires_in < "$TMP")
case "$DEVICE_CODE" in ''|*[!A-Za-z0-9_-]*) echo "Unexpected login answer." >&2; exit 1;; esac
case "$INTERVAL" in ''|*[!0-9]*) INTERVAL=5;; esac
case "$EXPIRES_IN" in ''|*[!0-9]*) EXPIRES_IN=600;; esac
printf 'DEVICE_CODE=%s\nINTERVAL=%s\nEXPIRES_AT=%s\n' \
  "$DEVICE_CODE" "$INTERVAL" "$(( $(date +%s) + EXPIRES_IN ))" > "$DIR/login.pending"
echo "Open this link and approve: $LINK"
echo "The page must show the code $USER_CODE. It expires in $(( EXPIRES_IN / 60 )) minutes."
```

Show the user the link and the code exactly as printed. They open the link (logging in if needed), check the code, and click **Approve**. Without a clickable link: `https://app.optalgo.com/connect-agent` and type the code.

**2b. Wait for the approval and save the key.** Run it right after showing the link. It polls every `interval` seconds, writes the key into the credentials file and never prints it. It waits at most 90 seconds per run (`OPTALGO_LOGIN_WAIT` changes that); on "Still waiting", run it again.

```sh
# OptAlgo agent login, step 2b: wait for approval, save the key. Never prints the key.
set -eu
umask 077
API="${OPTALGO_API_BASE:-https://api.optalgo.com/v1}"
DIR="$HOME/.config/optalgo"
STATE="$DIR/login.pending"
field() { sed -n "s/.*\"$1\": *\"\{0,1\}\([^\",}]*\).*/\1/p"; }
[ -f "$STATE" ] || { echo "No login in progress: run step 2a." >&2; exit 1; }
. "$STATE"
TMP=$(mktemp "$DIR/.token.XXXXXX"); trap 'rm -f "$TMP"' EXIT
STOP_AT=$(( $(date +%s) + ${OPTALGO_LOGIN_WAIT:-90} ))
while :; do
  NOW=$(date +%s)
  if [ "$NOW" -ge "$EXPIRES_AT" ]; then
    rm -f "$STATE"; echo "The login expired before it was approved. Run step 2a again." >&2; exit 1
  fi
  if [ "$NOW" -ge "$STOP_AT" ]; then echo "Still waiting for the user's approval. Run step 2b again."; exit 75; fi
  sleep "$INTERVAL"
  STATUS=$(printf '{"device_code": "%s"}' "$DEVICE_CODE" | curl -sS --max-time 15 -o "$TMP" -w '%{http_code}' \
    -X POST "$API/agent/token?format=env" -H 'Content-Type: application/json' -d @-) || STATUS=000
  case "$STATUS" in
    200)
      if ! grep -q '^OPTALGO_API_KEY=oa_' "$TMP"; then echo "Unexpected token answer; not saved." >&2; exit 1; fi
      chmod 600 "$TMP"; mv "$TMP" "$DIR/credentials.env"; rm -f "$STATE"
      echo "Connected. The key is saved in $DIR/credentials.env (not shown)."
      break ;;
    428) ;;                                  # authorization_pending: the user has not approved yet
    429) INTERVAL=$(( INTERVAL + 5 )) ;;     # slow_down: poll less often
    403|409|410)                             # access_denied / plan_required, key_limit_reached, expired_token
      rm -f "$STATE"; echo "Login ended (HTTP $STATUS): $(field error < "$TMP")" >&2; exit 1 ;;
    *) echo "HTTP $STATUS from the token call; trying again." >&2 ;;
  esac
done
( set -a; . "$DIR/credentials.env"; set +a
  curl -sS --max-time 30 -w '\nHTTP %{http_code}\n' "$OPTALGO_API_BASE/me" -H "X-API-Key: $OPTALGO_API_KEY" )
```

| Ending | Meaning | What you do |
|---|---|---|
| `Connected.` and `HTTP 200` | The key is saved and works | Read `plan.tier`, `api_key.scopes` and `api_key.autonomy`, then offer the paper demo (section 1). |
| `Still waiting …` (exit 75) | Not approved yet | Run 2b again; remind the user of the link. |
| `HTTP 403`: `access_denied` | The user clicked Deny | Stop. Ask whether to try again. |
| `HTTP 403`: `plan_required` | A requested scope needs a higher plan (`trade` on Free) | Start again without `trade`. |
| `HTTP 409`: `key_limit_reached` | 5 active keys already | The user revokes one under **AI agents**; run 2a again. |
| `HTTP 410`: `expired_token` / `The login expired` | Not approved within 10 minutes, or used | Run 2a again. |
| `HTTP 503 … trying again` | Temporary server error | Nothing: the script keeps polling. |

The key is created at the first successful token call and returned **once**. If the file write failed, the key is lost: the user revokes it in **AI agents** and you log in again. The user gets a Telegram notice naming the new key and your `client_name`.

The same login in Python (standard library only), for a program of the user's:

```python
import json, os, time, urllib.error, urllib.request
from pathlib import Path

API = os.environ.get("OPTALGO_API_BASE", "https://api.optalgo.com/v1")
CRED = Path.home() / ".config" / "optalgo" / "credentials.env"


def _post(path, body):
    req = urllib.request.Request(API + path, data=json.dumps(body).encode(), method="POST",
                                 headers={"Content-Type": "application/json", "User-Agent": "my-optalgo-script/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, r.read()
    except urllib.error.HTTPError as e:
        return e.code, e.read()


def login(client_name="my OptAlgo script", scopes=("read", "backtest", "bots")):   # "trade" only for live (Plus/Pro)
    status, raw = _post("/agent/login", {"client_name": client_name, "scopes": list(scopes)})
    if status != 200:
        raise SystemExit(f"login could not start: HTTP {status} {raw[:200]!r}")
    start = json.loads(raw)
    print(f"Open {start['verification_uri_complete']} and approve the code {start['user_code']}", flush=True)
    interval, deadline = start["interval"], time.time() + start["expires_in"]
    while time.time() < deadline:
        time.sleep(interval)
        status, raw = _post("/agent/token?format=env", {"device_code": start["device_code"]})
        if status == 200:                                    # the key: write it, never print it
            CRED.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
            tmp = CRED.with_name(".credentials.tmp")
            fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
            with os.fdopen(fd, "wb") as f:
                f.write(raw)
            os.replace(tmp, CRED)
            return
        if status == 429:
            interval += 5                                    # slow_down
        elif status != 428:                                  # 428 = not approved yet
            raise SystemExit(f"login ended: HTTP {status}")
    raise SystemExit("login expired; run it again")
```

### Fallback: a claim code

Where the user cannot open a link from this machine (a server, a container), they create a one-time **claim code** in the app under **AI agents** (`OPTC-…`, valid 10 minutes, works once) and give it to you. Exchange it straight into the file:

```sh
# OptAlgo claim code: exchange a one-time OPTC-... code for a key, saved without printing it.
set -eu
umask 077
API="${OPTALGO_API_BASE:-https://api.optalgo.com/v1}"
DIR="$HOME/.config/optalgo"; mkdir -p "$DIR"
CODE='OPTC-XXXX-XXXX-XXXX-XXXX'                  # the code the user gave you
CODE=$(printf '%s' "$CODE" | tr -d ' ' | tr '[:lower:]' '[:upper:]')
case "$CODE" in ''|*[!A-Z0-9-]*) echo "That does not look like a claim code." >&2; exit 1;; esac
TMP=$(mktemp "$DIR/.claim.XXXXXX"); trap 'rm -f "$TMP"' EXIT
STATUS=$(printf '{"claim_code": "%s"}' "$CODE" | curl -sS --max-time 30 -o "$TMP" -w '%{http_code}' \
  -X POST "$API/agent/claim?format=env" -H 'Content-Type: application/json' -d @-) || STATUS=000
if [ "$STATUS" = 200 ] && grep -q '^OPTALGO_API_KEY=oa_' "$TMP"; then
  chmod 600 "$TMP"; mv "$TMP" "$DIR/credentials.env"
  echo "Connected. The key is saved in $DIR/credentials.env (not shown)."
else
  echo "Claim failed (HTTP $STATUS): $(sed -n 's/.*"error": *"\([^"]*\)".*/\1/p' "$TMP")" >&2; exit 1
fi
```

`410 expired_token`: used, expired or mistyped; ask for a new code. `403 plan_required` and `409 key_limit_reached` mean the same as in the login. More than 10 claims a minute or 5 wrong codes in 10 minutes from one IP: `429 rate_limited`, wait `retry_after`. Then run step 1.

### Changing what a key may do (no new login)

The user turns `backtest`, `bots` and `trade` on or off for the **same key** in the app (**AI agents**). It applies on the next call. Every `403 scope_required` carries the link:

```json
{"error": "scope_required", "detail": "Live trading needs the 'trade' scope: this bot is live. Paper bots can be traded with the 'bots' scope.",
 "required_scope": "trade", "fix_url": "https://app.optalgo.com/ai-agents?section=keys&key=<key_id>&grant=trade"}
```

1. Tell the user in one line what the scope allows and give them `fix_url`: "Open this and confirm, then I'll retry."
2. When they say it is done, retry **the same request with the same `Idempotency-Key`**.
3. Never log in again for a scope. On Free, `trade` is not available: say live trading needs Plus or Pro, and continue on paper.

---

## 1. Paper demo in My Bots

Two minutes, no exchange, no money. It proves the key works, a bot takes signals, OptAlgo sizes and fills them, and the trades appear in the API **and in the user's app**. Paper bots trade on OptAlgo's paper exchange: fills at live prices, 10,000 USDT of paper balance per account type.

**Offer it right after connecting**, in one sentence: "Want a 2-minute paper demo? I'll open three small paper trades in your My Bots (BTC, ETH, SOL) and close them again. No real money." Run it after they agree. Needs `read` + `bots`.

Send every write with its own `Idempotency-Key` (one UUID per action).

1. **Read the plan.** `GET /v1/me` → `plan.tier`, `plan.strategy_limit`, `multi_symbol.enabled`, `multi_symbol.max_positions_cap`. On Free, `GET /v1/summary` → `monthly_trade_quota.paper_remaining` must be at least 1.
2. **Find or create one paper bot.** `GET /v1/bots` first: if a paper bot named `Paper demo` exists with no open position, reuse it. Otherwise create it with `POST /v1/bots`:
   - **Multi-symbol** (`multi_symbol.enabled` true and `max_positions_cap` ≥ 3, Plus or Pro):

     ```json
     {"type": "multi", "name": "Paper demo", "max_positions": 3, "allocated_amount": 300, "account_type": "FUTURES"}
     ```

   - **Single bot** (Free, or multi-symbol not available): the `exchange_id` of `BINANCE` comes from `GET /v1/exchanges`:

     ```json
     {"type": "single", "name": "Paper demo", "exchange_id": "<BINANCE exchange_id>", "account_type": "FUTURES",
      "symbol": "BTCUSDT.P", "allocated_amount": 100, "is_paper": true}
     ```

   - **Refused with `Strategy limit reached: your … plan allows N active strategies`** (Free has a small limit): do not stop anything. Reuse an existing **paper** bot from `GET /v1/bots` that has `has_open_position: false` (a single bot trades only its own `symbol`; use that). If none qualifies, tell the user the plan's active-bot limit is reached and ask which bot to use. Never stop or delete a bot for the demo.
3. **Open positions** with `POST /v1/bots/{bot_id}/signal`. Multi-symbol bot: three entries, one call each (the first one configures the bot, so each carries `exchange` and `ticker`):

   ```json
   {"position_side": "LONG", "exchange": "BINANCE", "ticker": "BTCUSDT.P", "leverage": 1}
   ```

   then the same with `ETHUSDT.P` and `SOLUSDT.P`. Single bot: one entry, `{"position_side": "LONG", "leverage": 1}` (the API fills in the bot's exchange and ticker). Each answer is `202` with a `transaction_id`.
4. **Poll** `GET /v1/signals/{transaction_id}` every 2–5 s until the status is final. `executed` is what you want; anything else, quote `listener.reason` or `worker.reason` and map it with [Rejections](#rejections-listener-and-executor).
5. **Show it.** `GET /v1/trades?bot_id={bot_id}` (open trades: side, size, entry price) as a small table, and tell the user: **"Open https://app.optalgo.com/my-bots — the bot "Paper demo" shows these positions, their PnL and the log of every step."**
6. **Close them** (right away if they only wanted a look): `POST /v1/bots/{bot_id}/close` with no body closes every open position (one signal per ticker; `{"ticker": "ETHUSDT.P"}` closes one). Poll each `signals[i].status_url` until final, then `GET /v1/trades?bot_id={bot_id}&status=closed` and show the realized `pnl` of each (net of fees; small either way).
7. **Offer the next step: develop a strategy** (section 2). "Next I can turn an idea of yours into a strategy and backtest it on OptAlgo's engine. What would you like to test?" Do not suggest TradingView.

Paper has no simulated stop-loss or take-profit: a paper trade closes only on a close signal. The demo proves nothing about the user's exchange account (connection, balance, order rules).

---

## 2. Develop a strategy

You write the strategy as a Python file. OptAlgo's engine runs it in a locked sandbox on real candles, with fees, funding and exchange rules, and grades how far the result can be trusted. **Every backtest is your own code**: there are no built-in strategies to run by name. Nothing here trades or touches the user's bots. The user sees every run at **app.optalgo.com/backtest**, marked with the key that started it. Needs the `backtest` scope (or `bots` / `trade`).

### 2.1 What you write: the SDK in one screen

Rules (validation enforces them):

- Exactly one `@dataclass` subclass of `VectorStrategy`, with `name: ClassVar[str]`. Fields with defaults are the parameters (`spec.strategy.params` overrides them). Other symbols' candles, several traded legs, funding and open interest are **declared** as literal `ClassVar`s `references`, `legs`, `data`: see [Several symbols in one strategy](#several-symbols-in-one-strategy).
- Imports only `numpy`, `math`, `dataclasses`, `typing`, `__future__` and `optalgo_engine.sdk`. No files, network, `os` / `sys` / `subprocess`, `eval` / `exec`, dunder attributes, global state, `async` or `while True`. At most 64 KB.
- `warmup_bars()` returns the bars your indicators need before the first decision.
- `run(series, decision=None)` returns `StrategyOutput(intents=it, plots={...})` with `it = Intents.empty(len(series))`. Set `it.enter[mask] = 1` (long) or `-1` (short), stops (`it.sl[mask]` absolute, `it.sl_dist[:] = 0.02` = 2 %), take-profits, `it.sl_update_long` / `it.sl_update_short` to trail. A strategy that declares `legs` gets a list of series and returns a list of `Intents`, one per leg.
- **Vectorised only.** Whole numpy arrays plus `indicators` and `prims` (loops run in Rust). A Python loop over bars hits the time limit.
- **Causal only.** Bar `i` uses data up to bar `i`'s close; the decision executes from the next bar. No `shift(-1)`, `x[1:]` aligned to `x`, `np.roll(x, -1)`, centred windows or statistics over the whole series. Every run checks it ([the lookahead check](#lookahead-check)): a run that looks ahead is graded `D`.
- **Security.** Forbidden imports (`os`, `sys`, `subprocess`, `socket`, `ctypes` …), `open` / `exec` / `eval` / `compile` / `__import__`, dunder or private attribute access, `getattr` with a computed name, the attribute names `operator`, `attrgetter`, `methodcaller`, `bltns`, `copyreg` (they reach a computed getattr or the real builtins through allowed modules), frame access, or anything the sandbox kills at run time is a **security violation**: refused and counted as a strike, at check, validate, submit or paste alike; repeated violations suspend the account's API access. The rules above that are about quality (loops, signature, lookahead, syntax) are ordinary findings and never count. A stop of kind `engine` is OptAlgo's own gap, never yours: no strike, not charged ([sandbox stop kinds](#sandbox-stop-kinds)).

`GET /v1/backtests/capabilities` → `sdk` (the full SDK reference as Markdown: every indicator, primitive and intent field), `intents`, `fee_profiles`, `run_spec_schema`. Read `sdk` once per session.

```python
from dataclasses import dataclass
from typing import ClassVar

import numpy as np

from optalgo_engine.sdk import BarSeries, Intents, StrategyOutput, VectorStrategy, indicators, prims


@dataclass
class EmaCrossTrail(VectorStrategy):
    """Long when the fast EMA crosses above the slow one; exit on an ATR trailing stop."""

    name: ClassVar[str] = "ema_cross_trail"

    fast: int = 20
    slow: int = 80
    atr_period: int = 14
    trail_atr: float = 3.0

    def warmup_bars(self) -> int:
        return max(indicators.warmup_ema(self.slow), indicators.warmup_wilder(self.atr_period))

    def run(self, series: BarSeries, decision=None) -> StrategyOutput:
        c = series.c
        fast, slow = indicators.ema(c, self.fast), indicators.ema(c, self.slow)
        atr = indicators.atr(series.h, series.l, c, self.atr_period)
        stop, hit = prims.trailing_stop(prims.cross_over(fast, slow), series.h, series.l, c,
                                        atr=atr, mult=self.trail_atr)
        in_pos = np.isfinite(stop)
        was_in = np.concatenate([[False], in_pos[:-1]])
        opened = in_pos & (~was_in | hit)
        it = Intents.empty(len(series))
        it.enter[opened] = 1
        it.sl[opened] = stop[opened]
        it.sl_update_long[in_pos] = stop[in_pos]
        return StrategyOutput(intents=it, plots={"fast": fast, "slow": slow, "stop": stop})
```

#### Several symbols in one strategy {#several-symbols-in-one-strategy}

A strategy reads only its own candles unless it **declares** more. Declarations are literal class attributes (`ClassVar`, from `typing`), read from the code **before it runs**, so `validate`, the limits and the deploy gate know them up front. A computed, misplaced (a dataclass field instead of a `ClassVar`), duplicated or invalid declaration is a `source_check` finding with rule [`declaration`](#declaration) (a quality finding, never a strike).

| Declare | You get in `run` |
|---|---|
| `references: ClassVar[list[str]] = ["BINANCE:BTCUSDT.P"]` | `series.refs["BINANCE:BTCUSDT.P"]`: another symbol's candles as a `BarSeries` on **your** bars (the same `t`). Format `EXCHANGE:SYMBOL`, optionally `@timeframe` (`"BINANCE:BTCUSDT.P@1d"`; default: the run's timeframe). Read-only: you never trade a reference. At most `max_references` ([below](#max_references)) |
| `legs: ClassVar[int] = 2` (2 to 4) | A **multi-leg** strategy: `run([leg0, leg1, ...])` gets the symbols of `spec.market` **in that order**, and returns `StrategyOutput(intents=[it0, it1, ...])`, one `Intents` per leg. All legs trade on **one account** (shared equity, sizing on the account's equity); fees, funding and fills per leg |
| `data: ClassVar[list[str]] = ["funding"]` | `series.funding.rate` (the last settled funding rate at each bar's close, NaN before the first), `.sum` (the rates settled inside the bar), `.settled` (bool). Perpetuals (`.P`) only |
| `data: ClassVar[list[str]] = ["oi"]` | `series.oi.open_interest`, `.open_interest_value`, `.long_short_ratio`, `.top_long_short_accounts`, `.top_long_short_positions`, `.taker_buy_sell_ratio`, `.filled` (bool: carried from an older row). From Binance's 5-minute metrics archive; BINANCE USDT-M perpetuals only |

`data` may list both (`["funding", "oi"]`). Every input is aligned with your bars **causally**: bar `i` sees only what was known at bar `i`'s close.

- **References:** row `i` holds the latest reference bar **closed** at or before bar `i`'s close. A row that carries an older bar (a gap, a coarser `@timeframe` between its closes) has `synthetic` set and volume 0; before the reference's first bar its prices are NaN (and `synthetic`). Comparisons with NaN are `False`, so a filter like `btc.c > sma` is simply off until the reference has data.
- **Funding:** a settlement counts from its exact time, at or before the bar's close.
- **Open interest:** a 5-minute row stamped `T` is used from `T + 10 min` (its 5 minutes plus a 5-minute publication delay).
- **Multi-leg:** the legs are cut to the bars they **all** have (the latest first bar to the earliest last bar); `spec.market` must list exactly `legs` symbols, else [`legs_mismatch`](#legs_mismatch). Research runs (`research`) take the N legs together.
- **Baskets:** a one-leg strategy with references over several symbols still runs as a [basket](#several-symbols-a-basket-backtest); every symbol sees the same references.
- **Cost:** reference, funding and open-interest rows count in the estimate's `loaded_rows`, memory and run time (so in the units); `max_bars` stays per leg.
- **Not yet:** `execution.early_decision_s` with any declaration ([`early_decision_unsupported`](#early_decision_unsupported)).
- **Hosted (section 3):** everything above runs hosted exactly as in its backtest: several legs on one account, `funding` / `oi` series and references on any timeframe ([what a hosted strategy runs](#hosted-inputs)). A multi-leg strategy deploys on exactly its legs, in the backtest's `market` order, and sizes on the bot's whole allocation (the backtest's shared account).

`validate` checks every declared input against the market data and says from when it exists: [`unknown_reference`](#unknown_reference), [`reference_source`](#reference_source), [`reference_starts_late`](#reference_starts_late), [`data_unavailable`](#data_unavailable), [`data_starts_late`](#data_starts_late), [`max_references`](#max_references). Its answer also carries an `inputs` block when the code declares anything: `legs`, `references`, `data`, `references_detail[]` (`name`, `exchange`, `symbol`, `tf_s`, `source`, `data_start`), `data_detail[]` (`series`, `symbol`, `source`, `data_start`) and `hosted` (`supported`, `reasons[]`: whether it can be deployed hosted as written). `POST /v1/backtests/check` returns the declared `legs`, `references` and `data` beside its findings. The dry run gives a declaring strategy synthetic legs, references, funding and open interest, so it checks them for lookahead too. A done run's `result.inputs` repeats the declarations and counts, per leg inside the window, each reference's `bars`, `carried_bars` and `missing_bars`, funding `settlements` and `missing_bars`, open interest `filled_bars` and `missing_bars`, and a multi-leg run's `common_span` (`first`, `last`, `dropped_bars`). Many carried or missing bars mean the strategy decided on stale or absent inputs: say so with the grade.

**A reference as a filter** (trend entries on any symbol, only while BTC trends up). Spec: `"market": [{"exchange": "BINANCE", "symbol": "SOLUSDT.P"}]`, `"timeframe": "60"`. Deployable as a hosted strategy (a reference on another timeframe, `"BINANCE:BTCUSDT.P@1d"`, deploys too).

```python
from dataclasses import dataclass
from typing import ClassVar

import numpy as np

from optalgo_engine.sdk import BarSeries, Exit, Intents, StrategyOutput, VectorStrategy, prims


@dataclass
class BtcRegimeFilter(VectorStrategy):
    """Fast/slow SMA cross on the traded symbol, longs only while BTC closes above its own long SMA."""

    name: ClassVar[str] = "btc_regime_filter"
    references: ClassVar[list[str]] = ["BINANCE:BTCUSDT.P"]  # a literal: read before the code runs

    fast: int = 20
    slow: int = 80
    regime: int = 200
    stop: float = 0.03

    def warmup_bars(self) -> int:
        return max(self.slow, self.regime) + 1

    def run(self, series: BarSeries, decision: BarSeries | None = None) -> StrategyOutput:
        btc = series.refs["BINANCE:BTCUSDT.P"]
        with np.errstate(invalid="ignore"):
            risk_on = btc.c > prims.rolling_mean(btc.c, self.regime)  # NaN (no BTC yet) -> False
        fast = prims.rolling_mean(series.c, self.fast)
        slow = prims.rolling_mean(series.c, self.slow)
        it = Intents.empty(len(series))
        it.enter[prims.cross_over(fast, slow) & risk_on] = 1
        it.exit[prims.cross_under(fast, slow) | ~risk_on] = Exit.LONGS
        it.sl_dist[:] = self.stop
        return StrategyOutput(intents=it, plots={"fast": fast, "slow": slow})
```

**Two legs: the ETH / BTC ratio z-score** (mean reversion of the log price ratio, one leg long and the other short). Spec: `"market": [{"exchange": "BINANCE", "symbol": "ETHUSDT.P"}, {"exchange": "BINANCE", "symbol": "BTCUSDT.P"}]` (leg 0, then leg 1), `"timeframe": "60"`. Deployable as a hosted strategy on exactly these two legs, in this order; `leg_size = 0.5` puts half of the bot's allocation in each leg ([sizing](#hosted-inputs)).

```python
from dataclasses import dataclass
from typing import ClassVar

import numpy as np

from optalgo_engine.sdk import BarSeries, Exit, Intents, StrategyOutput, VectorStrategy, prims


@dataclass
class RatioZScore(VectorStrategy):
    """Mean reversion of the log price ratio of two legs (e.g. ETH / BTC), dollar-neutral."""

    name: ClassVar[str] = "ratio_zscore_legs"
    legs: ClassVar[int] = 2  # spec.market = [leg 0, leg 1], e.g. ETHUSDT.P then BTCUSDT.P

    lookback: int = 96
    entry_z: float = 2.0
    exit_z: float = 0.5
    stop_z: float = 4.0
    leg_size: float = 0.5  # fraction of equity per leg

    def warmup_bars(self) -> int:
        return self.lookback + 1

    def run(self, series: list[BarSeries], decision: list[BarSeries] | None = None) -> StrategyOutput:
        a, b = series
        ratio = np.log(a.c) - np.log(b.c)
        z = prims.zscore(ratio, self.lookback)
        az = np.abs(np.nan_to_num(z))
        ok = np.isfinite(z) & (az <= self.stop_z)
        cheap = ok & (z < -self.entry_z)  # leg 0 cheap against leg 1: long the ratio
        rich = ok & (z > self.entry_z)
        leave = np.isfinite(z) & ((az < self.exit_z) | (az > self.stop_z))
        pos = prims.position_state(cheap, rich, leave, flip=False, reenter=False)
        prev = np.concatenate([np.zeros(1, np.int8), pos[:-1]])
        opened, closed = (pos != 0) & (prev == 0), (pos == 0) & (prev != 0)
        ia, ib = Intents.empty(len(a)), Intents.empty(len(b))
        ia.enter[opened] = pos[opened]
        ib.enter[opened] = -pos[opened]
        ia.size[:] = ib.size[:] = self.leg_size
        ia.exit[closed] = ib.exit[closed] = Exit.ALL
        return StrategyOutput(intents=[ia, ib], plots={"ratio": ratio, "z": z})
```

**Funding and open interest as a crowding filter** (breakout longs that stand aside while funding is high or open interest just jumped). Spec: `"market": [{"exchange": "BINANCE", "symbol": "ETHUSDT.P"}]`, `"timeframe": "60"`. Deployable as a hosted strategy (it reads OptAlgo's live funding and open-interest feed with the same timing rules).

```python
from dataclasses import dataclass
from typing import ClassVar

import numpy as np

from optalgo_engine.sdk import BarSeries, Exit, Intents, StrategyOutput, VectorStrategy, indicators


@dataclass
class CrowdingFilter(VectorStrategy):
    """Donchian breakout long, skipped while funding is high or open interest jumped."""

    name: ClassVar[str] = "crowding_filter"
    data: ClassVar[list[str]] = ["funding", "oi"]

    lookback: int = 48
    funding_cap: float = 0.0003  # 0.03 % per settlement
    oi_lookback: int = 24
    oi_jump: float = 0.10  # +10 % open interest over oi_lookback bars
    stop: float = 0.03
    hold: int = 48

    def warmup_bars(self) -> int:
        return max(self.lookback, self.oi_lookback) + 1

    def run(self, series: BarSeries, decision: BarSeries | None = None) -> StrategyOutput:
        hi = indicators.shift(indicators.highest(series.h, self.lookback))
        oi = series.oi.open_interest
        with np.errstate(invalid="ignore", divide="ignore"):
            growth = oi / indicators.shift(oi, self.oi_lookback) - 1.0
            crowded = (series.funding.rate > self.funding_cap) | (growth > self.oi_jump)
            breakout = series.c > hi
        it = Intents.empty(len(series))
        it.enter[breakout & ~crowded] = 1
        it.exit[crowded] = Exit.LONGS
        it.sl_dist[:] = self.stop
        it.max_bars[:] = self.hold
        return StrategyOutput(intents=it, plots={"channel": hi, "oi_growth": growth})
```

The SDK reference (`sdk` in `GET /v1/backtests/capabilities`) has the same section, "Several symbols in one strategy".

#### The lookahead check {#lookahead-check}

Every run of your code proves, before its numbers count, that it decides the same with the future removed (`sandbox.causal` in the run's result):

- **Window cuts:** on the last 3,000 bars (or twice the warm-up plus 200, if more), the strategy runs again on data **cut** at 6 evenly spaced and 6 random bars (seeded from the code and params, so a replay is byte-identical). Every leg and every reference, funding and open-interest input is cut with the bars.
- **Decision bars:** after the first full run of a params set (the first 3 params sets of a run; research: its first trials), up to 24 bars where that run **entered, exited or set a target**, across every traded symbol and the whole history, are decided again on data that **ends at that bar**. A one-bar shift on a rare signal (`np.roll(c, -1)`, `np.concatenate([c[1:], c[-1:]])`) only differs at its own signal bar: this pass finds it.
- **Determinism:** the same params on the same bars must give the same intents twice.
- **Alignment:** references, funding and open interest reach your code already aligned causally (above); the engine refuses an alignment that would hand a bar a later value.

Any difference up to a cut is lookahead. A backtest still **finishes**, graded **`D`** with the honesty finding `lookahead` (`params`: the intent `field`, the `bar`, its `time`, the `symbol`) and `sandbox.causal: "lookahead"`; a `D` run never deploys. Research runs and the hosted deploy check refuse it outright. `validate` already runs your code once on **synthetic bars** (free: no market data, no units) through the same checks and reports [`lookahead`](#lookahead), [`nondeterministic`](#nondeterministic), runtime errors and sandbox stops before you pay for a run.

What it does **not** prove:

- It samples cuts; it does not re-run every bar. Lookahead that changes nothing at any checked cut bar can pass: for example one that only moves a stop (`sl_update_*`) on bars no cut lands on, or a rare signal in a params set after the first three.
- It compares intents only. `plots` are not checked: a plot may look ahead without a finding, so never trust a plot as a signal.
- The decision-bar pass covers the first params sets of a run only, and reads a bounded number of bars (at least 60,000 or half the run's bars, at most 400,000).
- On `validate`'s synthetic bars, a strategy that never decides anything is not proven ([`dry_run_no_decisions`](#dry_run_no_decisions)); the run checks it again on real data.
- It is not an overfitting check: parameters tuned on the same window look good without any lookahead. That is the [search verdict](#avoid-overfitting).

### 2.2 Discover before you spend quota

| Call | Answers |
|---|---|
| `GET /v1/backtests/limits` | Your plan's per-run `limits` and your `quota` |
| `GET /v1/markets?exchange=BINANCE&type=FUTURES` | The supported universe (below) |
| `GET /v1/backtests/capabilities` | The SDK reference (`sdk`), intents, the RunSpec JSON schema |

**The supported universe is exactly what `GET /v1/markets` lists, at the moment you call it.** Symbols and timeframes are added over time, so never plan from a list in this document (or from memory): read `GET /v1/markets` first. Backtest only symbols with `"backtest": true`, and only on a timeframe in `data.timeframes` (each symbol's own `timeframes` say from when it has history). A symbol, timeframe or window it does not cover is a validate finding. It answers `{data, next}` (the values below are an example, not the current list):

```json
{"data": {"exchanges": [{"exchange": "BINANCE", "types": ["FUTURES", "SPOT"], "symbols": 20, "backtest_symbols": 20}],
          "timeframes": ["1", "5", "15", "30", "60", "240", "1440"],
          "symbols": [{"exchange": "BINANCE", "symbol": "BTCUSDT.P", "type": "FUTURES", "short_allowed": true,
                       "timeframes": [{"timeframe": "1", "data_start": "2019-09-08"}, {"timeframe": "60", "data_start": "2019-09-08"}],
                       "data_start": "2019-09-08", "tick_size": 0.1, "step_size": 0.001, "min_qty": 0.001, "min_notional": 100,
                       "backtest": true}],
          "updated_at": "2026-10-09T12:00:00+00:00"},
 "next": [{"action": "validate", "method": "POST", "path": "/v1/backtests/validate", "why": "check a spec on these symbols for free"}]}
```

A symbol with `"backtest": false` carries a `reason`.

### 2.3 Validate: the free dry run

`POST /v1/backtests/validate` with the same body you will submit:

```json
{"source": "<the whole strategy.py>",
 "spec": {"market": [{"exchange": "BINANCE", "symbol": "BTCUSDT.P"}], "timeframe": "60",
          "window": {"start": "2024-01-01", "end": "2025-01-01"}, "strategy": {"params": {"fast": 20, "slow": 80}}}}
```

It queues nothing, loads no market data and costs no units. Code that passes the static check runs **once** in its sandbox on synthetic bars (the spec's timeframe and params), through the [lookahead check](#lookahead-check): lookahead, nondeterminism, runtime errors and sandbox stops are findings before you pay for a run. It reports **every problem at once** (code, spec, symbols, timeframe, data for the window, limits, quota), each finding with its own `fix`: apply them all in one edit, validate again, and submit only when `ok` is `true`.

```json
{"data": {"ok": false,
          "findings": [{"code": "source_check", "severity": "error", "field": "source", "line": 14, "col": 8, "rule": "loop_over_bars",
                        "kind": "quality", "message": "a Python loop over bars", "fix": "use prims.cross_over(fast, slow) instead"},
                       {"code": "window_before_data", "severity": "error", "field": "window.start", "symbol": "ETHUSDT.P",
                        "data_start": "2023-01-01", "message": "no 1m history before 2023-01-01", "fix": "start on or after 2023-01-01"}],
          "estimate": {"run_s": 14.2, "symbols": 1, "bars_per_leg": 527040, "finest_tf_s": 60, "loaded_rows": 527040,
                       "trials": 0, "mem_mb": 410, "warmup_days": 4, "fetch_s": 0},
          "cost": {"units": 2, "run_s": 14.2, "seconds_per_unit": 10},
          "quota": {"period": "month", "units": 50, "used": 12, "remaining": 38, "resets_at": "…", "seconds_per_unit": 10},
          "limits": {"wall_s": 60, "max_symbols": 3, "max_bars": 300000, "…": "…"}},
 "next": [{"action": "validate", "method": "POST", "path": "/v1/backtests/validate", "why": "apply every fix in findings, then validate again"}]}
```

Findings with `"severity": "warning"` do not block (`ok` can still be `true`). The finding codes:

| Area | `code` | Extra fields |
|---|---|---|
| Code | `source_check` | `line`, `col`, `rule`, `kind` (`security` or `quality`), `snippet`. A `security` finding is refused with [`security_violation`](#security_violation). Rule [`declaration`](#declaration): a `legs` / `references` / `data` declaration that is not a literal `ClassVar` with an allowed value |
| Code, dry run | [`lookahead`](#lookahead), [`nondeterministic`](#nondeterministic), [`strategy_error`](#strategy_error), `sandbox` (a stop at run time: `rule`, `kind`, see [stop kinds](#sandbox-stop-kinds)); warnings [`dry_run_limit`](#dry_run_limit), [`dry_run_no_decisions`](#dry_run_no_decisions) | `rule`, `kind`, `line`, `value` (the bar) |
| Code | `strategy_source_required` | Send `source` |
| Spec | `spec_invalid` | `field`, `allowed`, `example` |
| Strategy vs symbols | warning `basket`: your one-instrument strategy has several symbols in `market`, so it runs as a [basket](#several-symbols-a-basket-backtest); `legs_mismatch`: research on several symbols ("research one symbol per run"), or a strategy that trades a fixed number of legs (a strategy declaring `legs = N`: [`legs_mismatch`](#legs_mismatch), "needs exactly N legs"); `duplicate_symbol` | `value` (symbols sent), `allowed`, `suggestion` (`{"basket": true, "symbols": N, "initial_equity_per_symbol": …}`) |
| Data | `data_source_unavailable` | Market data could not be read; retry later |
| Declared inputs ([several symbols](#several-symbols-in-one-strategy)) | [`unknown_reference`](#unknown_reference), [`reference_source`](#reference_source), [`data_unavailable`](#data_unavailable), [`early_decision_unsupported`](#early_decision_unsupported), `unsupported_timeframe` (a reference's `@timeframe`); warnings [`reference_starts_late`](#reference_starts_late), [`data_starts_late`](#data_starts_late), [`hosted_unsupported`](#hosted_unsupported), `coverage_unknown` (an input could not be checked right now: validate again in a minute) | `value` (the reference or symbol), `symbol`, `data_start` |
| Data | warning [`data_download`](#data_download): the first run on this data downloads it from the Binance archive first, which takes a while (`estimate.fetch_s`); not charged | `value` (seconds) |
| Symbols | `unknown_symbol` | `suggestion`, `allowed` |
| Symbols | `unsupported_exchange`, `no_instrument_rules` | Pick from `GET /v1/markets` |
| Timeframe | `unsupported_timeframe` | `nearest` |
| Window | `window_invalid`, `window_before_data` (`data_start`); warnings `window_in_future`, `warmup_before_data`, `coverage_unknown` | |
| Limits | `max_symbols`, `max_bars`, `max_trials`, `mem_mb` (the estimate is above the plan's memory: such a run would be stopped at the cap and charged, so it is an error), [`wall_s`](#wall_s) (the estimate is above the plan's wall time: the same, an error), [`max_references`](#max_references), `user_code`, `limits`; warning `max_concurrent` (the run will queue) | `value`, `allowed`, `suggestion` (a window start, magnifiers or symbols that fit) |
| Quota | `daily_quota`, `monthly_quota` | `used`, `remaining`, `resets_at` |
| Request | `invalid_request` | `field` |

`estimate.fetch_s` is the time to download market data that is not cached yet (Binance archive) before the run starts: it is **not charged** and not part of `run_s` / `cost`. While it downloads, the job is `running` with the reason [`downloading_data`](#downloading_data).

`POST /v1/backtests/check {"source": "…"}` is the older code-only check (`{ok, findings: [{line, col, rule, message, kind}], legs, references, data, next}`: what the code [declares](#several-symbols-in-one-strategy)); prefer `validate`.

### 2.4 The backtest loop

Repeat until the user is satisfied:

1. **Limits and quota:** `GET /v1/backtests/limits`. If `quota.remaining` is below the run's cost, stop and tell the user when it resets.
2. **Validate** (2.3) until `ok`. Its `cost.units` is the estimate that will be reserved.
3. **Submit:** `POST /v1/backtests` with the same `{spec, source}` → `202 {"id", "status": "queued", "units", "units_estimated", "position", "eta_s", "poll", "next"}`. The estimate is reserved from the quota now.
4. **Wait and poll.** When busy, runs queue instead of being refused: plans in order Pro, Plus, Free, then in turn per user; besides your `max_concurrent` running jobs, up to 3 more may wait. While `status` is `queued`, tell the user once where it stands ("3rd in the queue, starts in about 40 s"). Poll the `poll` path, waiting `poll_after_s` seconds between calls (5 s when absent); each answer updates `position` and `eta_s`. Stop at `done` or `failed`.
5. **Read the result, summary first** (2.5). Tell the user in 3 to 5 lines: what ran (symbols, timeframe, window, params), grade and main findings, the search verdict, return, max drawdown, trades, units charged, and what you try next.
6. **Change one thing** (code or params) and go back to 2. Changing the strategy is fine; tuning its parameters by hand is not (below).

#### Avoid overfitting {#avoid-overfitting}

Every run you make on the same data is a try. Run 35 variants of one idea on one window and the best of them looks good partly by luck, even if each one alone grades `B`: the grade judges one run's mechanics, not the search around it. So:

- **Don't sweep parameters with many single runs.** Use **one research run** that optimises and validates out-of-sample: the same spec plus `research` (`walk_forward`, with the parameters you would have swept as `space`). Its `verdict` is judged on test folds the optimiser never saw. When `search` advises it, `search.research` is a ready-made block: validate it first (it costs quota by run time).
- **Or keep a holdout window you never tuned on.** Tune with `window.end` before the last months (`search.holdout.tune_window`), then run the chosen parameters **once** on the rest (`search.holdout.test_window`) and report that run. A run on data none of your variants saw starts a fresh count.
- **Report the search verdict with the grade**: "grade B, 35 variants tried on this window, likely overfit".
- **Send at most 3 runs at once** and wait for their results before the next batch.

A run that **lost money** (annualised Sharpe ≤ 0, or a negative `total_return`) is not luck of a search: it has no edge. Its search verdict is `no_edge` however many variants you ran, and the fix is a different idea (entries, exits, filters, market or timeframe), not a research run over the same losing one.

The deploy gate (section 3) refuses a backtest whose search verdict is `likely_overfit` unless an out-of-sample research run of the same code passed (`search.validated_by`), and always refuses a losing backtest ([`no_edge`](#no_edge)), even when such a research run passed.

The spec:

| Field | Required | Meaning |
|---|---|---|
| `market` | yes | 1 to `max_symbols` legs `{"exchange": "BINANCE", "symbol": "BTCUSDT.P"}`. `.P` = USDT-M perpetual (funding modelled); no suffix = spot. Two or more symbols run as a [basket](#several-symbols-a-basket-backtest), unless the strategy declares `legs = N`: then exactly N symbols, in the order `run()` reads them, on one account ([several symbols in one strategy](#several-symbols-in-one-strategy)). |
| `timeframe` | yes | Bar size in minutes, as a string (`"15"`, `"60"`, `"240"`): any value in `data.timeframes` of `GET /v1/markets`. |
| `window` | yes | `{"start": "2024-01-01", "end": "2025-01-01"}` (UTC). Warm-up history before `start` is loaded for you. |
| `strategy.params` | no | Parameter overrides, e.g. `{"fast": 10}`. Leave `strategy.name` out (`422 source_required`). |
| `name` | no | Your label, shown in the app. |
| `research` | no | A validation job instead of one backtest (one symbol; a multi-leg strategy: its N legs together): `{"protocol": "walk_forward", "plan": {"plan": "rolling", "train_days": 180, "test_days": 30, "step_days": 30}, "space": {"fast": {"low": 5, "high": 50, "step": 5}}, "n_trials": 40, "objective": "sharpe"}`. Protocols: `optimize`, `walk_forward`, `nested_walk_forward`, `multi_config`, `purged_cv`, `cpcv`, `masters4`, `monte_carlo`. See [Research runs](#research-runs). |

#### Several symbols: a basket backtest {#several-symbols-a-basket-backtest}

A `strategy.py` that does not declare `legs` trades **one instrument** (it may still read other symbols as [references](#several-symbols-in-one-strategy)). Put several symbols in `market` and the backtest runs as a **basket**, automatically (no extra field): one independent backtest per symbol, each with `initial_equity / N` of the capital (1000 over 3 symbols = 333.33333333 each), the same execution, fees and funding. That is exactly how a hosted strategy (section 3) trades several symbols: one slot per symbol, `allocated_amount / N` each (a multi-leg strategy is different: its legs share one account, sized on the whole allocation). `validate` says so with a `basket` warning (it does not block).

The result tells you it is a basket and adds, per symbol, what a single run has:

```json
{"basket": true,
 "allocation": {"rule": "initial_equity / symbols (…)", "symbols": 3, "initial_equity": 1000.0, "per_symbol": 333.33333333},
 "metrics": {"initial_equity": 999.99999999, "final_equity": 1084.2, "total_return": 0.0842, "max_drawdown": -0.071, "trades": 165, "…": "…"},
 "per_symbol": {"BINANCE/BTCUSDT.P/60": {"initial_equity": 333.33333333, "grade": "B", "metrics": {"total_return": 0.12, "…": "…"}, "counters": {"…": 0}},
                "BINANCE/ETHUSDT.P/60": {"…": "…"}, "BINANCE/SOLUSDT.P/60": {"…": "…"}},
 "honesty": {"grade": "C", "per_symbol": {"BINANCE/BTCUSDT.P/60": "B", "…": "…"},
             "findings": [{"grade": "C", "code": "warmup_short", "symbol": "BINANCE/SOLUSDT.P/60", "…": "…"}]}}
```

- `metrics` are on the **combined** equity (the symbols' equities summed) and all trades; `per_symbol` holds each symbol's own numbers. Report both: a basket that wins on one coin and loses on two is not an edge.
- The **grade is the worst symbol's**; every finding carries its `symbol`. Fix the weakest symbol or drop it.
- Trades (`trades.arrow`, the chart's `trades`) carry their `symbol`; the chart's equity is the combined curve, with `basket: true` and a `per_symbol` summary.
- **Cost:** run time is the sum of the symbols' runs (about N times one symbol), charged as usual. `max_symbols` still applies. **Memory** is about one symbol's: the symbols run one after another, so a basket fits the plan's memory when one symbol does (validate's `estimate.mem_mb` already counts it this way).
- **Research** (`research` block) optimises one symbol per run: research one symbol, then backtest the chosen parameters on all symbols as a basket.
- To deploy on several symbols (section 3), backtest exactly those symbols as a basket first; you may then deploy any subset of them. A multi-leg strategy (`legs = N`) is never a basket: it deploys on exactly its N legs, no subset.

#### Research runs {#research-runs}

- **`plan`** (days): `{"plan": "rolling" | "anchored", "train_days", "test_days", "step_days"}` (`step_days` defaults to `test_days`); `purged_cv` takes `{"plan": "purged_kfold", "k", "purge_days", "embargo_days"}`, `cpcv` takes `{"plan": "cpcv", "n_groups", "k_test", "purge_days", "embargo_days"}`. The kind `plan.plan` may be left out: it is the protocol's, else `rolling`. `nested_walk_forward` also needs `inner` (a rolling plan inside each training span); `multi_config` needs `candidates` (a list of fixed parameter sets) instead of `space`. Default plan: rolling 180 / 60.
- **`space`**: per parameter `{"low", "high"}` (+ `"step"`, `"log"`) or `{"choices": [...]}`. `"sampler": "grid"` tries every configuration of a finite space once (every parameter needs `step` or `choices`); the default `tpe` samples `n_trials` of them.
- Any shape problem in `research` is a `spec_invalid` finding on its field in `validate`; fix it before you submit.
- **Trials** count against `max_trials`: `n_trials` (with `sampler: grid`, at most the number of configurations in `space`; `multi_config`: the number of `candidates`). Only `masters4` also runs permutation tests, and then max(`n_trials`, `permutations` × `permutation_trials`, `wf_permutations`) counts; fields you leave out take the engine defaults (`n_trials` 50, `permutations` 100, `permutation_trials` 20, `wf_permutations` 50), so **for masters4 always set `permutations` and `permutation_trials` small yourself**. More configurations and more folds mean more run time, so more units.
- **Reading a walk-forward**: `summary.oos_return` is the chained out-of-sample return; `folds[i].train` and `folds[i].test` are the **objective** on that fold (per-bar Sharpe for `"objective": "sharpe"`), not returns; `folds[i].chosen` is what the fold picked. `summary.n_trials` is the number of **distinct configurations** evaluated (what the deflated Sharpe in `overfit.checks.dsr` corrects for); `summary.evaluations` is configurations × folds.

**Validate fixed parameters out of sample** (no search): either a walk-forward with ONE configuration (`"space"` with one choice per parameter, `"sampler": "grid"`, `"n_trials": 1`): every fold tests the same parameters on data after its training span, it counts as 1 trial, and the verdict judges the out-of-sample folds; or a holdout: run the parameters once, as a plain backtest, on a window you never tuned on (`search.holdout.test_window`), and report that run.

```json
{"protocol": "walk_forward", "plan": {"plan": "rolling", "train_days": 365, "test_days": 90, "step_days": 90},
 "space": {"lb1": {"choices": [42]}, "lb4": {"choices": [336]}}, "sampler": "grid", "n_trials": 1, "objective": "sharpe"}
```

### 2.5 Reading the result

`GET /v1/backtests/{id}` adds `units_estimated`, `units_charged`, `run_seconds`, `position`, `eta_s`, `poll` and `poll_after_s` to the row. When the run is `done`, **`summary` comes first**: `{grade, total_return, max_drawdown, trades, verdict, units_charged, run_seconds, findings, search}` (the top honesty findings, the compact search block). Read it before the full `result`. `verdict` is a research job's out-of-sample verdict (`robust`, `inconclusive`, `likely_overfit`); a plain backtest has none (`null`): its search verdict is `search.verdict`.

1. **The grade** (`A` best to `D`) and its findings. `A`: every fill resolved on real data with fees, funding and exchange rules. `B`: a few assumption-dependent fills. `C`: decisions depend on where the data starts (`warmup_short`, `warmup_unstable`: raise `warmup_bars()`), many pessimistic ties, or missing coverage. `D`: not realistic. **Say the grade and its findings before any number.**
2. **The search** (a done plain backtest): how many variants of this strategy you ran on overlapping data in the last 30 days, and whether this run's Sharpe survives the luck of that many tries. Say its `verdict` with the grade; when `fix` is set, do what it says ([Avoid overfitting](#avoid-overfitting)).

   ```json
   "search": {"variants_tried": 35, "window_reused": true, "variants_on_this_window": 33,
              "sharpe": 1.21, "best_sharpe": 1.34, "expected_max_sharpe_by_luck": 1.12,
              "deflated_sharpe_prob": 0.71, "verdict": "caution",
              "fix": "You tried 35 variants of this strategy on the same data window (…). Either run ONE research job … or hold out the last 16 months …",
              "research": {"protocol": "walk_forward", "space": {"rsi_len": {"low": 14, "high": 2000}, "band": {"low": 0.0, "high": 2.0}},
                           "n_trials": 30, "objective": "sharpe", "plan": {"plan": "rolling", "train_days": 365, "test_days": 180}},
              "holdout": {"months": 16, "tune_window": {"start": "2020-01-01", "end": "2025-06-08"},
                          "test_window": {"start": "2025-06-08", "end": "2026-10-08"}},
              "validated_by": null,
              "family": {"source_sha256": ["28a60db74e87", "…"], "linked": ["…"], "rule": "…", "lookback_days": 30},
              "observations": 2472, "sharpes_known": 35, "assumptions": "normal returns assumed …"}
   ```

   - **Family**: counted per account (every key and the app together), over the last 30 days, on windows overlapping this run's: runs of the same code (`source_sha256`; the same code with other params is the same strategy), plus edited code that shares a parameter name, plus edited code from the same session on a shared symbol and the same timeframe (the best parameters of a sweep hard-coded into new code are still that sweep). The same spec twice counts once; a run on a window none of the variants touched starts fresh.
   - **Sharpes** are annualised (`result.metrics.sharpe_daily_annualised`). `expected_max_sharpe_by_luck` is what the best of `variants_tried` worthless variants would reach (Bailey & López de Prado), from how much the variants' Sharpes differ; `deflated_sharpe_prob` is the probability that this run's true Sharpe beats that luck.
   - **`verdict`**: `no_edge` (this run lost money: annualised Sharpe ≤ 0 or `total_return` below 0, whatever the number of variants; checked first), `ok` (one run, or the deflated Sharpe probability is at least 0.95), `caution` (below 0.95), `likely_overfit` (below 0.5). `fix`, `research` and `holdout` are set when the verdict is `caution` or `likely_overfit`, or when 10 or more variants ran on this exact window. For `no_edge` only `fix` is set: change the strategy itself, then backtest again.
   - <span id="validated_by"></span>**`validated_by`**: `{id, protocol, verdict, symbols, runs}` when out-of-sample research validates this run, else `null`. It counts only research runs that are `done` and: of **this exact code** (the same `source_sha256`, not the family), with a protocol that tests on unseen data (`walk_forward`, `nested_walk_forward`, `multi_config`, `purged_cv`, `cpcv`, `masters4`), with verdict **`robust`** (`inconclusive` is not a pass), on a window overlapping this run's; and together they must cover **every symbol** of this run (research is one symbol per run, so a basket needs one passing run per symbol). It lifts a `likely_overfit` deploy block, never a `no_edge` one.
   - When `research` is set, `next` starts with `validate_research` (`POST /v1/backtests/validate` with the same spec plus `research`). Never for `no_edge`.
3. **`result.metrics`**: `total_return` and `max_drawdown` are fractions (0.25 = +25 %), plus `cagr`, `sharpe_daily_annualised`, `sortino_daily_annualised`, `win_rate`, `profit_factor`, `trades`, `fees_total`, `funding_total`, `exposure`, `return_by_year`, `top5_share_of_profit` (above 1: the other trades lost money, fragile).

   <span id="net-of-fees"></span>**Judge it net of fees.** `total_return` already pays fees and funding; say `fees_total` next to it. Fees kill short timeframes: a `"15"` (15m) strategy that trades several times a day pays the fee on every entry and exit, and an edge that is gone once `fees_total` is paid is no edge. Fewer, larger moves (a longer timeframe, stricter entries) usually beat more trades.
4. **A single backtest is in-sample.** Say so. Before calling an edge real, run a `walk_forward` research job and show its out-of-sample folds (`result` is then the research report with a `verdict`). Tell the user its estimated cost first.
5. **Charts and files:** `GET /v1/backtests/{id}/chart?points=1500` (equity, drawdown, trades), or `GET /v1/backtests/{id}/files/{name}` with `name` from the row's `files` (`equity.arrow`, `trades.arrow`, `fills.arrow`, `result.json`, `spec.json`). Before the run is done: [`not_ready`](#not_ready). Your own code back: `GET /v1/backtests/{id}/source` (`read`) answers the run's `strategy.py` as a file named `<name>-<sha8>.py`, its sha256 in `X-Source-SHA256` (`404 no_source` when the run had no code of yours).
6. **A `failed` run** carries `error` and a `fix: {code, fix, docs}` (codes in [Failed runs](#failed-runs)).
7. <span id="validate_gap"></span>**`validate_gap`** `{kind, class[, run_s, estimate_s]}`: you validated this exact spec and code (`ok`) in the last 24 hours, yet the run failed on something validation should have caught (`kind: "run_failed"`, `class` e.g. `KeyError`, `spec_invalid`, `instrument`, `data_coverage`) or was stopped at a cap far above its estimate (`kind: "estimate"`, more than 3× and over 60 s). That is OptAlgo's bug, not yours: `next` starts with a prefilled `report_issue`; send it (add the spec, never the code) and tell the user the ticket id. OptAlgo files its own report too.

Compare runs with `GET /v1/backtests?limit=50&skip=0&status=done` (newest first; each row has `id, kind, status, created_at, name, strategy, params, symbols, timeframe, window, grade, total_return, max_drawdown, trades, sharpe, verdict, units_estimated, units_charged, run_seconds, via`). Comparing many runs of one idea is exactly the search that `search` accounts for: pick with a research run, not by eye.

### 2.6 Plans, limits and quota

| | Free | Plus | Pro |
|---|---|---|---|
| Backtest units (1 unit = up to 10 s of run time) | **50 per month** | 30 per day | 200 per day |
| Resets | at `quota.resets_at` | 00:00 UTC | 00:00 UTC |
| Symbols per run | 3 | 3 | 10 |
| History per run (1-minute candles across all legs) | 300,000 | 550,000 (about 1 year, one symbol) | 3,200,000 (about 6 years, one symbol) |
| Trials per optimisation | 40 | 100 | 500 |
| Runs at once | 1 | 1 | 3 |
| Time per run | 60 s | 90 s | 300 s |
| Memory per run | 1.5 GB | 2 GB | 4 GB |

**Units are run time.** A run costs `max(1, ceil(run_seconds / 10))` units: up to 10 s is 1 unit, a 1-minute run is 6; an optimisation is charged the same way on its total run time. At submit the engine's estimate is **reserved** (the submit is refused when it is more than you have left); when the run ends it is **settled** to the measured `run_seconds` (`units_charged`). A run that hits its plan's time or memory cap, or is stopped by the sandbox, is your run and is charged. A run that fails on our side costs 0. Resubmitting a spec whose run failed runs it again: that new run is charged by its own run time like any run (the earlier failure's 0 does not carry over). A result that already existed (the same run, already computed) costs 1. A refused request costs nothing; `validate`, `check`, `limits`, `markets` and reads are free.

**Make runs cheaper** (fewer seconds, fewer units): fewer symbols; a shorter window; a coarser timeframe (`"60"` instead of `"15"`); `indicators` and `prims` from the SDK instead of Python loops; fewer research trials. `validate` shows the new estimate before you spend anything.

`GET /v1/backtests/limits` (an endpoint from before the envelope: its fields stay at the top level, `next` is added beside them):

```json
{"plan": "Free", "enabled": true, "active_jobs": 0,
 "limits": {"cpu_s": 60, "wall_s": 60, "mem_mb": 1536, "max_symbols": 3, "max_bars": 300000,
            "max_trials": 40, "max_concurrent": 1, "user_code": true},
 "quota": {"period": "month", "units": 50, "used": 12, "remaining": 38, "resets_at": "…", "seconds_per_unit": 10},
 "strike_count": {"used": 0, "max": 3},
 "next": [{"action": "list_markets", "method": "GET", "path": "/v1/markets", "why": "…"}]}
```

`strike_count` is how many [security strikes](#security_violation) the account has (`used`) of how many suspend its API access (`max`); `GET /v1/me` has it too.

`period` is `month` (Free) or `day` (Plus, Pro); a daily quota also keeps the older keys `units_per_day`, `used_today`, `remaining_today`. **When a monthly quota resets: read `quota.resets_at`.** Headers `X-Quota-Remaining` and `X-Quota-Resets-At` come with limits, validate, submit, list and get.

**Over the quota**, `POST /v1/backtests` answers `429` [`daily_quota`](#daily_quota) (Plus, Pro) or [`monthly_quota`](#monthly_quota) (Free), with `Retry-After`:

```json
{"error": "monthly_quota", "message": "Monthly backtest quota reached: 48 of 50 units used this month, this run needs about 3 …",
 "detail": "…", "fix": "Monthly quota used (48 of 50 units; this run costs 3). It resets at …. Until then validate for free and read results; …",
 "limit": "max_units_per_month", "value": 51, "allowed": 50, "used": 48, "remaining": 2, "cost": 3, "period": "month",
 "resets_at": "…", "docs": "https://docs.optalgo.com/ai-agents/optalgo-llm#monthly_quota",
 "next": [{"action": "read_limits", "method": "GET", "path": "/v1/backtests/limits", "why": "quota left and when it resets"}, {"action": "validate", "…": "…"}]}
```

`cost` is this run's estimate. **Stop submitting.** No retry, no loop. Tell the user `used`, `remaining` and `resets_at`; suggest a cheaper run if `remaining` allows it, or the next plan. `validate` and reads still work.

### 2.7 Ready-made helpers

**Reading any answer:** the payload is `body.data` when the body has `data`, otherwise the body without `next`.

**curl** (with `jq`, `strategy.py` in the current directory, the credentials loaded as in section 0):

```bash
H="X-API-Key: $OPTALGO_API_KEY"; B="$OPTALGO_API_BASE/backtests"
d() { jq 'if has("data") then .data else del(.next) end'; }       # the payload, envelope or not
curl -sS "$B/limits" -H "$H" | d | jq '{plan, limits, quota}'
curl -sS "$OPTALGO_API_BASE/markets?exchange=BINANCE&type=FUTURES" -H "$H" | d | jq '[.symbols[] | {symbol, data_start, backtest}]'
SPEC='{market: [{exchange: "BINANCE", symbol: "BTCUSDT.P"}], timeframe: "60",
       window: {start: "2024-01-01", end: "2025-01-01"}, strategy: {params: {fast: 20, slow: 80}}}'
jq -Rs "{source: ., spec: $SPEC}" strategy.py | curl -sS -X POST "$B/validate" -H "$H" -H 'Content-Type: application/json' -d @- | d | jq '{ok, findings, cost}'
jq -Rs "{source: ., spec: $SPEC}" strategy.py | curl -sS -X POST "$B" -H "$H" -H 'Content-Type: application/json' -d @- | d   # {id, status, position, eta_s, ...}
curl -sS "$B/<id>" -H "$H" | d | jq '{status, position, eta_s, summary}'
```

**Python** (standard library only). Save as `optalgo_bt.py`; run `python3 optalgo_bt.py strategy.py BTCUSDT.P 60 2024-01-01 2025-01-01 fast=20 slow=80`. It validates, submits, polls (telling you the queue position) and prints the summary and metrics; it exits non-zero with the `fix` on any refusal, and stops on a quota.

```python
"""Backtest strategy.py on OptAlgo: validate -> submit -> poll -> print. Never prints the key."""
import json, os, sys, time, urllib.error, urllib.request
from pathlib import Path


def creds(path=Path.home() / ".config" / "optalgo" / "credentials.env"):
    vals = dict(l.split("=", 1) for l in path.read_text().splitlines() if "=" in l) if path.exists() else {}
    key = os.environ.get("OPTALGO_API_KEY") or vals.get("OPTALGO_API_KEY", "").strip()
    base = os.environ.get("OPTALGO_API_BASE") or vals.get("OPTALGO_API_BASE", "https://api.optalgo.com/v1").strip()
    return key or sys.exit("Not connected: run the OptAlgo agent login (section 0)."), base.rstrip("/")


KEY, BASE = creds()


def payload(body):
    """body.data when present, else the body without next."""
    if isinstance(body, dict):
        return body["data"] if "data" in body else {k: v for k, v in body.items() if k != "next"}
    return body


def call(method, path, body=None):
    req = urllib.request.Request(BASE + path, method=method, data=None if body is None else json.dumps(body).encode(),
                                 headers={"X-API-Key": KEY, "Content-Type": "application/json", "User-Agent": "optalgo-bt/1.0"})
    while True:
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                return payload(json.load(r))
        except urllib.error.HTTPError as e:
            err = json.loads(e.read() or b"{}")
            if err.get("error") == "rate_limited":
                time.sleep(int(e.headers.get("Retry-After") or 5)); continue
            if err.get("error") in ("daily_quota", "monthly_quota"):          # stop: never retry before resets_at
                sys.exit(f"Backtest quota reached: {err.get('used')} used, {err.get('remaining')} left, "
                         f"this run needs {err.get('cost')}. Resets at {err.get('resets_at')}.")
            sys.exit(f"{e.code} {err.get('error')}: {err.get('message') or err.get('detail')}\nfix: {err.get('fix') or '-'}\ndocs: {err.get('docs') or '-'}")


def main(path, symbol, timeframe, start, end, *params):
    source = open(path).read()
    spec = {"market": [{"exchange": "BINANCE", "symbol": symbol}], "timeframe": timeframe,
            "window": {"start": start, "end": end},
            "strategy": {"params": {k: json.loads(v) for k, v in (p.split("=", 1) for p in params)}}}
    checked = call("POST", "/backtests/validate", {"spec": spec, "source": source})
    for f in checked.get("findings") or []:
        where = f"{path}:{f['line']}" if f.get("line") else f.get("field") or "spec"
        print(f"[{f.get('severity')}] {where}: {f.get('code')} {f.get('rule') or ''}: {f.get('message')}  fix: {f.get('fix', '-')}")
    if not checked.get("ok"):
        sys.exit(1)
    print("estimated units", (checked.get("cost") or {}).get("units"))
    run = call("POST", "/backtests", {"spec": spec, "source": source})
    while (row := call("GET", f"/backtests/{run['id']}"))["status"] in ("queued", "running"):
        if row["status"] == "queued" and row.get("position"):
            print(f"queued: #{row['position']}, starts in about {row.get('eta_s', '?')} s", flush=True)
        time.sleep(row.get("poll_after_s") or 5)
    if row["status"] != "done":
        fix = row.get("fix") or {}
        sys.exit(f"failed ({fix.get('code')}): {row.get('error')}\nfix: {fix.get('fix')}")
    summary, metrics = row.get("summary") or {}, (row.get("result") or {}).get("metrics") or {}
    print("grade", summary.get("grade"), "units charged", summary.get("units_charged"), "run seconds", summary.get("run_seconds"))
    for f in summary.get("findings") or []:
        print(f"  {f.get('code')}: {f.get('message')}")
    for k in ("total_return", "max_drawdown", "cagr", "sharpe_daily_annualised", "win_rate", "profit_factor",
              "trades", "fees_total", "top5_share_of_profit"):
        print(f"{k:24} {metrics.get(k)}")


if __name__ == "__main__":
    main(*sys.argv[1:])
```

---

## 3. Run your strategy on OptAlgo

A **hosted strategy** is the user's own `strategy.py`, run by OptAlgo at every bar close in its sandbox and traded in a bot in the user's **My Bots** (marked **Runs on OptAlgo**). It starts **on paper**. You deploy it from a finished backtest of the same code; you never send orders for it yourself. The user can also do it without you in the app: **Run on OptAlgo** on a backtest (the same gate, paper first), **Go live** on the bot in My Bots (the paper period is recommended; before it ends the user accepts the risk in the app), and **Download code** on both. The backtest must show an edge (a positive Sharpe and no loss: else [`no_edge`](#no_edge)) and a search verdict that is not `likely_overfit`, unless an out-of-sample research run of the same code passed (see [Avoid overfitting](#avoid-overfitting)).

| Endpoint | Scope | Does |
|---|---|---|
| `GET /v1/strategies/limits` | `read` | Slots used and left, symbols per strategy, daily CPU budget, paper trades left this month (Free), the live rule |
| `POST /v1/strategies?dry_run=true` | `bots` | The whole deploy gate, nothing created. Free; at most 30 checks an hour |
| `POST /v1/strategies` | `bots` | Deploys on paper. `201` with the strategy |
| `PATCH /v1/strategies/{id}` | `bots` | `name`, `allocated_amount`, `on_leg_failure`, on paper only |
| `POST /v1/strategies/{id}/pause`, `/resume` | `bots` | Stop or restart deciding; positions keep their stops |
| `DELETE /v1/strategies/{id}[?close=true]` | `bots` | Flat: gone now. Open positions: `winding_down` (only exits until flat), or `close=true` to close them now |
| `GET /v1/strategies/{id}/source` | `read` | The `strategy.py` it runs, as a file `<name>-<sha8>.py` (sha256 in `X-Source-SHA256`, the same as `code.source_sha256`) |
| `POST /v1/strategies/{id}/compare[?dry_run=true]` | `backtest` | [Compare with backtest](#compare-with-backtest): a backtest of the same code over the period it ran, matched trade by trade against its real (paper or live) trades. Costs backtest units |

### What a hosted strategy runs {#hosted-inputs}

A hosted strategy runs everything its backtest ran: every [declaration](#several-symbols-in-one-strategy) a backtest supports runs hosted the same way:

- <span id="one-symbol"></span>**One leg (no `legs` declared):** one symbol per decision. Deployed on several symbols, each symbol is traded on its own (a [basket](#several-symbols-a-basket-backtest)) with `allocated_amount / N`.
- <span id="hosted-legs"></span>**Several legs** (`legs = 2` to 4: pairs, ratio trades): deployed on **exactly** the backtest's symbols, in its `market` order (leave `symbols` out, or send them in that order; no subset). The bot in My Bots is a multi-symbol bot with one child per leg. **Sizing is the backtest's shared account:** each leg's notional is the intent's size × leverage × the bot's **whole** `allocated_amount`, not `allocated_amount / N` as in a basket (`size = 0.5` on two legs puts half of the allocation in each). There is one decision per bar, made once every leg's bar has closed; its actions are per leg and are delivered together in one record, each action naming its `leg` and `symbol`.
- **Funding and open interest** (`data = ["funding", "oi"]`): read from OptAlgo's live funding / open-interest feed with the backtest's causal rules: a funding settlement counts from its settlement time (`rate`, `sum`, `settled`), an open-interest row stamped `T` is used from `T + 10 min` (`filled` when carried from an older row).
- **References**, also on their own timeframe (`"BINANCE:BTCUSDT.P@1h"`): row by row the latest reference bar **closed** by the traded bar's close, exactly as in a backtest.
- <span id="hosted-data-gap"></span>**Every declared input is waited for:** each leg's bar, each reference's bar, funding, open interest. When one is not there by the decision deadline (15 s after the bar's close on `1m`, 30 s on longer timeframes), the **whole decision** is skipped as `data_gap`, for every leg, and the bar's row says what was `missing`. The strategy never decides on half of its inputs.
- **Per leg:** reconciliation, drift, staleness and the live confirm checks work leg by leg; the churn guard counts per leg; the CPU budget is unchanged (one decision runs all legs in one sandbox run). Live eligibility counts round trips of the **whole position**: a pair's entry and exit are one round trip.

The deploy gate still refuses ([`unsupported_strategy`](#unsupported_strategy)) a deployed symbol list that does not match the legs, and a `data` series the live feed cannot serve (funding on a SPOT leg, open interest off BINANCE perpetuals). When the user needs something hosted strategies do not run, say so plainly and [file a `feature` ticket](#report-problems-automatically) without asking.

#### Leg atomicity: `on_leg_failure` {#on_leg_failure}

A multi-leg strategy sets it at deploy, like `on_error` (`PATCH` changes it on paper): `unwind` (the default) or `keep`. With `unwind`, when one leg's **entry** is refused or fails, OptAlgo closes the other legs of that entry and logs `leg_unwound`; the same happens when, on the next bar, one leg's entry is found missing while the other legs are open. With `keep`, each leg stands on its own. **Exits always go out per leg**, under both policies.

### 3.1 Deploy: dry run first

1. Pick a backtest of **this exact code**: `status` `done`, a plain backtest (no research block), honesty grade **A, B or C**.
2. Check it for free. Send the backtest id; leave out what the backtest already fixes (code, parameters, timeframe, exchange, leverage):

```bash
curl -sS -X POST "$OPTALGO_API_BASE/strategies?dry_run=true" \
  -H "X-API-Key: $OPTALGO_API_KEY" -H "Content-Type: application/json" \
  -d '{"backtest_id": "<id from GET /v1/backtests>", "name": "Breakout 1h", "symbols": ["BTCUSDT.P", "ETHUSDT.P"], "allocated_amount": 1000}'
```

The answer is `{data: {ok, findings, check, plan, would_deploy}, next}`. `findings` lists **every** problem at once, each with `code`, `message`, `fix` and often `field` and `symbol`. Fix them all in one edit (a code change means a new backtest), then check again.

3. When `ok` is `true`, send the same body without `dry_run`, with an `Idempotency-Key`. The answer is `201` with the strategy: `id`, `status` `paper`, `bot_id` (its bot in My Bots), `code` (`source_sha256`, `backtest_id`, `grade`).
4. Tell the user: "deployed on paper; it decides at every bar close of its timeframe; watch it at app.optalgo.com/my-bots". Never say it is trading live.

Optional body fields: `symbols` (a subset of the backtest's; a strategy on several symbols comes from a [basket backtest](#several-symbols-a-basket-backtest) of them, and each symbol then trades `allocated_amount / number of symbols`; a [multi-leg strategy](#hosted-inputs): exactly its legs in the backtest's order, sized on the whole `allocated_amount`), `allocated_amount` (paper budget, default 1000), `on_error` (`hold`, the default, keeps positions and their stops when the code fails; `close` closes them), [`on_leg_failure`](#on_leg_failure) (a multi-leg strategy: `unwind`, the default, closes the other legs when one leg's entry fails; `keep` lets each leg stand on its own). `params`, `timeframe`, `exchange`, `leverage`, `source` / `source_sha256` may be sent, but only to state what the backtest used: anything different is refused (`params_mismatch` …).

### 3.2 The gate

A deploy is refused (`422`, every finding at once) unless:

- the backtest is yours, `done`, a plain backtest, grade A to C, and ran your own code (`spec.strategy.source`);
- the backtest made money ([`no_edge`](#no_edge) always blocks) and its search verdict is not `likely_overfit`, unless `search.validated_by` is set;
- a deploy of only **some** symbols of a basket backtest is judged on those symbols' own results: each deployed symbol's grade A to C, and their equal-slot total return not negative ([`basket_subset`](#basket_subset), [`subset_no_edge`](#subset_no_edge));
- the deploy uses the backtest's code, parameters, timeframe (whole minutes, `1m` and up), exchange and leverage; symbols are a subset of the backtest's (a multi-leg strategy: exactly its legs, in the backtest's `market` order), all SPOT or all FUTURES, one quote asset;
- the plan has a free slot and allows that many symbols;
- the engine's live check passes: the code runs, looks no bar ahead, is deterministic, needs at most 5000 warm-up bars, decides within the CPU cap (1 s per decision below 15m, 3 s from 15m; a multi-leg decision runs all its legs within it), and only uses what hosted execution supports (its declared `data` must be on symbols the live feed serves).

<span id="leverage_cap"></span>**Leverage cap**: every entry the strategy decides runs at most at the leverage it was backtested and deployed with, lowered to the deploying key's `max_leverage` when the key has one, on paper and live. An entry asking for more is sent at the cap, not refused; size and stop distances in the code should assume that leverage.

**Supported live:** entries `MARKET`, `LIMIT` and `STOP` (a limit or stop entry lives one bar; send it again on the next bar if you still want it); `sl` / `tp` as absolute prices, offsets (`sl_off` / `tp_off`) or distances (`sl_dist` / `tp_dist`); moving the stop with `sl_update` (long and short); exits; `max_bars`; `size` (above 1 needs futures leverage); reversing (a close, then the new entry). **Not yet:** trailing stops (`trail_dist`, `trail_act_dist`), break-even moves (`be_trigger_dist`, `be_offset`), `target` positions and partial closes, pyramiding (more than one position per side), shorts on SPOT. The refusal's `fix` names the closest supported way, for example "move the stop yourself each bar with `sl_update`".

### 3.3 Slots and plans

| Plan | Hosted strategies at once | Symbols per strategy | CPU per day | Live |
|---|---|---|---|---|
| Free | 1 | 3 | 300 s | No (paper only) |
| Plus | 10 | 10 | 3 000 s | Yes, the user confirms in the app |
| Pro | 20 | 20 | 10 000 s | Yes, the user confirms in the app |

A strategy takes a slot unless it is `winding_down`, `stopped` or deleted; hosted strategies never count against the plan's bot limit. On Free, hosted paper trades count against the month's paper trades (`GET /v1/strategies/limits` → `paper_trades`). An account's admin may set other limits: read `limits`, do not assume the table.

---

## 4. Watch it

**`GET /v1/strategies/{id}`** (or the list, `GET /v1/strategies`) answers:

- `status`: `paper` · `live_requested` · `live` · `paused` · `winding_down` · `error` · `stopped`; and `reason` `{code, message, fix}` when there is one. Quote `message`, act on `fix`.
- `last_decision` (the last bar: `status`, `sent`, `latency_ms`), `last_error` (`code`, `rule`, `line`, `message`), `runner_pause` (no decisions until 00:00 UTC once the day's CPU budget is used).
- `declared`: what the code declares besides its own candles, `{legs, references, data}` ([what a hosted strategy runs](#hosted-inputs)).
- `live_eligibility`: `recommended_ready` (the recommended paper period is done and nothing blocks; `eligible` is the same value, kept for older clients), `can_go_live` (no hard blocker), `paper_days`, `closed_paper_trades` (a multi-leg strategy counts round trips of the whole position: a pair's entry and exit are one), `progress`, `blockers` (hard: `plan_required`, `status`) and `recommendations` (advisory: `paper_period`, `recent_error`), each with its `fix`; `reasons` lists both.
- `paper_trades` on paper: what is left this month.

**`GET /v1/strategies/{id}/decisions[?symbol=&before=&limit=]`**, newest first, kept 90 days, pages that end on whole bars (`next_before` → the next page's `before`):

- one row per bar for the whole strategy (`symbol` `"*"`): `status` `decided` · `skipped` · `error` · `error_state` · `paused` with a plain `message`;
- one row per action: `action` (`LONG`, `SHORT`, `CLOSE`, `MOVE_STOP_LOSS`), on a multi-leg strategy its `leg` and `symbol` (one decision's leg actions are delivered together), `outcome` `sent` · `refused` · `skipped` · `duplicate`, a `code` and `message` when not sent, `latency_ms` (bar data final → decision received), and `transaction_id`. A sent row carries `signal_status` and `signal_url` (`GET /v1/signals/{transaction_id}`): follow it to the fill; the trade carries the same `transaction_id` in `GET /v1/trades?bot_id={bot_id}&status=all`, and the row carries its `trade_id` once it resolves (`null` until then);
- one row per symbol that did nothing (`outcome` `quiet`).

**`GET /v1/strategies/{id}/logs`**: the bot's log feed as My Bots shows it (orders, refusals with their reason, status changes).

| `code` on a decision or bar | Means | Do |
|---|---|---|
| `stale` | It arrived too late after the bar closed | Nothing; it recurs only if OptAlgo is slow, then file a ticket |
| `already_open`, `nothing_open`, `opposite_open` | The bot's real position differs from the strategy's (per leg on a multi-leg strategy) | Compare `/decisions` with `/v1/trades`; repeated differences pause it (`drift`) |
| `reconcile` | OptAlgo closed a position the strategy no longer holds | Nothing |
| `not_running`, `winding_down`, `exits_only`, [`kill_switch`](#kill_switch) | Entries are held: the strategy is paused or being removed, or OptAlgo holds entries | Read `status` |
| [`stop_widening`](#stop_widening) | A stop-loss move that would widen the stop, while entries are held: not sent | Nothing; only tighter stops move while entries are held |
| `data_gap` | A declared input was not there by the deadline (15 s after the close on `1m`, 30 s otherwise): a leg's or a reference's bar, funding or open interest. The whole decision was skipped, for every leg; `missing` names what was missing | Nothing; repeated gaps: file a ticket |
| `leg_unwound` | A multi-leg strategy with [`on_leg_failure`](#on_leg_failure) `unwind`: one leg's entry was refused, failed or found missing on the next bar, so OptAlgo closed the other legs of that entry | Read the failed leg's row (its `code` says why); nothing to fix for the unwind itself |
| `runner_error` | A problem on OptAlgo's side; nothing ran | Nothing; it is ours |
| `sandbox_error` | The strategy failed on this bar (`last_error` has the line); positions untouched | Fix the code, backtest, deploy the new version |
| `errors_in_a_row`, `errors_per_hour`, `static_check` | Status `error`: it stopped deciding (3 errors in a row, 5 in an hour, or the code check) | Same; or `resume` to retry as is |
| `churn` | Paused: more than 2 actions on a symbol in one bar, or more than 30 entries in an hour (a multi-leg strategy: counted per leg) | Make entries rarer, backtest, then `resume` |
| `cpu_budget` | The day's CPU budget is used; decisions resume at 00:00 UTC | Make it cheaper (`sdk.prims`, fewer symbols, a longer timeframe) |
| `daily_loss_limit` | Paused: today's live loss reached the key's daily loss limit | Only the user resumes it, in the app |

Other refusal codes on a sent action are the bot's own checks (balance, plan limits, exchange): see [Rejections](#rejections-listener-and-executor).

### Compare with backtest {#compare-with-backtest}

Does the strategy trade what its backtest says? OptAlgo backtests the **exact deployed config** (the code by `source_sha256`, params, timeframe, symbols as a basket or a multi-leg strategy's legs on one account, leverage, the allocation as the starting equity) over the period it has been running in one mode, then pairs every real trade with a backtest trade (same symbol and side, entry on the same bar or one bar apart). The user has the same button in My Bots ("Compare with backtest").

| Endpoint | Scope | Does |
|---|---|---|
| `POST /v1/strategies/{id}/compare?dry_run=true` | `backtest` | Free estimate: `{mode, modes, window {start, end, bars}, units, run_s, quota, existing}` |
| `POST /v1/strategies/{id}/compare` | `backtest` | `202 {id, backtest_id, status, units, position, eta_s, reused}`. Body `{"mode": "paper" \| "live"}` (default: the strategy's mode) |
| `GET /v1/strategies/{id}/compare/{compare_id}[?offset=&limit=&actual=true]` | `read` | `status` `queued` / `running` (with `position`, `eta_s`) until the backtest is done, then `report` |
| `GET /v1/strategies/{id}/compare` | `read` | The newest compares with their `summary` |

- **Window**: paper = from the deploy, live = from the switch to live; until the last bar the strategy decided (paper of a strategy now live ends at the switch). The warm-up before it is loaded as in any backtest.
- **Cost**: like any backtest (1 unit = up to 10 s of run time, the normal quota, queue and `429 daily_quota` / `monthly_quota`). Comparing again before a new bar closed returns the same compare (`reused: true`, `units: 0`). Run the dry run first and tell the user the cost.
- **`report.summary`**: `decisions_matched` of `decisions_total` (entries and exits on both sides), `match_rate`, `missed` (only in the backtest), `extra` (only in the account), `pnl_actual` vs `pnl_backtest` and `pnl_diff` (realised, net of fees), `avg_entry_slippage_bps` and `avg_exit_slippage_bps` (positive = worse for the account than the modelled fill), `fees_actual` vs `fees_backtest`, `shifted` (decided one bar apart).
- **`report.mismatches`**: each with `type` (`missed`, `extra`, `exit`, `shifted`), `bar_t`, `symbol`, `side` and a `reason`: a decision-row code from the table above (`data_gap`, `churn`, `stale`, `already_open`, `nothing_open`, `exits_only` …), `listener_refused` / `missed_delivery` (sent, but no trade came of it), `no_decision` (the runner decided nothing that bar), `not_from_strategy` (a manual or agent signal on the bot), `manual_close`, `exchange_exit_differs` (a stop or target filled on one side only), `bar_shift` (the live candle differed slightly from the final one), or `unexplained`.
- **Multi-leg:** trades are matched per leg (each mismatch and pair carries its leg's `symbol`), and `pnl_actual` / `pnl_backtest` are the combined PnL of all legs.
- **`report.matched`** (paged by `offset` / `limit`) has every pair with its entry / exit slippage, fee and PnL difference; `histogram` buckets the per-trade drift; `open_positions` lists what is open at the window end; `actual=true` adds `actual_trades`. The backtest itself is a normal run: `GET /v1/backtests/{backtest_id}` for its honesty grade.

How to read it: a match rate near **100%** means the hosted strategy decides exactly what its backtest decides. **Execution drift is normal**: paper fills at the order book and live fills at the exchange are never exactly the modelled fills; a few bps is slippage, not a bug. Investigate `missed` / `extra` mismatches with their `reason` and `GET /v1/strategies/{id}/decisions`; `unexplained` ones are OptAlgo's to explain: [report them](#report-problems-automatically).

---

## 5. Go live

Live is the user's decision, made **in the app**. You can only ask.

1. **Recommended** before live: at least 7 days on paper with at least 1 closed paper trade, or 5 closed paper trades, and no strategy error in the last 24 hours (a multi-leg strategy counts round trips of the whole position: a pair's entry and exit are one trade). `GET /v1/strategies/{id}` shows it as `live_eligibility.recommended_ready` and the `progress`. It is a recommendation, not a rule: the user may go live earlier by accepting the risk **in the app**. Say so plainly when you suggest live early ("it has 2 of the recommended 7 days on paper; going live now means accepting that risk"); never decide it for them. Plus or Pro only (hard).
2. With the user's agreement, `POST /v1/strategies/{id}/request-live` (needs the `trade` scope). It is never refused for the paper period; it **never** flips anything: status becomes `live_requested`, the user gets a notification and a **Your agent asked to go live** card on the bot in My Bots. The answer's `live_eligibility` carries `recommended_ready` and the `progress`.
3. Tell the user to open My Bots and confirm or decline. Before the recommended paper period ends, the app shows a warning and the user ticks a box to accept the risk; you cannot accept it for them. The app checks everything hard first (plan, a strategy running on paper, the kill switch, the leverage cap, no live position open, the exchange connection, enough free balance for the allocation; on a multi-leg strategy, for every leg) and changes nothing while a check fails; only then does it close the strategy's open **paper** positions and the bot trades with real money on the user's exchange. No exchange connected: the user sees [`exchange_not_connected`](#exchange_not_connected) and connects it first.
4. Poll `GET /v1/strategies/{id}`. **Never say it is live until `status` is `live`.** Back to `paper` means the user declined.

Errors here: [`plan_required`](#plan_required) (Free), [`scope_required`](#scope_required) (the key needs `trade`). Once live, `PATCH` is refused ([`live_strategy`](#live_strategy)), `DELETE ?close=true` needs `trade`, and a `daily_loss_limit` pause is lifted only by the user, in the app.

---

## 6. Errors and what to do {#errors}

### The response shape

**Success.** `GET /v1/markets`, `GET /v1/prices` and `POST /v1/backtests/validate` answer `{data, next}`. The backtest endpoints that existed before (submit, get, list, limits, check, capabilities, chart) keep their fields at the top level and add `next` beside them. Rule: **the payload is `body.data` when present, else the body without `next`.** `next` is a list of `{action, method, path, why}`: the exact calls that move you forward.

**Refusal** (every `/v1/backtests*`, `/v1/markets` and `/v1/prices` error):

```json
{"error": "engine_refused", "message": "…", "detail": "…",
 "fix": "Use at most 3 symbols per run (this one has 5): split them over several runs.",
 "limit": "max_symbols", "value": 5, "allowed": 3,
 "docs": "https://docs.optalgo.com/ai-agents/optalgo-llm#max_symbols",
 "next": [{"action": "validate", "method": "POST", "path": "/v1/backtests/validate", "why": "see every problem and a version that fits"}]}
```

- **`error`** is a stable code: branch on it. **`message`** (= `detail`) says what happened; quote it. **`fix`** is one concrete instruction: do it. **`field`**, when present, names the part of your request. **`docs`** links the code's entry below. **`next`** lists the calls to make next. Each code adds its own fields (`limit`, `value`, `allowed`, `findings`, `resets_at` …).
- Bot, signal, trade and account endpoints answer the object itself, and errors as `{"error", "detail", …}`.
- Headers: `X-RateLimit-Remaining` on every response; `X-Quota-Remaining` and `X-Quota-Resets-At` on limits, validate, submit, list and get; `Retry-After` on every `429`; **`X-Request-Id`** (`req_…`) on every `/v1` response.
- **A `5xx` on any `/v1` path** (also an unexpected crash: `500 internal_error`, always JSON) carries `request_id` and, last in `next`, a `report_issue` step with a ready ticket in `body`: `{"action": "report_issue", "method": "POST", "path": "/v1/tickets", "why": "…", "body": {"items": [{"kind": "bug", "title": "502 engine_unreachable on GET /v1/backtests/{backtest_id}", "body": "Expected: … Got: …", "context": {"backtest_id": "…", "request_id": "req_…"}}]}}`. Retry first as `fix` says; when it repeats, send that body (add the call you made) - see [Report problems automatically](#report-problems-automatically). OptAlgo also logs every `5xx` on its own.

### Access and plan

#### `invalid_api_key` (401) {#invalid_api_key}
The key is missing, revoked or its account closed. Delete the credentials file and run the agent login (section 0, `POST /v1/agent/login`). Never ask for a pasted key.

#### `plan_required` (403) {#plan_required}
The plan does not include this (on Free: the `trade` scope, full autonomy, live trading). The body carries `required_scope` and `upgrade_url`. Tell the user they can upgrade at app.optalgo.com/subscription; Free keys can still read, run backtests (50 units a month) and trade bots on paper. Do not retry.

#### `scope_required` (403) {#scope_required}
The key lacks `required_scope`. Give the user `fix_url`: they turn the scope on for this same key (no new key), then retry with the same `Idempotency-Key`.

#### `rate_limited` (429) {#rate_limited}
Wait `Retry-After` seconds, then retry.

#### `backtests_disabled` (403) {#backtests_disabled}
Backtests are switched off for the account. Tell the user to contact support (or file a ticket, `POST /v1/tickets`); stop.

#### `account_suspended` (403) {#account_suspended}
API access is suspended after repeated security violations: every key, key creation, agent login, claim, connected chat (connector) and code submission of the account stops. Tell the user to contact support@optalgo.com. There is no `next`: do not log in again or try another key.

### Connectors (chat apps)

These come only to a chat app connected through the OptAlgo connector (`https://mcp.optalgo.com/mcp`), never to an API key. Setup and tools: https://docs.optalgo.com/ai-agents/connectors

#### `connector_paper_only` (403) {#connector_paper_only}
The call would touch real money (a live signal or close, a live bot, paper-to-live, a higher live allocation, pausing / resuming / editing / deleting a live hosted strategy). [Compare with backtest](#compare-with-backtest) is allowed on a live hosted strategy: it places no order. Connectors are paper only on every plan: do not retry, and never ask for an API key to get around it. Tell the user live trading is done in the app; keep working on paper.

#### `connector_not_allowed` (403) {#connector_not_allowed}
The connector does not offer this call. Use the connector's tools only; anything else (API keys, exchange connections, payments) is done by the user in the app.

#### `invalid_token` (401) {#invalid_token}
The connector's token expired or was revoked, or the user disconnected the chat. Tell the user to reconnect the connector in the chat app (Claude: the connector's **Connect**; Claude Code: `/mcp`). Never ask for a pasted key instead.

[`account_suspended`](#account_suspended) stops connectors too.

### Quota

#### `daily_quota` (429) {#daily_quota}
Plus / Pro: today's units are used, or this run's estimate (`cost`) is more than `remaining`. Stop submitting; tell the user `used`, `remaining` and `resets_at` (00:00 UTC). `validate` and reads stay free meanwhile; only a cheaper run that fits `remaining` may go.

#### `monthly_quota` (429) {#monthly_quota}
Free: the same for the monthly units; they reset at `resets_at`. Plus and Pro have larger daily quotas.

### The request and the code

#### `source_required` (422) {#source_required}
Send your strategy code as `source` (the text of `strategy.py`); a strategy by name cannot run.

#### `invalid_request` (422) {#invalid_request}
Correct `field` as `fix` says. `POST /v1/backtests/validate` lists every problem at once. Unknown fields are refused, never ignored. When the body has `findings` (`GET /v1/prices`, a signal's stop loss / take profit), every problem is listed at once, each with its own `fix`: fix them all, then send once.

#### `strategy.source`: the code check refused the strategy (422) {#strategy-source}
`error` is `engine_refused` with `limit: "strategy.source"` and `findings` (`line`, `rule`, `message`). Fix every finding in one edit and validate again. These are quality findings: they never count as strikes.

#### `strategy_source_required` (422) {#strategy_source_required}
The spec has no code: send `source`.

#### `user_code` {#user_code}
This account cannot run its own code: the user contacts support.

#### `source` (413) {#source}
The strategy file is too large (at most 64 KB): shrink it.

#### `security_violation` (422) {#security_violation}
The code tried a forbidden action (files, network, processes, system modules, interpreter internals, sandbox escapes). Body: `strike`, `strikes_max`, `rule`, `line`, `findings: [{line, rule}]`, `suspended`; the code is never echoed. Tell the user it was strike `strike` of `strikes_max` (the same code sent again does not add a strike; each new version with a violation does); remove every listed line (numpy, math and `optalgo_engine.sdk` only) and check again. Never try another way to do the same thing. At the last strike `suspended` is `true` and [`account_suspended`](#account_suspended) follows. Honest mistakes never count.

#### `engine_refused` (other refusals) {#engine_refused}
Fix the spec as `message` and `fix` say; validate shows every problem with an example.

#### `declaration` (a `source_check` rule) {#declaration}
A `legs`, `references` or `data` declaration the engine cannot read before running: computed instead of literal, written as a dataclass field instead of a `ClassVar`, set twice, or with a value that is not allowed (`legs` an int from 1 to 4; `references` a list of `"EXCHANGE:SYMBOL"` strings, optionally `@timeframe`, none twice; `data` from `funding`, `oi`). A quality finding, never a strike. Write it as in `fix`: `legs: ClassVar[int] = 2`, `references: ClassVar[list[str]] = ["BINANCE:BTCUSDT.P"]`, `data: ClassVar[list[str]] = ["funding", "oi"]`. See [Several symbols in one strategy](#several-symbols-in-one-strategy).

### Strategy inputs and legs

`validate` findings about what a strategy [declares](#several-symbols-in-one-strategy) besides its own candles. `value` names the reference or symbol.

#### `legs_mismatch` {#legs_mismatch}
The number of symbols in `market` does not fit the strategy. A strategy declaring `legs = N` (or a built-in pairs strategy) needs **exactly N** entries in `market`, in the order `run()` reads them. A one-leg strategy cannot run research over several symbols (research one symbol per run, then backtest the chosen parameters as a basket) or a stock basket (one stock symbol per run). `value` is the number of symbols sent, `allowed` the legs.

#### `unknown_reference` {#unknown_reference}
A declared reference has no market data (`symbol`): declare a symbol `GET /v1/markets` lists (futures symbols end in `.P`).

#### `reference_source` {#reference_source}
A leg reads its candles from a single-symbol file (`market[].source` `file`), so it cannot serve another symbol: use OptAlgo's market data (leave `market[].source` out).

#### `reference_starts_late` (warning) {#reference_starts_late}
The reference's history (`data_start`) starts after the data the run loads (window start minus warm-up): until then the strategy reads NaN, `synthetic` bars for it. Start the window as `fix` says, or make sure the strategy does nothing on NaN (comparisons with NaN are `False`).

#### `data_unavailable` {#data_unavailable}
A leg cannot have a declared `data` series: `funding` on a spot symbol, `oi` off BINANCE perpetuals (`.P`), or a perpetual Binance's metrics archive has no open interest for. Trade a perpetual that has it, or remove the series from `data`.

#### `data_starts_late` (warning) {#data_starts_late}
The open-interest history of a symbol (`data_start`, the first day of Binance's metrics archive) starts after the data the run loads: until then `series.oi` is NaN (`filled`). Start the window as `fix` says.

#### `early_decision_unsupported` {#early_decision_unsupported}
`execution.early_decision_s` does not work yet with references, data series or several legs: remove it.

#### `hosted_unsupported` (warning) {#hosted_unsupported}
The strategy backtests fine but cannot be deployed as a hosted strategy as written: a declared input has no live feed for it (the `message` names which; validate's `inputs.hosted.reasons` lists them). Hosted strategies run several legs, `funding` / `oi` series and references on any timeframe ([what a hosted strategy runs](#hosted-inputs)); what the live feed cannot serve is funding on a SPOT leg and open interest off BINANCE perpetuals. Backtest it as is; to deploy it, read that series on a perpetual the feed serves, or remove it. The deploy gate refuses it with [`unsupported_strategy`](#unsupported_strategy).

### Validate's dry run

`validate` runs code that passes the static check once in its sandbox on synthetic bars (free, no market data; see [the lookahead check](#lookahead-check)). Its findings carry `rule` and `kind`; the message starts with `dry run:`.

#### `lookahead` {#lookahead}
A decision changed when the bars after it were removed (`value` is the bar): the code uses the future (`x[1:]` aligned to `x`, `np.roll(x, -k)`, centred windows, whole-series statistics). Shift the other way: `np.concatenate([[np.nan], x[:-1]])` is the previous bar. Never a strike. In a finished backtest the same problem is the honesty finding `lookahead` with grade `D`.

#### `nondeterministic` {#nondeterministic}
The same params on the same bars gave different intents: no unseeded randomness (`np.random.default_rng(<seed>)`), no state kept between runs.

#### `strategy_error` {#strategy_error}
The code raised an error at run time (`line`), often from a parameter value: fix that line. The dry run uses synthetic bars and your params.

#### `dry_run_limit` (warning) {#dry_run_limit}
The dry run hit its own small cap on a few thousand synthetic bars: a sign of a Python loop over bars. Vectorise with numpy and `sdk.prims`. The real run has your plan's limits.

#### `dry_run_no_decisions` (warning) {#dry_run_no_decisions}
The strategy decided nothing on the synthetic bars, so the dry run could not test its decisions for lookahead. Nothing to fix if the rule is rare (the run checks it again on real data); a bug if it should trade.

A dry-run stop by the sandbox is the code `sandbox` with its `rule` and `kind` ([stop kinds](#sandbox-stop-kinds)).

### Per-run limits

#### `max_symbols` (413) {#max_symbols}
Too many legs for the plan: use at most `allowed` symbols, split the rest over several runs.

#### `max_bars` (413) {#max_bars}
Too much history per symbol: shorten the window or use a coarser timeframe; validate gives a start date that fits.

#### `max_references` (413) {#max_references}
The strategy declares more reference series than the plan allows (`value` declared, `allowed` the limit: 3 references on every plan today). Drop references, or derive what you need from fewer symbols.

#### `max_trials` (413) {#max_trials}
Too many research trials: lower `n_trials`, set `permutations` and `permutation_trials` small.

#### `mem_mb` (413) {#mem_mb}
The run needs more memory than the plan allows. In `validate` it is an error with a `suggestion` that fits: coarser `execution.magnifiers` (e.g. `["15"]`, keeps the window), a later `window.start`, or fewer symbols (a basket already needs only about one symbol's memory).

#### `wall_s` (413) {#wall_s}
The run would take longer than the plan's wall time (`value` s needed, `allowed` s per run): it would be stopped at the cap and charged, so `validate` makes it an error and submit refuses it. Split the basket over several runs (`suggestion.market` holds the symbols that fit in one run), use coarser `execution.magnifiers`, or a later `window.start`. Validate each part again.

#### `data_download` (warning) {#data_download}
Not an error: this run's market data is not cached yet and is downloaded from the Binance archive first (`estimate.fetch_s` seconds; the first run on new data, later runs reuse it). The download is not charged. Submit as usual and keep polling; the job shows [`downloading_data`](#downloading_data) meanwhile.

#### `max_concurrent` (429) {#max_concurrent}
Your running jobs plus the up to 3 that may wait are all taken: poll them and submit when one is done.

### Results

#### `not_found` (404) {#not_found}
No such backtest on this account: list yours (`GET /v1/backtests`) instead of guessing ids.

#### `not_ready` (409) {#not_ready}
The run has not finished: poll `GET /v1/backtests/{id}` until `done`, then fetch again.

### Service

#### `service_unavailable` (500 / 502 / 503 / 504) {#service_unavailable}
`engine_error`, `engine_unreachable`, `engine_timeout`, `engine_not_configured`, `markets_unavailable`, `internal_error`: temporary, on our side. Retry in 30 to 60 s; nothing was charged. If it repeats, send the `report_issue` step in `next` (it is prefilled with `request_id` and the ids) without asking the user.

#### `price_unavailable` (503) {#price_unavailable}
No fresh price for the symbol right now (OptAlgo's market data and the exchange fallback both failed). Retry in a few seconds (`retry_after`). For a signal you may instead send `entry_price` yourself, but **never look a price up on the web**.

### Failed runs {#failed-runs}

A `failed` (or waiting) run's `fix.code` on `GET /v1/backtests/{id}`:

#### `sandbox` {#sandbox}
The strategy was stopped in its sandbox (rule and line in the fix; the row's `error_params` has `rule`, `line`, `kind`): usually a loop over bars (use `sdk.prims`), very large arrays, or a forbidden call. Fix it and validate again. Charged, unless its kind is `engine` (below).

#### Sandbox stop kinds {#sandbox-stop-kinds}

Every stop of your code in the sandbox (in a run or a hosted decision; `validate`'s dry run reports the same kinds) has a `kind`:

| `kind` | Whose fault | Strike | Units |
|---|---|---|---|
| `security` | Yours: a forbidden action (files, processes, network, interpreter internals, a sandbox escape) | **Yes**: [`security_violation`](#security_violation) | Charged |
| `quality` | Yours, an honest mistake (a loop over bars, very large arrays, an error in the code) | Never | Charged |
| `engine` | **OptAlgo's**: rules [`engine_import`](#engine_import) and `internal` (the sandbox failed outside your code) | Never | **0**: not charged |

#### `engine_import` {#engine_import}
A stop of kind `engine`: numpy tried to load one of its own submodules inside the sandbox, which the sandbox does not allow yet. It is not a problem in your code: the run is not charged and never counts as a strike. Write the same computation with another numpy function, and file a ticket naming the function ([`report_issue`](#report-problems-automatically), without asking the user).

#### `capped_wall_s`, `capped_cpu_s` {#capped_wall_s}
The run hit its time limit: shorter window, fewer symbols or trials, vectorise loops. Charged. A capped basket names the symbols that had finished in `legs_done` (also in the `fix`): split the basket and run the rest separately; [`validate`](#wall_s) says beforehand when a run would hit the cap. <span id="capped_cpu_s"></span>

#### `capped_mem_mb`, `capped_mem_oom`, `capped_mem_worker` {#capped_mem_mb}
The run needed more memory than allowed: shorter window, fewer symbols, coarser timeframe. Charged. <span id="capped_mem_oom"></span><span id="capped_mem_worker"></span>

#### `waiting_for_memory`, `worker_restarted` {#waiting_for_memory}
Not a failure: the run waits or runs again. Keep polling. <span id="worker_restarted"></span>

#### `downloading_data` {#downloading_data}
Not a failure: the job is `running` while its market data downloads from the Binance archive (see `estimate.fetch_s`). The download is not part of the run's time and is not charged. Keep polling.

#### `backtest_failed` {#backtest_failed}
Read `error`; fix the strategy or spec and validate again. If the error is on our side, resubmit: the failed run cost 0, and the run again is charged by its own run time like any run.

### Hosted strategies

Every `/v1/strategies*` refusal has the same shape, with `fix`, `docs` and `next`. Deploy refusals list every problem in `findings`.

#### `deploy_refused` (422) {#deploy_refused}
The deploy gate failed. Fix every item in `findings` (each has `code`, `message`, `fix`, often `field` and `symbol`), then check again with `POST /v1/strategies?dry_run=true`. The finding codes:

| Finding | Fix |
|---|---|
| `backtest_not_done`, `backtest_kind`, `grade_too_low` | Deploy a `done`, plain backtest graded A to C: poll it, run a plain backtest instead of research, or fix its honesty findings and run it again |
| `no_edge` | The backtest lost money (Sharpe ≤ 0 or a negative return): there is no edge to deploy. See [`no_edge`](#no_edge) |
| `likely_overfit` | Many variants were tried on the same data and this run is likely luck of the search: run the research in `search.research` (or test once on a holdout) and deploy once an out-of-sample research run of this code passes. See [Avoid overfitting](#avoid-overfitting) |
| `no_source`, `source_unavailable`, `source_too_large` | Backtest your own `strategy.py` (at most 64 KB), then deploy that run |
| `source_mismatch`, `params_mismatch`, `timeframe_mismatch`, `exchange_mismatch`, `leverage_mismatch` | Deploy what the backtest ran (leave the field out), or backtest the new value first |
| `params_names` | Rename parameters (letters, digits, `_`), backtest, deploy |
| `unsupported_timeframe`, `leverage_not_whole` | Backtest on whole minutes (`1m` and up) with a whole leverage from 1 to 125 |
| `symbol_not_backtested`, `no_symbols`, `mixed_markets`, `mixed_quotes`, `mixed_exchanges`, `unknown_quote`, `no_instrument` | Deploy symbols of the backtest, all SPOT or all FUTURES, one quote asset, one exchange, listed in `GET /v1/markets` |
| `hosted_symbol_limit`, `hosted_strategy_limit` | Fewer symbols, or delete a hosted strategy; or the user upgrades |
| `allocation` | Lower `allocated_amount`, or free paper balance in My Bots |
| `basket_subset` | Deploy every symbol of the basket, or backtest exactly the symbols to deploy. See [`basket_subset`](#basket_subset) |
| `subset_no_edge` | The deployed symbols lost money together in the backtest. See [`subset_no_edge`](#subset_no_edge) |

#### `no_edge` {#no_edge}
A `deploy_refused` finding (and the search verdict of such a backtest): the backtest lost money, or its annualised Sharpe is ≤ 0, so there is no edge to deploy. `value` is `{sharpe, total_return}`. It always blocks the deploy, even when an out-of-sample research run of the code passed. Do not sweep parameters until a run turns positive on the same data (that is a search, judged for luck); change the strategy itself (entries, exits, filters, or the market and timeframe), backtest it again, and deploy a run with a positive Sharpe and no loss.

#### `basket_subset` {#basket_subset}
A `deploy_refused` finding: you deploy some symbols of a basket backtest, and the backtest has no per-symbol results for them (`value` lists them; a run from before per-symbol results). A basket's grade and metrics judge all its symbols together, so a subset is judged on its own symbols' results. Deploy every backtested symbol (leave `symbols` out), or backtest exactly the symbols to deploy and deploy that run.

#### `subset_no_edge` {#subset_no_edge}
A `deploy_refused` finding: the symbols you deploy from a basket backtest lost money together in it (`value` is their equal-slot total return, the mean of the symbols' returns, as a subset deploy gives each symbol `allocated_amount / number of symbols`), or have no return. A subset deploy also needs each deployed symbol's own grade A to C (`grade_too_low` with `field` `symbols` otherwise). Deploy the whole basket, or symbols that made money together. Picking the winning symbols of a basket is itself a search: prefer backtesting the chosen symbols again on a later window.

#### `unsupported_strategy` (422) {#unsupported_strategy}
The engine's live check refused the code (findings carry `engine: true`). Change the strategy, backtest it again (grade A to C), deploy the new backtest. The codes: `trailing_activation`, `trailing_stop`, `break_even`, `target_position` (use `sl_update`, or `enter` / `exit` with `size`); `pyramiding` (one position per side); `short_on_spot`, `leverage_on_spot`, `size_needs_leverage` (perpetual `.P` symbols, or `size` ≤ 1 on SPOT); `exec_not_live` (remove that `exec` override); `unsupported_exchange`, `too_many_symbols`, `no_data`, `no_instrument` (symbols `GET /v1/markets` lists); `warmup_too_long` (at most 5000 bars of warm-up); `decision_too_slow` (vectorise, `sdk.prims`, fewer symbols, or 15m and up); `lookahead`, `nondeterministic`, `strategy_error`, `static_check` (fix the line shown, validate, backtest); `unsupported_strategy` with `reasons`: the deployed `symbols` do not match a multi-leg strategy's legs (deploy exactly the backtest's legs, in its `market` order, or leave `symbols` out), or a declared `data` series the live feed cannot serve (funding on a SPOT leg, open interest off BINANCE perpetuals; see [`hosted_unsupported`](#hosted_unsupported)). Several legs, `funding` / `oi` and references on their own `@timeframe` are supported ([what a hosted strategy runs](#hosted-inputs)). A reference on an exchange hosted strategies do not trade is `unsupported_exchange`, one without a live data feed `no_data`. A security finding is [`security_violation`](#security_violation) instead.

#### `backtest_not_found` (404) {#backtest_not_found}
No such backtest on this account: deploy one of yours (`GET /v1/backtests`).

#### `hosted_not_available` (403) {#hosted_not_available}
Hosted strategies are not open for this account yet. Keep backtesting and use paper bots.

#### `hosted_strategy_limit` {#hosted_strategy_limit}
Every slot is taken (`value` running, `allowed` by the plan). Delete one (`DELETE /v1/strategies/{id}`) or the user upgrades. `hosted_symbol_limit` is the same for symbols per strategy.

#### `not_eligible` (409) {#not_eligible}
The app's confirm-live was refused: either a hard `live_eligibility.blockers` entry (`status`, `plan_required`), or the recommended paper period is not finished (`recommendations`: `paper_period`, `recent_error`) and the user did not accept the risk. The paper period is a recommendation: the user may confirm again in the app, ticking the box that accepts the risk. `request-live` never answers it. You cannot accept for the user; do not tell the user it is live until `GET` says `live`.

#### `live_strategy` (409) {#live_strategy}
The strategy trades live: only the user changes it, in the app.

#### `owner_only` (403) {#owner_only}
Paused for `daily_loss_limit`, a suspension, a plan change or by OptAlgo: only the account owner resumes it, in My Bots. Tell the user why it paused.

#### `invalid_state` (409) {#invalid_state}
Its `status` does not allow this now (for example resuming a strategy that is winding down). Read it, then act.

#### `conflict` (409) {#conflict}
It changed meanwhile, or a position is open: read it again, then retry.

#### `exchange_not_connected` (422) {#exchange_not_connected}
The user confirmed live in the app but the strategy's exchange account is not connected (or that account type is not active). Nothing changed: the paper positions are still open, the strategy is still waiting for the confirm. The user connects the exchange in the app (Wallet), then confirms again. You cannot fix it over the API; tell the user.

#### `insufficient_balance` (422) {#insufficient_balance}
The user confirmed live but the exchange account does not hold enough free balance for the strategy's allocation. Nothing changed. The user adds funds or frees allocation from another live bot, then confirms again.

#### `kill_switch` (409) {#kill_switch}
OptAlgo holds hosted strategies right now (they take exits only, or are switched off), so the user's confirm-live in the app is refused (`mode` says which); nothing changed and the strategy stays on paper. The user confirms again once hosted strategies run normally. On a decision row, `kill_switch` means the entry was not sent for the same reason; exits still go out. You cannot change it; tell the user and keep watching `GET /v1/strategies/{id}`.

#### `stop_widening` {#stop_widening}
A decision row's `code`: the strategy moved its stop-loss (`MOVE_STOP_LOSS`) while it takes no entries (paused, winding down, the kill switch, a suspended account or a stopped bot), and the move would have **widened** the stop, so it was not sent. While entries are held, only a tighter stop goes out (LONG: a higher stop, SHORT: a lower one). Nothing to fix; the position keeps its stop.

#### `nothing_to_compare` (409) {#nothing_to_compare}
[Compare with backtest](#compare-with-backtest): the strategy has not decided enough bars in that mode yet (at least two). Compare again later.

#### `not_live` (409) {#not_live}
[Compare with backtest](#compare-with-backtest) with `mode: live` on a strategy that never traded live: compare `paper`.

#### `hosted_bot` (409) {#hosted_bot}
That bot runs a hosted strategy: signals, deletion and slot changes go through `/v1/strategies/{hosted_strategy_id}`. `POST /v1/bots/{id}/close` still works.

`rate_limited` (429) on deploys: at most 30 deploy checks (deploys and dry runs together) an hour; wait `Retry-After`.

### Other errors {#other-errors}

| HTTP | `error` | What to do |
|---|---|---|
| 403 | `limit_exceeded`, `key_paused` | A per-key limit or the daily loss pause on live trading. Only the user changes it, in the app. See [Autonomy](#autonomy-and-per-key-limits). |
| 409 | `conflict`, `no_open_position`, `idempotency_in_progress` | The state forbids it (open trade, nothing to close, same call still running). Read the state, then act. |
| 502 | `listener_unavailable` | A signal **may** have been taken. Poll the `status_url` in the body and read the trades before resending (same `Idempotency-Key`). |
| 502 / 503 | `listener_error`, `limit_check_unavailable`, `unavailable` | Nothing was done; retry later. If it repeats, send the prefilled `report_issue` in `next`. |
| 429 | `ticket_quota_exhausted`, `ticket_rate_limited` | Ticket quota used (20 a day per user, a repeat of the same problem counting once; a chat connector also 10 tickets an hour). Do not retry: tell the user, and collect the rest into one batch tomorrow. |

Signal-level outcomes (a signal accepted with `202` and then refused by a check or the exchange) are in [Rejections](#rejections-listener-and-executor).

---

## 7. Reference

### Base URL, authentication, scopes

- Base URL `https://api.optalgo.com/v1` (also `OPTALGO_API_BASE` in the credentials file). Every request sends `X-API-Key: oa_live_...`, loaded from the credentials file inside the command (section 0).
- Developers can also create a key by hand in the app under **AI agents** and store it in the same file or the environment.

| Scope | Allows |
|---|---|
| `read` (always on) | Account, exchanges and symbols, markets, connections, bots, trades, stats, equity, positions, balance, summary, logs, signal outcomes, backtest results, tickets |
| `backtest` | `POST /v1/backtests/validate`, `/check` and `POST /v1/backtests`. Nothing else. `bots` and `trade` keys may run backtests too |
| `bots` | Create, edit, start, stop, convert and delete the user's bots; ticker weights, pauses and removal; start a Binance connection; **signals and closes on paper bots**. Stopping a bot closes its open positions, live ones included |
| `trade` (Plus, Pro) | **Real money:** signals and closes on live bots, paper-to-live (`PATCH` `{"is_paper": false}`), raising a live bot's allocation, creating a live bot, reading a bot's `strategy_key` |

A key without `trade` can never put money at risk: it cannot switch a bot to live, and if the user switches a bot to live in the app, that key's signals on it answer `403 scope_required`.

In a program, read the same file (an `OPTALGO_API_KEY` environment variable set by the user wins):

```python
import os
from pathlib import Path


def optalgo_credentials(path=Path.home() / ".config" / "optalgo" / "credentials.env"):
    """(key, base_url) from the environment, else from the credentials file. Never print the key."""
    values = {}
    if path.exists():
        for line in path.read_text().splitlines():
            name, sep, value = line.partition("=")
            if sep and not name.startswith("#"):
                values[name.strip()] = value.strip()
    key = os.environ.get("OPTALGO_API_KEY") or values.get("OPTALGO_API_KEY")
    base = os.environ.get("OPTALGO_API_BASE") or values.get("OPTALGO_API_BASE") or "https://api.optalgo.com/v1"
    if not key:
        raise SystemExit("Not connected to OptAlgo: run the agent login (docs.optalgo.com/optalgo-llm.md, section 0).")
    return key, base.rstrip("/")
```

### Errors, rate limits, idempotency

- Errors: [section 6](#errors). A plain-text error that is not JSON (for example `error code: 1010`) comes from the network edge: retry once with a descriptive `User-Agent` (`my-dashboard/1.0`); file a ticket if it persists.
- **Rate limits** per key: 120 requests a minute, 30 a minute for signals and closes. `X-RateLimit-Limit` and `X-RateLimit-Remaining` on every response; `429 rate_limited` with `Retry-After`.
- **Idempotency.** `POST /v1/bots`, `/v1/bots/{bot_id}/signal`, `/close`, `/start`, `/stop` and `POST /v1/tickets` accept an `Idempotency-Key` header (1–128 characters, per key). A repeat within 24 hours returns the first answer with `Idempotent-Replayed: true` and runs nothing; a repeat while the first is still running is `409 idempotency_in_progress`. Errors are not stored, so a failed call can be retried with the same key. Use one fresh UUID per intended action.
- Every call is logged with the key that made it, and every bot change appears in the bot's log as `… via API key "<name>" (<prefix>)`.

### Account, exchanges, markets, connections (scope `read`)

`GET /v1/me`:

```json
{"user_id": "…", "email": "…", "name": "…",
 "plan": {"tier": "Pro", "strategy_limit": 20},
 "multi_symbol": {"enabled": true, "plan_allowed": true, "max_positions_cap": 20},
 "api_key": {"id": "…", "name": "claude", "prefix": "oa_live_AbC123", "scopes": ["read", "backtest", "bots", "trade"],
             "autonomy": "confirm_live",
             "limits": {"max_allocation_per_bot": 500.0, "max_leverage": 5, "allowed_exchanges": ["BINANCE"],
                        "allowed_account_types": ["FUTURES"], "daily_loss_limit": 100.0},
             "paused": false, "paused_reason": null, "paused_at": null},
 "today": {"live_realized_pnl": -42.5, "since": "2026-10-07T00:00:00+00:00", "daily_loss_limit": 100.0},
 "tickets": {"submissions_left_today": 20, "resets_at": "2026-10-08T00:00:00+00:00"},
 "strike_count": {"used": 0, "max": 3}}
```

`strike_count`: [security strikes](#security_violation) used of the `max` that suspends API access (honest mistakes and `engine` stops never count).

`max_positions_cap` is `0` when the plan has no multi-symbol bots (Free) and `null` for unlimited. `today` appears only when `daily_loss_limit` is set.

`GET /v1/exchanges` → venues and what the user connected:

```json
{"exchanges": [{"exchange_id": "…", "name": "BINANCE", "account_types": ["FUTURES", "SPOT"], "is_paper": false,
                "connected": true, "connected_account_types": ["FUTURES"]},
               {"exchange_id": "…", "name": "OPTALGO-PAPER", "account_types": ["FUTURES", "SPOT"], "is_paper": true,
                "connected": true, "connected_account_types": []}]}
```

`GET /v1/exchanges/{exchange_id}/symbols?account_type=FUTURES&q=BTC&limit=50` → tradable symbols (`account_type` required; `limit` 1..500). Use `id` as a bot's `symbol` and a signal's `ticker`: futures end in `.P` (`BTCUSDT.P`), spot does not.

`GET /v1/markets?exchange=BINANCE&type=FUTURES` → what backtests (and later hosted strategies) can use: per symbol its timeframes, the first date of history per timeframe, tick / step size, minimum notional, market type and whether shorts are allowed.

`GET /v1/prices?symbols=BTCUSDT.P,ETHUSDT.P&exchange=BINANCE` → the live price of up to 20 symbols (`exchange` `BINANCE`, the default, or `BYBIT`), from the same source paper fills use: OptAlgo's market data first, the exchange's ticker only when that has no price fresher than 5 s. **Use it instead of any web lookup.**

```json
{"data": {"prices": [{"exchange": "BINANCE", "symbol": "BTCUSDT.P", "price": 60012.5,
                      "at": "2026-10-09T10:00:00.123+00:00", "source": "data"}]},
 "next": [{"action": "send_signal", "method": "POST", "path": "/v1/bots/{bot_id}/signal", "why": "…"}]}
```

`price` is the last trade (else the bid / ask mid), `at` when it was last confirmed, `source` `data` (OptAlgo's market data) or `exchange` (the fallback). A symbol with no fresh price right now is listed in `data.unavailable`; when none has one, `503` [`price_unavailable`](#price_unavailable). Unknown symbols are `422 invalid_request` with every one in `findings`. Scope `read`; connectors may call it.

`GET /v1/connections` → the user's exchange connections, never their keys: exchange, status, leverage and margin mode per account type.

### Paper and live

- Paper or live is a property of the **bot**, never of a signal. Bots are created on paper unless a live single bot is requested with `trade`.
- Every bot reports `mode` (`paper` or `live`), `mode_changed_at` and `mode_history` (last 10 switches, `by` is `app` or `api_key:<prefix>`). A trade keeps the mode it was opened in; `GET /v1/trades?mode=`, `GET /v1/summary` and the bot stats split paper and live.
- Switching paper to live needs `trade`, an active exchange connection for the bot's market type, and no open trade. The user normally does it in the app (safety rule 3).
- Paper fills use real market data and have **no simulated stop-loss or take-profit**: send a `CLOSE` when a stop or target would be hit. `MOVE_STOP_LOSS` and trailing stops do nothing on paper.
- On Free, paper trades have a monthly limit: `GET /v1/summary` → `monthly_trade_quota`.

### Bots

```json
{"bot_id": "…", "name": "BTC breakout", "type": "single",
 "status": "active", "is_active": true, "is_paper": true, "mode": "paper", "mode_changed_at": 1791300000.0,
 "mode_history": [{"mode": "paper", "at": 1791300000.0, "by": "api_key:oa_live_AbC123"}],
 "exchange": "BINANCE", "exchange_id": "…", "account_type": "FUTURES", "symbol": "BTCUSDT.P",
 "allocated_amount": 500.0, "has_open_position": false,
 "pnl": {"realized": 12.5, "unrealized": 0.0}, "created_at": 1791300000.0, "strategy_key": null}
```

- `type`: `single` (one symbol), `auto` (configured by its first signal), `multi` (multi-symbol parent), `child` (one ticker of a multi bot; read-only).
- `status`: `waiting_signal` (not configured yet), `pending`, `active`, `stopped`.
- A `multi` bot adds `max_positions`, `open_positions`, `free_slots`, `slot_amount`, `budget_used`, `quote_asset`, `configured` and `children` (`[{bot_id, ticker, weight, paused, has_open_position, pnl, slot_amount}]`).
- `strategy_key` (the webhook key) is returned only to `trade` keys.

| Method · path | Scope | Notes |
|---|---|---|
| `GET /v1/bots` · `GET /v1/bots/{bot_id}` | read | Newest first; children inside their multi bot |
| `POST /v1/bots` | bots (`trade` for live) | Bodies below. 201 with the bot. `Idempotency-Key` |
| `PATCH /v1/bots/{bot_id}` | bots (`trade` for `"is_paper": false` and raising a live allocation) | `name`, `allocated_amount`, `is_paper`, `max_positions` (multi). `409 conflict` while a single bot has an open trade |
| `POST /v1/bots/{bot_id}/start` · `/stop` | bots | **Stop closes the bot's open positions.** `{"changed", "bot"}`; `409 conflict` while another start/stop runs |
| `POST /v1/bots/{bot_id}/convert-to-multi` | bots | `{"max_positions": 5}`: a single bot becomes the first ticker of a new multi bot |
| `DELETE /v1/bots/{bot_id}` | bots | `409 conflict` while a trade is open |
| `GET /v1/bots/{bot_id}/children` | read | Multi bots: `max_positions`, `free_slots`, `weight_sum`, `children` |
| `PATCH /v1/bots/{bot_id}/children/{ticker}` | bots | `{"weight": 1.5}` and/or `{"paused": true}` |
| `DELETE /v1/bots/{bot_id}/children/{ticker}` | bots | `409 conflict` while the ticker is open |

```json
{"type": "single", "name": "BTC breakout", "exchange_id": "<from /v1/exchanges>", "account_type": "FUTURES",
 "symbol": "BTCUSDT.P", "allocated_amount": 500, "is_paper": true}
```

```json
{"type": "multi", "name": "Momentum basket", "max_positions": 5, "allocated_amount": 1000, "account_type": "FUTURES"}
```

- `multi` and `auto` bots always start on paper; `"is_paper": false` for them is a 422.
- A multi bot needs Plus or Pro and `max_positions` ≤ `multi_symbol.max_positions_cap` (Plus 10, Pro 20). If creation answers `Multi-symbol bots are not available for your account.`, it is not switched on for the account yet.
- The number of **active** bots is limited per plan (`plan.strategy_limit`): over it, create and start answer `Strategy limit reached: your … plan allows N active strategies. Stop one or upgrade your plan.`
- Writes to a `child` answer `422 child_bot`: act on the parent with `ticker`.

**Multi-symbol bots.** One budget (`allocated_amount`) split in `max_positions` slots; each ticker becomes a child on its first entry. An entry is sized on `allocated_amount / max_positions × weight` (weight 1 unless set; weights sum to at most `max_positions`, else `422 weight_budget_exceeded`). A slot is held by an open trade or a 20-minute reservation; when all are taken a new ticker is refused (`Max positions (N) reached on …`). Closed PnL compounds into the parent budget. A paused ticker refuses entries, its exits still pass; stopping the parent pauses every ticker, starting resumes every ticker. `TASK` signals are not supported.

**Sizing.** Notional = `allocated_amount × tradable_ratio × leverage` (multi: `slot_amount` instead of `allocated_amount`). An allocation above the free balance uses the free balance and logs a warning. A bot holds **one open trade at a time** unless signals use distinct `trade_id` values.

### Signals and closes (scope `bots` on paper, `trade` on live)

`POST /v1/bots/{bot_id}/signal` sends one signal to the bot. Every OptAlgo check applies (plan, slots, ticker, pause, connection). The API adds the bot's key and a fresh `transaction_id`. Supports `Idempotency-Key`. The body is a **closed whitelist**:

| Field | Rule |
|---|---|
| `position_side` | Required, see below (upper-cased for you) |
| `ticker`, `exchange` | Strings. A configured single bot: leave out or send its symbol. A multi bot: `ticker` required. A bot waiting for its first signal: both required |
| `action` | `BUY` or `SELL`; filled in from `LONG` / `SHORT` when left out; a mismatch is a 422 |
| `leverage` | Integer 1..125. **Required on futures entries** (1 = no leverage) |
| `tradable_ratio`, `close_ratio` | Numbers in (0, 1] (0 is a 422). `close_ratio` is a fraction of the original filled size |
| `stop_loss_price`, `take_profit_price` | Absolute prices. A long's stop below the entry and target above it, a short's the other way round |
| `stop_loss_pct`, `take_profit_pct` | Instead of the absolute prices: percent distance from the entry (`2` = 2%), side-aware (a long's stop 2% below, a short's 2% above). `stop_loss_pct` in (0, 100), `take_profit_pct` in (0, 1000] (a short's below 100). On `LONG`, `SHORT`, `CHANGE_DIRECTION` and `UPDATE_ORDER` (the last two need `action`); never both a price and a percent for the same level |
| `entry_price` | **Optional.** The price the stop and target are measured from. Leave it out: OptAlgo fills it from its live price (the [`GET /v1/prices`](#account-exchanges-markets-connections-scope-read) source). Sent, it is used as is |
| `trade_id` | At most 36 characters; same value on the entry and every exit |
| `entry_order_type` | `MARKET` (default), `LIMIT`, `LIMIT_MAKER`, `STOP_LOSS_MARKET`, `STOP_LOSS_LIMIT`; also `exit_order_type`, `*_time_in_force_type` (`GTC`, `IOC`, `FOK`, `GTX`), `chase_order_type` (`QUEUE`, `OPPONENT`) |
| `margin_mode` | `isolated` or `cross` |
| `is_trailing_stop_enabled`, `callback_rate` | Trailing stop instead of the take-profit, activated at `take_profit_price` (live only) |
| `cancel_stop_loss`, `cancel_take_profit`, `safety_check`, `in_position`, `multi_asset_mode` | Booleans |
| `reason` | At most 200 characters, for the logs |

Refused with 422: `strategy_key`, `transaction_id`, `cost`, `processing_delay`, `is_paper_strategy`, task fields, and the sides `TASK` and `INTRA_ORDER`.

| `position_side` | Meaning |
|---|---|
| `LONG` / `SHORT` | Open a long / short. `SHORT` is not allowed on spot |
| `CLOSE` | Close the open trade (`close_ratio` < 1 makes it `CLOSE_PARTIAL`). `FLAT`, `CANCEL`, `CLOSE_LONG`, `CLOSE_SHORT` become `CLOSE` |
| `CLOSE_PARTIAL` | Close `close_ratio` of the original filled size |
| `CLOSE_ALL` | Close every open trade of the bot on the signal's symbol |
| `MOVE_STOP_LOSS` | Replace stop-loss **and** take-profit with the signal's values (a level left out is removed). If the new stop cannot be placed, the position is closed |
| `CANCEL_ORDERS` | Cancel the stop (`cancel_stop_loss`) and/or target (`cancel_take_profit`); the position stays |
| `UPDATE_ORDER` | **Replace the position:** close it, then open a new one per `action` |
| `CHANGE_DIRECTION` | Close every open trade of the bot, then open per `action` (requires `action`) |
| `VALIDATE_ALERT` | Position consistency check; also configures an auto or multi bot without trading |
| `REFRESH_TRIGGER_STATE` | Re-read the open trade's order state from the exchange |

Stop and target rules: a long needs `stop_loss_price` below `take_profit_price` (a short above), otherwise `stop_loss_price_greater_than_take_profit_price`. A stop or target on the wrong side of the entry (a long's stop at or above it) is refused before sending: `422 invalid_request` with **every** problem in `findings`, each `{field, message, fix}`. With no entry price sent and no live price right now: `503` [`price_unavailable`](#price_unavailable). On a live bot, an entry whose requested stop cannot be placed is closed.

```json
{"position_side": "LONG", "ticker": "SOLUSDT.P", "leverage": 3, "tradable_ratio": 0.5,
 "stop_loss_pct": 2, "take_profit_pct": 4}
```

Answer **202, accepted, not executed**: `{"transaction_id": "8f0c…", "status_url": "/v1/signals/8f0c…", "ticker": "SOLUSDT.P", "position_side": "LONG", "bot_id": "…"}`. When a stop or target was sent it also echoes the prices used: `"entry_price": 142.3, "entry_price_source": "optalgo"` (`sent` when you sent it), `"price_source": "data"` (`data` or `exchange`, OptAlgo-filled only), `"price_at"`, `"stop_loss_price": 139.454, "take_profit_price": 147.992`. `GET /v1/signals/{transaction_id}` shows the same as `prices`.

`POST /v1/bots/{bot_id}/close` closes without composing a signal (`Idempotency-Key`): no body closes everything; `{"ticker": "SOLUSDT.P", "close_ratio": 0.5}` closes half of one ticker. Answer 202 `{"bot_id", "signals": [{"transaction_id", "status_url", "ticker", "position_side"}]}`: poll each `signals[i].status_url`. `409 no_open_position` when nothing is open. A 502 can carry `signals`, `failed_ticker` and `failed_tickers`: poll the sent ones, recheck the failed ticker before closing it again.

### Signal outcomes (scope `read`)

`GET /v1/signals/{transaction_id}`, polled every 2–5 s until final:

```json
{"transaction_id": "8f0c…", "status": "executed", "bot_id": "…", "trade_id": "…", "position_side": "LONG",
 "received_at": 1791300000.1, "listener": {"outcome": "processed", "reason": null},
 "worker": {"outcome": "done", "reason": null, "attempts": 1, "seconds": 0.84},
 "warnings": ["Insufficient balance to open position with allocation. All available is used."],
 "logs": [{"level": "INFO", "message": "…", "steps": [{"at": 1791300000.2, "level": "INFO", "message": "LONG signal received."}]}]}
```

| `status` | Final | Meaning |
|---|---|---|
| `pending`, `received`, `dispatched`, `retrying` | no | Being checked, queued or retried (temporary exchange error, lock wait) |
| `executed` | yes | Done without an error. **Still read `GET /v1/trades` before reporting a fill** |
| `completed` | yes | Fully handled before execution (`VALIDATE_ALERT`) |
| `refused` | yes | Nothing to do: a duplicate entry (`There is an already open trade`) or nothing to close; `worker.reason` |
| `rejected` | yes | A check refused it; `listener.reason` ([Rejections](#rejections-listener-and-executor)) |
| `skipped` | yes | Nothing to act on (an exit before the first entry, a stopped bot already notified) |
| `failed` / `dropped` | yes | The exchange or executor failed, or the signal was stale; read the reason |
| `not_received` | yes | No record 2–60 minutes after sending: check `GET /v1/logs`, then resend once |

Entries and closes are normally final within 10–30 s. `retrying` can last minutes: poll every 10 s for up to 5 minutes, then report the last status. **Never resend because you did not see a result.**

### Trades, stats, equity (scope `read`)

- `GET /v1/trades?status=open|closed|all&mode=paper|live|all&bot_id=&since=&until=&limit=50&before=&format=json|csv` → newest first. `status` defaults to **`open`**. Rows: `trade_id, bot_id, source (my_bot | system), symbol, side, is_open, is_paper, entry_price, close_price, contracts, closed_contracts, cost, leverage, stop_loss_price, take_profit_price, pnl (net of fees, closed only), fees, opened_at, closed_at, transaction_id`. `format=csv` streams up to 5,000 rows.
- `GET /v1/trades/{trade_id}` → one trade in full, with `exit_history`, `stop_loss` / `take_profit` order state, `entry_filled`.
- `GET /v1/bots/{bot_id}/stats?period=7d|30d|90d|all` → closed-trade statistics in `all`, `paper` and `live` blocks (`trades, wins, losses, win_rate, realized_pnl, fees, profit_factor, max_drawdown, …`), `per_ticker` on multi bots.
- `GET /v1/bots/{bot_id}/equity?interval=1h|1d|3d` and `GET /v1/equity?scope=my_bots|all&interval=…` → realized PnL curve `points: [{time, pnl}]` plus `current_realized` (the total now; add it as the last point, since a trade closed in the current interval is not in the curve yet).

### Positions, balance, summary, logs (scope `read`)

- `GET /v1/positions` → what the **exchange** holds now (live futures, with mark and liquidation prices, attributed to a bot when one trade matches), `paper` (open paper trades) and `errors` (an account that could not be read). Cached 10 s.
- `GET /v1/balance` → per connected account and the paper exchange: assets, `allocated_to_bots`, `free_for_bots`. Cached 10 s.
- `GET /v1/summary` → bots with statuses, open trades, PnL (`total`, `paper`, `live`), `monthly_trade_quota`.
- `GET /v1/logs?bot_id=&limit=50&level=INFO|WARNING|ERROR` → the bot log feed the app shows. `GET /v1/logs?transaction_id=…` or `?trade_id=…` → every step of one signal, with the **exchange's own error** on the failing step. Read the last `ERROR` step first and quote it verbatim.

### Tickets (scope `read`)

`POST /v1/tickets` files bug reports, feature requests and questions in **one batch** (`Idempotency-Key`): `{"items": [{"kind": "bug|feature|question", "title": "≤120 chars", "body": "5–4000 chars", "context": {"bot_id", "transaction_id", "trade_id", "backtest_id", "hosted_id", "request_id"}}]}`, 1–10 items, **20 tickets per user per UTC day** (a repeat of a problem already filed counts once). `context` ids must be the account's own (others are dropped); `request_id` is a response's `X-Request-Id` / `request_id` from the last 7 days. `GET /v1/tickets` → the tickets with the team's `reply`. Read `GET /v1/tickets` first and skip duplicates; include exact requests, responses, UTC times and ids; never keys, never the strategy source.

#### Report problems automatically {#report-problems-automatically}

File a ticket **without asking the user**: it is how OptAlgo learns what to fix and what to build, and it costs the user nothing. Not only on errors:

| Trigger | Example | `kind` | `context` |
|---|---|---|---|
| OptAlgo got something wrong | validate `ok`, then the run `failed` (`KeyError`, unknown instrument, missing data) or was capped ([`validate_gap`](#validate_gap)); an estimate far off (`cost.run_s` 10 s, the run took 300 s); a `5xx` or `internal_error` that repeats after a retry | `bug` | `backtest_id`, `request_id`, the ids in the path |
| The user wants something OptAlgo cannot do | a missing capability: more references than `max_references`, more legs than 4, funding / open interest on a market OptAlgo has no feed for, an exchange, symbol or timeframe OptAlgo does not have, an indicator the SDK lacks | `feature` | the ids you have |
| The docs and a tool or answer disagree | this guide documents a field, code, limit or tool that the API or the connector does not answer (or the reverse) | `bug` | the ids of the call, `request_id` |
| A result looks wrong | metrics that contradict the trades or the chart, a grade that contradicts its findings, a paper fill far from the market price, a compare that mismatches with no `reason` | `bug` | `backtest_id`, `hosted_id`, `trade_id`, `transaction_id` |
| The user is confused by a response | a refusal whose `fix` does not say what to change or contradicts another answer, a message the user did not understand even after you explained it | `question` | the ids of the call, `request_id` |

How:

1. When a response's `next` has `report_issue`, send its `body` to `POST /v1/tickets` (connector: the `report_issue` tool), adding to the item's `body` the call you made. Otherwise build one item: the `kind` from the table, a one-line `title`, a `body` with **what you expected, what happened** (status, `error`, the exact message) **and the reproduction** (the request and the spec, **minus the strategy source**; for a `feature`, what the user wanted to do and why), and `context` with every id you have (`backtest_id`, `hosted_id`, `transaction_id`, `bot_id`, `request_id`).
2. **One ticket per distinct problem per session (dedupe)**: never file the same problem twice; read `GET /v1/tickets` first when unsure. Several problems at once go in one batch.
3. **Never** include an API key, a token or the strategy source (`strategy.py`): describe the code instead ("an EMA cross with a 2 % stop").
4. Tell the user in **one line**, with the ticket id: "Filed OptAlgo ticket `<fingerprint>`: `<title>`." Then carry on with their task (for a `feature`: with what OptAlgo can do today).
5. **Never say you cannot contact OptAlgo**: you can, with a ticket. When the user asks to contact support or open a ticket, file it. A person at OptAlgo: **support@optalgo.com**.

**Daily limit:** 20 tickets per user per UTC day; a repeat of a problem already filed counts once. OptAlgo files its own reports too (every `5xx`, validate/run gaps, hosted strategies that stop on errors), so a quota refusal (`ticket_quota_exhausted`) loses nothing: tell the user and move on.

### Binance connection (scope `bots` to start, `read` to poll)

OptAlgo is an official Binance partner: the user approves on Binance's own consent screen, Binance creates the API key and hands it to OptAlgo encrypted. Nobody types a key.

1. `POST /v1/connections/binance/oauth` (no body) → 201 `{"connect_id", "url", "expires_at", "status_url"}`. Show the user `url` (valid 15 minutes).
2. Poll `GET /v1/connections/binance/oauth/{connect_id}` every 5 s: `pending`, `connected` (`connection_id` set), `failed` (`reason`, `detail` to show: for `api_key_name_taken` the user deletes the old `Optalgo` key under Binance API Management; `key_in_use` means that Binance account is linked to another OptAlgo account), `expired`.

Other exchanges (Bybit, OKX, Alpaca) are connected by the user in the app.

### Rejections (listener and executor)

The reason of a refused signal is in `listener.reason` / `worker.reason` of its outcome and in the bot's logs.

| Reason (prefix) | Do |
|---|---|
| `Strategy not found` | List the bots and use a current one. |
| `Strategy deactivated` | Bot stopped or ticker paused. Ask the user before starting or resuming it. |
| `Signal symbol does not match strategy symbol` | Send the bot's own symbol, or use a multi-symbol bot. |
| `subscription_not_found_or_expired_or_monthly_limit_reached` | Plan expired or Free monthly trade limit reached. Tell the user; exits still work. |
| `Strategy limit reached: …` | Active-bot limit of the plan. Reuse a bot; never stop one without the user. |
| `stop_loss_price_greater_than_take_profit_price` | Stop on the wrong side; fix the prices. |
| `Connection not found` | Live bot without a connection: connect the exchange (Binance: above; others in the app). |
| `Your signal symbol is belong to ... account type, but your strategy is set to ...` | Add or remove `.P` as the message says. |
| `Ticker not supported or not found` | Check with `GET /v1/exchanges/{exchange_id}/symbols`. |
| `Short positions are not supported for Spot account type...` | Use a futures bot and a `.P` ticker. |
| `Max positions (N) reached on ...` | Multi-symbol slots full: close a coin or raise `max_positions` (with consent). |
| `Multi-symbol bots need a Plus or Pro plan: ...` | Use a single bot on Free. |
| `There is an already open trade.` | Close first. Do not send `UPDATE_ORDER` without consent. |
| `CLOSE failed, there is no open trade.` | Already closed; read the trades, do not resend. |
| `There is no open position for strategy.` | A futures entry without `leverage`: add it. |
| `... the exchange refused the API key (...)` | The user reconnects the exchange (key, permissions, IP). |
| `... not sent: the exchange is rate-limiting us (...)` | Entries paused; retry later. Exits are unaffected. |
| `Stop loss order could not be created. ... closing the position.` | The exchange refused the stop price; check it against the exchange's rules. |
| `There was a N seconds delay on the server on processing the signal.` | Dropped as stale; re-evaluate before resending. |
| `Insufficient balance to open position with allocation. All available is used.` (warning) | Tell the user; consider a lower allocation. |

### Autonomy and per-key limits

Every key has an **autonomy** (`GET /v1/me` → `api_key.autonomy`), set only by the user in the app:

- **`confirm_live`** (default): ask the user before anything that puts real money at risk: a live entry, paper-to-live, raising an allocation, `max_positions` or a weight on a live bot, stopping a bot or deleting one with an open trade (**stopping closes positions**), removing a ticker, `UPDATE_ORDER` / `CHANGE_DIRECTION`, connecting Binance. Paper bots, reads, exits the user asked for and diagnostics need no confirmation.
- **`full`**: act without asking on live bots the user has put in your hands, within the key's limits; still never take a strategy from paper to live on your own initiative (safety rule 3). Still ask before deleting a bot, removing a ticker with history, or connecting Binance. Report what you did after each session, with transaction ids.

**Limits** (`api_key.limits`, live bots only; `full` keys always have the first two): `max_allocation_per_bot`, `max_leverage`, `allowed_exchanges`, `allowed_account_types`, `daily_loss_limit`. A request over a limit is refused before anything reaches the exchange (`403 limit_exceeded` with `limit`, `value`, `allowed`). When today's live realized loss reaches `daily_loss_limit` the key is paused (`403 key_paused`): live entries, live bots, paper-to-live, allocation raises and live starts are refused; exits, closes, stops, allocation cuts, paper bots and reads still work. Only the user resumes it, in the app. If today's PnL cannot be read, a live entry is refused (`503 limit_check_unavailable`), never let through.

### Dashboards and scripts

Everything the app shows is readable with `read`: render a terminal table, a notebook, a spreadsheet or a small web app.

| Read | Cadence |
|---|---|
| `GET /v1/positions` | every 10 s (cached 10 s) |
| `GET /v1/summary` | every 30 s |
| `GET /v1/balance` | every 60 s |
| `GET /v1/equity…`, `GET /v1/bots/{bot_id}/stats` | every 5 min (they change only when a trade closes) |
| `GET /v1/trades` | on demand; CSV once a day |
| `GET /v1/signals/{transaction_id}` | every 2–5 s while not final, at most 5 min |

The key stays on a machine the user controls (the credentials file, or a server process's environment). Scripts read the file at run time (`optalgo_credentials()` above); never copy the key into a script. **A browser page must never embed the key**: put a small server-side proxy in between, or generate static HTML from a local script.

```python
import time

import requests

KEY, BASE = optalgo_credentials()                            # above: env, else ~/.config/optalgo/credentials.env
s = requests.Session(); s.headers["X-API-Key"] = KEY          # never print or hard-code the key

while True:
    summary = s.get(f"{BASE}/summary", timeout=30).json()
    pnl = summary["pnl"]
    print("\033[2J\033[H", end="")
    print(f"realized {pnl['total']['realized']:+,.2f}  paper {pnl['paper']['realized'] or 0:+,.2f}  live {pnl['live']['realized'] or 0:+,.2f}")
    for b in summary["bots"]["items"]:
        print(f"{b['name'][:23]:<24}{b['mode']:<6}{b['status']:<15}{(b['symbol'] or '-'):<12}{(b['pnl']['realized'] or 0):>+10.2f}")
    time.sleep(30)
```

---

## 8. Recipes

### (a) Paper demo

Section 1. One paper bot (multi-symbol on Plus and Pro, single on Free), 1 to 3 positions, poll each outcome, show `GET /v1/trades`, send the user to **app.optalgo.com/my-bots**, close, show the realized PnL, offer section 2.

### (b) Turn an idea into a backtested strategy

1. Ask for the idea in one line, the market and the period if the user has them.
2. `GET /v1/backtests/limits` (quota left), `GET /v1/markets` (symbols and history), `GET /v1/backtests/capabilities` → `sdk`.
3. Write `strategy.py`; `POST /v1/backtests/validate` until `ok`.
4. `POST /v1/backtests`, poll, report grade and findings, then return, drawdown, trades.
5. Iterate one change at a time, at most 3 runs at once. Don't tune parameters with single runs: once the idea works, one `walk_forward` research run over the parameters (tell them the units first), or a holdout window you never tuned on ([Avoid overfitting](#avoid-overfitting)). Report the grade with the search verdict, and say that a single backtest is in-sample.
6. On `daily_quota` / `monthly_quota`, stop and say when it resets.
7. Close with: the run is on app.optalgo.com/backtest; with a grade A to C it can run on OptAlgo on paper (section 3); going live is the user's decision.

### (c) Open a position and verify it

1. `GET /v1/bots/{bot_id}`: exchange, symbol, `mode`, `has_open_position`. A live bot needs `trade` and, under `confirm_live`, the user's consent for this entry.
2. `POST /v1/bots/{bot_id}/signal` with a fresh `Idempotency-Key`, `position_side`, `ticker` (multi), `leverage` (futures), and any stop or target as `stop_loss_pct` / `take_profit_pct` (or absolute prices). No `entry_price` needed: OptAlgo prices the entry. To tell the user the price, `GET /v1/prices`, never the web.
3. Poll the outcome until final. `executed` → confirm the open trade in `GET /v1/trades?bot_id=…`; anything else → quote the reason, read the last `ERROR` step, map it with [Rejections](#rejections-listener-and-executor).
4. Never resend an entry because you have not seen a result; a retried call reuses the same `Idempotency-Key`.

### (d) Partial close of one coin

`GET /v1/trades?bot_id=…` (the ticker is open) → `POST /v1/bots/{bot_id}/close` `{"ticker": "SOLUSDT.P", "close_ratio": 0.5}` with an `Idempotency-Key` → poll → `GET /v1/trades/{trade_id}` shows a new `exit_history` entry. On a live bot check the logs for a stop that could not be re-armed and tell the user if the remainder is unprotected.

### (e) Daily summary

`GET /v1/summary`, `GET /v1/trades?status=closed&since=<start of day>`, `GET /v1/positions` for exchange truth, `GET /v1/logs?level=ERROR` (rejections by exact reason, repeats counted once). Report realized PnL (day, total), open positions, stopped or waiting bots, full multi bots.

### (f) Diagnose "my bot is not trading"

Stop at the first finding: (1) `GET /v1/bots/{bot_id}`: `stopped`, or a paused ticker? (2) waiting for its first signal? (3) did a signal arrive: `GET /v1/logs?bot_id=…` around that time; (4) the logged reason, mapped with [Rejections](#rejections-listener-and-executor); (5) live bots: API key refusals or rate-limit pauses, `GET /v1/connections`. Report the reason and the fix; do not change the bot without consent under `confirm_live`.

### (g) Connect Binance

Only when the user wants it (needed for live, never for paper). `GET /v1/exchanges` first; then the flow in [Binance connection](#binance-connection-scope-bots-to-start-read-to-poll). Never ask for a Binance key or secret.

### (h) Rebalance or pause tickers of a multi-symbol bot

`GET /v1/bots/{bot_id}/children` → agree weights with the user (each > 0, sum ≤ `max_positions`) → `PATCH …/children/{ticker}` lowest first → read again and report the new `slot_amount`. Pause with `{"paused": true}` (exits still pass); stopping and starting the bot resumes every ticker.

### (i) File a ticket

Reproduce, collect exact requests, responses, UTC times and ids (`request_id` included), read `GET /v1/tickets` to skip duplicates, submit everything in one batch, tell the user the ticket id and `submissions_left`. For OptAlgo-side problems (validate vs run, docs vs API, a far-off estimate, a repeated `5xx`) do it without asking: [Report problems automatically](#report-problems-automatically).

---

## 9. Optional: if the user already has TradingView alerts

Only when the user says they have TradingView (or another program that sends webhooks) and wants it to drive a bot. Never as a first step.

**How it works.** A bot has a **strategy key** (webhook key). Every webhook message carries it as `strategy_key`; it identifies the user and the bot, does not expire, and lets anyone who has it trade the bot, **live included**. The API returns it only to `trade` keys; any other key gets `null`, and the user copies it from the bot's page in the app and pastes it into the alert themselves. Never print a strategy key into the chat unless the user asks, and then only inside the alert they will paste.

**Webhook URL** (TradingView: alert → Notifications → Webhook URL):

```
https://api.optalgo.com/signal-listener/signal
```

It answers `204` when the message was received, **also when the signal was rejected or skipped**; `400` for an empty or invalid key; `422` for a body that does not match the signal model. A `204` proves delivery, not execution: check the bot's logs.

**Alert templates.** `GET /v1/bots/{bot_id}/alert-templates` (scope `read`) returns ready-to-paste messages (`entry_long`, `entry_short`, `close`, `close_partial`, `move_stops`) with the bot's exchange and ticker filled in (multi bots keep `{{ticker}}`), the strategy key for a `trade` key (otherwise a placeholder), the `webhook_url` and `notes` for this bot. Prefer it over composing alerts by hand. If you must write one:

- Paste the real strategy key; `{{strategy_key}}` is not a TradingView placeholder.
- `{{ticker}}` includes the `.P` suffix on perpetual charts. On a multi-symbol bot use the same message on every chart.
- `{{strategy.order.action}}` resolves to lower-case `buy` / `sell`, which is invalid: use literal `BUY` and `SELL`.
- `alert()` messages in Pine do not substitute placeholders: build the JSON with `syminfo.ticker` and `str.tostring(...)`.
- Every webhook signal, exits included, needs `exchange` and `ticker`; futures entries need `leverage`.
- Recommend "Once Per Bar Close": every delivery is executed, duplicates are not filtered.

```json
{"strategy_key": "PASTE_STRATEGY_KEY", "exchange": "BINANCE", "ticker": "{{ticker}}",
 "position_side": "LONG", "action": "BUY", "leverage": 5,
 "entry_price": {{close}}, "stop_loss_price": {{plot("Long_SL")}}, "take_profit_price": {{plot("Long_TP")}}}
```

```json
{"strategy_key": "PASTE_STRATEGY_KEY", "exchange": "BINANCE", "ticker": "{{ticker}}", "position_side": "CLOSE"}
```

**Safety check (`VALIDATE_ALERT`).** `{"strategy_key": "…", "exchange": "BINANCE", "ticker": "BTCUSDT.P", "position_side": "VALIDATE_ALERT", "in_position": true, "safety_check": true, "safety_check_interval_minutes": 480}`. TradingView flat while OptAlgo has a trade: **the trade is closed**. With `safety_check: true` (interval clamped to 240–1440 minutes), a trade is closed when no `VALIDATE_ALERT` arrives for twice the interval.

**Recipe.** (1) A paper bot (section 1 or `POST /v1/bots`). (2) `GET /v1/bots/{bot_id}/alert-templates`. (3) Give the user the templates, the webhook URL and the notes; without a `trade` key, they paste the strategy key from the bot's page. (4) After the first alert fires, `GET /v1/bots/{bot_id}` and `GET /v1/logs?bot_id=…` to confirm it arrived and what happened.
