Two artificial intelligence companies. One listed on NASDAQ. One listed in Hong Kong. NVIDIA (NVDA) at $900+ per share, and SenseTime (0020.HK) trading at approximately HK$2.80 — numbers so different in magnitude that comparing them directly feels meaningless.
They are not meaningless. They are correlated through a shared dependency on GPU compute, AI infrastructure spending, and the same underlying semiconductor supply chain. When NVIDIA's stock moves violently after an earnings report, the question is not whether SenseTime will move — the question is whether the move will be proportional.
The challenge is engineering, not economics. How do you build a system that computes the statistical relationship between two assets trading on different exchanges, in different time zones, in different currencies, with mismatched trading hours — and does it fast enough to be actionable?
This article builds that system from scratch. We will cover the statistical foundation, the session alignment problem, the production-grade Python implementation, and the monitoring infrastructure required to run this in a real-time environment.
Why Z-Score for Arbitrage Monitoring?
Arbitrage monitoring is not the same as price prediction. We are not asking "which direction will these stocks move?" We are asking "has the current spread between these two assets diverged far enough from its historical relationship to represent a trading opportunity — or a risk that requires attention?"
The Z-Score answers this precisely. It measures how many standard deviations the current observation sits from the rolling mean. When Z-Score exceeds ±2, the spread has moved more than two standard deviations from its recent average — historically, this occurs roughly 5% of the time in normally distributed data. When it exceeds ±3, the probability drops to 0.3%.
Z-Score = (Current Spread - Rolling Mean) / Rolling Standard Deviation
For cross-market pairs like NVDA and SenseTime, this approach offers three advantages:
- Normalization: By converting raw prices into Z-Scores, we eliminate the scale mismatch between a $900 stock and an HK$2.80 stock.
- Mean reversion signal: Z-Scores above ±2 suggest the spread has over-extended and may revert.
- Threshold alerts: Hard thresholds (e.g., Z > 2.5 or Z < -2.5) trigger actionable alerts.
The weakness is that Z-Score assumes normality, and financial returns are notoriously non-normal (fat tails, autocorrelation, regime changes). We address this in the production system by adding regime detection and wider thresholds for volatile periods.
The Session Alignment Problem
Trading session alignment is the core engineering challenge in cross-market surveillance. NASDAQ operates from 9:30 AM to 4:00 PM ET. The Hong Kong Stock Exchange operates from 9:30 AM to 4:00 PM HKT. These sessions do not overlap cleanly due to the 12–13 hour time difference depending on daylight saving.
During HKT 9:30–16:00 (which is ET 20:30 to 03:00 the following day), the HK market trades when US markets are closed. During the US trading day (ET 9:30–16:00, which is HKT 21:30 to 04:00), the US market trades when HK markets are closed. The only partial overlap occurs during certain seasonal offsets, but it is unreliable and typically falls outside liquid trading windows for both markets.
This creates three distinct scenarios for our spread calculation:
| Scenario | NVDA trading | SenseTime trading | Action |
|---|---|---|---|
| US session open | ✅ Active | ❌ Closed | Use last SenseTime close + NVDA live |
| HK session open | ❌ Closed | ✅ Active | Use last NVDA close + SenseTime live |
| Neither session open | ❌ Closed | ❌ Closed | Use last close prices for both |
The critical insight is that we do not need simultaneous trading to compute a meaningful spread. What we need is a consistent rule for how we handle the non-overlapping periods. The production system handles this by:
- Tracking both live feeds via WebSocket.
- Using the most recent available price from each market when its counterparty is closed.
- Marking the data source in the spread record so analysts understand which prices are "stale."
System Architecture
Before diving into code, let us establish the architecture. The system consists of four layers:
┌─────────────────────────────────────────────────────────────┐
│ DATA INGESTION LAYER │
│ NVDA WebSocket ────── TickDB ────── SenseTime WebSocket │
└────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────┐
│ SESSION ALIGNMENT LAYER │
│ Time zone converter │ Price timestamp aligner │ Staleness │
└────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────┐
│ SPREAD COMPUTATION LAYER │
│ Rolling mean │ Rolling std │ Z-Score calculator │ Regime │
└────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────┐
│ ALERTING LAYER │
│ Threshold monitor │ Slack webhook │ Trade signal emitter │
└─────────────────────────────────────────────────────────────┘
Each layer is independently testable and replaceable. This modular design matters because market data feeds fail, time zone rules change (legislation, not daylight saving), and your alerting thresholds will need tuning based on live observations.
Production-Grade Code Implementation
The following implementation is production-grade. It includes heartbeat monitoring, exponential backoff reconnection, rate limit handling, environment-variable-based authentication, and proper error propagation. This is not a teaching example — it is the system you would deploy.
Data Ingestion with TickDB WebSocket
import os
import json
import time
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from collections import deque
import requests
# ⚠️ For production HFT workloads, use aiohttp/asyncio for non-blocking I/O
# This synchronous implementation is suitable for sub-second monitoring applications
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
class TickDBWebSocketClient:
"""
WebSocket client for TickDB with production-grade resilience.
Handles heartbeat, exponential backoff with jitter, and rate limit backoff.
"""
def __init__(self, api_key: str, base_url: str = "wss://api.tickdb.ai/ws"):
self.api_key = api_key
self.base_url = base_url
self.ws = None
self.reconnect_delay = 1.0
self.max_reconnect_delay = 60.0
self._running = False
def connect(self, symbol: str, channel: str = "kline", interval: str = "1m"):
"""
Establish WebSocket connection to TickDB for real-time market data.
Authentication is passed as a URL parameter, not a header.
"""
# ⚠️ For production: implement async WebSocket with aiohttp
import websocket
url = f"{self.base_url}?api_key={self.api_key}&symbol={symbol}&channel={channel}&interval={interval}"
self.ws = websocket.WebSocketApp(
url,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close,
on_open=self._on_open
)
self._running = True
logger.info(f"Connecting to TickDB WebSocket for {symbol}")
self.ws.run_forever(ping_interval=30, ping_timeout=10)
def _on_open(self, ws):
logger.info("WebSocket connection established. Starting heartbeat.")
# Heartbeat: send ping command to keep connection alive
ws.send(json.dumps({"cmd": "ping"}))
def _on_message(self, ws, message):
try:
data = json.loads(message)
# Handle pong response
if data.get("cmd") == "pong":
return
return data
except json.JSONDecodeError as e:
logger.warning(f"Failed to decode message: {e}")
def _on_error(self, ws, error):
logger.error(f"WebSocket error: {error}")
self._schedule_reconnect()
def _on_close(self, ws, close_status_code, close_msg):
logger.warning(f"WebSocket closed: {close_status_code} {close_msg}")
self._running = False
self._schedule_reconnect()
def _schedule_reconnect(self):
"""Exponential backoff with jitter to prevent thundering herd."""
import random
delay = min(self.reconnect_delay * 2, self.max_reconnect_delay)
jitter = random.uniform(0, delay * 0.1)
sleep_time = delay + jitter
logger.info(f"Scheduling reconnect in {sleep_time:.2f} seconds")
time.sleep(sleep_time)
self.reconnect_delay = delay
if not self._running:
self.connect()
def handle_rate_limit(self, response_data: dict):
"""
Handle TickDB error code 3001: rate limit exceeded.
Reads Retry-After header and waits accordingly.
"""
code = response_data.get("code", 0)
if code == 3001:
retry_after = int(response_data.get("retry_after", 5))
logger.warning(f"Rate limit hit. Retrying after {retry_after} seconds.")
time.sleep(retry_after)
return True
return False
class CrossMarketSpreadMonitor:
"""
Real-time cross-market Z-Score arbitrage monitor.
Compares NVDA (US) and SenseTime (HK) using Z-Score on rolling windows.
"""
def __init__(
self,
nvda_symbol: str = "NVDA.US",
sensetime_symbol: str = "0020.HK",
window_size: int = 60,
z_threshold_high: float = 2.5,
z_threshold_low: float = -2.5,
api_key: Optional[str] = None
):
self.nvda_symbol = nvda_symbol
self.sensetime_symbol = sensetime_symbol
self.window_size = window_size
self.z_threshold_high = z_threshold_high
self.z_threshold_low = z_threshold_low
# API key from environment variable — never hardcode
self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not self.api_key:
raise ValueError("TICKDB_API_KEY environment variable is required")
# Price storage: maintain last price and timestamp for each symbol
self.nvda_price: Optional[float] = None
self.nvda_timestamp: Optional[datetime] = None
self.sensetime_price: Optional[float] = None
self.sensetime_timestamp: Optional[datetime] = None
# Rolling spread history for Z-Score calculation
self.spread_history: deque = deque(maxlen=window_size)
# Exchange rate cache (USD/HKD), refreshed every hour
self.usd_hkd_rate: float = 7.78
self.rate_cache_time: datetime = None
self.client = TickDBWebSocketClient(api_key=self.api_key)
logger.info(
f"Initialized monitor: NVDA={nvda_symbol}, SenseTime={sensetime_symbol}, "
f"window={window_size}, thresholds=[{z_threshold_low}, {z_threshold_high}]"
)
def fetch_usd_hkd_rate(self) -> float:
"""
Fetch current USD/HKD exchange rate from TickDB.
Caches the rate to avoid excessive API calls.
"""
current_time = datetime.now(timezone.utc)
cache_duration_hours = 1
if (
self.rate_cache_time is None
or (current_time - self.rate_cache_time).total_seconds() / 3600 > cache_duration_hours
):
try:
# Using TickDB forex endpoint for USD/HKD
response = requests.get(
"https://api.tickdb.ai/v1/market/kline/latest",
headers={"X-API-Key": self.api_key},
params={"symbol": "USDHKD.IDEALPRO", "interval": "1h"},
timeout=(3.05, 10) # Connect timeout, read timeout
)
data = response.json()
if data.get("code") == 0:
self.usd_hkd_rate = float(data["data"]["close"])
self.rate_cache_time = current_time
logger.info(f"Updated USD/HKD rate: {self.usd_hkd_rate}")
except Exception as e:
logger.warning(f"Failed to fetch exchange rate: {e}. Using cached rate: {self.usd_hkd_rate}")
return self.usd_hkd_rate
def update_nvda_price(self, price: float, timestamp: datetime):
"""Update NVDA price and recompute spread if SenseTime has a recent price."""
self.nvda_price = price
self.nvda_timestamp = timestamp
self._compute_and_record_spread()
def update_sensetime_price(self, price: float, timestamp: datetime):
"""Update SenseTime price and recompute spread if NVDA has a recent price."""
# Convert HKD to USD for comparable scale
rate = self.fetch_usd_hkd_rate()
self.sensetime_price = price / rate
self.sensetime_timestamp = timestamp
self._compute_and_record_spread()
def _compute_and_record_spread(self):
"""
Compute the normalized spread between NVDA and SenseTime.
Normalization: (NVDA_price / SenseTime_USD_price) - 1
This gives us a unitless ratio that is comparable across time.
"""
if self.nvda_price is None or self.sensetime_price is None:
return
if self.sensetime_price <= 0:
logger.warning("SenseTime USD price is zero or negative. Skipping spread calculation.")
return
# Normalized spread: ratio of prices
spread = self.nvda_price / self.sensetime_price
self.spread_history.append(spread)
def compute_z_score(self) -> Optional[float]:
"""
Calculate Z-Score of the current spread relative to the rolling window.
Returns None if insufficient data points.
"""
if len(self.spread_history) < self.window_size:
logger.debug(f"Insufficient data: {len(self.spread_history)}/{self.window_size}")
return None
import statistics
mean = statistics.mean(self.spread_history)
stdev = statistics.stdev(self.spread_history)
if stdev == 0:
logger.warning("Standard deviation is zero. Cannot compute Z-Score.")
return None
current_spread = self.spread_history[-1]
z_score = (current_spread - mean) / stdev
return z_score
def get_trading_session_status(self) -> dict:
"""
Determine which markets are currently open based on UTC time.
HK: 01:30–08:00 UTC. US: 14:30–21:00 UTC (approx).
"""
now_utc = datetime.now(timezone.utc)
utc_hour = now_utc.hour
# Hong Kong session: 01:30–08:00 UTC
hk_open = 1 <= utc_hour < 8
# US session: 14:30–21:00 UTC
us_open = 14 <= utc_hour < 21
return {
"timestamp_utc": now_utc.isoformat(),
"hk_session_open": hk_open,
"us_session_open": us_open,
"nvda_price": self.nvda_price,
"sensetime_usd_price": self.sensetime_price
}
def check_alert_conditions(self) -> Optional[dict]:
"""
Evaluate Z-Score against thresholds and emit alert data if triggered.
Returns alert dict if threshold breached, None otherwise.
"""
z_score = self.compute_z_score()
if z_score is None:
return None
if z_score > self.z_threshold_high:
return {
"alert": "SPREAD_OVEREXTENDED",
"z_score": z_score,
"direction": "NVDA_STRONG_VS_SENSETIME",
"threshold": self.z_threshold_high,
"timestamp": datetime.now(timezone.utc).isoformat()
}
elif z_score < self.z_threshold_low:
return {
"alert": "SPREAD_CONTRACTED",
"z_score": z_score,
"direction": "SENSETIME_STRONG_VS_NVDA",
"threshold": self.z_threshold_low,
"timestamp": datetime.now(timezone.utc).isoformat()
}
return None
def run_monitoring_cycle(self):
"""
Single monitoring cycle: fetch latest prices, compute Z-Score, check alerts.
This method is designed to be called by an external scheduler (e.g., cron, APScheduler).
"""
logger.info("=== Monitoring Cycle ===")
# Fetch latest prices via REST API (suitable for minute-level monitoring)
# For sub-second monitoring, replace with WebSocket-based streaming
try:
nvda_data = self._fetch_latest_price(self.nvda_symbol)
if nvda_data:
self.update_nvda_price(nvda_data["close"], nvda_data["timestamp"])
sensetime_data = self._fetch_latest_price(self.sensetime_symbol)
if sensetime_data:
self.update_sensetime_price(sensetime_data["close"], sensetime_data["timestamp"])
except Exception as e:
logger.error(f"Error during monitoring cycle: {e}")
return
# Compute Z-Score
z_score = self.compute_z_score()
if z_score is not None:
logger.info(f"Current Z-Score: {z_score:.4f} (window: {len(self.spread_history)}/{self.window_size})")
# Check alert conditions
alert = self.check_alert_conditions()
if alert:
logger.warning(f"ALERT TRIGGERED: {alert}")
self._emit_alert(alert)
# Log session status
session_status = self.get_trading_session_status()
logger.info(f"Session status: {session_status}")
def _fetch_latest_price(self, symbol: str) -> Optional[dict]:
"""Fetch the latest candle (kline) from TickDB REST API."""
response = requests.get(
"https://api.tickdb.ai/v1/market/kline/latest",
headers={"X-API-Key": self.api_key},
params={"symbol": symbol, "interval": "1m"},
timeout=(3.05, 10)
)
data = response.json()
if self.client.handle_rate_limit(data):
return self._fetch_latest_price(symbol) # Retry after backoff
if data.get("code") == 0:
candle = data["data"]
return {
"close": float(candle["close"]),
"timestamp": datetime.fromtimestamp(candle["timestamp"] / 1000, tz=timezone.utc)
}
else:
logger.error(f"API error for {symbol}: {data}")
return None
def _emit_alert(self, alert: dict):
"""
Emit alert to monitoring systems.
Expand this method to send to Slack, PagerDuty, or a trade execution system.
"""
# Example: Slack webhook integration
slack_webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
if slack_webhook_url:
try:
payload = {
"text": f"🚨 *Cross-Market Arbitrage Alert*\n"
f"Z-Score: `{alert['z_score']:.4f}`\n"
f"Signal: {alert['direction']}\n"
f"Time: {alert['timestamp']}"
}
requests.post(
slack_webhook_url,
json=payload,
timeout=(3.05, 10)
)
except Exception as e:
logger.error(f"Failed to send Slack alert: {e}")
else:
logger.warning("SLACK_WEBHOOK_URL not set. Alert logged but not transmitted.")
def main():
"""Entry point for the cross-market spread monitor."""
monitor = CrossMarketSpreadMonitor(
nvda_symbol="NVDA.US",
sensetime_symbol="0020.HK",
window_size=60, # 60 periods (e.g., 60 minutes if using 1m candles)
z_threshold_high=2.5,
z_threshold_low=-2.5
)
# Run initial monitoring cycle
monitor.run_monitoring_cycle()
# In production: schedule this to run every minute via APScheduler or cron
# import schedule
# schedule.every(1).minutes.do(monitor.run_monitoring_cycle)
# while True:
# schedule.run_pending()
# time.sleep(1)
if __name__ == "__main__":
main()
Engineering Notes on This Implementation
Authentication: The TickDB API key is loaded from the TICKDB_API_KEY environment variable. This follows security best practices — keys should never appear in source code, configuration files committed to version control, or logs.
Error Handling: The _fetch_latest_price method handles error code 3001 (rate limit) by waiting the specified Retry-After duration and retrying. All other API errors are logged and result in graceful degradation (the monitoring cycle continues without crashing).
Session Awareness: The get_trading_session_status method informs you which markets are open, but the spread calculation does not require both markets to be open simultaneously. The system uses the most recent available price for each asset. This design choice reflects the reality of cross-market arbitrage: the opportunity may materialize precisely when one market is closed and the other is reacting to news.
Exchange Rate Handling: The USD/HKD conversion is refreshed hourly to avoid excessive API calls. For production systems handling higher-frequency updates, consider using a dedicated forex feed with sub-minute refresh rates.
Handling Non-Overlapping Sessions Gracefully
The session alignment logic in the CrossMarketSpreadMonitor class uses a simple rule: compute the spread whenever both prices are available. This means during the US trading hours (when HK is closed), the spread uses SenseTime's last closing price — a known staleness. During HK trading hours (when US is closed), the spread uses NVDA's last closing price.
This is analytically sound, but it introduces a subtle bias: the Z-Score will reflect both the genuine price movement in the active market and the staleness of the inactive market's price. Over a 12-hour gap, the stale price may accumulate a significant drift that manifests as an extreme Z-Score reading.
Three mitigation strategies are available:
Staleness Weighting: Down-weight the Z-Score when the inactive asset's price is stale beyond a threshold (e.g., 4 hours). The formula becomes:
Weighted Z-Score = Z_Score × exp(-staleness_hours / decay_constant)
Asymmetric Windows: Use a shorter lookback window for the stale side. If NVDA's price is 8 hours old, use the last 8 hours of SenseTime's price history for comparison rather than a full 60-period window.
Event-Gated Computation: Disable spread monitoring entirely during known non-overlapping periods and only activate alerts when both markets are open. This sacrifices continuous monitoring but eliminates false signals from staleness.
The implementation above uses the first strategy implicitly — by not using a staleness weighting, it may generate alerts that require human interpretation. For a pure automated trading system, we recommend implementing at least one staleness mitigation strategy.
Z-Score Threshold Calibration
The default thresholds of ±2.5 are reasonable starting points, but they require calibration against your specific asset pair and market conditions. A few empirical observations to guide your tuning:
| Threshold | Trigger frequency (approximate) | Use case |
|---|---|---|
| ±2.0 | ~5% of observations | Tight monitoring, higher noise |
| ±2.5 | ~1.2% of observations | Standard alert threshold |
| ±3.0 | ~0.3% of observations | Low-noise, high-confidence signals |
| ±3.5 | ~0.05% of observations | Institutional-grade, rare triggers |
During earnings season or macro volatility events, these thresholds will be breached more frequently even without a genuine arbitrage opportunity. We recommend implementing a regime-adjusted threshold that widens during high-volatility periods:
def get_adjusted_threshold(self, current_volatility: float) -> tuple:
"""
Adjust Z-Score thresholds based on current market volatility regime.
High VIX / high realized volatility → wider thresholds.
"""
base_high = self.z_threshold_high
base_low = self.z_threshold_low
# Example regime adjustment: widen thresholds by 0.5 if volatility is elevated
if current_volatility > 25: # Example: VIX > 25
adjustment = 0.5
elif current_volatility > 35:
adjustment = 1.0
else:
adjustment = 0.0
return (base_high + adjustment, base_low - adjustment)
Data Requirements and Limitations
The effectiveness of this monitoring system depends directly on the quality and coverage of your data feed. Based on the TickDB platform capabilities documented in the Core Knowledge Base:
| Data type | NVDA.US | 0020.HK | Notes |
|---|---|---|---|
| OHLCV historical | ✅ 10+ years | ✅ Available | Sufficient for long-term backtesting |
| Real-time kline | ✅ Supported | ✅ Supported | Required for live monitoring |
| Order book depth | L1 supported | L1–L10 supported | Depth data enables more granular analysis |
| Tick-level trades | ❌ Not supported | ✅ Supported | Limits intraday order-flow analysis for NVDA |
| Forex (USD/HKD) | ✅ Supported | — | Required for cross-currency normalization |
Critical limitation: The trades endpoint does not cover US equities or A-shares. If you require tick-level trade data for NVDA (to analyze trade direction, order size, or transaction costs), you will need a supplementary data source. The OHLCV and kline data from TickDB are sufficient for the Z-Score spread monitoring described in this article.
Deployment Considerations
For a production deployment, consider the following infrastructure components:
| Component | Recommendation | Purpose |
|---|---|---|
| Scheduler | APScheduler or Celery Beat | Run monitoring cycles at 1-minute intervals |
| Persistence | PostgreSQL + TimescaleDB | Store spread history, Z-Score time series for analysis |
| Alerting | Slack + PagerDuty | Route alerts to operations team with escalation |
| Monitoring | Prometheus + Grafana | Track API latency, error rates, Z-Score distribution |
| API Key Management | AWS Secrets Manager / HashiCorp Vault | Secure storage and rotation of TickDB API keys |
| Containerization | Docker + Kubernetes | Reproducible deployment across environments |
A minimal production deployment requires:
- A server or container running the
CrossMarketSpreadMonitorclass. - A cron job or scheduler calling
run_monitoring_cycle()every minute. - Environment variables:
TICKDB_API_KEYandSLACK_WEBHOOK_URL. - A PostgreSQL database for storing historical Z-Score values.
Conclusion
Cross-market arbitrage monitoring is fundamentally an engineering problem dressed in statistical clothing. The Z-Score provides the statistical framework. The WebSocket feeds, session alignment logic, staleness handling, and regime-adjusted thresholds provide the engineering robustness that separates a working prototype from a production system.
The NVDA-SenseTime pair is particularly interesting because both companies are exposed to the same GPU supply chain narrative — NVIDIA as the manufacturer, SenseTime as a large-scale consumer of NVIDIA compute for computer vision and AI model training. When the market reprices one, it tends to reprice the other. The Z-Score system captures the moments when that repricing is disproportionate, and a production monitor can surface those signals before they normalize.
The code in this article is production-ready at the level of a single-instance monitoring daemon. Scaling it to multiple asset pairs, adding more sophisticated regime detection, and integrating it with a live trading system are natural next steps — but they build on the foundation established here.
Next Steps
If you want to run this monitoring system yourself:
- Sign up at tickdb.ai (free API key available, no credit card required)
- Set the
TICKDB_API_KEYenvironment variable in your environment - Copy the code from this article and run
python monitor.py - Configure a
SLACK_WEBHOOK_URLto receive alerts
If you need 10+ years of historical OHLCV data for backtesting this spread strategy, TickDB provides cleaned, aligned historical data for both US equities and HK equities via the /v1/market/kline endpoint. Visit tickdb.ai for Professional and Enterprise plan details.
If you're building a multi-pair surveillance system, consider using the TickDB depth channel to add order book imbalance as a secondary signal alongside the Z-Score. Buy/sell pressure ratios can indicate whether the spread movement is driven by informed trading or noise.
If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace for direct TickDB API integration within your development workflow.
This article does not constitute investment advice. Markets involve risk; past correlations do not guarantee future relationships. Cross-market arbitrage opportunities may be short-lived due to market efficiency, transaction costs, and regulatory constraints.