""" Base strategy class for NautilusTrader + Hyperliquid. Provides shared lifecycle for all FTDT strategies: - Instrument resolution from Hyperliquid catalog - Fee-aware position sizing from StrategyConfig - Shared signal pipeline (OBI, Hurst, VPIN computations) - on_start / on_bar / on_stop hooks Strategies inherit this and override signal logic. """ from __future__ import annotations import logging from collections import deque from typing import Any import numpy as np from nautilus_trader.common.actor import Actor from nautilus_trader.model.data import Bar from nautilus_trader.model.enums import OrderSide from nautilus_trader.model.identifiers import InstrumentId from nautilus_trader.trading.strategy import Strategy from framework.config import StrategyConfig from framework.data import HyperliquidDataProvider from framework.instruments import HyperliquidInstrumentCatalog logger = logging.getLogger(__name__) class BaseHlStrategy(Strategy): """Base strategy with Hyperliquid-specific utilities. Inherits NautilusTrader Strategy lifecycle: on_start → on_bar (repeated) → on_stop """ def __init__(self, config: StrategyConfig): super().__init__() self._cfg = config self._instrument: InstrumentId | None = None self._asset = config.asset # Price history for signal calculations self._prices: deque[float] = deque(maxlen=300) # Signal state self._last_signal: dict[str, Any] | None = None self._position_open: bool = False self._entry_price: float = 0.0 @property def config(self) -> StrategyConfig: return self._cfg @property def instrument_id(self) -> InstrumentId | None: return self._instrument # ── Lifecycle ─────────────────────────────────────────────── def on_start(self): """Called when strategy is started. Resolve instruments.""" if not self._instrument: # Try to resolve from catalog catalog = HyperliquidInstrumentCatalog(testnet=self._cfg.testnet) inst_map = catalog.load(assets=[self._asset]) inst = inst_map.get(self._asset.upper()) if inst: self._instrument = inst.id else: self._instrument = InstrumentId.from_str( f"{self._asset.upper()}-USD-PERP.HYPERLIQUID" ) # Subscribe to 1-minute bars self.subscribe_bars(self._instrument) logger.info("%s started on %s", self._cfg.name, self._instrument) def on_stop(self): logger.info("%s stopped", self._cfg.name) def on_bar(self, bar: Bar): """Process each bar. Override in subclasses for custom signal logic.""" self._prices.append(float(bar.close)) signal = self.compute_signal() if signal: self._last_signal = signal self.handle_signal(signal) # ── Signal computation (override in subclass) ─────────────── def compute_signal(self) -> dict[str, Any] | None: """Override in subclass to compute trading signals.""" return None def handle_signal(self, signal: dict[str, Any]): """Default: submit a limit order based on signal direction.""" side = signal.get("signal", "") strength = signal.get("strength", 0.0) # Check minimum strength threshold if strength < 0.15: return if "BUY" in str(side).upper(): self._submit_order(OrderSide.BUY) elif "SELL" in str(side).upper(): self._submit_order(OrderSide.SELL) # ── Order submission (override or use directly) ───────────── def _submit_order(self, side: OrderSide, size: float | None = None): """Submit a limit order at current price.""" sz = size or self._cfg.order_size price = self._prices[-1] if self._prices else 0.0 if price <= 0: return try: self.submit_order( instrument_id=self._instrument, order_side=side, order_type="LIMIT", quantity=Quantity.from_str(str(sz)), price=Price.from_str(str(int(price))), post_only=True, ) except Exception as e: logger.warning("%s order failed: %s", self._cfg.name, e) # ── Signal library (shared across strategies) ─────────────── def signal_zscore(self, window: int = 20, threshold: float = 1.5) -> dict | None: """Z-score mean reversion signal based on price history.""" if len(self._prices) < window: return None prices = list(self._prices) recent = prices[-window:] mu = np.mean(recent) std = np.std(recent, ddof=1) if std <= 0: return None z = (prices[-1] - mu) / std if z > threshold: return {"signal": "SELL", "strength": z / threshold} elif z < -threshold: return {"signal": "BUY", "strength": abs(z) / threshold} return None def signal_bollinger(self, window: int = 20, n_std: float = 2.0) -> dict | None: """Bollinger band breakout signal.""" if len(self._prices) < window: return None prices = list(self._prices) recent = prices[-window:] sma = np.mean(recent) std = np.std(recent, ddof=1) if std <= 0: return None cur = prices[-1] if cur > sma + n_std * std: return {"signal": "BUY", "strength": (cur - sma - n_std * std) / std} elif cur < sma - n_std * std: return {"signal": "SELL", "strength": (sma - n_std * std - cur) / std} return None def signal_trend(self, window: int = 10, threshold: float = 0.7) -> dict | None: """Directional trend strength signal.""" if len(self._prices) < window: return None prices = list(self._prices) up = sum(1 for i in range(-window + 1, 0) if prices[i + 1] > prices[i]) ratio = up / (window - 1) if ratio >= threshold: return {"signal": "BUY", "strength": ratio} elif ratio <= 1.0 - threshold: return {"signal": "SELL", "strength": 1.0 - ratio} return None def signal_vwap_deviation(self, window: int = 20, threshold: float = 1.0) -> dict | None: """VWAP deviation signal (mean-reverting).""" if len(self._prices) < window: return None prices = list(self._prices) prior = prices[-(window + 1):-1] cur = prices[-1] vwap = np.mean(prior) std = np.std(prior, ddof=1) if std <= 0: return None dev = (cur - vwap) / std if dev > threshold: return {"signal": "SELL", "strength": dev / threshold} elif dev < -threshold: return {"signal": "BUY", "strength": abs(dev) / threshold} return None