Every trading day between 9:30 and 16:00 HKT, a peculiar arbitrage window opens and closes across the Hang Seng. Mainland investors—restricted by capital controls but empowered by the Stock Connect program—pour south through a narrow channel into Hong Kong's market. Their footprints are invisible in any single stock's order book. Yet the aggregate signal is profound: correlated price movements across specific sectors, persistent premiums on dual-listed securities, and volume spikes that defy local news flow.

The Hong Kong-Shanghai and Hong Kong-Shenzhen Stock Connect schemes processed over RMB 14 trillion in cumulative northbound and southbound turnover since their 2014 and 2016 launches respectively. For quantitative researchers and systematic traders, the question is not whether southbound capital influences HK markets—it demonstrably does—but how to detect and measure its flow in near real-time without access to the China Securities Regulatory Commission (CSRC) settlement data that institutional brokers receive with a one-day lag.

This article constructs a practical capital heat estimation framework using publicly accessible real-time market data. The methodology combines volume anomaly detection, cross-market price convergence analysis, and time-series decomposition to infer directional pressure from mainland investors. All code examples leverage TickDB's real-time WebSocket streams and historical kline endpoints to construct a production-grade monitoring pipeline.


The Information Problem: Why Southbound Flow Is Hard to Track Directly

Official southbound flow figures—published by HKEX at the end of each trading day—arrive too late for intraday strategy development. Brokers with Prime Services agreements can access slightly faster flow data, but the latency remains measured in hours, not seconds. Retail and independent quant researchers face a structural information disadvantage.

The practical consequence is significant. When mainland investors concentrate buying into specific tickers—typically large-cap Hang Seng Index constituents with limited A-share counterparts—the price impact can persist for hours before the official flow figures confirm what price action already revealed. A trader who recognizes the signal early captures the alpha. One who waits for confirmation pays the spread.

The workaround is to infer flow direction from observable market mechanics. Southbound capital exhibits several detectable signatures:

  1. Sector-concentrated volume spikes: Mainland investors tend to favor specific sectors—technology, consumer discretionary, and financials—over commodity or property names. Unusual volume in these sectors relative to market-wide turnover suggests directional pressure.

  2. Price premium maintenance: Stocks dual-listed in Hong Kong and Shanghai/Shenzhen (H-share/A-share pairs) should theoretically trade at parity after currency adjustment. Persistent premiums or discounts indicate directional capital pressure that cannot be explained by local supply and demand.

  3. Intraday price momentum clustering: When southbound buying is active, correlated intraday momentum appears across the targeted sector before fundamental news justifies it.

  4. After-hours volume anomalies: While HK stocks lack a formal pre-market session, unusual volatility in the first 15 minutes of the trading day—before mainland markets open at 10:00 HKT—can signal overnight positioning by informed southbound participants.

The following sections develop each detection module and integrate them into a composite capital heat score.


Microstructure Analysis: Quantifying the Baseline

Before detecting anomalies, establish the normal trading profile for each monitored ticker. The baseline requires three data inputs: average daily volume (ADV) over a rolling 20-day window, typical intraday volume distribution (by 5-minute bucket), and historical price return volatility.

For this analysis, we monitor a representative basket of HK Stock Connect-eligible stocks spanning the Hang Seng Index. The basket includes technology leaders, financial institutions, and consumer names where southbound flow concentration is historically highest.

Ticker Name ADV (3M, shares) Avg Daily Turnover (HKD M) HS Index Weight
0700.HK Tencent Holdings 18,420,000 4,210 8.2%
9988.HK Alibaba Group 31,850,000 6,890 7.4%
0941.HK China Mobile 14,200,000 2,340 4.1%
3690.HK Meituan 22,100,000 5,120 5.8%
2319.HK Mengniu Dairy 8,900,000 1,180 1.2%
2628.HK China Life 19,400,000 2,960 3.3%
3968.HK China Merchants Bank 12,300,000 2,210 2.7%

The volume figures above represent aggregate HK Exchange turnover and do not disaggregate Stock Connect flows from local institutional or retail activity. The disaggregation is precisely the estimation problem this framework addresses.

Intraday Volume Distribution Baseline

Southbound flow tends to concentrate during specific time windows. Empirical observation across 2024 trading sessions reveals elevated southbound activity during:

  • 09:30–09:45 HKT: First-bell momentum from overnight mainland positioning
  • 10:00–11:00 HKT: Concurrent with mainland market open; correlated sector buying
  • 14:00–15:00 HKT: Pre-close repositioning; potential index rebalancing effects

A normal intraday volume curve for a large-cap HK stock typically follows a U-shaped pattern with a lunch-hour depression between 12:00 and 13:00. Deviations from this pattern—particularly elevated volume during the mainland open window—constitute the first anomaly signal.


Architecture: Real-Time Capital Heat Monitoring Pipeline

The monitoring system consists of three processing layers:

┌─────────────────────────────────────────────────────────────┐
│                    Data Ingestion Layer                      │
│  TickDB WebSocket ──► Depth + Trades + Kline Streams        │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                  Feature Computation Layer                   │
│  • Volume anomaly score (rolling z-score)                    │
│  • Price momentum clustering (sector correlation)            │
│  • Cross-market premium tracking (H-share/A-share spread)   │
│  • Intraday distribution deviation                           │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                 Composite Scoring Layer                      │
│  Weighted capital heat index → Alert thresholds            │
└─────────────────────────────────────────────────────────────┘

The pipeline consumes real-time TickDB streams for order book depth and trade ticks, supplemented by hourly kline snapshots for rolling baseline computation. All processing runs in near-real-time with sub-second latency from market event to heat score update.


Production-Grade Code: Real-Time Volume Anomaly Detection

The following Python module implements the volume anomaly detection component. It connects to TickDB's WebSocket endpoint for real-time trade stream consumption, maintains a rolling volume baseline, and emits z-score alerts when current volume deviates significantly from historical norms.

"""
Southbound Capital Flow Monitor — Volume Anomaly Module
Real-time trade stream processing with rolling baseline comparison.

⚠️ For production HFT workloads, migrate to asyncio/aiohttp for non-blocking I/O.
"""

import os
import json
import time
import math
import logging
from collections import deque
from datetime import datetime
from typing import Optional
import requests
import websocket

# ─────────────────────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────────────────────

TICKDB_WS_URL = "wss://api.tickdb.ai/ws/market/trades"
TICKDB_REST_BASE = "https://api.tickdb.ai/v1/market"

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

# Monitoring basket — HK Stock Connect eligible, high southbound concentration
MONITORED_TICKERS = [
    "0700.HK",  # Tencent
    "9988.HK",  # Alibaba
    "0941.HK",  # China Mobile
    "3690.HK",  # Meituan
    "2319.HK",  # Mengniu Dairy
]

# Rolling baseline parameters
VOLUME_WINDOW_MINUTES = 20      # Baseline calculation window
VOLUME_HISTORY_SIZE = 480       # Keep 480 x 5-min bars = ~40 trading hours
ANOMALY_Z_THRESHOLD = 2.0        # Z-score threshold for anomaly flag
RECONNECT_BASE_DELAY = 1.0       # Exponential backoff base (seconds)
RECONNECT_MAX_DELAY = 60.0       # Maximum backoff cap
WS_HEARTBEAT_INTERVAL = 20       # Ping interval (seconds)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)


# ─────────────────────────────────────────────────────────────
# Volume Statistics Engine
# ─────────────────────────────────────────────────────────────

class VolumeAnomalyDetector:
    """
    Computes rolling volume statistics and flags anomalies.
    
    Maintains a per-ticker deque of (timestamp, volume) tuples.
    On each new trade, updates the running statistics and emits
    a z-score if sufficient history exists.
    """
    
    def __init__(self, ticker: str, window_minutes: int = 20, history_size: int = 480):
        self.ticker = ticker
        self.window_minutes = window_minutes
        self.window_ms = window_minutes * 60 * 1000
        
        # Deque of (timestamp_ms, volume)
        self.volume_history: deque = deque(maxlen=history_size)
        self._current_bar_start: Optional[int] = None
        self._current_bar_volume: int = 0
        
    def process_trade(self, timestamp_ms: int, trade_volume: int, trade_price: float) -> Optional[dict]:
        """
        Process a single trade tick. Returns anomaly signal if detected.
        
        Args:
            timestamp_ms: Trade timestamp in milliseconds
            trade_volume: Number of shares traded
            trade_price: Trade price
            
        Returns:
            Anomaly dict with z-score and metadata, or None if insufficient history
        """
        bar_start = self._bar_start(timestamp_ms)
        
        # If we've moved to a new 5-minute bar, archive the previous one
        if self._current_bar_start is not None and bar_start > self._current_bar_start:
            self.volume_history.append((self._current_bar_start, self._current_bar_volume))
            self._current_bar_volume = 0
        
        self._current_bar_start = bar_start
        self._current_bar_volume += trade_volume
        
        # Need at least 20 bars for meaningful z-score
        if len(self.volume_history) < 20:
            return None
        
        current_bar_volume = self._current_bar_volume
        if current_bar_volume == 0:
            return None
            
        # Compute rolling mean and standard deviation
        volumes = [v for _, v in self.volume_history]
        mean_vol = sum(volumes) / len(volumes)
        
        # Population standard deviation (N, not N-1, for streaming)
        variance = sum((v - mean_vol) ** 2 for v in volumes) / len(volumes)
        std_vol = math.sqrt(variance)
        
        if std_vol == 0:
            return None
            
        z_score = (current_bar_volume - mean_vol) / std_vol
        
        if abs(z_score) >= ANOMALY_Z_THRESHOLD:
            return {
                "ticker": self.ticker,
                "timestamp": timestamp_ms,
                "current_volume": current_bar_volume,
                "mean_volume": round(mean_vol, 2),
                "std_volume": round(std_vol, 2),
                "z_score": round(z_score, 3),
                "direction": "buy_pressure" if z_score > 0 else "sell_pressure",
                "trade_price": trade_price
            }
        
        return None
    
    def _bar_start(self, timestamp_ms: int) -> int:
        """Snap timestamp to the start of its 5-minute bar."""
        bar_size_ms = 5 * 60 * 1000
        return (timestamp_ms // bar_size_ms) * bar_size_ms


# ─────────────────────────────────────────────────────────────
# WebSocket Client with Production-Grade Resilience
# ─────────────────────────────────────────────────────────────

class TickDBWebSocketClient:
    """
    WebSocket client for TickDB trade stream with:
    - Heartbeat ping/pong
    - Exponential backoff + jitter on reconnect
    - Rate-limit handling (3001 error code)
    - Graceful degradation on transient failures
    """
    
    def __init__(self, url: str, api_key: str, tickers: list):
        self.url = f"{url}?api_key={api_key}"
        self.tickers = tickers
        self.ws: Optional[websocket.WebSocketApp] = None
        self._reconnect_delay = RECONNECT_BASE_DELAY
        self._last_ping_time: float = 0
        self._is_running = False
        
        # Per-ticker anomaly detectors
        self.detectors: dict[str, VolumeAnomalyDetector] = {
            t: VolumeAnomalyDetector(t) for t in tickers
        }
        
    def connect(self) -> None:
        """Establish WebSocket connection with subscription."""
        logger.info(f"Connecting to TickDB WebSocket: {self.url[:50]}...")
        
        self.ws = websocket.WebSocketApp(
            self.url,
            on_open=self._on_open,
            on_message=self._on_message,
            on_error=self._on_error,
            on_close=self._on_close,
            on_ping=self._on_ping,
            on_pong=self._on_pong
        )
        
        self._is_running = True
        self.ws.run_forever(
            ping_interval=WS_HEARTBEAT_INTERVAL,
            ping_timeout=10
        )
    
    def _on_open(self, ws) -> None:
        """Send subscription request after connection opens."""
        logger.info("WebSocket connection established. Sending subscriptions...")
        
        subscribe_payload = {
            "cmd": "subscribe",
            "params": {
                "symbols": self.tickers,
                "channels": ["trades"]
            }
        }
        ws.send(json.dumps(subscribe_payload))
        
        # Reset reconnect delay on successful connection
        self._reconnect_delay = RECONNECT_BASE_DELAY
        logger.info(f"Subscribed to trades for {len(self.tickers)} tickers")
    
    def _on_message(self, ws, message: str) -> None:
        """Process incoming trade messages."""
        try:
            data = json.loads(message)
            
            # Handle ping from server
            if data.get("type") == "ping":
                ws.send(json.dumps({"type": "pong"}))
                return
            
            # Handle trade data
            if data.get("type") == "trades" and "data" in data:
                for trade in data["data"]:
                    ticker = trade.get("s")
                    timestamp_ms = trade.get("t")
                    volume = trade.get("v")
                    price = trade.get("p")
                    
                    if ticker not in self.detectors:
                        continue
                    
                    anomaly = self.detectors[ticker].process_trade(
                        timestamp_ms=timestamp_ms,
                        trade_volume=volume,
                        trade_price=price
                    )
                    
                    if anomaly:
                        self._emit_alert(anomaly)
                        
        except json.JSONDecodeError as e:
            logger.warning(f"JSON decode error: {e}")
        except Exception as e:
            logger.error(f"Message processing error: {e}")
    
    def _on_ping(self, ws, data: bytes) -> None:
        """Respond to server ping."""
        ws.pong(data)
        
    def _on_pong(self, ws, data: bytes) -> None:
        """Track pong response for keepalive monitoring."""
        self._last_ping_time = time.time()
        
    def _on_error(self, ws, error) -> None:
        """Log errors and trigger reconnect logic."""
        logger.error(f"WebSocket error: {error}")
        
    def _on_close(self, ws, close_status_code, close_msg) -> None:
        """Handle connection close with exponential backoff reconnect."""
        logger.warning(f"WebSocket closed: {close_status_code} — {close_msg}")
        self._is_running = False
        self._schedule_reconnect()
    
    def _schedule_reconnect(self) -> None:
        """Reconnect with exponential backoff + jitter."""
        import random
        
        # Exponential backoff
        delay = self._reconnect_delay * (2 ** (self._reconnect_attempts or 0))
        delay = min(delay, RECONNECT_MAX_DELAY)
        
        # Add jitter (up to 10% of delay)
        jitter = random.uniform(0, delay * 0.1)
        total_delay = delay + jitter
        
        self._reconnect_attempts = (self._reconnect_attempts or 0) + 1
        self._reconnect_delay = min(self._reconnect_delay * 1.5, RECONNECT_MAX_DELAY)
        
        logger.info(f"Scheduling reconnect in {total_delay:.1f}s (attempt {self._reconnect_attempts})")
        time.sleep(total_delay)
        
        if not self._is_running:
            self.connect()
    
    def _emit_alert(self, anomaly: dict) -> None:
        """
        Emit anomaly alert. In production, forward to alerting system.
        
        Production integrations:
        - Slack: POST to webhook URL
        - PagerDuty: Events API v2
        - Kafka: Produce to alert topic
        - Custom: Execute callback function
        """
        ts = datetime.fromtimestamp(anomaly["timestamp"] / 1000).strftime("%H:%M:%S")
        direction = anomaly["direction"].replace("_", " ")
        
        logger.warning(
            f"[ALERT] {anomaly['ticker']} @ {ts} | "
            f"Volume: {anomaly['current_volume']:,} | "
            f"Z-score: {anomaly['z_score']:+.2f} | "
            f"Assessment: {direction}"
        )


# ─────────────────────────────────────────────────────────────
# Historical Baseline Loader
# ─────────────────────────────────────────────────────────────

def load_historical_klines(ticker: str, interval: str = "5m", limit: int = 500) -> Optional[list]:
    """
    Load historical kline data to prime the rolling baseline.
    
    Uses TickDB /v1/market/kline endpoint.
    
    Args:
        ticker: HK stock ticker symbol
        interval: Kline interval (e.g., "5m", "1h", "1d")
        limit: Number of klines to retrieve
        
    Returns:
        List of kline dicts with OHLCV data
    """
    url = f"{TICKDB_REST_BASE}/kline"
    headers = {"X-API-Key": API_KEY}
    
    params = {
        "symbol": ticker,
        "interval": interval,
        "limit": limit
    }
    
    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:
            return data.get("data", [])
        else:
            logger.error(f"API error {data.get('code')}: {data.get('message')}")
            return None
            
    except requests.exceptions.Timeout:
        logger.error(f"Request timeout for {ticker}")
        return None
    except requests.exceptions.RequestException as e:
        logger.error(f"Request failed for {ticker}: {e}")
        return None


def prime_baseline(ticker: str, detector: VolumeAnomalyDetector) -> None:
    """Load historical 5-minute klines to populate the rolling baseline."""
    klines = load_historical_klines(ticker, interval="5m", limit=480)
    
    if not klines:
        logger.warning(f"No historical data loaded for {ticker}")
        return
        
    # Extract volume from each kline bar
    for kline in klines:
        # Kline format: {"t": timestamp, "o": open, "h": high, "l": low, "c": close, "v": volume}
        ts_ms = kline.get("t")
        vol = kline.get("v", 0)
        
        if ts_ms and vol:
            # Use midpoint of the kline interval as timestamp
            bar_start = detector._bar_start(ts_ms + (2.5 * 60 * 1000))
            detector.volume_history.append((bar_start, vol))
    
    logger.info(f"Primed {ticker} baseline with {len(detector.volume_history)} bars")


# ─────────────────────────────────────────────────────────────
# Main Entry Point
# ─────────────────────────────────────────────────────────────

def main():
    """Initialize detectors, prime baselines, and start WebSocket monitoring."""
    logger.info("Starting Southbound Capital Flow Monitor")
    
    # Prime each detector with historical data
    client = TickDBWebSocketClient(
        url=TICKDB_WS_URL,
        api_key=API_KEY,
        tickers=MONITORED_TICKERS
    )
    
    for ticker in MONITORED_TICKERS:
        prime_baseline(ticker, client.detectors[ticker])
    
    # Start real-time monitoring
    try:
        client.connect()
    except KeyboardInterrupt:
        logger.info("Shutting down monitor...")
    finally:
        if client.ws:
            client.ws.close()


if __name__ == "__main__":
    main()

Order Book Depth and Pressure Ratio Analysis

While the trade stream provides volume signals, the order book depth channel reveals directional intent. A trader executing a single large buy order against the offer generates a volume spike—but directional pressure in the order book can accumulate silently before the trade executes.

The buy/sell pressure ratio (BSPR) quantifies this imbalance:

BSPR = Σ(bid sizes, top 5 levels) / Σ(ask sizes, top 5 levels)

When BSPR exceeds 1.5, buying pressure is elevated relative to historical norms. Values above 3.0 signal extreme directional pressure consistent with institutional accumulation.

"""
Capital Heat Module — Order Book Pressure Analysis
Processes TickDB depth snapshots to compute buy/sell pressure ratios.
"""

import os
import json
import time
import math
from collections import deque
from typing import Optional
import websocket

TICKDB_WS_DEPTH_URL = "wss://api.tickdb.ai/ws/market/depth"
API_KEY = os.environ.get("TICKDB_API_KEY")

PRESSURE_WINDOW_BARS = 12     # Rolling 12 x 5-min bars = 1 hour
PRESSURE_THRESHOLD_HIGH = 3.0 # Extreme buying pressure
PRESSURE_THRESHOLD_LOW = 0.33 # Extreme selling pressure


class DepthPressureAnalyzer:
    """
    Computes rolling buy/sell pressure ratio from order book depth.
    
    Maintains a deque of pressure ratios per ticker and emits
    alerts when the current ratio deviates from the rolling mean
    by more than 1.5 standard deviations.
    """
    
    def __init__(self, ticker: str, levels: int = 5):
        self.ticker = ticker
        self.levels = levels
        self.pressure_history: deque = deque(maxlen=PRESSURE_WINDOW_BARS)
        
    def process_depth(self, timestamp_ms: int, bids: list, asks: list) -> Optional[dict]:
        """
        Process a depth snapshot and compute pressure ratio.
        
        Args:
            timestamp_ms: Snapshot timestamp
            bids: List of (price, size) tuples for bid levels
            asks: List of (price, size) tuples for ask levels
            
        Returns:
            Pressure analysis dict if sufficient history exists
        """
        bid_size = sum(size for _, size in bids[:self.levels])
        ask_size = sum(size for _, size in asks[:self.levels])
        
        if ask_size == 0:
            return None
            
        current_ratio = bid_size / ask_size
        self.pressure_history.append(current_ratio)
        
        if len(self.pressure_history) < 6:
            return None
            
        # Compute rolling statistics
        ratios = list(self.pressure_history)
        mean_ratio = sum(ratios) / len(ratios)
        variance = sum((r - mean_ratio) ** 2 for r in ratios) / len(ratios)
        std_ratio = math.sqrt(variance)
        
        if std_ratio == 0:
            return None
            
        # Deviation from rolling mean (in standard deviations)
        z_score = (current_ratio - mean_ratio) / std_ratio
        
        # Determine regime
        if current_ratio > PRESSURE_THRESHOLD_HIGH and z_score > 1.5:
            regime = "EXTREME_BUY_PRESSURE"
        elif current_ratio < PRESSURE_THRESHOLD_LOW and z_score < -1.5:
            regime = "EXTREME_SELL_PRESSURE"
        elif z_score > 1.0:
            regime = "ELEVATED_BUY_PRESSURE"
        elif z_score < -1.0:
            regime = "ELEVATED_SELL_PRESSURE"
        else:
            regime = "NORMAL"
        
        return {
            "ticker": self.ticker,
            "timestamp": timestamp_ms,
            "pressure_ratio": round(current_ratio, 3),
            "mean_ratio": round(mean_ratio, 3),
            "std_ratio": round(std_ratio, 3),
            "z_score": round(z_score, 3),
            "regime": regime,
            "bid_total": bid_size,
            "ask_total": ask_size
        }


def start_depth_monitor(tickers: list):
    """Start WebSocket monitoring for depth channel."""
    
    url = f"{TICKDB_WS_DEPTH_URL}?api_key={API_KEY}"
    analyzers = {t: DepthPressureAnalyzer(t) for t in tickers}
    
    def on_message(ws, message):
        data = json.loads(message)
        
        if data.get("type") == "depth" and "data" in data:
            for item in data["data"]:
                ticker = item.get("s")
                ts = item.get("t")
                bids = [(b["p"], b["v"]) for b in item.get("bids", [])]
                asks = [(a["p"], a["v"]) for a in item.get("asks", [])]
                
                if ticker not in analyzers:
                    continue
                    
                result = analyzers[ticker].process_depth(ts, bids, asks)
                
                if result and result["regime"] != "NORMAL":
                    ts_str = time.strftime("%H:%M:%S", time.gmtime(ts / 1000))
                    print(f"[{ts_str}] {ticker} | BSPR: {result['pressure_ratio']} | "
                          f"Regime: {result['regime']} | Z: {result['z_score']:+.2f}")
    
    ws = websocket.WebSocketApp(
        url,
        on_message=on_message
    )
    
    # Subscribe
    ws.on_open = lambda ws: ws.send(json.dumps({
        "cmd": "subscribe",
        "params": {"symbols": tickers, "channels": ["depth"]}
    }))
    
    ws.run_forever()


if __name__ == "__main__":
    start_depth_monitor(["0700.HK", "9988.HK", "3690.HK"])

Composite Capital Heat Score

Individual signals—volume anomaly, pressure ratio deviation, momentum clustering—are combined into a single capital heat index using a weighted scoring framework. Each signal is normalized to a 0–100 scale, with 100 representing maximum inferred southbound buying pressure.

Signal Component Weight Normalization Method
Volume anomaly z-score 30% Clipped to ±3σ → 0–100
Buy/sell pressure ratio deviation 25% Ratio relative to 1.0, log-scaled
Intraday momentum clustering 20% Correlation of 5-min returns within sector
Price premium vs A-share pair 15% H-share discount/premium vs Shanghai
First-bell volume deviation 10% Volume in first 15 min vs daily baseline

The composite score is computed as a weighted average:

Heat Score = 0.30 × Volume_Z_norm + 0.25 × BSPR_norm + 0.20 × Momentum_norm 
           + 0.15 × Premium_norm + 0.10 × BellVolume_norm

A heat score above 70 indicates sustained southbound buying pressure. Scores above 85 warrant attention for potential sector rotation trades.


Dual-List Premium Tracking: H-Share vs A-Share

A subset of HK-listed companies have corresponding A-shares on the Shanghai or Shenzhen exchanges. Under the Stock Connect framework, mainland investors can purchase these stocks via the southbound channel, creating a persistent premium or discount relative to their mainland counterparts.

Monitoring the H-share/A-share spread provides a direct signal of southbound flow intensity. When the spread widens beyond historical norms, it suggests mainland buying is driving the HK price away from fair value relative to the A-share.

For example, the China Life insurance pair (2628.HK / 601628.SH):

  • Currency-adjusted fair value spread: typically ±2%
  • During peak southbound periods: spread can widen to 5–8%
  • The divergence typically mean-reverts within 3–5 trading days as HK arbitrageurs close the gap

TickDB's multi-market kline endpoint enables synchronized comparison of H-share and A-share price series, computing the spread in real-time.


Deployment Configuration by Scale

Use Case Data Subscription Processing Alert Channel
Individual researcher Single ticker focus Local Python process Console / Slack webhook
Trading desk Full HS Index basket Dedicated server (4+ cores) Slack + email + PagerDuty
Institutional quant team Multi-market (HK + Shanghai Shenzhen) Distributed processing cluster Kafka → internal dashboard

For production deployment, consider containerizing the monitoring pipeline with Docker, using environment variables for API key management, and implementing a watchdog process that restarts the client if the WebSocket connection drops for more than 60 seconds.


Limitations and Caveats

The capital heat framework provides directional inference, not definitive flow measurement. Key limitations include:

  1. Correlation is not causation: Elevated volume and pressure ratios may reflect local institutional activity, short covering, or hedge fund rebalancing rather than southbound flow.

  2. Lag in mean reversion: The H-share/A-share premium signal mean-reverts unpredictably; a widened premium does not guarantee a profitable reversal trade.

  3. Sample period sensitivity: The rolling baseline requires at least 20 trading days of history. During market regime changes (e.g., COVID crash, regulatory announcements), historical baselines may become stale.

  4. No account-level granularity: The framework cannot distinguish between southbound flows from different investor types (e.g., index funds vs active managers).

For live trading decisions, combine the heat indicator with additional risk controls: position sizing limits, hard stops on sector concentration, and correlation checks against known southbound flow proxies.


Next Steps

If you are building a quantitative trading system and need reliable real-time HK market data, TickDB provides WebSocket streams for trades, depth, and klines with sub-second latency and native heartbeat/reconnection handling.

If you want to experiment with the capital heat framework immediately:

  1. Sign up at tickdb.ai (free tier available, no credit card required)
  2. Generate an API key in your dashboard
  3. Set the TICKDB_API_KEY environment variable
  4. Copy the code from this article into your local environment

If you are an institutional team requiring full historical HK equity OHLCV data for backtesting, TickDB offers 10+ years of cleaned, exchange-aligned data via the /v1/market/kline endpoint. Contact [email protected] for plan details.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace to enable direct TickDB API queries within your development environment.


This article does not constitute investment advice. Markets involve risk; past patterns in capital flow data do not guarantee future alpha. The capital heat framework is an analytical tool, not a trading signal. Always implement appropriate risk controls before live deployment.