diff --git a/hummingbot/strategy_v2/executors/triangular_arb_executor/__init__.py b/hummingbot/strategy_v2/executors/triangular_arb_executor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/strategy_v2/executors/triangular_arb_executor/data_types.py b/hummingbot/strategy_v2/executors/triangular_arb_executor/data_types.py new file mode 100644 index 0000000000..b50b50c506 --- /dev/null +++ b/hummingbot/strategy_v2/executors/triangular_arb_executor/data_types.py @@ -0,0 +1,88 @@ +from decimal import Decimal +from enum import Enum + +from attr import dataclass + +from hummingbot.core.data_type.in_flight_order import InFlightOrder +from hummingbot.strategy_v2.executors.data_types import ConnectorPair, ExecutorConfigBase +from hummingbot.strategy_v2.models.executors import TrackedOrder + + +class ArbitrageDirection(Enum): + FORWARD = 0 + BACKWARD = 1 + + +class TriangularArbExecutorConfig(ExecutorConfigBase): + type: str = "triangular_arb_executor" + arb_asset: str + arb_asset_wrapped: str + proxy_asset: str + stable_asset: str + buying_market: ConnectorPair + proxy_market: ConnectorPair + selling_market: ConnectorPair + order_amount: Decimal + min_profitability_percent: Decimal = 1.5 + max_retries: int = 3 + + +class Idle: + pass + + +class InProgress: + def __init__(self, buy_order: TrackedOrder, proxy_order: TrackedOrder, sell_order: TrackedOrder): + self._buy_order: TrackedOrder = buy_order + self._proxy_order: TrackedOrder = proxy_order + self._sell_order: TrackedOrder = sell_order + + @property + def buy_order(self) -> TrackedOrder: + return self._buy_order + + @buy_order.setter + def buy_order(self, order: TrackedOrder): + self._buy_order = order + + def update_buy_order(self, order: InFlightOrder): + self._buy_order.order = order + + @property + def proxy_order(self) -> TrackedOrder: + return self._proxy_order + + @proxy_order.setter + def proxy_order(self, order: TrackedOrder): + self._proxy_order = order + + def update_proxy_order(self, order: InFlightOrder): + self._proxy_order.order = order + + @property + def sell_order(self) -> TrackedOrder: + return self._sell_order + + @sell_order.setter + def sell_order(self, order: TrackedOrder): + self._sell_order = order + + def update_sell_order(self, order: InFlightOrder): + self._sell_order.order = order + + +@dataclass +class Completed: + buy_order_exec_price: Decimal + proxy_order_exec_price: Decimal + sell_order_exec_price: Decimal + + +class FailureReason(Enum): + INSUFFICIENT_BALANCE = 0 + TOO_MANY_FAILURES = 1 + + +class Failed: + def __init__(self, reason: FailureReason): + self.reason: FailureReason = reason diff --git a/hummingbot/strategy_v2/executors/triangular_arb_executor/triangular_arb_executor.py b/hummingbot/strategy_v2/executors/triangular_arb_executor/triangular_arb_executor.py new file mode 100644 index 0000000000..45bea9b5c6 --- /dev/null +++ b/hummingbot/strategy_v2/executors/triangular_arb_executor/triangular_arb_executor.py @@ -0,0 +1,186 @@ +import asyncio +import logging +from typing import Optional, Union + +from hummingbot.connector.connector_base import ConnectorBase +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.event.events import BuyOrderCreatedEvent, MarketOrderFailureEvent, SellOrderCreatedEvent +from hummingbot.logger import HummingbotLogger +from hummingbot.strategy.script_strategy_base import ScriptStrategyBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair +from hummingbot.strategy_v2.executors.executor_base import ExecutorBase +from hummingbot.strategy_v2.executors.triangular_arb_executor.data_types import ( + ArbitrageDirection, + Completed, + Failed, + FailureReason, + Idle, + InProgress, + TriangularArbExecutorConfig, +) +from hummingbot.strategy_v2.models.executors import TrackedOrder + + +class TriangularArbExecutor(ExecutorBase): + _logger = None + _cumulative_failures: int = 0 + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + @property + def is_closed(self): + return type(self.state) is Completed or type(self.state) is Failed + + def __init__(self, strategy: ScriptStrategyBase, config: TriangularArbExecutorConfig, update_interval: float = 1.0): + super().__init__(strategy=strategy, + connectors=[config.buying_market.connector_name, + config.proxy_market.connector_name, + config.selling_market.connector_name], + config=config, update_interval=update_interval) + + arb_direction = is_valid_arbitrage(config.arb_asset, config.arb_asset_wrapped, config.proxy_asset, + config.stable_asset, config.buying_market, config.proxy_market, + config.selling_market) + if arb_direction: + self.arb_direction: ArbitrageDirection = arb_direction + + self._buying_market = config.buying_market + self._proxy_market = config.proxy_market + self._selling_market = config.selling_market + + self.arb_asset = config.arb_asset + self.arb_asset_wrapped = config.arb_asset_wrapped + self.proxy_asset = config.proxy_asset + self.stable_asset = config.stable_asset + self.order_amount = config.order_amount + self.min_profitability_percent = config.min_profitability_percent + self.max_retries = config.max_retries + + self.state: Idle | InProgress | Completed | Failed = Idle() + else: + raise Exception("Arbitrage is not valid.") + + def buying_market(self) -> ConnectorBase: + return self.connectors[self._buying_market.connector_name] + + def proxy_market(self) -> ConnectorBase: + return self.connectors[self._proxy_market.connector_name] + + def selling_market(self) -> ConnectorBase: + return self.connectors[self._selling_market.connector_name] + + def validate_sufficient_balance(self): + if self.arb_direction is ArbitrageDirection.FORWARD: + buying_account_not_ok = self.buying_market().get_balance(self.stable_asset) < self.order_amount + proxy_account_not_ok = self.proxy_market().get_balance(self.proxy_asset) < self.order_amount + selling_account_not_ok = self.selling_market().get_balance(self.arb_asset_wrapped) < self.order_amount + if buying_account_not_ok or proxy_account_not_ok or selling_account_not_ok: + self.state = Failed(FailureReason.INSUFFICIENT_BALANCE) + self.logger().error("Not enough budget to open position.") + else: + buying_account_not_ok = self.buying_market().get_balance(self.proxy_asset) < self.order_amount + proxy_account_not_ok = self.selling_market().get_balance(self.stable_asset) < self.order_amount + selling_account_not_ok = self.proxy_market().get_balance(self.arb_asset) < self.order_amount + if buying_account_not_ok or proxy_account_not_ok or selling_account_not_ok: + self.state = Failed(FailureReason.INSUFFICIENT_BALANCE) + self.logger().error("Not enough budget to open position.") + + async def control_task(self): + if type(self.state) is Idle: + await self.init_arbitrage() + elif type(self.state) is InProgress: + state = self.state + if self._cumulative_failures > self.max_retries: + self.state = Failed(FailureReason.TOO_MANY_FAILURES) + self.stop() + elif state.buy_order.is_filled and state.proxy_order.is_filled and state.sell_order.is_filled: + self.state = Completed(buy_order_exec_price=state.buy_order.average_executed_price, + proxy_order_exec_price=state.proxy_order.average_executed_price, + sell_order_exec_price=state.sell_order.average_executed_price) + self.stop() + + async def init_arbitrage(self): + buy_order = asyncio.create_task(self.place_buy_order()) + proxy_order = asyncio.create_task(self.place_proxy_order()) + sell_order = asyncio.create_task(self.place_sell_order()) + buy_order, proxy_order, sell_order = await asyncio.gather(buy_order, proxy_order, sell_order) + self.state = InProgress( + buy_order=buy_order, + proxy_order=proxy_order, + sell_order=sell_order, + ) + + async def place_buy_order(self) -> TrackedOrder: + market = self._buying_market + order_id = self.place_order(connector_name=market.connector_name, trading_pair=market.trading_pair, + order_type=OrderType.MARKET, side=TradeType.BUY, amount=self.order_amount) + return TrackedOrder(order_id) + + async def place_proxy_order(self) -> TrackedOrder: + market = self._proxy_market + order_id = self.place_order(connector_name=market.connector_name, trading_pair=market.trading_pair, + order_type=OrderType.MARKET, + side=TradeType.BUY if self.arb_direction is ArbitrageDirection.FORWARD else TradeType.SELL, + amount=self.order_amount) + return TrackedOrder(order_id) + + async def place_sell_order(self) -> TrackedOrder: + market = self._selling_market + order_id = self.place_order(connector_name=market.connector_name, trading_pair=market.trading_pair, + order_type=OrderType.MARKET, side=TradeType.SELL, amount=self.order_amount) + return TrackedOrder(order_id) + + def process_order_created_event(self, + event_tag: int, + market: ConnectorBase, + event: Union[BuyOrderCreatedEvent, SellOrderCreatedEvent]): + if type(self.state) is InProgress: + order_id = event.order_id + if order_id == self.state.buy_order.order_id: + self.logger().info("Buy order created") + self.state.update_buy_order(self.get_in_flight_order(self._buying_market.connector_name, order_id)) + elif order_id == self.state.proxy_order.order_id: + self.logger().info("Proxy order created") + self.state.update_proxy_order(self.get_in_flight_order(self._proxy_market.connector_name, order_id)) + elif order_id == self.state.sell_order.order_id: + self.logger().info("Sell order created") + self.state.update_sell_order(self.get_in_flight_order(self._selling_market.connector_name, order_id)) + + def process_order_failed_event(self, _, market, event: MarketOrderFailureEvent): + self._cumulative_failures += 1 + if type(self.state) is InProgress and self._cumulative_failures < self.max_retries: + order_id = event.order_id + if order_id == self.state.buy_order.order_id: + self.state.buy_order = asyncio.run(self.place_sell_order()) + elif order_id == self.state.proxy_order.order_id: + self.state.proxy_order = asyncio.run(self.place_proxy_order()) + elif order_id == self.state.sell_order.order_id: + self.state.sell_order = asyncio.run(self.place_sell_order()) + + +def is_valid_arbitrage(arb_asset: str, + arb_asset_wrapped: str, + proxy_asset: str, + stable_asset: str, + buying_market: ConnectorPair, + proxy_market: ConnectorPair, + selling_market: ConnectorPair) -> Optional[ArbitrageDirection]: + buying_pair_assets = buying_market.trading_pair.split("-") + proxy_pair_assets = proxy_market.trading_pair.split("-") + selling_pair_assets = selling_market.trading_pair.split("-") + proxy_market_ok = proxy_asset in proxy_pair_assets and stable_asset in proxy_pair_assets + if arb_asset in buying_pair_assets: + buying_market_ok = stable_asset in buying_pair_assets and arb_asset is buying_pair_assets[0] + selling_market_ok = proxy_asset in selling_pair_assets and arb_asset_wrapped in selling_pair_assets + if buying_market_ok and proxy_market_ok and selling_market_ok: + return ArbitrageDirection.FORWARD + elif arb_asset in selling_pair_assets: + buying_market_ok = proxy_asset in buying_pair_assets and arb_asset_wrapped is buying_pair_assets[0] + selling_market_ok = stable_asset in selling_pair_assets + if buying_market_ok and proxy_market_ok and selling_market_ok: + return ArbitrageDirection.BACKWARD + return None diff --git a/scripts/community/triangular_arb_v2.py b/scripts/community/triangular_arb_v2.py new file mode 100644 index 0000000000..37b66355c6 --- /dev/null +++ b/scripts/community/triangular_arb_v2.py @@ -0,0 +1,137 @@ +import asyncio +import os +from asyncio import Future +from decimal import Decimal +from typing import Dict, List, Set + +from pydantic import Field + +from hummingbot.client.config.config_data_types import ClientFieldData +from hummingbot.connector.connector_base import ConnectorBase +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.data_feed.candles_feed.data_types import CandlesConfig +from hummingbot.strategy.strategy_v2_base import StrategyV2Base, StrategyV2ConfigBase +from hummingbot.strategy_v2.executors.data_types import ConnectorPair +from hummingbot.strategy_v2.executors.triangular_arb_executor.data_types import ( + ArbitrageDirection, + TriangularArbExecutorConfig, +) +from hummingbot.strategy_v2.models.executor_actions import CreateExecutorAction, ExecutorAction + + +class TriangularArbV2Config(StrategyV2ConfigBase): + script_file_name: str = Field(default_factory=lambda: os.path.basename(__file__)) + candles_config: List[CandlesConfig] = [] + controllers_config: List[str] = [] + markets: Dict[str, Set[str]] = {} + cex_connector_main: str = Field( + default="kucoin", + client_data=ClientFieldData( + prompt=lambda e: "Enter main CEX connector: ", + prompt_on_new=True + )) + cex_connector_proxy: str = Field( + default="binance", + client_data=ClientFieldData( + prompt=lambda e: "Enter proxy CEX connector: ", + prompt_on_new=True + )) + dex_connector: str = Field( + default="splash", + client_data=ClientFieldData( + prompt=lambda e: "Enter DEX connector: ", + prompt_on_new=True + )) + arb_asset: str = Field(default="ERG") + arb_asset_wrapped: str = Field(default="rsERG") + proxy_asset: str = Field(default="ADA") + stable_asset: str = Field(default="USDT") + min_arbitrage_percent: Decimal = Field(default=Decimal("1.5")) + # In stable asset + min_arbitrage_volume: Decimal = Field(default=Decimal("1000")) + + +class TriangularArbV2(StrategyV2Base): + _arb_task: Future = None + + @classmethod + def init_markets(cls, config: TriangularArbV2Config): + cls.markets = {config.cex_connector_main: {f"{config.arb_asset}-{config.stable_asset}"}, + config.cex_connector_proxy: {f"{config.proxy_asset}-{config.stable_asset}"}, + config.dex_connector: {f"{config.arb_asset_wrapped}-{config.proxy_asset}"}, } + + def __init__(self, connectors: Dict[str, ConnectorBase], config: TriangularArbV2Config): + super().__init__(connectors, config) + self.config = config + + def arbitrage_config(self, direction: ArbitrageDirection) -> TriangularArbExecutorConfig: + cex_main = ConnectorPair(conector_name=self.config.cex_connector_main, + trading_pair=self.markets[self.config.cex_connector_main][0]) + dex = ConnectorPair(conector_name=self.config.dex_connector, + trading_pair=self.markets[self.config.dex_connector][0]) + return TriangularArbExecutorConfig( + arb_asset=self.config.arb_asset, + arb_asset_wrapped=self.config.arb_asset_wrapped, + proxy_asset=self.config.proxy_asset, + stable_asset=self.config.stable_asset, + buying_market=cex_main if direction is ArbitrageDirection.FORWARD else dex, + proxy_market=ConnectorPair(conector_name=self.config.cex_connector_proxy, + trading_pair=self.markets[self.config.cex_connector_proxy][0]), + selling_market=dex if direction is ArbitrageDirection.FORWARD else cex_main, + order_amount=self.config.min_arbitrage_volume, + ) + + def determine_executor_actions(self) -> List[ExecutorAction]: + executor_actions = [] + if self._arb_task is None: + self._arb_task = safe_ensure_future(self.try_create_arbitrage_action()) + elif self._arb_task.done(): + executor_actions.append(self._arb_task.result()) + self._arb_task = safe_ensure_future(self.try_create_arbitrage_action()) + return executor_actions + + async def try_create_arbitrage_action(self) -> List[ExecutorAction]: + executor_actions = [] + active_executors = self.filter_executors( + executors=self.get_all_executors(), + filter_func=lambda e: not e.is_done + ) + if len(active_executors) == 0: + forward_arbitrage_percent = await self.estimate_arbitrage_percent(ArbitrageDirection.FORWARD) + if forward_arbitrage_percent >= self.config.min_arbitrage_percent: + executor_actions.append( + CreateExecutorAction(executor_config=self.arbitrage_config(ArbitrageDirection.FORWARD))) + else: + backward_arbitrage_percent = await self.estimate_arbitrage_percent(ArbitrageDirection.BACKWARD) + if -backward_arbitrage_percent >= self.config.min_arbitrage_percent: + executor_actions.append( + CreateExecutorAction(executor_config=self.arbitrage_config(ArbitrageDirection.BACKWARD))) + return executor_actions + + async def estimate_arbitrage_percent(self, direction: ArbitrageDirection) -> Decimal: + forward = direction is ArbitrageDirection.FORWARD + p_arb_asset_in_stable_asset = self.connectors[self.config.cex_connector_main].get_quote_price( + trading_pair=self.markets[self.config.cex_connector_main][0], is_buy=forward, + amount=self.config.min_arbitrage_volume) + p_proxy_asset_in_stable_asset = self.connectors[self.config.cex_connector_proxy].get_quote_price( + trading_pair=self.markets[self.config.cex_connector_proxy][0], is_buy=not forward, + amount=self.config.min_arbitrage_volume) + p_arb_asset_in_stable_asset, p_proxy_asset_in_stable_asset = await asyncio.gather(p_arb_asset_in_stable_asset, + p_proxy_asset_in_stable_asset) + arb_vol_in_proxy_asset = self.config.min_arbitrage_volume / p_proxy_asset_in_stable_asset + p_arb_asset_wrapped_asset_in_proxy_asset = await self.connectors[self.config.dex_connector].get_quote_price( + trading_pair=self.markets[self.config.dex_connector][0], is_buy=not forward, + amount=arb_vol_in_proxy_asset) + return get_arbitrage_percent(p_arb_asset_in_stable_asset, + p_proxy_asset_in_stable_asset, + p_arb_asset_wrapped_asset_in_proxy_asset) + + +# Important: all prices must be given in Base/Quote format, assuming +# arb_asset, proxy_asset, arb_asset_wrapped are quote assets in corresponding pairs. +def get_arbitrage_percent(p_arb_asset_in_stable_asset: Decimal, p_proxy_asset_in_stable_asset: Decimal, + p_arb_asset_wrapped_in_proxy_asset: Decimal) -> Decimal: + p_arb_asset_wrapped_in_stable_asset = p_proxy_asset_in_stable_asset * p_arb_asset_wrapped_in_proxy_asset + price_diff = p_arb_asset_wrapped_in_stable_asset - p_arb_asset_in_stable_asset + return price_diff * Decimal(100) / ( + p_arb_asset_wrapped_in_stable_asset if price_diff >= Decimal(0) else p_arb_asset_in_stable_asset)