Skip to main content

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​

StepYou doThe user sees
0. ConnectDevice login; the key goes straight into a file, never into the chatAn approval page in the app
1. Paper demoOpen 1 to 3 paper positions in a My Bots bot, show them, close themThe bot and its trades at app.optalgo.com/my-bots
2. Develop a strategyWrite strategy.py, validate, backtest, read the grade, iterateEvery run at app.optalgo.com/backtest
3. Run it on OptAlgoDeploy a backtested strategy (grade A to C), dry run firstThe bot, marked Runs on OptAlgo, in My Bots, on paper
4. Watch itRead its status, decisions and logsEvery decision on the bot's page
5. Go liveAsk (the paper period is recommended, not required); never flip it yourselfA Your agent asked to go live card: Confirm or Decline
LiveNever your decision. The user switches to live in the appThe switch, in My Bots

Start with OptAlgo itself. Do not open with TradingView, webhooks or alert messages: they are optional (section 9) 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​

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 you call the tool.

#StepAPIConnector tool
1Markets and limits: which symbols and timeframes exist and from when; plan limits and quota leftGET /v1/markets, GET /v1/backtests/limitsmarkets, limits
2SDK: how to write strategy.py, the intents and the run spec. Read it once per sessionGET /v1/backtests/capabilities → sdkstrategy_sdk
3Validate: free dry run that lists every problem at once. Fix them all, validate again until okPOST /v1/backtests/validatevalidate_strategy
4Run: costs quota units (1 unit = up to 10 s of run time). At most 3 runs at oncePOST /v1/backtestsrun_backtest
5Status: poll until done, respecting poll_after_sGET /v1/backtests/{id}backtest_status
6Result: 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-sampleGET /v1/backtests/{id}, /chartbacktest_result, backtest_chart
7Paper: 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 itcreate_paper_bot, paper_signal; deploy_strategy (dry_run)
8Watch: status, every decision, logsGET /v1/strategies/{id}, /decisions, /logsstrategy_status, strategy_decisions
9Compare: its real trades against a backtest of the same code over the same periodPOST /v1/strategies/{id}/comparecompare_with_backtest

Something wrong, missing or confusing on OptAlgo's side at any step: file a ticket without asking (POST /v1/tickets, tool report_issue). A human: [email protected].

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 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) until [email protected] 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: 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 [email protected]. Details: 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):

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

Every plan can connect, Free included.

PlanScopes a key can haveMoney
Freeread, backtest, botsPaper only. trade answers 403 plan_required
Plus, Proread, backtest, bots, tradePaper, 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:

    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?​

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.

# 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.

# 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" )
EndingMeaningWhat you do
Connected. and HTTP 200The key is saved and worksRead plan.tier, api_key.scopes and api_key.autonomy, then offer the paper demo (section 1).
Still waiting … (exit 75)Not approved yetRun 2b again; remind the user of the link.
HTTP 403: access_deniedThe user clicked DenyStop. Ask whether to try again.
HTTP 403: plan_requiredA requested scope needs a higher plan (trade on Free)Start again without trade.
HTTP 409: key_limit_reached5 active keys alreadyThe user revokes one under AI agents; run 2a again.
HTTP 410: expired_token / The login expiredNot approved within 10 minutes, or usedRun 2a again.
HTTP 503 … trying againTemporary server errorNothing: 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:

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:

# 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:

{"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):

      {"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:

      {"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):

    {"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.

  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 ClassVars references, legs, data: see 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): 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).

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.

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​

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 (a quality finding, never a strike).

DeclareYou 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)
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. Research runs (research) take the N legs together.
  • Baskets: a one-leg strategy with references over several symbols still runs as a basket; 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).
  • 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). 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, reference_source, reference_starts_late, data_unavailable, data_starts_late, 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).

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).

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).

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​

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, 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); 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.

2.2 Discover before you spend quota​

CallAnswers
GET /v1/backtests/limitsYour plan's per-run limits and your quota
GET /v1/markets?exchange=BINANCE&type=FUTURESThe supported universe (below)
GET /v1/backtests/capabilitiesThe 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):

{"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:

{"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, 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.

{"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:

AreacodeExtra fields
Codesource_checkline, col, rule, kind (security or quality), snippet. A security finding is refused with security_violation. Rule declaration: a legs / references / data declaration that is not a literal ClassVar with an allowed value
Code, dry runlookahead, nondeterministic, strategy_error, sandbox (a stop at run time: rule, kind, see stop kinds); warnings dry_run_limit, dry_run_no_decisionsrule, kind, line, value (the bar)
Codestrategy_source_requiredSend source
Specspec_invalidfield, allowed, example
Strategy vs symbolswarning basket: your one-instrument strategy has several symbols in market, so it runs as a basket; 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, "needs exactly N legs"); duplicate_symbolvalue (symbols sent), allowed, suggestion ({"basket": true, "symbols": N, "initial_equity_per_symbol": …})
Datadata_source_unavailableMarket data could not be read; retry later
Declared inputs (several symbols)unknown_reference, reference_source, data_unavailable, early_decision_unsupported, unsupported_timeframe (a reference's @timeframe); warnings reference_starts_late, data_starts_late, 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
Datawarning data_download: the first run on this data downloads it from the Binance archive first, which takes a while (estimate.fetch_s); not chargedvalue (seconds)
Symbolsunknown_symbolsuggestion, allowed
Symbolsunsupported_exchange, no_instrument_rulesPick from GET /v1/markets
Timeframeunsupported_timeframenearest
Windowwindow_invalid, window_before_data (data_start); warnings window_in_future, warmup_before_data, coverage_unknown
Limitsmax_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 (the estimate is above the plan's wall time: the same, an error), max_references, user_code, limits; warning max_concurrent (the run will queue)value, allowed, suggestion (a window start, magnifiers or symbols that fit)
Quotadaily_quota, monthly_quotaused, remaining, resets_at
Requestinvalid_requestfield

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.

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); 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​

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), even when such a research run passed.

The spec:

FieldRequiredMeaning
marketyes1 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, unless the strategy declares legs = N: then exactly N symbols, in the order run() reads them, on one account (several symbols in one strategy).
timeframeyesBar size in minutes, as a string ("15", "60", "240"): any value in data.timeframes of GET /v1/markets.
windowyes{"start": "2024-01-01", "end": "2025-01-01"} (UTC). Warm-up history before start is loaded for you.
strategy.paramsnoParameter overrides, e.g. {"fast": 10}. Leave strategy.name out (422 source_required).
namenoYour label, shown in the app.
researchnoA 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.

Several symbols: a basket backtest​

A strategy.py that does not declare legs trades one instrument (it may still read other symbols as references). 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:

{"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​

  • 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.

{"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).

    "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.
    • 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).

    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. 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).

  7. 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​

FreePlusPro
Backtest units (1 unit = up to 10 s of run time)50 per month30 per day200 per day
Resetsat quota.resets_at00:00 UTC00:00 UTC
Symbols per run3310
History per run (1-minute candles across all legs)300,000550,000 (about 1 year, one symbol)3,200,000 (about 6 years, one symbol)
Trials per optimisation40100500
Runs at once113
Time per run60 s90 s300 s
Memory per run1.5 GB2 GB4 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):

{"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 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 (Plus, Pro) or monthly_quota (Free), with Retry-After:

{"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):

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.

"""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) and a search verdict that is not likely_overfit, unless an out-of-sample research run of the same code passed (see Avoid overfitting).

EndpointScopeDoes
GET /v1/strategies/limitsreadSlots used and left, symbols per strategy, daily CPU budget, paper trades left this month (Free), the live rule
POST /v1/strategies?dry_run=truebotsThe whole deploy gate, nothing created. Free; at most 30 checks an hour
POST /v1/strategiesbotsDeploys on paper. 201 with the strategy
PATCH /v1/strategies/{id}botsname, allocated_amount, on_leg_failure, on paper only
POST /v1/strategies/{id}/pause, /resumebotsStop or restart deciding; positions keep their stops
DELETE /v1/strategies/{id}[?close=true]botsFlat: gone now. Open positions: winding_down (only exits until flat), or close=true to close them now
GET /v1/strategies/{id}/sourcereadThe 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]backtestCompare 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​

A hosted strategy runs everything its backtest ran: every declaration a backtest supports runs hosted the same way:

  • One leg (no legs declared): one symbol per decision. Deployed on several symbols, each symbol is traded on its own (a basket) with allocated_amount / N.
  • 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.
  • 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) 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 without asking.

Leg atomicity: 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):
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.

  1. 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).
  2. 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 of them, and each symbol then trades allocated_amount / number of symbols; a multi-leg strategy: 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 (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 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, 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).

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​

PlanHosted strategies at onceSymbols per strategyCPU per dayLive
Free13300 sNo (paper only)
Plus10103 000 sYes, the user confirms in the app
Pro202010 000 sYes, 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).
  • 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 barMeansDo
staleIt arrived too late after the bar closedNothing; it recurs only if OptAlgo is slow, then file a ticket
already_open, nothing_open, opposite_openThe 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)
reconcileOptAlgo closed a position the strategy no longer holdsNothing
not_running, winding_down, exits_only, kill_switchEntries are held: the strategy is paused or being removed, or OptAlgo holds entriesRead status
stop_wideningA stop-loss move that would widen the stop, while entries are held: not sentNothing; only tighter stops move while entries are held
data_gapA 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 missingNothing; repeated gaps: file a ticket
leg_unwoundA multi-leg strategy with 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 entryRead the failed leg's row (its code says why); nothing to fix for the unwind itself
runner_errorA problem on OptAlgo's side; nothing ranNothing; it is ours
sandbox_errorThe strategy failed on this bar (last_error has the line); positions untouchedFix the code, backtest, deploy the new version
errors_in_a_row, errors_per_hour, static_checkStatus error: it stopped deciding (3 errors in a row, 5 in an hour, or the code check)Same; or resume to retry as is
churnPaused: 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_budgetThe day's CPU budget is used; decisions resume at 00:00 UTCMake it cheaper (sdk.prims, fewer symbols, a longer timeframe)
daily_loss_limitPaused: today's live loss reached the key's daily loss limitOnly 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.

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").

EndpointScopeDoes
POST /v1/strategies/{id}/compare?dry_run=truebacktestFree estimate: {mode, modes, window {start, end, bars}, units, run_s, quota, existing}
POST /v1/strategies/{id}/comparebacktest202 {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]readstatus queued / running (with position, eta_s) until the backtest is done, then report
GET /v1/strategies/{id}/comparereadThe 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.


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 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 (Free), scope_required (the key needs trade). Once live, PATCH is refused (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​

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):

{"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. OptAlgo also logs every 5xx on its own.

Access and plan​

invalid_api_key (401)​

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)​

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)​

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)​

Wait Retry-After seconds, then retry.

backtests_disabled (403)​

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

account_suspended (403)​

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 [email protected]. 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)​

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 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)​

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)​

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 stops connectors too.

Quota​

daily_quota (429)​

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)​

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)​

Send your strategy code as source (the text of strategy.py); a strategy by name cannot run.

invalid_request (422)​

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)​

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)​

The spec has no code: send source.

user_code​

This account cannot run its own code: the user contacts support.

source (413)​

The strategy file is too large (at most 64 KB): shrink it.

security_violation (422)​

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 follows. Honest mistakes never count.

engine_refused (other refusals)​

Fix the spec as message and fix say; validate shows every problem with an example.

declaration (a source_check rule)​

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.

Strategy inputs and legs​

validate findings about what a strategy declares besides its own candles. value names the reference or symbol.

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​

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

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)​

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​

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)​

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​

execution.early_decision_s does not work yet with references, data series or several legs: remove it.

hosted_unsupported (warning)​

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); 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.

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). Its findings carry rule and kind; the message starts with dry run:.

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​

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​

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)​

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)​

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).

Per-run limits​

max_symbols (413)​

Too many legs for the plan: use at most allowed symbols, split the rest over several runs.

max_bars (413)​

Too much history per symbol: shorten the window or use a coarser timeframe; validate gives a start date that fits.

max_references (413)​

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)​

Too many research trials: lower n_trials, set permutations and permutation_trials small.

mem_mb (413)​

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)​

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)​

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 meanwhile.

max_concurrent (429)​

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)​

No such backtest on this account: list yours (GET /v1/backtests) instead of guessing ids.

not_ready (409)​

The run has not finished: poll GET /v1/backtests/{id} until done, then fetch again.

Service​

service_unavailable (500 / 502 / 503 / 504)​

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)​

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​

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

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​

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:

kindWhose faultStrikeUnits
securityYours: a forbidden action (files, processes, network, interpreter internals, a sandbox escape)Yes: security_violationCharged
qualityYours, an honest mistake (a loop over bars, very large arrays, an error in the code)NeverCharged
engineOptAlgo's: rules engine_import and internal (the sandbox failed outside your code)Never0: not charged

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, without asking the user).

capped_wall_s, capped_cpu_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 says beforehand when a run would hit the cap.

capped_mem_mb, capped_mem_oom, capped_mem_worker​

The run needed more memory than allowed: shorter window, fewer symbols, coarser timeframe. Charged.

waiting_for_memory, worker_restarted​

Not a failure: the run waits or runs again. Keep polling.

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​

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)​

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:

FindingFix
backtest_not_done, backtest_kind, grade_too_lowDeploy 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_edgeThe backtest lost money (Sharpe ≤ 0 or a negative return): there is no edge to deploy. See no_edge
likely_overfitMany 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
no_source, source_unavailable, source_too_largeBacktest your own strategy.py (at most 64 KB), then deploy that run
source_mismatch, params_mismatch, timeframe_mismatch, exchange_mismatch, leverage_mismatchDeploy what the backtest ran (leave the field out), or backtest the new value first
params_namesRename parameters (letters, digits, _), backtest, deploy
unsupported_timeframe, leverage_not_wholeBacktest 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_instrumentDeploy symbols of the backtest, all SPOT or all FUTURES, one quote asset, one exchange, listed in GET /v1/markets
hosted_symbol_limit, hosted_strategy_limitFewer symbols, or delete a hosted strategy; or the user upgrades
allocationLower allocated_amount, or free paper balance in My Bots
basket_subsetDeploy every symbol of the basket, or backtest exactly the symbols to deploy. See basket_subset
subset_no_edgeThe deployed symbols lost money together in the backtest. See subset_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​

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​

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)​

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). Several legs, funding / oi and references on their own @timeframe are supported (what a hosted strategy runs). 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 instead.

backtest_not_found (404)​

No such backtest on this account: deploy one of yours (GET /v1/backtests).

hosted_not_available (403)​

Hosted strategies are not open for this account yet. Keep backtesting and use paper bots.

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)​

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)​

The strategy trades live: only the user changes it, in the app.

owner_only (403)​

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)​

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

conflict (409)​

It changed meanwhile, or a position is open: read it again, then retry.

exchange_not_connected (422)​

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)​

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)​

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​

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)​

Compare with backtest: the strategy has not decided enough bars in that mode yet (at least two). Compare again later.

not_live (409)​

Compare with backtest with mode: live on a strategy that never traded live: compare paper.

hosted_bot (409)​

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​

HTTPerrorWhat to do
403limit_exceeded, key_pausedA per-key limit or the daily loss pause on live trading. Only the user changes it, in the app. See Autonomy.
409conflict, no_open_position, idempotency_in_progressThe state forbids it (open trade, nothing to close, same call still running). Read the state, then act.
502listener_unavailableA signal may have been taken. Poll the status_url in the body and read the trades before resending (same Idempotency-Key).
502 / 503listener_error, limit_check_unavailable, unavailableNothing was done; retry later. If it repeats, send the prefilled report_issue in next.
429ticket_quota_exhausted, ticket_rate_limitedTicket 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.


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.
ScopeAllows
read (always on)Account, exchanges and symbols, markets, connections, bots, trades, stats, equity, positions, balance, summary, logs, signal outcomes, backtest results, tickets
backtestPOST /v1/backtests/validate, /check and POST /v1/backtests. Nothing else. bots and trade keys may run backtests too
botsCreate, 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):

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. 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:

{"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 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:

{"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.

{"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. 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​

{"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 · pathScopeNotes
GET /v1/bots · GET /v1/bots/{bot_id}readNewest first; children inside their multi bot
POST /v1/botsbots (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 · /stopbotsStop closes the bot's open positions. {"changed", "bot"}; 409 conflict while another start/stop runs
POST /v1/bots/{bot_id}/convert-to-multibots{"max_positions": 5}: a single bot becomes the first ticker of a new multi bot
DELETE /v1/bots/{bot_id}bots409 conflict while a trade is open
GET /v1/bots/{bot_id}/childrenreadMulti 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}bots409 conflict while the ticker is open
{"type": "single", "name": "BTC breakout", "exchange_id": "<from /v1/exchanges>", "account_type": "FUTURES",
"symbol": "BTCUSDT.P", "allocated_amount": 500, "is_paper": true}
{"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:

FieldRule
position_sideRequired, see below (upper-cased for you)
ticker, exchangeStrings. A configured single bot: leave out or send its symbol. A multi bot: ticker required. A bot waiting for its first signal: both required
actionBUY or SELL; filled in from LONG / SHORT when left out; a mismatch is a 422
leverageInteger 1..125. Required on futures entries (1 = no leverage)
tradable_ratio, close_ratioNumbers in (0, 1] (0 is a 422). close_ratio is a fraction of the original filled size
stop_loss_price, take_profit_priceAbsolute prices. A long's stop below the entry and target above it, a short's the other way round
stop_loss_pct, take_profit_pctInstead 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_priceOptional. The price the stop and target are measured from. Leave it out: OptAlgo fills it from its live price (the GET /v1/prices source). Sent, it is used as is
trade_idAt most 36 characters; same value on the entry and every exit
entry_order_typeMARKET (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_modeisolated or cross
is_trailing_stop_enabled, callback_rateTrailing 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_modeBooleans
reasonAt 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_sideMeaning
LONG / SHORTOpen a long / short. SHORT is not allowed on spot
CLOSEClose the open trade (close_ratio < 1 makes it CLOSE_PARTIAL). FLAT, CANCEL, CLOSE_LONG, CLOSE_SHORT become CLOSE
CLOSE_PARTIALClose close_ratio of the original filled size
CLOSE_ALLClose every open trade of the bot on the signal's symbol
MOVE_STOP_LOSSReplace 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_ORDERSCancel the stop (cancel_stop_loss) and/or target (cancel_take_profit); the position stays
UPDATE_ORDERReplace the position: close it, then open a new one per action
CHANGE_DIRECTIONClose every open trade of the bot, then open per action (requires action)
VALIDATE_ALERTPosition consistency check; also configures an auto or multi bot without trading
REFRESH_TRIGGER_STATERe-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. On a live bot, an entry whose requested stop cannot be placed is closed.

{"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:

{"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."}]}]}
statusFinalMeaning
pending, received, dispatched, retryingnoBeing checked, queued or retried (temporary exchange error, lock wait)
executedyesDone without an error. Still read GET /v1/trades before reporting a fill
completedyesFully handled before execution (VALIDATE_ALERT)
refusedyesNothing to do: a duplicate entry (There is an already open trade) or nothing to close; worker.reason
rejectedyesA check refused it; listener.reason (Rejections)
skippedyesNothing to act on (an exit before the first entry, a stopped bot already notified)
failed / droppedyesThe exchange or executor failed, or the signal was stale; read the reason
not_receivedyesNo 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​

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:

TriggerExamplekindcontext
OptAlgo got something wrongvalidate ok, then the run failed (KeyError, unknown instrument, missing data) or was capped (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 retrybugbacktest_id, request_id, the ids in the path
The user wants something OptAlgo cannot doa 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 lacksfeaturethe ids you have
The docs and a tool or answer disagreethis guide documents a field, code, limit or tool that the API or the connector does not answer (or the reverse)bugthe ids of the call, request_id
A result looks wrongmetrics 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 reasonbugbacktest_id, hosted_id, trade_id, transaction_id
The user is confused by a responsea refusal whose fix does not say what to change or contradicts another answer, a message the user did not understand even after you explained itquestionthe 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: [email protected].

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 foundList the bots and use a current one.
Strategy deactivatedBot stopped or ticker paused. Ask the user before starting or resuming it.
Signal symbol does not match strategy symbolSend the bot's own symbol, or use a multi-symbol bot.
subscription_not_found_or_expired_or_monthly_limit_reachedPlan 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_priceStop on the wrong side; fix the prices.
Connection not foundLive 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 foundCheck 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.

ReadCadence
GET /v1/positionsevery 10 s (cached 10 s)
GET /v1/summaryevery 30 s
GET /v1/balanceevery 60 s
GET /v1/equity…, GET /v1/bots/{bot_id}/statsevery 5 min (they change only when a trade closes)
GET /v1/tradeson 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.

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). 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.
  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; (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. 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.


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.
{"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")}}}
{"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.