Hawkes OFI + Deep LOB: two new strategies from advanced microstructure research
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Deep Limit Order Book Strategy.
|
||||
|
||||
Analyzes the full orderbook beyond top-of-book to detect:
|
||||
1. **Wall detection** — large resting orders that indicate support/resistance
|
||||
2. **Depth imbalance** — ratio of cumulative depth on bid vs ask side
|
||||
3. **Orderbook skew** — asymmetry in volume distribution across price levels
|
||||
4. **Thin-side prediction** — when one side of the book is thin, price likely moves that way
|
||||
|
||||
When a large wall sits at a certain price level, the market is unlikely to
|
||||
break through it quickly. When the ask side is thin relative to the bid side,
|
||||
buying pressure is likely to push price up.
|
||||
|
||||
Usage:
|
||||
from strategies.deep_lob import DeepLOB
|
||||
model = DeepLOB()
|
||||
signal = model.analyze(orderbook_bids, orderbook_asks, mark_price)
|
||||
"""
|
||||
import math
|
||||
from collections import deque
|
||||
|
||||
class DeepLOB:
|
||||
"""Analyze full orderbook depth for trade signals."""
|
||||
|
||||
def __init__(self, depth_levels: int = 10):
|
||||
self.depth_levels = depth_levels
|
||||
self.signal_history: deque = deque(maxlen=50)
|
||||
|
||||
def analyze(self, bids: list, asks: list, mark_price: float) -> dict:
|
||||
"""Analyze orderbook and return a signal.
|
||||
|
||||
Args:
|
||||
bids: list of [price, size] pairs sorted best→worst (descending)
|
||||
asks: list of [price, size] pairs sorted best→worst (ascending)
|
||||
mark_price: current mark price
|
||||
|
||||
Returns:
|
||||
dict with signal, strength, and metrics
|
||||
"""
|
||||
if not bids or not asks or mark_price <= 0:
|
||||
return {"signal": None, "strength": 0.0}
|
||||
|
||||
# 1. Cumulative depth on each side
|
||||
bid_depth = sum(sz for _, sz in bids[:self.depth_levels])
|
||||
ask_depth = sum(sz for _, sz in asks[:self.depth_levels])
|
||||
|
||||
# 2. Wall detection — find largest single order on each side
|
||||
bid_walls = sorted([(px, sz) for px, sz in bids[:self.depth_levels]], key=lambda x: x[1], reverse=True)
|
||||
ask_walls = sorted([(px, sz) for px, sz in asks[:self.depth_levels]], key=lambda x: x[1], reverse=True)
|
||||
|
||||
largest_bid_wall = bid_walls[0] if bid_walls else (0, 0)
|
||||
largest_ask_wall = ask_walls[0] if ask_walls else (0, 0)
|
||||
|
||||
# 3. Wall-to-depth ratio — how concentrated is the orderbook?
|
||||
bid_concentration = largest_bid_wall[1] / bid_depth if bid_depth > 0 else 0
|
||||
ask_concentration = largest_ask_wall[1] / ask_depth if ask_depth > 0 else 0
|
||||
|
||||
# 4. Depth-weighted mid price (more accurate than top-of-book mid)
|
||||
# Weight prices by their size to get "fair value"
|
||||
bid_weighted = sum(px * sz for px, sz in bids[:self.depth_levels]) / bid_depth if bid_depth > 0 else 0
|
||||
ask_weighted = sum(px * sz for px, sz in asks[:self.depth_levels]) / ask_depth if ask_depth > 0 else 0
|
||||
fair_price = (bid_weighted + ask_weighted) / 2 if bid_weighted > 0 and ask_weighted > 0 else mark_price
|
||||
|
||||
# 5. Depth imbalance ratio
|
||||
total_depth = bid_depth + ask_depth
|
||||
depth_imbalance = (bid_depth - ask_depth) / total_depth if total_depth > 0 else 0
|
||||
|
||||
# 6. Thin-side detection
|
||||
# If one side is much thinner, price likely moves that way
|
||||
depth_ratio = bid_depth / ask_depth if ask_depth > 0 else 999
|
||||
thin_side = None
|
||||
if depth_ratio > 3.0:
|
||||
thin_side = "ask" # Ask side thin → buyers will push through → bullish
|
||||
elif depth_ratio < 0.33:
|
||||
thin_side = "bid" # Bid side thin → sellers will push through → bearish
|
||||
|
||||
# 7. Mark price vs fair value
|
||||
fv_deviation = (mark_price - fair_price) / fair_price if fair_price > 0 else 0
|
||||
|
||||
# 8. Volume-weighted average spread
|
||||
vwap_spread = 0
|
||||
for i in range(min(len(bids), len(asks), 5)):
|
||||
spread_i = asks[i][0] - bids[i][0]
|
||||
vwap_spread += spread_i
|
||||
avg_spread = vwap_spread / min(len(bids), len(asks), 5) if bids and asks else 0
|
||||
|
||||
# ═══════════════ Signal Generation ═══════════════
|
||||
|
||||
signal = None
|
||||
strength = 0.0
|
||||
reasons = []
|
||||
|
||||
# Wall imbalance signal
|
||||
if largest_bid_wall[1] > 2 * largest_ask_wall[1]:
|
||||
strength += 0.3
|
||||
reasons.append("large_bid_wall")
|
||||
elif largest_ask_wall[1] > 2 * largest_bid_wall[1]:
|
||||
strength -= 0.3
|
||||
reasons.append("large_ask_wall")
|
||||
|
||||
# Depth imbalance signal
|
||||
if depth_imbalance > 0.4:
|
||||
strength += 0.25
|
||||
reasons.append("bid_depth_dominant")
|
||||
elif depth_imbalance < -0.4:
|
||||
strength -= 0.25
|
||||
reasons.append("ask_depth_dominant")
|
||||
|
||||
# Thin-side prediction
|
||||
if thin_side == "ask":
|
||||
strength += 0.35
|
||||
reasons.append("thin_ask_pressure")
|
||||
elif thin_side == "bid":
|
||||
strength -= 0.35
|
||||
reasons.append("thin_bid_pressure")
|
||||
|
||||
# Fair value deviation
|
||||
if fv_deviation > 0.002:
|
||||
strength -= 0.15 # Overpriced relative to weighted depth
|
||||
reasons.append("overvalued")
|
||||
elif fv_deviation < -0.002:
|
||||
strength += 0.15 # Underpriced
|
||||
reasons.append("undervalued")
|
||||
|
||||
# Determine final signal
|
||||
threshold = 0.3
|
||||
if strength > threshold:
|
||||
signal = "BUY"
|
||||
elif strength < -threshold:
|
||||
signal = "SELL"
|
||||
|
||||
self.signal_history.append({
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"depth_imbalance": round(depth_imbalance, 4),
|
||||
"thin_side": thin_side,
|
||||
})
|
||||
|
||||
return {
|
||||
"signal": signal,
|
||||
"strength": round(abs(strength) if signal else strength, 4),
|
||||
"fair_price": round(fair_price, 1),
|
||||
"depth_imbalance": round(depth_imbalance, 4),
|
||||
"depth_ratio": round(depth_ratio, 2),
|
||||
"thin_side": thin_side,
|
||||
"bid_concentration": round(bid_concentration, 3),
|
||||
"ask_concentration": round(ask_concentration, 3),
|
||||
"avg_spread": round(avg_spread, 1),
|
||||
"largest_bid_wall": round(largest_bid_wall[1], 2),
|
||||
"largest_ask_wall": round(largest_ask_wall[1], 2),
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Hawkes Process Order Flow Imbalance Strategy.
|
||||
|
||||
Models trade arrivals as self-exciting point processes.
|
||||
Unlike Poisson (independent arrivals), Hawkes processes recognize
|
||||
that trades cluster — a large buy triggers further buying.
|
||||
|
||||
λ(t) = μ + Σ α·e^(-β(t-t_i)) for t_i < t
|
||||
|
||||
where μ = baseline intensity, α = self-excitation, β = decay rate.
|
||||
|
||||
The OFI signal is the difference between buy-side and sell-side
|
||||
Hawkes intensity. When OFI crosses a threshold, it predicts
|
||||
short-term price direction.
|
||||
|
||||
Usage:
|
||||
from strategies.hawkes_ofi import HawkesOFI
|
||||
model = HawkesOFI(alpha=0.3, beta=0.5)
|
||||
signal = model.update(trade_side, trade_size, trade_price)
|
||||
"""
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
class HawkesOFI:
|
||||
"""Single-asset Hawkes process for OFI signal generation."""
|
||||
|
||||
def __init__(self, alpha: float = 0.3, beta: float = 0.5, mu: float = 0.05):
|
||||
self.alpha = alpha # Self-excitation strength
|
||||
self.beta = beta # Decay rate
|
||||
self.mu = mu # Baseline intensity
|
||||
|
||||
# Track recent trade events: (timestamp, side_multiplier)
|
||||
# side_multiplier = +1 for buys, -1 for sells
|
||||
self.events: deque = deque(maxlen=200)
|
||||
self._last_update = time.time()
|
||||
|
||||
# Rolling statistics
|
||||
self.buy_intensity = mu
|
||||
self.sell_intensity = mu
|
||||
self.ofi_history: deque = deque(maxlen=50)
|
||||
self.price_history: deque = deque(maxlen=100)
|
||||
|
||||
def compute_intensity(self, t: float, side_filter: int) -> float:
|
||||
"""Compute Hawkes intensity at time t for a given side filter.
|
||||
|
||||
side_filter = +1 → only count buys
|
||||
side_filter = -1 → only count sells
|
||||
side_filter = 0 → count both
|
||||
"""
|
||||
intensity = self.mu
|
||||
for ev_t, ev_side in self.events:
|
||||
dt = t - ev_t
|
||||
if dt > 10.0: # Ignore events older than 10 seconds
|
||||
continue
|
||||
if side_filter == 0 or ev_side == side_filter:
|
||||
intensity += self.alpha * math.exp(-self.beta * dt)
|
||||
return intensity
|
||||
|
||||
def update(self, side: str, size: float, price: float) -> float:
|
||||
"""Process a new trade and return the OFI signal.
|
||||
|
||||
Args:
|
||||
side: 'B' for buy, 'S' for sell
|
||||
size: trade size
|
||||
price: trade price
|
||||
|
||||
Returns:
|
||||
ofi_signal: positive → bullish, negative → bearish, near 0 → neutral
|
||||
"""
|
||||
t = time.time()
|
||||
side_mult = +1 if side in ('B', 'BUY', 'b') else -1
|
||||
|
||||
# Add event
|
||||
self.events.append((t, side_mult))
|
||||
self.price_history.append(price)
|
||||
|
||||
# Compute intensities
|
||||
buy_intensity = self.compute_intensity(t, +1)
|
||||
sell_intensity = self.compute_intensity(t, -1)
|
||||
|
||||
self.buy_intensity = buy_intensity
|
||||
self.sell_intensity = sell_intensity
|
||||
|
||||
# OFI = normalized difference in intensities
|
||||
total = buy_intensity + sell_intensity
|
||||
if total > 0:
|
||||
ofi = (buy_intensity - sell_intensity) / total
|
||||
else:
|
||||
ofi = 0.0
|
||||
|
||||
self.ofi_history.append(ofi)
|
||||
self._last_update = t
|
||||
|
||||
return ofi
|
||||
|
||||
def get_signal(self) -> dict:
|
||||
"""Get current trading signal based on OFI."""
|
||||
if len(self.ofi_history) < 10:
|
||||
return {"signal": None, "strength": 0.0, "confidence": 0.0}
|
||||
|
||||
# Current OFI
|
||||
current_ofi = self.ofi_history[-1]
|
||||
|
||||
# Trend in OFI (momentum of the imbalance)
|
||||
if len(self.ofi_history) >= 5:
|
||||
recent = list(self.ofi_history)[-5:]
|
||||
ofi_trend = sum(recent) / len(recent)
|
||||
else:
|
||||
ofi_trend = current_ofi
|
||||
|
||||
# Z-score of current OFI
|
||||
ofi_list = list(self.ofi_history)
|
||||
mean_ofi = sum(ofi_list) / len(ofi_list)
|
||||
var_ofi = sum((o - mean_ofi)**2 for o in ofi_list) / len(ofi_list)
|
||||
std_ofi = math.sqrt(var_ofi) if var_ofi > 0 else 0.01
|
||||
|
||||
z_score = (current_ofi - mean_ofi) / std_ofi if std_ofi > 0 else 0.0
|
||||
|
||||
# Signal logic
|
||||
threshold = 0.15
|
||||
z_threshold = 1.5
|
||||
|
||||
if current_ofi > threshold and z_score > z_threshold:
|
||||
signal = "BUY"
|
||||
strength = min(abs(z_score) / 3, 1.0)
|
||||
elif current_ofi < -threshold and z_score < -z_threshold:
|
||||
signal = "SELL"
|
||||
strength = min(abs(z_score) / 3, 1.0)
|
||||
elif abs(ofi_trend) > threshold * 0.8:
|
||||
signal = "BUY" if ofi_trend > 0 else "SELL"
|
||||
strength = abs(ofi_trend) / threshold
|
||||
else:
|
||||
signal = None
|
||||
strength = 0.0
|
||||
|
||||
# Confidence based on how extreme the deviation is
|
||||
confidence = min(abs(z_score) / 3, 1.0)
|
||||
|
||||
return {
|
||||
"signal": signal,
|
||||
"strength": round(strength, 4),
|
||||
"confidence": round(confidence, 4),
|
||||
"ofi": round(current_ofi, 4),
|
||||
"buy_intensity": round(self.buy_intensity, 4),
|
||||
"sell_intensity": round(self.sell_intensity, 4),
|
||||
}
|
||||
Reference in New Issue
Block a user