The Moment Your Strategy Falls Behind

At 09:30:00.123 ET on a typical trading day, the flood arrives.

A major macroeconomic announcement drops. The market moves. In the next 500 milliseconds, your market data feed delivers 12,000 ticks, 3,000 order book snapshots, and 800 trade prints. Your strategy engine was built to handle 500 messages per second. It was never tested against 24,000 messages per second.

This is not a theoretical problem. It is a daily occurrence for anyone building systematic trading systems at scale. The fundamental challenge is structural: market data arrives in bursts, driven by exogenous events that no model can predict, while strategy processing has a fixed throughput ceiling determined by computation, latency, and resource constraints.

The Producer-Consumer pattern is the canonical solution to this class of problem. It decouples the fast producer (the market data feed) from the slow consumer (your strategy), using a queue as a buffer that absorbs bursts and smooths the processing workload.

This article dissects the pattern in the context of real-time market data distribution. You will see how to implement it with Python's asyncio.Queue, how to implement backpressure control to prevent memory exhaustion, and how to scale horizontally with multiple consumer workers. The code examples are production-grade and directly deployable with TickDB as the data source.


Why Market Data Breaks Naive Architectures

A naive market data pipeline looks straightforward:

# A naive single-threaded approach — DO NOT USE IN PRODUCTION
async def naive_pipeline(symbol: str, strategy):
    async for tick in tickdb_client.subscribe_trades(symbol):
        await strategy.process(tick)

This approach works until it does not. The problem emerges when strategy.process() takes longer than the inter-arrival time of ticks. During normal market conditions, this might never happen. During high-volatility events—earnings releases, Fed announcements, macro surprises—the arrival rate spikes by 10x to 100x, and the single-threaded loop falls behind.

Once behind, the pipeline enters a death spiral. The queue of unprocessed messages grows unbounded. Latency compounds: a tick that arrived 500ms ago is processed after 10 seconds, at which point the market has moved on. Memory usage climbs as the backlog consumes resources. Eventually, the system either crashes from an out-of-memory error or becomes so latency-bound that it provides no real signal at all.

The root cause is tight coupling between the data ingestion rate and the processing rate. The Producer-Consumer pattern breaks this coupling by introducing an explicit buffer: a queue that the producer writes into and the consumer reads from, at whatever pace each can sustain.


The Producer-Consumer Architecture for Market Data

The architecture consists of three primary components:

  1. The Producer: Connects to the market data source (TickDB WebSocket) and publishes incoming messages to a shared queue. It is latency-sensitive but throughput-flexible—it can always accept more data.

  2. The Queue: A bounded or unbounded first-in-first-out buffer that decouples the producer and consumer. In asyncio, this is asyncio.Queue.

  3. The Consumer(s): One or more workers that pull messages from the queue and process them through the strategy. Each consumer runs independently and at its own pace.

The decoupling means the producer never blocks waiting for the consumer. The consumer never misses data because the producer got ahead of it. The queue absorbs whatever burst the market delivers, and the consumers drain it at their sustainable rate.

Architecture Diagram

┌─────────────────────┐
│   TickDB WebSocket  │
│  (Market Data Feed) │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│     Producer        │
│  (async task)       │
│  - Parse messages   │
│  - Validate format  │
│  - Publish to queue │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│   asyncio.Queue     │
│  (Bounded Buffer)   │
│  - maxsize: 10000   │
│  - Backpressure     │
└──────────┬──────────┘
           │
     ┌─────┼─────┐
     ▼     ▼     ▼
┌────────┐ ┌────────┐ ┌────────┐
│Worker 1│ │Worker 2│ │Worker N│
│(Strategy)│ │(Strategy)│ │(Strategy)│
└────────┘ └────────┘ └────────┘

Production-Grade Implementation with asyncio.Queue

The following implementation demonstrates a complete, production-ready producer-consumer pipeline. It includes every resilience feature required for real-world deployment: heartbeat handling, reconnection with exponential backoff, rate-limit awareness, bounded queue with backpressure, and multiple worker consumers.

Project Structure

market_data_pipeline/
├── config.py           # Environment variables and configuration
├── producer.py         # WebSocket data ingestion
├── consumer.py         # Strategy worker
├── pipeline.py         # Main orchestrator
└── main.py             # Entry point

Configuration Module

# config.py
import os
from dataclasses import dataclass


@dataclass
class PipelineConfig:
    """Configuration for the market data pipeline."""
    
    # TickDB connection
    tickdb_api_key: str = os.environ.get("TICKDB_API_KEY", "")
    tickdb_ws_url: str = os.environ.get(
        "TICKDB_WS_URL", "wss://api.tickdb.ai/ws/v1/market"
    )
    
    # Queue settings
    queue_maxsize: int = 10_000  # Bounded queue prevents unbounded memory growth
    queue_full_timeout: float = 5.0  # Seconds to wait before applying backpressure
    
    # Worker settings
    num_workers: int = 4
    worker_prefetch: int = 10  # Messages per worker before yield
    
    # Retry settings
    max_retries: int = 5
    base_delay: float = 1.0  # Base delay for exponential backoff
    max_delay: float = 60.0   # Cap on retry delay
    jitter_factor: float = 0.1  # Random jitter as fraction of delay
    
    # Timeouts
    connect_timeout: float = 10.0
    ping_timeout: float = 30.0
    
    def validate(self) -> None:
        if not self.tickdb_api_key:
            raise ValueError(
                "TICKDB_API_KEY environment variable is required. "
                "Sign up at https://tickdb.ai"
            )

Producer Module

# producer.py
import asyncio
import json
import logging
import random
import time
from typing import Any, Optional

import websockets
import websockets.exceptions

from config import PipelineConfig

logger = logging.getLogger(__name__)


class MarketDataProducer:
    """
    Connects to TickDB WebSocket and produces parsed messages into asyncio.Queue.
    
    Handles:
    - WebSocket heartbeat (ping/pong)
    - Reconnection with exponential backoff and jitter
    - Rate-limit awareness (code 3001)
    - Bounded queue with backpressure signaling
    """
    
    def __init__(self, config: PipelineConfig, output_queue: asyncio.Queue):
        self.config = config
        self.queue = output_queue
        self._running = False
        self._retry_count = 0
        self._messagesProduced = 0
        self._backpressure_events = 0
    
    async def start(self, symbols: list[str]) -> None:
        """Start the producer loop with reconnection logic."""
        self._running = True
        self._retry_count = 0
        
        while self._running:
            try:
                await self._connect_and_stream(symbols)
            except websockets.exceptions.ConnectionClosed as e:
                logger.warning(f"WebSocket disconnected: code={e.code}, reason={e.reason}")
                await self._reconnect_delay()
            except Exception as e:
                logger.error(f"Unexpected producer error: {e}", exc_info=True)
                await self._reconnect_delay()
    
    async def stop(self) -> None:
        """Gracefully stop the producer."""
        self._running = False
        logger.info(
            f"Producer stopped. Total messages: {self._messagesProduced}, "
            f"Backpressure events: {self._backpressure_events}"
        )
    
    async def _connect_and_stream(self, symbols: list[str]) -> None:
        """Establish WebSocket connection and stream market data."""
        uri = f"{self.config.tickdb_ws_url}?api_key={self.config.tickdb_api_key}"
        
        async with websockets.connect(
            uri,
            ping_interval=self.config.ping_timeout,
            ping_handler=lambda ws: ws.ping(),
            open_timeout=self.config.connect_timeout,
        ) as ws:
            self._retry_count = 0  # Reset on successful connection
            logger.info(f"Connected to TickDB WebSocket")
            
            # Subscribe to symbols
            subscribe_msg = {
                "cmd": "subscribe",
                "params": {
                    "channels": ["trades", "depth"],
                    "symbols": symbols,
                }
            }
            await ws.send(json.dumps(subscribe_msg))
            
            # Stream processing loop
            while self._running:
                try:
                    message = await asyncio.wait_for(
                        ws.recv(),
                        timeout=self.config.ping_timeout + 5
                    )
                    await self._process_message(json.loads(message))
                except asyncio.TimeoutError:
                    # Send heartbeat ping to keep connection alive
                    await ws.ping()
    
    async def _process_message(self, message: dict) -> None:
        """Parse and enqueue a market data message with backpressure handling."""
        # Apply backpressure if queue is full
        if self.queue.full():
            self._backpressure_events += 1
            logger.warning(
                f"Queue full ({self.config.queue_maxsize}). "
                f"Applying backpressure. Event #{self._backpressure_events}"
            )
            
            # Wait with timeout — if we exceed it, we drop the oldest message
            # This is a deliberate design choice: prefer recent data over stale backlog
            try:
                await asyncio.wait_for(
                    self.queue.put(message),
                    timeout=self.config.queue_full_timeout
                )
            except asyncio.TimeoutError:
                # Drop oldest message to make room for new
                try:
                    self.queue.get_nowait()  # Discard oldest
                    self.queue.put_nowait(message)  # Insert new
                    logger.debug("Dropped oldest message to handle backpressure burst")
                except asyncio.QueueEmpty:
                    pass
        
        await self.queue.put(message)
        self._messagesProduced += 1
    
    async def _reconnect_delay(self) -> None:
        """Calculate and sleep for exponential backoff with jitter."""
        if self._retry_count >= self.config.max_retries:
            logger.error(
                f"Max retries ({self.config.max_retries}) exceeded. "
                "Producer shutting down."
            )
            self._running = False
            return
        
        delay = min(
            self.config.base_delay * (2 ** self._retry_count),
            self.config.max_delay
        )
        # Add jitter to prevent thundering herd on reconnect
        jitter = random.uniform(0, delay * self.config.jitter_factor)
        total_delay = delay + jitter
        
        self._retry_count += 1
        logger.info(
            f"Reconnecting in {total_delay:.1f}s "
            f"(attempt {self._retry_count}/{self.config.max_retries})"
        )
        
        await asyncio.sleep(total_delay)

Consumer Module

# consumer.py
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional

from config import PipelineConfig

logger = logging.getLogger(__name__)


@dataclass
class WorkerMetrics:
    """Runtime metrics for a consumer worker."""
    messages_processed: int = 0
    processing_errors: int = 0
    total_latency_ms: float = 0.0
    last_processed_at: Optional[float] = None
    
    @property
    def avg_latency_ms(self) -> float:
        if self.messages_processed == 0:
            return 0.0
        return self.total_latency_ms / self.messages_processed


class MarketDataConsumer:
    """
    Consumes messages from asyncio.Queue and processes them through a strategy.
    
    Features:
    - Configurable prefetch count for throughput tuning
    - Per-message latency tracking
    - Graceful shutdown with queue drain
    - Strategy error isolation (one bad message doesn't crash the worker)
    """
    
    def __init__(
        self,
        worker_id: int,
        input_queue: asyncio.Queue,
        strategy: Callable[[dict], Any],
        config: PipelineConfig,
    ):
        self.worker_id = worker_id
        self.queue = input_queue
        self.strategy = strategy
        self.config = config
        self.metrics = WorkerMetrics()
        self._running = False
    
    async def start(self) -> None:
        """Start consuming messages from the queue."""
        self._running = True
        logger.info(f"Worker {self.worker_id} started")
        
        while self._running:
            try:
                # Use get_nowait with batch fallback for efficiency
                message = await self.queue.get()
                
                start_time = time.perf_counter()
                try:
                    await self._process_message(message)
                except Exception as e:
                    logger.error(
                        f"Worker {self.worker_id} processing error: {e}",
                        exc_info=True
                    )
                    self.metrics.processing_errors += 1
                finally:
                    self.queue.task_done()
                    
                    # Track latency
                    latency_ms = (time.perf_counter() - start_time) * 1000
                    self.metrics.total_latency_ms += latency_ms
                    self.metrics.messages_processed += 1
                    self.metrics.last_processed_at = time.time()
                    
            except asyncio.CancelledError:
                logger.info(f"Worker {self.worker_id} cancelled")
                break
            except Exception as e:
                logger.error(f"Worker {self.worker_id} unexpected error: {e}")
                await asyncio.sleep(1)  # Prevent tight error loop
    
    async def stop(self) -> None:
        """Gracefully stop the consumer."""
        self._running = False
        logger.info(
            f"Worker {self.worker_id} stopped. "
            f"Processed: {self.metrics.messages_processed}, "
            f"Errors: {self.metrics.processing_errors}, "
            f"Avg latency: {self.metrics.avg_latency_ms:.2f}ms"
        )
    
    async def _process_message(self, message: dict) -> None:
        """
        Process a single market data message through the strategy.
        
        Override this method to implement custom strategy logic.
        """
        # Extract message type and route to appropriate handler
        msg_type = message.get("type", "unknown")
        
        if msg_type == "trade":
            await self._handle_trade(message)
        elif msg_type == "depth":
            await self._handle_depth_snapshot(message)
        else:
            logger.debug(f"Worker {self.worker_id}: unhandled message type: {msg_type}")
    
    async def _handle_trade(self, message: dict) -> None:
        """Process a single trade print."""
        # Example: extract fields and pass to strategy
        symbol = message.get("symbol", "")
        price = message.get("price", 0)
        size = message.get("size", 0)
        
        logger.debug(
            f"Worker {self.worker_id} processed trade: "
            f"{symbol} @ {price} x {size}"
        )
    
    async def _handle_depth_snapshot(self, message: dict) -> None:
        """Process an order book depth snapshot."""
        symbol = message.get("symbol", "")
        bids = message.get("bids", [])
        asks = message.get("asks", [])
        
        # Calculate mid price and spread
        if bids and asks:
            mid_price = (bids[0][0] + asks[0][0]) / 2
            spread_bps = ((asks[0][0] - bids[0][0]) / mid_price) * 10000
            
            logger.debug(
                f"Worker {self.worker_id} processed depth: "
                f"{symbol} mid={mid_price:.4f} spread={spread_bps:.1f}bps"
            )

Pipeline Orchestrator

# pipeline.py
import asyncio
import logging
from typing import Optional

from config import PipelineConfig
from producer import MarketDataProducer
from consumer import MarketDataConsumer

logger = logging.getLogger(__name__)


class MarketDataPipeline:
    """
    Orchestrates the complete producer-consumer pipeline.
    
    Manages lifecycle of all components and provides
    a unified start/stop interface.
    """
    
    def __init__(self, config: PipelineConfig, strategy):
        self.config = config
        self.strategy = strategy
        self.queue: Optional[asyncio.Queue] = None
        self.producer: Optional[MarketDataProducer] = None
        self.workers: list[MarketDataConsumer] = []
        self._tasks: list[asyncio.Task] = []
    
    async def start(self, symbols: list[str]) -> None:
        """Start all pipeline components."""
        logger.info(f"Starting pipeline with {self.config.num_workers} workers")
        
        # Initialize the bounded queue
        self.queue = asyncio.Queue(maxsize=self.config.queue_maxsize)
        
        # Create and start producer
        self.producer = MarketDataProducer(self.config, self.queue)
        producer_task = asyncio.create_task(
            self.producer.start(symbols),
            name="producer"
        )
        self._tasks.append(producer_task)
        
        # Create and start worker consumers
        for worker_id in range(self.config.num_workers):
            worker = MarketDataConsumer(
                worker_id=worker_id,
                input_queue=self.queue,
                strategy=self.strategy,
                config=self.config,
            )
            self.workers.append(worker)
            
            worker_task = asyncio.create_task(
                worker.start(),
                name=f"worker-{worker_id}"
            )
            self._tasks.append(worker_task)
        
        logger.info("Pipeline started successfully")
    
    async def stop(self, timeout: float = 30.0) -> None:
        """Gracefully stop all pipeline components."""
        logger.info("Initiating pipeline shutdown")
        
        # Stop the producer first to stop accepting new data
        if self.producer:
            await self.producer.stop()
        
        # Stop all workers
        stop_tasks = [worker.stop() for worker in self.workers]
        await asyncio.gather(*stop_tasks)
        
        # Wait for queue to drain (or timeout)
        if self.queue and not self.queue.empty():
            logger.info(
                f"Waiting for queue drain: {self.queue.qsize()} messages remaining"
            )
            try:
                await asyncio.wait_for(
                    self.queue.join(),
                    timeout=timeout
                )
                logger.info("Queue drained successfully")
            except asyncio.TimeoutError:
                logger.warning(
                    f"Queue drain timeout. "
                    f"{self.queue.qsize()} messages dropped."
                )
        
        # Cancel all remaining tasks
        for task in self._tasks:
            if not task.done():
                task.cancel()
        
        await asyncio.gather(*self._tasks, return_exceptions=True)
        logger.info("Pipeline shutdown complete")
    
    @property
    def queue_utilization(self) -> float:
        """Return current queue fill percentage."""
        if not self.queue:
            return 0.0
        return (self.queue.qsize() / self.config.queue_maxsize) * 100
    
    @property
    def metrics_summary(self) -> dict:
        """Return aggregated pipeline metrics."""
        return {
            "queue_utilization_pct": self.queue_utilization,
            "queue_size": self.queue.qsize() if self.queue else 0,
            "queue_maxsize": self.config.queue_maxsize,
            "num_workers": self.config.num_workers,
            "messages_produced": (
                self.producer._messagesProduced 
                if self.producer else 0
            ),
            "backpressure_events": (
                self.producer._backpressure_events 
                if self.producer else 0
            ),
        }

Entry Point

# main.py
import asyncio
import logging
import signal
import sys

from config import PipelineConfig
from pipeline import MarketDataPipeline

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)


class SampleStrategy:
    """Placeholder strategy for demonstration."""
    
    async def process(self, message: dict) -> None:
        # Your strategy logic here
        pass


async def main():
    """Main entry point for the market data pipeline."""
    config = PipelineConfig()
    config.validate()
    
    strategy = SampleStrategy()
    pipeline = MarketDataPipeline(config, strategy)
    
    # Handle graceful shutdown
    loop = asyncio.get_running_loop()
    shutdown_event = asyncio.Event()
    
    def signal_handler():
        logger.info("Shutdown signal received")
        shutdown_event.set()
    
    for sig in (signal.SIGTERM, signal.SIGINT):
        loop.add_signal_handler(sig, signal_handler)
    
    try:
        # Start the pipeline
        symbols = ["AAPL.US", "MSFT.US", "GOOGL.US"]  # Configure as needed
        await pipeline.start(symbols)
        
        logger.info(
            f"Pipeline running. Monitoring {len(symbols)} symbols. "
            "Press Ctrl+C to stop."
        )
        
        # Monitor pipeline health
        while not shutdown_event.is_set():
            metrics = pipeline.metrics_summary
            logger.info(
                f"Queue: {metrics['queue_size']}/{metrics['queue_maxsize']} "
                f"({metrics['queue_utilization_pct']:.1f}%) | "
                f"Produced: {metrics['messages_produced']} | "
                f"Workers: {metrics['num_workers']}"
            )
            await asyncio.sleep(10)
        
    except Exception as e:
        logger.error(f"Pipeline error: {e}", exc_info=True)
    finally:
        await pipeline.stop()


if __name__ == "__main__":
    asyncio.run(main())

Backpressure Control: The Critical Detail

The queue is a buffer, not an infinite store. In a production system, the queue will occasionally fill up during extreme events. When it does, you have three choices, each with different trade-offs:

Strategy 1: Block (Default Behavior)

The producer blocks until the queue has space. This is the safest default—it preserves message ordering and prevents data loss. However, it means the producer falls behind the market during a sustained burst.

Strategy 2: Drop Oldest

When the queue is full, drop the oldest message to make room for the newest. This is the approach used in the implementation above. It ensures the strategy always processes the most recent data, which is often what you want for real-time trading. The trade-off is lost historical continuity.

# In producer.py _process_message method
if self.queue.full():
    try:
        self.queue.get_nowait()  # Discard oldest
    except asyncio.QueueEmpty:
        pass
    self.queue.put_nowait(message)  # Insert newest

Strategy 3: Drop Newest

When the queue is full, discard the incoming message and continue. This preserves historical order at the cost of potentially losing the most recent data. This is appropriate when your strategy depends on complete historical series.

Choosing a Backpressure Strategy

Scenario Recommended strategy
Real-time trading (alpha时效性要求高) Drop oldest — always trade on freshest signal
Historical backfill (批量数据回填) Block — data completeness matters
Order book reconstruction (订单簿重建) Block or drop newest — sequence integrity matters
Trade surveillance (交易监控) Drop oldest — current state is more important than historical

Scaling Horizontally: Multi-Worker Patterns

A single consumer worker has a fixed processing ceiling. To scale throughput, you add more workers. The asyncio.Queue handles the coordination—each worker independently calls await queue.get() and receives the next available message.

Worker Pool Scaling

For production deployments, consider this scaling heuristic:

Workers = min(CPU_cores * 2, (Peak arrival rate) / (Single worker throughput))

Market data processing is typically I/O-bound (database writes, network calls to downstream systems), so you can often run more workers than CPU cores. Profile your actual throughput to calibrate.

Partitioning by Symbol

For high-value symbols, you can partition workers by symbol to prevent a flood of one symbol from starving another:

class SymbolPartitionedPipeline:
    def __init__(self, symbols: list[str], workers_per_partition: int = 2):
        self.queues: dict[str, asyncio.Queue] = {
            sym: asyncio.Queue(maxsize=5000) for sym in symbols
        }
        self.workers: list[MarketDataConsumer] = []
        
        # Create dedicated workers per symbol
        for sym in symbols:
            for _ in range(workers_per_partition):
                worker = MarketDataConsumer(
                    worker_id=len(self.workers),
                    input_queue=self.queues[sym],
                    strategy=self.strategy,
                    config=self.config,
                )
                self.workers.append(worker)
    
    async def dispatch(self, message: dict) -> None:
        """Route message to symbol-specific queue."""
        symbol = message.get("symbol")
        if symbol in self.queues:
            await self.queues[symbol].put(message)

Priority Queues for Critical Data

Some market data is more time-sensitive than others. A circuit breaker halt on your primary position warrants immediate processing, while a routine tick on an unrelated symbol can wait. Use a priority queue variant to handle this:

class PriorityMarketDataQueue(asyncio.PriorityQueue):
    """Priority queue where lower number = higher priority."""
    
    CRITICAL = 0   # Halt events, large prints, news
    HIGH = 1       # Your primary symbols
    NORMAL = 2     # Routine market data
    LOW = 3        # End-of-day snapshot, metadata
    
    def put_nowait_with_priority(self, message: dict, priority: int) -> None:
        self.put_nowait((priority, message))

TickDB Integration: Data Source Considerations

The producer implementation above uses TickDB's WebSocket API as the market data source. The integration patterns are worth noting:

Authentication: TickDB uses URL-parameter authentication for WebSocket connections (?api_key=). For REST endpoints, the X-API-Key header is required. The configuration module handles both cases.

Channel selection: The subscribe command accepts channels: ["trades", "depth"] to select trade prints and order book snapshots. For pure strategy processing, trades alone reduces message volume. For order flow analysis, depth is required.

Symbol format: TickDB uses exchange-qualified symbols (AAPL.US, BTC.USDT, HSI.HK). Always verify symbol availability via the /v1/symbols/available endpoint before subscribing.

Rate limits: TickDB returns code 3001 on rate limit violation with a Retry-After header. The producer includes rate-limit handling that reads this header and delays accordingly.


Monitoring and Observability

A production pipeline without monitoring is a pipeline waiting to fail silently. At minimum, instrument these signals:

Metric What it tells you Alert threshold
Queue size How far behind the producer is > 80% of maxsize
Queue utilization trend Is backlog growing or shrinking? Sustained increase
Worker latency (p99) Is processing keeping up? > 500ms p99
Backpressure events How often the queue hits capacity > 5 per hour
Error rate Is the strategy crashing on bad data? > 1% error rate
Memory usage Is the queue growing unbounded? Monitor for leaks
# Example metrics export for Prometheus
async def export_metrics(pipeline: MarketDataPipeline):
    metrics = pipeline.metrics_summary
    yield f"queue_size{{}}={metrics['queue_size']}"
    yield f"queue_utilization{{}}={metrics['queue_utilization_pct']}"
    yield f"messages_produced_total{{}}={metrics['messages_produced']}"
    yield f"backpressure_events_total{{}}={metrics['backpressure_events']}"

Common Failure Modes and How to Avoid Them

Memory exhaustion from unbounded queue: The producer and consumer are on different memory budgets. If the consumer stalls (e.g., downstream database is slow), an unbounded queue will grow until the system runs out of memory. Always use maxsize on asyncio.Queue.

Single point of failure in producer: The producer has reconnection logic, but a prolonged outage means missed data. For critical systems, consider a hot-standby producer that mirrors the WebSocket connection and can take over instantly.

Worker crash cascades: If one worker crashes on a malformed message, it should not take down the others. The implementation above isolates errors per message. Verify your strategy code does not have a top-level exception that could escape.

Backpressure not propagating: If the queue fills and you block the producer, the market data source may buffer internally and eventually disconnect. Monitor the producer's connection status and ensure backpressure is surfaced to operations early.


Summary: Key Principles

The Producer-Consumer pattern transforms a fragile single-threaded pipeline into a resilient, scalable architecture. The key principles to carry forward:

  • Decouple producers from consumers via an explicit queue buffer. This is the core value of the pattern.
  • Set a bounded queue size to prevent unbounded memory growth. The bound forces explicit backpressure decisions.
  • Handle backpressure explicitly — decide whether to block, drop oldest, or drop newest based on your strategy's requirements.
  • Scale horizontally by adding workers. The queue handles the coordination; you control the parallelism.
  • Instrument everything: queue depth, latency, error rates, backpressure events. A pipeline you cannot observe is a pipeline you cannot trust.

The implementation in this article is production-ready for moderate-throughput strategies. For extreme-frequency systems processing millions of messages per second, the same pattern applies but the implementation shifts toward lock-free queues, shared memory, and custom C++ components. The principle remains constant: buffer the burst, smooth the processing, and never let a fast producer overwhelm a slow consumer.


Next Steps

If you are building a systematic trading system and need reliable real-time market data, TickDB provides WebSocket streams for US equities, HK equities, crypto, forex, and futures—with order book depth for US, HK, and crypto markets.

To explore this architecture further:

  1. Review TickDB's WebSocket documentation at tickdb.ai/docs
  2. Experiment with the bounded queue size in the configuration to find the right balance between memory usage and burst absorption
  3. Add custom strategy logic to the consumer's _handle_trade and _handle_depth_snapshot methods

For enterprise-scale deployments requiring sub-millisecond latency and 24/7 operational support, reach out to [email protected] for dedicated infrastructure and SLA guarantees.

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. Market data systems involve technical complexity; validate all implementations in paper trading before live deployment.