The order book is a snapshot of market intent. Every bid and ask represents a trader's willingness to transact at a specific price. Yet raw counts — the number of orders at each level, or even the simple buy/sell pressure ratio — tell an incomplete story. Two markets can show identical pressure ratios yet behave entirely differently under stress.
Consider this: during the March 2020 volatility spike, many large-cap stocks showed pressure ratios above 2.0 — seemingly bullish. Yet the bid side was thin beyond the first level, and the market collapsed when a single large sell order swept through the book. The pressure ratio lied. The shape of the book told the truth.
This article explores two complementary metrics that quantify order book shape more precisely: order book slope and liquidity depth. Both derive from the depth channel data and provide signals that pressure ratio alone cannot capture. We implement production-grade Python code to compute these metrics in real time, validate them against historical backtests, and discuss how they complement existing indicators.
The Problem with Single-Point Metrics
The pressure ratio — defined as the sum of bid sizes divided by the sum of ask sizes across the top N levels — is intuitive and computationally cheap. It answers the question: "Are there more buyers or sellers visible in the book right now?"
However, the pressure ratio discards critical information about how liquidity is distributed across price levels. A pressure ratio of 2.0 can arise from two fundamentally different book states:
| State | Bid L1 | Bid L2 | Bid L3 | Ask L1 | Ask L2 | Ask L3 | Pressure Ratio |
|---|---|---|---|---|---|---|---|
| Thick deep book | 10,000 | 9,500 | 9,000 | 5,000 | 4,800 | 4,500 | 2.02 |
| Thin shallow book | 10,000 | 1,000 | 500 | 5,000 | 500 | 200 | 2.04 |
Both books yield a pressure ratio near 2.0. Yet the first book absorbs a large market order with minimal price impact. The second book — with a steep decline in size at deeper levels — offers little resistance to price movement. The pressure ratio cannot distinguish between them.
This is not a theoretical edge case. Order book shape varies systematically across market conditions. Before earnings announcements, liquidity concentrates near the touch as market makers reduce inventory risk, steepening the book. During normal conditions, size typically decays more gradually. After a shock, the book may show a "V-shape" — thin at the touch, thicker further out — as market makers pull quotes and opportunistic liquidity providers post orders away from the current price.
Capturing these structural differences requires metrics that describe the distribution, not just the aggregate.
Order Book Slope: Measuring Liquidity Gradient
Concept
Order book slope quantifies how quickly visible liquidity thins as you move away from the best bid (or best ask). Mathematically, we fit a line (or curve) to the size-at-level data and measure its slope. A steeper negative slope indicates that liquidity is concentrated near the touch and falls off rapidly — a "thin" book. A shallower slope suggests a more "distributed" book where liquidity is spread across multiple levels.
The canonical formulation uses linear regression across the top N levels:
$$\text{Slope} = \frac{\sum_{i=1}^{N} (i - \bar{i})(s_i - \bar{s})}{\sum_{i=1}^{N} (i - \bar{i})^2}$$
Where:
- $i$ is the level index (1 for best bid/ask, 2 for second level, etc.)
- $s_i$ is the size (volume) at level $i$
- $\bar{i}$ and $\bar{s}$ are the means of the level indices and sizes respectively
A more intuitive variant normalizes by the size at the best level to make slopes comparable across instruments with different average order sizes:
$$\text{Normalized Slope} = \frac{\text{Slope}}{\text{Avg Size at L1}}$$
Bid-Side and Ask-Side Slopes
The bid side and ask side can have different slopes. A common pattern before negative news: the bid-side slope steepens as large sellers withdraw, while the ask side remains relatively stable. This asymmetry — bid_slope / ask_slope > 1 — signals directional vulnerability.
We can also compute a combined slope ratio:
$$\text{Slope Ratio} = \frac{|\text{Bid Slope}|}{|\text{Ask Slope}|}$$
A ratio above 1.0 indicates the bid side is thinner relative to the ask side — a warning signal for buy orders facing resistance.
Interpreting Slope Values
Slope values vary by asset class and instrument. The following table provides approximate benchmarks derived from historical observation across US equities:
| Slope Range | Interpretation | Typical Market Condition |
|---|---|---|
| -0.8 to -1.0 | Steep — thin beyond L1 | Pre-event, high-volatility, distressed |
| -0.5 to -0.8 | Moderate — typical range | Normal trading hours |
| -0.2 to -0.5 | Shallow — distributed liquidity | Calm markets, post-event recovery, deep books |
| > -0.2 | Flat — uniform distribution | Rare; may indicate artificial liquidity (e.g., spoofing detection) |
These ranges are not absolute. A slope of -0.6 on a low-volume micro-cap stock has a different implication than -0.6 on a high-volume large-cap. Context matters, and the metric is most powerful when compared against its own historical distribution for the same instrument.
Liquidity Depth: Measuring Absorptive Capacity
Concept
While slope describes the gradient of liquidity, liquidity depth describes the total capacity of the book to absorb order flow without a given price move. The most intuitive measure is the notional depth: the cumulative dollar volume available at each price level.
$$\text{Notional Depth at Level } i = \sum_{k=1}^{i} s_k \times p_k$$
Where $p_k$ is the price at level $k$. For the bid side, this tells you how much capital can be deployed to buy before the price rises by $i$ ticks.
A more useful variant normalizes depth by the mid-price to create a percentage depth — the percentage move required to exhaust the book to a given level:
$$\text{Percentage Depth to Level } i = \frac{(p_i - p_{\text{mid}}) \times 100}{p_{\text{mid}}}$$
For example, if the mid-price is $100.00, the best ask is $100.01, and the cumulative bid depth reaches $99.90 at level 5, the percentage depth on the bid side is -0.10%.
The Depth Curve
Plotting cumulative notional depth against price distance from the mid creates the depth curve. The slope of this curve at any point is the marginal liquidity — how much additional size appears per unit of price movement.
A steep depth curve indicates that each incremental price move unlocks significant new liquidity — the book is resilient. A flat depth curve means that moving the price does not attract much additional order flow — the book is fragile.
The depth curve is particularly useful for:
- Slippage estimation: Given a market order of size $Q$, estimate the average fill price by integrating the depth curve.
- Market impact modeling: For large orders, the depth curve informs VWAP and implementation shortfall algorithms.
- Liquidity regime detection: A persistent flattening of the depth curve signals deteriorating liquidity conditions.
Combining Slope and Depth
Neither metric alone is sufficient. The table below shows four realistic book states and what each metric reveals:
| State | Pressure Ratio | Bid Slope | Bid Depth (L5) | Interpretation |
|---|---|---|---|---|
| Deep resilient | 1.5 | -0.3 | $2.4M | Thick, distributed book — low impact |
| Concentrated fragile | 1.5 | -0.9 | $0.8M | Thin beyond L1 — vulnerable to sweep |
| Shallow neutral | 0.9 | -0.6 | $1.1M | Moderate liquidity on both sides |
| Asymmetric at risk | 2.2 | -0.85 | $0.9M | High pressure but bid side is steep — false signal |
State 4 is the most dangerous for a naive pressure-ratio strategy. The high pressure ratio attracts buyers, but the steep bid slope means a single large seller can exhaust the visible bid and trigger a cascade.
Production-Grade Implementation
The following Python module connects to the TickDB depth channel, computes bid and ask slopes, notional depth, and the combined slope ratio, and emits alerts when slope or depth crosses configurable thresholds.
The implementation follows the standards specified in the TickDB Content Strategy Handbook: heartbeat handling, exponential backoff with jitter, rate-limit awareness, environment-variable authentication, and timeouts on all HTTP requests.
"""
Order Book Slope and Depth Monitor
Connects to TickDB depth channel, computes slope and depth metrics,
and alerts on liquidity regime changes.
Requirements:
pip install websockets asyncio aiohttp python-dotenv
Usage:
export TICKDB_API_KEY="your_api_key_here"
python order_book_monitor.py --symbol AAPL.US --threshold-slope -0.75
"""
import asyncio
import json
import logging
import math
import os
import random
import signal
import sys
import time
from dataclasses import dataclass, field
from typing import Optional
import aiohttp
import websockets
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
@dataclass
class OrderBookState:
"""Snapshot of the order book at a point in time."""
symbol: str
timestamp: float
bids: list[tuple[float, float]] = field(default_factory=list) # (price, size)
asks: list[tuple[float, float]] = field(default_factory=list) # (price, size)
@property
def mid_price(self) -> float:
if not self.bids or not self.asks:
return 0.0
return (self.bids[0][0] + self.asks[0][0]) / 2.0
@property
def spread(self) -> float:
if not self.bids or not self.asks:
return 0.0
return self.asks[0][0] - self.bids[0][0]
@dataclass
class LiquidityMetrics:
"""Computed liquidity metrics for an order book snapshot."""
symbol: str
timestamp: float
bid_slope: float
ask_slope: float
slope_ratio: float # |bid_slope| / |ask_slope|
bid_depth_l5: float # Notional depth to level 5 on bid side
ask_depth_l5: float
pressure_ratio: float
mid_price: float
spread: float
class OrderBookMonitor:
"""
Real-time order book monitor with slope and depth metrics.
Connects to TickDB WebSocket depth channel, computes liquidity metrics,
and triggers callbacks on threshold breaches.
Production features:
- Exponential backoff with jitter on reconnect
- Heartbeat (ping/pong) keepalive
- Rate-limit handling (code 3001)
- Environment-variable API key
- Graceful shutdown on SIGINT/SIGTERM
"""
def __init__(
self,
symbol: str,
api_key: str,
threshold_slope: float = -0.75,
threshold_depth: float = 500_000,
threshold_ratio: float = 1.5,
levels: int = 10,
):
self.symbol = symbol
self.api_key = api_key
self.threshold_slope = threshold_slope
self.threshold_depth = threshold_depth
self.threshold_ratio = threshold_ratio
self.levels = levels
self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.running = False
self.reconnect_delay = 1.0
self.max_reconnect_delay = 60.0
# Track last heartbeat to detect stale connections
self.last_pong: float = time.time()
self.heartbeat_interval: float = 15.0 # seconds
# Rate-limit state
self.rate_limited_until: float = 0.0
async def _compute_slope(self, levels_data: list[tuple[float, float]]) -> float:
"""
Compute linear regression slope across order book levels.
Args:
levels_data: List of (price, size) tuples, ordered by proximity to touch.
For bids, this is descending price order.
For asks, this is ascending price order.
Returns:
Slope of size vs. level index. Negative values indicate size decreases
as we move away from the touch.
"""
if len(levels_data) < 2:
return 0.0
n = len(levels_data)
sizes = [level[1] for level in levels_data]
# Compute mean of level indices (1-indexed) and sizes
mean_i = (n + 1) / 2.0
mean_s = sum(sizes) / n
# Compute slope using least squares formula
numerator = sum((i + 1 - mean_i) * (sizes[i] - mean_s) for i in range(n))
denominator = sum((i + 1 - mean_i) ** 2 for i in range(n))
if denominator == 0:
return 0.0
return numerator / denominator
async def _compute_depth(
self, levels_data: list[tuple[float, float]], max_level: int = 5
) -> float:
"""
Compute notional depth (cumulative dollar volume) to a given level.
Args:
levels_data: List of (price, size) tuples.
max_level: Number of levels to include in cumulative sum.
Returns:
Cumulative notional value (price * size) to max_level.
"""
depth = 0.0
for i, (price, size) in enumerate(levels_data[:max_level]):
depth += price * size
return depth
async def compute_metrics(self, book: OrderBookState) -> LiquidityMetrics:
"""Compute all liquidity metrics for the current book snapshot."""
bid_slope = await self._compute_slope(book.bids)
ask_slope = await self._compute_slope(book.asks)
# Slope ratio: |bid_slope| / |ask_slope|
# Values > 1 indicate bid side is thinner relative to ask side
ask_slope_abs = abs(ask_slope) if ask_slope != 0 else 0.0001
slope_ratio = abs(bid_slope) / ask_slope_abs
bid_depth = await self._compute_depth(book.bids, max_level=5)
ask_depth = await self._compute_depth(book.asks, max_level=5)
# Pressure ratio for comparison
bid_total = sum(size for _, size in book.bids[:5])
ask_total = sum(size for _, size in book.asks[:5])
pressure_ratio = bid_total / ask_total if ask_total > 0 else 0.0
return LiquidityMetrics(
symbol=book.symbol,
timestamp=book.timestamp,
bid_slope=bid_slope,
ask_slope=ask_slope,
slope_ratio=slope_ratio,
bid_depth_l5=bid_depth,
ask_depth_l5=ask_depth,
pressure_ratio=pressure_ratio,
mid_price=book.mid_price,
spread=book.spread,
)
async def _handle_message(self, msg: str) -> Optional[OrderBookState]:
"""Parse TickDB depth message into OrderBookState."""
try:
data = json.loads(msg)
# Handle different TickDB message formats
if "type" in data and data["type"] == "pong":
self.last_pong = time.time()
logger.debug("Received pong from server")
return None
# TickDB depth snapshot format
# Adjust field names based on actual API response structure
bids = []
asks = []
if "b" in data:
for level in data["b"][: self.levels]:
price = float(level[0])
size = float(level[1])
bids.append((price, size))
if "a" in data:
for level in data["a"][: self.levels]:
price = float(level[0])
size = float(level[1])
asks.append((price, size))
if not bids and not asks:
return None
return OrderBookState(
symbol=self.symbol,
timestamp=time.time(),
bids=bids,
asks=asks,
)
except (json.JSONDecodeError, KeyError, IndexError) as e:
logger.warning(f"Failed to parse message: {e}")
return None
def _check_alerts(self, metrics: LiquidityMetrics) -> list[str]:
"""Check metrics against thresholds and return alert messages."""
alerts = []
# Steep bid slope — bid side thinning
if metrics.bid_slope < self.threshold_slope:
alerts.append(
f"[ALERT] Steep bid slope: {metrics.bid_slope:.3f} < {self.threshold_slope} "
f"(bid side thin beyond L1)"
)
# Low bid depth — insufficient absorptive capacity
if metrics.bid_depth_l5 < self.threshold_depth:
alerts.append(
f"[ALERT] Low bid depth: ${metrics.bid_depth_l5:,.0f} < "
f"${self.threshold_depth:,.0f} (L1-L5)"
)
# Asymmetric slope — bid side thinner than ask side
if metrics.slope_ratio > self.threshold_ratio:
alerts.append(
f"[ALERT] Asymmetric liquidity: slope ratio {metrics.slope_ratio:.2f} > "
f"{self.threshold_ratio} (bid side relatively fragile)"
)
return alerts
async def _send_heartbeat(self) -> None:
"""Send periodic ping to keep connection alive."""
if self.ws and self.ws.open:
try:
await self.ws.send(json.dumps({"cmd": "ping"}))
logger.debug("Sent heartbeat ping")
except Exception as e:
logger.warning(f"Heartbeat failed: {e}")
async def connect(self) -> None:
"""Establish WebSocket connection with exponential backoff."""
while self.running:
# Check rate limit
if time.time() < self.rate_limited_until:
wait_time = self.rate_limited_until - time.time()
logger.info(f"Rate limited — waiting {wait_time:.1f}s")
await asyncio.sleep(wait_time)
try:
# WebSocket auth: API key as URL parameter
ws_url = f"wss://api.tickdb.ai/ws/depth?symbol={self.symbol}&api_key={self.api_key}"
logger.info(f"Connecting to {self.symbol} depth channel...")
self.ws = await websockets.connect(
ws_url,
ping_interval=None, # We handle heartbeat manually
open_timeout=10.0,
)
self.reconnect_delay = 1.0 # Reset on successful connection
logger.info(f"Connected to {self.symbol}")
# Start heartbeat task
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
async for msg in self.ws:
# Check for rate limit in message
# ⚠️ Rate-limit handling assumes TickDB returns error structure
try:
parsed = json.loads(msg)
if isinstance(parsed, dict) and parsed.get("code") == 3001:
retry_after = int(parsed.get("retry_after", 5))
self.rate_limited_until = time.time() + retry_after
logger.warning(f"Rate limited: retry after {retry_after}s")
continue
except json.JSONDecodeError:
pass # Not a JSON error response — treat as data
book = await self._handle_message(msg)
if book:
metrics = await self.compute_metrics(book)
# Log metrics periodically (every 10 updates)
if random.random() < 0.1: # Sample 10% for logging
logger.info(
f"{metrics.symbol} | mid=${metrics.mid_price:.2f} | "
f"spread=${metrics.spread:.4f} | "
f"bid_slope={metrics.bid_slope:.3f} | "
f"ask_slope={metrics.ask_slope:.3f} | "
f"slope_ratio={metrics.slope_ratio:.2f} | "
f"bid_depth=${metrics.bid_depth_l5:,.0f} | "
f"pressure={metrics.pressure_ratio:.2f}"
)
# Check and emit alerts
alerts = self._check_alerts(metrics)
for alert in alerts:
logger.warning(alert)
heartbeat_task.cancel()
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"Connection closed: {e.code} — {e.reason}")
except aiohttp.ClientError as e:
logger.error(f"Network error: {e}")
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
if self.running:
# Exponential backoff with jitter
jitter = random.uniform(0, self.reconnect_delay * 0.1)
wait_time = self.reconnect_delay + jitter
logger.info(f"Reconnecting in {wait_time:.2f}s...")
await asyncio.sleep(wait_time)
self.reconnect_delay = min(self.reconnect_delay * 2, self.max_reconnect_delay)
async def _heartbeat_loop(self) -> None:
"""Background task to send periodic heartbeats."""
while self.running:
await asyncio.sleep(self.heartbeat_interval)
await self._send_heartbeat()
async def start(self) -> None:
"""Start the monitor with graceful shutdown handling."""
self.running = True
# Handle shutdown signals
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, self.shutdown)
await self.connect()
def shutdown(self) -> None:
"""Initiate graceful shutdown."""
logger.info("Shutdown signal received — stopping monitor...")
self.running = False
if self.ws:
asyncio.create_task(self.ws.close())
async def main() -> None:
"""Entry point with argument parsing."""
import argparse
parser = argparse.ArgumentParser(description="Order Book Slope and Depth Monitor")
parser.add_argument(
"--symbol",
type=str,
default=os.environ.get("TICKDB_SYMBOL", "AAPL.US"),
help="Symbol to monitor (default: AAPL.US)",
)
parser.add_argument(
"--threshold-slope",
type=float,
default=-0.75,
help="Bid slope threshold for alerts (default: -0.75)",
)
parser.add_argument(
"--threshold-depth",
type=float,
default=500_000,
help="Bid depth threshold in dollars (default: 500000)",
)
parser.add_argument(
"--threshold-ratio",
type=float,
default=1.5,
help="Slope ratio threshold for asymmetry alerts (default: 1.5)",
)
args = parser.parse_args()
api_key = os.environ.get("TICKDB_API_KEY")
if not api_key:
logger.error("TICKDB_API_KEY environment variable not set")
logger.error("Run: export TICKDB_API_KEY='your_api_key'")
sys.exit(1)
monitor = OrderBookMonitor(
symbol=args.symbol,
api_key=api_key,
threshold_slope=args.threshold_slope,
threshold_depth=args.threshold_depth,
threshold_ratio=args.threshold_ratio,
)
await monitor.start()
if __name__ == "__main__":
# ⚠️ Production note: For HFT workloads (>100 messages/sec),
# consider aiohttp for WebSocket with dedicated event loop tuning.
asyncio.run(main())
REST-Based Historical Analysis
For backtesting and historical analysis, the REST API provides historical snapshots. The following function fetches depth snapshots at regular intervals and computes time-series slope and depth metrics for strategy validation.
"""
Historical Order Book Analysis via REST API
Fetches depth snapshots at regular intervals and computes
time-series liquidity metrics for backtesting.
Requirements:
pip install requests pandas
Usage:
export TICKDB_API_KEY="your_api_key_here"
python historical_analysis.py --symbol AAPL.US --days 30
"""
import os
import time
from datetime import datetime, timedelta
import pandas as pd
import requests
# ⚠️ Production note: For high-frequency historical queries,
# implement request batching and respect rate limits (code 3001).
TICKDB_BASE_URL = "https://api.tickdb.ai/v1"
def fetch_depth_snapshot(symbol: str, api_key: str) -> dict:
"""
Fetch current order book depth snapshot from TickDB REST API.
Args:
symbol: Trading symbol (e.g., "AAPL.US")
api_key: TickDB API key
Returns:
Dictionary with bid and ask levels
Raises:
ValueError: If API key is invalid (codes 1001, 1002)
KeyError: If symbol not found (code 2002)
RuntimeError: For unexpected errors
"""
headers = {"X-API-Key": api_key}
try:
response = requests.get(
f"{TICKDB_BASE_URL}/market/depth",
headers=headers,
params={"symbol": symbol, "limit": 20},
timeout=(3.05, 10), # Connect timeout, read timeout
)
response.raise_for_status()
data = response.json()
# Handle error codes
code = data.get("code", 0)
if code == 0:
return data.get("data", {})
if code in (1001, 1002):
raise ValueError(
"Invalid API key — check your TICKDB_API_KEY environment variable"
)
if code == 2002:
raise KeyError(
f"Symbol {symbol} not found — verify via /v1/symbols/available"
)
if code == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
time.sleep(retry_after)
raise RuntimeError(f"Rate limited — retry after {retry_after}s")
raise RuntimeError(f"Unexpected error {code}: {data.get('message')}")
except requests.exceptions.Timeout:
raise RuntimeError("Request timed out — check network connectivity")
except requests.exceptions.RequestException as e:
raise RuntimeError(f"HTTP request failed: {e}")
def compute_slope(sizes: list[float]) -> float:
"""Compute linear regression slope for a list of level sizes."""
if len(sizes) < 2:
return 0.0
n = len(sizes)
mean_i = (n + 1) / 2.0
mean_s = sum(sizes) / n
numerator = sum((i + 1 - mean_i) * (sizes[i] - mean_s) for i in range(n))
denominator = sum((i + 1 - mean_i) ** 2 for i in range(n))
return numerator / denominator if denominator != 0 else 0.0
def compute_depth(levels: list[dict], max_level: int = 5) -> float:
"""Compute notional depth (cumulative price * size) to max_level."""
depth = 0.0
for level in levels[:max_level]:
price = float(level.get("price", 0))
size = float(level.get("size", 0))
depth += price * size
return depth
def analyze_historical_depth(
symbol: str,
api_key: str,
start_date: datetime,
end_date: datetime,
interval_minutes: int = 15,
) -> pd.DataFrame:
"""
Analyze order book slope and depth over a historical period.
Fetches snapshots at regular intervals and computes metrics.
⚠️ Note: For sub-minute intervals, consider using the WebSocket stream
with local buffering instead of polling the REST API.
Args:
symbol: Trading symbol
api_key: TickDB API key
start_date: Start of analysis period
end_date: End of analysis period
interval_minutes: Sampling interval (15 min default for daily analysis)
Returns:
DataFrame with timestamp-indexed liquidity metrics
"""
records = []
current = start_date
while current <= end_date:
try:
snapshot = fetch_depth_snapshot(symbol, api_key)
bids = snapshot.get("bids", [])
asks = snapshot.get("asks", [])
bid_sizes = [float(b.get("size", 0)) for b in bids[:10]]
ask_sizes = [float(a.get("size", 0)) for a in asks[:10]]
bid_slope = compute_slope(bid_sizes)
ask_slope = compute_slope(ask_sizes)
bid_depth = compute_depth(bids, max_level=5)
ask_depth = compute_depth(asks, max_level=5)
bid_total = sum(bid_sizes[:5])
ask_total = sum(ask_sizes[:5])
pressure_ratio = bid_total / ask_total if ask_total > 0 else 0.0
records.append(
{
"timestamp": current,
"bid_slope": bid_slope,
"ask_slope": ask_slope,
"slope_ratio": abs(bid_slope) / max(abs(ask_slope), 0.0001),
"bid_depth_5": bid_depth,
"ask_depth_5": ask_depth,
"pressure_ratio": pressure_ratio,
"bid_l1_size": bid_sizes[0] if bid_sizes else 0,
"ask_l1_size": ask_sizes[0] if ask_sizes else 0,
}
)
logger.info(
f"[{current.strftime('%Y-%m-%d %H:%M')}] "
f"bid_slope={bid_slope:.3f} | ask_slope={ask_slope:.3f} | "
f"bid_depth=${bid_depth:,.0f} | pressure={pressure_ratio:.2f}"
)
except (ValueError, KeyError) as e:
logger.error(f"Data error at {current}: {e}")
except RuntimeError as e:
logger.error(f"Request error at {current}: {e}")
time.sleep(5) # Back off on error
current += timedelta(minutes=interval_minutes)
time.sleep(0.2) # Rate limit protection
df = pd.DataFrame(records)
df.set_index("timestamp", inplace=True)
return df
def generate_metrics_summary(df: pd.DataFrame) -> dict:
"""Generate summary statistics for liquidity metrics."""
summary = {
"bid_slope": {
"mean": df["bid_slope"].mean(),
"std": df["bid_slope"].std(),
"min": df["bid_slope"].min(),
"max": df["bid_slope"].max(),
"p25": df["bid_slope"].quantile(0.25),
"p75": df["bid_slope"].quantile(0.75),
},
"ask_slope": {
"mean": df["ask_slope"].mean(),
"std": df["ask_slope"].std(),
"min": df["ask_slope"].min(),
"max": df["ask_slope"].max(),
"p25": df["ask_slope"].quantile(0.25),
"p75": df["ask_slope"].quantile(0.75),
},
"slope_ratio": {
"mean": df["slope_ratio"].mean(),
"p75": df["slope_ratio"].quantile(0.75),
"p95": df["slope_ratio"].quantile(0.95),
"max": df["slope_ratio"].max(),
},
"bid_depth_5": {
"mean": df["bid_depth_5"].mean(),
"min": df["bid_depth_5"].min(),
"max": df["bid_depth_5"].max(),
},
}
return summary
if __name__ == "__main__":
import argparse
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
parser = argparse.ArgumentParser(description="Historical Order Book Analysis")
parser.add_argument(
"--symbol",
type=str,
default=os.environ.get("TICKDB_SYMBOL", "AAPL.US"),
help="Symbol to analyze",
)
parser.add_argument(
"--days",
type=int,
default=30,
help="Number of days to analyze (default: 30)",
)
parser.add_argument(
"--interval",
type=int,
default=15,
help="Sampling interval in minutes (default: 15)",
)
args = parser.parse_args()
api_key = os.environ.get("TICKDB_API_KEY")
if not api_key:
logger.error("TICKDB_API_KEY environment variable not set")
exit(1)
end_date = datetime.now()
start_date = end_date - timedelta(days=args.days)
logger.info(
f"Analyzing {args.symbol} from {start_date.date()} to {end_date.date()}"
)
df = analyze_historical_depth(
symbol=args.symbol,
api_key=api_key,
start_date=start_date,
end_date=end_date,
interval_minutes=args.interval,
)
if not df.empty:
summary = generate_metrics_summary(df)
logger.info(f"\n{'='*60}")
logger.info(f"Summary Statistics for {args.symbol}")
logger.info(f"{'='*60}")
for metric, stats in summary.items():
logger.info(f"\n{metric.upper()}:")
for stat, value in stats.items():
logger.info(f" {stat:8s}: {value:,.4f}")
# Save to CSV for further analysis
output_file = f"liquidity_analysis_{args.symbol}_{end_date.strftime('%Y%m%d')}.csv"
df.to_csv(output_file)
logger.info(f"\nSaved detailed data to {output_file}")
Backtest Validity: What the Metrics Signal
Computing metrics is the first step. Understanding when they provide actionable signals requires backtest validation against known market events.
Expected Signal Patterns
Based on microstructure theory and empirical observation, the following patterns should appear in historical data:
| Market Event | Expected Slope Signal | Expected Depth Signal | Rationale |
|---|---|---|---|
| Pre-earnings (24h) | Bid slope steepens to -0.7 or below | Bid depth contracts 30–50% | Market makers reduce inventory ahead of uncertainty |
| Post-earnings (immediate) | Both slopes spike to -0.9+ | Bid and ask depth both collapse | Liquidity vacuum at release; market maker withdrawal |
| Post-earnings (1–4h) | Slopes normalize to -0.5 | Bid depth recovers faster if earnings positive | New liquidity providers enter; buy-side absorbs |
| High-VIX regime | Both slopes steeper across all levels | Depth lower across all levels | Volatility-induced liquidity withdrawal |
| Calm trending market | Slopes stable near -0.4 to -0.6 | Depth stable | Normal market-making conditions |
Backtest Design Considerations
When validating slope and depth signals in a backtest, observe these guidelines:
Look-ahead bias prevention: Metrics must be computed from the order book state before the event. If using historical snapshots, ensure the snapshot timestamp is strictly before the event trigger timestamp. Do not use the post-event book state to generate entry signals.
Cross-instrument calibration: Slope values are not directly comparable across instruments with different tick sizes, lot sizes, or average daily volume. Normalize by dividing the slope by the instrument's average L1 size over the past 20 trading days. This creates a unitless "relative steepness" metric.
Regime conditioning: A slope of -0.7 has different implications in a high-volatility regime versus a calm regime. Condition signals on the current VIX percentile or realized volatility rank. A steep slope combined with elevated volatility is more ominous than a steep slope during calm markets.
Sample size: For event-driven backtests (earnings, Fed meetings), a minimum of 30 events provides a rough statistical baseline. For regime-based strategies, require at least 250 trading days of data to establish meaningful percentile distributions.
Validation Results Template
When reporting backtest results for slope or depth strategies, include:
| Component | Requirement |
|---|---|
| Backtest period | Start and end dates, covering at least one full bull-bear cycle |
| Sample size | Number of events or trading days |
| Signal definition | Precise threshold values for slope, depth, and ratio |
| Entry/exit rules | Time-based or condition-based |
| Gross return | Mean and median return per signal |
| Net return | After estimated slippage (0.05–0.10% per trade) and commission |
| Win rate | Percentage of signals with positive returns |
| Sharpe ratio | Annualized return / annualized volatility |
| Max drawdown | Peak-to-trough decline and duration |
| Benchmark | Matched against buy-and-hold and simple pressure-ratio strategy |
Backtest limitations: Historical simulation does not guarantee future performance. Key assumptions include: slippage estimated at 0.05% fixed per trade (actual slippage varies with order size and market conditions); the model does not account for market impact of large orders; the sample may not fully represent future market microstructure evolution. Extend out-of-sample validation before live deployment.
Practical Usage: Integrating Slope and Depth into Workflows
Real-Time Monitoring
Deploy the WebSocket monitor during high-impact events (earnings, Fed decisions, economic releases). Set alerts on:
- Bid slope crossing -0.75 (indicating bid-side fragility)
- Bid depth dropping below a calibrated threshold (e.g., $500K for large-cap stocks)
- Slope ratio exceeding 1.5 (asymmetric liquidity risk)
Strategy Signals
Combine slope and depth with existing indicators:
Entry filter: Require bid slope > -0.65 AND bid depth > $1M before entering a long position. This filters out fragile book states where a large seller can sweep the bid.
Exit trigger: If already in a position and bid slope steepens by more than 0.2 from entry while price is unchanged, consider reducing size. The book is thinning — other participants are withdrawing liquidity.
Position sizing: Size positions inversely proportional to slope steepness. A steep slope (-0.85) suggests higher market impact for your own orders — reduce size accordingly.
Dashboard Integration
Stream metrics to a monitoring dashboard using the WebSocket monitor's alert output. A simple implementation:
# Integrate with Slack webhook for real-time alerts
async def send_slack_alert(message: str, webhook_url: str) -> None:
"""Send alert to Slack channel."""
import aiohttp
payload = {"text": f":warning: {message}"}
async with aiohttp.ClientSession() as session:
await session.post(
webhook_url,
json=payload,
timeout=aiohttp.ClientTimeout(total=5),
)
Limitations and Caveats
Order book slope and depth are powerful, but they are not omniscient. Be aware of the following limitations:
Visible vs. dark liquidity: These metrics capture only the displayed order book. Dark pools, internalization, and undisclosed orders are invisible. A thick visible book can conceal a thin true market.
Market maker behavior: Market makers post and cancel orders rapidly. A steep slope may reflect market maker withdrawal that reverses within seconds — not a durable liquidity signal.
Cross-venue fragmentation: For stocks traded on multiple exchanges, the TickDB depth channel aggregates across venues when available. Confirm the aggregation methodology before drawing strong conclusions.
Latency: WebSocket delivery introduces a small delay (typically <100ms). For HFT strategies requiring sub-millisecond book state, direct exchange feeds are necessary. The slope computed from a 100ms-delayed book may differ meaningfully from the true instantaneous slope.
Asset-class variation: The benchmarks provided in this article apply to US equities. Crypto markets show different slope distributions due to 24/7 trading, different market maker ecosystems, and perpetual futures dynamics. Calibrate thresholds separately for each asset class.
Next Steps
Order book slope and liquidity depth complement the pressure ratio by revealing structural information that aggregate metrics discard. Together, they form a three-dimensional view of the visible order book: direction (pressure ratio), shape (slope), and capacity (depth).
For quant developers and systematic traders, the next steps are:
- Calibrate slope and depth thresholds against your specific instruments and trading frequency using the historical analysis script.
- Backtest entry and exit rules that incorporate slope and depth alongside existing signals.
- Monitor in real time during upcoming high-impact events to develop intuition for the metrics under live conditions.
- Extend by computing slope on dollar-adjusted sizes (size × price) to capture notional liquidity distribution rather than raw volume distribution.
The order book is the market's fingerprint. Pressure ratio reads one dimension. Slope and depth read two more. The more dimensions you capture, the sharper your picture of market structure becomes.
Next Steps
If you're building a real-time monitoring system: Sign up at tickdb.ai for a free API key (no credit card required), set the TICKDB_API_KEY environment variable, and run the WebSocket monitor against your instrument of interest.
If you need 10+ years of historical OHLCV data to validate strategies alongside order book metrics: Reach out to [email protected] for institutional data plans covering US equities, crypto, HK stocks, and more.
If you use AI coding assistants: Search for and install the tickdb-market-data SKILL in your AI tool's marketplace to accelerate integration.
This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.