""" Order Book Imbalance NautilusTrader strategy. Trades on L2 bid/ask volume skew. When bids dominate, price tends to rise as heavy bid side absorbs sell market orders. When asks dominate, price tends to fall. Dual-mode operation: - Backtest: volume-based OBI proxy from candle OHLCV (buy_vol if close > open else sell_vol, rolling window) - Live/Paper: real L2 orderbook from HyperliquidDataProvider.fetch_orderbook() (bid_vol / total_vol at top N levels) Strategy logic: 1. Compute imbalance over lookback window 2. Signal when |imbalance| > entry threshold 3. Exit on reversion, stop-loss, or take-profit 4. Cooldown bars between signals to avoid overtrading """ from __future__ import annotations import logging from collections import deque from typing import Any import numpy as np from nautilus_trader.model.data import Bar from nautilus_trader.model.enums import OrderSide from framework.base_strategy import BaseHlStrategy from framework.config import StrategyConfig logger = logging.getLogger(__name__) class OBINT(BaseHlStrategy): """Order Book Imbalance — volume skew mean-reversion / momentum.""" def __init__(self, config: StrategyConfig): super().__init__(config) # OBI params self._obi_lookback = config.params.get("obi_lookback", 20) self._obi_entry = config.params.get("obi_entry", 0.35) # |imbalance| > this → enter self._obi_exit = config.params.get("obi_exit", 0.10) # |imbalance| < this → exit self._obi_depth = config.params.get("obi_depth", 10) # L2 depth levels (live mode) self._cooldown_bars = config.params.get("cooldown_bars", 3) self._stop_loss_pct = config.params.get("stop_loss_pct", 0.02) self._take_profit_pct = config.params.get("take_profit_pct", 0.005) # Volume tracking for candle-based OBI proxy self._buy_volumes: deque[float] = deque(maxlen=self._obi_lookback) self._sell_volumes: deque[float] = deque(maxlen=self._obi_lookback) # State self._bars_since_trade = self._cooldown_bars # start ready self._in_trade = False self._trade_direction: str | None = None self._entry_price: float = 0.0 # ── Bar handler (backtest mode — candle proxy) ───────────── def on_bar(self, bar: Bar): price = float(bar.close) self._prices.append(price) # Classify volume: buy if close > open, sell if close < open close_px = float(bar.close) open_px = float(bar.open) volume = float(bar.volume) if hasattr(bar, 'volume') else 1.0 if close_px > open_px: self._buy_volumes.append(volume) self._sell_volumes.append(0.0) elif close_px < open_px: self._buy_volumes.append(0.0) self._sell_volumes.append(volume) else: # Flat bar — split evenly self._buy_volumes.append(volume * 0.5) self._sell_volumes.append(volume * 0.5) self._bars_since_trade += 1 # Check exits first if in trade if self._in_trade: if self._check_exit(price): return return # Cooldown check if self._bars_since_trade < self._cooldown_bars: return # Compute signal signal = self._compute_obi_signal() if signal: self._last_signal = signal self.handle_signal(signal) # ── Live/paper mode — real L2 orderbook ──────────────────── def compute_signal(self, price: float | None = None, orderbook: dict | None = None) -> dict | None: """Entry point for paper trader / deploy orchestrator. If orderbook is provided, use real L2 OBI. Otherwise fall back to candle proxy with price feed. """ if orderbook is not None: return self._compute_l2_signal(orderbook) if price is None: return None self._prices.append(price) if self._in_trade: if self._check_exit(price): return self._last_signal if self._bars_since_trade < self._cooldown_bars: self._bars_since_trade += 1 return None return self._compute_obi_signal() def _compute_l2_signal(self, orderbook: dict) -> dict | None: """Compute OBI from real L2 orderbook snapshot.""" bids = orderbook.get("bids", []) asks = orderbook.get("asks", []) if not bids or not asks: return None depth = min(self._obi_depth, len(bids), len(asks)) bid_vol = sum(bids[i][1] for i in range(depth)) ask_vol = sum(asks[i][1] for i in range(depth)) total = bid_vol + ask_vol if total <= 0: return None obi = bid_vol / total # 0-1: > 0.5 = bids heavier, < 0.5 = asks heavier # Convert to signed imbalance (-1 to +1) imbalance = (obi - 0.5) * 2 # Entry if not self._in_trade: if imbalance > self._obi_entry: self._in_trade = True self._trade_direction = "long" self._entry_price = bids[0][0] if bids else 0 self._bars_since_trade = 0 return {"signal": "BUY", "strength": imbalance / self._obi_entry, "obi": round(obi, 3), "reason": "l2_bid_heavy"} if imbalance < -self._obi_entry: self._in_trade = True self._trade_direction = "short" self._entry_price = asks[0][0] if asks else 0 self._bars_since_trade = 0 return {"signal": "SELL", "strength": abs(imbalance) / self._obi_entry, "obi": round(obi, 3), "reason": "l2_ask_heavy"} # Exit — imbalance reverted elif abs(imbalance) < self._obi_exit: exit_signal = "SELL" if self._trade_direction == "long" else "BUY" self._in_trade = False self._trade_direction = None return {"signal": exit_signal, "strength": 0.0, "reason": "l2_imbalance_exit"} return None # ── Candle-based OBI (backtest proxy) ────────────────────── def _compute_obi_signal(self) -> dict | None: if len(self._buy_volumes) < self._obi_lookback: return None buy_vol_list = list(self._buy_volumes) sell_vol_list = list(self._sell_volumes) total_buy = sum(buy_vol_list) total_sell = sum(sell_vol_list) total_vol = total_buy + total_sell if total_vol <= 0: return None # Signed imbalance: +1 = all buy, -1 = all sell imbalance = (total_buy - total_sell) / total_vol if imbalance > self._obi_entry: self._in_trade = True self._trade_direction = "long" self._entry_price = self._prices[-1] if self._prices else 0 self._bars_since_trade = 0 return { "signal": "BUY", "strength": imbalance / self._obi_entry, "imbalance": round(imbalance, 3), "reason": "candle_bid_heavy", } if imbalance < -self._obi_entry: self._in_trade = True self._trade_direction = "short" self._entry_price = self._prices[-1] if self._prices else 0 self._bars_since_trade = 0 return { "signal": "SELL", "strength": abs(imbalance) / self._obi_entry, "imbalance": round(imbalance, 3), "reason": "candle_ask_heavy", } return None # ── Exit logic ───────────────────────────────────────────── def _check_exit(self, current_price: float) -> bool: """Check exit conditions. Returns True if an exit signal was generated.""" if not self._in_trade or self._entry_price <= 0: return False change_pct = (current_price - self._entry_price) / self._entry_price if self._trade_direction == "long": pnl_pct = change_pct else: pnl_pct = -change_pct exit_reason = None # Stop loss if pnl_pct <= -self._stop_loss_pct: exit_reason = "stop_loss" # Take profit elif pnl_pct >= self._take_profit_pct: exit_reason = "take_profit" # Imbalance reversion (check candle proxy) elif len(self._buy_volumes) >= self._obi_lookback: total_buy = sum(self._buy_volumes) total_sell = sum(self._sell_volumes) total_vol = total_buy + total_sell if total_vol > 0: imbalance = (total_buy - total_sell) / total_vol if (self._trade_direction == "long" and imbalance < -self._obi_exit) or \ (self._trade_direction == "short" and imbalance > self._obi_exit): exit_reason = "imbalance_flip" if exit_reason is None: return False exit_side = "SELL" if self._trade_direction == "long" else "BUY" self._last_signal = { "signal": exit_side, "strength": abs(pnl_pct) / self._stop_loss_pct, "pnl_pct": round(pnl_pct * 100, 2), "reason": exit_reason, } self._in_trade = False self._trade_direction = None self.handle_signal(self._last_signal) return True # ── Order ────────────────────────────────────────────────── def handle_signal(self, signal: dict): side_str = signal.get("signal", "") if "BUY" in side_str: self._submit_order(OrderSide.BUY) elif "SELL" in side_str: self._submit_order(OrderSide.SELL)