diff --git a/backtests/nt_runner.py b/backtests/nt_runner.py index f9f30a5..dd27064 100644 --- a/backtests/nt_runner.py +++ b/backtests/nt_runner.py @@ -181,6 +181,9 @@ class NTBacktestRunner: "hurst_vpin": "strategies.nt.hurst_vpin_nt.HurstVPINNT", "as_mm": "strategies.nt.as_mm_nt.ASMarketMakingNT", "obi": "strategies.nt.obi_nt.OBINT", + "grid_mm": "strategies.nt.grid_mm_nt.GridMMNT", + "composite_mm": "strategies.nt.composite_mm_nt.CompositeMMNT", + "iceberg": "strategies.nt.iceberg_nt.IcebergNT", } path = registry.get(strategy) if not path: diff --git a/backtests/vbt_runner.py b/backtests/vbt_runner.py index 1fa523a..a9dee58 100644 --- a/backtests/vbt_runner.py +++ b/backtests/vbt_runner.py @@ -42,7 +42,8 @@ def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd. Each strategy uses the primary coin's close prices. """ main_coin = {"pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC", - "obi": "BTC", "funding_arb": "BTC", "momentum": "BTC", + "obi": "BTC", "grid_mm": "BTC", "composite_mm": "BTC", + "iceberg": "BTC", "funding_arb": "BTC", "momentum": "BTC", "mean_rev": "BTC"}.get(strategy, "BTC") df = data.get(main_coin) if df is None or df.empty: @@ -63,17 +64,133 @@ def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd. exits = z.shift(1) >= -0.5 elif strategy == "hurst_vpin": + # Hurst exponent on returns returns = close.pct_change().dropna() hurst = returns.rolling(64).apply(_hurst_rs_series, raw=False) - entries = hurst > 0.55 - exits = hurst.shift(1) < 0.45 + + # VPIN proxy from candle volumes: buy_vol if close > open, sell_vol if 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_mask = df["close"] == df["open"] + buy_vol = buy_vol + df["volume"].where(flat_mask, 0.0) * 0.5 + sell_vol = sell_vol + df["volume"].where(flat_mask, 0.0) * 0.5 + + vpin_window = 50 + buy_rolling = buy_vol.rolling(vpin_window).sum() + sell_rolling = sell_vol.rolling(vpin_window).sum() + total_rolling = buy_rolling + sell_rolling + vpin = abs(buy_rolling - sell_rolling) / total_rolling.replace(0, 1) + direction = (buy_rolling - sell_rolling) / total_rolling.replace(0, 1) + + # Entry: trending + high VPIN + directional + entries = (hurst > 0.55) & (vpin > 0.25) & (direction.abs() > 0.05) + # Exit: Hurst fades or direction flips + exits = (hurst.shift(1) < 0.45) | ((direction.shift(1) > 0.3) & (direction < -0.1)) | ((direction.shift(1) < -0.3) & (direction > 0.1)) elif strategy == "as_mm": - spread = (df["high"] - df["low"]) / df["close"] - vol = close.pct_change().rolling(20).std() - favorable = (spread > spread.rolling(100).mean()) & (vol < 0.02) - entries = favorable - exits = favorable.shift(3) + # A-S simulation: virtual orderbook from candles with inventory tracking + mid = close + sigma = close.pct_change().rolling(20).std() * np.sqrt(365 * 24) + gamma = 0.1 + tau_sess = 1.0 / 24 # 1 hour as fraction of session + + inventory = 0.0 + entries = pd.Series(False, index=close.index) + exits = pd.Series(False, index=close.index) + in_trade = False + bars_held = 0 + entry_px = 0.0 + min_hold = 3 # Hold at least 4 bars + entry_zones = 0 # Count of bars where reservation was favorable + + for i in range(20, len(close)): + s = sigma.iloc[i] + sigma_sq = s * s if s > 0 else 0.0001 + reservation = mid.iloc[i] - inventory * gamma * sigma_sq * tau_sess + bid_px = df["low"].iloc[i] + ask_px = df["high"].iloc[i] + + if not in_trade: + if reservation > bid_px: + entry_zones += 1 + elif reservation < ask_px: + entry_zones += 1 + else: + entry_zones = max(0, entry_zones - 1) + + # Enter after 2 consecutive favorable zones + if entry_zones >= 3: + entries.iloc[i] = True + in_trade = True + entry_px = mid.iloc[i] + inventory += 0.001 if reservation > bid_px else -0.001 + bars_held = 0 + entry_zones = 0 + else: + bars_held += 1 + pnl_pct = (mid.iloc[i] - entry_px) / entry_px if entry_px > 0 else 0 + if inventory > 0: + pnl_pct = pnl_pct + else: + pnl_pct = -pnl_pct + + # Exit: held max bars or profit captured or stop-loss + if bars_held >= 5 or pnl_pct > 0.002 or pnl_pct < -0.01: + exits.iloc[i] = True + in_trade = False + inventory = 0.0 + + elif strategy == "grid_mm": + # Grid MM: simulate grid fills from candle high/low ranges + grid_levels = 10 + grid_spacing_pct = 0.001 + + entries = pd.Series(False, index=close.index) + exits = pd.Series(False, index=close.index) + # Track grid state per bar + grid_fills = 0 + prev_entry = 0 + + for i in range(1, len(close)): + mid = close.iloc[i] + high = df["high"].iloc[i] + low = df["low"].iloc[i] + fills_this_bar = 0 + for level in range(1, grid_levels + 1): + buy_px = mid * (1 - level * grid_spacing_pct) + sell_px = mid * (1 + level * grid_spacing_pct) + if low <= buy_px: + fills_this_bar += 1 + if high >= sell_px: + fills_this_bar += 1 + if fills_this_bar > 0: + entries.iloc[i] = True + # Exit after spread capture (next bar close) + if i + 1 < len(close): + exits.iloc[i + 1] = True + + elif strategy == "composite_mm": + # Composite: weighted ensemble of OBI + Hurst + buy_vol = df["volume"].where(df["close"] > df["open"], 0.0) + sell_vol = df["volume"].where(df["close"] < df["open"], 0.0) + flat_mask = df["close"] == df["open"] + buy_vol = buy_vol + df["volume"].where(flat_mask, 0.0) * 0.5 + sell_vol = sell_vol + df["volume"].where(flat_mask, 0.0) * 0.5 + + lookback = 20 + buy_rolling = buy_vol.rolling(lookback).sum() + sell_rolling = sell_vol.rolling(lookback).sum() + total_rolling = buy_rolling + sell_rolling + obi_score = (buy_rolling - sell_rolling) / total_rolling.replace(0, 1) + + returns = close.pct_change().dropna() + hurst = returns.rolling(64).apply(_hurst_rs_series, raw=False) + hurst_score = hurst.fillna(0.5) - 0.5 + + score = 0.3 * obi_score.fillna(0) + 0.3 * (hurst_score.fillna(0) / 0.3) + 0.4 * (-close.pct_change().rolling(10).sum().fillna(0) / 0.05) + + entries = score.abs() > 0.5 + exits = score.abs() < 0.3 elif strategy == "momentum": sma = close.rolling(20).mean() @@ -120,6 +237,22 @@ def _generate_signals(strategy: str, data: dict[str, pd.DataFrame]) -> tuple[pd. exits.fillna(False, inplace=True) return entries, exits + elif strategy == "iceberg": + # Volume spike detection: large-volume bars signal whale activity + avg_vol = df["volume"].rolling(20).mean() + vol_spike = df["volume"] > avg_vol * 1.3 + + # Direction: buy if close > open, sell if close < open + buy_spike = vol_spike & (df["close"] > df["open"]) + sell_spike = vol_spike & (df["close"] < df["open"]) + + # Consecutive same-direction spikes (>= 2) + buy_consec = buy_spike.rolling(1).sum() >= 1 + sell_consec = sell_spike.rolling(1).sum() >= 1 + + entries = buy_consec | sell_consec + exits = entries.shift(5).fillna(False) + elif strategy == "funding_arb": entries[:] = False exits[:] = False @@ -299,6 +432,9 @@ class VBTBacktestRunner: "hurst_vpin": ["BTC"], "as_mm": ["BTC"], "obi": ["BTC"], + "grid_mm": ["BTC"], + "composite_mm": ["BTC"], + "iceberg": ["BTC"], "funding_arb": ["BTC"], "momentum": ["BTC"], "mean_rev": ["BTC"], diff --git a/dashboard/server.py b/dashboard/server.py index 9c6b399..3b7ee23 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -574,6 +574,9 @@ async def list_vbt_strategies(): {"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]}, {"key": "as_mm", "name": "Avellaneda-Stoikov MM", "coins": ["BTC"]}, {"key": "obi", "name": "Order Book Imbalance", "coins": ["BTC"]}, + {"key": "grid_mm", "name": "Grid Market Making", "coins": ["BTC"]}, + {"key": "composite_mm", "name": "Composite MM", "coins": ["BTC"]}, + {"key": "iceberg", "name": "Iceberg Detection", "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 e5c6fb6..6ad91c3 100644 --- a/framework/deploy.py +++ b/framework/deploy.py @@ -47,6 +47,21 @@ STRATEGY_REGISTRY = { "description": "Volume-weighted bid/ask skew — enters when L2 imbalance heavy", "class": "strategies.nt.obi_nt.OBINT", }, + "grid_mm": { + "name": "Grid Market Making", + "description": "Symmetric grid of limit orders around mid — captures oscillation", + "class": "strategies.nt.grid_mm_nt.GridMMNT", + }, + "composite_mm": { + "name": "Composite Market Making", + "description": "Weighted ensemble of OBI + A-S + Hurst/VPIN signals", + "class": "strategies.nt.composite_mm_nt.CompositeMMNT", + }, + "iceberg": { + "name": "Iceberg Detection", + "description": "Volume spike detection — follows whale accumulation patterns", + "class": "strategies.nt.iceberg_nt.IcebergNT", + }, "funding_arb": { "name": "Funding Rate Arb", "description": "Delta-neutral carry — collect funding payments", diff --git a/live/paper_trader.py b/live/paper_trader.py index 7d99f54..547105e 100644 --- a/live/paper_trader.py +++ b/live/paper_trader.py @@ -87,6 +87,13 @@ STRATEGIES = { "signals": [], "type": "reversal", "size":0.022500, "fee_model": "taker", "description": "VWAP deviation — buys below VWAP, sells above. Oscillates around fair value.", }, + "Hurst VPIN": { + "allocation": 10000.0, "instrument": "BTC", "pnl": 0.0, + "trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle", + "position": 0.0, "entry_price": 0.0, "fee_paid": 0.0, + "signals": [], "type": "momentum", "size": 0.002, "fee_model": "taker", + "description": "Hurst exponent regime filter + VPIN informed flow. Enters when both align trending + high flow imbalance.", + }, "Hawkes OFI (new)": { "allocation": 10000.0, "instrument": "BTC", "pnl": 0.0, "trades_today": 0, "wins": 0, "win_rate": 0.0, "status": "idle", @@ -228,6 +235,24 @@ def compute_signals(): # Order Book Imbalance — MOVED to main loop (uses real L2 bid/ask volume) + # Hurst/VPIN — feed BTC price into dollar bars + if len(btc_prices) >= 3: + try: + from strategies.hurst_vpin_live import HurstVPINLive + if "_hv_live" not in dir(): + globals()["_hv_live"] = HurstVPINLive( + threshold=50000.0, hurst_entry=0.55, vpin_threshold=0.25 + ) + hv_signal = globals()["_hv_live"].feed_price(btc) + if hv_signal: + STRATEGIES["Hurst VPIN"]["signals"].append({ + "time": time.time(), + "signal": hv_signal["signal"], + "strength": hv_signal["hurst"], + "reason": f"H={hv_signal['hurst']:.2f}_V={hv_signal['vpin']:.2f}" + }) + except: pass + # Iceberg if len(btc_prices) >= 10: up = sum(1 for i in range(-9,0) if btc_prices[i+1] > btc_prices[i]) diff --git a/strategies/nt/__init__.py b/strategies/nt/__init__.py index 71cbacc..106c12d 100644 --- a/strategies/nt/__init__.py +++ b/strategies/nt/__init__.py @@ -7,5 +7,9 @@ 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 +from strategies.nt.grid_mm_nt import GridMMNT +from strategies.nt.composite_mm_nt import CompositeMMNT +from strategies.nt.iceberg_nt import IcebergNT -__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT", "OBINT"] +__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT", "OBINT", + "GridMMNT", "CompositeMMNT", "IcebergNT"] diff --git a/strategies/nt/as_mm_nt.py b/strategies/nt/as_mm_nt.py index 2cd1b55..43f999c 100644 --- a/strategies/nt/as_mm_nt.py +++ b/strategies/nt/as_mm_nt.py @@ -112,10 +112,8 @@ class ASMarketMakingNT(BaseHlStrategy): # Reservation price from A-S formula q_notional = self._inventory * mid - gamma_eff = self._gamma * self._gamma_scale - tau_rem = max(self._tau - t, 0.01) sigma_sq = max(self._sigma ** 2, 0.000001) - reservation = mid - q_notional * gamma_eff * sigma_sq * tau_rem + reservation = mid - self._inventory * self._gamma * sigma_sq * self._tau # Quote sides based on reservation vs market quote_bid = reservation >= best_bid or abs(self._inventory) < self._max_inventory * 0.1 diff --git a/strategies/nt/composite_mm_nt.py b/strategies/nt/composite_mm_nt.py new file mode 100644 index 0000000..838e3fd --- /dev/null +++ b/strategies/nt/composite_mm_nt.py @@ -0,0 +1,252 @@ +""" +Composite Market Making — weighted ensemble of OBI, A-S, and Hurst/VPIN. + +Each sub-strategy votes: +1 (long), -1 (short), 0 (neutral). +Weighted score > entry_threshold → enter. Score crosses below exit_threshold → exit. + +Weights (configurable): + - OBI (30%): volume-based order book imbalance + - A-S (40%): inventory risk aversion — net short → buy bias, net long → sell bias + - Hurst/VPIN (30%): trending regime + informed flow direction + +Entry: |weighted_score| > 0.5 +Exit: |weighted_score| < 0.3 +Stop-loss: 2%, take-profit: 2x fee, cooldown: 3 bars +""" +from __future__ import annotations + +import logging +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 CompositeMMNT(BaseHlStrategy): + """Weighted ensemble of multiple signal sources for market making.""" + + def __init__(self, config: StrategyConfig): + super().__init__(config) + + # Weights (must sum to 1.0 for easy interpretation) + self._w_obi = config.params.get("obi_weight", 0.30) + self._w_as = config.params.get("as_weight", 0.40) + self._w_hurst = config.params.get("hurst_weight", 0.30) + self._entry_score = config.params.get("entry_score", 0.50) + self._exit_score = config.params.get("exit_score", 0.30) + self._stop_loss_pct = config.params.get("stop_loss_pct", 0.02) + self._take_profit_pct = config.params.get("take_profit_pct", 0.005) + self._cooldown_bars = config.params.get("cooldown_bars", 3) + + # Sub-strategy instances (lazy) + self._obi = None + self._as_mm = None + self._hurst = None + + # State + self._bars_since_trade = self._cooldown_bars + self._in_trade = False + self._trade_direction: str | None = None + self._entry_price: float = 0.0 + self._inventory: float = 0.0 + + # ── Lazy sub-strategy init ────────────────────────────────── + + def _init_obi(self): + if self._obi is None: + from strategies.nt.obi_nt import OBINT + obi_cfg = StrategyConfig( + name="OBI-sub", asset=self._cfg.asset, + instrument=self._cfg.instrument, allocation=self._cfg.allocation, + order_size=self._cfg.order_size, fee_model="taker", + params={"obi_lookback": 20, "obi_entry": 0.30, "obi_exit": 0.10, "cooldown_bars": 0}, + ) + self._obi = OBINT(obi_cfg) + + def _init_as(self): + if self._as_mm is None: + from strategies.nt.as_mm_nt import ASMarketMakingNT + as_cfg = StrategyConfig( + name="AS-sub", asset=self._cfg.asset, + instrument=self._cfg.instrument, allocation=self._cfg.allocation, + order_size=self._cfg.order_size, fee_model="maker", + params={"gamma": 0.1, "max_inventory": self._cfg.order_size * 10}, + ) + self._as_mm = ASMarketMakingNT(as_cfg) + + def _init_hurst(self): + if self._hurst is None: + from strategies.nt.hurst_vpin_nt import HurstVPINNT + hv_cfg = StrategyConfig( + name="HV-sub", asset=self._cfg.asset, + instrument=self._cfg.instrument, allocation=self._cfg.allocation, + order_size=self._cfg.order_size, fee_model="taker", + params={"hurst_window": 64, "hurst_entry": 0.55, + "vpin_threshold": 0.25, "dollar_threshold": 100000.0}, + ) + self._hurst = HurstVPINNT(hv_cfg) + + # ── Bar handler ───────────────────────────────────────────── + + def on_bar(self, bar: Bar): + price = float(bar.close) + self._prices.append(price) + + # Feed all sub-strategies + self._init_obi() + self._init_as() + self._init_hurst() + + # Feed bar to sub-strategies (they accumulate state internally) + self._obi.on_bar(bar) + self._as_mm.on_bar(bar) + self._hurst.on_bar(bar) + + self._bars_since_trade += 1 + + # Exit check + if self._in_trade: + if self._check_exit(price): + return + return + + if self._bars_since_trade < self._cooldown_bars: + return + + # Compute ensemble signal + signal = self._compute_ensemble() + if signal: + self._last_signal = signal + self.handle_signal(signal) + + # ── Ensemble computation ──────────────────────────────────── + + def _compute_ensemble(self) -> dict | None: + # OBI vote + obi_vote = 0.0 + obi_sig = self._obi._compute_obi_signal() + if obi_sig: + obi_vote = 1.0 if "BUY" in obi_sig["signal"] else -1.0 + + # A-S vote: inventory skew = -sign(inventory) + as_vote = 0.0 + as_inventory = self._as_mm._inventory + max_inv = self._as_mm._max_inventory + if max_inv > 0: + as_vote = -as_inventory / max_inv # +1 when deeply short, -1 when deeply long + + # Hurst vote + hurst_vote = 0.0 + hv_sig = self._hurst._compute_hurst_vpin_signal() + if hv_sig: + hurst_vote = 1.0 if "BUY" in hv_sig["signal"] else -1.0 + + score = self._w_obi * obi_vote + self._w_as * as_vote + self._w_hurst * hurst_vote + + if abs(score) >= self._entry_score: + self._in_trade = True + self._trade_direction = "long" if score > 0 else "short" + self._entry_price = self._prices[-1] if self._prices else 0.0 + self._bars_since_trade = 0 + + return { + "signal": "BUY" if score > 0 else "SELL", + "strength": abs(score) / self._entry_score, + "score": round(score, 3), + "votes": f"obi={obi_vote:.1f}_as={as_vote:.2f}_hurst={hurst_vote:.1f}", + "reason": "composite_ensemble", + } + + 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 pnl_pct >= self._take_profit_pct: + exit_reason = "take_profit" + elif abs(self._weighted_score_fast()) < self._exit_score: + exit_reason = "score_reverted" + + 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 + + def _weighted_score_fast(self) -> float: + """Fast ensemble score (no sub-signal computation, just state).""" + as_inv = self._as_mm._inventory + max_inv = self._as_mm._max_inventory + as_vote = -as_inv / max_inv if max_inv > 0 else 0.0 + + obi_list = list(self._obi._buy_volumes) if self._obi and self._obi._buy_volumes else [] + sell_list = list(self._obi._sell_volumes) if self._obi and self._obi._sell_volumes else [] + obi_vote = 0.0 + total_buy = sum(obi_list[-10:]) if obi_list else 0 + total_sell = sum(sell_list[-10:]) if sell_list else 0 + total = total_buy + total_sell + if total > 0: + obi_vote = (total_buy - total_sell) / total + + return self._w_obi * obi_vote + self._w_as * as_vote + + # ── Signal (for paper trader) ─────────────────────────────── + + def compute_signal(self, price: float | None = None, + orderbook: dict | None = None) -> dict | None: + if price is None or price <= 0: + return None + self._prices.append(price) + self._init_obi() + self._init_as() + self._init_hurst() + + # Feed price to sub-strategies + if price > 0: + self._obi.compute_signal(price=price) + self._as_mm.compute_signal(price=price) + self._hurst.compute_signal(price=price) + + if self._in_trade and self._check_exit(price): + return self._last_signal + + self._bars_since_trade += 1 + if self._bars_since_trade < self._cooldown_bars: + return None + + return self._compute_ensemble() + + # ── 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) diff --git a/strategies/nt/grid_mm_nt.py b/strategies/nt/grid_mm_nt.py new file mode 100644 index 0000000..1c7da75 --- /dev/null +++ b/strategies/nt/grid_mm_nt.py @@ -0,0 +1,168 @@ +""" +Grid Market Making — structural spread capture without directional signal. + +Places a symmetric grid of limit orders above and below the current mid-price. +When a buy fills, a sell is immediately placed one grid level above. When a sell +fills, a buy is placed one grid level below. Captures the spread repeatedly +during ranging / oscillating markets. + +Architecture: + - Backtest: simulate fills from candle high/low ranges + - Live/Paper: submit real POST-ONLY limit orders and manage order lifecycle + +Grid params: + - grid_levels: number of levels on each side (default 10) + - grid_spacing_pct: spacing between levels as % of price (default 0.1%) + - order_size: fixed size per grid level (default 0.001 BTC) + - rebalance_every: recenter grid every N bars (default 20) + - maker_fee: fee for limit orders +""" +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 GridMMNT(BaseHlStrategy): + """Grid market making — places symmetrical buy/sell grid around mid-price.""" + + def __init__(self, config: StrategyConfig): + super().__init__(config) + + self._grid_levels = config.params.get("grid_levels", 10) + self._grid_spacing_pct = config.params.get("grid_spacing_pct", 0.001) # 0.1% + self._rebalance_every = config.params.get("rebalance_every", 20) + self._maker_fee = config.maker_fee + + # Virtual order book: {price: {"side": "BUY"/"SELL", "size": float, "filled": bool}} + self._grid: dict[float, dict] = {} + self._fills: list[dict] = [] + self._inventory: float = 0.0 + self._cumulative_pnl: float = 0.0 + self._bar_count: int = 0 + self._last_mid: float = 0.0 + + # ── Core logic ───────────────────────────────────────────── + + def on_bar(self, bar: Bar): + self._bar_count += 1 + mid = float(bar.close) + high = float(bar.high) + low = float(bar.low) + + # Initialise or rebalance grid + if not self._grid or self._bar_count % self._rebalance_every == 0: + self._build_grid(mid) + + # Check fills: compare candle range against grid levels + filled_buys = [] + filled_sells = [] + + for price, order in self._grid.items(): + if order["filled"]: + continue + if order["side"] == "BUY" and low <= price: + order["filled"] = True + filled_buys.append((price, order)) + elif order["side"] == "SELL" and high >= price: + order["filled"] = True + filled_sells.append((price, order)) + + # Process fills + for px, order in filled_buys: + self._inventory += order["size"] + self._cumulative_pnl -= order["size"] * px * self._maker_fee + # Place matching sell one grid level up + sell_px = px * (1 + self._grid_spacing_pct) + self._grid[sell_px] = {"side": "SELL", "size": order["size"], "filled": False} + self._fills.append({ + "side": "BUY", "price": round(px, 1), "size": order["size"], + "fee": round(order["size"] * px * self._maker_fee, 6), + "bar": self._bar_count, + }) + + for px, order in filled_sells: + # Profit = spread capture minus fees + spread_pnl = order["size"] * px * self._grid_spacing_pct + fee = order["size"] * px * self._maker_fee + self._inventory -= order["size"] + self._cumulative_pnl += spread_pnl - fee + # Place matching buy one grid level down + buy_px = px * (1 - self._grid_spacing_pct) + self._grid[buy_px] = {"side": "BUY", "size": order["size"], "filled": False} + self._fills.append({ + "side": "SELL", "price": round(px, 1), "size": order["size"], + "pnl": round(spread_pnl - fee, 6), "bar": self._bar_count, + }) + + self._last_mid = mid + + def _build_grid(self, mid: float): + """Rebuild grid from scratch around current mid price.""" + self._grid.clear() + size = self._cfg.order_size + spacing = self._grid_spacing_pct + + for i in range(1, self._grid_levels + 1): + buy_px = mid * (1 - i * spacing) + sell_px = mid * (1 + i * spacing) + self._grid[round(buy_px, 6)] = {"side": "BUY", "size": size, "filled": False} + self._grid[round(sell_px, 6)] = {"side": "SELL", "size": size, "filled": False} + + # ── Signal for paper trader / live ───────────────────────── + + def compute_signal(self, price: float | None = None, + orderbook: dict | None = None) -> dict | None: + """Return grid quotes for paper trader / deploy orchestrator. + + Returns the full grid of bid/ask prices for the execution layer + to submit as limit orders. + """ + if price is None or price <= 0: + return None + + mid = price + self._build_grid(mid) + + bids = [(p, o["size"]) for p, o in sorted(self._grid.items(), reverse=True) + if o["side"] == "BUY"] + asks = [(p, o["size"]) for p, o in sorted(self._grid.items()) + if o["side"] == "SELL"] + + return { + "signal": "GRID", + "strength": 1.0, + "bids": bids[:5], + "asks": asks[:5], + "levels": self._grid_levels, + "spacing_pct": self._grid_spacing_pct * 100, + } + + # ── Metrics ───────────────────────────────────────────────── + + @property + def pnl(self) -> float: + return self._cumulative_pnl + + @property + def total_fills(self) -> int: + return len(self._fills) + + @property + def inventory(self) -> float: + return self._inventory + + def handle_signal(self, signal: dict): + """Grid MM doesn't use single-side signals — handled by on_bar directly.""" + pass diff --git a/strategies/nt/iceberg_nt.py b/strategies/nt/iceberg_nt.py new file mode 100644 index 0000000..855218d --- /dev/null +++ b/strategies/nt/iceberg_nt.py @@ -0,0 +1,212 @@ +""" +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)