2022-2026

Algorithmic Trading System

PythonTypeScriptFastAPIPostgreSQLZMQInteractive Brokers APIAsyncsDockerPandas
Architecture Diagram
Architecture Diagram

Abstract

This is a project I have been working on since university. The stock market has always seemed like this grand game to me, where thousands of people participate and the prize is something more than pride, more than glory–it's something everyone wants... money.

Infrastructure Breakdown

The code interfaces with Interactive Brokers using its Python API. The IBgateway acts as a network gateway between my home internet and IB's servers. All micro-services, including the gateway are hosted on a single PC in my basement. So everything in the architecture diagram is a logical separation until further compute is available. To manage the IB gateway, I use an open source tool called IBC (IB controller). It handles auto login and timeout recovery by interacting with the GUI of the gateway. It's not perfect, but it adds some robustness to our most critical failure point.

My services can be broadly categorized into Server-Worker pattern. Servers connect directly to the gateway and act as central handlers for market data and order management. These components include the master market data client, the master order client, and the stock screener service. The data is then multiplexed via a message queue to workers, which perform calculations and route orders back through the master order client.

This was the best approach due to how the IB API is designed and was decided based on the following constraints

  1. Orders are required to have a globally unique order ID, so it is better to have a single entity handle it.
  2. It is inefficient to request possibly duplicated market data from multiple clients.
  3. IB gateway limits the maximum number of concurrent client connections to 10.

Designing for Failure on a budget

In a professional software development setting, we are constantly thinking about how things could fail, both at a software level and a hardware level. We have failovers, graceful restarts, state restoration, and many other solutions. In a personal project setting, I believe we face diminishing returns on redundancy. How effective are Docker auto-rebuilds when everything is hosted on my 12-year-old PC? How useful is Grafana when my internet connection dies and I can no longer access my brokerage account? Therefore, instead of trying to prevent failure, I coded expecting it. The system is designed assuming my power could go out at any moment. This meant offloading as much calculation as possible to IB’s servers. For example, I would never open a position without a stop-loss order attached. From my perspective, even as system redundancies improve, I still need to ensure that my trading system can survive the worst-case scenario. To me, that means embracing failure rather than trying to prevent it.

System Visibility

It's critically important to see what your systems are doing. To view the logs and performance of containers, Grafana, Loki, and Prometheus seem to be the standard. At the start, I didn’t bother with complex monitoring. I only set up critical alerts that pinged me using Discord. While writing this post, I took the chance to set up Grafana, Loki, and Prometheus, and I have to admit it was much easier than I thought. It only took a couple of hours of back-and-forth with Claude to have everything up and running. Everything works out of the box with dockerized services, so all you have to do is compose up and forget. Tracking CPU usage, memory, and I/O really helped me understand how my services are performing and where the bottlenecks are.

Architecture Diagram
Services CPU Usage

IB Controller

Stock Screener

The purpose of the stock screener service is to find “in Play” stocks in real time using IB’s built in market scanner. I configure it with different parameters to find stocks that are experiencing volatility.

The IB scanner is a subscription service that provides callbacks every minute by default. To increase the polling rate, I continuously cancel and resubscribe. To determine if a stock is “in play” I consider the following criterion.

  • Currently trading between $0.5 - $25
  • Cumulative volume of at least 1 Million (measured from 4am)
  • Current volume 3 times above average (spiking volume)
  • Total float below 15M shares

The architecture of the scanner is fairly simple. We have a main thread and a callback thread. The main thread calls reqScannerSubscription() for each scan code and then waits on an event for the scanner callbacks to run. Once all the tickers have been saved to a dictionary, the main thread continues. It filters out stocks that do not meet the float requirement, along with duplicates and ETFs. We then request news and live market snapshots for those securities and push them to the PUB-SUB message queue.

scanner.py
def scannerData(self, reqId: int, rank: int, contractDetails: ContractDetails, ... ):
    contract = contractDetails.contract
    
    scan_appearance = ScanAppearance(
        scan_code=self.scanner_id_to_code[reqId],
        time_added=datetime.now(),
        time_exited=None            
    )

    with self.lock:
        if contract.symbol not in self.current_scan:
            self.current_scan[contract.symbol] = ScannedStock(
                contract=contract,
                scan_appearances=[scan_appearance]
            )
        else:
            self.current_scan[contract.symbol].scan_appearances.append(scan_appearance)
            
def scannerDataEnd(self, reqId: int):
    print("ScannerDataEnd for", self.scanner_id_to_code[reqId])
    # Current scan only adds things that are not in our watchlist
    print("current scan", self.current_scan.keys())
    
    # Wake up main thread to merge callback data
    with self.lock:
        self.scans_pending -= 1
        
        print("scan finished", self.scanner_id_to_code[reqId])
        if self.scans_pending == 0:
            print("all scans done setting event")
            self.scanner_done_event.set()
            self.scans_pending = len(self.my_scan_codes)
            self.scan_to_process = self.current_scan.copy()
            self.current_scan.clear()

Processing Level 1 Market Data in IB

Originally, I tried using reqMktData() for a live connection. It's the most obvious choice—just looking at the name—but at this point, I think it's more of a legacy service with several drawbacks. The design of reqMktData() is problematic: instead of receiving actual ticks, you are sampling and aggregating data every 250ms. That in itself is fine, because approximating order flow is sufficient for my strategies. The real problem lies in how the data is presented. Data is returned via two separate callbacks, TickPrice() and TickSize(). TickPrice() returns the bid, ask, and last traded price, while TickSize() returns the corresponding bid size, ask size, and last trade volume. The issue is that the order of these callbacks is not guaranteed. Therefore, it is entirely possible to incorrectly classify buy and sell orders, especially when trading the highly volatile securities I focus on.

Market Data Master

The market data master is the central client that requests live market data from IB’s servers. Strategy services subscribe to the market data master using a ZeroMQ ROUTER/DEALER socket and receive updates via a ZeroMQ PUB/SUB socket. When a client requests data from the market data master, it first receives a backlog of historical bars (1-minute, 5-minute, etc.) to populate any indicators or lookback windows. It then begins receiving "live" data (1-second bar updates). If any two clients call for the same ticker, the second receives the cached historical bars plus any delta from the time the first client joined to the current time. Finally, the client joins the message queue as a consumer of live data.

Purpose

The purpose of the Market Data Master is to multiplex market data lines in the case of multiple strategies requesting the same ticker. It also allows us to easily manage market data limitations, as we do not have to coordinate multiple independent clients connecting to IB.

market_master.py
def _advance_bar(self, state: LiveBarSub, pb_bar: msg.Bar, historical_call=False) -> None:
    with self.lock:
        if historical_call:
            self.bar_cache.setdefault(state.ticker, []).append(pb_bar)
            state.last_bar = pb_bar
            return

        if state.seeded and not historical_call:
            previous = state.last_bar
            if previous is not None and previous.timestamp != pb_bar.timestamp:
                self.bar_cache.setdefault(state.ticker, []).append(previous)
            state.last_bar = pb_bar
            return

        # using pending bars, update fired out of order
        if not state.seeded and not historical_call:
            previous = state.pending_last_bar
            if previous is not None and previous.timestamp != pb_bar.timestamp:
                state.pending_cache.append(previous)
            state.pending_last_bar = pb_bar
            return
        
def handle_subscribe(self, sender, command_id, sub_req : msg.SubscriptionReq):
    with self.lock:
        state = self.live_bar_subs.get(sub_req.ticker)

        if state is not None:
            state.subscribers.add(sender)
            joined_mid_seed = not state.seeded # joined during a historical backfill
            if joined_mid_seed:
                state.pending_senders.append((sender, command_id))
            else:
                self._send_cached_bars(sender, command_id, sub_req.ticker)

        else:
            # first subscriber
            req_id = self._next_req_id()
            state = LiveBarSub(req_id=req_id, ticker=sub_req.ticker, subscribers={sender})
            state.seeded = False
            self.live_bar_subs[sub_req.ticker] = state
            self.live_by_reqid[req_id] = state
            self.bar_cache[sub_req.ticker] = []

            self.seeding_acks[state.req_id] = (sender, command_id)
            contract = Contract()
            contract.symbol = sub_req.ticker 
            contract.secType = "STK"
            contract.exchange = "SMART"
            contract.currency = "USD"
            self.reqHistoricalData(state.req_id, contract, "", self._duration_since_cache_start(), "1 min", "TRADES", 0, 2, True, [])

def historicalData(self, reqId: int, bar: BarData):
    state = self.live_by_reqid.get(reqId)
    if state is None:
        return
    pb_bar = self._to_pb_bar(state.ticker, bar)
    self._advance_bar(state, pb_bar, historical_call=True)

def historicalDataEnd(self, reqId: int, start: str, end: str):
    state = self.live_by_reqid.get(reqId)
    if state is None:
        logger.error("HistoricalDataEnd ran for a ticker state that does not exist")
        return

    logger.info(f"Number of historical bars for {state.ticker} : {len(self.bar_cache[state.ticker])}")

    with self.lock:
        state.seeded = True
        if state.pending_cache:
            logger.warning(f"historicalDataUpdate fired before historicalDataEnd... reconciling live cache of length f{len(state.pending_cache)}")
            # merge caches TODO check no duplicates in live cache
            self.bar_cache[state.ticker] += state.pending_cache

            # Update the last bar with the one from live
            if state.pending_last_bar is None: 
                logger.error(f"{state.ticker} last pending bar is None, but pending cache is populated") 
            elif state.last_bar is None:
                logger.warning(f"{state.ticker} No historical data recevied")
            elif not (self._bar_ts_key(state.pending_last_bar) >= self._bar_ts_key(state.last_bar)):
                logger.error("live bar cache (fired out of order) is older than historical update")

            state.last_bar = state.pending_last_bar

    with self.lock:
        waiters = state.pending_senders
        state.pending_senders = []

    # Cache is now fully built -- send everyone waiting on it, in the order
    # they arrived: the original first subscriber, then each mid-seed joiner.
    logger.info(f"{state.ticker} Cache fully built")
    
    entry = self.seeding_acks.pop(reqId, None)
    if entry is not None:
        sender, command_id = entry
        self._send_cached_bars(sender, command_id, state.ticker)
    for sender, command_id in waiters:
        self._send_cached_bars(sender, command_id, state.ticker)

def historicalDataUpdate(self, reqId: int, bar: BarData):
    state = self.live_by_reqid.get(reqId)
    if state is None:
        logger.error("Historical Update recevied for a state that has not been created")
        return
    pb_bar = self._to_pb_bar(state.ticker, bar)

    self._advance_bar(state, pb_bar)
    self.pub_socket.send_multipart([f"BAR:{state.ticker}".encode(), pb_bar.SerializeToString()])

Code Breakdown

When a client sends a request, they provide their sender address, and command_id (the requested unique identifier). The command_id is used on the client side to acknowledge that the server has responded to outgoing requests. The sender address is set to a fixed string, which must be unique across strategies. This way if a client disconnects its state within the server object is preserved, it also helps with debugging messages.

The handle subscribe handles two cases.

  1. 1. The first subscriber to a ticker
  2. 2. Subsequent subscribers to a given ticker

If a given sender is the first to subscribe to a ticker then the Data Master will call reqHistoricaldata(). Create a publisher stream for that ticker, and then return the historical backfill. While handling multiple subscribers the Data Master must maintain the consistency of its historical data cache. The cache is populated by these three callbacks. historicalData(), historicalDataEnd(), historicalDataUpdate(). For the sake of correctness I assumed it would be possible for IB to send updates out of order. Meaning that while historicalData() is filling up the historical portion of our cache, historicalDataUpdate() can also fire with live bars. This leaves the cache in two possible states.

  1. 1. Seeded - historicalData() has run and is now synced with live data
  2. 2. Unseeded - historicalData() has not run, and has possible desync with live data

If historicalDataUpdate() fires before seeding is complete. It will be put into a pending_bar list. Which mirrors how the main cache itself is updated with new bars. Once historicalDataEnd() runs, if there are any pending bars, they will be merged into the main cache.

Market Proto

The proto Design is pretty straight forward in this case. We need an OHCL bar object and some req/resp machinery

market_master.py
message Bar {
  string ticker = 1;
  google.protobuf.Timestamp timestamp = 2;
  float open = 3;
  float close = 4;
  float high = 5;
  float low = 6;
  float volume = 7;
  int32 num_trades = 8;
}

message BarBatch {
  repeated Bar bars = 1;
  bool is_end = 2;
  string error = 3;
}

enum SubscriptionAction {
  SUBSCRIBE = 0;
  CANCEL = 1;
}

enum DataType {
  BAR = 0;
  TICK = 1;
}

message SubscriptionReq {
  string ticker = 1;
  SubscriptionAction action = 2;
  DataType data_type = 3;
}

Market Data Client

The purpose of the market data client is for strategies to be able to manage market data streams in a reusable way. It allows clients to:

  1. 1. Access the current live price
  2. 2. Store a record of previous prices
  3. 3. Access a livestream of Tick Data
  4. 4. Access a livestream of Tick Data
market_data_client.py
class LiveMarketDataClient:
    def __init__(self, strategy_id: str, logger: logging.Logger, pn: PushNotification, host=IB_HOST):
        self.strategy_id = strategy_id
        self.logger = logger
        self.pn = pn

        command_addr = f"tcp://{host}:{MARKET_DATA_SUBSCRIPTION_PORT}"
        pub_addr = f"tcp://{host}:{MARKET_DATA_BAR_PORT}"

        self.book = MarketDataBook()
        self._network = MarketDataNetworkThread(command_addr, pub_addr, self.book, logger, pn, strategy_id)
        self._network.start()

    def subscribe_bars(self, ticker: str, timeout_s: float = SUBSCRIBE_TIMEOUT_S) -> bool:
        """
        Subscribes to the live topic FIRST so live bars start accumulating
        in the book immediately, then requests backfill and merges it in.
        This ordering means there's no join-race window to lose a bar in --
        whatever backfill doesn't cover, the live feed already caught.
        Blocks until backfill fully arrives. Returns False on timeout or
        an error from the master.
        """
        topic = f"BAR:{ticker}".encode()
        self._network.subscribe_topic(topic)

        req = msg.SubscriptionReq(ticker=ticker, action=msg.SUBSCRIBE, data_type=msg.BAR)
        command_id, q = self._network.send_and_collect(req.SerializeToString())

        bars: List[Bar] = []
        while True:
            try:
                payload = q.get(timeout=timeout_s)
            except queue.Empty:
                self._network.drop_pending(command_id)
                self._network.unsubscribe_topic(topic)
                self.logger.error(f"Timed out waiting for backfill: {ticker}")
                self.pn.send_notif(f"@everyone MARKET DATA CLIENT TIMEOUT: backfill {ticker}")
                return False

            batch = msg.BarBatch()
            batch.ParseFromString(payload)
            if batch.error:
                self._network.drop_pending(command_id)
                self._network.unsubscribe_topic(topic)
                self.logger.error(f"Backfill failed for {ticker}: {batch.error}")
                return False

            bars.extend(_to_domain_bar(pb) for pb in batch.bars)
            if batch.is_end:
                self._network.drop_pending(command_id)
                break

        self.book.merge_backfill(ticker, bars)
        return True

The general interaction from the client side is as follows: The client sends a request → It gets backfilled 1 minute bars for the whole day up to this point → It gets a live connection to bar updates. The problem I ran into had to do with the synchronization of historical data and live data when multiple clients were involved. The server maintains its own historical cache, updated every minute, using its own output stream of live bars. Suppose a scenario where the client requests data at 9:01:59, the cache contains bars up to 9:01, the backfill stream takes 2 seconds to complete. So we end up missing the final update for the 9:02 bar on the client side. My workaround to this race condition was to invert the order. We establish the connection to live data first on the client side, and start tracking any updates, then I pull a backfill from the data masters cache. This guarantees that anything we missed will be caught in the backfill.

Market Data Thread

The market data thread handles reading data from ZMQ, and updating the Candle Store. The Market Data Thread is responsible for sending out requests,receiving and formatting replies and managing timeouts.

market_data_thread.py
# ---- thread body -- all zmq I/O happens here, nowhere else ----
def _run(self):    
    try:
        while not self._stop_event.is_set():
            socks = dict(poller.poll(POLL_INTERVAL_MS))
            if cmd_sock in socks:
                self._handle_command_reply(cmd_sock)
            if sub_sock in socks:
                self._handle_data(sub_sock)
            self._drain_outgoing(cmd_sock)
            self._drain_topic_actions(sub_sock)
    finally:
        cmd_sock.close()
        sub_sock.close()
        context.term()

def _drain_outgoing(self, cmd_sock: zmq.Socket):
    while True:
        try:
            cmd = self._outgoing.get_nowait()
        except queue.Empty:
            return
        cmd_sock.send_multipart([b"", cmd.command_id.encode(), cmd.payload])

def _drain_topic_actions(self, sub_sock: zmq.Socket):
    while True:
        try:
            action, topic = self._topic_actions.get_nowait()
        except queue.Empty:
            return
        opt = zmq.SUBSCRIBE if action == "SUB" else zmq.UNSUBSCRIBE
        sub_sock.setsockopt(opt, topic)

Candle Store

The market data book is the shared memory which holds all the local history and the current live bar for the client. The Market Data thread reads from the ZMQ sockets and writes the Data Book. Concurrently, the client can query the book to read bars and fill indicators. The Data book is in charge of resolving the consistency of the data, once historical data comes in, it will merge that with the live data, effectively syncing the client to the same timestep as the Data master.

candle_store.py
class MarketDataBook:
    def __init__(self):
        self._lock = threading.Lock()
        self._history: Dict[str, Deque[Bar]] = {}
        self._current: Dict[str, Bar] = {}
        self._indicators: Dict[str, List[Indicator]] = {}

    # ---- writers ----

    def apply_live_bar(self, ticker: str, bar: Bar) -> None:
        with self._lock:
            current = self._current.get(ticker)
            completed = None
            if current is not None and current.timestamp != bar.timestamp:
                completed = current
                self._history.setdefault(ticker, deque()).append(completed)
            self._current[ticker] = bar
            indicators = list(self._indicators.get(ticker, ()))

        if completed is not None:
            for indicator in indicators:
                indicator.update(completed)

    def merge_backfill(self, ticker: str, backfill_bars: List[Bar]) -> None:
        """Called once backfill arrives, whenever that is relative to the
        live feed. Only inserts backfill bars for periods not already
        observed live; never regresses `current` if live is further along."""
        if not backfill_bars:
            return
        with self._lock:
            history = self._history.setdefault(ticker, deque(maxlen=DEFAULT_HISTORY_MAXLEN))
            current = self._current.get(ticker)
            known = {b.timestamp for b in history} | ({current.timestamp} if current else set())

            *closed, last_bar = backfill_bars
            missing = [b for b in closed if b.timestamp not in known]

            if missing:
                merged = sorted(list(history) + missing, key=lambda b: b.timestamp)
                maxlen = history.maxlen
                history.clear()
                history.extend(merged[-maxlen:] if maxlen else merged)

            if current is None or last_bar.timestamp >= current.timestamp:
                self._current[ticker] = last_bar

Order Master

The order client is the central client which routes all orders to IB's servers. Using the message queue, independent strategies send their order requests to the order client, which tags each order with a unique ID, and sends it to IB.

Purpose

Reuse code, else we would have to implement order management for every strategy, as well as connect it to IB. Allows us to expand the number of strategies unbounded by the IB client connection limit.

order_master.py
...
while True:
    now = datetime.now().time()
    if now > time(20, 0): # 8:00 PM
        break

    try:
        sender, empty,  command_id, order_binary = order_socket.recv_multipart()
        order_msg = msg.TradeOrder()
        order_msg.ParseFromString(order_binary)

        logger.info(f"Message Received {order_msg.action} {order_msg.ticker}")

        # Send a single order
        if order_msg.classification == msg.OrderAction.NEW:
            client.send_order(order_msg,sender, command_id)

        elif order_msg.classification == msg.OrderAction.DELETE:
            client.cancel_existing_order(order_msg.order_id, sender, command_id)
            
        elif order_msg.classification == msg.OrderAction.MODIFY:
            client.send_order(order_msg,sender, command_id, modify=True)
        
        elif order_msg.classification == msg.OrderAction.GETID:
            start_id, end_id = client.get_order_id_slice(int(order_msg.qty))
            resp = msg.Ticket(
                order_id_start=start_id,
                order_id_end = end_id
            )
            order_socket.send_multipart([sender, b"", resp.SerializeToString()])

    except zmq.Again:
        # It just loops back up to check 'is_market_open' again
        continue

Order Proto

For the proto design, I just mimicked the IB Order object, allowing support for every order type that we might use. IB will just ignore the values of fields that are unrelated to the order you are sending. Currently supporting the following orders:

  1. 1. Limit
  2. 2. Market
  3. 3. Stop/Market if touched
  4. 4. Stop Limit/ Limit if touched
  5. 5. Dynamic Stop
order.proto
enum OrderAction {
    NEW = 0;
    MODIFY = 1;
    GETID = 2;
    DELETE = 3;
}

message TradeOrder {
  OrderAction classification = 1;
  string ticker = 2;
  string action = 3;
  float qty = 4;
  string order_type = 5;
  // Stop/StopLimit/Limit Orders
  optional double auxprice = 6;
  optional double lmt_price = 7;
  // To cancel or modify an order
  optional int32 order_id = 8;
  // Trailing Stop Orders
  optional float trailingPercent = 9;
  optional int32 trailStopPrice = 10;

}

message TradeUpdate {
    // Unique identifiers
    int32 order_id = 1;
    string ticker = 2;
    // Status info
    string status = 3;
    // Order Info
    string order_type = 4;
    string action = 5;  
    // Quantity tracking
    double total_qty = 6;
    double filled_qty = 7;
    double remaining_qty = 8;   
    // Pricing
    double avg_fill_price = 9;
    double last_fill_price = 10; // Price of the most recent execution
    // Updates for limit and stop orders
    optional double auxprice = 11;
    optional double lmt_price = 12;   
    // Metadata
    string timestamp = 13;      // ISO format or Unix
    optional string error_message = 14;
}

Order Client

The purpose of the order client is for strategy microservices to be able to access order updates, and send orders in a synchronous manner. The client encapsulates interaction with the Order Master in a reusable way. The client has three components.

  • 1. The order thread, a separate thread used to receive and send messages into the ZMQ socket going to Order Master.
  • 2. The order book, shared memory that tracks the states of orders as updates come from the Order Master.
  • 3. The client wrapper, the user facing interface that clients can call to wait for an order to fill, check the status of the order or submit a new order

Order Thread

The client communicates with the order thread using a python Queue (which is thread safe). New requests get put into the outgoing messages queue, and a future is declared. The future tracks the acknowledgement from the Order Master that the order has been submitted if the future times out, then we raise an network error.

order_thread.py
def send_command(self, payload: bytes, parse: Callable[[bytes], object],
                    context_msg: str, timeout_s: float = ACK_TIMEOUT_S):
    """
    Enqueue a command for the network thread to send, then block the *calling* thread
    (not the network thread) until its ack arrives or timeout_s elapses.
    Returns None on timeout, after logging and alerting -- never raises.
    """
    future: concurrent.futures.Future = concurrent.futures.Future()
    command_id = uuid.uuid4().hex

    with self._pending_lock:
        self._pending[command_id] = _PendingCommand(future=future, parse=parse, 
                                                    context_msg=context_msg)

    self._outgoing.put(_OutgoingCommand(command_id=command_id, payload=payload))

    try:
        return future.result(timeout=timeout_s)
    except concurrent.futures.TimeoutError:
        with self._pending_lock:
            self._pending.pop(command_id, None)
        self._logger.error()
        self._pn.send_notif()
        return None

Order Book

When responses from the message queue come in, the Order thread writes the updated state of the order to the orderbook. This is the shared object that both the Client Thread, the Order Thread touch.

order_book.py
class OrderBook:
    """
    Locked dict of order_id -> OrderRecord, plus a derived net-position view.

    Written to only by the network thread (via `apply_update`).
    Read from by strategy threads (via `get_status` / `wait_for_terminal` /
    `get_position` / `get_all_positions` / `get_open_order_ids`).
    """

    def __init__(self):
        self._lock = threading.Lock()
        self._orders: Dict[int, OrderRecord] = {}
        # Net open position by ticker, derived from fills seen via apply_update.
        # Positive = long, negative = short. Absent key == flat.
        self._positions: Dict[str, float] = {}

    def apply_update(self, update: msg.TradeUpdate) -> None:
        with self._lock:
            record = self._orders.setdefault(update.order_id, OrderRecord(order_id=update.order_id))
            if _is_duplicate(record.latest, update):
                return

Client wrapper

The client thread provides public facing functions to check the status of an order as well as submit, cancel and modify orders. Each function call returns a response object giving a status update from the server, if the response is None, it means the server did not process the request and an error is sent to me on discord.

client_wrapper.py
class LiveOrderClient():
    def __init__(self, strategy_id: str, logger: logging.Logger, pn: PushNotification, host=IB_HOST):
        self.strategy_id = strategy_id
        self.logger = logger

        server_addr = f"tcp://{host}:{ORDER_SERVICE_PORT}"
        self.order_book = OrderBook()
        self._network = OrderNetworkThread(server_addr, self.order_book, logger, pn, strategy_id)
        self._network.start()

    # ---- commands: fast acks only, never block on a fill ----

    def placeOrder(self, order: msg.TradeOrder) -> Optional[msg.TradeUpdate]:

        resp = self._network.send_command(
                    payload=order.SerializeToString(),
                    parse=_parse_trade_update,
                    context_msg=f"placeOrder {order.ticker} {order.action} {order.qty}",
                )
        
        return resp

Admin Client & Order Validator

The validator service exists to reconcile the state of the Order Service (orders being sent out, and filled) with the accounting service (IB reqAccountDetails callback). The positions being reported by the order service should be reflected in our IB account details. If there is a drift between the two, the validator will send out an alert for manual review. The Validator receives universal order updates from a dedicated socket coming out of Order Master. The messages are sorted by strategy ID and the net positions holding are summed in memory. This result is checked against the data the Validator receives from IB which is a streamed callback updating each time a net position changes. When deciding the synchronization for the Validator my main focus was on eliminating false positives, so the alert rules are very strict.

Essentially the problem is that we have to reconcile two independent data streams, with their own network speed, and zero ordering guarantees. This can never be done 100% reliably, but there is a threshold where a divergence is worth reporting. For instance if a ticker is highly traded, then we will essentially be unable to check it. We would be getting two streams, one detailing the constantly changing position sizes. Eg (10 AAPL, 20 AAPL, 15 AAPL,40 AAPL). The other detailing all deltas +10 AAPL +10AAPL -5 AAPL +15 AAPL. The problem is that we cannot find a single timeslice to compare states at, since information is continuous and could be coming out of order. The only time we can make an informed comparison is when there is a pause in data incoming. If during this pause there is a divergence between the net positions. It could indicate a problem, the longer this divergence lasts, the higher the likelihood that we have identified a true problem. Therefore, the Validator waits for a 2s silence for a given symbol. Then runs a state check, if the state check comes back divergent the validator waits for another 5s. If no further updates come for that ticker during this window, the validator will report an error. If an update does arrive the validator will restart the checking process again waiting for another silence. This system works because I don't run any high frequency trading strategies. Typically just one trade per a ticker a day. Second, the system should perform as expected 99% of the time, the goal of the validator is to look for any anomalies, so the error checking is strict by design. In the worst possible case divergences will be reported at EOD at the latest.

Frontend Bridge Service

The frontend bridge is a smaller tertiary service that exists to convert ZMQ messages into Websocket messages. It allows me to monitor messages being sent between services on the frontend. For instance, I can monitor all the orders being routed by the Order Service, or all the data streams currently being requested by the Market Data Service.

Batch Data Collection Service

The data collection service manages all the non-realtime batch jobs that keep the system functioning under the hood. I use APScheduler to organize everything. I chose APScheduler because I wanted something more versatile than a simple Cron Job but not as bulky as Airflow. To be completely honest the amount of data I am processing does not require the distributed processing power of Airflow, nor do I have the infrastructure to support it. At most I am pulling price and fundamental data for ~7000 tickers which is less than 100,000 data points. The multithreaded scheduling that APscheduler allows is enough for my use case.

schedular.py
logger = logging.getLogger(__name__)

# Create scheduler
scheduler = BlockingScheduler(
    executors={
        'default': ThreadPoolExecutor(max_workers=10),   # I/O-bound jobs (API calls, DB writes)
        'processpool': ProcessPoolExecutor(max_workers=4),  # reserved for future CPU-heavy jobs
    },
    job_defaults={
        'coalesce': True,
        'max_instances': 1,
    }
)

### Update Universe ###
scheduler.add_job(
    runner,
    'interval',
    weeks=2,
    start_date='2026-08-27 11:30:00',
    kwargs={"_func": get_all_listed,
            "_job_name": "Request Target Universe Float"},
    id='get_all_listed_stocks',
    executor='default'
)

scheduler.add_job(
    runner, 
    'cron', 
    hour=22, 
    minute=30, 
    kwargs={
        "_func": run_backfill_session, 
        "_job_name": "Fill data",
        "session_minutes": 60
    },
    id='run_daily_bars_backfill',
    executor='default'
)

def on_fill_executed(event):
    if event.job_id == 'run_daily_bar_backfill':
        # Start downstream dependant jobs
        scheduler.add_job(
            runner, 
            kwargs={
                "_func": generateGapWatchlist, 
                "_job_name": "Generate Gap Watchlist"
            },
            executor='default', 
            id='gap_watchlist_run')
    

def on_fill_error(event):
    if event.job_id == 'run_daily_bar_backfill':
        logger.error(f"Fill job failed, skipping all downstream watchlist jobs: {event.exception}")

# Graceful shutdown
def shutdown(signum, frame):
    logger.info("Shutting down...")
    scheduler.shutdown()
    sys.exit(0)

signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)

if __name__ == "__main__":
    configure_logging(service_name="stocks-data-storage")

    logger.info("Starting BlockingScheduler...")
    scheduler.start()

Getting Financials

I use the edgar tools library to pull fundamental data from the SEC website. To fully flush this out, I first used fuzzy matching to clean out obvious duplicates such as “totalAsserts” vs “Total Assets”. After normalizing the data I used an embedding model to group together the remaining words. I reviewed it by hand afterward and built a mapping table. It’s quite a heavy process, but it’s run very rarely.

Getting Historical Data

The second most commonly used job is the historical OHCL bar collection. I used the IB’s reqHistoricalData() to fetch everything. I had to play around with IB’s rate limits. Although the limits are not explicitly written, I believe IB has loosened them (used to be very restrictive for historical data). The general message limit for IB gateway is 50 requests/S. I went with a more conservative limit of 20 requests/S for this job. I’ve only tested it on daily bars, but it worked fine for 5Y daily bars for ~7000 instruments taking about 7 minutes to run. My hypothesis is that as long as you don’t flood in requests faster than 50/S you should be fine. IB will just gradually return data in whatever internal rate it’s comfortable with.

daily_historical_job.py
# IB pacing rules, for daily bars
_rates = [
    Rate(25, Duration.SECOND)
]
limiter = Limiter(_rates)

while i < len(pending):
    if datetime.now() >= session_deadline:
        logger.info(f"Time budget reached. {total_done} done this run, {len(pending) - total_done} remain.")
        return

    batch = pending[i:i + BATCH_SIZE]
    deltas = [compute_delta_days(last_dates.get(s), today) for s in batch]

    # One acquire per underlying request this batch will make against IB's
    # shared budget -- blocks (sleeps internally) until there's room under
    # both the 10-min window and the 2-sec burst rule.
    for _ in batch:
        limiter.try_acquire("ib_historical_data")

    logger.info(f"Processing Batch : {i//BATCH_SIZE}/{ceil(len(pending)/BATCH_SIZE)}")

    bars_by_symbol = ib_client.get_historical_data(
        tickers=batch,
        deltas=deltas,
        bar_duration="1 day",
    )

    if ib_client.pacing_violation:
        logger.warning(f"Pacing Volation Recevied. Sleeping and reprocessing Batch : {i//BATCH_SIZE}/{ceil(len(pending)/BATCH_SIZE)}")
        continue

    # Resets each call so pull it before you call
    invalid_tickers = ib_client.invalid_tickers

    if invalid_tickers:
        upsert_invalid_tickers(invalid_tickers)

    for symbol, bars in bars_by_symbol.items():
        if not bars:
            logger.info(f"No bars returned for {symbol}, will retry next session")
            continue
        upsert_daily_bars(symbol, bars)
        total_done += 1

    logger.info(f"{total_done}/{len(pending)} symbols done this session")
    i += BATCH_SIZE

logger.info(f"All pending symbols processed this session. Total: {total_done}")

Database Design

I use Postgres and MongoDB to persist the data that the system requires. MongoDB is used for the financials and Postgres is used for everything else. I use a sharelib called trading_util to organize the various Postgres queries. The sharelib is a custom library installed into all of my services, and holds code that will likely be reused across multiple services.

Within trading_util, the database functions are organized into objects holding queries for a specific function. It is not enforced that each object maps to 1 table. Some of the current DB categories include:

  1. HistoricalPriceDB
  2. AccountingDetailsDB
  3. GapReversalWatchlistDB
  4. FundementalData
gapWatchlistDB.py and sql_conn.py
from trading_util.db_util import StocksDBConnection

import os
from contextlib import contextmanager
import threading

from psycopg2 import pool

DB_PORT = 5432
DB_HOST='192.168.2.60'

class StocksDBConnection:
    def __init__(self, minconn: int = 1, maxconn: int = 10):
        self._pool = pool.ThreadedConnectionPool(
            minconn, 
            maxconn,
            host=host
            dbname='stocks',
            user='brian',
            password=password,
            port=DB_PORT
        )
        self._semaphore = threading.Semaphore(maxconn)

    # @classmethod
    # def from_env(cls, env_var: str = "STOCKS_DB_DSN", **kwargs) -> "StocksDBConnection":
    #     dsn = os.environ[env_var]
    #     return cls(dsn=dsn, **kwargs)

    def close(self):
        """Call on service shutdown to release all pooled connections cleanly."""
        self._pool.closeall()

    @contextmanager
    def cursor(self, timeout: float | None = None):
        acquired = self._semaphore.acquire(timeout=timeout)
        if not acquired:
            raise TimeoutError("Timed out waiting for a free DB connection")

        conn = self._pool.getconn()
        try:
            with conn.cursor() as cur:
                yield cur
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            self._pool.putconn(conn)
            self._semaphore.release()


class GapWatchlistDB:
    def __init__(self, conn: StocksDBConnection):
        self._conn = conn

    def get_active_watchlist(self) -> dict:
        """{stock: {"date_added", "last_updated", "entry_rsi", "entry_atr", "entry_close",
                    "latest_rsi", "latest_atr", "latest_close"}}"""
        with self._conn.cursor() as cur:
            cur.execute("""
                SELECT stock, date_added, last_updated, entry_rsi, entry_atr, entry_close,
                       latest_rsi, latest_atr, latest_close
                FROM gap_watchlist
            """)
            rows = cur.fetchall()
            return {
                r[0]: {
                    "date_added": r[1],
                    "last_updated": r[2],
                    "entry_rsi": r[3],
                    "entry_atr": r[4],
                    "entry_close": r[5],
                    "latest_rsi": r[6],
                    "latest_atr": r[7],
                    "latest_close": r[8],
                }
                for r in rows
            }

    ...

Trading Central Console

This is the service which displays information such as fundamental data, strategy performance and backtesting results. The service consists of a FastAPI backend, a NextJs frontend and the aforementioned Frontend Bridge for websocket communication.

Trading Console
Trading Console Home Page

Emerging Stocks Momentum Strategy

Strategy Screenshot
Emerging Stocks Moement Based Strategy

The momentum stocks trading strategy is an outlier detection problem. I am looking at the 1-minute and 5-minute timeframe for bars that can indicate that the stock will have future upward movement. The general idea is that if there is a large bar with large volume, the stock price is likely to trend upward following it.

The core of the problem is defining what "large" is. Fortunately, this boils down to a simple machine learning problem, hyperparameter optimization. I personally used Bayesian Optimization to tune the parameters for this strategy. If you want to read about the details, I will releasing a follow up article and linking it here

Buy on Gap Reversal Strategy

The gap reversal strategy combines a mean reversion on a higher timeframe with momentum based trend following on a intra-day timeframe. The strategy is designed to trade in a sideways market on a daily timeframe. We look for a big move (greater than ATR) that breaks the 9/21 EMA. Normally this is followed by a number of red candles. We are hunting for the first green candle the break the close.

Once we find the green candle, we want to ride the mean reversion back to EMA levels and exit.