from decimal import Decimal from typing import Dict, List, Set, Union import pandas_ta as ta # noqa: F401 from pydantic import Field, field_validator 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.data_types import ConnectorPair from hummingbot.strategy_v2.executors.grid_executor.data_types import GridExecutorConfig from hummingbot.strategy_v2.executors.position_executor.data_types import TripleBarrierConfig from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, StopExecutorAction from hummingbot.strategy_v2.models.executors_info import ExecutorInfo class QGAConfig(ControllerConfigBase): controller_name: str = "quantum_grid_allocator" candles_config: List[CandlesConfig] = [] # Portfolio allocation zones long_only_threshold: Decimal = Field(default=Decimal("0.2"), json_schema_extra={"is_updatable": True}) short_only_threshold: Decimal = Field(default=Decimal("0.2"), json_schema_extra={"is_updatable": True}) hedge_ratio: Decimal = Field(default=Decimal("2"), json_schema_extra={"is_updatable": True}) # Grid allocation multipliers base_grid_value_pct: Decimal = Field(default=Decimal("0.08"), json_schema_extra={"is_updatable": True}) max_grid_value_pct: Decimal = Field(default=Decimal("0.15"), json_schema_extra={"is_updatable": True}) # Order frequency settings safe_extra_spread: Decimal = Field(default=Decimal("0.0001"), json_schema_extra={"is_updatable": True}) favorable_order_frequency: int = Field(default=2, json_schema_extra={"is_updatable": True}) unfavorable_order_frequency: int = Field(default=5, json_schema_extra={"is_updatable": True}) max_orders_per_batch: int = Field(default=1, json_schema_extra={"is_updatable": True}) # Portfolio allocation portfolio_allocation: Dict[str, Decimal] = Field( default={ "SOL": Decimal("0.50"), # 50% }, json_schema_extra={"is_updatable": True}) # Grid parameters grid_range: Decimal = Field(default=Decimal("0.002"), json_schema_extra={"is_updatable": True}) tp_sl_ratio: Decimal = Field(default=Decimal("0.8"), json_schema_extra={"is_updatable": True}) min_order_amount: Decimal = Field(default=Decimal("5"), json_schema_extra={"is_updatable": True}) # Risk parameters max_deviation: Decimal = Field(default=Decimal("0.05"), json_schema_extra={"is_updatable": True}) max_open_orders: int = Field(default=2, json_schema_extra={"is_updatable": True}) # Exchange settings connector_name: str = "binance" leverage: int = 1 position_mode: PositionMode = PositionMode.HEDGE quote_asset: str = "FDUSD" fee_asset: str = "BNB" # Grid price multipliers min_spread_between_orders: Decimal = Field( default=Decimal("0.0001"), # 0.01% between orders json_schema_extra={"is_updatable": True}) grid_tp_multiplier: Decimal = Field( default=Decimal("0.0001"), # 0.2% take profit json_schema_extra={"is_updatable": True}) # Grid safety parameters limit_price_spread: Decimal = Field( default=Decimal("0.001"), # 0.1% spread for limit price json_schema_extra={"is_updatable": True}) activation_bounds: Decimal = Field( default=Decimal("0.0002"), # Activation bounds for orders json_schema_extra={"is_updatable": True}) bb_lenght: int = 100 bb_std_dev: float = 2.0 interval: str = "1s" dynamic_grid_range: bool = Field(default=False, json_schema_extra={"is_updatable": True}) show_terminated_details: bool = False @property def quote_asset_allocation(self) -> Decimal: """Calculate the implicit quote asset (FDUSD) allocation""" return Decimal("1") - sum(self.portfolio_allocation.values()) @field_validator("portfolio_allocation") @classmethod def validate_allocation(cls, v): total = sum(v.values()) if total >= Decimal("1"): raise ValueError(f"Total allocation {total} exceeds or equals 100%. Must leave room for FDUSD allocation.") if "FDUSD" in v: raise ValueError("FDUSD should not be explicitly allocated as it is the quote asset") return v def update_markets(self, markets: Dict[str, Set[str]]) -> Dict[str, Set[str]]: if self.connector_name not in markets: markets[self.connector_name] = set() for asset in self.portfolio_allocation: markets[self.connector_name].add(f"{asset}-{self.quote_asset}") return markets class QuantumGridAllocator(ControllerBase): def __init__(self, config: QGAConfig, *args, **kwargs): self.config = config self.metrics = {} # Track unfavorable grid IDs self.unfavorable_grid_ids = set() # Track held positions from unfavorable grids self.unfavorable_positions = { f"{asset}-{config.quote_asset}": { 'long': {'size': Decimal('0'), 'value': Decimal('0'), 'weighted_price': Decimal('0')}, 'short': {'size': Decimal('0'), 'value': Decimal('0'), 'weighted_price': Decimal('0')} } for asset in config.portfolio_allocation } self.config.candles_config = [CandlesConfig( connector=config.connector_name, trading_pair=trading_pair + "-" + config.quote_asset, interval=config.interval, max_records=config.bb_lenght + 100 ) for trading_pair in config.portfolio_allocation.keys()] super().__init__(config, *args, **kwargs) self.initialize_rate_sources() def initialize_rate_sources(self): fee_pair = ConnectorPair(connector_name=self.config.connector_name, trading_pair=f"{self.config.fee_asset}-{self.config.quote_asset}") self.market_data_provider.initialize_rate_sources([fee_pair]) async def update_processed_data(self): # Get the bb width to use it as the range for the grid for asset in self.config.portfolio_allocation: trading_pair = f"{asset}-{self.config.quote_asset}" candles = self.market_data_provider.get_candles_df( connector_name=self.config.connector_name, trading_pair=trading_pair, interval=self.config.interval, max_records=self.config.bb_lenght + 100 ) if len(candles) == 0: bb_width = self.config.grid_range else: bb = ta.bbands(candles["close"], length=self.config.bb_lenght, std=self.config.bb_std_dev) bb_width = bb[f"BBB_{self.config.bb_lenght}_{self.config.bb_std_dev}"].iloc[-1] / 100 self.processed_data[trading_pair] = { "bb_width": bb_width } def update_portfolio_metrics(self): """ Calculate theoretical vs actual portfolio allocations """ metrics = { "theoretical": {}, "actual": {}, "difference": {}, } # Get real balances and calculate total portfolio value quote_balance = self.market_data_provider.get_balance(self.config.connector_name, self.config.quote_asset) total_value_quote = quote_balance # Calculate actual allocations including positions for asset in self.config.portfolio_allocation: trading_pair = f"{asset}-{self.config.quote_asset}" price = self.get_mid_price(trading_pair) # Get balance and add any position from active grid balance = self.market_data_provider.get_balance(self.config.connector_name, asset) value = balance * price total_value_quote += value metrics["actual"][asset] = value # Calculate theoretical allocations and differences for asset in self.config.portfolio_allocation: theoretical_value = total_value_quote * self.config.portfolio_allocation[asset] metrics["theoretical"][asset] = theoretical_value metrics["difference"][asset] = metrics["actual"][asset] - theoretical_value # Add quote asset metrics metrics["actual"][self.config.quote_asset] = quote_balance metrics["theoretical"][self.config.quote_asset] = total_value_quote * self.config.quote_asset_allocation metrics["difference"][self.config.quote_asset] = quote_balance - metrics["theoretical"][self.config.quote_asset] metrics["total_portfolio_value"] = total_value_quote self.metrics = metrics def get_active_grids_by_asset(self) -> Dict[str, List[ExecutorInfo]]: """Group active grids by asset using filter_executors""" active_grids = {} for asset in self.config.portfolio_allocation: if asset == self.config.quote_asset: continue trading_pair = f"{asset}-{self.config.quote_asset}" active_executors = self.filter_executors( executors=self.executors_info, filter_func=lambda e: ( e.is_active and e.config.trading_pair == trading_pair ) ) if active_executors: active_grids[asset] = active_executors return active_grids def to_format_status(self) -> List[str]: """Generate a detailed status report with portfolio, grid, and position information""" status_lines = [] total_value = self.metrics.get("total_portfolio_value", Decimal("0")) # Portfolio Status status_lines.append(f"Total Portfolio Value: ${total_value:,.2f}") status_lines.append("") status_lines.append("Portfolio Status:") status_lines.append("-" * 80) status_lines.append( f"{'Asset':<8} | " f"{'Actual':>10} | " f"{'Target':>10} | " f"{'Diff':>10} | " f"{'Dev %':>8}" ) status_lines.append("-" * 80) # Show metrics for each asset for asset in self.config.portfolio_allocation: actual = self.metrics["actual"].get(asset, Decimal("0")) theoretical = self.metrics["theoretical"].get(asset, Decimal("0")) difference = self.metrics["difference"].get(asset, Decimal("0")) deviation_pct = (difference / theoretical * 100) if theoretical != Decimal("0") else Decimal("0") status_lines.append( f"{asset:<8} | " f"${actual:>9.2f} | " f"${theoretical:>9.2f} | " f"${difference:>+9.2f} | " f"{deviation_pct:>+7.1f}%" ) # Add quote asset metrics quote_asset = self.config.quote_asset actual = self.metrics["actual"].get(quote_asset, Decimal("0")) theoretical = self.metrics["theoretical"].get(quote_asset, Decimal("0")) difference = self.metrics["difference"].get(quote_asset, Decimal("0")) deviation_pct = (difference / theoretical * 100) if theoretical != Decimal("0") else Decimal("0") status_lines.append("-" * 80) status_lines.append( f"{quote_asset:<8} | " f"${actual:>9.2f} | " f"${theoretical:>9.2f} | " f"${difference:>+9.2f} | " f"{deviation_pct:>+7.1f}%" ) # Active Grids Summary active_grids = self.get_active_grids_by_asset() if active_grids: status_lines.append("") status_lines.append("Active Grids:") status_lines.append("-" * 140) status_lines.append( f"{'Asset':<8} {'Side':<6} | " f"{'Total ($)':<10} {'Position':<10} {'Volume':<10} | " f"{'PnL':<10} {'RPnL':<10} {'Fees':<10} | " f"{'Start':<10} {'Current':<10} {'End':<10} {'Limit':<10}" ) status_lines.append("-" * 140) for asset, executors in active_grids.items(): for executor in executors: config = executor.config custom_info = executor.custom_info trading_pair = config.trading_pair current_price = self.get_mid_price(trading_pair) # Get grid metrics total_amount = Decimal(str(config.total_amount_quote)) position_size = Decimal(str(custom_info.get('position_size_quote', '0'))) volume = executor.filled_amount_quote pnl = executor.net_pnl_quote realized_pnl_quote = custom_info.get('realized_pnl_quote', Decimal('0')) fees = executor.cum_fees_quote status_lines.append( f"{asset:<8} {config.side.name:<6} | " f"${total_amount:<9.2f} ${position_size:<9.2f} ${volume:<9.2f} | " f"${pnl:>+9.2f} ${realized_pnl_quote:>+9.2f} ${fees:>9.2f} | " f"{config.start_price:<10.4f} {current_price:<10.4f} {config.end_price:<10.4f} {config.limit_price:<10.4f}" ) status_lines.append("-" * 100 + "\n") return status_lines def tp_multiplier(self): return self.config.tp_sl_ratio def sl_multiplier(self): return 1 - self.config.tp_sl_ratio def determine_executor_actions(self) -> List[Union[CreateExecutorAction, StopExecutorAction]]: actions = [] self.update_portfolio_metrics() active_grids_by_asset = self.get_active_grids_by_asset() for asset in self.config.portfolio_allocation: if asset == self.config.quote_asset: continue trading_pair = f"{asset}-{self.config.quote_asset}" # Check if there are any active grids for this asset if asset in active_grids_by_asset: self.logger().debug(f"Skipping {trading_pair} - Active grid exists") continue theoretical = self.metrics["theoretical"][asset] difference = self.metrics["difference"][asset] deviation = difference / theoretical if theoretical != Decimal("0") else Decimal("0") mid_price = self.get_mid_price(trading_pair) # Calculate dynamic grid value percentage based on deviation abs_deviation = abs(deviation) grid_value_pct = self.config.max_grid_value_pct if abs_deviation > self.config.max_deviation else self.config.base_grid_value_pct self.logger().info( f"{trading_pair} Grid Sizing - " f"Deviation: {deviation:+.1%}, " f"Grid Value %: {grid_value_pct:.1%}" ) if self.config.dynamic_grid_range: grid_range = Decimal(self.processed_data[trading_pair]["bb_width"]) else: grid_range = self.config.grid_range # Determine which zone we're in by normalizing the deviation over the theoretical allocation if deviation < -self.config.long_only_threshold: # Long-only zone - only create buy grids if difference < Decimal("0"): # Only if we need to buy grid_value = min(abs(difference), theoretical * grid_value_pct) start_price = mid_price * (1 - grid_range * self.sl_multiplier()) end_price = mid_price * (1 + grid_range * self.tp_multiplier()) grid_action = self.create_grid_executor( trading_pair=trading_pair, side=TradeType.BUY, start_price=start_price, end_price=end_price, grid_value=grid_value, is_unfavorable=False ) if grid_action is not None: actions.append(grid_action) elif deviation > self.config.short_only_threshold: # Short-only zone - only create sell grids if difference > Decimal("0"): # Only if we need to sell grid_value = min(abs(difference), theoretical * grid_value_pct) start_price = mid_price * (1 - grid_range * self.tp_multiplier()) end_price = mid_price * (1 + grid_range * self.sl_multiplier()) grid_action = self.create_grid_executor( trading_pair=trading_pair, side=TradeType.SELL, start_price=start_price, end_price=end_price, grid_value=grid_value, is_unfavorable=False ) if grid_action is not None: actions.append(grid_action) else: # we create a buy and a sell grid with higher range pct and the base grid value pct # to hedge the position grid_value = theoretical * grid_value_pct if difference < Decimal("0"): # create a bigger buy grid and sell grid # Create buy grid start_price = mid_price * (1 - 2 * grid_range * self.sl_multiplier()) end_price = mid_price * (1 + grid_range * self.tp_multiplier()) buy_grid_action = self.create_grid_executor( trading_pair=trading_pair, side=TradeType.BUY, start_price=start_price, end_price=end_price, grid_value=grid_value, is_unfavorable=False ) if buy_grid_action is not None: actions.append(buy_grid_action) # Create sell grid start_price = mid_price * (1 - grid_range * self.tp_multiplier()) end_price = mid_price * (1 + 2 * grid_range * self.sl_multiplier()) sell_grid_action = self.create_grid_executor( trading_pair=trading_pair, side=TradeType.SELL, start_price=start_price, end_price=end_price, grid_value=grid_value, is_unfavorable=False ) if sell_grid_action is not None: actions.append(sell_grid_action) if difference > Decimal("0"): # Create sell grid start_price = mid_price * (1 - 2 * grid_range * self.tp_multiplier()) end_price = mid_price * (1 + grid_range * self.sl_multiplier()) sell_grid_action = self.create_grid_executor( trading_pair=trading_pair, side=TradeType.SELL, start_price=start_price, end_price=end_price, grid_value=grid_value, is_unfavorable=False ) if sell_grid_action is not None: actions.append(sell_grid_action) # Create buy grid start_price = mid_price * (1 - grid_range * self.sl_multiplier()) end_price = mid_price * (1 + 2 * grid_range * self.tp_multiplier()) buy_grid_action = self.create_grid_executor( trading_pair=trading_pair, side=TradeType.BUY, start_price=start_price, end_price=end_price, grid_value=grid_value, is_unfavorable=False ) if buy_grid_action is not None: actions.append(buy_grid_action) return actions def create_grid_executor( self, trading_pair: str, side: TradeType, start_price: Decimal, end_price: Decimal, grid_value: Decimal, is_unfavorable: bool = False ) -> CreateExecutorAction: """Creates a grid executor with dynamic sizing and range adjustments""" # Get trading rules and minimum notional trading_rules = self.market_data_provider.get_trading_rules(self.config.connector_name, trading_pair) min_notional = max( self.config.min_order_amount, trading_rules.min_notional_size if trading_rules else Decimal("5.0") ) # Add safety margin and check if grid value is sufficient min_grid_value = min_notional * Decimal("5") # Ensure room for at least 5 levels if grid_value < min_grid_value: self.logger().info( f"Grid value {grid_value} is too small for {trading_pair}. " f"Minimum required for viable grid: {min_grid_value}" ) return None # Skip grid creation if value is too small # Select order frequency based on grid favorability order_frequency = ( self.config.unfavorable_order_frequency if is_unfavorable else self.config.favorable_order_frequency ) # Calculate limit price to be more aggressive than grid boundaries if side == TradeType.BUY: # For buys, limit price should be lower than start price limit_price = start_price * (1 - self.config.limit_price_spread) else: # For sells, limit price should be higher than end price limit_price = end_price * (1 + self.config.limit_price_spread) # Create the executor action action = CreateExecutorAction( controller_id=self.config.id, executor_config=GridExecutorConfig( timestamp=self.market_data_provider.time(), connector_name=self.config.connector_name, trading_pair=trading_pair, side=side, start_price=start_price, end_price=end_price, limit_price=limit_price, leverage=self.config.leverage, total_amount_quote=grid_value, safe_extra_spread=self.config.safe_extra_spread, min_spread_between_orders=self.config.min_spread_between_orders, min_order_amount_quote=self.config.min_order_amount, max_open_orders=self.config.max_open_orders, order_frequency=order_frequency, # Use dynamic order frequency max_orders_per_batch=self.config.max_orders_per_batch, activation_bounds=self.config.activation_bounds, keep_position=True, # Always keep position for potential reversal coerce_tp_to_step=True, triple_barrier_config=TripleBarrierConfig( take_profit=self.config.grid_tp_multiplier, open_order_type=OrderType.LIMIT_MAKER, take_profit_order_type=OrderType.LIMIT_MAKER, stop_loss=None, time_limit=None, trailing_stop=None, ))) # Track unfavorable grid configs if is_unfavorable: self.unfavorable_grid_ids.add(action.executor_config.id) self.logger().info( f"Created unfavorable grid for {trading_pair} - " f"Side: {side.name}, Value: ${grid_value:,.2f}, " f"Order Frequency: {order_frequency}s" ) else: self.logger().info( f"Created favorable grid for {trading_pair} - " f"Side: {side.name}, Value: ${grid_value:,.2f}, " f"Order Frequency: {order_frequency}s" ) return action def get_mid_price(self, trading_pair: str) -> Decimal: return self.market_data_provider.get_price_by_type(self.config.connector_name, trading_pair, PriceType.MidPrice)