import {
Broker,
BrokerBase,
BrokerOrderOpenPayload,
BrokerOrderClosePayload,
OrderRejectedError,
} from "backtest-kit";
import { memoize, sleep } from "functools-kit";
import Binance from "node-binance-api";
const FILL_POLL_ATTEMPTS = 10; // 10 checks ...
const FILL_POLL_INTERVAL_MS = 10_000; // ... every 10 seconds = up to ~100s waiting for the fill
const LAST_OPEN_ATTEMPT = 4; // the fifth try under CC_ORDER_OPEN_RETRY_ATTEMPTS = 5
const CANCEL_ROUNDS = 10; // cancel sweeps while flattening the symbol on close
const TRADE_SELL_LOWER_PERCENT = 0.999; // exit limit price slightly below market — fills faster
const roundTicks = (value: number, tickSize: string) => {
const precision = Math.max(tickSize.replace(/0+$/, "").indexOf("1") - 1, 0);
return Number(value).toFixed(precision);
};
const getExchangeInfo = memoize(
([symbol, filterType]) => `${symbol}-${filterType}`,
async (symbol: string, filterType = "LOT_SIZE", binance: Binance) => {
const exchangeInfo = await binance.exchangeInfo();
const filters = Object.values(exchangeInfo.symbols)
.map(({ symbol, filters }) => [
symbol,
filters.find((f: any) => f.filterType === filterType),
])
.reduce<any>((acm, [k, v]) => ({ ...acm, [k]: v }), {});
const { stepSize, tickSize, minQty } = filters[symbol];
return { stepSize, tickSize, minQty };
}
);
const formatQuantity = async (symbol: string, quantity: number, binance: Binance) => {
const { stepSize } = await getExchangeInfo(symbol, "LOT_SIZE", binance);
return roundTicks(quantity, stepSize);
};
const formatPrice = async (symbol: string, price: number, binance: Binance) => {
const { tickSize } = await getExchangeInfo(symbol, "PRICE_FILTER", binance);
return roundTicks(price, tickSize);
};
const getCoinName = (symbol: string) => symbol.replace(/USDT$/, "");
const percentValue = (value: number, percent: number) => (value * percent) / 100;
class SpotBroker extends BrokerBase {
override async waitForInit() {
await getBinance(); // your singleshot instance; the recommended place for an orphan sweep by clientOrderId
}
override async onOrderOpenCommit(payload: BrokerOrderOpenPayload) {
if (payload.backtest) return; // never touch the exchange in backtest mode
if (payload.type !== "active") return; // "schedule" (resting-order placement) is a separate branch
const { symbol, signalId, attempt, cost, priceOpen } = payload;
const binance = await getBinance();
// Rule 2: attempt > 0 — the previous attempt may have reached the exchange.
// Reconcile by clientOrderId BEFORE posting again: an order filled behind a
// lost response resolves to "already bought", a stale one gets cancelled.
if (attempt > 0) {
const prior = await binance
.orderStatus(symbol, undefined, undefined, { origClientOrderId: signalId })
.catch(() => null);
if (prior?.status === "FILLED") {
return; // the position was already opened by the previous attempt — confirm without re-sending
}
if (prior && (prior.status === "NEW" || prior.status === "PARTIALLY_FILLED")) {
await binance.cancel(symbol, prior.orderId); // kill the stale remainder
}
}
const quantity = await formatQuantity(symbol, cost / priceOpen, binance);
const price = await formatPrice(symbol, priceOpen, binance);
// clientOrderId = signalId — the idempotency anchor for the reconcile above
const order = await binance.order("LIMIT", "BUY", symbol, Number(quantity), Number(price), {
newClientOrderId: signalId,
});
if (order.status === "FILLED") {
return; // confirmed — the engine opens the position
}
// Wait for the order to close: await + sleep in a loop
let last = order;
for (let i = 0; i !== FILL_POLL_ATTEMPTS; i++) {
await sleep(FILL_POLL_INTERVAL_MS);
last = await binance.orderStatus(symbol, order.orderId);
if (last.status === "FILLED") {
return; // fill arrived — confirm
}
}
// Rule 1: timeout — the order must NOT stay alive on the exchange.
// Cancel and roll back the partial fill so the state is clean before the retry.
await binance.cancel(symbol, order.orderId);
const executedQty = Number(last?.executedQty ?? 0);
if (executedQty > 0) {
const sellQty = await formatQuantity(symbol, executedQty, binance);
await binance.marketSell(symbol, Number(sellQty));
}
// Rule 3: the fifth attempt is a terminal rejection. The engine consumes
// OrderRejectedError into lastPendingId: this signal id is never re-issued.
if (attempt >= LAST_OPEN_ATTEMPT) {
throw new OrderRejectedError(
`entry ${signalId} not filled after ${attempt + 1} attempts — giving up`
);
}
// Transient: the engine retries on the next tick with the SAME signalId,
// attempt arrives incremented — the reconcile at the top kicks in.
throw new Error(`Limit order [buy ${quantity} ${symbol} @ ${price}] not filled — backtest-kit will retry`);
}
// Closing a position = "drop everything on the symbol and exit to cash":
// cancel ALL open orders (TP/SL/stale limit orders — including artifacts of
// previous attempts) and sell the ENTIRE free coin balance to USDT — not just
// the engine's position size, so orphan tranches bought outside the engine
// are swept by the same exit. Any throw from here is transient: the engine
// keeps the position open and retries the close on the next tick with
// attempt+1; on exhausting CC_ORDER_CLOSE_RETRY_ATTEMPTS it force-closes its
// own state (fatal exit AFTER the durable teardown — 16.5.x fix).
override async onOrderCloseCommit(payload: BrokerOrderClosePayload) {
if (payload.backtest) return; // never touch the exchange in backtest mode
const { symbol, currentPrice } = payload;
const binance = await getBinance();
const coinName = getCoinName(symbol);
// Step 1: cancel every open order on the symbol (up to CANCEL_ROUNDS sweeps,
// individual cancel failures are tolerated — the sweep repeats)
{
let error;
for (let i = 0; i !== CANCEL_ROUNDS; i++) {
let isOk = true;
const orders = await binance.openOrders(symbol);
for (const order of orders) {
try {
await sleep(1_000);
await binance.cancel(symbol, order.orderId);
error = null;
} catch (e) {
isOk = false;
error = e;
continue;
}
}
if (!orders.length) {
error = null;
break;
}
if (isOk) {
break;
}
}
if (error) {
throw error; // transient — the engine retries the close on the next tick
}
}
// Step 2: verify not a single live order is left on the symbol
{
let error;
for (let i = 0; i !== CANCEL_ROUNDS; i++) {
try {
await sleep(1_000);
const { length: hasOrders } = await binance.openOrders(symbol);
if (hasOrders) {
error = new Error("Order not canceled");
} else {
error = null;
break;
}
} catch (e) {
error = e;
}
}
if (error) {
throw error;
}
}
// Step 3: exit to cash — sell the ENTIRE free coin balance
const account = await binance.account();
const coinBalance = account.balances.find(({ asset }) => asset === coinName);
if (!coinBalance) {
throw new Error(`Can't fetch balance (close) for ${coinName}`);
}
const freeQty = parseFloat(coinBalance.free);
const { minQty } = await getExchangeInfo(symbol, "LOT_SIZE", binance);
if (!minQty) {
throw new Error(`Can't fetch minimal quantity (close) for ${coinName}`);
}
const maker = account.makerCommission / 100;
const quantity = freeQty - percentValue(freeQty, maker) - Number(minQty);
if (quantity <= Number(minQty)) {
return; // dust — nothing to sell, confirm the close
}
const sellQty = await formatQuantity(symbol, quantity, binance);
const sellPrice = await formatPrice(
symbol,
currentPrice * TRADE_SELL_LOWER_PERCENT,
binance
);
const order = await binance.order("LIMIT", "SELL", symbol, Number(sellQty), Number(sellPrice));
if (order.status === "FILLED") {
return; // cashed out — the engine records the close
}
// Same wait loop (await + sleep) as on open
let last = order;
for (let i = 0; i !== FILL_POLL_ATTEMPTS; i++) {
await sleep(FILL_POLL_INTERVAL_MS);
last = await binance.orderStatus(symbol, order.orderId);
if (last.status === "FILLED") {
return;
}
}
// The limit order did not fill — cancel it and finish the remainder with a
// market sell: the cash exit is guaranteed, the position never stays hanging
await binance.cancel(symbol, order.orderId);
const restQty = await formatQuantity(
symbol,
Number(sellQty) - Number(last?.executedQty ?? 0),
binance
);
await binance.marketSell(symbol, Number(restQty));
}
// onOrderActiveCheck / onPartial*Commit and the remaining hooks —
// as in your current implementation (BrokerBase provides defaults for the rest)
}
Broker.useBrokerAdapter(SpotBroker);
Broker.enable();