"Price is the effect. The order book is the cause."

At 4:01 PM ET on August 5th, a semiconductor company's Q2 earnings crossed the wire. Revenue beat consensus by 12%. The headline number looked like a blowout. Yet within 90 seconds of the release, the stock gapped down 7.3% — not because the news was bad, but because the order book told a different story than the headline.

Traders who watched only the tape missed the warning. Traders who watched the depth channel saw it coming 40 seconds before the first large print.

This article dissects the microstructure signature of post-earnings liquidity vacuums: what the order book looks like in the 60 seconds before it collapses, how to measure liquidity depth degradation in real time, and how to wire a production-grade WebSocket subscriber that captures depth snapshots at sub-second resolution.


The Anatomy of an Earnings Liquidity Vacuum

Why Earnings Break Order Books

Corporate earnings releases create a predictable sequence of market microstructure failures. The root cause is information asymmetry collapsing violently:

  1. Pre-announcement equilibrium: Market makers maintain tight two-sided markets based on implied volatility and existing information sets.
  2. Announcement shock: The actual numbers diverge from the implied distribution — either direction.
  3. Liquidity withdrawal: Market makers cannot hedge fast enough. They widen spreads dramatically and reduce size to protect against adverse selection.
  4. Vacuum window: Sell limit orders flee; buy limit orders thin out. The bid side erodes faster than the ask side in most earnings reactions because institutional short sellers hit the bids aggressively while retail holders are slower to react.
  5. Price discovery chaos: With no depth on either side, individual prints cause outsized price moves.

The result is a characteristic pattern in the depth snapshot: the bid side thins faster than the ask side, the spread widens, and the pressure ratio — the ratio of cumulative bid size to cumulative ask size — inverts dramatically.

Quantifying the Vacuum: A Typical Post-Earnings Depth Profile

The following table represents idealized order book behavior around a large-cap earnings release, reconstructed from observed microstructure patterns. Real data will vary by company, pre-announcement volatility, and news surprise magnitude.

Timestamp (ET) Bid L1 Size Ask L1 Size Spread ($) Pressure Ratio (Bid/Ask) Liquidity Depth
T −60 sec 18,400 17,900 0.02 1.03 Baseline
T −30 sec 16,200 16,500 0.02 0.98 Slight ask pressure
T −10 sec 14,800 18,100 0.03 0.82 Bid thinning begins
T −5 sec 11,300 21,400 0.04 0.53 Aggressive ask absorption
T +2 sec 6,200 28,700 0.09 0.22 Vacuum window opens
T +15 sec 3,100 42,800 0.18 0.07 Maximum dislocation
T +60 sec 8,400 31,200 0.11 0.27 Partial rebalancing
T +5 min 15,200 22,600 0.06 0.67 Mean reversion begins

Several patterns stand out:

The pressure ratio inversion is the leading signal. In this example, the pressure ratio fell from 1.03 (balanced) to 0.07 (severe bid-side vacuum) before the largest price decline occurred. The inversion began roughly 40 seconds before the sharpest price move.

Spread widening is a lagging indicator. The spread only widened to $0.18 at T+15 sec. By the time traders reacting to spread widening made decisions, the primary move was already complete.

Liquidity depth recovery is asymmetric. The bid side recovers more slowly than the ask side. In bear-surprise scenarios, this asymmetry can persist for 20–45 minutes post-release.

Why Traditional OHLCV Data Misses This

Standard candlestick (OHLCV) data captures what happened. It does not capture why it happened or when the transition began. A 1-minute candle showing a 7% decline contains no information about the 40-second warning window in the depth data. This is the primary reason event-driven traders who rely solely on kline data underperform those with real-time depth visibility: they enter after the vacuum has already formed, paying the spread widening as an entry cost.


The Three-Phase Architecture of an Earnings Depth Monitor

A robust earnings depth monitoring system operates across three distinct phases. Each phase has different data requirements and alerting thresholds.

Phase 1: Baseline Capture (T −15 min to T −30 sec)

Before the announcement, the system establishes a depth baseline by sampling the order book at regular intervals. The key metric here is the baseline pressure ratio — the average pressure ratio over the 15 minutes preceding the release.

Baseline Phase Metrics:
- Sample interval: 5 seconds
- Baseline pressure ratio: rolling 20-period mean
- Baseline spread: rolling 20-period mean
- Alert threshold: pressure ratio falls below 0.80 of baseline

During this phase, the system also captures the options market's implied volatility surface if available. A spike in IV in the hours before earnings — particularly in the short-dated options — often correlates with a more violent post-announcement depth collapse.

Phase 2: Real-Time Monitoring (T −30 sec to T +5 min)

This is the critical window. The system subscribes to the depth channel and computes pressure ratio and liquidity depth in real time. Alert thresholds tighten as the announcement approaches.

Monitoring Phase Thresholds:
- T −30 sec to T: Alert if pressure ratio < 0.75
- T to T +30 sec: Alert if pressure ratio < 0.50 OR spread > 3x baseline
- T +30 sec to T +5 min: Monitor for mean reversion signal

The system should also track the top-of-book imbalance, defined as the difference between the top bid size and the top ask size, normalized by their sum:

Top-of-Book Imbalance = (Bid_L1 − Ask_L1) / (Bid_L1 + Ask_L1)

A value of +0.30 means the bid at the best price is 30% larger than the ask. During a vacuum, this value drops sharply toward −0.50 or lower.

Phase 3: Post-Event Analysis (T +5 min to T +60 min)

After the initial vacuum, the system transitions to monitoring the recovery. Mean reversion in the pressure ratio typically begins within 3–8 minutes of the announcement, but the path is non-linear. Some stocks rebalance quickly; others drift for an hour.

Post-Event Metrics:
- Recovery slope: rate of pressure ratio normalization
- Depth replenishment: cumulative new bid size at L1
- Volatility normalization: spread returning to baseline

Production-Grade Depth Channel Subscriber

The following code implements a real-time depth monitor for the pre-earnings and post-earnings window. It uses the TickDB WebSocket API to subscribe to the depth channel, computes pressure ratio and top-of-book imbalance in real time, and fires alerts when configurable thresholds are breached.

⚠️ Engineering note: This implementation uses the websocket-client library for simplicity. For production HFT workloads processing multiple symbols simultaneously, migrate to asyncio with aiohttp or a native WebSocket client with a connection pool. The architecture below is suitable for monitoring 1–10 symbols in parallel on a single-threaded event loop.

import json
import os
import time
import random
import threading
from datetime import datetime
from collections import deque
import websocket  # pip install websocket-client

# ─── Configuration ────────────────────────────────────────────────────────────
TICKDB_WS_URL = "wss://api.tickdb.ai/ws"
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")

if not TICKDB_API_KEY:
    raise EnvironmentError(
        "TICKDB_API_KEY environment variable is not set. "
        "Generate an API key at https://tickdb.ai/dashboard"
    )

# Symbol to monitor — replace with your target
SYMBOL = "NVDA.US"

# Alert thresholds
BASELINE_PRESSURE_RATIO = None  # Set dynamically after baseline capture
THRESHOLD_PRESSURE_RATIO = 0.75  # Alert if pressure ratio falls below 75% of baseline
THRESHOLD_SPREAD_MULTIPLIER = 3.0  # Alert if spread exceeds 3x baseline

# Baseline capture settings
BASELINE_WINDOW_SECONDS = 15 * 60  # 15 minutes
BASELINE_SAMPLE_INTERVAL = 5  # seconds
BASELINE_BUFFER_SIZE = BASELINE_WINDOW_SECONDS // BASELINE_SAMPLE_INTERVAL

# Reconnection settings
RECONNECT_BASE_DELAY = 1.0
RECONNECT_MAX_DELAY = 60.0
MAX_RECONNECT_ATTEMPTS = 10


class DepthMonitor:
    """Real-time depth monitor for earnings liquidity vacuum detection."""

    def __init__(self, symbol, ws_url, api_key):
        self.symbol = symbol
        self.ws_url = ws_url
        self.api_key = api_key
        self.ws = None
        self.running = False
        self.reconnect_attempts = 0

        # Baseline metrics
        self.pressure_ratio_buffer = deque(maxlen=BASELINE_BUFFER_SIZE)
        self.spread_buffer = deque(maxlen=BASELINE_BUFFER_SIZE)
        self.baseline_established = False
        self.baseline_start_time = None

        # Real-time metrics
        self.current_depth = None
        self.last_ping_time = None
        self.last_pong_time = None

        # Thread safety for metric updates
        self._lock = threading.Lock()

    def compute_pressure_ratio(self, depth_data):
        """
        Compute buy/sell pressure ratio from depth snapshot.

        Ratio = Σ(bid_sizes, top N levels) / Σ(ask_sizes, top N levels)

        A ratio > 1.0 indicates buy-side dominance.
        A ratio < 1.0 indicates sell-side dominance.
        A ratio < 0.50 indicates severe liquidity vacuum on the bid side.
        """
        bids = depth_data.get("b", [])
        asks = depth_data.get("a", [])

        bid_total = sum(float(size) for _, size in bids)
        ask_total = sum(float(size) for _, size in asks)

        if ask_total == 0:
            return None

        return bid_total / ask_total

    def compute_top_of_book_imbalance(self, depth_data):
        """
        Compute top-of-book imbalance.

        Imbalance = (Bid_L1 − Ask_L1) / (Bid_L1 + Ask_L1)

        Range: −1.0 (extreme ask pressure) to +1.0 (extreme bid pressure)
        """
        bids = depth_data.get("b", [])
        asks = depth_data.get("a", [])

        if not bids or not asks:
            return None

        bid_l1 = float(bids[0][1])
        ask_l1 = float(asks[0][1])

        return (bid_l1 - ask_l1) / (bid_l1 + ask_l1)

    def compute_spread(self, depth_data):
        """Compute bid-ask spread in dollars."""
        bids = depth_data.get("b", [])
        asks = depth_data.get("a", [])

        if not bids or not asks:
            return None

        bid_price = float(bids[0][0])
        ask_price = float(asks[0][0])

        return ask_price - bid_price

    def establish_baseline(self, sample_interval=BASELINE_SAMPLE_INTERVAL):
        """
        Capture baseline depth metrics over the configured window.
        Call this before the earnings announcement window.
        """
        print(f"[{datetime.now():%H:%M:%S}] Establishing baseline for {self.symbol}...")
        print(f"    Collecting {BASELINE_BUFFER_SIZE} samples over {BASELINE_WINDOW_SECONDS}s")

        self.baseline_start_time = time.time()
        self.pressure_ratio_buffer.clear()
        self.spread_buffer.clear()

        while (time.time() - self.baseline_start_time) < BASELINE_WINDOW_SECONDS:
            if self.current_depth is not None:
                with self._lock:
                    depth = self.current_depth

                pr = self.compute_pressure_ratio(depth)
                spread = self.compute_spread(depth)

                if pr is not None:
                    self.pressure_ratio_buffer.append(pr)
                if spread is not None:
                    self.spread_buffer.append(spread)

            time.sleep(sample_interval)

        if len(self.pressure_ratio_buffer) > 0:
            self.baseline_established = True
            avg_pr = sum(self.pressure_ratio_buffer) / len(self.pressure_ratio_buffer)
            avg_spread = sum(self.spread_buffer) / len(self.spread_buffer)
            print(f"[{datetime.now():%H:%M:%S}] Baseline established.")
            print(f"    Avg pressure ratio: {avg_pr:.3f}")
            print(f"    Avg spread: ${avg_spread:.4f}")
            return avg_pr, avg_spread
        else:
            print(f"[{datetime.now():%H:%M:%S}] WARNING: Failed to establish baseline. Using defaults.")
            return 1.0, 0.02  # Fallback defaults

    def check_alert_conditions(self, depth_data):
        """Evaluate alert conditions and print warnings if thresholds are breached."""
        if not self.baseline_established:
            return

        pr = self.compute_pressure_ratio(depth_data)
        spread = self.compute_spread(depth_data)
        imbalance = self.compute_top_of_book_imbalance(depth_data)

        if pr is None:
            return

        baseline_pr = sum(self.pressure_ratio_buffer) / len(self.pressure_ratio_buffer) if self.pressure_ratio_buffer else 1.0
        baseline_spread = sum(self.spread_buffer) / len(self.spread_buffer) if self.spread_buffer else 0.02

        timestamp = datetime.now().strftime("%H:%M:%S")

        # Check pressure ratio threshold
        if pr < THRESHOLD_PRESSURE_RATIO * baseline_pr:
            severity = "CRITICAL" if pr < 0.50 else "WARNING"
            print(f"[{timestamp}] [{severity}] Pressure ratio: {pr:.3f} (baseline: {baseline_pr:.3f})")
            print(f"    → Bid-side liquidity vacuum detected. Ratio at {pr/baseline_pr*100:.1f}% of baseline.")

        # Check spread widening
        if spread is not None and spread > THRESHOLD_SPREAD_MULTIPLIER * baseline_spread:
            print(f"[{timestamp}] [WARNING] Spread: ${spread:.4f} (baseline: ${baseline_spread:.4f})")
            print(f"    → Spread widened to {spread/baseline_spread:.1f}x baseline.")

        # Check top-of-book imbalance
        if imbalance is not None and imbalance < -0.30:
            print(f"[{timestamp}] [INFO] Top-of-book imbalance: {imbalance:.3f}")
            print(f"    → Significant ask-side pressure at best bid/ask.")

        # Always log current state for backtesting
        print(f"[{timestamp}] Depth update → PR: {pr:.3f} | Spread: ${spread:.4f} | Imbalance: {imbalance:.3f}")

    def on_depth_update(self, depth_data):
        """Callback invoked on each depth snapshot received."""
        with self._lock:
            self.current_depth = depth_data

        self.check_alert_conditions(depth_data)

    def on_message(self, ws, message):
        """Handle incoming WebSocket messages."""
        try:
            data = json.loads(message)

            # Handle ping/pong for keepalive
            if data.get("cmd") == "pong":
                self.last_pong_time = time.time()
                return

            # Route data messages by channel
            channel = data.get("channel")
            if channel == "depth":
                self.on_depth_update(data.get("data", {}))
            elif channel == " trades":
                # Trades data — useful for post-event confirmation
                pass
            else:
                # Subscription confirmations, error messages, etc.
                print(f"[DEBUG] Message on channel '{channel}': {data}")

        except json.JSONDecodeError as e:
            print(f"[ERROR] Failed to parse message: {e}")

    def on_error(self, ws, error):
        """Handle WebSocket errors."""
        print(f"[{datetime.now():%H:%M:%S}] [ERROR] WebSocket error: {error}")

    def on_close(self, ws, close_status_code, close_msg):
        """Handle WebSocket disconnection."""
        print(f"[{datetime.now():%H:%M:%S}] [INFO] WebSocket closed: {close_status_code} — {close_msg}")
        if self.running:
            self._schedule_reconnect()

    def on_open(self, ws):
        """Subscribe to the depth channel on connection open."""
        print(f"[{datetime.now():%H:%M:%S}] [INFO] Connected. Subscribing to depth channel for {self.symbol}...")

        subscribe_message = {
            "cmd": "subscribe",
            "channel": "depth",
            "params": {
                "symbol": self.symbol,
                "limit": 10  # Capture up to 10 levels for pressure ratio computation
            }
        }
        ws.send(json.dumps(subscribe_message))
        print(f"[{datetime.now():%H:%M:%S}] [INFO] Subscription sent. Waiting for depth updates...")

    def _schedule_reconnect(self):
        """Schedule a reconnection attempt with exponential backoff and jitter."""
        if self.reconnect_attempts >= MAX_RECONNECT_ATTEMPTS:
            print(f"[ERROR] Max reconnect attempts ({MAX_RECONNECT_ATTEMPTS}) reached. Giving up.")
            return

        self.reconnect_attempts += 1
        delay = min(RECONNECT_BASE_DELAY * (2 ** self.reconnect_attempts), RECONNECT_MAX_DELAY)
        jitter = random.uniform(0, delay * 0.1)
        sleep_time = delay + jitter

        print(f"[{datetime.now():%H:%M:%S}] Reconnecting in {sleep_time:.2f}s (attempt {self.reconnect_attempts}/{MAX_RECONNECT_ATTEMPTS})...")
        time.sleep(sleep_time)
        self._connect()

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

    def _connect(self):
        """Establish WebSocket connection with authentication."""
        ws_url = f"{self.ws_url}?api_key={self.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 in a thread to avoid blocking
        ws_thread = threading.Thread(target=self.ws.run_forever, daemon=True)
        ws_thread.start()

    def start(self):
        """Start the depth monitor."""
        self.running = True
        self.reconnect_attempts = 0

        print(f"[{datetime.now():%H:%M:%S}] [INFO] Starting DepthMonitor for {self.symbol}")
        self._connect()

        # Start heartbeat thread
        heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
        heartbeat_thread.start()

        # Establish baseline before earnings window
        self.establish_baseline()

        # Keep main thread alive
        try:
            while self.running:
                time.sleep(1)
        except KeyboardInterrupt:
            print(f"\n[{datetime.now():%H:%M:%S}] [INFO] Shutting down...")
            self.stop()

    def stop(self):
        """Stop the depth monitor and close the WebSocket connection."""
        self.running = False
        if self.ws:
            self.ws.close()
        print(f"[{datetime.now():%H:%M:%S}] [INFO] DepthMonitor stopped.")


if __name__ == "__main__":
    monitor = DepthMonitor(
        symbol=SYMBOL,
        ws_url=TICKDB_WS_URL,
        api_key=TICKDB_API_KEY
    )
    monitor.start()

Key Engineering Decisions

Reconnection with exponential backoff and jitter: Network disruptions during high-volatility windows (exactly when you need the data most) are common. The backoff formula min(base × 2^attempt, max_delay) ensures the system retries aggressively at first, then backs off to avoid overwhelming the API when connectivity is restored. The jitter component (random.uniform(0, delay × 0.1)) prevents thundering-herd reconnection storms if multiple clients reconnect simultaneously.

Baseline establishment phase: The monitor spends the first 15 minutes establishing a rolling baseline rather than using hardcoded thresholds. This is critical because a "normal" pressure ratio varies by stock, sector, and market regime. A ratio of 0.80 might be alarming for a highly liquid large-cap but completely normal for a mid-cap with naturally wider spreads.

Thread-safe metric updates: The _lock ensures that updates to self.current_depth from the WebSocket thread are read atomically in establish_baseline(). Without this lock, a mid-read update could produce partially constructed metrics that trigger false alerts.

10-level depth limit: The limit: 10 parameter captures sufficient depth for pressure ratio computation without excessive bandwidth. For US equities, TickDB provides L1 depth data; for HK and crypto markets, up to 10 levels are available. The pressure ratio using only the top level (L1) is noisier but faster to compute and sufficient for real-time alerting.


Extending to Multi-Symbol Surveillance

The single-symbol monitor above is suitable for tracking one earnings announcement in real time. Production surveillance systems typically monitor multiple symbols simultaneously. The extension uses a shared WebSocket connection with per-symbol routing:

# Multi-symbol depth monitor — extends DepthMonitor for N symbols
class MultiSymbolDepthMonitor:
    """
    Monitor multiple symbols on a single WebSocket connection.
    Suitable for earnings season surveillance of a portfolio or watchlist.
    """

    def __init__(self, symbols, ws_url, api_key):
        self.symbols = set(symbols)
        self.monitors = {sym: DepthMonitor(sym, ws_url, api_key) for sym in symbols}
        self.ws = None
        self.running = False

    def on_message(self, ws, message):
        """Route incoming depth updates to the correct symbol monitor."""
        try:
            data = json.loads(message)
            channel = data.get("channel")

            if channel == "depth":
                symbol = data.get("symbol")
                depth_data = data.get("data", {})

                if symbol in self.monitors:
                    self.monitors[symbol].on_depth_update(depth_data)
                else:
                    print(f"[DEBUG] Received depth for untracked symbol: {symbol}")

        except json.JSONDecodeError:
            pass

    def subscribe_all(self, ws):
        """Subscribe to depth channel for all monitored symbols."""
        for symbol in self.symbols:
            subscribe_message = {
                "cmd": "subscribe",
                "channel": "depth",
                "params": {"symbol": symbol, "limit": 10}
            }
            ws.send(json.dumps(subscribe_message))
            time.sleep(0.5)  # Brief stagger to avoid rate limiting on bulk subscribe

        print(f"[INFO] Subscribed to {len(self.symbols)} symbols: {', '.join(self.symbols)}")

    # Start/stop/heartbeat logic mirrors DepthMonitor with multi-symbol routing

⚠️ Rate limit warning: When subscribing to more than 5 symbols simultaneously on a single connection, monitor the 3001 error code in subscription acknowledgments. If rate-limited, implement a subscription stagger delay of 1–2 seconds between symbols.


Deploying the Monitor: Three Scenarios

The appropriate deployment configuration depends on your trading context.

Scenario Configuration Recommended API Tier
Individual quant researcher, single stock Run locally; connect to one symbol; establish 15-min baseline before announcement Free tier (1 symbol, limited history)
Quant fund, 10–20 symbol watchlist during earnings season Deploy on a cloud VM (AWS us-east-1 or GCP); run MultiSymbolDepthMonitor; persist alerts to a time-series database Professional tier
Institutional event-driven desk, real-time risk management Dedicated EC2 instance per 50 symbols; WebSocket connection pool; integrate with OMS via webhook alerts; log all depth snapshots for post-trade analysis Enterprise — contact enterprise@tickdb.ai

Limitations and Backtest Disclosure

The patterns described in this article — pressure ratio inversion, spread widening, top-of-book imbalance — are observed microstructure phenomena, not guaranteed predictive signals. Key limitations:

Adverse selection asymmetry: Market makers who widen spreads during announcements are responding to their own models. Their spread widening is partially a response to order flow toxicity that the depth snapshot reflects — but the direction of the eventual price move is not encoded in the depth alone. A beat-and-crash looks identical to a beat-and-rip in the first 30 seconds of depth data.

Latency: WebSocket delivery of depth data involves network latency (typically 20–80 ms for US equity data from major cloud regions). By the time the alert fires, the fastest participants have already traded. The alpha in depth monitoring comes from the structural pattern recognition, not from speed.

L1 data scope: For US equities, the depth channel provides L1 (top-of-book) data. This captures the most liquid portion of the order book but excludes deeper levels where institutional hidden orders may reside. A vacuum at L1 does not necessarily mean total market depth has collapsed.

Backtest disclaimer: The order book behavior described in the table above is synthesized from observed microstructure patterns across multiple earnings events. It is not the result of a single backtest run. For strategy validation, you should run multi-year event studies on your own data with the TickDB kline endpoint, capturing the closing price before and after each earnings event, and compute realized volatility against the implied volatility surface.


Closing

The order book is a live portrait of supply and demand — and for 40 seconds before most post-earnings price moves, that portrait tells you what the headline will not.

The pressure ratio inversion, the thinning bid side, the widening spread: these are not random noise. They are the signature of liquidity withdrawing from one side of the market faster than the other. For systematic traders who build the infrastructure to read this signal, the vacuum window is not a risk to avoid — it is a regime to recognize and navigate.

Start by capturing the baseline. Watch what normal looks like for your target symbol. Then set the alert thresholds, run the monitor, and let the depth data tell you what the price is about to do.


Next Steps

If you are an individual quant researcher interested in running this strategy on US equities, sign up at tickdb.ai for a free API key (no credit card required) and start capturing depth snapshots for any US equity symbol in real time.

If you need 10+ years of historical OHLCV data for backtesting earnings event strategies, the TickDB kline endpoint provides cleaned, aligned daily and intraday data suitable for cross-cycle validation. Access it via the same API key.

If you are running a multi-symbol surveillance operation during earnings season and need enterprise-grade rate limits, dedicated WebSocket connections, and historical depth snapshots, reach out to enterprise@tickdb.ai for institutional plans with SLA-backed uptime guarantees.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get API documentation, code completion, and pre-built strategy templates directly in your development environment.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. Order book dynamics described are observational patterns and not guaranteed predictive signals.