Skip to content

Docs

From signal to broker: live execution at IBKR

The relay hands you the target book; your code owns execution. The relay is infrastructure, not execution — it never routes orders and never holds broker credentials — so the last mile is a small Python process you run yourself: subscribe with the SDK, diff each signal against your live Interactive Brokers account, and place the residual orders. This page builds that process line by line — about eighty lines end to end. New to the portfolio_rebalance/1.1 contract itself? Start with the Python walkthrough.

1 · Setup and dependencies

Two packages, one from each side of the boundary: multiedge-relay for the signal transport and ib_async for your broker connection. Python 3.11+, plus an IB Gateway or TWS session of your own with API access switched on.

shell
$ uv add multiedge-relay ib_async   # or: pip install multiedge-relay ib_async
$ export MULTIEDGE_API_KEY="mesk_..."   # subscriber-scoped key from the portal
# Plus a running IB Gateway or TWS of YOUR OWN, with API connections
# enabled (Configure -> Settings -> API -> Enable ActiveX and Socket
# Clients). Your broker credentials stay in that session — they never
# touch the relay or this SDK.
  • ib_async is the actively maintained successor to the archived ib_insync — Interactive Brokers' own third-party-API docs point migrants to it.
  • Your broker credentials live in Gateway/TWS on your machine and nowhere else. Nothing broker-related ever touches the relay — that separation is the product, not an accident.
  • Integrate against the sandbox and a paper account first — same contract, synthetic data, zero consequences.

2 · The architecture in three sentences

The relay delivers a sequenced, replayable, at-least-once stream of complete target books — one signal per date, resumable from a durable cursor after any outage. Your process subscribes, computes the difference between each book and your live IBKR account, and places only the residual orders. The boundary is absolute: the relay is infrastructure, not execution — everything below this line is your code, your account, your responsibility.

3 · Connect to IB Gateway or TWS

ib_async talks to a locally running Gateway or TWS over a socket. The port selects the account class, which makes paper-versus-live an explicit, greppable choice.

connect_ib.py
from ib_async import IB

# Your broker session's API ports — paper first, ALWAYS:
#   TWS         7497 (paper)   7496 (live)
#   IB Gateway  4002 (paper)   4001 (live)
# Going live later is a one-character change: the port.

ib = IB()
ib.connect("127.0.0.1", 4002, clientId=17)   # paper Gateway; clientId unique per script
print(ib.isConnected())
ib.disconnect()
  • TWS listens on 7497 (paper) / 7496 (live); IB Gateway on 4002 (paper) / 4001 (live). Gateway is the headless choice for an always-on consumer.
  • Pick a stable clientId unique to this script — IB allows one connection per id, so a colliding id silently bumps the other session.

4 · Subscribe with exactly-once processing

Delivery is at-least-once end to end, and the cursor commits only after your handler returns — a crash mid-execution means redelivery, never a silently skipped signal. SqliteStateStore adds the dedup half: a signal_id the handler completed is never run twice.

subscribe_execute.py
import os

from multiedge_relay import SignalSubscriber, SqliteStateStore

store = SqliteStateStore()   # one local SQLite file: cursor + dedup together

subscriber = SignalSubscriber(
    api_key=os.environ["MULTIEDGE_API_KEY"],
    strategy_id="str_the_feed_you_follow",
    # exactly_once() never re-runs a signal_id the handler completed. But
    # order placement is an EXTERNAL side effect: a crash in the instant
    # between handler return and commit re-runs it once. That is why the
    # handler trades to TARGETS (idempotent — re-running computes zero
    # deltas), never replays raw orders.
    on_signal=store.exactly_once(execute_rebalance),
    cursor_store=store,
    on_error=lambda exc: log.warning("relay transport: %s", exc),
)
subscriber.run()   # blocks: catch-up in sequence order, then live polling
  • Be honest about the limit: placing orders is an external side effect, so a crash in the instant between handler return and the dedup commit re-runs the handler once. That residual window is why the handler trades to targets — the next section makes re-running it a no-op instead of a double-trade.
  • No explicit acking exists, by design: returning normally is the ack; raising is the retry request.

5 · Rebalance-to-target: weights into orders

Never replay the signal's verbs as orders — diff its weights against the live account. Fetch net liquidation value and current positions from IB, compute the share delta per ticker, and skip anything inside the rebalance band.

rebalance_to_target.py
from ib_async import IB, Stock

MIN_ORDER_NOTIONAL = 200.0   # rebalance band: skipping dust beats churn

def compute_orders(ib: IB, targets: dict[str, float]) -> list[tuple[Stock, int]]:
    """Share deltas that move the LIVE account onto the signal's weights."""
    net_liq = float(next(
        v.value for v in ib.accountSummary() if v.tag == "NetLiquidation"
    ))
    held = {p.contract.symbol: int(p.position) for p in ib.positions()}

    ib.reqMarketDataType(3)   # delayed quotes size a daily rebalance fine
    tickers = sorted(set(held) | set(targets))
    contracts = {t: Stock(t, "SMART", "USD") for t in tickers}
    ib.qualifyContracts(*contracts.values())

    orders: list[tuple[Stock, int]] = []
    for ticker in tickers:
        price = ib.reqTickers(contracts[ticker])[0].marketPrice()
        if not price or price != price:   # 0 or NaN: no sane quote, no trade
            raise RuntimeError(f"{ticker}: no sane price — refusing to size")
        # Absent from a non-empty book => weight 0 => LIQUIDATION. HOLD
        # lines carry their unchanged weight => delta ~0 => no order.
        desired = round(net_liq * targets.get(ticker, 0.0) / price)
        delta = desired - held.get(ticker, 0)
        if abs(delta) * price < MIN_ORDER_NOTIONAL:
            continue
        orders.append((contracts[ticker], delta))
    # Idempotent by construction: re-running against an already-rebalanced
    # account computes zero deltas and places nothing.
    return orders
  • targets.get(ticker, 0.0) is the contract's omission rule: a held ticker absent from a non-empty book has target weight 0 and gets liquidated — deliberately, as one visible line, never as an accident.
  • HOLD lines carry their unchanged weight, so they produce a near-zero delta and no order. Trade the weights; the verbs are annotation.
  • This is what absorbs at-least-once delivery: re-running against an already-rebalanced account computes zero deltas and places nothing. Idempotency comes from the algorithm, not from bookkeeping.

6 · The handler: contract semantics at the broker

The same four-step order as the walkthrough's apply step, now with real broker calls: heartbeat short-circuit, the per-date supersede rule, target construction, then qualified contracts and market orders.

handle_signal.py
from ib_async import IB, MarketOrder
from multiedge_relay import ReceivedSignal, SignalMeta

def execute_rebalance(signal: ReceivedSignal, meta: SignalMeta) -> None:
    """One complete portfolio in — orders at YOUR broker out."""
    payload = signal.payload

    # 1. Heartbeat: an explicit no-action day. Return before any trading
    #    logic — it never trades and never liquidates.
    if not payload["positions"]:
        return

    # 2. Corrections: the HIGHEST sequence per signal_date wins; a stale
    #    revision is dropped in one comparison.
    if applied_sequence_for(payload["signal_date"]) >= signal.sequence:
        return

    # 3. The weights are the instruction; the verbs annotate it.
    targets = {
        p["ticker"]: p["signal_portfolio_weight"] for p in payload["positions"]
    }

    # 4. Connect lazily. subscriber.run() owns this thread, and a
    #    persistently connected IB() starves without its event loop being
    #    pumped — at daily cadence, connect -> trade -> disconnect is both
    #    simpler and safer.
    ib = IB()
    ib.connect("127.0.0.1", 4002, clientId=17)   # paper
    try:
        for contract, delta in compute_orders(ib, targets):
            side = "BUY" if delta > 0 else "SELL"
            trade = ib.placeOrder(contract, MarketOrder(side, abs(delta)))
            while not trade.isDone():
                ib.sleep(0.25)   # pumps ib_async's event loop while waiting
            if trade.orderStatus.status in ("Cancelled", "Inactive"):
                raise RuntimeError(f"{contract.symbol}: {side} {abs(delta)} rejected")
        mark_applied(payload["signal_date"], signal.sequence)
    finally:
        ib.disconnect()
  • Step 1 first, always: an empty positions list means "no action", never "liquidate everything". Returning before any trading logic makes that structurally impossible to get wrong.
  • Connect lazily, inside the handler. subscriber.run() owns the thread, and a persistently connected IB() starves when its event loop isn't pumped; at daily cadence, connect → trade → disconnect is simpler and safer.
  • ib.sleep() in the wait loop is not a plain sleep — it pumps ib_async's event loop so order status updates actually arrive.

7 · Error handling on both legs

Transport faults and broker faults want opposite treatment: the SDK already retries the former with jittered backoff, while the latter should stop the handler loudly and lean on redelivery.

errors.py
# Two legs, two failure domains — keep them separate.

# RELAY LEG: transient transport errors go to on_error while the SDK
# retries with jittered backoff. You log; you fix nothing.
def log_transport_error(exc: Exception) -> None:
    log.warning("relay transport: %s", exc)

# BROKER LEG: raising OUT of the handler is the safety mechanism. The
# cursor commits only after the handler returns, so an exception means
# the signal is REDELIVERED once the fault is fixed — never skipped, and
# never marked done half-executed. Redelivery is safe because the next
# run recomputes deltas from the live account: only the residual trades.
def execute_rebalance(signal, meta):
    ib = IB()
    ib.connect("127.0.0.1", 4002, clientId=17)
    if not ib.isConnected():
        raise RuntimeError("IB Gateway down — leaving the signal uncommitted")
    ...
    # A reject is a loud stop, not a shrug: reconcile before the signal
    # can ever be marked done.
    if trade.orderStatus.status in ("Cancelled", "Inactive"):
        raise RuntimeError(f"rejected: {trade.log[-1].message}")
  • Raising out of the handler is the safety mechanism, not a failure: the cursor never commits, the signal redelivers after the fault is fixed, and rebalance-to-target ensures the retry places only what is still missing.
  • Never swallow a rejected order and let the signal be marked done — a book that silently diverges from the signal is the worst failure mode this page exists to prevent.

8 · Guardrails and paper-trading-first

Hard guardrails, enforced in code before any order leaves the process — not soft suggestions in a runbook.

guardrails.py
from pathlib import Path

MAX_ORDER_NOTIONAL = 50_000.0        # hard per-order cap — size it to YOUR book
KILL_SWITCH = Path("~/.trading/HALT").expanduser()

def guard_order(ticker: str, delta: int, price: float) -> None:
    """Hard guardrails, checked BEFORE any order leaves the process."""
    if KILL_SWITCH.exists():
        # touch ~/.trading/HALT from any shell to stop all trading NOW;
        # the uncommitted signal redelivers after the file is removed.
        raise RuntimeError("kill switch present — no orders")
    if abs(delta) * price > MAX_ORDER_NOTIONAL:
        raise RuntimeError(f"{ticker}: notional cap breached — refusing")

# And the cheapest guardrail of all: stay on the paper port (4002) until
# a full week of signals — including a heartbeat and a correction — has
# executed cleanly end to end.
  • The kill switch is a file so that stopping trading requires no deploy, no dashboard, and no working Python — just touch from any shell. Because the guard raises, the halted signal stays uncommitted and redelivers when you remove the file.
  • Run against the paper port until a full week of signals — including a heartbeat and a correction — has executed cleanly. Alert on every reject and every kill-switch trip.

9 · Putting it together

The whole execution layer in one file: subscribe, dedup, diff, guard, trade. This is everything — there is no hidden framework behind it.

run_execution.py
"""Relay signal in, IBKR orders out — your own execution layer, complete.

The relay delivers the target book; this process owns execution. Paper
account first (Gateway port 4002); the only change for live is the port.
"""
import logging
import os
from pathlib import Path

from ib_async import IB, MarketOrder, Stock
from multiedge_relay import (
    ReceivedSignal,
    SignalMeta,
    SignalSubscriber,
    SqliteStateStore,
)

log = logging.getLogger("execution")

IB_HOST, IB_PORT, IB_CLIENT_ID = "127.0.0.1", 4002, 17   # 4002 = Gateway paper
MIN_ORDER_NOTIONAL = 200.0
MAX_ORDER_NOTIONAL = 50_000.0
KILL_SWITCH = Path("~/.trading/HALT").expanduser()

# signal_date -> highest applied sequence. In-process is enough for the
# supersede rule at runtime (exactly_once already blocks replays across
# restarts); persist it next to your book when you outgrow one process.
applied: dict[str, int] = {}


def compute_orders(ib: IB, targets: dict[str, float]) -> list[tuple[Stock, int]]:
    """Share deltas that move the live account onto the signal's weights."""
    net_liq = float(next(
        v.value for v in ib.accountSummary() if v.tag == "NetLiquidation"
    ))
    held = {p.contract.symbol: int(p.position) for p in ib.positions()}
    ib.reqMarketDataType(3)   # delayed quotes size a daily rebalance fine
    tickers = sorted(set(held) | set(targets))
    contracts = {t: Stock(t, "SMART", "USD") for t in tickers}
    ib.qualifyContracts(*contracts.values())
    orders: list[tuple[Stock, int]] = []
    for ticker in tickers:
        price = ib.reqTickers(contracts[ticker])[0].marketPrice()
        if not price or price != price:
            raise RuntimeError(f"{ticker}: no sane price — refusing to size")
        desired = round(net_liq * targets.get(ticker, 0.0) / price)  # absent => liquidate
        delta = desired - held.get(ticker, 0)
        if abs(delta) * price < MIN_ORDER_NOTIONAL:
            continue   # HOLD lines and dust land here: no order
        if abs(delta) * price > MAX_ORDER_NOTIONAL:
            raise RuntimeError(f"{ticker}: notional cap breached — refusing")
        orders.append((contracts[ticker], delta))
    return orders   # idempotent: an already-rebalanced book yields []


def execute_rebalance(signal: ReceivedSignal, meta: SignalMeta) -> None:
    payload = signal.payload
    if not payload["positions"]:
        return   # heartbeat: no action, nothing liquidated
    date = payload["signal_date"]
    if applied.get(date, -1) >= signal.sequence:
        return   # a later correction for this date already applied
    if KILL_SWITCH.exists():
        raise RuntimeError("kill switch present — leaving the signal uncommitted")
    targets = {
        p["ticker"]: p["signal_portfolio_weight"] for p in payload["positions"]
    }
    ib = IB()
    ib.connect(IB_HOST, IB_PORT, clientId=IB_CLIENT_ID)
    try:
        for contract, delta in compute_orders(ib, targets):
            side = "BUY" if delta > 0 else "SELL"
            trade = ib.placeOrder(contract, MarketOrder(side, abs(delta)))
            while not trade.isDone():
                ib.sleep(0.25)
            if trade.orderStatus.status in ("Cancelled", "Inactive"):
                raise RuntimeError(f"{contract.symbol}: {side} {abs(delta)} rejected")
            log.info("%s %s %d filled", contract.symbol, side, abs(delta))
        applied[date] = signal.sequence
    finally:
        ib.disconnect()


def main() -> None:
    logging.basicConfig(level=logging.INFO)
    store = SqliteStateStore()
    subscriber = SignalSubscriber(
        api_key=os.environ["MULTIEDGE_API_KEY"],
        strategy_id="str_the_feed_you_follow",
        on_signal=store.exactly_once(execute_rebalance),
        cursor_store=store,
        on_error=lambda exc: log.warning("relay transport: %s", exc),
    )
    try:
        subscriber.run()
    except KeyboardInterrupt:
        subscriber.stop()
    finally:
        subscriber.close()
        store.close()


if __name__ == "__main__":
    main()
  • Going live is two deliberate edits: the port (4002 → 4001) and the notional caps — everything else is already production shape.

Where next

The contract in depth in the Python walkthrough, the full REST surface in the API reference, and a free evaluation tenant in the sandbox.

Try it in the sandbox