Interface IBroker

Broker adapter interface for live order execution.

Implement this interface to connect the framework to a real exchange or broker. All methods are called BEFORE the corresponding DI-core state mutation, so if any method throws, the internal state remains unchanged (transaction semantics).

In backtest mode all calls are silently skipped by BrokerAdapter — the adapter never receives backtest traffic.

class MyBroker implements IBroker {
async waitForInit() {
await this.exchange.connect();
}
async onOrderOpenCommit(payload) {
await this.exchange.placeOrder({ symbol: payload.symbol, side: payload.position });
}
// ... other methods
}

Broker.useBrokerAdapter(MyBroker);
interface IBroker {
    onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void>;
    onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void>;
    onOrderActiveCheck(payload: BrokerOrderCheckPayload): Promise<void>;
    onOrderCloseCommit(payload: BrokerOrderClosePayload): Promise<void>;
    onOrderOpenCommit(payload: BrokerOrderOpenPayload): Promise<void>;
    onOrderScheduleCheck(payload: BrokerOrderCheckPayload): Promise<void>;
    onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void>;
    onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void>;
    onSignalActivePing(payload: BrokerActivePingPayload): Promise<void>;
    onSignalIdlePing(payload: BrokerIdlePingPayload): Promise<void>;
    onSignalPendingClose(payload: BrokerPendingClosePayload): Promise<void>;
    onSignalPendingOpen(payload: BrokerPendingOpenPayload): Promise<void>;
    onSignalScheduleCancelled(
        payload: BrokerScheduleCancelledPayload,
    ): Promise<void>;
    onSignalScheduleOpen(payload: BrokerScheduleOpenPayload): Promise<void>;
    onSignalSchedulePing(payload: BrokerSchedulePingPayload): Promise<void>;
    onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void>;
    onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void>;
    waitForInit(): Promise<void>;
}

Implemented by

Methods

  • Called on every live tick while a pending signal (open position) is monitored, BEFORE TP/SL/time evaluation (payload.type is always "active").

    Query the exchange by payload.signalId. Return normally to keep monitoring. THROW semantics (resolved into IBrokerOrderVerdict):

    • OrderDeletedError — the CONFIRMED "order not found by id": the framework closes the position with closeReason "closed" AT ONCE, bypassing the tolerance counter.
    • plain Error or OrderTransientError (timeout, 5xx, rate limit, disconnect) → "transient": the failed check is TOLERATED — the order is assumed still open, monitoring continues and the next ping carries payload.attempt incremented, up to CC_ORDER_CHECK_RETRY_ATTEMPTS CONSECUTIVE failures (a successful check resets the streak to 0). A connectivity blip no longer closes a live position on the spot. Exhaustion acts terminally (close "closed") and signals a fatal exit (exitEmitter). With the config at 0 any failure is terminal immediately (legacy).
    • OrderRejectedError here is a userspace protocol violation (it belongs to the open/close gates) and intentionally degrades to "transient".

    Manual wiring — EXCEPTION-BASED VARIANT

    This is the throw-driven alternative to the imperative commit-function wiring in onSignalActivePing:

    • Exception-based (here): THROW → framework closes the position with closeReason "closed". One binary gate, no reason distinction. Good when "order gone" is the only condition you handle.
    • Imperative (onSignalActivePing + src/function/strategy.ts): call commitClosePending / commitCreateTakeProfit / commitCreateStopLoss to close with the correct reason and handle TP vs SL vs no-counterparty separately.

    Pick ONE per condition — do not both throw here AND commitClosePending in the active-ping for the same "order gone" event.

    Parameters

    Returns Promise<void>

    async onOrderActiveCheck(payload: BrokerOrderCheckPayload) {
    let order: Order | null;
    try {
    order = await this.exchange.getOrderById(payload.signalId);
    } catch (networkError) {
    // transient — tolerated up to CC_ORDER_CHECK_RETRY_ATTEMPTS consecutive failures
    throw OrderTransientError.fromError(networkError);
    }
    if (!order) {
    throw new OrderDeletedError(`Order ${payload.signalId} not found`); // confirmed gone -> close "closed" at once
    }
    }
  • Called when a signal is being closed (take-profit, stop-loss, or manual close). Emitted via syncSubject BEFORE the framework mutates strategy state, so it is also the close gate.

    MANUAL WIRING — EXCEPTION-BASED: place the real exit order here (tag/look up by payload.signalId) and record final PnL. Return normally to let the close proceed. THROW semantics (resolved into IBrokerOrderVerdict):

    • plain Error or OrderTransientError ("the network/exchange failed temporarily") → "transient": the close is SKIPPED, the position stays open and the close retries on the next tick with payload.attempt incremented, up to CC_ORDER_CLOSE_RETRY_ATTEMPTS consecutive rejections. On exhaustion the engine FORCE-CLOSES its own state with the ORIGINAL closeReason and signals a fatal exit (exitEmitter) — the real exchange position must then be reconciled by the adapter/operator (the close lifecycle event still reaches onSignalPendingClose). With the config at 0 the cap is disabled and a rejected close retries forever (legacy).
    • OrderRejectedError ("no counterparty, retrying is pointless") → "rejected", TERMINAL: the engine force-closes immediately, bypassing the retry counter. No fatal exit (business outcome, not a network failure).
    • OrderDeletedError here is a userspace protocol violation (it belongs to the check hooks) and intentionally degrades to "transient". Backtest short-circuits this (no live exchange), so the gate is live-only.

    This differs from onSignalPendingClose, which is the informational lifecycle hook that fires AFTER the close is committed (and cannot veto it).

    Parameters

    Returns Promise<void>

  • Called when an order is being opened. Emitted via syncSubject BEFORE the framework mutates strategy state, so it is also the open gate. Discriminated by payload.type:

    • "active" — position entry (immediate open or activation fill of the resting order);
    • "schedule" — PLACEMENT of the resting entry order at scheduled-signal creation.

    MANUAL WIRING — EXCEPTION-BASED: place the real order here (tag the exchange order with clientOrderId = payload.signalId so later onOrderActiveCheck / onOrderScheduleCheck / onSignalActivePing can find it). Return normally to let the open proceed. THROW semantics (resolved into IBrokerOrderVerdict):

    • plain Error or OrderTransientError ("the network/exchange failed temporarily, outcome unknown") → "transient": the framework ROLLS BACK (type "active": pending returns to idle; type "schedule": scheduled not registered, risk reservation released) and retries IDENTITY-STABLY — the SAME signal row with the SAME signalId is re-submitted on the next tick with payload.attempt incremented, up to CC_ORDER_OPEN_RETRY_ATTEMPTS. The attempt is PRE-ARMED (persisted before this hook runs), so even a crash mid-attempt resumes with attempt >= 1. Because the id is stable, the adapter MUST reconcile at attempt > 0: query the prior order by clientOrderId BEFORE re-sending and confirm the open if it filled. Do NOT rely on catching a "duplicate" error on re-send — on Binance the duplicate-clientOrderId guard only covers OPEN orders, an instantly-filled market order will not dup. Exhaustion drops the signal and signals a fatal exit (exitEmitter). With the config at 0 the retry slot is disabled: the next tick regenerates a FRESH id.
    • OrderRejectedError ("the exchange definitively refused, retrying is pointless") → "rejected", TERMINAL: the open is dropped at once, no retry armed, an already-armed retry for this id is wiped. No fatal exit (business outcome).
    • OrderDeletedError here is a userspace protocol violation (it belongs to the check hooks) and intentionally degrades to "transient". Backtest short-circuits this, so the gate is live-only.

    This differs from onSignalPendingOpen, which is the informational lifecycle hook that fires AFTER the open is committed (and cannot veto it).

    Parameters

    Returns Promise<void>

  • Called on every live tick while a scheduled signal (resting entry order) is monitored, BEFORE timeout/price-activation evaluation (payload.type is always "schedule").

    Query the exchange by payload.signalId. Return normally to keep monitoring. THROW semantics (resolved into IBrokerOrderVerdict):

    • OrderDeletedError — the CONFIRMED "resting order not found by id": the framework cancels the scheduled signal with reason "user" AT ONCE, bypassing the tolerance counter. A FILLED resting order is NOT a deleted order — confirm the fill via commitActivateScheduled instead (a throw here is a terminal cancel, not an activation).
    • plain Error or OrderTransientError (timeout, 5xx, rate limit, disconnect) → "transient": the failed check is TOLERATED — the resting order is assumed still open, the next ping carries payload.attempt incremented, up to CC_ORDER_CHECK_RETRY_ATTEMPTS CONSECUTIVE failures (a successful check resets the streak). Exhaustion cancels the scheduled signal (reason "user") and signals a fatal exit (exitEmitter). With the config at 0 any failure is terminal immediately (legacy).
    • OrderRejectedError here is a userspace protocol violation (it belongs to the open/close gates) and intentionally degrades to "transient".

    Manual wiring — EXCEPTION-BASED VARIANT: the throw-driven alternative to the imperative commit-function wiring in onSignalSchedulePing (commitActivateScheduled / commitCancelScheduled). Pick ONE per condition.

    Parameters

    Returns Promise<void>

    async onOrderScheduleCheck(payload: BrokerOrderCheckPayload) {
    let order: Order | null;
    try {
    order = await this.exchange.getOrderById(payload.signalId);
    } catch (networkError) {
    // transient — tolerated up to CC_ORDER_CHECK_RETRY_ATTEMPTS consecutive failures
    throw OrderTransientError.fromError(networkError);
    }
    if (!order) {
    throw new OrderDeletedError(`Order ${payload.signalId} not found`); // confirmed gone -> cancel "user" at once
    }
    }
  • Called on every live tick while a pending (open) signal is monitored. Purely informational mirror of the active-ping lifecycle — a throw here does NOT close the position (unlike onOrderActiveCheck).

    Manual wiring — EVENT-BASED (driving an open position from real exchange state)

    Primary per-tick event-based hook for an open position (a throw does NOT close it — react to the event and decide imperatively). This is where you reconcile the framework's VWAP view with real fills: catch a SL that gapped through the level, or a TP that filled before VWAP reached it. Poll your real order and translate its state into strategy state via the commit-functions from src/function/strategy.ts (callable here because the ping is emitted inside the strategy tick; effects are deferred to the next tick):

    • commitCreateTakeProfit(symbol, { id }) — real TP order filled (possibly before VWAP reached the level) → force close, reason "take_profit".
    • commitCreateStopLoss(symbol, { id }) — real SL order filled (e.g. price gapped through SL) → force close, reason "stop_loss".
    • commitClosePending(symbol, { id }) — no counterparty (no buyer/seller, liquidity gap) → close now with reason "closed", instead of throwing.

    Parameters

    Returns Promise<void>

    import { commitCreateTakeProfit, commitCreateStopLoss, commitClosePending } from "backtest-kit";

    async onSignalActivePing(payload: BrokerActivePingPayload) {
    const order = await this.exchange.getOrderById(payload.signalId);
    if (order?.status === "filled" && order.kind === "take_profit") {
    await commitCreateTakeProfit(payload.symbol, { id: order.id });
    } else if (order?.status === "filled" && order.kind === "stop_loss") {
    await commitCreateStopLoss(payload.symbol, { id: order.id });
    } else if (order?.status === "no_counterparty") {
    await commitClosePending(payload.symbol, { id: order.id });
    }
    }
  • Called on every live tick while the strategy is idle (no pending or scheduled signal). Purely informational.

    MANUAL WIRING — EVENT-BASED: no signal is active, so there is nothing to commit; use it for idle heartbeats / housekeeping. A throw does not affect strategy state.

    Parameters

    Returns Promise<void>

  • Called when a pending position is closed (reason: take_profit / stop_loss / time_expired / closed).

    Manual wiring — EVENT-BASED (tearing down the position)

    Outbound side — the framework has already removed the pending signal, so there is nothing to commitClosePending here; instead flatten the real position and cancel leftover TP/SL orders by payload.signalId, and record final PnL. payload.closeReason says which path closed it. If you need to FORCE the close yourself (e.g. no counterparty), do it earlier in onSignalActivePing.

    Parameters

    Returns Promise<void>

    async onSignalPendingClose(payload: BrokerPendingClosePayload) {
    await this.exchange.flatten(payload.symbol);
    await this.exchange.cancelProtectiveOrders(payload.signalId);
    }
  • Called when a pending position is opened (new signal / immediate / scheduled or user activation). Purely informational lifecycle hook for the active phase of a signal.

    Manual wiring — EVENT-BASED (placing entry + protective orders)

    Fires ONCE at open — place the real entry confirmation and protective TP/SL orders (tag them with payload.signalId). Drive the rest per-tick from onSignalActivePing. This hook does not gate the position; for a true entry gate use onOrderSync (signal-open).

    Parameters

    Returns Promise<void>

  • Called when a scheduled signal is cancelled before it ever activated (reason: timeout / price_reject / user).

    Manual wiring — EVENT-BASED (tearing down the resting order)

    Outbound side — the framework has already dropped the scheduled signal, so there is nothing to commitCancelScheduled here; instead cancel the real resting order you placed in onSignalScheduleOpen (look it up by payload.signalId). payload.reason tells you why.

    Returns Promise<void>

    async onSignalScheduleCancelled(payload: BrokerScheduleCancelledPayload) {
    await this.exchange.cancelOrderById(payload.signalId);
    }
  • Called when a new scheduled signal is created and starts waiting for priceOpen activation. The scheduled -> active transition is reported via onOrderOpenCommit, not here.

    Manual wiring — EVENT-BASED (placing the resting order)

    Fires ONCE at creation — place the real resting/limit order (tag it with payload.signalId so onSignalSchedulePing can poll it later). If it resolves immediately, promote it with commitActivateScheduled(symbol, { id }); if rejected, drop it with commitCancelScheduled(symbol, { id }). Use onSignalSchedulePing for ongoing polling.

    Parameters

    Returns Promise<void>

    import { commitActivateScheduled, commitCancelScheduled } from "backtest-kit";

    async onSignalScheduleOpen(payload: BrokerScheduleOpenPayload) {
    const order = await this.exchange.placeLimitOrder({
    id: payload.signalId,
    symbol: payload.symbol,
    side: payload.position,
    price: payload.priceOpen,
    });
    if (order.status === "filled") await commitActivateScheduled(payload.symbol, { id: order.id });
    else if (order.status === "rejected") await commitCancelScheduled(payload.symbol, { id: order.id });
    }
  • Called on every live tick while a scheduled signal is monitored (waiting for priceOpen activation). Purely informational.

    Manual wiring — EVENT-BASED (driving the scheduled phase from real exchange state)

    Per-tick event-based hook (a throw does NOT veto anything — react and decide imperatively). Poll your real resting/limit order and translate it via the commit-functions from src/function/strategy.ts (deferred to the next tick):

    • commitActivateScheduled(symbol, { id }) — resting order filled/resolved → activate now, without waiting for VWAP to reach priceOpen (surfaces as onOrderOpenCommit next tick).
    • commitCancelScheduled(symbol, { id }) — resting order cancelled/rejected externally → drop it.

    Parameters

    Returns Promise<void>

    import { commitActivateScheduled, commitCancelScheduled } from "backtest-kit";

    async onSignalSchedulePing(payload: BrokerSchedulePingPayload) {
    const order = await this.exchange.getOrderById(payload.signalId);
    if (order?.status === "filled" || order?.status === "resolved") {
    await commitActivateScheduled(payload.symbol, { id: order.id });
    } else if (order?.status === "cancelled" || order?.status === "rejected") {
    await commitCancelScheduled(payload.symbol, { id: order.id });
    }
    }
  • Called once before first use. Connect to exchange, load credentials, etc.

    RECOMMENDED: run an ORPHAN SWEEP here. After a fatal exit (transient-budget exhaustion — see OrderTransientError) the exchange may hold artifacts the engine has already forgotten: a FILLED entry order under clientOrderId = signalId whose open was never confirmed (dropped signal), or a still-open position the engine force-closed on its side. waitForInit runs before the first event of a fresh process — the one moment to reconcile: list open orders/positions, match clientOrderIds against the engine state (getStrategyStatus / getPendingSignal), then either flatten the orphans on the exchange or re-adopt a live position via commitCreateSignal so it comes back under TP/SL management. Skipping the sweep risks trading a fresh signal ON TOP of an unmanaged orphan position.

    TIMING CAVEATS for the re-adoption branch (commitCreateSignal):

    • waitForInit is LAZY — it is awaited before the FIRST proxied hook call, not at enable(). If the strategy emits a signal on its very first tick, that first call is the open gate with the retry slot already PRE-ARMED, and commitCreateSignal throws "a rejected open is awaiting retry".
    • an idle tick does NOT mean the engine is empty: rejected opens (armed retry slot) and rejected user-close drains (position still live) also return idle. Always check getStrategyStatus first — adopt only when pendingSignalId, retryOpenSignal, closedSignal and createdSignal are all clear; otherwise restrict the sweep to flattening exchange-side orphans.

    Returns Promise<void>