Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/backtest-kit/ai-trading-mcp/llms.txt

Use this file to discover all available pages before exploring further.

The live broker adapter in modules/live.module.ts was written after a real incident post-mortem. Binance spot has a specific set of failure modes — frozen balances, duplicate clientOrderId collisions, race conditions between fills and cancels — that naive adapters trip over silently. This page explains the three-commit methodology that eliminates those failure modes one by one, and the tuning constants that control timing across each phase.
This adapter is spot-only. The ccxt exchange client is instantiated with defaultType: "spot" and makes no provision for futures or margin trading. Attempting to open a short position is rejected immediately as an OrderRejectedError — spot has no native short mechanism.

Why OCO Instead of Two Separate Sell Orders

The most common naive implementation places a take-profit limit sell and a stop-loss sell as two independent orders. On Binance spot, this cascade-fails every time:
Naive approachWhat Binance actually doesThis adapter
Two independent TP + SL sell ordersFirst sell freezes the coins; second sell gets InsufficientFunds — this is the cascade rootOne OCO: TP + stop-limit SL in a single freeze
Check free balance to confirm entryAfter entry, coins sit frozen inside the OCO; free ≈ 0 → buys again (position doubling)Checks free + used (fetchTotalQty)
Retry entry POST over a live NEW order-2010 duplicate clientOrderId — terminal drop while your own order is still on bookCancel first (frees the id), then re-enter
Treat a failed cancel as a failureOrder filled between last poll and cancel (-2011) — that is a fillcancelOrderSafe re-reads the order, returns "filled"
Market-sell the unwind qty directlyTries to sell what its own TP order froze → unwind failsCancel-sweep → verify clean book → sell free balance
A single OCO call (privatePostOrderOco) creates both legs atomically against one freeze. The coins can only be released once — either by the TP triggering, the SL triggering, or an explicit cancel-sweep.

The Three-Commit Methodology

Every open/close lifecycle passes through exactly three commits. Each is responsible for one phase and each handles its own failure modes internally.
1

commit_buy — Guaranteed Entry

Places a limit buy at the signal price, polls for a fill, and falls back to a market top-up for any unfilled remainder. The order never lingers on the book after this step completes.
const order = await exchange.createOrder(symbol, "limit", "buy", qty, price, {
  clientOrderId: signalId,
});
// poll up to 10 × 10 s …
if (last.status !== "closed") {
  if (await cancelOrderSafe(exchange, order.id, symbol) === "filled") return;  // filled at the flag
  const final = await exchange.fetchOrder(order.id, symbol);
  const remainder = truncateQty(exchange, symbol, qty - (final.filled ?? 0));
  if (remainder > 0) await exchange.createOrder(symbol, "market", "buy", remainder);
}
The cancelOrderSafe wrapper handles the fill-vs-cancel race: if the cancel throws for any reason, it re-reads the order status; if the order is "closed", it returns "filled" — that is not an error, it is a fill at the last moment.
2

commit_trade — Atomic Brackets

Places TP and stop-limit SL in a single OCO order. One call, one freeze, both levels armed atomically.
await (exchange as any).privatePostOrderOco({
  symbol: market.id,
  side: "SELL",
  quantity: exchange.amountToPrecision(symbol, qty),
  price: exchange.priceToPrecision(symbol, tpPrice),         // TP limit
  stopPrice: exchange.priceToPrecision(symbol, slPrice),     // SL trigger
  stopLimitPrice: exchange.priceToPrecision(
    symbol,
    slPrice * STOP_LIMIT_SLIPPAGE
  ),
  stopLimitTimeInForce: "GTC",
});
The bracket quantity is taken from fetchFreeQty at the moment of placement — not from the original order qty — so partial fills during the limit+market entry phase are handled correctly. If the OCO itself fails, unwindPosition runs: cancel-sweep → verify clean book → market-sell free balance, then re-throws the original error typed.
3

commit_cancel — Verified Close

Cancels every open order for the symbol with up to CANCEL_ROUNDS retries, then verifies the book is empty before selling. Selling over a live sell order is InsufficientFunds — the verification loop is not optional.After the book is confirmed clean, the adapter sells the entire free balance of the base coin — not just the engine’s tracked position size. This sweeps any orphan tranches from partial fills or leftover dust alongside the main close. If the remaining quantity is below minNotional, the close is confirmed as dust and returns without placing an order.

Idempotent Recovery via clientOrderId = signalId

Every entry order is placed with clientOrderId set to the engine’s signalId. This makes recovery unconditional — not just on explicit retries — because engine revalidation can deliver the same signalId with attempt = 0.
const prior = await fetchEntryByClientId(exchange, symbol, signalId);
if (prior && prior.executedQty > 0) {
  const totalQty = await fetchTotalQty(exchange, symbol);  // free + used!
  if (totalQty * openPrice >= minNotional) {
    if (!(await exchange.fetchOpenOrders(symbol)).length) await confirmWithBrackets();
    return;  // entry confirmed — do NOT buy again
  }
}

New signal ID

fetchEntryByClientId gets -2013 from Binance (Order does not exist), returns null, and the adapter proceeds to place the entry normally. The check costs one API call.

Known signal ID, filled

executedQty > 0 — the previous POST executed but the process crashed before brackets were placed. The adapter checks fetchTotalQty (free + used — checking only free would show ≈ 0 while coins are frozen in an existing OCO), and places brackets if no open orders exist.

Known signal ID, still live

Status is NEW or PARTIALLY_FILLED — a prior attempt’s order is on the book. The adapter cancels it first (freeing the clientOrderId so the next POST won’t get -2010), waits CANCEL_SETTLE_MS, then re-enters.

Known signal ID, filled at cancel flag

cancelOrderSafe returned "filled" — the order filled in the race window between the last poll and the cancel call. Brackets are placed immediately and the open returns success.
The fetchTotalQty distinction matters precisely because of OCO bracket behavior. After a successful entry, the coins are frozen inside the OCO and free ≈ 0. An adapter that checks only free would see an empty balance, conclude no position exists, and buy again — doubling the position. This was the root cause of the incident that prompted the post-mortem.

Tuning Constants

All timing and price-shaping constants are defined at the top of modules/live.module.ts. They are not configuration — they are hardcoded after post-mortem calibration.
ConstantValuePurpose
FILL_POLL_INTERVAL_MS × FILL_POLL_ATTEMPTS10 s × 10Limit entry gets ~100 s to fill before the market top-up fires
CANCEL_SETTLE_MS2 sExchange settle time after cancel before re-reading the filled qty
CANCEL_ROUNDS10Cancel-sweep retries when clearing the book on close
STOP_LIMIT_SLIPPAGE0.995Stop-limit price parked just below the SL trigger to guarantee execution
TRADE_SELL_LOWER_PERCENT0.999Close limit priced a hair under market to fill fast without crossing the spread
The FILL_POLL_INTERVAL_MS × FILL_POLL_ATTEMPTS product (~100 s) is intentionally generous. On a quiet market the limit fill usually arrives in the first few polls; the full window only opens for illiquid symbols or fast-moving entries where the price has already moved past the limit. The market top-up covers only the remainder — whatever wasn’t filled by the limit — so slippage is bounded by the unfilled fraction, not the whole order.

Spot Sizing

Entry cost is capped at min(signalCost, freeUSDT × 0.98) — a 2 % buffer for fees and lot-size rounding. If the effective cost falls below the symbol’s minNotional, the adapter throws OrderRejectedError immediately rather than spamming retry attempts every tick:
const freeUsdt = parseFloat(String((await exchange.fetchBalance())?.free?.["USDT"] ?? 0));
const effectiveCost = Math.min(cost, freeUsdt * 0.98);
if (effectiveCost < minNotional) {
  throw new OrderRejectedError(
    `SpotBrokerAdapter: free USDT ${freeUsdt.toFixed(2)} → cost ${effectiveCost.toFixed(2)} < minNotional ${minNotional} (${symbol}) — вход пропущен`
  );
}
This is a permanent rejection (OrderRejectedError) — the engine will not retry it. Insufficient funds is not a transient condition.

Build docs developers (and LLMs) love