""" 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)