You have built a beautiful trading system. The backtest looks promising. The strategy logic is sound. And then you hit the production wall — the part where the market data bill arrives.
For individual quant developers and small trading teams, the choice between a market data vendor's free tier and its paid plans is not academic. It is the difference between a hobby project that survives and one that gets shut down at 3 AM because an API quota reset triggered a cascade failure. Understanding exactly what a free tier can handle — and where it breaks — is not optional. It is operational survival.
This article is a systematic, production-minded examination of the TickDB free tier. We will cover the quotas that are documented, the behaviors that are not, and the exact failure modes you will encounter when you push the limits. The goal is not to discourage you from using the free tier — it is to help you design around it.
What the Free Tier Actually Gives You
Before we measure limits, we need to establish what you are working with. The TickDB free tier is not a sandboxed demo environment. It provides real market data across six asset classes — US equities, HK stocks, A-shares, crypto, forex, and commodities — via REST and WebSocket endpoints.
The free tier provides access to:
- REST API with
X-API-Keyheader authentication - WebSocket subscriptions for real-time data (depth, trades, ticker)
- Historical OHLCV data (kline endpoint) for backtesting — 10+ years for US equities
- Symbol discovery (
/v1/symbols/available) - Kline latest for current candle data
What the free tier does not include — and this is critical — is tick-level trade data for US equities or A-shares. The trades endpoint covers HK equities and crypto, but not US markets. If your strategy depends on trade-level flow analysis for US stocks, the free tier will not support it regardless of how many API calls you make.
The Depth Channel on Free Tier
The depth channel (order book snapshots) is supported on the free tier for US equities at L1 only (best bid/best ask). HK and crypto markets support L1–L10 depth on the free tier. This asymmetry matters. If you are building an order book imbalance strategy on US equities, L1 data is the baseline — but you should know that L1 is the ceiling on the free plan, not a floor.
Daily Call Limits: What the Numbers Say
The most common question about any API free tier is: how many calls can I make per day?
TickDB's rate limiting operates on two axes simultaneously:
| Limit type | Free tier specification |
|---|---|
| Requests per minute (RPM) | Tier-dependent; free tier starts at a baseline |
| Requests per day (RPD) | Daily aggregate cap |
| WebSocket connection duration | Session timeout after sustained inactivity |
The exact numbers are not published as a fixed constant in the public documentation — they are tier-dependent and subject to adjustment. What we can do is establish the empirical behavior by examining the error responses when limits are approached.
Measuring the Rate Limit Boundary
The canonical way to detect that you are approaching a rate limit is to monitor for HTTP status and the error code in the response body. When the limit is exceeded, TickDB returns:
{
"code": 3001,
"message": "Rate limit exceeded",
"data": null
}
Alongside this response, the server sends a Retry-After header:
Retry-After: 5
The integer value indicates the number of seconds you must wait before the next request will be accepted. This is RFC 6585-compliant behavior and is the correct signal to act on.
A Production-Grade Rate Limit Handler
The following code implements a resilient rate limit handler that respects the Retry-After header, implements exponential backoff with jitter for transient failures, and logs all boundary events for operational monitoring.
import os
import time
import random
import logging
from datetime import datetime
from typing import Optional, Any
import requests
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s"
)
logger = logging.getLogger(__name__)
API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1"
HEADERS = {"X-API-Key": API_KEY}
def handle_tickdb_error(response: requests.Response, retry_count: int) -> Optional[Any]:
"""
Process a TickDB error response.
Returns None if the request should be retried after waiting.
Raises on unrecoverable errors.
"""
try:
body = response.json()
except ValueError:
body = {}
code = body.get("code", 0)
# Rate limit exceeded — respect Retry-After header
if code == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
logger.warning(
f"Rate limit hit (attempt {retry_count}). "
f"Retrying after {retry_after}s"
)
time.sleep(retry_after)
return None
# Authentication failures — do not retry
if code in (1001, 1002):
raise ValueError(
f"Authentication error (code {code}): "
f"check TICKDB_API_KEY environment variable"
)
# Symbol not found — do not retry
if code == 2002:
raise KeyError(f"Symbol not found in response: {body.get('message')}")
# Generic server error — exponential backoff with jitter
if response.status_code >= 500:
delay = min(2 ** retry_count + random.uniform(0, 1), 30)
logger.warning(
f"Server error {response.status_code}, "
f"retrying in {delay:.2f}s (attempt {retry_count})"
)
time.sleep(delay)
return None
# Unknown error
raise RuntimeError(
f"Unexpected response {response.status_code}: {body.get('message', response.text)}"
)
def request_with_retry(
method: str,
endpoint: str,
params: Optional[dict] = None,
max_retries: int = 5,
timeout: tuple = (3.05, 10)
) -> Any:
"""
Make a REST request to TickDB with retry logic and rate limit handling.
"""
url = f"{BASE_URL}{endpoint}"
retry_count = 0
while retry_count < max_retries:
try:
response = requests.request(
method=method,
url=url,
headers=HEADERS,
params=params,
timeout=timeout
)
if response.status_code == 200:
data = response.json()
if data.get("code") == 0:
return data.get("data")
return handle_tickdb_error(response, retry_count)
elif response.status_code >= 400:
result = handle_tickdb_error(response, retry_count)
if result is None:
retry_count += 1
continue
return result
except requests.exceptions.Timeout:
logger.warning(f"Request timeout, retrying (attempt {retry_count + 1})")
time.sleep(1 + random.uniform(0, 0.5))
retry_count += 1
except requests.exceptions.ConnectionError as e:
logger.warning(f"Connection error: {e}, retrying")
time.sleep(2 + random.uniform(0, 1))
retry_count += 1
raise RuntimeError(
f"Max retries ({max_retries}) exceeded for {method} {endpoint}"
)
# ── Usage Example ────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Fetch current kline data for Apple
try:
data = request_with_retry(
method="GET",
endpoint="/market/kline/latest",
params={"symbol": "AAPL.US", "interval": "1m"}
)
logger.info(f"Latest candle: {data}")
except Exception as e:
logger.error(f"Request failed: {e}")
Engineering notes:
- The
timeouttuple uses(connect_timeout, read_timeout)perrequestsconventions. A 3.05-second connect timeout prevents hanging during network partitions. - The
Retry-Afterheader is authoritative — do not replace it with a fixed wait time. The server knows its own refill rate. - Exponential backoff with jitter is essential. Without jitter, a burst of retried requests will re-trigger the rate limit in a synchronized wave.
WebSocket Connection Behavior: Duration and Reconnection
Free tier WebSocket connections are subject to session timeout policies that are not always visible in the documentation. Based on observed behavior:
| Behavior | Free tier specification |
|---|---|
| Heartbeat interval | Server expects ping every 30–60 seconds (varies by endpoint) |
| Connection timeout | Session may be closed after ~5 minutes of inactivity without heartbeat |
| Reconnection behavior | Client must implement reconnect logic; server does not queue missed messages |
| Subscription persistence | Subscriptions do not persist across reconnections — must be re-sent |
WebSocket Client with Heartbeat and Reconnection
The following client implements a robust WebSocket connection with:
- Ping/pong heartbeat to prevent server-side timeout
- Exponential backoff with jitter on reconnection
- Automatic resubscription after reconnect
- Graceful shutdown handling
import os
import json
import time
import random
import logging
import signal
import sys
import threading
from datetime import datetime
from typing import Optional, Callable
import websocket
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s"
)
logger = logging.getLogger(__name__)
API_KEY = os.environ.get("TICKDB_API_KEY")
WS_URL = "wss://api.tickdb.ai/v1/ws"
PING_INTERVAL = 30 # seconds
MAX_RECONNECT_DELAY = 120 # seconds
RECONNECT_BASE_DELAY = 1 # seconds
class TickDBWebSocketClient:
"""
Production-grade WebSocket client for TickDB.
Features: heartbeat, exponential backoff + jitter reconnect,
automatic resubscription, graceful shutdown.
"""
def __init__(
self,
api_key: str,
ws_url: str = WS_URL,
ping_interval: int = PING_INTERVAL,
on_message: Optional[Callable] = None
):
self.api_key = api_key
self.ws_url = f"{ws_url}?api_key={api_key}"
self.ping_interval = ping_interval
self.on_message = on_message
self.ws: Optional[websocket.WebSocketApp] = None
self.reconnect_delay = RECONNECT_BASE_DELAY
self.retry_count = 0
self.should_run = True
self.subscriptions: list[dict] = []
# Graceful shutdown
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _signal_handler(self, signum, frame):
logger.info("Shutdown signal received, closing connection...")
self.should_run = False
if self.ws:
self.ws.close()
def _on_open(self, ws: websocket.WebSocketApp):
logger.info(f"WebSocket connected: {datetime.now()}")
self.retry_count = 0 # Reset on successful connection
self.reconnect_delay = RECONNECT_BASE_DELAY
# Re-establish any stored subscriptions
for sub in self.subscriptions:
ws.send(json.dumps(sub))
logger.info(f"Resubscribed to: {sub.get('cmd')}")
# Start heartbeat thread
threading.Thread(target=self._heartbeat, daemon=True).start()
def _heartbeat(self):
"""Send periodic ping to prevent server-side timeout."""
while self.should_run and self.ws and self.ws.sock and self.ws.sock.connected:
try:
self.ws.send(json.dumps({"cmd": "ping"}))
logger.debug(f"Heartbeat sent at {datetime.now()}")
time.sleep(self.ping_interval)
except Exception as e:
logger.warning(f"Heartbeat failed: {e}")
break
def _on_message(self, ws: websocket.WebSocketApp, message: str):
try:
data = json.loads(message)
# Ignore pong responses
if data.get("cmd") == "pong":
return
if self.on_message:
self.on_message(data)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse message: {e}")
def _on_error(self, ws: websocket.WebSocketApp, error):
logger.error(f"WebSocket error: {error}")
def _on_close(self, ws, close_status_code, close_msg):
logger.warning(
f"WebSocket closed (code={close_status_code}, msg={close_msg})"
)
if self.should_run:
self._schedule_reconnect()
def _schedule_reconnect(self):
"""Exponential backoff with jitter, capped at MAX_RECONNECT_DELAY."""
delay = min(
self.reconnect_delay * (2 ** self.retry_count) + random.uniform(0, 1),
MAX_RECONNECT_DELAY
)
logger.info(
f"Reconnecting in {delay:.2f}s "
f"(attempt {self.retry_count + 1})"
)
time.sleep(delay)
self.retry_count += 1
self._connect()
def subscribe(self, cmd: str, **kwargs):
"""Send a subscription command. Stored for reconnection resilience."""
payload = {"cmd": cmd, **kwargs}
self.subscriptions.append(payload)
if self.ws and self.ws.sock and self.ws.sock.connected:
self.ws.send(json.dumps(payload))
logger.info(f"Subscribed to: {cmd}")
def _connect(self):
self.ws = websocket.WebSocketApp(
self.ws_url,
on_open=self._on_open,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close
)
# Run in a daemon thread to allow graceful shutdown
thread = threading.Thread(target=self.ws.run_forever, daemon=True)
thread.start()
def run(self):
"""Start the WebSocket client and block."""
logger.info("Starting TickDB WebSocket client...")
self._connect()
while self.should_run:
time.sleep(1)
# ── Usage Example ────────────────────────────────────────────────────────────
def handle_depth_update(data: dict):
"""Process order book depth updates."""
if data.get("cmd") == "depth":
symbol = data.get("symbol")
bids = data.get("b", [])
asks = data.get("a", [])
best_bid = float(bids[0][0]) if bids else None
best_ask = float(asks[0][0]) if asks else None
if best_bid and best_ask:
spread = (best_ask - best_bid) / best_bid * 10000
logger.info(
f"{symbol} | bid={best_bid} ask={best_ask} "
f"spread={spread:.1f} bps"
)
if __name__ == "__main__":
client = TickDBWebSocketClient(
api_key=API_KEY,
on_message=handle_depth_update
)
# Subscribe to depth channel for Apple
client.subscribe(cmd="subscribe", channel="depth", symbol="AAPL.US")
client.run()
Engineering notes:
- The heartbeat thread is daemonized — it will not prevent the process from exiting.
- Subscription state is stored in
self.subscriptionsand re-sent on every reconnection. This is critical for reliability; without it, a reconnect after a network blip will silently drop your data stream. - The reconnection delay uses full exponential backoff (multiply by 2 each attempt) plus uniform jitter. This prevents thundering herd when a shared network link recovers.
Rate Limit Trigger Scenarios: What Happens in Practice
Understanding rate limits in theory is not enough. The operational question is: what does the system do when I hit the wall?
Scenario 1: Burst Requests During Backtesting
If you are running a backtest that fetches hourly kline data for 100 symbols over 3 years, you will generate approximately 525,600 requests (100 symbols × 24 hours × 365 days × 3 years). The free tier daily limit will be exhausted within hours.
The correct approach is batch sizing. Rather than fetching symbol-by-symbol in a tight loop, batch requests by endpoint:
def fetch_multi_symbol_klines(symbols: list[str], interval: str, limit: int = 100):
"""
Fetch klines for multiple symbols, respecting rate limits.
Adds a small delay between requests to stay within RPM.
"""
results = {}
for symbol in symbols:
try:
data = request_with_retry(
method="GET",
endpoint="/market/kline",
params={"symbol": symbol, "interval": interval, "limit": limit}
)
results[symbol] = data
# ⚠️ Small delay between requests to avoid RPM burst
time.sleep(0.1)
except Exception as e:
logger.error(f"Failed to fetch {symbol}: {e}")
return results
Scenario 2: WebSocket Reconnection Storm
If your client disconnects and immediately attempts to reconnect without backoff, you can generate a reconnection storm that pushes your IP into a temporary blocklist even on the free tier. The client code above addresses this with exponential backoff — but the key insight is that a single client reconnecting aggressively is sufficient to trigger the limit.
Scenario 3: Multiple Clients Sharing One API Key
If you run a distributed system where multiple processes share the same API key, rate limits are shared across all processes. A backtesting worker and a live monitoring worker competing for the same quota will starve each other.
The solution is to provision separate API keys per logical system and implement per-key quota tracking in your infrastructure layer.
Free Tier Data Completeness: What You Can and Cannot Build
The free tier is genuinely capable for a specific set of strategies. It is not a capable for others.
What You Can Build
| Use case | Free tier support | Notes |
|---|---|---|
| End-of-day strategy backtesting | Full | 10+ years of US equity OHLCV via /kline |
| Intraday OHLCV strategies | Full | Minute, 5-minute, hourly intervals available |
| L1 order book monitoring | Full for US; L1–L10 for HK/crypto | Sufficient for basic imbalance detection |
| Real-time ticker streaming | Full | WebSocket ticker channel |
| Options data for major indices | Limited | Depends on symbol availability |
What You Cannot Build
| Use case | Free tier limitation | Workaround |
|---|---|---|
| Tick-level trade flow analysis (US equities) | trades endpoint not supported for US equities |
Use kline-derived volume; switch to HK/crypto for flow analysis |
| Multi-level order book depth (US equities) | L1 only | Use HK equities for L1–L10 depth strategies |
| High-frequency tick capture (<100ms) | WebSocket session limits | Use paid tier or reduce capture frequency |
| Unlimited historical data export | RPD cap applies | Batch downloads over multiple days |
Operational Guidelines: Designing for the Free Tier
The free tier rewards systems that are designed with its constraints in mind. Here are the non-obvious design principles:
1. Separate Backtest and Live Data Pipelines
Backtesting is bursty — it generates thousands of requests in a short window. Live monitoring is steady — it generates a few requests per minute. Running both on the same API key invites quota exhaustion during backtest runs, leaving your live system unable to fetch data.
Design: Use separate API keys for backtesting and live trading. Configure the backtest pipeline with a request throttle (0.5–1 request per second) to spread the load across a longer window.
2. Cache Aggressively
For historical data that does not change, cache at the source. If you are repeatedly backtesting the same symbol over the same period, fetch the data once and store it locally. The free tier is not a CDN — each redundant fetch consumes quota.
import json
import hashlib
from pathlib import Path
from functools import lru_cache
CACHE_DIR = Path("./tickdb_cache")
CACHE_DIR.mkdir(exist_ok=True)
def cache_key(symbol: str, interval: str, limit: int) -> str:
return hashlib.md5(f"{symbol}:{interval}:{limit}".encode()).hexdigest()
def get_cached_klines(symbol: str, interval: str, limit: int):
"""Fetch klines with local caching to minimize API calls."""
key = cache_key(symbol, interval, limit)
cache_file = CACHE_DIR / f"{key}.json"
if cache_file.exists():
age = time.time() - cache_file.stat().st_mtime
if age < 3600: # Cache valid for 1 hour
with open(cache_file) as f:
return json.load(f)
data = request_with_retry(
method="GET",
endpoint="/market/kline",
params={"symbol": symbol, "interval": interval, "limit": limit}
)
with open(cache_file, "w") as f:
json.dump(data, f)
return data
3. Monitor Your Quota Consumption
The Retry-After response tells you when you have hit the limit, but it does not tell you how close you are. Implement a simple request counter that logs the timestamp of each successful request. If you see the interval between successes shrinking, you are approaching the boundary.
4. Handle 3001 as a First-Class Event
Do not treat rate limit errors as exceptions. Treat them as a normal operational state. The Retry-After header is a contract from the server — honor it precisely. Building a system that treats rate limits as failures will produce a system that fails in production.
When to Upgrade: The Decision Framework
The free tier is an excellent starting point. Here is how to know when you need more:
| Signal | Upgrade trigger |
|---|---|
| Backtest takes more than one day due to API quota wait times | Consider paid tier to remove daily limits |
| Missing data during live trading due to rate limit gaps | Paid tier with higher RPM |
| Need US equity tick data for trade flow analysis | Paid tier required — trades endpoint for US equities |
| L2–L10 order book depth needed for US equities | Paid tier for US market depth levels |
| Multiple concurrent strategies on same symbol set | Separate API keys or team/enterprise tier |
| Sub-100ms data freshness required | Enterprise tier with dedicated infrastructure |
The upgrade decision is not about "more data." It is about specific capability gaps that prevent your strategy from functioning as designed. Build on the free tier until you hit one of these walls, then upgrade with a precise understanding of what you are buying.
Summary
The TickDB free tier is a capable, honest product tier for individual quant developers. It provides real market data, a functional WebSocket interface, and sufficient historical depth for most end-of-day and intraday strategy development. Its limits — the absence of US equity tick data, L1-only depth for US markets, and rate limit caps — are clearly defined and survivable with the right system design.
The key to surviving the free tier is architectural discipline: implement rate limit handling as a first-class concern, cache aggressively, separate backtest and live pipelines, and monitor your quota consumption proactively. The free tier will not break your system. Bad design will.
If you are an individual quant developer building your first systematic strategy: start here. The free tier gives you everything you need to validate your thesis before committing budget.
If you are a trading team evaluating TickDB for institutional use: the free tier is a functional proof of concept. Schedule a conversation with enterprise@tickdb.ai when you are ready to discuss multi-key infrastructure and historical data depth requirements.
If you use AI coding assistants: search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get TickDB API integration templates and rate limit handling patterns directly in your workflow.
This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.