diff --git a/Makefile b/Makefile index b240cd28..b608e236 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ run: uvicorn main:app --reload uninstall: - conda env remove -n backend-api + conda env remove -n backend-api -y install: if conda env list | grep -q '^backend-api '; then \ @@ -34,6 +34,7 @@ install: else \ conda env create -f environment.yml; \ fi + conda activate backend-api $(MAKE) install-pre-commit install-pre-commit: diff --git a/bots/controllers/directional_trading/ai_livestream.py b/bots/controllers/directional_trading/ai_livestream.py new file mode 100644 index 00000000..6cef9cfa --- /dev/null +++ b/bots/controllers/directional_trading/ai_livestream.py @@ -0,0 +1,86 @@ +from decimal import Decimal +from typing import List + +import pandas_ta as ta # noqa: F401 +from pydantic import Field + +from hummingbot.core.data_type.common import TradeType +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.remote_iface.mqtt import ExternalTopicFactory +from hummingbot.strategy_v2.controllers.directional_trading_controller_base import ( + DirectionalTradingControllerBase, + DirectionalTradingControllerConfigBase, +) +from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig + + +class AILivestreamControllerConfig(DirectionalTradingControllerConfigBase): + controller_name: str = "ai_livestream" + candles_config: List[CandlesConfig] = [] + long_threshold: float = Field(default=0.5, json_schema_extra={"is_updatable": True}) + short_threshold: float = Field(default=0.5, json_schema_extra={"is_updatable": True}) + topic: str = "hbot/predictions" + + +class AILivestreamController(DirectionalTradingControllerBase): + def __init__(self, config: AILivestreamControllerConfig, *args, **kwargs): + self.config = config + super().__init__(config, *args, **kwargs) + # Start ML signal listener + self._init_ml_signal_listener() + + def _init_ml_signal_listener(self): + """Initialize a listener for ML signals from the MQTT broker""" + try: + normalized_pair = self.config.trading_pair.replace("-", "_").lower() + topic = f"{self.config.topic}/{normalized_pair}/ML_SIGNALS" + self._ml_signal_listener = ExternalTopicFactory.create_async( + topic=topic, + callback=self._handle_ml_signal, + use_bot_prefix=False, + ) + self.logger().info("ML signal listener initialized successfully") + except Exception as e: + self.logger().error(f"Failed to initialize ML signal listener: {str(e)}") + self._ml_signal_listener = None + + def _handle_ml_signal(self, signal: dict, topic: str): + """Handle incoming ML signal""" + # self.logger().info(f"Received ML signal: {signal}") + short, neutral, long = signal["probabilities"] + if short > self.config.short_threshold: + self.processed_data["signal"] = -1 + elif long > self.config.long_threshold: + self.processed_data["signal"] = 1 + else: + self.processed_data["signal"] = 0 + self.processed_data["features"] = signal + + async def update_processed_data(self): + pass + + def get_executor_config(self, trade_type: TradeType, price: Decimal, amount: Decimal): + """ + Get the executor config based on the trade_type, price and amount. This method can be overridden by the + subclasses if required. + """ + return PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + side=trade_type, + entry_price=price, + amount=amount, + triple_barrier_config=self.config.triple_barrier_config.new_instance_with_adjusted_volatility( + volatility_factor=self.processed_data["features"].get("target_pct", 0.01)), + leverage=self.config.leverage, + ) + + def to_format_status(self) -> List[str]: + lines = [] + features = self.processed_data.get("features", {}) + lines.append(f"Signal: {self.processed_data.get('signal', 'N/A')}") + lines.append(f"Timestamp: {features.get('timestamp', 'N/A')}") + lines.append(f"Probabilities: {features.get('probabilities', 'N/A')}") + lines.append(f"Target Pct: {features.get('target_pct', 'N/A')}") + return lines diff --git a/bots/controllers/directional_trading/bollinger_v1.py b/bots/controllers/directional_trading/bollinger_v1.py index 8f1e92e2..bfb476b1 100644 --- a/bots/controllers/directional_trading/bollinger_v1.py +++ b/bots/controllers/directional_trading/bollinger_v1.py @@ -1,56 +1,53 @@ from typing import List import pandas_ta as ta # noqa: F401 -from hummingbot.client.config.config_data_types import ClientFieldData +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo + from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.directional_trading_controller_base import ( DirectionalTradingControllerBase, DirectionalTradingControllerConfigBase, ) -from pydantic import Field, validator class BollingerV1ControllerConfig(DirectionalTradingControllerConfigBase): - controller_name = "bollinger_v1" + controller_name: str = "bollinger_v1" candles_config: List[CandlesConfig] = [] - candles_connector: str = Field(default=None) - candles_trading_pair: str = Field(default=None) + candles_connector: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the connector for the candles data, leave empty to use the same exchange as the connector: ", + "prompt_on_new": True}) + candles_trading_pair: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the trading pair for the candles data, leave empty to use the same trading pair as the connector: ", + "prompt_on_new": True}) interval: str = Field( default="3m", - client_data=ClientFieldData( - prompt=lambda mi: "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", - prompt_on_new=False)) + json_schema_extra={ + "prompt": "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", + "prompt_on_new": True}) bb_length: int = Field( default=100, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands length: ", - prompt_on_new=True)) - bb_std: float = Field( - default=2.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands standard deviation: ", - prompt_on_new=False)) - bb_long_threshold: float = Field( - default=0.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands long threshold: ", - prompt_on_new=True)) - bb_short_threshold: float = Field( - default=1.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands short threshold: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the Bollinger Bands length: ", "prompt_on_new": True}) + bb_std: float = Field(default=2.0) + bb_long_threshold: float = Field(default=0.0) + bb_short_threshold: float = Field(default=1.0) - @validator("candles_connector", pre=True, always=True) - def set_candles_connector(cls, v, values): + @field_validator("candles_connector", mode="before") + @classmethod + def set_candles_connector(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("connector_name") + return validation_info.data.get("connector_name") return v - @validator("candles_trading_pair", pre=True, always=True) - def set_candles_trading_pair(cls, v, values): + @field_validator("candles_trading_pair", mode="before") + @classmethod + def set_candles_trading_pair(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("trading_pair") + return validation_info.data.get("trading_pair") return v diff --git a/bots/controllers/directional_trading/dman_v3.py b/bots/controllers/directional_trading/dman_v3.py index cdf3c13a..ca648d76 100644 --- a/bots/controllers/directional_trading/dman_v3.py +++ b/bots/controllers/directional_trading/dman_v3.py @@ -3,7 +3,6 @@ from typing import List, Optional, Tuple import pandas_ta as ta # noqa: F401 -from hummingbot.client.config.config_data_types import ClientFieldData from hummingbot.core.data_type.common import TradeType from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.directional_trading_controller_base import ( @@ -12,74 +11,72 @@ ) from hummingbot.strategy_v2.executors.dca_executor.data_types import DCAExecutorConfig, DCAMode from hummingbot.strategy_v2.executors.position_executor.data_types import TrailingStop -from pydantic import Field, validator +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo class DManV3ControllerConfig(DirectionalTradingControllerConfigBase): controller_name: str = "dman_v3" candles_config: List[CandlesConfig] = [] - candles_connector: str = Field(default=None) - candles_trading_pair: str = Field(default=None) + candles_connector: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the connector for the candles data, leave empty to use the same exchange as the connector: ", + "prompt_on_new": True}) + candles_trading_pair: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the trading pair for the candles data, leave empty to use the same trading pair as the connector: ", + "prompt_on_new": True}) interval: str = Field( - default="30m", - client_data=ClientFieldData( - prompt=lambda mi: "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", - prompt_on_new=True)) + default="3m", + json_schema_extra={ + "prompt": "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", + "prompt_on_new": True}) bb_length: int = Field( default=100, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands length: ", - prompt_on_new=True)) - bb_std: float = Field( - default=2.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands standard deviation: ", - prompt_on_new=False)) - bb_long_threshold: float = Field( - default=0.0, - client_data=ClientFieldData( - is_updatable=True, - prompt=lambda mi: "Enter the Bollinger Bands long threshold: ", - prompt_on_new=True)) - bb_short_threshold: float = Field( - default=1.0, - client_data=ClientFieldData( - is_updatable=True, - prompt=lambda mi: "Enter the Bollinger Bands short threshold: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the Bollinger Bands length: ", "prompt_on_new": True}) + bb_std: float = Field(default=2.0) + bb_long_threshold: float = Field(default=0.0) + bb_short_threshold: float = Field(default=1.0) + trailing_stop: Optional[TrailingStop] = Field( + default="0.015,0.005", + json_schema_extra={ + "prompt": "Enter the trailing stop parameters (activation_price, trailing_delta) as a comma-separated list: ", + "prompt_on_new": True, + } + ) dca_spreads: List[Decimal] = Field( default="0.001,0.018,0.15,0.25", - client_data=ClientFieldData( - prompt=lambda - mi: "Enter the spreads for each DCA level (comma-separated) if dynamic_spread=True this value " - "will multiply the Bollinger Bands width, e.g. if the Bollinger Bands width is 0.1 (10%)" - "and the spread is 0.2, the distance of the order to the current price will be 0.02 (2%) ", - prompt_on_new=True)) + json_schema_extra={ + "prompt": "Enter the spreads for each DCA level (comma-separated) if dynamic_spread=True this value " + "will multiply the Bollinger Bands width, e.g. if the Bollinger Bands width is 0.1 (10%)" + "and the spread is 0.2, the distance of the order to the current price will be 0.02 (2%) ", + "prompt_on_new": True}, + ) dca_amounts_pct: List[Decimal] = Field( default=None, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the amounts for each DCA level (as a percentage of the total balance, " - "comma-separated). Don't worry about the final sum, it will be normalized. ", - prompt_on_new=True)) + json_schema_extra={ + "prompt": "Enter the amounts for each DCA level (as a percentage of the total balance, " + "comma-separated). Don't worry about the final sum, it will be normalized. ", + "prompt_on_new": True}, + ) dynamic_order_spread: bool = Field( default=None, - client_data=ClientFieldData( - prompt=lambda mi: "Do you want to make the spread dynamic? (Yes/No) ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Do you want to make the spread dynamic? (Yes/No) ", "prompt_on_new": True}) dynamic_target: bool = Field( default=None, - client_data=ClientFieldData( - prompt=lambda mi: "Do you want to make the target dynamic? (Yes/No) ", - prompt_on_new=True)) - + json_schema_extra={"prompt": "Do you want to make the target dynamic? (Yes/No) ", "prompt_on_new": True}) activation_bounds: Optional[List[Decimal]] = Field( default=None, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the activation bounds for the orders " - "(e.g., 0.01 activates the next order when the price is closer than 1%): ", - prompt_on_new=True)) - - @validator("activation_bounds", pre=True, always=True) + json_schema_extra={ + "prompt": "Enter the activation bounds for the orders (e.g., 0.01 activates the next order when the price is closer than 1%): ", + "prompt_on_new": True, + } + ) + + @field_validator("activation_bounds", mode="before") + @classmethod def parse_activation_bounds(cls, v): if isinstance(v, str): if v == "": @@ -89,15 +86,17 @@ def parse_activation_bounds(cls, v): return [Decimal(val) for val in v] return v - @validator('dca_spreads', pre=True, always=True) + @field_validator('dca_spreads', mode="before") + @classmethod def validate_spreads(cls, v): if isinstance(v, str): return [Decimal(val) for val in v.split(",")] return v - @validator('dca_amounts_pct', pre=True, always=True) - def validate_amounts(cls, v, values): - spreads = values.get("dca_spreads") + @field_validator('dca_amounts_pct', mode="before") + @classmethod + def validate_amounts(cls, v, validation_info: ValidationInfo): + spreads = validation_info.data.get("dca_spreads") if isinstance(v, str): if v == "": return [Decimal('1.0') / len(spreads) for _ in spreads] @@ -109,9 +108,21 @@ def validate_amounts(cls, v, values): return [Decimal('1.0') / len(spreads) for _ in spreads] return v - def get_spreads_and_amounts_in_quote(self, - trade_type: TradeType, - total_amount_quote: Decimal) -> Tuple[List[Decimal], List[Decimal]]: + @field_validator("candles_connector", mode="before") + @classmethod + def set_candles_connector(cls, v, validation_info: ValidationInfo): + if v is None or v == "": + return validation_info.data.get("connector_name") + return v + + @field_validator("candles_trading_pair", mode="before") + @classmethod + def set_candles_trading_pair(cls, v, validation_info: ValidationInfo): + if v is None or v == "": + return validation_info.data.get("trading_pair") + return v + + def get_spreads_and_amounts_in_quote(self, trade_type: TradeType, total_amount_quote: Decimal) -> Tuple[List[Decimal], List[Decimal]]: amounts_pct = self.dca_amounts_pct if amounts_pct is None: # Equally distribute if amounts_pct is not set @@ -125,25 +136,12 @@ def get_spreads_and_amounts_in_quote(self, return self.dca_spreads, [amt_pct * total_amount_quote for amt_pct in normalized_amounts_pct] - @validator("candles_connector", pre=True, always=True) - def set_candles_connector(cls, v, values): - if v is None or v == "": - return values.get("connector_name") - return v - - @validator("candles_trading_pair", pre=True, always=True) - def set_candles_trading_pair(cls, v, values): - if v is None or v == "": - return values.get("trading_pair") - return v - class DManV3Controller(DirectionalTradingControllerBase): """ Mean reversion strategy with Grid execution making use of Bollinger Bands indicator to make spreads dynamic and shift the mid-price. """ - def __init__(self, config: DManV3ControllerConfig, *args, **kwargs): self.config = config self.max_records = config.bb_length @@ -194,9 +192,12 @@ def get_executor_config(self, trade_type: TradeType, price: Decimal, amount: Dec prices = [price * (1 + spread * spread_multiplier) for spread in spread] if self.config.dynamic_target: stop_loss = self.config.stop_loss * spread_multiplier - trailing_stop = TrailingStop( - activation_price=self.config.trailing_stop.activation_price * spread_multiplier, - trailing_delta=self.config.trailing_stop.trailing_delta * spread_multiplier) + if self.config.trailing_stop: + trailing_stop = TrailingStop( + activation_price=self.config.trailing_stop.activation_price * spread_multiplier, + trailing_delta=self.config.trailing_stop.trailing_delta * spread_multiplier) + else: + trailing_stop = None else: stop_loss = self.config.stop_loss trailing_stop = self.config.trailing_stop diff --git a/bots/controllers/directional_trading/macd_bb_v1.py b/bots/controllers/directional_trading/macd_bb_v1.py index ab215cbd..f792ecf8 100644 --- a/bots/controllers/directional_trading/macd_bb_v1.py +++ b/bots/controllers/directional_trading/macd_bb_v1.py @@ -1,71 +1,62 @@ from typing import List import pandas_ta as ta # noqa: F401 -from hummingbot.client.config.config_data_types import ClientFieldData +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo + from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.directional_trading_controller_base import ( DirectionalTradingControllerBase, DirectionalTradingControllerConfigBase, ) -from pydantic import Field, validator class MACDBBV1ControllerConfig(DirectionalTradingControllerConfigBase): - controller_name = "macd_bb_v1" + controller_name: str = "macd_bb_v1" candles_config: List[CandlesConfig] = [] - candles_connector: str = Field(default=None) - candles_trading_pair: str = Field(default=None) + candles_connector: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the connector for the candles data, leave empty to use the same exchange as the connector: ", + "prompt_on_new": True}) + candles_trading_pair: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the trading pair for the candles data, leave empty to use the same trading pair as the connector: ", + "prompt_on_new": True}) interval: str = Field( default="3m", - client_data=ClientFieldData( - prompt=lambda mi: "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", - prompt_on_new=False)) + json_schema_extra={ + "prompt": "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", + "prompt_on_new": True}) bb_length: int = Field( default=100, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands length: ", - prompt_on_new=True)) - bb_std: float = Field( - default=2.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands standard deviation: ", - prompt_on_new=False)) - bb_long_threshold: float = Field( - default=0.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands long threshold: ", - prompt_on_new=True)) - bb_short_threshold: float = Field( - default=1.0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the Bollinger Bands short threshold: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the Bollinger Bands length: ", "prompt_on_new": True}) + bb_std: float = Field(default=2.0) + bb_long_threshold: float = Field(default=0.0) + bb_short_threshold: float = Field(default=1.0) macd_fast: int = Field( default=21, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the MACD fast period: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the MACD fast period: ", "prompt_on_new": True}) macd_slow: int = Field( default=42, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the MACD slow period: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the MACD slow period: ", "prompt_on_new": True}) macd_signal: int = Field( default=9, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the MACD signal period: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the MACD signal period: ", "prompt_on_new": True}) - @validator("candles_connector", pre=True, always=True) - def set_candles_connector(cls, v, values): + @field_validator("candles_connector", mode="before") + @classmethod + def set_candles_connector(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("connector_name") + return validation_info.data.get("connector_name") return v - @validator("candles_trading_pair", pre=True, always=True) - def set_candles_trading_pair(cls, v, values): + @field_validator("candles_trading_pair", mode="before") + @classmethod + def set_candles_trading_pair(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("trading_pair") + return validation_info.data.get("trading_pair") return v @@ -73,7 +64,7 @@ class MACDBBV1Controller(DirectionalTradingControllerBase): def __init__(self, config: MACDBBV1ControllerConfig, *args, **kwargs): self.config = config - self.max_records = max(config.macd_slow, config.macd_fast, config.macd_signal, config.bb_length) + self.max_records = max(config.macd_slow, config.macd_fast, config.macd_signal, config.bb_length) + 20 if len(self.config.candles_config) == 0: self.config.candles_config = [CandlesConfig( connector=config.candles_connector, diff --git a/bots/controllers/directional_trading/supertrend_v1.py b/bots/controllers/directional_trading/supertrend_v1.py index e96f4465..10f3ea84 100644 --- a/bots/controllers/directional_trading/supertrend_v1.py +++ b/bots/controllers/directional_trading/supertrend_v1.py @@ -1,39 +1,54 @@ -from typing import List, Optional +from typing import List import pandas_ta as ta # noqa: F401 -from hummingbot.client.config.config_data_types import ClientFieldData +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo + from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.directional_trading_controller_base import ( DirectionalTradingControllerBase, DirectionalTradingControllerConfigBase, ) -from pydantic import Field, validator class SuperTrendConfig(DirectionalTradingControllerConfigBase): controller_name: str = "supertrend_v1" candles_config: List[CandlesConfig] = [] - candles_connector: Optional[str] = Field(default=None) - candles_trading_pair: Optional[str] = Field(default=None) - interval: str = Field(default="3m") - length: int = Field(default=20, client_data=ClientFieldData(prompt=lambda mi: "Enter the supertrend length: ", - prompt_on_new=True)) - multiplier: float = Field(default=4.0, - client_data=ClientFieldData(prompt=lambda mi: "Enter the supertrend multiplier: ", - prompt_on_new=True)) - percentage_threshold: float = Field(default=0.01, client_data=ClientFieldData( - prompt=lambda mi: "Enter the percentage threshold: ", prompt_on_new=True)) + candles_connector: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the connector for the candles data, leave empty to use the same exchange as the connector: ", + "prompt_on_new": True}) + candles_trading_pair: str = Field( + default=None, + json_schema_extra={ + "prompt": "Enter the trading pair for the candles data, leave empty to use the same trading pair as the connector: ", + "prompt_on_new": True}) + interval: str = Field( + default="3m", + json_schema_extra={"prompt": "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", "prompt_on_new": True}) + length: int = Field( + default=20, + json_schema_extra={"prompt": "Enter the supertrend length: ", "prompt_on_new": True}) + multiplier: float = Field( + default=4.0, + json_schema_extra={"prompt": "Enter the supertrend multiplier: ", "prompt_on_new": True}) + percentage_threshold: float = Field( + default=0.01, + json_schema_extra={"prompt": "Enter the percentage threshold: ", "prompt_on_new": True}) - @validator("candles_connector", pre=True, always=True) - def set_candles_connector(cls, v, values): + @field_validator("candles_connector", mode="before") + @classmethod + def set_candles_connector(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("connector_name") + return validation_info.data.get("connector_name") return v - @validator("candles_trading_pair", pre=True, always=True) - def set_candles_trading_pair(cls, v, values): + @field_validator("candles_trading_pair", mode="before") + @classmethod + def set_candles_trading_pair(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("trading_pair") + return validation_info.data.get("trading_pair") return v @@ -57,14 +72,11 @@ async def update_processed_data(self): max_records=self.max_records) # Add indicators df.ta.supertrend(length=self.config.length, multiplier=self.config.multiplier, append=True) - df["percentage_distance"] = abs(df["close"] - df[f"SUPERT_{self.config.length}_{self.config.multiplier}"]) / df[ - "close"] + df["percentage_distance"] = abs(df["close"] - df[f"SUPERT_{self.config.length}_{self.config.multiplier}"]) / df["close"] # Generate long and short conditions - long_condition = (df[f"SUPERTd_{self.config.length}_{self.config.multiplier}"] == 1) & ( - df["percentage_distance"] < self.config.percentage_threshold) - short_condition = (df[f"SUPERTd_{self.config.length}_{self.config.multiplier}"] == -1) & ( - df["percentage_distance"] < self.config.percentage_threshold) + long_condition = (df[f"SUPERTd_{self.config.length}_{self.config.multiplier}"] == 1) & (df["percentage_distance"] < self.config.percentage_threshold) + short_condition = (df[f"SUPERTd_{self.config.length}_{self.config.multiplier}"] == -1) & (df["percentage_distance"] < self.config.percentage_threshold) # Choose side df['signal'] = 0 diff --git a/bots/controllers/generic/arbitrage_controller.py b/bots/controllers/generic/arbitrage_controller.py new file mode 100644 index 00000000..ff8f6517 --- /dev/null +++ b/bots/controllers/generic/arbitrage_controller.py @@ -0,0 +1,157 @@ +from decimal import Decimal +from typing import Dict, List, Set + +import pandas as pd + +from hummingbot.client.ui.interface_utils import format_df_for_printout +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy_v2.controllers.controller_base import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.arbitrage_executor.data_types import ArbitrageExecutorConfig +from hummingbot.strategy_v2.executors.data_types import ConnectorPair +from hummingbot.strategy_v2.models.base import RunnableStatus +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction + + +class ArbitrageControllerConfig(ControllerConfigBase): + controller_name: str = "arbitrage_controller" + candles_config: List[CandlesConfig] = [] + exchange_pair_1: ConnectorPair = ConnectorPair(connector_name="binance", trading_pair="PENGU-USDT") + exchange_pair_2: ConnectorPair = ConnectorPair(connector_name="solana_jupiter_mainnet-beta", trading_pair="PENGU-USDC") + min_profitability: Decimal = Decimal("0.01") + delay_between_executors: int = 10 # in seconds + max_executors_imbalance: int = 1 + rate_connector: str = "binance" + quote_conversion_asset: str = "USDT" + + def update_markets(self, markets: Dict[str, Set[str]]) -> Dict[str, Set[str]]: + if self.exchange_pair_1.connector_name == self.exchange_pair_2.connector_name: + markets.update({ + self.exchange_pair_1.connector_name: {self.exchange_pair_1.trading_pair, + self.exchange_pair_2.trading_pair} + }) + else: + markets.update({ + self.exchange_pair_1.connector_name: {self.exchange_pair_1.trading_pair}, + self.exchange_pair_2.connector_name: {self.exchange_pair_2.trading_pair} + }) + return markets + + +class ArbitrageController(ControllerBase): + gas_token_by_network = { + "ethereum": "ETH", + "solana": "SOL", + "binance-smart-chain": "BNB", + "polygon": "POL", + "avalanche": "AVAX", + "dexalot": "AVAX" + } + + def __init__(self, config: ArbitrageControllerConfig, *args, **kwargs): + self.config = config + super().__init__(config, *args, **kwargs) + self._imbalance = 0 + self._last_buy_closed_timestamp = 0 + self._last_sell_closed_timestamp = 0 + self._len_active_buy_arbitrages = 0 + self._len_active_sell_arbitrages = 0 + self.base_asset = self.config.exchange_pair_1.trading_pair.split("-")[0] + self.initialize_rate_sources() + + def initialize_rate_sources(self): + rates_required = [] + for connector_pair in [self.config.exchange_pair_1, self.config.exchange_pair_2]: + base, quote = connector_pair.trading_pair.split("-") + # Add rate source for gas token + if connector_pair.is_amm_connector(): + gas_token = self.get_gas_token(connector_pair.connector_name) + if gas_token != quote: + rates_required.append(ConnectorPair(connector_name=self.config.rate_connector, + trading_pair=f"{gas_token}-{quote}")) + + # Add rate source for quote conversion asset + if quote != self.config.quote_conversion_asset: + rates_required.append(ConnectorPair(connector_name=self.config.rate_connector, + trading_pair=f"{quote}-{self.config.quote_conversion_asset}")) + + # Add rate source for trading pairs + rates_required.append(ConnectorPair(connector_name=connector_pair.connector_name, + trading_pair=connector_pair.trading_pair)) + if len(rates_required) > 0: + self.market_data_provider.initialize_rate_sources(rates_required) + + def get_gas_token(self, connector_name: str) -> str: + _, chain, _ = connector_name.split("_") + return self.gas_token_by_network[chain] + + async def update_processed_data(self): + pass + + def determine_executor_actions(self) -> List[ExecutorAction]: + self.update_arbitrage_stats() + executor_actions = [] + current_time = self.market_data_provider.time() + if (abs(self._imbalance) >= self.config.max_executors_imbalance or + self._last_buy_closed_timestamp + self.config.delay_between_executors > current_time or + self._last_sell_closed_timestamp + self.config.delay_between_executors > current_time): + return executor_actions + if self._len_active_buy_arbitrages == 0: + executor_actions.append(self.create_arbitrage_executor_action(self.config.exchange_pair_1, + self.config.exchange_pair_2)) + if self._len_active_sell_arbitrages == 0: + executor_actions.append(self.create_arbitrage_executor_action(self.config.exchange_pair_2, + self.config.exchange_pair_1)) + return executor_actions + + def create_arbitrage_executor_action(self, buying_exchange_pair: ConnectorPair, + selling_exchange_pair: ConnectorPair): + try: + if buying_exchange_pair.is_amm_connector(): + gas_token = self.get_gas_token(buying_exchange_pair.connector_name) + pair = buying_exchange_pair.trading_pair.split("-")[0] + "-" + gas_token + gas_conversion_price = self.market_data_provider.get_rate(pair) + elif selling_exchange_pair.is_amm_connector(): + gas_token = self.get_gas_token(selling_exchange_pair.connector_name) + pair = selling_exchange_pair.trading_pair.split("-")[0] + "-" + gas_token + gas_conversion_price = self.market_data_provider.get_rate(pair) + else: + gas_conversion_price = None + rate = self.market_data_provider.get_rate(self.base_asset + "-" + self.config.quote_conversion_asset) + amount_quantized = self.market_data_provider.quantize_order_amount( + buying_exchange_pair.connector_name, buying_exchange_pair.trading_pair, + self.config.total_amount_quote / rate) + arbitrage_config = ArbitrageExecutorConfig( + timestamp=self.market_data_provider.time(), + buying_market=buying_exchange_pair, + selling_market=selling_exchange_pair, + order_amount=amount_quantized, + min_profitability=self.config.min_profitability, + gas_conversion_price=gas_conversion_price, + ) + return CreateExecutorAction( + executor_config=arbitrage_config, + controller_id=self.config.id) + except Exception as e: + self.logger().error( + f"Error creating executor to buy on {buying_exchange_pair.connector_name} and sell on {selling_exchange_pair.connector_name}, {e}") + + def update_arbitrage_stats(self): + closed_executors = [e for e in self.executors_info if e.status == RunnableStatus.TERMINATED] + active_executors = [e for e in self.executors_info if e.status != RunnableStatus.TERMINATED] + buy_arbitrages = [arbitrage for arbitrage in closed_executors if + arbitrage.config.buying_market == self.config.exchange_pair_1] + sell_arbitrages = [arbitrage for arbitrage in closed_executors if + arbitrage.config.buying_market == self.config.exchange_pair_2] + self._imbalance = len(buy_arbitrages) - len(sell_arbitrages) + self._last_buy_closed_timestamp = max([arbitrage.close_timestamp for arbitrage in buy_arbitrages]) if len( + buy_arbitrages) > 0 else 0 + self._last_sell_closed_timestamp = max([arbitrage.close_timestamp for arbitrage in sell_arbitrages]) if len( + sell_arbitrages) > 0 else 0 + self._len_active_buy_arbitrages = len([arbitrage for arbitrage in active_executors if + arbitrage.config.buying_market == self.config.exchange_pair_1]) + self._len_active_sell_arbitrages = len([arbitrage for arbitrage in active_executors if + arbitrage.config.buying_market == self.config.exchange_pair_2]) + + def to_format_status(self) -> List[str]: + all_executors_custom_info = pd.DataFrame(e.custom_info for e in self.executors_info) + return [format_df_for_printout(all_executors_custom_info, table_format="psql", )] diff --git a/bots/controllers/generic/basic_order_example.py b/bots/controllers/generic/basic_order_example.py new file mode 100644 index 00000000..10368da4 --- /dev/null +++ b/bots/controllers/generic/basic_order_example.py @@ -0,0 +1,55 @@ +from decimal import Decimal +from typing import Dict, Set + +from hummingbot.core.data_type.common import PositionMode, PriceType, TradeType +from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction + + +class BasicOrderExampleConfig(ControllerConfigBase): + controller_name: str = "basic_order_example" + controller_type: str = "generic" + connector_name: str = "binance_perpetual" + trading_pair: str = "WLD-USDT" + side: TradeType = TradeType.BUY + position_mode: PositionMode = PositionMode.HEDGE + leverage: int = 50 + amount_quote: Decimal = Decimal("10") + order_frequency: int = 10 + + 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() + markets[self.connector_name].add(self.trading_pair) + return markets + + +class BasicOrderExample(ControllerBase): + def __init__(self, config: BasicOrderExampleConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + self.last_timestamp = 0 + + def determine_executor_actions(self) -> list[ExecutorAction]: + if (self.processed_data["n_active_executors"] == 0 and + self.market_data_provider.time() - self.last_timestamp > self.config.order_frequency): + self.last_timestamp = self.market_data_provider.time() + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + side=self.config.side, + amount=self.config.amount_quote / self.processed_data["mid_price"], + execution_strategy=ExecutionStrategy.MARKET, + price=self.processed_data["mid_price"], + ) + return [CreateExecutorAction( + controller_id=self.config.id, + executor_config=config)] + return [] + + async def update_processed_data(self): + mid_price = self.market_data_provider.get_price_by_type(self.config.connector_name, self.config.trading_pair, PriceType.MidPrice) + n_active_executors = len([executor for executor in self.executors_info if executor.is_active]) + self.processed_data = {"mid_price": mid_price, "n_active_executors": n_active_executors} diff --git a/bots/controllers/generic/basic_order_open_close_example.py b/bots/controllers/generic/basic_order_open_close_example.py new file mode 100644 index 00000000..bfeef02d --- /dev/null +++ b/bots/controllers/generic/basic_order_open_close_example.py @@ -0,0 +1,87 @@ +from decimal import Decimal +from typing import Dict, Set + +from hummingbot.core.data_type.common import PositionAction, PositionMode, PriceType, TradeType +from hummingbot.strategy_v2.controllers import ControllerBase, ControllerConfigBase +from hummingbot.strategy_v2.executors.order_executor.data_types import ExecutionStrategy, OrderExecutorConfig +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction + + +class BasicOrderOpenCloseExampleConfig(ControllerConfigBase): + controller_name: str = "basic_order_open_close_example" + controller_type: str = "generic" + connector_name: str = "binance_perpetual" + trading_pair: str = "WLD-USDT" + side: TradeType = TradeType.BUY + position_mode: PositionMode = PositionMode.HEDGE + leverage: int = 50 + close_order_delay: int = 10 + open_short_to_close_long: bool = False + close_partial_position: bool = False + amount_quote: Decimal = Decimal("20") + + 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() + markets[self.connector_name].add(self.trading_pair) + return markets + + +class BasicOrderOpenClose(ControllerBase): + def __init__(self, config: BasicOrderOpenCloseExampleConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + self.open_order_placed = False + self.closed_order_placed = False + self.last_timestamp = 0 + self.open_side = self.config.side + self.close_side = TradeType.SELL if self.config.side == TradeType.BUY else TradeType.BUY + + def get_position(self, connector_name, trading_pair): + for position in self.positions_held: + if position.connector_name == connector_name and position.trading_pair == trading_pair: + return position + + def determine_executor_actions(self) -> list[ExecutorAction]: + mid_price = self.market_data_provider.get_price_by_type(self.config.connector_name, self.config.trading_pair, PriceType.MidPrice) + if not self.open_order_placed: + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + side=self.config.side, + amount=self.config.amount_quote / mid_price, + execution_strategy=ExecutionStrategy.MARKET, + position_action=PositionAction.OPEN, + price=mid_price, + ) + self.open_order_placed = True + self.last_timestamp = self.market_data_provider.time() + return [CreateExecutorAction( + controller_id=self.config.id, + executor_config=config)] + else: + if self.market_data_provider.time() - self.last_timestamp > self.config.close_order_delay and not self.closed_order_placed: + current_position = self.get_position(self.config.connector_name, self.config.trading_pair) + if current_position is None: + self.logger().info("The original position is not found, can close the position") + else: + amount = current_position.amount / 2 if self.config.close_partial_position else current_position.amount + config = OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + side=self.close_side, + amount=amount, + execution_strategy=ExecutionStrategy.MARKET, + position_action=PositionAction.OPEN if self.config.open_short_to_close_long else PositionAction.CLOSE, + price=mid_price, + ) + self.closed_order_placed = True + return [CreateExecutorAction( + controller_id=self.config.id, + executor_config=config)] + return [] + + async def update_processed_data(self): + pass diff --git a/bots/controllers/generic/grid_strike.py b/bots/controllers/generic/grid_strike.py index f5062502..a45b83c7 100644 --- a/bots/controllers/generic/grid_strike.py +++ b/bots/controllers/generic/grid_strike.py @@ -1,54 +1,55 @@ from decimal import Decimal from typing import Dict, List, Optional, Set -from hummingbot.client.config.config_data_types import ClientFieldData from hummingbot.core.data_type.common import OrderType, PositionMode, PriceType, TradeType -from hummingbot.core.data_type.trade_fee import TokenAmount 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 +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, ExecutorAction from hummingbot.strategy_v2.models.executors_info import ExecutorInfo -from hummingbot.strategy_v2.utils.distributions import Distributions -from pydantic import BaseModel, Field - - -class GridRange(BaseModel): - id: str - start_price: Decimal - end_price: Decimal - total_amount_pct: Decimal - side: TradeType = TradeType.BUY - open_order_type: OrderType = OrderType.LIMIT_MAKER - take_profit_order_type: OrderType = OrderType.LIMIT - active: bool = True +from pydantic import Field class GridStrikeConfig(ControllerConfigBase): """ Configuration required to run the GridStrike strategy for one connector and trading pair. """ + controller_type: str = "generic" controller_name: str = "grid_strike" candles_config: List[CandlesConfig] = [] - controller_type = "generic" - connector_name: str = "binance" - trading_pair: str = "BTC-USDT" - total_amount_quote: Decimal = Field(default=Decimal("1000"), client_data=ClientFieldData(is_updatable=True)) - grid_ranges: List[GridRange] = Field(default=[GridRange(id="R0", start_price=Decimal("40000"), - end_price=Decimal("60000"), - total_amount_pct=Decimal("0.1"))], - client_data=ClientFieldData(is_updatable=True)) + + # Account configuration + leverage: int = 20 position_mode: PositionMode = PositionMode.HEDGE - leverage: int = 1 - time_limit: Optional[int] = Field(default=60 * 60 * 24 * 2, client_data=ClientFieldData(is_updatable=True)) - activation_bounds: Decimal = Field(default=Decimal("0.01"), client_data=ClientFieldData(is_updatable=True)) - min_spread_between_orders: Optional[Decimal] = Field(default=None, - client_data=ClientFieldData(is_updatable=True)) - min_order_amount: Optional[Decimal] = Field(default=Decimal("1"), - client_data=ClientFieldData(is_updatable=True)) - max_open_orders: int = Field(default=5, client_data=ClientFieldData(is_updatable=True)) - grid_range_update_interval: int = Field(default=60, client_data=ClientFieldData(is_updatable=True)) - extra_balance_base_usd: Decimal = Decimal("10") + + # Boundaries + connector_name: str = "binance_perpetual" + trading_pair: str = "WLD-USDT" + side: TradeType = TradeType.BUY + start_price: Decimal = Field(default=Decimal("0.58"), json_schema_extra={"is_updatable": True}) + end_price: Decimal = Field(default=Decimal("0.95"), json_schema_extra={"is_updatable": True}) + limit_price: Decimal = Field(default=Decimal("0.55"), json_schema_extra={"is_updatable": True}) + + # Profiling + total_amount_quote: Decimal = Field(default=Decimal("1000"), json_schema_extra={"is_updatable": True}) + min_spread_between_orders: Optional[Decimal] = Field(default=Decimal("0.001"), json_schema_extra={"is_updatable": True}) + min_order_amount_quote: Optional[Decimal] = Field(default=Decimal("5"), json_schema_extra={"is_updatable": True}) + + # Execution + max_open_orders: int = Field(default=2, json_schema_extra={"is_updatable": True}) + max_orders_per_batch: Optional[int] = Field(default=1, json_schema_extra={"is_updatable": True}) + order_frequency: int = Field(default=3, json_schema_extra={"is_updatable": True}) + activation_bounds: Optional[Decimal] = Field(default=None, json_schema_extra={"is_updatable": True}) + keep_position: bool = Field(default=False, json_schema_extra={"is_updatable": True}) + + # Risk Management + triple_barrier_config: TripleBarrierConfig = TripleBarrierConfig( + take_profit=Decimal("0.001"), + open_order_type=OrderType.LIMIT_MAKER, + take_profit_order_type=OrderType.LIMIT_MAKER, + ) def update_markets(self, markets: Dict[str, Set[str]]) -> Dict[str, Set[str]]: if self.connector_name not in markets: @@ -57,16 +58,6 @@ def update_markets(self, markets: Dict[str, Set[str]]) -> Dict[str, Set[str]]: return markets -class GridLevel(BaseModel): - id: str - price: Decimal - amount: Decimal - step: Decimal - side: TradeType - open_order_type: OrderType - take_profit_order_type: OrderType - - class GridStrike(ControllerBase): def __init__(self, config: GridStrikeConfig, *args, **kwargs): super().__init__(config, *args, **kwargs) @@ -74,151 +65,134 @@ def __init__(self, config: GridStrikeConfig, *args, **kwargs): self._last_grid_levels_update = 0 self.trading_rules = None self.grid_levels = [] + self.initialize_rate_sources() - def _calculate_grid_config(self): - self.trading_rules = self.market_data_provider.get_trading_rules(self.config.connector_name, - self.config.trading_pair) - grid_levels = [] - if self.config.min_spread_between_orders: - spread_between_orders = self.config.min_spread_between_orders * self.get_mid_price() - step_proposed = max(self.trading_rules.min_price_increment, spread_between_orders) - else: - step_proposed = self.trading_rules.min_price_increment - amount_proposed = max(self.trading_rules.min_notional_size, self.config.min_order_amount) if \ - self.config.min_order_amount else self.trading_rules.min_order_size - for grid_range in self.config.grid_ranges: - if grid_range.active: - total_amount = grid_range.total_amount_pct * self.config.total_amount_quote - theoretical_orders_by_step = (grid_range.end_price - grid_range.start_price) / step_proposed - theoretical_orders_by_amount = total_amount / amount_proposed - orders = int(min(theoretical_orders_by_step, theoretical_orders_by_amount)) - prices = Distributions.linear(orders, float(grid_range.start_price), float(grid_range.end_price)) - step = (grid_range.end_price - grid_range.start_price) / grid_range.end_price / orders - if orders == 0: - self.logger().warning(f"Grid range {grid_range.id} has no orders, change the parameters " - f"(min order amount, amount pct, min spread between orders or total amount)") - amount_quote = total_amount / orders - for i, price in enumerate(prices): - price_quantized = self.market_data_provider.quantize_order_price( - self.config.connector_name, - self.config.trading_pair, price) - amount_quantized = self.market_data_provider.quantize_order_amount( - self.config.connector_name, - self.config.trading_pair, amount_quote / self.get_mid_price()) - # amount_quantized = amount_quote / self.get_mid_price() - grid_levels.append(GridLevel(id=f"{grid_range.id}_P{i}", - price=price_quantized, - amount=amount_quantized, - step=step, side=grid_range.side, - open_order_type=grid_range.open_order_type, - take_profit_order_type=grid_range.take_profit_order_type, - )) - return grid_levels - - def get_balance_requirements(self) -> List[TokenAmount]: - if "perpetual" in self.config.connector_name: - return [] - base_currency = self.config.trading_pair.split("-")[0] - return [TokenAmount(base_currency, self.config.extra_balance_base_usd / self.get_mid_price())] - - def get_mid_price(self) -> Decimal: - return self.market_data_provider.get_price_by_type( - self.config.connector_name, - self.config.trading_pair, - PriceType.MidPrice - ) - - def active_executors(self, is_trading: bool) -> List[ExecutorInfo]: + def initialize_rate_sources(self): + self.market_data_provider.initialize_rate_sources([ConnectorPair(connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair)]) + + def active_executors(self) -> List[ExecutorInfo]: return [ executor for executor in self.executors_info - if executor.is_active and executor.is_trading == is_trading + if executor.is_active ] + def is_inside_bounds(self, price: Decimal) -> bool: + return self.config.start_price <= price <= self.config.end_price + def determine_executor_actions(self) -> List[ExecutorAction]: - if self.market_data_provider.time() - self._last_grid_levels_update > 60: - self._last_grid_levels_update = self.market_data_provider.time() - self.grid_levels = self._calculate_grid_config() - return self.determine_create_executor_actions() + self.determine_stop_executor_actions() + mid_price = self.market_data_provider.get_price_by_type( + self.config.connector_name, self.config.trading_pair, PriceType.MidPrice) + if len(self.active_executors()) == 0 and self.is_inside_bounds(mid_price): + return [CreateExecutorAction( + controller_id=self.config.id, + executor_config=GridExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + start_price=self.config.start_price, + end_price=self.config.end_price, + leverage=self.config.leverage, + limit_price=self.config.limit_price, + side=self.config.side, + total_amount_quote=self.config.total_amount_quote, + min_spread_between_orders=self.config.min_spread_between_orders, + min_order_amount_quote=self.config.min_order_amount_quote, + max_open_orders=self.config.max_open_orders, + max_orders_per_batch=self.config.max_orders_per_batch, + order_frequency=self.config.order_frequency, + activation_bounds=self.config.activation_bounds, + triple_barrier_config=self.config.triple_barrier_config, + level_id=None, + keep_position=self.config.keep_position, + ))] + return [] async def update_processed_data(self): - mid_price = self.get_mid_price() - self.processed_data.update({ - "mid_price": mid_price, - "active_executors_order_placed": self.active_executors(is_trading=False), - "active_executors_order_trading": self.active_executors(is_trading=True), - "long_activation_bounds": mid_price * (1 - self.config.activation_bounds), - "short_activation_bounds": mid_price * (1 + self.config.activation_bounds), - }) - - def determine_create_executor_actions(self) -> List[ExecutorAction]: - mid_price = self.processed_data["mid_price"] - long_activation_bounds = self.processed_data["long_activation_bounds"] - short_activation_bounds = self.processed_data["short_activation_bounds"] - levels_allowed = [] - for level in self.grid_levels: - if (level.side == TradeType.BUY and level.price >= long_activation_bounds) or \ - (level.side == TradeType.SELL and level.price <= short_activation_bounds): - levels_allowed.append(level) - active_executors = self.processed_data["active_executors_order_placed"] + \ - self.processed_data["active_executors_order_trading"] - active_executors_level_id = [executor.custom_info["level_id"] for executor in active_executors] - levels_allowed = sorted([level for level in levels_allowed if level.id not in active_executors_level_id], - key=lambda level: abs(level.price - mid_price)) - levels_allowed = levels_allowed[:self.config.max_open_orders] - create_actions = [] - for level in levels_allowed: - if level.side == TradeType.BUY and level.price > mid_price: - entry_price = mid_price - take_profit = max(level.step * 2, ((level.price - mid_price) / mid_price) + level.step) - trailing_stop = None - # trailing_stop_ap = max(level.step * 2, ((mid_price - level.price) / mid_price) + level.step) - # trailing_stop = TrailingStop(activation_price=trailing_stop_ap, trailing_delta=level.step / 2) - elif level.side == TradeType.SELL and level.price < mid_price: - entry_price = mid_price - take_profit = max(level.step * 2, ((mid_price - level.price) / mid_price) + level.step) - # trailing_stop_ap = max(level.step * 2, ((mid_price - level.price) / mid_price) + level.step) - # trailing_stop = TrailingStop(activation_price=trailing_stop_ap, trailing_delta=level.step / 2) - trailing_stop = None - else: - entry_price = level.price - take_profit = level.step - trailing_stop = None - create_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, - entry_price=entry_price, - amount=level.amount, - leverage=self.config.leverage, - side=level.side, - level_id=level.id, - activation_bounds=[self.config.activation_bounds, - self.config.activation_bounds], - triple_barrier_config=TripleBarrierConfig( - take_profit=take_profit, - time_limit=self.config.time_limit, - open_order_type=OrderType.LIMIT_MAKER, - take_profit_order_type=level.take_profit_order_type, - trailing_stop=trailing_stop, - )))) - return create_actions - - def determine_stop_executor_actions(self) -> List[ExecutorAction]: - long_activation_bounds = self.processed_data["long_activation_bounds"] - short_activation_bounds = self.processed_data["short_activation_bounds"] - active_executors_order_placed = self.processed_data["active_executors_order_placed"] - non_active_ranges = [grid_range.id for grid_range in self.config.grid_ranges if not grid_range.active] - active_executor_of_non_active_ranges = [executor.id for executor in self.executors_info if - executor.is_active and - executor.custom_info["level_id"].split("_")[0] in non_active_ranges] - long_executors_to_stop = [executor.id for executor in active_executors_order_placed if - executor.side == TradeType.BUY and - executor.config.entry_price <= long_activation_bounds] - short_executors_to_stop = [executor.id for executor in active_executors_order_placed if - executor.side == TradeType.SELL and - executor.config.entry_price >= short_activation_bounds] - executors_id_to_stop = set( - active_executor_of_non_active_ranges + long_executors_to_stop + short_executors_to_stop) - return [StopExecutorAction(controller_id=self.config.id, executor_id=executor) for executor in - list(executors_id_to_stop)] + pass + + def to_format_status(self) -> List[str]: + status = [] + mid_price = self.market_data_provider.get_price_by_type( + self.config.connector_name, self.config.trading_pair, PriceType.MidPrice) + # Define standard box width for consistency + box_width = 114 + # Top Grid Configuration box with simple borders + status.append("┌" + "─" * box_width + "┐") + # First line: Grid Configuration and Mid Price + left_section = "Grid Configuration:" + padding = box_width - len(left_section) - 4 # -4 for the border characters and spacing + config_line1 = f"│ {left_section}{' ' * padding}" + padding2 = box_width - len(config_line1) + 1 # +1 for correct right border alignment + config_line1 += " " * padding2 + "│" + status.append(config_line1) + # Second line: Configuration parameters + config_line2 = f"│ Start: {self.config.start_price:.4f} │ End: {self.config.end_price:.4f} │ Side: {self.config.side} │ Limit: {self.config.limit_price:.4f} │ Mid Price: {mid_price:.4f} │" + padding = box_width - len(config_line2) + 1 # +1 for correct right border alignment + config_line2 += " " * padding + "│" + status.append(config_line2) + # Third line: Max orders and Inside bounds + config_line3 = f"│ Max Orders: {self.config.max_open_orders} │ Inside bounds: {1 if self.is_inside_bounds(mid_price) else 0}" + padding = box_width - len(config_line3) + 1 # +1 for correct right border alignment + config_line3 += " " * padding + "│" + status.append(config_line3) + status.append("└" + "─" * box_width + "┘") + for level in self.active_executors(): + # Define column widths for perfect alignment + col_width = box_width // 3 # Dividing the total width by 3 for equal columns + total_width = box_width + # Grid Status header - use long line and running status + status_header = f"Grid Status: {level.id} (RunnableStatus.RUNNING)" + status_line = f"┌ {status_header}" + "─" * (total_width - len(status_header) - 2) + "┐" + status.append(status_line) + # Calculate exact column widths for perfect alignment + col1_end = col_width + # Column headers + header_line = "│ Level Distribution" + " " * (col1_end - 20) + "│" + header_line += " Order Statistics" + " " * (col_width - 18) + "│" + header_line += " Performance Metrics" + " " * (col_width - 21) + "│" + status.append(header_line) + # Data for the three columns + level_dist_data = [ + f"NOT_ACTIVE: {len(level.custom_info['levels_by_state'].get('NOT_ACTIVE', []))}", + f"OPEN_ORDER_PLACED: {len(level.custom_info['levels_by_state'].get('OPEN_ORDER_PLACED', []))}", + f"OPEN_ORDER_FILLED: {len(level.custom_info['levels_by_state'].get('OPEN_ORDER_FILLED', []))}", + f"CLOSE_ORDER_PLACED: {len(level.custom_info['levels_by_state'].get('CLOSE_ORDER_PLACED', []))}", + f"COMPLETE: {len(level.custom_info['levels_by_state'].get('COMPLETE', []))}" + ] + order_stats_data = [ + f"Total: {sum(len(level.custom_info[k]) for k in ['filled_orders', 'failed_orders', 'canceled_orders'])}", + f"Filled: {len(level.custom_info['filled_orders'])}", + f"Failed: {len(level.custom_info['failed_orders'])}", + f"Canceled: {len(level.custom_info['canceled_orders'])}" + ] + perf_metrics_data = [ + f"Buy Vol: {level.custom_info['realized_buy_size_quote']:.4f}", + f"Sell Vol: {level.custom_info['realized_sell_size_quote']:.4f}", + f"R. PnL: {level.custom_info['realized_pnl_quote']:.4f}", + f"R. Fees: {level.custom_info['realized_fees_quote']:.4f}", + f"P. PnL: {level.custom_info['position_pnl_quote']:.4f}", + f"Position: {level.custom_info['position_size_quote']:.4f}" + ] + # Build rows with perfect alignment + max_rows = max(len(level_dist_data), len(order_stats_data), len(perf_metrics_data)) + for i in range(max_rows): + col1 = level_dist_data[i] if i < len(level_dist_data) else "" + col2 = order_stats_data[i] if i < len(order_stats_data) else "" + col3 = perf_metrics_data[i] if i < len(perf_metrics_data) else "" + row = "│ " + col1 + row += " " * (col1_end - len(col1) - 2) # -2 for the "│ " at the start + row += "│ " + col2 + row += " " * (col_width - len(col2) - 2) # -2 for the "│ " before col2 + row += "│ " + col3 + row += " " * (col_width - len(col3) - 2) # -2 for the "│ " before col3 + row += "│" + status.append(row) + # Liquidity line with perfect alignment + status.append("├" + "─" * total_width + "┤") + liquidity_line = f"│ Open Liquidity: {level.custom_info['open_liquidity_placed']:.4f} │ Close Liquidity: {level.custom_info['close_liquidity_placed']:.4f} │" + liquidity_line += " " * (total_width - len(liquidity_line) + 1) # +1 for correct right border alignment + liquidity_line += "│" + status.append(liquidity_line) + status.append("└" + "─" * total_width + "┘") + return status diff --git a/bots/controllers/generic/pmm.py b/bots/controllers/generic/pmm.py new file mode 100644 index 00000000..7a66baf9 --- /dev/null +++ b/bots/controllers/generic/pmm.py @@ -0,0 +1,520 @@ +from decimal import Decimal +from typing import Dict, List, Optional, Set, Tuple, Union + +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo + +from hummingbot.core.data_type.common import OrderType, PositionMode, PriceType, TradeType +from hummingbot.core.data_type.trade_fee import TokenAmount +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy_v2.controllers.controller_base 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 +from hummingbot.strategy_v2.models.executors import CloseType + + +class PMMConfig(ControllerConfigBase): + """ + This class represents the base configuration for a market making controller. + """ + controller_type: str = "generic" + controller_name: str = "pmm" + candles_config: List[CandlesConfig] = [] + connector_name: str = Field( + default="binance", + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Enter the name of the connector to use (e.g., binance):", + } + ) + trading_pair: str = Field( + default="BTC-FDUSD", + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Enter the trading pair to trade on (e.g., BTC-FDUSD):", + } + ) + portfolio_allocation: Decimal = Field( + default=Decimal("0.05"), + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Enter the portfolio allocation (e.g., 0.05 for 5%):", + } + ) + target_base_pct: Decimal = Field( + default=Decimal("0.2"), + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Enter the target base percentage (e.g., 0.2 for 20%):", + } + ) + min_base_pct: Decimal = Field( + default=Decimal("0.1"), + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Enter the minimum base percentage (e.g., 0.1 for 10%):", + } + ) + max_base_pct: Decimal = Field( + default=Decimal("0.4"), + json_schema_extra={ + "prompt_on_new": True, + "prompt": "Enter the maximum base percentage (e.g., 0.4 for 40%):", + } + ) + buy_spreads: List[float] = Field( + default="0.01,0.02", + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter a comma-separated list of buy spreads (e.g., '0.01, 0.02'):", + } + ) + sell_spreads: List[float] = Field( + default="0.01,0.02", + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter a comma-separated list of sell spreads (e.g., '0.01, 0.02'):", + } + ) + buy_amounts_pct: Union[List[Decimal], None] = Field( + default=None, + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter a comma-separated list of buy amounts as percentages (e.g., '50, 50'), or leave blank to distribute equally:", + } + ) + sell_amounts_pct: Union[List[Decimal], None] = Field( + default=None, + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter a comma-separated list of sell amounts as percentages (e.g., '50, 50'), or leave blank to distribute equally:", + } + ) + executor_refresh_time: int = Field( + default=60 * 5, + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter the refresh time in seconds for executors (e.g., 300 for 5 minutes):", + } + ) + cooldown_time: int = Field( + default=15, + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter the cooldown time in seconds between after replacing an executor that traded (e.g., 15):", + } + ) + leverage: int = Field( + default=20, + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter the leverage to use for trading (e.g., 20 for 20x leverage). Set it to 1 for spot trading:", + } + ) + position_mode: PositionMode = Field(default="HEDGE") + take_profit: Optional[Decimal] = Field( + default=Decimal("0.02"), gt=0, + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter the take profit as a decimal (e.g., 0.02 for 2%):", + } + ) + take_profit_order_type: Optional[OrderType] = Field( + default="LIMIT_MAKER", + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter the order type for take profit (e.g., LIMIT_MAKER):", + } + ) + max_skew: Decimal = Field( + default=Decimal("1.0"), + json_schema_extra={ + "prompt_on_new": True, "is_updatable": True, + "prompt": "Enter the maximum skew factor (e.g., 1.0):", + } + ) + global_take_profit: Decimal = Decimal("0.02") + + @field_validator("take_profit", mode="before") + @classmethod + def validate_target(cls, v): + if isinstance(v, str): + if v == "": + return None + return Decimal(v) + return v + + @field_validator('take_profit_order_type', mode="before") + @classmethod + def validate_order_type(cls, v) -> OrderType: + if isinstance(v, OrderType): + return v + elif v is None: + return OrderType.MARKET + elif isinstance(v, str): + if v.upper() in OrderType.__members__: + return OrderType[v.upper()] + elif isinstance(v, int): + try: + return OrderType(v) + except ValueError: + pass + raise ValueError(f"Invalid order type: {v}. Valid options are: {', '.join(OrderType.__members__)}") + + @field_validator('buy_spreads', 'sell_spreads', mode="before") + @classmethod + def parse_spreads(cls, v): + if v is None: + return [] + if isinstance(v, str): + if v == "": + return [] + return [float(x.strip()) for x in v.split(',')] + return v + + @field_validator('buy_amounts_pct', 'sell_amounts_pct', mode="before") + @classmethod + def parse_and_validate_amounts(cls, v, validation_info: ValidationInfo): + field_name = validation_info.field_name + if v is None or v == "": + spread_field = field_name.replace('amounts_pct', 'spreads') + return [1 for _ in validation_info.data[spread_field]] + if isinstance(v, str): + return [float(x.strip()) for x in v.split(',')] + elif isinstance(v, list) and len(v) != len(validation_info.data[field_name.replace('amounts_pct', 'spreads')]): + raise ValueError( + f"The number of {field_name} must match the number of {field_name.replace('amounts_pct', 'spreads')}.") + return v + + @field_validator('position_mode', mode="before") + @classmethod + def validate_position_mode(cls, v) -> PositionMode: + if isinstance(v, str): + if v.upper() in PositionMode.__members__: + return PositionMode[v.upper()] + raise ValueError(f"Invalid position mode: {v}. Valid options are: {', '.join(PositionMode.__members__)}") + return v + + @property + def triple_barrier_config(self) -> TripleBarrierConfig: + return TripleBarrierConfig( + take_profit=self.take_profit, + trailing_stop=None, + open_order_type=OrderType.LIMIT_MAKER, # Defaulting to LIMIT as is a Maker Controller + take_profit_order_type=self.take_profit_order_type, + stop_loss_order_type=OrderType.MARKET, # Defaulting to MARKET as per requirement + time_limit_order_type=OrderType.MARKET # Defaulting to MARKET as per requirement + ) + + def update_parameters(self, trade_type: TradeType, new_spreads: Union[List[float], str], new_amounts_pct: Optional[Union[List[int], str]] = None): + spreads_field = 'buy_spreads' if trade_type == TradeType.BUY else 'sell_spreads' + amounts_pct_field = 'buy_amounts_pct' if trade_type == TradeType.BUY else 'sell_amounts_pct' + + setattr(self, spreads_field, self.parse_spreads(new_spreads)) + if new_amounts_pct is not None: + setattr(self, amounts_pct_field, self.parse_and_validate_amounts(new_amounts_pct, self.__dict__, self.__fields__[amounts_pct_field])) + else: + setattr(self, amounts_pct_field, [1 for _ in getattr(self, spreads_field)]) + + def get_spreads_and_amounts_in_quote(self, trade_type: TradeType) -> Tuple[List[float], List[float]]: + buy_amounts_pct = getattr(self, 'buy_amounts_pct') + sell_amounts_pct = getattr(self, 'sell_amounts_pct') + + # Calculate total percentages across buys and sells + total_pct = sum(buy_amounts_pct) + sum(sell_amounts_pct) + + # Normalize amounts_pct based on total percentages + if trade_type == TradeType.BUY: + normalized_amounts_pct = [amt_pct / total_pct for amt_pct in buy_amounts_pct] + else: # TradeType.SELL + normalized_amounts_pct = [amt_pct / total_pct for amt_pct in sell_amounts_pct] + + spreads = getattr(self, f'{trade_type.name.lower()}_spreads') + return spreads, [amt_pct * self.total_amount_quote * self.portfolio_allocation for amt_pct in normalized_amounts_pct] + + 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() + markets[self.connector_name].add(self.trading_pair) + return markets + + +class PMM(ControllerBase): + """ + This class represents the base class for a market making controller. + """ + + def __init__(self, config: PMMConfig, *args, **kwargs): + super().__init__(config, *args, **kwargs) + self.config = config + self.market_data_provider.initialize_rate_sources([ConnectorPair( + connector_name=config.connector_name, trading_pair=config.trading_pair)]) + + def determine_executor_actions(self) -> List[ExecutorAction]: + """ + Determine actions based on the provided executor handler report. + """ + actions = [] + actions.extend(self.create_actions_proposal()) + actions.extend(self.stop_actions_proposal()) + return actions + + def create_actions_proposal(self) -> List[ExecutorAction]: + """ + Create actions proposal based on the current state of the controller. + """ + create_actions = [] + if self.processed_data["current_base_pct"] > self.config.target_base_pct and self.processed_data["unrealized_pnl_pct"] > self.config.global_take_profit: + # Create a global take profit executor + create_actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=OrderExecutorConfig( + timestamp=self.market_data_provider.time(), + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + side=TradeType.SELL, + amount=self.processed_data["position_amount"], + execution_strategy=ExecutionStrategy.MARKET, + price=self.processed_data["reference_price"], + ) + )) + return create_actions + levels_to_execute = self.get_levels_to_execute() + # Pre-calculate all spreads and amounts for buy and sell sides + buy_spreads, buy_amounts_quote = self.config.get_spreads_and_amounts_in_quote(TradeType.BUY) + sell_spreads, sell_amounts_quote = self.config.get_spreads_and_amounts_in_quote(TradeType.SELL) + reference_price = Decimal(self.processed_data["reference_price"]) + # Get current position info for skew calculation + current_pct = self.processed_data["current_base_pct"] + min_pct = self.config.min_base_pct + max_pct = self.config.max_base_pct + # Calculate skew factors (0 to 1) - how much to scale orders + if max_pct > min_pct: # Prevent division by zero + # For buys: full size at min_pct, decreasing as we approach max_pct + buy_skew = (max_pct - current_pct) / (max_pct - min_pct) + # For sells: full size at max_pct, decreasing as we approach min_pct + sell_skew = (current_pct - min_pct) / (max_pct - min_pct) + # Ensure values stay between 0.2 and 1.0 (never go below 20% of original size) + 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") + # Create executors for each level + for level_id in levels_to_execute: + trade_type = self.get_trade_type_from_level_id(level_id) + level = self.get_level_from_level_id(level_id) + if trade_type == TradeType.BUY: + spread_in_pct = Decimal(buy_spreads[level]) * Decimal(self.processed_data["spread_multiplier"]) + amount_quote = Decimal(buy_amounts_quote[level]) + skew = buy_skew + else: # TradeType.SELL + spread_in_pct = Decimal(sell_spreads[level]) * Decimal(self.processed_data["spread_multiplier"]) + amount_quote = Decimal(sell_amounts_quote[level]) + skew = sell_skew + # Calculate price + side_multiplier = Decimal("-1") if trade_type == TradeType.BUY else Decimal("1") + price = reference_price * (Decimal("1") + side_multiplier * spread_in_pct) + # Calculate amount with skew applied + amount = self.market_data_provider.quantize_order_amount(self.config.connector_name, + self.config.trading_pair, + (amount_quote / price) * skew) + if amount == Decimal("0"): + self.logger().warning(f"The amount of the level {level_id} is 0. Skipping.") + executor_config = self.get_executor_config(level_id, price, amount) + if executor_config is not None: + create_actions.append(CreateExecutorAction( + controller_id=self.config.id, + executor_config=executor_config + )) + return create_actions + + def get_levels_to_execute(self) -> List[str]: + working_levels = self.filter_executors( + executors=self.executors_info, + filter_func=lambda x: x.is_active or (x.close_type == CloseType.STOP_LOSS and self.market_data_provider.time() - x.close_timestamp < self.config.cooldown_time) + ) + working_levels_ids = [executor.custom_info["level_id"] for executor in working_levels] + return self.get_not_active_levels_ids(working_levels_ids) + + def stop_actions_proposal(self) -> List[ExecutorAction]: + """ + Create a list of actions to stop the executors based on order refresh and early stop conditions. + """ + stop_actions = [] + stop_actions.extend(self.executors_to_refresh()) + stop_actions.extend(self.executors_to_early_stop()) + return stop_actions + + def executors_to_refresh(self) -> List[ExecutorAction]: + executors_to_refresh = self.filter_executors( + executors=self.executors_info, + filter_func=lambda x: not x.is_trading and x.is_active and self.market_data_provider.time() - x.timestamp > self.config.executor_refresh_time) + return [StopExecutorAction( + controller_id=self.config.id, + keep_position=True, + executor_id=executor.id) for executor in executors_to_refresh] + + def executors_to_early_stop(self) -> List[ExecutorAction]: + """ + Get the executors to early stop based on the current state of market data. This method can be overridden to + implement custom behavior. + """ + executors_to_early_stop = self.filter_executors( + executors=self.executors_info, + filter_func=lambda x: x.is_active and x.is_trading and self.market_data_provider.time() - x.custom_info["open_order_last_update"] > self.config.cooldown_time) + return [StopExecutorAction( + controller_id=self.config.id, + keep_position=True, + executor_id=executor.id) for executor in executors_to_early_stop] + + async def update_processed_data(self): + """ + Update the processed data for the controller. This method should be reimplemented to modify the reference price + and spread multiplier based on the market data. By default, it will update the reference price as mid price and + the spread multiplier as 1. + """ + reference_price = self.market_data_provider.get_price_by_type(self.config.connector_name, + self.config.trading_pair, PriceType.MidPrice) + position_held = next((position for position in self.positions_held if + (position.trading_pair == self.config.trading_pair) & + (position.connector_name == self.config.connector_name)), None) + target_position = self.config.total_amount_quote * self.config.target_base_pct + if position_held is not None: + position_amount = position_held.amount + current_base_pct = position_held.amount_quote / self.config.total_amount_quote + deviation = (target_position - position_held.amount_quote) / target_position + unrealized_pnl_pct = position_held.unrealized_pnl_quote / position_held.amount_quote if position_held.amount_quote != 0 else Decimal("0") + else: + position_amount = 0 + current_base_pct = 0 + deviation = 1 + unrealized_pnl_pct = 0 + + self.processed_data = {"reference_price": Decimal(reference_price), "spread_multiplier": Decimal("1"), + "deviation": deviation, "current_base_pct": current_base_pct, + "unrealized_pnl_pct": unrealized_pnl_pct, "position_amount": position_amount} + + def get_executor_config(self, level_id: str, price: Decimal, amount: Decimal): + """ + Get the executor config for a given level id. + """ + trade_type = self.get_trade_type_from_level_id(level_id) + level_multiplier = self.get_level_from_level_id(level_id) + 1 + return PositionExecutorConfig( + timestamp=self.market_data_provider.time(), + level_id=level_id, + connector_name=self.config.connector_name, + trading_pair=self.config.trading_pair, + entry_price=price, + amount=amount, + triple_barrier_config=self.config.triple_barrier_config.new_instance_with_adjusted_volatility(level_multiplier), + leverage=self.config.leverage, + side=trade_type, + ) + + def get_level_id_from_side(self, trade_type: TradeType, level: int) -> str: + """ + Get the level id based on the trade type and the level. + """ + return f"{trade_type.name.lower()}_{level}" + + def get_trade_type_from_level_id(self, level_id: str) -> TradeType: + return TradeType.BUY if level_id.startswith("buy") else TradeType.SELL + + def get_level_from_level_id(self, level_id: str) -> int: + return int(level_id.split('_')[1]) + + def get_not_active_levels_ids(self, active_levels_ids: List[str]) -> List[str]: + """ + Get the levels to execute based on the current state of the controller. + """ + buy_ids_missing = [self.get_level_id_from_side(TradeType.BUY, level) for level in range(len(self.config.buy_spreads)) + if self.get_level_id_from_side(TradeType.BUY, level) not in active_levels_ids] + sell_ids_missing = [self.get_level_id_from_side(TradeType.SELL, level) for level in range(len(self.config.sell_spreads)) + if self.get_level_id_from_side(TradeType.SELL, level) not in active_levels_ids] + if self.processed_data["current_base_pct"] < self.config.min_base_pct: + return buy_ids_missing + elif self.processed_data["current_base_pct"] > self.config.max_base_pct: + return sell_ids_missing + return buy_ids_missing + sell_ids_missing + + def get_balance_requirements(self) -> List[TokenAmount]: + """ + Get the balance requirements for the controller. + """ + base_asset, quote_asset = self.config.trading_pair.split("-") + _, amounts_quote = self.config.get_spreads_and_amounts_in_quote(TradeType.BUY) + _, amounts_base = self.config.get_spreads_and_amounts_in_quote(TradeType.SELL) + return [TokenAmount(base_asset, Decimal(sum(amounts_base) / self.processed_data["reference_price"])), + TokenAmount(quote_asset, Decimal(sum(amounts_quote)))] + + def to_format_status(self) -> List[str]: + """ + Get the status of the controller in a formatted way with ASCII visualizations. + """ + status = [] + status.append(f"Controller ID: {self.config.id}") + status.append(f"Connector: {self.config.connector_name}") + status.append(f"Trading Pair: {self.config.trading_pair}") + status.append(f"Portfolio Allocation: {self.config.portfolio_allocation}") + status.append(f"Reference Price: {self.processed_data['reference_price']}") + status.append(f"Spread Multiplier: {self.processed_data['spread_multiplier']}") + + # Base percentage visualization + base_pct = self.processed_data['current_base_pct'] + min_pct = self.config.min_base_pct + max_pct = self.config.max_base_pct + target_pct = self.config.target_base_pct + # Create base percentage bar + bar_width = 50 + filled_width = int(base_pct * bar_width) + min_pos = int(min_pct * bar_width) + max_pos = int(max_pct * bar_width) + target_pos = int(target_pct * bar_width) + base_bar = "Base %: [" + for i in range(bar_width): + if i == filled_width: + base_bar += "O" # Current position + elif i == min_pos: + base_bar += "m" # Min threshold + elif i == max_pos: + base_bar += "M" # Max threshold + elif i == target_pos: + base_bar += "T" # Target threshold + elif i < filled_width: + base_bar += "=" + else: + base_bar += " " + base_bar += f"] {base_pct:.2%}" + status.append(base_bar) + status.append(f"Min: {min_pct:.2%} | Target: {target_pct:.2%} | Max: {max_pct:.2%}") + # Skew visualization + skew = base_pct - target_pct + skew_pct = skew / target_pct if target_pct != 0 else Decimal('0') + max_skew = getattr(self.config, 'max_skew', Decimal('0.0')) + skew_bar_width = 30 + skew_bar = "Skew: " + center = skew_bar_width // 2 + skew_pos = center + int(skew_pct * center * 2) + skew_pos = max(0, min(skew_bar_width, skew_pos)) + for i in range(skew_bar_width): + if i == center: + skew_bar += "|" # Center line + elif i == skew_pos: + skew_bar += "*" # Current skew + else: + skew_bar += "-" + skew_bar += f" {skew_pct:+.2%} (max: {max_skew:.2%})" + status.append(skew_bar) + # Active executors summary + status.append("\nActive Executors:") + active_buy = sum(1 for info in self.executors_info if self.get_trade_type_from_level_id(info.custom_info["level_id"]) == TradeType.BUY) + active_sell = sum(1 for info in self.executors_info if self.get_trade_type_from_level_id(info.custom_info["level_id"]) == TradeType.SELL) + status.append(f"Total: {len(self.executors_info)} (Buy: {active_buy}, Sell: {active_sell})") + # Deviation info + if 'deviation' in self.processed_data: + deviation = self.processed_data['deviation'] + status.append(f"Deviation: {deviation:.4f}") + return status diff --git a/bots/controllers/generic/quantum_grid_allocator.py b/bots/controllers/generic/quantum_grid_allocator.py new file mode 100644 index 00000000..19b7a47c --- /dev/null +++ b/bots/controllers/generic/quantum_grid_allocator.py @@ -0,0 +1,492 @@ +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) diff --git a/bots/controllers/generic/spot_perp_arbitrage.py b/bots/controllers/generic/spot_perp_arbitrage.py deleted file mode 100644 index b477e0f2..00000000 --- a/bots/controllers/generic/spot_perp_arbitrage.py +++ /dev/null @@ -1,192 +0,0 @@ -from decimal import Decimal -from typing import Dict, List, Set - -from hummingbot.client.config.config_data_types import ClientFieldData -from hummingbot.core.data_type.common import OrderType, PositionAction, PriceType, TradeType -from hummingbot.data_feed.candles_feed.data_types import CandlesConfig -from hummingbot.strategy_v2.controllers.controller_base 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 -from pydantic import Field - - -class SpotPerpArbitrageConfig(ControllerConfigBase): - controller_name: str = "spot_perp_arbitrage" - candles_config: List[CandlesConfig] = [] - spot_connector: str = Field( - default="binance", - client_data=ClientFieldData( - prompt=lambda e: "Enter the spot connector: ", - prompt_on_new=True - )) - spot_trading_pair: str = Field( - default="DOGE-USDT", - client_data=ClientFieldData( - prompt=lambda e: "Enter the spot trading pair: ", - prompt_on_new=True - )) - perp_connector: str = Field( - default="binance_perpetual", - client_data=ClientFieldData( - prompt=lambda e: "Enter the perp connector: ", - prompt_on_new=True - )) - perp_trading_pair: str = Field( - default="DOGE-USDT", - client_data=ClientFieldData( - prompt=lambda e: "Enter the perp trading pair: ", - prompt_on_new=True - )) - profitability: Decimal = Field( - default=0.002, - client_data=ClientFieldData( - prompt=lambda e: "Enter the minimum profitability: ", - prompt_on_new=True - )) - position_size_quote: float = Field( - default=50, - client_data=ClientFieldData( - prompt=lambda e: "Enter the position size in quote currency: ", - prompt_on_new=True - )) - - def update_markets(self, markets: Dict[str, Set[str]]) -> Dict[str, Set[str]]: - if self.spot_connector not in markets: - markets[self.spot_connector] = set() - markets[self.spot_connector].add(self.spot_trading_pair) - if self.perp_connector not in markets: - markets[self.perp_connector] = set() - markets[self.perp_connector].add(self.perp_trading_pair) - return markets - - -class SpotPerpArbitrage(ControllerBase): - - def __init__(self, config: SpotPerpArbitrageConfig, *args, **kwargs): - self.config = config - super().__init__(config, *args, **kwargs) - - @property - def spot_connector(self): - return self.market_data_provider.connectors[self.config.spot_connector] - - @property - def perp_connector(self): - return self.market_data_provider.connectors[self.config.perp_connector] - - def get_current_profitability_after_fees(self): - """ - This methods compares the profitability of buying at market in the two exchanges. If the side is TradeType.BUY - means that the operation is long on connector 1 and short on connector 2. - """ - spot_trading_pair = self.config.spot_trading_pair - perp_trading_pair = self.config.perp_trading_pair - - connector_spot_price = Decimal(self.market_data_provider.get_price_for_quote_volume( - connector_name=self.config.spot_connector, - trading_pair=spot_trading_pair, - quote_volume=self.config.position_size_quote, - is_buy=True, - ).result_price) - connector_perp_price = Decimal(self.market_data_provider.get_price_for_quote_volume( - connector_name=self.config.spot_connector, - trading_pair=perp_trading_pair, - quote_volume=self.config.position_size_quote, - is_buy=False, - ).result_price) - estimated_fees_spot_connector = self.spot_connector.get_fee( - base_currency=spot_trading_pair.split("-")[0], - quote_currency=spot_trading_pair.split("-")[1], - order_type=OrderType.MARKET, - order_side=TradeType.BUY, - amount=self.config.position_size_quote / float(connector_spot_price), - price=connector_spot_price, - is_maker=False, - ).percent - estimated_fees_perp_connector = self.perp_connector.get_fee( - base_currency=perp_trading_pair.split("-")[0], - quote_currency=perp_trading_pair.split("-")[1], - order_type=OrderType.MARKET, - order_side=TradeType.BUY, - amount=self.config.position_size_quote / float(connector_perp_price), - price=connector_perp_price, - is_maker=False, - position_action=PositionAction.OPEN - ).percent - - estimated_trade_pnl_pct = (connector_perp_price - connector_spot_price) / connector_spot_price - return estimated_trade_pnl_pct - estimated_fees_spot_connector - estimated_fees_perp_connector - - def is_active_arbitrage(self): - executors = self.filter_executors( - executors=self.executors_info, - filter_func=lambda e: e.is_active - ) - return len(executors) > 0 - - def current_pnl_pct(self): - executors = self.filter_executors( - executors=self.executors_info, - filter_func=lambda e: e.is_active - ) - filled_amount = sum(e.filled_amount_quote for e in executors) - return sum(e.net_pnl_quote for e in executors) / filled_amount if filled_amount > 0 else 0 - - async def update_processed_data(self): - self.processed_data = { - "profitability": self.get_current_profitability_after_fees(), - "active_arbitrage": self.is_active_arbitrage(), - "current_pnl": self.current_pnl_pct() - } - - def determine_executor_actions(self) -> List[ExecutorAction]: - executor_actions = [] - executor_actions.extend(self.create_new_arbitrage_actions()) - executor_actions.extend(self.stop_arbitrage_actions()) - return executor_actions - - def create_new_arbitrage_actions(self): - create_actions = [] - if not self.processed_data["active_arbitrage"] and \ - self.processed_data["profitability"] > self.config.profitability: - mid_price = self.market_data_provider.get_price_by_type(self.config.spot_connector, - self.config.spot_trading_pair, PriceType.MidPrice) - create_actions.append(CreateExecutorAction( - controller_id=self.config.id, - executor_config=PositionExecutorConfig( - timestamp=self.market_data_provider.time(), - connector_name=self.config.spot_connector, - trading_pair=self.config.spot_trading_pair, - side=TradeType.BUY, - amount=Decimal(self.config.position_size_quote) / mid_price, - triple_barrier_config=TripleBarrierConfig(open_order_type=OrderType.MARKET), - ) - )) - create_actions.append(CreateExecutorAction( - controller_id=self.config.id, - executor_config=PositionExecutorConfig( - timestamp=self.market_data_provider.time(), - connector_name=self.config.perp_connector, - trading_pair=self.config.perp_trading_pair, - side=TradeType.SELL, - amount=Decimal(self.config.position_size_quote) / mid_price, - triple_barrier_config=TripleBarrierConfig(open_order_type=OrderType.MARKET), - )) - ) - return create_actions - - def stop_arbitrage_actions(self): - stop_actions = [] - if self.processed_data["current_pnl"] > 0.003: - executors = self.filter_executors( - executors=self.executors_info, - filter_func=lambda e: e.is_active - ) - for executor in executors: - stop_actions.append(StopExecutorAction(controller_id=self.config.id, executor_id=executor.id)) - - def to_format_status(self) -> List[str]: - return [ - f"Current profitability: {self.processed_data['profitability']} | Min profitability: {self.config.profitability}", - f"Active arbitrage: {self.processed_data['active_arbitrage']}", - f"Current PnL: {self.processed_data['current_pnl']}"] diff --git a/bots/controllers/generic/xemm_multiple_levels.py b/bots/controllers/generic/xemm_multiple_levels.py index 2eb70d67..9983e118 100644 --- a/bots/controllers/generic/xemm_multiple_levels.py +++ b/bots/controllers/generic/xemm_multiple_levels.py @@ -3,7 +3,8 @@ from typing import Dict, List, Set import pandas as pd -from hummingbot.client.config.config_data_types import ClientFieldData +from pydantic import Field, field_validator + from hummingbot.client.ui.interface_utils import format_df_for_printout from hummingbot.core.data_type.common import PriceType, TradeType from hummingbot.data_feed.candles_feed.data_types import CandlesConfig @@ -11,71 +12,46 @@ from hummingbot.strategy_v2.executors.data_types import ConnectorPair from hummingbot.strategy_v2.executors.xemm_executor.data_types import XEMMExecutorConfig from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction -from pydantic import Field, validator class XEMMMultipleLevelsConfig(ControllerConfigBase): controller_name: str = "xemm_multiple_levels" candles_config: List[CandlesConfig] = [] maker_connector: str = Field( - default="kucoin", - client_data=ClientFieldData( - prompt=lambda e: "Enter the maker connector: ", - prompt_on_new=True - )) + default="mexc", + json_schema_extra={"prompt": "Enter the maker connector: ", "prompt_on_new": True}) maker_trading_pair: str = Field( - default="LBR-USDT", - client_data=ClientFieldData( - prompt=lambda e: "Enter the maker trading pair: ", - prompt_on_new=True - )) + default="PEPE-USDT", + json_schema_extra={"prompt": "Enter the maker trading pair: ", "prompt_on_new": True}) taker_connector: str = Field( - default="okx", - client_data=ClientFieldData( - prompt=lambda e: "Enter the taker connector: ", - prompt_on_new=True - )) + default="binance", + json_schema_extra={"prompt": "Enter the taker connector: ", "prompt_on_new": True}) taker_trading_pair: str = Field( - default="LBR-USDT", - client_data=ClientFieldData( - prompt=lambda e: "Enter the taker trading pair: ", - prompt_on_new=True - )) + default="PEPE-USDT", + json_schema_extra={"prompt": "Enter the taker trading pair: ", "prompt_on_new": True}) buy_levels_targets_amount: List[List[Decimal]] = Field( default="0.003,10-0.006,20-0.009,30", - client_data=ClientFieldData( - prompt=lambda e: "Enter the buy levels targets with the following structure: " - "(target_profitability1,amount1-target_profitability2,amount2): ", - prompt_on_new=True - )) + json_schema_extra={ + "prompt": "Enter the buy levels targets with the following structure: (target_profitability1,amount1-target_profitability2,amount2): ", + "prompt_on_new": True}) sell_levels_targets_amount: List[List[Decimal]] = Field( default="0.003,10-0.006,20-0.009,30", - client_data=ClientFieldData( - prompt=lambda e: "Enter the sell levels targets with the following structure: " - "(target_profitability1,amount1-target_profitability2,amount2): ", - prompt_on_new=True - )) + json_schema_extra={ + "prompt": "Enter the sell levels targets with the following structure: (target_profitability1,amount1-target_profitability2,amount2): ", + "prompt_on_new": True}) min_profitability: Decimal = Field( - default=0.002, - client_data=ClientFieldData( - prompt=lambda e: "Enter the minimum profitability: ", - prompt_on_new=True - )) + default=0.003, + json_schema_extra={"prompt": "Enter the minimum profitability: ", "prompt_on_new": True}) max_profitability: Decimal = Field( default=0.01, - client_data=ClientFieldData( - prompt=lambda e: "Enter the maximum profitability: ", - prompt_on_new=True - )) + json_schema_extra={"prompt": "Enter the maximum profitability: ", "prompt_on_new": True}) max_executors_imbalance: int = Field( default=1, - client_data=ClientFieldData( - prompt=lambda e: "Enter the maximum executors imbalance: ", - prompt_on_new=True - )) + json_schema_extra={"prompt": "Enter the maximum executors imbalance: ", "prompt_on_new": True}) - @validator("buy_levels_targets_amount", "sell_levels_targets_amount", pre=True, always=True) - def validate_levels_targets_amount(cls, v, values): + @field_validator("buy_levels_targets_amount", "sell_levels_targets_amount", mode="before") + @classmethod + def validate_levels_targets_amount(cls, v): if isinstance(v, str): v = [list(map(Decimal, x.split(","))) for x in v.split("-")] return v @@ -103,8 +79,7 @@ async def update_processed_data(self): def determine_executor_actions(self) -> List[ExecutorAction]: executor_actions = [] - mid_price = self.market_data_provider.get_price_by_type(self.config.maker_connector, - self.config.maker_trading_pair, PriceType.MidPrice) + mid_price = self.market_data_provider.get_price_by_type(self.config.maker_connector, self.config.maker_trading_pair, PriceType.MidPrice) active_buy_executors = self.filter_executors( executors=self.executors_info, filter_func=lambda e: not e.is_done and e.config.maker_side == TradeType.BUY @@ -115,18 +90,19 @@ def determine_executor_actions(self) -> List[ExecutorAction]: ) stopped_buy_executors = self.filter_executors( executors=self.executors_info, - filter_func=lambda e: e.is_done and e.config.maker_side == TradeType.BUY and e.filled_amount != 0 + filter_func=lambda e: e.is_done and e.config.maker_side == TradeType.BUY and e.filled_amount_quote != 0 ) stopped_sell_executors = self.filter_executors( executors=self.executors_info, - filter_func=lambda e: e.is_done and e.config.maker_side == TradeType.SELL and e.filled_amount != 0 + filter_func=lambda e: e.is_done and e.config.maker_side == TradeType.SELL and e.filled_amount_quote != 0 ) imbalance = len(stopped_buy_executors) - len(stopped_sell_executors) for target_profitability, amount in self.buy_levels_targets_amount: - active_buy_executors_target = [e.config.target_profitability == target_profitability for e in - active_buy_executors] + active_buy_executors_target = [e.config.target_profitability == target_profitability for e in active_buy_executors] if len(active_buy_executors_target) == 0 and imbalance < self.config.max_executors_imbalance: + min_profitability = target_profitability - self.config.min_profitability + max_profitability = target_profitability + self.config.max_profitability config = XEMMExecutorConfig( controller_id=self.config.id, timestamp=self.market_data_provider.time(), @@ -136,15 +112,16 @@ def determine_executor_actions(self) -> List[ExecutorAction]: trading_pair=self.config.taker_trading_pair), maker_side=TradeType.BUY, order_amount=amount / mid_price, - min_profitability=self.config.min_profitability, + min_profitability=min_profitability, target_profitability=target_profitability, - max_profitability=self.config.max_profitability + max_profitability=max_profitability ) executor_actions.append(CreateExecutorAction(executor_config=config, controller_id=self.config.id)) for target_profitability, amount in self.sell_levels_targets_amount: - active_sell_executors_target = [e.config.target_profitability == target_profitability for e in - active_sell_executors] + active_sell_executors_target = [e.config.target_profitability == target_profitability for e in active_sell_executors] if len(active_sell_executors_target) == 0 and imbalance > -self.config.max_executors_imbalance: + min_profitability = target_profitability - self.config.min_profitability + max_profitability = target_profitability + self.config.max_profitability config = XEMMExecutorConfig( controller_id=self.config.id, timestamp=time.time(), @@ -154,9 +131,9 @@ def determine_executor_actions(self) -> List[ExecutorAction]: trading_pair=self.config.maker_trading_pair), maker_side=TradeType.SELL, order_amount=amount / mid_price, - min_profitability=self.config.min_profitability, + min_profitability=min_profitability, target_profitability=target_profitability, - max_profitability=self.config.max_profitability + max_profitability=max_profitability ) executor_actions.append(CreateExecutorAction(executor_config=config, controller_id=self.config.id)) return executor_actions diff --git a/bots/controllers/market_making/dman_maker_v2.py b/bots/controllers/market_making/dman_maker_v2.py index b822d83b..6cba442e 100644 --- a/bots/controllers/market_making/dman_maker_v2.py +++ b/bots/controllers/market_making/dman_maker_v2.py @@ -2,7 +2,6 @@ from typing import List, Optional import pandas_ta as ta # noqa: F401 -from hummingbot.client.config.config_data_types import ClientFieldData from hummingbot.core.data_type.common import TradeType from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.market_making_controller_base import ( @@ -11,7 +10,7 @@ ) from hummingbot.strategy_v2.executors.dca_executor.data_types import DCAExecutorConfig, DCAMode from hummingbot.strategy_v2.models.executor_actions import ExecutorAction, StopExecutorAction -from pydantic import Field, validator +from pydantic import Field, field_validator class DManMakerV2Config(MarketMakingControllerConfigBase): @@ -24,38 +23,15 @@ class DManMakerV2Config(MarketMakingControllerConfigBase): # DCA configuration dca_spreads: List[Decimal] = Field( default="0.01,0.02,0.04,0.08", - client_data=ClientFieldData( - prompt_on_new=True, - prompt=lambda mi: "Enter a comma-separated list of spreads for each DCA level: ")) + json_schema_extra={"prompt": "Enter a comma-separated list of spreads for each DCA level: ", "prompt_on_new": True}) dca_amounts: List[Decimal] = Field( default="0.1,0.2,0.4,0.8", - client_data=ClientFieldData( - prompt_on_new=True, - prompt=lambda mi: "Enter a comma-separated list of amounts for each DCA level: ")) - time_limit: int = Field( - default=60 * 60 * 24 * 7, gt=0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the time limit for each DCA level: ", - prompt_on_new=False)) - stop_loss: Decimal = Field( - default=Decimal("0.03"), gt=0, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the stop loss (as a decimal, e.g., 0.03 for 3%): ", - prompt_on_new=True)) - top_executor_refresh_time: Optional[float] = Field( - default=None, - client_data=ClientFieldData( - is_updatable=True, - prompt_on_new=False)) - executor_activation_bounds: Optional[List[Decimal]] = Field( - default=None, - client_data=ClientFieldData( - is_updatable=True, - prompt=lambda mi: "Enter the activation bounds for the orders " - "(e.g., 0.01 activates the next order when the price is closer than 1%): ", - prompt_on_new=False)) + json_schema_extra={"prompt": "Enter a comma-separated list of amounts for each DCA level: ", "prompt_on_new": True}) + top_executor_refresh_time: Optional[float] = Field(default=None, json_schema_extra={"is_updatable": True}) + executor_activation_bounds: Optional[List[Decimal]] = Field(default=None, json_schema_extra={"is_updatable": True}) - @validator("executor_activation_bounds", pre=True, always=True) + @field_validator("executor_activation_bounds", mode="before") + @classmethod def parse_activation_bounds(cls, v): if isinstance(v, list): return [Decimal(val) for val in v] @@ -65,8 +41,9 @@ def parse_activation_bounds(cls, v): return [Decimal(val) for val in v.split(",")] return v - @validator('dca_spreads', pre=True, always=True) - def parse_spreads(cls, v): + @field_validator('dca_spreads', mode="before") + @classmethod + def parse_dca_spreads(cls, v): if v is None: return [] if isinstance(v, str): @@ -75,15 +52,16 @@ def parse_spreads(cls, v): return [float(x.strip()) for x in v.split(',')] return v - @validator('dca_amounts', pre=True, always=True) - def parse_and_validate_amounts(cls, v, values, field): + @field_validator('dca_amounts', mode="before") + @classmethod + def parse_and_validate_dca_amounts(cls, v, validation_info): if v is None or v == "": - return [1 for _ in values[values['dca_spreads']]] + return [1 for _ in validation_info.data['dca_spreads']] if isinstance(v, str): return [float(x.strip()) for x in v.split(',')] - elif isinstance(v, list) and len(v) != len(values['dca_spreads']): + elif isinstance(v, list) and len(v) != len(validation_info.data['dca_spreads']): raise ValueError( - f"The number of {field.name} must match the number of {values['dca_spreads']}.") + f"The number of dca amounts must match the number of {validation_info.data['dca_spreads']}.") return v @@ -97,17 +75,16 @@ def __init__(self, config: DManMakerV2Config, *args, **kwargs): def first_level_refresh_condition(self, executor): if self.config.top_executor_refresh_time is not None: if self.get_level_from_level_id(executor.custom_info["level_id"]) == 0: - return self.market_data_provider.time() - executor.timestamp > self.config.top_executor_refresh_time * 1000 + return self.market_data_provider.time() - executor.timestamp > self.config.top_executor_refresh_time return False def order_level_refresh_condition(self, executor): - return self.market_data_provider.time() - executor.timestamp > self.config.executor_refresh_time * 1000 + return self.market_data_provider.time() - executor.timestamp > self.config.executor_refresh_time def executors_to_refresh(self) -> List[ExecutorAction]: executors_to_refresh = self.filter_executors( executors=self.executors_info, - filter_func=lambda x: not x.is_trading and x.is_active and ( - self.order_level_refresh_condition(x) or self.first_level_refresh_condition(x))) + filter_func=lambda x: not x.is_trading and x.is_active and (self.order_level_refresh_condition(x) or self.first_level_refresh_condition(x))) return [StopExecutorAction( controller_id=self.config.id, executor_id=executor.id) for executor in executors_to_refresh] diff --git a/bots/controllers/market_making/pmm_dynamic.py b/bots/controllers/market_making/pmm_dynamic.py index baad3fda..612f7c9b 100644 --- a/bots/controllers/market_making/pmm_dynamic.py +++ b/bots/controllers/market_making/pmm_dynamic.py @@ -2,82 +2,72 @@ from typing import List import pandas_ta as ta # noqa: F401 -from hummingbot.client.config.config_data_types import ClientFieldData +from pydantic import Field, field_validator +from pydantic_core.core_schema import ValidationInfo + from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.market_making_controller_base import ( MarketMakingControllerBase, MarketMakingControllerConfigBase, ) from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig -from pydantic import Field, validator class PMMDynamicControllerConfig(MarketMakingControllerConfigBase): - controller_name = "pmm_dynamic" + controller_name: str = "pmm_dynamic" candles_config: List[CandlesConfig] = [] buy_spreads: List[float] = Field( default="1,2,4", - client_data=ClientFieldData( - is_updatable=True, - prompt_on_new=True, - prompt=lambda mi: "Enter a comma-separated list of buy spreads (e.g., '0.01, 0.02'):")) + json_schema_extra={ + "prompt": "Enter a comma-separated list of buy spreads measured in units of volatility(e.g., '1, 2'): ", + "prompt_on_new": True, "is_updatable": True} + ) sell_spreads: List[float] = Field( default="1,2,4", - client_data=ClientFieldData( - is_updatable=True, - prompt_on_new=True, - prompt=lambda mi: "Enter a comma-separated list of sell spreads (e.g., '0.01, 0.02'):")) + json_schema_extra={ + "prompt": "Enter a comma-separated list of sell spreads measured in units of volatility(e.g., '1, 2'): ", + "prompt_on_new": True, "is_updatable": True} + ) candles_connector: str = Field( default=None, - client_data=ClientFieldData( - prompt_on_new=True, - prompt=lambda mi: "Enter the connector for the candles data, leave empty to use the same " - "exchange as the connector: ", ) - ) + json_schema_extra={ + "prompt": "Enter the connector for the candles data, leave empty to use the same exchange as the connector: ", + "prompt_on_new": True}) candles_trading_pair: str = Field( default=None, - client_data=ClientFieldData( - prompt_on_new=True, - prompt=lambda mi: "Enter the trading pair for the candles data, leave empty to use the same " - "trading pair as the connector: ", ) - ) + json_schema_extra={ + "prompt": "Enter the trading pair for the candles data, leave empty to use the same trading pair as the connector: ", + "prompt_on_new": True}) interval: str = Field( default="3m", - client_data=ClientFieldData( - prompt=lambda mi: "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", - prompt_on_new=False)) - + json_schema_extra={ + "prompt": "Enter the candle interval (e.g., 1m, 5m, 1h, 1d): ", + "prompt_on_new": True}) macd_fast: int = Field( - default=12, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the MACD fast length: ", - prompt_on_new=True)) + default=21, + json_schema_extra={"prompt": "Enter the MACD fast period: ", "prompt_on_new": True}) macd_slow: int = Field( - default=26, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the MACD slow length: ", - prompt_on_new=True)) + default=42, + json_schema_extra={"prompt": "Enter the MACD slow period: ", "prompt_on_new": True}) macd_signal: int = Field( default=9, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the MACD signal length: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the MACD signal period: ", "prompt_on_new": True}) natr_length: int = Field( default=14, - client_data=ClientFieldData( - prompt=lambda mi: "Enter the NATR length: ", - prompt_on_new=True)) + json_schema_extra={"prompt": "Enter the NATR length: ", "prompt_on_new": True}) - @validator("candles_connector", pre=True, always=True) - def set_candles_connector(cls, v, values): + @field_validator("candles_connector", mode="before") + @classmethod + def set_candles_connector(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("connector_name") + return validation_info.data.get("connector_name") return v - @validator("candles_trading_pair", pre=True, always=True) - def set_candles_trading_pair(cls, v, values): + @field_validator("candles_trading_pair", mode="before") + @classmethod + def set_candles_trading_pair(cls, v, validation_info: ValidationInfo): if v is None or v == "": - return values.get("trading_pair") + return validation_info.data.get("trading_pair") return v @@ -86,10 +76,9 @@ class PMMDynamicController(MarketMakingControllerBase): This is a dynamic version of the PMM controller.It uses the MACD to shift the mid-price and the NATR to make the spreads dynamic. It also uses the Triple Barrier Strategy to manage the risk. """ - def __init__(self, config: PMMDynamicControllerConfig, *args, **kwargs): self.config = config - self.max_records = max(config.macd_slow, config.macd_fast, config.macd_signal, config.natr_length) + 10 + self.max_records = max(config.macd_slow, config.macd_fast, config.macd_signal, config.natr_length) + 100 if len(self.config.candles_config) == 0: self.config.candles_config = [CandlesConfig( connector=config.candles_connector, diff --git a/bots/controllers/market_making/pmm_simple.py b/bots/controllers/market_making/pmm_simple.py index 4e47e404..6b09f337 100644 --- a/bots/controllers/market_making/pmm_simple.py +++ b/bots/controllers/market_making/pmm_simple.py @@ -1,20 +1,20 @@ from decimal import Decimal from typing import List -from hummingbot.client.config.config_data_types import ClientFieldData +from pydantic import Field + from hummingbot.data_feed.candles_feed.data_types import CandlesConfig from hummingbot.strategy_v2.controllers.market_making_controller_base import ( MarketMakingControllerBase, MarketMakingControllerConfigBase, ) from hummingbot.strategy_v2.executors.position_executor.data_types import PositionExecutorConfig -from pydantic import Field class PMMSimpleConfig(MarketMakingControllerConfigBase): - controller_name = "pmm_simple" + controller_name: str = "pmm_simple" # As this controller is a simple version of the PMM, we are not using the candles feed - candles_config: List[CandlesConfig] = Field(default=[], client_data=ClientFieldData(prompt_on_new=False)) + candles_config: List[CandlesConfig] = Field(default=[]) class PMMSimpleController(MarketMakingControllerBase): diff --git a/bots/credentials/master_account/conf_client.yml b/bots/credentials/master_account/conf_client.yml index ccd8729a..2ad547af 100644 --- a/bots/credentials/master_account/conf_client.yml +++ b/bots/credentials/master_account/conf_client.yml @@ -24,8 +24,6 @@ kill_switch_mode: {} # What to auto-fill in the prompt after each import command (start/config) autofill_import: disabled -telegram_mode: {} - # MQTT Bridge configuration. mqtt_bridge: mqtt_host: localhost @@ -60,8 +58,6 @@ previous_strategy: some-strategy.yml db_mode: db_engine: sqlite -pmm_script_mode: {} - # Balance Limit Configurations # e.g. Setting USDT and BTC limits on Binance. # balance_asset_limit: @@ -192,6 +188,6 @@ color: tick_size: 1.0 market_data_collection: - market_data_collection_enabled: true + market_data_collection_enabled: false market_data_collection_interval: 60 market_data_collection_depth: 20 diff --git a/bots/scripts/v2_with_controllers.py b/bots/scripts/v2_with_controllers.py index 7a2e9a16..c62d585c 100644 --- a/bots/scripts/v2_with_controllers.py +++ b/bots/scripts/v2_with_controllers.py @@ -12,17 +12,15 @@ from hummingbot.strategy.strategy_v2_base import StrategyV2Base, StrategyV2ConfigBase from hummingbot.strategy_v2.models.base import RunnableStatus from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, StopExecutorAction -from pydantic import Field class GenericV2StrategyWithCashOutConfig(StrategyV2ConfigBase): - script_file_name: str = Field(default_factory=lambda: os.path.basename(__file__)) + script_file_name: str = os.path.basename(__file__) candles_config: List[CandlesConfig] = [] markets: Dict[str, Set[str]] = {} time_to_cash_out: Optional[int] = None max_global_drawdown: Optional[float] = None max_controller_drawdown: Optional[float] = None - performance_report_interval: int = 1 rebalance_interval: Optional[int] = None extra_inventory: Optional[float] = 0.02 min_amount_to_rebalance_usd: Decimal = Decimal("8") @@ -40,6 +38,7 @@ class GenericV2StrategyWithCashOut(StrategyV2Base): specific controller and wait until the active executors finalize their execution. The rest of the executors will wait until the main strategy stops them. """ + performance_report_interval: int = 1 def __init__(self, connectors: Dict[str, ConnectorBase], config: GenericV2StrategyWithCashOutConfig): super().__init__(connectors, config) @@ -50,7 +49,6 @@ def __init__(self, connectors: Dict[str, ConnectorBase], config: GenericV2Strate self.max_global_pnl = Decimal("0") self.drawdown_exited_controllers = [] self.closed_executors_buffer: int = 30 - self.performance_report_interval: int = self.config.performance_report_interval self.rebalance_interval: int = self.config.rebalance_interval self._last_performance_report_timestamp = 0 self._last_rebalance_check_timestamp = 0 @@ -81,8 +79,7 @@ async def on_stop(self): def on_tick(self): super().on_tick() - self.performance_reports = {controller_id: self.executor_orchestrator.generate_performance_report( - controller_id=controller_id).dict() for controller_id in self.controllers.keys()} + self.performance_reports = {controller_id: self.executor_orchestrator.generate_performance_report(controller_id=controller_id).dict() for controller_id in self.controllers.keys()} self.control_rebalance() self.control_cash_out() self.control_max_drawdown() @@ -115,48 +112,31 @@ def control_rebalance(self): amount_with_safe_margin = amount * (1 + Decimal(self.config.extra_inventory)) active_executors_for_pair = self.filter_executors( executors=self.get_all_executors(), - filter_func=lambda x: x.is_active and x.trading_pair == trading_pair and - x.connector_name == connector_name + filter_func=lambda x: x.is_active and x.trading_pair == trading_pair and x.connector_name == connector_name ) - unmatched_amount = sum([executor.filled_amount_quote for executor in active_executors_for_pair if - executor.side == TradeType.SELL]) - sum( - [executor.filled_amount_quote for executor in active_executors_for_pair if - executor.side == TradeType.BUY]) + unmatched_amount = sum([executor.filled_amount_quote for executor in active_executors_for_pair if executor.side == TradeType.SELL]) - sum([executor.filled_amount_quote for executor in active_executors_for_pair if executor.side == TradeType.BUY]) balance += unmatched_amount / mid_price base_balance_diff = balance - amount_with_safe_margin abs_balance_diff = abs(base_balance_diff) - trading_rules_condition = abs_balance_diff > trading_rule.min_order_size and \ - abs_balance_diff * mid_price > trading_rule.min_notional_size and \ - abs_balance_diff * mid_price > self.config.min_amount_to_rebalance_usd + trading_rules_condition = abs_balance_diff > trading_rule.min_order_size and abs_balance_diff * mid_price > trading_rule.min_notional_size and abs_balance_diff * mid_price > self.config.min_amount_to_rebalance_usd order_type = OrderType.MARKET if base_balance_diff > 0: if trading_rules_condition: - self.logger().info( - f"Rebalance: Selling {amount_with_safe_margin} {token} to " - f"{self.config.asset_to_rebalance}. Balance: {balance} | " - f"Executors unmatched balance {unmatched_amount / mid_price}") + self.logger().info(f"Rebalance: Selling {amount_with_safe_margin} {token} to {self.config.asset_to_rebalance}. Balance: {balance} | Executors unmatched balance {unmatched_amount / mid_price}") connector.sell( trading_pair=trading_pair, amount=abs_balance_diff, order_type=order_type, price=mid_price) else: - self.logger().info( - "Skipping rebalance due a low amount to sell that may cause future imbalance") + self.logger().info("Skipping rebalance due a low amount to sell that may cause future imbalance") else: if not trading_rules_condition: - amount = max( - [self.config.min_amount_to_rebalance_usd / mid_price, trading_rule.min_order_size, - trading_rule.min_notional_size / mid_price]) - self.logger().info( - f"Rebalance: Buying for a higher value to avoid future imbalance {amount} {token} to " - f"{self.config.asset_to_rebalance}. Balance: {balance} | " - f"Executors unmatched balance {unmatched_amount}") + amount = max([self.config.min_amount_to_rebalance_usd / mid_price, trading_rule.min_order_size, trading_rule.min_notional_size / mid_price]) + self.logger().info(f"Rebalance: Buying for a higher value to avoid future imbalance {amount} {token} to {self.config.asset_to_rebalance}. Balance: {balance} | Executors unmatched balance {unmatched_amount}") else: amount = abs_balance_diff - self.logger().info( - f"Rebalance: Buying {amount} {token} to {self.config.asset_to_rebalance}. " - f"Balance: {balance} | Executors unmatched balance {unmatched_amount}") + self.logger().info(f"Rebalance: Buying {amount} {token} to {self.config.asset_to_rebalance}. Balance: {balance} | Executors unmatched balance {unmatched_amount}") connector.buy( trading_pair=trading_pair, amount=amount, @@ -172,6 +152,8 @@ def control_max_drawdown(self): def check_max_controller_drawdown(self): for controller_id, controller in self.controllers.items(): + if controller.status != RunnableStatus.RUNNING: + continue controller_pnl = self.performance_reports[controller_id]["global_pnl_quote"] last_max_pnl = self.max_pnl_by_controller[controller_id] if controller_pnl > last_max_pnl: @@ -186,8 +168,7 @@ def check_max_controller_drawdown(self): filter_func=lambda x: x.is_active and not x.is_trading, ) self.executor_orchestrator.execute_actions( - actions=[StopExecutorAction(controller_id=controller_id, executor_id=executor.id) for executor - in executors_order_placed] + actions=[StopExecutorAction(controller_id=controller_id, executor_id=executor.id) for executor in executors_order_placed] ) self.drawdown_exited_controllers.append(controller_id) @@ -203,8 +184,7 @@ def check_max_global_drawdown(self): HummingbotApplication.main_application().stop() def send_performance_report(self): - if self.current_timestamp - self._last_performance_report_timestamp >= self.performance_report_interval and \ - self.mqtt_enabled: + if self.current_timestamp - self._last_performance_report_timestamp >= self.performance_report_interval and self.mqtt_enabled: self._pub(self.performance_reports) self._last_performance_report_timestamp = self.current_timestamp @@ -273,7 +253,6 @@ def apply_initial_setting(self): connectors_position_mode[config_dict["connector_name"]] = config_dict["position_mode"] if "leverage" in config_dict: self.connectors[config_dict["connector_name"]].set_leverage(leverage=config_dict["leverage"], - trading_pair=config_dict[ - "trading_pair"]) + trading_pair=config_dict["trading_pair"]) for connector_name, position_mode in connectors_position_mode.items(): self.connectors[connector_name].set_position_mode(position_mode) diff --git a/config.py b/config.py index b36db450..f37dae8f 100644 --- a/config.py +++ b/config.py @@ -12,4 +12,4 @@ BROKER_USERNAME = os.getenv("BROKER_USERNAME", "admin") BROKER_PASSWORD = os.getenv("BROKER_PASSWORD", "password") PASSWORD_VERIFICATION_PATH = "bots/credentials/master_account/.password_verification" -BANNED_TOKENS = os.getenv("BANNED_TOKENS", "NAV,ARS,ETHW").split(",") \ No newline at end of file +BANNED_TOKENS = os.getenv("BANNED_TOKENS", "NAV,ARS,ETHW,ETHF").split(",") \ No newline at end of file diff --git a/environment.yml b/environment.yml index 69b97efe..d41035c1 100644 --- a/environment.yml +++ b/environment.yml @@ -3,19 +3,16 @@ channels: - conda-forge - defaults dependencies: - - python=3.10 + - python=3.12 - fastapi - uvicorn + - boto3 - libcxx + - python-dotenv + - docker-py - pip - pip: - hummingbot - - numpy==1.26.4 - - git+https://github.com/felixfontein/docker-py - - python-dotenv - - boto3 - - python-multipart==0.0.12 - - PyYAML - git+https://github.com/hummingbot/hbot-remote-client-py.git - flake8 - isort diff --git a/routers/manage_files.py b/routers/manage_files.py index 68fa7217..058b72c7 100644 --- a/routers/manage_files.py +++ b/routers/manage_files.py @@ -51,6 +51,21 @@ async def list_controllers(): "market_making": market_making_controllers, "generic": generic_controllers} +@router.get("/controller-config-pydantic/{controller_type}/{controller_name}", response_model=dict) +async def get_controller_config_pydantic(controller_type: str, controller_name: str): + """ + Retrieves the configuration parameters for a given controller. + :param controller_name: The name of the controller. + :return: JSON containing the configuration parameters. + """ + config_class = file_system.load_controller_config_class(controller_type, controller_name) + if config_class is None: + raise HTTPException(status_code=404, detail="Controller configuration class not found") + + # Extracting fields and default values + config_fields = {name: field.default for name, field in config_class.model_fields.items()} + return json.loads(json.dumps(config_fields, default=str)) + @router.get("/list-controllers-configs", response_model=List[str]) async def list_controllers_configs(): diff --git a/routers/manage_market_data.py b/routers/manage_market_data.py index 13529f6a..a5e2bcd7 100644 --- a/routers/manage_market_data.py +++ b/routers/manage_market_data.py @@ -1,21 +1,13 @@ import asyncio from fastapi import APIRouter -from hummingbot.data_feed.candles_feed.candles_factory import CandlesConfig, CandlesFactory -from pydantic import BaseModel +from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig, HistoricalCandlesConfig router = APIRouter(tags=["Market Data"]) candles_factory = CandlesFactory() -class HistoricalCandlesConfig(BaseModel): - connector_name: str = "binance_perpetual" - trading_pair: str = "BTC-USDT" - interval: str = "3m" - start_time: int = 1672542000 - end_time: int = 1672628400 - - @router.post("/real-time-candles") async def get_candles(candles_config: CandlesConfig): try: diff --git a/services/accounts_service.py b/services/accounts_service.py index ae0f9c2b..5cfeb3e0 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -27,10 +27,9 @@ class AccountsService: """ def __init__(self, - update_account_state_interval_minutes: int = 1, + update_account_state_interval_minutes: int = 5, default_quote: str = "USDT", - account_history_file: str = "account_state_history.json", - account_history_dump_interval_minutes: int = 1): + account_history_file: str = "account_state_history.json"): # TODO: Add database to store the balances of each account each time it is updated. self.secrets_manager = ETHKeyFileSecretManger(CONFIG_PASSWORD) self.accounts = {} @@ -40,14 +39,15 @@ def __init__(self, self.update_account_state_interval = update_account_state_interval_minutes * 60 self.default_quote = default_quote self.history_file = account_history_file - self.account_history_dump_interval = account_history_dump_interval_minutes self._update_account_state_task: Optional[asyncio.Task] = None - self._dump_account_state_task: Optional[asyncio.Task] = None def get_accounts_state(self): return self.accounts_state - def get_default_market(self, token): + def get_default_market(self, token: str): + if token.startswith("LD") and token != "LDO": + # These tokens are staked in binance earn + token = token.replace("LD", "") return f"{token}-{self.default_quote}" def start_update_account_state_loop(self): @@ -56,7 +56,6 @@ def start_update_account_state_loop(self): :return: """ self._update_account_state_task = asyncio.create_task(self.update_account_state_loop()) - self._dump_account_state_task = asyncio.create_task(self.dump_account_state_loop()) def stop_update_account_state_loop(self): """ @@ -65,10 +64,7 @@ def stop_update_account_state_loop(self): """ if self._update_account_state_task: self._update_account_state_task.cancel() - if self._dump_account_state_task: - self._dump_account_state_task.cancel() self._update_account_state_task = None - self._dump_account_state_task = None async def update_account_state_loop(self): """ @@ -81,31 +77,12 @@ async def update_account_state_loop(self): await self.update_balances() await self.update_trading_rules() await self.update_account_state() + await self.dump_account_state() except Exception as e: logging.error(f"Error updating account state: {e}") finally: await asyncio.sleep(self.update_account_state_interval) - async def dump_account_state_loop(self): - """ - The loop that dumps the current account state to a file at fixed intervals. - :return: - """ - await self.account_state_update_event.wait() - while True: - try: - await self.dump_account_state() - except Exception as e: - logging.error(f"Error dumping account state: {e}") - finally: - now = datetime.now() - next_log_time = (now + timedelta(minutes=self.account_history_dump_interval)).replace(second=0, - microsecond=0) - next_log_time = next_log_time - timedelta( - minutes=next_log_time.minute % self.account_history_dump_interval) - sleep_duration = (next_log_time - now).total_seconds() - await asyncio.sleep(sleep_duration) - async def dump_account_state(self): """ Dump the current account state to a JSON file. Create it if the file not exists. @@ -252,8 +229,6 @@ async def _safe_get_last_traded_prices(self, connector, trading_pairs, timeout=5 try: # TODO: Fix OKX connector to return the markets in Hummingbot format. last_traded = await asyncio.wait_for(connector.get_last_traded_prices(trading_pairs=trading_pairs), timeout=timeout) - if connector.name == "okx_perpetual": - return {pair.strip("-SWAP"): value for pair, value in last_traded.items()} return last_traded except asyncio.TimeoutError: logging.error(f"Timeout getting last traded prices for trading pairs {trading_pairs}") diff --git a/utils/file_system.py b/utils/file_system.py index 0d549be9..040126f8 100644 --- a/utils/file_system.py +++ b/utils/file_system.py @@ -10,6 +10,9 @@ import yaml from hummingbot.client.config.config_data_types import BaseClientModel from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.strategy_v2.controllers.directional_trading_controller_base import DirectionalTradingControllerConfigBase +from hummingbot.strategy_v2.controllers.market_making_controller_base import MarketMakingControllerConfigBase +from hummingbot.strategy_v2.controllers.controller_base import ControllerConfigBase class FileSystemUtil: @@ -177,6 +180,30 @@ def load_script_config_class(script_name): print(f"Error loading script class: {e}") # Handle or log the error appropriately return None + @staticmethod + def load_controller_config_class(controller_type: str, controller_name: str): + """ + Dynamically loads a controller's configuration class. + :param controller_name: The name of the controller file (without the '.py' extension). + :return: The configuration class from the controller, or None if not found. + """ + try: + # Assuming controllers are in a package named 'controllers' + module_name = f"bots.controllers.{controller_type}.{controller_name.replace('.py', '')}" + if module_name not in sys.modules: + script_module = importlib.import_module(module_name) + else: + script_module = importlib.reload(sys.modules[module_name]) + + # Find the subclass of BaseClientModel in the module + for _, cls in inspect.getmembers(script_module, inspect.isclass): + if (issubclass(cls, DirectionalTradingControllerConfigBase) and cls is not DirectionalTradingControllerConfigBase)\ + or (issubclass(cls, MarketMakingControllerConfigBase) and cls is not MarketMakingControllerConfigBase)\ + or (issubclass(cls, ControllerConfigBase) and cls is not ControllerConfigBase): + return cls + except Exception as e: + print(f"Error loading controller class: {e}") + @staticmethod def ensure_file_and_dump_text(file_path, text): """ diff --git a/utils/models.py b/utils/models.py index 1ecb977b..7e49da31 100644 --- a/utils/models.py +++ b/utils/models.py @@ -8,7 +8,7 @@ class BackendAPIConfigAdapter(ClientConfigAdapter): def _encrypt_secrets(self, conf_dict: Dict[str, Any]): from utils.security import BackendAPISecurity for attr, value in conf_dict.items(): - attr_type = self._hb_config.__fields__[attr].type_ + attr_type = self._hb_config.model_fields[attr].annotation if attr_type == SecretStr: clear_text_value = value.get_secret_value() if isinstance(value, SecretStr) else value conf_dict[attr] = BackendAPISecurity.secrets_manager.encrypt_secret_value(attr, clear_text_value) diff --git a/utils/security.py b/utils/security.py index 6d71bd9e..22c89aa4 100644 --- a/utils/security.py +++ b/utils/security.py @@ -3,7 +3,6 @@ from hummingbot.client.config.config_crypt import PASSWORD_VERIFICATION_WORD, BaseSecretsManager from hummingbot.client.config.config_helpers import ( ClientConfigAdapter, - _load_yml_data_into_map, connector_name_from_file, get_connector_hb_config, read_yml_file, @@ -47,9 +46,9 @@ def decrypt_connector_config(cls, file_path: Path): def load_connector_config_map_from_file(cls, yml_path: Path) -> BackendAPIConfigAdapter: config_data = read_yml_file(yml_path) connector_name = connector_name_from_file(yml_path) - hb_config = get_connector_hb_config(connector_name) + hb_config = get_connector_hb_config(connector_name).model_validate(config_data) config_map = BackendAPIConfigAdapter(hb_config) - _load_yml_data_into_map(config_data, config_map) + config_map.decrypt_all_secure_data() return config_map @classmethod