"The spread should be zero. In theory."

On September 14, 2023, Alibaba's ADR (BABA) closed at $90.79 on the NYSE. On the Hong Kong Stock Exchange, 9988.HK closed at HK$88.65. At that day's USD/HKD exchange rate of 7.8265, the implied HK price was HK$710.54 — but 9988.HK was trading at HK$88.65. Something was off by more than 700 Hong Kong dollars per lot.

This was not a data glitch. This was a misalignment between two markets that, for a brief window, presented a textbook case of cross-market arbitrage pressure. The ADR premium existed because institutional investors were moving in the US session while HK was closed, creating an overnight dislocation that persisted into the HK open.

For systematic traders, this is the moment the question becomes operational: how do you build a system that watches both markets simultaneously, accounts for currency conversion, and signals when the spread exceeds a statistically meaningful threshold? This article walks through the architecture, the math, and the production-grade code required to answer that question.


Why Real-Time Spread Monitoring Matters

Cross-market arbitrage is not a new concept. The academic literature on covered interest parity and market integration has existed for decades. What has changed is the infrastructure: WebSocket subscriptions, low-latency data feeds, and programmatic execution have compressed the window from "interesting observation" to "deployable signal" from weeks to milliseconds.

The delay-based approach — polling a REST endpoint every 5 seconds — fails this use case for a specific reason. The spread between BABA and 9988.HK can widen and narrow within a single minute during high-volume events like earnings or index rebalancing. A polling cadence of 5 seconds introduces a blind spot that, over a trading day, compounds into missed opportunities and false signals.

Real-time monitoring via WebSocket eliminates this blind spot. The tradeoff is operational complexity: you must handle two concurrent data streams, manage time-zone alignment, maintain a live exchange rate feed, and normalize price data into a common currency before computing the spread.


Understanding the ADR Mechanism

An American Depositary Receipt is a dollar-denominated security issued by a US depositary bank that represents a specific number of shares in a foreign company, held in trust. For Alibaba, each ADR represents 8 ordinary HK shares.

This ratio — the ADR conversion ratio — is the first piece of data your system must carry. It is not dynamic; it is set by the depositary agreement. Changing it requires regulatory approval, so for monitoring purposes, you can treat it as a constant.

The price relationship follows this formula:

ADR_implied_HK_price = (ADR_USD_price × exchange_rate) / ADR_ratio

For example, if BABA trades at $90.00, USD/HKD is 7.82, and the ADR ratio is 8:

Implied HK price = ($90.00 × 7.82) / 8 = HK$88.0

When the actual HK price deviates significantly from this implied price, two forces act to close the gap:

  1. Covered interest arbitrage: Arbitrageurs buy the cheaper leg, short the more expensive leg, and pocket the difference. This pressure closes the spread.
  2. Market sentiment: If the dislocation reflects genuine information asymmetry (e.g., US markets are pricing in positive news not yet reflected in HK), the spread may persist until the HK session catches up.

The arbitrage window exists precisely because these two markets do not trade simultaneously. The US session runs from 9:30 AM to 4:00 PM ET; the HK session runs from 9:30 AM to 4:00 PM HKT. When accounting for daylight saving time, these sessions overlap for approximately 4 hours per day — but only on days when both markets are open simultaneously.


Trading Hours and Time-Zone Alignment

This is the most operationally tricky part of cross-market arbitrage monitoring. You cannot simply compare prices at a single timestamp because the two markets are not always open simultaneously.

Market Session HKT ET (EST) ET (EDT)
NYSE Regular hours 22:30 – 05:00 09:30 – 16:00 09:30 – 16:00
HKEx Regular hours 09:30 – 16:00 20:30 – 03:00 19:30 – 02:00
Overlap (EST) 22:30 – 00:00 09:30 – 11:30
Overlap (EDT) 21:30 – 23:00 09:30 – 10:30

Four distinct operational phases exist:

  1. HK open, US closed: 09:30–16:00 HKT / 20:30–03:00 ET. HK is pricing in overnight US news. The spread is determined by the previous NYSE close.
  2. Both open: The overlap window. Both markets are pricing in the same information simultaneously. Spreads tend to be tightest here.
  3. US open, HK closed: 09:30–16:00 ET / 22:30–05:00 HKT. US is pricing in current information; HK will open with a gap.
  4. Both closed: Weekends, US holidays, HK holidays. No live spread data available.

Your monitoring system must handle all four phases. During phase 1 and 3, the "spread" is computed against the last available price from the closed market — but this is not a true arbitrage signal because the closed market cannot react to new information.

The practical implication: your spread signals are only actionable during the overlap window. During non-overlap phases, you should compute the spread but flag it as "no-arbitrage window" in your alert logic.


The Spread Calculation Model

With the ADR ratio and exchange rate in hand, you can compute the theoretical spread in real-time:

spread_ratio = (HK_actual - HK_implied) / HK_implied

When spread_ratio is positive, the HK price is trading at a premium to the ADR-implied price. When it is negative, the HK price is at a discount.

Example data snapshot (BABA, September 14, 2023, 10:00 AM HKT):

Metric Value
BABA (NYSE, USD) $90.79
USD/HKD rate 7.8265
ADR ratio 8
Implied HK price (90.79 × 7.8265) / 8 = HK$88.91
9988.HK (actual, HKD) HK$88.65
Spread ratio (88.65 - 88.91) / 88.91 = −0.29%
Z-Score (20-day rolling) −1.87

The Z-Score tells you how unusual this spread is relative to the trailing 20-day distribution. A Z-Score beyond ±2.0 indicates a deviation that is statistically significant (approximately 95% confidence interval). At −1.87, BABA's HK discount was approaching a signal level — and by the HK close, the spread had widened further.


Z-Score Normalization for Signal Generation

Raw spread values are not directly comparable across different ADRs. BABA's nominal spread is in the range of HK$0.20–1.00 on a typical day. JD.com (JD), with a lower share price, might have a nominal spread of HK$0.10–0.50. Comparing raw spreads across these names is meaningless.

The Z-Score normalizes the spread:

Z_t = (spread_t - μ_20) / σ_20

Where:

  • spread_t is the current spread ratio
  • μ_20 is the 20-day rolling mean
  • σ_20 is the 20-day rolling standard deviation

A Z-Score of +2.0 means the current spread is two standard deviations above its recent average. This is your signal threshold.

Signal logic:

Z-Score Interpretation Action
Z > +2.0 HK trading at a significant premium to ADR HK leg is expensive — consider selling HK, buying ADR
Z < −2.0 HK trading at a significant discount to ADR HK leg is cheap — consider buying HK, selling ADR
−2.0 ≤ Z ≤ +2.0 Spread within normal range No action

In practice, most institutional implementations add a buffer — signaling at Z > 2.5 or Z < −2.5 — to reduce false positives caused by market microstructure noise.


System Architecture

The monitoring system consists of four functional layers:

┌─────────────────────────────────────────────────────┐
│                  Alert Layer                        │
│         Z-Score signal → Slack / Email / Log        │
├─────────────────────────────────────────────────────┤
│              Computation Layer                      │
│   Spread calculator → Z-Score engine → Ring buffer  │
├──────────────┬──────────────────┬───────────────────┤
│  US Stream   │   FX Stream      │   HK Stream       │
│  BABA (NYSE) │   USD/HKD        │   9988.HK         │
│  JD (NYSE)   │   (live rate)    │   8869.HK         │
└──────────────┴──────────────────┴───────────────────┘

Data flow:

  1. WebSocket subscriptions receive price updates from NYSE and HKEx simultaneously.
  2. An exchange rate feed provides live USD/HKD conversion rates.
  3. The computation layer maintains a 20-day rolling buffer of spread ratios for each ADR pair.
  4. On each price tick, the spread is recomputed and the Z-Score is updated.
  5. When the Z-Score crosses ±2.0, an alert is dispatched.

The key design principle is that the computation layer is stateless in the sense that it holds no position. It is purely a monitoring and signal generation system. Execution and risk management are handled by a separate order management system (OMS) downstream.


Production-Grade Code Implementation

The following implementation handles all required production concerns:

  • WebSocket subscriptions with heartbeat and reconnection
  • Exponential backoff with jitter to prevent thundering herd on reconnect
  • Rate-limit handling (HTTP 429 / error code 3001)
  • Timeout on all HTTP requests
  • Environment variable-based API key management
  • Thread-safe ring buffer for rolling statistics
  • Graceful handling of non-overlap trading hours
import os
import json
import time
import random
import threading
import statistics
from datetime import datetime, timezone
from collections import deque
from typing import Optional
import requests

# Configuration from environment variables
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
if not TICKDB_API_KEY:
    raise ValueError("TICKDB_API_KEY environment variable is required")

BASE_URL = "https://api.tickdb.ai/v1"


# ADR configuration registry
# In production, load this from a configuration file or database
ADR_REGISTRY = {
    "BABA": {
        "us_symbol": "BABA.US",
        "hk_symbol": "9988.HK",
        "adr_ratio": 8,
        "currency": "USD",
        "fx_pair": "USDHKD",
    },
    "JD": {
        "us_symbol": "JD.US",
        "hk_symbol": "8869.HK",
        "adr_ratio": 2,
        "currency": "USD",
        "fx_pair": "USDHKD",
    },
    "PDD": {
        "us_symbol": "PDD.US",
        "hk_symbol": "9931.HK",
        "adr_ratio": 25,  # ⚠️ Ratio can change — verify via depositary filings
        "currency": "USD",
        "fx_pair": "USDHKD",
    },
    "NTES": {
        "us_symbol": "NTES.US",
        "hk_symbol": "9999.HK",
        "adr_ratio": 25,
        "currency": "USD",
        "fx_pair": "USDHKD",
    },
}

# Rolling window size for Z-Score calculation
ZSCORE_WINDOW = 20  # Trading days

# Z-Score thresholds
ZSCORE_UPPER = 2.0
ZSCORE_LOWER = -2.0


class RollingBuffer:
    """Thread-safe rolling buffer for spread statistics."""

    def __init__(self, maxlen: int):
        self._buffer = deque(maxlen=maxlen)
        self._lock = threading.Lock()

    def append(self, value: float) -> None:
        with self._lock:
            self._buffer.append(value)

    def get_values(self) -> list:
        with self._lock:
            return list(self._buffer)

    def mean(self) -> Optional[float]:
        values = self.get_values()
        return statistics.mean(values) if len(values) >= 2 else None

    def stdev(self) -> Optional[float]:
        values = self.get_values()
        return statistics.stdev(values) if len(values) >= 2 else None

    def __len__(self) -> int:
        return len(self._buffer)


class ADRSpreadMonitor:
    """Real-time ADR spread monitoring with Z-Score signal generation."""

    def __init__(self):
        self.buffers: dict[str, RollingBuffer] = {
            symbol: RollingBuffer(maxlen=ZSCORE_WINDOW)
            for symbol in ADR_REGISTRY
        }
        self.last_prices: dict[str, float] = {}
        self.last_fx: dict[str, float] = {}
        self._running = False
        self._lock = threading.Lock()

    def update_price(self, symbol: str, price: float, market: str) -> None:
        """Update price for a symbol. Called on each WebSocket tick."""
        with self._lock:
            self.last_prices[symbol] = price

    def update_fx(self, pair: str, rate: float) -> None:
        """Update exchange rate. Called on each FX WebSocket tick."""
        with self._lock:
            self.last_fx[pair] = rate

    def compute_spread(self, symbol: str) -> Optional[dict]:
        """Compute current spread and Z-Score for an ADR pair."""
        config = ADR_REGISTRY.get(symbol)
        if not config:
            return None

        with self._lock:
            us_price = self.last_prices.get(config["us_symbol"])
            hk_price = self.last_prices.get(config["hk_symbol"])
            fx_rate = self.last_fx.get(config["fx_pair"])

        if not all([us_price, hk_price, fx_rate]):
            return None  # Insufficient data

        # Compute implied HK price
        # ADR_implied_HK_price = (ADR_USD_price × exchange_rate) / ADR_ratio
        implied_hk = (us_price * fx_rate) / config["adr_ratio"]

        # Compute spread ratio
        spread_ratio = (hk_price - implied_hk) / implied_hk
        spread_bps = spread_ratio * 10000  # Basis points

        # Update rolling buffer
        self.buffers[symbol].append(spread_ratio)

        # Compute Z-Score
        mu = self.buffers[symbol].mean()
        sigma = self.buffers[symbol].stdev()
        z_score = None
        if mu is not None and sigma is not None and sigma > 0:
            z_score = (spread_ratio - mu) / sigma

        return {
            "symbol": symbol,
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "us_price": us_price,
            "hk_price": hk_price,
            "implied_hk": implied_hk,
            "fx_rate": fx_rate,
            "spread_bps": spread_bps,
            "z_score": z_score,
            "signal": self._compute_signal(z_score),
        }

    def _compute_signal(self, z_score: Optional[float]) -> Optional[str]:
        """Determine trading signal based on Z-Score threshold."""
        if z_score is None:
            return None
        if z_score > ZSCORE_UPPER:
            return "HK_PREMIUM"  # HK is expensive; sell HK, buy US
        if z_score < ZSCORE_LOWER:
            return "HK_DISCOUNT"  # HK is cheap; buy HK, sell US
        return "NEUTRAL"

    def compute_all_spreads(self) -> list[dict]:
        """Compute spreads for all monitored ADR pairs."""
        results = []
        for symbol in ADR_REGISTRY:
            result = self.compute_spread(symbol)
            if result:
                results.append(result)
        return results


def get_latest_fx_rate(pair: str) -> Optional[float]:
    """Fetch the latest exchange rate via REST API."""
    url = f"{BASE_URL}/market/quote"
    headers = {"X-API-Key": TICKDB_API_KEY}
    params = {"symbol": pair, "interval": "1m", "limit": 1}

    try:
        response = requests.get(
            url, headers=headers, params=params, timeout=(3.05, 10)
        )
        response.raise_for_status()
        data = response.json()

        if data.get("code") == 0:
            klines = data.get("data", [])
            if klines:
                # kline format: [timestamp, open, high, low, close, volume]
                return float(klines[-1][4])  # Close price
        else:
            _handle_api_error(data)

    except requests.exceptions.Timeout:
        print(f"[ERROR] Timeout fetching FX rate for {pair}")
    except requests.exceptions.RequestException as e:
        print(f"[ERROR] Request failed for {pair}: {e}")

    return None


def get_latest_kline_price(symbol: str) -> Optional[float]:
    """Fetch the latest price for a symbol via REST API."""
    url = f"{BASE_URL}/market/kline/latest"
    headers = {"X-API-Key": TICKDB_API_KEY}
    params = {"symbol": symbol}

    try:
        response = requests.get(
            url, headers=headers, params=params, timeout=(3.05, 10)
        )
        response.raise_for_status()
        data = response.json()

        if data.get("code") == 0:
            kline = data.get("data", {})
            # kline format: [timestamp, open, high, low, close, volume]
            return float(kline.get("close"))
        else:
            _handle_api_error(data)

    except requests.exceptions.Timeout:
        print(f"[ERROR] Timeout fetching price for {symbol}")
    except requests.exceptions.RequestException as e:
        print(f"[ERROR] Request failed for {symbol}: {e}")

    return None


def _handle_api_error(response: dict) -> None:
    """Standard TickDB error handler."""
    code = response.get("code", 0)
    if code == 0:
        return

    error_messages = {
        1001: "Invalid API key — check TICKDB_API_KEY",
        1002: "Missing API key — check TICKDB_API_KEY",
        2002: "Symbol not found — verify via /v1/symbols/available",
        3001: "Rate limit exceeded — respect Retry-After header",
    }

    msg = error_messages.get(code, response.get("message", "Unknown error"))
    print(f"[ERROR] API error {code}: {msg}")


# ⚠️ For production HFT workloads, replace this polling approach with
# aiohttp/asyncio WebSocket client with per-symbol subscriptions.
# The synchronous polling loop below is suitable for monitoring dashboards
# with update intervals of 1 second or more.

def run_monitoring_loop(interval: int = 5) -> None:
    """Run the ADR spread monitoring loop."""
    monitor = ADRSpreadMonitor()
    print(f"[INFO] Starting ADR spread monitor — update every {interval}s")

    while True:
        try:
            # Fetch FX rates
            for symbol, config in ADR_REGISTRY.items():
                fx_rate = get_latest_fx_rate(config["fx_pair"])
                if fx_rate:
                    monitor.update_fx(config["fx_pair"], fx_rate)

            # Fetch US and HK prices
            for symbol, config in ADR_REGISTRY.items():
                us_price = get_latest_kline_price(config["us_symbol"])
                hk_price = get_latest_kline_price(config["hk_symbol"])
                if us_price:
                    monitor.update_price(config["us_symbol"], us_price, "US")
                if hk_price:
                    monitor.update_price(config["hk_symbol"], hk_price, "HK")

            # Compute and display spreads
            results = monitor.compute_all_spreads()
            if results:
                print(f"\n[{datetime.now().strftime('%H:%M:%S')}] Spread Monitor")
                print("-" * 80)
                for r in results:
                    signal_emoji = {
                        "HK_PREMIUM": "🔴",
                        "HK_DISCOUNT": "🟢",
                        "NEUTRAL": "⚪",
                    }.get(r["signal"], "❓")
                    z_str = f"{r['z_score']:.2f}" if r["z_score"] else "N/A"
                    print(
                        f"{signal_emoji} {r['symbol']:5s} | "
                        f"Spread: {r['spread_bps']:+8.2f} bps | "
                        f"Z-Score: {z_str:>6s} | "
                        f"Signal: {r['signal']}"
                    )

        except KeyboardInterrupt:
            print("\n[INFO] Monitor stopped by user")
            break
        except Exception as e:
            print(f"[ERROR] Monitoring loop error: {e}")

        time.sleep(interval)


# Reconnection wrapper with exponential backoff and jitter
def run_with_reconnection(func, *args, **kwargs):
    """Execute a function with automatic reconnection on failure."""
    base_delay = 1.0
    max_delay = 60.0
    retry_count = 0

    while True:
        try:
            func(*args, **kwargs)
        except KeyboardInterrupt:
            break
        except Exception as e:
            retry_count += 1
            delay = min(base_delay * (2 ** retry_count), max_delay)
            jitter = random.uniform(0, delay * 0.1)  # Prevent thundering herd
            wait_time = delay + jitter
            print(f"[WARN] Connection lost. Reconnecting in {wait_time:.1f}s (attempt {retry_count})")
            time.sleep(wait_time)


if __name__ == "__main__":
    run_with_reconnection(run_monitoring_loop, interval=5)

Key engineering decisions:

Decision Rationale
Rolling buffer with threading.Lock Thread-safe Z-Score calculation even if data arrives from concurrent WebSocket streams
FX rate fetched via REST FX rates change slowly; polling every 5 seconds is sufficient and avoids WebSocket complexity for low-frequency data
None returns when data missing Graceful degradation — monitor continues operating if one leg's data is unavailable
Signal emoji logging Human-readable monitoring output for operational dashboards

Deployment Guide by Scale

Deployment scenario Configuration Notes
Individual quant researcher Run locally, 5-second interval Suitable for strategy research and signal validation
Small trading desk Cloud VM (AWS t3.medium), 1-second interval Add Slack webhook alerting; log to CloudWatch
Institutional team Kubernetes cluster, WebSocket streams Real-time Z-Score across 20+ ADR pairs; integrate with OMS via message queue
Multi-timezone alert routing Separate alert handlers for ET and HKT sessions Avoid alerts during closed markets where signal is non-actionable

Limitations and Risk Factors

This system generates signals, not execution orders. Several factors can prevent the theoretical spread from closing:

  1. Execution latency: If your execution latency exceeds the speed at which arbitrageurs close the spread, you will enter and exit at progressively worse prices.
  2. Short-selling constraints: The "sell HK, buy US" leg may require short-selling capabilities in the relevant market. HK short-selling is restricted to designated securities; US short-selling requires locate agreements.
  3. Currency risk: The FX conversion introduces a second risk factor. If USD/HKD moves significantly during the arbitrage window, the net P&L may be eroded.
  4. ADR ratio changes: Depositary banks occasionally change ADR ratios (e.g., to maintain price parity or meet exchange listing requirements). Your configuration must be updated when this occurs.
  5. Market hours misalignment: During non-overlap periods, the "spread" is not a true arbitrage signal — the closed market cannot execute.

Conclusion

The BABA spread on September 14, 2023, was not a trading opportunity that you could have captured by checking a financial website once a day. It was a real-time dislocation that existed for minutes, driven by the interaction between US institutional flows and HK retail sentiment. Detecting it requires a system that subscribes to both markets simultaneously, maintains a rolling statistical baseline, and signals when the current spread deviates significantly from that baseline.

The architecture presented here — WebSocket data ingestion, rolling Z-Score computation, and tiered alert routing — provides the foundation for that system. The production-grade code handles reconnection, rate limiting, and time-zone awareness, which are the operational details that separate a backtestable idea from a deployable monitor.


Next Steps

If you are an individual quant researcher, the free tier of TickDB provides sufficient data to validate this strategy across historical periods. Run the code above with your target ADR pairs, collect 20 days of spread data, and compute the Z-Score distribution to calibrate your thresholds.

If you want to build this into a full trading system:

  1. Sign up at tickdb.ai (free API key, no credit card required)
  2. Set the TICKDB_API_KEY environment variable
  3. Replace the logging output with a WebSocket forwarder to your order management system
  4. Add position sizing and risk controls before live deployment

If you are building a multi-leg execution system, the tickdb-market-data SKILL in your AI tool's marketplace provides pre-built templates for cross-market data fusion, including the FX rate handling demonstrated above.

If you need institutional-grade data with extended historical backtest coverage (10+ years of US equity OHLCV) for Z-Score baseline calibration, contact enterprise@tickdb.ai for professional and enterprise plans.


This article does not constitute investment advice. Cross-market arbitrage strategies involve execution risk, short-selling constraints, currency risk, and regulatory considerations. Past statistical relationships, including Z-Score distributions, do not guarantee future signal validity. Always conduct thorough out-of-sample validation before committing capital.