diff --git a/backtests/nt_runner.py b/backtests/nt_runner.py index ac60953..f9f30a5 100644 --- a/backtests/nt_runner.py +++ b/backtests/nt_runner.py @@ -180,6 +180,7 @@ class NTBacktestRunner: "pairs": "strategies.nt.pairs_trading_nt.PairsTradingNT", "hurst_vpin": "strategies.nt.hurst_vpin_nt.HurstVPINNT", "as_mm": "strategies.nt.as_mm_nt.ASMarketMakingNT", + "obi": "strategies.nt.obi_nt.OBINT", } path = registry.get(strategy) if not path: diff --git a/backtests/vbt_runner.py b/backtests/vbt_runner.py index 710c4c8..1fa523a 100644 --- a/backtests/vbt_runner.py +++ b/backtests/vbt_runner.py @@ -83,12 +83,43 @@ def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd. entries = (close > upper) | (close < lower) exits = (close.shift(1) > sma.shift(1)) & (close < sma) - elif strategy in ("mean_rev", "obi"): + elif strategy in ("mean_rev",): sma = close.rolling(20).mean() std = close.rolling(20).std() entries = (close < sma - 1.0 * std) | (close > sma + 1.0 * std) exits = abs((close - sma) / std) < 0.3 + elif strategy == "obi": + # Volume-based order book imbalance proxy + # Buy volume = volume where close > open, sell vol = volume where close < open + buy_vol = df["volume"].where(df["close"] > df["open"], 0.0) + sell_vol = df["volume"].where(df["close"] < df["open"], 0.0) + # Flat bars: split volume evenly + flat_mask = df["close"] == df["open"] + buy_vol_adj = buy_vol + df["volume"].where(flat_mask, 0.0) * 0.5 + sell_vol_adj = sell_vol + df["volume"].where(flat_mask, 0.0) * 0.5 + + lookback = 20 + entry_threshold = 0.35 + exit_threshold = 0.10 + + buy_rolling = buy_vol_adj.rolling(lookback).sum() + sell_rolling = sell_vol_adj.rolling(lookback).sum() + total_rolling = buy_rolling + sell_rolling + + imbalance = (buy_rolling - sell_rolling) / total_rolling.replace(0, 1) + imbalance = imbalance.fillna(0) + + entries = (imbalance > entry_threshold) | (imbalance < -entry_threshold) + # Exit when imbalance crosses back toward zero + exits = ((imbalance.shift(1) > exit_threshold) & (imbalance < exit_threshold)) | \ + ((imbalance.shift(1) < -exit_threshold) & (imbalance > -exit_threshold)) + exits = exits.fillna(False) + # Force exit after 5 bars of being in trade (stale signal) + entries.fillna(False, inplace=True) + exits.fillna(False, inplace=True) + return entries, exits + elif strategy == "funding_arb": entries[:] = False exits[:] = False diff --git a/dashboard/server.py b/dashboard/server.py index 1f0d4da..9c6b399 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -573,8 +573,9 @@ async def list_vbt_strategies(): {"key": "pairs", "name": "Pairs Trading", "coins": ["BTC", "ETH"]}, {"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]}, {"key": "as_mm", "name": "Avellaneda-Stoikov MM", "coins": ["BTC"]}, - {"key": "momentum", "name": "Momentum Breakout", "coins": ["BTC"]}, - {"key": "mean_rev", "name": "Mean Reversion", "coins": ["BTC"]}, + {"key": "obi", "name": "Order Book Imbalance", "coins": ["BTC"]}, + {"key": "momentum", "name": "Momentum Breakout", "coins": ["ETH"]}, + {"key": "mean_rev", "name": "Mean Reversion", "coins": ["ETH"]}, ]) diff --git a/framework/deploy.py b/framework/deploy.py index 51880f5..e5c6fb6 100644 --- a/framework/deploy.py +++ b/framework/deploy.py @@ -44,8 +44,8 @@ STRATEGY_REGISTRY = { }, "obi": { "name": "Order Book Imbalance", - "description": "L2 bid/ask volume skew reversal", - "class": None, # Not yet ported + "description": "Volume-weighted bid/ask skew — enters when L2 imbalance heavy", + "class": "strategies.nt.obi_nt.OBINT", }, "funding_arb": { "name": "Funding Rate Arb", diff --git a/strategies/nt/__init__.py b/strategies/nt/__init__.py index fc471ec..71cbacc 100644 --- a/strategies/nt/__init__.py +++ b/strategies/nt/__init__.py @@ -6,5 +6,6 @@ Ported from existing strategies for unified backtest → paper → live pipeline from strategies.nt.pairs_trading_nt import PairsTradingNT from strategies.nt.hurst_vpin_nt import HurstVPINNT from strategies.nt.as_mm_nt import ASMarketMakingNT +from strategies.nt.obi_nt import OBINT -__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT"] +__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT", "OBINT"] diff --git a/strategies/nt/obi_nt.py b/strategies/nt/obi_nt.py new file mode 100644 index 0000000..cfb4767 --- /dev/null +++ b/strategies/nt/obi_nt.py @@ -0,0 +1,275 @@ +""" +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)