From 90ff9d29694c3c84d622bcb1522dc91be4a0abf5 Mon Sep 17 00:00:00 2001 From: pierluigipanariello Date: Fri, 19 Jun 2026 12:00:51 +0200 Subject: [PATCH] Add custom modifications --- bots/controllers/generic/anti_folla_v1.py | 548 +++++++++++++++ bots/controllers/generic/delta_neutral_mm.py | 597 ++++++++++++++++ bots/controllers/generic/funding_rate_arb.py | 417 +++++++++++ bots/controllers/generic/stat_arb_v2.py | 658 ++++++++++++++++++ .../market_making/lm_multi_pair_dex.py | 418 +++++++++++ 5 files changed, 2638 insertions(+) create mode 100644 bots/controllers/generic/anti_folla_v1.py create mode 100644 bots/controllers/generic/delta_neutral_mm.py create mode 100644 bots/controllers/generic/funding_rate_arb.py create mode 100755 bots/controllers/generic/stat_arb_v2.py create mode 100644 bots/controllers/market_making/lm_multi_pair_dex.py diff --git a/bots/controllers/generic/anti_folla_v1.py b/bots/controllers/generic/anti_folla_v1.py new file mode 100644 index 00000000..73ac7614 --- /dev/null +++ b/bots/controllers/generic/anti_folla_v1.py @@ -0,0 +1,548 @@ +from decimal import Decimal +from typing import Any, Dict, List, Optional + +import numpy as np +import pandas as pd +from pydantic import Field + +from hummingbot.core.data_type.common import OrderType, PositionMode, PriceType, TradeType +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.position_executor.data_types import ( + PositionExecutorConfig, + TripleBarrierConfig, +) +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction + + +class AntiFollaV1Config(ControllerConfigBase): + """ + Anti-Folla V1 Controller - Crowd-contrarian directional trading. + + This controller replicates the signal logic from Condor's Anti-Folla V1 + analysis, allowing the strategy to run natively in Hummingbot. + """ + controller_type: str = "directional_trading" + controller_name: str = "anti_folla_v1" + + # --- Exchange and pair --- + connector_name: str = Field( + default="binance_perpetual", + json_schema_extra={"prompt": "Enter the exchange connector name: "}, + ) + trading_pair: str = Field( + default="SOL-USDT", + json_schema_extra={"prompt": "Enter the trading pair: "}, + ) + leverage: int = Field(default=1, json_schema_extra={"prompt": "Leverage: "}) + position_mode: PositionMode = Field(default=PositionMode.HEDGE) + + # --- Capital and risk --- + total_amount_quote: Decimal = Field( + default=Decimal("1000"), json_schema_extra={"prompt": "Total amount in quote currency: "} + ) + max_executors_per_side: int = Field(default=1) + cooldown_time: int = Field(default=60) + stop_loss: Decimal = Field(default=Decimal("0.05")) + take_profit: Decimal = Field(default=Decimal("0.03")) + trailing_stop: Optional[Dict[str, Decimal]] = Field( + default={"activation_price": Decimal("0.015"), "trailing_delta": Decimal("0.005")} + ) + + # --- Candles configuration --- + candles_connector: Optional[str] = Field(default=None) + candles_trading_pair: Optional[str] = Field(default=None) + interval: str = Field(default="3m") + + # --- Anti-Folla parameters --- + vwap_period: int = Field(default=20) + donchian_period: int = Field(default=20) + obv_divergence_lookback: int = Field(default=10) + volume_spike_threshold: float = Field(default=2.5) + + # --- Order book imbalance (OBI) --- + enable_order_book_imbalance: bool = Field(default=True) + obi_depth_percentage: float = Field(default=0.02) + obi_buy_threshold: float = Field(default=1.5) + obi_sell_threshold: float = Field(default=0.67) + + # --- Score thresholds and weights --- + score_buy_threshold: float = Field(default=50.0) + score_sell_threshold: float = Field(default=-50.0) + + weight_vwap: float = Field(default=15) + weight_donchian: float = Field(default=10) + weight_obv: float = Field(default=15) + weight_obi: float = Field(default=20) + weight_volume_spike: float = Field(default=10) + weight_trade_flow: float = Field(default=15) + weight_funding: float = Field(default=15) + + # --- Perpetual flag --- + is_perpetual: bool = Field(default=False) + + @property + def triple_barrier_config(self) -> TripleBarrierConfig: + """Triple barrier configuration for position executors.""" + trailing_stop = None + if self.trailing_stop: + trailing_stop = { + "activation_price": float(self.trailing_stop.get("activation_price", 0)), + "trailing_delta": float(self.trailing_stop.get("trailing_delta", 0)), + } + return TripleBarrierConfig( + stop_loss=float(self.stop_loss), + take_profit=float(self.take_profit), + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + trailing_stop=trailing_stop, + ) + + def update_markets(self, markets: Dict[str, Any]) -> Dict[str, Any]: + """Register the trading pair for the connector.""" + if self.connector_name not in markets: + markets[self.connector_name] = set() + markets[self.connector_name].add(self.trading_pair) + return markets + + +class AntiFollaV1(ControllerBase): + """ + Anti-Folla V1 execution controller. + + Calculates the composite score using the same logic as the Condor controller + and generates BUY/SELL signals based on configurable thresholds. + """ + + def __init__(self, config: AntiFollaV1Config, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + self._last_timestamp = 0.0 + + # Set up candles data provider + candles_connector = self.config.candles_connector or self.config.connector_name + candles_pair = self.config.candles_trading_pair or self.config.trading_pair + + self._candles = self.market_data_provider.get_candles_df( + connector_name=candles_connector, + trading_pair=candles_pair, + interval=self.config.interval, + max_records=500, + ) + + # Configure perpetual connector if needed + if self.config.is_perpetual or "_perpetual" in self.config.connector_name: + try: + connector = self.market_data_provider.get_connector(self.config.connector_name) + connector.set_position_mode(self.config.position_mode) + connector.set_leverage(self.config.trading_pair, self.config.leverage) + except Exception as e: + self.logger().warning(f"Could not configure connector: {e}") + + # Processed data storage + self.processed_data = { + "signal": 0, + "composite_score": 0.0, + "funding_rate": None, + "obi": None, + } + + # ------------------------------------------------------------------ + # Signal calculation (mirrors Condor's analysis.py) + # ------------------------------------------------------------------ + + def _calculate_rolling_vwap(self, df: pd.DataFrame) -> pd.Series: + """Calculate rolling VWAP.""" + pv = df["close"] * df["volume"] + return pv.rolling(self.config.vwap_period).sum() / df["volume"].rolling(self.config.vwap_period).sum() + + def _calculate_donchian(self, df: pd.DataFrame) -> tuple[pd.Series, pd.Series]: + """Calculate Donchian channel with shift(1) to exclude current candle.""" + upper = df["high"].shift(1).rolling(self.config.donchian_period).max() + lower = df["low"].shift(1).rolling(self.config.donchian_period).min() + return upper, lower + + def _calculate_obv(self, df: pd.DataFrame) -> pd.Series: + """Calculate On-Balance Volume.""" + direction = np.sign(df["close"].diff().fillna(0)) + return (direction * df["volume"]).cumsum() + + def _detect_obv_divergence(self, df: pd.DataFrame, obv: pd.Series) -> str: + """Detect OBV divergence.""" + if len(df) < self.config.obv_divergence_lookback: + return "none" + price_trend = df["close"].diff(self.config.obv_divergence_lookback).iloc[-1] + obv_trend = obv.diff(self.config.obv_divergence_lookback).iloc[-1] + if price_trend < 0 and obv_trend > 0: + return "bullish" + if price_trend > 0 and obv_trend < 0: + return "bearish" + return "none" + + def _detect_volume_spike(self, df: pd.DataFrame) -> tuple[bool, float]: + """Detect volume spike.""" + if len(df) < 22: + return False, 1.0 + avg_vol = df["volume"].iloc[-21:-1].mean() + if avg_vol == 0: + return False, 1.0 + multiplier = df["volume"].iloc[-1] / avg_vol + return multiplier >= self.config.volume_spike_threshold, float(multiplier) + + def _analyze_trade_flow(self, df: pd.DataFrame) -> Dict[str, Any]: + """Analyze buy/sell pressure from OHLCV.""" + if len(df) < 11: + return {"whale_buying": False, "whale_selling": False, "buy_pressure": 0.5} + + recent = df.iloc[-10:] + bull_vol = recent[recent["close"] > recent["open"]]["volume"].sum() + bear_vol = recent[recent["close"] <= recent["open"]]["volume"].sum() + total_vol = bull_vol + bear_vol + buy_pressure = bull_vol / total_vol if total_vol > 0 else 0.5 + + # Whale detection: last candle > 3x avg volume and large body + avg_vol = recent["volume"].mean() + avg_body = (recent["close"] - recent["open"]).abs().mean() + last = df.iloc[-1] + last_body = abs(last["close"] - last["open"]) + last_vol = last["volume"] + + whale_buying = ( + last_vol > avg_vol * 3.0 + and last["close"] > last["open"] + and last_body > avg_body + ) + whale_selling = ( + last_vol > avg_vol * 3.0 + and last["close"] < last["open"] + and last_body > avg_body + ) + + return { + "whale_buying": whale_buying, + "whale_selling": whale_selling, + "buy_pressure": buy_pressure, + } + + async def _get_funding_rate(self) -> Optional[float]: + """Get current funding rate for perpetual connectors.""" + if not self.config.is_perpetual and "_perpetual" not in self.config.connector_name: + return None + try: + connector = self.market_data_provider.get_connector(self.config.connector_name) + funding_info = await connector.get_funding_info(self.config.trading_pair) + return float(funding_info.rate) * 100 # Convert to percentage + except Exception as e: + self.logger().debug(f"Could not fetch funding rate: {e}") + return None + + async def _get_order_book_imbalance(self) -> Optional[float]: + """Calculate Order Book Imbalance (OBI).""" + if not self.config.enable_order_book_imbalance: + return None + try: + connector = self.market_data_provider.get_connector(self.config.connector_name) + order_book = connector.get_order_book(self.config.trading_pair) + + if not order_book: + return None + + # Calculate OBI at specified depth + depth = self.config.obi_depth_percentage + best_bid = order_book.best_bid[0] if order_book.best_bid else None + best_ask = order_book.best_ask[0] if order_book.best_ask else None + + if not best_bid or not best_ask: + return None + + # Bid depth up to (1 + depth)% from best bid + bid_cutoff = best_bid * (1 - depth) + ask_cutoff = best_ask * (1 + depth) + + bid_volume = sum(price * amount for price, amount in order_book.bid_entries() if price >= bid_cutoff) + ask_volume = sum(price * amount for price, amount in order_book.ask_entries() if price <= ask_cutoff) + + if ask_volume == 0: + return 2.0 if bid_volume > 0 else 1.0 + + obi = bid_volume / ask_volume + return float(obi) + except Exception as e: + self.logger().debug(f"Could not calculate OBI: {e}") + return None + + def _calculate_composite_score(self, signals: Dict[str, Any]) -> float: + """Calculate weighted composite score.""" + score = 0.0 + total_weight = 0.0 + + # VWAP + if signals.get("vwap_above"): + score += self.config.weight_vwap + total_weight += self.config.weight_vwap + elif signals.get("vwap_below"): + score -= self.config.weight_vwap + total_weight += self.config.weight_vwap + + # Donchian breakout + if signals.get("donchian_breakout_up"): + score += self.config.weight_donchian + total_weight += self.config.weight_donchian + elif signals.get("donchian_breakout_down"): + score -= self.config.weight_donchian + total_weight += self.config.weight_donchian + + # OBV divergence + obv_div = signals.get("obv_divergence", "none") + if obv_div == "bullish": + score += self.config.weight_obv + total_weight += self.config.weight_obv + elif obv_div == "bearish": + score -= self.config.weight_obv + total_weight += self.config.weight_obv + + # OBI + obi = signals.get("obi") + if obi is not None: + if obi >= self.config.obi_buy_threshold: + score += self.config.weight_obi + total_weight += self.config.weight_obi + elif obi <= self.config.obi_sell_threshold: + score -= self.config.weight_obi + total_weight += self.config.weight_obi + + # Volume spike + if signals.get("volume_spike"): + price_trend = signals.get("price_trend", 0) + if price_trend > 0: + score += self.config.weight_volume_spike + else: + score -= self.config.weight_volume_spike + total_weight += self.config.weight_volume_spike + + # Whale activity + if signals.get("whale_buying"): + score += self.config.weight_trade_flow + total_weight += self.config.weight_trade_flow + elif signals.get("whale_selling"): + score -= self.config.weight_trade_flow + total_weight += self.config.weight_trade_flow + + # Funding rate (contrarian) + funding_rate = signals.get("funding_rate") + if funding_rate is not None: + if funding_rate > 0.05: # Too many longs β†’ contrarian short + score -= self.config.weight_funding + total_weight += self.config.weight_funding + elif funding_rate < -0.05: # Too many shorts β†’ contrarian long + score += self.config.weight_funding + total_weight += self.config.weight_funding + + if total_weight > 0: + score = (score / total_weight) * 100 + + return score + + async def _get_current_signal(self) -> tuple[int, float]: + """ + Calculate current composite score and generate signal. + + Returns: + (signal, score) where signal is 1 (BUY), -1 (SELL), or 0 (NEUTRAL) + """ + # Get fresh candles + candles_connector = self.config.candles_connector or self.config.connector_name + candles_pair = self.config.candles_trading_pair or self.config.trading_pair + + df = self.market_data_provider.get_candles_df( + connector_name=candles_connector, + trading_pair=candles_pair, + interval=self.config.interval, + max_records=200, + ) + + if df.empty or len(df) < 50: + self.logger().warning("Insufficient candle data for signal calculation") + return 0, 0.0 + + # Calculate indicators + vwap = self._calculate_rolling_vwap(df) + donchian_upper, donchian_lower = self._calculate_donchian(df) + obv = self._calculate_obv(df) + + current_price = df["close"].iloc[-1] + current_vwap = vwap.iloc[-1] + current_upper = donchian_upper.iloc[-1] + current_lower = donchian_lower.iloc[-1] + + # Detect signals + obv_divergence = self._detect_obv_divergence(df, obv) + is_spike, _ = self._detect_volume_spike(df) + trade_flow = self._analyze_trade_flow(df) + + # Price trend (20-candle) + price_trend = (df["close"].iloc[-1] - df["close"].iloc[-21]) / df["close"].iloc[-21] if len(df) >= 21 else 0 + + # Get OBI and funding rate + obi = await self._get_order_book_imbalance() + funding_rate = await self._get_funding_rate() + + signals = { + "vwap_above": current_price > current_vwap, + "vwap_below": current_price < current_vwap, + "donchian_breakout_up": not pd.isna(current_upper) and current_price > current_upper, + "donchian_breakout_down": not pd.isna(current_lower) and current_price < current_lower, + "obv_divergence": obv_divergence, + "obi": obi, + "volume_spike": is_spike, + "price_trend": price_trend, + "funding_rate": funding_rate, + **trade_flow, + } + + score = self._calculate_composite_score(signals) + + if score >= self.config.score_buy_threshold: + signal = 1 + elif score <= self.config.score_sell_threshold: + signal = -1 + else: + signal = 0 + + return signal, score + + # ------------------------------------------------------------------ + # Position management + # ------------------------------------------------------------------ + + def _get_current_position_side(self) -> Optional[TradeType]: + """Get the side of the current open position.""" + for position in self.positions_held: + if position.amount > 0: + return position.side + return None + + def _create_position_executor(self, side: TradeType, price: Decimal) -> CreateExecutorAction: + """Create a position executor for the given side.""" + amount = self.config.total_amount_quote / price + + executor_config = PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + side=side, + entry_price=price, + amount=amount, + triple_barrier_config=self.config.triple_barrier_config, + leverage=self.config.leverage, + ) + + return CreateExecutorAction( + controller_id=self.config.id, + executor_config=executor_config, + ) + + # ------------------------------------------------------------------ + # Main execution + # ------------------------------------------------------------------ + + async def update_processed_data(self): + """Update processed data with current signal and score.""" + signal, score = await self._get_current_signal() + self.processed_data.update( + { + "signal": signal, + "composite_score": score, + } + ) + + def determine_executor_actions(self) -> List[ExecutorAction]: + """Determine executor actions based on current signal.""" + actions: List[ExecutorAction] = [] + + signal = self.processed_data.get("signal", 0) + current_position_side = self._get_current_position_side() + current_price = self.market_data_provider.get_price_by_type( + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + price_type=PriceType.MidPrice, + ) + + # Log current state + self.logger().info( + f"Signal: {signal:+d} | Score: {self.processed_data.get('composite_score', 0):.1f} | " + f"Position: {current_position_side if current_position_side else 'NONE'} | " + f"Price: {current_price:.6f}" + ) + + # No signal or score in neutral zone + if signal == 0: + return actions + + # Check if we already have a position in the same direction + target_side = TradeType.BUY if signal == 1 else TradeType.SELL + + if current_position_side == target_side: + # Already have position in correct direction + return actions + + # Close existing position if opposite + if current_position_side is not None and current_position_side != target_side: + for position in self.positions_held: + if position.amount > 0: + # Create a stop action to close the position + actions.append( + StopExecutorAction( + controller_id=self.config.id, + executor_id=position.executor_id, + keep_position=False, + ) + ) + + # Create new position if no position or after closing + if current_position_side != target_side: + actions.append(self._create_position_executor(target_side, Decimal(str(current_price)))) + + return actions + + def to_format_status(self) -> List[str]: + """Format status for display.""" + d = self.processed_data + signal = d.get("signal", 0) + signal_str = "🟒 BUY" if signal == 1 else ("πŸ”΄ SELL" if signal == -1 else "βšͺ NEUTRAL") + + lines = [ + f"Anti-Folla V1 - {self.config.trading_pair}", + f" Signal: {signal_str}", + f" Score: {d.get('composite_score', 0):.1f}", + f" Thresholds: BUY β‰₯{self.config.score_buy_threshold:.0f} | SELL ≀{self.config.score_sell_threshold:.0f}", + f" Interval: {self.config.interval}", + "", + f" Weights: VWAP={self.config.weight_vwap:.0f} Donchian={self.config.weight_donchian:.0f} " + f"OBV={self.config.weight_obv:.0f} OBI={self.config.weight_obi:.0f} " + f"Spike={self.config.weight_volume_spike:.0f} Flow={self.config.weight_trade_flow:.0f} " + f"Funding={self.config.weight_funding:.0f}", + ] + + if self.positions_held: + lines.append(" Positions:") + for pos in self.positions_held: + lines.append( + f" {pos.side.name} {pos.amount:.4f} @ {pos.average_entry:.6f} | " + f"PnL: {pos.global_pnl_pct:.2%} | Value: ${pos.amount_quote:.2f}" + ) + + return lines + + def get_candles_config(self) -> List[CandlesConfig]: + """Return candles configuration for the data provider.""" + candles_connector = self.config.candles_connector or self.config.connector_name + candles_pair = self.config.candles_trading_pair or self.config.trading_pair + return [ + CandlesConfig( + connector=candles_connector, + trading_pair=candles_pair, + interval=self.config.interval, + max_records=500, + ) + ] \ No newline at end of file diff --git a/bots/controllers/generic/delta_neutral_mm.py b/bots/controllers/generic/delta_neutral_mm.py new file mode 100644 index 00000000..7424f9ee --- /dev/null +++ b/bots/controllers/generic/delta_neutral_mm.py @@ -0,0 +1,597 @@ +from decimal import Decimal +from typing import Dict, List, Optional + +import pandas_ta as ta # noqa: F401 +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo + +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PriceType, TradeType +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair +from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig +from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig, TripleBarrierConfig +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction + + +def is_perpetual_connector(connector_name: str) -> bool: + """Detect if connector is perpetual by name conventions.""" + name_lower = connector_name.lower() + return "_perpetual" in name_lower or "perp" in name_lower + + +class DeltaNeutralMMConfig(ControllerConfigBase): + """ + Delta Neutral Market Making. + """ + controller_type: str = "generic" + controller_name: str = "delta_neutral_mm" + + # --- Exchanges --- + connector_pair_maker: ConnectorPair = ConnectorPair( + connector_name="kucoin", + trading_pair="SOL-USDT" + ) + connector_pair_hedge: ConnectorPair = ConnectorPair( + connector_name="hyperliquid_perpetual", + trading_pair="SOL-USDT" + ) + + # --- Candles --- + candles_connector: Optional[str] = Field( + default=None, + json_schema_extra={"prompt": "Candles connector (leave empty = maker exchange): ", + "prompt_on_new": True} + ) + candles_trading_pair: Optional[str] = Field( + default=None, + json_schema_extra={"prompt": "Candles pair (leave empty = maker pair): ", + "prompt_on_new": True} + ) + interval: str = Field(default="3m") + + # --- MACD parameters --- + macd_fast: int = Field(default=21) + macd_slow: int = Field(default=42) + macd_signal: int = Field(default=9) + + # --- NATR parameters --- + natr_length: int = Field(default=14) + + # --- Market making levels --- + buy_spreads: str = Field( + default="1.0,2.0,3.0", + json_schema_extra={"prompt": "Buy spreads as comma-separated values (e.g., 1.0,2.0,3.0): ", + "prompt_on_new": True} + ) + sell_spreads: str = Field( + default="1.0,2.0,3.0", + json_schema_extra={"prompt": "Sell spreads as comma-separated values (e.g., 1.0,2.0,3.0): ", + "prompt_on_new": True} + ) + + order_amount_quote: Decimal = Field( + default=Decimal("15"), + json_schema_extra={"prompt": "Order amount in quote currency per level: ", + "prompt_on_new": True} + ) + order_refresh_time: int = Field( + default=30, + json_schema_extra={"prompt": "Refresh unfilled orders after (seconds): ", + "prompt_on_new": True} + ) + + # --- Delta / hedge parameters --- + hedge_threshold_quote: Decimal = Field( + default=Decimal("10"), + json_schema_extra={"prompt": "Hedge when delta exceeds (USDT): ", + "prompt_on_new": True} + ) + max_delta_quote: Decimal = Field( + default=Decimal("50"), + json_schema_extra={"prompt": "Maximum unhedged delta before emergency (USDT): ", + "prompt_on_new": True} + ) + + # --- Hedge perp settings --- + leverage: int = Field( + default=1, + json_schema_extra={"prompt": "Leverage for hedge positions (1x recommended): ", + "prompt_on_new": True} + ) + position_mode: PositionMode = PositionMode.HEDGE + + # --- Global safety --- + sl_global: Decimal = Field( + default=Decimal("0.03"), + json_schema_extra={"prompt": "Global stop loss (e.g., 0.03 = 3%): ", + "prompt_on_new": True} + ) + tp_global: Decimal = Field( + default=Decimal("0.05"), + json_schema_extra={"prompt": "Global take profit (e.g., 0.05 = 5%): ", + "prompt_on_new": True} + ) + + # --- Hedge position timeout --- + hedge_position_timeout: int = Field( + default=3600, + json_schema_extra={"prompt": "Close hedge positions after (seconds, 0 = disabled): ", + "prompt_on_new": True} + ) + + # --- Take profit multiplier for maker orders --- + maker_tp_multiplier: Decimal = Field( + default=Decimal("1.0"), + json_schema_extra={"prompt": "Take profit multiplier for maker orders (1.0 = spread Γ— 1): ", + "prompt_on_new": True} + ) + + @field_validator("candles_connector", mode="before") + @classmethod + def set_candles_connector(cls, v, validation_info: ValidationInfo): + if v is None or v == "": + cp = validation_info.data.get("connector_pair_maker") + if cp and hasattr(cp, "connector_name"): + return cp.connector_name + return "kucoin" + return v + @field_validator("buy_spreads", "sell_spreads", mode="before") + @classmethod + def parse_spreads_string(cls, v): + if isinstance(v, str): + # Restituisci la stringa cosΓ¬ com'Γ¨ (per la serializzazione) + return v + # Se arriva giΓ  come lista (es. da vecchie config), converti in stringa + if isinstance(v, list): + return ",".join(str(x) for x in v) + return v + + # Aggiungi property per ottenere la lista (usata nel resto del controller) + @property + def buy_spreads_list(self) -> List[float]: + return [float(x.strip()) for x in self.buy_spreads.split(",")] + + @property + def sell_spreads_list(self) -> List[float]: + return [float(x.strip()) for x in self.sell_spreads.split(",")] + @field_validator("candles_trading_pair", mode="before") + @classmethod + def set_candles_trading_pair(cls, v, validation_info: ValidationInfo): + if v is None or v == "": + cp = validation_info.data.get("connector_pair_maker") + if cp and hasattr(cp, "trading_pair"): + return cp.trading_pair + return "SOL-USDT" + return v + + @field_validator("buy_spreads", "sell_spreads", mode="before") + @classmethod + def parse_spreads(cls, v): + if isinstance(v, str): + return [float(x.strip()) for x in v.split(",")] + return v + + def update_markets(self, markets: dict) -> dict: + for cp in [self.connector_pair_maker, self.connector_pair_hedge]: + if cp.connector_name not in markets: + markets[cp.connector_name] = set() + markets[cp.connector_name].add(cp.trading_pair) + return markets + + +class DeltaNeutralMM(ControllerBase): + """Delta Neutral MM execution engine.""" + + def __init__(self, config: DeltaNeutralMMConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + self.max_records = max( + config.macd_slow, config.macd_fast, + config.macd_signal, config.natr_length + ) + 100 + + # Configure hedge perp connector + if is_perpetual_connector(config.connector_pair_hedge.connector_name): + try: + connector = self.market_data_provider.get_connector( + config.connector_pair_hedge.connector_name + ) + connector.set_position_mode(config.position_mode) + connector.set_leverage(config.connector_pair_hedge.trading_pair, config.leverage) + except Exception as e: + self.logger().warning(f"Could not configure hedge connector: {e}") + + self.processed_data = { + "reference_price": None, + "spread_multiplier": None, + "natr": None, + "macd_signal_value": None, + "net_delta_quote": Decimal("0"), + "hedge_position_quote": Decimal("0"), + "combined_pnl_pct": Decimal("0"), + "active_maker_orders": [], + "active_hedge_positions": [], + } + + # Track hedge positions with their creation timestamp + # Chiave = executor_id (string), valore = timestamp (float) + self._hedge_positions_timestamp: Dict[str, float] = {} + + # ------------------------------------------------------------------ + # MAIN LOGIC + # ------------------------------------------------------------------ + + def determine_executor_actions(self) -> List[ExecutorAction]: + actions: List[ExecutorAction] = [] + + if self.processed_data["reference_price"] is None: + return actions + + # 1. Emergency exit + if self._should_emergency_exit(): + self.logger().warning( + f"Emergency exit β€” combined PnL: " + f"{self.processed_data['combined_pnl_pct']:.4%}" + ) + actions.extend(self._close_all()) + return actions + + # 2. Check hedge position timeout (traccia executor esistenti) + if self.config.hedge_position_timeout > 0: + self._track_existing_hedge_positions() + actions.extend(self._check_hedge_timeout()) + + # 3. Emergency delta cap + net_delta = self.processed_data["net_delta_quote"] + if abs(net_delta) > self.config.max_delta_quote: + self.logger().warning( + f"Delta cap breached: {net_delta:.2f} USDT β€” force hedging" + ) + actions.extend(self._place_hedge_order(net_delta)) + return actions + + # 4. Normal hedge + if abs(net_delta) > self.config.hedge_threshold_quote: + actions.extend(self._place_hedge_order(net_delta)) + + # 5. Refresh stale maker orders + actions.extend(self._refresh_stale_maker_orders()) + + # 6. Place new maker orders + actions.extend(self._place_maker_orders()) + + return actions + + def _track_existing_hedge_positions(self): + """Traccia gli executor hedge esistenti che non sono ancora nel dizionario.""" + for executor in self.executors_info: + if (executor.connector_name == self.config.connector_pair_hedge.connector_name and + executor.id not in self._hedge_positions_timestamp and + executor.is_active): + self._hedge_positions_timestamp[executor.id] = self.market_data_provider.time() + self.logger().debug(f"Tracking hedge position {executor.id}") + + def _check_hedge_timeout(self) -> List[ExecutorAction]: + """Close hedge positions that have been open too long.""" + actions = [] + now = self.market_data_provider.time() + + for executor_id, timestamp in list(self._hedge_positions_timestamp.items()): + if now - timestamp > self.config.hedge_position_timeout: + executor = next( + (e for e in self.executors_info if e.id == executor_id), + None + ) + if executor and executor.is_active: + self.logger().info(f"Closing hedge position {executor_id} due to timeout ({now - timestamp:.0f}s > {self.config.hedge_position_timeout}s)") + actions.append(StopExecutorAction( + controller_id=self.config.id, + executor_id=executor_id, + keep_position=False + )) + # Rimuovi dal tracking anche se l'executor non esiste piΓΉ + del self._hedge_positions_timestamp[executor_id] + + return actions + + async def update_processed_data(self): + candles = self.market_data_provider.get_candles_df( + connector_name=self.config.candles_connector, + trading_pair=self.config.candles_trading_pair, + interval=self.config.interval, + max_records=self.max_records + ) + if candles.empty: + return + + # NATR + natr = ta.natr( + candles["high"], candles["low"], candles["close"], + length=self.config.natr_length + ) / 100 + + # MACD + macd_output = ta.macd( + candles["close"], + fast=self.config.macd_fast, + slow=self.config.macd_slow, + signal=self.config.macd_signal + ) + macd_col = f"MACD_{self.config.macd_fast}_{self.config.macd_slow}_{self.config.macd_signal}" + macdh_col = f"MACDh_{self.config.macd_fast}_{self.config.macd_slow}_{self.config.macd_signal}" + + macd = macd_output[macd_col] + if macd.std() != 0: + macd_norm = -(macd - macd.mean()) / macd.std() + else: + macd_norm = macd * 0 + + macdh = macd_output[macdh_col] + macdh_signal = macdh.apply(lambda x: 1 if x > 0 else -1) + + # Price shift + max_shift = natr / 2 + price_multiplier = ((0.5 * macd_norm + 0.5 * macdh_signal) * max_shift).iloc[-1] + + reference_price = Decimal(str(candles["close"].iloc[-1])) * ( + Decimal("1") + Decimal(str(price_multiplier)) + ) + spread_multiplier = Decimal(str(natr.iloc[-1])) + + net_delta_quote = self._compute_net_delta_quote() + combined_pnl_pct = self._compute_combined_pnl_pct() + + active_maker = self.filter_executors( + self.executors_info, + filter_func=lambda e: ( + e.connector_name == self.config.connector_pair_maker.connector_name + and e.is_active + ) + ) + active_hedge = self.filter_executors( + self.executors_info, + filter_func=lambda e: ( + e.connector_name == self.config.connector_pair_hedge.connector_name + and e.is_active + ) + ) + + self.processed_data.update({ + "reference_price": reference_price, + "spread_multiplier": spread_multiplier, + "natr": spread_multiplier, + "macd_signal_value": float(price_multiplier), + "net_delta_quote": net_delta_quote, + "combined_pnl_pct": combined_pnl_pct, + "active_maker_orders": active_maker, + "active_hedge_positions": active_hedge, + }) + + # ------------------------------------------------------------------ + # MAKER ORDERS + # ------------------------------------------------------------------ + + def _place_maker_orders(self) -> List[ExecutorAction]: + actions = [] + ref_price = self.processed_data["reference_price"] + spread_mult = self.processed_data["spread_multiplier"] + + active_maker = self.processed_data["active_maker_orders"] + active_buy_levels = sum( + 1 for e in active_maker + if hasattr(e, 'config') and e.config.side == TradeType.BUY and not e.is_trading + ) + active_sell_levels = sum( + 1 for e in active_maker + if hasattr(e, 'config') and e.config.side == TradeType.SELL and not e.is_trading + ) + + # Buy levels + for i, spread in enumerate(self.config.buy_spreads_list): + if active_buy_levels >= len(self.config.buy_spreads_list): + break + price = ref_price * (Decimal("1") - Decimal(str(spread)) * spread_mult) + amount = self.config.order_amount_quote / price + + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + level_id=f"buy_{i}", + connector_name=self.config.connector_pair_maker.connector_name, + trading_pair=self.config.connector_pair_maker.trading_pair, + side=TradeType.BUY, + entry_price=price, + amount=amount, + triple_barrier_config=TripleBarrierConfig( + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + take_profit=Decimal(str(spread)) * spread_mult * self.config.maker_tp_multiplier, + ), + leverage=1, + ) + )) + + # Sell levels + for i, spread in enumerate(self.config.sell_spreads_list): + if active_sell_levels >= len(self.config.sell_spreads_list): + break + price = ref_price * (Decimal("1") + Decimal(str(spread)) * spread_mult) + amount = self.config.order_amount_quote / price + + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + level_id=f"sell_{i}", + connector_name=self.config.connector_pair_maker.connector_name, + trading_pair=self.config.connector_pair_maker.trading_pair, + side=TradeType.SELL, + entry_price=price, + amount=amount, + triple_barrier_config=TripleBarrierConfig( + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + take_profit=Decimal(str(spread)) * spread_mult * self.config.maker_tp_multiplier, + ), + leverage=1, + ) + )) + + return actions + + def _refresh_stale_maker_orders(self) -> List[ExecutorAction]: + now = self.market_data_provider.time() + stale = self.filter_executors( + self.executors_info, + filter_func=lambda e: ( + e.connector_name == self.config.connector_pair_maker.connector_name + and e.is_active + and not e.is_trading + and (now - e.timestamp) > self.config.order_refresh_time + ) + ) + return [ + StopExecutorAction( + controller_id=self.config.id, + executor_id=e.id, + keep_position=False + ) + for e in stale + ] + + # ------------------------------------------------------------------ + # HEDGE ORDERS + # ------------------------------------------------------------------ + + def _place_hedge_order(self, net_delta_quote: Decimal) -> List[ExecutorAction]: + hedge_side = TradeType.SELL if net_delta_quote > Decimal("0") else TradeType.BUY + hedge_price = self.market_data_provider.get_price_by_type( + connector_name=self.config.connector_pair_hedge.connector_name, + trading_pair=self.config.connector_pair_hedge.trading_pair, + price_type=PriceType.MidPrice + ) + hedge_amount = abs(net_delta_quote) / hedge_price + + self.logger().info( + f"Hedging delta: {net_delta_quote:+.2f} USDT β†’ " + f"{hedge_side.name} {hedge_amount:.4f} on " + f"{self.config.connector_pair_hedge.connector_name}" + ) + + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_pair_hedge.connector_name, + trading_pair=self.config.connector_pair_hedge.trading_pair, + side=hedge_side, + amount=hedge_amount, + position_action=PositionAction.OPEN, + execution_strategy=ExecutionStrategy.MARKET, + leverage=self.config.leverage, + ) + + action = CreateExecutorAction(controller_id=self.config.id, executor_config=config) + + # NOTA: Non possiamo registrare il timestamp qui perchΓ© l'executor_id non esiste ancora. + # Il tracking avverrΓ  in _track_existing_hedge_positions() nel prossimo ciclo. + + return [action] + + # ------------------------------------------------------------------ + # UTILS + # ------------------------------------------------------------------ + + def _compute_net_delta_quote(self) -> Decimal: + maker_delta = Decimal("0") + for pos in self.positions_held: + if pos.connector_name == self.config.connector_pair_maker.connector_name: + if pos.side == TradeType.BUY: + maker_delta += pos.amount_quote + else: + maker_delta -= pos.amount_quote + + hedge_delta = Decimal("0") + for pos in self.positions_held: + if pos.connector_name == self.config.connector_pair_hedge.connector_name: + if pos.side == TradeType.BUY: + hedge_delta += pos.amount_quote + else: + hedge_delta -= pos.amount_quote + + return maker_delta + hedge_delta + + def _compute_combined_pnl_pct(self) -> Decimal: + total_value = sum(p.amount_quote for p in self.positions_held) + total_pnl = sum(p.global_pnl_quote for p in self.positions_held) + if total_value == Decimal("0"): + return Decimal("0") + return total_pnl / total_value + + def _should_emergency_exit(self) -> bool: + pnl = self.processed_data.get("combined_pnl_pct", Decimal("0")) + return pnl < -self.config.sl_global or pnl > self.config.tp_global + + def _close_all(self) -> List[ExecutorAction]: + actions = [] + for pos in self.positions_held: + if pos.amount <= Decimal("0"): + continue + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=pos.connector_name, + trading_pair=pos.trading_pair, + side=TradeType.BUY if pos.side == TradeType.SELL else TradeType.SELL, + amount=pos.amount, + position_action=PositionAction.CLOSE, + execution_strategy=ExecutionStrategy.MARKET, + leverage=self.config.leverage, + ) + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=config + )) + return actions + + def to_format_status(self) -> List[str]: + d = self.processed_data + ref = d.get("reference_price") or Decimal("0") + natr = d.get("natr") or Decimal("0") + macd_v = d.get("macd_signal_value") or 0.0 + active_maker = d.get("active_maker_orders", []) + active_hedge = d.get("active_hedge_positions", []) + + placed_buys = sum(1 for e in active_maker if hasattr(e, 'config') and e.config.side == TradeType.BUY and not e.is_trading) + placed_sells = sum(1 for e in active_maker if hasattr(e, 'config') and e.config.side == TradeType.SELL and not e.is_trading) + filled_maker = sum(1 for e in active_maker if e.is_trading) + + # Mostra timeout info + timeout_info = f" (timeout: {self.config.hedge_position_timeout}s)" if self.config.hedge_position_timeout > 0 else " (timeout disabled)" + + return [f""" +Delta Neutral Market Making +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Maker : {self.config.connector_pair_maker.connector_name} +Hedge : {self.config.connector_pair_hedge.connector_name} + +MACD signal : {macd_v:+.6f} β”‚ NATR spread: {natr:.4%} +Reference px : {ref:.4f} + +Maker orders : {placed_buys} buys placed β”‚ {placed_sells} sells placed β”‚ {filled_maker} filled +Active hedge : {len(active_hedge)} positions{timeout_info} + +Net delta : {d['net_delta_quote']:+.2f} USDT + threshold : Β±{self.config.hedge_threshold_quote} USDT + max cap : Β±{self.config.max_delta_quote} USDT + +Combined PnL : {d['combined_pnl_pct']:.4%} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +"""] + + def get_candles_config(self) -> List[CandlesConfig]: + return [CandlesConfig( + connector=self.config.candles_connector, + trading_pair=self.config.candles_trading_pair, + interval=self.config.interval, + max_records=self.max_records + )] diff --git a/bots/controllers/generic/funding_rate_arb.py b/bots/controllers/generic/funding_rate_arb.py new file mode 100644 index 00000000..aece8b51 --- /dev/null +++ b/bots/controllers/generic/funding_rate_arb.py @@ -0,0 +1,417 @@ +from decimal import Decimal +from typing import Dict, List, Optional + +from pydantic import Field + +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PriceType, TradeType +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair, PositionSummary +from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig +from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig, TripleBarrierConfig +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction + + +def is_spot_connector(connector_name: str) -> bool: + """Detect if connector is spot by checking name conventions.""" + name_lower = connector_name.lower() + # Se contiene "_perpetual" o "perp" Γ¨ perpetual + if "_perpetual" in name_lower or "perp" in name_lower: + return False + # Altrimenti Γ¨ spot (o margin, ma margin ha funding) + # Per margin bisognerebbe distinguere, ma di default trattiamo come perp + return "margin" not in name_lower + + +class FundingRateArbConfig(ControllerConfigBase): + """ + Funding Rate Arbitrage β€” multi-exchange, any connector combination. + + Supports: + β€’ Perp ↔ Perp : full delta neutral + β€’ Spot ↔ Perp : cash-and-carry + """ + controller_type: str = "generic" + controller_name: str = "funding_rate_arb" + + connector_pair_a: ConnectorPair = ConnectorPair( + connector_name="kucoin_perpetual", + trading_pair="SOL-USDT" + ) + connector_pair_b: ConnectorPair = ConnectorPair( + connector_name="hyperliquid_perpetual", + trading_pair="SOL-USDT" + ) + + # ========== RENDERE CONFIGURABILE VIA YAML ========== + # Funding intervals in hours (se non specificato, usa default 8) + funding_interval_a_hours: Optional[int] = Field( + default=None, + json_schema_extra={"prompt": "Funding interval for exchange A in hours (leave empty for auto-detect): ", + "prompt_on_new": True} + ) + funding_interval_b_hours: Optional[int] = Field( + default=None, + json_schema_extra={"prompt": "Funding interval for exchange B in hours (leave empty for auto-detect): ", + "prompt_on_new": True} + ) + # ==================================================== + + entry_threshold: Decimal = Decimal("0.000025") + exit_threshold: Decimal = Decimal("0.000005") + + total_amount_quote: Decimal = Decimal("100") + leverage: int = 1 + position_mode: PositionMode = PositionMode.HEDGE + + sl_global: Decimal = Decimal("0.03") + tp_global: Decimal = Decimal("0.05") + + funding_check_interval: int = 300 + executor_refresh_time: int = 60 + + @property + def position_amount_quote(self) -> Decimal: + return self.total_amount_quote / Decimal("2") + + @property + def mode(self) -> str: + if is_spot_connector(self.connector_pair_a.connector_name) or \ + is_spot_connector(self.connector_pair_b.connector_name): + return "spot+perp (cash-and-carry)" + return "perp+perp (delta neutral)" + + def update_markets(self, markets: dict) -> dict: + for cp in [self.connector_pair_a, self.connector_pair_b]: + if cp.connector_name not in markets: + markets[cp.connector_name] = set() + markets[cp.connector_name].add(cp.trading_pair) + return markets + + +class FundingRateArb(ControllerBase): + """Funding Rate Arbitrage execution engine.""" + + def __init__(self, config: FundingRateArbConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + + # Cache per funding intervals (ottenuti dinamicamente dall'exchange) + self._funding_interval_cache_a: Optional[int] = None + self._funding_interval_cache_b: Optional[int] = None + + for cp in [self.config.connector_pair_a, self.config.connector_pair_b]: + if not is_spot_connector(cp.connector_name): + try: + connector = self.market_data_provider.get_connector(cp.connector_name) + connector.set_position_mode(self.config.position_mode) + connector.set_leverage(cp.trading_pair, self.config.leverage) + except Exception as e: + self.logger().warning(f"Could not configure {cp.connector_name}: {e}") + + self.processed_data = { + "funding_rate_a_raw": Decimal("0"), + "funding_rate_b_raw": Decimal("0"), + "funding_rate_a_hourly": Decimal("0"), + "funding_rate_b_hourly": Decimal("0"), + "net_rate_hourly": Decimal("0"), + "apy_estimate_pct": Decimal("0"), + "signal": 0, + "pair_pnl_pct": Decimal("0"), + "last_check_time": 0, + } + + # ------------------------------------------------------------------ + # FUNDING INTERVAL - OTTENUTO DINAMICAMENTE DALL'EXCHANGE + # ------------------------------------------------------------------ + + async def _get_funding_interval_hours(self, connector_pair: ConnectorPair) -> int: + """ + Get funding interval from exchange dynamically. + Returns hours between funding payments. + """ + # Usa valore configurato se presente + if connector_pair == self.config.connector_pair_a: + if self.config.funding_interval_a_hours is not None: + return self.config.funding_interval_a_hours + if self._funding_interval_cache_a is not None: + return self._funding_interval_cache_a + else: + if self.config.funding_interval_b_hours is not None: + return self.config.funding_interval_b_hours + if self._funding_interval_cache_b is not None: + return self._funding_interval_cache_b + + try: + connector = self.market_data_provider.get_connector(connector_pair.connector_name) + # Prova a ottenere funding info + funding_info = await connector.get_funding_info(connector_pair.trading_pair) + # Alcuni exchange forniscono l'intervallo, altri no + if hasattr(funding_info, 'interval_hours') and funding_info.interval_hours: + interval = int(funding_info.interval_hours) + elif hasattr(funding_info, 'rate_interval') and funding_info.rate_interval: + # Es: "8 hours" β†’ 8 + interval = int(funding_info.rate_interval.split()[0]) + else: + # Default: 8 ore per la maggior parte degli exchange + interval = 8 + # Hyperliquid Γ¨ 1 ora + if "hyperliquid" in connector_pair.connector_name.lower(): + interval = 1 + except Exception as e: + self.logger().warning(f"Cannot get funding interval for {connector_pair}: {e}") + # Fallback basato su nome + if "hyperliquid" in connector_pair.connector_name.lower(): + interval = 1 + else: + interval = 8 + + # Cache + if connector_pair == self.config.connector_pair_a: + self._funding_interval_cache_a = interval + else: + self._funding_interval_cache_b = interval + + return interval + + # ------------------------------------------------------------------ + # MAIN LOGIC + # ------------------------------------------------------------------ + + def determine_executor_actions(self) -> List[ExecutorAction]: + actions: List[ExecutorAction] = [] + + if self._should_emergency_exit(): + self.logger().warning( + f"Emergency exit β€” PnL: {self.processed_data['pair_pnl_pct']:.4%}" + ) + for position in self.positions_held: + actions.extend(self._close_position(position)) + return actions + + actions.extend(self._refresh_stale_executors()) + + signal = self.processed_data["signal"] + current_signal = self._get_current_position_signal() + + if current_signal != 0 and (signal == 0 or signal != current_signal): + self.logger().info( + f"Closing β€” net/h: {self.processed_data['net_rate_hourly']:.6%} " + f"| signal {current_signal} β†’ {signal}" + ) + for position in self.positions_held: + actions.extend(self._close_position(position)) + return actions + + if signal != 0 and current_signal == 0 and len(self.positions_held) == 0: + actions.extend(self._open_position(signal)) + + return actions + + def _refresh_stale_executors(self) -> List[ExecutorAction]: + now = self.market_data_provider.time() + stale = self.filter_executors( + self.executors_info, + filter_func=lambda e: ( + e.is_active + and not e.is_trading + and (now - e.timestamp) > self.config.executor_refresh_time + ) + ) + return [ + StopExecutorAction( + controller_id=self.config.id, + executor_id=e.id, + keep_position=False + ) + for e in stale + ] + + async def update_processed_data(self): + now = self.market_data_provider.time() + if now - self.processed_data.get("last_check_time", 0) < self.config.funding_check_interval: + return + + raw_a = await self._get_funding_rate_raw(self.config.connector_pair_a) + raw_b = await self._get_funding_rate_raw(self.config.connector_pair_b) + + interval_a = await self._get_funding_interval_hours(self.config.connector_pair_a) + interval_b = await self._get_funding_interval_hours(self.config.connector_pair_b) + + hourly_a = self._normalize_to_hourly(raw_a, interval_a) + hourly_b = self._normalize_to_hourly(raw_b, interval_b) + net = hourly_a - hourly_b + + apy = net * Decimal("24") * Decimal("365") * Decimal("100") + + if net > self.config.entry_threshold: + signal = 1 + elif net < -self.config.entry_threshold: + signal = -1 + elif abs(net) < self.config.exit_threshold: + signal = 0 + else: + signal = self.processed_data.get("signal", 0) + + self.processed_data.update({ + "funding_rate_a_raw": raw_a, + "funding_rate_b_raw": raw_b, + "funding_rate_a_hourly": hourly_a, + "funding_rate_b_hourly": hourly_b, + "interval_a_hours": interval_a, + "interval_b_hours": interval_b, + "net_rate_hourly": net, + "apy_estimate_pct": apy, + "signal": signal, + "pair_pnl_pct": self._compute_pair_pnl_pct(), + "last_check_time": now, + }) + + self.logger().info( + f"[{self.config.mode}] " + f"A({self.config.connector_pair_a.connector_name}): " + f"raw={raw_a:.6%} interval={interval_a}h β†’ {hourly_a:.6%}/h | " + f"B({self.config.connector_pair_b.connector_name}): " + f"raw={raw_b:.6%} interval={interval_b}h β†’ {hourly_b:.6%}/h | " + f"net={net:+.6%}/h | APY={apy:.1f}% | signal={signal:+d}" + ) + + # ------------------------------------------------------------------ + # POSITION HELPERS + # ------------------------------------------------------------------ + + def _open_position(self, signal: int) -> List[ExecutorAction]: + actions = [] + a_spot = is_spot_connector(self.config.connector_pair_a.connector_name) + b_spot = is_spot_connector(self.config.connector_pair_b.connector_name) + + if a_spot or b_spot: + spot_cp = self.config.connector_pair_a if a_spot else self.config.connector_pair_b + perp_cp = self.config.connector_pair_b if a_spot else self.config.connector_pair_a + legs = [(spot_cp, TradeType.BUY), (perp_cp, TradeType.SELL)] + elif signal == 1: + legs = [ + (self.config.connector_pair_a, TradeType.SELL), + (self.config.connector_pair_b, TradeType.BUY), + ] + else: + legs = [ + (self.config.connector_pair_a, TradeType.BUY), + (self.config.connector_pair_b, TradeType.SELL), + ] + + for cp, side in legs: + price = self._get_mid_price(cp) + amount = self.config.position_amount_quote / price + + config = PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=cp.connector_name, + trading_pair=cp.trading_pair, + side=side, + entry_price=price, + amount=amount, + triple_barrier_config=TripleBarrierConfig( + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + ), + leverage=self.config.leverage, + ) + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=config + )) + + self.logger().info( + f"Opening [{self.config.mode}] | signal={signal:+d} | " + f"APY={self.processed_data['apy_estimate_pct']:.1f}% | " + f"size/leg={self.config.position_amount_quote} USDT" + ) + return actions + + def _close_position(self, position: PositionSummary) -> List[ExecutorAction]: + if position.amount <= Decimal("0"): + return [] + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=position.connector_name, + trading_pair=position.trading_pair, + side=TradeType.BUY if position.side == TradeType.SELL else TradeType.SELL, + amount=position.amount, + position_action=PositionAction.CLOSE, + execution_strategy=ExecutionStrategy.MARKET, + leverage=self.config.leverage, + ) + return [CreateExecutorAction(controller_id=self.config.id, executor_config=config)] + + # ------------------------------------------------------------------ + # RATE UTILITIES + # ------------------------------------------------------------------ + + async def _get_funding_rate_raw(self, connector_pair: ConnectorPair) -> Decimal: + if is_spot_connector(connector_pair.connector_name): + return Decimal("0") + try: + connector = self.market_data_provider.get_connector(connector_pair.connector_name) + funding_info = await connector.get_funding_info(connector_pair.trading_pair) + return Decimal(str(funding_info.rate)) + except Exception as e: + self.logger().warning(f"Cannot fetch funding rate for {connector_pair}: {e}") + return Decimal("0") + + def _normalize_to_hourly(self, raw_rate: Decimal, interval_hours: int) -> Decimal: + if interval_hours <= 0: + return Decimal("0") + return raw_rate / Decimal(str(interval_hours)) + + # ------------------------------------------------------------------ + # UTILS + # ------------------------------------------------------------------ + + def _get_current_position_signal(self) -> int: + pos_a = next( + (p for p in self.positions_held + if p.connector_name == self.config.connector_pair_a.connector_name + and p.trading_pair == self.config.connector_pair_a.trading_pair), + None + ) + if pos_a is None: + return 0 + return 1 if pos_a.side == TradeType.SELL else -1 + + def _should_emergency_exit(self) -> bool: + pnl = self.processed_data.get("pair_pnl_pct", Decimal("0")) + return pnl < -self.config.sl_global or pnl > self.config.tp_global + + def _compute_pair_pnl_pct(self) -> Decimal: + total_value = sum(p.amount_quote for p in self.positions_held) + total_pnl = sum(p.global_pnl_quote for p in self.positions_held) + if total_value == Decimal("0"): + return Decimal("0") + return total_pnl / total_value + + def _get_mid_price(self, connector_pair: ConnectorPair) -> Decimal: + return self.market_data_provider.get_price_by_type( + connector_name=connector_pair.connector_name, + trading_pair=connector_pair.trading_pair, + price_type=PriceType.MidPrice + ) + + def to_format_status(self) -> List[str]: + d = self.processed_data + return [f""" +Funding Rate Arbitrage [{self.config.mode}] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Exchange A : {self.config.connector_pair_a.connector_name} (interval: {d.get('interval_a_hours', '?')}h) + raw={d['funding_rate_a_raw']:.6%} β†’ {d['funding_rate_a_hourly']:.6%}/h +Exchange B : {self.config.connector_pair_b.connector_name} (interval: {d.get('interval_b_hours', '?')}h) + raw={d['funding_rate_b_raw']:.6%} β†’ {d['funding_rate_b_hourly']:.6%}/h +Net rate : {d['net_rate_hourly']:+.6%}/h β”‚ APY: {d['apy_estimate_pct']:.1f}% +Signal : {d['signal']:+d} β”‚ Combined PnL: {d['pair_pnl_pct']:.4%} +Thresholds : entry={self.config.entry_threshold:.6%}/h exit={self.config.exit_threshold:.6%}/h +Capital : {self.config.total_amount_quote} USDT total ({self.config.position_amount_quote} per leg) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +"""] + + def get_candles_config(self) -> List[CandlesConfig]: + return [] \ No newline at end of file diff --git a/bots/controllers/generic/stat_arb_v2.py b/bots/controllers/generic/stat_arb_v2.py new file mode 100755 index 00000000..8bc1aef7 --- /dev/null +++ b/bots/controllers/generic/stat_arb_v2.py @@ -0,0 +1,658 @@ +from decimal import Decimal +from typing import List, Optional, Tuple + +import numpy as np +from sklearn.linear_model import LinearRegression +from statsmodels.tsa.stattools import adfuller + +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PriceType, TradeType +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair, PositionSummary +from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig +from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig, TripleBarrierConfig +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction + + +class StatArbConfig(ControllerConfigBase): + """ + Statistical arbitrage controller β€” v2. + + Simplified configuration: a SINGLE connector (exchange + market type) and two trading pairs. + The strategy trades the spread between two assets on the same exchange. + + YAML fields + ----------- + controller_name : stat_arb_v2 + controller_type : generic + total_amount_quote : total capital in quote currency (e.g. 1000) + + connector_name : exchange connector (e.g. "binance_perpetual") + trading_pair_dominant : first asset (e.g. "SOL-USDT") + trading_pair_hedge : second asset (e.g. "XRP-USDT") + + interval : candle interval (1m, 3m, 5m …) + lookback_period : number of candles used for regression and z-score (e.g. 300) + entry_threshold : z-score level that triggers a signal (e.g. 2.0) + + take_profit : per-executor TP as fraction of entry (e.g. 0.0008 = 0.08%) + tp_global : total pair PnL% to close all positions (e.g. 0.01 = 1%) + sl_global : total pair PnL% loss to close all positions (e.g. 0.05 = 5%) + + min_amount_quote : notional per order on dominant leg in quote (e.g. 10) + quoter_spread : offset from mid for limit entries (e.g. 0.0001) + quoter_cooldown : seconds before a filled executor is released (e.g. 30) + quoter_refresh : seconds before an unfilled order is repriced (e.g. 10) + max_orders_placed_per_side : max pending (unfilled) orders per leg (e.g. 2) + max_orders_filled_per_side : max active (filled) orders per leg (e.g. 2) + max_position_deviation : imbalance threshold that blocks one leg (e.g. 0.1 = 10%) + + leverage : leverage for perp connectors (e.g. 20) + position_mode : HEDGE or ONEWAY + + # v2 statistical quality filters + min_r_squared : minimum OLS RΒ² to allow signals (e.g. 0.70) + adf_pvalue_threshold : max ADF p-value to allow signals (e.g. 0.05) + use_dynamic_hedge_ratio : true = size hedge leg via OLS beta, false = use pos_hedge_ratio + pos_hedge_ratio : fallback hedge ratio when use_dynamic_hedge_ratio is false (e.g. 1.0) + max_dynamic_hedge_ratio : cap on dynamic ratio to avoid extreme sizing (e.g. 3.0) + min_dynamic_hedge_ratio : floor on dynamic ratio (e.g. 0.2) + """ + controller_type: str = "generic" + controller_name: str = "stat_arb_v2" + + # Unified connector (single exchange) + connector_name: str # e.g. "binance_perpetual" + + # Two trading pairs + trading_pair_dominant: str # e.g. "SOL-USDT" + trading_pair_hedge: str # e.g. "XRP-USDT" + + # Candle settings + interval: str = "1m" + lookback_period: int = 300 + + # Signal + entry_threshold: Decimal = Decimal("2.0") + + # Exit + take_profit: Decimal = Decimal("0.0008") + tp_global: Decimal = Decimal("0.01") + sl_global: Decimal = Decimal("0.05") + + # Order sizing / quoting + min_amount_quote: Decimal = Decimal("10") + quoter_spread: Decimal = Decimal("0.0001") + quoter_cooldown: int = 30 + quoter_refresh: int = 10 + max_orders_placed_per_side: int = 2 + max_orders_filled_per_side: int = 2 + max_position_deviation: Decimal = Decimal("0.1") + + # Position + leverage: int = 20 + position_mode: PositionMode = PositionMode.HEDGE + + # v2 β€” statistical quality filters + min_r_squared: float = 0.70 + adf_pvalue_threshold: float = 0.05 + use_dynamic_hedge_ratio: bool = True + pos_hedge_ratio: Decimal = Decimal("1.0") + max_dynamic_hedge_ratio: Decimal = Decimal("3.0") + min_dynamic_hedge_ratio: Decimal = Decimal("0.2") + + # --- Derived properties for internal use (compatible with original code) --- + @property + def connector_pair_dominant(self) -> ConnectorPair: + return ConnectorPair(connector_name=self.connector_name, trading_pair=self.trading_pair_dominant) + + @property + def connector_pair_hedge(self) -> ConnectorPair: + return ConnectorPair(connector_name=self.connector_name, trading_pair=self.trading_pair_hedge) + + @property + def triple_barrier_config(self) -> TripleBarrierConfig: + return TripleBarrierConfig( + take_profit=self.take_profit, + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + ) + + def update_markets(self, markets: dict) -> dict: + """Add both trading pairs under the same connector.""" + if self.connector_name not in markets: + markets[self.connector_name] = set() + markets[self.connector_name].add(self.trading_pair_dominant) + markets[self.connector_name].add(self.trading_pair_hedge) + return markets + + +class StatArb(ControllerBase): + """ + Statistical arbitrage controller β€” v2 (unified connector version). + """ + + def __init__(self, config: StatArbConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + + # Theoretical quotes are recomputed dynamically each tick when use_dynamic_hedge_ratio=True. + self.theoretical_dominant_quote = self.config.total_amount_quote * ( + Decimal("1") / (Decimal("1") + self.config.pos_hedge_ratio) + ) + self.theoretical_hedge_quote = self.config.total_amount_quote * ( + self.config.pos_hedge_ratio / (Decimal("1") + self.config.pos_hedge_ratio) + ) + + self.processed_data = { + "dominant_price": None, + "hedge_price": None, + "spread": None, + "z_score": None, + "hedge_ratio": None, + "position_dominant": Decimal("0"), + "position_hedge": Decimal("0"), + "active_orders_dominant": [], + "active_orders_hedge": [], + "pair_pnl": Decimal("0"), + "signal": 0, + "r_squared": None, + "adf_pvalue": None, + "half_life": None, + "dynamic_hedge_ratio": self.config.pos_hedge_ratio, + "signal_blocked_reason": "initializing", + } + + self.max_records = self.config.lookback_period + 20 + + # Configure connector if perpetual + if "_perpetual" in self.config.connector_name: + connector = self.market_data_provider.get_connector(self.config.connector_name) + connector.set_position_mode(self.config.position_mode) + connector.set_leverage(self.config.trading_pair_dominant, self.config.leverage) + connector.set_leverage(self.config.trading_pair_hedge, self.config.leverage) + + # ------------------------------------------------------------------------- + # MAIN LOOP (identical to previous v2, but uses config.connector_pair_* properties) + # ------------------------------------------------------------------------- + + def determine_executor_actions(self) -> List[ExecutorAction]: + actions: List[ExecutorAction] = [] + if self.processed_data["pair_pnl_pct"] > self.config.tp_global or \ + self.processed_data["pair_pnl_pct"] < -self.config.sl_global: + for position in self.positions_held: + actions.extend(self.get_executors_to_reduce_position(position)) + return actions + elif self.processed_data["signal"] != 0: + actions.extend(self.get_executors_to_quote()) + actions.extend(self.get_executors_to_reduce_position_on_opposite_signal()) + + actions.extend(self.get_executors_to_keep_position()) + actions.extend(self.get_executors_to_refresh()) + return actions + + # ------------------------------------------------------------------------- + # SIGNAL / DATA UPDATE + # ------------------------------------------------------------------------- + + async def update_processed_data(self): + result = self.get_spread_and_z_score() + if result is None: + self.processed_data["signal"] = 0 + self.processed_data["signal_blocked_reason"] = "insufficient candle data" + return + + spread, z_score, beta, r_squared, adf_pvalue, half_life = result + + signal_blocked_reason = None + + if r_squared < self.config.min_r_squared: + signal = 0 + signal_blocked_reason = f"RΒ²={r_squared:.3f} < min={self.config.min_r_squared}" + self.logger().warning(f"[StatArb] Signal suppressed: {signal_blocked_reason}") + + elif adf_pvalue > self.config.adf_pvalue_threshold: + signal = 0 + signal_blocked_reason = f"ADF p-value={adf_pvalue:.3f} > threshold={self.config.adf_pvalue_threshold} (spread not stationary)" + self.logger().warning(f"[StatArb] Signal suppressed: {signal_blocked_reason}") + + else: + entry_threshold = float(self.config.entry_threshold) + if z_score > entry_threshold: + signal = 1 + dominant_side, hedge_side = TradeType.BUY, TradeType.SELL + elif z_score < -entry_threshold: + signal = -1 + dominant_side, hedge_side = TradeType.SELL, TradeType.BUY + else: + signal = 0 + dominant_side, hedge_side = None, None + + # Dynamic hedge ratio + if self.config.use_dynamic_hedge_ratio and beta > 0: + raw_ratio = Decimal(str(round(1.0 / beta, 6))) + effective_hedge_ratio = max( + self.config.min_dynamic_hedge_ratio, + min(self.config.max_dynamic_hedge_ratio, raw_ratio) + ) + else: + effective_hedge_ratio = self.config.pos_hedge_ratio + + theoretical_dominant_quote = self.config.total_amount_quote * ( + Decimal("1") / (Decimal("1") + effective_hedge_ratio) + ) + theoretical_hedge_quote = self.config.total_amount_quote * ( + effective_hedge_ratio / (Decimal("1") + effective_hedge_ratio) + ) + self.theoretical_dominant_quote = theoretical_dominant_quote + self.theoretical_hedge_quote = theoretical_hedge_quote + + dominant_price, hedge_price = self.get_pairs_prices() + + if signal != 0: + dominant_side_for_lookup = dominant_side + hedge_side_for_lookup = hedge_side + else: + dominant_side_for_lookup = None + hedge_side_for_lookup = None + + positions_dominant = next( + (p for p in self.positions_held + if p.connector_name == self.config.connector_name + and p.trading_pair == self.config.trading_pair_dominant + and (p.side == dominant_side_for_lookup or dominant_side_for_lookup is None)), + None + ) + positions_hedge = next( + (p for p in self.positions_held + if p.connector_name == self.config.connector_name + and p.trading_pair == self.config.trading_pair_hedge + and (p.side == hedge_side_for_lookup or hedge_side_for_lookup is None)), + None + ) + + position_dominant_quote = positions_dominant.amount_quote if positions_dominant else Decimal("0") + position_hedge_quote = positions_hedge.amount_quote if positions_hedge else Decimal("0") + position_dominant_pnl_quote = positions_dominant.global_pnl_quote if positions_dominant else Decimal("0") + position_hedge_pnl_quote = positions_hedge.global_pnl_quote if positions_hedge else Decimal("0") + pair_pnl_pct = ( + (position_dominant_pnl_quote + position_hedge_pnl_quote) + / (position_dominant_quote + position_hedge_quote) + if (position_dominant_quote + position_hedge_quote) != 0 + else Decimal("0") + ) + + executors_dominant_placed, executors_dominant_filled = self.get_executors_dominant() + executors_hedge_placed, executors_hedge_filled = self.get_executors_hedge() + + min_price_dominant = Decimal(str(min(e.config.entry_price for e in executors_dominant_placed))) if executors_dominant_placed else None + max_price_dominant = Decimal(str(max(e.config.entry_price for e in executors_dominant_placed))) if executors_dominant_placed else None + min_price_hedge = Decimal(str(min(e.config.entry_price for e in executors_hedge_placed))) if executors_hedge_placed else None + max_price_hedge = Decimal(str(max(e.config.entry_price for e in executors_hedge_placed))) if executors_hedge_placed else None + + active_amount_dominant = Decimal(str(sum(e.filled_amount_quote for e in executors_dominant_filled))) + active_amount_hedge = Decimal(str(sum(e.filled_amount_quote for e in executors_hedge_filled))) + + dominant_gap = theoretical_dominant_quote - position_dominant_quote - active_amount_dominant + hedge_gap = theoretical_hedge_quote - position_hedge_quote - active_amount_hedge + imbalance = position_dominant_quote - position_hedge_quote + imbalance_scaled = position_dominant_quote - position_hedge_quote * effective_hedge_ratio + imbalance_scaled_pct = ( + imbalance_scaled / position_dominant_quote + if position_dominant_quote != Decimal("0") + else Decimal("0") + ) + filter_connector_pair = None + if imbalance_scaled_pct > self.config.max_position_deviation: + filter_connector_pair = self.config.connector_pair_dominant + elif imbalance_scaled_pct < -self.config.max_position_deviation: + filter_connector_pair = self.config.connector_pair_hedge + + self.processed_data.update({ + "dominant_price": Decimal(str(dominant_price)), + "hedge_price": Decimal(str(hedge_price)), + "spread": Decimal(str(spread)), + "z_score": Decimal(str(z_score)), + "dominant_gap": Decimal(str(dominant_gap)), + "hedge_gap": Decimal(str(hedge_gap)), + "position_dominant_quote": position_dominant_quote, + "position_hedge_quote": position_hedge_quote, + "active_amount_dominant": active_amount_dominant, + "active_amount_hedge": active_amount_hedge, + "signal": signal, + "imbalance": Decimal(str(imbalance)), + "imbalance_scaled_pct": Decimal(str(imbalance_scaled_pct)), + "filter_connector_pair": filter_connector_pair, + "min_price_dominant": min_price_dominant if min_price_dominant is not None else Decimal(str(dominant_price)), + "max_price_dominant": max_price_dominant if max_price_dominant is not None else Decimal(str(dominant_price)), + "min_price_hedge": min_price_hedge if min_price_hedge is not None else Decimal(str(hedge_price)), + "max_price_hedge": max_price_hedge if max_price_hedge is not None else Decimal(str(hedge_price)), + "executors_dominant_filled": executors_dominant_filled, + "executors_hedge_filled": executors_hedge_filled, + "executors_dominant_placed": executors_dominant_placed, + "executors_hedge_placed": executors_hedge_placed, + "pair_pnl_pct": pair_pnl_pct, + "alpha": self.processed_data.get("alpha", 0), + "beta": beta, + "r_squared": r_squared, + "adf_pvalue": adf_pvalue, + "half_life": half_life, + "dynamic_hedge_ratio": effective_hedge_ratio, + "theoretical_dominant_quote": theoretical_dominant_quote, + "theoretical_hedge_quote": theoretical_hedge_quote, + "signal_blocked_reason": signal_blocked_reason, + }) + + # ------------------------------------------------------------------------- + # STATISTICAL CORE (unchanged from v2) + # ------------------------------------------------------------------------- + + def get_spread_and_z_score(self) -> Optional[Tuple]: + dominant_df = self.market_data_provider.get_candles_df( + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair_dominant, + interval=self.config.interval, + max_records=self.max_records + ) + hedge_df = self.market_data_provider.get_candles_df( + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair_hedge, + interval=self.config.interval, + max_records=self.max_records + ) + + if dominant_df.empty or hedge_df.empty: + self.logger().warning("[StatArb] Empty candle data") + return None + + dominant_prices = dominant_df['close'].values + hedge_prices = hedge_df['close'].values + min_length = min(len(dominant_prices), len(hedge_prices)) + + if min_length < self.config.lookback_period: + self.logger().warning(f"[StatArb] Not enough data: need {self.config.lookback_period}, have {min_length}") + return None + + dominant_prices = np.array(dominant_prices[-self.config.lookback_period:], dtype=float) + hedge_prices = np.array(hedge_prices[-self.config.lookback_period:], dtype=float) + + dominant_pct = np.diff(dominant_prices) / dominant_prices[:-1] + hedge_pct = np.diff(hedge_prices) / hedge_prices[:-1] + dominant_cum = np.cumprod(dominant_pct + 1) + hedge_cum = np.cumprod(hedge_pct + 1) + dominant_cum = dominant_cum / dominant_cum[0] + hedge_cum = hedge_cum / hedge_cum[0] + + reg = LinearRegression().fit(dominant_cum.reshape(-1, 1), hedge_cum) + alpha = reg.intercept_ + beta = reg.coef_[0] + r_squared = reg.score(dominant_cum.reshape(-1, 1), hedge_cum) + + y_pred = alpha + beta * dominant_cum + spread_pct = (hedge_cum - y_pred) / y_pred * 100 + + mean_spread = np.mean(spread_pct) + std_spread = np.std(spread_pct) + if std_spread == 0: + self.logger().warning("[StatArb] Spread std is zero") + return None + current_spread = spread_pct[-1] + current_z_score = (current_spread - mean_spread) / std_spread + + try: + adf_result = adfuller(spread_pct, maxlag=1, autolag=None) + adf_pvalue = float(adf_result[1]) + except Exception as e: + self.logger().warning(f"[StatArb] ADF test failed: {e}") + adf_pvalue = 1.0 + + try: + spread_lag = spread_pct[:-1] + delta_spread = np.diff(spread_pct) + ou_reg = LinearRegression().fit(spread_lag.reshape(-1, 1), delta_spread) + lambda_ou = ou_reg.coef_[0] + half_life = float(-np.log(2) / lambda_ou) if lambda_ou < 0 else None + except Exception: + half_life = None + + self.processed_data["alpha"] = alpha + return current_spread, current_z_score, beta, r_squared, adf_pvalue, half_life + + # ------------------------------------------------------------------------- + # EXECUTION HELPERS (unchanged) + # ------------------------------------------------------------------------- + + def get_executors_to_reduce_position_on_opposite_signal(self) -> List[ExecutorAction]: + if self.processed_data["signal"] == 1: + dominant_side, hedge_side = TradeType.SELL, TradeType.BUY + elif self.processed_data["signal"] == -1: + dominant_side, hedge_side = TradeType.BUY, TradeType.SELL + else: + return [] + dominant_to_stop = self.filter_executors( + self.executors_info, + filter_func=lambda e: + e.connector_name == self.config.connector_name + and e.trading_pair == self.config.trading_pair_dominant + and e.side == dominant_side + ) + hedge_to_stop = self.filter_executors( + self.executors_info, + filter_func=lambda e: + e.connector_name == self.config.connector_name + and e.trading_pair == self.config.trading_pair_hedge + and e.side == hedge_side + ) + stop_actions = [ + StopExecutorAction(controller_id=self.config.id, executor_id=e.id, keep_position=False) + for e in dominant_to_stop + hedge_to_stop + ] + reduce_actions: List[ExecutorAction] = [] + for position in self.positions_held: + if (position.connector_name == self.config.connector_name + and position.trading_pair == self.config.trading_pair_dominant + and position.side == dominant_side): + reduce_actions.extend(self.get_executors_to_reduce_position(position)) + elif (position.connector_name == self.config.connector_name + and position.trading_pair == self.config.trading_pair_hedge + and position.side == hedge_side): + reduce_actions.extend(self.get_executors_to_reduce_position(position)) + return stop_actions + reduce_actions + + def get_executors_to_keep_position(self) -> List[ExecutorAction]: + stop_actions: List[ExecutorAction] = [] + for executor in (self.processed_data["executors_dominant_filled"] + + self.processed_data["executors_hedge_filled"]): + if self.market_data_provider.time() - executor.timestamp >= self.config.quoter_cooldown: + stop_actions.append( + StopExecutorAction(controller_id=self.config.id, executor_id=executor.id, keep_position=True) + ) + return stop_actions + + def get_executors_to_refresh(self) -> List[ExecutorAction]: + refresh_actions: List[ExecutorAction] = [] + for executor in (self.processed_data["executors_dominant_placed"] + + self.processed_data["executors_hedge_placed"]): + if self.market_data_provider.time() - executor.timestamp >= self.config.quoter_refresh: + refresh_actions.append( + StopExecutorAction(controller_id=self.config.id, executor_id=executor.id, keep_position=False) + ) + return refresh_actions + + def get_executors_to_quote(self) -> List[ExecutorAction]: + actions: List[ExecutorAction] = [] + trade_type_dominant = TradeType.BUY if self.processed_data["signal"] == 1 else TradeType.SELL + trade_type_hedge = TradeType.SELL if self.processed_data["signal"] == 1 else TradeType.BUY + + dynamic_hedge_ratio = self.processed_data["dynamic_hedge_ratio"] + hedge_amount_quote = self.config.min_amount_quote * dynamic_hedge_ratio + + # Dominant leg + if (self.processed_data["dominant_gap"] > Decimal("0") + and self.processed_data["filter_connector_pair"] != self.config.connector_pair_dominant + and len(self.processed_data["executors_dominant_placed"]) < self.config.max_orders_placed_per_side + and len(self.processed_data["executors_dominant_filled"]) < self.config.max_orders_filled_per_side): + price = ( + self.processed_data["min_price_dominant"] * (1 - self.config.quoter_spread) + if trade_type_dominant == TradeType.BUY + else self.processed_data["max_price_dominant"] * (1 + self.config.quoter_spread) + ) + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair_dominant, + side=trade_type_dominant, + entry_price=price, + amount=self.config.min_amount_quote / self.processed_data["dominant_price"], + triple_barrier_config=self.config.triple_barrier_config, + leverage=self.config.leverage, + ) + )) + + # Hedge leg + if (self.processed_data["hedge_gap"] > Decimal("0") + and self.processed_data["filter_connector_pair"] != self.config.connector_pair_hedge + and len(self.processed_data["executors_hedge_placed"]) < self.config.max_orders_placed_per_side + and len(self.processed_data["executors_hedge_filled"]) < self.config.max_orders_filled_per_side): + price = ( + self.processed_data["min_price_hedge"] * (1 - self.config.quoter_spread) + if trade_type_hedge == TradeType.BUY + else self.processed_data["max_price_hedge"] * (1 + self.config.quoter_spread) + ) + actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair_hedge, + side=trade_type_hedge, + entry_price=price, + amount=hedge_amount_quote / self.processed_data["hedge_price"], + triple_barrier_config=self.config.triple_barrier_config, + leverage=self.config.leverage, + ) + )) + return actions + + def get_executors_to_reduce_position(self, position: PositionSummary) -> List[ExecutorAction]: + if position.amount > Decimal("0"): + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=position.connector_name, + trading_pair=position.trading_pair, + side=TradeType.BUY if position.side == TradeType.SELL else TradeType.SELL, + amount=position.amount, + position_action=PositionAction.CLOSE, + execution_strategy=ExecutionStrategy.MARKET, + leverage=self.config.leverage, + ) + return [CreateExecutorAction(controller_id=self.config.id, executor_config=config)] + return [] + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def get_pairs_prices(self): + dominant_price = self.market_data_provider.get_price_by_type( + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair_dominant, + price_type=PriceType.MidPrice + ) + hedge_price = self.market_data_provider.get_price_by_type( + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair_hedge, + price_type=PriceType.MidPrice + ) + return dominant_price, hedge_price + + def get_executors_dominant(self): + placed = self.filter_executors( + self.executors_info, + filter_func=lambda e: + e.connector_name == self.config.connector_name + and e.trading_pair == self.config.trading_pair_dominant + and e.is_active and not e.is_trading and e.type == "position_executor" + ) + filled = self.filter_executors( + self.executors_info, + filter_func=lambda e: + e.connector_name == self.config.connector_name + and e.trading_pair == self.config.trading_pair_dominant + and e.is_active and e.is_trading and e.type == "position_executor" + ) + return placed, filled + + def get_executors_hedge(self): + placed = self.filter_executors( + self.executors_info, + filter_func=lambda e: + e.connector_name == self.config.connector_name + and e.trading_pair == self.config.trading_pair_hedge + and e.is_active and not e.is_trading and e.type == "position_executor" + ) + filled = self.filter_executors( + self.executors_info, + filter_func=lambda e: + e.connector_name == self.config.connector_name + and e.trading_pair == self.config.trading_pair_hedge + and e.is_active and e.is_trading and e.type == "position_executor" + ) + return placed, filled + + # ------------------------------------------------------------------------- + # STATUS + # ------------------------------------------------------------------------- + + def to_format_status(self) -> List[str]: + half_life = self.processed_data.get("half_life") + half_life_str = f"{half_life:.1f} candles" if half_life is not None else "N/A (not mean-reverting)" + blocked = self.processed_data.get("signal_blocked_reason") + blocked_str = f" ⚠️ SIGNAL BLOCKED: {blocked}" if blocked else " βœ… Signal active" + + return [f""" +Connector : {self.config.connector_name} +Dominant : {self.config.trading_pair_dominant} +Hedge : {self.config.trading_pair_hedge} +Timeframe : {self.config.interval} | Lookback: {self.config.lookback_period} | Entry threshold: {self.config.entry_threshold} + +── Statistical quality ────────────────────────────────────────────── +RΒ² : {self.processed_data.get('r_squared', 0):.4f} (min: {self.config.min_r_squared}) +ADF p-value : {self.processed_data.get('adf_pvalue', 1):.4f} (max: {self.config.adf_pvalue_threshold}) +Half-life : {half_life_str} +Alpha / Beta : {self.processed_data.get('alpha', 0):.4f} / {self.processed_data.get('beta', 0):.4f} +{blocked_str} + +── Signal ─────────────────────────────────────────────────────────── +Signal : {self.processed_data['signal']} | Z-Score: {self.processed_data['z_score']:.4f} | Spread: {self.processed_data['spread']:.4f} + +── Positions ──────────────────────────────────────────────────────── +Dynamic ratio : {self.processed_data['dynamic_hedge_ratio']:.4f} (use_dynamic={self.config.use_dynamic_hedge_ratio}) +Theoretical : dominant={self.processed_data.get('theoretical_dominant_quote', 0):.2f} | hedge={self.processed_data.get('theoretical_hedge_quote', 0):.2f} +Actual : dominant={self.processed_data['position_dominant_quote']:.2f} | hedge={self.processed_data['position_hedge_quote']:.2f} +Imbalance : {self.processed_data['imbalance']:.2f} | Imbalance scaled: {self.processed_data['imbalance_scaled_pct']:.2f}% + +── Executors ──────────────────────────────────────────────────────── +Placed : dominant={len(self.processed_data['executors_dominant_placed'])} | hedge={len(self.processed_data['executors_hedge_placed'])} +Filled : dominant={len(self.processed_data['executors_dominant_filled'])} | hedge={len(self.processed_data['executors_hedge_filled'])} + +Pair PnL: {self.processed_data['pair_pnl_pct'] * 100:.3f}% +"""] + + def get_candles_config(self) -> List[CandlesConfig]: + return [ + CandlesConfig( + connector=self.config.connector_name, + trading_pair=self.config.trading_pair_dominant, + interval=self.config.interval, + max_records=self.max_records + ), + CandlesConfig( + connector=self.config.connector_name, + trading_pair=self.config.trading_pair_hedge, + interval=self.config.interval, + max_records=self.max_records + ) + ] \ No newline at end of file diff --git a/bots/controllers/market_making/lm_multi_pair_dex.py b/bots/controllers/market_making/lm_multi_pair_dex.py new file mode 100644 index 00000000..0d4ebc70 --- /dev/null +++ b/bots/controllers/market_making/lm_multi_pair_dex.py @@ -0,0 +1,418 @@ +from decimal import Decimal +from typing import List, Dict, Optional +from pydantic import Field, field_validator + +from hummingbot.core.data_type.common import MarketDict, OrderType, PriceType, TradeType +from hummingbot.strategy_v2.controllers.controller_base import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair +from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig, TripleBarrierConfig +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction, StopExecutorAction + + +class LMMultiPairDEXConfig(ControllerConfigBase): + """ + Configurazione per Liquidity Mining multi-coppia su DEX con order book. + + Supporta: + - XRPL DEX (latenza 3-5s, fee ~$0.00001) + - Hyperliquid (latenza 0.2ms, maker rebate -0.01%) + + I parametri si adattano automaticamente in base al connector_name. + """ + controller_type: str = "generic" + controller_name: str = "lm_multi_pair_dex" + + # Exchange - decide automaticamente i parametri ottimali + connector_name: str = Field( + default="xrpl", + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Exchange connector (xrpl or hyperliquid):" + } + ) + + # Markets + markets: List[str] = Field( + default=["XRP-RLUSD"], + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Trading pairs (comma-separated). XRPL: XRP-RLUSD, BTC-XRP | Hyperliquid: SOL-USDC, ETH-USDC:" + } + ) + + # Token unico + token: str = Field( + default="XRP", + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Unified token (XRPL: XRP or RLUSD | Hyperliquid: USDC recommended):" + } + ) + + # Allocazione capitale + portfolio_allocation: Decimal = Field(default=Decimal("0.1"), gt=0, le=1) + total_amount_quote: Decimal = Field(default=Decimal("1000"), gt=0) + # Spread - valori base (verranno automaticamente scalati in base al DEX) + # Su XRPL: piΓΉ larghi, su Hyperliquid: piΓΉ stretti + buy_spreads: List[float] = Field(default="0.005,0.01,0.02", validate_default=True) + sell_spreads: List[float] = Field(default="0.005,0.01,0.02", validate_default=True) + + # Timing - valori base + order_refresh_time: int = Field(default=45) + cooldown_time: int = Field(default=20) + order_refresh_tolerance_pct: Decimal = Field(default=Decimal("0.01"), ge=-1, le=1) + + # Skew parameters + target_base_pct: Decimal = Field(default=Decimal("0.5"), ge=0, le=1) + min_base_pct: Decimal = Field(default=Decimal("0.3"), ge=0, le=1) + max_base_pct: Decimal = Field(default=Decimal("0.7"), ge=0, le=1) + max_skew: Decimal = Field(default=Decimal("0.2"), ge=0, le=1) + + leverage: int = Field(default=1) + take_profit: Optional[Decimal] = Field(default=None) + use_dynamic_spreads: bool = Field(default=True) + atr_length: int = Field(default=14) + atr_multiplier_min: float = Field(default=0.5) + atr_multiplier_max: float = Field(default=2.0) + min_liquidity_score: float = Field(default=0.3) + min_volume_usd: float = Field(default=10000) + max_spread_multiplier: float = Field(default=3.0) + min_spread_multiplier: float = Field(default=0.3) + @field_validator('markets', mode='before') + def parse_markets(cls, v): + if isinstance(v, str): + return [x.strip() for x in v.split(',')] + return v + + @field_validator('buy_spreads', 'sell_spreads', mode='before') + def parse_spreads(cls, v): + if isinstance(v, str): + return [float(x.strip()) for x in v.split(',')] + return v + + def update_markets(self, markets: MarketDict) -> MarketDict: + for pair in self.markets: + markets.add_or_update(self.connector_name, pair) + return markets + + +class LMMultiPairDEX(ControllerBase): + def __init__(self, config: LMMultiPairDEXConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + + # === AUTO-OTTIMIZZAZIONE in base al DEX === + self._dex_type = self._detect_dex_type() + self._apply_dex_optimizations() + + # Verifica configurazione specifica per DEX + self._validate_dex_config() + + self.market_data_provider.initialize_rate_sources([ + ConnectorPair(config.connector_name, pair) for pair in config.markets + ]) + + self._pair_last_fill: Dict[str, float] = {pair: 0 for pair in config.markets} + self._active_executor_ids_per_pair: Dict[str, set] = {pair: set() for pair in config.markets} + + def _detect_dex_type(self) -> str: + """Rileva automaticamente il tipo di DEX dal connector_name.""" + connector = self.config.connector_name.lower() + if "xrpl" in connector: + return "xrpl" + elif "hyperliquid" in connector: + return "hyperliquid" + else: + self.logger().warning(f"Connector {connector} non riconosciuto. Usando parametri default.") + return "unknown" + + def _apply_dex_optimizations(self): + """Applica ottimizzazioni specifiche per il DEX rilevato.""" + if self._dex_type == "xrpl": + self.logger().info("πŸ”΅ RILEVATO XRPL DEX - Applicando ottimizzazioni: spread larghi, refresh lento") + + # XRPL: allarga spread se sono troppo stretti + min_recommended_spread = 0.003 # 0.3% minimo raccomandato + for i, spread in enumerate(self.config.buy_spreads): + if spread < min_recommended_spread: + self.config.buy_spreads[i] = min_recommended_spread + self.logger().warning(f"Spread buy livello {i} aumentato a {min_recommended_spread}% (minimo per XRPL)") + + # XRPL: rallenta refresh time (latenza 3-5 secondi) + if self.config.order_refresh_time < 60: + old = self.config.order_refresh_time + self.config.order_refresh_time = 60 + self.logger().info(f"order_refresh_time aumentato da {old}s a 60s per XRPL") + + if self.config.cooldown_time < 30: + old = self.config.cooldown_time + self.config.cooldown_time = 30 + self.logger().info(f"cooldown_time aumentato da {old}s a 30s per XRPL") + + # XRPL: tolleranza piΓΉ alta (prezzi meno precisi) + if self.config.order_refresh_tolerance_pct < Decimal("0.01"): + old = self.config.order_refresh_tolerance_pct + self.config.order_refresh_tolerance_pct = Decimal("0.01") + self.logger().info(f"order_refresh_tolerance_pct aumentato da {old} a 1% per XRPL") + + elif self._dex_type == "hyperliquid": + self.logger().info("🟣 RILEVATO HYPERLIQUID - Applicando ottimizzazioni: spread medi, refresh veloce") + + # Hyperliquid: restringi spread (rebate maker permette spread piΓΉ stretti) + max_recommended_spread = 0.01 # 1% massimo raccomandato + for i, spread in enumerate(self.config.buy_spreads): + if spread > max_recommended_spread: + self.config.buy_spreads[i] = max_recommended_spread + self.logger().warning(f"Spread buy livello {i} ridotto a {max_recommended_spread}% (massimo per Hyperliquid)") + + # Hyperliquid: refresh piΓΉ veloce (latenza 0.2ms) + if self.config.order_refresh_time > 45: + old = self.config.order_refresh_time + self.config.order_refresh_time = 30 + self.logger().info(f"order_refresh_time ridotto da {old}s a 30s per Hyperliquid") + + if self.config.cooldown_time > 25: + old = self.config.cooldown_time + self.config.cooldown_time = 15 + self.logger().info(f"cooldown_time ridotto da {old}s a 15s per Hyperliquid") + + # Hyperliquid: tolleranza piΓΉ bassa (prezzi molto precisi) + if self.config.order_refresh_tolerance_pct > Decimal("0.005") and self.config.order_refresh_tolerance_pct != Decimal("-1"): + old = self.config.order_refresh_tolerance_pct + self.config.order_refresh_tolerance_pct = Decimal("0.005") + self.logger().info(f"order_refresh_tolerance_pct ridotto da {old} a 0.5% per Hyperliquid") + + def _validate_dex_config(self): + """Verifica che la configurazione sia valida per il DEX.""" + if self._dex_type == "xrpl": + # Verifica token nativi + if self.config.token not in ["XRP", "RLUSD"]: + self.logger().warning( + f"Token {self.config.token} non Γ¨ nativo di XRPL. " + f"Assicurati di aver stabilito una trust line." + ) + + # Verifica fee XRP per transazioni + xrp_balance = self.market_data_provider.get_balance(self.config.connector_name, "XRP") + if xrp_balance < Decimal("10"): + self.logger().warning( + f"Hai solo {xrp_balance} XRP per le fee. Minimo raccomandato: 10 XRP." + ) + + elif self._dex_type == "hyperliquid": + # Verifica token USDC + if self.config.token != "USDC": + self.logger().warning( + f"Token {self.config.token} non Γ¨ USDC. " + f"Le migliori fee su Hyperliquid sono con USDC." + ) + + # Verifica che si usi spot, non perp + if "perpetual" in self.config.connector_name.lower(): + self.logger().error( + "Questo controller Γ¨ per spot market making. " + "Usa 'hyperliquid' (spot), non 'hyperliquid_perpetual'." + ) + + async def update_processed_data(self): + ref_prices = {} + for pair in self.config.markets: + price = self.market_data_provider.get_price_by_type( + self.config.connector_name, pair, PriceType.MidPrice + ) + ref_prices[pair] = Decimal(str(price)) if price else Decimal("0") + + # Calcola percentuale di base per lo skew + base, quote = pair.split("-") + base_balance = self.market_data_provider.get_balance(self.config.connector_name, base) + quote_balance = self.market_data_provider.get_balance(self.config.connector_name, quote) + price_ = ref_prices[pair] + total_value = base_balance * price_ + quote_balance if price_ > 0 else Decimal("0") + base_pct = (base_balance * price_) / total_value if total_value > 0 else Decimal("0") + self.processed_data[f"base_pct_{pair}"] = base_pct + + self.processed_data["ref_prices"] = ref_prices + + def _calculate_skew(self, base_pct: Decimal) -> tuple[Decimal, Decimal]: + min_pct = self.config.min_base_pct + max_pct = self.config.max_base_pct + if max_pct > min_pct: + buy_skew = (max_pct - base_pct) / (max_pct - min_pct) + sell_skew = (base_pct - min_pct) / (max_pct - min_pct) + buy_skew = max(min(buy_skew, Decimal("1.0")), self.config.max_skew) + sell_skew = max(min(sell_skew, Decimal("1.0")), self.config.max_skew) + else: + buy_skew = sell_skew = Decimal("1.0") + return buy_skew, sell_skew + + def _is_within_tolerance(self, current_price: Decimal, theoretical_price: Decimal) -> bool: + if self.config.order_refresh_tolerance_pct < 0: + return False + if current_price == 0 or theoretical_price == 0: + return False + diff = abs(current_price - theoretical_price) / current_price + return diff <= self.config.order_refresh_tolerance_pct + + def determine_executor_actions(self) -> List[ExecutorAction]: + actions = [] + num_pairs = len(self.config.markets) + if num_pairs == 0: + return actions + + total_quote = self.config.total_amount_quote * self.config.portfolio_allocation + quote_per_pair = total_quote / Decimal(num_pairs) + current_time = self.market_data_provider.time() + + # Aggiorna mappa executor attivi + for pair in self.config.markets: + self._active_executor_ids_per_pair[pair] = set() + for ex in self.executors_info: + if ex.is_active: + pair = ex.custom_info.get("trading_pair") + if pair in self._active_executor_ids_per_pair: + self._active_executor_ids_per_pair[pair].add(ex.id) + + for pair in self.config.markets: + ref_price = self.processed_data["ref_prices"].get(pair, Decimal("0")) + if ref_price <= 0: + continue + + base_pct = self.processed_data.get(f"base_pct_{pair}", Decimal("0")) + buy_skew, sell_skew = self._calculate_skew(base_pct) + + active_executors = [ + e for e in self.executors_info + if e.is_active and e.custom_info.get("trading_pair") == pair + ] + + # Refresh executor scaduti e fuori tolleranza + for ex in active_executors: + if ex.is_trading: + continue + age = current_time - ex.timestamp + if age <= self.config.order_refresh_time: + continue + + level_id = ex.custom_info.get("level_id") + if not level_id or not hasattr(ex.config, 'price'): + continue + + trade_type = TradeType.BUY if level_id.startswith("buy") else TradeType.SELL + level = int(level_id.split('_')[1]) + if trade_type == TradeType.BUY: + spread = Decimal(self.config.buy_spreads[level]) + theoretical = ref_price * (Decimal("1") - spread) + else: + spread = Decimal(self.config.sell_spreads[level]) + theoretical = ref_price * (Decimal("1") + spread) + + current_price = Decimal(str(ex.config.price)) + if not self._is_within_tolerance(current_price, theoretical): + actions.append(StopExecutorAction( + controller_id=self.config.id, + executor_id=ex.id, + keep_position=True + )) + + # Determina livelli mancanti + active_level_ids = {e.custom_info.get("level_id") for e in active_executors if e.custom_info.get("level_id")} + buy_levels_needed = [f"buy_{i}" for i in range(len(self.config.buy_spreads)) if f"buy_{i}" not in active_level_ids] + sell_levels_needed = [f"sell_{i}" for i in range(len(self.config.sell_spreads)) if f"sell_{i}" not in active_level_ids] + + # Cooldown + last_fill = self._pair_last_fill.get(pair, 0) + if current_time - last_fill < self.config.cooldown_time: + continue + + # Crea executor per buy + for level_id in buy_levels_needed: + level = int(level_id.split('_')[1]) + spread = Decimal(self.config.buy_spreads[level]) + price = ref_price * (Decimal("1") - spread) + num_buy_levels = len(self.config.buy_spreads) + amount_quote = quote_per_pair / Decimal(num_buy_levels) + amount = amount_quote / price + amount = amount * buy_skew + amount = self.market_data_provider.quantize_order_amount(self.config.connector_name, pair, amount) + if amount <= 0: + continue + + executor_config = PositionExecutorConfig( + timestamp=current_time, + level_id=level_id, + connector_name=self.config.connector_name, + trading_pair=pair, + entry_price=price, + amount=amount, + triple_barrier_config=TripleBarrierConfig( + take_profit=self.config.take_profit, + stop_loss=self.config.stop_loss, # <-- questa riga + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + ), + leverage=self.config.leverage, + side=TradeType.BUY, + ) + executor_config.custom_info = {"trading_pair": pair, "level_id": level_id} + actions.append(CreateExecutorAction(controller_id=self.config.id, executor_config=executor_config)) + + # Crea executor per sell + for level_id in sell_levels_needed: + level = int(level_id.split('_')[1]) + spread = Decimal(self.config.sell_spreads[level]) + price = ref_price * (Decimal("1") + spread) + num_sell_levels = len(self.config.sell_spreads) + amount_quote = quote_per_pair / Decimal(num_sell_levels) + amount = amount_quote / price + amount = amount * sell_skew + amount = self.market_data_provider.quantize_order_amount(self.config.connector_name, pair, amount) + if amount <= 0: + continue + + executor_config = PositionExecutorConfig( + timestamp=current_time, + level_id=level_id, + connector_name=self.config.connector_name, + trading_pair=pair, + entry_price=price, + amount=amount, + triple_barrier_config=TripleBarrierConfig( + take_profit=self.config.take_profit, + stop_loss=self.config.stop_loss, # <-- questa riga + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + ), + leverage=self.config.leverage, + side=TradeType.SELL, + ) + executor_config.custom_info = {"trading_pair": pair, "level_id": level_id} + actions.append(CreateExecutorAction(controller_id=self.config.id, executor_config=executor_config)) + + return actions + + def did_fill_order(self, event): + pair = event.trading_pair + if pair in self._pair_last_fill: + self._pair_last_fill[pair] = self.market_data_provider.time() + super().did_fill_order(event) + + def to_format_status(self) -> List[str]: + dex_icon = "πŸ”΅ XRPL" if self._dex_type == "xrpl" else "🟣 HYPERLIQUID" if self._dex_type == "hyperliquid" else "βšͺ UNKNOWN" + + lines = [ + f"{dex_icon} Liquidity Mining | Token: {self.config.token}", + f"Allocazione: {self.config.portfolio_allocation:.1%} | Livelli: buy={self.config.buy_spreads} sell={self.config.sell_spreads}", + f"Refresh: {self.config.order_refresh_time}s | Cooldown: {self.config.cooldown_time}s | Tolleranza: {self.config.order_refresh_tolerance_pct:.2%}" + ] + + # Aggiungi info specifiche per DEX + if self._dex_type == "hyperliquid": + lines.append(f"πŸ’° Maker rebate attivo: -0.01% (ti pagano per ogni ordine eseguito)") + elif self._dex_type == "xrpl": + lines.append(f"πŸ’° Fee per ordine: ~0.000012 XRP (quasi zero)") + + for pair in self.config.markets: + active = len(self._active_executor_ids_per_pair.get(pair, set())) + base_pct = self.processed_data.get(f"base_pct_{pair}", Decimal("0")) + lines.append(f" {pair}: {active} attivi | base%: {base_pct:.1%}") + return lines