feat: proper Order Book Imbalance strategy for BTC-USD on HL

strategies/nt/obi_nt.py:
- Dual-mode OBI: candle proxy (backtest) + real L2 orderbook (live)
- Volume-based imbalance: buy_vol / (buy_vol + sell_vol) over rolling window
- Entry when |imbalance| > 0.35, exit on reversion < 0.10
- Stop-loss 2%, take-profit 0.5%, cooldown 3 bars
- compute_signal(price, orderbook=None) for paper trader integration

backtests/vbt_runner.py:
- Replaced placeholder z-score with proper volume-based OBI
- Buy vol = volume where close > open, sell vol = volume where close < open
- Rolling window imbalance computation
- Parameter sweep support with 12 combos tested

Registered across: deploy.py, nt_runner.py, dashboard, strategies/nt/__init__

Verified:
- VectorBT OBI backtest: 15 trades, -7.2% on default (window=20)
- Param sweep best: w=30 t=0.35 → sharpe -0.82, 49% win, 23% DD
- Real L2 orderbook signal: BUY obi=0.880 (bids 88% of depth)
- NT backtest engine: 201 bars, 8 days, 236ms
This commit is contained in:
ramseshk
2026-08-07 10:50:43 +08:00
parent 879372f69e
commit 37da46a016
6 changed files with 315 additions and 6 deletions
+275
View File
@@ -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)