Files
ftdt-quant-lab/strategies/nt/composite_mm_nt.py
T
ramseshk 3606e7f92e feat: proper Grid MM, Composite MM, Hurst/VPIN, Iceberg, A-S strategies
New strategies (strategies/nt/):
- GridMMNT: symmetric limit order grid around mid-price, captures spread from
  oscillation. Simulates fills from candle high/low. Rebuilds grid every 20 bars.
- CompositeMMNT: weighted ensemble of OBI (30%) + A-S inventory skew (40%) +
  Hurst/VPIN (30%). Votes: +1 long, -1 short, 0 neutral. Entry |score| > 0.5.
- IcebergNT: volume spike detection for whale accumulation. Dual-mode:
  candle proxy (volume > avg*2.5, >= 3 consecutive same-direction) and
  L2 wall detection (single level > avg*3). Exit on stop-loss/time/spike-fade.

Fixed strategies:
- Hurst/VPIN VBT: added proper VPIN proxy from candle volume (buy_vol when close
  > open, sell_vol when close < open). 50-bar rolling VPIN window. Signal:
  H>0.55 AND VPIN>0.25 AND |direction|>0.05. Exit: H<0.45 or direction flips.
- Hurst/VPIN paper trader: added HurstVPINLive integration (was missing entirely)
- A-S VBT: replaced placeholder spread filter with proper A-S simulation using
  reservation price formula (mid - q*gamma*sigma^2*tau), inventory tracking
- A-S NT formula: fixed to standard: mid - q*gamma*sigma^2*tau (was scaled by
  notional and gamma_scale improperly)
- Iceberg VBT: new volume spike detection replacing the old trend proxy

Registry: all 7 strategies now  (pairs, hurst_vpin, as_mm, obi, grid_mm,
         composite_mm, iceberg)

VBT backtest results (500 BTC 1h bars):
  pairs:       -2.81%  13 trades   38% win
  hurst_vpin:  -0.77%   1 trade    (VPIN now active, very selective)
  as_mm:       -16.38%  73 trades  29% win
  obi:         -7.19%  15 trades    7% win
  grid_mm:     -4.79%  22 trades  33% win
  iceberg:     0 trades (threshold strict for 1h BTC data)
2026-08-07 11:42:32 +08:00

253 lines
9.4 KiB
Python

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