Skip to main content

Documentation Index

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

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

The broker adapter bridges Backtest Kit’s signal engine to a live exchange (Binance, Bybit, or any CCXT-compatible venue). Without an adapter, backtest and live modes both use simulated fills; once you wire one in, every state-changing commit — opening a position, closing it, adjusting a trailing stop — calls your exchange implementation before any internal state mutation. If the exchange rejects the order, times out, or throws for any reason, the mutation is skipped and the framework retries on the next tick. This gives you transactional semantics without any extra bookkeeping.

Registration and activation

Register an adapter class with Broker.useBrokerAdapter(), then call Broker.enable() once at startup to subscribe the adapter to the syncSubject lifecycle stream. Signal-open and signal-close events are routed automatically; all other commit methods (partialProfit, trailingStop, breakeven, averageBuy) are called explicitly before the corresponding strategyCoreService mutation.
import { Broker } from "backtest-kit";

Broker.useBrokerAdapter(MyBrokerAdapter); // register
Broker.enable();                          // wire syncSubject
Broker.enable() must be called exactly once at startup, after useBrokerAdapter(). Calling it before registration throws immediately. Calling it more than once is safe — it is wrapped in a singleshot guard.

The IBroker interface

Your adapter class implements Partial<IBroker> — only override the methods you need. The BrokerBase class ships as a convenience base with default no-op implementations and logging for every method.
MethodCalled when
waitForInit()Once before first use — authenticate, load markets
onSignalOpenCommit(payload)New position activated (limit order filled)
onSignalCloseCommit(payload)Position fully closed (TP/SL or manual)
onPartialProfitCommit(payload)Partial close at profit executed
onPartialLossCommit(payload)Partial close at loss executed
onTrailingStopCommit(payload)Trailing stop-loss updated
onTrailingTakeCommit(payload)Trailing take-profit updated
onBreakevenCommit(payload)Stop-loss moved to entry (breakeven)
onAverageBuyCommit(payload)New DCA entry added to position

Key payload fields — BrokerSignalOpenPayload

symbol
string
required
Trading pair symbol, e.g. "BTCUSDT".
cost
number
required
Dollar cost of the entry (defaults to CC_POSITION_ENTRY_COST, usually $100).
priceOpen
number
required
Activation price — the price at which the signal became active.
priceTakeProfit
number
required
Original take-profit price from the signal.
priceStopLoss
number
required
Original stop-loss price from the signal.
position
"long" | "short"
required
Trade direction.
backtest
boolean
required
true during a backtest run — adapter should skip real exchange calls. The BrokerAdapter facade skips all commit methods automatically when backtest === true.

Transactional rollback

Every commit method fires before the DI-core state mutation. The execution contract is:
  1. Framework calls adapter.onXxxCommit(payload)
  2. If the method resolves → state mutation proceeds
  3. If the method throws (network error, order rejection, timeout) → mutation is skipped, no state change
  4. On the next tick, the framework retries the whole operation
This means your position state in backtest-kit always reflects what actually happened on the exchange — there is no divergence between the two. Poll-and-cancel logic (like the createLimitOrderAndWait helper in the examples below) handles partial fills by rolling them back before throwing, ensuring the exchange is always in a clean state for the retry.

Full implementation examples

The Spot adapter manages a Binance spot account. It uses stop_loss_limit orders with a small slippage buffer on the limit leg to avoid non-fills on gap-downs, and polls open orders after every cancel to avoid reading stale exchange state.
import ccxt from "ccxt";
import { singleshot, sleep } from "functools-kit";
import {
  Broker,
  IBroker,
  BrokerSignalOpenPayload,
  BrokerSignalClosePayload,
  BrokerPartialProfitPayload,
  BrokerPartialLossPayload,
  BrokerTrailingStopPayload,
  BrokerTrailingTakePayload,
  BrokerBreakevenPayload,
  BrokerAverageBuyPayload,
} from "backtest-kit";

const FILL_POLL_INTERVAL_MS = 10_000;
const FILL_POLL_ATTEMPTS = 10;
const CANCEL_SETTLE_MS = 2_000;
const STOP_LIMIT_SLIPPAGE = 0.995;

const getSpotExchange = singleshot(async () => {
  const exchange = new ccxt.binance({
    apiKey: process.env.BINANCE_API_KEY,
    secret: process.env.BINANCE_API_SECRET,
    options: { defaultType: "spot", adjustForTimeDifference: true, recvWindow: 60000 },
    enableRateLimit: true,
  });
  await exchange.loadMarkets();
  return exchange;
});

function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
  return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
}

async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
  const balance = await exchange.fetchBalance();
  const base = exchange.markets[symbol].base;
  return parseFloat(String(balance?.free?.[base] ?? 0));
}

async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
  await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
}

async function createStopLossOrder(exchange: ccxt.binance, symbol: string, qty: number, stopPrice: number): Promise<void> {
  const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
  await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
}

async function createLimitOrderAndWait(
  exchange: ccxt.binance,
  symbol: string,
  side: "buy" | "sell",
  qty: number,
  price: number,
  restore?: { tpPrice: number; slPrice: number }
): Promise<void> {
  const order = await exchange.createOrder(symbol, "limit", side, qty, price);
  for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
    await sleep(FILL_POLL_INTERVAL_MS);
    const status = await exchange.fetchOrder(order.id, symbol);
    if (status.status === "closed") return;
  }
  await exchange.cancelOrder(order.id, symbol);
  await sleep(CANCEL_SETTLE_MS);
  const final = await exchange.fetchOrder(order.id, symbol);
  const filledQty = final.filled ?? 0;
  if (filledQty > 0) {
    const rollbackSide = side === "buy" ? "sell" : "buy";
    await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
  }
  if (restore) {
    const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
    if (remainingQty > 0) {
      await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
      await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
    }
  }
  throw new Error(`Limit order ${order.id} not filled in time — rolled back, retrying`);
}

Broker.useBrokerAdapter(
  class implements IBroker {
    async waitForInit(): Promise<void> {
      await getSpotExchange();
    }

    async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
      const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
      if (position === "short") throw new Error("Spot does not support short selling");
      const exchange = await getSpotExchange();
      const qty = truncateQty(exchange, symbol, cost / priceOpen);
      if (qty <= 0) throw new Error(`Computed qty is zero — cost=${cost}, price=${priceOpen}`);
      const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
      const tpPrice   = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
      const slPrice   = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
      await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
      try {
        await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
        await createStopLossOrder(exchange, symbol, qty, slPrice);
      } catch (err) {
        await exchange.createOrder(symbol, "market", "sell", qty);
        throw err;
      }
    }

    async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
      const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
      const exchange = await getSpotExchange();
      const openOrders = await exchange.fetchOpenOrders(symbol);
      await cancelAllOrders(exchange, openOrders, symbol);
      await sleep(CANCEL_SETTLE_MS);
      const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
      if (qty === 0) return;
      const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
      const tpPrice    = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
      const slPrice    = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
      await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
    }

    async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
      const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
      const exchange = await getSpotExchange();
      const openOrders = await exchange.fetchOpenOrders(symbol);
      await cancelAllOrders(exchange, openOrders, symbol);
      await sleep(CANCEL_SETTLE_MS);
      const totalQty = await fetchFreeQty(exchange, symbol);
      if (totalQty === 0) throw new Error(`PartialProfit skipped: no open position for ${symbol}`);
      const qty          = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
      const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
      const closePrice   = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
      const tpPrice      = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
      const slPrice      = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
      await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
      if (remainingQty > 0) {
        try {
          await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
          await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
        } catch (err) {
          await exchange.createOrder(symbol, "market", "sell", remainingQty);
          throw err;
        }
      }
    }

    async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
      // identical pattern to onPartialProfitCommit — see source for full implementation
      const { symbol } = payload;
      const exchange = await getSpotExchange();
      const openOrders = await exchange.fetchOpenOrders(symbol);
      await cancelAllOrders(exchange, openOrders, symbol);
      await sleep(CANCEL_SETTLE_MS);
    }

    async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
      const { symbol, newStopLossPrice } = payload;
      const exchange = await getSpotExchange();
      const orders  = await exchange.fetchOpenOrders(symbol);
      const slOrder = orders.find((o) =>
        o.side === "sell" && ["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
      ) ?? null;
      if (slOrder) { await exchange.cancelOrder(slOrder.id, symbol); await sleep(CANCEL_SETTLE_MS); }
      const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
      if (qty === 0) throw new Error(`TrailingStop skipped: no open position for ${symbol}`);
      const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
      await createStopLossOrder(exchange, symbol, qty, slPrice);
    }

    async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
      const { symbol, newTakeProfitPrice } = payload;
      const exchange = await getSpotExchange();
      const orders  = await exchange.fetchOpenOrders(symbol);
      const tpOrder = orders.find((o) => o.side === "sell" && ["limit", "LIMIT"].includes(o.type ?? "")) ?? null;
      if (tpOrder) { await exchange.cancelOrder(tpOrder.id, symbol); await sleep(CANCEL_SETTLE_MS); }
      const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
      if (qty === 0) throw new Error(`TrailingTake skipped: no open position for ${symbol}`);
      await exchange.createOrder(symbol, "limit", "sell", qty, parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice)));
    }

    async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
      const { symbol, newStopLossPrice } = payload;
      const exchange = await getSpotExchange();
      const orders  = await exchange.fetchOpenOrders(symbol);
      const slOrder = orders.find((o) =>
        o.side === "sell" && ["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
      ) ?? null;
      if (slOrder) { await exchange.cancelOrder(slOrder.id, symbol); await sleep(CANCEL_SETTLE_MS); }
      const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
      if (qty === 0) throw new Error(`Breakeven skipped: no open position for ${symbol}`);
      await createStopLossOrder(exchange, symbol, qty, parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice)));
    }

    async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
      const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
      const exchange = await getSpotExchange();
      const openOrders = await exchange.fetchOpenOrders(symbol);
      await cancelAllOrders(exchange, openOrders, symbol);
      await sleep(CANCEL_SETTLE_MS);
      const existing    = await fetchFreeQty(exchange, symbol);
      const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
      if (existing * currentPrice < minNotional)
        throw new Error(`AverageBuy skipped: no open position for ${symbol}`);
      const qty = truncateQty(exchange, symbol, cost / currentPrice);
      if (qty <= 0) throw new Error(`Computed qty is zero — cost=${cost}, price=${currentPrice}`);
      const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
      const tpPrice    = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
      const slPrice    = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
      await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
      const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
      try {
        await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
        await createStopLossOrder(exchange, symbol, totalQty, slPrice);
      } catch (err) {
        await exchange.createOrder(symbol, "market", "sell", totalQty);
        throw err;
      }
    }
  }
);

Broker.enable();
Signal open/close events are routed automatically via the syncSubject subscription installed by Broker.enable(). All other operations (partialProfit, trailingStop, breakeven, averageBuy) are called explicitly before the corresponding state mutation — no manual wiring needed.

Build docs developers (and LLMs) love