""" Iceberg Detection NautilusTrader strategy. Detects whale TWAP/iceberg accumulation by tracking volume spikes and consecutive same-direction large orders. Dual-mode: - Backtest: volume spike proxy from candles (volume > avg * multiplier for >= min_consecutive bars in same direction) - Live/Paper: real L2 orderbook wall detection (single level > avg * 3 persisting for >= 3 updates) Entry: consecutive same-direction spikes/walls → follow smart money Exit: spike count drops below 2 OR trend reverses OR 2% stop-loss """ 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 IcebergNT(BaseHlStrategy): """Iceberg/whale accumulation detection — follows smart money flow.""" def __init__(self, config: StrategyConfig): super().__init__(config) self._vol_lookback = config.params.get("vol_lookback", 40) self._vol_spike_mult = config.params.get("vol_spike_mult", 2.5) self._min_consecutive = config.params.get("min_consecutive", 3) self._max_bars_held = config.params.get("max_bars_held", 8) self._stop_loss_pct = config.params.get("stop_loss_pct", 0.02) self._cooldown_bars = config.params.get("cooldown_bars", 4) # Volume tracking self._volumes: deque[float] = deque(maxlen=self._vol_lookback) self._spike_count: int = 0 self._prev_spike_dir: str | None = None # State self._bars_since_trade = self._cooldown_bars self._bars_held: int = 0 self._in_trade = False self._trade_direction: str | None = None self._entry_price: float = 0.0 # ── Candle mode (backtest) ────────────────────────────────── def on_bar(self, bar: Bar): price = float(bar.close) volume = float(bar.volume) if hasattr(bar, 'volume') else 1.0 self._prices.append(price) self._volumes.append(volume) self._bars_since_trade += 1 # Exit check if self._in_trade: self._bars_held += 1 if self._check_exit(price): return return if self._bars_since_trade < self._cooldown_bars: return signal = self._detect_iceberg(price, volume) if signal: self._last_signal = signal self.handle_signal(signal) def _detect_iceberg(self, price: float, volume: float) -> dict | None: if len(self._volumes) < self._vol_lookback: return None avg_vol = np.mean(self._volumes) if avg_vol <= 0: return None is_spike = volume > avg_vol * self._vol_spike_mult if not is_spike: self._spike_count = 0 self._prev_spike_dir = None return None # Determine direction: buy if close > previous close (price going up) if len(self._prices) < 2: return None is_buy = self._prices[-1] > self._prices[-2] spike_dir = "buy" if is_buy else "sell" # Track consecutive same-direction spikes if spike_dir == self._prev_spike_dir: self._spike_count += 1 else: self._spike_count = 1 self._prev_spike_dir = spike_dir if self._spike_count >= self._min_consecutive: self._in_trade = True self._trade_direction = "long" if spike_dir == "buy" else "short" self._entry_price = price self._bars_since_trade = 0 self._bars_held = 0 self._spike_count = 0 return { "signal": "BUY" if spike_dir == "buy" else "SELL", "strength": min(1.0, self._spike_count / self._min_consecutive), "vol_ratio": round(volume / avg_vol, 1), "spikes": self._spike_count, "reason": f"iceberg_{spike_dir}", } return None # ── L2 mode (live/paper) ──────────────────────────────────── def compute_signal(self, price: float | None = None, orderbook: dict | None = None) -> dict | None: """Entry point for paper trader / deploy orchestrator. If orderbook provided, use L2 wall detection. Otherwise fall back to candle proxy. """ if orderbook is not None and price is not None: return self._detect_l2_walls(orderbook, price) if price is None: return None return self._detect_iceberg(price, 1.0) def _detect_l2_walls(self, orderbook: dict, price: float) -> dict | None: """Detect walls in real L2 orderbook.""" bids = orderbook.get("bids", []) asks = orderbook.get("asks", []) # Find largest single level size all_sizes = [b[1] for b in bids] + [a[1] for a in asks] if not all_sizes: return None avg_size = np.mean(all_sizes) # Check for bid wall (single level > avg * 3) bid_wall = False ask_wall = False for px, sz in bids: if sz > avg_size * 3: bid_wall = True break for px, sz in asks: if sz > avg_size * 3: ask_wall = True break if bid_wall and not ask_wall: return {"signal": "BUY", "strength": 0.8, "reason": "l2_bid_wall"} elif ask_wall and not bid_wall: return {"signal": "SELL", "strength": 0.8, "reason": "l2_ask_wall"} return None # ── Exit logic ────────────────────────────────────────────── def _check_exit(self, current_price: float) -> bool: if not self._in_trade or self._entry_price <= 0: return False change_pct = (current_price - self._entry_price) / self._entry_price pnl_pct = change_pct if self._trade_direction == "long" else -change_pct exit_reason = None if pnl_pct <= -self._stop_loss_pct: exit_reason = "stop_loss" elif self._bars_held >= self._max_bars_held: exit_reason = "time_exit" elif self._spike_count < 2: exit_reason = "spikes_faded" 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), "bars_held": self._bars_held, "reason": exit_reason, } self._in_trade = False self._trade_direction = None self.handle_signal(self._last_signal) return True 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)