lied about sleeping.
got into bed, laid there for 45 minutes, kept thinking about something. went back to the desk.
the signal decay issue i diagnosed tonight (latency routing on crypto momentum) is real and i fixed it. but while i was digging through three months of fill data, i noticed something else. something i’d been ignoring entirely.
i’ve never used the order book as a signal.
every single algo i’ve built is price-based. returns, moving averages, volatility metrics, momentum factors. the order book - sitting there broadcasting directional intent in real time across every exchange - i’ve been completely ignoring it.
it’s 3:45am and i can’t stop thinking about it. so let me write this up while it’s fresh.
what order book imbalance actually is #
order book imbalance (OBI) is the simplest possible measure of directional pressure in a market’s pending orders:
OBI = (bid_volume - ask_volume) / (bid_volume + ask_volume)
range: -1 (pure sell pressure) to +1 (pure buy pressure). zero means balanced.
why this matters more for crypto than equities:
crypto order books are relatively thin. institutional HFT hasn’t fully colonized crypto order books the way it has equities. large orders create visible imbalances you can actually measure.
perp futures books are mostly algorithmic. the patterns in binance/coinbase perp order books are systematic. that makes them learnable.
the predictive horizon is short but real. OBI predicts 1-60 second price moves. useless for multi-day strategies. potentially very useful as a filter for intraday momentum entries.
i ran a 6-month backtest on binance BTC/USDT perp data at 1-second snapshot resolution. measured: does OBI at time T actually correlate with forward returns at various horizons?
OBI information coefficient (rank correlation with forward returns) across prediction horizons. composite OBI peaks at 1-5s (IC ~0.11-0.13) then decays fast. price momentum does the opposite - weak at 1s, strong at 5min+. they’re measuring different things. they should be additive.
the key insight from this chart: OBI and momentum are complementary signals, not competing ones.
OBI is strong at 1-15 second horizons, weak at 5+ minutes. price momentum is weak at 1-5 seconds, strong at 5+ minutes. they’re capturing different phenomena.
using both together should add alpha. using only price momentum (what i’ve been doing) was leaving the short-horizon prediction entirely unmeasured.
the implementation #
built a real-time OBI processor. streaming via ccxt WebSocket, computing OBI at multiple depth levels, storing to redis for downstream signal generation. runs on the chicago colo so snapshots are 4-8ms fresh instead of 45-80ms stale from san diego.
import asyncio
import time
import json
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Tuple
from collections import deque
import ccxt.pro as ccxtpro
import redis.asyncio as aioredis
import numpy as np
logger = logging.getLogger(__name__)
@dataclass
class OrderBookSnapshot:
symbol: str
exchange: str
timestamp_ms: float
bids: List[Tuple[float, float]] # (price, volume)
asks: List[Tuple[float, float]] # (price, volume)
mid_price: float = 0.0
def __post_init__(self):
if self.bids and self.asks:
self.mid_price = (self.bids[0][0] + self.asks[0][0]) / 2
def compute_obi_multilevel(
bids: List[Tuple[float, float]],
asks: List[Tuple[float, float]],
levels: List[int] = [1, 5, 10, 20]
) -> Dict[str, float]:
"""
compute order book imbalance at multiple depth levels.
formula: OBI_n = (bid_vol_n - ask_vol_n) / (bid_vol_n + ask_vol_n)
multiple levels because level-1 is reactive but noisy.
level-20 is slower but captures structural positioning.
composite across levels gives a more robust signal.
"""
result = {}
for n in levels:
bid_vol = sum(vol for _, vol in bids[:n])
ask_vol = sum(vol for _, vol in asks[:n])
total = bid_vol + ask_vol
result[f'obi_{n}'] = round((bid_vol - ask_vol) / total, 4) if total > 0 else 0.0
return result
@dataclass
class RollingOBISignal:
"""
aggregates raw per-snapshot OBI into a smoothed directional signal.
uses exponential weighting so recent snapshots dominate.
also tracks directional_consistency: what % of the rolling window
agrees with the current direction. low consistency = noisy/indeterminate book.
"""
symbol: str
window_seconds: int = 10
_history: deque = field(default_factory=lambda: deque(maxlen=600))
# weights for composite OBI across depth levels (sum to 1.0)
LEVEL_WEIGHTS = {'obi_1': 0.45, 'obi_5': 0.30, 'obi_10': 0.15, 'obi_20': 0.10}
def update(self, obi_snapshot: Dict[str, float], ts_ms: float) -> Dict[str, float]:
self._history.append({'ts': ts_ms, **obi_snapshot})
return self._compute()
def _compute(self) -> Dict[str, float]:
if len(self._history) < 3:
return {}
now = self._history[-1]['ts']
cutoff = now - (self.window_seconds * 1000)
recent = [h for h in self._history if h['ts'] >= cutoff]
if not recent:
return {}
composites = np.array([
sum(h.get(k, 0) * w for k, w in self.LEVEL_WEIGHTS.items())
for h in recent
])
# exponential weights: most recent snapshot gets highest weight
alpha = 0.15
n = len(composites)
exp_weights = np.array([(1 - alpha) ** i for i in range(n - 1, -1, -1)])
ewm_composite = float(np.average(composites, weights=exp_weights))
current_dir = np.sign(composites[-1])
directional_consistency = float(np.mean(np.sign(composites) == current_dir))
return {
'composite_obi': round(ewm_composite, 4),
'obi_direction': int(current_dir),
'directional_consistency': round(directional_consistency, 3),
'n_snapshots': len(recent)
}
class OBIStreamProcessor:
"""
live orderbook stream via ccxt.pro websocket.
computes multilevel OBI on every book update.
stores current signal + 1000-snapshot history to redis.
redis keys:
obi:{exchange}:{symbol}:latest -> current signal JSON
obi:{exchange}:{symbol}:history -> list of last 1000 snapshots
"""
def __init__(
self,
exchange_id: str,
symbols: List[str],
redis_client: aioredis.Redis,
window_seconds: int = 10
):
self.exchange_id = exchange_id
self.symbols = symbols
self.redis = redis_client
self._signals: Dict[str, RollingOBISignal] = {
s: RollingOBISignal(s, window_seconds) for s in symbols
}
self._exchange = None
async def start(self) -> None:
ExchangeClass = getattr(ccxtpro, self.exchange_id)
self._exchange = ExchangeClass({
'enableRateLimit': True,
'options': {'defaultType': 'future'} # perp futures
})
logger.info(f"OBI stream starting: {self.exchange_id} {self.symbols}")
try:
await asyncio.gather(*[self._stream_symbol(s) for s in self.symbols])
finally:
await self._exchange.close()
async def _stream_symbol(self, symbol: str) -> None:
while True:
try:
ob = await self._exchange.watch_order_book(symbol, limit=20)
snap = OrderBookSnapshot(
symbol=symbol,
exchange=self.exchange_id,
timestamp_ms=time.time() * 1000,
bids=ob['bids'][:20],
asks=ob['asks'][:20]
)
raw_obi = compute_obi_multilevel(snap.bids, snap.asks)
signal = self._signals[symbol].update(raw_obi, snap.timestamp_ms)
if signal:
await self._persist(symbol, snap.mid_price, raw_obi, signal)
except Exception as e:
logger.warning(f"stream error {symbol}: {e}, reconnecting...")
await asyncio.sleep(1.0)
async def _persist(
self,
symbol: str,
mid: float,
raw_obi: Dict[str, float],
signal: Dict[str, float]
) -> None:
key_latest = f"obi:{self.exchange_id}:{symbol}:latest"
key_hist = f"obi:{self.exchange_id}:{symbol}:history"
payload = json.dumps({'ts': time.time() * 1000, 'mid': mid, **raw_obi, **signal})
pipe = self.redis.pipeline()
pipe.set(key_latest, payload)
pipe.lpush(key_hist, payload)
pipe.ltrim(key_hist, 0, 999)
await pipe.execute()
the directional_consistency field is the one i’m going to use as a filter gate. if less than 60% of snapshots in the rolling window agree on direction, the book is too mixed to trade on.
how it plugs into signal generation #
OBI doesn’t replace momentum signals. it filters and sizes them.
async def should_enter(signal: dict, obi: dict) -> tuple[bool, float]:
"""
apply OBI filter to a momentum entry signal.
returns: (should_enter: bool, size_multiplier: float)
"""
momentum_dir = signal['direction'] # +1 long, -1 short
obi_dir = obi.get('obi_direction', 0)
consistency = obi.get('directional_consistency', 0.0)
composite_obi = obi.get('composite_obi', 0.0)
# OBI confirms momentum + high consistency -> full size
if obi_dir == momentum_dir and consistency > 0.65:
return True, 1.0
# neutral book (OBI near zero) -> reduced size, still enter
if abs(composite_obi) < 0.04:
return True, 0.70
# OBI actively opposes momentum + high consistency -> skip
if obi_dir == -momentum_dir and consistency > 0.65:
return False, 0.0
# ambiguous or low consistency -> moderate entry
return True, 0.55
simple rules. the book either confirms, is ambiguous, or contradicts. act accordingly.
in backtesting this filter improved win rate on crypto momentum by 5-7 percentage points and reduced average losing trade magnitude. not backtest-optimized yet, just a first-pass implementation with sensible thresholds.
obi vs price over a live session #
here’s what composite OBI looks like plotted against cumulative price return over a two-hour BTC session i analyzed this week. shows the relationship that makes this worth building.
composite OBI (blue, left axis) vs cumulative BTC/USDT perp return (orange, right axis) over a 2-hour session. in trending regimes (minutes 25-50, 65-80), sustained OBI directional pressure precedes or coincides with price movement. in choppy periods, OBI oscillates without price follow-through — exactly the situations where momentum signals get stopped out.
the choppy periods are the interesting part. OBI oscillates without follow-through. those are exactly the conditions where momentum signals get stopped out. if i’d filtered those entries based on OBI consistency, a chunk of my february losses wouldn’t have happened.
why the colo matters for this #
order book data from san diego arrives 45-80ms after it happens.
an OBI snapshot that’s 80ms old is essentially useless. the book may have completely flipped in that time. you’d be trading on a ghost.
the OBIStreamProcessor runs on chicago colo. orderbook snapshots are 4-8ms fresh. that’s actual current state.
this is the third piece of infrastructure that only makes sense from colo: signal routing (latency), signal execution (fills), and now signal input (orderbook freshness). the home server in san diego is great for development and slow strategies. anything with a signal half-life under 500ms needs to run in chicago.
been comparing notes with a few other algo traders over at NexusFi who are building OBI-based filters for ES and NQ futures. different microstructure (deeper book, more institutional flow) but same underlying logic. their experience: top 5 levels most predictive for fast moves, levels 10-20 capture more structural positioning. consistent with what i’m seeing in crypto.
where things stand #
OBI filter is live on paper trading as of tonight. running alongside the existing crypto momentum strategies.
three weeks paper trading. then 25% size live. then full size if it validates.
march is still underperforming after february’s loss. the latency routing fix from earlier tonight should help. the OBI filter is the next thing.
goal: q1 breakeven. q2 recovery.
not stressed about it. this is how strategy development works. you find the gaps, you fix them, you validate.
it’s almost 4am. A. is asleep. came to check on me around 2am, saw i was still at my desk, didn’t say anything, just put a glass of water next to my keyboard and went back to bed. she knows what building looks like.
thought about my dad for a second tonight. he was a VP of engineering - the kind of person who would spend hours debugging something just to understand why it was wrong, not just to fix it. all this latency analysis and orderbook microstructure work is exactly the kind of rabbit hole he would have gone completely down. weird how that shows up at 4am.
ok. actually sleeping now.
-AK