Files
deploy/bots/controllers/generic/quantum_grid_allocator.py
2025-07-11 02:57:08 +03:00

493 lines
24 KiB
Python

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)