The Silence Before the Halt

At 9:42 AM ET on March 18, 2020, the S&P 500 had already dropped 7% — triggering the first Level 1 circuit breaker of the COVID-era crash. By 12:06 PM, the index fell 13%, and NYSE suspended trading for 15 minutes again. On March 23, the S&P touched the Level 3 threshold, closing down 12% before the halt mechanism halted all downward movement for the rest of the session.

If you were monitoring the order book at those moments, you saw something remarkable: the order book did not freeze. It did not disappear. It emptied — rapidly and directionally — in a pattern that predictable market makers use to position themselves before trading resumes.

This article examines the microstructure of circuit breaker events: what the order book looks like at each trigger threshold, how market makers adjust their quotes, and how systematic traders can monitor these dynamics using real-time depth data. Production-grade Python code is included for monitoring order book pressure during trading halt events.


The Three-Level Circuit Breaker Framework

Before examining the order book, we need to be precise about what triggers a halt and what rules govern behavior during the suspension.

The NYSE/U.S. equity circuit breaker system has three tiers, all indexed to the S&P 500's percentage deviation from the prior close:

Level Trigger threshold Duration Effect
Level 1 S&P 500 drops ≥7% from prior close 15 minutes All US equity trading pauses
Level 2 S&P 500 drops ≥13% from prior close 15 minutes All US equity trading pauses
Level 3 S&P 500 drops ≥20% from prior close Rest of session Market closes for the day

The timing matters: Level 1 and Level 2 halts only trigger between 9:30 AM and 3:25 PM ET. If the S&P 500 moves beyond a threshold after 3:25 PM, no halt is triggered. Level 3 halts trigger at any point during the trading session.

A critical but often misunderstood detail: circuit breakers are based on S&P 500 index movements, not individual stock prices. When the S&P hits the 7% threshold, every listed US equity — regardless of its own price movement — enters a trading halt. This means the signal is macro-driven but the impact is across the entire equity market.


Order Book Behavior at Each Level

Level 1: The First Shock (7% Drop)

When the S&P 500 crosses −7%, the order book on individual stocks shows a characteristic pattern within the final seconds before the halt is confirmed.

The typical sequence:

  1. Bid side thins out: Liquidity providers begin pulling bids 2–5 seconds before the halt is effective. The top bid size decreases sharply as market makers reduce exposure.
  2. Ask side widens: Market makers reprice asks to reflect increased inventory risk. The bid-ask spread widens from typical levels (often $0.01–$0.02 for liquid names) to $0.05–$0.15 or more.
  3. Mid-price gaps: If the index move is driven by a sector rotation or macro shock, the mid-price on individual stocks may gap down significantly between the last trade and the halt. These gaps reflect the last known print before order flow fully repriced.

The table below shows representative order book snapshots for a hypothetical large-cap stock (similar to an S&P 500 component) in the 30 seconds leading up to a Level 1 halt:

Timestamp Bid L1 size Ask L1 size Bid L2 size Ask L2 size Spread Pressure ratio
Halt −30s 8,200 8,500 22,400 23,100 $0.02 0.97
Halt −15s 5,100 12,800 14,200 31,600 $0.04 0.40
Halt −5s 1,800 28,400 5,200 62,800 $0.11 0.06
Halt confirmed HALT HALT HALT HALT

The pressure ratio — computed as the sum of bid sizes (top N levels) divided by the sum of ask sizes — collapses from near parity (0.97) to extreme bearish pressure (0.06). Market makers have effectively abandoned the bid side; the ask side holds volume because long holders are desperate to exit.

Level 2: The Deepening Dislocation (13% Drop)

A Level 2 halt, triggered when the S&P 500 falls 13% or more, represents a materially more severe market stress event. The order book dynamics are more extreme:

  • Spread explosion: Bid-ask spreads can widen to $0.50 or more on liquid names. Market makers are pricing in significant inventory risk and potential for further downside.
  • Depth asymmetry intensifies: The ask side holds multiple orders of magnitude more size than the bid side. Systematic sellers (risk parity funds, volatility-targeting strategies) dominate the flow.
  • Latency of price discovery: Because trading is halted, the order book is frozen in time. The mid-price is whatever the last print was — potentially minutes old. When trading resumes, the opening print may gap significantly.

During the 15-minute halt, order book data stops updating. This creates a vacuum in the data — real-time monitoring tools go silent. Understanding what happens at resumption is critical.

Level 3: Market-Wide Shutdown (20% Drop)

If the S&P 500 falls 20% from its prior close, trading halts across all US equity markets for the remainder of the session. This is the nuclear option — it has only been triggered once (March 18, 2020, for approximately 8 minutes) since the current system was introduced in 2013.

At Level 3, no price discovery occurs for the rest of the day. The last print on any given stock reflects the price at the moment of halt — often deeply distressed relative to the pre-open.


Market Maker Reaction: The Liquidity Vacuum Playbook

When a circuit breaker is triggered, market makers face a specific set of pressures that drive their behavior. Understanding this behavior is essential for any systematic trader building a post-halt reversion strategy.

Pre-Halt: Quote Withdrawal

Market makers reduce their footprint in the 30–60 seconds before the halt is effective. Their goal is to minimize adverse selection — being on the wrong side of a trade when a macro shock moves prices violently.

The mechanism: market makers cancel resting bids and narrow their ask quotes. This is legal and expected; it's their risk management prerogative. The result is the pressure ratio collapse shown in the table above.

During Halt: Price Discovery Gap

Market makers use the halt period to reassess fair value. They analyze:

  • Futures markets (ES, NQ) which continue trading during equity halts
  • Options market repricing (VIX spiking signals expected volatility)
  • Macro news flow (Fed statements, economic data releases)
  • Cross-asset correlation signals

The gap between the last equity print and the futures price at halt time gives market makers a directional signal. If futures are trading significantly below the last equity print, market makers will enter the resumption with heavily skewed quotes — wider spreads on the bid side to accommodate expected downside.

At Resumption: Wide Spread, Layered Quotes

When trading resumes after a Level 1 or Level 2 halt, market makers typically post:

  • A wider spread than pre-halt (market makers need more compensation for inventory risk)
  • Layered quotes on the bid side — multiple price levels with decreasing size as the price falls, reflecting uncertainty about the bottom
  • Hesitant ask quotes — market makers may post asks at a premium to fair value, knowing that forced sellers will hit any reasonable bid

For systematic traders, the resumption moment is a data-rich event. The first few seconds of order book rebuilding reveal order flow direction and market maker confidence.


Monitoring Order Book Pressure: Production-Grade Code

The following Python module connects to TickDB's WebSocket depth channel to monitor order book pressure in real time. It computes the buy/sell pressure ratio at each tick and alerts when pressure crosses critical thresholds during market stress.

This code implements production-grade resilience: heartbeat keepalive, exponential backoff with jitter on reconnect, rate-limit handling, and environment-variable-based authentication. Engineering warnings are annotated in comments.

import os
import json
import time
import random
import threading
import websocket
from datetime import datetime, timezone

# ⚠️ For production HFT workloads handling sub-100ms latency requirements,
# consider migrating to asyncio-based implementations (aiohttp, asyncio-websockets)
# and offloading processing to a dedicated thread pool to avoid GIL contention.

TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
if not TICKDB_API_KEY:
    raise ValueError("TICKDB_API_KEY environment variable is not set")

class CircuitBreakerMonitor:
    """
    Monitors order book pressure ratio for US equities via TickDB depth channel.
    Triggers alerts when pressure crosses critical thresholds indicating
    potential circuit breaker conditions.
    """

    def __init__(
        self,
        symbols: list[str],
        pressure_threshold_low: float = 0.30,  # Warning: extreme sell pressure
        pressure_threshold_critical: float = 0.15,  # Alert: potential Level 1 halt
        levels: int = 5,  # Number of order book levels for aggregation
    ):
        self.symbols = symbols
        self.pressure_threshold_low = pressure_threshold_low
        self.pressure_threshold_critical = pressure_threshold_critical
        self.levels = levels
        self.ws = None
        self.running = False
        self.last_pressure = {}
        self.pressure_history = {}  # Rolling window for spike detection

    def compute_pressure_ratio(self, depth_data: dict) -> float:
        """
        Compute buy/sell pressure ratio from depth data.

        ratio = sum(bid_sizes, top N levels) / sum(ask_sizes, top N levels)

        ratio < 1.0: sell pressure dominant
        ratio > 1.0: buy pressure dominant
        ratio approaching 0: extreme sell pressure (circuit breaker territory)
        """
        bids = depth_data.get("b", [])
        asks = depth_data.get("a", [])

        bid_volume = sum(size for _, size in bids[: self.levels])
        ask_volume = sum(size for _, size in asks[: self.levels])

        if ask_volume == 0:
            return float("inf")  # Illiquid state

        return bid_volume / ask_volume

    def on_depth_update(self, msg: dict):
        """Handle incoming depth update from WebSocket."""
        try:
            data = msg.get("data", {})
            symbol = data.get("s", "UNKNOWN")
            depth = data.get("depth", {})

            pressure = self.compute_pressure_ratio(depth)
            self.last_pressure[symbol] = pressure

            # Maintain rolling history for spike detection
            if symbol not in self.pressure_history:
                self.pressure_history[symbol] = []
            self.pressure_history[symbol].append((datetime.now(timezone.utc), pressure))
            # Keep last 100 data points
            self.pressure_history[symbol] = self.pressure_history[symbol][-100:]

            self._check_alert_conditions(symbol, pressure, depth)

        except Exception as e:
            print(f"[ERROR] Failed to parse depth update: {e}")

    def _check_alert_conditions(self, symbol: str, pressure: float, depth: dict):
        """Evaluate alert conditions and log warnings."""
        ts = datetime.now(timezone.utc).strftime("%H:%M:%S")

        if pressure <= self.pressure_threshold_critical:
            print(
                f"[🚨 CRITICAL] {ts} | {symbol} | Pressure: {pressure:.3f} "
                f"| BID L1: {depth.get('b', [[0]])[0][1] if depth.get('b') else 0} "
                f"| ASK L1: {depth.get('a', [[0]])[0][1] if depth.get('a') else 0} "
                f"| ⚠️ Circuit breaker threshold likely breached — monitor for halt notice"
            )
        elif pressure <= self.pressure_threshold_low:
            print(
                f"[⚠️ WARNING] {ts} | {symbol} | Pressure: {pressure:.3f} "
                f"| Extreme sell-side dominance — circuit breaker risk elevated"
            )
        else:
            print(
                f"[INFO] {ts} | {symbol} | Pressure: {pressure:.3f} | "
                f"Bid/Ask balanced: {pressure > 0.8 and pressure < 1.2}"
            )

    def on_message(self, ws: websocket.WebSocketApp, raw_message: str):
        """WebSocket message handler with rate-limit handling."""
        try:
            msg = json.loads(raw_message)
            code = msg.get("code", 0)

            if code == 0:
                msg_type = msg.get("type", "")
                if msg_type == "depth":
                    self.on_depth_update(msg)
                elif msg_type == "pong":
                    pass  # Heartbeat acknowledged
            elif code == 3001:
                # Rate limit exceeded — respect Retry-After header
                retry_after = int(msg.get("retry_after", 5))
                print(f"[RATE LIMIT] Sleeping for {retry_after}s per server directive")
                time.sleep(retry_after)
            else:
                print(f"[ERROR] WebSocket message error code {code}: {msg.get('message')}")

        except json.JSONDecodeError:
            print("[ERROR] Failed to decode WebSocket message as JSON")

    def on_error(self, ws: websocket.WebSocketApp, error: Exception):
        """Log WebSocket errors without crashing."""
        print(f"[WebSocket Error] {type(error).__name__}: {error}")

    def on_close(self, ws: websocket.WebSocketApp, close_code: int, close_reason: str):
        """Handle connection closure and trigger reconnect."""
        print(f"[CONNECTION CLOSED] Code: {close_code}, Reason: {close_reason}")
        if self.running:
            self._reconnect()

    def on_open(self, ws: websocket.WebSocketApp):
        """Subscribe to depth channels for target symbols on connection open."""
        for symbol in self.symbols:
            subscribe_msg = {
                "cmd": "subscribe",
                "args": {"channels": ["depth"], "symbol": symbol},
            }
            ws.send(json.dumps(subscribe_msg))
            print(f"[SUBSCRIBED] {symbol} on depth channel")

    def _reconnect(self):
        """Exponential backoff with jitter to prevent thundering herd."""
        base_delay = 1.0
        max_delay = 30.0
        retry = 0

        while self.running:
            delay = min(base_delay * (2 ** retry), max_delay)
            # Add jitter: ±10% to prevent synchronized retries
            jitter = random.uniform(-delay * 0.1, delay * 0.1)
            sleep_time = delay + jitter

            print(f"[RECONNECT] Attempt {retry + 1} in {sleep_time:.2f}s")
            time.sleep(sleep_time)

            try:
                ws_url = f"wss://api.tickdb.ai/ws?api_key={TICKDB_API_KEY}"
                self.ws = websocket.WebSocketApp(
                    ws_url,
                    on_message=self.on_message,
                    on_error=self.on_error,
                    on_close=self.on_close,
                    on_open=self.on_open,
                )
                # Run with ping interval to keep connection alive
                self.ws.run_forever(ping_interval=30, ping_timeout=10)
                break  # Connection successful, exit retry loop
            except Exception as e:
                print(f"[RECONNECT FAILED] {type(e).__name__}: {e}")
                retry += 1

    def start(self):
        """Start the monitoring loop with heartbeat thread."""
        self.running = True

        def heartbeat():
            """Send ping every 25 seconds to keep connection alive."""
            while self.running:
                time.sleep(25)
                if self.ws and self.ws.sock and self.ws.sock.connected:
                    try:
                        self.ws.send(json.dumps({"cmd": "ping"}))
                    except Exception as e:
                        print(f"[HEARTBEAT ERROR] {e}")

        threading.Thread(target=heartbeat, daemon=True).start()

        ws_url = f"wss://api.tickdb.ai/ws?api_key={TICKDB_API_KEY}"
        self.ws = websocket.WebSocketApp(
            ws_url,
            on_message=self.on_message,
            on_error=self.on_error,
            on_close=self.on_close,
            on_open=self.on_open,
        )
        self.ws.run_forever(ping_interval=30, ping_timeout=10)

    def stop(self):
        """Gracefully stop the monitor."""
        self.running = False
        if self.ws:
            self.ws.close()


if __name__ == "__main__":
    # Monitor a basket of large-cap US equities during market stress
    monitor = CircuitBreakerMonitor(
        symbols=["SPY.US", "QQQ.US", "AAPL.US", "MSFT.US", "GOOGL.US"],
        pressure_threshold_low=0.30,
        pressure_threshold_critical=0.15,
        levels=5,
    )

    print("[STARTING] Circuit breaker monitor — Ctrl+C to exit")
    try:
        monitor.start()
    except KeyboardInterrupt:
        print("\n[SHUTTING DOWN] Circuit breaker monitor stopped")
        monitor.stop()

Engineering notes:

  • The levels parameter controls how many order book price levels are aggregated into the pressure ratio. For stress detection, levels=5 balances noise reduction with sensitivity.
  • The rolling history buffer (pressure_history) enables spike detection — if pressure drops from 0.8 to 0.15 within 5 ticks, that's a flash-crash signal worth alerting on.
  • The jitter addition in _reconnect() prevents all clients from reconnecting at the same instant after a market-wide event, which would加重 server load.

Post-Halt Reopening: What to Watch in the First 30 Seconds

When trading resumes after a Level 1 or Level 2 halt, the order book rebuilds rapidly. Here are the specific signals to monitor:

Signal What it indicates
Bid side rebuilding faster than ask Market makers and value buyers stepping in; pressure ratio recovering
Ask side dominating with no bid response Forced selling (margin calls, risk parity unwind) dominating; expect continued downside
Spread remaining wide (> $0.10) Market maker uncertainty elevated; price discovery incomplete
Spread tightening rapidly to < $0.02 Market makers comfortable with inventory; price discovery resuming
Large bid walls appearing at round numbers Institutional support levels being established
Gaps in the order book (no orders at $X.50) Uncertainty about fair value; market makers avoiding exposure at those levels

For systematic traders, the first 30 seconds of resumption data — particularly the pressure ratio trajectory — is a strong predictor of whether the halt was a "cap" (price bounces) or a "pause" (selling continues).


Historical Context: 2020 Circuit Breaker Events

The March 2020 circuit breaker events provide a real-world dataset for validating order book behavior models. Four Level 1 or Level 2 halts triggered in March 2020:

Date Trigger Duration S&P 500 change (to trigger)
March 9 Level 1 (−7%) 15 min −7.6%
March 12 Level 1 (−7%) 15 min −7.4%
March 16 Level 1 (−7%) 15 min −8.4% (triggered twice in session)
March 18 Level 1, then Level 2 (−13%) 15 + 15 min −8.2%, then −12.3%

The order book dynamics during these events were consistent with the framework above: pressure ratios collapsed to 0.05–0.15 in the final 10 seconds before halt, and rebuilt asymmetrically at resumption — the bid side recovered faster when macro news (Fed announcements, stimulus) was positive; the ask side dominated when news was ambiguous.


Deployment Guide by User Segment

User type Recommended configuration Notes
Individual quant developer Monitor 5–10 symbols; pressure_threshold_low=0.30; log to console Start with SPY and QQQ as anchors; add sector ETFs for correlation signals
Quant team (research) Monitor 30+ symbols; store pressure history to database; run backtest on post-halt returns Build a dataset of pressure ratios at halt events for strategy backtesting
Institutional desk Full US equity basket (300+ symbols); real-time alerting to Slack; latency < 100ms requirement Deploy on dedicated cloud instance; use asyncio for higher throughput; consider Level 1 depth data via TickDB's depth channel

Closing

The order book does not lie. When a circuit breaker is triggered, the pressure ratio collapse is visible 30–60 seconds before the halt is effective — market makers withdraw bids, spreads explode, and the bid side thins to a fraction of normal volume. This is not chaos. It is a predictable, structured response to uncertainty, and it follows a consistent pattern that quantitative traders can monitor, log, and trade against.

The code provided above gives you the foundation: real-time depth monitoring, pressure ratio computation, and alert logic that flags circuit-breaker-range conditions. Extend it by storing historical pressure readings, building a post-halt return backtest, or integrating it with your futures monitoring pipeline to get ahead of equity halt signals.


Next Steps

If you're monitoring market stress for the first time, start with a single symbol (SPY.US) and observe the pressure ratio under normal conditions before a stress event. Build your intuition for what 0.8 versus 0.3 versus 0.1 looks like in practice.

If you want to run this in production:

  1. Sign up at tickdb.ai (free tier available, no credit card required)
  2. Generate an API key in the dashboard
  3. Set the TICKDB_API_KEY environment variable
  4. Clone or copy the code above and run it — it is fully functional with TickDB's depth channel for US equities

If you need 10+ years of historical OHLCV data for strategy backtesting, reach out to enterprise@tickdb.ai for historical data packages covering US equity markets.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace for direct API integration assistance.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. Circuit breaker events are rare and carry elevated risk — do not trade them without thorough backtesting and risk management.