diff --git a/.github/ISSUE_TEMPLATE/bounty_request.yml b/.github/ISSUE_TEMPLATE/bounty_request.yml index d2a4152660..09128b2e8e 100644 --- a/.github/ISSUE_TEMPLATE/bounty_request.yml +++ b/.github/ISSUE_TEMPLATE/bounty_request.yml @@ -1,6 +1,6 @@ name: Bounty Request description: Create a bounty for developers to work on -title: "SUMMARY OF BOUNTY REQUEST" +title: "Bounty Request " labels: bounty body: - type: markdown @@ -8,6 +8,7 @@ body: value: | ## **Before Submitting:** + * Please edit the "Bounty request" to the title of the bug/issue * Please make sure to look on our GitHub issues to avoid duplicate tickets * You can add additional `Labels` to support this ticket (connectors, strategies, etc) * See https://docs.hummingbot.org/governance/bounties/sponsors/ for more information on bounties diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4b836aa5d8..1382495c81 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report description: Create a bug report to help us improve -title: "SUMMARY OF BUG" +title: "Bug Report" labels: bug body: - type: markdown @@ -8,6 +8,7 @@ body: value: | ## **Before Submitting:** + * Please edit the "Bug Report" to the title of the bug or issue * Please make sure to look on our GitHub issues to avoid duplicate tickets * You can add additional `Labels` to support this ticket (connectors, strategies, etc) * If this is something to do with installation and how to's we would recommend to visit our [Discord server](https://discord.gg/hummingbot) and [Hummingbot docs](https://docs.hummingbot.org/) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8c6a71deea..0037cbab03 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature request description: Suggest an idea that will improve the Hummingbot codebase -title: "SUMMARY OF FEATURE REQUEST" +title: "Feature Request" labels: enhancement body: - type: markdown @@ -8,6 +8,7 @@ body: value: | ## **Before Submitting:** + * Please edit the "Feature Request" to the title of the feature * Please make sure to look on our GitHub issues to avoid duplicate tickets * You can add additional `Labels` to support this ticket (connectors, strategies, etc) * If this is something to do with installation and how to's we would recommend to visit our [Discord server](https://discord.gg/hummingbot) and [Hummingbot docs](https://docs.hummingbot.org/) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index d67dcb06d8..30f291424a 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -25,7 +25,7 @@ jobs: if: env.GIT_DIFF run: | echo ${{ env.GIT_DIFF }} - echo "{is_set}={true}" >> $GITHUB_OUTPUT + echo "::set-output name=is_set::true" build_hummingbot: name: Hummingbot build + stable tests diff --git a/Dockerfile b/Dockerfile index 92dbf4c517..bb2924402d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,4 +73,4 @@ SHELL [ "/bin/bash", "-lc" ] # Set the default command to run when starting the container -CMD conda activate hummingbot && ./bin/hummingbot_quickstart.py 2>./logs/standard_error_output.txt \ No newline at end of file +CMD conda activate hummingbot && ./bin/hummingbot_quickstart.py 2>> ./logs/errors.log \ No newline at end of file diff --git a/bin/hummingbot.py b/bin/hummingbot.py index 6360af0b71..52cb112dab 100755 --- a/bin/hummingbot.py +++ b/bin/hummingbot.py @@ -7,6 +7,7 @@ import path_util # noqa: F401 from hummingbot import chdir_to_data_directory, init_logging +from hummingbot.client.config.client_config_map import ClientConfigMap from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger from hummingbot.client.config.config_helpers import ( ClientConfigAdapter, @@ -85,8 +86,12 @@ def main(): ev_loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() asyncio.set_event_loop(ev_loop) - client_config_map = load_client_config_map_from_file() - if login_prompt(secrets_manager_cls, style=load_style(client_config_map)): + # We need to load a default style for the login screen because the password is required to load the + # real configuration now that it can include secret parameters + style = load_style(ClientConfigAdapter(ClientConfigMap())) + + if login_prompt(secrets_manager_cls, style=style): + client_config_map = load_client_config_map_from_file() ev_loop.run_until_complete(main_async(client_config_map)) diff --git a/docker-compose.yml b/docker-compose.yml index 090a4bebeb..32f66ecf43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,47 +1,47 @@ version: "3.9" services: -# hummingbot: -# container_name: hummingbot -# build: -# context: . -# dockerfile: Dockerfile -# volumes: -# - ./conf:/home/hummingbot/conf -# - ./conf/connectors:/home/hummingbot/conf/connectors -# - ./conf/strategies:/home/hummingbot/conf/strategies -# - ./logs:/home/hummingbot/logs -# - ./data:/home/hummingbot/data -# - ./scripts:/home/hummingbot/scripts -# environment: -# - CONFIG_PASSWORD=a -# - CONFIG_FILE_NAME=directional_strategy_rsi.py -# logging: -# driver: "json-file" -# options: -# max-size: "10m" -# max-file: 5 -# tty: true -# stdin_open: true -# network_mode: host -# -# dashboard: -# container_name: dashboard -# image: hummingbot/dashboard:latest -# volumes: -# - ./data:/home/dashboard/data -# ports: -# - "8501:8501" - - gateway: - container_name: gateway - image: hummingbot/gateway:latest - ports: - - "15888:15888" - - "8080:8080" + hummingbot: + container_name: hummingbot + build: + context: . + dockerfile: Dockerfile volumes: - - "./gateway_files/conf:/home/gateway/conf" - - "./gateway_files/logs:/home/gateway/logs" - - "./gateway_files/db:/home/gateway/db" - - "./certs:/home/gateway/certs" - environment: - - GATEWAY_PASSPHRASE=a \ No newline at end of file + - ./conf:/home/hummingbot/conf + - ./conf/connectors:/home/hummingbot/conf/connectors + - ./conf/strategies:/home/hummingbot/conf/strategies + - ./logs:/home/hummingbot/logs + - ./data:/home/hummingbot/data + - ./scripts:/home/hummingbot/scripts + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: 5 + tty: true + stdin_open: true + network_mode: host + # environment: + # - CONFIG_PASSWORD=a + # - CONFIG_FILE_NAME=simple_pmm_example.py + + # dashboard: + # container_name: dashboard + # image: hummingbot/dashboard:latest + # volumes: + # - ./data:/home/dashboard/data + # ports: + # - "8501:8501" + + # gateway: + # container_name: gateway + # image: hummingbot/gateway:latest + # ports: + # - "15888:15888" + # - "8080:8080" + # volumes: + # - "./gateway_files/conf:/home/gateway/conf" + # - "./gateway_files/logs:/home/gateway/logs" + # - "./gateway_files/db:/home/gateway/db" + # - "./certs:/home/gateway/certs" + # environment: + # - GATEWAY_PASSPHRASE=a \ No newline at end of file diff --git a/hummingbot/VERSION b/hummingbot/VERSION index dafcae148a..55383ee68c 100644 --- a/hummingbot/VERSION +++ b/hummingbot/VERSION @@ -1 +1 @@ -dev-1.19.0 +dev-1.20.0 diff --git a/hummingbot/client/config/client_config_map.py b/hummingbot/client/config/client_config_map.py index 2fe35eb8a9..d0a3f99009 100644 --- a/hummingbot/client/config/client_config_map.py +++ b/hummingbot/client/config/client_config_map.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union -from pydantic import BaseModel, Field, root_validator, validator +from pydantic import BaseModel, Field, SecretStr, root_validator, validator from tabulate import tabulate_formats from hummingbot.client.config.config_data_types import BaseClientModel, ClientConfigEnum, ClientFieldData @@ -805,6 +805,87 @@ def post_validations(cls, values: Dict): return values +class CoinCapRateSourceMode(RateSourceModeBase): + name: str = Field( + default="coin_cap", + const=True, + client_data=None, + ) + assets_map: Dict[str, str] = Field( + default=",".join( + [ + ":".join(pair) for pair in { + "BTC": "bitcoin", + "ETH": "ethereum", + "USDT": "tether", + "CONV": "convergence", + "FIRO": "zcoin", + "BUSD": "binance-usd", + "ONE": "harmony", + "PDEX": "polkadex", + }.items() + ] + ), + description=( + "The symbol-to-asset ID map for CoinCap. Assets IDs can be found by selecting a symbol" + " on https://coincap.io/ and extracting the last segment of the URL path." + ), + client_data=ClientFieldData( + prompt=lambda cm: ( + "CoinCap symbol-to-asset ID map (e.g. 'BTC:bitcoin,ETH:ethereum', find IDs on https://coincap.io/" + " by selecting a symbol and extracting the last segment of the URL path)" + ), + is_connect_key=True, + prompt_on_new=True, + ), + ) + api_key: SecretStr = Field( + default=SecretStr(""), + description="API key to use to request information from CoinCap (if empty public requests will be used)", + client_data=ClientFieldData( + prompt=lambda cm: "CoinCap API key (optional, but improves rate limits)", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ), + ) + + class Config: + title = "coin_cap" + + def build_rate_source(self) -> RateSourceBase: + rate_source = RATE_ORACLE_SOURCES["coin_cap"]( + assets_map=self.assets_map, api_key=self.api_key.get_secret_value() + ) + return rate_source + + @validator("assets_map", pre=True) + def validate_extra_tokens(cls, value: Union[str, Dict[str, str]]): + if isinstance(value, str): + value = {key: val for key, val in [v.split(":") for v in value.split(",")]} + return value + + # === post-validations === + + @root_validator() + def post_validations(cls, values: Dict): + cls.rate_oracle_source_on_validated(values) + return values + + @classmethod + def rate_oracle_source_on_validated(cls, values: Dict): + RateOracle.get_instance().source = cls._build_rate_source_cls( + assets_map=values["assets_map"], api_key=values["api_key"] + ) + + @classmethod + def _build_rate_source_cls(cls, assets_map: Dict[str, str], api_key: SecretStr) -> RateSourceBase: + rate_source = RATE_ORACLE_SOURCES["coin_cap"]( + assets_map=assets_map, api_key=api_key.get_secret_value() + ) + return rate_source + + class KuCoinRateSourceMode(ExchangeRateSourceModeBase): name: str = Field( default="kucoin", @@ -831,6 +912,7 @@ class Config: AscendExRateSourceMode.Config.title: AscendExRateSourceMode, BinanceRateSourceMode.Config.title: BinanceRateSourceMode, CoinGeckoRateSourceMode.Config.title: CoinGeckoRateSourceMode, + CoinCapRateSourceMode.Config.title: CoinCapRateSourceMode, KuCoinRateSourceMode.Config.title: KuCoinRateSourceMode, GateIoRateSourceMode.Config.title: GateIoRateSourceMode, } @@ -1153,7 +1235,5 @@ def post_validations(cls, values: Dict): @classmethod def rate_oracle_source_on_validated(cls, values: Dict): rate_source_mode: RateSourceModeBase = values["rate_oracle_source"] - rate_source_name = rate_source_mode.Config.title - if rate_source_name != RateOracle.get_instance().source.name: - RateOracle.get_instance().source = rate_source_mode.build_rate_source() + RateOracle.get_instance().source = rate_source_mode.build_rate_source() RateOracle.get_instance().quote_token = values["global_token"].global_token_name diff --git a/hummingbot/client/config/config_helpers.py b/hummingbot/client/config/config_helpers.py index 7e66cda3ce..f03975ecde 100644 --- a/hummingbot/client/config/config_helpers.py +++ b/hummingbot/client/config/config_helpers.py @@ -38,63 +38,6 @@ AllConnectorSettings, ) -# Use ruamel.yaml to preserve order and comments in .yml file -yaml_parser = ruamel.yaml.YAML() # legacy - - -def decimal_representer(dumper: SafeDumper, data: Decimal): - return dumper.represent_float(float(data)) - - -def enum_representer(dumper: SafeDumper, data: ClientConfigEnum): - return dumper.represent_str(str(data)) - - -def date_representer(dumper: SafeDumper, data: date): - return dumper.represent_date(data) - - -def time_representer(dumper: SafeDumper, data: time): - return dumper.represent_str(data.strftime("%H:%M:%S")) - - -def datetime_representer(dumper: SafeDumper, data: datetime): - return dumper.represent_datetime(data) - - -def path_representer(dumper: SafeDumper, data: Path): - return dumper.represent_str(str(data)) - - -def command_shortcut_representer(dumper: SafeDumper, data: CommandShortcutModel): - return dumper.represent_dict(data.__dict__) - - -yaml.add_representer( - data_type=Decimal, representer=decimal_representer, Dumper=SafeDumper -) -yaml.add_multi_representer( - data_type=ClientConfigEnum, multi_representer=enum_representer, Dumper=SafeDumper -) -yaml.add_representer( - data_type=date, representer=date_representer, Dumper=SafeDumper -) -yaml.add_representer( - data_type=time, representer=time_representer, Dumper=SafeDumper -) -yaml.add_representer( - data_type=datetime, representer=datetime_representer, Dumper=SafeDumper -) -yaml.add_representer( - data_type=Path, representer=path_representer, Dumper=SafeDumper -) -yaml.add_representer( - data_type=PosixPath, representer=path_representer, Dumper=SafeDumper -) -yaml.add_representer( - data_type=CommandShortcutModel, representer=command_shortcut_representer, Dumper=SafeDumper -) - class ConfigValidationError(Exception): pass @@ -228,6 +171,8 @@ def get_default_str_repr(self, attr_name: str) -> str: default_str = "" elif isinstance(default, (List, Tuple)): default_str = ",".join(default) + elif isinstance(default, BaseClientModel): + default_str = default.Config.title else: default_str = str(default) return default_str @@ -242,12 +187,18 @@ def generate_yml_output_str_with_comments(self) -> str: return yml_str def validate_model(self) -> List[str]: - results = validate_model(type(self._hb_config), json.loads(self._hb_config.json())) + input_data = self._hb_config.dict() + results = validate_model(model=type(self._hb_config), input_data=input_data) # coerce types conf_dict = results[0] for key, value in conf_dict.items(): self.setattr_no_validation(key, value) - self._decrypt_all_internal_secrets() + self.decrypt_all_secure_data() + input_data = self._hb_config.dict() + results = validate_model(model=type(self._hb_config), input_data=input_data) # validate decrypted values + conf_dict = results[0] errors = results[2] + for key, value in conf_dict.items(): + self.setattr_no_validation(key, value) validation_errors = [] if errors is not None: errors = errors.errors() @@ -264,6 +215,29 @@ def setattr_no_validation(self, attr: str, value: Any): def full_copy(self): return self.__class__(hb_config=self._hb_config.copy(deep=True)) + def decrypt_all_secure_data(self): + from hummingbot.client.config.security import Security # avoids circular import + + secure_config_items = ( + traversal_item + for traversal_item in self.traverse() + if traversal_item.client_field_data is not None and traversal_item.client_field_data.is_secure + ) + for traversal_item in secure_config_items: + value = traversal_item.value + if isinstance(value, SecretStr): + value = value.get_secret_value() + if value == "" or Security.secrets_manager is None: + decrypted_value = value + else: + decrypted_value = Security.secrets_manager.decrypt_secret_value(attr=traversal_item.attr, value=value) + *intermediate_items, final_config_element = traversal_item.config_path.split(".") + config_model = self + if len(intermediate_items) > 0: + for attr in intermediate_items: + config_model = config_model.__getattr__(attr) + setattr(config_model, final_config_element, decrypted_value) + @contextlib.contextmanager def _disable_validation(self): self._hb_config.Config.validate_assignment = False @@ -385,6 +359,79 @@ def lock_config(cls, config_map: ClientConfigMap): return cls(config_map._hb_config) +# Use ruamel.yaml to preserve order and comments in .yml file +yaml_parser = ruamel.yaml.YAML() # legacy + + +def decimal_representer(dumper: SafeDumper, data: Decimal): + return dumper.represent_float(float(data)) + + +def enum_representer(dumper: SafeDumper, data: ClientConfigEnum): + return dumper.represent_str(str(data)) + + +def date_representer(dumper: SafeDumper, data: date): + return dumper.represent_date(data) + + +def time_representer(dumper: SafeDumper, data: time): + return dumper.represent_str(data.strftime("%H:%M:%S")) + + +def datetime_representer(dumper: SafeDumper, data: datetime): + return dumper.represent_datetime(data) + + +def path_representer(dumper: SafeDumper, data: Path): + return dumper.represent_str(str(data)) + + +def command_shortcut_representer(dumper: SafeDumper, data: CommandShortcutModel): + return dumper.represent_dict(data.__dict__) + + +def client_config_adapter_representer(dumper: SafeDumper, data: ClientConfigAdapter): + return dumper.represent_dict(data._dict_in_conf_order()) + + +def base_client_model_representer(dumper: SafeDumper, data: BaseClientModel): + dictionary_representation = ClientConfigAdapter(data)._dict_in_conf_order() + return dumper.represent_dict(dictionary_representation) + + +yaml.add_representer( + data_type=Decimal, representer=decimal_representer, Dumper=SafeDumper +) +yaml.add_multi_representer( + data_type=ClientConfigEnum, multi_representer=enum_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=date, representer=date_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=time, representer=time_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=datetime, representer=datetime_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=Path, representer=path_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=PosixPath, representer=path_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=CommandShortcutModel, representer=command_shortcut_representer, Dumper=SafeDumper +) +yaml.add_representer( + data_type=ClientConfigAdapter, representer=client_config_adapter_representer, Dumper=SafeDumper +) +yaml.add_multi_representer( + data_type=BaseClientModel, multi_representer=base_client_model_representer, Dumper=SafeDumper +) + + def parse_cvar_value(cvar: ConfigVar, value: Any) -> Any: """ Based on the target type specified in `ConfigVar.type_str`, parses a string value into the target type. @@ -809,7 +856,7 @@ def save_to_yml_legacy(yml_path: str, cm: Dict[str, ConfigVar]): data = yaml_parser.load(stream) or {} for key in cm: cvar = cm.get(key) - if type(cvar.value) == Decimal: + if isinstance(cvar.value, Decimal): data[key] = float(cvar.value) else: data[key] = cvar.value diff --git a/hummingbot/connector/connector_status.py b/hummingbot/connector/connector_status.py index 71035e6176..0e6ebd5279 100644 --- a/hummingbot/connector/connector_status.py +++ b/hummingbot/connector/connector_status.py @@ -62,6 +62,10 @@ 'vertex': 'bronze', 'vertex_testnet': 'bronze', 'injective_v2': 'bronze', + 'injective_v2_perpetual': 'bronze', + 'plenty': 'bronze', + 'woo_x': 'bronze', + 'woo_x_testnet': 'bronze', 'kujira': 'bronze', } diff --git a/hummingbot/connector/derivative/dydx_perpetual/dydx_perpetual_derivative.py b/hummingbot/connector/derivative/dydx_perpetual/dydx_perpetual_derivative.py index ca4e5e7d37..b75c9581c1 100644 --- a/hummingbot/connector/derivative/dydx_perpetual/dydx_perpetual_derivative.py +++ b/hummingbot/connector/derivative/dydx_perpetual/dydx_perpetual_derivative.py @@ -38,20 +38,19 @@ class DydxPerpetualDerivative(PerpetualDerivativePyBase): - web_utils = web_utils def __init__( - self, - client_config_map: "ClientConfigAdapter", - dydx_perpetual_api_key: str, - dydx_perpetual_api_secret: str, - dydx_perpetual_passphrase: str, - dydx_perpetual_ethereum_address: str, - dydx_perpetual_stark_private_key: str, - trading_pairs: Optional[List[str]] = None, - trading_required: bool = True, - domain: str = CONSTANTS.DEFAULT_DOMAIN, + self, + client_config_map: "ClientConfigAdapter", + dydx_perpetual_api_key: str, + dydx_perpetual_api_secret: str, + dydx_perpetual_passphrase: str, + dydx_perpetual_ethereum_address: str, + dydx_perpetual_stark_private_key: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, ): self._dydx_perpetual_api_key = dydx_perpetual_api_key self._dydx_perpetual_api_secret = dydx_perpetual_api_secret @@ -223,15 +222,15 @@ async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder): return True async def _place_order( - self, - order_id: str, - trading_pair: str, - amount: Decimal, - trade_type: TradeType, - order_type: OrderType, - price: Decimal, - position_action: PositionAction = PositionAction.NIL, - **kwargs, + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Decimal, + position_action: PositionAction = PositionAction.NIL, + **kwargs, ) -> Tuple[str, float]: if self._current_place_order_requests == 0: # No requests are under way, the dictionary can be cleaned @@ -240,12 +239,29 @@ async def _place_order( # Increment number of currently undergoing requests self._current_place_order_requests += 1 + if order_type.is_limit_type(): + time_in_force = CONSTANTS.TIF_GOOD_TIL_TIME + else: + time_in_force = CONSTANTS.TIF_IMMEDIATE_OR_CANCEL + if trade_type.name.lower() == 'buy': + # The price needs to be relatively high before the transaction, whether the test will be cancelled + price = Decimal("1.5") * self.get_price_for_volume( + trading_pair, + True, + amount + ).result_price + else: + price = Decimal("0.75") * self.get_price_for_volume( + trading_pair, + False, + amount + ).result_price + notional_amount = amount * price if notional_amount not in self._order_notional_amounts.keys(): self._order_notional_amounts[notional_amount] = len(self._order_notional_amounts.keys()) # Set updated rate limits self._throttler.set_rate_limits(self.rate_limits_rules) - size = str(amount) price = str(price) side = "BUY" if trade_type == TradeType.BUY else "SELL" @@ -254,7 +270,6 @@ async def _place_order( reduce_only = False post_only = order_type is OrderType.LIMIT_MAKER - time_in_force = CONSTANTS.TIF_GOOD_TIL_TIME market = await self.exchange_symbol_associated_to_pair(trading_pair) signature = self._auth.get_order_signature( @@ -307,15 +322,15 @@ async def _place_order( return str(resp["order"]["id"]), iso_to_epoch_seconds(resp["order"]["createdAt"]) def _get_fee( - self, - base_currency: str, - quote_currency: str, - order_type: OrderType, - order_side: TradeType, - position_action: PositionAction, - amount: Decimal, - price: Decimal = s_decimal_NaN, - is_maker: Optional[bool] = None, + self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + position_action: PositionAction, + amount: Decimal, + price: Decimal = s_decimal_NaN, + is_maker: Optional[bool] = None, ) -> TradeFeeBase: is_maker = is_maker or False if CONSTANTS.FEES_KEY not in self._trading_fees.keys(): @@ -541,8 +556,8 @@ async def _process_funding_payments(self, funding_payments: List): if trading_pair not in prev_timestamps.keys(): prev_timestamps[trading_pair] = None if ( - prev_timestamps[trading_pair] is not None - and dateparse(funding_payment["effectiveAt"]).timestamp() <= prev_timestamps[trading_pair] + prev_timestamps[trading_pair] is not None + and dateparse(funding_payment["effectiveAt"]).timestamp() <= prev_timestamps[trading_pair] ): continue timestamp = dateparse(funding_payment["effectiveAt"]).timestamp() diff --git a/hummingbot/connector/derivative/gate_io_perpetual/gate_io_perpetual_derivative.py b/hummingbot/connector/derivative/gate_io_perpetual/gate_io_perpetual_derivative.py index 093504cf38..33d7af3bcf 100644 --- a/hummingbot/connector/derivative/gate_io_perpetual/gate_io_perpetual_derivative.py +++ b/hummingbot/connector/derivative/gate_io_perpetual/gate_io_perpetual_derivative.py @@ -703,9 +703,14 @@ async def _update_positions(self): hb_trading_pair = await self.trading_pair_associated_to_exchange_symbol(ex_trading_pair) amount = Decimal(position.get("size")) - position_side = PositionSide.LONG if Decimal(position.get("size")) > 0 else PositionSide.SHORT - - pos_key = self._perpetual_trading.position_key(hb_trading_pair, position_side) + ex_mode = position.get("mode") + if ex_mode == 'single': + mode = PositionMode.ONEWAY + position_side = PositionSide.LONG if Decimal(position.get("size")) > 0 else PositionSide.SHORT + else: + mode = PositionMode.HEDGE + position_side = PositionSide.LONG if ex_mode == "dual_long" else PositionSide.SHORT + pos_key = self._perpetual_trading.position_key(hb_trading_pair, position_side, mode) if amount != 0: trading_rule = self._trading_rules[hb_trading_pair] diff --git a/hummingbot/connector/derivative/injective_v2_perpetual/README.md b/hummingbot/connector/derivative/injective_v2_perpetual/README.md new file mode 100644 index 0000000000..162d7949d3 --- /dev/null +++ b/hummingbot/connector/derivative/injective_v2_perpetual/README.md @@ -0,0 +1,4 @@ +## Injective v2 Perpetual + +This is a perpetual connector created by **[Injective Labs](https://injectivelabs.org/)**. +The description and configuration steps for the perpetual connector are identical to the spot connector. Please check the README file in the Injective v2 spot connector folder. diff --git a/hummingbot/connector/exchange/injective_v2.injective_cookie b/hummingbot/connector/derivative/injective_v2_perpetual/__init__.py similarity index 100% rename from hummingbot/connector/exchange/injective_v2.injective_cookie rename to hummingbot/connector/derivative/injective_v2_perpetual/__init__.py diff --git a/hummingbot/connector/derivative/injective_v2_perpetual/injective_constants.py b/hummingbot/connector/derivative/injective_v2_perpetual/injective_constants.py new file mode 100644 index 0000000000..e474272bf1 --- /dev/null +++ b/hummingbot/connector/derivative/injective_v2_perpetual/injective_constants.py @@ -0,0 +1,12 @@ +from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS + +EXCHANGE_NAME = "injective_v2_perpetual" + +DEFAULT_DOMAIN = "" +TESTNET_DOMAIN = "testnet" + +TRANSACTIONS_CHECK_INTERVAL = CONSTANTS.TRANSACTIONS_CHECK_INTERVAL + +ORDER_STATE_MAP = CONSTANTS.ORDER_STATE_MAP + +ORDER_NOT_FOUND_ERROR_MESSAGE = CONSTANTS.ORDER_NOT_FOUND_ERROR_MESSAGE diff --git a/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_api_order_book_data_source.py b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_api_order_book_data_source.py new file mode 100644 index 0000000000..19ce377302 --- /dev/null +++ b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_api_order_book_data_source.py @@ -0,0 +1,93 @@ +import asyncio +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.derivative.injective_v2_perpetual import injective_constants as CONSTANTS +from hummingbot.connector.exchange.injective_v2.data_sources.injective_data_source import InjectiveDataSource +from hummingbot.core.data_type.funding_info import FundingInfo, FundingInfoUpdate +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.perpetual_api_order_book_data_source import PerpetualAPIOrderBookDataSource +from hummingbot.core.event.event_forwarder import EventForwarder +from hummingbot.core.event.events import MarketEvent, OrderBookDataSourceEvent + +if TYPE_CHECKING: + from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_derivative import ( + InjectiveV2Dericative, + ) + + +class InjectiveV2PerpetualAPIOrderBookDataSource(PerpetualAPIOrderBookDataSource): + + def __init__( + self, + trading_pairs: List[str], + connector: "InjectiveV2Dericative", + data_source: InjectiveDataSource, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + super().__init__(trading_pairs=trading_pairs) + self._ev_loop = asyncio.get_event_loop() + self._connector = connector + self._data_source = data_source + self._domain = domain + self._forwarders = [] + self._configure_event_forwarders() + + async def get_funding_info(self, trading_pair: str) -> FundingInfo: + market_id = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + funding_info = await self._data_source.funding_info(market_id=market_id) + + return funding_info + + async def get_last_traded_prices(self, trading_pairs: List[str], domain: Optional[str] = None) -> Dict[str, float]: + return await self._connector.get_last_traded_prices(trading_pairs=trading_pairs) + + async def listen_for_subscriptions(self): + # Subscriptions to streams is handled by the data_source + # Here we just make sure the data_source is listening to the streams + market_ids = [await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + for trading_pair in self._trading_pairs] + await self._data_source.start(market_ids=market_ids) + + async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: + symbol = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + snapshot = await self._data_source.perpetual_order_book_snapshot(market_id=symbol, trading_pair=trading_pair) + return snapshot + + async def _parse_order_book_diff_message(self, raw_message: OrderBookMessage, message_queue: asyncio.Queue): + # In Injective 'raw_message' is not a raw message, but the OrderBookMessage with type Trade created + # by the data source + message_queue.put_nowait(raw_message) + + async def _parse_trade_message(self, raw_message: OrderBookMessage, message_queue: asyncio.Queue): + # In Injective 'raw_message' is not a raw message, but the OrderBookMessage with type Trade created + # by the data source + message_queue.put_nowait(raw_message) + + async def _parse_funding_info_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + # In Injective 'raw_message' is not a raw message, but the FundingInfoUpdate created + # by the data source + message_queue.put_nowait(raw_message) + + def _configure_event_forwarders(self): + event_forwarder = EventForwarder(to_function=self._process_order_book_event) + self._forwarders.append(event_forwarder) + self._data_source.add_listener( + event_tag=OrderBookDataSourceEvent.DIFF_EVENT, listener=event_forwarder + ) + + event_forwarder = EventForwarder(to_function=self._process_public_trade_event) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=OrderBookDataSourceEvent.TRADE_EVENT, listener=event_forwarder) + + event_forwarder = EventForwarder(to_function=self._process_funding_info_event) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=MarketEvent.FundingInfo, listener=event_forwarder) + + def _process_order_book_event(self, order_book_diff: OrderBookMessage): + self._message_queue[self._diff_messages_queue_key].put_nowait(order_book_diff) + + def _process_public_trade_event(self, trade_update: OrderBookMessage): + self._message_queue[self._trade_messages_queue_key].put_nowait(trade_update) + + def _process_funding_info_event(self, funding_info_update: FundingInfoUpdate): + self._message_queue[self._funding_info_messages_queue_key].put_nowait(funding_info_update) diff --git a/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_derivative.py b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_derivative.py new file mode 100644 index 0000000000..00f3798738 --- /dev/null +++ b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_derivative.py @@ -0,0 +1,1132 @@ +import asyncio +from collections import defaultdict +from decimal import Decimal +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union + +from async_timeout import timeout + +from hummingbot.connector.client_order_tracker import ClientOrderTracker +from hummingbot.connector.constants import FUNDING_FEE_POLL_INTERVAL, s_decimal_NaN +from hummingbot.connector.derivative.injective_v2_perpetual import ( + injective_constants as CONSTANTS, + injective_v2_perpetual_web_utils as web_utils, +) +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_api_order_book_data_source import ( + InjectiveV2PerpetualAPIOrderBookDataSource, +) +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_utils import InjectiveConfigMap +from hummingbot.connector.derivative.position import Position +from hummingbot.connector.exchange.injective_v2.injective_events import InjectiveEvent +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayPerpetualInFlightOrder +from hummingbot.connector.gateway.gateway_order_tracker import GatewayOrderTracker +from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import combine_to_hb_trading_pair, get_new_client_order_id +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType +from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.market_order import MarketOrder +from hummingbot.core.data_type.perpetual_api_order_book_data_source import PerpetualAPIOrderBookDataSource +from hummingbot.core.data_type.trade_fee import TradeFeeBase, TradeFeeSchema +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.event.event_forwarder import EventForwarder +from hummingbot.core.event.events import AccountEvent, BalanceUpdateEvent, MarketEvent, PositionUpdateEvent +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.utils.estimate_fee import build_perpetual_trade_fee +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + +if TYPE_CHECKING: + from hummingbot.client.config.config_helpers import ClientConfigAdapter + + +class InjectiveV2PerpetualDerivative(PerpetualDerivativePyBase): + web_utils = web_utils + + def __init__( + self, + client_config_map: "ClientConfigAdapter", + connector_configuration: InjectiveConfigMap, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + **kwargs, + ): + self._orders_processing_delta_time = 0.5 + + self._trading_required = trading_required + self._trading_pairs = trading_pairs + self._data_source = connector_configuration.create_data_source() + self._rate_limits = connector_configuration.network.rate_limits() + + super().__init__(client_config_map=client_config_map) + self._data_source.configure_throttler(throttler=self._throttler) + self._forwarders = [] + self._configure_event_forwarders() + self._latest_polled_order_fill_time: float = self._time() + self._orders_transactions_check_task: Optional[asyncio.Task] = None + self._last_received_message_timestamp = 0 + self._orders_queued_to_create: List[GatewayPerpetualInFlightOrder] = [] + self._orders_queued_to_cancel: List[GatewayPerpetualInFlightOrder] = [] + + self._orders_transactions_check_task = None + self._queued_orders_task = None + self._all_trading_events_queue = asyncio.Queue() + + @property + def name(self) -> str: + return CONSTANTS.EXCHANGE_NAME + + @property + def authenticator(self) -> AuthBase: + return None + + @property + def rate_limits_rules(self) -> List[RateLimit]: + return self._rate_limits + + @property + def domain(self) -> str: + return self._data_source.network_name + + @property + def client_order_id_max_length(self) -> int: + return None + + @property + def client_order_id_prefix(self) -> str: + return "" + + @property + def trading_rules_request_path(self) -> str: + raise NotImplementedError + + @property + def trading_pairs_request_path(self) -> str: + raise NotImplementedError + + @property + def check_network_request_path(self) -> str: + raise NotImplementedError + + @property + def trading_pairs(self) -> List[str]: + return self._trading_pairs + + @property + def is_cancel_request_in_exchange_synchronous(self) -> bool: + return False + + @property + def is_trading_required(self) -> bool: + return self._trading_required + + @property + def funding_fee_poll_interval(self) -> int: + return FUNDING_FEE_POLL_INTERVAL + + def supported_position_modes(self) -> List[PositionMode]: + return [PositionMode.ONEWAY] + + def get_buy_collateral_token(self, trading_pair: str) -> str: + trading_rule: TradingRule = self._trading_rules[trading_pair] + return trading_rule.buy_order_collateral_token + + def get_sell_collateral_token(self, trading_pair: str) -> str: + trading_rule: TradingRule = self._trading_rules[trading_pair] + return trading_rule.sell_order_collateral_token + + @property + def status_dict(self) -> Dict[str, bool]: + status = super().status_dict + status["data_source_initialized"] = self._data_source.is_started() + return status + + async def start_network(self): + await super().start_network() + + market_ids = [ + await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + for trading_pair in self._trading_pairs + ] + await self._data_source.start(market_ids=market_ids) + + if self.is_trading_required: + self._orders_transactions_check_task = safe_ensure_future(self._check_orders_transactions()) + self._queued_orders_task = safe_ensure_future(self._process_queued_orders()) + + async def stop_network(self): + """ + This function is executed when the connector is stopped. It performs a general cleanup and stops all background + tasks that require the connection with the exchange to work. + """ + await super().stop_network() + await self._data_source.stop() + self._forwarders = [] + if self._orders_transactions_check_task is not None: + self._orders_transactions_check_task.cancel() + self._orders_transactions_check_task = None + if self._queued_orders_task is not None: + self._queued_orders_task.cancel() + self._queued_orders_task = None + + def supported_order_types(self) -> List[OrderType]: + return self._data_source.supported_order_types() + + def start_tracking_order( + self, + order_id: str, + exchange_order_id: Optional[str], + trading_pair: str, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + order_type: OrderType, + position_action: PositionAction = PositionAction.NIL, + **kwargs, + ): + leverage = self.get_leverage(trading_pair=trading_pair) + self._order_tracker.start_tracking_order( + GatewayPerpetualInFlightOrder( + client_order_id=order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + order_type=order_type, + trade_type=trade_type, + amount=amount, + price=price, + creation_timestamp=self.current_timestamp, + leverage=leverage, + position=position_action, + ) + ) + + def batch_order_create(self, orders_to_create: List[Union[MarketOrder, LimitOrder]]) -> List[LimitOrder]: + """ + Issues a batch order creation as a single API request for exchanges that implement this feature. The default + implementation of this method is to send the requests discretely (one by one). + :param orders_to_create: A list of LimitOrder or MarketOrder objects representing the orders to create. The order IDs + can be blanc. + :returns: A tuple composed of LimitOrder or MarketOrder objects representing the created orders, complete with the generated + order IDs. + """ + orders_with_ids_to_create = [] + for order in orders_to_create: + client_order_id = get_new_client_order_id( + is_buy=order.is_buy, + trading_pair=order.trading_pair, + hbot_order_id_prefix=self.client_order_id_prefix, + max_id_len=self.client_order_id_max_length, + ) + orders_with_ids_to_create.append(order.copy_with_id(client_order_id=client_order_id)) + safe_ensure_future(self._execute_batch_order_create(orders_to_create=orders_with_ids_to_create)) + return orders_with_ids_to_create + + def batch_order_cancel(self, orders_to_cancel: List[LimitOrder]): + """ + Issues a batch order cancelation as a single API request for exchanges that implement this feature. The default + implementation of this method is to send the requests discretely (one by one). + :param orders_to_cancel: A list of the orders to cancel. + """ + safe_ensure_future(coro=self._execute_batch_cancel(orders_to_cancel=orders_to_cancel)) + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + """ + Cancels all currently active orders. The cancellations are performed in parallel tasks. + + :param timeout_seconds: the maximum time (in seconds) the cancel logic should run + + :return: a list of CancellationResult instances, one for each of the orders to be cancelled + """ + incomplete_orders = {} + limit_orders = [] + successful_cancellations = [] + + for order in self.in_flight_orders.values(): + if not order.is_done: + incomplete_orders[order.client_order_id] = order + limit_orders.append(order.to_limit_order()) + + if len(limit_orders) > 0: + try: + async with timeout(timeout_seconds): + cancellation_results = await self._execute_batch_cancel(orders_to_cancel=limit_orders) + for cr in cancellation_results: + if cr.success: + del incomplete_orders[cr.order_id] + successful_cancellations.append(CancellationResult(cr.order_id, True)) + except Exception: + self.logger().network( + "Unexpected error cancelling orders.", + exc_info=True, + app_warning_msg="Failed to cancel order. Check API key and network connection." + ) + failed_cancellations = [CancellationResult(oid, False) for oid in incomplete_orders.keys()] + return successful_cancellations + failed_cancellations + + async def cancel_all_subaccount_orders(self): + markets_ids = [await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + for trading_pair in self.trading_pairs] + await self._data_source.cancel_all_subaccount_orders(perpetual_markets_ids=markets_ids) + + async def check_network(self) -> NetworkStatus: + """ + Checks connectivity with the exchange using the API + """ + try: + status = await self._data_source.check_network() + except asyncio.CancelledError: + raise + except Exception: + status = NetworkStatus.NOT_CONNECTED + return status + + def trigger_event(self, event_tag: Enum, message: any): + # Reimplemented because Injective connector has trading pairs with modified token names, because market tickers + # are not always unique. + # We need to change the original trading pair in all events to the real tokens trading pairs to not impact the + # bot events processing + trading_pair = getattr(message, "trading_pair", None) + if trading_pair is not None: + new_trading_pair = self._data_source.real_tokens_perpetual_trading_pair(unique_trading_pair=trading_pair) + if isinstance(message, tuple): + message = message._replace(trading_pair=new_trading_pair) + else: + setattr(message, "trading_pair", new_trading_pair) + + super().trigger_event(event_tag=event_tag, message=message) + + async def _update_positions(self): + positions = await self._data_source.account_positions() + self._perpetual_trading.account_positions.clear() + + for position in positions: + position_key = self._perpetual_trading.position_key( + trading_pair=position.trading_pair, + side=position.position_side, + ) + self._perpetual_trading.set_position(pos_key=position_key, position=position) + + async def _trading_pair_position_mode_set(self, mode: PositionMode, trading_pair: str) -> Tuple[bool, str]: + # Injective supports only one mode. It can't be changes in the chain + return True, "" + + async def _set_trading_pair_leverage(self, trading_pair: str, leverage: int) -> Tuple[bool, str]: + """ + Leverage is set on a per order basis. See place_order() + """ + return True, "" + + async def _fetch_last_fee_payment(self, trading_pair: str) -> Tuple[float, Decimal, Decimal]: + last_funding_rate = Decimal("-1") + market_id = await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + payment_amount, payment_timestamp = await self._data_source.last_funding_payment(market_id=market_id) + + if payment_amount != Decimal(-1) and payment_timestamp != 0: + last_funding_rate = await self._data_source.last_funding_rate(market_id=market_id) + + return payment_timestamp, last_funding_rate, payment_amount + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception) -> bool: + return False + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + return CONSTANTS.ORDER_NOT_FOUND_ERROR_MESSAGE in str(status_update_exception) + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + # For Injective the cancelation is done by sending a transaction to the chain. + # The cancel request is not validated until the transaction is included in a block, and so this does not apply + return False + + async def _place_cancel(self, order_id: str, tracked_order: GatewayPerpetualInFlightOrder): + # Not required because of _execute_order_cancel redefinition + raise NotImplementedError + + async def _execute_order_cancel(self, order: GatewayPerpetualInFlightOrder) -> str: + # Order cancelation requests for single orders are queued to be executed in batch if possible + self._orders_queued_to_cancel.append(order) + return None + + async def _place_order(self, order_id: str, trading_pair: str, amount: Decimal, trade_type: TradeType, + order_type: OrderType, price: Decimal, **kwargs) -> Tuple[str, float]: + # Not required because of _place_order_and_process_update redefinition + raise NotImplementedError + + async def _create_order( + self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Optional[Decimal] = None, + position_action: PositionAction = PositionAction.NIL, + **kwargs, + ): + """ + Creates an order in the exchange using the parameters to configure it + + :param trade_type: the side of the order (BUY of SELL) + :param order_id: the id that should be assigned to the order (the client id) + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + :param position_action: is the order opening or closing a position + """ + try: + if price is None: + calculated_price = self.get_price_for_volume( + trading_pair=trading_pair, + is_buy=trade_type == TradeType.BUY, + volume=amount, + ).result_price + calculated_price = self.quantize_order_price(trading_pair, calculated_price) + else: + calculated_price = price + + await super()._create_order( + trade_type=trade_type, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=calculated_price, + position_action=position_action, + **kwargs + ) + + except asyncio.CancelledError: + raise + except Exception as ex: + self._on_order_failure( + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + trade_type=trade_type, + order_type=order_type, + price=price, + exception=ex, + **kwargs, + ) + + async def _place_order_and_process_update(self, order: GatewayPerpetualInFlightOrder, **kwargs) -> str: + # Order creation requests for single orders are queued to be executed in batch if possible + self._orders_queued_to_create.append(order) + return None + + async def _execute_batch_order_create(self, orders_to_create: List[Union[MarketOrder, LimitOrder]]): + inflight_orders_to_create = [] + for order in orders_to_create: + valid_order = await self._start_tracking_and_validate_order( + trade_type=TradeType.BUY if order.is_buy else TradeType.SELL, + order_id=order.client_order_id, + trading_pair=order.trading_pair, + amount=order.quantity, + order_type=order.order_type(), + price=order.price, + position_action=order.position, + ) + if valid_order is not None: + inflight_orders_to_create.append(valid_order) + await self._execute_batch_inflight_order_create(inflight_orders_to_create=inflight_orders_to_create) + + async def _execute_batch_inflight_order_create(self, inflight_orders_to_create: List[GatewayPerpetualInFlightOrder]): + try: + place_order_results = await self._data_source.create_orders( + perpetual_orders=inflight_orders_to_create + ) + for place_order_result, in_flight_order in ( + zip(place_order_results, inflight_orders_to_create) + ): + if place_order_result.exception: + self._on_order_creation_failure( + order_id=in_flight_order.client_order_id, + trading_pair=in_flight_order.trading_pair, + amount=in_flight_order.amount, + trade_type=in_flight_order.trade_type, + order_type=in_flight_order.order_type, + price=in_flight_order.price, + exception=place_order_result.exception, + ) + else: + self._update_order_after_creation_success( + exchange_order_id=place_order_result.exchange_order_id, + order=in_flight_order, + update_timestamp=self.current_timestamp, + misc_updates=place_order_result.misc_updates, + ) + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().network("Batch order create failed.") + for order in inflight_orders_to_create: + self._on_order_creation_failure( + order_id=order.client_order_id, + trading_pair=order.trading_pair, + amount=order.amount, + trade_type=order.trade_type, + order_type=order.order_type, + price=order.price, + exception=ex, + ) + + async def _start_tracking_and_validate_order( + self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Optional[Decimal] = None, + **kwargs + ) -> Optional[GatewayPerpetualInFlightOrder]: + trading_rule = self._trading_rules[trading_pair] + + if price is None: + calculated_price = self.get_price_for_volume( + trading_pair=trading_pair, + is_buy=trade_type == TradeType.BUY, + volume=amount, + ).result_price + calculated_price = self.quantize_order_price(trading_pair, calculated_price) + else: + calculated_price = price + + price = self.quantize_order_price(trading_pair, calculated_price) + amount = self.quantize_order_amount(trading_pair=trading_pair, amount=amount) + + self.start_tracking_order( + order_id=order_id, + exchange_order_id=None, + trading_pair=trading_pair, + order_type=order_type, + trade_type=trade_type, + price=price, + amount=amount, + **kwargs, + ) + order = self._order_tracker.active_orders[order_id] + + if order_type not in self.supported_order_types(): + self.logger().error(f"{order_type} is not in the list of supported order types") + self._update_order_after_creation_failure(order_id=order_id, trading_pair=trading_pair) + order = None + elif amount < trading_rule.min_order_size: + self.logger().warning(f"{trade_type.name.title()} order amount {amount} is lower than the minimum order" + f" size {trading_rule.min_order_size}. The order will not be created.") + self._update_order_after_creation_failure(order_id=order_id, trading_pair=trading_pair) + order = None + elif price is not None and amount * price < trading_rule.min_notional_size: + self.logger().warning(f"{trade_type.name.title()} order notional {amount * price} is lower than the " + f"minimum notional size {trading_rule.min_notional_size}. " + "The order will not be created.") + self._update_order_after_creation_failure(order_id=order_id, trading_pair=trading_pair) + order = None + + return order + + def _update_order_after_creation_success( + self, + exchange_order_id: Optional[str], + order: GatewayPerpetualInFlightOrder, + update_timestamp: float, + misc_updates: Optional[Dict[str, Any]] = None + ): + order_update: OrderUpdate = OrderUpdate( + client_order_id=order.client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=order.trading_pair, + update_timestamp=update_timestamp, + new_state=order.current_state, + misc_updates=misc_updates, + ) + self.logger().debug(f"\nCreated order {order.client_order_id} ({exchange_order_id}) with TX {misc_updates}") + self._order_tracker.process_order_update(order_update) + + def _on_order_creation_failure( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Optional[Decimal], + exception: Exception, + ): + self.logger().network( + f"Error submitting {trade_type.name.lower()} {order_type.name.upper()} order to {self.name_cap} for " + f"{amount} {trading_pair} {price}.", + exc_info=exception, + app_warning_msg=f"Failed to submit buy order to {self.name_cap}. Check API key and network connection." + ) + self._update_order_after_creation_failure(order_id=order_id, trading_pair=trading_pair) + + def _update_order_after_creation_failure(self, order_id: str, trading_pair: str): + order_update: OrderUpdate = OrderUpdate( + client_order_id=order_id, + trading_pair=trading_pair, + update_timestamp=self.current_timestamp, + new_state=OrderState.FAILED, + ) + self._order_tracker.process_order_update(order_update) + + async def _execute_batch_cancel(self, orders_to_cancel: List[LimitOrder]) -> List[CancellationResult]: + results = [] + tracked_orders_to_cancel = [] + + for order in orders_to_cancel: + tracked_order = self._order_tracker.all_updatable_orders.get(order.client_order_id) + if tracked_order is not None: + tracked_orders_to_cancel.append(tracked_order) + else: + results.append(CancellationResult(order_id=order.client_order_id, success=False)) + + if len(tracked_orders_to_cancel) > 0: + results.extend(await self._execute_batch_order_cancel(orders_to_cancel=tracked_orders_to_cancel)) + + return results + + async def _execute_batch_order_cancel( + self, orders_to_cancel: List[GatewayPerpetualInFlightOrder], + ) -> List[CancellationResult]: + try: + cancel_order_results = await self._data_source.cancel_orders(perpetual_orders=orders_to_cancel) + cancelation_results = [] + for cancel_order_result in cancel_order_results: + success = True + if cancel_order_result.not_found: + self.logger().warning( + f"Failed to cancel the order {cancel_order_result.client_order_id} due to the order" + f" not being found." + ) + await self._order_tracker.process_order_not_found( + client_order_id=cancel_order_result.client_order_id + ) + success = False + elif cancel_order_result.exception is not None: + self.logger().error( + f"Failed to cancel order {cancel_order_result.client_order_id}", + exc_info=cancel_order_result.exception, + ) + success = False + else: + order_update: OrderUpdate = OrderUpdate( + client_order_id=cancel_order_result.client_order_id, + trading_pair=cancel_order_result.trading_pair, + update_timestamp=self.current_timestamp, + new_state=(OrderState.CANCELED + if self.is_cancel_request_in_exchange_synchronous + else OrderState.PENDING_CANCEL), + misc_updates=cancel_order_result.misc_updates, + ) + self._order_tracker.process_order_update(order_update) + cancelation_results.append( + CancellationResult(order_id=cancel_order_result.client_order_id, success=success) + ) + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + f"Failed to cancel orders {', '.join([o.client_order_id for o in orders_to_cancel])}", + exc_info=True, + ) + cancelation_results = [ + CancellationResult(order_id=order.client_order_id, success=False) + for order in orders_to_cancel + ] + + return cancelation_results + + def _update_order_after_cancelation_success(self, order: GatewayPerpetualInFlightOrder): + order_update: OrderUpdate = OrderUpdate( + client_order_id=order.client_order_id, + trading_pair=order.trading_pair, + update_timestamp=self.current_timestamp, + new_state=(OrderState.CANCELED + if self.is_cancel_request_in_exchange_synchronous + else OrderState.PENDING_CANCEL), + ) + self._order_tracker.process_order_update(order_update) + + def _get_fee( + self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + position_action: PositionAction, + amount: Decimal, + price: Decimal = s_decimal_NaN, + is_maker: Optional[bool] = None, + ) -> TradeFeeBase: + is_maker = is_maker or (order_type is OrderType.LIMIT_MAKER) + trading_pair = combine_to_hb_trading_pair(base=base_currency, quote=quote_currency) + if trading_pair in self._trading_fees: + fee_schema: TradeFeeSchema = self._trading_fees[trading_pair] + fee_rate = fee_schema.maker_percent_fee_decimal if is_maker else fee_schema.taker_percent_fee_decimal + fee = TradeFeeBase.new_perpetual_fee( + fee_schema=fee_schema, + position_action=position_action, + percent=fee_rate, + percent_token=fee_schema.percent_fee_token, + ) + else: + fee = build_perpetual_trade_fee( + self.name, + is_maker, + position_action=position_action, + base_currency=base_currency, + quote_currency=quote_currency, + order_type=order_type, + order_side=order_side, + amount=amount, + price=price, + ) + return fee + + async def _update_trading_fees(self): + self._trading_fees = await self._data_source.get_derivative_trading_fees() + + async def _user_stream_event_listener(self): + while True: + try: + event_message = await self._all_trading_events_queue.get() + channel = event_message["channel"] + event_data = event_message["data"] + + if channel == "transaction": + transaction_hash = event_data["hash"] + await self._check_created_orders_status_for_transaction(transaction_hash=transaction_hash) + elif channel == "trade": + trade_update = event_data + tracked_order = self._order_tracker.all_fillable_orders_by_exchange_order_id.get( + trade_update.exchange_order_id + ) + if tracked_order is not None: + new_trade_update = TradeUpdate( + trade_id=trade_update.trade_id, + client_order_id=tracked_order.client_order_id, + exchange_order_id=trade_update.exchange_order_id, + trading_pair=trade_update.trading_pair, + fill_timestamp=trade_update.fill_timestamp, + fill_price=trade_update.fill_price, + fill_base_amount=trade_update.fill_base_amount, + fill_quote_amount=trade_update.fill_quote_amount, + fee=trade_update.fee, + is_taker=trade_update.is_taker, + ) + self._order_tracker.process_trade_update(new_trade_update) + elif channel == "order": + order_update = event_data + tracked_order = self._order_tracker.all_updatable_orders_by_exchange_order_id.get( + order_update.exchange_order_id) + if tracked_order is not None: + new_order_update = OrderUpdate( + trading_pair=order_update.trading_pair, + update_timestamp=order_update.update_timestamp, + new_state=order_update.new_state, + client_order_id=tracked_order.client_order_id, + exchange_order_id=order_update.exchange_order_id, + misc_updates=order_update.misc_updates, + ) + self._order_tracker.process_order_update(order_update=new_order_update) + elif channel == "balance": + if event_data.total_balance is not None: + self._account_balances[event_data.asset_name] = event_data.total_balance + if event_data.available_balance is not None: + self._account_available_balances[event_data.asset_name] = event_data.available_balance + elif channel == "position": + position_update: PositionUpdateEvent = event_data + position_key = self._perpetual_trading.position_key( + position_update.trading_pair, position_update.position_side + ) + if position_update.amount == Decimal("0"): + self._perpetual_trading.remove_position(post_key=position_key) + else: + position: Position = self._perpetual_trading.get_position( + trading_pair=position_update.trading_pair, side=position_update.position_side + ) + if position is not None: + position.update_position( + position_side=position_update.position_side, + unrealized_pnl=position_update.unrealized_pnl, + entry_price=position_update.entry_price, + amount=position_update.amount, + leverage=position_update.leverage, + ) + else: + position = Position( + trading_pair=position_update.trading_pair, + position_side=position_update.position_side, + unrealized_pnl=position_update.unrealized_pnl, + entry_price=position_update.entry_price, + amount=position_update.amount, + leverage=position_update.leverage, + ) + self._perpetual_trading.set_position(pos_key=position_key, position=position) + + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error in user stream listener loop") + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + # Not used in Injective + raise NotImplementedError # pragma: no cover + + async def _update_trading_rules(self): + await self._data_source.update_markets() + await self._initialize_trading_pair_symbol_map() + trading_rules_list = await self._data_source.derivative_trading_rules() + trading_rules = {} + for trading_rule in trading_rules_list: + trading_rules[trading_rule.trading_pair] = trading_rule + self._trading_rules.clear() + self._trading_rules.update(trading_rules) + + async def _update_balances(self): + all_balances = await self._data_source.all_account_balances() + + self._account_available_balances.clear() + self._account_balances.clear() + + for token, token_balance_info in all_balances.items(): + self._account_balances[token] = token_balance_info["total_balance"] + self._account_available_balances[token] = token_balance_info["available_balance"] + + async def _all_trade_updates_for_order(self, order: GatewayPerpetualInFlightOrder) -> List[TradeUpdate]: + # Not required because of _update_orders_fills redefinition + raise NotImplementedError + + async def _update_orders_fills(self, orders: List[GatewayPerpetualInFlightOrder]): + oldest_order_creation_time = self.current_timestamp + all_market_ids = set() + orders_by_hash = {} + + for order in orders: + oldest_order_creation_time = min(oldest_order_creation_time, order.creation_timestamp) + all_market_ids.add(await self.exchange_symbol_associated_to_pair(trading_pair=order.trading_pair)) + if order.exchange_order_id is not None: + orders_by_hash[order.exchange_order_id] = order + + try: + start_time = min(oldest_order_creation_time, self._latest_polled_order_fill_time) + trade_updates = await self._data_source.perpetual_trade_updates(market_ids=all_market_ids, start_time=start_time) + for trade_update in trade_updates: + tracked_order = orders_by_hash.get(trade_update.exchange_order_id) + if tracked_order is not None: + fee = TradeFeeBase.new_perpetual_fee( + fee_schema=self.trade_fee_schema(), + position_action=tracked_order.position, + percent_token=trade_update.fee.percent_token, + flat_fees=trade_update.fee.flat_fees, + ) + new_trade_update = TradeUpdate( + trade_id=trade_update.trade_id, + client_order_id=tracked_order.client_order_id, + exchange_order_id=trade_update.exchange_order_id, + trading_pair=trade_update.trading_pair, + fill_timestamp=trade_update.fill_timestamp, + fill_price=trade_update.fill_price, + fill_base_amount=trade_update.fill_base_amount, + fill_quote_amount=trade_update.fill_quote_amount, + fee=fee, + is_taker=trade_update.is_taker, + ) + self._latest_polled_order_fill_time = max(self._latest_polled_order_fill_time, + trade_update.fill_timestamp) + self._order_tracker.process_trade_update(new_trade_update) + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().warning( + f"Failed to fetch trade updates. Error: {ex}", + exc_info=ex, + ) + + async def _request_order_status(self, tracked_order: GatewayPerpetualInFlightOrder) -> OrderUpdate: + # Not required due to the redefinition of _update_orders_with_error_handler + raise NotImplementedError + + async def _update_orders_with_error_handler(self, orders: List[GatewayPerpetualInFlightOrder], error_handler: Callable): + oldest_order_creation_time = self.current_timestamp + all_market_ids = set() + orders_by_hash = {} + + for order in orders: + oldest_order_creation_time = min(oldest_order_creation_time, order.creation_timestamp) + all_market_ids.add(await self.exchange_symbol_associated_to_pair(trading_pair=order.trading_pair)) + if order.exchange_order_id is not None: + orders_by_hash[order.exchange_order_id] = order + + try: + order_updates = await self._data_source.perpetual_order_updates( + market_ids=all_market_ids, + start_time=oldest_order_creation_time - self.LONG_POLL_INTERVAL + ) + + for order_update in order_updates: + tracked_order = orders_by_hash.get(order_update.exchange_order_id) + if tracked_order is not None: + try: + new_order_update = OrderUpdate( + trading_pair=order_update.trading_pair, + update_timestamp=order_update.update_timestamp, + new_state=order_update.new_state, + client_order_id=tracked_order.client_order_id, + exchange_order_id=order_update.exchange_order_id, + misc_updates=order_update.misc_updates, + ) + + if tracked_order.current_state == OrderState.PENDING_CREATE and new_order_update.new_state != OrderState.OPEN: + open_update = OrderUpdate( + trading_pair=order_update.trading_pair, + update_timestamp=order_update.update_timestamp, + new_state=OrderState.OPEN, + client_order_id=tracked_order.client_order_id, + exchange_order_id=order_update.exchange_order_id, + misc_updates=order_update.misc_updates, + ) + self._order_tracker.process_order_update(open_update) + + del orders_by_hash[order_update.exchange_order_id] + self._order_tracker.process_order_update(new_order_update) + except asyncio.CancelledError: + raise + except Exception as ex: + await error_handler(tracked_order, ex) + + if len(orders_by_hash) > 0: + # await self._data_source.check_order_hashes_synchronization(orders=orders_by_hash.values()) + for order in orders_by_hash.values(): + not_found_error = RuntimeError( + f"There was a problem updating order {order.client_order_id} " + f"({CONSTANTS.ORDER_NOT_FOUND_ERROR_MESSAGE})" + ) + await error_handler(order, not_found_error) + except asyncio.CancelledError: + raise + except Exception as request_error: + for order in orders_by_hash.values(): + await error_handler(order, request_error) + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return WebAssistantsFactory(throttler=self._throttler) + + def _create_order_tracker(self) -> ClientOrderTracker: + tracker = GatewayOrderTracker(connector=self) + return tracker + + def _create_order_book_data_source(self) -> PerpetualAPIOrderBookDataSource: + return InjectiveV2PerpetualAPIOrderBookDataSource( + trading_pairs=self.trading_pairs, + connector=self, + data_source=self._data_source, + domain=self.domain + ) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + # Not used in Injective + raise NotImplementedError # pragma: no cover + + def _is_user_stream_initialized(self): + # Injective does not have private websocket endpoints + return self._data_source.is_started() + + def _create_user_stream_tracker(self): + # Injective does not use a tracker for the private streams + return None + + def _create_user_stream_tracker_task(self): + # Injective does not use a tracker for the private streams + return None + + def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dict[str, Any]): + # Not used in Injective + raise NotImplementedError() # pragma: no cover + + async def _initialize_trading_pair_symbol_map(self): + exchange_info = None + try: + mapping = await self._data_source.derivative_market_and_trading_pair_map() + self._set_trading_pair_symbol_map(mapping) + except Exception: + self.logger().exception("There was an error requesting exchange info.") + return exchange_info + + def _configure_event_forwarders(self): + event_forwarder = EventForwarder(to_function=self._process_user_trade_update) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=MarketEvent.TradeUpdate, listener=event_forwarder) + + event_forwarder = EventForwarder(to_function=self._process_user_order_update) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=MarketEvent.OrderUpdate, listener=event_forwarder) + + event_forwarder = EventForwarder(to_function=self._process_balance_event) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=AccountEvent.BalanceEvent, listener=event_forwarder) + + event_forwarder = EventForwarder(to_function=self._process_position_event) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=AccountEvent.PositionUpdate, listener=event_forwarder) + + event_forwarder = EventForwarder(to_function=self._process_transaction_event) + self._forwarders.append(event_forwarder) + self._data_source.add_listener(event_tag=InjectiveEvent.ChainTransactionEvent, listener=event_forwarder) + + def _process_balance_event(self, event: BalanceUpdateEvent): + self._last_received_message_timestamp = self._time() + self._all_trading_events_queue.put_nowait( + {"channel": "balance", "data": event} + ) + + def _process_position_event(self, event: BalanceUpdateEvent): + self._last_received_message_timestamp = self._time() + self._all_trading_events_queue.put_nowait( + {"channel": "position", "data": event} + ) + + def _process_user_order_update(self, order_update: OrderUpdate): + self._last_received_message_timestamp = self._time() + self._all_trading_events_queue.put_nowait( + {"channel": "order", "data": order_update} + ) + + def _process_user_trade_update(self, trade_update: TradeUpdate): + self._last_received_message_timestamp = self._time() + self._all_trading_events_queue.put_nowait( + {"channel": "trade", "data": trade_update} + ) + + def _process_transaction_event(self, transaction_event: Dict[str, Any]): + self._last_received_message_timestamp = self._time() + self._all_trading_events_queue.put_nowait( + {"channel": "transaction", "data": transaction_event} + ) + + async def _check_orders_transactions(self): + while True: + try: + # Executing the process shielded from this async task to isolate it from network disconnections + # (network disconnections cancel this task) + task = asyncio.create_task(self._check_orders_creation_transactions()) + await asyncio.shield(task) + await self._sleep(CONSTANTS.TRANSACTIONS_CHECK_INTERVAL) + except NotImplementedError: + raise + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error while running the transactions check process", exc_info=True) + await self._sleep(0.5) + + async def _check_orders_creation_transactions(self): + orders: List[GatewayPerpetualInFlightOrder] = self._order_tracker.active_orders.values() + orders_by_creation_tx = defaultdict(list) + orders_with_inconsistent_hash = [] + + for order in orders: + if order.creation_transaction_hash is not None and order.is_pending_create: + orders_by_creation_tx[order.creation_transaction_hash].append(order) + + for transaction_hash, orders in orders_by_creation_tx.items(): + all_orders = orders.copy() + try: + order_updates = await self._data_source.order_updates_for_transaction( + transaction_hash=transaction_hash, perpetual_orders=orders + ) + + for order_update in order_updates: + tracked_order = self._order_tracker.active_orders.get(order_update.client_order_id) + if tracked_order is not None: + all_orders.remove(tracked_order) + if (tracked_order.exchange_order_id is not None + and tracked_order.exchange_order_id != order_update.exchange_order_id): + tracked_order.update_exchange_order_id(order_update.exchange_order_id) + orders_with_inconsistent_hash.append(tracked_order) + self._order_tracker.process_order_update(order_update=order_update) + + for not_found_order in all_orders: + self._update_order_after_failure( + order_id=not_found_order.client_order_id, + trading_pair=not_found_order.trading_pair + ) + + except ValueError: + self.logger().debug(f"Transaction not included in a block yet ({transaction_hash})") + + if len(orders_with_inconsistent_hash) > 0: + async with self._data_source.order_creation_lock: + active_orders = [ + order for order in self._order_tracker.active_orders.values() + if order not in orders_with_inconsistent_hash and order.current_state == OrderState.PENDING_CREATE + ] + await self._data_source.reset_order_hash_generator(active_orders=active_orders) + + async def _check_created_orders_status_for_transaction(self, transaction_hash: str): + transaction_orders = [] + order: GatewayPerpetualInFlightOrder + for order in self.in_flight_orders.values(): + if order.creation_transaction_hash == transaction_hash and order.is_pending_create: + transaction_orders.append(order) + + if len(transaction_orders) > 0: + order_updates = await self._data_source.order_updates_for_transaction( + transaction_hash=transaction_hash, perpetual_orders=transaction_orders + ) + + for order_update in order_updates: + tracked_order = self._order_tracker.active_orders.get(order_update.client_order_id) + if (tracked_order is not None + and tracked_order.exchange_order_id is not None + and tracked_order.exchange_order_id != order_update.exchange_order_id): + tracked_order.update_exchange_order_id(order_update.exchange_order_id) + self._order_tracker.process_order_update(order_update=order_update) + + async def _process_queued_orders(self): + while True: + try: + # Executing the batch cancelation and creation process shielded from this async task to isolate the + # creation/cancelation process from network disconnections (network disconnections cancel this task) + task = asyncio.create_task(self._cancel_and_create_queued_orders()) + await asyncio.shield(task) + sleep_time = (self.clock.tick_size * 0.5 + if self.clock is not None + else self._orders_processing_delta_time) + await self._sleep(sleep_time) + except NotImplementedError: + raise + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error while processing queued individual orders", exc_info=True) + await self._sleep(self.clock.tick_size * 0.5) + + async def _cancel_and_create_queued_orders(self): + if len(self._orders_queued_to_cancel) > 0: + orders = [order.to_limit_order() for order in self._orders_queued_to_cancel] + self._orders_queued_to_cancel = [] + await self._execute_batch_cancel(orders_to_cancel=orders) + if len(self._orders_queued_to_create) > 0: + orders = self._orders_queued_to_create + self._orders_queued_to_create = [] + await self._execute_batch_inflight_order_create(inflight_orders_to_create=orders) + + async def _get_last_traded_price(self, trading_pair: str) -> float: + market_id = await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + last_price = await self._data_source.last_traded_price(market_id=market_id) + return float(last_price) + + def _get_poll_interval(self, timestamp: float) -> float: + last_recv_diff = timestamp - self._last_received_message_timestamp + poll_interval = ( + self.SHORT_POLL_INTERVAL + if last_recv_diff > self.TICK_INTERVAL_LIMIT + else self.LONG_POLL_INTERVAL + ) + return poll_interval diff --git a/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_utils.py b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_utils.py new file mode 100644 index 0000000000..da2c346da3 --- /dev/null +++ b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_utils.py @@ -0,0 +1,82 @@ +from decimal import Decimal +from typing import Dict, Union + +from pydantic import Field +from pydantic.class_validators import validator + +from hummingbot.client.config.config_data_types import BaseConnectorConfigMap, ClientFieldData +from hummingbot.connector.exchange.injective_v2.injective_v2_utils import ( + ACCOUNT_MODES, + NETWORK_MODES, + InjectiveMainnetNetworkMode, + InjectiveReadOnlyAccountMode, +) +from hummingbot.core.data_type.trade_fee import TradeFeeSchema + +CENTRALIZED = False +EXAMPLE_PAIR = "INJ-USDT" + +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0"), + taker_percent_fee_decimal=Decimal("0"), +) + + +class InjectiveConfigMap(BaseConnectorConfigMap): + # Setting a default dummy configuration to allow the bot to create a dummy instance to fetch all trading pairs + connector: str = Field(default="injective_v2_perpetual", const=True, client_data=None) + receive_connector_configuration: bool = Field( + default=True, const=True, + client_data=ClientFieldData(), + ) + network: Union[tuple(NETWORK_MODES.values())] = Field( + default=InjectiveMainnetNetworkMode(), + client_data=ClientFieldData( + prompt=lambda cm: f"Select the network ({'/'.join(list(NETWORK_MODES.keys()))})", + prompt_on_new=True, + ), + ) + account_type: Union[tuple(ACCOUNT_MODES.values())] = Field( + default=InjectiveReadOnlyAccountMode(), + client_data=ClientFieldData( + prompt=lambda cm: f"Select the type of account configuration ({'/'.join(list(ACCOUNT_MODES.keys()))})", + prompt_on_new=True, + ), + ) + + class Config: + title = "injective_v2_perpetual" + + @validator("network", pre=True) + def validate_network(cls, v: Union[(str, Dict) + tuple(NETWORK_MODES.values())]): + if isinstance(v, tuple(NETWORK_MODES.values()) + (Dict,)): + sub_model = v + elif v not in NETWORK_MODES: + raise ValueError( + f"Invalid network, please choose a value from {list(NETWORK_MODES.keys())}." + ) + else: + sub_model = NETWORK_MODES[v].construct() + return sub_model + + @validator("account_type", pre=True) + def validate_account_type(cls, v: Union[(str, Dict) + tuple(ACCOUNT_MODES.values())]): + if isinstance(v, tuple(ACCOUNT_MODES.values()) + (Dict,)): + sub_model = v + elif v not in ACCOUNT_MODES: + raise ValueError( + f"Invalid account type, please choose a value from {list(ACCOUNT_MODES.keys())}." + ) + else: + sub_model = ACCOUNT_MODES[v].construct() + return sub_model + + def create_data_source(self): + return self.account_type.create_data_source( + network=self.network.network(), + use_secure_connection=self.network.use_secure_connection(), + rate_limits=self.network.rate_limits(), + ) + + +KEYS = InjectiveConfigMap.construct() diff --git a/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_web_utils.py b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_web_utils.py new file mode 100644 index 0000000000..082f23287b --- /dev/null +++ b/hummingbot/connector/derivative/injective_v2_perpetual/injective_v2_perpetual_web_utils.py @@ -0,0 +1,15 @@ +import time +from typing import Optional + +from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + +async def get_current_server_time( + throttler: Optional[AsyncThrottler] = None, domain: str = CONSTANTS.DEFAULT_DOMAIN +) -> float: + return _time() * 1e3 + + +def _time() -> float: + return time.time() diff --git a/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_constants.py b/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_constants.py index 2412e4ab01..6c4cf19531 100644 --- a/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_constants.py +++ b/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_constants.py @@ -6,6 +6,8 @@ EXCHANGE_NAME = "phemex_perpetual" MAX_ORDER_ID_LEN = 40 +HB_PARTNER_ID = "HBOT" + DEFAULT_DOMAIN = "" TESTNET_DOMAIN = "phemex_perpetual_testnet" diff --git a/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_derivative.py b/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_derivative.py index 046fb0afa0..dc355bb548 100644 --- a/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_derivative.py +++ b/hummingbot/connector/derivative/phemex_perpetual/phemex_perpetual_derivative.py @@ -87,7 +87,7 @@ def client_order_id_max_length(self) -> int: @property def client_order_id_prefix(self) -> str: - return "" + return CONSTANTS.HB_PARTNER_ID @property def trading_rules_request_path(self) -> str: diff --git a/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx b/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx index d349a911c8..1c945c296e 100644 --- a/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx +++ b/hummingbot/connector/exchange/bitfinex/bitfinex_exchange.pyx @@ -475,7 +475,7 @@ cdef class BitfinexExchange(ExchangeBase): http_method: str, url, headers, - data_str: Optional[str, list] = None) -> list: + data_str = None) -> list: """ A wrapper for submitting API requests to Bitfinex :returns: json data from the endpoints diff --git a/hummingbot/connector/exchange/gate_io/gate_io_exchange.py b/hummingbot/connector/exchange/gate_io/gate_io_exchange.py index ed626294ee..688c19e6a5 100644 --- a/hummingbot/connector/exchange/gate_io/gate_io_exchange.py +++ b/hummingbot/connector/exchange/gate_io/gate_io_exchange.py @@ -12,7 +12,7 @@ from hummingbot.connector.exchange_py_base import ExchangePyBase from hummingbot.connector.trading_rule import TradingRule from hummingbot.connector.utils import combine_to_hb_trading_pair -from hummingbot.core.data_type.common import OrderType, PriceType, TradeType +from hummingbot.core.data_type.common import OrderType, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState, OrderUpdate, TradeUpdate from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase @@ -217,9 +217,11 @@ async def _place_order(self, }) if trade_type.name.lower() == 'buy': if price.is_nan(): - price = self.get_price_by_type( + price = self.get_price_for_volume( trading_pair, - price_type=PriceType.BestAsk if trade_type is TradeType.BUY else PriceType.BestBid) + True, + amount + ).result_price data.update({ "amount": f"{price * amount:f}", }) diff --git a/hummingbot/connector/exchange/injective_v2/README.md b/hummingbot/connector/exchange/injective_v2/README.md index da6396900f..a56271975d 100644 --- a/hummingbot/connector/exchange/injective_v2/README.md +++ b/hummingbot/connector/exchange/injective_v2/README.md @@ -6,6 +6,8 @@ The connector supports two different account modes: - Trading with delegate accounts - Trading through off-chain vault contracts +There is a third account type called `read_only_account`. This mode only allows to request public information from the nodes, but since it does not require credentials it does not allow to perform trading operations. + ### Delegate account mode When configuring the connector with this mode, the account used to send the transactions to the chain for trading is not the account holding the funds. The user will need to have one portfolio account and at least one trading account. And permissions should be granted with the portfolio account to the trading account for it to operate using the portfolio account's funds. @@ -31,4 +33,4 @@ When configuring a new instance of the connector in Hummingbot the following par - **private_key**: the vault's admin account private key - **subaccount_index**: the index (decimal number) of the subaccount from the vault's admin account -- **vault_contract_address**: the address in the chain for the vault contract \ No newline at end of file +- **vault_contract_address**: the address in the chain for the vault contract diff --git a/hummingbot/connector/exchange/injective_v2/account_delegation_script.py b/hummingbot/connector/exchange/injective_v2/account_delegation_script.py index 015eb694d9..a1922d6fb7 100644 --- a/hummingbot/connector/exchange/injective_v2/account_delegation_script.py +++ b/hummingbot/connector/exchange/injective_v2/account_delegation_script.py @@ -12,11 +12,14 @@ GRANTER_ACCOUNT_PRIVATE_KEY = "" GRANTER_SUBACCOUNT_INDEX = 0 GRANTEE_PUBLIC_INJECTIVE_ADDRESS = "" -MARKET_IDS = [] +SPOT_MARKET_IDS = [] +DERIVATIVE_MARKET_IDS = [] # List of the ids of all the markets the grant will include, for example: -# MARKET_IDS = ["0x0511ddc4e6586f3bfe1acb2dd905f8b8a82c97e1edaef654b12ca7e6031ca0fa"] # noqa: mock +# SPOT_MARKET_IDS = ["0x0511ddc4e6586f3bfe1acb2dd905f8b8a82c97e1edaef654b12ca7e6031ca0fa"] # noqa: mock # Mainnet spot markets: https://lcd.injective.network/injective/exchange/v1beta1/spot/markets # Testnet spot markets: https://k8s.testnet.lcd.injective.network/injective/exchange/v1beta1/spot/markets +# Mainnet derivative markets: https://lcd.injective.network/injective/exchange/v1beta1/derivative/markets +# Testnet derivative markets: https://k8s.testnet.lcd.injective.network/injective/exchange/v1beta1/derivative/markets # Fixed values, do not change SECONDS_PER_DAY = 60 * 60 * 24 @@ -36,18 +39,37 @@ async def main() -> None: account = await client.get_account(granter_address.to_acc_bech32()) # noqa: F841 granter_subaccount_id = granter_address.get_subaccount_id(index=GRANTER_SUBACCOUNT_INDEX) - msg = composer.MsgGrantTyped( + msg_spot_market = composer.MsgGrantTyped( + granter=granter_address.to_acc_bech32(), + grantee=GRANTEE_PUBLIC_INJECTIVE_ADDRESS, + msg_type="CreateSpotMarketOrderAuthz", + expire_in=GRANT_EXPIRATION_IN_DAYS * SECONDS_PER_DAY, + subaccount_id=granter_subaccount_id, + market_ids=SPOT_MARKET_IDS, + ) + + msg_derivative_market = composer.MsgGrantTyped( + granter=granter_address.to_acc_bech32(), + grantee=GRANTEE_PUBLIC_INJECTIVE_ADDRESS, + msg_type="CreateDerivativeMarketOrderAuthz", + expire_in=GRANT_EXPIRATION_IN_DAYS * SECONDS_PER_DAY, + subaccount_id=granter_subaccount_id, + market_ids=DERIVATIVE_MARKET_IDS, + ) + + msg_batch_update = composer.MsgGrantTyped( granter = granter_address.to_acc_bech32(), grantee = GRANTEE_PUBLIC_INJECTIVE_ADDRESS, msg_type = "BatchUpdateOrdersAuthz", expire_in=GRANT_EXPIRATION_IN_DAYS * SECONDS_PER_DAY, subaccount_id=granter_subaccount_id, - spot_markets=MARKET_IDS, + spot_markets=SPOT_MARKET_IDS, + derivative_markets=DERIVATIVE_MARKET_IDS, ) tx = ( Transaction() - .with_messages(msg) + .with_messages(msg_spot_market, msg_derivative_market, msg_batch_update) .with_sequence(client.get_sequence()) .with_account_num(client.get_number()) .with_chain_id(NETWORK.chain_id) diff --git a/hummingbot/connector/exchange/injective_v2/data_sources/injective_data_source.py b/hummingbot/connector/exchange/injective_v2/data_sources/injective_data_source.py index 1be1b8955f..956eebb910 100644 --- a/hummingbot/connector/exchange/injective_v2/data_sources/injective_data_source.py +++ b/hummingbot/connector/exchange/injective_v2/data_sources/injective_data_source.py @@ -5,26 +5,39 @@ from abc import ABC, abstractmethod from decimal import Decimal from enum import Enum +from functools import partial from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from google.protobuf import any_pb2 from pyinjective import Transaction from pyinjective.composer import Composer, injective_exchange_tx_pb +from hummingbot.connector.derivative.position import Position from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS from hummingbot.connector.exchange.injective_v2.injective_events import InjectiveEvent -from hummingbot.connector.exchange.injective_v2.injective_market import InjectiveToken +from hummingbot.connector.exchange.injective_v2.injective_market import ( + InjectiveDerivativeMarket, + InjectiveSpotMarket, + InjectiveToken, +) from hummingbot.connector.gateway.common_types import CancelOrderResult, PlaceOrderResult -from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder, GatewayPerpetualInFlightOrder from hummingbot.connector.trading_rule import TradingRule from hummingbot.core.api_throttler.async_throttler_base import AsyncThrottlerBase -from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionSide, TradeType +from hummingbot.core.data_type.funding_info import FundingInfo, FundingInfoUpdate from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate, TradeUpdate from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase, TradeFeeSchema from hummingbot.core.event.event_listener import EventListener -from hummingbot.core.event.events import AccountEvent, BalanceUpdateEvent, MarketEvent, OrderBookDataSourceEvent +from hummingbot.core.event.events import ( + AccountEvent, + BalanceUpdateEvent, + MarketEvent, + OrderBookDataSourceEvent, + PositionUpdateEvent, +) from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.utils.async_utils import safe_gather from hummingbot.logger import HummingbotLogger @@ -106,11 +119,19 @@ async def timeout_height(self) -> int: raise NotImplementedError @abstractmethod - async def market_and_trading_pair_map(self): + async def spot_market_and_trading_pair_map(self): raise NotImplementedError @abstractmethod - async def market_info_for_id(self, market_id: str): + async def spot_market_info_for_id(self, market_id: str): + raise NotImplementedError + + @abstractmethod + async def derivative_market_and_trading_pair_map(self): + raise NotImplementedError + + @abstractmethod + async def derivative_market_info_for_id(self, market_id: str): raise NotImplementedError @abstractmethod @@ -118,11 +139,19 @@ async def trading_pair_for_market(self, market_id: str): raise NotImplementedError @abstractmethod - async def market_id_for_trading_pair(self, trading_pair: str) -> str: + async def market_id_for_spot_trading_pair(self, trading_pair: str) -> str: + raise NotImplementedError + + @abstractmethod + async def market_id_for_derivative_trading_pair(self, trading_pair: str) -> str: + raise NotImplementedError + + @abstractmethod + async def spot_markets(self): raise NotImplementedError @abstractmethod - async def all_markets(self): + async def derivative_markets(self): raise NotImplementedError @abstractmethod @@ -158,15 +187,26 @@ async def update_markets(self): raise NotImplementedError @abstractmethod - def real_tokens_trading_pair(self, unique_trading_pair: str) -> str: + def real_tokens_spot_trading_pair(self, unique_trading_pair: str) -> str: + raise NotImplementedError + + @abstractmethod + def real_tokens_perpetual_trading_pair(self, unique_trading_pair: str) -> str: raise NotImplementedError @abstractmethod async def order_updates_for_transaction( - self, transaction_hash: str, transaction_orders: List[GatewayInFlightOrder] + self, + transaction_hash: str, + spot_orders: Optional[List[GatewayInFlightOrder]] = None, + perpetual_orders: Optional[List[GatewayPerpetualInFlightOrder]] = None, ) -> List[OrderUpdate]: raise NotImplementedError + @abstractmethod + def supported_order_types(self) -> List[OrderType]: + raise NotImplementedError + def is_started(self): return len(self.events_listening_tasks()) > 0 @@ -184,22 +224,49 @@ async def start(self, market_ids: List[str]): if not self.is_started(): await self.initialize_trading_account() if not self.is_started(): - self.add_listening_task(asyncio.create_task(self._listen_to_public_trades(market_ids=market_ids))) - self.add_listening_task(asyncio.create_task(self._listen_to_order_book_updates(market_ids=market_ids))) + spot_markets = [] + derivative_markets = [] + for market_id in market_ids: + if market_id in await self.spot_market_and_trading_pair_map(): + spot_markets.append(market_id) + else: + derivative_markets.append(market_id) + + if len(spot_markets) > 0: + self.add_listening_task(asyncio.create_task(self._listen_to_public_spot_trades(market_ids=spot_markets))) + self.add_listening_task(asyncio.create_task(self._listen_to_spot_order_book_updates(market_ids=spot_markets))) + for market_id in spot_markets: + self.add_listening_task(asyncio.create_task( + self._listen_to_subaccount_spot_order_updates(market_id=market_id)) + ) + self.add_listening_task(asyncio.create_task( + self._listen_to_subaccount_spot_order_updates(market_id=market_id)) + ) + if len(derivative_markets) > 0: + self.add_listening_task( + asyncio.create_task(self._listen_to_public_derivative_trades(market_ids=derivative_markets))) + self.add_listening_task( + asyncio.create_task(self._listen_to_derivative_order_book_updates(market_ids=derivative_markets))) + self.add_listening_task( + asyncio.create_task(self._listen_to_positions_updates()) + ) + for market_id in derivative_markets: + self.add_listening_task(asyncio.create_task( + self._listen_to_subaccount_derivative_order_updates(market_id=market_id)) + ) + self.add_listening_task( + asyncio.create_task(self._listen_to_funding_info_updates(market_id=market_id)) + ) self.add_listening_task(asyncio.create_task(self._listen_to_account_balance_updates())) self.add_listening_task(asyncio.create_task(self._listen_to_chain_transactions())) - for market_id in market_ids: - self.add_listening_task(asyncio.create_task( - self._listen_to_subaccount_order_updates(market_id=market_id)) - ) await self._initialize_timeout_height() async def stop(self): for task in self.events_listening_tasks(): task.cancel() cookie_file_path = Path(self._chain_cookie_file_path()) - cookie_file_path.unlink() + cookie_file_path.unlink(missing_ok=True) def add_listener(self, event_tag: Enum, listener: EventListener): self.publisher.add_listener(event_tag=event_tag, listener=listener) @@ -207,33 +274,46 @@ def add_listener(self, event_tag: Enum, listener: EventListener): def remove_listener(self, event_tag: Enum, listener: EventListener): self.publisher.remove_listener(event_tag=event_tag, listener=listener) - async def all_trading_rules(self) -> List[TradingRule]: - all_markets = await self.all_markets() - trading_rules = [] + async def spot_trading_rules(self) -> List[TradingRule]: + markets = await self.spot_markets() + trading_rules = self._create_trading_rules(markets=markets) + + return trading_rules + + async def derivative_trading_rules(self) -> List[TradingRule]: + markets = await self.derivative_markets() + trading_rules = self._create_trading_rules(markets=markets) - for market in all_markets: - try: - min_price_tick_size = market.min_price_tick_size() - min_quantity_tick_size = market.min_quantity_tick_size() - trading_rule = TradingRule( - trading_pair=market.trading_pair(), - min_order_size=min_quantity_tick_size, - min_price_increment=min_price_tick_size, - min_base_amount_increment=min_quantity_tick_size, - min_quote_amount_increment=min_price_tick_size, - ) - trading_rules.append(trading_rule) - except asyncio.CancelledError: - raise - except Exception: - self.logger().exception(f"Error parsing the trading pair rule: {market.market_info}. Skipping...") return trading_rules - async def order_book_snapshot(self, market_id: str, trading_pair: str) -> OrderBookMessage: - async with self.throttler.execute_task(limit_id=CONSTANTS.ORDERBOOK_LIMIT_ID): + async def spot_order_book_snapshot(self, market_id: str, trading_pair: str) -> OrderBookMessage: + async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_ORDERBOOK_LIMIT_ID): snapshot_data = await self.query_executor.get_spot_orderbook(market_id=market_id) - market = await self.market_info_for_id(market_id=market_id) + market = await self.spot_market_info_for_id(market_id=market_id) + bids = [(market.price_from_chain_format(chain_price=Decimal(price)), + market.quantity_from_chain_format(chain_quantity=Decimal(quantity))) + for price, quantity, _ in snapshot_data["buys"]] + asks = [(market.price_from_chain_format(chain_price=Decimal(price)), + market.quantity_from_chain_format(chain_quantity=Decimal(quantity))) + for price, quantity, _ in snapshot_data["sells"]] + snapshot_msg = OrderBookMessage( + message_type=OrderBookMessageType.SNAPSHOT, + content={ + "trading_pair": trading_pair, + "update_id": snapshot_data["sequence"], + "bids": bids, + "asks": asks, + }, + timestamp=snapshot_data["timestamp"] * 1e-3, + ) + return snapshot_msg + + async def perpetual_order_book_snapshot(self, market_id: str, trading_pair: str) -> OrderBookMessage: + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_ORDERBOOK_LIMIT_ID): + snapshot_data = await self.query_executor.get_derivative_orderbook(market_id=market_id) + + market = await self.derivative_market_info_for_id(market_id=market_id) bids = [(market.price_from_chain_format(chain_price=Decimal(price)), market.quantity_from_chain_format(chain_quantity=Decimal(quantity))) for price, quantity, _ in snapshot_data["buys"]] @@ -299,87 +379,180 @@ async def all_account_balances(self) -> Dict[str, Dict[str, Decimal]]: return balances_dict - async def create_orders(self, orders_to_create: List[GatewayInFlightOrder]) -> List[PlaceOrderResult]: + async def account_positions(self) -> List[Position]: + done = False + skip = 0 + position_entries = [] + + while not done: + async with self.throttler.execute_task(limit_id=CONSTANTS.POSITIONS_LIMIT_ID): + positions_response = await self.query_executor.get_derivative_positions( + subaccount_id=self.portfolio_account_subaccount_id, + skip=skip, + ) + if "positions" in positions_response: + total = int(positions_response["paging"]["total"]) + entries = positions_response["positions"] + + position_entries.extend(entries) + done = len(position_entries) >= total + skip += len(entries) + else: + done = True + + positions = [] + for position_entry in position_entries: + position_update = await self._parse_position_update_event(event=position_entry) + + position = Position( + trading_pair=position_update.trading_pair, + position_side=position_update.position_side, + unrealized_pnl=position_update.unrealized_pnl, + entry_price=position_update.entry_price, + amount=position_update.amount, + leverage=position_update.leverage, + ) + + positions.append(position) + + return positions + + async def create_orders( + self, + spot_orders: Optional[List[GatewayInFlightOrder]] = None, + perpetual_orders: Optional[List[GatewayPerpetualInFlightOrder]] = None, + ) -> List[PlaceOrderResult]: + spot_orders = spot_orders or [] + perpetual_orders = perpetual_orders or [] + results = [] if self.order_creation_lock.locked(): raise RuntimeError("It is not possible to create new orders because the hash manager is not synchronized") - async with self.order_creation_lock: - results = [] - order_creation_message, order_hashes = await self._order_creation_message( - spot_orders_to_create=orders_to_create) + if len(spot_orders) > 0 or len(perpetual_orders) > 0: + async with self.order_creation_lock: - try: - result = await self._send_in_transaction(message=order_creation_message) - if result["rawLog"] != "[]" or result["txhash"] in [None, ""]: - raise ValueError(f"Error sending the order creation transaction ({result['rawLog']})") - else: - transaction_hash = result["txhash"] + order_creation_messages, spot_order_hashes, derivative_order_hashes = await self._order_creation_messages( + spot_orders_to_create=spot_orders, + derivative_orders_to_create=perpetual_orders, + ) + + try: + result = await self._send_in_transaction(messages=order_creation_messages) + if result["rawLog"] != "[]" or result["txhash"] in [None, ""]: + raise ValueError(f"Error sending the order creation transaction ({result['rawLog']})") + else: + transaction_hash = result["txhash"] + results = self._place_order_results( + orders_to_create=spot_orders + perpetual_orders, + order_hashes=spot_order_hashes + derivative_order_hashes, + misc_updates={ + "creation_transaction_hash": transaction_hash, + }, + ) + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().debug( + f"Error broadcasting transaction to create orders (message: {order_creation_messages})") results = self._place_order_results( - orders_to_create=orders_to_create, - order_hashes=order_hashes, - misc_updates={ - "creation_transaction_hash": transaction_hash, - }, + orders_to_create=spot_orders + perpetual_orders, + order_hashes=spot_order_hashes + derivative_order_hashes, + misc_updates={}, + exception=ex, ) - except asyncio.CancelledError: - raise - except Exception as ex: - results = self._place_order_results( - orders_to_create=orders_to_create, - order_hashes=order_hashes, - misc_updates={}, - exception=ex, - ) return results - async def cancel_orders(self, orders_to_cancel: List[GatewayInFlightOrder]) -> List[CancelOrderResult]: + async def cancel_orders( + self, + spot_orders: Optional[List[GatewayInFlightOrder]] = None, + perpetual_orders: Optional[List[GatewayPerpetualInFlightOrder]] = None, + ) -> List[CancelOrderResult]: + spot_orders = spot_orders or [] + perpetual_orders = perpetual_orders or [] + orders_with_hash = [] - orders_data = [] + spot_orders_data = [] + derivative_orders_data = [] results = [] - for order in orders_to_cancel: - if order.exchange_order_id is None: - results.append(CancelOrderResult( - client_order_id=order.client_order_id, - trading_pair=order.trading_pair, - not_found=True, - )) - else: - order_data = await self._generate_injective_order_data(order=order) - orders_data.append(order_data) - orders_with_hash.append(order) - - delegated_message = self._order_cancel_message( - spot_orders_to_cancel=orders_data - ) - - try: - result = await self._send_in_transaction(message=delegated_message) - if result["rawLog"] != "[]": - raise ValueError(f"Error sending the order cancel transaction ({result['rawLog']})") - else: - cancel_transaction_hash = result.get("txhash", "") - results.extend([ - CancelOrderResult( + if len(spot_orders) > 0 or len(perpetual_orders) > 0: + for order in spot_orders: + if order.exchange_order_id is None: + results.append(CancelOrderResult( client_order_id=order.client_order_id, trading_pair=order.trading_pair, - misc_updates={"cancelation_transaction_hash": cancel_transaction_hash}, - ) for order in orders_with_hash - ]) - except asyncio.CancelledError: - raise - except Exception as ex: - results.extend([ - CancelOrderResult( - client_order_id=order.client_order_id, - trading_pair=order.trading_pair, - exception=ex, - ) for order in orders_with_hash - ]) + not_found=True, + )) + else: + market_id = await self.market_id_for_spot_trading_pair(trading_pair=order.trading_pair) + order_data = self._generate_injective_order_data(order=order, market_id=market_id) + spot_orders_data.append(order_data) + orders_with_hash.append(order) + + for order in perpetual_orders: + if order.exchange_order_id is None: + results.append(CancelOrderResult( + client_order_id=order.client_order_id, + trading_pair=order.trading_pair, + not_found=True, + )) + else: + market_id = await self.market_id_for_derivative_trading_pair(trading_pair=order.trading_pair) + order_data = self._generate_injective_order_data(order=order, market_id=market_id) + derivative_orders_data.append(order_data) + orders_with_hash.append(order) + + if len(orders_with_hash) > 0: + delegated_message = self._order_cancel_message( + spot_orders_to_cancel=spot_orders_data, + derivative_orders_to_cancel=derivative_orders_data, + ) + + try: + result = await self._send_in_transaction(messages=[delegated_message]) + if result["rawLog"] != "[]": + raise ValueError(f"Error sending the order cancel transaction ({result['rawLog']})") + else: + cancel_transaction_hash = result.get("txhash", "") + results.extend([ + CancelOrderResult( + client_order_id=order.client_order_id, + trading_pair=order.trading_pair, + misc_updates={"cancelation_transaction_hash": cancel_transaction_hash}, + ) for order in orders_with_hash + ]) + except asyncio.CancelledError: + raise + except Exception as ex: + self.logger().debug(f"Error broadcasting transaction to cancel orders (message: {delegated_message})") + results.extend([ + CancelOrderResult( + client_order_id=order.client_order_id, + trading_pair=order.trading_pair, + exception=ex, + ) for order in orders_with_hash + ]) return results + async def cancel_all_subaccount_orders( + self, + spot_markets_ids: Optional[List[str]] = None, + perpetual_markets_ids: Optional[List[str]] = None, + ): + spot_markets_ids = spot_markets_ids or [] + perpetual_markets_ids = perpetual_markets_ids or [] + + delegated_message = self._all_subaccount_orders_cancel_message( + spot_markets_ids=spot_markets_ids, + derivative_markets_ids=perpetual_markets_ids, + ) + + result = await self._send_in_transaction(messages=[delegated_message]) + if result["rawLog"] != "[]": + raise ValueError(f"Error sending the order cancel transaction ({result['rawLog']})") + async def spot_trade_updates(self, market_ids: List[str], start_time: float) -> List[TradeUpdate]: done = False skip = 0 @@ -403,7 +576,34 @@ async def spot_trade_updates(self, market_ids: List[str], start_time: float) -> else: done = True - trade_updates = [await self._parse_trade_entry(trade_info=trade_info) for trade_info in trade_entries] + trade_updates = [await self._parse_spot_trade_entry(trade_info=trade_info) for trade_info in trade_entries] + + return trade_updates + + async def perpetual_trade_updates(self, market_ids: List[str], start_time: float) -> List[TradeUpdate]: + done = False + skip = 0 + trade_entries = [] + + while not done: + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_TRADES_LIMIT_ID): + trades_response = await self.query_executor.get_derivative_trades( + market_ids=market_ids, + subaccount_id=self.portfolio_account_subaccount_id, + start_time=int(start_time * 1e3), + skip=skip, + ) + if "trades" in trades_response: + total = int(trades_response["paging"]["total"]) + entries = trades_response["trades"] + + trade_entries.extend(entries) + done = len(trade_entries) >= total + skip += len(entries) + else: + done = True + + trade_updates = [await self._parse_derivative_trade_entry(trade_info=trade_info) for trade_info in trade_entries] return trade_updates @@ -434,6 +634,33 @@ async def spot_order_updates(self, market_ids: List[str], start_time: float) -> return order_updates + async def perpetual_order_updates(self, market_ids: List[str], start_time: float) -> List[OrderUpdate]: + done = False + skip = 0 + order_entries = [] + + while not done: + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_ORDERS_HISTORY_LIMIT_ID): + orders_response = await self.query_executor.get_historical_derivative_orders( + market_ids=market_ids, + subaccount_id=self.portfolio_account_subaccount_id, + start_time=int(start_time * 1e3), + skip=skip, + ) + if "orders" in orders_response: + total = int(orders_response["paging"]["total"]) + entries = orders_response["orders"] + + order_entries.extend(entries) + done = len(order_entries) >= total + skip += len(entries) + else: + done = True + + order_updates = [await self._parse_order_entry(order_info=order_info) for order_info in order_entries] + + return order_updates + async def reset_order_hash_generator(self, active_orders: List[GatewayInFlightOrder]): if not self.order_creation_lock.locked: raise RuntimeError("The order creation lock should be acquired before resetting the order hash manager") @@ -451,75 +678,112 @@ async def reset_order_hash_generator(self, active_orders: List[GatewayInFlightOr await safe_gather(*transaction_wait_tasks, return_exceptions=True) self._reset_order_hash_manager() - async def get_trading_fees(self) -> Dict[str, TradeFeeSchema]: - markets = await self.all_markets() - fees = {} - for market in markets: - trading_pair = await self.trading_pair_for_market(market_id=market.market_id) - fees[trading_pair] = TradeFeeSchema( - percent_fee_token=market.quote_token.unique_symbol, - maker_percent_fee_decimal=market.maker_fee_rate(), - taker_percent_fee_decimal=market.taker_fee_rate(), - ) + async def get_spot_trading_fees(self) -> Dict[str, TradeFeeSchema]: + markets = await self.spot_markets() + fees = await self._create_trading_fees(markets=markets) return fees - @abstractmethod - async def _initialize_timeout_height(self): - raise NotImplementedError + async def get_derivative_trading_fees(self) -> Dict[str, TradeFeeSchema]: + markets = await self.derivative_markets() + fees = await self._create_trading_fees(markets=markets) - @abstractmethod - def _sign_and_encode(self, transaction: Transaction) -> bytes: - raise NotImplementedError + return fees - @abstractmethod - def _uses_default_portfolio_subaccount(self) -> bool: - raise NotImplementedError + async def funding_info(self, market_id: str) -> FundingInfo: + funding_rate = await self.last_funding_rate(market_id=market_id) + oracle_price = await self._oracle_price(market_id=market_id) + last_traded_price = await self.last_traded_price(market_id=market_id) + updated_market_info = await self._updated_derivative_market_info_for_id(market_id=market_id) + + funding_info = FundingInfo( + trading_pair=await self.trading_pair_for_market(market_id=market_id), + index_price=last_traded_price, # Use the last traded price as the index_price + mark_price=oracle_price, + next_funding_utc_timestamp=updated_market_info.next_funding_timestamp(), + rate=funding_rate, + ) + return funding_info - @abstractmethod - def _order_book_updates_stream(self, market_ids: List[str]): - raise NotImplementedError + async def last_funding_rate(self, market_id: str) -> Decimal: + async with self.throttler.execute_task(limit_id=CONSTANTS.FUNDING_RATES_LIMIT_ID): + response = await self.query_executor.get_funding_rates(market_id=market_id, limit=1) + rate = Decimal(response["fundingRates"][0]["rate"]) + + return rate + + async def last_funding_payment(self, market_id: str) -> Tuple[Decimal, float]: + async with self.throttler.execute_task(limit_id=CONSTANTS.FUNDING_PAYMENTS_LIMIT_ID): + response = await self.query_executor.get_funding_payments( + subaccount_id=self.portfolio_account_subaccount_id, + market_id=market_id, + limit=1 + ) + + last_payment = Decimal(-1) + last_timestamp = 0 + payments = response.get("payments", []) + + if len(payments) > 0: + last_payment = Decimal(payments[0]["amount"]) + last_timestamp = int(payments[0]["timestamp"]) * 1e-3 + + return last_payment, last_timestamp @abstractmethod - def _public_trades_stream(self, market_ids: List[str]): + async def _initialize_timeout_height(self): raise NotImplementedError @abstractmethod - def _subaccount_balance_stream(self): + def _sign_and_encode(self, transaction: Transaction) -> bytes: raise NotImplementedError @abstractmethod - def _subaccount_orders_stream(self, market_id: str): + def _uses_default_portfolio_subaccount(self) -> bool: raise NotImplementedError @abstractmethod - def _transactions_stream(self): + async def _calculate_order_hashes( + self, + spot_orders: List[GatewayInFlightOrder], + derivative_orders: [GatewayPerpetualInFlightOrder] + ) -> Tuple[List[str], List[str]]: raise NotImplementedError @abstractmethod - def _calculate_order_hashes(self, orders: List[GatewayInFlightOrder]) -> List[str]: + def _reset_order_hash_manager(self): raise NotImplementedError @abstractmethod - def _reset_order_hash_manager(self): + async def _order_creation_messages( + self, + spot_orders_to_create: List[GatewayInFlightOrder], + derivative_orders_to_create: List[GatewayPerpetualInFlightOrder], + ) -> Tuple[List[any_pb2.Any], List[str], List[str]]: raise NotImplementedError @abstractmethod - async def _last_traded_price(self, market_id: str) -> Decimal: + def _order_cancel_message( + self, + spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData], + derivative_orders_to_cancel: List[injective_exchange_tx_pb.OrderData] + ) -> any_pb2.Any: raise NotImplementedError @abstractmethod - async def _order_creation_message( - self, spot_orders_to_create: List[GatewayInFlightOrder] - ) -> Tuple[any_pb2.Any, List[str]]: + def _all_subaccount_orders_cancel_message( + self, + spot_markets_ids: List[str], + derivative_markets_ids: List[str] + ) -> any_pb2.Any: raise NotImplementedError @abstractmethod - def _order_cancel_message(self, spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData]) -> any_pb2.Any: + def _generate_injective_order_data(self, order: GatewayInFlightOrder, market_id: str) -> injective_exchange_tx_pb.OrderData: raise NotImplementedError @abstractmethod - def _generate_injective_order_data(self, order: GatewayInFlightOrder) -> injective_exchange_tx_pb.OrderData: + async def _updated_derivative_market_info_for_id(self, market_id: str) -> InjectiveDerivativeMarket: raise NotImplementedError @abstractmethod @@ -535,6 +799,34 @@ def _place_order_results( def _chain_cookie_file_path(self) -> str: return f"{os.path.join(os.path.dirname(__file__), '../.injective_cookie')}" + async def _last_traded_price(self, market_id: str) -> Decimal: + price = Decimal("nan") + if market_id in await self.spot_market_and_trading_pair_map(): + market = await self.spot_market_info_for_id(market_id=market_id) + async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_TRADES_LIMIT_ID): + trades_response = await self.query_executor.get_spot_trades( + market_ids=[market_id], + limit=1, + ) + trades = trades_response.get("trades", []) + if len(trades) > 0: + price = market.price_from_chain_format( + chain_price=Decimal(trades[0]["price"]["price"])) + + else: + market = await self.derivative_market_info_for_id(market_id=market_id) + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_TRADES_LIMIT_ID): + trades_response = await self.query_executor.get_derivative_trades( + market_ids=[market_id], + limit=1, + ) + trades = trades_response.get("trades", []) + if len(trades) > 0: + price = market.price_from_chain_format( + chain_price=Decimal(trades_response["trades"][0]["positionDelta"]["executionPrice"])) + + return price + async def _transaction_from_chain(self, tx_hash: str, retries: int) -> int: executed_tries = 0 found = False @@ -543,7 +835,7 @@ async def _transaction_from_chain(self, tx_hash: str, retries: int) -> int: while executed_tries < retries and not found: executed_tries += 1 try: - async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_ORDERS_HISTORY_LIMIT_ID): + async with self.throttler.execute_task(limit_id=CONSTANTS.GET_TRANSACTION_CHAIN_LIMIT_ID): block_height = await self.query_executor.get_tx_block_height(tx_hash=tx_hash) found = True except ValueError: @@ -557,9 +849,68 @@ async def _transaction_from_chain(self, tx_hash: str, retries: int) -> int: return block_height - async def _parse_trade_entry(self, trade_info: Dict[str, Any]) -> TradeUpdate: + async def _oracle_price(self, market_id: str) -> Decimal: + market = await self.derivative_market_info_for_id(market_id=market_id) + async with self.throttler.execute_task(limit_id=CONSTANTS.ORACLE_PRICES_LIMIT_ID): + response = await self.query_executor.get_oracle_prices( + base_symbol=market.oracle_base(), + quote_symbol=market.oracle_quote(), + oracle_type=market.oracle_type(), + oracle_scale_factor=0, + ) + price = Decimal(response["price"]) + + return price + + def _spot_order_book_updates_stream(self, market_ids: List[str]): + stream = self.query_executor.spot_order_book_updates_stream(market_ids=market_ids) + return stream + + def _public_spot_trades_stream(self, market_ids: List[str]): + stream = self.query_executor.public_spot_trades_stream(market_ids=market_ids) + return stream + + def _derivative_order_book_updates_stream(self, market_ids: List[str]): + stream = self.query_executor.derivative_order_book_updates_stream(market_ids=market_ids) + return stream + + def _public_derivative_trades_stream(self, market_ids: List[str]): + stream = self.query_executor.public_derivative_trades_stream(market_ids=market_ids) + return stream + + def _oracle_prices_stream(self, oracle_base: str, oracle_quote: str, oracle_type: str): + stream = self.query_executor.oracle_prices_stream( + oracle_base=oracle_base, oracle_quote=oracle_quote, oracle_type=oracle_type + ) + return stream + + def _subaccount_positions_stream(self): + stream = self.query_executor.subaccount_positions_stream(subaccount_id=self.portfolio_account_subaccount_id) + return stream + + def _subaccount_balance_stream(self): + stream = self.query_executor.subaccount_balance_stream(subaccount_id=self.portfolio_account_subaccount_id) + return stream + + def _subaccount_spot_orders_stream(self, market_id: str): + stream = self.query_executor.subaccount_historical_spot_orders_stream( + market_id=market_id, subaccount_id=self.portfolio_account_subaccount_id + ) + return stream + + def _subaccount_derivative_orders_stream(self, market_id: str): + stream = self.query_executor.subaccount_historical_derivative_orders_stream( + market_id=market_id, subaccount_id=self.portfolio_account_subaccount_id + ) + return stream + + def _transactions_stream(self): + stream = self.query_executor.transactions_stream() + return stream + + async def _parse_spot_trade_entry(self, trade_info: Dict[str, Any]) -> TradeUpdate: exchange_order_id: str = trade_info["orderHash"] - market = await self.market_info_for_id(market_id=trade_info["marketId"]) + market = await self.spot_market_info_for_id(market_id=trade_info["marketId"]) trading_pair = await self.trading_pair_for_market(market_id=trade_info["marketId"]) trade_id: str = trade_info["tradeId"] @@ -592,6 +943,40 @@ async def _parse_trade_entry(self, trade_info: Dict[str, Any]) -> TradeUpdate: return trade_update + async def _parse_derivative_trade_entry(self, trade_info: Dict[str, Any]) -> TradeUpdate: + exchange_order_id: str = trade_info["orderHash"] + market = await self.derivative_market_info_for_id(market_id=trade_info["marketId"]) + trading_pair = await self.trading_pair_for_market(market_id=trade_info["marketId"]) + trade_id: str = trade_info["tradeId"] + + price = market.price_from_chain_format(chain_price=Decimal(trade_info["positionDelta"]["executionPrice"])) + size = market.quantity_from_chain_format(chain_quantity=Decimal(trade_info["positionDelta"]["executionQuantity"])) + is_taker: bool = trade_info["executionSide"] == "taker" + trade_time = int(trade_info["executedAt"]) * 1e-3 + + fee_amount = market.quote_token.value_from_chain_format(chain_value=Decimal(trade_info["fee"])) + fee = TradeFeeBase.new_perpetual_fee( + fee_schema=TradeFeeSchema(), + position_action=PositionAction.OPEN, # will be changed by the exchange class + percent_token=market.quote_token.symbol, + flat_fees=[TokenAmount(amount=fee_amount, token=market.quote_token.symbol)] + ) + + trade_update = TradeUpdate( + trade_id=trade_id, + client_order_id=None, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + fill_timestamp=trade_time, + fill_price=price, + fill_base_amount=size, + fill_quote_amount=size * price, + fee=fee, + is_taker=is_taker, + ) + + return trade_update + async def _parse_order_entry(self, order_info: Dict[str, Any]) -> OrderUpdate: exchange_order_id: str = order_info["orderHash"] trading_pair = await self.trading_pair_for_market(market_id=order_info["marketId"]) @@ -606,9 +991,42 @@ async def _parse_order_entry(self, order_info: Dict[str, Any]) -> OrderUpdate: return status_update - async def _send_in_transaction(self, message: any_pb2.Any) -> Dict[str, Any]: + async def _parse_position_update_event(self, event: Dict[str, Any]) -> PositionUpdateEvent: + market = await self.derivative_market_info_for_id(market_id=event["marketId"]) + trading_pair = await self.trading_pair_for_market(market_id=event["marketId"]) + + if "direction" in event: + position_side = PositionSide[event["direction"].upper()] + amount_sign = Decimal(-1) if position_side == PositionSide.SHORT else Decimal(1) + chain_entry_price = Decimal(event["entryPrice"]) + chain_mark_price = Decimal(event["markPrice"]) + chain_amount = Decimal(event["quantity"]) + chain_margin = Decimal(event["margin"]) + entry_price = market.price_from_chain_format(chain_price=chain_entry_price) + mark_price = market.price_from_chain_format(chain_price=chain_mark_price) + amount = market.quantity_from_chain_format(chain_quantity=chain_amount) + leverage = (chain_amount * chain_entry_price) / chain_margin + unrealized_pnl = (mark_price - entry_price) * amount * amount_sign + else: + position_side = None + entry_price = unrealized_pnl = amount = Decimal("0") + leverage = amount_sign = Decimal("1") + + parsed_event = PositionUpdateEvent( + timestamp=int(event["updatedAt"]) * 1e-3, + trading_pair=trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount * amount_sign, + leverage=leverage, + ) + + return parsed_event + + async def _send_in_transaction(self, messages: List[any_pb2.Any]) -> Dict[str, Any]: transaction = Transaction() - transaction.with_messages(message) + transaction.with_messages(*messages) transaction.with_sequence(await self.trading_account_sequence()) transaction.with_account_num(await self.trading_account_number()) transaction.with_chain_id(self.injective_chain_id) @@ -644,89 +1062,112 @@ async def _send_in_transaction(self, message: any_pb2.Any) -> Dict[str, Any]: return result - async def _listen_to_order_book_updates(self, market_ids: List[str]): - while True: - try: - updates_stream = self._order_book_updates_stream(market_ids=market_ids) - async for update in updates_stream: - try: - await self._process_order_book_update(order_book_update=update) - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().warning(f"Invalid orderbook diff event format ({ex})\n{update}") - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error(f"Error while listening to order book updates, reconnecting ... ({ex})") + async def _listen_to_spot_order_book_updates(self, market_ids: List[str]): + await self._listen_stream_events( + stream_provider=partial(self._spot_order_book_updates_stream, market_ids=market_ids), + event_processor=self._process_order_book_update, + event_name_for_errors="spot order book", + ) - async def _listen_to_public_trades(self, market_ids: List[str]): - while True: - try: - public_trades_stream = self._public_trades_stream(market_ids=market_ids) - async for trade in public_trades_stream: - try: - await self._process_public_trade_update(trade_update=trade) - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().warning(f"Invalid public trade event format ({ex})\n{trade}") - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error(f"Error while listening to public trades, reconnecting ... ({ex})") + async def _listen_to_public_spot_trades(self, market_ids: List[str]): + await self._listen_stream_events( + stream_provider=partial(self._public_spot_trades_stream, market_ids=market_ids), + event_processor=self._process_public_spot_trade_update, + event_name_for_errors="public spot trade", + ) + + async def _listen_to_derivative_order_book_updates(self, market_ids: List[str]): + await self._listen_stream_events( + stream_provider=partial(self._derivative_order_book_updates_stream, market_ids=market_ids), + event_processor=self._process_order_book_update, + event_name_for_errors="derivative order book", + ) + + async def _listen_to_public_derivative_trades(self, market_ids: List[str]): + await self._listen_stream_events( + stream_provider=partial(self._public_derivative_trades_stream, market_ids=market_ids), + event_processor=self._process_public_derivative_trade_update, + event_name_for_errors="public derivative trade", + ) + + async def _listen_to_funding_info_updates(self, market_id: str): + market = await self.derivative_market_info_for_id(market_id=market_id) + await self._listen_stream_events( + stream_provider=partial( + self._oracle_prices_stream, + oracle_base=market.oracle_base(), + oracle_quote=market.oracle_quote(), + oracle_type=market.oracle_type() + ), + event_processor=self._process_oracle_price_update, + event_name_for_errors="funding info", + market_id=market_id, + ) + + async def _listen_to_positions_updates(self): + await self._listen_stream_events( + stream_provider=self._subaccount_positions_stream, + event_processor=self._process_position_update, + event_name_for_errors="position", + ) async def _listen_to_account_balance_updates(self): - while True: - try: - balance_stream = self._subaccount_balance_stream() - async for balance_event in balance_stream: - try: - await self._process_subaccount_balance_update(balance_event=balance_event) - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().warning(f"Invalid balance event format ({ex})\n{balance_event}") - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error(f"Error while listening to balance updates, reconnecting ... ({ex})") + await self._listen_stream_events( + stream_provider=self._subaccount_balance_stream, + event_processor=self._process_subaccount_balance_update, + event_name_for_errors="balance", + ) - async def _listen_to_subaccount_order_updates(self, market_id: str): - while True: - try: - orders_stream = self._subaccount_orders_stream(market_id=market_id) - async for order_event in orders_stream: - try: - await self._process_subaccount_order_update(order_event=order_event) - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().warning(f"Invalid order event format ({ex})\n{order_event}") - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error(f"Error while listening to subaccount orders updates, reconnecting ... ({ex})") + async def _listen_to_subaccount_spot_order_updates(self, market_id: str): + await self._listen_stream_events( + stream_provider=partial(self._subaccount_spot_orders_stream, market_id=market_id), + event_processor=self._process_subaccount_order_update, + event_name_for_errors="subaccount spot order", + ) + + async def _listen_to_subaccount_derivative_order_updates(self, market_id: str): + await self._listen_stream_events( + stream_provider=partial(self._subaccount_derivative_orders_stream, market_id=market_id), + event_processor=self._process_subaccount_order_update, + event_name_for_errors="subaccount derivative order", + ) async def _listen_to_chain_transactions(self): + await self._listen_stream_events( + stream_provider=self._transactions_stream, + event_processor=self._process_transaction_update, + event_name_for_errors="transaction", + ) + + async def _listen_stream_events( + self, + stream_provider: Callable, + event_processor: Callable, + event_name_for_errors: str, + **kwargs): while True: + self.logger().debug(f"Starting stream for {event_name_for_errors}") try: - transactions_stream = self._transactions_stream() - async for transaction_event in transactions_stream: + stream = stream_provider() + async for event in stream: try: - await self._process_transaction_update(transaction_event=transaction_event) + await event_processor(event, **kwargs) except asyncio.CancelledError: raise except Exception as ex: - self.logger().warning(f"Invalid transaction event format ({ex})\n{transaction_event}") + self.logger().warning(f"Invalid {event_name_for_errors} event format ({ex})\n{event}") except asyncio.CancelledError: raise except Exception as ex: - self.logger().error(f"Error while listening to transactions stream, reconnecting ... ({ex})") + self.logger().error(f"Error while listening to {event_name_for_errors} stream, reconnecting ... ({ex})") + self.logger().debug(f"Reconnecting stream for {event_name_for_errors}") async def _process_order_book_update(self, order_book_update: Dict[str, Any]): market_id = order_book_update["marketId"] - market_info = await self.market_info_for_id(market_id=market_id) + if market_id in await self.spot_market_and_trading_pair_map(): + market_info = await self.spot_market_info_for_id(market_id=market_id) + else: + market_info = await self.derivative_market_info_for_id(market_id=market_id) trading_pair = await self.trading_pair_for_market(market_id=market_id) bids = [(market_info.price_from_chain_format(chain_price=Decimal(bid["price"])), @@ -751,9 +1192,9 @@ async def _process_order_book_update(self, order_book_update: Dict[str, Any]): event_tag=OrderBookDataSourceEvent.DIFF_EVENT, message=diff_message ) - async def _process_public_trade_update(self, trade_update: Dict[str, Any]): + async def _process_public_spot_trade_update(self, trade_update: Dict[str, Any]): market_id = trade_update["marketId"] - market_info = await self.market_info_for_id(market_id=market_id) + market_info = await self.spot_market_info_for_id(market_id=market_id) trading_pair = await self.trading_pair_for_market(market_id=market_id) timestamp = int(trade_update["executedAt"]) * 1e-3 @@ -776,9 +1217,55 @@ async def _process_public_trade_update(self, trade_update: Dict[str, Any]): event_tag=OrderBookDataSourceEvent.TRADE_EVENT, message=trade_message ) - update = await self._parse_trade_entry(trade_info=trade_update) + update = await self._parse_spot_trade_entry(trade_info=trade_update) self.publisher.trigger_event(event_tag=MarketEvent.TradeUpdate, message=update) + async def _process_public_derivative_trade_update(self, trade_update: Dict[str, Any]): + market_id = trade_update["marketId"] + market_info = await self.derivative_market_info_for_id(market_id=market_id) + + trading_pair = await self.trading_pair_for_market(market_id=market_id) + timestamp = int(trade_update["executedAt"]) * 1e-3 + trade_type = (float(TradeType.BUY.value) + if trade_update["positionDelta"]["tradeDirection"] == "buy" + else float(TradeType.SELL.value)) + message_content = { + "trade_id": trade_update["tradeId"], + "trading_pair": trading_pair, + "trade_type": trade_type, + "amount": market_info.quantity_from_chain_format( + chain_quantity=Decimal(str(trade_update["positionDelta"]["executionQuantity"]))), + "price": market_info.price_from_chain_format( + chain_price=Decimal(str(trade_update["positionDelta"]["executionPrice"]))), + } + trade_message = OrderBookMessage( + message_type=OrderBookMessageType.TRADE, + content=message_content, + timestamp=timestamp, + ) + self.publisher.trigger_event( + event_tag=OrderBookDataSourceEvent.TRADE_EVENT, message=trade_message + ) + + update = await self._parse_derivative_trade_entry(trade_info=trade_update) + self.publisher.trigger_event(event_tag=MarketEvent.TradeUpdate, message=update) + + async def _process_oracle_price_update(self, oracle_price_update: Dict[str, Any], market_id: str): + trading_pair = await self.trading_pair_for_market(market_id=market_id) + funding_info = await self.funding_info(market_id=market_id) + funding_info_update = FundingInfoUpdate( + trading_pair=trading_pair, + index_price=funding_info.index_price, + mark_price=funding_info.mark_price, + next_funding_utc_timestamp=funding_info.next_funding_utc_timestamp, + rate=funding_info.rate, + ) + self.publisher.trigger_event(event_tag=MarketEvent.FundingInfo, message=funding_info_update) + + async def _process_position_update(self, position_event: Dict[str, Any]): + parsed_event = await self._parse_position_update_event(event=position_event) + self.publisher.trigger_event(event_tag=AccountEvent.PositionUpdate, message=parsed_event) + async def _process_subaccount_balance_update(self, balance_event: Dict[str, Any]): updated_token = await self.token(denom=balance_event["balance"]["denom"]) if updated_token is not None: @@ -812,7 +1299,7 @@ async def _process_transaction_update(self, transaction_event: Dict[str, Any]): self.publisher.trigger_event(event_tag=InjectiveEvent.ChainTransactionEvent, message=transaction_event) async def _create_spot_order_definition(self, order: GatewayInFlightOrder): - market_id = await self.market_id_for_trading_pair(order.trading_pair) + market_id = await self.market_id_for_spot_trading_pair(order.trading_pair) definition = self.composer.SpotOrder( market_id=market_id, subaccount_id=self.portfolio_account_subaccount_id, @@ -824,6 +1311,58 @@ async def _create_spot_order_definition(self, order: GatewayInFlightOrder): ) return definition + async def _create_derivative_order_definition(self, order: GatewayPerpetualInFlightOrder): + market_id = await self.market_id_for_derivative_trading_pair(order.trading_pair) + definition = self.composer.DerivativeOrder( + market_id=market_id, + subaccount_id=self.portfolio_account_subaccount_id, + fee_recipient=self.portfolio_account_injective_address, + price=order.price, + quantity=order.amount, + leverage=order.leverage, + is_buy=order.trade_type == TradeType.BUY, + is_po=order.order_type == OrderType.LIMIT_MAKER, + is_reduce_only = order.position == PositionAction.CLOSE, + ) + return definition + + def _create_trading_rules( + self, markets: List[Union[InjectiveSpotMarket, InjectiveDerivativeMarket]] + ) -> List[TradingRule]: + trading_rules = [] + for market in markets: + try: + min_price_tick_size = market.min_price_tick_size() + min_quantity_tick_size = market.min_quantity_tick_size() + trading_rule = TradingRule( + trading_pair=market.trading_pair(), + min_order_size=min_quantity_tick_size, + min_price_increment=min_price_tick_size, + min_base_amount_increment=min_quantity_tick_size, + min_quote_amount_increment=min_price_tick_size, + ) + trading_rules.append(trading_rule) + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception(f"Error parsing the trading pair rule: {market.market_info}. Skipping...") + + return trading_rules + + async def _create_trading_fees( + self, markets: List[Union[InjectiveSpotMarket, InjectiveDerivativeMarket]] + ) -> Dict[str, TradeFeeSchema]: + fees = {} + for market in markets: + trading_pair = await self.trading_pair_for_market(market_id=market.market_id) + fees[trading_pair] = TradeFeeSchema( + percent_fee_token=market.quote_token.unique_symbol, + maker_percent_fee_decimal=market.maker_fee_rate(), + taker_percent_fee_decimal=market.taker_fee_rate(), + ) + + return fees + def _time(self): return time.time() diff --git a/hummingbot/connector/exchange/injective_v2/data_sources/injective_grantee_data_source.py b/hummingbot/connector/exchange/injective_v2/data_sources/injective_grantee_data_source.py index 826d4a5479..e514d7e1dc 100644 --- a/hummingbot/connector/exchange/injective_v2/data_sources/injective_grantee_data_source.py +++ b/hummingbot/connector/exchange/injective_v2/data_sources/injective_grantee_data_source.py @@ -16,14 +16,19 @@ from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS from hummingbot.connector.exchange.injective_v2.data_sources.injective_data_source import InjectiveDataSource -from hummingbot.connector.exchange.injective_v2.injective_market import InjectiveSpotMarket, InjectiveToken +from hummingbot.connector.exchange.injective_v2.injective_market import ( + InjectiveDerivativeMarket, + InjectiveSpotMarket, + InjectiveToken, +) from hummingbot.connector.exchange.injective_v2.injective_query_executor import PythonSDKInjectiveQueryExecutor from hummingbot.connector.gateway.common_types import PlaceOrderResult -from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder, GatewayPerpetualInFlightOrder from hummingbot.connector.utils import combine_to_hb_trading_pair from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.api_throttler.async_throttler_base import AsyncThrottlerBase -from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate from hummingbot.core.pubsub import PubSub from hummingbot.logger import HummingbotLogger @@ -39,6 +44,7 @@ def __init__( granter_address: str, granter_subaccount_index: int, network: Network, + rate_limits: List[RateLimit], use_secure_connection: bool = True): self._network = network self._client = AsyncClient( @@ -71,15 +77,15 @@ def __init__( self._publisher = PubSub() self._last_received_message_time = 0 self._order_creation_lock = asyncio.Lock() - # We create a throttler instance here just to have a fully valid instance from the first moment. - # The connector using this data source should replace the throttler with the one used by the connector. - self._throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) + self._throttler = AsyncThrottler(rate_limits=rate_limits) self._is_timeout_height_initialized = False self._is_trading_account_initialized = False self._markets_initialization_lock = asyncio.Lock() - self._market_info_map: Optional[Dict[str, InjectiveSpotMarket]] = None - self._market_and_trading_pair_map: Optional[Mapping[str, str]] = None + self._spot_market_info_map: Optional[Dict[str, InjectiveSpotMarket]] = None + self._derivative_market_info_map: Optional[Dict[str, InjectiveDerivativeMarket]] = None + self._spot_market_and_trading_pair_map: Optional[Mapping[str, str]] = None + self._derivative_market_and_trading_pair_map: Optional[Mapping[str, str]] = None self._tokens_map: Optional[Dict[str, InjectiveToken]] = None self._token_symbol_symbol_and_denom_map: Optional[Mapping[str, str]] = None @@ -144,44 +150,79 @@ async def timeout_height(self) -> int: await self._initialize_timeout_height() return self._client.timeout_height - async def market_and_trading_pair_map(self): - if self._market_and_trading_pair_map is None: + async def spot_market_and_trading_pair_map(self): + if self._spot_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None: await self.update_markets() - return self._market_and_trading_pair_map.copy() + return self._spot_market_and_trading_pair_map.copy() - async def market_info_for_id(self, market_id: str): - if self._market_info_map is None: + async def spot_market_info_for_id(self, market_id: str): + if self._spot_market_info_map is None: async with self._markets_initialization_lock: - if self._market_info_map is None: + if self._spot_market_info_map is None: await self.update_markets() - return self._market_info_map[market_id] + return self._spot_market_info_map[market_id] + + async def derivative_market_and_trading_pair_map(self): + if self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + return self._derivative_market_and_trading_pair_map.copy() + + async def derivative_market_info_for_id(self, market_id: str): + if self._derivative_market_info_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_info_map is None: + await self.update_markets() + + return self._derivative_market_info_map[market_id] async def trading_pair_for_market(self, market_id: str): - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None or self._derivative_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None or self._derivative_market_and_trading_pair_map is None: await self.update_markets() - return self._market_and_trading_pair_map[market_id] + trading_pair = self._spot_market_and_trading_pair_map.get(market_id) - async def market_id_for_trading_pair(self, trading_pair: str) -> str: - if self._market_and_trading_pair_map is None: + if trading_pair is None: + trading_pair = self._derivative_market_and_trading_pair_map[market_id] + return trading_pair + + async def market_id_for_spot_trading_pair(self, trading_pair: str) -> str: + if self._spot_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None: await self.update_markets() - return self._market_and_trading_pair_map.inverse[trading_pair] + return self._spot_market_and_trading_pair_map.inverse[trading_pair] - async def all_markets(self): - if self._market_info_map is None: + async def market_id_for_derivative_trading_pair(self, trading_pair: str) -> str: + if self._derivative_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_info_map is None: + if self._derivative_market_and_trading_pair_map is None: await self.update_markets() - return list(self._market_info_map.values()) + return self._derivative_market_and_trading_pair_map.inverse[trading_pair] + + async def spot_markets(self): + if self._spot_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None: + await self.update_markets() + + return list(self._spot_market_info_map.values()) + + async def derivative_markets(self): + if self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + + return list(self._derivative_market_info_map.values()) async def token(self, denom: str) -> InjectiveToken: if self._tokens_map is None: @@ -212,25 +253,37 @@ async def initialize_trading_account(self): await self._client.get_account(address=self.trading_account_injective_address) self._is_trading_account_initialized = True - def order_hash_manager(self) -> OrderHashManager: + async def order_hash_manager(self) -> OrderHashManager: if self._order_hash_manager is None: - self._order_hash_manager = OrderHashManager( - address=self._granter_address, - network=self._network, - subaccount_indexes=[self._granter_subaccount_index] - ) + async with self.throttler.execute_task(limit_id=CONSTANTS.GET_SUBACCOUNT_LIMIT_ID): + self._order_hash_manager = OrderHashManager( + address=self._granter_address, + network=self._network, + subaccount_indexes=[self._granter_subaccount_index] + ) return self._order_hash_manager + def supported_order_types(self) -> List[OrderType]: + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + async def update_markets(self): self._tokens_map = {} self._token_symbol_symbol_and_denom_map = bidict() - markets = await self._query_executor.spot_markets(status="active") - markets_map = {} - market_id_to_trading_pair = bidict() + spot_markets_map = {} + derivative_markets_map = {} + spot_market_id_to_trading_pair = bidict() + derivative_market_id_to_trading_pair = bidict() + + async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_MARKETS_LIMIT_ID): + markets = await self._query_executor.spot_markets(status="active") for market_info in markets: try: - ticker_base, ticker_quote = market_info["ticker"].split("/") + if "/" in market_info["ticker"]: + ticker_base, ticker_quote = market_info["ticker"].split("/") + else: + ticker_base = market_info["ticker"] + ticker_quote = None base_token = self._token_from_market_info( denom=market_info["baseDenom"], token_meta=market_info["baseTokenMeta"], @@ -247,39 +300,79 @@ async def update_markets(self): quote_token=quote_token, market_info=market_info ) - market_id_to_trading_pair[market.market_id] = market.trading_pair() - markets_map[market.market_id] = market + spot_market_id_to_trading_pair[market.market_id] = market.trading_pair() + spot_markets_map[market.market_id] = market + except KeyError: + self.logger().debug(f"The spot market {market_info['marketId']} will be excluded because it could not " + f"be parsed ({market_info})") + continue + + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_MARKETS_LIMIT_ID): + markets = await self._query_executor.derivative_markets(status="active") + for market_info in markets: + try: + market = self._parse_derivative_market_info(market_info=market_info) + if market.trading_pair() in derivative_market_id_to_trading_pair.inverse: + self.logger().debug( + f"The derivative market {market_info['marketId']} will be excluded because there is other" + f" market with trading pair {market.trading_pair()} ({market_info})") + continue + derivative_market_id_to_trading_pair[market.market_id] = market.trading_pair() + derivative_markets_map[market.market_id] = market except KeyError: - self.logger().debug(f"The market {market_info['marketId']} will be excluded because it could not be " - f"parsed ({market_info})") + self.logger().debug(f"The derivative market {market_info['marketId']} will be excluded because it could" + f" not be parsed ({market_info})") continue - self._market_info_map = markets_map - self._market_and_trading_pair_map = market_id_to_trading_pair + self._spot_market_info_map = spot_markets_map + self._spot_market_and_trading_pair_map = spot_market_id_to_trading_pair + self._derivative_market_info_map = derivative_markets_map + self._derivative_market_and_trading_pair_map = derivative_market_id_to_trading_pair async def order_updates_for_transaction( - self, transaction_hash: str, transaction_orders: List[GatewayInFlightOrder] + self, + transaction_hash: str, + spot_orders: Optional[List[GatewayInFlightOrder]] = None, + perpetual_orders: Optional[List[GatewayPerpetualInFlightOrder]] = None, ) -> List[OrderUpdate]: + spot_orders = spot_orders or [] + perpetual_orders = perpetual_orders or [] + transaction_orders = spot_orders + perpetual_orders + order_updates = [] + transaction_market_orders = [] transaction_spot_orders = [] + transaction_derivative_orders = [] - async with self.throttler.execute_task(limit_id=CONSTANTS.GET_TRANSACTION_LIMIT_ID): + async with self.throttler.execute_task(limit_id=CONSTANTS.GET_TRANSACTION_INDEXER_LIMIT_ID): transaction_info = await self.query_executor.get_tx_by_hash(tx_hash=transaction_hash) transaction_messages = json.loads(base64.b64decode(transaction_info["data"]["messages"]).decode()) for message_info in transaction_messages[0]["value"]["msgs"]: - if message_info.get("@type") == "/injective.exchange.v1beta1.MsgBatchUpdateOrders": + if message_info.get("@type") in CONSTANTS.MARKET_ORDER_MESSAGE_TYPES: + transaction_market_orders.append(message_info["order"]) + elif message_info.get("@type") == CONSTANTS.BATCH_UPDATE_ORDERS_MESSAGE_TYPE: transaction_spot_orders.extend(message_info.get("spot_orders_to_create", [])) + transaction_derivative_orders.extend(message_info.get("derivative_orders_to_create", [])) transaction_data = str(base64.b64decode(transaction_info["data"]["data"])) - spot_order_hashes = re.findall(r"(0[xX][0-9a-fA-F]{64})", transaction_data) - - for order_info, order_hash in zip(transaction_spot_orders, spot_order_hashes): - market = await self.market_info_for_id(market_id=order_info["market_id"]) + order_hashes = re.findall(r"(0[xX][0-9a-fA-F]{64})", transaction_data) + + for order_info, order_hash in zip( + transaction_market_orders + transaction_spot_orders + transaction_derivative_orders, order_hashes + ): + market_id = order_info["market_id"] + if market_id in await self.spot_market_and_trading_pair_map(): + market = await self.spot_market_info_for_id(market_id=market_id) + else: + market = await self.derivative_market_info_for_id(market_id=market_id) price = market.price_from_chain_format(chain_price=Decimal(order_info["order_info"]["price"])) amount = market.quantity_from_chain_format(chain_quantity=Decimal(order_info["order_info"]["quantity"])) trade_type = TradeType.BUY if "BUY" in order_info["order_type"] else TradeType.SELL for transaction_order in transaction_orders: - market_id = await self.market_id_for_trading_pair(trading_pair=transaction_order.trading_pair) + if transaction_order in spot_orders: + market_id = await self.market_id_for_spot_trading_pair(trading_pair=transaction_order.trading_pair) + else: + market_id = await self.market_id_for_derivative_trading_pair(trading_pair=transaction_order.trading_pair) if (market_id == order_info["market_id"] and transaction_order.amount == amount and transaction_order.price == price @@ -301,12 +394,12 @@ async def order_updates_for_transaction( return order_updates - def real_tokens_trading_pair(self, unique_trading_pair: str) -> str: + def real_tokens_spot_trading_pair(self, unique_trading_pair: str) -> str: resulting_trading_pair = unique_trading_pair - if (self._market_and_trading_pair_map is not None - and self._market_info_map is not None): - market_id = self._market_and_trading_pair_map.inverse.get(unique_trading_pair) - market = self._market_info_map.get(market_id) + if (self._spot_market_and_trading_pair_map is not None + and self._spot_market_info_map is not None): + market_id = self._spot_market_and_trading_pair_map.inverse.get(unique_trading_pair) + market = self._spot_market_info_map.get(market_id) if market is not None: resulting_trading_pair = combine_to_hb_trading_pair( base=market.base_token.symbol, @@ -315,6 +408,20 @@ def real_tokens_trading_pair(self, unique_trading_pair: str) -> str: return resulting_trading_pair + def real_tokens_perpetual_trading_pair(self, unique_trading_pair: str) -> str: + resulting_trading_pair = unique_trading_pair + if (self._derivative_market_and_trading_pair_map is not None + and self._derivative_market_info_map is not None): + market_id = self._derivative_market_and_trading_pair_map.inverse.get(unique_trading_pair) + market = self._derivative_market_info_map.get(market_id) + if market is not None: + resulting_trading_pair = combine_to_hb_trading_pair( + base=market.base_token_symbol(), + quote=market.quote_token.symbol, + ) + + return resulting_trading_pair + async def _initialize_timeout_height(self): await self._client.sync_timeout_height() self._is_timeout_height_initialized = True @@ -331,12 +438,14 @@ def _sign_and_encode(self, transaction: Transaction) -> bytes: def _uses_default_portfolio_subaccount(self) -> bool: return self._granter_subaccount_index == CONSTANTS.DEFAULT_SUBACCOUNT_INDEX - def _token_from_market_info(self, denom: str, token_meta: Dict[str, Any], candidate_symbol: str) -> InjectiveToken: + def _token_from_market_info( + self, denom: str, token_meta: Dict[str, Any], candidate_symbol: Optional[str] = None + ) -> InjectiveToken: token = self._tokens_map.get(denom) if token is None: unique_symbol = token_meta["symbol"] if unique_symbol in self._token_symbol_symbol_and_denom_map: - if candidate_symbol not in self._token_symbol_symbol_and_denom_map: + if candidate_symbol is not None and candidate_symbol not in self._token_symbol_symbol_and_denom_map: unique_symbol = candidate_symbol else: unique_symbol = token_meta["name"] @@ -352,78 +461,155 @@ def _token_from_market_info(self, denom: str, token_meta: Dict[str, Any], candid return token - async def _last_traded_price(self, market_id: str) -> Decimal: - async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_TRADES_LIMIT_ID): - trades_response = await self.query_executor.get_spot_trades( - market_ids=[market_id], - limit=1, - ) + def _parse_derivative_market_info(self, market_info: Dict[str, Any]) -> InjectiveDerivativeMarket: + ticker_quote = None + if "/" in market_info["ticker"]: + _, ticker_quote = market_info["ticker"].split("/") + quote_token = self._token_from_market_info( + denom=market_info["quoteDenom"], + token_meta=market_info["quoteTokenMeta"], + candidate_symbol=ticker_quote, + ) + market = InjectiveDerivativeMarket( + market_id=market_info["marketId"], + quote_token=quote_token, + market_info=market_info + ) + return market - price = Decimal("nan") - if len(trades_response["trades"]) > 0: - market = await self.market_info_for_id(market_id=market_id) - price = market.price_from_chain_format(chain_price=Decimal(trades_response["trades"][0]["price"]["price"])) + async def _updated_derivative_market_info_for_id(self, market_id: str) -> InjectiveDerivativeMarket: + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_MARKETS_LIMIT_ID): + market_info = await self._query_executor.derivative_market(market_id=market_id) - return price + market = self._parse_derivative_market_info(market_info=market_info) + return market - def _calculate_order_hashes(self, orders) -> List[str]: - hash_manager = self.order_hash_manager() - hash_manager_result = hash_manager.compute_order_hashes( - spot_orders=orders, derivative_orders=[], subaccount_index=self._granter_subaccount_index - ) - return hash_manager_result.spot + async def _calculate_order_hashes( + self, + spot_orders: List[GatewayInFlightOrder], + derivative_orders: [GatewayPerpetualInFlightOrder] + ) -> Tuple[List[str], List[str]]: + spot_hashes = [] + derivative_hashes = [] + + if len(spot_orders) > 0 or len(derivative_orders) > 0: + hash_manager = await self.order_hash_manager() + hash_manager_result = hash_manager.compute_order_hashes( + spot_orders=spot_orders, + derivative_orders=derivative_orders, + subaccount_index=self._granter_subaccount_index, + ) + spot_hashes = hash_manager_result.spot + derivative_hashes = hash_manager_result.derivative - def _order_book_updates_stream(self, market_ids: List[str]): - stream = self._query_executor.spot_order_book_updates_stream(market_ids=market_ids) - return stream + return spot_hashes, derivative_hashes - def _public_trades_stream(self, market_ids: List[str]): - stream = self._query_executor.public_spot_trades_stream(market_ids=market_ids) - return stream + async def _order_creation_messages( + self, + spot_orders_to_create: List[GatewayInFlightOrder], + derivative_orders_to_create: List[GatewayPerpetualInFlightOrder], + ) -> Tuple[List[any_pb2.Any], List[str], List[str]]: + composer = self.composer + spot_market_order_definitions = [] + derivative_market_order_definitions = [] + spot_order_definitions = [] + derivative_order_definitions = [] + all_messages = [] - def _subaccount_balance_stream(self): - stream = self._query_executor.subaccount_balance_stream(subaccount_id=self.portfolio_account_subaccount_id) - return stream + for order in spot_orders_to_create: + if order.order_type == OrderType.MARKET: + market_id = await self.market_id_for_spot_trading_pair(order.trading_pair) + creation_message = composer.MsgCreateSpotMarketOrder( + sender=self.portfolio_account_injective_address, + market_id=market_id, + subaccount_id=self.portfolio_account_subaccount_id, + fee_recipient=self.portfolio_account_injective_address, + price=order.price, + quantity=order.amount, + is_buy=order.trade_type == TradeType.BUY, + ) + spot_market_order_definitions.append(creation_message.order) + all_messages.append(creation_message) + else: + order_definition = await self._create_spot_order_definition(order=order) + spot_order_definitions.append(order_definition) + + for order in derivative_orders_to_create: + if order.order_type == OrderType.MARKET: + market_id = await self.market_id_for_derivative_trading_pair(order.trading_pair) + creation_message = composer.MsgCreateDerivativeMarketOrder( + sender=self.portfolio_account_injective_address, + market_id=market_id, + subaccount_id=self.portfolio_account_subaccount_id, + fee_recipient=self.portfolio_account_injective_address, + price=order.price, + quantity=order.amount, + leverage=order.leverage, + is_buy=order.trade_type == TradeType.BUY, + is_reduce_only=order.position == PositionAction.CLOSE, + ) + derivative_market_order_definitions.append(creation_message.order) + all_messages.append(creation_message) + else: + order_definition = await self._create_derivative_order_definition(order=order) + derivative_order_definitions.append(order_definition) + + market_spot_hashes, market_derivative_hashes = await self._calculate_order_hashes( + spot_orders=spot_market_order_definitions, + derivative_orders=derivative_market_order_definitions, + ) + limit_spot_hashes, limit_derivative_hashes = await self._calculate_order_hashes( + spot_orders=spot_order_definitions, + derivative_orders=derivative_order_definitions, + ) + spot_order_hashes = market_spot_hashes + limit_spot_hashes + derivative_order_hashes = market_derivative_hashes + limit_derivative_hashes + + if len(limit_spot_hashes) > 0 or len(limit_derivative_hashes) > 0: + message = composer.MsgBatchUpdateOrders( + sender=self.portfolio_account_injective_address, + spot_orders_to_create=spot_order_definitions, + derivative_orders_to_create=derivative_order_definitions, + ) + all_messages.append(message) - def _subaccount_orders_stream(self, market_id: str): - stream = self._query_executor.subaccount_historical_spot_orders_stream( - market_id=market_id, subaccount_id=self.portfolio_account_subaccount_id + delegated_message = composer.MsgExec( + grantee=self.trading_account_injective_address, + msgs=all_messages ) - return stream - def _transactions_stream(self): - stream = self._query_executor.transactions_stream() - return stream + return [delegated_message], spot_order_hashes, derivative_order_hashes - async def _order_creation_message( - self, spot_orders_to_create: List[GatewayInFlightOrder] - ) -> Tuple[any_pb2.Any, List[str]]: + def _order_cancel_message( + self, + spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData], + derivative_orders_to_cancel: List[injective_exchange_tx_pb.OrderData] + ) -> any_pb2.Any: composer = self.composer - order_definitions = [] - - for order in spot_orders_to_create: - order_definition = await self._create_spot_order_definition(order=order) - order_definitions.append(order_definition) - - order_hashes = self._calculate_order_hashes(orders=order_definitions) message = composer.MsgBatchUpdateOrders( sender=self.portfolio_account_injective_address, - spot_orders_to_create=order_definitions, + spot_orders_to_cancel=spot_orders_to_cancel, + derivative_orders_to_cancel=derivative_orders_to_cancel, ) delegated_message = composer.MsgExec( grantee=self.trading_account_injective_address, msgs=[message] ) + return delegated_message - return delegated_message, order_hashes - - def _order_cancel_message(self, spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData]) -> any_pb2.Any: + def _all_subaccount_orders_cancel_message( + self, + spot_markets_ids: List[str], + derivative_markets_ids: List[str] + ) -> any_pb2.Any: composer = self.composer message = composer.MsgBatchUpdateOrders( sender=self.portfolio_account_injective_address, - spot_orders_to_cancel=spot_orders_to_cancel, + subaccount_id=self.portfolio_account_subaccount_id, + spot_market_ids_to_cancel_all=spot_markets_ids, + derivative_market_ids_to_cancel_all=derivative_markets_ids, ) delegated_message = composer.MsgExec( grantee=self.trading_account_injective_address, @@ -431,8 +617,7 @@ def _order_cancel_message(self, spot_orders_to_cancel: List[injective_exchange_t ) return delegated_message - async def _generate_injective_order_data(self, order: GatewayInFlightOrder) -> injective_exchange_tx_pb.OrderData: - market_id = await self.market_id_for_trading_pair(trading_pair=order.trading_pair) + def _generate_injective_order_data(self, order: GatewayInFlightOrder, market_id: str) -> injective_exchange_tx_pb.OrderData: order_data = self.composer.OrderData( market_id=market_id, subaccount_id=self.portfolio_account_subaccount_id, diff --git a/hummingbot/connector/exchange/injective_v2/data_sources/injective_read_only_data_source.py b/hummingbot/connector/exchange/injective_v2/data_sources/injective_read_only_data_source.py new file mode 100644 index 0000000000..6e23aa9cf7 --- /dev/null +++ b/hummingbot/connector/exchange/injective_v2/data_sources/injective_read_only_data_source.py @@ -0,0 +1,411 @@ +import asyncio +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from bidict import bidict +from google.protobuf import any_pb2 +from pyinjective import Transaction +from pyinjective.async_client import AsyncClient +from pyinjective.composer import Composer, injective_exchange_tx_pb +from pyinjective.constant import Network + +from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS +from hummingbot.connector.exchange.injective_v2.data_sources.injective_data_source import InjectiveDataSource +from hummingbot.connector.exchange.injective_v2.injective_market import ( + InjectiveDerivativeMarket, + InjectiveSpotMarket, + InjectiveToken, +) +from hummingbot.connector.exchange.injective_v2.injective_query_executor import PythonSDKInjectiveQueryExecutor +from hummingbot.connector.gateway.common_types import PlaceOrderResult +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder, GatewayPerpetualInFlightOrder +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.api_throttler.async_throttler_base import AsyncThrottlerBase +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.common import OrderType +from hummingbot.core.data_type.in_flight_order import OrderUpdate +from hummingbot.core.pubsub import PubSub +from hummingbot.logger import HummingbotLogger + + +class InjectiveReadOnlyDataSource(InjectiveDataSource): + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + network: Network, + rate_limits: List[RateLimit], + use_secure_connection: bool = True): + self._network = network + self._client = AsyncClient( + network=self._network, + insecure=not use_secure_connection, + chain_cookie_location=self._chain_cookie_file_path(), + ) + self._composer = Composer(network=self._network.string()) + self._query_executor = PythonSDKInjectiveQueryExecutor(sdk_client=self._client) + + self._publisher = PubSub() + self._last_received_message_time = 0 + self._throttler = AsyncThrottler(rate_limits=rate_limits) + + self._markets_initialization_lock = asyncio.Lock() + self._spot_market_info_map: Optional[Dict[str, InjectiveSpotMarket]] = None + self._derivative_market_info_map: Optional[Dict[str, InjectiveDerivativeMarket]] = None + self._spot_market_and_trading_pair_map: Optional[Mapping[str, str]] = None + self._derivative_market_and_trading_pair_map: Optional[Mapping[str, str]] = None + self._tokens_map: Optional[Dict[str, InjectiveToken]] = None + self._token_symbol_symbol_and_denom_map: Optional[Mapping[str, str]] = None + + self._events_listening_tasks: List[asyncio.Task] = [] + + @property + def publisher(self): + return self._publisher + + @property + def query_executor(self): + return self._query_executor + + @property + def composer(self) -> Composer: + return self._composer + + @property + def order_creation_lock(self) -> asyncio.Lock: + return None + + @property + def throttler(self): + return self._throttler + + @property + def portfolio_account_injective_address(self) -> str: + raise NotImplementedError + + @property + def portfolio_account_subaccount_id(self) -> str: + raise NotImplementedError + + @property + def trading_account_injective_address(self) -> str: + raise NotImplementedError + + @property + def injective_chain_id(self) -> str: + return self._network.chain_id + + @property + def fee_denom(self) -> str: + return self._network.fee_denom + + @property + def portfolio_account_subaccount_index(self) -> int: + raise NotImplementedError + + @property + def network_name(self) -> str: + return self._network.string() + + async def timeout_height(self) -> int: + raise NotImplementedError + + async def spot_market_and_trading_pair_map(self): + if self._spot_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None: + await self.update_markets() + return self._spot_market_and_trading_pair_map.copy() + + async def spot_market_info_for_id(self, market_id: str): + if self._spot_market_info_map is None: + async with self._markets_initialization_lock: + if self._spot_market_info_map is None: + await self.update_markets() + + return self._spot_market_info_map[market_id] + + async def derivative_market_and_trading_pair_map(self): + if self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + return self._derivative_market_and_trading_pair_map.copy() + + async def derivative_market_info_for_id(self, market_id: str): + if self._derivative_market_info_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_info_map is None: + await self.update_markets() + + return self._derivative_market_info_map[market_id] + + async def trading_pair_for_market(self, market_id: str): + if self._spot_market_and_trading_pair_map is None or self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None or self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + + trading_pair = self._spot_market_and_trading_pair_map.get(market_id) + + if trading_pair is None: + trading_pair = self._derivative_market_and_trading_pair_map[market_id] + return trading_pair + + async def market_id_for_spot_trading_pair(self, trading_pair: str) -> str: + if self._spot_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None: + await self.update_markets() + + return self._spot_market_and_trading_pair_map.inverse[trading_pair] + + async def market_id_for_derivative_trading_pair(self, trading_pair: str) -> str: + if self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + + return self._derivative_market_and_trading_pair_map.inverse[trading_pair] + + async def spot_markets(self): + if self._spot_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None: + await self.update_markets() + + return list(self._spot_market_info_map.values()) + + async def derivative_markets(self): + if self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + + return list(self._derivative_market_info_map.values()) + + async def token(self, denom: str) -> InjectiveToken: + if self._tokens_map is None: + async with self._markets_initialization_lock: + if self._tokens_map is None: + await self.update_markets() + + return self._tokens_map.get(denom) + + def events_listening_tasks(self) -> List[asyncio.Task]: + return self._events_listening_tasks.copy() + + def add_listening_task(self, task: asyncio.Task): + self._events_listening_tasks.append(task) + + def configure_throttler(self, throttler: AsyncThrottlerBase): + self._throttler = throttler + + async def trading_account_sequence(self) -> int: + raise NotImplementedError + + async def trading_account_number(self) -> int: + raise NotImplementedError + + async def initialize_trading_account(self): + raise NotImplementedError + + async def update_markets(self): + self._tokens_map = {} + self._token_symbol_symbol_and_denom_map = bidict() + spot_markets_map = {} + derivative_markets_map = {} + spot_market_id_to_trading_pair = bidict() + derivative_market_id_to_trading_pair = bidict() + + async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_MARKETS_LIMIT_ID): + markets = await self._query_executor.spot_markets(status="active") + + for market_info in markets: + try: + if "/" in market_info["ticker"]: + ticker_base, ticker_quote = market_info["ticker"].split("/") + else: + ticker_base = market_info["ticker"] + ticker_quote = None + base_token = self._token_from_market_info( + denom=market_info["baseDenom"], + token_meta=market_info["baseTokenMeta"], + candidate_symbol=ticker_base, + ) + quote_token = self._token_from_market_info( + denom=market_info["quoteDenom"], + token_meta=market_info["quoteTokenMeta"], + candidate_symbol=ticker_quote, + ) + market = InjectiveSpotMarket( + market_id=market_info["marketId"], + base_token=base_token, + quote_token=quote_token, + market_info=market_info + ) + spot_market_id_to_trading_pair[market.market_id] = market.trading_pair() + spot_markets_map[market.market_id] = market + except KeyError: + self.logger().debug(f"The spot market {market_info['marketId']} will be excluded because it could not " + f"be parsed ({market_info})") + continue + + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_MARKETS_LIMIT_ID): + markets = await self._query_executor.derivative_markets(status="active") + for market_info in markets: + try: + market = self._parse_derivative_market_info(market_info=market_info) + if market.trading_pair() in derivative_market_id_to_trading_pair.inverse: + self.logger().debug( + f"The derivative market {market_info['marketId']} will be excluded because there is other" + f" market with trading pair {market.trading_pair()} ({market_info})") + continue + derivative_market_id_to_trading_pair[market.market_id] = market.trading_pair() + derivative_markets_map[market.market_id] = market + except KeyError: + self.logger().debug(f"The derivative market {market_info['marketId']} will be excluded because it could" + f" not be parsed ({market_info})") + continue + + self._spot_market_info_map = spot_markets_map + self._spot_market_and_trading_pair_map = spot_market_id_to_trading_pair + self._derivative_market_info_map = derivative_markets_map + self._derivative_market_and_trading_pair_map = derivative_market_id_to_trading_pair + + def real_tokens_spot_trading_pair(self, unique_trading_pair: str) -> str: + resulting_trading_pair = unique_trading_pair + if (self._spot_market_and_trading_pair_map is not None + and self._spot_market_info_map is not None): + market_id = self._spot_market_and_trading_pair_map.inverse.get(unique_trading_pair) + market = self._spot_market_info_map.get(market_id) + if market is not None: + resulting_trading_pair = combine_to_hb_trading_pair( + base=market.base_token.symbol, + quote=market.quote_token.symbol, + ) + + return resulting_trading_pair + + def real_tokens_perpetual_trading_pair(self, unique_trading_pair: str) -> str: + resulting_trading_pair = unique_trading_pair + if (self._derivative_market_and_trading_pair_map is not None + and self._derivative_market_info_map is not None): + market_id = self._derivative_market_and_trading_pair_map.inverse.get(unique_trading_pair) + market = self._derivative_market_info_map.get(market_id) + if market is not None: + resulting_trading_pair = combine_to_hb_trading_pair( + base=market.base_token_symbol(), + quote=market.quote_token.symbol, + ) + + return resulting_trading_pair + + async def order_updates_for_transaction( + self, + transaction_hash: str, + spot_orders: Optional[List[GatewayInFlightOrder]] = None, + perpetual_orders: Optional[List[GatewayPerpetualInFlightOrder]] = None + ) -> List[OrderUpdate]: + raise NotImplementedError + + def supported_order_types(self) -> List[OrderType]: + return [] + + async def _initialize_timeout_height(self): + raise NotImplementedError + + def _sign_and_encode(self, transaction: Transaction) -> bytes: + raise NotImplementedError + + def _uses_default_portfolio_subaccount(self) -> bool: + raise NotImplementedError + + async def _calculate_order_hashes( + self, + spot_orders: List[GatewayInFlightOrder], + derivative_orders: [GatewayPerpetualInFlightOrder]) -> Tuple[List[str], List[str]]: + raise NotImplementedError + + def _reset_order_hash_manager(self): + raise NotImplementedError + + async def _order_creation_messages( + self, + spot_orders_to_create: List[GatewayInFlightOrder], + derivative_orders_to_create: List[GatewayPerpetualInFlightOrder] + ) -> Tuple[List[any_pb2.Any], List[str], List[str]]: + raise NotImplementedError + + def _order_cancel_message( + self, + spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData], + derivative_orders_to_cancel: List[injective_exchange_tx_pb.OrderData] + ) -> any_pb2.Any: + raise NotImplementedError + + def _all_subaccount_orders_cancel_message( + self, + spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData], + derivative_orders_to_cancel: List[injective_exchange_tx_pb.OrderData] + ) -> any_pb2.Any: + raise NotImplementedError + + def _generate_injective_order_data(self, order: GatewayInFlightOrder, + market_id: str) -> injective_exchange_tx_pb.OrderData: + raise NotImplementedError + + async def _updated_derivative_market_info_for_id(self, market_id: str) -> InjectiveDerivativeMarket: + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_MARKETS_LIMIT_ID): + market_info = await self._query_executor.derivative_market(market_id=market_id) + + market = self._parse_derivative_market_info(market_info=market_info) + return market + + def _place_order_results( + self, + orders_to_create: List[GatewayInFlightOrder], + order_hashes: List[str], + misc_updates: Dict[str, Any], + exception: Optional[Exception] = None + ) -> List[PlaceOrderResult]: + raise NotImplementedError + + def _token_from_market_info( + self, denom: str, token_meta: Dict[str, Any], candidate_symbol: Optional[str] = None + ) -> InjectiveToken: + token = self._tokens_map.get(denom) + if token is None: + unique_symbol = token_meta["symbol"] + if unique_symbol in self._token_symbol_symbol_and_denom_map: + if candidate_symbol is not None and candidate_symbol not in self._token_symbol_symbol_and_denom_map: + unique_symbol = candidate_symbol + else: + unique_symbol = token_meta["name"] + token = InjectiveToken( + denom=denom, + symbol=token_meta["symbol"], + unique_symbol=unique_symbol, + name=token_meta["name"], + decimals=token_meta["decimals"] + ) + self._tokens_map[denom] = token + self._token_symbol_symbol_and_denom_map[unique_symbol] = denom + + return token + + def _parse_derivative_market_info(self, market_info: Dict[str, Any]) -> InjectiveDerivativeMarket: + ticker_quote = None + if "/" in market_info["ticker"]: + _, ticker_quote = market_info["ticker"].split("/") + quote_token = self._token_from_market_info( + denom=market_info["quoteDenom"], + token_meta=market_info["quoteTokenMeta"], + candidate_symbol=ticker_quote, + ) + market = InjectiveDerivativeMarket( + market_id=market_info["marketId"], + quote_token=quote_token, + market_info=market_info + ) + return market diff --git a/hummingbot/connector/exchange/injective_v2/data_sources/injective_vaults_data_source.py b/hummingbot/connector/exchange/injective_v2/data_sources/injective_vaults_data_source.py index 8bd9cd81d1..d7ad51e264 100644 --- a/hummingbot/connector/exchange/injective_v2/data_sources/injective_vaults_data_source.py +++ b/hummingbot/connector/exchange/injective_v2/data_sources/injective_vaults_data_source.py @@ -3,7 +3,7 @@ import json import re from decimal import Decimal -from typing import Any, Dict, List, Mapping, Optional, Tuple +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union from bidict import bidict from google.protobuf import any_pb2, json_format @@ -16,14 +16,19 @@ from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS from hummingbot.connector.exchange.injective_v2.data_sources.injective_data_source import InjectiveDataSource -from hummingbot.connector.exchange.injective_v2.injective_market import InjectiveSpotMarket, InjectiveToken +from hummingbot.connector.exchange.injective_v2.injective_market import ( + InjectiveDerivativeMarket, + InjectiveSpotMarket, + InjectiveToken, +) from hummingbot.connector.exchange.injective_v2.injective_query_executor import PythonSDKInjectiveQueryExecutor from hummingbot.connector.gateway.common_types import PlaceOrderResult -from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder, GatewayPerpetualInFlightOrder from hummingbot.connector.utils import combine_to_hb_trading_pair from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.api_throttler.async_throttler_base import AsyncThrottlerBase -from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate from hummingbot.core.pubsub import PubSub from hummingbot.logger import HummingbotLogger @@ -39,6 +44,7 @@ def __init__( vault_contract_address: str, vault_subaccount_index: int, network: Network, + rate_limits: List[RateLimit], use_secure_connection: bool = True): self._network = network self._client = AsyncClient( @@ -71,15 +77,15 @@ def __init__( self._publisher = PubSub() self._last_received_message_time = 0 self._order_creation_lock = asyncio.Lock() - # We create a throttler instance here just to have a fully valid instance from the first moment. - # The connector using this data source should replace the throttler with the one used by the connector. - self._throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) + self._throttler = AsyncThrottler(rate_limits=rate_limits) self._is_timeout_height_initialized = False self._is_trading_account_initialized = False self._markets_initialization_lock = asyncio.Lock() - self._market_info_map: Optional[Dict[str, InjectiveSpotMarket]] = None - self._market_and_trading_pair_map: Optional[Mapping[str, str]] = None + self._spot_market_info_map: Optional[Dict[str, InjectiveSpotMarket]] = None + self._derivative_market_info_map: Optional[Dict[str, InjectiveDerivativeMarket]] = None + self._spot_market_and_trading_pair_map: Optional[Mapping[str, str]] = None + self._derivative_market_and_trading_pair_map: Optional[Mapping[str, str]] = None self._tokens_map: Optional[Dict[str, InjectiveToken]] = None self._token_symbol_symbol_and_denom_map: Optional[Mapping[str, str]] = None @@ -144,44 +150,79 @@ async def timeout_height(self) -> int: await self._initialize_timeout_height() return self._client.timeout_height - async def market_and_trading_pair_map(self): - if self._market_and_trading_pair_map is None: + async def spot_market_and_trading_pair_map(self): + if self._spot_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None: await self.update_markets() - return self._market_and_trading_pair_map.copy() + return self._spot_market_and_trading_pair_map.copy() - async def market_info_for_id(self, market_id: str): - if self._market_info_map is None: + async def spot_market_info_for_id(self, market_id: str): + if self._spot_market_info_map is None: async with self._markets_initialization_lock: - if self._market_info_map is None: + if self._spot_market_info_map is None: await self.update_markets() - return self._market_info_map[market_id] + return self._spot_market_info_map[market_id] + + async def derivative_market_and_trading_pair_map(self): + if self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + return self._derivative_market_and_trading_pair_map.copy() + + async def derivative_market_info_for_id(self, market_id: str): + if self._derivative_market_info_map is None: + async with self._markets_initialization_lock: + if self._derivative_market_info_map is None: + await self.update_markets() + + return self._derivative_market_info_map[market_id] async def trading_pair_for_market(self, market_id: str): - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None or self._derivative_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None or self._derivative_market_and_trading_pair_map is None: + await self.update_markets() + + trading_pair = self._spot_market_and_trading_pair_map.get(market_id) + + if trading_pair is None: + trading_pair = self._derivative_market_and_trading_pair_map[market_id] + return trading_pair + + async def market_id_for_spot_trading_pair(self, trading_pair: str) -> str: + if self._spot_market_and_trading_pair_map is None: + async with self._markets_initialization_lock: + if self._spot_market_and_trading_pair_map is None: + await self.update_markets() + + return self._spot_market_and_trading_pair_map.inverse[trading_pair] + + async def market_id_for_derivative_trading_pair(self, trading_pair: str) -> str: + if self._derivative_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_and_trading_pair_map is None: + if self._derivative_market_and_trading_pair_map is None: await self.update_markets() - return self._market_and_trading_pair_map[market_id] + return self._derivative_market_and_trading_pair_map.inverse[trading_pair] - async def market_id_for_trading_pair(self, trading_pair: str) -> str: - if self._market_and_trading_pair_map is None: + async def spot_markets(self): + if self._spot_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_and_trading_pair_map is None: + if self._spot_market_and_trading_pair_map is None: await self.update_markets() - return self._market_and_trading_pair_map.inverse[trading_pair] + return list(self._spot_market_info_map.values()) - async def all_markets(self): - if self._market_info_map is None: + async def derivative_markets(self): + if self._derivative_market_and_trading_pair_map is None: async with self._markets_initialization_lock: - if self._market_info_map is None: + if self._derivative_market_and_trading_pair_map is None: await self.update_markets() - return list(self._market_info_map.values()) + return list(self._derivative_market_info_map.values()) async def token(self, denom: str) -> InjectiveToken: if self._tokens_map is None: @@ -212,16 +253,27 @@ async def initialize_trading_account(self): await self._client.get_account(address=self.trading_account_injective_address) self._is_trading_account_initialized = True + def supported_order_types(self) -> List[OrderType]: + return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + async def update_markets(self): self._tokens_map = {} self._token_symbol_symbol_and_denom_map = bidict() - markets = await self._query_executor.spot_markets(status="active") - markets_map = {} - market_id_to_trading_pair = bidict() + spot_markets_map = {} + derivative_markets_map = {} + spot_market_id_to_trading_pair = bidict() + derivative_market_id_to_trading_pair = bidict() + + async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_MARKETS_LIMIT_ID): + markets = await self._query_executor.spot_markets(status="active") for market_info in markets: try: - ticker_base, ticker_quote = market_info["ticker"].split("/") + if "/" in market_info["ticker"]: + ticker_base, ticker_quote = market_info["ticker"].split("/") + else: + ticker_base = market_info["ticker"] + ticker_quote = None base_token = self._token_from_market_info( denom=market_info["baseDenom"], token_meta=market_info["baseTokenMeta"], @@ -238,76 +290,80 @@ async def update_markets(self): quote_token=quote_token, market_info=market_info ) - market_id_to_trading_pair[market.market_id] = market.trading_pair() - markets_map[market.market_id] = market + spot_market_id_to_trading_pair[market.market_id] = market.trading_pair() + spot_markets_map[market.market_id] = market + except KeyError: + self.logger().debug(f"The spot market {market_info['marketId']} will be excluded because it could not " + f"be parsed ({market_info})") + continue + + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_MARKETS_LIMIT_ID): + markets = await self._query_executor.derivative_markets(status="active") + for market_info in markets: + try: + market = self._parse_derivative_market_info(market_info=market_info) + if market.trading_pair() in derivative_market_id_to_trading_pair.inverse: + self.logger().debug( + f"The derivative market {market_info['marketId']} will be excluded because there is other" + f" market with trading pair {market.trading_pair()} ({market_info})") + continue + derivative_market_id_to_trading_pair[market.market_id] = market.trading_pair() + derivative_markets_map[market.market_id] = market except KeyError: - self.logger().debug(f"The market {market_info['marketId']} will be excluded because it could not be " - f"parsed ({market_info})") + self.logger().debug(f"The derivative market {market_info['marketId']} will be excluded because it could" + f" not be parsed ({market_info})") continue - self._market_info_map = markets_map - self._market_and_trading_pair_map = market_id_to_trading_pair + self._spot_market_info_map = spot_markets_map + self._spot_market_and_trading_pair_map = spot_market_id_to_trading_pair + self._derivative_market_info_map = derivative_markets_map + self._derivative_market_and_trading_pair_map = derivative_market_id_to_trading_pair async def order_updates_for_transaction( - self, transaction_hash: str, transaction_orders: List[GatewayInFlightOrder] + self, + transaction_hash: str, + spot_orders: Optional[List[GatewayInFlightOrder]] = None, + perpetual_orders: Optional[List[GatewayPerpetualInFlightOrder]] = None, ) -> List[OrderUpdate]: - order_updates = [] + spot_orders = spot_orders or [] + perpetual_orders = perpetual_orders or [] - async with self.throttler.execute_task(limit_id=CONSTANTS.GET_TRANSACTION_LIMIT_ID): + async with self.throttler.execute_task(limit_id=CONSTANTS.GET_TRANSACTION_INDEXER_LIMIT_ID): transaction_info = await self.query_executor.get_tx_by_hash(tx_hash=transaction_hash) transaction_messages = json.loads(base64.b64decode(transaction_info["data"]["messages"]).decode()) transaction_spot_orders = transaction_messages[0]["value"]["msg"]["admin_execute_message"]["injective_message"]["custom"]["msg_data"]["batch_update_orders"]["spot_orders_to_create"] - transaction_logs = json.loads(base64.b64decode(transaction_info["data"]["logs"]).decode()) - batch_orders_message_event = next( - (event for event in transaction_logs[0].get("events", []) if event.get("type") == "wasm"), - {} + transaction_derivative_orders = transaction_messages[0]["value"]["msg"]["admin_execute_message"]["injective_message"]["custom"]["msg_data"]["batch_update_orders"]["derivative_orders_to_create"] + + spot_order_hashes = self._order_hashes_from_transaction( + transaction_info=transaction_info, + hashes_group_key="spot_order_hashes", + ) + derivative_order_hashes = self._order_hashes_from_transaction( + transaction_info=transaction_info, + hashes_group_key="derivative_order_hashes", ) - response = next( - (attribute.get("value", "") - for attribute in batch_orders_message_event.get("attributes", []) - if attribute.get("key") == "batch_update_orders_response"), "") - spot_order_hashes_match = re.search(r"spot_order_hashes: (\[.*?\])", response) - if spot_order_hashes_match is not None: - spot_order_hashes_text = spot_order_hashes_match.group(1) - else: - spot_order_hashes_text = "" - spot_order_hashes = re.findall(r"[\"'](0x\w+)[\"']", spot_order_hashes_text) - for order_info, order_hash in zip(transaction_spot_orders, spot_order_hashes): - market = await self.market_info_for_id(market_id=order_info["market_id"]) - price = market.price_from_chain_format(chain_price=Decimal(order_info["order_info"]["price"])) - amount = market.quantity_from_chain_format(chain_quantity=Decimal(order_info["order_info"]["quantity"])) - trade_type = TradeType.BUY if order_info["order_type"] in [1, 7, 9] else TradeType.SELL - for transaction_order in transaction_orders: - market_id = await self.market_id_for_trading_pair(trading_pair=transaction_order.trading_pair) - if (market_id == order_info["market_id"] - and transaction_order.amount == amount - and transaction_order.price == price - and transaction_order.trade_type == trade_type): - new_state = OrderState.OPEN if transaction_order.is_pending_create else transaction_order.current_state - order_update = OrderUpdate( - trading_pair=transaction_order.trading_pair, - update_timestamp=self._time(), - new_state=new_state, - client_order_id=transaction_order.client_order_id, - exchange_order_id=order_hash, - ) - transaction_orders.remove(transaction_order) - order_updates.append(order_update) - self.logger().debug( - f"Exchange order id found for order {transaction_order.client_order_id} ({order_update})" - ) - break + spot_order_updates = await self._transaction_order_updates( + orders=spot_orders, + transaction_orders_info=transaction_spot_orders, + order_hashes=spot_order_hashes + ) - return order_updates + derivative_order_updates = await self._transaction_order_updates( + orders=perpetual_orders, + transaction_orders_info=transaction_derivative_orders, + order_hashes=derivative_order_hashes + ) - def real_tokens_trading_pair(self, unique_trading_pair: str) -> str: + return spot_order_updates + derivative_order_updates + + def real_tokens_spot_trading_pair(self, unique_trading_pair: str) -> str: resulting_trading_pair = unique_trading_pair - if (self._market_and_trading_pair_map is not None - and self._market_info_map is not None): - market_id = self._market_and_trading_pair_map.inverse.get(unique_trading_pair) - market = self._market_info_map.get(market_id) + if (self._spot_market_and_trading_pair_map is not None + and self._spot_market_info_map is not None): + market_id = self._spot_market_and_trading_pair_map.inverse.get(unique_trading_pair) + market = self._spot_market_info_map.get(market_id) if market is not None: resulting_trading_pair = combine_to_hb_trading_pair( base=market.base_token.symbol, @@ -316,12 +372,27 @@ def real_tokens_trading_pair(self, unique_trading_pair: str) -> str: return resulting_trading_pair + def real_tokens_perpetual_trading_pair(self, unique_trading_pair: str) -> str: + resulting_trading_pair = unique_trading_pair + if (self._derivative_market_and_trading_pair_map is not None + and self._derivative_market_info_map is not None): + market_id = self._derivative_market_and_trading_pair_map.inverse.get(unique_trading_pair) + market = self._derivative_market_info_map.get(market_id) + if market is not None: + resulting_trading_pair = combine_to_hb_trading_pair( + base=market.base_token_symbol(), + quote=market.quote_token.symbol, + ) + + return resulting_trading_pair + async def _initialize_timeout_height(self): await self._client.sync_timeout_height() self._is_timeout_height_initialized = True def _reset_order_hash_manager(self): - raise NotImplementedError + # The vaults data source does not calculate locally the order hashes + pass def _sign_and_encode(self, transaction: Transaction) -> bytes: sign_doc = transaction.get_sign_doc(self._public_key) @@ -332,12 +403,14 @@ def _sign_and_encode(self, transaction: Transaction) -> bytes: def _uses_default_portfolio_subaccount(self) -> bool: return self._vault_subaccount_index == CONSTANTS.DEFAULT_SUBACCOUNT_INDEX - def _token_from_market_info(self, denom: str, token_meta: Dict[str, Any], candidate_symbol: str) -> InjectiveToken: + def _token_from_market_info( + self, denom: str, token_meta: Dict[str, Any], candidate_symbol: Optional[str] = None + ) -> InjectiveToken: token = self._tokens_map.get(denom) if token is None: unique_symbol = token_meta["symbol"] if unique_symbol in self._token_symbol_symbol_and_denom_map: - if candidate_symbol not in self._token_symbol_symbol_and_denom_map: + if candidate_symbol is not None and candidate_symbol not in self._token_symbol_symbol_and_denom_map: unique_symbol = candidate_symbol else: unique_symbol = token_meta["name"] @@ -353,58 +426,57 @@ def _token_from_market_info(self, denom: str, token_meta: Dict[str, Any], candid return token - async def _last_traded_price(self, market_id: str) -> Decimal: - async with self.throttler.execute_task(limit_id=CONSTANTS.SPOT_TRADES_LIMIT_ID): - trades_response = await self.query_executor.get_spot_trades( - market_ids=[market_id], - limit=1, - ) + def _parse_derivative_market_info(self, market_info: Dict[str, Any]) -> InjectiveDerivativeMarket: + ticker_quote = None + if "/" in market_info["ticker"]: + _, ticker_quote = market_info["ticker"].split("/") + quote_token = self._token_from_market_info( + denom=market_info["quoteDenom"], + token_meta=market_info["quoteTokenMeta"], + candidate_symbol=ticker_quote, + ) + market = InjectiveDerivativeMarket( + market_id=market_info["marketId"], + quote_token=quote_token, + market_info=market_info + ) + return market - price = Decimal("nan") - if len(trades_response["trades"]) > 0: - market = await self.market_info_for_id(market_id=market_id) - price = market.price_from_chain_format(chain_price=Decimal(trades_response["trades"][0]["price"]["price"])) + async def _updated_derivative_market_info_for_id(self, market_id: str) -> InjectiveDerivativeMarket: + async with self.throttler.execute_task(limit_id=CONSTANTS.DERIVATIVE_MARKETS_LIMIT_ID): + market_info = await self._query_executor.derivative_market(market_id=market_id) - return price + market = self._parse_derivative_market_info(market_info=market_info) + return market - def _calculate_order_hashes(self, orders: List[GatewayInFlightOrder]) -> List[str]: + async def _calculate_order_hashes( + self, + spot_orders: List[GatewayInFlightOrder], + derivative_orders: [GatewayPerpetualInFlightOrder] + ) -> Tuple[List[str], List[str]]: raise NotImplementedError - def _order_book_updates_stream(self, market_ids: List[str]): - stream = self._query_executor.spot_order_book_updates_stream(market_ids=market_ids) - return stream - - def _public_trades_stream(self, market_ids: List[str]): - stream = self._query_executor.public_spot_trades_stream(market_ids=market_ids) - return stream - - def _subaccount_balance_stream(self): - stream = self._query_executor.subaccount_balance_stream(subaccount_id=self.portfolio_account_subaccount_id) - return stream - - def _subaccount_orders_stream(self, market_id: str): - stream = self._query_executor.subaccount_historical_spot_orders_stream( - market_id=market_id, subaccount_id=self.portfolio_account_subaccount_id - ) - return stream - - def _transactions_stream(self): - stream = self._query_executor.transactions_stream() - return stream - - async def _order_creation_message( - self, spot_orders_to_create: List[GatewayInFlightOrder] - ) -> Tuple[any_pb2.Any, List[str]]: + async def _order_creation_messages( + self, + spot_orders_to_create: List[GatewayInFlightOrder], + derivative_orders_to_create: List[GatewayPerpetualInFlightOrder], + ) -> Tuple[List[any_pb2.Any], List[str], List[str]]: composer = self.composer - order_definitions = [] + spot_order_definitions = [] + derivative_order_definitions = [] for order in spot_orders_to_create: order_definition = await self._create_spot_order_definition(order=order) - order_definitions.append(order_definition) + spot_order_definitions.append(order_definition) + + for order in derivative_orders_to_create: + order_definition = await self._create_derivative_order_definition(order=order) + derivative_order_definitions.append(order_definition) message = composer.MsgBatchUpdateOrders( sender=self.portfolio_account_injective_address, - spot_orders_to_create=order_definitions, + spot_orders_to_create=spot_order_definitions, + derivative_orders_to_create=derivative_order_definitions, ) message_as_dictionary = json_format.MessageToDict( @@ -423,14 +495,19 @@ async def _order_creation_message( msg=json.dumps(execute_message_parameter), ) - return execute_contract_message, [] + return [execute_contract_message], [], [] - def _order_cancel_message(self, spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData]) -> any_pb2.Any: + def _order_cancel_message( + self, + spot_orders_to_cancel: List[injective_exchange_tx_pb.OrderData], + derivative_orders_to_cancel: List[injective_exchange_tx_pb.OrderData] + ) -> any_pb2.Any: composer = self.composer message = composer.MsgBatchUpdateOrders( sender=self.portfolio_account_injective_address, spot_orders_to_cancel=spot_orders_to_cancel, + derivative_orders_to_cancel=derivative_orders_to_cancel, ) message_as_dictionary = json_format.MessageToDict( @@ -451,8 +528,39 @@ def _order_cancel_message(self, spot_orders_to_cancel: List[injective_exchange_t return execute_contract_message - async def _generate_injective_order_data(self, order: GatewayInFlightOrder) -> injective_exchange_tx_pb.OrderData: - market_id = await self.market_id_for_trading_pair(trading_pair=order.trading_pair) + def _all_subaccount_orders_cancel_message( + self, + spot_markets_ids: List[str], + derivative_markets_ids: List[str] + ) -> any_pb2.Any: + composer = self.composer + + message = composer.MsgBatchUpdateOrders( + sender=self.portfolio_account_injective_address, + subaccount_id=self.portfolio_account_subaccount_id, + spot_market_ids_to_cancel_all=spot_markets_ids, + derivative_market_ids_to_cancel_all=derivative_markets_ids, + ) + + message_as_dictionary = json_format.MessageToDict( + message=message, + including_default_value_fields=True, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + + execute_message_parameter = self._create_execute_contract_internal_message( + batch_update_orders_params=message_as_dictionary) + + execute_contract_message = composer.MsgExecuteContract( + sender=self._vault_admin_address.to_acc_bech32(), + contract=self._vault_contract_address.to_acc_bech32(), + msg=json.dumps(execute_message_parameter), + ) + + return execute_contract_message + + def _generate_injective_order_data(self, order: GatewayInFlightOrder, market_id: str) -> injective_exchange_tx_pb.OrderData: order_data = self.composer.OrderData( market_id=market_id, subaccount_id=str(self.portfolio_account_subaccount_index), @@ -465,8 +573,8 @@ async def _generate_injective_order_data(self, order: GatewayInFlightOrder) -> i async def _create_spot_order_definition(self, order: GatewayInFlightOrder): # Both price and quantity have to be adjusted because the vaults expect to receive those values without - # the extra 18 zeros that the chain backend expectes for direct trading messages - market_id = await self.market_id_for_trading_pair(order.trading_pair) + # the extra 18 zeros that the chain backend expects for direct trading messages + market_id = await self.market_id_for_spot_trading_pair(order.trading_pair) definition = self.composer.SpotOrder( market_id=market_id, subaccount_id=str(self.portfolio_account_subaccount_index), @@ -481,6 +589,27 @@ async def _create_spot_order_definition(self, order: GatewayInFlightOrder): definition.order_info.price = f"{(Decimal(definition.order_info.price) * Decimal('1e-18')).normalize():f}" return definition + async def _create_derivative_order_definition(self, order: GatewayPerpetualInFlightOrder): + # Price, quantity and margin have to be adjusted because the vaults expect to receive those values without + # the extra 18 zeros that the chain backend expects for direct trading messages + market_id = await self.market_id_for_derivative_trading_pair(order.trading_pair) + definition = self.composer.DerivativeOrder( + market_id=market_id, + subaccount_id=str(self.portfolio_account_subaccount_index), + fee_recipient=self.portfolio_account_injective_address, + price=order.price, + quantity=order.amount, + leverage=order.leverage, + is_buy=order.trade_type == TradeType.BUY, + is_po=order.order_type == OrderType.LIMIT_MAKER, + is_reduce_only = order.position == PositionAction.CLOSE, + ) + + definition.order_info.quantity = f"{(Decimal(definition.order_info.quantity) * Decimal('1e-18')).normalize():f}" + definition.order_info.price = f"{(Decimal(definition.order_info.price) * Decimal('1e-18')).normalize():f}" + definition.margin = f"{(Decimal(definition.margin) * Decimal('1e-18')).normalize():f}" + return definition + def _place_order_results( self, orders_to_create: List[GatewayInFlightOrder], @@ -512,3 +641,62 @@ def _create_execute_contract_internal_message(self, batch_update_orders_params: } } } + + def _order_hashes_from_transaction(self, transaction_info: Dict[str, Any], hashes_group_key: str) -> List[str]: + transaction_logs = json.loads(base64.b64decode(transaction_info["data"]["logs"]).decode()) + batch_orders_message_event = next( + (event for event in transaction_logs[0].get("events", []) if event.get("type") == "wasm"), + {} + ) + response = next( + (attribute.get("value", "") + for attribute in batch_orders_message_event.get("attributes", []) + if attribute.get("key") == "batch_update_orders_response"), "") + order_hashes_match = re.search(f"{hashes_group_key}: (\\[.*?\\])", response) + if order_hashes_match is not None: + order_hashes_text = order_hashes_match.group(1) + else: + order_hashes_text = "" + order_hashes = re.findall(r"[\"'](0x\w+)[\"']", order_hashes_text) + + return order_hashes + + async def _transaction_order_updates( + self, + orders: List[Union[GatewayInFlightOrder, GatewayPerpetualInFlightOrder]], + transaction_orders_info: List[Dict[str, Any]], + order_hashes: List[str], + ) -> List[OrderUpdate]: + order_updates = [] + + for order_info, order_hash in zip(transaction_orders_info, order_hashes): + market_id = order_info["market_id"] + if market_id in await self.spot_market_and_trading_pair_map(): + market = await self.spot_market_info_for_id(market_id=market_id) + else: + market = await self.derivative_market_info_for_id(market_id=market_id) + market_trading_pair = await self.trading_pair_for_market(market_id=market_id) + price = market.price_from_chain_format(chain_price=Decimal(order_info["order_info"]["price"])) + amount = market.quantity_from_chain_format(chain_quantity=Decimal(order_info["order_info"]["quantity"])) + trade_type = TradeType.BUY if order_info["order_type"] in [1, 7, 9] else TradeType.SELL + for transaction_order in orders: + if (transaction_order.trading_pair == market_trading_pair + and transaction_order.amount == amount + and transaction_order.price == price + and transaction_order.trade_type == trade_type): + new_state = OrderState.OPEN if transaction_order.is_pending_create else transaction_order.current_state + order_update = OrderUpdate( + trading_pair=transaction_order.trading_pair, + update_timestamp=self._time(), + new_state=new_state, + client_order_id=transaction_order.client_order_id, + exchange_order_id=order_hash, + ) + orders.remove(transaction_order) + order_updates.append(order_update) + self.logger().debug( + f"Exchange order id found for order {transaction_order.client_order_id} ({order_update})" + ) + break + + return order_updates diff --git a/hummingbot/connector/exchange/injective_v2/injective_constants.py b/hummingbot/connector/exchange/injective_v2/injective_constants.py index b5ba662a22..cfa4c6a03f 100644 --- a/hummingbot/connector/exchange/injective_v2/injective_constants.py +++ b/hummingbot/connector/exchange/injective_v2/injective_constants.py @@ -1,6 +1,6 @@ import sys -from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit from hummingbot.core.data_type.in_flight_order import OrderState EXCHANGE_NAME = "injective_v2" @@ -16,30 +16,137 @@ TRANSACTIONS_CHECK_INTERVAL = 3 * EXPECTED_BLOCK_TIME # Public limit ids -ORDERBOOK_LIMIT_ID = "OrderBookSnapshot" -GET_TRANSACTION_LIMIT_ID = "GetTransaction" -GET_CHAIN_TRANSACTION_LIMIT_ID = "GetChainTransaction" +SPOT_MARKETS_LIMIT_ID = "SpotMarkets" +DERIVATIVE_MARKETS_LIMIT_ID = "DerivativeMarkets" +SPOT_ORDERBOOK_LIMIT_ID = "SpotOrderBookSnapshot" +DERIVATIVE_ORDERBOOK_LIMIT_ID = "DerivativeOrderBookSnapshot" +GET_TRANSACTION_INDEXER_LIMIT_ID = "GetTransactionIndexer" +GET_TRANSACTION_CHAIN_LIMIT_ID = "GetTransactionChain" +FUNDING_RATES_LIMIT_ID = "FundingRates" +ORACLE_PRICES_LIMIT_ID = "OraclePrices" +FUNDING_PAYMENTS_LIMIT_ID = "FundingPayments" +GET_SUBACCOUNT_LIMIT_ID = "GetSubaccount" # Private limit ids PORTFOLIO_BALANCES_LIMIT_ID = "AccountPortfolio" +POSITIONS_LIMIT_ID = "Positions" SPOT_ORDERS_HISTORY_LIMIT_ID = "SpotOrdersHistory" +DERIVATIVE_ORDERS_HISTORY_LIMIT_ID = "DerivativeOrdersHistory" SPOT_TRADES_LIMIT_ID = "SpotTrades" +DERIVATIVE_TRADES_LIMIT_ID = "DerivativeTrades" SIMULATE_TRANSACTION_LIMIT_ID = "SimulateTransaction" SEND_TRANSACTION = "SendTransaction" +CHAIN_ENDPOINTS_GROUP_LIMIT_ID = "ChainGroupLimit" +INDEXER_ENDPOINTS_GROUP_LIMIT_ID = "IndexerGroupLimit" + NO_LIMIT = sys.maxsize ONE_SECOND = 1 -RATE_LIMITS = [ - RateLimit(limit_id=ORDERBOOK_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=GET_TRANSACTION_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=GET_CHAIN_TRANSACTION_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=PORTFOLIO_BALANCES_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=SPOT_ORDERS_HISTORY_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=SPOT_TRADES_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=SIMULATE_TRANSACTION_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), - RateLimit(limit_id=SEND_TRANSACTION, limit=NO_LIMIT, time_interval=ONE_SECOND), +ENDPOINTS_RATE_LIMITS = [ + RateLimit( + limit_id=GET_SUBACCOUNT_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(CHAIN_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=GET_TRANSACTION_CHAIN_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(CHAIN_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=SIMULATE_TRANSACTION_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(CHAIN_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=SEND_TRANSACTION, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(CHAIN_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=SPOT_MARKETS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=DERIVATIVE_MARKETS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=SPOT_ORDERBOOK_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=DERIVATIVE_ORDERBOOK_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=GET_TRANSACTION_INDEXER_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=PORTFOLIO_BALANCES_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=POSITIONS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=SPOT_ORDERS_HISTORY_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=DERIVATIVE_ORDERS_HISTORY_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=SPOT_TRADES_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=DERIVATIVE_TRADES_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=FUNDING_RATES_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=ORACLE_PRICES_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), + RateLimit( + limit_id=FUNDING_PAYMENTS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=ONE_SECOND, + linked_limits=[LinkedLimitWeightPair(INDEXER_ENDPOINTS_GROUP_LIMIT_ID)]), +] + +PUBLIC_NODE_RATE_LIMITS = [ + RateLimit(limit_id=CHAIN_ENDPOINTS_GROUP_LIMIT_ID, limit=20, time_interval=ONE_SECOND), + RateLimit(limit_id=INDEXER_ENDPOINTS_GROUP_LIMIT_ID, limit=50, time_interval=ONE_SECOND), ] +PUBLIC_NODE_RATE_LIMITS.extend(ENDPOINTS_RATE_LIMITS) + +CUSTOM_NODE_RATE_LIMITS = [ + RateLimit(limit_id=CHAIN_ENDPOINTS_GROUP_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), + RateLimit(limit_id=INDEXER_ENDPOINTS_GROUP_LIMIT_ID, limit=NO_LIMIT, time_interval=ONE_SECOND), +] +CUSTOM_NODE_RATE_LIMITS.extend(ENDPOINTS_RATE_LIMITS) ORDER_STATE_MAP = { "booked": OrderState.OPEN, @@ -50,3 +157,9 @@ ORDER_NOT_FOUND_ERROR_MESSAGE = "order not found" ACCOUNT_SEQUENCE_MISMATCH_ERROR = "account sequence mismatch" + +BATCH_UPDATE_ORDERS_MESSAGE_TYPE = "/injective.exchange.v1beta1.MsgBatchUpdateOrders" +MARKET_ORDER_MESSAGE_TYPES = [ + "/injective.exchange.v1beta1.MsgCreateSpotMarketOrder", + "/injective.exchange.v1beta1.MsgCreateDerivativeMarketOrder", +] diff --git a/hummingbot/connector/exchange/injective_v2/injective_market.py b/hummingbot/connector/exchange/injective_v2/injective_market.py index e2733c13c3..5b9efb6cdb 100644 --- a/hummingbot/connector/exchange/injective_v2/injective_market.py +++ b/hummingbot/connector/exchange/injective_v2/injective_market.py @@ -48,3 +48,51 @@ def maker_fee_rate(self) -> Decimal: def taker_fee_rate(self) -> Decimal: return Decimal(self.market_info["takerFeeRate"]) + + +@dataclass(frozen=True) +class InjectiveDerivativeMarket: + market_id: str + quote_token: InjectiveToken + market_info: Dict[str, Any] + + def base_token_symbol(self): + ticker_base, _ = self.market_info["ticker"].split("/") + return ticker_base + + def trading_pair(self): + ticker_base, _ = self.market_info["ticker"].split("/") + return combine_to_hb_trading_pair(ticker_base, self.quote_token.unique_symbol) + + def quantity_from_chain_format(self, chain_quantity: Decimal) -> Decimal: + return chain_quantity + + def price_from_chain_format(self, chain_price: Decimal) -> Decimal: + scaler = Decimal(f"1e{-self.quote_token.decimals}") + return chain_price * scaler + + def min_price_tick_size(self) -> Decimal: + min_price_tick_size = Decimal(self.market_info["minPriceTickSize"]) + return self.price_from_chain_format(chain_price=min_price_tick_size) + + def min_quantity_tick_size(self) -> Decimal: + min_quantity_tick_size = Decimal(self.market_info["minQuantityTickSize"]) + return self.quantity_from_chain_format(chain_quantity=min_quantity_tick_size) + + def maker_fee_rate(self) -> Decimal: + return Decimal(self.market_info["makerFeeRate"]) + + def taker_fee_rate(self) -> Decimal: + return Decimal(self.market_info["takerFeeRate"]) + + def oracle_base(self) -> str: + return self.market_info["oracleBase"] + + def oracle_quote(self) -> str: + return self.market_info["oracleQuote"] + + def oracle_type(self) -> str: + return self.market_info["oracleType"] + + def next_funding_timestamp(self) -> int: + return int(self.market_info["perpetualMarketInfo"]["nextFundingTimestamp"]) diff --git a/hummingbot/connector/exchange/injective_v2/injective_query_executor.py b/hummingbot/connector/exchange/injective_v2/injective_query_executor.py index 1fc66eee01..354934138b 100644 --- a/hummingbot/connector/exchange/injective_v2/injective_query_executor.py +++ b/hummingbot/connector/exchange/injective_v2/injective_query_executor.py @@ -16,10 +16,22 @@ async def ping(self): async def spot_markets(self, status: str) -> Dict[str, Any]: raise NotImplementedError + @abstractmethod + async def derivative_markets(self, status: str) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def derivative_market(self, market_id: str) -> Dict[str, Any]: + raise NotImplementedError + @abstractmethod async def get_spot_orderbook(self, market_id: str) -> Dict[str, Any]: raise NotImplementedError # pragma: no cover + @abstractmethod + async def get_derivative_orderbook(self, market_id: str) -> Dict[str, Any]: + raise NotImplementedError # pragma: no cover + @abstractmethod async def get_tx_by_hash(self, tx_hash: str) -> Dict[str, Any]: raise NotImplementedError @@ -51,6 +63,17 @@ async def get_spot_trades( ) -> Dict[str, Any]: raise NotImplementedError + @abstractmethod + async def get_derivative_trades( + self, + market_ids: List[str], + subaccount_id: Optional[str] = None, + start_time: Optional[int] = None, + skip: Optional[int] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + raise NotImplementedError + @abstractmethod async def get_historical_spot_orders( self, @@ -61,6 +84,38 @@ async def get_historical_spot_orders( ) -> Dict[str, Any]: raise NotImplementedError + @abstractmethod + async def get_historical_derivative_orders( + self, + market_ids: List[str], + subaccount_id: str, + start_time: int, + skip: int, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def get_funding_rates(self, market_id: str, limit: int) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def get_oracle_prices( + self, + base_symbol: str, + quote_symbol: str, + oracle_type: str, + oracle_scale_factor: int, + ) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def get_funding_payments(self, subaccount_id: str, market_id: str, limit: int) -> Dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def get_derivative_positions(self, subaccount_id: str, skip: int) -> Dict[str, Any]: + raise NotImplementedError + @abstractmethod async def spot_order_book_updates_stream(self, market_ids: List[str]): raise NotImplementedError # pragma: no cover @@ -69,6 +124,22 @@ async def spot_order_book_updates_stream(self, market_ids: List[str]): async def public_spot_trades_stream(self, market_ids: List[str]): raise NotImplementedError # pragma: no cover + @abstractmethod + async def derivative_order_book_updates_stream(self, market_ids: List[str]): + raise NotImplementedError # pragma: no cover + + @abstractmethod + async def public_derivative_trades_stream(self, market_ids: List[str]): + raise NotImplementedError # pragma: no cover + + @abstractmethod + async def oracle_prices_stream(self, oracle_base: str, oracle_quote: str, oracle_type: str): + raise NotImplementedError # pragma: no cover + + @abstractmethod + async def subaccount_positions_stream(self, subaccount_id: str): + raise NotImplementedError # pragma: no cover + @abstractmethod async def subaccount_balance_stream(self, subaccount_id: str): raise NotImplementedError # pragma: no cover @@ -79,6 +150,16 @@ async def subaccount_historical_spot_orders_stream( ): raise NotImplementedError + @abstractmethod + async def subaccount_historical_derivative_orders_stream( + self, market_id: str, subaccount_id: str + ): + raise NotImplementedError + + @abstractmethod + async def transactions_stream(self): # pragma: no cover + raise NotImplementedError + class PythonSDKInjectiveQueryExecutor(BaseInjectiveQueryExecutor): @@ -98,6 +179,21 @@ async def spot_markets(self, status: str) -> List[Dict[str, Any]]: # pragma: no return markets + async def derivative_markets(self, status: str) -> List[Dict[str, Any]]: # pragma: no cover + response = await self._sdk_client.get_derivative_markets(status=status) + markets = [] + + for market_info in response.markets: + markets.append(json_format.MessageToDict(market_info)) + + return markets + + async def derivative_market(self, market_id: str) -> List[Dict[str, Any]]: # pragma: no cover + response = await self._sdk_client.get_derivative_market(market_id=market_id) + market = json_format.MessageToDict(response.market) + + return market + async def get_spot_orderbook(self, market_id: str) -> Dict[str, Any]: # pragma: no cover order_book_response = await self._sdk_client.get_spot_orderbookV2(market_id=market_id) order_book_data = order_book_response.orderbook @@ -110,6 +206,18 @@ async def get_spot_orderbook(self, market_id: str) -> Dict[str, Any]: # pragma: return result + async def get_derivative_orderbook(self, market_id: str) -> Dict[str, Any]: # pragma: no cover + order_book_response = await self._sdk_client.get_derivative_orderbooksV2(market_ids=[market_id]) + order_book_data = order_book_response.orderbooks[0].orderbook + result = { + "buys": [(buy.price, buy.quantity, buy.timestamp) for buy in order_book_data.buys], + "sells": [(buy.price, buy.quantity, buy.timestamp) for buy in order_book_data.sells], + "sequence": order_book_data.sequence, + "timestamp": order_book_data.timestamp, + } + + return result + async def get_tx_by_hash(self, tx_hash: str) -> Dict[str, Any]: # pragma: no cover try: transaction_response = await self._sdk_client.get_tx_by_hash(tx_hash=tx_hash) @@ -169,6 +277,24 @@ async def get_spot_trades( result = json_format.MessageToDict(response) return result + async def get_derivative_trades( + self, + market_ids: List[str], + subaccount_id: Optional[str] = None, + start_time: Optional[int] = None, + skip: Optional[int] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: # pragma: no cover + response = await self._sdk_client.get_derivative_trades( + market_ids=market_ids, + subaccount_id=subaccount_id, + start_time=start_time, + skip=skip, + limit=limit, + ) + result = json_format.MessageToDict(response) + return result + async def get_historical_spot_orders( self, market_ids: List[str], @@ -185,6 +311,59 @@ async def get_historical_spot_orders( result = json_format.MessageToDict(response) return result + async def get_historical_derivative_orders( + self, + market_ids: List[str], + subaccount_id: str, + start_time: int, + skip: int, + ) -> Dict[str, Any]: # pragma: no cover + response = await self._sdk_client.get_historical_derivative_orders( + market_ids=market_ids, + subaccount_id=subaccount_id, + start_time=start_time, + skip=skip, + ) + result = json_format.MessageToDict(response) + return result + + async def get_funding_rates(self, market_id: str, limit: int) -> Dict[str, Any]: + response = await self._sdk_client.get_funding_rates(market_id=market_id, limit=limit) + result = json_format.MessageToDict(response) + return result + + async def get_funding_payments(self, subaccount_id: str, market_id: str, limit: int) -> Dict[str, Any]: + response = await self._sdk_client.get_funding_payments( + subaccount_id=subaccount_id, + market_id=market_id, + limit=limit + ) + result = json_format.MessageToDict(response) + return result + + async def get_derivative_positions(self, subaccount_id: str, skip: int) -> Dict[str, Any]: + response = await self._sdk_client.get_derivative_positions( + subaccount_id=subaccount_id, skip=skip + ) + result = json_format.MessageToDict(response) + return result + + async def get_oracle_prices( + self, + base_symbol: str, + quote_symbol: str, + oracle_type: str, + oracle_scale_factor: int, + ) -> Dict[str, Any]: + response = await self._sdk_client.get_oracle_prices( + base_symbol=base_symbol, + quote_symbol=quote_symbol, + oracle_type=oracle_type, + oracle_scale_factor=oracle_scale_factor + ) + result = json_format.MessageToDict(response) + return result + async def spot_order_book_updates_stream(self, market_ids: List[str]): # pragma: no cover stream = await self._sdk_client.stream_spot_orderbook_update(market_ids=market_ids) async for update in stream: @@ -197,6 +376,31 @@ async def public_spot_trades_stream(self, market_ids: List[str]): # pragma: no trade_data = trade.trade yield json_format.MessageToDict(trade_data) + async def derivative_order_book_updates_stream(self, market_ids: List[str]): # pragma: no cover + stream = await self._sdk_client.stream_derivative_orderbook_update(market_ids=market_ids) + async for update in stream: + order_book_update = update.orderbook_level_updates + yield json_format.MessageToDict(order_book_update) + + async def public_derivative_trades_stream(self, market_ids: List[str]): # pragma: no cover + stream = await self._sdk_client.stream_derivative_trades(market_ids=market_ids) + async for trade in stream: + trade_data = trade.trade + yield json_format.MessageToDict(trade_data) + + async def oracle_prices_stream(self, oracle_base: str, oracle_quote: str, oracle_type: str): # pragma: no cover + stream = await self._sdk_client.stream_oracle_prices( + base_symbol=oracle_base, quote_symbol=oracle_quote, oracle_type=oracle_type + ) + async for update in stream: + yield json_format.MessageToDict(update) + + async def subaccount_positions_stream(self, subaccount_id: str): # pragma: no cover + stream = await self._sdk_client.stream_derivative_positions(subaccount_id=subaccount_id) + async for event in stream: + event_data = event.position + yield json_format.MessageToDict(event_data) + async def subaccount_balance_stream(self, subaccount_id: str): # pragma: no cover stream = await self._sdk_client.stream_subaccount_balance(subaccount_id=subaccount_id) async for event in stream: @@ -210,6 +414,14 @@ async def subaccount_historical_spot_orders_stream( event_data = event.order yield json_format.MessageToDict(event_data) + async def subaccount_historical_derivative_orders_stream( + self, market_id: str, subaccount_id: str + ): # pragma: no cover + stream = await self._sdk_client.stream_historical_derivative_orders(market_id=market_id, subaccount_id=subaccount_id) + async for event in stream: + event_data = event.order + yield json_format.MessageToDict(event_data) + async def transactions_stream(self): # pragma: no cover stream = await self._sdk_client.stream_txs() async for event in stream: diff --git a/hummingbot/connector/exchange/injective_v2/injective_v2_api_order_book_data_source.py b/hummingbot/connector/exchange/injective_v2/injective_v2_api_order_book_data_source.py index 4808ed40dc..1aea444740 100644 --- a/hummingbot/connector/exchange/injective_v2/injective_v2_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/injective_v2/injective_v2_api_order_book_data_source.py @@ -41,7 +41,7 @@ async def listen_for_subscriptions(self): async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: symbol = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) - snapshot = await self._data_source.order_book_snapshot(market_id=symbol, trading_pair=trading_pair) + snapshot = await self._data_source.spot_order_book_snapshot(market_id=symbol, trading_pair=trading_pair) return snapshot async def _parse_order_book_diff_message(self, raw_message: OrderBookMessage, message_queue: asyncio.Queue): diff --git a/hummingbot/connector/exchange/injective_v2/injective_v2_exchange.py b/hummingbot/connector/exchange/injective_v2/injective_v2_exchange.py index 5710226737..125fe4caa8 100644 --- a/hummingbot/connector/exchange/injective_v2/injective_v2_exchange.py +++ b/hummingbot/connector/exchange/injective_v2/injective_v2_exchange.py @@ -2,7 +2,7 @@ from collections import defaultdict from decimal import Decimal from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union from async_timeout import timeout @@ -27,6 +27,7 @@ from hummingbot.core.data_type.common import OrderType, TradeType from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate, TradeUpdate from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.market_order import MarketOrder from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.core.data_type.trade_fee import TradeFeeBase, TradeFeeSchema from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource @@ -58,6 +59,7 @@ def __init__( self._trading_required = trading_required self._trading_pairs = trading_pairs self._data_source = connector_configuration.create_data_source() + self._rate_limits = connector_configuration.network.rate_limits() super().__init__(client_config_map=client_config_map) self._data_source.configure_throttler(throttler=self._throttler) @@ -83,7 +85,7 @@ def authenticator(self) -> AuthBase: @property def rate_limits_rules(self) -> List[RateLimit]: - return CONSTANTS.RATE_LIMITS + return self._rate_limits @property def domain(self) -> str: @@ -156,7 +158,7 @@ async def stop_network(self): self._queued_orders_task = None def supported_order_types(self) -> List[OrderType]: - return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + return self._data_source.supported_order_types() def start_tracking_order( self, @@ -182,13 +184,13 @@ def start_tracking_order( ) ) - def batch_order_create(self, orders_to_create: List[LimitOrder]) -> List[LimitOrder]: + def batch_order_create(self, orders_to_create: List[Union[MarketOrder, LimitOrder]]) -> List[LimitOrder]: """ Issues a batch order creation as a single API request for exchanges that implement this feature. The default implementation of this method is to send the requests discretely (one by one). - :param orders_to_create: A list of LimitOrder objects representing the orders to create. The order IDs + :param orders_to_create: A list of LimitOrder or MarketOrder objects representing the orders to create. The order IDs can be blanc. - :returns: A tuple composed of LimitOrder objects representing the created orders, complete with the generated + :returns: A tuple composed of LimitOrder or MarketOrder objects representing the created orders, complete with the generated order IDs. """ orders_with_ids_to_create = [] @@ -199,20 +201,7 @@ def batch_order_create(self, orders_to_create: List[LimitOrder]) -> List[LimitOr hbot_order_id_prefix=self.client_order_id_prefix, max_id_len=self.client_order_id_max_length, ) - orders_with_ids_to_create.append( - LimitOrder( - client_order_id=client_order_id, - trading_pair=order.trading_pair, - is_buy=order.is_buy, - base_currency=order.base_currency, - quote_currency=order.quote_currency, - price=order.price, - quantity=order.quantity, - filled_quantity=order.filled_quantity, - creation_timestamp=order.creation_timestamp, - status=order.status, - ) - ) + orders_with_ids_to_create.append(order.copy_with_id(client_order_id=client_order_id)) safe_ensure_future(self._execute_batch_order_create(orders_to_create=orders_with_ids_to_create)) return orders_with_ids_to_create @@ -258,6 +247,11 @@ async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: failed_cancellations = [CancellationResult(oid, False) for oid in incomplete_orders.keys()] return successful_cancellations + failed_cancellations + async def cancel_all_subaccount_orders(self): + markets_ids = [await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + for trading_pair in self.trading_pairs] + await self._data_source.cancel_all_subaccount_orders(spot_markets_ids=markets_ids) + async def check_network(self) -> NetworkStatus: """ Checks connectivity with the exchange using the API @@ -277,7 +271,7 @@ def trigger_event(self, event_tag: Enum, message: any): # bot events processing trading_pair = getattr(message, "trading_pair", None) if trading_pair is not None: - new_trading_pair = self._data_source.real_tokens_trading_pair(unique_trading_pair=trading_pair) + new_trading_pair = self._data_source.real_tokens_spot_trading_pair(unique_trading_pair=trading_pair) if isinstance(message, tuple): message = message._replace(trading_pair=new_trading_pair) else: @@ -310,12 +304,65 @@ async def _place_order(self, order_id: str, trading_pair: str, amount: Decimal, # Not required because of _place_order_and_process_update redefinition raise NotImplementedError + async def _create_order(self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + order_type: OrderType, + price: Optional[Decimal] = None, + **kwargs): + """ + Creates an order in the exchange using the parameters to configure it + + :param trade_type: the side of the order (BUY of SELL) + :param order_id: the id that should be assigned to the order (the client id) + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + """ + try: + if price is None: + calculated_price = self.get_price_for_volume( + trading_pair=trading_pair, + is_buy=trade_type == TradeType.BUY, + volume=amount, + ).result_price + calculated_price = self.quantize_order_price(trading_pair, calculated_price) + else: + calculated_price = price + + await super()._create_order( + trade_type=trade_type, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=calculated_price, + ** kwargs + ) + + except asyncio.CancelledError: + raise + except Exception as ex: + self._on_order_failure( + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + trade_type=trade_type, + order_type=order_type, + price=price, + exception=ex, + **kwargs, + ) + async def _place_order_and_process_update(self, order: GatewayInFlightOrder, **kwargs) -> str: # Order creation requests for single orders are queued to be executed in batch if possible self._orders_queued_to_create.append(order) return None - async def _execute_batch_order_create(self, orders_to_create: List[LimitOrder]): + async def _execute_batch_order_create(self, orders_to_create: List[Union[MarketOrder, LimitOrder]]): inflight_orders_to_create = [] for order in orders_to_create: valid_order = await self._start_tracking_and_validate_order( @@ -323,7 +370,7 @@ async def _execute_batch_order_create(self, orders_to_create: List[LimitOrder]): order_id=order.client_order_id, trading_pair=order.trading_pair, amount=order.quantity, - order_type=OrderType.LIMIT, + order_type=order.order_type(), price=order.price, ) if valid_order is not None: @@ -333,7 +380,7 @@ async def _execute_batch_order_create(self, orders_to_create: List[LimitOrder]): async def _execute_batch_inflight_order_create(self, inflight_orders_to_create: List[GatewayInFlightOrder]): try: place_order_results = await self._data_source.create_orders( - orders_to_create=inflight_orders_to_create + spot_orders=inflight_orders_to_create ) for place_order_result, in_flight_order in ( zip(place_order_results, inflight_orders_to_create) @@ -382,8 +429,17 @@ async def _start_tracking_and_validate_order( ) -> Optional[GatewayInFlightOrder]: trading_rule = self._trading_rules[trading_pair] - if order_type in [OrderType.LIMIT, OrderType.LIMIT_MAKER]: - price = self.quantize_order_price(trading_pair, price) + if price is None: + calculated_price = self.get_price_for_volume( + trading_pair=trading_pair, + is_buy=trade_type == TradeType.BUY, + volume=amount, + ).result_price + calculated_price = self.quantize_order_price(trading_pair, calculated_price) + else: + calculated_price = price + + price = self.quantize_order_price(trading_pair, calculated_price) amount = self.quantize_order_amount(trading_pair=trading_pair, amount=amount) self.start_tracking_order( @@ -431,6 +487,7 @@ def _update_order_after_creation_success( new_state=order.current_state, misc_updates=misc_updates, ) + self.logger().debug(f"\nCreated order {order.client_order_id} ({exchange_order_id}) with TX {misc_updates}") self._order_tracker.process_order_update(order_update) def _on_order_creation_failure( @@ -478,7 +535,7 @@ async def _execute_batch_cancel(self, orders_to_cancel: List[LimitOrder]) -> Lis async def _execute_batch_order_cancel(self, orders_to_cancel: List[GatewayInFlightOrder]) -> List[CancellationResult]: try: - cancel_order_results = await self._data_source.cancel_orders(orders_to_cancel=orders_to_cancel) + cancel_order_results = await self._data_source.cancel_orders(spot_orders=orders_to_cancel) cancelation_results = [] for cancel_order_result in cancel_order_results: success = True @@ -564,7 +621,7 @@ def _get_fee(self, base_currency: str, quote_currency: str, order_type: OrderTyp return fee async def _update_trading_fees(self): - self._trading_fees = await self._data_source.get_trading_fees() + self._trading_fees = await self._data_source.get_spot_trading_fees() async def _user_stream_event_listener(self): while True: @@ -624,6 +681,16 @@ async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> Lis # Not used in Injective raise NotImplementedError # pragma: no cover + async def _update_trading_rules(self): + await self._data_source.update_markets() + await self._initialize_trading_pair_symbol_map() + trading_rules_list = await self._data_source.spot_trading_rules() + trading_rules = {} + for trading_rule in trading_rules_list: + trading_rules[trading_rule.trading_pair] = trading_rule + self._trading_rules.clear() + self._trading_rules.update(trading_rules) + async def _update_balances(self): all_balances = await self._data_source.all_account_balances() @@ -781,22 +848,12 @@ def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dic async def _initialize_trading_pair_symbol_map(self): exchange_info = None try: - mapping = await self._data_source.market_and_trading_pair_map() + mapping = await self._data_source.spot_market_and_trading_pair_map() self._set_trading_pair_symbol_map(mapping) except Exception: self.logger().exception("There was an error requesting exchange info.") return exchange_info - async def _update_trading_rules(self): - await self._data_source.update_markets() - await self._initialize_trading_pair_symbol_map() - trading_rules_list = await self._data_source.all_trading_rules() - trading_rules = {} - for trading_rule in trading_rules_list: - trading_rules[trading_rule.trading_pair] = trading_rule - self._trading_rules.clear() - self._trading_rules.update(trading_rules) - def _configure_event_forwarders(self): event_forwarder = EventForwarder(to_function=self._process_user_trade_update) self._forwarders.append(event_forwarder) @@ -841,7 +898,10 @@ def _process_transaction_event(self, transaction_event: Dict[str, Any]): async def _check_orders_transactions(self): while True: try: - await self._check_orders_creation_transactions() + # Executing the process shielded from this async task to isolate it from network disconnections + # (network disconnections cancel this task) + task = asyncio.create_task(self._check_orders_creation_transactions()) + await asyncio.shield(task) await self._sleep(CONSTANTS.TRANSACTIONS_CHECK_INTERVAL) except NotImplementedError: raise @@ -864,7 +924,7 @@ async def _check_orders_creation_transactions(self): all_orders = orders.copy() try: order_updates = await self._data_source.order_updates_for_transaction( - transaction_hash=transaction_hash, transaction_orders=orders + transaction_hash=transaction_hash, spot_orders=orders ) for order_update in order_updates: @@ -903,7 +963,7 @@ async def _check_created_orders_status_for_transaction(self, transaction_hash: s if len(transaction_orders) > 0: order_updates = await self._data_source.order_updates_for_transaction( - transaction_hash=transaction_hash, transaction_orders=transaction_orders + transaction_hash=transaction_hash, spot_orders=transaction_orders ) for order_update in order_updates: @@ -917,7 +977,10 @@ async def _check_created_orders_status_for_transaction(self, transaction_hash: s async def _process_queued_orders(self): while True: try: - await self._cancel_and_create_queued_orders() + # Executing the batch cancelation and creation process shielded from this async task to isolate the + # creation/cancelation process from network disconnections (network disconnections cancel this task) + task = asyncio.create_task(self._cancel_and_create_queued_orders()) + await asyncio.shield(task) sleep_time = (self.clock.tick_size * 0.5 if self.clock is not None else self._orders_processing_delta_time) diff --git a/hummingbot/connector/exchange/injective_v2/injective_v2_utils.py b/hummingbot/connector/exchange/injective_v2/injective_v2_utils.py index 44c7b15723..4636bdfa8d 100644 --- a/hummingbot/connector/exchange/injective_v2/injective_v2_utils.py +++ b/hummingbot/connector/exchange/injective_v2/injective_v2_utils.py @@ -1,18 +1,23 @@ from abc import ABC, abstractmethod from decimal import Decimal -from typing import TYPE_CHECKING, Dict, Union +from typing import TYPE_CHECKING, Dict, List, Union from pydantic import Field, SecretStr from pydantic.class_validators import validator from pyinjective.constant import Network from hummingbot.client.config.config_data_types import BaseClientModel, BaseConnectorConfigMap, ClientFieldData +from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS from hummingbot.connector.exchange.injective_v2.data_sources.injective_grantee_data_source import ( InjectiveGranteeDataSource, ) +from hummingbot.connector.exchange.injective_v2.data_sources.injective_read_only_data_source import ( + InjectiveReadOnlyDataSource, +) from hummingbot.connector.exchange.injective_v2.data_sources.injective_vaults_data_source import ( InjectiveVaultsDataSource, ) +from hummingbot.core.api_throttler.data_types import RateLimit from hummingbot.core.data_type.trade_fee import TradeFeeSchema if TYPE_CHECKING: @@ -27,6 +32,7 @@ ) MAINNET_NODES = ["lb", "sentry0", "sentry1", "sentry3"] +TESTNET_NODES = ["lb", "sentry"] class InjectiveNetworkMode(BaseClientModel, ABC): @@ -44,7 +50,7 @@ class InjectiveMainnetNetworkMode(InjectiveNetworkMode): node: str = Field( default="lb", client_data=ClientFieldData( - prompt=lambda cm: ("Enter the mainnet node you want to connect to"), + prompt=lambda cm: (f"Enter the mainnet node you want to connect to ({'/'.join(MAINNET_NODES)})"), prompt_on_new=True ), ) @@ -64,16 +70,36 @@ def network(self) -> Network: def use_secure_connection(self) -> bool: return self.node == "lb" + def rate_limits(self) -> List[RateLimit]: + return CONSTANTS.PUBLIC_NODE_RATE_LIMITS + class InjectiveTestnetNetworkMode(InjectiveNetworkMode): + testnet_node: str = Field( + default="lb", + client_data=ClientFieldData( + prompt=lambda cm: (f"Enter the testnet node you want to connect to ({'/'.join(TESTNET_NODES)})"), + prompt_on_new=True + ), + ) + + class Config: + title = "testnet_network" + + @validator("testnet_node", pre=True) + def validate_node(cls, v: str): + if v not in TESTNET_NODES: + raise ValueError(f"{v} is not a valid node ({TESTNET_NODES})") + return v + def network(self) -> Network: - return Network.testnet() + return Network.testnet(node=self.testnet_node) def use_secure_connection(self) -> bool: return True - class Config: - title = "testnet_network" + def rate_limits(self) -> List[RateLimit]: + return CONSTANTS.PUBLIC_NODE_RATE_LIMITS class InjectiveCustomNetworkMode(InjectiveNetworkMode): @@ -151,6 +177,9 @@ def network(self) -> Network: def use_secure_connection(self) -> bool: return self.secure_connection + def rate_limits(self) -> List[RateLimit]: + return CONSTANTS.CUSTOM_NODE_RATE_LIMITS + NETWORK_MODES = { InjectiveMainnetNetworkMode.Config.title: InjectiveMainnetNetworkMode, @@ -162,7 +191,9 @@ def use_secure_connection(self) -> bool: class InjectiveAccountMode(BaseClientModel, ABC): @abstractmethod - def create_data_source(self, network: Network, use_secure_connection: bool) -> "InjectiveDataSource": + def create_data_source( + self, network: Network, use_secure_connection: bool, rate_limits: List[RateLimit], + ) -> "InjectiveDataSource": pass @@ -201,7 +232,9 @@ class InjectiveDelegatedAccountMode(InjectiveAccountMode): class Config: title = "delegate_account" - def create_data_source(self, network: Network, use_secure_connection: bool) -> "InjectiveDataSource": + def create_data_source( + self, network: Network, use_secure_connection: bool, rate_limits: List[RateLimit], + ) -> "InjectiveDataSource": return InjectiveGranteeDataSource( private_key=self.private_key.get_secret_value(), subaccount_index=self.subaccount_index, @@ -209,6 +242,7 @@ def create_data_source(self, network: Network, use_secure_connection: bool) -> " granter_subaccount_index=self.granter_subaccount_index, network=network, use_secure_connection=use_secure_connection, + rate_limits=rate_limits, ) @@ -245,7 +279,9 @@ class InjectiveVaultAccountMode(InjectiveAccountMode): class Config: title = "vault_account" - def create_data_source(self, network: Network, use_secure_connection: bool) -> "InjectiveDataSource": + def create_data_source( + self, network: Network, use_secure_connection: bool, rate_limits: List[RateLimit], + ) -> "InjectiveDataSource": return InjectiveVaultsDataSource( private_key=self.private_key.get_secret_value(), subaccount_index=self.subaccount_index, @@ -253,16 +289,34 @@ def create_data_source(self, network: Network, use_secure_connection: bool) -> " vault_subaccount_index=self.vault_subaccount_index, network=network, use_secure_connection=use_secure_connection, + rate_limits=rate_limits, + ) + + +class InjectiveReadOnlyAccountMode(InjectiveAccountMode): + + class Config: + title = "read_only_account" + + def create_data_source( + self, network: Network, use_secure_connection: bool, rate_limits: List[RateLimit], + ) -> "InjectiveDataSource": + return InjectiveReadOnlyDataSource( + network=network, + use_secure_connection=use_secure_connection, + rate_limits=rate_limits, ) ACCOUNT_MODES = { InjectiveDelegatedAccountMode.Config.title: InjectiveDelegatedAccountMode, InjectiveVaultAccountMode.Config.title: InjectiveVaultAccountMode, + InjectiveReadOnlyAccountMode.Config.title: InjectiveReadOnlyAccountMode, } class InjectiveConfigMap(BaseConnectorConfigMap): + # Setting a default dummy configuration to allow the bot to create a dummy instance to fetch all trading pairs connector: str = Field(default="injective_v2", const=True, client_data=None) receive_connector_configuration: bool = Field( default=True, const=True, @@ -276,12 +330,7 @@ class InjectiveConfigMap(BaseConnectorConfigMap): ), ) account_type: Union[tuple(ACCOUNT_MODES.values())] = Field( - default=InjectiveDelegatedAccountMode( - private_key="0000000000000000000000000000000000000000000000000000000000000000", # noqa: mock - subaccount_index=0, - granter_address="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", # noqa: mock - granter_subaccount_index=0, - ), + default=InjectiveReadOnlyAccountMode(), client_data=ClientFieldData( prompt=lambda cm: f"Select the type of account configuration ({'/'.join(list(ACCOUNT_MODES.keys()))})", prompt_on_new=True, @@ -317,7 +366,9 @@ def validate_account_type(cls, v: Union[(str, Dict) + tuple(ACCOUNT_MODES.values def create_data_source(self): return self.account_type.create_data_source( - network=self.network.network(), use_secure_connection=self.network.use_secure_connection() + network=self.network.network(), + use_secure_connection=self.network.use_secure_connection(), + rate_limits=self.network.rate_limits(), ) diff --git a/hummingbot/connector/exchange/mexc/mexc_api_order_book_data_source.py b/hummingbot/connector/exchange/mexc/mexc_api_order_book_data_source.py old mode 100644 new mode 100755 index 69411ed752..0846aa44b3 --- a/hummingbot/connector/exchange/mexc/mexc_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/mexc/mexc_api_order_book_data_source.py @@ -1,288 +1,142 @@ -#!/usr/bin/env python -import aiohttp -import aiohttp.client_ws import asyncio -import logging -import pandas as pd import time -from typing import ( - Any, - Dict, - List, - Optional, -) +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from hummingbot.connector.exchange.mexc.mexc_utils import ( - convert_from_exchange_trading_pair, - convert_to_exchange_trading_pair, - microseconds, -) +from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS, mexc_web_utils as web_utils +from hummingbot.connector.exchange.mexc.mexc_order_book import MexcOrderBook from hummingbot.core.data_type.order_book_message import OrderBookMessage -from hummingbot.core.data_type.order_book import OrderBook from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant from hummingbot.logger import HummingbotLogger -from hummingbot.connector.exchange.mexc.mexc_order_book import MexcOrderBook -from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS -from dateutil.parser import parse as dateparse -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.connector.exchange.mexc.mexc_websocket_adaptor import MexcWebSocketAdaptor -from collections import defaultdict + +if TYPE_CHECKING: + from hummingbot.connector.exchange.mexc.mexc_exchange import MexcExchange class MexcAPIOrderBookDataSource(OrderBookTrackerDataSource): - MESSAGE_TIMEOUT = 120.0 - PING_TIMEOUT = 10.0 + HEARTBEAT_TIME_INTERVAL = 30.0 + TRADE_STREAM_ID = 1 + DIFF_STREAM_ID = 2 + ONE_HOUR = 60 * 60 _logger: Optional[HummingbotLogger] = None - def __init__(self, trading_pairs: List[str], - shared_client: Optional[aiohttp.ClientSession] = None, - throttler: Optional[AsyncThrottler] = None, ): + def __init__(self, + trading_pairs: List[str], + connector: 'MexcExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN): super().__init__(trading_pairs) - self._trading_pairs: List[str] = trading_pairs - self._throttler = throttler or self._get_throttler_instance() - self._shared_client = shared_client or self._get_session_instance() - self._message_queue: Dict[str, asyncio.Queue] = defaultdict(asyncio.Queue) - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._logger is None: - cls._logger = logging.getLogger(__name__) - return cls._logger - - @classmethod - def _get_session_instance(cls) -> aiohttp.ClientSession: - session = aiohttp.ClientSession() - return session - - @classmethod - def _get_throttler_instance(cls) -> AsyncThrottler: - throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - return throttler - - @staticmethod - async def fetch_trading_pairs() -> List[str]: - async with aiohttp.ClientSession() as client: - throttler = MexcAPIOrderBookDataSource._get_throttler_instance() - async with throttler.execute_task(CONSTANTS.MEXC_SYMBOL_URL): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_SYMBOL_URL - async with client.get(url) as products_response: - - products_response: aiohttp.ClientResponse = products_response - if products_response.status != 200: - return [] - # raise IOError(f"Error fetching active MEXC. HTTP status is {products_response.status}.") - - data = await products_response.json() - data = data['data'] - - trading_pairs = [] - for item in data: - if item['state'] == "ENABLED": - trading_pairs.append(convert_from_exchange_trading_pair(item["symbol"])) - return trading_pairs - - async def get_new_order_book(self, trading_pair: str) -> OrderBook: - snapshot: Dict[str, Any] = await self.get_snapshot(self._shared_client, trading_pair) - - snapshot_msg: OrderBookMessage = MexcOrderBook.snapshot_message_from_exchange( - snapshot, - trading_pair, - timestamp=microseconds(), - metadata={"trading_pair": trading_pair}) - order_book: OrderBook = self.order_book_create_function() - order_book.apply_snapshot(snapshot_msg.bids, snapshot_msg.asks, snapshot_msg.update_id) - return order_book - - @classmethod - async def get_last_traded_prices(cls, trading_pairs: List[str], throttler: Optional[AsyncThrottler] = None, - shared_client: Optional[aiohttp.ClientSession] = None) -> Dict[str, float]: - client = shared_client or cls._get_session_instance() - throttler = throttler or cls._get_throttler_instance() - async with throttler.execute_task(CONSTANTS.MEXC_TICKERS_URL): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_TICKERS_URL - async with client.get(url) as products_response: - products_response: aiohttp.ClientResponse = products_response - if products_response.status != 200: - # raise IOError(f"Error get tickers from MEXC markets. HTTP status is {products_response.status}.") - return {} - data = await products_response.json() - data = data['data'] - all_markets: pd.DataFrame = pd.DataFrame.from_records(data=data) - all_markets.set_index("symbol", inplace=True) - - out: Dict[str, float] = {} - - for trading_pair in trading_pairs: - exchange_trading_pair = convert_to_exchange_trading_pair(trading_pair) - out[trading_pair] = float(all_markets['last'][exchange_trading_pair]) - return out - - async def get_trading_pairs(self) -> List[str]: - if not self._trading_pairs: - try: - self._trading_pairs = await self.fetch_trading_pairs() - except Exception: - self._trading_pairs = [] - self.logger().network( - "Error getting active exchange information.", - exc_info=True, - app_warning_msg="Error getting active exchange information. Check network connection." - ) - return self._trading_pairs - - @staticmethod - async def get_snapshot(client: aiohttp.ClientSession, trading_pair: str, - throttler: Optional[AsyncThrottler] = None) -> Dict[str, Any]: - throttler = throttler or MexcAPIOrderBookDataSource._get_throttler_instance() - async with throttler.execute_task(CONSTANTS.MEXC_DEPTH_URL): - trading_pair = convert_to_exchange_trading_pair(trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - async with client.get(url) as response: - response: aiohttp.ClientResponse = response - status = response.status - if status != 200: - raise IOError(f"Error fetching MEXC market snapshot for {trading_pair}. " - f"HTTP status is {status}.") - api_data = await response.json() - data = api_data['data'] - data['ts'] = microseconds() - - return data + self._connector = connector + self._trade_messages_queue_key = CONSTANTS.TRADE_EVENT_TYPE + self._diff_messages_queue_key = CONSTANTS.DIFF_EVENT_TYPE + self._domain = domain + self._api_factory = api_factory + + async def get_last_traded_prices(self, + trading_pairs: List[str], + domain: Optional[str] = None) -> Dict[str, float]: + return await self._connector.get_last_traded_prices(trading_pairs=trading_pairs) + + async def _request_order_book_snapshot(self, trading_pair: str) -> Dict[str, Any]: + """ + Retrieves a copy of the full order book from the exchange, for a particular trading pair. - @classmethod - def iso_to_timestamp(cls, date: str): - return dateparse(date).timestamp() + :param trading_pair: the trading pair for which the order book will be retrieved - async def _sleep(self, delay): - """ - Function added only to facilitate patching the sleep in unit tests without affecting the asyncio module + :return: the response from the exchange (JSON dictionary) """ - await asyncio.sleep(delay) - - async def _create_websocket_connection(self) -> MexcWebSocketAdaptor: + params = { + "symbol": await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair), + "limit": "1000" + } + + rest_assistant = await self._api_factory.get_rest_assistant() + data = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self._domain), + params=params, + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.SNAPSHOT_PATH_URL, + ) + + return data + + async def _subscribe_channels(self, ws: WSAssistant): """ - Initialize WebSocket client for UserStreamDataSource + Subscribes to the trade events and diff orders events through the provided websocket connection. + :param ws: the websocket assistant used to connect to the exchange """ try: - ws = MexcWebSocketAdaptor(throttler=self._throttler, shared_client=self._shared_client) - await ws.connect() - return ws + trade_params = [] + depth_params = [] + for trading_pair in self._trading_pairs: + symbol = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + trade_params.append(f"spot@public.deals.v3.api@{symbol}") + depth_params.append(f"spot@public.increase.depth.v3.api@{symbol}") + payload = { + "method": "SUBSCRIPTION", + "params": trade_params, + "id": 1 + } + subscribe_trade_request: WSJSONRequest = WSJSONRequest(payload=payload) + + payload = { + "method": "SUBSCRIPTION", + "params": depth_params, + "id": 2 + } + subscribe_orderbook_request: WSJSONRequest = WSJSONRequest(payload=payload) + + await ws.send(subscribe_trade_request) + await ws.send(subscribe_orderbook_request) + + self.logger().info("Subscribed to public order book and trade channels...") except asyncio.CancelledError: raise - except Exception as e: - self.logger().network(f"Unexpected error occured connecting to {CONSTANTS.EXCHANGE_NAME} WebSocket API. " - f"({e})") + except Exception: + self.logger().error( + "Unexpected error occurred subscribing to order book trading and delta streams...", + exc_info=True + ) raise - async def listen_for_subscriptions(self): - ws = None - while True: - try: - ws = await self._create_websocket_connection() - await ws.subscribe_to_order_book_streams(self._trading_pairs) - - async for msg in ws.iter_messages(): - decoded_msg: dict = msg + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + await ws.connect(ws_url=CONSTANTS.WSS_URL.format(self._domain), + ping_timeout=CONSTANTS.WS_HEARTBEAT_TIME_INTERVAL) + return ws - if 'channel' in decoded_msg.keys() and decoded_msg['channel'] in MexcWebSocketAdaptor.SUBSCRIPTION_LIST: - self._message_queue[decoded_msg['channel']].put_nowait(decoded_msg) - else: - self.logger().debug(f"Unrecognized message received from MEXC websocket: {decoded_msg}") - except asyncio.CancelledError: - raise - except Exception: - self.logger().error( - "Unexpected error occurred when listening to order book streams. Retrying in 5 seconds...", - exc_info=True, - ) - await self._sleep(5.0) - finally: - ws and await ws.disconnect() - - async def listen_for_trades(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - msg_queue = self._message_queue[MexcWebSocketAdaptor.DEAL_CHANNEL_ID] - while True: - try: - decoded_msg = await msg_queue.get() - self.logger().debug(f"Recived new trade: {decoded_msg}") - - for data in decoded_msg['data']['deals']: - trading_pair = convert_from_exchange_trading_pair(decoded_msg['symbol']) - trade_message: OrderBookMessage = MexcOrderBook.trade_message_from_exchange( - data, data['t'], metadata={"trading_pair": trading_pair} - ) - self.logger().debug(f'Putting msg in queue: {str(trade_message)}') - output.put_nowait(trade_message) - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error with WebSocket connection ,Retrying after 30 seconds...", - exc_info=True) - await self._sleep(30.0) - - async def listen_for_order_book_diffs(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - msg_queue = self._message_queue[MexcWebSocketAdaptor.DEPTH_CHANNEL_ID] - while True: - try: - decoded_msg = await msg_queue.get() - if decoded_msg['data'].get('asks'): - asks = [ - { - 'price': ask['p'], - 'quantity': ask['q'] - } - for ask in decoded_msg["data"]["asks"]] - decoded_msg['data']['asks'] = asks - if decoded_msg['data'].get('bids'): - bids = [ - { - 'price': bid['p'], - 'quantity': bid['q'] - } - for bid in decoded_msg["data"]["bids"]] - decoded_msg['data']['bids'] = bids - order_book_message: OrderBookMessage = MexcOrderBook.diff_message_from_exchange( - decoded_msg['data'], microseconds(), - metadata={"trading_pair": convert_from_exchange_trading_pair(decoded_msg['symbol'])} - ) - output.put_nowait(order_book_message) - - except asyncio.CancelledError: - raise - except Exception: - self.logger().error("Unexpected error with WebSocket connection. Retrying after 30 seconds...", - exc_info=True) - await self._sleep(30.0) - - async def listen_for_order_book_snapshots(self, ev_loop: asyncio.BaseEventLoop, output: asyncio.Queue): - while True: - try: - trading_pairs: List[str] = await self.get_trading_pairs() - session = self._shared_client - for trading_pair in trading_pairs: - try: - snapshot: Dict[str, Any] = await self.get_snapshot(session, trading_pair) - snapshot_msg: OrderBookMessage = MexcOrderBook.snapshot_message_from_exchange( - snapshot, - trading_pair, - timestamp=microseconds(), - metadata={"trading_pair": trading_pair}) - output.put_nowait(snapshot_msg) - self.logger().debug(f"Saved order book snapshot for {trading_pair}") - await self._sleep(5.0) - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error("Unexpected error." + repr(ex), exc_info=True) - await self._sleep(5.0) - this_hour: pd.Timestamp = pd.Timestamp.utcnow().replace(minute=0, second=0, microsecond=0) - next_hour: pd.Timestamp = this_hour + pd.Timedelta(hours=1) - delta: float = next_hour.timestamp() - time.time() - await self._sleep(delta) - except asyncio.CancelledError: - raise - except Exception as ex1: - self.logger().error("Unexpected error." + repr(ex1), exc_info=True) - await self._sleep(5.0) + async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: + snapshot: Dict[str, Any] = await self._request_order_book_snapshot(trading_pair) + snapshot_timestamp: float = time.time() + snapshot_msg: OrderBookMessage = MexcOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + return snapshot_msg + + async def _parse_trade_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + if "code" not in raw_message: + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=raw_message["s"]) + for sinlge_msg in raw_message['d']['deals']: + trade_message = MexcOrderBook.trade_message_from_exchange( + sinlge_msg, timestamp=raw_message['t'], metadata={"trading_pair": trading_pair}) + message_queue.put_nowait(trade_message) + + async def _parse_order_book_diff_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + if "code" not in raw_message: + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=raw_message["s"]) + order_book_message: OrderBookMessage = MexcOrderBook.diff_message_from_exchange( + raw_message, raw_message['t'], {"trading_pair": trading_pair}) + message_queue.put_nowait(order_book_message) + + def _channel_originating_message(self, event_message: Dict[str, Any]) -> str: + channel = "" + if "code" not in event_message: + event_type = event_message.get("c", "") + channel = (self._diff_messages_queue_key if CONSTANTS.DIFF_EVENT_TYPE in event_type + else self._trade_messages_queue_key) + return channel diff --git a/hummingbot/connector/exchange/mexc/mexc_api_user_stream_data_source.py b/hummingbot/connector/exchange/mexc/mexc_api_user_stream_data_source.py old mode 100644 new mode 100755 index 8dd1dde147..06fc46289c --- a/hummingbot/connector/exchange/mexc/mexc_api_user_stream_data_source.py +++ b/hummingbot/connector/exchange/mexc/mexc_api_user_stream_data_source.py @@ -1,117 +1,184 @@ -#!/usr/bin/env python import asyncio -import hashlib -import json -from urllib.parse import urlencode - -import aiohttp -import aiohttp.client_ws - -import logging - -from typing import ( - Optional, - AsyncIterable, - List, - Dict, - Any -) +import time +from typing import TYPE_CHECKING, List, Optional -from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS, mexc_web_utils as web_utils +from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant from hummingbot.logger import HummingbotLogger -from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth - -import time -from websockets.exceptions import ConnectionClosed +if TYPE_CHECKING: + from hummingbot.connector.exchange.mexc.mexc_exchange import MexcExchange class MexcAPIUserStreamDataSource(UserStreamTrackerDataSource): - _logger: Optional[HummingbotLogger] = None - MESSAGE_TIMEOUT = 300.0 - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._logger is None: - cls._logger = logging.getLogger(__name__) - - return cls._logger - - def __init__(self, throttler: AsyncThrottler, mexc_auth: MexcAuth, trading_pairs: Optional[List[str]] = [], - shared_client: Optional[aiohttp.ClientSession] = None): - self._shared_client = shared_client or self._get_session_instance() - self._last_recv_time: float = 0 - self._auth: MexcAuth = mexc_auth - self._trading_pairs = trading_pairs - self._throttler = throttler - super().__init__() + LISTEN_KEY_KEEP_ALIVE_INTERVAL = 1800 # Recommended to Ping/Update listen key to keep connection alive + HEARTBEAT_TIME_INTERVAL = 30.0 - @classmethod - def _get_session_instance(cls) -> aiohttp.ClientSession: - session = aiohttp.ClientSession() - return session - - @property - def last_recv_time(self) -> float: - return self._last_recv_time + _logger: Optional[HummingbotLogger] = None - async def _authenticate_client(self): - pass + def __init__(self, + auth: MexcAuth, + trading_pairs: List[str], + connector: 'MexcExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN): + super().__init__() + self._auth: MexcAuth = auth + self._current_listen_key = None + self._domain = domain + self._api_factory = api_factory + + self._listen_key_initialized_event: asyncio.Event = asyncio.Event() + self._last_listen_key_ping_ts = 0 + + async def _connected_websocket_assistant(self) -> WSAssistant: + """ + Creates an instance of WSAssistant connected to the exchange + """ + self._manage_listen_key_task = safe_ensure_future(self._manage_listen_key_task_loop()) + await self._listen_key_initialized_event.wait() + + ws: WSAssistant = await self._get_ws_assistant() + url = f"{CONSTANTS.WSS_URL.format(self._domain)}?listenKey={self._current_listen_key}" + await ws.connect(ws_url=url, ping_timeout=CONSTANTS.WS_HEARTBEAT_TIME_INTERVAL) + return ws + + async def _subscribe_channels(self, websocket_assistant: WSAssistant): + """ + Subscribes to order events and balance events. + + :param websocket_assistant: the websocket assistant used to connect to the exchange + """ + try: - async def listen_for_user_stream(self, output: asyncio.Queue): - while True: - session = self._shared_client - try: - ws = await session.ws_connect(CONSTANTS.MEXC_WS_URL_PUBLIC) - ws: aiohttp.client_ws.ClientWebSocketResponse = ws - try: - params: Dict[str, Any] = { - 'api_key': self._auth.api_key, - "op": "sub.personal", - 'req_time': int(time.time() * 1000), - "api_secret": self._auth.secret_key, - } - params_sign = urlencode(params) - sign_data = hashlib.md5(params_sign.encode()).hexdigest() - del params['api_secret'] - params["sign"] = sign_data - async with self._throttler.execute_task(CONSTANTS.MEXC_WS_URL_PUBLIC): - await ws.send_str(json.dumps(params)) - - async for raw_msg in self._inner_messages(ws): - self._last_recv_time = time.time() - decoded_msg: dict = raw_msg - if 'channel' in decoded_msg.keys() and decoded_msg['channel'] == 'push.personal.order': - output.put_nowait(decoded_msg) - elif 'channel' in decoded_msg.keys() and decoded_msg['channel'] == 'sub.personal': - pass - else: - self.logger().debug(f"other message received from MEXC websocket: {decoded_msg}") - except Exception as ex2: - raise ex2 - finally: - await ws.close() - - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error("Unexpected error with WebSocket connection ,Retrying after 30 seconds..." + str(ex), - exc_info=True) - await asyncio.sleep(30.0) - - async def _inner_messages(self, - ws: aiohttp.ClientWebSocketResponse) -> AsyncIterable[str]: + orders_change_payload = { + "method": "SUBSCRIPTION", + "params": [CONSTANTS.USER_ORDERS_ENDPOINT_NAME], + "id": 1 + } + subscribe_order_change_request: WSJSONRequest = WSJSONRequest(payload=orders_change_payload) + + trades_payload = { + "method": "SUBSCRIPTION", + "params": [CONSTANTS.USER_TRADES_ENDPOINT_NAME], + "id": 2 + } + subscribe_trades_request: WSJSONRequest = WSJSONRequest(payload=trades_payload) + + balance_payload = { + "method": "SUBSCRIPTION", + "params": [CONSTANTS.USER_BALANCE_ENDPOINT_NAME], + "id": 3 + } + subscribe_balance_request: WSJSONRequest = WSJSONRequest(payload=balance_payload) + + await websocket_assistant.send(subscribe_order_change_request) + await websocket_assistant.send(subscribe_trades_request) + await websocket_assistant.send(subscribe_balance_request) + + self.logger().info("Subscribed to private order changes and balance updates channels...") + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception("Unexpected error occurred subscribing to user streams...") + raise + + async def _get_listen_key(self): + rest_assistant = await self._api_factory.get_rest_assistant() + try: + data = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self._domain), + method=RESTMethod.POST, + throttler_limit_id=CONSTANTS.MEXC_USER_STREAM_PATH_URL, + headers=self._auth.header_for_authentication(), + is_auth_required=True + ) + except asyncio.CancelledError: + raise + except Exception as exception: + raise IOError(f"Error fetching user stream listen key. Error: {exception}") + + return data["listenKey"] + + async def _ping_listen_key(self) -> bool: + rest_assistant = await self._api_factory.get_rest_assistant() + try: + data = await rest_assistant.execute_request( + url=web_utils.public_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self._domain), + params={"listenKey": self._current_listen_key}, + method=RESTMethod.PUT, + return_err=True, + throttler_limit_id=CONSTANTS.MEXC_USER_STREAM_PATH_URL, + headers=self._auth.header_for_authentication() + ) + + if "code" in data: + self.logger().warning(f"Failed to refresh the listen key {self._current_listen_key}: {data}") + return False + + except asyncio.CancelledError: + raise + except Exception as exception: + self.logger().warning(f"Failed to refresh the listen key {self._current_listen_key}: {exception}") + return False + + return True + + async def _manage_listen_key_task_loop(self): try: while True: - msg = await asyncio.wait_for(ws.receive(), timeout=self.MESSAGE_TIMEOUT) - if msg.type == aiohttp.WSMsgType.CLOSED: - raise ConnectionError - yield json.loads(msg.data) - except asyncio.TimeoutError: - return - except ConnectionClosed: - return - except ConnectionError: - return + now = int(time.time()) + if self._current_listen_key is None: + self._current_listen_key = await self._get_listen_key() + self.logger().info(f"Successfully obtained listen key {self._current_listen_key}") + self._listen_key_initialized_event.set() + self._last_listen_key_ping_ts = int(time.time()) + + if now - self._last_listen_key_ping_ts >= self.LISTEN_KEY_KEEP_ALIVE_INTERVAL: + success: bool = await self._ping_listen_key() + if not success: + self.logger().error("Error occurred renewing listen key ...") + break + else: + self.logger().info(f"Refreshed listen key {self._current_listen_key}.") + self._last_listen_key_ping_ts = int(time.time()) + else: + await self._sleep(self.LISTEN_KEY_KEEP_ALIVE_INTERVAL) + finally: + self._current_listen_key = None + self._listen_key_initialized_event.clear() + + async def _get_ws_assistant(self) -> WSAssistant: + if self._ws_assistant is None: + self._ws_assistant = await self._api_factory.get_ws_assistant() + return self._ws_assistant + + async def _send_ping(self, websocket_assistant: WSAssistant): + payload = { + "method": "PING", + } + ping_request: WSJSONRequest = WSJSONRequest(payload=payload) + await websocket_assistant.send(ping_request) + + async def _on_user_stream_interruption(self, websocket_assistant: Optional[WSAssistant]): + await super()._on_user_stream_interruption(websocket_assistant=websocket_assistant) + self._manage_listen_key_task and self._manage_listen_key_task.cancel() + self._current_listen_key = None + self._listen_key_initialized_event.clear() + await self._sleep(5) + + async def _process_websocket_messages(self, websocket_assistant: WSAssistant, queue: asyncio.Queue): + while True: + try: + await asyncio.wait_for( + super()._process_websocket_messages(websocket_assistant=websocket_assistant, queue=queue), + timeout=CONSTANTS.WS_CONNECTION_TIME_INTERVAL + ) + except asyncio.TimeoutError: + ping_request = WSJSONRequest(payload={"method": "PING"}) + await websocket_assistant.send(ping_request) diff --git a/hummingbot/connector/exchange/mexc/mexc_auth.py b/hummingbot/connector/exchange/mexc/mexc_auth.py index cb1f64a3bc..f988b44695 100644 --- a/hummingbot/connector/exchange/mexc/mexc_auth.py +++ b/hummingbot/connector/exchange/mexc/mexc_auth.py @@ -1,68 +1,64 @@ -#!/usr/bin/env python - -import base64 import hashlib import hmac -from typing import ( - Any, - Dict, Optional -) +import json +from collections import OrderedDict +from typing import Any, Dict +from urllib.parse import urlencode + +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest, WSRequest + -from hummingbot.connector.exchange.mexc import mexc_utils -from urllib.parse import urlencode, unquote +class MexcAuth(AuthBase): + def __init__(self, api_key: str, secret_key: str, time_provider: TimeSynchronizer): + self.api_key = api_key + self.secret_key = secret_key + self.time_provider = time_provider + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + """ + Adds the server time and the signature to the request, required for authenticated interactions. It also adds + the required parameter in the request header. + :param request: the request to be configured for authenticated interaction + """ + if request.method == RESTMethod.POST: + request.data = self.add_auth_to_params(params=json.loads(request.data) if request.data is not None else {}) + else: + request.params = self.add_auth_to_params(params=request.params) + headers = {} + if request.headers is not None: + headers.update(request.headers) + headers.update(self.header_for_authentication()) + request.headers = headers -class MexcAuth: - def __init__(self, api_key: str, secret_key: str): - self.api_key: str = api_key - self.secret_key: str = secret_key + return request - def _sig(self, method, path, original_params=None): - params = { - 'api_key': self.api_key, - 'req_time': mexc_utils.seconds() - } - if original_params is not None: - params.update(original_params) - params_str = '&'.join('{}={}'.format(k, params[k]) for k in sorted(params)) - to_sign = '\n'.join([method, path, params_str]) - params.update({'sign': hmac.new(self.secret_key.encode(), to_sign.encode(), hashlib.sha256).hexdigest()}) - if path in ('/open/api/v2/order/cancel', '/open/api/v2/order/query'): - if 'order_ids' in params: - params.update({'order_ids': unquote(params['order_ids'])}) - if 'client_order_ids' in params: - params.update({'client_order_ids': unquote(params['client_order_ids'])}) - return params + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + """ + This method is intended to configure a websocket request to be authenticated. Mexc does not use this + functionality + """ + return request # pass-through def add_auth_to_params(self, - method: str, - path_url: str, - params: Optional[Dict[str, Any]] = {}, - is_auth_required: bool = False - ) -> Dict[str, Any]: - uppercase_method = method.upper() - params = params if params else dict() - if not is_auth_required: - params.update({'api_key': self.api_key}) - else: - params = self._sig(uppercase_method, path_url, params) - if params: - path_url = path_url + '?' + urlencode(params) - return path_url + params: Dict[str, Any]): + timestamp = int(self.time_provider.time() * 1e3) + + request_params = OrderedDict(params or {}) + request_params["timestamp"] = timestamp + + signature = self._generate_signature(params=request_params) + request_params["signature"] = signature + + return request_params - def get_signature(self, operation, timestamp) -> str: - auth = operation + timestamp + self.api_key + def header_for_authentication(self) -> Dict[str, str]: + return {"X-MEXC-APIKEY": self.api_key} - _hash = hmac.new(self.secret_key.encode(), auth.encode(), hashlib.sha256).digest() - signature = base64.b64encode(_hash).decode() - return signature + def _generate_signature(self, params: Dict[str, Any]) -> str: - def generate_ws_auth(self, operation: str): - # timestamp = str(int(time.time())) - # return { - # "op": operation, # sub key - # "api_key": self.api_key, # - # "sign": self.get_signature(operation, timestamp), # - # "req_time": timestamp # - # } - pass + encoded_params_str = urlencode(params) + digest = hmac.new(self.secret_key.encode("utf8"), encoded_params_str.encode("utf8"), hashlib.sha256).hexdigest() + return digest diff --git a/hummingbot/connector/exchange/mexc/mexc_constants.py b/hummingbot/connector/exchange/mexc/mexc_constants.py index 06e1058624..69f82f86c6 100644 --- a/hummingbot/connector/exchange/mexc/mexc_constants.py +++ b/hummingbot/connector/exchange/mexc/mexc_constants.py @@ -1,117 +1,113 @@ -from hummingbot.core.api_throttler.data_types import RateLimit, LinkedLimitWeightPair +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit +from hummingbot.core.data_type.in_flight_order import OrderState -EXCHANGE_NAME = "mexc" -# URLs +DEFAULT_DOMAIN = "com" -MEXC_BASE_URL = "https://www.mexc.com" +HBOT_ORDER_ID_PREFIX = "x-XEKWYICX" +MAX_ORDER_ID_LEN = 32 -MEXC_SYMBOL_URL = '/open/api/v2/market/symbols' -MEXC_TICKERS_URL = '/open/api/v2/market/ticker' -MEXC_DEPTH_URL = '/open/api/v2/market/depth?symbol={trading_pair}&depth=200' -MEXC_PRICE_URL = '/open/api/v2/market/ticker?symbol={trading_pair}' -MEXC_PING_URL = '/open/api/v2/common/ping' +# Base URL +REST_URL = "https://api.mexc.{}/api/" +WSS_URL = "wss://wbs.mexc.{}/ws" +PUBLIC_API_VERSION = "v3" +PRIVATE_API_VERSION = "v3" -MEXC_PLACE_ORDER = "/open/api/v2/order/place" -MEXC_ORDER_DETAILS_URL = '/open/api/v2/order/query' -MEXC_ORDER_CANCEL = '/open/api/v2/order/cancel' -MEXC_BATCH_ORDER_CANCEL = '/open/api/v2/order/cancel' -MEXC_BALANCE_URL = '/open/api/v2/account/info' -MEXC_DEAL_DETAIL = '/open/api/v2/order/deal_detail' +# Public API endpoints or MexcClient function +TICKER_PRICE_CHANGE_PATH_URL = "/ticker/24hr" +TICKER_BOOK_PATH_URL = "/ticker/bookTicker" +EXCHANGE_INFO_PATH_URL = "/exchangeInfo" +SUPPORTED_SYMBOL_PATH_URL = "/defaultSymbols" +PING_PATH_URL = "/ping" +SNAPSHOT_PATH_URL = "/depth" +SERVER_TIME_PATH_URL = "/time" -# WS -MEXC_WS_URL_PUBLIC = 'wss://wbs.mexc.com/raw/ws' +# Private API endpoints or MexcClient function +ACCOUNTS_PATH_URL = "/account" +MY_TRADES_PATH_URL = "/myTrades" +ORDER_PATH_URL = "/order" +MEXC_USER_STREAM_PATH_URL = "/userDataStream" -MINUTE = 1 -SECOND_MINUTE = 2 -HTTP_ENDPOINTS_LIMIT_ID = "AllHTTP" -HTTP_LIMIT = 20 -WS_AUTH_LIMIT_ID = "AllWsAuth" -WS_ENDPOINTS_LIMIT_ID = "AllWs" -WS_LIMIT = 20 +WS_HEARTBEAT_TIME_INTERVAL = 30 -RATE_LIMITS = [ - RateLimit( - limit_id=HTTP_ENDPOINTS_LIMIT_ID, - limit=HTTP_LIMIT, - time_interval=MINUTE - ), - # public http - RateLimit( - limit_id=MEXC_SYMBOL_URL, - limit=HTTP_LIMIT, - time_interval=SECOND_MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_TICKERS_URL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_DEPTH_URL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - # private http - RateLimit( - limit_id=MEXC_PRICE_URL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_PING_URL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_PLACE_ORDER, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_ORDER_DETAILS_URL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_ORDER_CANCEL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_BATCH_ORDER_CANCEL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_BALANCE_URL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - RateLimit( - limit_id=MEXC_DEAL_DETAIL, - limit=HTTP_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(HTTP_ENDPOINTS_LIMIT_ID)], - ), - # ws public - RateLimit(limit_id=WS_AUTH_LIMIT_ID, limit=50, time_interval=MINUTE), - RateLimit(limit_id=WS_ENDPOINTS_LIMIT_ID, limit=WS_LIMIT, time_interval=MINUTE), - RateLimit( - limit_id=MEXC_WS_URL_PUBLIC, - limit=WS_LIMIT, - time_interval=MINUTE, - linked_limits=[LinkedLimitWeightPair(WS_ENDPOINTS_LIMIT_ID)], - ), +# Mexc params + +SIDE_BUY = "BUY" +SIDE_SELL = "SELL" + +TIME_IN_FORCE_GTC = "GTC" # Good till cancelled +TIME_IN_FORCE_IOC = "IOC" # Immediate or cancel +TIME_IN_FORCE_FOK = "FOK" # Fill or kill + +# Rate Limit Type +IP_REQUEST_WEIGHT = "IP_REQUEST_WEIGHT" +UID_REQUEST_WEIGHT = "UID_REQUEST_WEIGHT" + +# Rate Limit time intervals +ONE_MINUTE = 60 +ONE_SECOND = 1 +ONE_DAY = 86400 + +MAX_REQUEST = 5000 +# Order States +ORDER_STATE = { + "PENDING": OrderState.PENDING_CREATE, + "NEW": OrderState.OPEN, + "FILLED": OrderState.FILLED, + "PARTIALLY_FILLED": OrderState.PARTIALLY_FILLED, + "PENDING_CANCEL": OrderState.OPEN, + "CANCELED": OrderState.CANCELED, + "REJECTED": OrderState.FAILED, + "EXPIRED": OrderState.FAILED, +} + +# WS Order States +WS_ORDER_STATE = { + 1: OrderState.OPEN, + 2: OrderState.FILLED, + 3: OrderState.PARTIALLY_FILLED, + 4: OrderState.CANCELED, + 5: OrderState.OPEN, +} + +# Websocket event types +DIFF_EVENT_TYPE = "increase.depth" +TRADE_EVENT_TYPE = "public.deals" + +USER_TRADES_ENDPOINT_NAME = "spot@private.deals.v3.api" +USER_ORDERS_ENDPOINT_NAME = "spot@private.orders.v3.api" +USER_BALANCE_ENDPOINT_NAME = "spot@private.account.v3.api" +WS_CONNECTION_TIME_INTERVAL = 20 +RATE_LIMITS = [ + RateLimit(limit_id=IP_REQUEST_WEIGHT, limit=20000, time_interval=ONE_MINUTE), + RateLimit(limit_id=UID_REQUEST_WEIGHT, limit=240000, time_interval=ONE_MINUTE), + # Weighted Limits + RateLimit(limit_id=TICKER_PRICE_CHANGE_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 1)]), + RateLimit(limit_id=TICKER_BOOK_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 2)]), + RateLimit(limit_id=EXCHANGE_INFO_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 10)]), + RateLimit(limit_id=SUPPORTED_SYMBOL_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 10)]), + RateLimit(limit_id=SNAPSHOT_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 50)]), + RateLimit(limit_id=MEXC_USER_STREAM_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(UID_REQUEST_WEIGHT, 1)]), + RateLimit(limit_id=SERVER_TIME_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 1)]), + RateLimit(limit_id=PING_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(IP_REQUEST_WEIGHT, 1)]), + RateLimit(limit_id=ACCOUNTS_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(UID_REQUEST_WEIGHT, 10)]), + RateLimit(limit_id=MY_TRADES_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(UID_REQUEST_WEIGHT, 10)]), + RateLimit(limit_id=ORDER_PATH_URL, limit=MAX_REQUEST, time_interval=ONE_MINUTE, + linked_limits=[LinkedLimitWeightPair(UID_REQUEST_WEIGHT, 2)]) ] + +ORDER_NOT_EXIST_ERROR_CODE = -2013 +ORDER_NOT_EXIST_MESSAGE = "Order does not exist" +UNKNOWN_ORDER_ERROR_CODE = -2011 +UNKNOWN_ORDER_MESSAGE = "Unknown order sent" diff --git a/hummingbot/connector/exchange/mexc/mexc_exchange.py b/hummingbot/connector/exchange/mexc/mexc_exchange.py old mode 100644 new mode 100755 index 42de70c2ea..3b3094627a --- a/hummingbot/connector/exchange/mexc/mexc_exchange.py +++ b/hummingbot/connector/exchange/mexc/mexc_exchange.py @@ -1,963 +1,562 @@ import asyncio -import logging from decimal import Decimal -from typing import TYPE_CHECKING, Any, AsyncIterable, Dict, List, Optional -from urllib.parse import quote, urljoin +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -import aiohttp -import ujson +from bidict import bidict -from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS +from hummingbot.connector.constants import s_decimal_NaN +from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS, mexc_utils, mexc_web_utils as web_utils from hummingbot.connector.exchange.mexc.mexc_api_order_book_data_source import MexcAPIOrderBookDataSource +from hummingbot.connector.exchange.mexc.mexc_api_user_stream_data_source import MexcAPIUserStreamDataSource from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth -from hummingbot.connector.exchange.mexc.mexc_in_flight_order import MexcInFlightOrder -from hummingbot.connector.exchange.mexc.mexc_order_book_tracker import MexcOrderBookTracker -from hummingbot.connector.exchange.mexc.mexc_user_stream_tracker import MexcUserStreamTracker -from hummingbot.connector.exchange.mexc.mexc_utils import ( - convert_from_exchange_trading_pair, - convert_to_exchange_trading_pair, - num_to_increment, - ws_order_status_convert_to_str, -) -from hummingbot.connector.exchange_base import ExchangeBase, s_decimal_NaN +from hummingbot.connector.exchange_py_base import ExchangePyBase from hummingbot.connector.trading_rule import TradingRule -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.core.clock import Clock -from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.connector.utils import TradeFillOrderDetails, combine_to_hb_trading_pair from hummingbot.core.data_type.common import OrderType, TradeType -from hummingbot.core.data_type.limit_order import LimitOrder -from hummingbot.core.data_type.order_book import OrderBook -from hummingbot.core.data_type.order_book_tracker import OrderBookTrackerDataSourceType -from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee -from hummingbot.core.event.events import ( - BuyOrderCompletedEvent, - BuyOrderCreatedEvent, - MarketEvent, - MarketOrderFailureEvent, - OrderCancelledEvent, - OrderFilledEvent, - SellOrderCompletedEvent, - SellOrderCreatedEvent, -) -from hummingbot.core.network_iterator import NetworkStatus -from hummingbot.core.utils.async_call_scheduler import AsyncCallScheduler -from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather -from hummingbot.core.utils.tracking_nonce import get_tracking_nonce -from hummingbot.logger import HummingbotLogger +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.trade_fee import DeductedFromReturnsTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.event.events import MarketEvent, OrderFilledEvent +from hummingbot.core.utils.async_utils import safe_gather +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory if TYPE_CHECKING: from hummingbot.client.config.config_helpers import ClientConfigAdapter -hm_logger = None -s_decimal_0 = Decimal(0) - - -class MexcAPIError(IOError): - def __init__(self, error_payload: Dict[str, Any]): - super().__init__(str(error_payload)) - self.error_payload = error_payload - - -class MexcExchange(ExchangeBase): - MARKET_RECEIVED_ASSET_EVENT_TAG = MarketEvent.ReceivedAsset - MARKET_BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted - MARKET_SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted - MARKET_WITHDRAW_ASSET_EVENT_TAG = MarketEvent.WithdrawAsset - MARKET_ORDER_CANCELED_EVENT_TAG = MarketEvent.OrderCancelled - MARKET_TRANSACTION_FAILURE_EVENT_TAG = MarketEvent.TransactionFailure - MARKET_ORDER_FAILURE_EVENT_TAG = MarketEvent.OrderFailure - MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled - MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated - MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated - API_CALL_TIMEOUT = 10.0 - UPDATE_ORDERS_INTERVAL = 10.0 - SHORT_POLL_INTERVAL = 5.0 - MORE_SHORT_POLL_INTERVAL = 1.0 - LONG_POLL_INTERVAL = 120.0 - ORDER_LEN_LIMIT = 20 - - _logger = None - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._logger is None: - cls._logger = logging.getLogger(__name__) - return cls._logger + +class MexcExchange(ExchangePyBase): + UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 + + web_utils = web_utils def __init__(self, client_config_map: "ClientConfigAdapter", mexc_api_key: str, - mexc_secret_key: str, - poll_interval: float = 5.0, - order_book_tracker_data_source_type: OrderBookTrackerDataSourceType = OrderBookTrackerDataSourceType.EXCHANGE_API, + mexc_api_secret: str, trading_pairs: Optional[List[str]] = None, - trading_required: bool = True): - - super().__init__(client_config_map=client_config_map) - self._throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - self._shared_client = aiohttp.ClientSession() - self._async_scheduler = AsyncCallScheduler(call_interval=0.5) - self._data_source_type = order_book_tracker_data_source_type - self._ev_loop = asyncio.get_event_loop() - self._mexc_auth = MexcAuth(api_key=mexc_api_key, secret_key=mexc_secret_key) - self._in_flight_orders = {} - self._last_poll_timestamp = 0 - self._last_timestamp = 0 - self._set_order_book_tracker(MexcOrderBookTracker( - throttler=self._throttler, trading_pairs=trading_pairs, shared_client=self._shared_client)) - self._poll_notifier = asyncio.Event() - self._poll_interval = poll_interval - self._status_polling_task = None + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + self.api_key = mexc_api_key + self.secret_key = mexc_api_secret + self._domain = domain self._trading_required = trading_required - self._trading_rules = {} - self._trading_rules_polling_task = None - self._user_stream_tracker = MexcUserStreamTracker(throttler=self._throttler, - mexc_auth=self._mexc_auth, - trading_pairs=trading_pairs, - shared_client=self._shared_client) - self._user_stream_tracker_task = None - self._user_stream_event_listener_task = None + self._trading_pairs = trading_pairs + self._last_trades_poll_mexc_timestamp = 1.0 + super().__init__(client_config_map) - @property - def name(self) -> str: - return "mexc" + @staticmethod + def mexc_order_type(order_type: OrderType) -> str: + return order_type.name.upper() - @property - def order_books(self) -> Dict[str, OrderBook]: - return self.order_book_tracker.order_books + @staticmethod + def to_hb_order_type(mexc_type: str) -> OrderType: + return OrderType[mexc_type] @property - def trading_rules(self) -> Dict[str, TradingRule]: - return self._trading_rules + def authenticator(self): + return MexcAuth( + api_key=self.api_key, + secret_key=self.secret_key, + time_provider=self._time_synchronizer) @property - def in_flight_orders(self) -> Dict[str, MexcInFlightOrder]: - return self._in_flight_orders + def name(self) -> str: + if self._domain == "com": + return "mexc" + else: + return f"mexc_{self._domain}" @property - def limit_orders(self) -> List[LimitOrder]: - return [ - in_flight_order.to_limit_order() - for in_flight_order in self._in_flight_orders.values() - ] + def rate_limits_rules(self): + return CONSTANTS.RATE_LIMITS @property - def tracking_states(self) -> Dict[str, Any]: - return { - client_oid: order.to_json() - for client_oid, order in self._in_flight_orders.items() - if not order.is_done - } - - def restore_tracking_states(self, saved_states: Dict[str, Any]): - self._in_flight_orders.update({ - key: MexcInFlightOrder.from_json(value) - for key, value in saved_states.items() - }) + def domain(self): + return self._domain @property - def shared_client(self) -> aiohttp.ClientSession: - return self._shared_client + def client_order_id_max_length(self): + return CONSTANTS.MAX_ORDER_ID_LEN @property - def user_stream_tracker(self) -> MexcUserStreamTracker: - return self._user_stream_tracker - - @shared_client.setter - def shared_client(self, client: aiohttp.ClientSession): - self._shared_client = client - - def start(self, clock: Clock, timestamp: float): - """ - This function is called automatically by the clock. - """ - super().start(clock, timestamp) + def client_order_id_prefix(self): + return CONSTANTS.HBOT_ORDER_ID_PREFIX - def stop(self, clock: Clock): - """ - This function is called automatically by the clock. - """ - super().stop(clock) - - async def start_network(self): - """ - This function is required by NetworkIterator base class and is called automatically. - It starts tracking order book, polling trading rules, - updating statuses and tracking user data. - """ - await self.stop_network() - self.order_book_tracker.start() - self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) - - if self._trading_required: - self._status_polling_task = safe_ensure_future(self._status_polling_loop()) - self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) - self._user_stream_event_listener_task = safe_ensure_future(self._user_stream_event_listener()) - await self._update_balances() - - async def stop_network(self): - self.order_book_tracker.stop() - if self._status_polling_task is not None: - self._status_polling_task.cancel() - self._status_polling_task = None - if self._trading_rules_polling_task is not None: - self._trading_rules_polling_task.cancel() - self._trading_rules_polling_task = None - if self._user_stream_tracker_task is not None: - self._user_stream_tracker_task.cancel() - self._user_stream_tracker_task = None - if self._user_stream_event_listener_task is not None: - self._user_stream_event_listener_task.cancel() - self._user_stream_event_listener_task = None - - async def check_network(self) -> NetworkStatus: - try: - resp = await self._api_request(method="GET", path_url=CONSTANTS.MEXC_PING_URL) - if 'code' not in resp or resp['code'] != 200: - raise Exception() - except asyncio.CancelledError: - raise - except Exception: - return NetworkStatus.NOT_CONNECTED - return NetworkStatus.CONNECTED - - def tick(self, timestamp: float): - """ - Is called automatically by the clock for each clock's tick (1 second by default). - It checks if status polling task is due for execution. - """ - # now = time.time() - poll_interval = self.MORE_SHORT_POLL_INTERVAL - last_tick = int(self._last_timestamp / poll_interval) - current_tick = int(timestamp / poll_interval) - if current_tick > last_tick: - if not self._poll_notifier.is_set(): - self._poll_notifier.set() - self._last_timestamp = timestamp - - async def _http_client(self) -> aiohttp.ClientSession: - if self._shared_client is None: - self._shared_client = aiohttp.ClientSession() - return self._shared_client - - async def _api_request(self, - method: str, - path_url: str, - params: Optional[Dict[str, Any]] = {}, - data={}, - is_auth_required: bool = False, - limit_id: Optional[str] = None) -> Dict[str, Any]: - - headers = {"Content-Type": "application/json"} - if path_url in CONSTANTS.MEXC_PLACE_ORDER: - headers.update({'source': 'HUMBOT'}) - client = await self._http_client() - text_data = ujson.dumps(data) if data else None - limit_id = limit_id or path_url - path_url = self._mexc_auth.add_auth_to_params(method, path_url, params, is_auth_required) - url = urljoin(CONSTANTS.MEXC_BASE_URL, path_url) - async with self._throttler.execute_task(limit_id): - response_core = await client.request( - method=method.upper(), - url=url, - headers=headers, - # params=params if params else None, #mexc`s params is already in the url - data=text_data, - ) - - # async with response_core as response: - if response_core.status != 200: - raise IOError(f"Error request from {url}. Response: {await response_core.json()}.") - try: - parsed_response = await response_core.json() - return parsed_response - except Exception as ex: - raise IOError(f"Error parsing data from {url}." + repr(ex)) - - async def _update_balances(self): - path_url = CONSTANTS.MEXC_BALANCE_URL - msg = await self._api_request("GET", path_url=path_url, is_auth_required=True) - if msg['code'] == 200: - balances = msg['data'] - else: - raise Exception(msg) - self.logger().info(f" _update_balances error: {msg} ") - return - - self._account_available_balances.clear() - self._account_balances.clear() - for k, balance in balances.items(): - # if Decimal(balance['frozen']) + Decimal(balance['available']) > Decimal(0.0001): - self._account_balances[k] = Decimal(balance['frozen']) + Decimal(balance['available']) - self._account_available_balances[k] = Decimal(balance['available']) - - async def _update_trading_rules(self): - try: - last_tick = int(self._last_timestamp / 60.0) - current_tick = int(self.current_timestamp / 60.0) - if current_tick > last_tick or len(self._trading_rules) < 1: - exchange_info = await self._api_request("GET", path_url=CONSTANTS.MEXC_SYMBOL_URL) - trading_rules_list = self._format_trading_rules(exchange_info['data']) - self._trading_rules.clear() - for trading_rule in trading_rules_list: - self._trading_rules[trading_rule.trading_pair] = trading_rule - except Exception as ex: - self.logger().error("Error _update_trading_rules:" + str(ex), exc_info=True) - - def _format_trading_rules(self, raw_trading_pair_info: List[Dict[str, Any]]) -> List[TradingRule]: - trading_rules = [] - for info in raw_trading_pair_info: - try: - trading_rules.append( - TradingRule(trading_pair=convert_from_exchange_trading_pair(info['symbol']), - # min_order_size=Decimal(info["min_amount"]), - # max_order_size=Decimal(info["max_amount"]), - min_price_increment=Decimal(num_to_increment(info["price_scale"])), - min_base_amount_increment=Decimal(num_to_increment(info["quantity_scale"])), - # min_quote_amount_increment=Decimal(info["1e-{info['value-precision']}"]), - # min_notional_size=Decimal(info["min-order-value"]) - min_notional_size=Decimal(info["min_amount"]), - # max_notional_size=Decimal(info["max_amount"]), - - ) - ) - except Exception: - self.logger().error(f"Error parsing the trading pair rule {info}. Skipping.", exc_info=True) - return trading_rules - - async def get_order_status(self, exchangge_order_id: str, trading_pair: str) -> Dict[str, Any]: - params = {"order_ids": exchangge_order_id} - msg = await self._api_request("GET", - path_url=CONSTANTS.MEXC_ORDER_DETAILS_URL, - params=params, - is_auth_required=True) - - if msg["code"] == 200: - return msg['data'][0] - - async def _update_order_status(self): - last_tick = int(self._last_poll_timestamp / self.UPDATE_ORDERS_INTERVAL) - current_tick = int(self.current_timestamp / self.UPDATE_ORDERS_INTERVAL) - if current_tick > last_tick and len(self._in_flight_orders) > 0: - tracked_orders = list(self._in_flight_orders.values()) - for tracked_order in tracked_orders: - try: - exchange_order_id = await tracked_order.get_exchange_order_id() - try: - order_update = await self.get_order_status(exchange_order_id, tracked_order.trading_pair) - except MexcAPIError as ex: - err_code = ex.error_payload.get("error").get('err-code') - self.stop_tracking_order(tracked_order.client_order_id) - self.logger().info(f"The limit order {tracked_order.client_order_id} " - f"has failed according to order status API. - {err_code}") - self.trigger_event( - self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent( - self.current_timestamp, - tracked_order.client_order_id, - tracked_order.order_type - ) - ) - continue - - if order_update is None: - self.logger().network( - f"Error fetching status update for the order {tracked_order.client_order_id}: " - f"{exchange_order_id}.", - app_warning_msg=f"Could not fetch updates for the order {tracked_order.client_order_id}. " - f"The order has either been filled or canceled." - ) - continue - tracked_order.last_state = order_update['state'] - order_status = order_update['state'] - new_confirmed_amount = Decimal(order_update['deal_quantity']) - execute_amount_diff = new_confirmed_amount - tracked_order.executed_amount_base - - if execute_amount_diff > s_decimal_0: - execute_price = Decimal( - Decimal(order_update['deal_amount']) / Decimal(order_update['deal_quantity'])) - tracked_order.executed_amount_base = Decimal(order_update['deal_quantity']) - tracked_order.executed_amount_quote = Decimal(order_update['deal_amount']) - - order_filled_event = OrderFilledEvent( - self.current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - execute_price, - execute_amount_diff, - self.get_fee( - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.order_type, - tracked_order.trade_type, - execute_amount_diff, - execute_price, - ), - exchange_trade_id=str(int(self._time() * 1e6)) - ) - self.logger().info(f"Filled {execute_amount_diff} out of {tracked_order.amount} of the " - f"order {tracked_order.client_order_id}.") - self.trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, order_filled_event) - if order_status == "FILLED": - fee_paid, fee_currency = await self.get_deal_detail_fee(tracked_order.exchange_order_id) - tracked_order.fee_paid = fee_paid - tracked_order.fee_asset = fee_currency - tracked_order.last_state = order_status - self.stop_tracking_order(tracked_order.client_order_id) - if tracked_order.trade_type is TradeType.BUY: - self.logger().info( - f"The BUY {tracked_order.order_type} order {tracked_order.client_order_id} has completed " - f"according to order delta restful API.") - self.trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self.current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.order_type)) - elif tracked_order.trade_type is TradeType.SELL: - self.logger().info( - f"The SELL {tracked_order.order_type} order {tracked_order.client_order_id} has completed " - f"according to order delta restful API.") - self.trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self.current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.order_type)) - continue - if order_status == "CANCELED" or order_status == "PARTIALLY_CANCELED": - tracked_order.last_state = order_status - self.stop_tracking_order(tracked_order.client_order_id) - self.logger().info(f"Order {tracked_order.client_order_id} has been canceled " - f"according to order delta restful API.") - self.trigger_event(self.MARKET_ORDER_CANCELED_EVENT_TAG, - OrderCancelledEvent(self.current_timestamp, - tracked_order.client_order_id)) - except Exception as ex: - self.logger().error("_update_order_status error ..." + repr(ex), exc_info=True) - - def _reset_poll_notifier(self): - self._poll_notifier = asyncio.Event() - - async def _status_polling_loop(self): - while True: - try: - self._reset_poll_notifier() - await self._poll_notifier.wait() - await safe_gather( - self._update_balances(), - self._update_order_status(), - ) - self._last_poll_timestamp = self.current_timestamp - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().network("Unexpected error while fetching account updates." + repr(ex), - exc_info=True, - app_warning_msg="Could not fetch account updates from MEXC. " - "Check API key and network connection.") - await asyncio.sleep(0.5) - - async def _trading_rules_polling_loop(self): - while True: - try: - await self._update_trading_rules() - await asyncio.sleep(60) - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().network("Unexpected error while fetching trading rules." + repr(ex), - exc_info=True, - app_warning_msg="Could not fetch new trading rules from MEXC. " - "Check network connection.") - await asyncio.sleep(0.5) - - async def _iter_user_event_queue(self) -> AsyncIterable[Dict[str, Any]]: - while True: - try: - yield await self._user_stream_tracker.user_stream.get() - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().error(f"Unknown error. Retrying after 1 second. {ex}", exc_info=True) - await asyncio.sleep(1.0) + @property + def trading_rules_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL - async def _user_stream_event_listener(self): - async for stream_message in self._iter_user_event_queue(): - # self.logger().info(f"stream_message:{stream_message}") - try: - if 'channel' in stream_message.keys() and stream_message['channel'] == 'push.personal.account': - continue - elif 'channel' in stream_message.keys() and stream_message['channel'] == 'push.personal.order': - await self._process_order_message(stream_message) - else: - self.logger().debug(f"Unknown event received from the connector ({stream_message})") - except asyncio.CancelledError: - raise - except Exception as e: - self.logger().error(f"Unexpected error in user stream listener lopp. {e}", exc_info=True) - await asyncio.sleep(5.0) - - async def _process_order_message(self, stream_message: Dict[str, Any]): - client_order_id = stream_message["data"]["clientOrderId"] - # trading_pair = convert_from_exchange_trading_pair(stream_message["symbol"]) - # 1:NEW,2:FILLED,3:PARTIALLY_FILLED,4:CANCELED,5:PARTIALLY_CANCELED - order_status = ws_order_status_convert_to_str(stream_message["data"]["status"]) - tracked_order = self._in_flight_orders.get(client_order_id, None) - if tracked_order is None: - return - # Update balance in time - await self._update_balances() - - if order_status in {"FILLED", "PARTIALLY_FILLED"}: - executed_amount = Decimal(str(stream_message["data"]['quantity'])) - Decimal( - str(stream_message["data"]['remainQuantity'])) - execute_price = Decimal(str(stream_message["data"]['price'])) - execute_amount_diff = executed_amount - tracked_order.executed_amount_base - if execute_amount_diff > s_decimal_0: - tracked_order.executed_amount_base = executed_amount - tracked_order.executed_amount_quote = Decimal( - str(stream_message["data"]['amount'])) - Decimal( - str(stream_message["data"]['remainAmount'])) - - current_fee = self.get_fee(tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.order_type, - tracked_order.trade_type, - execute_amount_diff, - execute_price) - self.logger().info(f"Filled {execute_amount_diff} out of {tracked_order.amount} of ") - self.trigger_event(self.MARKET_ORDER_FILLED_EVENT_TAG, - OrderFilledEvent(self.current_timestamp, - tracked_order.client_order_id, - tracked_order.trading_pair, - tracked_order.trade_type, - tracked_order.order_type, - execute_price, - execute_amount_diff, - current_fee, - exchange_trade_id=str(int(self._time() * 1e6)))) - if order_status == "FILLED": - fee_paid, fee_currency = await self.get_deal_detail_fee(tracked_order.exchange_order_id) - tracked_order.fee_paid = fee_paid - tracked_order.fee_asset = fee_currency - tracked_order.last_state = order_status - if tracked_order.trade_type is TradeType.BUY: - self.logger().info( - f"The BUY {tracked_order.order_type} order {tracked_order.client_order_id} has completed " - f"according to order delta websocket API.") - self.trigger_event(self.MARKET_BUY_ORDER_COMPLETED_EVENT_TAG, - BuyOrderCompletedEvent(self.current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.order_type)) - elif tracked_order.trade_type is TradeType.SELL: - self.logger().info( - f"The SELL {tracked_order.order_type} order {tracked_order.client_order_id} has completed " - f"according to order delta websocket API.") - self.trigger_event(self.MARKET_SELL_ORDER_COMPLETED_EVENT_TAG, - SellOrderCompletedEvent(self.current_timestamp, - tracked_order.client_order_id, - tracked_order.base_asset, - tracked_order.quote_asset, - tracked_order.executed_amount_base, - tracked_order.executed_amount_quote, - tracked_order.order_type)) - self.stop_tracking_order(tracked_order.client_order_id) - return + @property + def trading_pairs_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL - if order_status == "CANCELED" or order_status == "PARTIALLY_CANCELED": - tracked_order.last_state = order_status - self.logger().info(f"Order {tracked_order.client_order_id} has been canceled " - f"according to order delta websocket API.") - self.trigger_event(self.MARKET_ORDER_CANCELED_EVENT_TAG, - OrderCancelledEvent(self.current_timestamp, - tracked_order.client_order_id)) - self.stop_tracking_order(tracked_order.client_order_id) + @property + def check_network_request_path(self): + return CONSTANTS.PING_PATH_URL @property - def status_dict(self) -> Dict[str, bool]: - return { - "order_books_initialized": self.order_book_tracker.ready, - "acount_balance": len(self._account_balances) > 0 if self._trading_required else True, - "trading_rule_initialized": len(self._trading_rules) > 0 - } + def trading_pairs(self): + return self._trading_pairs - def supported_order_types(self): - return [OrderType.LIMIT, OrderType.MARKET] + @property + def is_cancel_request_in_exchange_synchronous(self) -> bool: + return True @property - def ready(self) -> bool: - return all(self.status_dict.values()) - - async def place_order(self, - order_id: str, - trading_pair: str, - amount: Decimal, - is_buy: bool, - order_type: OrderType, - price: Decimal) -> str: - - if order_type is OrderType.LIMIT: - order_type_str = "LIMIT_ORDER" - elif order_type is OrderType.LIMIT_MAKER: - order_type_str = "POST_ONLY" - - data = { - 'client_order_id': order_id, - 'order_type': order_type_str, - 'trade_type': "BID" if is_buy else "ASK", - 'symbol': convert_to_exchange_trading_pair(trading_pair), - 'quantity': format(Decimal(str(amount)), "f"), - 'price': format(Decimal(str(price)), "f") - } + def is_trading_required(self) -> bool: + return self._trading_required - exchange_order_id = await self._api_request( - "POST", - path_url=CONSTANTS.MEXC_PLACE_ORDER, - params={}, - data=data, - is_auth_required=True + def supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + async def get_all_pairs_prices(self) -> List[Dict[str, str]]: + pairs_prices = await self._api_get(path_url=CONSTANTS.TICKER_BOOK_PATH_URL) + return pairs_prices + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception): + error_description = str(request_exception) + is_time_synchronizer_related = ("-1021" in error_description + and "Timestamp for this request" in error_description) + return is_time_synchronizer_related + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + return str(CONSTANTS.ORDER_NOT_EXIST_ERROR_CODE) in str( + status_update_exception + ) and CONSTANTS.ORDER_NOT_EXIST_MESSAGE in str(status_update_exception) + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + return str(CONSTANTS.UNKNOWN_ORDER_ERROR_CODE) in str( + cancelation_exception + ) and CONSTANTS.UNKNOWN_ORDER_MESSAGE in str(cancelation_exception) + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return web_utils.build_api_factory( + throttler=self._throttler, + time_synchronizer=self._time_synchronizer, + domain=self._domain, + auth=self._auth) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return MexcAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + connector=self, + domain=self.domain, + api_factory=self._web_assistants_factory) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + return MexcAPIUserStreamDataSource( + auth=self._auth, + trading_pairs=self._trading_pairs, + connector=self, + api_factory=self._web_assistants_factory, + domain=self.domain, ) - return str(exchange_order_id.get('data')) - - async def execute_buy(self, - order_id: str, - trading_pair: str, - amount: Decimal, - order_type: OrderType, - price: Optional[Decimal] = s_decimal_0): - - trading_rule = self._trading_rules[trading_pair] - - if not order_type.is_limit_type(): - self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) - raise Exception(f"Unsupported order type: {order_type}") + def _get_fee(self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN, + is_maker: Optional[bool] = None) -> TradeFeeBase: + is_maker = order_type is OrderType.LIMIT_MAKER + return DeductedFromReturnsTradeFee(percent=self.estimate_fee_pct(is_maker)) - decimal_price = self.quantize_order_price(trading_pair, price) - decimal_amount = self.quantize_order_amount(trading_pair, amount, decimal_price) - if decimal_price * decimal_amount < trading_rule.min_notional_size: - self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) - raise ValueError(f"Buy order amount {decimal_amount} is lower than the notional size ") - try: - exchange_order_id = await self.place_order(order_id, trading_pair, decimal_amount, True, order_type, - decimal_price) - self.start_tracking_order( - order_id=order_id, - exchange_order_id=exchange_order_id, - trading_pair=trading_pair, - order_type=order_type, - trade_type=TradeType.BUY, - price=decimal_price, - amount=decimal_amount - ) - tracked_order = self._in_flight_orders.get(order_id) - if tracked_order is not None: - self.logger().info( - f"Created {order_type.name.upper()} buy order {order_id} for {decimal_amount} {trading_pair}.") - self.trigger_event(self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, - BuyOrderCreatedEvent( - self.current_timestamp, - order_type, - trading_pair, - decimal_amount, - decimal_price, - order_id, - tracked_order.creation_timestamp - )) - except asyncio.CancelledError: - raise - except Exception as ex: - self.stop_tracking_order(order_id) - order_type_str = order_type.name.lower() - - self.logger().network( - f"Error submitting buy {order_type_str} order to Mexc for " - f"{decimal_amount} {trading_pair} " - f"{decimal_price if order_type is OrderType.LIMIT else ''}." - f"{decimal_price}." + repr(ex), - exc_info=True, - app_warning_msg="Failed to submit buy order to Mexc. Check API key and network connection." - ) - self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) - - def buy(self, trading_pair: str, amount: Decimal, order_type=OrderType.MARKET, - price: Decimal = s_decimal_NaN, **kwargs) -> str: - tracking_nonce = int(get_tracking_nonce()) - order_id = self._shorten_trading_pair("buy", trading_pair, tracking_nonce) - safe_ensure_future(self.execute_buy(order_id, trading_pair, amount, order_type, price)) - return order_id - - def _shorten_trading_pair(self, prefix, trading_pair, tracking_nonce, max_length=32): - max_nonce_length = max_length - len(prefix) - len(trading_pair) - 2 - tracking_nonce = str(tracking_nonce)[-max_nonce_length:] - return f"{prefix}-{trading_pair}-{tracking_nonce}" - - async def execute_sell(self, + async def _place_order(self, order_id: str, trading_pair: str, amount: Decimal, + trade_type: TradeType, order_type: OrderType, - price: Optional[Decimal] = s_decimal_0): - trading_rule = self._trading_rules[trading_pair] - - if not order_type.is_limit_type(): - self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) - raise Exception(f"Unsupported order type: {order_type}") - - decimal_price = self.quantize_order_price(trading_pair, price) - decimal_amount = self.quantize_order_amount(trading_pair, amount, decimal_price) - - if decimal_price * decimal_amount < trading_rule.min_notional_size: - self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) - raise ValueError(f"Sell order amount {decimal_amount} is lower than the notional size ") - - try: - exchange_order_id = await self.place_order(order_id, trading_pair, decimal_amount, False, order_type, - decimal_price) - self.start_tracking_order( - order_id=order_id, - exchange_order_id=exchange_order_id, - trading_pair=trading_pair, - order_type=order_type, - trade_type=TradeType.SELL, - price=decimal_price, - amount=decimal_amount - ) - tracked_order = self._in_flight_orders.get(order_id) - if tracked_order is not None: - self.logger().info( - f"Created {order_type.name.upper()} sell order {order_id} for {decimal_amount} {trading_pair}.") - self.trigger_event(self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, - SellOrderCreatedEvent( - self.current_timestamp, - order_type, - trading_pair, - decimal_amount, - decimal_price, - order_id, - tracked_order.creation_timestamp - )) - except asyncio.CancelledError: - raise - except Exception as ex: - self.stop_tracking_order(order_id) - order_type_str = order_type.name.lower() - self.logger().network( - f"Error submitting sell {order_type_str} order to Mexc for " - f"{decimal_amount} {trading_pair} " - f"{decimal_price if order_type is OrderType.LIMIT else ''}." - f"{decimal_price}." + ",ex:" + repr(ex), - exc_info=True, - app_warning_msg="Failed to submit sell order to Mexc. Check API key and network connection." - ) - self.trigger_event(self.MARKET_ORDER_FAILURE_EVENT_TAG, - MarketOrderFailureEvent(self.current_timestamp, order_id, order_type)) - - def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType = OrderType.MARKET, - price: Decimal = s_decimal_NaN, **kwargs) -> str: - - tracking_nonce = int(get_tracking_nonce()) - order_id = self._shorten_trading_pair("sell", trading_pair, tracking_nonce) - - safe_ensure_future(self.execute_sell(order_id, trading_pair, amount, order_type, price)) - return order_id - - async def execute_cancel(self, trading_pair: str, client_order_id: str): - try: - tracked_order = self._in_flight_orders.get(client_order_id) - if tracked_order is None: - # raise ValueError(f"Failed to cancel order - {client_order_id}. Order not found.") - self.logger().network(f"Failed to cancel order - {client_order_id}. Order not found.") - return - params = { - "client_order_ids": client_order_id, - } - response = await self._api_request("DELETE", path_url=CONSTANTS.MEXC_ORDER_CANCEL, params=params, - is_auth_required=True) - - if not response['code'] == 200: - raise MexcAPIError("Order could not be canceled") - - except MexcAPIError as ex: - self.logger().network( - f"Failed to cancel order {client_order_id} : {repr(ex)}", - exc_info=True, - app_warning_msg=f"Failed to cancel the order {client_order_id} on Mexc. " - f"Check API key and network connection." - ) - - def cancel(self, trading_pair: str, order_id: str): - safe_ensure_future(self.execute_cancel(trading_pair, order_id)) - return order_id - - async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: - orders_by_trading_pair = {} - - for order in self._in_flight_orders.values(): - orders_by_trading_pair[order.trading_pair] = orders_by_trading_pair.get(order.trading_pair, []) - orders_by_trading_pair[order.trading_pair].append(order) - - if len(orders_by_trading_pair) == 0: - return [] - - for trading_pair in orders_by_trading_pair: - cancel_order_ids = [o.exchange_order_id for o in orders_by_trading_pair[trading_pair]] - is_need_loop = True - while is_need_loop: - if len(cancel_order_ids) > self.ORDER_LEN_LIMIT: - is_need_loop = True - this_turn_cancel_order_ids = cancel_order_ids[:self.ORDER_LEN_LIMIT] - cancel_order_ids = cancel_order_ids[self.ORDER_LEN_LIMIT:] - else: - this_turn_cancel_order_ids = cancel_order_ids - is_need_loop = False - self.logger().debug( - f"cancel_order_ids {this_turn_cancel_order_ids} orders_by_trading_pair[trading_pair]") - params = { - 'order_ids': quote(','.join([o for o in this_turn_cancel_order_ids])), - } - - cancellation_results = [] - try: - cancel_all_results = await self._api_request( - "DELETE", - path_url=CONSTANTS.MEXC_ORDER_CANCEL, - params=params, - is_auth_required=True + price: Decimal, + **kwargs) -> Tuple[str, float]: + order_result = None + amount_str = f"{amount:f}" + type_str = MexcExchange.mexc_order_type(order_type) + side_str = CONSTANTS.SIDE_BUY if trade_type is TradeType.BUY else CONSTANTS.SIDE_SELL + symbol = await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + api_params = {"symbol": symbol, + "side": side_str, + "quantity": amount_str, + # "quoteOrderQty": amount_str, + "type": type_str, + "newClientOrderId": order_id} + if order_type.is_limit_type(): + price_str = f"{price:f}" + api_params["price"] = price_str + else: + if trade_type.name.lower() == 'buy': + if price.is_nan(): + price = self.get_price_for_volume( + trading_pair, + True, + amount ) + del api_params['quantity'] + api_params.update({ + "quoteOrderQty": f"{price * amount:f}", + }) + if order_type == OrderType.LIMIT: + api_params["timeInForce"] = CONSTANTS.TIME_IN_FORCE_GTC - for order_result_client_order_id, order_result_value in cancel_all_results['data'].items(): - for o in orders_by_trading_pair[trading_pair]: - if o.client_order_id == order_result_client_order_id: - result_bool = True if order_result_value == "invalid order state" or order_result_value == "success" else False - cancellation_results.append(CancellationResult(o.client_order_id, result_bool)) - if result_bool: - self.trigger_event(self.MARKET_ORDER_CANCELED_EVENT_TAG, - OrderCancelledEvent(self.current_timestamp, - order_id=o.client_order_id, - exchange_order_id=o.exchange_order_id)) - self.stop_tracking_order(o.client_order_id) + try: + order_result = await self._api_post( + path_url=CONSTANTS.ORDER_PATH_URL, + data=api_params, + is_auth_required=True) + o_id = str(order_result["orderId"]) + transact_time = order_result["transactTime"] * 1e-3 + except IOError as e: + error_description = str(e) + is_server_overloaded = ("status is 503" in error_description + and "Unknown error, please check your request or try again later." in error_description) + if is_server_overloaded: + o_id = "UNKNOWN" + transact_time = self._time_synchronizer.time() + else: + raise + return o_id, transact_time - except Exception as ex: + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder): + symbol = await self.exchange_symbol_associated_to_pair(trading_pair=tracked_order.trading_pair) + api_params = { + "symbol": symbol, + "origClientOrderId": order_id, + } + cancel_result = await self._api_delete( + path_url=CONSTANTS.ORDER_PATH_URL, + params=api_params, + is_auth_required=True) + if cancel_result.get("status") == "NEW": + return True + return False + + async def _format_trading_rules(self, exchange_info_dict: Dict[str, Any]) -> List[TradingRule]: + trading_pair_rules = exchange_info_dict.get("symbols", []) + retval = [] + for rule in filter(mexc_utils.is_exchange_information_valid, trading_pair_rules): + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol=rule.get("symbol")) + min_order_size = Decimal(rule.get("baseSizePrecision")) + min_price_inc = Decimal(f"1e-{rule['quotePrecision']}") + min_amount_inc = Decimal(f"1e-{rule['baseAssetPrecision']}") + min_notional = Decimal(rule['quoteAmountPrecision']) + retval.append( + TradingRule(trading_pair, + min_order_size=min_order_size, + min_price_increment=min_price_inc, + min_base_amount_increment=min_amount_inc, + min_notional_size=min_notional)) - self.logger().network( - f"Failed to cancel all orders: {this_turn_cancel_order_ids}" + repr(ex), - exc_info=True, - app_warning_msg="Failed to cancel all orders on Mexc. Check API key and network connection." - ) - return cancellation_results - - def get_order_book(self, trading_pair: str) -> OrderBook: - if trading_pair not in self.order_book_tracker.order_books: - raise ValueError(f"No order book exists for '{trading_pair}'.") - return self.order_book_tracker.order_books[trading_pair] - - def start_tracking_order(self, - order_id: str, - exchange_order_id: Optional[str], - trading_pair: str, - trade_type: TradeType, - price: Decimal, - amount: Decimal, - order_type: OrderType): - self._in_flight_orders[order_id] = MexcInFlightOrder( - client_order_id=order_id, - exchange_order_id=exchange_order_id, - trading_pair=trading_pair, - order_type=order_type, - trade_type=trade_type, - price=price, - amount=amount, - creation_timestamp=self.current_timestamp - ) + except Exception: + self.logger().exception(f"Error parsing the trading pair rule {rule}. Skipping.") + return retval - def stop_tracking_order(self, order_id: str): - if order_id in self._in_flight_orders: - del self._in_flight_orders[order_id] + async def _status_polling_loop_fetch_updates(self): + await self._update_order_fills_from_trades() + await super()._status_polling_loop_fetch_updates() - def get_order_price_quantum(self, trading_pair: str, price: Decimal) -> Decimal: + async def _update_trading_fees(self): """ - Used by quantize_order_price() in _create_order() - Returns a price step, a minimum price increment for a given trading pair. + Update fees information from the exchange """ - trading_rule = self._trading_rules[trading_pair] - return trading_rule.min_price_increment + pass - def get_order_size_quantum(self, trading_pair: str, order_size: Decimal) -> Decimal: + async def _user_stream_event_listener(self): """ - Used by quantize_order_price() in _create_order() - Returns an order amount step, a minimum amount increment for a given trading pair. + Listens to messages from _user_stream_tracker.user_stream queue. + Traders, Orders, and Balance updates from the WS. """ - trading_rule = self._trading_rules[trading_pair] - return Decimal(trading_rule.min_base_amount_increment) - - def quantize_order_amount(self, trading_pair: str, amount: Decimal, price: Decimal = s_decimal_0) -> Decimal: + user_channels = [ + CONSTANTS.USER_TRADES_ENDPOINT_NAME, + CONSTANTS.USER_ORDERS_ENDPOINT_NAME, + CONSTANTS.USER_BALANCE_ENDPOINT_NAME, + ] + async for event_message in self._iter_user_event_queue(): + try: + channel: str = event_message.get("c", None) + results: Dict[str, Any] = event_message.get("d", {}) + if "code" not in event_message and channel not in user_channels: + self.logger().error( + f"Unexpected message in user stream: {event_message}.", exc_info=True) + continue + if channel == CONSTANTS.USER_TRADES_ENDPOINT_NAME: + self._process_trade_message(results) + elif channel == CONSTANTS.USER_ORDERS_ENDPOINT_NAME: + self._process_order_message(event_message) + elif channel == CONSTANTS.USER_BALANCE_ENDPOINT_NAME: + self._process_balance_message_ws(results) - trading_rule = self._trading_rules[trading_pair] + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error in user stream listener loop.", exc_info=True) + await self._sleep(5.0) + + def _process_balance_message_ws(self, account): + asset_name = account["a"] + self._account_available_balances[asset_name] = Decimal(str(account["f"])) + self._account_balances[asset_name] = Decimal(str(account["f"])) + Decimal(str(account["l"])) + + def _create_trade_update_with_order_fill_data( + self, + order_fill: Dict[str, Any], + order: InFlightOrder): + + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=order_fill["N"], + flat_fees=[TokenAmount( + amount=Decimal(order_fill["n"]), + token=order_fill["N"] + )] + ) + trade_update = TradeUpdate( + trade_id=str(order_fill["t"]), + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + trading_pair=order.trading_pair, + fee=fee, + fill_base_amount=Decimal(order_fill["v"]), + fill_quote_amount=Decimal(order_fill["a"]), + fill_price=Decimal(order_fill["p"]), + fill_timestamp=order_fill["T"] * 1e-3, + ) + return trade_update - quantized_amount = ExchangeBase.quantize_order_amount(self, trading_pair, amount) + def _process_trade_message(self, trade: Dict[str, Any], client_order_id: Optional[str] = None): + client_order_id = client_order_id or str(trade["c"]) + tracked_order = self._order_tracker.all_fillable_orders.get(client_order_id) + if tracked_order is None: + self.logger().debug(f"Ignoring trade message with id {client_order_id}: not in in_flight_orders.") + else: + trade_update = self._create_trade_update_with_order_fill_data( + order_fill=trade, + order=tracked_order) + self._order_tracker.process_trade_update(trade_update) + + def _create_order_update_with_order_status_data(self, order_status: Dict[str, Any], order: InFlightOrder): + client_order_id = str(order_status["d"].get("c", "")) + order_update = OrderUpdate( + trading_pair=order.trading_pair, + update_timestamp=int(order_status["t"] * 1e-3), + new_state=CONSTANTS.WS_ORDER_STATE[order_status["d"]["s"]], + client_order_id=client_order_id, + exchange_order_id=str(order_status["d"]["i"]), + ) + return order_update + + def _process_order_message(self, raw_msg: Dict[str, Any]): + order_msg = raw_msg.get("d", {}) + client_order_id = str(order_msg.get("c", "")) + tracked_order = self._order_tracker.all_updatable_orders.get(client_order_id) + if not tracked_order: + self.logger().debug(f"Ignoring order message with id {client_order_id}: not in in_flight_orders.") + return - current_price = self.get_price(trading_pair, False) + order_update = self._create_order_update_with_order_status_data(order_status=raw_msg, order=tracked_order) + self._order_tracker.process_order_update(order_update=order_update) - calc_price = current_price if price == s_decimal_0 else price + async def _update_order_fills_from_trades(self): + """ + This is intended to be a backup measure to get filled events with trade ID for orders, + in case Mexc's user stream events are not working. + NOTE: It is not required to copy this functionality in other connectors. + This is separated from _update_order_status which only updates the order status without producing filled + events, since Mexc's get order endpoint does not return trade IDs. + The minimum poll interval for order status is 10 seconds. + """ + small_interval_last_tick = self._last_poll_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + small_interval_current_tick = self.current_timestamp / self.UPDATE_ORDER_STATUS_MIN_INTERVAL + long_interval_last_tick = self._last_poll_timestamp / self.LONG_POLL_INTERVAL + long_interval_current_tick = self.current_timestamp / self.LONG_POLL_INTERVAL + + if (long_interval_current_tick > long_interval_last_tick + or (self.in_flight_orders and small_interval_current_tick > small_interval_last_tick)): + query_time = int(self._last_trades_poll_mexc_timestamp * 1e3) + self._last_trades_poll_mexc_timestamp = self._time_synchronizer.time() + order_by_exchange_id_map = {} + for order in self._order_tracker.all_fillable_orders.values(): + order_by_exchange_id_map[order.exchange_order_id] = order + + tasks = [] + trading_pairs = self.trading_pairs + for trading_pair in trading_pairs: + params = { + "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + } + if self._last_poll_timestamp > 0: + params["startTime"] = query_time + tasks.append(self._api_get( + path_url=CONSTANTS.MY_TRADES_PATH_URL, + params=params, + is_auth_required=True)) - notional_size = calc_price * quantized_amount + self.logger().debug(f"Polling for order fills of {len(tasks)} trading pairs.") + results = await safe_gather(*tasks, return_exceptions=True) - if notional_size < trading_rule.min_notional_size * Decimal("1"): - return s_decimal_0 + for trades, trading_pair in zip(results, trading_pairs): - return quantized_amount + if isinstance(trades, Exception): + self.logger().network( + f"Error fetching trades update for the order {trading_pair}: {trades}.", + app_warning_msg=f"Failed to fetch trade update for {trading_pair}." + ) + continue + for trade in trades: + exchange_order_id = str(trade["orderId"]) + if exchange_order_id in order_by_exchange_id_map: + # This is a fill for a tracked order + tracked_order = order_by_exchange_id_map[exchange_order_id] + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=tracked_order.trade_type, + percent_token=trade["commissionAsset"], + flat_fees=[TokenAmount(amount=Decimal(trade["commission"]), token=trade["commissionAsset"])] + ) + trade_update = TradeUpdate( + trade_id=str(trade["id"]), + client_order_id=tracked_order.client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + fee=fee, + fill_base_amount=Decimal(trade["qty"]), + fill_quote_amount=Decimal(trade["quoteQty"]), + fill_price=Decimal(trade["price"]), + fill_timestamp=trade["time"] * 1e-3, + ) + self._order_tracker.process_trade_update(trade_update) + elif self.is_confirmed_new_order_filled_event(str(trade["id"]), exchange_order_id, trading_pair): + # This is a fill of an order registered in the DB but not tracked any more + self._current_trade_fills.add(TradeFillOrderDetails( + market=self.display_name, + exchange_trade_id=str(trade["id"]), + symbol=trading_pair)) + self.trigger_event( + MarketEvent.OrderFilled, + OrderFilledEvent( + timestamp=float(trade["time"]) * 1e-3, + order_id=self._exchange_order_ids.get(str(trade["orderId"]), None), + trading_pair=trading_pair, + trade_type=TradeType.BUY if trade["isBuyer"] else TradeType.SELL, + order_type=OrderType.LIMIT_MAKER if trade["isMaker"] else OrderType.LIMIT, + price=Decimal(trade["price"]), + amount=Decimal(trade["qty"]), + trade_fee=DeductedFromReturnsTradeFee( + flat_fees=[ + TokenAmount( + trade["commissionAsset"], + Decimal(trade["commission"]) + ) + ] + ), + exchange_trade_id=str(trade["id"]) + )) + self.logger().info(f"Recreating missing trade in TradeFill: {trade}") + + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: + trade_updates = [] + + if order.exchange_order_id is not None: + exchange_order_id = order.exchange_order_id + trading_pair = await self.exchange_symbol_associated_to_pair(trading_pair=order.trading_pair) + all_fills_response = await self._api_get( + path_url=CONSTANTS.MY_TRADES_PATH_URL, + params={ + "symbol": trading_pair, + "orderId": exchange_order_id + }, + is_auth_required=True, + limit_id=CONSTANTS.MY_TRADES_PATH_URL) + + for trade in all_fills_response: + exchange_order_id = str(trade["orderId"]) + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=trade["commissionAsset"], + flat_fees=[TokenAmount(amount=Decimal(trade["commission"]), token=trade["commissionAsset"])] + ) + trade_update = TradeUpdate( + trade_id=str(trade["id"]), + client_order_id=order.client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + fee=fee, + fill_base_amount=Decimal(trade["qty"]), + fill_quote_amount=Decimal(trade["quoteQty"]), + fill_price=Decimal(trade["price"]), + fill_timestamp=trade["time"] * 1e-3, + ) + trade_updates.append(trade_update) + + return trade_updates + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + trading_pair = await self.exchange_symbol_associated_to_pair(trading_pair=tracked_order.trading_pair) + updated_order_data = await self._api_get( + path_url=CONSTANTS.ORDER_PATH_URL, + params={ + "symbol": trading_pair, + "origClientOrderId": tracked_order.client_order_id}, + is_auth_required=True) + + new_state = CONSTANTS.ORDER_STATE[updated_order_data["status"]] + + order_update = OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=str(updated_order_data["orderId"]), + trading_pair=tracked_order.trading_pair, + update_timestamp=updated_order_data["updateTime"] * 1e-3, + new_state=new_state, + ) - def get_fee(self, - base_currency: str, - quote_currency: str, - order_type: OrderType, - order_side: TradeType, - amount: Decimal, - price: Decimal = s_decimal_NaN, - is_maker: Optional[bool] = None) -> AddedToCostTradeFee: - is_maker = order_type is OrderType.LIMIT_MAKER - return AddedToCostTradeFee(percent=self.estimate_fee_pct(is_maker)) + return order_update - async def get_deal_detail_fee(self, order_id: str) -> Dict[str, Any]: + async def _update_balances(self): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + + account_info = await self._api_get( + path_url=CONSTANTS.ACCOUNTS_PATH_URL, + is_auth_required=True) + + balances = account_info["balances"] + for balance_entry in balances: + asset_name = balance_entry["asset"] + free_balance = Decimal(balance_entry["free"]) + total_balance = Decimal(balance_entry["free"]) + Decimal(balance_entry["locked"]) + self._account_available_balances[asset_name] = free_balance + self._account_balances[asset_name] = total_balance + remote_asset_names.add(asset_name) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dict[str, Any]): + mapping = bidict() + for symbol_data in filter(mexc_utils.is_exchange_information_valid, exchange_info["symbols"]): + mapping[symbol_data["symbol"]] = combine_to_hb_trading_pair(base=symbol_data["baseAsset"], + quote=symbol_data["quoteAsset"]) + self._set_trading_pair_symbol_map(mapping) + + async def _get_last_traded_price(self, trading_pair: str) -> float: params = { - 'order_id': order_id, + "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) } - msg = await self._api_request("GET", path_url=CONSTANTS.MEXC_DEAL_DETAIL, params=params, is_auth_required=True) - fee = s_decimal_0 - fee_currency = None - if msg['code'] == 200: - balances = msg['data'] - else: - raise Exception(msg) - for order in balances: - fee += Decimal(order['fee']) - fee_currency = order['fee_currency'] - return fee, fee_currency - - async def all_trading_pairs(self) -> List[str]: - # This method should be removed and instead we should implement _initialize_trading_pair_symbol_map - return await MexcAPIOrderBookDataSource.fetch_trading_pairs() - - async def get_last_traded_prices(self, trading_pairs: List[str]) -> Dict[str, float]: - # This method should be removed and instead we should implement _get_last_traded_price - return await MexcAPIOrderBookDataSource.get_last_traded_prices( - trading_pairs=trading_pairs, - throttler=self._throttler, - shared_client=self._shared_client) + + resp_json = await self._api_request( + method=RESTMethod.GET, + path_url=CONSTANTS.TICKER_PRICE_CHANGE_PATH_URL, + params=params + ) + + return float(resp_json["lastPrice"]) diff --git a/hummingbot/connector/exchange/mexc/mexc_in_flight_order.py b/hummingbot/connector/exchange/mexc/mexc_in_flight_order.py deleted file mode 100644 index 0c79b689f0..0000000000 --- a/hummingbot/connector/exchange/mexc/mexc_in_flight_order.py +++ /dev/null @@ -1,48 +0,0 @@ -from decimal import Decimal - -from hummingbot.connector.in_flight_order_base import InFlightOrderBase -from hummingbot.core.data_type.common import OrderType, TradeType - - -class MexcInFlightOrder(InFlightOrderBase): - def __init__(self, - client_order_id: str, - exchange_order_id: str, - trading_pair: str, - order_type: OrderType, - trade_type: TradeType, - price: Decimal, - amount: Decimal, - creation_timestamp: float, - initial_state: str = "NEW"): - super().__init__( - client_order_id, - exchange_order_id, - trading_pair, - order_type, - trade_type, - price, - amount, - creation_timestamp, - initial_state, # submitted, partial-filled, cancelling, filled, canceled, partial-canceled - ) - self.fee_asset = self.quote_asset - - @property - def is_done(self) -> bool: - return self.last_state in {"FILLED", "CANCELED", "PARTIALLY_CANCELED"} - - @property - def is_cancelled(self) -> bool: - return self.last_state in {"CANCELED", "PARTIALLY_CANCELED"} - - @property - def is_failure(self) -> bool: - return self.last_state in {"CANCELED", "PARTIALLY_CANCELED"} - - @property - def is_open(self) -> bool: - return self.last_state in {"NEW", "PARTIALLY_FILLED"} - - def mark_as_filled(self): - self.last_state = "FILLED" diff --git a/hummingbot/connector/exchange/mexc/mexc_order_book.py b/hummingbot/connector/exchange/mexc/mexc_order_book.py index 8ad297f941..abbab662da 100644 --- a/hummingbot/connector/exchange/mexc/mexc_order_book.py +++ b/hummingbot/connector/exchange/mexc/mexc_order_book.py @@ -1,81 +1,74 @@ -import logging -from typing import ( - Any, - Optional, - Dict -) +from typing import Dict, Optional -from hummingbot.connector.exchange.mexc.mexc_order_book_message import MexcOrderBookMessage from hummingbot.core.data_type.common import TradeType from hummingbot.core.data_type.order_book import OrderBook from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType -from hummingbot.logger import HummingbotLogger - -_logger = None class MexcOrderBook(OrderBook): - @classmethod - def logger(cls) -> HummingbotLogger: - global _logger - if _logger is None: - _logger = logging.getLogger(__name__), - return _logger @classmethod def snapshot_message_from_exchange(cls, - msg: Dict[str, Any], - trading_pair: str, - timestamp: Optional[float] = None, + msg: Dict[str, any], + timestamp: float, metadata: Optional[Dict] = None) -> OrderBookMessage: + """ + Creates a snapshot message with the order book snapshot message + :param msg: the response from the exchange when requesting the order book snapshot + :param timestamp: the snapshot timestamp + :param metadata: a dictionary with extra information to add to the snapshot data + :return: a snapshot message with the snapshot information received from the exchange + """ if metadata: msg.update(metadata) - msg_ts = int(timestamp * 1e-3) - content = { - "trading_pair": trading_pair, - "update_id": msg_ts, + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "trading_pair": msg["trading_pair"], + "update_id": msg["lastUpdateId"], "bids": msg["bids"], "asks": msg["asks"] - } - return MexcOrderBookMessage(OrderBookMessageType.SNAPSHOT, content, timestamp or msg_ts) + }, timestamp=timestamp) @classmethod - def trade_message_from_exchange(cls, - msg: Dict[str, Any], - timestamp: Optional[float] = None, - metadata: Optional[Dict] = None) -> OrderBookMessage: + def diff_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None) -> OrderBookMessage: + """ + Creates a diff message with the changes in the order book received from the exchange + :param msg: the changes in the order book + :param timestamp: the timestamp of the difference + :param metadata: a dictionary with extra information to add to the difference data + :return: a diff message with the changes in the order book notified by the exchange + """ if metadata: msg.update(metadata) - msg_ts = int(timestamp * 1e-3) - content = { + return OrderBookMessage(OrderBookMessageType.DIFF, { "trading_pair": msg["trading_pair"], - "trade_type": float(TradeType.SELL.value) if msg["T"] == 2 else float(TradeType.BUY.value), - "trade_id": msg["t"], - "update_id": msg["t"], - "amount": msg["q"], - "price": msg["p"] - } - return MexcOrderBookMessage(OrderBookMessageType.TRADE, content, timestamp or msg_ts) + "update_id": int(msg['d']["r"]), + "bids": [[i['p'], i['v']] for i in msg['d'].get("bids", [])], + "asks": [[i['p'], i['v']] for i in msg['d'].get("asks", [])], + }, timestamp=timestamp * 1e-3) @classmethod - def diff_message_from_exchange(cls, - data: Dict[str, Any], - timestamp: float = None, - metadata: Optional[Dict] = None) -> OrderBookMessage: + def trade_message_from_exchange(cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None): + """ + Creates a trade message with the information from the trade event sent by the exchange + :param msg: the trade event details sent by the exchange + :param timestamp: the timestamp of the difference + :param metadata: a dictionary with extra information to add to trade message + :return: a trade message with the details of the trade as provided by the exchange + """ if metadata: - data.update(metadata) - - msg_ts = int(timestamp * 1e-3) - content = { - "trading_pair": data["trading_pair"], - "update_id": msg_ts, - "bids": data.get("bids", []), - "asks": data.get("asks", []) - } - return MexcOrderBookMessage(OrderBookMessageType.DIFF, content, timestamp or msg_ts) - - @classmethod - def from_snapshot(cls, msg: OrderBookMessage) -> OrderBook: - retval = MexcOrderBook() - retval.apply_snapshot(msg.bids, msg.asks, msg.update_id) - return retval + msg.update(metadata) + ts = timestamp + return OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": msg["trading_pair"], + "trade_type": float(TradeType.SELL.value) if msg["S"] == 2 else float(TradeType.BUY.value), + "trade_id": msg["t"], + "update_id": ts, + "price": msg["p"], + "amount": msg["v"] + }, timestamp=ts * 1e-3) diff --git a/hummingbot/connector/exchange/mexc/mexc_order_book_message.py b/hummingbot/connector/exchange/mexc/mexc_order_book_message.py deleted file mode 100644 index f2f1caa253..0000000000 --- a/hummingbot/connector/exchange/mexc/mexc_order_book_message.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python - -from typing import ( - Dict, - List, - Optional, -) - -from hummingbot.core.data_type.order_book_row import OrderBookRow -from hummingbot.core.data_type.order_book_message import ( - OrderBookMessage, - OrderBookMessageType, -) - - -class MexcOrderBookMessage(OrderBookMessage): - def __new__( - cls, - message_type: OrderBookMessageType, - content: Dict[str, any], - timestamp: Optional[float] = None, - *args, - **kwargs, - ): - if timestamp is None: - if message_type is OrderBookMessageType.SNAPSHOT: - raise ValueError("timestamp must not be None when initializing snapshot messages.") - timestamp = content["time"] * 1e-3 - return super(MexcOrderBookMessage, cls).__new__( - cls, message_type, content, timestamp=timestamp, *args, **kwargs - ) - - @property - def update_id(self) -> int: - return int(self.timestamp * 1e3) - - @property - def trade_id(self) -> int: - return int(self.timestamp * 1e3) - - @property - def trading_pair(self) -> str: - return self.content.get('trading_pair', None) - - @property - def asks(self) -> (List[OrderBookRow]): - return [ - OrderBookRow(float(ask["price"]), float(ask["quantity"]), self.update_id) - for ask in self.content.get("asks", []) - ] - - @property - def bids(self) -> (List[OrderBookRow]): - return [ - OrderBookRow(float(bid["price"]), float(bid["quantity"]), self.update_id) - for bid in self.content.get("bids", []) - ] - - def __hash__(self) -> int: - return hash((self.type, self.timestamp)) diff --git a/hummingbot/connector/exchange/mexc/mexc_order_book_tracker.py b/hummingbot/connector/exchange/mexc/mexc_order_book_tracker.py deleted file mode 100644 index 1b93720d19..0000000000 --- a/hummingbot/connector/exchange/mexc/mexc_order_book_tracker.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- - -import asyncio -import logging -import time -from typing import ( - List, - Optional -) - -import aiohttp -from hummingbot.core.data_type.order_book import OrderBook - -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.core.data_type.order_book_message import ( - OrderBookMessage, - OrderBookMessageType -) - -from hummingbot.core.data_type.order_book_tracker import OrderBookTracker -from hummingbot.core.utils.async_utils import safe_ensure_future -from hummingbot.logger import HummingbotLogger -from hummingbot.connector.exchange.mexc.mexc_api_order_book_data_source import MexcAPIOrderBookDataSource - - -class MexcOrderBookTracker(OrderBookTracker): - _mexcobt_logger: Optional[HummingbotLogger] = None - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._mexcobt_logger is None: - cls._mexcobt_logger = logging.getLogger(__name__) - return cls._mexcobt_logger - - def __init__(self, - trading_pairs: Optional[List[str]] = None, - shared_client: Optional[aiohttp.ClientSession] = None, - throttler: Optional[AsyncThrottler] = None,): - super().__init__(MexcAPIOrderBookDataSource(trading_pairs, shared_client=shared_client, throttler=throttler), trading_pairs) - self._order_book_diff_stream: asyncio.Queue = asyncio.Queue() - self._order_book_snapshot_stream: asyncio.Queue = asyncio.Queue() - self._ev_loop: asyncio.BaseEventLoop = asyncio.get_event_loop() - self._order_book_stream_listener_task: Optional[asyncio.Task] = None - - @property - def exchange_name(self) -> str: - return "mexc" - - def start(self): - super().start() - self._order_book_stream_listener_task = safe_ensure_future(self._data_source.listen_for_subscriptions()) - - def stop(self): - if self._order_book_stream_listener_task: - self._order_book_stream_listener_task.cancel() - super().stop() - - async def _order_book_diff_router(self): - last_message_timestamp: float = time.time() - messages_queued: int = 0 - messages_accepted: int = 0 - messages_rejected: int = 0 - - while True: - try: - ob_message: OrderBookMessage = await self._order_book_diff_stream.get() - trading_pair: str = ob_message.trading_pair - - if trading_pair not in self._tracking_message_queues: - continue - message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] - order_book: OrderBook = self._order_books[trading_pair] - - if order_book.snapshot_uid > ob_message.update_id: - messages_rejected += 1 - continue - await message_queue.put(ob_message) - messages_accepted += 1 - - now: float = time.time() - if int(now / 60.0) > int(last_message_timestamp / 60.0): - self.logger().debug(f"Diff messages processed: {messages_accepted}, " - f"rejected: {messages_rejected}, queued: {messages_queued}") - messages_accepted = 0 - messages_rejected = 0 - messages_queued = 0 - - last_message_timestamp = now - - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().network( - "Unexpected error routing order book messages." + repr(ex), - exc_info=True, - app_warning_msg="Unexpected error routing order book messages. Retrying after 5 seconds." - ) - await asyncio.sleep(5.0) - - async def _track_single_book(self, trading_pair: str): - message_queue: asyncio.Queue = self._tracking_message_queues[trading_pair] - order_book: OrderBook = self._order_books[trading_pair] - last_message_timestamp: float = time.time() - diff_messages_accepted: int = 0 - - while True: - try: - message: OrderBookMessage = await message_queue.get() - if message.type is OrderBookMessageType.DIFF: - order_book.apply_diffs(message.bids, message.asks, message.update_id) - diff_messages_accepted += 1 - - now: float = time.time() - if int(now / 60.0) > int(last_message_timestamp / 60): - self.logger().debug(f"Processed {diff_messages_accepted} order book diffs for {trading_pair}.") - diff_messages_accepted = 0 - last_message_timestamp = now - elif message.type is OrderBookMessageType.SNAPSHOT: - order_book.apply_snapshot(message.bids, message.asks, message.update_id) - self.logger().debug(f"Processed order book snapshot for {trading_pair}.") - except asyncio.CancelledError: - raise - except Exception as ex: - self.logger().network( - f"Unexpected error tracking order book for {trading_pair}." + repr(ex), - exc_info=True, - app_warning_msg="Unexpected error tracking order book. Retrying after 5 seconds." - ) - await asyncio.sleep(5.0) diff --git a/hummingbot/connector/exchange/mexc/mexc_user_stream_tracker.py b/hummingbot/connector/exchange/mexc/mexc_user_stream_tracker.py deleted file mode 100644 index 7f77416fd0..0000000000 --- a/hummingbot/connector/exchange/mexc/mexc_user_stream_tracker.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -from typing import ( - List, - Optional, -) - -import aiohttp - -from hummingbot.connector.exchange.mexc.mexc_api_user_stream_data_source import MexcAPIUserStreamDataSource -from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.core.data_type.user_stream_tracker import UserStreamTracker -from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource -from hummingbot.core.utils.async_utils import ( - safe_ensure_future, - safe_gather, -) -from hummingbot.logger import HummingbotLogger - - -class MexcUserStreamTracker(UserStreamTracker): - _mexcust_logger: Optional[HummingbotLogger] = None - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._mexcust_logger is None: - cls._mexcust_logger = logging.getLogger(__name__) - return cls._mexcust_logger - - def __init__(self, - throttler: AsyncThrottler, - mexc_auth: Optional[MexcAuth] = None, - trading_pairs: Optional[List[str]] = None, - shared_client: Optional[aiohttp.ClientSession] = None - ): - self._shared_client = shared_client - self._mexc_auth: MexcAuth = mexc_auth - self._trading_pairs: List[str] = trading_pairs or [] - self._throttler = throttler - super().__init__(data_source=MexcAPIUserStreamDataSource( - throttler=self._throttler, - mexc_auth=self._mexc_auth, - trading_pairs=self._trading_pairs, - shared_client=self._shared_client)) - - @property - def data_source(self) -> UserStreamTrackerDataSource: - if not self._data_source: - self._data_source = MexcAPIUserStreamDataSource(throttler=self._throttler, - mexc_auth=self._mexc_auth, - trading_pairs=self._trading_pairs, - shared_client=self._shared_client) - return self._data_source - - @property - def exchange_name(self) -> str: - return "mexc" - - async def start(self): - self._user_stream_tracking_task = safe_ensure_future( - self.data_source.listen_for_user_stream(self._user_stream) - ) - await safe_gather(self._user_stream_tracking_task) diff --git a/hummingbot/connector/exchange/mexc/mexc_utils.py b/hummingbot/connector/exchange/mexc/mexc_utils.py index 7f25cdd9c1..0acf5520f6 100644 --- a/hummingbot/connector/exchange/mexc/mexc_utils.py +++ b/hummingbot/connector/exchange/mexc/mexc_utils.py @@ -1,39 +1,46 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- -import time from decimal import Decimal +from typing import Any, Dict from pydantic import Field, SecretStr from hummingbot.client.config.config_data_types import BaseConnectorConfigMap, ClientFieldData - - -def num_to_increment(num): - return Decimal(10) ** -num - +from hummingbot.core.data_type.trade_fee import TradeFeeSchema CENTRALIZED = True +EXAMPLE_PAIR = "ZRX-ETH" -EXAMPLE_PAIR = 'BTC-USDT' +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.000"), + taker_percent_fee_decimal=Decimal("0.000"), + buy_percent_fee_deducted_from_returns=True +) -DEFAULT_FEES = [0.2, 0.2] + +def is_exchange_information_valid(exchange_info: Dict[str, Any]) -> bool: + """ + Verifies if a trading pair is enabled to operate with based on its exchange information + :param exchange_info: the exchange information for a trading pair + :return: True if the trading pair is enabled, False otherwise + """ + return exchange_info.get("status", None) == "ENABLED" and "SPOT" in exchange_info.get("permissions", list()) \ + and exchange_info.get("isSpotTradingAllowed", True) is True class MexcConfigMap(BaseConnectorConfigMap): - connector: str = Field(default="mexc", client_data=None) + connector: str = Field(default="mexc", const=True, client_data=None) mexc_api_key: SecretStr = Field( default=..., client_data=ClientFieldData( - prompt=lambda cm: "Enter your MEXC API key", + prompt=lambda cm: "Enter your Mexc API key", is_secure=True, is_connect_key=True, prompt_on_new=True, ) ) - mexc_secret_key: SecretStr = Field( + mexc_api_secret: SecretStr = Field( default=..., client_data=ClientFieldData( - prompt=lambda cm: "Enter your MEXC secret key", + prompt=lambda cm: "Enter your Mexc API secret", is_secure=True, is_connect_key=True, prompt_on_new=True, @@ -45,35 +52,3 @@ class Config: KEYS = MexcConfigMap.construct() - -ws_status = { - 1: 'NEW', - 2: 'FILLED', - 3: 'PARTIALLY_FILLED', - 4: 'CANCELED', - 5: 'PARTIALLY_CANCELED' -} - - -def seconds(): - return int(time.time()) - - -def milliseconds(): - return int(time.time() * 1000) - - -def microseconds(): - return int(time.time() * 1000000) - - -def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> str: - return exchange_trading_pair.replace("_", "-") - - -def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: - return hb_trading_pair.replace("-", "_") - - -def ws_order_status_convert_to_str(ws_order_status: int) -> str: - return ws_status[ws_order_status] diff --git a/hummingbot/connector/exchange/mexc/mexc_web_utils.py b/hummingbot/connector/exchange/mexc/mexc_web_utils.py new file mode 100644 index 0000000000..88ec348b4e --- /dev/null +++ b/hummingbot/connector/exchange/mexc/mexc_web_utils.py @@ -0,0 +1,75 @@ +from typing import Callable, Optional + +import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.connector.utils import TimeSynchronizerRESTPreProcessor +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +def public_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + """ + Creates a full URL for provided public REST endpoint + :param path_url: a public REST endpoint + :param domain: the Mexc domain to connect to ("com" or "us"). The default value is "com" + :return: the full URL to the endpoint + """ + return CONSTANTS.REST_URL.format(domain) + CONSTANTS.PUBLIC_API_VERSION + path_url + + +def private_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + """ + Creates a full URL for provided private REST endpoint + :param path_url: a private REST endpoint + :param domain: the Mexc domain to connect to ("com" or "us"). The default value is "com" + :return: the full URL to the endpoint + """ + return CONSTANTS.REST_URL.format(domain) + CONSTANTS.PRIVATE_API_VERSION + path_url + + +def build_api_factory( + throttler: Optional[AsyncThrottler] = None, + time_synchronizer: Optional[TimeSynchronizer] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + time_provider: Optional[Callable] = None, + auth: Optional[AuthBase] = None, ) -> WebAssistantsFactory: + throttler = throttler or create_throttler() + time_synchronizer = time_synchronizer or TimeSynchronizer() + time_provider = time_provider or (lambda: get_current_server_time( + throttler=throttler, + domain=domain, + )) + api_factory = WebAssistantsFactory( + throttler=throttler, + auth=auth, + rest_pre_processors=[ + TimeSynchronizerRESTPreProcessor(synchronizer=time_synchronizer, time_provider=time_provider), + ]) + return api_factory + + +def build_api_factory_without_time_synchronizer_pre_processor(throttler: AsyncThrottler) -> WebAssistantsFactory: + api_factory = WebAssistantsFactory(throttler=throttler) + return api_factory + + +def create_throttler() -> AsyncThrottler: + return AsyncThrottler(CONSTANTS.RATE_LIMITS) + + +async def get_current_server_time( + throttler: Optional[AsyncThrottler] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, +) -> float: + throttler = throttler or create_throttler() + api_factory = build_api_factory_without_time_synchronizer_pre_processor(throttler=throttler) + rest_assistant = await api_factory.get_rest_assistant() + response = await rest_assistant.execute_request( + url=public_rest_url(path_url=CONSTANTS.SERVER_TIME_PATH_URL, domain=domain), + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.SERVER_TIME_PATH_URL, + ) + server_time = response["serverTime"] + return server_time diff --git a/hummingbot/connector/exchange/mexc/mexc_websocket_adaptor.py b/hummingbot/connector/exchange/mexc/mexc_websocket_adaptor.py deleted file mode 100644 index 4a7c2e7e61..0000000000 --- a/hummingbot/connector/exchange/mexc/mexc_websocket_adaptor.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python -import json - -import aiohttp -import asyncio -import logging - -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS -import hummingbot.connector.exchange.mexc.mexc_utils as mexc_utils - -from typing import Dict, Optional, AsyncIterable, Any, List - -from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.logger import HummingbotLogger - - -class MexcWebSocketAdaptor: - - DEAL_CHANNEL_ID = "push.deal" - DEPTH_CHANNEL_ID = "push.depth" - SUBSCRIPTION_LIST = set([DEAL_CHANNEL_ID, DEPTH_CHANNEL_ID]) - - _ID_FIELD_NAME = "id" - - _logger: Optional[HummingbotLogger] = None - - MESSAGE_TIMEOUT = 120.0 - PING_TIMEOUT = 10.0 - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls._logger is None: - cls._logger = logging.getLogger(__name__) - return cls._logger - - def __init__( - self, - throttler: AsyncThrottler, - auth: Optional[MexcAuth] = None, - shared_client: Optional[aiohttp.ClientSession] = None, - ): - - self._auth: Optional[MexcAuth] = auth - self._is_private = True if self._auth is not None else False - self._WS_URL = CONSTANTS.MEXC_WS_URL_PUBLIC - self._shared_client = shared_client - self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None - self._throttler = throttler - - def get_shared_client(self) -> aiohttp.ClientSession: - if not self._shared_client: - self._shared_client = aiohttp.ClientSession() - return self._shared_client - - async def send_request(self, payload: Dict[str, Any]): - await self._websocket.send_json(payload) - - async def send_request_str(self, payload: str): - await self._websocket.send_str(payload) - - async def subscribe_to_order_book_streams(self, trading_pairs: List[str]): - try: - for trading_pair in trading_pairs: - trading_pair = mexc_utils.convert_to_exchange_trading_pair(trading_pair) - subscribe_deal_request: Dict[str, Any] = { - "op": "sub.deal", - "symbol": trading_pair, - } - async with self._throttler.execute_task(CONSTANTS.MEXC_WS_URL_PUBLIC): - await self.send_request_str(json.dumps(subscribe_deal_request)) - subscribe_depth_request: Dict[str, Any] = { - "op": "sub.depth", - "symbol": trading_pair, - } - async with self._throttler.execute_task(CONSTANTS.MEXC_WS_URL_PUBLIC): - await self.send_request_str(json.dumps(subscribe_depth_request)) - - except asyncio.CancelledError: - raise - except Exception: - self.logger().error( - "Unexpected error occurred subscribing to order book trading and delta streams...", exc_info=True - ) - raise - - async def subscribe_to_user_streams(self): - pass - - async def authenticate(self): - pass - - async def connect(self): - try: - self._websocket = await self.get_shared_client().ws_connect( - url=self._WS_URL) - - except Exception as e: - self.logger().error(f"Websocket error: '{str(e)}'", exc_info=True) - raise - - # disconnect from exchange - async def disconnect(self): - if self._websocket is None: - return - await self._websocket.close() - - async def iter_messages(self) -> AsyncIterable[Any]: - try: - while True: - try: - msg = await asyncio.wait_for(self._websocket.receive(), timeout=self.MESSAGE_TIMEOUT) - if msg.type == aiohttp.WSMsgType.CLOSED: - raise ConnectionError - yield json.loads(msg.data) - except asyncio.TimeoutError: - pong_waiter = self._websocket.ping() - self.logger().warning("WebSocket receive_json timeout ...") - await asyncio.wait_for(pong_waiter, timeout=self.PING_TIMEOUT) - except ConnectionError: - return diff --git a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx index 9fbc4cad0a..9a1c7caba6 100644 --- a/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx +++ b/hummingbot/connector/exchange/paper_trade/paper_trade_exchange.pyx @@ -336,6 +336,7 @@ cdef class PaperTradeExchange(ExchangeBase): string cpp_trading_pair_str = trading_pair_str.encode("utf8") string cpp_base_asset = self._trading_pairs[trading_pair_str].base_asset.encode("utf8") string cpp_quote_asset = quote_asset.encode("utf8") + string cpp_position = "NIL".encode("utf8") LimitOrdersIterator map_it SingleTradingPairLimitOrders *limit_orders_collection_ptr = NULL pair[LimitOrders.iterator, cppbool] insert_result @@ -366,7 +367,8 @@ cdef class PaperTradeExchange(ExchangeBase): quantized_amount, None, int(self._current_timestamp * 1e6), - 0 + 0, + cpp_position, )) safe_ensure_future(self.trigger_event_async( self.MARKET_BUY_ORDER_CREATED_EVENT_TAG, @@ -395,6 +397,7 @@ cdef class PaperTradeExchange(ExchangeBase): string cpp_trading_pair_str = trading_pair_str.encode("utf8") string cpp_base_asset = base_asset.encode("utf8") string cpp_quote_asset = self._trading_pairs[trading_pair_str].quote_asset.encode("utf8") + string cpp_position = "NIL".encode("utf8") LimitOrdersIterator map_it SingleTradingPairLimitOrders *limit_orders_collection_ptr = NULL pair[LimitOrders.iterator, cppbool] insert_result @@ -424,7 +427,8 @@ cdef class PaperTradeExchange(ExchangeBase): quantized_amount, None, int(self._current_timestamp * 1e6), - 0 + 0, + cpp_position, )) safe_ensure_future(self.trigger_event_async( self.MARKET_SELL_ORDER_CREATED_EVENT_TAG, diff --git a/hummingbot/connector/exchange/polkadex/polkadex_api_order_book_data_source.py b/hummingbot/connector/exchange/polkadex/polkadex_api_order_book_data_source.py index 0d608fab7f..eb003cf180 100644 --- a/hummingbot/connector/exchange/polkadex/polkadex_api_order_book_data_source.py +++ b/hummingbot/connector/exchange/polkadex/polkadex_api_order_book_data_source.py @@ -1,13 +1,12 @@ import asyncio -from copy import copy, deepcopy from typing import TYPE_CHECKING, Any, Dict, List, Optional from hummingbot.connector.exchange.polkadex import polkadex_constants as CONSTANTS from hummingbot.connector.exchange.polkadex.polkadex_data_source import PolkadexDataSource -from hummingbot.connector.exchange.polkadex.polkadex_events import PolkadexOrderBookEvent -from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType +from hummingbot.core.data_type.order_book_message import OrderBookMessage from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.core.event.event_forwarder import EventForwarder +from hummingbot.core.event.events import OrderBookEvent from hummingbot.core.web_assistant.ws_assistant import WSAssistant if TYPE_CHECKING: @@ -67,43 +66,23 @@ async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: async def _parse_trade_message(self, raw_message: OrderBookMessage, message_queue: asyncio.Queue): # In Polkadex 'raw_message' is not a raw message, but the OrderBookMessage with type Trade created # by the data source - - message_content = deepcopy(raw_message.content) - message_content["trading_pair"] = await self._connector.trading_pair_associated_to_exchange_symbol( - symbol=message_content["trading_pair"] - ) - - trade_message = OrderBookMessage( - message_type=OrderBookMessageType.TRADE, - content=message_content, - timestamp=raw_message.timestamp, - ) - message_queue.put_nowait(trade_message) + message_queue.put_nowait(raw_message) async def _parse_order_book_diff_message(self, raw_message: OrderBookMessage, message_queue: asyncio.Queue): # In Polkadex 'raw_message' is not a raw message, but the OrderBookMessage with type Trade created # by the data source - message_content = copy(raw_message.content) - message_content["trading_pair"] = await self._connector.trading_pair_associated_to_exchange_symbol( - symbol=message_content["trading_pair"] - ) - diff_message = OrderBookMessage( - message_type=OrderBookMessageType.DIFF, - content=message_content, - timestamp=raw_message.timestamp, - ) - message_queue.put_nowait(diff_message) + message_queue.put_nowait(raw_message) def _configure_event_forwarders(self): event_forwarder = EventForwarder(to_function=self._process_order_book_event) self._forwarders.append(event_forwarder) self._data_source.add_listener( - event_tag=PolkadexOrderBookEvent.OrderBookDataSourceUpdateEvent, listener=event_forwarder + event_tag=OrderBookEvent.OrderBookDataSourceUpdateEvent, listener=event_forwarder ) event_forwarder = EventForwarder(to_function=self._process_public_trade_event) self._forwarders.append(event_forwarder) - self._data_source.add_listener(event_tag=PolkadexOrderBookEvent.PublicTradeEvent, listener=event_forwarder) + self._data_source.add_listener(event_tag=OrderBookEvent.TradeEvent, listener=event_forwarder) def _process_order_book_event(self, order_book_diff: OrderBookMessage): self._message_queue[self._diff_messages_queue_key].put_nowait(order_book_diff) diff --git a/hummingbot/connector/exchange/polkadex/polkadex_constants.py b/hummingbot/connector/exchange/polkadex/polkadex_constants.py index 036013d8f8..6f65c8f5e8 100644 --- a/hummingbot/connector/exchange/polkadex/polkadex_constants.py +++ b/hummingbot/connector/exchange/polkadex/polkadex_constants.py @@ -10,15 +10,12 @@ CLIENT_ID_PREFIX = "HBOT" DEFAULT_DOMAIN = "" -TESTNET_DOMAIN = "testnet" GRAPHQL_ENDPOINTS = { - DEFAULT_DOMAIN: "https://gu5xqmhhcnfeveotzwhe6ohfba.appsync-api.eu-central-1.amazonaws.com/graphql", - TESTNET_DOMAIN: "https://kckpespz5bb2rmdnuxycz6e7he.appsync-api.eu-central-1.amazonaws.com/graphql", + DEFAULT_DOMAIN: "https://yx375ldozvcvthjk2nczch3fhq.appsync-api.eu-central-1.amazonaws.com/graphql", } BLOCKCHAIN_URLS = { - DEFAULT_DOMAIN: "wss://mainnet.polkadex.trade", - TESTNET_DOMAIN: "wss://blockchain.polkadex.trade", + DEFAULT_DOMAIN: "wss://polkadex.public.curie.radiumblock.co/ws", } POLKADEX_SS58_PREFIX = 88 @@ -32,10 +29,12 @@ FIND_USER_LIMIT_ID = "FindUser" PUBLIC_TRADES_LIMIT_ID = "RecentTrades" ALL_BALANCES_LIMIT_ID = "AllBalances" +ALL_FILLS_LIMIT_ID = "AllFills" PLACE_ORDER_LIMIT_ID = "PlaceOrder" CANCEL_ORDER_LIMIT_ID = "CancelOrder" BATCH_ORDER_UPDATES_LIMIT_ID = "BatchOrderUpdates" ORDER_UPDATE_LIMIT_ID = "OrderUpdate" +LIST_OPEN_ORDERS_LIMIT_ID = "ListOpenOrders" NO_LIMIT = sys.maxsize @@ -70,6 +69,11 @@ limit=NO_LIMIT, time_interval=SECOND, ), + RateLimit( + limit_id=ALL_FILLS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=SECOND, + ), RateLimit( limit_id=PLACE_ORDER_LIMIT_ID, limit=NO_LIMIT, @@ -90,6 +94,11 @@ limit=NO_LIMIT, time_interval=SECOND, ), + RateLimit( + limit_id=LIST_OPEN_ORDERS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=SECOND, + ), ] @@ -119,14 +128,7 @@ ["timestamp", "i64"], ], }, - "CancelOrderPayload": {"type": "struct", "type_mapping": [["id", "String"]]}, - "TradingPair": { - "type": "struct", - "type_mapping": [ - ["base_asset", "AssetId"], - ["quote_asset", "AssetId"], - ], - }, + "order_id": "H256", "OrderSide": { "type": "enum", "type_mapping": [ @@ -134,13 +136,6 @@ ["Bid", "Null"], ], }, - "AssetId": { - "type": "enum", - "type_mapping": [ - ["asset", "u128"], - ["polkadex", "Null"], - ], - }, "OrderType": { "type": "enum", "type_mapping": [ diff --git a/hummingbot/connector/exchange/polkadex/polkadex_data_source.py b/hummingbot/connector/exchange/polkadex/polkadex_data_source.py index 2119815f4c..ffbc87c8f8 100644 --- a/hummingbot/connector/exchange/polkadex/polkadex_data_source.py +++ b/hummingbot/connector/exchange/polkadex/polkadex_data_source.py @@ -3,31 +3,34 @@ import logging import time from decimal import Decimal -from typing import Any, Dict, List, Mapping, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple from urllib.parse import urlparse from bidict import bidict from gql.transport.appsync_auth import AppSyncJWTAuthentication +from scalecodec import ScaleBytes from substrateinterface import Keypair, KeypairType, SubstrateInterface from hummingbot.connector.exchange.polkadex import polkadex_constants as CONSTANTS, polkadex_utils -from hummingbot.connector.exchange.polkadex.polkadex_events import PolkadexOrderBookEvent from hummingbot.connector.exchange.polkadex.polkadex_query_executor import GrapQLQueryExecutor from hummingbot.connector.trading_rule import TradingRule -from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.connector.utils import combine_to_hb_trading_pair, split_hb_trading_pair from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.api_throttler.async_throttler_base import AsyncThrottlerBase from hummingbot.core.data_type.common import OrderType, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState, OrderUpdate, TradeUpdate from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType -from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase, TradeFeeSchema +from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase from hummingbot.core.event.event_listener import EventListener -from hummingbot.core.event.events import AccountEvent, BalanceUpdateEvent, MarketEvent +from hummingbot.core.event.events import AccountEvent, BalanceUpdateEvent, MarketEvent, OrderBookEvent from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.pubsub import Enum, PubSub from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.logger import HummingbotLogger +if TYPE_CHECKING: + from hummingbot.connector.exchange_py_base import ExchangePyBase + class PolkadexDataSource: _logger: Optional[HummingbotLogger] = None @@ -38,8 +41,16 @@ def logger(cls) -> HummingbotLogger: cls._logger = logging.getLogger(HummingbotLogger.logger_name_for_class(cls)) return cls._logger - def __init__(self, seed_phrase: str, domain: Optional[str] = CONSTANTS.DEFAULT_DOMAIN): + def __init__( + self, + connector: "ExchangePyBase", + seed_phrase: str, + domain: Optional[str] = CONSTANTS.DEFAULT_DOMAIN, + trading_required: bool = True, + ): + self._connector = connector self._domain = domain + self._trading_required = trading_required graphql_host = CONSTANTS.GRAPHQL_ENDPOINTS[self._domain] netloc_host = urlparse(graphql_host).netloc self._keypair = None @@ -51,15 +62,10 @@ def __init__(self, seed_phrase: str, domain: Optional[str] = CONSTANTS.DEFAULT_D self._user_proxy_address = self._keypair.ss58_address self._auth = AppSyncJWTAuthentication(netloc_host, self._user_proxy_address) else: - self._user_proxy_address = "no_address" - self._auth = AppSyncJWTAuthentication(netloc_host, "no_address") + self._user_proxy_address = "READ_ONLY" + self._auth = AppSyncJWTAuthentication(netloc_host, "READ_ONLY") - self._substrate_interface = SubstrateInterface( - url=CONSTANTS.BLOCKCHAIN_URLS[self._domain], - ss58_format=CONSTANTS.POLKADEX_SS58_PREFIX, - type_registry=CONSTANTS.CUSTOM_TYPES, - auto_discover=False, - ) + self._substrate_interface = self._build_substrate_interface() self._query_executor = GrapQLQueryExecutor(auth=self._auth, domain=self._domain) self._publisher = PubSub() @@ -68,17 +74,25 @@ def __init__(self, seed_phrase: str, domain: Optional[str] = CONSTANTS.DEFAULT_D # The connector using this data source should replace the throttler with the one used by the connector. self._throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) self._events_listening_tasks = [] - self._assets_map: Optional[Dict[str, str]] = None + self._assets_map: Dict[str, str] = {} self._polkadex_order_type = { OrderType.MARKET: "MARKET", OrderType.LIMIT: "LIMIT", OrderType.LIMIT_MAKER: "LIMIT", } + self._hummingbot_order_type = { + "LIMIT": OrderType.LIMIT, + "MARKET": OrderType.MARKET, + } self._polkadex_trade_type = { TradeType.BUY: "Bid", TradeType.SELL: "Ask", } + self._hummingbot_trade_type = { + "Bid": TradeType.BUY, + "Ask": TradeType.SELL, + } def is_started(self) -> bool: return len(self._events_listening_tasks) > 0 @@ -87,8 +101,6 @@ async def start(self, market_symbols: List[str]): if len(self._events_listening_tasks) > 0: raise AssertionError("Polkadex datasource is already listening to events and can't be started again") - main_address = await self.user_main_address() - for market_symbol in market_symbols: self._events_listening_tasks.append( asyncio.create_task( @@ -105,20 +117,22 @@ async def start(self, market_symbols: List[str]): ) ) - self._events_listening_tasks.append( - asyncio.create_task( - self._query_executor.listen_to_private_events( - events_handler=self._process_private_event, address=self._user_proxy_address + if self._trading_required: + self._events_listening_tasks.append( + asyncio.create_task( + self._query_executor.listen_to_private_events( + events_handler=self._process_private_event, address=self._user_proxy_address + ) ) ) - ) - self._events_listening_tasks.append( - asyncio.create_task( - self._query_executor.listen_to_private_events( - events_handler=self._process_private_event, address=main_address + main_address = await self.user_main_address() + self._events_listening_tasks.append( + asyncio.create_task( + self._query_executor.listen_to_private_events( + events_handler=self._process_private_event, address=main_address + ) ) ) - ) async def stop(self): for task in self._events_listening_tasks: @@ -148,20 +162,17 @@ async def exchange_status(self): return result async def assets_map(self) -> Dict[str, str]: - if self._assets_map is None: - async with self._throttler.execute_task(limit_id=CONSTANTS.ALL_ASSETS_LIMIT_ID): - all_assets = await self._query_executor.all_assets() - self._assets_map = { - asset["asset_id"]: polkadex_utils.normalized_asset_name( - asset_id=asset["asset_id"], asset_name=asset["name"] - ) - for asset in all_assets["getAllAssets"]["items"] - } + async with self._throttler.execute_task(limit_id=CONSTANTS.ALL_ASSETS_LIMIT_ID): + all_assets = await self._query_executor.all_assets() + self._assets_map = { + asset["asset_id"]: polkadex_utils.normalized_asset_name( + asset_id=asset["asset_id"], asset_name=asset["name"] + ) + for asset in all_assets["getAllAssets"]["items"] + } - if len(self._assets_map) > 0: - self._assets_map[ - "polkadex" - ] = "PDEX" # required due to inconsistent token name in private balance event + if len(self._assets_map) > 0: + self._assets_map["polkadex"] = "PDEX" # required due to inconsistent token name in private balance event return self._assets_map @@ -189,7 +200,10 @@ async def all_trading_rules(self) -> List[TradingRule]: trading_rules = [] for market_info in markets["getAllMarkets"]["items"]: try: - trading_pair = market_info["market"] + exchange_trading_pair = market_info["market"] + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol( + symbol=exchange_trading_pair + ) min_order_size = Decimal(market_info["min_order_qty"]) max_order_size = Decimal(market_info["max_order_qty"]) min_order_price = Decimal(market_info["min_order_price"]) @@ -234,6 +248,8 @@ async def order_book_snapshot(self, market_symbol: str, trading_pair: str) -> Or else: asks.append((price, amount)) + update_id = max(update_id, int(orderbook_entry["stid"])) + order_book_message_content = { "trading_pair": trading_pair, "update_id": update_id, @@ -293,19 +309,18 @@ async def place_order( order_type: OrderType, ) -> Tuple[str, float]: main_account = await self.user_main_address() - translated_client_order_id = f"0x{client_order_id.encode('utf-8').hex()}" - price = round(price, 4) - amount = round(amount, 4) + price = self.normalize_fraction(price) + amount = self.normalize_fraction(amount) timestamp = self._time() order_parameters = { "user": self._user_proxy_address, "main_account": main_account, "pair": market_symbol, - "qty": f"{amount:f}"[:12], - "price": f"{price:f}"[:12], - "quote_order_quantity": "0", - "timestamp": int(timestamp), - "client_order_id": translated_client_order_id, + "qty": f"{amount}", + "price": f"{price}", + "quote_order_quantity": "0", # No need to be 8 decimal points + "timestamp": int(timestamp * 1e3), + "client_order_id": client_order_id, "order_type": self._polkadex_order_type[order_type], "side": self._polkadex_trade_type[trade_type], } @@ -318,57 +333,29 @@ async def place_order( polkadex_order=order_parameters, signature={"Sr25519": signature.hex()}, ) + place_order_data = json.loads(response["place_order"]) - exchange_order_id = response["place_order"] + exchange_order_id = None + if place_order_data["is_success"] is True: + exchange_order_id = place_order_data["body"] if exchange_order_id is None: raise ValueError(f"Error in Polkadex creating order {client_order_id}") return exchange_order_id, timestamp - async def cancel_order(self, order: InFlightOrder, market_symbol: str, timestamp: float) -> bool: - cancel_request = self._substrate_interface.create_scale_object("H256").encode(order.exchange_order_id) - signature = self._keypair.sign(cancel_request) - - async with self._throttler.execute_task(limit_id=CONSTANTS.CANCEL_ORDER_LIMIT_ID): - cancel_result = await self._query_executor.cancel_order( - order_id=order.exchange_order_id, - market_symbol=market_symbol, - proxy_address=self._user_proxy_address, - signature={"Sr25519": signature.hex()}, - ) - - if cancel_result["cancel_order"]: - success = True + async def cancel_order(self, order: InFlightOrder, market_symbol: str, timestamp: float) -> OrderState: + try: + cancel_result = await self._place_order_cancel(order=order, market_symbol=market_symbol) + except Exception as e: + if "Order is not active" in str(e): + new_order_state = OrderState.CANCELED + else: + raise else: - success = False - - return success - - async def order_updates_from_account(self, from_time: float) -> List[OrderUpdate]: - order_updates = [] - async with self._throttler.execute_task(limit_id=CONSTANTS.BATCH_ORDER_UPDATES_LIMIT_ID): - response = await self._query_executor.list_order_history_by_account( - main_account=self._user_proxy_address, - from_time=from_time, - to_time=self._time(), - ) + new_order_state = OrderState.PENDING_CANCEL if cancel_result["cancel_order"] else order.current_state - for order_info in response["listOrderHistorybyMainAccount"]["items"]: - new_state = CONSTANTS.ORDER_STATE[order_info["st"]] - filled_amount = Decimal(order_info["fq"]) - if new_state == OrderState.OPEN and filled_amount > 0: - new_state = OrderState.PARTIALLY_FILLED - order_update = OrderUpdate( - client_order_id=order_info["cid"], - exchange_order_id=order_info["id"], - trading_pair=order_info["m"], - update_timestamp=self._time(), - new_state=new_state, - ) - order_updates.append(order_update) - - return order_updates + return new_order_state async def order_update(self, order: InFlightOrder, market_symbol: str) -> OrderUpdate: async with self._throttler.execute_task(limit_id=CONSTANTS.ORDER_UPDATE_LIMIT_ID): @@ -396,23 +383,102 @@ async def order_update(self, order: InFlightOrder, market_symbol: str) -> OrderU ) return order_update + async def get_all_fills( + self, from_timestamp: float, to_timestamp: float, orders: List[InFlightOrder] + ) -> List[TradeUpdate]: + trade_updates = [] + + async with self._throttler.execute_task(limit_id=CONSTANTS.ALL_FILLS_LIMIT_ID): + fills = await self._query_executor.get_order_fills_by_main_account( + from_timestamp=from_timestamp, to_timestamp=to_timestamp, main_account=self._user_proxy_address + ) + + exchange_order_id_to_order = {order.exchange_order_id: order for order in orders} + for fill in fills["listTradesByMainAccount"]["items"]: + exchange_trading_pair = fill["m"] + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol( + symbol=exchange_trading_pair + ) + + price = Decimal(fill["p"]) + size = Decimal(fill["q"]) + order = exchange_order_id_to_order.get(fill["m_id"], None) + if order is None: + order = exchange_order_id_to_order.get(fill["t_id"], None) + if order is not None: + exchange_order_id = order.exchange_order_id + client_order_id = order.client_order_id + + fee = await self._build_fee_for_event(event=fill, trade_type=order.trade_type) + trade_updates.append( + TradeUpdate( + trade_id=fill["trade_id"], + client_order_id=client_order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + fill_timestamp=int(fill["t"]) * 1e-3, + fill_price=price, + fill_base_amount=size, + fill_quote_amount=price * size, + fee=fee, + ) + ) + + return trade_updates + + async def _place_order_cancel(self, order: InFlightOrder, market_symbol: str) -> Dict[str, Any]: + cancel_request = self._build_substrate_request_with_retries( + type_string="H256", encode_value=order.exchange_order_id + ) + signature = self._keypair.sign(cancel_request) + + async with self._throttler.execute_task(limit_id=CONSTANTS.CANCEL_ORDER_LIMIT_ID): + cancel_result = await self._query_executor.cancel_order( + order_id=order.exchange_order_id, + market_symbol=market_symbol, + main_address=self._user_main_address, + proxy_address=self._user_proxy_address, + signature={"Sr25519": signature.hex()}, + ) + + return cancel_result + + def _build_substrate_request_with_retries( + self, type_string: str, encode_value: Any, retries_left: int = 1 + ) -> ScaleBytes: + try: + request = self._substrate_interface.create_scale_object(type_string=type_string).encode(value=encode_value) + except BrokenPipeError: + self.logger().exception("Rebuilding the substrate interface.") + if retries_left == 0: + raise + self._substrate_interface = self._build_substrate_interface() + request = self._build_substrate_request_with_retries( + type_string=type_string, encode_value=encode_value, retries_left=retries_left - 1 + ) + return request + + def _build_substrate_interface(self) -> SubstrateInterface: + substrate_interface = SubstrateInterface( + url=CONSTANTS.BLOCKCHAIN_URLS[self._domain], + ss58_format=CONSTANTS.POLKADEX_SS58_PREFIX, + type_registry=CONSTANTS.CUSTOM_TYPES, + auto_discover=False, + ) + return substrate_interface + def _process_order_book_event(self, event: Dict[str, Any], market_symbol: str): + safe_ensure_future(self._process_order_book_event_async(event=event, market_symbol=market_symbol)) + + async def _process_order_book_event_async(self, event: Dict[str, Any], market_symbol: str): diff_data = json.loads(event["websocket_streams"]["data"]) timestamp = self._time() - update_id = -1 - bids = [] - asks = [] - - for diff_update in diff_data["changes"]: - update_id = max(update_id, diff_update[3]) - price_amount_pair = (diff_update[1], diff_update[2]) - if diff_update[0] == "Bid": - bids.append(price_amount_pair) - else: - asks.append(price_amount_pair) + update_id = diff_data["i"] + asks = [(Decimal(price), Decimal(amount)) for price, amount in diff_data["a"].items()] + bids = [(Decimal(price), Decimal(amount)) for price, amount in diff_data["b"].items()] order_book_message_content = { - "trading_pair": market_symbol, + "trading_pair": await self._connector.trading_pair_associated_to_exchange_symbol(symbol=market_symbol), "update_id": update_id, "bids": bids, "asks": asks, @@ -422,19 +488,21 @@ def _process_order_book_event(self, event: Dict[str, Any], market_symbol: str): content=order_book_message_content, timestamp=timestamp, ) - self._publisher.trigger_event( - event_tag=PolkadexOrderBookEvent.OrderBookDataSourceUpdateEvent, message=diff_message - ) + self._publisher.trigger_event(event_tag=OrderBookEvent.OrderBookDataSourceUpdateEvent, message=diff_message) def _process_recent_trades_event(self, event: Dict[str, Any]): + safe_ensure_future(self._process_recent_trades_event_async(event=event)) + + async def _process_recent_trades_event_async(self, event: Dict[str, Any]): trade_data = json.loads(event["websocket_streams"]["data"]) - symbol = trade_data["m"] + exchange_trading_pair = trade_data["m"] + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=exchange_trading_pair) timestamp = int(trade_data["t"]) * 1e-3 - trade_type = float(TradeType.SELL.value) # Unfortunately Polkadex does not indicate the trade side + trade_type = float(self._hummingbot_trade_type[trade_data["m_side"]].value) message_content = { - "trade_id": trade_data["tid"], - "trading_pair": symbol, + "trade_id": trade_data["trade_id"], + "trading_pair": trading_pair, "trade_type": trade_type, "amount": Decimal(str(trade_data["q"])), "price": Decimal(str(trade_data["p"])), @@ -444,9 +512,7 @@ def _process_recent_trades_event(self, event: Dict[str, Any]): content=message_content, timestamp=timestamp, ) - self._publisher.trigger_event( - event_tag=PolkadexOrderBookEvent.PublicTradeEvent, message=trade_message - ) + self._publisher.trigger_event(event_tag=OrderBookEvent.TradeEvent, message=trade_message) def _process_private_event(self, event: Dict[str, Any]): event_data = json.loads(event["websocket_streams"]["data"]) @@ -455,6 +521,8 @@ def _process_private_event(self, event: Dict[str, Any]): safe_ensure_future(self._process_balance_event(event=event_data)) elif event_data["type"] == "Order": safe_ensure_future(self._process_private_order_update_event(event=event_data)) + elif event_data["type"] == "TradeFormat": + safe_ensure_future(self._process_private_trade_event(event=event_data)) async def _process_balance_event(self, event: Dict[str, Any]): self._last_received_message_time = self._time() @@ -475,45 +543,63 @@ async def _process_balance_event(self, event: Dict[str, Any]): async def _process_private_order_update_event(self, event: Dict[str, Any]): self._last_received_message_time = self._time() - client_order_id = event["client_order_id"] exchange_order_id = event["id"] - trading_pair = event["pair"] - fee_amount = Decimal(event["fee"]) - fill_price = Decimal(event["avg_filled_price"]) + base = event["pair"]["base"]["asset"] + quote = event["pair"]["quote"]["asset"] + trading_pair = combine_to_hb_trading_pair(base=self._assets_map[base], quote=self._assets_map[quote]) fill_amount = Decimal(event["filled_quantity"]) - fill_quote_amount = Decimal(event["filled_quantity"]) + order_state = CONSTANTS.ORDER_STATE[event["status"]] - fee = TradeFeeBase.new_spot_fee( - fee_schema=TradeFeeSchema(), - trade_type=TradeType.BUY if event["side"] == "Bid" else TradeType.SELL, - flat_fees=[TokenAmount(amount=fee_amount, token=None)], + if order_state == OrderState.OPEN and fill_amount > 0: + order_state = OrderState.PARTIALLY_FILLED + order_update = OrderUpdate( + trading_pair=trading_pair, + update_timestamp=event["stid"], + new_state=order_state, + client_order_id=event["client_order_id"], + exchange_order_id=exchange_order_id, ) + self._publisher.trigger_event(event_tag=MarketEvent.OrderUpdate, message=order_update) + + async def _process_private_trade_event(self, event: Dict[str, Any]): + exchange_trading_pair = event["m"] + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=exchange_trading_pair) + price = Decimal(event["p"]) + size = Decimal(event["q"]) + trade_type = self._hummingbot_trade_type[event["s"]] + fee = await self._build_fee_for_event(event=event, trade_type=trade_type) trade_update = TradeUpdate( - trade_id=str(event["event_id"]), - client_order_id=client_order_id, - exchange_order_id=exchange_order_id, + trade_id=event["trade_id"], + client_order_id=event["cid"], + exchange_order_id=event["order_id"], trading_pair=trading_pair, fill_timestamp=self._time(), - fill_price=fill_price, - fill_base_amount=fill_amount, - fill_quote_amount=fill_quote_amount, + fill_price=price, + fill_base_amount=size, + fill_quote_amount=price * size, fee=fee, ) self._publisher.trigger_event(event_tag=MarketEvent.TradeUpdate, message=trade_update) - client_order_id = event["client_order_id"] - order_state = CONSTANTS.ORDER_STATE[event["status"]] - if order_state == OrderState.OPEN and fill_amount > 0: - order_state = OrderState.PARTIALLY_FILLED - order_update = OrderUpdate( - trading_pair=trading_pair, - update_timestamp=self._time(), - new_state=order_state, - client_order_id=client_order_id, - exchange_order_id=event["id"], + async def _build_fee_for_event(self, event: Dict[str, Any], trade_type: TradeType) -> TradeFeeBase: + """Builds a TradeFee object from the given event data.""" + exchange_trading_pair = event["m"] + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol(symbol=exchange_trading_pair) + _, quote = split_hb_trading_pair(trading_pair=trading_pair) + fee = TradeFeeBase.new_spot_fee( + fee_schema=self._connector.trade_fee_schema(), + trade_type=trade_type, + percent_token=quote, + flat_fees=[TokenAmount(token=quote, amount=Decimal("0"))], # feels will be zero for the foreseeable future ) - self._publisher.trigger_event(event_tag=MarketEvent.OrderUpdate, message=order_update) + return fee def _time(self): return time.time() + + @staticmethod + def normalize_fraction(decimal_value: Decimal) -> Decimal: + normalized = decimal_value.normalize() + sign, digit, exponent = normalized.as_tuple() + return normalized if exponent <= 0 else normalized.quantize(1) diff --git a/hummingbot/connector/exchange/polkadex/polkadex_events.py b/hummingbot/connector/exchange/polkadex/polkadex_events.py deleted file mode 100644 index 42f27e1081..0000000000 --- a/hummingbot/connector/exchange/polkadex/polkadex_events.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum - - -class PolkadexOrderBookEvent(int, Enum): - OrderBookDataSourceUpdateEvent = 904 - PublicTradeEvent = 905 diff --git a/hummingbot/connector/exchange/polkadex/polkadex_exchange.py b/hummingbot/connector/exchange/polkadex/polkadex_exchange.py index 5a7f321150..774ee9bf27 100644 --- a/hummingbot/connector/exchange/polkadex/polkadex_exchange.py +++ b/hummingbot/connector/exchange/polkadex/polkadex_exchange.py @@ -1,4 +1,5 @@ import asyncio +import math from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from _decimal import Decimal @@ -9,15 +10,16 @@ from hummingbot.connector.exchange.polkadex.polkadex_data_source import PolkadexDataSource from hummingbot.connector.exchange_py_base import ExchangePyBase from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import get_new_client_order_id from hummingbot.core.api_throttler.data_types import RateLimit from hummingbot.core.data_type.common import OrderType, TradeType -from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState, OrderUpdate, TradeUpdate from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource from hummingbot.core.event.event_forwarder import EventForwarder from hummingbot.core.event.events import AccountEvent, BalanceUpdateEvent, MarketEvent -from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.network_iterator import NetworkStatus, safe_ensure_future from hummingbot.core.utils.estimate_fee import build_trade_fee from hummingbot.core.web_assistant.auth import AuthBase from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory @@ -36,12 +38,13 @@ def __init__( trading_pairs: Optional[List[str]] = None, trading_required: bool = True, domain: str = CONSTANTS.DEFAULT_DOMAIN, - shallow_order_book: bool = False, # Polkadex can't support shallow order book because (no ticker endpoint) ): self._trading_required = trading_required self._trading_pairs = trading_pairs self._domain = domain - self._data_source = PolkadexDataSource(seed_phrase=polkadex_seed_phrase, domain=self._domain) + self._data_source = PolkadexDataSource( + connector=self, seed_phrase=polkadex_seed_phrase, domain=self._domain, trading_required=trading_required + ) super().__init__(client_config_map=client_config_map) self._data_source.configure_throttler(throttler=self._throttler) self._forwarders = [] @@ -111,7 +114,6 @@ async def stop_network(self): """ await super().stop_network() await self._data_source.stop() - self._forwarders = [] def supported_order_types(self) -> List[OrderType]: return [OrderType.LIMIT, OrderType.MARKET] @@ -128,26 +130,107 @@ async def check_network(self) -> NetworkStatus: status = NetworkStatus.NOT_CONNECTED return status + # === Orders placing === + + def buy(self, + trading_pair: str, + amount: Decimal, + order_type=OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs) -> str: + """ + Creates a promise to create a buy order using the parameters + + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + + :return: the id assigned by the connector to the order (the client id) + """ + order_id = get_new_client_order_id( + is_buy=True, + trading_pair=trading_pair, + hbot_order_id_prefix=self.client_order_id_prefix, + max_id_len=self.client_order_id_max_length + ) + hex_order_id = f"0x{order_id.encode('utf-8').hex()}" + safe_ensure_future(self._create_order( + trade_type=TradeType.BUY, + order_id=hex_order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price, + **kwargs)) + return hex_order_id + + def sell(self, + trading_pair: str, + amount: Decimal, + order_type: OrderType = OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs) -> str: + """ + Creates a promise to create a sell order using the parameters. + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + :return: the id assigned by the connector to the order (the client id) + """ + order_id = get_new_client_order_id( + is_buy=False, + trading_pair=trading_pair, + hbot_order_id_prefix=self.client_order_id_prefix, + max_id_len=self.client_order_id_max_length + ) + hex_order_id = f"0x{order_id.encode('utf-8').hex()}" + safe_ensure_future(self._create_order( + trade_type=TradeType.SELL, + order_id=hex_order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price, + **kwargs)) + return hex_order_id + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception) -> bool: # Polkadex does not use a time synchronizer return False def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: - return "Order not found" in str(status_update_exception) + return CONSTANTS.ORDER_NOT_FOUND_MESSAGE in str(status_update_exception) def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: - return str(CONSTANTS.ORDER_NOT_FOUND_ERROR_CODE) in str( - cancelation_exception - ) and CONSTANTS.ORDER_NOT_FOUND_MESSAGE in str(cancelation_exception) + return CONSTANTS.ORDER_NOT_FOUND_MESSAGE in str(cancelation_exception) + + async def _execute_order_cancel_and_process_update(self, order: InFlightOrder) -> bool: + new_order_state = await self._place_cancel(order.client_order_id, order) + cancelled = new_order_state in [OrderState.CANCELED, OrderState.PENDING_CANCEL] + if cancelled: + update_timestamp = self.current_timestamp + if update_timestamp is None or math.isnan(update_timestamp): + update_timestamp = self._time() + order_update: OrderUpdate = OrderUpdate( + client_order_id=order.client_order_id, + trading_pair=order.trading_pair, + update_timestamp=update_timestamp, + new_state=new_order_state, + ) + self._order_tracker.process_order_update(order_update) + return cancelled - async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder) -> bool: + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder) -> OrderState: await tracked_order.get_exchange_order_id() market_symbol = await self.exchange_symbol_associated_to_pair(trading_pair=tracked_order.trading_pair) - await self._data_source.cancel_order( + new_order_state = await self._data_source.cancel_order( order=tracked_order, market_symbol=market_symbol, timestamp=self.current_timestamp ) - return True + + return new_order_state async def _place_order( self, @@ -219,12 +302,32 @@ async def _update_balances(self): self._account_balances[token_balance_info["token_name"]] = token_balance_info["total_balance"] self._account_available_balances[token_balance_info["token_name"]] = token_balance_info["available_balance"] + async def _update_orders_fills(self, orders: List[InFlightOrder]): + try: + if len(orders) != 0: + minimum_creation_timestamp = min([order.creation_timestamp for order in orders]) + current_timestamp = self.current_timestamp + trade_updates = await self._data_source.get_all_fills( + from_timestamp=minimum_creation_timestamp, + to_timestamp=current_timestamp, + orders=orders, + ) + + for trade_update in trade_updates: + self._order_tracker.process_trade_update(trade_update=trade_update) + + except asyncio.CancelledError: + raise + except Exception: + self.logger().warning("Error fetching trades updates.") + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: - # Polkadex does not provide an endpoint to get trades. They have to be processed from the stream updates - return [] + # not used + raise NotImplementedError async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: symbol = await self.exchange_symbol_associated_to_pair(tracked_order.trading_pair) + await tracked_order.get_exchange_order_id() order_update = await self._data_source.order_update(order=tracked_order, market_symbol=symbol) return order_update @@ -270,20 +373,12 @@ async def _initialize_trading_pair_symbol_map(self): async def _update_trading_rules(self): trading_rules_list = await self._data_source.all_trading_rules() - self._trading_rules.clear() - for trading_rule in trading_rules_list: - trading_pair = await self.trading_pair_associated_to_exchange_symbol(trading_rule.trading_pair) - new_trading_rule = TradingRule( - trading_pair=trading_pair, - min_order_size=trading_rule.min_order_size, - max_order_size=trading_rule.max_order_size, - min_price_increment=trading_rule.min_price_increment, - min_base_amount_increment=trading_rule.min_base_amount_increment, - min_quote_amount_increment=trading_rule.min_quote_amount_increment, - min_notional_size=trading_rule.min_notional_size, - min_order_value=trading_rule.min_order_value, - ) - self._trading_rules[trading_pair] = new_trading_rule + self._trading_rules = {trading_rule.trading_pair: trading_rule for trading_rule in trading_rules_list} + + async def _get_all_pairs_prices(self) -> Dict[str, Any]: + # Polkadex is configured to not be a price provider (check is_price_provider) + # This method should never be called + raise NotImplementedError # pragma: no cover async def _get_last_traded_price(self, trading_pair: str) -> float: symbol = await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) @@ -308,9 +403,8 @@ def _process_balance_event(self, event: BalanceUpdateEvent): self._account_available_balances[event.asset_name] = event.available_balance def _process_user_order_update(self, order_update: OrderUpdate): - tracked_order = self._order_tracker.all_updatable_orders_by_exchange_order_id.get( - order_update.exchange_order_id - ) + tracked_order = self._order_tracker.all_updatable_orders.get(order_update.client_order_id) + if tracked_order is not None: self.logger().debug(f"Processing order update {order_update}\nUpdatable order {tracked_order.to_json()}") order_update_to_process = OrderUpdate( diff --git a/hummingbot/connector/exchange/polkadex/polkadex_query_executor.py b/hummingbot/connector/exchange/polkadex/polkadex_query_executor.py index 74ac694bf9..7b288fd029 100644 --- a/hummingbot/connector/exchange/polkadex/polkadex_query_executor.py +++ b/hummingbot/connector/exchange/polkadex/polkadex_query_executor.py @@ -40,6 +40,12 @@ async def recent_trades(self, market_symbol: str, limit: int) -> Dict[str, Any]: async def get_all_balances_by_main_account(self, main_account: str) -> Dict[str, Any]: raise NotImplementedError # pragma: no cover + @abstractmethod + async def get_order_fills_by_main_account( + self, from_timestamp: float, to_timestamp: float, main_account: str + ) -> Dict[str, Any]: + raise NotImplementedError # pragma: no cover + @abstractmethod async def place_order(self, polkadex_order: Dict[str, Any], signature: Dict[str, Any]) -> Dict[str, Any]: raise NotImplementedError # pragma: no cover @@ -50,6 +56,7 @@ async def cancel_order( order_id: str, market_symbol: str, proxy_address: str, + main_address: str, signature: Dict[str, Any], ) -> Dict[str, Any]: raise NotImplementedError # pragma: no cover @@ -64,6 +71,10 @@ async def list_order_history_by_account( async def find_order_by_main_account(self, main_account: str, market_symbol: str, order_id: str) -> Dict[str, Any]: raise NotImplementedError # pragma: no cover + @abstractmethod + async def list_open_orders_by_main_account(self, main_account: str) -> Dict[str, Any]: + raise NotImplementedError # pragma: no cover + @abstractmethod async def listen_to_orderbook_updates(self, events_handler: Callable, market_symbol: str): raise NotImplementedError # pragma: no cover @@ -94,7 +105,7 @@ def __init__(self, auth: AppSyncAuthentication, domain: Optional[str] = CONSTANT async def all_assets(self): query = gql( """ - query MyQuery { + query GetAllAssets { getAllAssets { items { asset_id @@ -115,14 +126,14 @@ async def all_markets(self): query MyQuery { getAllMarkets { items { - base_asset_precision market max_order_price - max_order_qty min_order_price min_order_qty + max_order_qty price_tick_size qty_step_size + base_asset_precision quote_asset_precision } } @@ -144,7 +155,9 @@ async def get_orderbook(self, market_symbol: str) -> Dict[str, Any]: p q s + stid } + nextToken } } """ @@ -160,16 +173,19 @@ async def main_account_from_proxy(self, proxy_account=str) -> str: """ query findUserByProxyAccount($proxy_account: String!) { findUserByProxyAccount(proxy_account: $proxy_account) { - items + items { + hash_key + range_key + stid + } } } """ ) - parameters = {"proxy_account": proxy_account} result = await self._execute_query(query=query, parameters=parameters) - main_account = result["findUserByProxyAccount"]["items"][0].split(",")[2][11:-1] + main_account = result["findUserByProxyAccount"]["items"][0]["range_key"] return main_account async def recent_trades(self, market_symbol: str, limit: int) -> Dict[str, Any]: @@ -183,7 +199,6 @@ async def recent_trades(self, market_symbol: str, limit: int) -> Dict[str, Any]: p q t - sid } } } @@ -198,7 +213,7 @@ async def recent_trades(self, market_symbol: str, limit: int) -> Dict[str, Any]: async def get_all_balances_by_main_account(self, main_account: str) -> Dict[str, Any]: query = gql( """ - query getAllBalancesByMainAccount($main: String!) { + query GetAllBalancesByMainAccount($main: String!) { getAllBalancesByMainAccount(main_account: $main) { items { a @@ -215,11 +230,56 @@ async def get_all_balances_by_main_account(self, main_account: str) -> Dict[str, result = await self._execute_query(query=query, parameters=parameters) return result + async def get_order_fills_by_main_account( + self, from_timestamp: float, to_timestamp: float, main_account: str + ) -> Dict[str, Any]: + query = gql( + """ + query listTradesByMainAccount( + $main_account:String! + $limit: Int + $from: AWSDateTime! + $to: AWSDateTime! + $nextToken: String + ) { + listTradesByMainAccount( + main_account: $main_account + from: $from + to: $to + limit: $limit + nextToken: $nextToken + ) { + items { + isReverted + m + m_id + p + q + stid + t + t_id + trade_id + } + } + } + """ + ) + + parameters = { + "main_account": main_account, + "from": self._timestamp_to_aws_datetime_string(timestamp=from_timestamp), + "to": self._timestamp_to_aws_datetime_string(timestamp=to_timestamp), + } + + result = await self._execute_query(query=query, parameters=parameters) + + return result + async def place_order(self, polkadex_order: Dict[str, Any], signature: Dict[str, Any]) -> Dict[str, Any]: query = gql( """ - mutation PlaceOrder($input: UserActionInput!) { - place_order(input: $input) + mutation PlaceOrder($payload: String!) { + place_order(input: {payload: $payload}) } """ ) @@ -228,7 +288,7 @@ async def place_order(self, polkadex_order: Dict[str, Any], signature: Dict[str, polkadex_order, signature, ] - parameters = {"input": {"payload": json.dumps({"PlaceOrder": input_parameters})}} + parameters = {"payload": json.dumps({"PlaceOrder": input_parameters})} result = await self._execute_query(query=query, parameters=parameters) return result @@ -237,24 +297,26 @@ async def cancel_order( self, order_id: str, market_symbol: str, + main_address: str, proxy_address: str, signature: Dict[str, Any], ) -> Dict[str, Any]: query = gql( """ - mutation CancelOrder($input: UserActionInput!) { - cancel_order(input: $input) + mutation CancelOrder($payload: String!) { + cancel_order(input: {payload: $payload}) } """ ) input_parameters = [ order_id, + main_address, proxy_address, market_symbol, signature, ] - parameters = {"input": {"payload": json.dumps({"CancelOrder": input_parameters})}} + parameters = {"payload": json.dumps({"CancelOrder": input_parameters})} result = await self._execute_query(query=query, parameters=parameters) return result @@ -264,24 +326,35 @@ async def list_order_history_by_account( ) -> Dict[str, Any]: query = gql( """ - query ListOrderHistory($main_account: String!, $to: AWSDateTime!, $from: AWSDateTime!) { - listOrderHistorybyMainAccount(main_account: $main_account, to: $to, from: $from) { + query ListOrderHistory( + $main_account:String! + $limit: Int + $from: AWSDateTime! + $to: AWSDateTime! + $nextToken: String + ) { + listOrderHistorybyMainAccount( + main_account: $main_account + from: $from + to: $to + limit: $limit + nextToken: $nextToken + ) { items { - afp + u cid - fee - fq id - isReverted + t m + s ot + st p q - s - sid - st - t - u + afp + fq + fee + isReverted } } } @@ -290,8 +363,8 @@ async def list_order_history_by_account( parameters = { "main_account": main_account, - "to": datetime.utcfromtimestamp(to_time).isoformat(timespec="milliseconds") + "Z", - "from": datetime.utcfromtimestamp(from_time).isoformat(timespec="milliseconds") + "Z", + "to": self._timestamp_to_aws_datetime_string(timestamp=to_time), + "from": self._timestamp_to_aws_datetime_string(timestamp=from_time), } result = await self._execute_query(query=query, parameters=parameters) @@ -313,8 +386,8 @@ async def find_order_by_main_account(self, main_account: str, market_symbol: str p q s - sid st + stid t u } @@ -331,6 +404,38 @@ async def find_order_by_main_account(self, main_account: str, market_symbol: str result = await self._execute_query(query=query, parameters=parameters) return result + async def list_open_orders_by_main_account(self, main_account: str) -> Dict[str, Any]: + query = gql( + """ + query ListOpenOrdersByMainAccount($main_account: String!, $limit: Int, $nextToken: String) { + listOpenOrdersByMainAccount(main_account: $main_account, limit: $limit, nextToken: $nextToken) { + items { + u + cid + id + t + m + s + ot + st + p + q + afp + fq + fee + stid + isReverted + } + } + } + """ + ) + + parameters = {"main_account": main_account} + + result = await self._execute_query(query=query, parameters=parameters) + return result + async def listen_to_orderbook_updates(self, events_handler: Callable, market_symbol: str): while True: try: @@ -395,3 +500,8 @@ async def _subscribe_to_stream(self, stream_name: str) -> AsyncIterable: async with Client(transport=transport, fetch_schema_from_transport=False) as session: async for result in session.subscribe(query, variable_values=variables, parse_result=True): yield result + + @staticmethod + def _timestamp_to_aws_datetime_string(timestamp: float) -> str: + timestamp_string = datetime.utcfromtimestamp(timestamp).isoformat(timespec="milliseconds") + "Z" + return timestamp_string diff --git a/hummingbot/connector/exchange/polkadex/polkadex_utils.py b/hummingbot/connector/exchange/polkadex/polkadex_utils.py index 6bc97f5d74..afe091f6ab 100644 --- a/hummingbot/connector/exchange/polkadex/polkadex_utils.py +++ b/hummingbot/connector/exchange/polkadex/polkadex_utils.py @@ -9,14 +9,16 @@ EXAMPLE_PAIR = "PDEX-1" DEFAULT_FEES = TradeFeeSchema( - maker_percent_fee_decimal=Decimal("0.002"), - taker_percent_fee_decimal=Decimal("0.002"), + maker_percent_fee_decimal=Decimal("0"), + taker_percent_fee_decimal=Decimal("0"), ) def normalized_asset_name(asset_id: str, asset_name: str) -> str: name = asset_name if asset_id.isdigit() else asset_id name = name.replace("CHAINBRIDGE-", "C") + name = name.replace("TEST DEX", "TDEX") + name = name.replace("TEST BRIDGE", "TBRI") return name diff --git a/hummingbot/connector/exchange/woo_x/__init__.py b/hummingbot/connector/exchange/woo_x/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/exchange/woo_x/dummy.pxd b/hummingbot/connector/exchange/woo_x/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/woo_x/dummy.pyx b/hummingbot/connector/exchange/woo_x/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/exchange/woo_x/woo_x_api_order_book_data_source.py b/hummingbot/connector/exchange/woo_x/woo_x_api_order_book_data_source.py new file mode 100644 index 0000000000..d9301455f9 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_api_order_book_data_source.py @@ -0,0 +1,189 @@ +import asyncio +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS, woo_x_web_utils as web_utils +from hummingbot.connector.exchange.woo_x.woo_x_order_book import WooXOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.woo_x.woo_x_exchange import WooXExchange + + +class WooXAPIOrderBookDataSource(OrderBookTrackerDataSource): + HEARTBEAT_TIME_INTERVAL = 30.0 + TRADE_STREAM_ID = 1 + DIFF_STREAM_ID = 2 + ONE_HOUR = 60 * 60 + + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + trading_pairs: List[str], + connector: 'WooXExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN + ): + super().__init__(trading_pairs) + self._connector = connector + self._trade_messages_queue_key = CONSTANTS.TRADE_EVENT_TYPE + self._diff_messages_queue_key = CONSTANTS.DIFF_EVENT_TYPE + self._domain = domain + self._api_factory = api_factory + + async def get_last_traded_prices( + self, + trading_pairs: List[str], + domain: Optional[str] = None + ) -> Dict[str, float]: + return await self._connector.get_last_traded_prices(trading_pairs=trading_pairs) + + async def _request_order_book_snapshot(self, trading_pair: str) -> Dict[str, Any]: + """ + Retrieves a copy of the full order book from the exchange, for a particular trading pair. + + :param trading_pair: the trading pair for which the order book will be retrieved + + :return: the response from the exchange (JSON dictionary) + """ + + rest_assistant = await self._api_factory.get_rest_assistant() + + data = await rest_assistant.execute_request( + url=web_utils.public_rest_url( + path_url=f"{CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL}/{await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair)}", + domain=self._domain + ), + method=RESTMethod.GET, + throttler_limit_id=CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL, + ) + + return data + + async def _subscribe_channels(self, ws: WSAssistant): + """ + Subscribes to the trade events and diff orders events through the provided websocket connection. + :param ws: the websocket assistant used to connect to the exchange + """ + try: + channels = ['trade', 'orderbookupdate'] + + topics = [] + + for trading_pair in self._trading_pairs: + symbol = await self._connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + + for channel in channels: + topics.append(f"{symbol}@{channel}") + + payloads = [ + { + "id": str(i), + "topic": topic, + "event": "subscribe" + } + for i, topic in enumerate(topics) + ] + + await asyncio.gather(*[ + ws.send(WSJSONRequest(payload=payload)) for payload in payloads + ]) + + self.logger().info("Subscribed to public order book and trade channels...") + except asyncio.CancelledError: + raise + except Exception: + self.logger().error( + "Unexpected error occurred subscribing to order book trading and delta streams...", + exc_info=True + ) + + raise + + async def _process_websocket_messages(self, websocket_assistant: WSAssistant): + async def ping(): + await websocket_assistant.send(WSJSONRequest(payload={'event': 'ping'})) + + async for ws_response in websocket_assistant.iter_messages(): + data: Dict[str, Any] = ws_response.data + + if data.get('event') == 'ping': + asyncio.ensure_future(ping()) + + if data is not None: # data will be None when the websocket is disconnected + channel: str = self._channel_originating_message(event_message=data) + valid_channels = self._get_messages_queue_keys() + if channel in valid_channels: + self._message_queue[channel].put_nowait(data) + else: + await self._process_message_for_unknown_channel( + event_message=data, websocket_assistant=websocket_assistant + ) + + async def _connected_websocket_assistant(self) -> WSAssistant: + ws: WSAssistant = await self._api_factory.get_ws_assistant() + + await ws.connect( + ws_url=web_utils.wss_public_url(self._domain).format(self._connector.application_id), + ping_timeout=CONSTANTS.WS_HEARTBEAT_TIME_INTERVAL + ) + + return ws + + async def _order_book_snapshot(self, trading_pair: str) -> OrderBookMessage: + snapshot: Dict[str, Any] = await self._request_order_book_snapshot(trading_pair) + + snapshot_timestamp: int = snapshot['timestamp'] + + snapshot_msg: OrderBookMessage = WooXOrderBook.snapshot_message_from_exchange( + snapshot, + snapshot_timestamp, + metadata={"trading_pair": trading_pair} + ) + + return snapshot_msg + + async def _parse_trade_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol( + symbol=raw_message['topic'].split('@')[0] + ) + + trade_message = WooXOrderBook.trade_message_from_exchange( + raw_message, + {"trading_pair": trading_pair} + ) + + message_queue.put_nowait(trade_message) + + async def _parse_order_book_diff_message(self, raw_message: Dict[str, Any], message_queue: asyncio.Queue): + trading_pair = await self._connector.trading_pair_associated_to_exchange_symbol( + symbol=raw_message['topic'].split('@')[0] + ) + + order_book_message: OrderBookMessage = WooXOrderBook.diff_message_from_exchange( + raw_message, + raw_message['ts'], + {"trading_pair": trading_pair} + ) + + message_queue.put_nowait(order_book_message) + + def _channel_originating_message(self, event_message: Dict[str, Any]) -> str: + channel = "" + + if "topic" in event_message: + channel = event_message.get("topic").split('@')[1] + + relations = { + CONSTANTS.DIFF_EVENT_TYPE: self._diff_messages_queue_key, + CONSTANTS.TRADE_EVENT_TYPE: self._trade_messages_queue_key + } + + channel = relations.get(channel, "") + + return channel diff --git a/hummingbot/connector/exchange/woo_x/woo_x_api_user_stream_data_source.py b/hummingbot/connector/exchange/woo_x/woo_x_api_user_stream_data_source.py new file mode 100644 index 0000000000..a2c6f9bde6 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_api_user_stream_data_source.py @@ -0,0 +1,110 @@ +import asyncio +import json +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS, woo_x_web_utils as web_utils +from hummingbot.connector.exchange.woo_x.woo_x_auth import WooXAuth +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.web_assistant.connections.data_types import WSJSONRequest +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.core.web_assistant.ws_assistant import WSAssistant +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.connector.exchange.woo_x.woo_x_exchange import WooXExchange + + +class WooXAPIUserStreamDataSource(UserStreamTrackerDataSource): + LISTEN_KEY_KEEP_ALIVE_INTERVAL = 1800 # Recommended to Ping/Update listen key to keep connection alive + + HEARTBEAT_TIME_INTERVAL = 30 + + _logger: Optional[HummingbotLogger] = None + + def __init__( + self, + auth: WooXAuth, + trading_pairs: List[str], + connector: 'WooXExchange', + api_factory: WebAssistantsFactory, + domain: str = CONSTANTS.DEFAULT_DOMAIN + ): + super().__init__() + + self._auth: WooXAuth = auth + self._trading_pairs = trading_pairs + self._connector = connector + self._api_factory = api_factory + self._domain = domain + + async def _connected_websocket_assistant(self) -> WSAssistant: + """ + Creates an instance of WSAssistant connected to the exchange + """ + websocket_assistant = await self._api_factory.get_ws_assistant() + + await websocket_assistant.connect( + ws_url=web_utils.wss_private_url(self._domain).format(self._connector.application_id), + message_timeout=CONSTANTS.SECONDS_TO_WAIT_TO_RECEIVE_MESSAGE + ) + + timestamp = int(time.time() * 1e3) + + await websocket_assistant.send(WSJSONRequest(payload={ + 'id': 'auth', + 'event': 'auth', + 'params': { + 'apikey': self._connector.api_key, + 'sign': self._auth.signature(timestamp), + 'timestamp': timestamp + } + })) + + response = await websocket_assistant.receive() + + if not response.data['success']: + self.logger().error(f"Error authenticating the private websocket connection: {json.dumps(response.data)}") + + raise IOError("Private websocket connection authentication failed") + + return websocket_assistant + + async def _subscribe_channels(self, websocket_assistant: WSAssistant): + """ + Subscribes to the trade events and diff orders events through the provided websocket connection. + + :param websocket_assistant: the websocket assistant used to connect to the exchange + """ + + channels = ['executionreport', 'balance'] + + for channel in channels: + await websocket_assistant.send(WSJSONRequest(payload={ + "id": channel, + "topic": channel, + "event": "subscribe" + })) + + response = await websocket_assistant.receive() + + if not response.data['success']: + raise IOError(f"Error subscribing to the {channel} channel: {json.dumps(response)}") + + self.logger().info("Subscribed to private account and orders channels...") + + async def _process_websocket_messages(self, websocket_assistant: WSAssistant, queue: asyncio.Queue): + async def ping(): + await websocket_assistant.send(WSJSONRequest(payload={'event': 'ping'})) + + async for ws_response in websocket_assistant.iter_messages(): + data = ws_response.data + + if data.get('event') == 'ping': + asyncio.ensure_future(ping()) + + await self._process_event_message(event_message=data, queue=queue) + + async def _process_event_message(self, event_message: Dict[str, Any], queue: asyncio.Queue): + if len(event_message) > 0: + queue.put_nowait(event_message) diff --git a/hummingbot/connector/exchange/woo_x/woo_x_auth.py b/hummingbot/connector/exchange/woo_x/woo_x_auth.py new file mode 100644 index 0000000000..30fcb4ac37 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_auth.py @@ -0,0 +1,58 @@ +import hashlib +import hmac +import json +from typing import Dict + +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest, WSRequest + + +class WooXAuth(AuthBase): + def __init__(self, api_key: str, secret_key: str, time_provider: TimeSynchronizer): + self.api_key = api_key + self.secret_key = secret_key + self.time_provider = time_provider + + async def rest_authenticate(self, request: RESTRequest) -> RESTRequest: + """ + Adds authentication headers to the request + Adds the server time and the signature to the request, required for authenticated interactions. It also adds + the required parameter in the request header. + :param request: the request to be configured for authenticated interaction + """ + timestamp = str(int(self.time_provider.time() * 1e3)) + + if request.method == RESTMethod.POST: + request.headers = self.headers(timestamp, **json.loads(request.data or json.dumps({}))) + + request.data = json.loads(request.data or json.dumps({})) # Allow aiohttp to send as application/x-www-form-urlencoded + else: + request.headers = self.headers(timestamp, **(request.params or {})) + + return request + + async def ws_authenticate(self, request: WSRequest) -> WSRequest: + """ + This method is intended to configure a websocket request to be authenticated. + Woo X does not use this functionality + """ + return request # pass-through + + def signature(self, timestamp, **kwargs): + signable = '&'.join([f"{key}={value}" for key, value in sorted(kwargs.items())]) + f"|{timestamp}" + + return hmac.new( + bytes(self.secret_key, "utf-8"), + bytes(signable, "utf-8"), + hashlib.sha256 + ).hexdigest().upper() + + def headers(self, timestamp, **kwargs) -> Dict[str, str]: + return { + 'x-api-timestamp': timestamp, + 'x-api-key': self.api_key, + 'x-api-signature': self.signature(timestamp, **kwargs), + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + } diff --git a/hummingbot/connector/exchange/woo_x/woo_x_constants.py b/hummingbot/connector/exchange/woo_x/woo_x_constants.py new file mode 100644 index 0000000000..d17163532d --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_constants.py @@ -0,0 +1,70 @@ +from hummingbot.core.api_throttler.data_types import RateLimit +from hummingbot.core.data_type.in_flight_order import OrderState + +DEFAULT_DOMAIN = "woo_x" + +MAX_ORDER_ID_LEN = 19 + +HBOT_ORDER_ID_PREFIX = "" + +REST_URLS = { + "woo_x": "https://api.woo.org", + "woo_x_testnet": "https://api.staging.woo.org", +} + +WSS_PUBLIC_URLS = { + "woo_x": "wss://wss.woo.org/ws/stream/{}", + "woo_x_testnet": "wss://wss.staging.woo.org/ws/stream/{}" +} + +WSS_PRIVATE_URLS = { + "woo_x": "wss://wss.woo.org/v2/ws/private/stream/{}", + "woo_x_testnet": "wss://wss.staging.woo.org/v2/ws/private/stream/{}" +} + +WS_HEARTBEAT_TIME_INTERVAL = 30 + +EXCHANGE_INFO_PATH_URL = '/v1/public/info' +MARKET_TRADES_PATH = '/v1/public/market_trades' +ORDERBOOK_SNAPSHOT_PATH_URL = '/v1/public/orderbook' +ORDER_PATH_URL = '/v1/order' +CANCEL_ORDER_PATH_URL = '/v1/client/order' +ACCOUNTS_PATH_URL = '/v2/client/holding' +GET_TRADES_BY_ORDER_ID_PATH = '/v1/order/{}/trades' +GET_ORDER_BY_CLIENT_ORDER_ID_PATH = '/v1/client/order/{}' + + +RATE_LIMITS = [ + RateLimit(limit_id=EXCHANGE_INFO_PATH_URL, limit=10, time_interval=1), + RateLimit(limit_id=CANCEL_ORDER_PATH_URL, limit=10, time_interval=1), + RateLimit(limit_id=GET_TRADES_BY_ORDER_ID_PATH, limit=10, time_interval=1), + RateLimit(limit_id=MARKET_TRADES_PATH, limit=10, time_interval=1), + RateLimit(limit_id=ORDERBOOK_SNAPSHOT_PATH_URL, limit=10, time_interval=1), + RateLimit(limit_id=ORDER_PATH_URL, limit=10, time_interval=1), + RateLimit(limit_id=ACCOUNTS_PATH_URL, limit=10, time_interval=1), + RateLimit(limit_id=GET_ORDER_BY_CLIENT_ORDER_ID_PATH, limit=10, time_interval=1) +] + +# Websocket event types +DIFF_EVENT_TYPE = "orderbookupdate" +TRADE_EVENT_TYPE = "trade" + +SECONDS_TO_WAIT_TO_RECEIVE_MESSAGE = 20 # According to the documentation this has to be less than 30 seconds + +ORDER_STATE = { + "NEW": OrderState.OPEN, + "CANCELLED": OrderState.CANCELED, + "PARTIAL_FILLED": OrderState.PARTIALLY_FILLED, + "FILLED": OrderState.FILLED, + "REJECTED": OrderState.FAILED, + "INCOMPLETE": OrderState.OPEN, + "COMPLETED": OrderState.COMPLETED, +} + +ORDER_NOT_EXIST_ERROR_CODE = -1006 + +UNKNOWN_ORDER_ERROR_CODE = -1004 + +TIME_IN_FORCE_GTC = "GTC" # Good till cancelled +TIME_IN_FORCE_IOC = "IOC" # Immediate or cancel +TIME_IN_FORCE_FOK = "FOK" # Fill or kill diff --git a/hummingbot/connector/exchange/woo_x/woo_x_exchange.py b/hummingbot/connector/exchange/woo_x/woo_x_exchange.py new file mode 100644 index 0000000000..0ade660951 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_exchange.py @@ -0,0 +1,499 @@ +import asyncio +import secrets +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +from bidict import bidict + +from hummingbot.connector.constants import s_decimal_NaN +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS, woo_x_utils, woo_x_web_utils as web_utils +from hummingbot.connector.exchange.woo_x.woo_x_api_order_book_data_source import WooXAPIOrderBookDataSource +from hummingbot.connector.exchange.woo_x.woo_x_api_user_stream_data_source import WooXAPIUserStreamDataSource +from hummingbot.connector.exchange.woo_x.woo_x_auth import WooXAuth +from hummingbot.connector.exchange_py_base import ExchangePyBase +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.order_book_tracker_data_source import OrderBookTrackerDataSource +from hummingbot.core.data_type.trade_fee import DeductedFromReturnsTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.data_type.user_stream_tracker_data_source import UserStreamTrackerDataSource +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + +if TYPE_CHECKING: + from hummingbot.client.config.config_helpers import ClientConfigAdapter + + +class WooXExchange(ExchangePyBase): + UPDATE_ORDER_STATUS_MIN_INTERVAL = 10.0 + + web_utils = web_utils + + def __init__( + self, + client_config_map: "ClientConfigAdapter", + public_api_key: str, + secret_api_key: str, + application_id: str, + trading_pairs: Optional[List[str]] = None, + trading_required: bool = True, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + ): + self.api_key = public_api_key + self.secret_key = secret_api_key + self.application_id = application_id + self._domain = domain + self._trading_required = trading_required + self._trading_pairs = trading_pairs + self._last_trades_poll_woo_x_timestamp = 1.0 + super().__init__(client_config_map) + + @staticmethod + def woo_x_order_type(order_type: OrderType) -> str: + if order_type.name == 'LIMIT_MAKER': + return 'POST_ONLY' + else: + return order_type.name.upper() + + @staticmethod + def to_hb_order_type(woo_x_type: str) -> OrderType: + return OrderType[woo_x_type] + + @property + def authenticator(self) -> WooXAuth: + return WooXAuth( + api_key=self.api_key, + secret_key=self.secret_key, + time_provider=self._time_synchronizer + ) + + @property + def name(self) -> str: + return self._domain + + @property + def rate_limits_rules(self): + return CONSTANTS.RATE_LIMITS + + @property + def domain(self): + return self._domain + + @property + def client_order_id_max_length(self): + return CONSTANTS.MAX_ORDER_ID_LEN + + @property + def client_order_id_prefix(self): + return CONSTANTS.HBOT_ORDER_ID_PREFIX + + @property + def trading_rules_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def trading_pairs_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def check_network_request_path(self): + return CONSTANTS.EXCHANGE_INFO_PATH_URL + + @property + def trading_pairs(self): + return self._trading_pairs + + @property + def is_cancel_request_in_exchange_synchronous(self) -> bool: + return True + + @property + def is_trading_required(self) -> bool: + return self._trading_required + + def supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + def _is_request_exception_related_to_time_synchronizer(self, request_exception: Exception): + error_description = str(request_exception) + + is_time_synchronizer_related = ( + "-1021" in error_description and "Timestamp for this request" in error_description + ) + + return is_time_synchronizer_related + + def _is_order_not_found_during_status_update_error(self, status_update_exception: Exception) -> bool: + # TODO: implement this method correctly for the connector + # The default implementation was added when the functionality to detect not found orders was introduced in the + # ExchangePyBase class. Also fix the unit test test_lost_order_removed_if_not_found_during_order_status_update + # when replacing the dummy implementation + return False + + def _is_order_not_found_during_cancelation_error(self, cancelation_exception: Exception) -> bool: + # TODO: implement this method correctly for the connector + # The default implementation was added when the functionality to detect not found orders was introduced in the + # ExchangePyBase class. Also fix the unit test test_cancel_order_not_found_in_the_exchange when replacing the + # dummy implementation + return False + + def _create_web_assistants_factory(self) -> WebAssistantsFactory: + return web_utils.build_api_factory( + throttler=self._throttler, + time_synchronizer=self._time_synchronizer, + domain=self._domain, + auth=self._auth + ) + + def _create_order_book_data_source(self) -> OrderBookTrackerDataSource: + return WooXAPIOrderBookDataSource( + trading_pairs=self._trading_pairs, + connector=self, + domain=self.domain, + api_factory=self._web_assistants_factory + ) + + def _create_user_stream_data_source(self) -> UserStreamTrackerDataSource: + return WooXAPIUserStreamDataSource( + auth=self._auth, + trading_pairs=self._trading_pairs, + connector=self, + api_factory=self._web_assistants_factory, + domain=self.domain, + ) + + def _get_fee( + self, + base_currency: str, + quote_currency: str, + order_type: OrderType, + order_side: TradeType, + amount: Decimal, + price: Decimal = s_decimal_NaN, + is_maker: Optional[bool] = None + ) -> TradeFeeBase: + is_maker = order_type is OrderType.LIMIT_MAKER + + return DeductedFromReturnsTradeFee(percent=self.estimate_fee_pct(is_maker)) + + def buy(self, + trading_pair: str, + amount: Decimal, + order_type=OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs) -> str: + """ + Creates a promise to create a buy order using the parameters + + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + + :return: the id assigned by the connector to the order (the client id) + """ + order_id = str(secrets.randbelow(9223372036854775807)) + + safe_ensure_future(self._create_order( + trade_type=TradeType.BUY, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price, + **kwargs) + ) + + return order_id + + def sell(self, + trading_pair: str, + amount: Decimal, + order_type: OrderType = OrderType.LIMIT, + price: Decimal = s_decimal_NaN, + **kwargs) -> str: + """ + Creates a promise to create a sell order using the parameters. + :param trading_pair: the token pair to operate with + :param amount: the order amount + :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) + :param price: the order price + :return: the id assigned by the connector to the order (the client id) + """ + + order_id = str(secrets.randbelow(9223372036854775807)) + + safe_ensure_future(self._create_order( + trade_type=TradeType.SELL, + order_id=order_id, + trading_pair=trading_pair, + amount=amount, + order_type=order_type, + price=price, + **kwargs) + ) + + return order_id + + async def _place_order( + self, + order_id: str, + trading_pair: str, + amount: Decimal, + trade_type: TradeType, + order_type: OrderType, + price: Decimal, + **kwargs + ) -> Tuple[str, float]: + data = { + "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair), + "order_type": self.woo_x_order_type(order_type), + "side": trade_type.name.upper(), + "order_quantity": float(amount), + "client_order_id": order_id + } + + if order_type is OrderType.LIMIT or order_type is OrderType.LIMIT_MAKER: + data["order_price"] = float(price) + + response = await self._api_post( + path_url=CONSTANTS.ORDER_PATH_URL, + data=data, + is_auth_required=True + ) + + return str(response["order_id"]), int(float(response['timestamp']) * 1e3) + + async def _place_cancel(self, order_id: str, tracked_order: InFlightOrder): + params = { + "client_order_id": order_id, + "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=tracked_order.trading_pair), + } + + cancel_result = await self._api_delete( + path_url=CONSTANTS.CANCEL_ORDER_PATH_URL, + params=params, + is_auth_required=True + ) + + if cancel_result.get("status") != "CANCEL_SENT": + raise IOError() + + return True + + async def _format_trading_rules(self, exchange_info: Dict[str, Any]) -> List[TradingRule]: + result = [] + + for entry in filter(woo_x_utils.is_exchange_information_valid, exchange_info.get("rows", [])): + try: + trading_pair = await self.trading_pair_associated_to_exchange_symbol(symbol=entry.get("symbol")) + trading_rule = TradingRule( + trading_pair=trading_pair, + min_order_size=Decimal(str(entry['base_min'])), + min_price_increment=Decimal(str(entry['quote_tick'])), + min_base_amount_increment=Decimal(str(entry['base_tick'])), + min_notional_size=Decimal(str(entry['min_notional'])) + ) + + result.append(trading_rule) + + except Exception: + self.logger().exception(f"Error parsing the trading pair rule {entry}. Skipping.") + return result + + async def _status_polling_loop_fetch_updates(self): + await super()._status_polling_loop_fetch_updates() + + async def _update_trading_fees(self): + """ + Update fees information from the exchange + """ + pass + + async def _user_stream_event_listener(self): + """ + This functions runs in background continuously processing the events received from the exchange by the user + stream data source. It keeps reading events from the queue until the task is interrupted. + The events received are balance updates, order updates and trade events. + """ + async for event_message in self._iter_user_event_queue(): + try: + event_type = event_message.get("topic") + + if event_type == "executionreport": + event_data = event_message.get("data") + + execution_type = event_data.get("status") + + client_order_id = event_data.get("clientOrderId") + + if execution_type in ["PARTIAL_FILLED", "FILLED"]: + tracked_order = self._order_tracker.all_fillable_orders.get(str(client_order_id)) + + if tracked_order is not None: + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=tracked_order.trade_type, + percent_token=event_data["feeAsset"], + flat_fees=[ + TokenAmount( + amount=Decimal(event_data["fee"]), + token=event_data["feeAsset"] + ) + ] + ) + + trade_update = TradeUpdate( + trade_id=str(event_data["tradeId"]), + client_order_id=tracked_order.client_order_id, + exchange_order_id=str(event_data["orderId"]), + trading_pair=tracked_order.trading_pair, + fee=fee, + fill_base_amount=Decimal(str(event_data["executedQuantity"])), + fill_quote_amount=Decimal(str(event_data["executedQuantity"])) * Decimal(str(event_data["executedPrice"])), + fill_price=Decimal(str(event_data["executedPrice"])), + fill_timestamp=event_data["timestamp"] * 1e-3, + ) + + self._order_tracker.process_trade_update(trade_update) + + tracked_order = self._order_tracker.all_updatable_orders.get(str(client_order_id)) + + if tracked_order is not None: + order_update = OrderUpdate( + trading_pair=tracked_order.trading_pair, + update_timestamp=event_data["timestamp"] * 1e-3, + new_state=CONSTANTS.ORDER_STATE[event_data["status"]], + client_order_id=tracked_order.client_order_id, + exchange_order_id=tracked_order.exchange_order_id, + ) + + self._order_tracker.process_order_update(order_update=order_update) + elif event_type == "balance": + balances = event_message["data"]["balances"] + + for asset_name, balance_entry in balances.items(): + free, frozen = Decimal(str(balance_entry["holding"])), Decimal(str(balance_entry["frozen"])) + + total = free + frozen + + self._account_available_balances[asset_name] = free + + self._account_balances[asset_name] = total + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error in user stream listener loop.", exc_info=True) + await self._sleep(5.0) + + async def _all_trade_updates_for_order(self, order: InFlightOrder) -> List[TradeUpdate]: + trade_updates = [] + + if order.exchange_order_id is not None: + symbol = await self.exchange_symbol_associated_to_pair(trading_pair=order.trading_pair) + + content = await self._api_get( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id), + limit_id=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH, + is_auth_required=True, + ) + + for trade in content['Transactions']: + fee = TradeFeeBase.new_spot_fee( + fee_schema=self.trade_fee_schema(), + trade_type=order.trade_type, + percent_token=trade["fee_asset"], + flat_fees=[ + TokenAmount( + amount=Decimal(str(trade["fee"])), + token=trade["fee_asset"] + ) + ] + ) + + trade_update = TradeUpdate( + trade_id=str(trade["id"]), + client_order_id=order.client_order_id, + exchange_order_id=order.exchange_order_id, + trading_pair=symbol, + fee=fee, + fill_base_amount=Decimal(str(trade["executed_quantity"])), + fill_quote_amount=Decimal(str(trade["executed_price"])) * Decimal(str(trade["executed_quantity"])), + fill_price=Decimal(str(trade["executed_price"])), + fill_timestamp=float(trade["executed_timestamp"]) * 1e-3, + ) + + trade_updates.append(trade_update) + + return trade_updates + + async def _request_order_status(self, tracked_order: InFlightOrder) -> OrderUpdate: + updated_order_data = await self._api_get( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(tracked_order.client_order_id), + is_auth_required=True, + limit_id=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH + ) + + new_state = CONSTANTS.ORDER_STATE[updated_order_data["status"]] + + order_update = OrderUpdate( + client_order_id=tracked_order.client_order_id, + exchange_order_id=str(updated_order_data["order_id"]), + trading_pair=tracked_order.trading_pair, + update_timestamp=float(updated_order_data["created_time"]), + new_state=new_state, + ) + + return order_update + + async def _update_balances(self): + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + + account_info = await self._api_get( + path_url=CONSTANTS.ACCOUNTS_PATH_URL, + is_auth_required=True + ) + + balances = account_info.get('holding', []) + + for balance_info in balances: + asset = balance_info['token'] + holding = balance_info['holding'] + frozen = balance_info['frozen'] + + self._account_available_balances[asset] = Decimal(holding) + self._account_balances[asset] = Decimal(holding) + Decimal(frozen) + remote_asset_names.add(asset) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + def _initialize_trading_pair_symbols_from_exchange_info(self, exchange_info: Dict[str, Any]): + mapping = bidict() + + for entry in filter(woo_x_utils.is_exchange_information_valid, exchange_info["rows"]): + base, quote = entry['symbol'].split('_')[1:] + + mapping[entry["symbol"]] = combine_to_hb_trading_pair( + base=base, + quote=quote + ) + + self._set_trading_pair_symbol_map(mapping) + + async def _get_last_traded_price(self, trading_pair: str) -> float: + content = await self._api_request( + method=RESTMethod.GET, + path_url=CONSTANTS.MARKET_TRADES_PATH, + params={ + "symbol": await self.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + } + ) + + return content['rows'][0]['executed_price'] diff --git a/hummingbot/connector/exchange/woo_x/woo_x_order_book.py b/hummingbot/connector/exchange/woo_x/woo_x_order_book.py new file mode 100644 index 0000000000..548178a191 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_order_book.py @@ -0,0 +1,81 @@ +from typing import Dict, Optional + +from hummingbot.core.data_type.common import TradeType +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + + +class WooXOrderBook(OrderBook): + @classmethod + def snapshot_message_from_exchange( + cls, + msg: Dict[str, any], + timestamp: float, + metadata: Optional[Dict] = None + ) -> OrderBookMessage: + """ + Creates a snapshot message with the order book snapshot message + :param msg: the response from the exchange when requesting the order book snapshot + :param timestamp: the snapshot timestamp + :param metadata: a dictionary with extra information to add to the snapshot data + :return: a snapshot message with the snapshot information received from the exchange + """ + + if metadata: + msg.update(metadata) + + return OrderBookMessage(OrderBookMessageType.SNAPSHOT, { + "update_id": timestamp, + "bids": [[entry['price'], entry['quantity']] for entry in msg["bids"]], + "asks": [[entry['price'], entry['quantity']] for entry in msg["asks"]], + }, timestamp=timestamp) + + @classmethod + def diff_message_from_exchange( + cls, + msg: Dict[str, any], + timestamp: Optional[float] = None, + metadata: Optional[Dict] = None + ) -> OrderBookMessage: + """ + Creates a diff message with the changes in the order book received from the exchange + :param msg: the changes in the order book + :param timestamp: the timestamp of the difference + :param metadata: a dictionary with extra information to add to the difference data + :return: a diff message with the changes in the order book notified by the exchange + """ + if metadata: + msg.update(metadata) + + return OrderBookMessage(OrderBookMessageType.DIFF, { + "trading_pair": msg['trading_pair'], + "update_id": msg['ts'], + "bids": msg['data']['bids'], + "asks": msg['data']['asks'] + }, timestamp=msg['ts']) + + @classmethod + def trade_message_from_exchange( + cls, + msg: Dict[str, any], + metadata: Optional[Dict] = None + ): + """ + Creates a trade message with the information from the trade event sent by the exchange + :param msg: the trade event details sent by the exchange + :param metadata: a dictionary with extra information to add to trade message + :return: a trade message with the details of the trade as provided by the exchange + """ + if metadata: + msg.update(metadata) + + timestamp = msg['ts'] + + return OrderBookMessage(OrderBookMessageType.TRADE, { + "trading_pair": msg['trading_pair'], + "trade_type": TradeType[msg['data']["side"]].value, + "trade_id": timestamp, + "update_id": timestamp, + "price": msg['data']['price'], + "amount": msg['data']['size'] + }, timestamp=timestamp * 1e-3) diff --git a/hummingbot/connector/exchange/woo_x/woo_x_utils.py b/hummingbot/connector/exchange/woo_x/woo_x_utils.py new file mode 100644 index 0000000000..9cb3289814 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_utils.py @@ -0,0 +1,107 @@ +from decimal import Decimal +from typing import Any, Dict + +from pydantic import Field, SecretStr + +from hummingbot.client.config.config_data_types import BaseConnectorConfigMap, ClientFieldData +from hummingbot.core.data_type.trade_fee import TradeFeeSchema + +CENTRALIZED = True + +EXAMPLE_PAIR = "BTC-USDT" + +DEFAULT_FEES = TradeFeeSchema( + maker_percent_fee_decimal=Decimal("0.0003"), + taker_percent_fee_decimal=Decimal("0.0003"), + buy_percent_fee_deducted_from_returns=True +) + + +def is_exchange_information_valid(exchange_info: Dict[str, Any]) -> bool: + """ + Verifies if a trading pair is enabled to operate with based on its exchange information + :param exchange_info: the exchange information for a trading pair + :return: True if the trading pair is enabled, False otherwise + """ + category, *rest = exchange_info['symbol'].split('_') + + return category == 'SPOT' + + +class WooXConfigMap(BaseConnectorConfigMap): + connector: str = Field(default="woo_x", const=True, client_data=None) + public_api_key: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Woo X public API key", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + secret_api_key: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Woo X secret API key", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + application_id: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Woo X application ID", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + + class Config: + title = "woo_x" + + +KEYS = WooXConfigMap.construct() + +OTHER_DOMAINS = ["woo_x_testnet"] +OTHER_DOMAINS_PARAMETER = {"woo_x_testnet": "woo_x_testnet"} +OTHER_DOMAINS_EXAMPLE_PAIR = {"woo_x_testnet": "BTC-USDT"} +OTHER_DOMAINS_DEFAULT_FEES = {"woo_x_testnet": DEFAULT_FEES} + + +class WooXTestnetConfigMap(BaseConnectorConfigMap): + connector: str = Field(default="woo_x_testnet", const=True, client_data=None) + public_api_key: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Woo X public API key", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + secret_api_key: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Woo X secret API key", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + application_id: SecretStr = Field( + default=..., + client_data=ClientFieldData( + prompt=lambda cm: "Enter your Woo X application ID", + is_secure=True, + is_connect_key=True, + prompt_on_new=True, + ) + ) + + class Config: + title = "woo_x_testnet" + + +OTHER_DOMAINS_KEYS = {"woo_x_testnet": WooXTestnetConfigMap.construct()} diff --git a/hummingbot/connector/exchange/woo_x/woo_x_web_utils.py b/hummingbot/connector/exchange/woo_x/woo_x_web_utils.py new file mode 100644 index 0000000000..5d36a2d235 --- /dev/null +++ b/hummingbot/connector/exchange/woo_x/woo_x_web_utils.py @@ -0,0 +1,58 @@ +import time +from typing import Callable, Optional + +import hummingbot.connector.exchange.woo_x.woo_x_constants as CONSTANTS +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler +from hummingbot.core.web_assistant.auth import AuthBase +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +def public_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + """ + Creates a full URL for provided public REST endpoint + :param path_url: a public REST endpoint + :param domain: the Woo X domain to connect to ("com" or "us"). The default value is "com" + :return: the full URL to the endpoint + """ + return CONSTANTS.REST_URLS[domain] + path_url + + +def private_rest_url(path_url: str, domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return public_rest_url(path_url, domain) + + +def wss_public_url(domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return CONSTANTS.WSS_PUBLIC_URLS[domain] + + +def wss_private_url(domain: str = CONSTANTS.DEFAULT_DOMAIN) -> str: + return CONSTANTS.WSS_PRIVATE_URLS[domain] + + +def build_api_factory( + throttler: Optional[AsyncThrottler] = None, + time_synchronizer: Optional[TimeSynchronizer] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, + time_provider: Optional[Callable] = None, + auth: Optional[AuthBase] = None +) -> WebAssistantsFactory: + throttler = throttler or create_throttler() + + api_factory = WebAssistantsFactory( + throttler=throttler, + auth=auth + ) + + return api_factory + + +async def get_current_server_time( + throttler: Optional[AsyncThrottler] = None, + domain: str = CONSTANTS.DEFAULT_DOMAIN, +) -> float: + return time.time() * 1e3 + + +def create_throttler() -> AsyncThrottler: + return AsyncThrottler(CONSTANTS.RATE_LIMITS) diff --git a/hummingbot/connector/exchange_py_base.py b/hummingbot/connector/exchange_py_base.py index f09d0f717b..d07ac722da 100644 --- a/hummingbot/connector/exchange_py_base.py +++ b/hummingbot/connector/exchange_py_base.py @@ -407,7 +407,6 @@ async def _create_order(self, :param order_type: the type of order to create (MARKET, LIMIT, LIMIT_MAKER) :param price: the order price """ - exchange_order_id = "" trading_rule = self._trading_rules[trading_pair] if order_type in [OrderType.LIMIT, OrderType.LIMIT_MAKER]: @@ -450,7 +449,8 @@ async def _create_order(self, self._update_order_after_failure(order_id=order_id, trading_pair=trading_pair) return try: - exchange_order_id = await self._place_order_and_process_update(order=order, **kwargs,) + await self._place_order_and_process_update(order=order, **kwargs,) + except asyncio.CancelledError: raise except Exception as ex: @@ -464,7 +464,6 @@ async def _create_order(self, exception=ex, **kwargs, ) - return order_id, exchange_order_id async def _place_order_and_process_update(self, order: InFlightOrder, **kwargs) -> str: exchange_order_id, update_timestamp = await self._place_order( @@ -935,11 +934,19 @@ async def _status_polling_loop_fetch_updates(self): ) async def _update_all_balances(self): - await self._update_balances() - if not self.real_time_balance_update: - # This is only required for exchanges that do not provide balance update notifications through websocket - self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self.in_flight_orders.items()} - self._in_flight_orders_snapshot_timestamp = self.current_timestamp + try: + await self._update_balances() + if not self.real_time_balance_update: + # This is only required for exchanges that do not provide balance update notifications through websocket + self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self.in_flight_orders.items()} + self._in_flight_orders_snapshot_timestamp = self.current_timestamp + except asyncio.CancelledError: + raise + except Exception as request_error: + self.logger().warning( + f"Failed to update balances. Error: {request_error}", + exc_info=request_error, + ) async def _update_orders_fills(self, orders: List[InFlightOrder]): for order in orders: diff --git a/hummingbot/connector/gateway/amm/gateway_tezos_amm.py b/hummingbot/connector/gateway/amm/gateway_tezos_amm.py new file mode 100644 index 0000000000..a21b438203 --- /dev/null +++ b/hummingbot/connector/gateway/amm/gateway_tezos_amm.py @@ -0,0 +1,138 @@ +import asyncio +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from hummingbot.connector.gateway.amm.gateway_evm_amm import GatewayEVMAMM +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.data_type.trade_fee import TokenAmount +from hummingbot.core.event.events import TradeType +from hummingbot.core.gateway import check_transaction_exceptions + +if TYPE_CHECKING: + from hummingbot.client.config.config_helpers import ClientConfigAdapter + + +class GatewayTezosAMM(GatewayEVMAMM): + """ + Defines basic functions common to connectors that interact with Gateway. + """ + + API_CALL_TIMEOUT = 60.0 + POLL_INTERVAL = 15.0 + + def __init__(self, + client_config_map: "ClientConfigAdapter", + connector_name: str, + chain: str, + network: str, + address: str, + trading_pairs: List[str] = [], + additional_spenders: List[str] = [], # not implemented + trading_required: bool = True + ): + """ + :param connector_name: name of connector on gateway + :param chain: refers to a block chain, e.g. ethereum or avalanche + :param network: refers to a network of a particular blockchain e.g. mainnet or kovan + :param address: the address of the eth wallet which has been added on gateway + :param trading_pairs: a list of trading pairs + :param trading_required: Whether actual trading is needed. Useful for some functionalities or commands like the balance command + """ + super().__init__(client_config_map=client_config_map, + connector_name=connector_name, + chain=chain, + network=network, + address=address, + trading_pairs=trading_pairs, + additional_spenders=additional_spenders, + trading_required=trading_required) + + async def get_chain_info(self): + """ + Calls the base endpoint of the connector on Gateway to know basic info about chain being used. + """ + try: + self._chain_info = await self._get_gateway_instance().get_network_status( + chain=self.chain, network=self.network + ) + if not isinstance(self._chain_info, list): + self._native_currency = self._chain_info.get("nativeCurrency", "XTZ") + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + "Error fetching chain info", + exc_info=True, + app_warning_msg=str(e) + ) + + def parse_price_response( + self, + base: str, + quote: str, + amount: Decimal, + side: TradeType, + price_response: Dict[str, Any], + process_exception: bool = True + ) -> Optional[Decimal]: + """ + Parses price response + :param base: The base asset + :param quote: The quote asset + :param amount: amount + :param side: trade side + :param price_response: Price response from Gateway. + :param process_exception: Flag to trigger error on exception + """ + required_items = ["price", "gasLimit", "gasPrice", "gasCost", "gasPriceToken"] + if any(item not in price_response.keys() for item in required_items): + if "info" in price_response.keys(): + self.logger().info(f"Unable to get price. {price_response['info']}") + else: + self.logger().info(f"Missing data from price result. Incomplete return result for ({price_response.keys()})") + else: + gas_price_token: str = price_response["gasPriceToken"] + gas_cost: Decimal = Decimal(price_response["gasCost"]) + price: Decimal = Decimal(price_response["price"]) + self.network_transaction_fee = TokenAmount(gas_price_token, gas_cost) + if process_exception is True: + gas_limit: int = int(price_response["gasLimit"]) + exceptions: List[str] = check_transaction_exceptions( + allowances=self._allowances, + balances=self._account_balances, + base_asset=base, + quote_asset=quote, + amount=amount, + side=side, + gas_limit=gas_limit, + gas_cost=gas_cost, + gas_asset=gas_price_token, + swaps_count=len(price_response.get("swaps", [])), + chain=self.chain + ) + for index in range(len(exceptions)): + self.logger().warning( + f"Warning! [{index + 1}/{len(exceptions)}] {side} order - {exceptions[index]}" + ) + if len(exceptions) > 0: + return None + return Decimal(str(price)) + return None + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + """ + This is intentionally left blank, because cancellation is not supported for tezos blockchain. + """ + return [] + + async def _execute_cancel(self, order_id: str, cancel_age: int) -> Optional[str]: + """ + This is intentionally left blank, because cancellation is not supported for tezos blockchain. + """ + pass + + async def cancel_outdated_orders(self, cancel_age: int) -> List[CancellationResult]: + """ + This is intentionally left blank, because cancellation is not supported for tezos blockchain. + """ + return [] diff --git a/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_api_data_source.py b/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_api_data_source.py index 6998c1f5b6..2da7df63b2 100644 --- a/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_api_data_source.py +++ b/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_api_data_source.py @@ -212,7 +212,8 @@ async def batch_order_create(self, orders_to_create: List[GatewayInFlightOrder]) candidate_orders = [in_flight_order] client_ids = [] for order_to_create in orders_to_create: - order_to_create.client_order_id = generate_hash(order_to_create) + if not order_to_create.client_order_id: + order_to_create.client_order_id = generate_hash(order_to_create) client_ids.append(order_to_create.client_order_id) candidate_order = in_flight_order.InFlightOrder( @@ -538,6 +539,7 @@ async def get_account_balances(self) -> Dict[str, Dict[str, Decimal]]: hb_balances = {} for token, balance in balances.items(): + balance = Decimal(balance) hb_balances[token] = DotMap({}, _dynamic=False) hb_balances[token]["total_balance"] = balance hb_balances[token]["available_balance"] = balance @@ -626,7 +628,6 @@ async def get_order_status_update(self, in_flight_order: GatewayInFlightOrder) - async def get_all_order_fills(self, in_flight_order: GatewayInFlightOrder) -> List[TradeUpdate]: if in_flight_order.exchange_order_id: - active_order = self.gateway_order_tracker.active_orders.get(in_flight_order.client_order_id) if active_order: @@ -725,20 +726,21 @@ async def check_network_status(self) -> NetworkStatus: # self.logger().debug("check_network_status: start") try: - await self._gateway_ping_gateway() + status = await self._gateway_ping_gateway() - output = NetworkStatus.CONNECTED + if status: + return NetworkStatus.CONNECTED + else: + return NetworkStatus.NOT_CONNECTED except asyncio.CancelledError: raise except Exception as exception: self.logger().error(exception) - output = NetworkStatus.NOT_CONNECTED + return NetworkStatus.NOT_CONNECTED # self.logger().debug("check_network_status: end") - return output - @property def is_cancel_request_in_exchange_synchronous(self) -> bool: self.logger().debug("is_cancel_request_in_exchange_synchronous: start") @@ -956,7 +958,7 @@ async def _update_all_active_orders(self): await asyncio.sleep(UPDATE_ORDER_STATUS_INTERVAL) @automatic_retry_with_timeout(retries=NUMBER_OF_RETRIES, delay=DELAY_BETWEEN_RETRIES, timeout=TIMEOUT) - async def _gateway_ping_gateway(self, request): + async def _gateway_ping_gateway(self, _request=None): return await self._gateway.ping_gateway() @automatic_retry_with_timeout(retries=NUMBER_OF_RETRIES, delay=DELAY_BETWEEN_RETRIES, timeout=TIMEOUT) diff --git a/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_helpers.py b/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_helpers.py index 21942f4d4f..7915af4607 100644 --- a/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_helpers.py +++ b/hummingbot/connector/gateway/clob_spot/data_sources/kujira/kujira_helpers.py @@ -35,19 +35,25 @@ def convert_market_name_to_hb_trading_pair(market_name: str) -> str: return market_name.replace("/", "-") -def automatic_retry_with_timeout(retries=1, delay=0, timeout=None): +def automatic_retry_with_timeout(retries=0, delay=0, timeout=None): def decorator(func): async def wrapper(*args, **kwargs): errors = [] - for i in range(retries): + + for i in range(retries + 1): try: result = await asyncio.wait_for(func(*args, **kwargs), timeout=timeout) + return result except Exception as e: tb_str = traceback.format_exception(type(e), value=e, tb=e.__traceback__) errors.append(''.join(tb_str)) - await asyncio.sleep(delay) + + if i < retries: + await asyncio.sleep(delay) + error_message = f"Function failed after {retries} attempts. Here are the errors:\n" + "\n".join(errors) + raise Exception(error_message) return wrapper return decorator diff --git a/hummingbot/connector/gateway/common_types.py b/hummingbot/connector/gateway/common_types.py index 54471a6e12..ff70776518 100644 --- a/hummingbot/connector/gateway/common_types.py +++ b/hummingbot/connector/gateway/common_types.py @@ -5,6 +5,7 @@ class Chain(Enum): ETHEREUM = ('ethereum', 'ETH') + TEZOS = ('tezos', 'XTZ') def __init__(self, chain: str, native_currency: str): self.chain = chain diff --git a/hummingbot/connector/test_support/exchange_connector_test.py b/hummingbot/connector/test_support/exchange_connector_test.py index 61a10fa672..d3fd1f0e0a 100644 --- a/hummingbot/connector/test_support/exchange_connector_test.py +++ b/hummingbot/connector/test_support/exchange_connector_test.py @@ -447,20 +447,28 @@ def configure_erroneous_trading_rules_response( mock_api.get(url, body=json.dumps(response), callback=callback) return [url] - def place_buy_order(self, amount: Decimal = Decimal("100"), price: Decimal = Decimal("10_000")): + def place_buy_order( + self, + amount: Decimal = Decimal("100"), + price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.LIMIT): order_id = self.exchange.buy( trading_pair=self.trading_pair, amount=amount, - order_type=OrderType.LIMIT, + order_type=order_type, price=price, ) return order_id - def place_sell_order(self, amount: Decimal = Decimal("100"), price: Decimal = Decimal("10_000")): + def place_sell_order( + self, + amount: Decimal = Decimal("100"), + price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.LIMIT): order_id = self.exchange.sell( trading_pair=self.trading_pair, amount=amount, - order_type=OrderType.LIMIT, + order_type=order_type, price=price, ) return order_id diff --git a/hummingbot/connector/test_support/network_mocking_assistant.py b/hummingbot/connector/test_support/network_mocking_assistant.py index 3191070743..07f2aa28fc 100644 --- a/hummingbot/connector/test_support/network_mocking_assistant.py +++ b/hummingbot/connector/test_support/network_mocking_assistant.py @@ -1,14 +1,38 @@ import asyncio import functools from collections import defaultdict, deque +from typing import Any, Dict, Optional, Tuple, Union from unittest.mock import AsyncMock, PropertyMock import aiohttp +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory + + +class MockWebsocketClientSession: + # Created this class instead of using a generic mock to be sure that no other methods from the client session + # are required when working with websockets + def __init__(self, mock_websocket: AsyncMock): + self._mock_websocket = mock_websocket + self._connection_args: Optional[Tuple[Any]] = None + self._connection_kwargs: Optional[Dict[str, Any]] = None + + @property + def connection_args(self) -> Tuple[Any]: + return self._connection_args or () + + @property + def connection_kwargs(self) -> Dict[str, Any]: + return self._connection_kwargs or {} + + async def ws_connect(self, *args, **kwargs): + self._connection_args = args + self._connection_kwargs = kwargs + return self._mock_websocket -class NetworkMockingAssistant: - def __init__(self): +class NetworkMockingAssistant: + def __init__(self, event_loop=None): super().__init__() self._response_text_queues = defaultdict(asyncio.Queue) @@ -60,6 +84,14 @@ def _handle_http_request(self, http_mock, url, headers=None, params=None, data=N return response + def configure_web_assistants_factory(self, web_assistants_factory: WebAssistantsFactory) -> AsyncMock: + websocket_mock = self.create_websocket_mock() + client_session_mock = MockWebsocketClientSession(mock_websocket=websocket_mock) + + web_assistants_factory._connections_factory._ws_independent_session = client_session_mock + + return websocket_mock + def configure_http_request_mock(self, http_request_mock): http_request_mock.side_effect = functools.partial(self._handle_http_request, http_request_mock) @@ -85,6 +117,8 @@ async def _get_next_websocket_aiohttp_message(self, websocket_mock, *args, **kwa message = await queue.get() if queue.empty(): self._all_incoming_websocket_aiohttp_delivered_event[websocket_mock].set() + if isinstance(message, (BaseException, Exception)): + raise message return message async def _get_next_websocket_text_message(self, websocket_mock, *args, **kwargs): @@ -121,6 +155,10 @@ def add_websocket_aiohttp_message( self._incoming_websocket_aiohttp_queues[websocket_mock].put_nowait(msg) self._all_incoming_websocket_aiohttp_delivered_event[websocket_mock].clear() + def add_websocket_aiohttp_exception(self, websocket_mock, exception: Union[Exception, BaseException]): + self._incoming_websocket_aiohttp_queues[websocket_mock].put_nowait(exception) + self._all_incoming_websocket_aiohttp_delivered_event[websocket_mock].clear() + def json_messages_sent_through_websocket(self, websocket_mock): return self._sent_websocket_json_messages[websocket_mock] diff --git a/hummingbot/connector/test_support/perpetual_derivative_test.py b/hummingbot/connector/test_support/perpetual_derivative_test.py index 4e3d06fd5f..fedc4fd156 100644 --- a/hummingbot/connector/test_support/perpetual_derivative_test.py +++ b/hummingbot/connector/test_support/perpetual_derivative_test.py @@ -159,12 +159,13 @@ def place_buy_order( self, amount: Decimal = Decimal("100"), price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.LIMIT, position_action: PositionAction = PositionAction.OPEN, ): order_id = self.exchange.buy( trading_pair=self.trading_pair, amount=amount, - order_type=OrderType.LIMIT, + order_type=order_type, price=price, position_action=position_action, ) @@ -174,12 +175,13 @@ def place_sell_order( self, amount: Decimal = Decimal("100"), price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.LIMIT, position_action: PositionAction = PositionAction.OPEN, ): order_id = self.exchange.sell( trading_pair=self.trading_pair, amount=amount, - order_type=OrderType.LIMIT, + order_type=order_type, price=price, position_action=position_action, ) @@ -196,14 +198,8 @@ def test_initial_status_dict(self): status_dict = self.exchange.status_dict - expected_initial_dict = { - "symbols_mapping_initialized": False, - "order_books_initialized": False, - "account_balance": False, - "trading_rule_initialized": False, - "user_stream_initialized": False, - "funding_info": False, - } + expected_initial_dict = self._expected_initial_status_dict() + expected_initial_dict["funding_info"] = False self.assertEqual(expected_initial_dict, status_dict) self.assertFalse(self.exchange.ready) @@ -432,15 +428,16 @@ def test_update_order_status_when_filled(self, mock_api): self.async_run_with_timeout(order.wait_until_completely_filled()) self.assertTrue(order.is_done) + if self.is_order_fill_http_update_included_in_status_update: self.assertTrue(order.is_filled) - if self.is_order_fill_http_update_included_in_status_update: - trades_request = self._all_executed_requests(mock_api, trade_url)[0] - self.validate_auth_credentials_present(trades_request) - self.validate_trades_request( - order=order, - request_call=trades_request) + if trade_url: + trades_request = self._all_executed_requests(mock_api, trade_url)[0] + self.validate_auth_credentials_present(trades_request) + self.validate_trades_request( + order=order, + request_call=trades_request) fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) diff --git a/hummingbot/core/cpp/LimitOrder.cpp b/hummingbot/core/cpp/LimitOrder.cpp index 4ea86b40c1..3fde75bf61 100644 --- a/hummingbot/core/cpp/LimitOrder.cpp +++ b/hummingbot/core/cpp/LimitOrder.cpp @@ -11,6 +11,7 @@ LimitOrder::LimitOrder() { this->filledQuantity = NULL; this->creationTimestamp = 0.0; this->status = 0; + this->position = "NIL"; } LimitOrder::LimitOrder(std::string clientOrderID, @@ -31,6 +32,7 @@ LimitOrder::LimitOrder(std::string clientOrderID, this->filledQuantity = NULL; this->creationTimestamp = 0.0; this->status = 0; + this->position = "NIL"; Py_XINCREF(price); Py_XINCREF(quantity); } @@ -44,7 +46,8 @@ LimitOrder::LimitOrder(std::string clientOrderID, PyObject *quantity, PyObject *filledQuantity, long creationTimestamp, - short int status + short int status, + std::string position ) { this->clientOrderID = clientOrderID; this->tradingPair = tradingPair; @@ -56,6 +59,7 @@ LimitOrder::LimitOrder(std::string clientOrderID, this->filledQuantity = filledQuantity; this->creationTimestamp = creationTimestamp; this->status = status; + this->position = position; Py_XINCREF(price); Py_XINCREF(quantity); Py_XINCREF(filledQuantity); @@ -72,6 +76,7 @@ LimitOrder::LimitOrder(const LimitOrder &other) { this->filledQuantity = other.filledQuantity; this->creationTimestamp = other.creationTimestamp; this->status = other.status; + this->position = other.position; Py_XINCREF(this->price); Py_XINCREF(this->quantity); Py_XINCREF(this->filledQuantity); @@ -97,6 +102,7 @@ LimitOrder &LimitOrder::operator=(const LimitOrder &other) { this->filledQuantity = other.filledQuantity; this->creationTimestamp = other.creationTimestamp; this->status = other.status; + this->position = other.position; Py_XINCREF(this->price); Py_XINCREF(this->quantity); Py_XINCREF(this->filledQuantity); @@ -152,3 +158,7 @@ long LimitOrder::getCreationTimestamp() const{ short int LimitOrder::getStatus() const{ return this->status; } + +std::string LimitOrder::getPosition() const{ + return this->position; +} diff --git a/hummingbot/core/cpp/LimitOrder.h b/hummingbot/core/cpp/LimitOrder.h index afd7bf3661..5e2c17f581 100644 --- a/hummingbot/core/cpp/LimitOrder.h +++ b/hummingbot/core/cpp/LimitOrder.h @@ -15,6 +15,7 @@ class LimitOrder { PyObject *filledQuantity; long creationTimestamp; short int status; + std::string position; public: LimitOrder(); @@ -34,7 +35,8 @@ class LimitOrder { PyObject *quantity, PyObject *filledQuantity, long creationTimestamp, - short int status); + short int status, + std::string position); ~LimitOrder(); LimitOrder(const LimitOrder &other); LimitOrder &operator=(const LimitOrder &other); @@ -50,6 +52,7 @@ class LimitOrder { PyObject *getFilledQuantity() const; long getCreationTimestamp() const; short int getStatus() const; + std::string getPosition() const; }; #endif diff --git a/hummingbot/core/data_type/LimitOrder.pxd b/hummingbot/core/data_type/LimitOrder.pxd index d3320b94f7..07c4988806 100644 --- a/hummingbot/core/data_type/LimitOrder.pxd +++ b/hummingbot/core/data_type/LimitOrder.pxd @@ -24,7 +24,8 @@ cdef extern from "../cpp/LimitOrder.h": PyObject *quantity, PyObject *filledQuantity, long long creationTimestamp, - short int status) + short int status, + string position) LimitOrder(const LimitOrder &other) LimitOrder &operator=(const LimitOrder &other) string getClientOrderID() @@ -37,3 +38,4 @@ cdef extern from "../cpp/LimitOrder.h": PyObject *getFilledQuantity() long long getCreationTimestamp() short int getStatus() + string getPosition() diff --git a/hummingbot/core/data_type/limit_order.pyx b/hummingbot/core/data_type/limit_order.pyx index 921f033922..ecda2408c2 100644 --- a/hummingbot/core/data_type/limit_order.pyx +++ b/hummingbot/core/data_type/limit_order.pyx @@ -8,6 +8,7 @@ import pandas as pd from cpython cimport PyObject from libcpp.string cimport string +from hummingbot.core.data_type.common import PositionAction, OrderType from hummingbot.core.event.events import LimitOrderStatus cdef class LimitOrder: @@ -65,12 +66,14 @@ cdef class LimitOrder: quantity: Decimal, filled_quantity: Decimal = Decimal("NaN"), creation_timestamp: int = 0, - status: LimitOrderStatus = LimitOrderStatus.UNKNOWN): + status: LimitOrderStatus = LimitOrderStatus.UNKNOWN, + position: PositionAction = PositionAction.NIL): cdef: string cpp_client_order_id = client_order_id.encode("utf8") string cpp_trading_pair = trading_pair.encode("utf8") string cpp_base_currency = base_currency.encode("utf8") string cpp_quote_currency = quote_currency.encode("utf8") + string cpp_position = position.value.encode("utf8") self._cpp_limit_order = CPPLimitOrder(cpp_client_order_id, cpp_trading_pair, is_buy, @@ -80,7 +83,8 @@ cdef class LimitOrder: quantity, filled_quantity, creation_timestamp, - status.value) + status.value, + cpp_position) @property def client_order_id(self) -> str: @@ -134,6 +138,13 @@ cdef class LimitOrder: def status(self) -> LimitOrderStatus: return LimitOrderStatus(self._cpp_limit_order.getStatus()) + @property + def position(self) -> PositionAction: + cdef: + string cpp_position = self._cpp_limit_order.getPosition() + str retval = cpp_position.decode("utf8") + return PositionAction(retval) + cdef long long c_age_til(self, long long end_timestamp): """ Calculates and returns age of the order since it was created til end_timestamp in seconds @@ -162,6 +173,24 @@ cdef class LimitOrder: def age_til(self, start_timestamp: int) -> int: return self.c_age_til(start_timestamp) + def order_type(self) -> OrderType: + return OrderType.LIMIT + + def copy_with_id(self, client_order_id: str): + return LimitOrder( + client_order_id=client_order_id, + trading_pair=self.trading_pair, + is_buy=self.is_buy, + base_currency=self.base_currency, + quote_currency=self.quote_currency, + price=self.price, + quantity=self.quantity, + filled_quantity=self.filled_quantity, + creation_timestamp=self.creation_timestamp, + status=self.status, + position=self.position, + ) + def __repr__(self) -> str: return (f"LimitOrder('{self.client_order_id}', '{self.trading_pair}', {self.is_buy}, '{self.base_currency}', " f"'{self.quote_currency}', {self.price}, {self.quantity}, {self.filled_quantity}, " diff --git a/hummingbot/core/data_type/market_order.py b/hummingbot/core/data_type/market_order.py index 14e579e667..e191d8560c 100644 --- a/hummingbot/core/data_type/market_order.py +++ b/hummingbot/core/data_type/market_order.py @@ -1,6 +1,9 @@ -from typing import NamedTuple, List +from typing import List, NamedTuple + import pandas as pd +from hummingbot.core.data_type.common import OrderType, PositionAction + class MarketOrder(NamedTuple): order_id: str @@ -10,6 +13,7 @@ class MarketOrder(NamedTuple): quote_asset: str amount: float timestamp: float + position: PositionAction = PositionAction.NIL @classmethod def to_pandas(cls, market_orders: List["MarketOrder"]) -> pd.DataFrame: @@ -24,3 +28,33 @@ def to_pandas(cls, market_orders: List["MarketOrder"]) -> pd.DataFrame: pd.Timestamp(market_order.timestamp, unit='s', tz='UTC').strftime('%Y-%m-%d %H:%M:%S') ] for market_order in market_orders] return pd.DataFrame(data=data, columns=columns) + + @property + def client_order_id(self): + # Added to make this class polymorphic with LimitOrder + return self.order_id + + @property + def quantity(self): + # Added to make this class polymorphic with LimitOrder + return self.amount + + @property + def price(self): + # Added to make this class polymorphic with LimitOrder + return None + + def order_type(self) -> OrderType: + return OrderType.MARKET + + def copy_with_id(self, client_order_id: str): + return MarketOrder( + order_id=client_order_id, + trading_pair=self.trading_pair, + is_buy=self.is_buy, + base_asset=self.base_asset, + quote_asset=self.quote_asset, + amount=self.amount, + timestamp=self.timestamp, + position=self.position, + ) diff --git a/hummingbot/core/data_type/order_candidate.py b/hummingbot/core/data_type/order_candidate.py index 66c5e5a44d..ccb4cd78dd 100644 --- a/hummingbot/core/data_type/order_candidate.py +++ b/hummingbot/core/data_type/order_candidate.py @@ -5,8 +5,8 @@ from typing import Dict, List, Optional from hummingbot.connector.utils import combine_to_hb_trading_pair, split_hb_trading_pair -from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType +from hummingbot.core.data_type.trade_fee import TokenAmount, TradeFeeBase from hummingbot.core.utils.estimate_fee import build_perpetual_trade_fee, build_trade_fee if typing.TYPE_CHECKING: # avoid circular import problems @@ -181,7 +181,7 @@ def _get_size_collateral_price( def _adjust_for_order_collateral(self, available_balances: Dict[str, Decimal]): if self.order_collateral is not None: token, amount = self.order_collateral - if available_balances[token] < amount: + if not amount.is_nan() and available_balances[token] < amount: scaler = available_balances[token] / amount self._scale_order(scaler) diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index 551cc8406b..f53258ae65 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -37,6 +37,7 @@ class MarketEvent(Enum): class OrderBookEvent(int, Enum): TradeEvent = 901 + OrderBookDataSourceUpdateEvent = 904 class OrderBookDataSourceEvent(int, Enum): diff --git a/hummingbot/core/gateway/__init__.py b/hummingbot/core/gateway/__init__.py index 5746c01521..5e6bd315e9 100644 --- a/hummingbot/core/gateway/__init__.py +++ b/hummingbot/core/gateway/__init__.py @@ -115,6 +115,8 @@ def check_transaction_exceptions( # check for gas limit set to low if chain == Chain.ETHEREUM: gas_limit_threshold: int = 21000 + elif chain == Chain.TEZOS.chain: + gas_limit_threshold: int = 0 else: raise ValueError(f"Unsupported chain: {chain}") if gas_limit < gas_limit_threshold: diff --git a/hummingbot/core/rate_oracle/rate_oracle.py b/hummingbot/core/rate_oracle/rate_oracle.py index 2538374380..2adcd4f6d2 100644 --- a/hummingbot/core/rate_oracle/rate_oracle.py +++ b/hummingbot/core/rate_oracle/rate_oracle.py @@ -9,6 +9,7 @@ from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.rate_oracle.sources.ascend_ex_rate_source import AscendExRateSource from hummingbot.core.rate_oracle.sources.binance_rate_source import BinanceRateSource +from hummingbot.core.rate_oracle.sources.coin_cap_rate_source import CoinCapRateSource from hummingbot.core.rate_oracle.sources.coin_gecko_rate_source import CoinGeckoRateSource from hummingbot.core.rate_oracle.sources.gate_io_rate_source import GateIoRateSource from hummingbot.core.rate_oracle.sources.kucoin_rate_source import KucoinRateSource @@ -20,6 +21,7 @@ RATE_ORACLE_SOURCES = { "binance": BinanceRateSource, "coin_gecko": CoinGeckoRateSource, + "coin_cap": CoinCapRateSource, "kucoin": KucoinRateSource, "ascend_ex": AscendExRateSource, "gate_io": GateIoRateSource, diff --git a/hummingbot/core/rate_oracle/sources/coin_cap_rate_source.py b/hummingbot/core/rate_oracle/sources/coin_cap_rate_source.py new file mode 100644 index 0000000000..afdfafa8c9 --- /dev/null +++ b/hummingbot/core/rate_oracle/sources/coin_cap_rate_source.py @@ -0,0 +1,39 @@ +from decimal import Decimal +from typing import Dict, Optional + +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.rate_oracle.sources.rate_source_base import RateSourceBase +from hummingbot.data_feed.coin_cap_data_feed import CoinCapDataFeed +from hummingbot.logger import HummingbotLogger + + +class CoinCapRateSource(RateSourceBase): + _logger: Optional[HummingbotLogger] = None + + def __init__(self, assets_map: Dict[str, str], api_key: str): + self._coin_cap_data_feed = CoinCapDataFeed(assets_map=assets_map, api_key=api_key) + + @property + def name(self) -> str: + return "coin_cap" + + async def start_network(self): + await self._coin_cap_data_feed.start_network() + + async def stop_network(self): + await self._coin_cap_data_feed.stop_network() + + async def check_network(self) -> NetworkStatus: + return await self._coin_cap_data_feed.check_network() + + async def get_prices(self, quote_token: Optional[str] = None) -> Dict[str, Decimal]: + prices = {} + + if quote_token == self._coin_cap_data_feed.universal_quote_token: + prices = await self._coin_cap_data_feed.get_all_usd_quoted_prices() + else: + self.logger().warning( + "CoinCapRateSource only supports USD as quote token. Please set your global token to USD." + ) + + return prices diff --git a/hummingbot/core/utils/gateway_config_utils.py b/hummingbot/core/utils/gateway_config_utils.py index 3104c9189f..612978fb97 100644 --- a/hummingbot/core/utils/gateway_config_utils.py +++ b/hummingbot/core/utils/gateway_config_utils.py @@ -15,6 +15,7 @@ "near": "NEAR", "injective": "INJ", "xdc": "XDC", + "tezos": "XTZ", "kujira": "KUJI" } diff --git a/hummingbot/core/web_assistant/connections/connections_factory.py b/hummingbot/core/web_assistant/connections/connections_factory.py index 6de83ead2f..d10b4945e8 100644 --- a/hummingbot/core/web_assistant/connections/connections_factory.py +++ b/hummingbot/core/web_assistant/connections/connections_factory.py @@ -1,6 +1,7 @@ from typing import Optional import aiohttp + from hummingbot.core.web_assistant.connections.rest_connection import RESTConnection from hummingbot.core.web_assistant.connections.ws_connection import WSConnection @@ -18,6 +19,9 @@ class ConnectionsFactory: """ def __init__(self): + # _ws_independent_session is intended to be used only in unit tests + self._ws_independent_session: Optional[aiohttp.ClientSession] = None + self._shared_client: Optional[aiohttp.ClientSession] = None async def get_rest_connection(self) -> RESTConnection: @@ -26,7 +30,7 @@ async def get_rest_connection(self) -> RESTConnection: return connection async def get_ws_connection(self) -> WSConnection: - shared_client = await self._get_shared_client() + shared_client = self._ws_independent_session or await self._get_shared_client() connection = WSConnection(aiohttp_client_session=shared_client) return connection diff --git a/hummingbot/core/web_assistant/rest_assistant.py b/hummingbot/core/web_assistant/rest_assistant.py index 9ad821ad15..be0c94ebe4 100644 --- a/hummingbot/core/web_assistant/rest_assistant.py +++ b/hummingbot/core/web_assistant/rest_assistant.py @@ -33,6 +33,32 @@ def __init__( self._throttler = throttler async def execute_request( + self, + url: str, + throttler_limit_id: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + method: RESTMethod = RESTMethod.GET, + is_auth_required: bool = False, + return_err: bool = False, + timeout: Optional[float] = None, + headers: Optional[Dict[str, Any]] = None, + ) -> Union[str, Dict[str, Any]]: + response = await self.execute_request_and_get_response( + url=url, + throttler_limit_id=throttler_limit_id, + params=params, + data=data, + method=method, + is_auth_required=is_auth_required, + return_err=return_err, + timeout=timeout, + headers=headers, + ) + response_json = await response.json() + return response_json + + async def execute_request_and_get_response( self, url: str, throttler_limit_id: str, @@ -42,7 +68,8 @@ async def execute_request( is_auth_required: bool = False, return_err: bool = False, timeout: Optional[float] = None, - headers: Optional[Dict[str, Any]] = None) -> Union[str, Dict[str, Any]]: + headers: Optional[Dict[str, Any]] = None, + ) -> RESTResponse: headers = headers or {} @@ -66,16 +93,12 @@ async def execute_request( response = await self.call(request=request, timeout=timeout) if 400 <= response.status: - if return_err: - error_response = await response.json() - return error_response - else: + if not return_err: error_response = await response.text() error_text = "N/A" if " RESTResponse: request = deepcopy(request) diff --git a/hummingbot/data_feed/coin_cap_data_feed.py b/hummingbot/data_feed/coin_cap_data_feed.py deleted file mode 100644 index 78f9663e6c..0000000000 --- a/hummingbot/data_feed/coin_cap_data_feed.py +++ /dev/null @@ -1,93 +0,0 @@ -import asyncio -import logging -from typing import ( - Dict, - Optional, -) -from hummingbot.data_feed.data_feed_base import DataFeedBase -from hummingbot.logger import HummingbotLogger -from hummingbot.core.utils.async_utils import safe_ensure_future - - -class CoinCapDataFeed(DataFeedBase): - ccdf_logger: Optional[HummingbotLogger] = None - _ccdf_shared_instance: "CoinCapDataFeed" = None - - COIN_CAP_BASE_URL = "https://api.coincap.io/v2" - - @classmethod - def get_instance(cls) -> "CoinCapDataFeed": - if cls._ccdf_shared_instance is None: - cls._ccdf_shared_instance = CoinCapDataFeed() - return cls._ccdf_shared_instance - - @classmethod - def logger(cls) -> HummingbotLogger: - if cls.ccdf_logger is None: - cls.ccdf_logger = logging.getLogger(__name__) - return cls.ccdf_logger - - def __init__(self, update_interval: float = 5.0): - super().__init__() - self._check_network_interval = 30.0 - self._ev_loop = asyncio.get_event_loop() - self._price_dict: Dict[str, float] = {} - self._update_interval: float = update_interval - self._fetch_price_task: Optional[asyncio.Task] = None - - @property - def name(self): - return "coincap_api" - - @property - def price_dict(self): - return self._price_dict.copy() - - @property - def health_check_endpoint(self): - # Only fetch data of one asset - so that the health check is faster - return "http://api.coincap.io/v2/assets/bitcoin" - - def get_price(self, asset: str) -> float: - return self._price_dict.get(asset.upper()) - - async def fetch_price_loop(self): - while True: - try: - await self.fetch_prices() - except asyncio.CancelledError: - raise - except Exception: - self.logger().network(f"Error fetching new prices from {self.name}.", exc_info=True, - app_warning_msg="Couldn't fetch newest prices from CoinCap. " - "Check network connection.") - - await asyncio.sleep(self._update_interval) - - async def fetch_prices(self): - client = await self._http_client() - async with client.request("GET", f"{self.COIN_CAP_BASE_URL}/assets") as resp: - rates_dict = await resp.json() - for rate_obj in rates_dict["data"]: - asset = rate_obj["symbol"].upper() - self._price_dict[asset] = float(rate_obj["priceUsd"]) - - # coincap does not include all coins in assets - async with client.request("GET", f"{self.COIN_CAP_BASE_URL}/rates") as resp: - rates_dict = await resp.json() - for rate_obj in rates_dict["data"]: - asset = rate_obj["symbol"].upper() - self._price_dict[asset] = float(rate_obj["rateUsd"]) - - # CoinCap does not have a separate feed for WETH - self._price_dict["WETH"] = self._price_dict["ETH"] - self._ready_event.set() - - async def start_network(self): - await self.stop_network() - self._fetch_price_task = safe_ensure_future(self.fetch_price_loop()) - - async def stop_network(self): - if self._fetch_price_task is not None: - self._fetch_price_task.cancel() - self._fetch_price_task = None diff --git a/hummingbot/data_feed/coin_cap_data_feed/__init__.py b/hummingbot/data_feed/coin_cap_data_feed/__init__.py new file mode 100644 index 0000000000..b24d31ee10 --- /dev/null +++ b/hummingbot/data_feed/coin_cap_data_feed/__init__.py @@ -0,0 +1,3 @@ +from hummingbot.data_feed.coin_cap_data_feed.coin_cap_data_feed import CoinCapDataFeed + +__all__ = ["CoinCapDataFeed"] diff --git a/hummingbot/data_feed/coin_cap_data_feed/coin_cap_constants.py b/hummingbot/data_feed/coin_cap_data_feed/coin_cap_constants.py new file mode 100644 index 0000000000..94458ebe56 --- /dev/null +++ b/hummingbot/data_feed/coin_cap_data_feed/coin_cap_constants.py @@ -0,0 +1,38 @@ +import sys + +from hummingbot.core.api_throttler.data_types import LinkedLimitWeightPair, RateLimit + +UNIVERSAL_QUOTE_TOKEN = "USD" # coincap only works with USD + +BASE_REST_URL = "https://api.coincap.io/v2" +BASE_WS_URL = "wss://ws.coincap.io/prices?assets=" + +ALL_ASSETS_ENDPOINT = "/assets" +ASSET_ENDPOINT = "/assets/{}" +HEALTH_CHECK_ENDPOINT = ASSET_ENDPOINT.format("bitcoin") # get a single asset + +ALL_ASSETS_LIMIT_ID = "allAssetsLimitID" +ASSET_LIMIT_ID = "assetLimitID" +NO_KEY_LIMIT_ID = "noKeyLimitID" +API_KEY_LIMIT_ID = "APIKeyLimitID" +WS_CONNECTIONS_LIMIT_ID = "WSConnectionsLimitID" +NO_KEY_LIMIT = 200 +API_KEY_LIMIT = 500 +NO_LIMIT = sys.maxsize +MINUTE = 60 +SECOND = 1 + +RATE_LIMITS = [ + RateLimit(limit_id=API_KEY_LIMIT_ID, limit=API_KEY_LIMIT, time_interval=MINUTE), + RateLimit( + limit_id=NO_KEY_LIMIT_ID, + limit=NO_KEY_LIMIT, + time_interval=MINUTE, + linked_limits=[LinkedLimitWeightPair(API_KEY_LIMIT_ID)], + ), + RateLimit( + limit_id=WS_CONNECTIONS_LIMIT_ID, + limit=NO_LIMIT, + time_interval=SECOND, + ), +] diff --git a/hummingbot/data_feed/coin_cap_data_feed/coin_cap_data_feed.py b/hummingbot/data_feed/coin_cap_data_feed/coin_cap_data_feed.py new file mode 100644 index 0000000000..46dacce4f4 --- /dev/null +++ b/hummingbot/data_feed/coin_cap_data_feed/coin_cap_data_feed.py @@ -0,0 +1,165 @@ +import asyncio +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Dict, Optional + +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.network_iterator import NetworkStatus, safe_ensure_future +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest, RESTResponse +from hummingbot.core.web_assistant.rest_pre_processors import RESTPreProcessorBase +from hummingbot.core.web_assistant.web_assistants_factory import WebAssistantsFactory +from hummingbot.data_feed.coin_cap_data_feed import coin_cap_constants as CONSTANTS +from hummingbot.data_feed.data_feed_base import DataFeedBase +from hummingbot.logger import HummingbotLogger + +if TYPE_CHECKING: + from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + +class CoinCapAPIKeyAppender(RESTPreProcessorBase): + def __init__(self, api_key: str): + super().__init__() + self._api_key = api_key + + async def pre_process(self, request: RESTRequest) -> RESTRequest: + request.headers = request.headers or {} + request.headers["Authorization"] = self._api_key + return request + + +class CoinCapDataFeed(DataFeedBase): + _logger: Optional[HummingbotLogger] = None + _async_throttler: Optional["AsyncThrottler"] = None + + @classmethod + def _get_async_throttler(cls) -> "AsyncThrottler": + """This avoids circular imports.""" + from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + if cls._async_throttler is None: + cls._async_throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) + return cls._async_throttler + + def __init__(self, assets_map: Dict[str, str], api_key: str): + super().__init__() + self._assets_map = assets_map + self._price_dict: Dict[str, Decimal] = {} + self._api_factory: Optional[WebAssistantsFactory] = None + self._api_key = api_key + self._is_api_key_authorized = True + self._prices_stream_task: Optional[asyncio.Task] = None + + self._ready_event.set() + + @property + def name(self): + return "coin_cap_api" + + @property + def health_check_endpoint(self): + return f"{CONSTANTS.BASE_REST_URL}{CONSTANTS.HEALTH_CHECK_ENDPOINT}" + + @property + def universal_quote_token(self) -> str: + return CONSTANTS.UNIVERSAL_QUOTE_TOKEN + + async def start_network(self): + self._prices_stream_task = safe_ensure_future(self._stream_prices()) + + async def stop_network(self): + self._prices_stream_task and self._prices_stream_task.cancel() + self._prices_stream_task = None + + async def check_network(self) -> NetworkStatus: + try: + await self._make_request(url=self.health_check_endpoint) + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + async def get_all_usd_quoted_prices(self) -> Dict[str, Decimal]: + prices = ( + self._price_dict + if self._prices_stream_task and len(self._price_dict) != 0 + else await self._get_all_usd_quoted_prices_by_rest_request() + ) + return prices + + def _get_api_factory(self) -> WebAssistantsFactory: + # Avoids circular logic (i.e. CoinCap needs a throttler, which needs a client config map, which needs + # a data feed — CoinCap, in this case) + if self._api_factory is None: + self._api_factory = WebAssistantsFactory( + throttler=self._get_async_throttler(), + rest_pre_processors=[CoinCapAPIKeyAppender(api_key=self._api_key)], + ) + return self._api_factory + + async def _get_all_usd_quoted_prices_by_rest_request(self) -> Dict[str, Decimal]: + prices = {} + url = f"{CONSTANTS.BASE_REST_URL}{CONSTANTS.ALL_ASSETS_ENDPOINT}" + + params = { + "ids": ",".join(self._assets_map.values()), + } + + data = await self._make_request(url=url, params=params) + for asset_data in data["data"]: + base = asset_data["symbol"] + trading_pair = combine_to_hb_trading_pair(base=base, quote=CONSTANTS.UNIVERSAL_QUOTE_TOKEN) + try: + prices[trading_pair] = Decimal(asset_data["priceUsd"]) + except TypeError: + continue + + return prices + + async def _make_request(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + api_factory = self._get_api_factory() + rest_assistant = await api_factory.get_rest_assistant() + rate_limit_id = CONSTANTS.API_KEY_LIMIT_ID if self._is_api_key_authorized else CONSTANTS.NO_KEY_LIMIT_ID + response = await rest_assistant.execute_request_and_get_response( + url=url, + throttler_limit_id=rate_limit_id, + params=params, + method=RESTMethod.GET, + ) + self._check_is_api_key_authorized(response=response) + data = await response.json() + return data + + def _check_is_api_key_authorized(self, response: RESTResponse): + self.logger().debug(f"CoinCap REST response headers: {response.headers}") + self._is_api_key_authorized = int(response.headers["X-Ratelimit-Limit"]) == CONSTANTS.API_KEY_LIMIT + if not self._is_api_key_authorized and self._api_key != "": + self.logger().warning("CoinCap API key is not authorized. Please check your API key.") + + async def _stream_prices(self): + while True: + try: + api_factory = self._get_api_factory() + self._price_dict = await self._get_all_usd_quoted_prices_by_rest_request() + ws = await api_factory.get_ws_assistant() + symbols_map = {asset_id: symbol for symbol, asset_id in self._assets_map.items()} + ws_url = f"{CONSTANTS.BASE_WS_URL}{','.join(self._assets_map.values())}" + async with api_factory.throttler.execute_task(limit_id=CONSTANTS.WS_CONNECTIONS_LIMIT_ID): + await ws.connect(ws_url=ws_url) + async for msg in ws.iter_messages(): + for asset_id, price_str in msg.data.items(): + base = symbols_map[asset_id] + trading_pair = combine_to_hb_trading_pair(base=base, quote=CONSTANTS.UNIVERSAL_QUOTE_TOKEN) + self._price_dict[trading_pair] = Decimal(price_str) + except asyncio.CancelledError: + raise + except Exception: + self.logger().network( + log_msg="Unexpected error while streaming prices. Restarting the stream.", + exc_info=True, + ) + await self._sleep(delay=1) + + @staticmethod + async def _sleep(delay: float): + """Used for unit-test mocking.""" + await asyncio.sleep(delay) diff --git a/hummingbot/strategy/amm_arb/start.py b/hummingbot/strategy/amm_arb/start.py index 65557c4ba3..0063b3757d 100644 --- a/hummingbot/strategy/amm_arb/start.py +++ b/hummingbot/strategy/amm_arb/start.py @@ -2,6 +2,7 @@ from typing import cast from hummingbot.connector.gateway.amm.gateway_evm_amm import GatewayEVMAMM +from hummingbot.connector.gateway.amm.gateway_tezos_amm import GatewayTezosAMM from hummingbot.connector.gateway.common_types import Chain from hummingbot.connector.gateway.gateway_price_shim import GatewayPriceShim from hummingbot.core.rate_oracle.rate_oracle import RateOracle @@ -44,6 +45,8 @@ def start(self): other_market_name = connector_1 if Chain.ETHEREUM.chain == amm_market_info.market.chain: amm_connector: GatewayEVMAMM = cast(GatewayEVMAMM, amm_market_info.market) + elif Chain.TEZOS.chain == amm_market_info.market.chain: + amm_connector: GatewayTezosAMM = cast(GatewayTezosAMM, amm_market_info.market) else: raise ValueError(f"Unsupported chain: {amm_market_info.market.chain}") GatewayPriceShim.get_instance().patch_prices( diff --git a/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map_pydantic.py b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map_pydantic.py index 41f5cf8dad..78768767b0 100644 --- a/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map_pydantic.py +++ b/hummingbot/strategy/avellaneda_market_making/avellaneda_market_making_config_map_pydantic.py @@ -44,7 +44,9 @@ class Config: title = "from_date_to_date" @validator("start_datetime", "end_datetime", pre=True) - def validate_execution_time(cls, v: str) -> Optional[str]: + def validate_execution_time(cls, v: Union[str, datetime]) -> Optional[str]: + if not isinstance(v, str): + v = v.strftime("%Y-%m-%d %H:%M:%S") ret = validate_datetime_iso_string(v) if ret is not None: raise ValueError(ret) @@ -73,7 +75,9 @@ class Config: title = "daily_between_times" @validator("start_time", "end_time", pre=True) - def validate_execution_time(cls, v: str) -> Optional[str]: + def validate_execution_time(cls, v: Union[str, datetime]) -> Optional[str]: + if not isinstance(v, str): + v = v.strftime("%H:%M:%S") ret = validate_time_iso_string(v) if ret is not None: raise ValueError(ret) diff --git a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.py b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.py index dc8b3932c0..f47fbec187 100755 --- a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.py +++ b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making.py @@ -247,7 +247,7 @@ def is_gateway_market(market_info: MarketTradingPairTuple) -> bool: return market_info.market.name in AllConnectorSettings.get_gateway_amm_connector_names() def get_conversion_rates(self, market_pair: MarketTradingPairTuple): - quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair, gas_rate_source,\ + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair, gas_rate_source, \ gas_rate = self._config_map.conversion_rate_mode.get_conversion_rates(market_pair) if quote_rate is None: self.logger().warning(f"Can't find a conversion rate for {quote_pair}") @@ -255,12 +255,12 @@ def get_conversion_rates(self, market_pair: MarketTradingPairTuple): self.logger().warning(f"Can't find a conversion rate for {base_pair}") if gas_rate is None: self.logger().warning(f"Can't find a conversion rate for {gas_pair}") - return quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair,\ + return quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair, \ gas_rate_source, gas_rate def log_conversion_rates(self): for market_pair in self._market_pairs.values(): - quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair,\ + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair, \ gas_rate_source, gas_rate = self.get_conversion_rates(market_pair) if quote_pair.split("-")[0] != quote_pair.split("-")[1]: self.logger().info(f"{quote_pair} ({quote_rate_source}) conversion rate: {PerformanceMetrics.smart_round(quote_rate)}") @@ -274,7 +274,7 @@ def oracle_status_df(self): columns = ["Source", "Pair", "Rate"] data = [] for market_pair in self._market_pairs.values(): - quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair,\ + quote_pair, quote_rate_source, quote_rate, base_pair, base_rate_source, base_rate, gas_pair, \ gas_rate_source, gas_rate = self.get_conversion_rates(market_pair) if quote_pair.split("-")[0] != quote_pair.split("-")[1]: data.extend([ @@ -345,7 +345,7 @@ def format_status(self) -> str: limit_orders = list(tracked_maker_orders[market_pair].values()) bid, ask = self.get_top_bid_ask(market_pair) mid_price = (bid + ask) / 2 - df = LimitOrder.to_pandas(limit_orders, mid_price) + df = LimitOrder.to_pandas(limit_orders, float(mid_price)) df_lines = str(df).split("\n") lines.extend(["", " Active maker market orders:"] + [" " + line for line in df_lines]) diff --git a/hummingbot/strategy/twap/twap.py b/hummingbot/strategy/twap/twap.py index 09d1a958f5..1fa01f3571 100644 --- a/hummingbot/strategy/twap/twap.py +++ b/hummingbot/strategy/twap/twap.py @@ -1,24 +1,16 @@ -from datetime import datetime -from decimal import Decimal import logging import statistics -from typing import ( - List, - Tuple, - Optional, - Dict -) +from datetime import datetime +from decimal import Decimal +from typing import Dict, List, Optional, Tuple from hummingbot.client.performance import PerformanceMetrics from hummingbot.connector.exchange_base import ExchangeBase from hummingbot.core.clock import Clock +from hummingbot.core.data_type.common import OrderType, TradeType from hummingbot.core.data_type.limit_order import LimitOrder from hummingbot.core.data_type.order_book import OrderBook -from hummingbot.core.event.events import (MarketOrderFailureEvent, - OrderCancelledEvent, - OrderExpiredEvent, - ) -from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.event.events import MarketOrderFailureEvent, OrderCancelledEvent, OrderExpiredEvent from hummingbot.core.network_iterator import NetworkStatus from hummingbot.logger import HummingbotLogger from hummingbot.strategy.conditional_execution_state import ConditionalExecutionState, RunAlwaysExecutionState @@ -167,7 +159,7 @@ def format_status(self) -> str: for market_info in self._market_infos.values(): price_provider = market_info if price_provider is not None: - df = LimitOrder.to_pandas(active_orders, mid_price=price_provider.get_mid_price()) + df = LimitOrder.to_pandas(active_orders, mid_price=float(price_provider.get_mid_price())) if self._is_buy: # Descend from the price closest to the mid price df = df.sort_values(by=['Price'], ascending=False) diff --git a/install b/install index 4a42eb6b4c..123662a956 100755 --- a/install +++ b/install @@ -35,3 +35,15 @@ conda develop . pip install objgraph pre-commit install + +# The following logic is required to replace the grpcio package installed from conda binaries in Mac Intel +# for binaries from Pypi. We need to do this because the conda binaries fro Mac Intel are broken. +# We can't use the Pypi binaries universally because they are broken for Mac ARM (M1 and M2). +# This logic can be removed once the grpcio conda binaries for Mac Intel are fixed +OS=`uname` +ARCH=`uname -m` + +if [[ "$OS" = "Darwin" && "$ARCH" = "x86_64" ]]; then + pip install grpcio --ignore-installed +fi + diff --git a/scripts/directional_strategy_bb_rsi_multi_timeframe.py b/scripts/directional_strategy_bb_rsi_multi_timeframe.py index 7b39cc591c..38d52198b4 100644 --- a/scripts/directional_strategy_bb_rsi_multi_timeframe.py +++ b/scripts/directional_strategy_bb_rsi_multi_timeframe.py @@ -39,7 +39,7 @@ class MultiTimeframeBBRSI(DirectionalStrategyBase): # Define the trading pair and exchange that we want to use and the csv where we are going to store the entries trading_pair: str = "ETH-USDT" exchange: str = "binance_perpetual" - order_amount_usd = Decimal("20") + order_amount_usd = Decimal("40") leverage = 10 # Configure the parameters for the position diff --git a/scripts/directional_strategy_macd_bb.py b/scripts/directional_strategy_macd_bb.py index e0ad856a19..7f2d3588a1 100644 --- a/scripts/directional_strategy_macd_bb.py +++ b/scripts/directional_strategy_macd_bb.py @@ -39,7 +39,7 @@ class MacdBB(DirectionalStrategyBase): # Define the trading pair and exchange that we want to use and the csv where we are going to store the entries trading_pair: str = "BTC-USDT" exchange: str = "binance_perpetual" - order_amount_usd = Decimal("20") + order_amount_usd = Decimal("40") leverage = 10 # Configure the parameters for the position diff --git a/scripts/directional_strategy_rsi.py b/scripts/directional_strategy_rsi.py index c9c9d829d6..e0d28cbc77 100644 --- a/scripts/directional_strategy_rsi.py +++ b/scripts/directional_strategy_rsi.py @@ -43,7 +43,7 @@ class RSI(DirectionalStrategyBase): # Define the trading pair and exchange that we want to use and the csv where we are going to store the entries trading_pair: str = "ETH-USDT" exchange: str = "binance_perpetual" - order_amount_usd = Decimal("20") + order_amount_usd = Decimal("40") leverage = 10 # Configure the parameters for the position diff --git a/scripts/directional_strategy_rsi_spot.py b/scripts/directional_strategy_rsi_spot.py index 22c54d2e76..723d5c1407 100644 --- a/scripts/directional_strategy_rsi_spot.py +++ b/scripts/directional_strategy_rsi_spot.py @@ -43,7 +43,7 @@ class RSISpot(DirectionalStrategyBase): # Define the trading pair and exchange that we want to use and the csv where we are going to store the entries trading_pair: str = "ETH-USDT" exchange: str = "binance" - order_amount_usd = Decimal("20") + order_amount_usd = Decimal("40") leverage = 10 # Configure the parameters for the position diff --git a/scripts/directional_strategy_trend_follower.py b/scripts/directional_strategy_trend_follower.py index 339f6fccdb..857750f42b 100644 --- a/scripts/directional_strategy_trend_follower.py +++ b/scripts/directional_strategy_trend_follower.py @@ -9,7 +9,7 @@ class TrendFollowingStrategy(DirectionalStrategyBase): directional_strategy_name = "trend_following" trading_pair = "DOGE-USDT" exchange = "binance_perpetual" - order_amount_usd = Decimal("20") + order_amount_usd = Decimal("40") leverage = 10 # Configure the parameters for the position diff --git a/scripts/directional_strategy_widening_ema_bands.py b/scripts/directional_strategy_widening_ema_bands.py index 9e6c2ba763..3a1946fea5 100644 --- a/scripts/directional_strategy_widening_ema_bands.py +++ b/scripts/directional_strategy_widening_ema_bands.py @@ -39,7 +39,7 @@ class WideningEMABands(DirectionalStrategyBase): # Define the trading pair and exchange that we want to use and the csv where we are going to store the entries trading_pair: str = "LINA-USDT" exchange: str = "binance_perpetual" - order_amount_usd = Decimal("20") + order_amount_usd = Decimal("40") leverage = 10 distance_pct_threshold = 0.02 diff --git a/scripts/fixed_grid.py b/scripts/fixed_grid.py index 7b13142ae2..09cfc4a516 100644 --- a/scripts/fixed_grid.py +++ b/scripts/fixed_grid.py @@ -189,7 +189,7 @@ def create_rebalance_proposal(self): if self.rebalance_order_buy is False: ref_price = self.connectors[self.exchange].get_price_by_type(self.trading_pair, self.price_source) - price = ref_price * (Decimal("1") + self.rebalance_order_spread) / Decimal("100") + price = ref_price * (Decimal("100") + self.rebalance_order_spread) / Decimal("100") size = self.rebalance_order_amount msg = (f"Placing sell order to rebalance; amount: {size}, price: {price}") self.log_with_clock(logging.INFO, msg) diff --git a/setup.py b/setup.py index 5419d5ea4f..4cf99d62e2 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def build_extensions(self): def main(): cpu_count = os.cpu_count() or 8 - version = "20230724" + version = "20230828" packages = find_packages(include=["hummingbot", "hummingbot.*"]) package_data = { "hummingbot": [ @@ -73,6 +73,9 @@ def main(): "eth-utils", "ethsnarks-loopring", "flake8", + "gql", + "grpcio", + "grpcio-tools" "hexbytes", "importlib-metadata", "injective-py" @@ -114,13 +117,14 @@ def main(): cython_sources = ["hummingbot/**/*.pyx"] + compiler_directives = { + "annotation_typing": False, + } if os.environ.get('WITHOUT_CYTHON_OPTIMIZATIONS'): - compiler_directives = { + compiler_directives.update({ "optimize.use_switch": False, "optimize.unpack_method_calls": False, - } - else: - compiler_directives = {} + }) if is_posix: cython_kwargs["nthreads"] = cpu_count diff --git a/setup/environment.yml b/setup/environment.yml index ca9b230575..8c44ffe0f7 100644 --- a/setup/environment.yml +++ b/setup/environment.yml @@ -3,34 +3,31 @@ channels: - conda-forge - defaults dependencies: - - aiounittest=1.4.1 - bidict - - coverage=5.5 - - gql - - grpcio + - coverage + - cython=3.0 - grpcio-tools - - nomkl=1.0 + - nomkl - nose=1.3.7 - nose-exclude - numpy=1.23.5 - numpy-base=1.23.5 - pandas=1.5.3 - - pip=23.1.2 + - pip - prompt_toolkit=3.0.20 - - pydantic=1.9.2 - - pytest==7.3.2 - - python=3.10.12 - - pytables=3.8.0 + - pydantic=1.10 + - pytest + - python=3.10 - scipy=1.10.1 - sqlalchemy=1.4 - tabulate==0.8.9 - - typing-extensions<4.6.0 - ujson - - zlib=1.2.13 + - zlib - pip: - aiohttp==3.* - aioprocessing==2.0 - aioresponses + - aiounittest - appdirs==1.4.3 - async-timeout - asyncssh==2.13.1 @@ -40,20 +37,19 @@ dependencies: - cachetools==4.0.0 - commlib-py==0.10.6 - cryptography==3.4.7 - - cython==3.0.0a10 - - diff-cover==5.1.2 + - diff-cover - docker==5.0.3 - eip712-structs==1.1.0 - dotmap==1.3.30 - ethsnarks-loopring==0.1.5 - flake8==3.7.9 + - gql - importlib-metadata==0.23 - injective-py==0.7.* - jsonpickle==3.0.1 - mypy-extensions==0.4.3 - pandas_ta==0.3.14b - pre-commit==2.18.1 - - protobuf>=4 - psutil==5.7.2 - ptpython==3.0.20 - pyjwt==1.7.1 @@ -69,4 +65,4 @@ dependencies: - websockets - yarl==1.* - git+https://github.com/CoinAlpha/python-signalr-client.git - - git+https://github.com/konichuvak/dydx-v3-python.git@web3 \ No newline at end of file + - git+https://github.com/konichuvak/dydx-v3-python.git@web3 diff --git a/start b/start new file mode 100755 index 0000000000..d048c725e6 --- /dev/null +++ b/start @@ -0,0 +1,16 @@ +#!/bin/bash + +# Check if bin/hummingbot.py exists +if [[ ! -f bin/hummingbot.py ]]; then + echo "Error: bin/hummingbot.py command not found. Make sure you are in the Hummingbot root directory" + exit 1 +fi + +# Check if the hummingbot conda environment is activated +if [[ $CONDA_DEFAULT_ENV != "hummingbot" ]]; then + echo "Error: 'hummingbot' conda environment is not activated. Please activate it and try again." + exit 1 +fi + +# Run bin/hummingbot.py and append errors to logs/errors.log +./bin/hummingbot.py 2>> ./logs/errors.log \ No newline at end of file diff --git a/test/hummingbot/client/config/test_config_helpers.py b/test/hummingbot/client/config/test_config_helpers.py index dd8c2e7b14..6f613313a8 100644 --- a/test/hummingbot/client/config/test_config_helpers.py +++ b/test/hummingbot/client/config/test_config_helpers.py @@ -11,7 +11,7 @@ from hummingbot.client.config import config_helpers from hummingbot.client.config.client_config_map import ClientConfigMap, CommandShortcutModel from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger -from hummingbot.client.config.config_data_types import BaseClientModel, BaseConnectorConfigMap +from hummingbot.client.config.config_data_types import BaseClientModel, BaseConnectorConfigMap, ClientFieldData from hummingbot.client.config.config_helpers import ( ClientConfigAdapter, ReadOnlyClientConfigAdapter, @@ -122,7 +122,7 @@ class Config: def test_load_connector_config_map_from_file_with_secrets(self, get_connector_config_keys_mock: MagicMock): class DummyConnectorModel(BaseConnectorConfigMap): connector = "some-connector" - secret_attr: Optional[SecretStr] = Field(default=None) + secret_attr: Optional[SecretStr] = Field(default=None, client_data=ClientFieldData(is_secure=True)) password = "some-pass" Security.secrets_manager = ETHKeyFileSecretManger(password) diff --git a/test/hummingbot/connector/derivative/dydx_perpetual/test_dydx_perpetual_derivative.py b/test/hummingbot/connector/derivative/dydx_perpetual/test_dydx_perpetual_derivative.py index 4c6dd1a505..8848ad0dd8 100644 --- a/test/hummingbot/connector/derivative/dydx_perpetual/test_dydx_perpetual_derivative.py +++ b/test/hummingbot/connector/derivative/dydx_perpetual/test_dydx_perpetual_derivative.py @@ -21,21 +21,23 @@ from hummingbot.connector.utils import combine_to_hb_trading_pair from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_row import OrderBookRow from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase from hummingbot.core.web_assistant.connections.data_types import RESTRequest class DydxPerpetualAuthMock(DydxPerpetualAuth): def get_order_signature( - self, - position_id: str, - client_id: str, - market: str, - side: str, - size: str, - price: str, - limit_fee: str, - expiration_epoch_seconds: int, + self, + position_id: str, + client_id: str, + market: str, + side: str, + size: str, + price: str, + limit_fee: str, + expiration_epoch_seconds: int, ) -> str: return "0123456789" @@ -487,28 +489,30 @@ def create_exchange_instance(self): return exchange def place_buy_order( - self, - amount: Decimal = Decimal("100"), - price: Decimal = Decimal("10_000"), - position_action: PositionAction = PositionAction.OPEN, + self, + amount: Decimal = Decimal("100"), + price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.LIMIT, + position_action: PositionAction = PositionAction.OPEN, ): notional_amount = amount * price self.exchange._order_notional_amounts[notional_amount] = len(self.exchange._order_notional_amounts.keys()) self.exchange._current_place_order_requests = 1 self.exchange._throttler.set_rate_limits(self.exchange.rate_limits_rules) - return super().place_buy_order(amount, price, position_action) + return super().place_buy_order(amount, price, order_type, position_action) def place_sell_order( - self, - amount: Decimal = Decimal("100"), - price: Decimal = Decimal("10_000"), - position_action: PositionAction = PositionAction.OPEN, + self, + amount: Decimal = Decimal("100"), + price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.LIMIT, + position_action: PositionAction = PositionAction.OPEN, ): notional_amount = amount * price self.exchange._order_notional_amounts[notional_amount] = len(self.exchange._order_notional_amounts.keys()) self.exchange._current_place_order_requests = 1 self.exchange._throttler.set_rate_limits(self.exchange.rate_limits_rules) - return super().place_sell_order(amount, price, position_action) + return super().place_sell_order(amount, price, order_type, position_action) def validate_auth_credentials_present(self, request_call: RequestCall): request_headers = request_call.kwargs["headers"] @@ -548,7 +552,8 @@ def validate_trades_request(self, order: InFlightOrder, request_call: RequestCal self.assertEqual(CONSTANTS.LAST_FILLS_MAX, request_params["limit"]) def configure_successful_cancelation_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: """ :return: the URL configured for the cancelation @@ -561,7 +566,8 @@ def configure_successful_cancelation_response( return url def configure_erroneous_cancelation_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: """ :return: the URL configured for the cancelation @@ -574,7 +580,7 @@ def configure_erroneous_cancelation_response( return url def configure_one_successful_one_erroneous_cancel_all_response( - self, successful_order: InFlightOrder, erroneous_order: InFlightOrder, mock_api: aioresponses + self, successful_order: InFlightOrder, erroneous_order: InFlightOrder, mock_api: aioresponses ) -> List[str]: """ :return: a list of all configured URLs for the cancelations @@ -602,7 +608,8 @@ def configure_order_not_found_error_order_status_response( raise NotImplementedError def configure_completely_filled_order_status_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: """ :return: the URL configured @@ -615,7 +622,8 @@ def configure_completely_filled_order_status_response( return [url_order_status] def configure_canceled_order_status_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: """ :return: the URL configured @@ -633,7 +641,8 @@ def configure_canceled_order_status_response( return [url_fills, url_order_status] def configure_open_order_status_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: """ :return: the URL configured @@ -646,7 +655,8 @@ def configure_open_order_status_response( return [url] def configure_http_error_order_status_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: """ :return: the URL configured @@ -658,19 +668,22 @@ def configure_http_error_order_status_response( return url def configure_partially_filled_order_status_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: # Dydx has no partial fill status raise NotImplementedError def configure_partial_fill_trade_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: # Dydx has no partial fill status raise NotImplementedError def configure_erroneous_http_fill_trade_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: """ :return: the URL configured @@ -681,7 +694,8 @@ def configure_erroneous_http_fill_trade_response( return url def configure_full_fill_trade_response( - self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: """ :return: the URL configured @@ -1065,28 +1079,28 @@ def position_event_for_full_fill_websocket_update(self, order: InFlightOrder, un } def configure_successful_set_position_mode( - self, - position_mode: PositionMode, - mock_api: aioresponses, - callback: Optional[Callable] = lambda *args, **kwargs: None, + self, + position_mode: PositionMode, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, ): # There's only one way position mode pass def configure_failed_set_position_mode( - self, - position_mode: PositionMode, - mock_api: aioresponses, - callback: Optional[Callable] = lambda *args, **kwargs: None, + self, + position_mode: PositionMode, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, ) -> Tuple[str, str]: # There's only one way position mode, this should never be called pass def configure_failed_set_leverage( - self, - leverage: int, - mock_api: aioresponses, - callback: Optional[Callable] = lambda *args, **kwargs: None, + self, + leverage: int, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, ) -> Tuple[str, str]: url = web_utils.public_rest_url(CONSTANTS.PATH_MARKETS) regex_url = re.compile(f"^{url}") @@ -1098,10 +1112,10 @@ def configure_failed_set_leverage( return url, "Failed to obtain markets information." def configure_successful_set_leverage( - self, - leverage: int, - mock_api: aioresponses, - callback: Optional[Callable] = lambda *args, **kwargs: None, + self, + leverage: int, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, ): url = web_utils.public_rest_url(CONSTANTS.PATH_MARKETS) regex_url = re.compile(f"^{url}") @@ -1213,3 +1227,53 @@ def test_lost_order_removed_if_not_found_during_order_status_update(self, mock_a # Disabling this test because the connector has not been updated yet to validate # order not found during status update (check _is_order_not_found_during_status_update_error) pass + + def place_buy_market_order( + self, + amount: Decimal = Decimal("100"), + price: Decimal = Decimal("10_000"), + order_type: OrderType = OrderType.MARKET, + position_action: PositionAction = PositionAction.OPEN, + ): + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[], + asks=[OrderBookRow(price=5.1, amount=2000, update_id=1)], + update_id=1, + ) + + notional_amount = amount * price + self.exchange._order_notional_amounts[notional_amount] = len(self.exchange._order_notional_amounts.keys()) + self.exchange._current_place_order_requests = 1 + self.exchange._throttler.set_rate_limits(self.exchange.rate_limits_rules) + order_id = self.exchange.buy( + trading_pair=self.trading_pair, + amount=amount, + order_type=order_type, + price=price, + position_action=position_action, + ) + return order_id + + @aioresponses() + def test_create_buy_market_order_successfully(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + url = self.order_creation_url + + creation_response = self.order_creation_request_successful_mock_response + + mock_api.post(url, + body=json.dumps(creation_response), + callback=lambda *args, **kwargs: request_sent_event.set()) + + leverage = 2 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + self.place_buy_market_order() + self.async_run_with_timeout(request_sent_event.wait()) + order_request = self._all_executed_requests(mock_api, url)[0] + request_data = json.loads(order_request.kwargs["data"]) + self.assertEqual(Decimal("1.5") * Decimal("5.1"), Decimal(request_data["price"])) diff --git a/test/hummingbot/connector/derivative/gate_io_perpetual/test_gate_io_perpetual_derivative.py b/test/hummingbot/connector/derivative/gate_io_perpetual/test_gate_io_perpetual_derivative.py index 81c9b74f06..3d486e99dd 100644 --- a/test/hummingbot/connector/derivative/gate_io_perpetual/test_gate_io_perpetual_derivative.py +++ b/test/hummingbot/connector/derivative/gate_io_perpetual/test_gate_io_perpetual_derivative.py @@ -843,6 +843,7 @@ def configure_successful_set_position_mode( "user": 1666, "currency": "USDT", "total": "9707.803567115145", + "size": "9707.803567115145", "unrealised_pnl": "3371.248828", "position_margin": "38.712189181", "order_margin": "0", @@ -850,6 +851,7 @@ def configure_successful_set_position_mode( "point": "0", "bonus": "0", "in_dual_mode": True if position_mode is PositionMode.HEDGE else False, + "mode": "single" if position_mode is PositionMode.ONEWAY else "dual_long", "history": { "dnw": "10000", "pnl": "68.3685", @@ -1710,3 +1712,94 @@ def test_create_buy_limit_maker_order_successfully(self, mock_api): f"{Decimal('100.000000')} to {PositionAction.OPEN.name} a {self.trading_pair} position." ) ) + + @aioresponses() + def test_update_position_mode( + self, + mock_api: aioresponses, + ): + self._simulate_trading_rules_initialized() + get_position_url = web_utils.public_rest_url( + endpoint=CONSTANTS.POSITION_INFORMATION_URL + ) + regex_get_position_url = re.compile(f"^{get_position_url}") + response = [ + { + "user": 10000, + "contract": "BTC_USDT", + "size": 9440, + "leverage": "0", + "risk_limit": "100", + "leverage_max": "100", + "maintenance_rate": "0.005", + "value": "2.497143098997", + "margin": "4.431548146258", + "entry_price": "3779.55", + "liq_price": "99999999", + "mark_price": "3780.32", + "unrealised_pnl": "-0.000507486844", + "realised_pnl": "0.045543982432", + "history_pnl": "0", + "last_close_pnl": "0", + "realised_point": "0", + "history_point": "0", + "adl_ranking": 5, + "pending_orders": 16, + "close_order": { + "id": 232323, + "price": "3779", + "is_liq": False + }, + "mode": "single", + "update_time": 1684994406, + "cross_leverage_limit": "0" + } + ] + mock_api.get(regex_get_position_url, body=json.dumps(response)) + self.async_run_with_timeout(self.exchange._update_positions()) + + position: Position = self.exchange.account_positions[self.trading_pair] + self.assertEqual(self.trading_pair, position.trading_pair) + self.assertEqual(PositionSide.LONG, position.position_side) + + get_position_url = web_utils.public_rest_url( + endpoint=CONSTANTS.POSITION_INFORMATION_URL + ) + regex_get_position_url = re.compile(f"^{get_position_url}") + response = [ + { + "user": 10000, + "contract": "BTC_USDT", + "size": 9440, + "leverage": "0", + "risk_limit": "100", + "leverage_max": "100", + "maintenance_rate": "0.005", + "value": "2.497143098997", + "margin": "4.431548146258", + "entry_price": "3779.55", + "liq_price": "99999999", + "mark_price": "3780.32", + "unrealised_pnl": "-0.000507486844", + "realised_pnl": "0.045543982432", + "history_pnl": "0", + "last_close_pnl": "0", + "realised_point": "0", + "history_point": "0", + "adl_ranking": 5, + "pending_orders": 16, + "close_order": { + "id": 232323, + "price": "3779", + "is_liq": False + }, + "mode": "dual_long", + "update_time": 1684994406, + "cross_leverage_limit": "0" + } + ] + mock_api.get(regex_get_position_url, body=json.dumps(response)) + self.async_run_with_timeout(self.exchange._update_positions()) + position: Position = self.exchange.account_positions[f"{self.trading_pair}LONG"] + self.assertEqual(self.trading_pair, position.trading_pair) + self.assertEqual(PositionSide.LONG, position.position_side) diff --git a/test/hummingbot/connector/derivative/injective_v2_perpetual/__init__.py b/test/hummingbot/connector/derivative/injective_v2_perpetual/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_derivative_for_delegated_account.py b/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_derivative_for_delegated_account.py new file mode 100644 index 0000000000..5c320aecf3 --- /dev/null +++ b/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_derivative_for_delegated_account.py @@ -0,0 +1,2911 @@ +import asyncio +import base64 +import json +from collections import OrderedDict +from decimal import Decimal +from functools import partial +from test.hummingbot.connector.exchange.injective_v2.programmable_query_executor import ProgrammableQueryExecutor +from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from unittest.mock import AsyncMock, MagicMock + +from aioresponses import aioresponses +from aioresponses.core import RequestCall +from bidict import bidict +from grpc import RpcError +from pyinjective import Address, PrivateKey +from pyinjective.orderhash import OrderHashResponse + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_derivative import ( + InjectiveV2PerpetualDerivative, +) +from hummingbot.connector.exchange.injective_v2.injective_v2_utils import ( + InjectiveConfigMap, + InjectiveDelegatedAccountMode, + InjectiveTestnetNetworkMode, +) +from hummingbot.connector.gateway.clob_spot.data_sources.injective.injective_utils import OrderHashManager +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayPerpetualInFlightOrder +from hummingbot.connector.test_support.perpetual_derivative_test import AbstractPerpetualDerivativeTests +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PositionSide, TradeType +from hummingbot.core.data_type.funding_info import FundingInfo, FundingInfoUpdate +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.market_order import MarketOrder +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_row import OrderBookRow +from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.event.events import ( + BuyOrderCompletedEvent, + BuyOrderCreatedEvent, + FundingPaymentCompletedEvent, + MarketOrderFailureEvent, + OrderCancelledEvent, + OrderFilledEvent, +) +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_gather + + +class InjectiveV2PerpetualDerivativeTests(AbstractPerpetualDerivativeTests.PerpetualDerivativeTests): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "INJ" + cls.quote_asset = "USDT" + cls.base_asset_denom = "inj" + cls.quote_asset_denom = "peggy0x87aB3B4C8661e07D6372361211B96ed4Dc36B1B5" # noqa: mock + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.market_id = "0x17ef48032cb24375ba7c2e39f384e56433bcab20cbee9a7357e4cba2eb00abe6" # noqa: mock + + _, grantee_private_key = PrivateKey.generate() + cls.trading_account_private_key = grantee_private_key.to_hex() + cls.trading_account_subaccount_index = 0 + _, granter_private_key = PrivateKey.generate() + granter_address = Address(bytes.fromhex(granter_private_key.to_public_key().to_hex())) + cls.portfolio_account_injective_address = granter_address.to_acc_bech32() + cls.portfolio_account_subaccount_index = 0 + portfolio_adderss = Address.from_acc_bech32(cls.portfolio_account_injective_address) + cls.portfolio_account_subaccount_id = portfolio_adderss.get_subaccount_id( + index=cls.portfolio_account_subaccount_index + ) + cls.base_decimals = 18 + cls.quote_decimals = 6 + + def setUp(self) -> None: + super().setUp() + self._original_async_loop = asyncio.get_event_loop() + self.async_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.async_loop) + self._logs_event: Optional[asyncio.Event] = None + self.exchange._data_source.logger().setLevel(1) + self.exchange._data_source.logger().addHandler(self) + + self.exchange._orders_processing_delta_time = 0.1 + self.async_tasks.append(self.async_loop.create_task(self.exchange._process_queued_orders())) + + def tearDown(self) -> None: + super().tearDown() + self.async_loop.stop() + self.async_loop.close() + asyncio.set_event_loop(self._original_async_loop) + self._logs_event = None + + def handle(self, record): + super().handle(record=record) + if self._logs_event is not None: + self._logs_event.set() + + def reset_log_event(self): + if self._logs_event is not None: + self._logs_event.clear() + + async def wait_for_a_log(self): + if self._logs_event is not None: + await self._logs_event.wait() + + @property + def expected_supported_position_modes(self) -> List[PositionMode]: + return [PositionMode.ONEWAY] + + @property + def funding_info_url(self): + raise NotImplementedError + + @property + def funding_payment_url(self): + raise NotImplementedError + + @property + def funding_info_mock_response(self): + raise NotImplementedError + + @property + def empty_funding_payment_mock_response(self): + raise NotImplementedError + + @property + def funding_payment_mock_response(self): + raise NotImplementedError + + def position_event_for_full_fill_websocket_update(self, order: InFlightOrder, unrealized_pnl: float): + raise NotImplementedError + + def configure_successful_set_position_mode( + self, + position_mode: PositionMode, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None): + raise NotImplementedError + + def configure_failed_set_position_mode( + self, + position_mode: PositionMode, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> Tuple[str, str]: + # Do nothing + return "", "" + + def configure_failed_set_leverage( + self, + leverage: int, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> Tuple[str, str]: + raise NotImplementedError + + def configure_successful_set_leverage( + self, + leverage: int, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ): + raise NotImplementedError + + def funding_info_event_for_websocket_update(self): + raise NotImplementedError + + @property + def all_symbols_url(self): + raise NotImplementedError + + @property + def latest_prices_url(self): + raise NotImplementedError + + @property + def network_status_url(self): + raise NotImplementedError + + @property + def trading_rules_url(self): + raise NotImplementedError + + @property + def order_creation_url(self): + raise NotImplementedError + + @property + def balance_url(self): + raise NotImplementedError + + @property + def all_symbols_request_mock_response(self): + raise NotImplementedError + + @property + def latest_prices_request_mock_response(self): + return { + "trades": [ + { + "orderHash": "0x9ffe4301b24785f09cb529c1b5748198098b17bd6df8fe2744d923a574179229", # noqa: mock + "subaccountId": "0xa73ad39eab064051fb468a5965ee48ca87ab66d4000000000000000000000000", # noqa: mock + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "tradeExecutionType": "limitMatchRestingOrder", + "positionDelta": { + "tradeDirection": "sell", + "executionPrice": str(Decimal(str(self.expected_latest_price)) * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": "142000000000000000000", + "executionMargin": "1245280000" + }, + "payout": "1187984833.579447998034818126", + "fee": "-112393", + "executedAt": "1688734042063", + "feeRecipient": "inj15uad884tqeq9r76x3fvktmjge2r6kek55c2zpa", # noqa: mock + "tradeId": "13374245_801_0", + "executionSide": "maker" + }, + ], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + response = self.all_derivative_markets_mock_response + response.append({ + "marketId": "invalid_market_id", + "marketStatus": "active", + "ticker": "INVALID/MARKET", + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + "minPriceTickSize": "0.000000000000001", + "minQuantityTickSize": "1000000000000000" + }) + + return ("INVALID_MARKET", response) + + @property + def network_status_request_successful_mock_response(self): + return {} + + @property + def trading_rules_request_mock_response(self): + raise NotImplementedError + + @property + def trading_rules_request_erroneous_mock_response(self): + return [{ + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "marketStatus": "active", + "ticker": f"{self.base_asset}/{self.quote_asset}", + "baseDenom": self.base_asset_denom, + "baseTokenMeta": { + "name": "Base Asset", + "address": "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30", # noqa: mock + "symbol": self.base_asset, + "logo": "https://static.alchemyapi.io/images/assets/7226.png", + "decimals": self.base_decimals, + "updatedAt": "1687190809715" + }, + "quoteDenom": self.quote_asset_denom, # noqa: mock + "quoteTokenMeta": { + "name": "Quote Asset", + "address": "0x0000000000000000000000000000000000000000", # noqa: mock + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": self.quote_decimals, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + }] + + @property + def order_creation_request_successful_mock_response(self): + return {"txhash": "017C130E3602A48E5C9D661CAC657BF1B79262D4B71D5C25B1DA62DE2338DA0E", "rawLog": "[]"} # noqa: mock + + @property + def balance_request_mock_response_for_base_and_quote(self): + return { + "accountAddress": self.portfolio_account_injective_address, + "bankBalances": [ + { + "denom": self.base_asset_denom, + "amount": str(Decimal(5) * Decimal(1e18)) + }, + { + "denom": self.quote_asset_denom, + "amount": str(Decimal(1000) * Decimal(1e6)) + } + ], + "subaccounts": [ + { + "subaccountId": self.portfolio_account_subaccount_id, + "denom": self.quote_asset_denom, + "deposit": { + "totalBalance": str(Decimal(1000) * Decimal(1e6)), + "availableBalance": str(Decimal(1000) * Decimal(1e6)) + } + }, + { + "subaccountId": self.portfolio_account_subaccount_id, + "denom": self.base_asset_denom, + "deposit": { + "totalBalance": str(Decimal(10) * Decimal(1e18)), + "availableBalance": str(Decimal(5) * Decimal(1e18)) + } + }, + ] + } + + @property + def balance_request_mock_response_only_base(self): + return { + "accountAddress": self.portfolio_account_injective_address, + "bankBalances": [ + { + "denom": self.base_asset_denom, + "amount": str(Decimal(5) * Decimal(1e18)) + }, + ], + "subaccounts": [ + { + "subaccountId": self.portfolio_account_subaccount_id, + "denom": self.base_asset_denom, + "deposit": { + "totalBalance": str(Decimal(10) * Decimal(1e18)), + "availableBalance": str(Decimal(5) * Decimal(1e18)) + } + }, + ] + } + + @property + def balance_event_websocket_update(self): + return { + "balance": { + "subaccountId": self.portfolio_account_subaccount_id, + "accountAddress": self.portfolio_account_injective_address, + "denom": self.base_asset_denom, + "deposit": { + "totalBalance": str(Decimal(15) * Decimal(1e18)), + "availableBalance": str(Decimal(10) * Decimal(1e18)), + } + }, + "timestamp": "1688659208000" + } + + @property + def expected_latest_price(self): + return 9999.9 + + @property + def expected_supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + @property + def expected_trading_rule(self): + market_info = self.all_derivative_markets_mock_response[0] + min_price_tick_size = (Decimal(market_info["minPriceTickSize"]) + * Decimal(f"1e{-market_info['quoteTokenMeta']['decimals']}")) + min_quantity_tick_size = Decimal(market_info["minQuantityTickSize"]) + trading_rule = TradingRule( + trading_pair=self.trading_pair, + min_order_size=min_quantity_tick_size, + min_price_increment=min_price_tick_size, + min_base_amount_increment=min_quantity_tick_size, + min_quote_amount_increment=min_price_tick_size, + ) + + return trading_rule + + @property + def expected_logged_error_for_erroneous_trading_rule(self): + erroneous_rule = self.trading_rules_request_erroneous_mock_response[0] + return f"Error parsing the trading pair rule: {erroneous_rule}. Skipping..." + + @property + def expected_exchange_order_id(self): + return "0x3870fbdd91f07d54425147b1bb96404f4f043ba6335b422a6d494d285b387f00" # noqa: mock + + @property + def is_order_fill_http_update_included_in_status_update(self) -> bool: + return True + + @property + def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: + return False + + @property + def expected_partial_fill_price(self) -> Decimal: + return Decimal("100") + + @property + def expected_partial_fill_amount(self) -> Decimal: + return Decimal("10") + + @property + def expected_fill_fee(self) -> TradeFeeBase: + return AddedToCostTradeFee( + percent_token=self.quote_asset, flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("30"))] + ) + + @property + def expected_fill_trade_id(self) -> str: + return "10414162_22_33" + + @property + def all_spot_markets_mock_response(self): + return [{ + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "marketStatus": "active", + "ticker": f"{self.base_asset}/{self.quote_asset}", + "baseDenom": self.base_asset_denom, + "baseTokenMeta": { + "name": "Base Asset", + "address": "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30", # noqa: mock + "symbol": self.base_asset, + "logo": "https://static.alchemyapi.io/images/assets/7226.png", + "decimals": self.base_decimals, + "updatedAt": "1687190809715" + }, + "quoteDenom": self.quote_asset_denom, # noqa: mock + "quoteTokenMeta": { + "name": "Quote Asset", + "address": "0x0000000000000000000000000000000000000000", # noqa: mock + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": self.quote_decimals, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + "minPriceTickSize": "0.000000000000001", + "minQuantityTickSize": "1000000000000000" + }] + + @property + def all_derivative_markets_mock_response(self): + return [ + { + "marketId": self.market_id, + "marketStatus": "active", + "ticker": f"{self.base_asset}/{self.quote_asset} PERP", + "oracleBase": "0x2d9315a88f3019f8efa88dfe9c0f0843712da0bac814461e27733f6b83eb51b3", # noqa: mock + "oracleQuote": "0x1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588", # noqa: mock + "oracleType": "pyth", + "oracleScaleFactor": 6, + "initialMarginRatio": "0.195", + "maintenanceMarginRatio": "0.05", + "quoteDenom": self.quote_asset_denom, + "quoteTokenMeta": { + "name": "Testnet Tether USDT", + "address": "0x0000000000000000000000000000000000000000", + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": self.quote_decimals, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0003", + "takerFeeRate": "0.003", + "serviceProviderFee": "0.4", + "isPerpetual": True, + "minPriceTickSize": "100", + "minQuantityTickSize": "0.0001", + "perpetualMarketInfo": { + "hourlyFundingRateCap": "0.000625", + "hourlyInterestRate": "0.00000416666", + "nextFundingTimestamp": str(self.target_funding_info_next_funding_utc_timestamp), + "fundingInterval": "3600" + }, + "perpetualMarketFunding": { + "cumulativeFunding": "81363.592243119007273334", + "cumulativePrice": "1.432536051546776736", + "lastTimestamp": "1689423842" + } + }, + ] + + def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: + return self.market_id + + def create_exchange_instance(self): + client_config_map = ClientConfigAdapter(ClientConfigMap()) + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") + + account_config = InjectiveDelegatedAccountMode( + private_key=self.trading_account_private_key, + subaccount_index=self.trading_account_subaccount_index, + granter_address=self.portfolio_account_injective_address, + granter_subaccount_index=self.portfolio_account_subaccount_index, + ) + + injective_config = InjectiveConfigMap( + network=network_config, + account_type=account_config, + ) + + exchange = InjectiveV2PerpetualDerivative( + client_config_map=client_config_map, + connector_configuration=injective_config, + trading_pairs=[self.trading_pair], + ) + + exchange._data_source._query_executor = ProgrammableQueryExecutor() + exchange._data_source._spot_market_and_trading_pair_map = bidict() + exchange._data_source._derivative_market_and_trading_pair_map = bidict({self.market_id: self.trading_pair}) + return exchange + + def validate_auth_credentials_present(self, request_call: RequestCall): + raise NotImplementedError + + def validate_order_creation_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def validate_order_cancelation_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def validate_order_status_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def validate_trades_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def configure_all_symbols_response( + self, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + all_markets_mock_response = self.all_spot_markets_mock_response + self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(all_markets_mock_response) + all_markets_mock_response = self.all_derivative_markets_mock_response + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait(all_markets_mock_response) + return "" + + def configure_trading_rules_response( + self, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> List[str]: + + self.configure_all_symbols_response(mock_api=mock_api, callback=callback) + return "" + + def configure_erroneous_trading_rules_response( + self, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> List[str]: + + self.exchange._data_source._query_executor._spot_markets_responses.put_nowait([]) + response = self.trading_rules_request_erroneous_mock_response + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait(response) + return "" + + def configure_successful_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + response = self._order_cancelation_request_successful_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + return "" + + def configure_erroneous_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + response = self._order_cancelation_request_erroneous_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + return "" + + def configure_order_not_found_error_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + raise NotImplementedError + + def configure_one_successful_one_erroneous_cancel_all_response( + self, + successful_order: InFlightOrder, + erroneous_order: InFlightOrder, + mock_api: aioresponses + ) -> List[str]: + raise NotImplementedError + + def configure_completely_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> List[str]: + self.configure_all_symbols_response(mock_api=mock_api) + response = self._order_status_request_completely_filled_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_canceled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> Union[str, List[str]]: + self.configure_all_symbols_response(mock_api=mock_api) + + self.exchange._data_source._query_executor._spot_trades_responses.put_nowait( + {"trades": [], "paging": {"total": "0"}}) + self.exchange._data_source._query_executor._derivative_trades_responses.put_nowait( + {"trades": [], "paging": {"total": "0"}}) + + response = self._order_status_request_canceled_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_open_order_status_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> List[str]: + self.configure_all_symbols_response(mock_api=mock_api) + + self.exchange._data_source._query_executor._derivative_trades_responses.put_nowait( + {"trades": [], "paging": {"total": "0"}}) + + response = self._order_status_request_open_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_http_error_order_status_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + self.configure_all_symbols_response(mock_api=mock_api) + + mock_queue = AsyncMock() + mock_queue.get.side_effect = IOError("Test error for trades responses") + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + + mock_queue = AsyncMock() + mock_queue.get.side_effect = IOError("Test error for historical orders responses") + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return None + + def configure_partially_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + self.configure_all_symbols_response(mock_api=mock_api) + response = self._order_status_request_partially_filled_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return None + + def configure_order_not_found_error_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> List[str]: + self.configure_all_symbols_response(mock_api=mock_api) + response = self._order_status_request_not_found_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_partial_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + response = self._order_fills_request_partial_fill_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + return None + + def configure_erroneous_http_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + mock_queue = AsyncMock() + mock_queue.get.side_effect = IOError("Test error for trades responses") + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + return None + + def configure_full_fill_trade_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + response = self._order_fills_request_full_fill_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + return None + + def order_event_for_new_order_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "booked", + "createdAt": "1688667498756", + "updatedAt": "1688667498756", + "direction": order.trade_type.name.lower(), + "margin": "31342413000", + "txHash": "0x0000000000000000000000000000000000000000000000000000000000000000" # noqa: mock + } + + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "canceled", + "createdAt": "1688667498756", + "updatedAt": "1688667498756", + "direction": order.trade_type.name.lower(), + "margin": "31342413000", + "txHash": "0x0000000000000000000000000000000000000000000000000000000000000000" # noqa: mock + } + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": str(order.amount), + "state": "filled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "direction": order.trade_type.name.lower(), + "margin": "31342413000", + "txHash": order.creation_transaction_hash + } + + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "subaccountId": self.portfolio_account_subaccount_id, + "marketId": self.market_id, + "tradeExecutionType": "limitMatchRestingOrder", + "positionDelta": { + "tradeDirection": order.trade_type.name.lower(), + "executionPrice": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": str(order.amount), + "executionMargin": "3693162304" + }, + "payout": "3693278402.762361271848955224", + "fee": str(self.expected_fill_fee.flat_fees[0].amount * Decimal(f"1e{self.quote_decimals}")), + "executedAt": "1687878089569", + "feeRecipient": self.portfolio_account_injective_address, # noqa: mock + "tradeId": self.expected_fill_trade_id, + "executionSide": "maker" + } + + @aioresponses() + def test_all_trading_pairs_does_not_raise_exception(self, mock_api): + self.exchange._set_trading_pair_symbol_map(None) + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + queue_mock = AsyncMock() + queue_mock.get.side_effect = Exception("Test error") + self.exchange._data_source._query_executor._spot_markets_responses = queue_mock + + result: List[str] = self.async_run_with_timeout(self.exchange.all_trading_pairs(), timeout=10) + + self.assertEqual(0, len(result)) + + def test_batch_order_create(self): + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1", "hash2"] + ) + + # Configure all symbols response to initialize the trading rules + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_rules()) + + buy_order_to_create = LimitOrder( + client_order_id="", + trading_pair=self.trading_pair, + is_buy=True, + base_currency=self.base_asset, + quote_currency=self.quote_asset, + price=Decimal("10"), + quantity=Decimal("2"), + ) + sell_order_to_create = LimitOrder( + client_order_id="", + trading_pair=self.trading_pair, + is_buy=False, + base_currency=self.base_asset, + quote_currency=self.quote_asset, + price=Decimal("11"), + quantity=Decimal("3"), + ) + orders_to_create = [buy_order_to_create, sell_order_to_create] + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + orders: List[LimitOrder] = self.exchange.batch_order_create(orders_to_create=orders_to_create) + + buy_order_to_create_in_flight = GatewayPerpetualInFlightOrder( + client_order_id=orders[0].client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=1640780000, + price=orders[0].price, + amount=orders[0].quantity, + exchange_order_id="hash1", + creation_transaction_hash=response["txhash"] + ) + sell_order_to_create_in_flight = GatewayPerpetualInFlightOrder( + client_order_id=orders[1].client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.SELL, + creation_timestamp=1640780000, + price=orders[1].price, + amount=orders[1].quantity, + exchange_order_id="hash2", + creation_transaction_hash=response["txhash"] + ) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(2, len(orders)) + self.assertEqual(2, len(self.exchange.in_flight_orders)) + + self.assertIn(buy_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + self.assertIn(sell_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + + self.assertEqual( + buy_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + buy_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + self.assertEqual( + sell_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + sell_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + + def test_batch_order_create_with_one_market_order(self): + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1", "hash2"] + ) + + # Configure all symbols response to initialize the trading rules + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_rules()) + + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[OrderBookRow(price=5000, amount=20, update_id=1)], + asks=[], + update_id=1, + ) + + buy_order_to_create = LimitOrder( + client_order_id="", + trading_pair=self.trading_pair, + is_buy=True, + base_currency=self.base_asset, + quote_currency=self.quote_asset, + price=Decimal("10"), + quantity=Decimal("2"), + position=PositionAction.OPEN, + ) + sell_order_to_create = MarketOrder( + order_id="", + trading_pair=self.trading_pair, + is_buy=False, + base_asset=self.base_asset, + quote_asset=self.quote_asset, + amount=3, + timestamp=self.exchange.current_timestamp, + position=PositionAction.CLOSE, + ) + orders_to_create = [buy_order_to_create, sell_order_to_create] + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + expected_price_for_volume = self.exchange.get_price_for_volume( + trading_pair=self.trading_pair, + is_buy=True, + volume=Decimal(str(sell_order_to_create.amount)), + ).result_price + + orders: List[LimitOrder] = self.exchange.batch_order_create(orders_to_create=orders_to_create) + + buy_order_to_create_in_flight = GatewayPerpetualInFlightOrder( + client_order_id=orders[0].client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=1640780000, + price=orders[0].price, + amount=orders[0].quantity, + exchange_order_id="hash1", + creation_transaction_hash=response["txhash"], + position=PositionAction.OPEN + ) + sell_order_to_create_in_flight = GatewayPerpetualInFlightOrder( + client_order_id=orders[1].order_id, + trading_pair=self.trading_pair, + order_type=OrderType.MARKET, + trade_type=TradeType.SELL, + creation_timestamp=1640780000, + price=expected_price_for_volume, + amount=orders[1].quantity, + exchange_order_id="hash2", + creation_transaction_hash=response["txhash"], + position=PositionAction.CLOSE + ) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(2, len(orders)) + self.assertEqual(2, len(self.exchange.in_flight_orders)) + + self.assertIn(buy_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + self.assertIn(sell_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + + self.assertEqual( + buy_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + buy_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + self.assertEqual( + sell_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + sell_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + + @aioresponses() + def test_create_buy_limit_order_successfully(self, mock_api): + """Open long position""" + # Configure all symbols response to initialize the trading rules + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_rules()) + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + leverage = 2 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + order_id = self.place_buy_order() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + @aioresponses() + def test_create_sell_limit_order_successfully(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_id = self.place_sell_order() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + @aioresponses() + def test_create_buy_market_order_successfully(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[], + asks=[OrderBookRow(price=5000, amount=20, update_id=1)], + update_id=1, + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_amount = Decimal(1) + expected_price_for_volume = self.exchange.get_price_for_volume( + trading_pair=self.trading_pair, + is_buy=True, + volume=order_amount + ).result_price + + order_id = self.place_buy_order(amount=order_amount, price=None, order_type=OrderType.MARKET) + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + self.assertEqual(expected_price_for_volume, order.price) + + @aioresponses() + def test_create_sell_market_order_successfully(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[OrderBookRow(price=5000, amount=20, update_id=1)], + asks=[], + update_id=1, + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_amount = Decimal(1) + expected_price_for_volume = self.exchange.get_price_for_volume( + trading_pair=self.trading_pair, + is_buy=False, + volume=order_amount + ).result_price + + order_id = self.place_sell_order(amount=order_amount, price=None, order_type=OrderType.MARKET) + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + self.assertEqual(expected_price_for_volume, order.price) + + @aioresponses() + def test_create_order_fails_and_raises_failure_event(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = {"txhash": "", "rawLog": "Error"} + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_id = self.place_buy_order() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertNotIn(order_id, self.exchange.in_flight_orders) + + self.assertEquals(0, len(self.buy_order_created_logger.event_log)) + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(OrderType.LIMIT, failure_event.order_type) + self.assertEqual(order_id, failure_event.order_id) + + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}', " + f"update_timestamp={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order_id}', exchange_order_id=None, misc_updates=None)" + ) + ) + + @aioresponses() + def test_create_order_fails_when_trading_rule_error_and_raises_failure_event(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + order_id_for_invalid_order = self.place_buy_order( + amount=Decimal("0.0001"), price=Decimal("0.0001") + ) + + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = {"txhash": "", "rawLog": "Error"} + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_id = self.place_buy_order() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertNotIn(order_id_for_invalid_order, self.exchange.in_flight_orders) + self.assertNotIn(order_id, self.exchange.in_flight_orders) + + self.assertEquals(0, len(self.buy_order_created_logger.event_log)) + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(OrderType.LIMIT, failure_event.order_type) + self.assertEqual(order_id_for_invalid_order, failure_event.order_id) + + self.assertTrue( + self.is_logged( + "WARNING", + "Buy order amount 0.0001 is lower than the minimum order size 0.01. The order will not be created, " + "increase the amount to be higher than the minimum order size." + ) + ) + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}', " + f"update_timestamp={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order_id}', exchange_order_id=None, misc_updates=None)" + ) + ) + + @aioresponses() + def test_create_order_to_close_short_position(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + leverage = 4 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + order_id = self.place_buy_order(position_action=PositionAction.CLOSE) + self.async_run_with_timeout(request_sent_event.wait()) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + @aioresponses() + def test_create_order_to_close_long_position(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=[], derivative=["hash1"] + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + leverage = 5 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + order_id = self.place_sell_order(position_action=PositionAction.CLOSE) + self.async_run_with_timeout(request_sent_event.wait()) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + def test_get_buy_and_sell_collateral_tokens(self): + self._simulate_trading_rules_initialized() + + linear_buy_collateral_token = self.exchange.get_buy_collateral_token(self.trading_pair) + linear_sell_collateral_token = self.exchange.get_sell_collateral_token(self.trading_pair) + + self.assertEqual(self.quote_asset, linear_buy_collateral_token) + self.assertEqual(self.quote_asset, linear_sell_collateral_token) + + def test_batch_order_cancel(self): + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id="11", + exchange_order_id=self.expected_exchange_order_id + "1", + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + self.exchange.start_tracking_order( + order_id="12", + exchange_order_id=self.expected_exchange_order_id + "2", + trading_pair=self.trading_pair, + trade_type=TradeType.SELL, + price=Decimal("11000"), + amount=Decimal("110"), + order_type=OrderType.LIMIT, + ) + + buy_order_to_cancel: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders["11"] + sell_order_to_cancel: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders["12"] + orders_to_cancel = [buy_order_to_cancel, sell_order_to_cancel] + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait(transaction_simulation_response) + + response = self._order_cancelation_request_successful_mock_response(order=buy_order_to_cancel) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + self.exchange.batch_order_cancel(orders_to_cancel=orders_to_cancel) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertIn(buy_order_to_cancel.client_order_id, self.exchange.in_flight_orders) + self.assertIn(sell_order_to_cancel.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(buy_order_to_cancel.is_pending_cancel_confirmation) + self.assertEqual(response["txhash"], buy_order_to_cancel.cancel_tx_hash) + self.assertTrue(sell_order_to_cancel.is_pending_cancel_confirmation) + self.assertEqual(response["txhash"], sell_order_to_cancel.cancel_tx_hash) + + @aioresponses() + def test_cancel_order_not_found_in_the_exchange(self, mock_api): + # This tests does not apply for Injective. The batch orders update message used for cancelations will not + # detect if the orders exists or not. That will happen when the transaction is executed. + pass + + @aioresponses() + def test_cancel_two_orders_with_cancel_all_and_one_fails(self, mock_api): + # This tests does not apply for Injective. The batch orders update message used for cancelations will not + # detect if the orders exists or not. That will happen when the transaction is executed. + pass + + def test_order_not_found_in_its_creating_transaction_marked_as_failed_during_order_creation_check(self): + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id="0x9f94598b4842ab66037eaa7c64ec10ae16dcf196e61db8522921628522c0f62e", # noqa: mock + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + order: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + order.update_creation_transaction_hash(creation_transaction_hash="66A360DA2FD6884B53B5C019F1A2B5BED7C7C8FC07E83A9C36AD3362EDE096AE") # noqa: mock + + transaction_data = (b'\x12\xd1\x01\n8/injective.exchange.v1beta1.MsgBatchUpdateOrdersResponse' + b'\x12\x94\x01\n\x02\x00\x00\x12\x02\x00\x00\x1aB' + b'0xc5d66f56942e1ae407c01eedccd0471deb8e202a514cde3bae56a8307e376cd1' # noqa: mock + b'\x1aB' + b'0x115975551b4f86188eee6b93d789fcc78df6e89e40011b929299b6e142f53515' # noqa: mock + b'"\x00"\x00') + transaction_messages = [ + { + "type": "/cosmos.authz.v1beta1.MsgExec", + "value": { + "grantee": PrivateKey.from_hex(self.trading_account_private_key).to_public_key().to_acc_bech32(), + "msgs": [ + { + "@type": "/injective.exchange.v1beta1.MsgBatchUpdateOrders", + "sender": self.portfolio_account_injective_address, + "subaccount_id": "", + "spot_market_ids_to_cancel_all": [], + "derivative_market_ids_to_cancel_all": [], + "spot_orders_to_cancel": [], + "derivative_orders_to_cancel": [], + "spot_orders_to_create": [ + { + "market_id": self.market_id, + "order_info": { + "subaccount_id": self.portfolio_account_subaccount_id, + "fee_recipient": self.portfolio_account_injective_address, + "price": str(order.price * Decimal(f"1e{self.quote_decimals - self.base_decimals}")), + "quantity": str((order.amount + Decimal(1)) * Decimal(f"1e{self.base_decimals}")) + }, + "order_type": order.trade_type.name, + "trigger_price": "0.000000000000000000" + } + ], + "derivative_orders_to_create": [], + "binary_options_orders_to_cancel": [], + "binary_options_market_ids_to_cancel_all": [], + "binary_options_orders_to_create": [] + } + ] + } + } + ] + transaction_response = { + "s": "ok", + "data": { + "blockNumber": "13302254", + "blockTimestamp": "2023-07-05 13:55:09.94 +0000 UTC", + "hash": "0x66a360da2fd6884b53b5c019f1a2b5bed7c7c8fc07e83a9c36ad3362ede096ae", # noqa: mock + "data": base64.b64encode(transaction_data).decode(), + "gasWanted": "168306", + "gasUsed": "167769", + "gasFee": { + "amount": [ + { + "denom": "inj", + "amount": "84153000000000" + } + ], + "gasLimit": "168306", + "payer": "inj1hkhdaj2a2clmq5jq6mspsggqs32vynpk228q3r" # noqa: mock + }, + "txType": "injective", + "messages": base64.b64encode(json.dumps(transaction_messages).encode()).decode(), + "signatures": [ + { + "pubkey": "035ddc4d5642b9383e2f087b2ee88b7207f6286ebc9f310e9df1406eccc2c31813", # noqa: mock + "address": "inj1hkhdaj2a2clmq5jq6mspsggqs32vynpk228q3r", # noqa: mock + "sequence": "16450", + "signature": "S9atCwiVg9+8vTpbciuwErh54pJOAry3wHvbHT2fG8IumoE+7vfuoP7mAGDy2w9am+HHa1yv60VSWo3cRhWC9g==" + } + ], + "txNumber": "13182", + "blockUnixTimestamp": "1688565309940", + "logs": "W3sibXNnX2luZGV4IjowLCJldmVudHMiOlt7InR5cGUiOiJtZXNzYWdlIiwiYXR0cmlidXRlcyI6W3sia2V5IjoiYWN0aW9uIiwidmFsdWUiOiIvaW5qZWN0aXZlLmV4Y2hhbmdlLnYxYmV0YTEuTXNnQmF0Y2hVcGRhdGVPcmRlcnMifSx7ImtleSI6InNlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJtb2R1bGUiLCJ2YWx1ZSI6ImV4Y2hhbmdlIn1dfSx7InR5cGUiOiJjb2luX3NwZW50IiwiYXR0cmlidXRlcyI6W3sia2V5Ijoic3BlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJhbW91bnQiLCJ2YWx1ZSI6IjE2NTE2NTAwMHBlZ2d5MHg4N2FCM0I0Qzg2NjFlMDdENjM3MjM2MTIxMUI5NmVkNERjMzZCMUI1In1dfSx7InR5cGUiOiJjb2luX3JlY2VpdmVkIiwiYXR0cmlidXRlcyI6W3sia2V5IjoicmVjZWl2ZXIiLCJ2YWx1ZSI6ImluajE0dm5tdzJ3ZWUzeHRyc3FmdnBjcWczNWpnOXY3ajJ2ZHB6eDBrayJ9LHsia2V5IjoiYW1vdW50IiwidmFsdWUiOiIxNjUxNjUwMDBwZWdneTB4ODdhQjNCNEM4NjYxZTA3RDYzNzIzNjEyMTFCOTZlZDREYzM2QjFCNSJ9XX0seyJ0eXBlIjoidHJhbnNmZXIiLCJhdHRyaWJ1dGVzIjpbeyJrZXkiOiJyZWNpcGllbnQiLCJ2YWx1ZSI6ImluajE0dm5tdzJ3ZWUzeHRyc3FmdnBjcWczNWpnOXY3ajJ2ZHB6eDBrayJ9LHsia2V5Ijoic2VuZGVyIiwidmFsdWUiOiJpbmoxaGtoZGFqMmEyY2xtcTVqcTZtc3BzZ2dxczMydnlucGsyMjhxM3IifSx7ImtleSI6ImFtb3VudCIsInZhbHVlIjoiMTY1MTY1MDAwcGVnZ3kweDg3YUIzQjRDODY2MWUwN0Q2MzcyMzYxMjExQjk2ZWQ0RGMzNkIxQjUifV19LHsidHlwZSI6Im1lc3NhZ2UiLCJhdHRyaWJ1dGVzIjpbeyJrZXkiOiJzZW5kZXIiLCJ2YWx1ZSI6ImluajFoa2hkYWoyYTJjbG1xNWpxNm1zcHNnZ3FzMzJ2eW5wazIyOHEzciJ9XX0seyJ0eXBlIjoiY29pbl9zcGVudCIsImF0dHJpYnV0ZXMiOlt7ImtleSI6InNwZW5kZXIiLCJ2YWx1ZSI6ImluajFoa2hkYWoyYTJjbG1xNWpxNm1zcHNnZ3FzMzJ2eW5wazIyOHEzciJ9LHsia2V5IjoiYW1vdW50IiwidmFsdWUiOiI1NTAwMDAwMDAwMDAwMDAwMDAwMGluaiJ9XX0seyJ0eXBlIjoiY29pbl9yZWNlaXZlZCIsImF0dHJpYnV0ZXMiOlt7ImtleSI6InJlY2VpdmVyIiwidmFsdWUiOiJpbmoxNHZubXcyd2VlM3h0cnNxZnZwY3FnMzVqZzl2N2oydmRwengwa2sifSx7ImtleSI6ImFtb3VudCIsInZhbHVlIjoiNTUwMDAwMDAwMDAwMDAwMDAwMDBpbmoifV19LHsidHlwZSI6InRyYW5zZmVyIiwiYXR0cmlidXRlcyI6W3sia2V5IjoicmVjaXBpZW50IiwidmFsdWUiOiJpbmoxNHZubXcyd2VlM3h0cnNxZnZwY3FnMzVqZzl2N2oydmRwengwa2sifSx7ImtleSI6InNlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJhbW91bnQiLCJ2YWx1ZSI6IjU1MDAwMDAwMDAwMDAwMDAwMDAwaW5qIn1dfSx7InR5cGUiOiJtZXNzYWdlIiwiYXR0cmlidXRlcyI6W3sia2V5Ijoic2VuZGVyIiwidmFsdWUiOiJpbmoxaGtoZGFqMmEyY2xtcTVqcTZtc3BzZ2dxczMydnlucGsyMjhxM3IifV19XX1d" # noqa: mock + } + } + self.exchange._data_source._query_executor._transaction_by_hash_responses.put_nowait(transaction_response) + + original_order_hash_manager = self.exchange._data_source.order_hash_manager + + self.async_run_with_timeout(self.exchange._check_orders_creation_transactions()) + + self.assertEquals(0, len(self.buy_order_created_logger.event_log)) + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(OrderType.LIMIT, failure_event.order_type) + self.assertEqual(order.client_order_id, failure_event.order_id) + + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order.client_order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}', " + f"update_timestamp={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order.client_order_id}', exchange_order_id=None, misc_updates=None)" + ) + ) + + self.assertNotEqual(original_order_hash_manager, self.exchange._data_source._order_hash_manager) + + def test_order_creation_check_waits_for_originating_transaction_to_be_mined(self): + request_sent_event = asyncio.Event() + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id="hash1", + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "2", + exchange_order_id="hash2", + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("20000"), + amount=Decimal("200"), + order_type=OrderType.LIMIT, + ) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + self.assertIn(self.client_order_id_prefix + "2", self.exchange.in_flight_orders) + + hash_not_matching_order: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + hash_not_matching_order.update_creation_transaction_hash(creation_transaction_hash="66A360DA2FD6884B53B5C019F1A2B5BED7C7C8FC07E83A9C36AD3362EDE096AE") # noqa: mock + + no_mined_tx_order: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "2"] + no_mined_tx_order.update_creation_transaction_hash( + creation_transaction_hash="HHHHHHHHHHHHHHH") + + transaction_data = (b'\x12\xd1\x01\n8/injective.exchange.v1beta1.MsgBatchUpdateOrdersResponse' + b'\x12\x94\x01\n\x02\x00\x00\x12\x02\x00\x00\x1aB' + b'0xc5d66f56942e1ae407c01eedccd0471deb8e202a514cde3bae56a8307e376cd1' # noqa: mock + b'\x1aB' + b'0x115975551b4f86188eee6b93d789fcc78df6e89e40011b929299b6e142f53515' # noqa: mock + b'"\x00"\x00') + transaction_messages = [ + { + "type": "/cosmos.authz.v1beta1.MsgExec", + "value": { + "grantee": PrivateKey.from_hex(self.trading_account_private_key).to_public_key().to_acc_bech32(), + "msgs": [ + { + "@type": "/injective.exchange.v1beta1.MsgBatchUpdateOrders", + "sender": self.portfolio_account_injective_address, + "subaccount_id": "", + "spot_market_ids_to_cancel_all": [], + "derivative_market_ids_to_cancel_all": [], + "spot_orders_to_cancel": [], + "derivative_orders_to_cancel": [], + "spot_orders_to_create": [], + "derivative_orders_to_create": [ + { + "market_id": self.market_id, + "order_info": { + "subaccount_id": self.portfolio_account_subaccount_id, + "fee_recipient": self.portfolio_account_injective_address, + "price": str( + hash_not_matching_order.price * Decimal(f"1e{self.quote_decimals}")), + "quantity": str(hash_not_matching_order.amount) + }, + "order_type": hash_not_matching_order.trade_type.name, + "trigger_price": "0.000000000000000000" + } + ], + "binary_options_orders_to_cancel": [], + "binary_options_market_ids_to_cancel_all": [], + "binary_options_orders_to_create": [] + } + ] + } + } + ] + transaction_response = { + "s": "ok", + "data": { + "blockNumber": "13302254", + "blockTimestamp": "2023-07-05 13:55:09.94 +0000 UTC", + "hash": "0x66a360da2fd6884b53b5c019f1a2b5bed7c7c8fc07e83a9c36ad3362ede096ae", # noqa: mock + "data": base64.b64encode(transaction_data).decode(), + "gasWanted": "168306", + "gasUsed": "167769", + "gasFee": { + "amount": [ + { + "denom": "inj", + "amount": "84153000000000" + } + ], + "gasLimit": "168306", + "payer": "inj1hkhdaj2a2clmq5jq6mspsggqs32vynpk228q3r" # noqa: mock + }, + "txType": "injective", + "messages": base64.b64encode(json.dumps(transaction_messages).encode()).decode(), + "signatures": [ + { + "pubkey": "035ddc4d5642b9383e2f087b2ee88b7207f6286ebc9f310e9df1406eccc2c31813", # noqa: mock + "address": "inj1hkhdaj2a2clmq5jq6mspsggqs32vynpk228q3r", # noqa: mock + "sequence": "16450", + "signature": "S9atCwiVg9+8vTpbciuwErh54pJOAry3wHvbHT2fG8IumoE+7vfuoP7mAGDy2w9am+HHa1yv60VSWo3cRhWC9g==" + } + ], + "txNumber": "13182", + "blockUnixTimestamp": "1688565309940", + "logs": "W3sibXNnX2luZGV4IjowLCJldmVudHMiOlt7InR5cGUiOiJtZXNzYWdlIiwiYXR0cmlidXRlcyI6W3sia2V5IjoiYWN0aW9uIiwidmFsdWUiOiIvaW5qZWN0aXZlLmV4Y2hhbmdlLnYxYmV0YTEuTXNnQmF0Y2hVcGRhdGVPcmRlcnMifSx7ImtleSI6InNlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJtb2R1bGUiLCJ2YWx1ZSI6ImV4Y2hhbmdlIn1dfSx7InR5cGUiOiJjb2luX3NwZW50IiwiYXR0cmlidXRlcyI6W3sia2V5Ijoic3BlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJhbW91bnQiLCJ2YWx1ZSI6IjE2NTE2NTAwMHBlZ2d5MHg4N2FCM0I0Qzg2NjFlMDdENjM3MjM2MTIxMUI5NmVkNERjMzZCMUI1In1dfSx7InR5cGUiOiJjb2luX3JlY2VpdmVkIiwiYXR0cmlidXRlcyI6W3sia2V5IjoicmVjZWl2ZXIiLCJ2YWx1ZSI6ImluajE0dm5tdzJ3ZWUzeHRyc3FmdnBjcWczNWpnOXY3ajJ2ZHB6eDBrayJ9LHsia2V5IjoiYW1vdW50IiwidmFsdWUiOiIxNjUxNjUwMDBwZWdneTB4ODdhQjNCNEM4NjYxZTA3RDYzNzIzNjEyMTFCOTZlZDREYzM2QjFCNSJ9XX0seyJ0eXBlIjoidHJhbnNmZXIiLCJhdHRyaWJ1dGVzIjpbeyJrZXkiOiJyZWNpcGllbnQiLCJ2YWx1ZSI6ImluajE0dm5tdzJ3ZWUzeHRyc3FmdnBjcWczNWpnOXY3ajJ2ZHB6eDBrayJ9LHsia2V5Ijoic2VuZGVyIiwidmFsdWUiOiJpbmoxaGtoZGFqMmEyY2xtcTVqcTZtc3BzZ2dxczMydnlucGsyMjhxM3IifSx7ImtleSI6ImFtb3VudCIsInZhbHVlIjoiMTY1MTY1MDAwcGVnZ3kweDg3YUIzQjRDODY2MWUwN0Q2MzcyMzYxMjExQjk2ZWQ0RGMzNkIxQjUifV19LHsidHlwZSI6Im1lc3NhZ2UiLCJhdHRyaWJ1dGVzIjpbeyJrZXkiOiJzZW5kZXIiLCJ2YWx1ZSI6ImluajFoa2hkYWoyYTJjbG1xNWpxNm1zcHNnZ3FzMzJ2eW5wazIyOHEzciJ9XX0seyJ0eXBlIjoiY29pbl9zcGVudCIsImF0dHJpYnV0ZXMiOlt7ImtleSI6InNwZW5kZXIiLCJ2YWx1ZSI6ImluajFoa2hkYWoyYTJjbG1xNWpxNm1zcHNnZ3FzMzJ2eW5wazIyOHEzciJ9LHsia2V5IjoiYW1vdW50IiwidmFsdWUiOiI1NTAwMDAwMDAwMDAwMDAwMDAwMGluaiJ9XX0seyJ0eXBlIjoiY29pbl9yZWNlaXZlZCIsImF0dHJpYnV0ZXMiOlt7ImtleSI6InJlY2VpdmVyIiwidmFsdWUiOiJpbmoxNHZubXcyd2VlM3h0cnNxZnZwY3FnMzVqZzl2N2oydmRwengwa2sifSx7ImtleSI6ImFtb3VudCIsInZhbHVlIjoiNTUwMDAwMDAwMDAwMDAwMDAwMDBpbmoifV19LHsidHlwZSI6InRyYW5zZmVyIiwiYXR0cmlidXRlcyI6W3sia2V5IjoicmVjaXBpZW50IiwidmFsdWUiOiJpbmoxNHZubXcyd2VlM3h0cnNxZnZwY3FnMzVqZzl2N2oydmRwengwa2sifSx7ImtleSI6InNlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJhbW91bnQiLCJ2YWx1ZSI6IjU1MDAwMDAwMDAwMDAwMDAwMDAwaW5qIn1dfSx7InR5cGUiOiJtZXNzYWdlIiwiYXR0cmlidXRlcyI6W3sia2V5Ijoic2VuZGVyIiwidmFsdWUiOiJpbmoxaGtoZGFqMmEyY2xtcTVqcTZtc3BzZ2dxczMydnlucGsyMjhxM3IifV19XX1d" # noqa: mock + } + } + mock_tx_by_hash_queue = AsyncMock() + mock_tx_by_hash_queue.get.side_effect = [transaction_response, ValueError("Transaction not found in a block")] + self.exchange._data_source._query_executor._transaction_by_hash_responses = mock_tx_by_hash_queue + + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=13302254 + ) + self.exchange._data_source._query_executor._transaction_block_height_responses = mock_queue + + original_order_hash_manager = self.exchange._data_source.order_hash_manager + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._check_orders_creation_transactions() + ) + ) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertNotEqual(original_order_hash_manager, self.exchange._data_source._order_hash_manager) + + mock_queue.get.assert_called() + + @aioresponses() + def test_update_order_status_when_order_has_not_changed_and_one_partial_fill(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + position_action=PositionAction.OPEN, + ) + order: InFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + self.configure_partially_filled_order_status_response( + order=order, + mock_api=mock_api) + + if self.is_order_fill_http_update_included_in_status_update: + self.configure_partial_fill_trade_response( + order=order, + mock_api=mock_api) + + self.assertTrue(order.is_open) + + self.async_run_with_timeout(self.exchange._update_order_status()) + + self.assertTrue(order.is_open) + self.assertEqual(OrderState.PARTIALLY_FILLED, order.current_state) + + if self.is_order_fill_http_update_included_in_status_update: + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(self.expected_partial_fill_price, fill_event.price) + self.assertEqual(self.expected_partial_fill_amount, fill_event.amount) + self.assertEqual(self.expected_fill_fee, fill_event.trade_fee) + + def test_user_stream_balance_update(self): + client_config_map = ClientConfigAdapter(ClientConfigMap()) + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") + + account_config = InjectiveDelegatedAccountMode( + private_key=self.trading_account_private_key, + subaccount_index=self.trading_account_subaccount_index, + granter_address=self.portfolio_account_injective_address, + granter_subaccount_index=1, + ) + + injective_config = InjectiveConfigMap( + network=network_config, + account_type=account_config, + ) + + exchange_with_non_default_subaccount = InjectiveV2PerpetualDerivative( + client_config_map=client_config_map, + connector_configuration=injective_config, + trading_pairs=[self.trading_pair], + ) + + exchange_with_non_default_subaccount._data_source._query_executor = self.exchange._data_source._query_executor + self.exchange = exchange_with_non_default_subaccount + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + balance_event = self.balance_event_websocket_update + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [balance_event, asyncio.CancelledError] + self.exchange._data_source._query_executor._subaccount_balance_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout(self.exchange._data_source._listen_to_account_balance_updates()) + except asyncio.CancelledError: + pass + + self.assertEqual(Decimal("10"), self.exchange.available_balances[self.base_asset]) + self.assertEqual(Decimal("15"), self.exchange.get_balance(self.base_asset)) + + def test_user_stream_update_for_new_order(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + order_event = self.order_event_for_new_order_websocket_update(order=order) + + mock_queue = AsyncMock() + event_messages = [order_event, asyncio.CancelledError] + mock_queue.get.side_effect = event_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + except asyncio.CancelledError: + pass + + event: BuyOrderCreatedEvent = self.buy_order_created_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, event.timestamp) + self.assertEqual(order.order_type, event.type) + self.assertEqual(order.trading_pair, event.trading_pair) + self.assertEqual(order.amount, event.amount) + self.assertEqual(order.price, event.price) + self.assertEqual(order.client_order_id, event.order_id) + self.assertEqual(order.exchange_order_id, event.exchange_order_id) + self.assertTrue(order.is_open) + + tracked_order: InFlightOrder = list(self.exchange.in_flight_orders.values())[0] + + self.assertTrue(self.is_logged("INFO", tracked_order.build_order_created_message())) + + def test_user_stream_update_for_canceled_order(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + order_event = self.order_event_for_canceled_order_websocket_update(order=order) + + mock_queue = AsyncMock() + event_messages = [order_event, asyncio.CancelledError] + mock_queue.get.side_effect = event_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + except asyncio.CancelledError: + pass + + cancel_event: OrderCancelledEvent = self.order_cancelled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, cancel_event.timestamp) + self.assertEqual(order.client_order_id, cancel_event.order_id) + self.assertEqual(order.exchange_order_id, cancel_event.exchange_order_id) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(order.is_cancelled) + self.assertTrue(order.is_done) + + self.assertTrue( + self.is_logged("INFO", f"Successfully canceled order {order.client_order_id}.") + ) + + @aioresponses() + def test_user_stream_update_for_order_full_fill(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + self.configure_all_symbols_response(mock_api=None) + order_event = self.order_event_for_full_fill_websocket_update(order=order) + trade_event = self.trade_event_for_full_fill_websocket_update(order=order) + + orders_queue_mock = AsyncMock() + trades_queue_mock = AsyncMock() + orders_messages = [] + trades_messages = [] + if trade_event: + trades_messages.append(trade_event) + if order_event: + orders_messages.append(order_event) + orders_messages.append(asyncio.CancelledError) + trades_messages.append(asyncio.CancelledError) + + orders_queue_mock.get.side_effect = orders_messages + trades_queue_mock.get.side_effect = trades_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = orders_queue_mock + self.exchange._data_source._query_executor._public_derivative_trade_updates = trades_queue_mock + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + tasks = [ + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_public_derivative_trades(market_ids=[self.market_id]) + ), + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + ] + try: + self.async_run_with_timeout(safe_gather(*tasks)) + except asyncio.CancelledError: + pass + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(order.wait_until_completely_filled()) + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + expected_fee = self.expected_fill_fee + self.assertEqual(expected_fee, fill_event.trade_fee) + + buy_event: BuyOrderCompletedEvent = self.buy_order_completed_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, buy_event.timestamp) + self.assertEqual(order.client_order_id, buy_event.order_id) + self.assertEqual(order.base_asset, buy_event.base_asset) + self.assertEqual(order.quote_asset, buy_event.quote_asset) + self.assertEqual(order.amount, buy_event.base_asset_amount) + self.assertEqual(order.amount * fill_event.price, buy_event.quote_asset_amount) + self.assertEqual(order.order_type, buy_event.order_type) + self.assertEqual(order.exchange_order_id, buy_event.exchange_order_id) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(order.is_filled) + self.assertTrue(order.is_done) + + self.assertTrue( + self.is_logged( + "INFO", + f"BUY order {order.client_order_id} completely filled." + ) + ) + + def test_user_stream_logs_errors(self): + # This test does not apply to Injective because it handles private events in its own data source + pass + + def test_user_stream_raises_cancel_exception(self): + # This test does not apply to Injective because it handles private events in its own data source + pass + + def test_lost_order_removed_after_cancel_status_user_event_received(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + for _ in range(self.exchange._order_tracker._lost_order_count_limit + 1): + self.async_run_with_timeout( + self.exchange._order_tracker.process_order_not_found(client_order_id=order.client_order_id)) + + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + + order_event = self.order_event_for_canceled_order_websocket_update(order=order) + + mock_queue = AsyncMock() + event_messages = [order_event, asyncio.CancelledError] + mock_queue.get.side_effect = event_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + except asyncio.CancelledError: + pass + + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.lost_orders) + self.assertEqual(0, len(self.order_cancelled_logger.event_log)) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertFalse(order.is_cancelled) + self.assertTrue(order.is_failure) + + @aioresponses() + def test_lost_order_user_stream_full_fill_events_are_processed(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + for _ in range(self.exchange._order_tracker._lost_order_count_limit + 1): + self.async_run_with_timeout( + self.exchange._order_tracker.process_order_not_found(client_order_id=order.client_order_id)) + + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + + self.configure_all_symbols_response(mock_api=None) + order_event = self.order_event_for_full_fill_websocket_update(order=order) + trade_event = self.trade_event_for_full_fill_websocket_update(order=order) + + orders_queue_mock = AsyncMock() + trades_queue_mock = AsyncMock() + orders_messages = [] + trades_messages = [] + if trade_event: + trades_messages.append(trade_event) + if order_event: + orders_messages.append(order_event) + orders_messages.append(asyncio.CancelledError) + trades_messages.append(asyncio.CancelledError) + + orders_queue_mock.get.side_effect = orders_messages + trades_queue_mock.get.side_effect = trades_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = orders_queue_mock + self.exchange._data_source._query_executor._public_derivative_trade_updates = trades_queue_mock + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + tasks = [ + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_public_derivative_trades(market_ids=[self.market_id]) + ), + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + ] + try: + self.async_run_with_timeout(safe_gather(*tasks)) + except asyncio.CancelledError: + pass + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(order.wait_until_completely_filled()) + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + expected_fee = self.expected_fill_fee + self.assertEqual(expected_fee, fill_event.trade_fee) + + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.lost_orders) + self.assertTrue(order.is_filled) + self.assertTrue(order.is_failure) + + @aioresponses() + def test_lost_order_included_in_order_fills_update_and_not_in_order_status_update(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + request_sent_event = asyncio.Event() + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + position_action=PositionAction.OPEN, + ) + order: InFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + for _ in range(self.exchange._order_tracker._lost_order_count_limit + 1): + self.async_run_with_timeout( + self.exchange._order_tracker.process_order_not_found(client_order_id=order.client_order_id)) + + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + + self.configure_completely_filled_order_status_response( + order=order, + mock_api=mock_api, + callback=lambda *args, **kwargs: request_sent_event.set()) + + if self.is_order_fill_http_update_included_in_status_update: + self.configure_full_fill_trade_response( + order=order, + mock_api=mock_api, + callback=lambda *args, **kwargs: request_sent_event.set()) + else: + # If the fill events will not be requested with the order status, we need to manually set the event + # to allow the ClientOrderTracker to process the last status update + order.completely_filled_event.set() + request_sent_event.set() + + self.async_run_with_timeout(self.exchange._update_order_status()) + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(request_sent_event.wait()) + + self.async_run_with_timeout(order.wait_until_completely_filled()) + self.assertTrue(order.is_done) + self.assertTrue(order.is_failure) + + if self.is_order_fill_http_update_included_in_status_update: + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + self.assertEqual(self.expected_fill_fee, fill_event.trade_fee) + + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) + self.assertIn(order.client_order_id, self.exchange._order_tracker.all_fillable_orders) + self.assertFalse( + self.is_logged( + "INFO", + f"BUY order {order.client_order_id} completely filled." + ) + ) + + request_sent_event.clear() + + # Configure again the response to the order fills request since it is required by lost orders update logic + self.configure_full_fill_trade_response( + order=order, + mock_api=mock_api, + callback=lambda *args, **kwargs: request_sent_event.set()) + + self.async_run_with_timeout(self.exchange._update_lost_orders_status()) + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertTrue(order.is_done) + self.assertTrue(order.is_failure) + + self.assertEqual(1, len(self.order_filled_logger.event_log)) + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.all_fillable_orders) + self.assertFalse( + self.is_logged( + "INFO", + f"BUY order {order.client_order_id} completely filled." + ) + ) + + @aioresponses() + def test_invalid_trading_pair_not_in_all_trading_pairs(self, mock_api): + self.exchange._set_trading_pair_symbol_map(None) + + invalid_pair, response = self.all_symbols_including_invalid_pair_mock_response + self.exchange._data_source._query_executor._spot_markets_responses.put_nowait( + self.all_spot_markets_mock_response + ) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait(response) + + all_trading_pairs = self.async_run_with_timeout(coroutine=self.exchange.all_trading_pairs()) + + self.assertNotIn(invalid_pair, all_trading_pairs) + + @aioresponses() + def test_check_network_success(self, mock_api): + response = self.network_status_request_successful_mock_response + self.exchange._data_source._query_executor._ping_responses.put_nowait(response) + + network_status = self.async_run_with_timeout(coroutine=self.exchange.check_network(), timeout=10) + + self.assertEqual(NetworkStatus.CONNECTED, network_status) + + @aioresponses() + def test_check_network_failure(self, mock_api): + mock_queue = AsyncMock() + mock_queue.get.side_effect = RpcError("Test Error") + self.exchange._data_source._query_executor._ping_responses = mock_queue + + ret = self.async_run_with_timeout(coroutine=self.exchange.check_network()) + + self.assertEqual(ret, NetworkStatus.NOT_CONNECTED) + + @aioresponses() + def test_check_network_raises_cancel_exception(self, mock_api): + mock_queue = AsyncMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.exchange._data_source._query_executor._ping_responses = mock_queue + + self.assertRaises(asyncio.CancelledError, self.async_run_with_timeout, self.exchange.check_network()) + + @aioresponses() + def test_get_last_trade_prices(self, mock_api): + self.configure_all_symbols_response(mock_api=mock_api) + response = self.latest_prices_request_mock_response + self.exchange._data_source._query_executor._derivative_trades_responses.put_nowait(response) + + latest_prices: Dict[str, float] = self.async_run_with_timeout( + self.exchange.get_last_traded_prices(trading_pairs=[self.trading_pair]) + ) + + self.assertEqual(1, len(latest_prices)) + self.assertEqual(self.expected_latest_price, latest_prices[self.trading_pair]) + + def test_get_fee(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_fees()) + + maker_fee_rate = Decimal(self.all_derivative_markets_mock_response[0]["makerFeeRate"]) + taker_fee_rate = Decimal(self.all_derivative_markets_mock_response[0]["takerFeeRate"]) + + maker_fee = self.exchange.get_fee( + base_currency=self.base_asset, + quote_currency=self.quote_asset, + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + position_action=PositionAction.OPEN, + amount=Decimal("1000"), + price=Decimal("5"), + is_maker=True + ) + + self.assertEqual(maker_fee_rate, maker_fee.percent) + self.assertEqual(self.quote_asset, maker_fee.percent_token) + + taker_fee = self.exchange.get_fee( + base_currency=self.base_asset, + quote_currency=self.quote_asset, + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + position_action=PositionAction.OPEN, + amount=Decimal("1000"), + price=Decimal("5"), + is_maker=False, + ) + + self.assertEqual(taker_fee_rate, taker_fee.percent) + self.assertEqual(self.quote_asset, maker_fee.percent_token) + + def test_restore_tracking_states_only_registers_open_orders(self): + orders = [] + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + )) + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "2", + exchange_order_id=self.exchange_order_id_prefix + "2", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + initial_state=OrderState.CANCELED + )) + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "3", + exchange_order_id=self.exchange_order_id_prefix + "3", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + initial_state=OrderState.FILLED + )) + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "4", + exchange_order_id=self.exchange_order_id_prefix + "4", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + initial_state=OrderState.FAILED + )) + + tracking_states = {order.client_order_id: order.to_json() for order in orders} + + self.exchange.restore_tracking_states(tracking_states) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + self.assertNotIn(self.client_order_id_prefix + "2", self.exchange.in_flight_orders) + self.assertNotIn(self.client_order_id_prefix + "3", self.exchange.in_flight_orders) + self.assertNotIn(self.client_order_id_prefix + "4", self.exchange.in_flight_orders) + + @aioresponses() + def test_set_position_mode_success(self, mock_api): + # There's only ONEWAY position mode + pass + + @aioresponses() + def test_set_position_mode_failure(self, mock_api): + # There's only ONEWAY position mode + pass + + @aioresponses() + def test_set_leverage_failure(self, mock_api): + # Leverage is configured in a per order basis + pass + + @aioresponses() + def test_set_leverage_success(self, mock_api): + # Leverage is configured in a per order basis + pass + + @aioresponses() + def test_funding_payment_polling_loop_sends_update_event(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + + self.async_tasks.append(asyncio.get_event_loop().create_task(self.exchange._funding_payment_polling_loop())) + + funding_payments = { + "payments": [{ + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "amount": str(self.target_funding_payment_payment_amount), + "timestamp": 1000 * 1e3, + }], + "paging": { + "total": 1000 + } + } + self.exchange._data_source.query_executor._funding_payments_responses.put_nowait(funding_payments) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_payment_funding_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=funding_rate + ) + self.exchange._data_source.query_executor._funding_rates_responses = mock_queue + + self.exchange._funding_fee_poll_notifier.set() + self.async_run_with_timeout(request_sent_event.wait()) + + request_sent_event.clear() + + funding_payments = { + "payments": [{ + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "amount": str(self.target_funding_payment_payment_amount), + "timestamp": self.target_funding_payment_timestamp * 1e3, + }], + "paging": { + "total": 1000 + } + } + self.exchange._data_source.query_executor._funding_payments_responses.put_nowait(funding_payments) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_payment_funding_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=funding_rate + ) + self.exchange._data_source.query_executor._funding_rates_responses = mock_queue + + self.exchange._funding_fee_poll_notifier.set() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.funding_payment_logger.event_log)) + funding_event: FundingPaymentCompletedEvent = self.funding_payment_logger.event_log[0] + self.assertEqual(self.target_funding_payment_timestamp, funding_event.timestamp) + self.assertEqual(self.exchange.name, funding_event.market) + self.assertEqual(self.trading_pair, funding_event.trading_pair) + self.assertEqual(self.target_funding_payment_payment_amount, funding_event.amount) + self.assertEqual(self.target_funding_payment_funding_rate, funding_event.funding_rate) + + def test_listen_for_funding_info_update_initializes_funding_info(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + self.configure_all_symbols_response(mock_api=None) + self.exchange._data_source._query_executor._derivative_market_responses.put_nowait( + self.all_derivative_markets_mock_response[0] + ) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_info_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.exchange._data_source.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": str(self.target_funding_info_mark_price) + } + self.exchange._data_source.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": str(self.target_funding_info_index_price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.exchange._data_source.query_executor._derivative_trades_responses.put_nowait(trades) + + funding_info_update = FundingInfoUpdate( + trading_pair=self.trading_pair, + index_price=Decimal("29423.16356086"), + mark_price=Decimal("9084900"), + next_funding_utc_timestamp=1690426800, + rate=Decimal("0.000004"), + ) + mock_queue = AsyncMock() + mock_queue.get.side_effect = [funding_info_update, asyncio.CancelledError] + self.exchange.order_book_tracker.data_source._message_queue[ + self.exchange.order_book_tracker.data_source._funding_info_messages_queue_key + ] = mock_queue + + try: + self.async_run_with_timeout(self.exchange._listen_for_funding_info()) + except asyncio.CancelledError: + pass + + funding_info: FundingInfo = self.exchange.get_funding_info(self.trading_pair) + + self.assertEqual(self.trading_pair, funding_info.trading_pair) + self.assertEqual(self.target_funding_info_index_price, funding_info.index_price) + self.assertEqual(self.target_funding_info_mark_price, funding_info.mark_price) + self.assertEqual( + self.target_funding_info_next_funding_utc_timestamp, funding_info.next_funding_utc_timestamp + ) + self.assertEqual(self.target_funding_info_rate, funding_info.rate) + + def test_listen_for_funding_info_update_updates_funding_info(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + self.configure_all_symbols_response(mock_api=None) + self.exchange._data_source._query_executor._derivative_market_responses.put_nowait( + self.all_derivative_markets_mock_response[0] + ) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_info_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.exchange._data_source.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": str(self.target_funding_info_mark_price) + } + self.exchange._data_source.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": str( + self.target_funding_info_index_price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.exchange._data_source.query_executor._derivative_trades_responses.put_nowait(trades) + + funding_info_update = FundingInfoUpdate( + trading_pair=self.trading_pair, + index_price=Decimal("29423.16356086"), + mark_price=Decimal("9084900"), + next_funding_utc_timestamp=1690426800, + rate=Decimal("0.000004"), + ) + mock_queue = AsyncMock() + mock_queue.get.side_effect = [funding_info_update, asyncio.CancelledError] + self.exchange.order_book_tracker.data_source._message_queue[ + self.exchange.order_book_tracker.data_source._funding_info_messages_queue_key + ] = mock_queue + + try: + self.async_run_with_timeout( + self.exchange._listen_for_funding_info()) + except asyncio.CancelledError: + pass + + self.assertEqual(1, self.exchange._perpetual_trading.funding_info_stream.qsize()) # rest in OB DS tests + + def test_existing_account_position_detected_on_positions_update(self): + self._simulate_trading_rules_initialized() + self.configure_all_symbols_response(mock_api=None) + + position_data = { + "ticker": "BTC/USDT PERP", + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "direction": "long", + "quantity": "0.01", + "entryPrice": "25000000000", + "margin": "248483436.058851", + "liquidationPrice": "47474612957.985809", + "markPrice": "28984256513.07", + "aggregateReduceOnlyQuantity": "0", + "updatedAt": "1691077382583", + "createdAt": "-62135596800000" + } + positions = { + "positions": [position_data], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + self.exchange._data_source._query_executor._derivative_positions_responses.put_nowait(positions) + + self.async_run_with_timeout(self.exchange._update_positions()) + + self.assertEqual(len(self.exchange.account_positions), 1) + pos = list(self.exchange.account_positions.values())[0] + self.assertEqual(self.trading_pair, pos.trading_pair) + self.assertEqual(PositionSide.LONG, pos.position_side) + self.assertEqual(Decimal(position_data["quantity"]), pos.amount) + entry_price = Decimal(position_data["entryPrice"]) * Decimal(f"1e{-self.quote_decimals}") + self.assertEqual(entry_price, pos.entry_price) + expected_leverage = ((Decimal(position_data["entryPrice"]) * Decimal(position_data["quantity"])) + / Decimal(position_data["margin"])) + self.assertEqual(expected_leverage, pos.leverage) + mark_price = Decimal(position_data["markPrice"]) * Decimal(f"1e{-self.quote_decimals}") + expected_unrealized_pnl = (mark_price - entry_price) * Decimal(position_data["quantity"]) + self.assertEqual(expected_unrealized_pnl, pos.unrealized_pnl) + + def test_user_stream_position_update(self): + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + position_data = { + "ticker": "BTC/USDT PERP", + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "direction": "long", + "quantity": "0.01", + "entryPrice": "25000000000", + "margin": "248483436.058851", + "liquidationPrice": "47474612957.985809", + "markPrice": "28984256513.07", + "aggregateReduceOnlyQuantity": "0", + "updatedAt": "1691077382583", + "createdAt": "-62135596800000" + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [position_data, asyncio.CancelledError] + self.exchange._data_source._query_executor._subaccount_positions_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout(self.exchange._data_source._listen_to_positions_updates()) + except asyncio.CancelledError: + pass + + self.assertEqual(len(self.exchange.account_positions), 1) + pos = list(self.exchange.account_positions.values())[0] + self.assertEqual(self.trading_pair, pos.trading_pair) + self.assertEqual(PositionSide.LONG, pos.position_side) + self.assertEqual(Decimal(position_data["quantity"]), pos.amount) + entry_price = Decimal(position_data["entryPrice"]) * Decimal(f"1e{-self.quote_decimals}") + self.assertEqual(entry_price, pos.entry_price) + expected_leverage = ((Decimal(position_data["entryPrice"]) * Decimal(position_data["quantity"])) + / Decimal(position_data["margin"])) + self.assertEqual(expected_leverage, pos.leverage) + mark_price = Decimal(position_data["markPrice"]) * Decimal(f"1e{-self.quote_decimals}") + expected_unrealized_pnl = (mark_price - entry_price) * Decimal(position_data["quantity"]) + self.assertEqual(expected_unrealized_pnl, pos.unrealized_pnl) + + def _expected_initial_status_dict(self) -> Dict[str, bool]: + status_dict = super()._expected_initial_status_dict() + status_dict["data_source_initialized"] = False + return status_dict + + @staticmethod + def _callback_wrapper_with_response(callback: Callable, response: Any, *args, **kwargs): + callback(args, kwargs) + if isinstance(response, Exception): + raise response + else: + return response + + def _configure_balance_response( + self, + response: Dict[str, Any], + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> str: + self.configure_all_symbols_response(mock_api=mock_api) + self.exchange._data_source._query_executor._account_portfolio_responses.put_nowait(response) + return "" + + def _msg_exec_simulation_mock_response(self) -> Any: + return { + "gasInfo": { + "gasWanted": "50000000", + "gasUsed": "90749" + }, + "result": { + "data": "Em8KJS9jb3Ntb3MuYXV0aHoudjFiZXRhMS5Nc2dFeGVjUmVzcG9uc2USRgpECkIweGYxNGU5NGMxZmQ0MjE0M2I3ZGRhZjA4ZDE3ZWMxNzAzZGMzNzZlOWU2YWI0YjY0MjBhMzNkZTBhZmFlYzJjMTA=", # noqa: mock + "log": "", + "events": [], + "msgResponses": [ + OrderedDict([ + ("@type", "/cosmos.authz.v1beta1.MsgExecResponse"), + ("results", [ + "CkIweGYxNGU5NGMxZmQ0MjE0M2I3ZGRhZjA4ZDE3ZWMxNzAzZGMzNzZlOWU2YWI0YjY0MjBhMzNkZTBhZmFlYzJjMTA="]) # noqa: mock + ]) + ] + } + } + + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Dict[str, Any]: + return {"txhash": "79DBF373DE9C534EE2DC9D009F32B850DA8D0C73833FAA0FD52C6AE8989EC659", "rawLog": "[]"} # noqa: mock + + def _order_cancelation_request_erroneous_mock_response(self, order: InFlightOrder) -> Dict[str, Any]: + return {"txhash": "79DBF373DE9C534EE2DC9D009F32B850DA8D0C73833FAA0FD52C6AE8989EC659", "rawLog": "Error"} # noqa: mock + + def _order_status_request_open_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "booked", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_status_request_partially_filled_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": str(self.expected_partial_fill_amount), + "state": "partial_filled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_status_request_completely_filled_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": str(order.amount), + "state": "filled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_status_request_canceled_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.portfolio_account_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "canceled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_status_request_not_found_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [], + "paging": { + "total": "0" + }, + } + + def _order_fills_request_partial_fill_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "trades": [ + { + "orderHash": order.exchange_order_id, + "subaccountId": self.portfolio_account_subaccount_id, + "marketId": self.market_id, + "tradeExecutionType": "limitFill", + "positionDelta": { + "tradeDirection": order.trade_type.name.lower, + "executionPrice": str(self.expected_partial_fill_price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": str(self.expected_partial_fill_amount), + "executionMargin": "1245280000" + }, + "payout": "1187984833.579447998034818126", + "fee": str(self.expected_fill_fee.flat_fees[0].amount * Decimal(f"1e{self.quote_decimals}")), + "executedAt": "1681735786785", + "feeRecipient": self.portfolio_account_injective_address, + "tradeId": self.expected_fill_trade_id, + "executionSide": "maker" + }, + ], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + + def _order_fills_request_full_fill_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "trades": [ + { + "orderHash": order.exchange_order_id, + "subaccountId": self.portfolio_account_subaccount_id, + "marketId": self.market_id, + "tradeExecutionType": "limitFill", + "positionDelta": { + "tradeDirection": order.trade_type.name.lower, + "executionPrice": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": str(order.amount), + "executionMargin": "1245280000" + }, + "payout": "1187984833.579447998034818126", + "fee": str(self.expected_fill_fee.flat_fees[0].amount * Decimal(f"1e{self.quote_decimals}")), + "executedAt": "1681735786785", + "feeRecipient": self.portfolio_account_injective_address, + "tradeId": self.expected_fill_trade_id, + "executionSide": "maker" + }, + ], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } diff --git a/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_derivative_for_offchain_vault.py b/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_derivative_for_offchain_vault.py new file mode 100644 index 0000000000..df48e1b0ea --- /dev/null +++ b/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_derivative_for_offchain_vault.py @@ -0,0 +1,2701 @@ +import asyncio +import base64 +import json +from collections import OrderedDict +from copy import copy +from decimal import Decimal +from functools import partial +from test.hummingbot.connector.exchange.injective_v2.programmable_query_executor import ProgrammableQueryExecutor +from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from unittest.mock import AsyncMock + +from aioresponses import aioresponses +from aioresponses.core import RequestCall +from bidict import bidict +from grpc import RpcError +from pyinjective import Address, PrivateKey + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_derivative import ( + InjectiveV2PerpetualDerivative, +) +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_utils import InjectiveConfigMap +from hummingbot.connector.exchange.injective_v2.injective_v2_utils import ( + InjectiveTestnetNetworkMode, + InjectiveVaultAccountMode, +) +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayPerpetualInFlightOrder +from hummingbot.connector.test_support.perpetual_derivative_test import AbstractPerpetualDerivativeTests +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, PositionSide, TradeType +from hummingbot.core.data_type.funding_info import FundingInfo, FundingInfoUpdate +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.event.events import ( + BuyOrderCompletedEvent, + BuyOrderCreatedEvent, + FundingPaymentCompletedEvent, + MarketOrderFailureEvent, + OrderCancelledEvent, + OrderFilledEvent, +) +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_gather + + +class InjectiveV2PerpetualDerivativeForOffChainVaultTests(AbstractPerpetualDerivativeTests.PerpetualDerivativeTests): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "INJ" + cls.quote_asset = "USDT" + cls.base_asset_denom = "inj" + cls.quote_asset_denom = "peggy0x87aB3B4C8661e07D6372361211B96ed4Dc36B1B5" # noqa: mock + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.market_id = "0x17ef48032cb24375ba7c2e39f384e56433bcab20cbee9a7357e4cba2eb00abe6" # noqa: mock + + _, grantee_private_key = PrivateKey.generate() + cls.trading_account_private_key = grantee_private_key.to_hex() + cls.trading_account_public_key = grantee_private_key.to_public_key().to_address().to_acc_bech32() + cls.trading_account_subaccount_index = 0 + cls.vault_contract_address = "inj1zlwdkv49rmsug0pnwu6fmwnl267lfr34yvhwgp" # noqa: mock" + cls.vault_contract_subaccount_index = 1 + vault_address = Address.from_acc_bech32(cls.vault_contract_address) + cls.vault_contract_subaccount_id = vault_address.get_subaccount_id( + index=cls.vault_contract_subaccount_index + ) + cls.base_decimals = 18 + cls.quote_decimals = 6 + + cls._transaction_hash = "017C130E3602A48E5C9D661CAC657BF1B79262D4B71D5C25B1DA62DE2338DA0E" # noqa: mock" + + def setUp(self) -> None: + super().setUp() + self._original_async_loop = asyncio.get_event_loop() + self.async_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.async_loop) + self._logs_event: Optional[asyncio.Event] = None + self.exchange._data_source.logger().setLevel(1) + self.exchange._data_source.logger().addHandler(self) + + self.exchange._orders_processing_delta_time = 0.1 + self.async_tasks.append(self.async_loop.create_task(self.exchange._process_queued_orders())) + + def tearDown(self) -> None: + super().tearDown() + self.async_loop.stop() + self.async_loop.close() + asyncio.set_event_loop(self._original_async_loop) + self._logs_event = None + + def handle(self, record): + super().handle(record=record) + if self._logs_event is not None: + self._logs_event.set() + + def reset_log_event(self): + if self._logs_event is not None: + self._logs_event.clear() + + async def wait_for_a_log(self): + if self._logs_event is not None: + await self._logs_event.wait() + + @property + def expected_supported_position_modes(self) -> List[PositionMode]: + return [PositionMode.ONEWAY] + + @property + def funding_info_url(self): + raise NotImplementedError + + @property + def funding_payment_url(self): + raise NotImplementedError + + @property + def funding_info_mock_response(self): + raise NotImplementedError + + @property + def empty_funding_payment_mock_response(self): + raise NotImplementedError + + @property + def funding_payment_mock_response(self): + raise NotImplementedError + + @property + def all_symbols_url(self): + raise NotImplementedError + + @property + def latest_prices_url(self): + raise NotImplementedError + + @property + def network_status_url(self): + raise NotImplementedError + + @property + def trading_rules_url(self): + raise NotImplementedError + + @property + def order_creation_url(self): + raise NotImplementedError + + @property + def balance_url(self): + raise NotImplementedError + + @property + def all_symbols_request_mock_response(self): + raise NotImplementedError + + @property + def latest_prices_request_mock_response(self): + return { + "trades": [ + { + "orderHash": "0x9ffe4301b24785f09cb529c1b5748198098b17bd6df8fe2744d923a574179229", # noqa: mock + "subaccountId": "0xa73ad39eab064051fb468a5965ee48ca87ab66d4000000000000000000000000", # noqa: mock + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "tradeExecutionType": "limitMatchRestingOrder", + "positionDelta": { + "tradeDirection": "sell", + "executionPrice": str( + Decimal(str(self.expected_latest_price)) * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": "142000000000000000000", + "executionMargin": "1245280000" + }, + "payout": "1187984833.579447998034818126", + "fee": "-112393", + "executedAt": "1688734042063", + "feeRecipient": "inj15uad884tqeq9r76x3fvktmjge2r6kek55c2zpa", # noqa: mock + "tradeId": "13374245_801_0", + "executionSide": "maker" + }, + ], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + response = self.all_derivative_markets_mock_response + response.append({ + "marketId": "invalid_market_id", + "marketStatus": "active", + "ticker": "INVALID/MARKET", + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + "minPriceTickSize": "0.000000000000001", + "minQuantityTickSize": "1000000000000000" + }) + + return ("INVALID_MARKET", response) + + @property + def network_status_request_successful_mock_response(self): + return {} + + @property + def trading_rules_request_mock_response(self): + raise NotImplementedError + + @property + def trading_rules_request_erroneous_mock_response(self): + return [{ + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "marketStatus": "active", + "ticker": f"{self.base_asset}/{self.quote_asset}", + "baseDenom": self.base_asset_denom, + "baseTokenMeta": { + "name": "Base Asset", + "address": "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30", # noqa: mock + "symbol": self.base_asset, + "logo": "https://static.alchemyapi.io/images/assets/7226.png", + "decimals": self.base_decimals, + "updatedAt": "1687190809715" + }, + "quoteDenom": self.quote_asset_denom, # noqa: mock + "quoteTokenMeta": { + "name": "Quote Asset", + "address": "0x0000000000000000000000000000000000000000", # noqa: mock + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": self.quote_decimals, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + }] + + @property + def order_creation_request_successful_mock_response(self): + return {"txhash": "017C130E3602A48E5C9D661CAC657BF1B79262D4B71D5C25B1DA62DE2338DA0E", # noqa: mock" + "rawLog": "[]"} + + @property + def balance_request_mock_response_for_base_and_quote(self): + return { + "accountAddress": self.vault_contract_address, + "bankBalances": [ + { + "denom": self.base_asset_denom, + "amount": str(Decimal(5) * Decimal(1e18)) + }, + { + "denom": self.quote_asset_denom, + "amount": str(Decimal(1000) * Decimal(1e6)) + } + ], + "subaccounts": [ + { + "subaccountId": self.vault_contract_subaccount_id, + "denom": self.quote_asset_denom, + "deposit": { + "totalBalance": str(Decimal(2000) * Decimal(1e6)), + "availableBalance": str(Decimal(2000) * Decimal(1e6)) + } + }, + { + "subaccountId": self.vault_contract_subaccount_id, + "denom": self.base_asset_denom, + "deposit": { + "totalBalance": str(Decimal(15) * Decimal(1e18)), + "availableBalance": str(Decimal(10) * Decimal(1e18)) + } + }, + ] + } + + @property + def balance_request_mock_response_only_base(self): + return { + "accountAddress": self.vault_contract_address, + "bankBalances": [], + "subaccounts": [ + { + "subaccountId": self.vault_contract_subaccount_id, + "denom": self.base_asset_denom, + "deposit": { + "totalBalance": str(Decimal(15) * Decimal(1e18)), + "availableBalance": str(Decimal(10) * Decimal(1e18)) + } + }, + ] + } + + @property + def balance_event_websocket_update(self): + return { + "balance": { + "subaccountId": self.vault_contract_subaccount_id, + "accountAddress": self.vault_contract_address, + "denom": self.base_asset_denom, + "deposit": { + "totalBalance": str(Decimal(15) * Decimal(1e18)), + "availableBalance": str(Decimal(10) * Decimal(1e18)), + } + }, + "timestamp": "1688659208000" + } + + @property + def expected_latest_price(self): + return 9999.9 + + @property + def expected_supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + + @property + def expected_trading_rule(self): + market_info = self.all_derivative_markets_mock_response[0] + min_price_tick_size = (Decimal(market_info["minPriceTickSize"]) + * Decimal(f"1e{-market_info['quoteTokenMeta']['decimals']}")) + min_quantity_tick_size = Decimal(market_info["minQuantityTickSize"]) + trading_rule = TradingRule( + trading_pair=self.trading_pair, + min_order_size=min_quantity_tick_size, + min_price_increment=min_price_tick_size, + min_base_amount_increment=min_quantity_tick_size, + min_quote_amount_increment=min_price_tick_size, + ) + + return trading_rule + + @property + def expected_logged_error_for_erroneous_trading_rule(self): + erroneous_rule = self.trading_rules_request_erroneous_mock_response[0] + return f"Error parsing the trading pair rule: {erroneous_rule}. Skipping..." + + @property + def expected_exchange_order_id(self): + return "0x3870fbdd91f07d54425147b1bb96404f4f043ba6335b422a6d494d285b387f00" # noqa: mock + + @property + def is_order_fill_http_update_included_in_status_update(self) -> bool: + return True + + @property + def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: + raise NotImplementedError + + @property + def expected_partial_fill_price(self) -> Decimal: + return Decimal("100") + + @property + def expected_partial_fill_amount(self) -> Decimal: + return Decimal("10") + + @property + def expected_fill_fee(self) -> TradeFeeBase: + return AddedToCostTradeFee( + percent_token=self.quote_asset, flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("30"))] + ) + + @property + def expected_fill_trade_id(self) -> str: + return "10414162_22_33" + + @property + def all_spot_markets_mock_response(self): + return [{ + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "marketStatus": "active", + "ticker": f"{self.base_asset}/{self.quote_asset}", + "baseDenom": self.base_asset_denom, + "baseTokenMeta": { + "name": "Base Asset", + "address": "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30", # noqa: mock + "symbol": self.base_asset, + "logo": "https://static.alchemyapi.io/images/assets/7226.png", + "decimals": self.base_decimals, + "updatedAt": "1687190809715" + }, + "quoteDenom": self.quote_asset_denom, # noqa: mock + "quoteTokenMeta": { + "name": "Quote Asset", + "address": "0x0000000000000000000000000000000000000000", # noqa: mock + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": self.quote_decimals, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + "minPriceTickSize": "0.000000000000001", + "minQuantityTickSize": "1000000000000000" + }] + + @property + def all_derivative_markets_mock_response(self): + return [ + { + "marketId": self.market_id, + "marketStatus": "active", + "ticker": f"{self.base_asset}/{self.quote_asset} PERP", + "oracleBase": "0x2d9315a88f3019f8efa88dfe9c0f0843712da0bac814461e27733f6b83eb51b3", # noqa: mock + "oracleQuote": "0x1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588", # noqa: mock + "oracleType": "pyth", + "oracleScaleFactor": 6, + "initialMarginRatio": "0.195", + "maintenanceMarginRatio": "0.05", + "quoteDenom": self.quote_asset_denom, + "quoteTokenMeta": { + "name": "Testnet Tether USDT", + "address": "0x0000000000000000000000000000000000000000", + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": self.quote_decimals, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0003", + "takerFeeRate": "0.003", + "serviceProviderFee": "0.4", + "isPerpetual": True, + "minPriceTickSize": "100", + "minQuantityTickSize": "0.0001", + "perpetualMarketInfo": { + "hourlyFundingRateCap": "0.000625", + "hourlyInterestRate": "0.00000416666", + "nextFundingTimestamp": str(self.target_funding_info_next_funding_utc_timestamp), + "fundingInterval": "3600" + }, + "perpetualMarketFunding": { + "cumulativeFunding": "81363.592243119007273334", + "cumulativePrice": "1.432536051546776736", + "lastTimestamp": "1689423842" + } + }, + ] + + def position_event_for_full_fill_websocket_update(self, order: InFlightOrder, unrealized_pnl: float): + raise NotImplementedError + + def configure_successful_set_position_mode(self, position_mode: PositionMode, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None): + raise NotImplementedError + + def configure_failed_set_position_mode( + self, + position_mode: PositionMode, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> Tuple[str, str]: + raise NotImplementedError + + def configure_failed_set_leverage( + self, + leverage: int, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> Tuple[str, str]: + raise NotImplementedError + + def configure_successful_set_leverage( + self, + leverage: int, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ): + raise NotImplementedError + + def funding_info_event_for_websocket_update(self): + raise NotImplementedError + + def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: + return self.market_id + + def create_exchange_instance(self): + client_config_map = ClientConfigAdapter(ClientConfigMap()) + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") + + account_config = InjectiveVaultAccountMode( + private_key=self.trading_account_private_key, + subaccount_index=self.trading_account_subaccount_index, + vault_contract_address=self.vault_contract_address, + ) + + injective_config = InjectiveConfigMap( + network=network_config, + account_type=account_config, + ) + + exchange = InjectiveV2PerpetualDerivative( + client_config_map=client_config_map, + connector_configuration=injective_config, + trading_pairs=[self.trading_pair], + ) + + exchange._data_source._query_executor = ProgrammableQueryExecutor() + exchange._data_source._spot_market_and_trading_pair_map = bidict() + exchange._data_source._derivative_market_and_trading_pair_map = bidict({self.market_id: self.trading_pair}) + return exchange + + def validate_auth_credentials_present(self, request_call: RequestCall): + raise NotImplementedError + + def validate_order_creation_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def validate_order_cancelation_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def validate_order_status_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def validate_trades_request(self, order: InFlightOrder, request_call: RequestCall): + raise NotImplementedError + + def configure_all_symbols_response( + self, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + all_markets_mock_response = self.all_spot_markets_mock_response + self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(all_markets_mock_response) + all_markets_mock_response = self.all_derivative_markets_mock_response + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait(all_markets_mock_response) + return "" + + def configure_trading_rules_response( + self, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> List[str]: + + self.configure_all_symbols_response(mock_api=mock_api, callback=callback) + return "" + + def configure_erroneous_trading_rules_response( + self, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> List[str]: + + self.exchange._data_source._query_executor._spot_markets_responses.put_nowait([]) + response = self.trading_rules_request_erroneous_mock_response + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait(response) + return "" + + def configure_successful_cancelation_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + response = self._order_cancelation_request_successful_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + return "" + + def configure_erroneous_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + response = self._order_cancelation_request_erroneous_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + return "" + + def configure_order_not_found_error_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + raise NotImplementedError + + def configure_one_successful_one_erroneous_cancel_all_response( + self, + successful_order: InFlightOrder, + erroneous_order: InFlightOrder, + mock_api: aioresponses + ) -> List[str]: + raise NotImplementedError + + def configure_completely_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> List[str]: + self.configure_all_symbols_response(mock_api=mock_api) + response = self._order_status_request_completely_filled_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_canceled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> Union[str, List[str]]: + self.configure_all_symbols_response(mock_api=mock_api) + + self.exchange._data_source._query_executor._spot_trades_responses.put_nowait( + {"trades": [], "paging": {"total": "0"}}) + self.exchange._data_source._query_executor._derivative_trades_responses.put_nowait( + {"trades": [], "paging": {"total": "0"}}) + + response = self._order_status_request_canceled_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_open_order_status_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> List[str]: + self.configure_all_symbols_response(mock_api=mock_api) + + self.exchange._data_source._query_executor._derivative_trades_responses.put_nowait( + {"trades": [], "paging": {"total": "0"}}) + + response = self._order_status_request_open_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_http_error_order_status_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + self.configure_all_symbols_response(mock_api=mock_api) + + mock_queue = AsyncMock() + mock_queue.get.side_effect = IOError("Test error for trades responses") + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + + mock_queue = AsyncMock() + mock_queue.get.side_effect = IOError("Test error for historical orders responses") + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return None + + def configure_partially_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + self.configure_all_symbols_response(mock_api=mock_api) + response = self._order_status_request_partially_filled_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return None + + def configure_order_not_found_error_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> List[str]: + self.configure_all_symbols_response(mock_api=mock_api) + response = self._order_status_request_not_found_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._historical_derivative_orders_responses = mock_queue + return [] + + def configure_partial_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + response = self._order_fills_request_partial_fill_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + return None + + def configure_erroneous_http_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + mock_queue = AsyncMock() + mock_queue.get.side_effect = IOError("Test error for trades responses") + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + return None + + def configure_full_fill_trade_response(self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + response = self._order_fills_request_full_fill_mock_response(order=order) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._derivative_trades_responses = mock_queue + return None + + def order_event_for_new_order_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "booked", + "createdAt": "1688667498756", + "updatedAt": "1688667498756", + "direction": order.trade_type.name.lower(), + "margin": "31342413000", + "txHash": "0x0000000000000000000000000000000000000000000000000000000000000000" # noqa: mock" + } + + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "canceled", + "createdAt": "1688667498756", + "updatedAt": "1688667498756", + "direction": order.trade_type.name.lower(), + "margin": "31342413000", + "txHash": "0x0000000000000000000000000000000000000000000000000000000000000000" # noqa: mock + } + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": str(order.amount), + "state": "filled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "direction": order.trade_type.name.lower(), + "margin": "31342413000", + "txHash": order.creation_transaction_hash + } + + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "orderHash": order.exchange_order_id, + "subaccountId": self.vault_contract_subaccount_id, + "marketId": self.market_id, + "tradeExecutionType": "limitMatchRestingOrder", + "positionDelta": { + "tradeDirection": order.trade_type.name.lower(), + "executionPrice": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": str(order.amount), + "executionMargin": "3693162304" + }, + "payout": "3693278402.762361271848955224", + "fee": str(self.expected_fill_fee.flat_fees[0].amount * Decimal(f"1e{self.quote_decimals}")), + "executedAt": "1687878089569", + "feeRecipient": self.vault_contract_address, # noqa: mock + "tradeId": self.expected_fill_trade_id, + "executionSide": "maker" + } + + @aioresponses() + def test_all_trading_pairs_does_not_raise_exception(self, mock_api): + self.exchange._set_trading_pair_symbol_map(None) + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + queue_mock = AsyncMock() + queue_mock.get.side_effect = Exception("Test error") + self.exchange._data_source._query_executor._spot_markets_responses = queue_mock + + result: List[str] = self.async_run_with_timeout(self.exchange.all_trading_pairs(), timeout=10) + + self.assertEqual(0, len(result)) + + def test_batch_order_create(self): + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + # Configure all symbols response to initialize the trading rules + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_rules()) + + buy_order_to_create = LimitOrder( + client_order_id="", + trading_pair=self.trading_pair, + is_buy=True, + base_currency=self.base_asset, + quote_currency=self.quote_asset, + price=Decimal("10"), + quantity=Decimal("2"), + ) + sell_order_to_create = LimitOrder( + client_order_id="", + trading_pair=self.trading_pair, + is_buy=False, + base_currency=self.base_asset, + quote_currency=self.quote_asset, + price=Decimal("11"), + quantity=Decimal("3"), + ) + orders_to_create = [buy_order_to_create, sell_order_to_create] + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + orders: List[LimitOrder] = self.exchange.batch_order_create(orders_to_create=orders_to_create) + + buy_order_to_create_in_flight = GatewayPerpetualInFlightOrder( + client_order_id=orders[0].client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=1640780000, + price=orders[0].price, + amount=orders[0].quantity, + exchange_order_id="0x05536de7e0a41f0bfb493c980c1137afd3e548ae7e740e2662503f940a80e944", # noqa: mock" + creation_transaction_hash=response["txhash"] + ) + sell_order_to_create_in_flight = GatewayPerpetualInFlightOrder( + client_order_id=orders[1].client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.SELL, + creation_timestamp=1640780000, + price=orders[1].price, + amount=orders[1].quantity, + exchange_order_id="0x05536de7e0a41f0bfb493c980c1137afd3e548ae7e740e2662503f940a80e945", # noqa: mock" + creation_transaction_hash=response["txhash"] + ) + + self.async_run_with_timeout(request_sent_event.wait()) + request_sent_event.clear() + + expected_order_hashes = [ + buy_order_to_create_in_flight.exchange_order_id, + sell_order_to_create_in_flight.exchange_order_id, + ] + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_chain_transactions() + ) + ) + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + full_transaction_response = self._orders_creation_transaction_response( + orders=[buy_order_to_create_in_flight, sell_order_to_create_in_flight], + order_hashes=[expected_order_hashes] + ) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=full_transaction_response + ) + self.exchange._data_source._query_executor._transaction_by_hash_responses = mock_queue + + transaction_event = self._orders_creation_transaction_event() + self.exchange._data_source._query_executor._transaction_events.put_nowait(transaction_event) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(2, len(orders)) + self.assertEqual(2, len(self.exchange.in_flight_orders)) + + self.assertIn(buy_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + self.assertIn(sell_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + + self.assertEqual( + buy_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + buy_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + self.assertEqual( + sell_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + sell_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + + @aioresponses() + def test_create_buy_limit_order_successfully(self, mock_api): + """Open long position""" + # Configure all symbols response to initialize the trading rules + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_rules()) + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + leverage = 2 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + order_id = self.place_buy_order() + self.async_run_with_timeout(request_sent_event.wait()) + request_sent_event.clear() + order = self.exchange.in_flight_orders[order_id] + + expected_order_hash = "0x05536de7e0a41f0bfb493c980c1137afd3e548ae7e740e2662503f940a80e944" # noqa: mock" + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_chain_transactions() + ) + ) + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + full_transaction_response = self._orders_creation_transaction_response(orders=[order], + order_hashes=[expected_order_hash]) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=full_transaction_response + ) + self.exchange._data_source._query_executor._transaction_by_hash_responses = mock_queue + + transaction_event = self._orders_creation_transaction_event() + self.exchange._data_source._query_executor._transaction_events.put_nowait(transaction_event) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual(expected_order_hash, order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + @aioresponses() + def test_create_sell_limit_order_successfully(self, mock_api): + self.configure_all_symbols_response(mock_api=None) + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_id = self.place_sell_order() + self.async_run_with_timeout(request_sent_event.wait()) + request_sent_event.clear() + order = self.exchange.in_flight_orders[order_id] + + expected_order_hash = "0x05536de7e0a41f0bfb493c980c1137afd3e548ae7e740e2662503f940a80e944" # noqa: mock" + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_chain_transactions() + ) + ) + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + full_transaction_response = self._orders_creation_transaction_response( + orders=[order], + order_hashes=[expected_order_hash] + ) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=full_transaction_response + ) + self.exchange._data_source._query_executor._transaction_by_hash_responses = mock_queue + + transaction_event = self._orders_creation_transaction_event() + self.exchange._data_source._query_executor._transaction_events.put_nowait(transaction_event) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual(expected_order_hash, order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + @aioresponses() + def test_create_order_fails_and_raises_failure_event(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = {"txhash": "", "rawLog": "Error"} + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_id = self.place_buy_order() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertNotIn(order_id, self.exchange.in_flight_orders) + + self.assertEquals(0, len(self.buy_order_created_logger.event_log)) + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(OrderType.LIMIT, failure_event.order_type) + self.assertEqual(order_id, failure_event.order_id) + + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}', " + f"update_timestamp={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order_id}', exchange_order_id=None, misc_updates=None)" + ) + ) + + @aioresponses() + def test_create_order_fails_when_trading_rule_error_and_raises_failure_event(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + order_id_for_invalid_order = self.place_buy_order( + amount=Decimal("0.0001"), price=Decimal("0.0001") + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = {"txhash": "", "rawLog": "Error"} + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_id = self.place_buy_order() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertNotIn(order_id_for_invalid_order, self.exchange.in_flight_orders) + self.assertNotIn(order_id, self.exchange.in_flight_orders) + + self.assertEquals(0, len(self.buy_order_created_logger.event_log)) + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(OrderType.LIMIT, failure_event.order_type) + self.assertEqual(order_id_for_invalid_order, failure_event.order_id) + + self.assertTrue( + self.is_logged( + "WARNING", + "Buy order amount 0.0001 is lower than the minimum order size 0.01. The order will not be created, " + "increase the amount to be higher than the minimum order size." + ) + ) + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}', " + f"update_timestamp={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order_id}', exchange_order_id=None, misc_updates=None)" + ) + ) + + @aioresponses() + def test_create_order_to_close_short_position(self, mock_api): + self.configure_all_symbols_response(mock_api=None) + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + leverage = 4 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + order_id = self.place_buy_order(position_action=PositionAction.CLOSE) + self.async_run_with_timeout(request_sent_event.wait()) + request_sent_event.clear() + order = self.exchange.in_flight_orders[order_id] + + expected_order_hash = "0x05536de7e0a41f0bfb493c980c1137afd3e548ae7e740e2662503f940a80e944" # noqa: mock" + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_chain_transactions() + ) + ) + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + full_transaction_response = self._orders_creation_transaction_response(orders=[order], + order_hashes=[expected_order_hash]) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=full_transaction_response + ) + self.exchange._data_source._query_executor._transaction_by_hash_responses = mock_queue + + transaction_event = self._orders_creation_transaction_event() + self.exchange._data_source._query_executor._transaction_events.put_nowait(transaction_event) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual(expected_order_hash, order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + @aioresponses() + def test_create_order_to_close_long_position(self, mock_api): + self.configure_all_symbols_response(mock_api=None) + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + leverage = 5 + self.exchange._perpetual_trading.set_leverage(self.trading_pair, leverage) + order_id = self.place_sell_order(position_action=PositionAction.CLOSE) + self.async_run_with_timeout(request_sent_event.wait()) + request_sent_event.clear() + order = self.exchange.in_flight_orders[order_id] + + expected_order_hash = "0x05536de7e0a41f0bfb493c980c1137afd3e548ae7e740e2662503f940a80e944" # noqa: mock" + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_chain_transactions() + ) + ) + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + full_transaction_response = self._orders_creation_transaction_response(orders=[order], + order_hashes=[expected_order_hash]) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=full_transaction_response + ) + self.exchange._data_source._query_executor._transaction_by_hash_responses = mock_queue + + transaction_event = self._orders_creation_transaction_event() + self.exchange._data_source._query_executor._transaction_events.put_nowait(transaction_event) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual(expected_order_hash, order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + + def test_batch_order_cancel(self): + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id="11", + exchange_order_id=self.expected_exchange_order_id + "1", + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + self.exchange.start_tracking_order( + order_id="12", + exchange_order_id=self.expected_exchange_order_id + "2", + trading_pair=self.trading_pair, + trade_type=TradeType.SELL, + price=Decimal("11000"), + amount=Decimal("110"), + order_type=OrderType.LIMIT, + ) + + buy_order_to_cancel: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders["11"] + sell_order_to_cancel: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders["12"] + orders_to_cancel = [buy_order_to_cancel, sell_order_to_cancel] + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait(transaction_simulation_response) + + response = self._order_cancelation_request_successful_mock_response(order=buy_order_to_cancel) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + self.exchange.batch_order_cancel(orders_to_cancel=orders_to_cancel) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertIn(buy_order_to_cancel.client_order_id, self.exchange.in_flight_orders) + self.assertIn(sell_order_to_cancel.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(buy_order_to_cancel.is_pending_cancel_confirmation) + self.assertEqual(response["txhash"], buy_order_to_cancel.cancel_tx_hash) + self.assertTrue(sell_order_to_cancel.is_pending_cancel_confirmation) + self.assertEqual(response["txhash"], sell_order_to_cancel.cancel_tx_hash) + + @aioresponses() + def test_cancel_order_not_found_in_the_exchange(self, mock_api): + # This tests does not apply for Injective. The batch orders update message used for cancelations will not + # detect if the orders exists or not. That will happen when the transaction is executed. + pass + + @aioresponses() + def test_cancel_two_orders_with_cancel_all_and_one_fails(self, mock_api): + # This tests does not apply for Injective. The batch orders update message used for cancelations will not + # detect if the orders exists or not. That will happen when the transaction is executed. + pass + + def test_get_buy_and_sell_collateral_tokens(self): + self._simulate_trading_rules_initialized() + + linear_buy_collateral_token = self.exchange.get_buy_collateral_token(self.trading_pair) + linear_sell_collateral_token = self.exchange.get_sell_collateral_token(self.trading_pair) + + self.assertEqual(self.quote_asset, linear_buy_collateral_token) + self.assertEqual(self.quote_asset, linear_sell_collateral_token) + + def test_order_not_found_in_its_creating_transaction_marked_as_failed_during_order_creation_check(self): + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id="0x9f94598b4842ab66037eaa7c64ec10ae16dcf196e61db8522921628522c0f62e", # noqa: mock + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + order: GatewayPerpetualInFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + order.update_creation_transaction_hash(creation_transaction_hash="66A360DA2FD6884B53B5C019F1A2B5BED7C7C8FC07E83A9C36AD3362EDE096AE") # noqa: mock + + modified_order = copy(order) + modified_order.amount = modified_order.amount + Decimal("1") + transaction_response = self._orders_creation_transaction_response( + orders=[modified_order], + order_hashes=["0xc5d66f56942e1ae407c01eedccd0471deb8e202a514cde3bae56a8307e376cd1"], # noqa: mock" + ) + self.exchange._data_source._query_executor._transaction_by_hash_responses.put_nowait(transaction_response) + + self.async_run_with_timeout(self.exchange._check_orders_creation_transactions()) + + self.assertEquals(0, len(self.buy_order_created_logger.event_log)) + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(OrderType.LIMIT, failure_event.order_type) + self.assertEqual(order.client_order_id, failure_event.order_id) + + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order.client_order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}', " + f"update_timestamp={self.exchange.current_timestamp}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order.client_order_id}', exchange_order_id=None, misc_updates=None)" + ) + ) + + def test_user_stream_balance_update(self): + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + balance_event = self.balance_event_websocket_update + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [balance_event, asyncio.CancelledError] + self.exchange._data_source._query_executor._subaccount_balance_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout(self.exchange._data_source._listen_to_account_balance_updates()) + except asyncio.CancelledError: + pass + + self.assertEqual(Decimal("10"), self.exchange.available_balances[self.base_asset]) + self.assertEqual(Decimal("15"), self.exchange.get_balance(self.base_asset)) + + def test_user_stream_update_for_new_order(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + order_event = self.order_event_for_new_order_websocket_update(order=order) + + mock_queue = AsyncMock() + event_messages = [order_event, asyncio.CancelledError] + mock_queue.get.side_effect = event_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + except asyncio.CancelledError: + pass + + event: BuyOrderCreatedEvent = self.buy_order_created_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, event.timestamp) + self.assertEqual(order.order_type, event.type) + self.assertEqual(order.trading_pair, event.trading_pair) + self.assertEqual(order.amount, event.amount) + self.assertEqual(order.price, event.price) + self.assertEqual(order.client_order_id, event.order_id) + self.assertEqual(order.exchange_order_id, event.exchange_order_id) + self.assertTrue(order.is_open) + + tracked_order: InFlightOrder = list(self.exchange.in_flight_orders.values())[0] + + self.assertTrue(self.is_logged("INFO", tracked_order.build_order_created_message())) + + def test_user_stream_update_for_canceled_order(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + order_event = self.order_event_for_canceled_order_websocket_update(order=order) + + mock_queue = AsyncMock() + event_messages = [order_event, asyncio.CancelledError] + mock_queue.get.side_effect = event_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + except asyncio.CancelledError: + pass + + cancel_event: OrderCancelledEvent = self.order_cancelled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, cancel_event.timestamp) + self.assertEqual(order.client_order_id, cancel_event.order_id) + self.assertEqual(order.exchange_order_id, cancel_event.exchange_order_id) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(order.is_cancelled) + self.assertTrue(order.is_done) + + self.assertTrue( + self.is_logged("INFO", f"Successfully canceled order {order.client_order_id}.") + ) + + @aioresponses() + def test_user_stream_update_for_order_full_fill(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + self.configure_all_symbols_response(mock_api=None) + order_event = self.order_event_for_full_fill_websocket_update(order=order) + trade_event = self.trade_event_for_full_fill_websocket_update(order=order) + + orders_queue_mock = AsyncMock() + trades_queue_mock = AsyncMock() + orders_messages = [] + trades_messages = [] + if trade_event: + trades_messages.append(trade_event) + if order_event: + orders_messages.append(order_event) + orders_messages.append(asyncio.CancelledError) + trades_messages.append(asyncio.CancelledError) + + orders_queue_mock.get.side_effect = orders_messages + trades_queue_mock.get.side_effect = trades_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = orders_queue_mock + self.exchange._data_source._query_executor._public_derivative_trade_updates = trades_queue_mock + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + tasks = [ + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_public_derivative_trades(market_ids=[self.market_id]) + ), + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + ] + try: + self.async_run_with_timeout(safe_gather(*tasks)) + except asyncio.CancelledError: + pass + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(order.wait_until_completely_filled()) + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + expected_fee = self.expected_fill_fee + self.assertEqual(expected_fee, fill_event.trade_fee) + + buy_event: BuyOrderCompletedEvent = self.buy_order_completed_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, buy_event.timestamp) + self.assertEqual(order.client_order_id, buy_event.order_id) + self.assertEqual(order.base_asset, buy_event.base_asset) + self.assertEqual(order.quote_asset, buy_event.quote_asset) + self.assertEqual(order.amount, buy_event.base_asset_amount) + self.assertEqual(order.amount * fill_event.price, buy_event.quote_asset_amount) + self.assertEqual(order.order_type, buy_event.order_type) + self.assertEqual(order.exchange_order_id, buy_event.exchange_order_id) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(order.is_filled) + self.assertTrue(order.is_done) + + self.assertTrue( + self.is_logged( + "INFO", + f"BUY order {order.client_order_id} completely filled." + ) + ) + + def test_user_stream_logs_errors(self): + # This test does not apply to Injective because it handles private events in its own data source + pass + + def test_user_stream_raises_cancel_exception(self): + # This test does not apply to Injective because it handles private events in its own data source + pass + + @aioresponses() + def test_update_order_status_when_order_has_not_changed_and_one_partial_fill(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + position_action=PositionAction.OPEN, + ) + order: InFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + self.configure_partially_filled_order_status_response( + order=order, + mock_api=mock_api) + + if self.is_order_fill_http_update_included_in_status_update: + self.configure_partial_fill_trade_response( + order=order, + mock_api=mock_api) + + self.assertTrue(order.is_open) + + self.async_run_with_timeout(self.exchange._update_order_status()) + + self.assertTrue(order.is_open) + self.assertEqual(OrderState.PARTIALLY_FILLED, order.current_state) + + if self.is_order_fill_http_update_included_in_status_update: + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(self.expected_partial_fill_price, fill_event.price) + self.assertEqual(self.expected_partial_fill_amount, fill_event.amount) + self.assertEqual(self.expected_fill_fee, fill_event.trade_fee) + + def test_lost_order_removed_after_cancel_status_user_event_received(self): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + for _ in range(self.exchange._order_tracker._lost_order_count_limit + 1): + self.async_run_with_timeout( + self.exchange._order_tracker.process_order_not_found(client_order_id=order.client_order_id)) + + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + + order_event = self.order_event_for_canceled_order_websocket_update(order=order) + + mock_queue = AsyncMock() + event_messages = [order_event, asyncio.CancelledError] + mock_queue.get.side_effect = event_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + except asyncio.CancelledError: + pass + + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.lost_orders) + self.assertEqual(0, len(self.order_cancelled_logger.event_log)) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertFalse(order.is_cancelled) + self.assertTrue(order.is_failure) + + @aioresponses() + def test_lost_order_user_stream_full_fill_events_are_processed(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + for _ in range(self.exchange._order_tracker._lost_order_count_limit + 1): + self.async_run_with_timeout( + self.exchange._order_tracker.process_order_not_found(client_order_id=order.client_order_id)) + + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + + self.configure_all_symbols_response(mock_api=None) + order_event = self.order_event_for_full_fill_websocket_update(order=order) + trade_event = self.trade_event_for_full_fill_websocket_update(order=order) + + orders_queue_mock = AsyncMock() + trades_queue_mock = AsyncMock() + orders_messages = [] + trades_messages = [] + if trade_event: + trades_messages.append(trade_event) + if order_event: + orders_messages.append(order_event) + orders_messages.append(asyncio.CancelledError) + trades_messages.append(asyncio.CancelledError) + + orders_queue_mock.get.side_effect = orders_messages + trades_queue_mock.get.side_effect = trades_messages + self.exchange._data_source._query_executor._historical_derivative_order_events = orders_queue_mock + self.exchange._data_source._query_executor._public_derivative_trade_updates = trades_queue_mock + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + tasks = [ + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_public_derivative_trades(market_ids=[self.market_id]) + ), + asyncio.get_event_loop().create_task( + self.exchange._data_source._listen_to_subaccount_derivative_order_updates(market_id=self.market_id) + ) + ] + try: + self.async_run_with_timeout(safe_gather(*tasks)) + except asyncio.CancelledError: + pass + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(order.wait_until_completely_filled()) + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + expected_fee = self.expected_fill_fee + self.assertEqual(expected_fee, fill_event.trade_fee) + + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.lost_orders) + self.assertTrue(order.is_filled) + self.assertTrue(order.is_failure) + + @aioresponses() + def test_lost_order_included_in_order_fills_update_and_not_in_order_status_update(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + request_sent_event = asyncio.Event() + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + position_action=PositionAction.OPEN, + ) + order: InFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + for _ in range(self.exchange._order_tracker._lost_order_count_limit + 1): + self.async_run_with_timeout( + self.exchange._order_tracker.process_order_not_found(client_order_id=order.client_order_id)) + + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + + self.configure_completely_filled_order_status_response( + order=order, + mock_api=mock_api, + callback=lambda *args, **kwargs: request_sent_event.set()) + + if self.is_order_fill_http_update_included_in_status_update: + self.configure_full_fill_trade_response( + order=order, + mock_api=mock_api, + callback=lambda *args, **kwargs: request_sent_event.set()) + else: + # If the fill events will not be requested with the order status, we need to manually set the event + # to allow the ClientOrderTracker to process the last status update + order.completely_filled_event.set() + request_sent_event.set() + + self.async_run_with_timeout(self.exchange._update_order_status()) + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(request_sent_event.wait()) + + self.async_run_with_timeout(order.wait_until_completely_filled()) + self.assertTrue(order.is_done) + self.assertTrue(order.is_failure) + + if self.is_order_fill_http_update_included_in_status_update: + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + self.assertEqual(self.expected_fill_fee, fill_event.trade_fee) + + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) + self.assertIn(order.client_order_id, self.exchange._order_tracker.all_fillable_orders) + self.assertFalse( + self.is_logged( + "INFO", + f"BUY order {order.client_order_id} completely filled." + ) + ) + + request_sent_event.clear() + + # Configure again the response to the order fills request since it is required by lost orders update logic + self.configure_full_fill_trade_response( + order=order, + mock_api=mock_api, + callback=lambda *args, **kwargs: request_sent_event.set()) + + self.async_run_with_timeout(self.exchange._update_lost_orders_status()) + # Execute one more synchronization to ensure the async task that processes the update is finished + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertTrue(order.is_done) + self.assertTrue(order.is_failure) + + self.assertEqual(1, len(self.order_filled_logger.event_log)) + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.all_fillable_orders) + self.assertFalse( + self.is_logged( + "INFO", + f"BUY order {order.client_order_id} completely filled." + ) + ) + + @aioresponses() + def test_invalid_trading_pair_not_in_all_trading_pairs(self, mock_api): + self.exchange._set_trading_pair_symbol_map(None) + + invalid_pair, response = self.all_symbols_including_invalid_pair_mock_response + self.exchange._data_source._query_executor._spot_markets_responses.put_nowait( + self.all_spot_markets_mock_response + ) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait(response) + + all_trading_pairs = self.async_run_with_timeout(coroutine=self.exchange.all_trading_pairs()) + + self.assertNotIn(invalid_pair, all_trading_pairs) + + @aioresponses() + def test_check_network_success(self, mock_api): + response = self.network_status_request_successful_mock_response + self.exchange._data_source._query_executor._ping_responses.put_nowait(response) + + network_status = self.async_run_with_timeout(coroutine=self.exchange.check_network(), timeout=10) + + self.assertEqual(NetworkStatus.CONNECTED, network_status) + + @aioresponses() + def test_check_network_failure(self, mock_api): + mock_queue = AsyncMock() + mock_queue.get.side_effect = RpcError("Test Error") + self.exchange._data_source._query_executor._ping_responses = mock_queue + + ret = self.async_run_with_timeout(coroutine=self.exchange.check_network()) + + self.assertEqual(ret, NetworkStatus.NOT_CONNECTED) + + @aioresponses() + def test_check_network_raises_cancel_exception(self, mock_api): + mock_queue = AsyncMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.exchange._data_source._query_executor._ping_responses = mock_queue + + self.assertRaises(asyncio.CancelledError, self.async_run_with_timeout, self.exchange.check_network()) + + @aioresponses() + def test_get_last_trade_prices(self, mock_api): + self.configure_all_symbols_response(mock_api=mock_api) + response = self.latest_prices_request_mock_response + self.exchange._data_source._query_executor._derivative_trades_responses.put_nowait(response) + + latest_prices: Dict[str, float] = self.async_run_with_timeout( + self.exchange.get_last_traded_prices(trading_pairs=[self.trading_pair]) + ) + + self.assertEqual(1, len(latest_prices)) + self.assertEqual(self.expected_latest_price, latest_prices[self.trading_pair]) + + def test_get_fee(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_fees()) + + maker_fee_rate = Decimal(self.all_derivative_markets_mock_response[0]["makerFeeRate"]) + taker_fee_rate = Decimal(self.all_derivative_markets_mock_response[0]["takerFeeRate"]) + + maker_fee = self.exchange.get_fee( + base_currency=self.base_asset, + quote_currency=self.quote_asset, + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + position_action=PositionAction.OPEN, + amount=Decimal("1000"), + price=Decimal("5"), + is_maker=True + ) + + self.assertEqual(maker_fee_rate, maker_fee.percent) + self.assertEqual(self.quote_asset, maker_fee.percent_token) + + taker_fee = self.exchange.get_fee( + base_currency=self.base_asset, + quote_currency=self.quote_asset, + order_type=OrderType.LIMIT, + order_side=TradeType.BUY, + position_action=PositionAction.OPEN, + amount=Decimal("1000"), + price=Decimal("5"), + is_maker=False, + ) + + self.assertEqual(taker_fee_rate, taker_fee.percent) + self.assertEqual(self.quote_asset, maker_fee.percent_token) + + def test_restore_tracking_states_only_registers_open_orders(self): + orders = [] + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + )) + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "2", + exchange_order_id=self.exchange_order_id_prefix + "2", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + initial_state=OrderState.CANCELED + )) + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "3", + exchange_order_id=self.exchange_order_id_prefix + "3", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + initial_state=OrderState.FILLED + )) + orders.append(GatewayPerpetualInFlightOrder( + client_order_id=self.client_order_id_prefix + "4", + exchange_order_id=self.exchange_order_id_prefix + "4", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + amount=Decimal("1000.0"), + price=Decimal("1.0"), + creation_timestamp=1640001112.223, + initial_state=OrderState.FAILED + )) + + tracking_states = {order.client_order_id: order.to_json() for order in orders} + + self.exchange.restore_tracking_states(tracking_states) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + self.assertNotIn(self.client_order_id_prefix + "2", self.exchange.in_flight_orders) + self.assertNotIn(self.client_order_id_prefix + "3", self.exchange.in_flight_orders) + self.assertNotIn(self.client_order_id_prefix + "4", self.exchange.in_flight_orders) + + @aioresponses() + def test_set_position_mode_success(self, mock_api): + # There's only ONEWAY position mode + pass + + @aioresponses() + def test_set_position_mode_failure(self, mock_api): + # There's only ONEWAY position mode + pass + + @aioresponses() + def test_set_leverage_failure(self, mock_api): + # Leverage is configured in a per order basis + pass + + @aioresponses() + def test_set_leverage_success(self, mock_api): + # Leverage is configured in a per order basis + pass + + @aioresponses() + def test_funding_payment_polling_loop_sends_update_event(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + + self.async_tasks.append(asyncio.get_event_loop().create_task(self.exchange._funding_payment_polling_loop())) + + funding_payments = { + "payments": [{ + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "amount": str(self.target_funding_payment_payment_amount), + "timestamp": 1000 * 1e3, + }], + "paging": { + "total": 1000 + } + } + self.exchange._data_source.query_executor._funding_payments_responses.put_nowait(funding_payments) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_payment_funding_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=funding_rate + ) + self.exchange._data_source.query_executor._funding_rates_responses = mock_queue + + self.exchange._funding_fee_poll_notifier.set() + self.async_run_with_timeout(request_sent_event.wait()) + + request_sent_event.clear() + + funding_payments = { + "payments": [{ + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "amount": str(self.target_funding_payment_payment_amount), + "timestamp": self.target_funding_payment_timestamp * 1e3, + }], + "paging": { + "total": 1000 + } + } + self.exchange._data_source.query_executor._funding_payments_responses.put_nowait(funding_payments) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_payment_funding_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=funding_rate + ) + self.exchange._data_source.query_executor._funding_rates_responses = mock_queue + + self.exchange._funding_fee_poll_notifier.set() + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.funding_payment_logger.event_log)) + funding_event: FundingPaymentCompletedEvent = self.funding_payment_logger.event_log[0] + self.assertEqual(self.target_funding_payment_timestamp, funding_event.timestamp) + self.assertEqual(self.exchange.name, funding_event.market) + self.assertEqual(self.trading_pair, funding_event.trading_pair) + self.assertEqual(self.target_funding_payment_payment_amount, funding_event.amount) + self.assertEqual(self.target_funding_payment_funding_rate, funding_event.funding_rate) + + def test_listen_for_funding_info_update_initializes_funding_info(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + self.configure_all_symbols_response(mock_api=None) + self.exchange._data_source._query_executor._derivative_market_responses.put_nowait( + self.all_derivative_markets_mock_response[0] + ) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_info_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.exchange._data_source.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": str(self.target_funding_info_mark_price) + } + self.exchange._data_source.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": str( + self.target_funding_info_index_price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.exchange._data_source.query_executor._derivative_trades_responses.put_nowait(trades) + + funding_info_update = FundingInfoUpdate( + trading_pair=self.trading_pair, + index_price=Decimal("29423.16356086"), + mark_price=Decimal("9084900"), + next_funding_utc_timestamp=1690426800, + rate=Decimal("0.000004"), + ) + mock_queue = AsyncMock() + mock_queue.get.side_effect = [funding_info_update, asyncio.CancelledError] + self.exchange.order_book_tracker.data_source._message_queue[ + self.exchange.order_book_tracker.data_source._funding_info_messages_queue_key + ] = mock_queue + + try: + self.async_run_with_timeout(self.exchange._listen_for_funding_info()) + except asyncio.CancelledError: + pass + + funding_info: FundingInfo = self.exchange.get_funding_info(self.trading_pair) + + self.assertEqual(self.trading_pair, funding_info.trading_pair) + self.assertEqual(self.target_funding_info_index_price, funding_info.index_price) + self.assertEqual(self.target_funding_info_mark_price, funding_info.mark_price) + self.assertEqual( + self.target_funding_info_next_funding_utc_timestamp, funding_info.next_funding_utc_timestamp + ) + self.assertEqual(self.target_funding_info_rate, funding_info.rate) + + def test_listen_for_funding_info_update_updates_funding_info(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None + self.configure_all_symbols_response(mock_api=None) + self.exchange._data_source._query_executor._derivative_market_responses.put_nowait( + self.all_derivative_markets_mock_response[0] + ) + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": str(self.target_funding_info_rate), + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.exchange._data_source.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": str(self.target_funding_info_mark_price) + } + self.exchange._data_source.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": str( + self.target_funding_info_index_price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.exchange._data_source.query_executor._derivative_trades_responses.put_nowait(trades) + + funding_info_update = FundingInfoUpdate( + trading_pair=self.trading_pair, + index_price=Decimal("29423.16356086"), + mark_price=Decimal("9084900"), + next_funding_utc_timestamp=1690426800, + rate=Decimal("0.000004"), + ) + mock_queue = AsyncMock() + mock_queue.get.side_effect = [funding_info_update, asyncio.CancelledError] + self.exchange.order_book_tracker.data_source._message_queue[ + self.exchange.order_book_tracker.data_source._funding_info_messages_queue_key + ] = mock_queue + + try: + self.async_run_with_timeout( + self.exchange._listen_for_funding_info()) + except asyncio.CancelledError: + pass + + self.assertEqual(1, self.exchange._perpetual_trading.funding_info_stream.qsize()) # rest in OB DS tests + + def test_existing_account_position_detected_on_positions_update(self): + self._simulate_trading_rules_initialized() + self.configure_all_symbols_response(mock_api=None) + + position_data = { + "ticker": "BTC/USDT PERP", + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "direction": "long", + "quantity": "0.01", + "entryPrice": "25000000000", + "margin": "248483436.058851", + "liquidationPrice": "47474612957.985809", + "markPrice": "28984256513.07", + "aggregateReduceOnlyQuantity": "0", + "updatedAt": "1691077382583", + "createdAt": "-62135596800000" + } + positions = { + "positions": [position_data], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + self.exchange._data_source._query_executor._derivative_positions_responses.put_nowait(positions) + + self.async_run_with_timeout(self.exchange._update_positions()) + + self.assertEqual(len(self.exchange.account_positions), 1) + pos = list(self.exchange.account_positions.values())[0] + self.assertEqual(self.trading_pair, pos.trading_pair) + self.assertEqual(PositionSide.LONG, pos.position_side) + self.assertEqual(Decimal(position_data["quantity"]), pos.amount) + entry_price = Decimal(position_data["entryPrice"]) * Decimal(f"1e{-self.quote_decimals}") + self.assertEqual(entry_price, pos.entry_price) + expected_leverage = ((Decimal(position_data["entryPrice"]) * Decimal(position_data["quantity"])) + / Decimal(position_data["margin"])) + self.assertEqual(expected_leverage, pos.leverage) + mark_price = Decimal(position_data["markPrice"]) * Decimal(f"1e{-self.quote_decimals}") + expected_unrealized_pnl = (mark_price - entry_price) * Decimal(position_data["quantity"]) + self.assertEqual(expected_unrealized_pnl, pos.unrealized_pnl) + + def test_user_stream_position_update(self): + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + position_data = { + "ticker": "BTC/USDT PERP", + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "direction": "long", + "quantity": "0.01", + "entryPrice": "25000000000", + "margin": "248483436.058851", + "liquidationPrice": "47474612957.985809", + "markPrice": "28984256513.07", + "aggregateReduceOnlyQuantity": "0", + "updatedAt": "1691077382583", + "createdAt": "-62135596800000" + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [position_data, asyncio.CancelledError] + self.exchange._data_source._query_executor._subaccount_positions_events = mock_queue + + self.async_tasks.append( + asyncio.get_event_loop().create_task( + self.exchange._user_stream_event_listener() + ) + ) + + try: + self.async_run_with_timeout(self.exchange._data_source._listen_to_positions_updates()) + except asyncio.CancelledError: + pass + + self.assertEqual(len(self.exchange.account_positions), 1) + pos = list(self.exchange.account_positions.values())[0] + self.assertEqual(self.trading_pair, pos.trading_pair) + self.assertEqual(PositionSide.LONG, pos.position_side) + self.assertEqual(Decimal(position_data["quantity"]), pos.amount) + entry_price = Decimal(position_data["entryPrice"]) * Decimal(f"1e{-self.quote_decimals}") + self.assertEqual(entry_price, pos.entry_price) + expected_leverage = ((Decimal(position_data["entryPrice"]) * Decimal(position_data["quantity"])) + / Decimal(position_data["margin"])) + self.assertEqual(expected_leverage, pos.leverage) + mark_price = Decimal(position_data["markPrice"]) * Decimal(f"1e{-self.quote_decimals}") + expected_unrealized_pnl = (mark_price - entry_price) * Decimal(position_data["quantity"]) + self.assertEqual(expected_unrealized_pnl, pos.unrealized_pnl) + + def _expected_initial_status_dict(self) -> Dict[str, bool]: + status_dict = super()._expected_initial_status_dict() + status_dict["data_source_initialized"] = False + return status_dict + + @staticmethod + def _callback_wrapper_with_response(callback: Callable, response: Any, *args, **kwargs): + callback(args, kwargs) + if isinstance(response, Exception): + raise response + else: + return response + + def _configure_balance_response( + self, + response: Dict[str, Any], + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None, + ) -> str: + self.configure_all_symbols_response(mock_api=mock_api) + self.exchange._data_source._query_executor._account_portfolio_responses.put_nowait(response) + return "" + + def _msg_exec_simulation_mock_response(self) -> Any: + return { + "gasInfo": { + "gasWanted": "50000000", + "gasUsed": "90749" + }, + "result": { + "data": "Em8KJS9jb3Ntb3MuYXV0aHoudjFiZXRhMS5Nc2dFeGVjUmVzcG9uc2USRgpECkIweGYxNGU5NGMxZmQ0MjE0M2I3ZGRhZjA4ZDE3ZWMxNzAzZGMzNzZlOWU2YWI0YjY0MjBhMzNkZTBhZmFlYzJjMTA=", # noqa: mock + "log": "", + "events": [], + "msgResponses": [ + OrderedDict([ + ("@type", "/cosmos.authz.v1beta1.MsgExecResponse"), + ("results", [ + "CkIweGYxNGU5NGMxZmQ0MjE0M2I3ZGRhZjA4ZDE3ZWMxNzAzZGMzNzZlOWU2YWI0YjY0MjBhMzNkZTBhZmFlYzJjMTA="]) # noqa: mock + ]) + ] + } + } + + def _orders_creation_transaction_event(self) -> Dict[str, Any]: + return { + 'blockNumber': '44237', + 'blockTimestamp': '2023-07-18 20:25:43.518 +0000 UTC', + 'hash': self._transaction_hash, + 'messages': '[{"type":"/cosmwasm.wasm.v1.MsgExecuteContract","value":{"sender":"inj15uad884tqeq9r76x3fvktmjge2r6kek55c2zpa","contract":"inj1zlwdkv49rmsug0pnwu6fmwnl267lfr34yvhwgp","msg":{"admin_execute_message":{"injective_message":{"custom":{"route":"exchange","msg_data":{"batch_update_orders":{"sender":"inj1zlwdkv49rmsug0pnwu6fmwnl267lfr34yvhwgp","spot_orders_to_create":[],"spot_market_ids_to_cancel_all":[],"derivative_market_ids_to_cancel_all":[],"spot_orders_to_cancel":[],"derivative_orders_to_cancel":[],"derivative_orders_to_create":[{"market_id":"0xa508cb32923323679f29a032c70342c147c17d0145625922b0ef22e955c844c0","order_info":{"subaccount_id":"1","price":"0.000000000002559000","quantity":"10000000000000000000.000000000000000000"},"order_type":1,"trigger_price":"0"}]}}}}}},"funds":[]}}]', # noqa: mock" + 'txNumber': '122692' + } + + def _orders_creation_transaction_response(self, orders: List[GatewayPerpetualInFlightOrder], order_hashes: List[str]): + derivative_orders = [] + for order in orders: + order_creation_message = { + "market_id": self.market_id, + "order_info": { + "subaccount_id": str(self.vault_contract_subaccount_index), + "fee_recipient": self.vault_contract_address, + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "quantity": str(order.amount) + }, + "order_type": 1 if order.trade_type == TradeType.BUY else 2, + "margin": str(order.amount * order.price * Decimal(f"1e{self.quote_decimals}")), + "trigger_price": "0" + } + derivative_orders.append(order_creation_message) + messages = [ + { + "type": "/cosmwasm.wasm.v1.MsgExecuteContract", + "value": { + "sender": self.trading_account_public_key, + "contract": self.vault_contract_address, + "msg": { + "admin_execute_message": { + "injective_message": { + "custom": { + "route": "exchange", + "msg_data": { + "batch_update_orders": { + "sender": self.vault_contract_address, + "spot_orders_to_create": [], + "spot_market_ids_to_cancel_all": [], + "derivative_market_ids_to_cancel_all": [], + "spot_orders_to_cancel": [], + "derivative_orders_to_cancel": [], + "derivative_orders_to_create": derivative_orders}}}}}}, + "funds": []}}] + + logs = [{ + "msg_index": 0, + "events": [ + { + "type": "message", + "attributes": [{"key": "action", "value": "/cosmwasm.wasm.v1.MsgExecuteContract"}, + {"key": "sender", "value": "inj15uad884tqeq9r76x3fvktmjge2r6kek55c2zpa"}, # noqa: mock" + {"key": "module", "value": "wasm"}]}, + { + "type": "execute", + "attributes": [ + {"key": "_contract_address", "value": "inj1zlwdkv49rmsug0pnwu6fmwnl267lfr34yvhwgp"}]}, # noqa: mock" + { + "type": "reply", + "attributes": [ + {"key": "_contract_address", "value": "inj1zlwdkv49rmsug0pnwu6fmwnl267lfr34yvhwgp"}]}, # noqa: mock" + { + "type": "wasm", + "attributes": [ + { + "key": "_contract_address", + "value": "inj1zlwdkv49rmsug0pnwu6fmwnl267lfr34yvhwgp"}, # noqa: mock" + { + "key": "method", + "value": "instantiate"}, + { + "key": "reply_id", + "value": "1"}, + { + "key": "batch_update_orders_response", + "value": f'MsgBatchUpdateOrdersResponse {{ spot_cancel_success: [], derivative_cancel_success: [], spot_order_hashes: [], derivative_order_hashes: {order_hashes}, binary_options_cancel_success: [], binary_options_order_hashes: [], unknown_fields: UnknownFields {{ fields: None }}, cached_size: CachedSize {{ size: 0 }} }}' + } + ] + } + ] + }] + + transaction_response = { + "s": "ok", + "data": { + "blockNumber": "30159", + "blockTimestamp": "2023-07-19 15:39:21.798 +0000 UTC", + "hash": self._transaction_hash, + "data": "Ei4KLC9jb3Ntd2FzbS53YXNtLnYxLk1zZ0V4ZWN1dGVDb250cmFjdFJlc3BvbnNl", # noqa: mock" + "gasWanted": "163571", + "gasUsed": "162984", + "gasFee": { + "amount": [ + { + "denom": "inj", + "amount": "81785500000000"}], "gasLimit": "163571", + "payer": "inj15uad884tqeq9r76x3fvktmjge2r6kek55c2zpa" # noqa: mock" + }, + "txType": "injective", + "messages": base64.b64encode(json.dumps(messages).encode()).decode(), + "signatures": [ + { + "pubkey": "0382e03bf4b0ad77bef5f756a717a1a54d3c444b250b4ce097acb578aa80f58aab", # noqa: mock" + "address": "inj15uad884tqeq9r76x3fvktmjge2r6kek55c2zpa", # noqa: mock" + "sequence": "2", + "signature": "mF+KepSndvbu5UznsqfSl3rS9HkQQkDIcwBM3UIEzlF/SORCoI2fLue5okALWX5ZzfZXmwJGdjLqfjHDcJ3uEg==" # noqa: mock" + } + ], + "txNumber": "5", + "blockUnixTimestamp": "1689781161798", + "logs": base64.b64encode(json.dumps(logs).encode()).decode(), + } + } + + return transaction_response + + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Dict[str, Any]: + return {"txhash": "79DBF373DE9C534EE2DC9D009F32B850DA8D0C73833FAA0FD52C6AE8989EC659", "rawLog": "[]"} # noqa: mock + + def _order_cancelation_request_erroneous_mock_response(self, order: InFlightOrder) -> Dict[str, Any]: + return {"txhash": "79DBF373DE9C534EE2DC9D009F32B850DA8D0C73833FAA0FD52C6AE8989EC659", "rawLog": "Error"} # noqa: mock + + def _order_status_request_partially_filled_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount * Decimal(f"1e{self.base_decimals}")), + "filledQuantity": str(self.expected_partial_fill_amount), + "state": "partial_filled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_fills_request_partial_fill_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "trades": [ + { + "orderHash": order.exchange_order_id, + "subaccountId": self.vault_contract_subaccount_id, + "marketId": self.market_id, + "tradeExecutionType": "limitFill", + "positionDelta": { + "tradeDirection": order.trade_type.name.lower, + "executionPrice": str(self.expected_partial_fill_price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": str(self.expected_partial_fill_amount), + "executionMargin": "1245280000" + }, + "payout": "1187984833.579447998034818126", + "fee": str(self.expected_fill_fee.flat_fees[0].amount * Decimal(f"1e{self.quote_decimals}")), + "executedAt": "1681735786785", + "feeRecipient": self.vault_contract_address, + "tradeId": self.expected_fill_trade_id, + "executionSide": "maker" + }, + ], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + + def _order_status_request_canceled_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "canceled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_status_request_completely_filled_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": str(order.amount), + "state": "filled", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_fills_request_full_fill_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "trades": [ + { + "orderHash": order.exchange_order_id, + "subaccountId": self.vault_contract_subaccount_id, + "marketId": self.market_id, + "tradeExecutionType": "limitFill", + "positionDelta": { + "tradeDirection": order.trade_type.name.lower, + "executionPrice": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "executionQuantity": str(order.amount), + "executionMargin": "1245280000" + }, + "payout": "1187984833.579447998034818126", + "fee": str(self.expected_fill_fee.flat_fees[0].amount * Decimal(f"1e{self.quote_decimals}")), + "executedAt": "1681735786785", + "feeRecipient": self.vault_contract_address, + "tradeId": self.expected_fill_trade_id, + "executionSide": "maker" + }, + ], + "paging": { + "total": "1", + "from": 1, + "to": 1 + } + } + + def _order_status_request_open_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [ + { + "orderHash": order.exchange_order_id, + "marketId": self.market_id, + "subaccountId": self.vault_contract_subaccount_id, + "executionType": "market" if order.order_type == OrderType.MARKET else "limit", + "orderType": order.trade_type.name.lower(), + "price": str(order.price * Decimal(f"1e{self.quote_decimals}")), + "triggerPrice": "0", + "quantity": str(order.amount), + "filledQuantity": "0", + "state": "booked", + "createdAt": "1688476825015", + "updatedAt": "1688476825015", + "isReduceOnly": True, + "direction": order.trade_type.name.lower(), + "margin": "7219676852.725", + "txHash": order.creation_transaction_hash, + }, + ], + "paging": { + "total": "1" + }, + } + + def _order_status_request_not_found_mock_response(self, order: GatewayPerpetualInFlightOrder) -> Dict[str, Any]: + return { + "orders": [], + "paging": { + "total": "0" + }, + } diff --git a/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_order_book_data_source.py b/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_order_book_data_source.py new file mode 100644 index 0000000000..02f932c01c --- /dev/null +++ b/test/hummingbot/connector/derivative/injective_v2_perpetual/test_injective_v2_perpetual_order_book_data_source.py @@ -0,0 +1,715 @@ +import asyncio +import re +from decimal import Decimal +from test.hummingbot.connector.exchange.injective_v2.programmable_query_executor import ProgrammableQueryExecutor +from typing import Awaitable, Optional, Union +from unittest import TestCase +from unittest.mock import AsyncMock, MagicMock, patch + +from bidict import bidict +from pyinjective import Address, PrivateKey + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_api_order_book_data_source import ( + InjectiveV2PerpetualAPIOrderBookDataSource, +) +from hummingbot.connector.derivative.injective_v2_perpetual.injective_v2_perpetual_derivative import ( + InjectiveV2PerpetualDerivative, +) +from hummingbot.connector.exchange.injective_v2.injective_v2_utils import ( + InjectiveConfigMap, + InjectiveDelegatedAccountMode, + InjectiveTestnetNetworkMode, +) +from hummingbot.core.data_type.common import TradeType +from hummingbot.core.data_type.funding_info import FundingInfo, FundingInfoUpdate +from hummingbot.core.data_type.order_book_message import OrderBookMessage, OrderBookMessageType + + +class InjectiveV2APIOrderBookDataSourceTests(TestCase): + # the level is required to receive logs from the data source logger + level = 0 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "INJ" + cls.quote_asset = "USDT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}/{cls.quote_asset}" + cls.market_id = "0x17ef48032cb24375ba7c2e39f384e56433bcab20cbee9a7357e4cba2eb00abe6" # noqa: mock + + @patch("hummingbot.core.utils.trading_pair_fetcher.TradingPairFetcher.fetch_all") + def setUp(self, _) -> None: + super().setUp() + self._original_async_loop = asyncio.get_event_loop() + self.async_loop = asyncio.new_event_loop() + self.async_tasks = [] + asyncio.set_event_loop(self.async_loop) + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + + _, grantee_private_key = PrivateKey.generate() + _, granter_private_key = PrivateKey.generate() + + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") + + account_config = InjectiveDelegatedAccountMode( + private_key=grantee_private_key.to_hex(), + subaccount_index=0, + granter_address=Address(bytes.fromhex(granter_private_key.to_public_key().to_hex())).to_acc_bech32(), + granter_subaccount_index=0, + ) + + injective_config = InjectiveConfigMap( + network=network_config, + account_type=account_config, + ) + + self.connector = InjectiveV2PerpetualDerivative( + client_config_map=client_config_map, + connector_configuration=injective_config, + trading_pairs=[self.trading_pair], + ) + self.data_source = InjectiveV2PerpetualAPIOrderBookDataSource( + trading_pairs=[self.trading_pair], + connector=self.connector, + data_source=self.connector._data_source, + ) + + self.initialize_trading_account_patch = patch( + "hummingbot.connector.exchange.injective_v2.data_sources.injective_grantee_data_source" + ".InjectiveGranteeDataSource.initialize_trading_account" + ) + self.initialize_trading_account_patch.start() + + self.query_executor = ProgrammableQueryExecutor() + self.connector._data_source._query_executor = self.query_executor + + self.log_records = [] + self._logs_event: Optional[asyncio.Event] = None + self.data_source.logger().setLevel(1) + self.data_source.logger().addHandler(self) + self.data_source._data_source.logger().setLevel(1) + self.data_source._data_source.logger().addHandler(self) + + self.connector._set_trading_pair_symbol_map(bidict({self.market_id: self.trading_pair})) + + def tearDown(self) -> None: + self.async_run_with_timeout(self.data_source._data_source.stop()) + self.initialize_trading_account_patch.stop() + for task in self.async_tasks: + task.cancel() + self.async_loop.stop() + # self.async_loop.close() + # Since the event loop will change we need to remove the logs event created in the old event loop + self._logs_event = None + asyncio.set_event_loop(self._original_async_loop) + super().tearDown() + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = self.async_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def create_task(self, coroutine: Awaitable) -> asyncio.Task: + task = self.async_loop.create_task(coroutine) + self.async_tasks.append(task) + return task + + def handle(self, record): + self.log_records.append(record) + if self._logs_event is not None: + self._logs_event.set() + + def is_logged(self, log_level: str, message: Union[str, re.Pattern]) -> bool: + expression = ( + re.compile( + f"^{message}$" + .replace(".", r"\.") + .replace("?", r"\?") + .replace("/", r"\/") + .replace("(", r"\(") + .replace(")", r"\)") + .replace("[", r"\[") + .replace("]", r"\]") + ) + if isinstance(message, str) + else message + ) + return any( + record.levelname == log_level and expression.match(record.getMessage()) is not None + for record in self.log_records + ) + + def test_get_new_order_book_successful(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + quote_decimals = derivative_markets_response[0]["quoteTokenMeta"]["decimals"] + + order_book_snapshot = { + "buys": [(Decimal("9487") * Decimal(f"1e{quote_decimals}"), + Decimal("336241"), + 1640001112223)], + "sells": [(Decimal("9487.5") * Decimal(f"1e{quote_decimals}"), + Decimal("522147"), + 1640001112224)], + "sequence": 512, + "timestamp": 1650001112223, + } + + self.query_executor._derivative_order_book_responses.put_nowait(order_book_snapshot) + + order_book = self.async_run_with_timeout(self.data_source.get_new_order_book(self.trading_pair)) + + expected_update_id = order_book_snapshot["sequence"] + + self.assertEqual(expected_update_id, order_book.snapshot_uid) + bids = list(order_book.bid_entries()) + asks = list(order_book.ask_entries()) + self.assertEqual(1, len(bids)) + self.assertEqual(9487, bids[0].price) + self.assertEqual(336241, bids[0].amount) + self.assertEqual(expected_update_id, bids[0].update_id) + self.assertEqual(1, len(asks)) + self.assertEqual(9487.5, asks[0].price) + self.assertEqual(522147, asks[0].amount) + self.assertEqual(expected_update_id, asks[0].update_id) + + def test_listen_for_trades_cancelled_when_listening(self): + mock_queue = MagicMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[self.data_source._trade_messages_queue_key] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.async_run_with_timeout(self.data_source.listen_for_trades(self.async_loop, msg_queue)) + + def test_listen_for_trades_logs_exception(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + self.query_executor._public_derivative_trade_updates.put_nowait({}) + trade_data = { + "orderHash": "0x86a2f3c8aba313569ae1c985e1ec155a77434c0c8d2b1feb629ebdf9d0b2515b", # noqa: mock + "subaccountId": "0x85123cdf535f83345417918d3a78e6a5ca07b9f0000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": "8205874.039333444390458155", + "executionQuantity": "4942.2013", + "executionMargin": "0" + }, + "payout": "20495725066.893133760410882059", + "fee": "36499573.210347000000000001", + "executedAt": "1689008963214", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13492005_801_0", + "executionSide": "taker" + } + self.query_executor._public_derivative_trade_updates.put_nowait(trade_data) + + self.async_run_with_timeout(self.data_source.listen_for_subscriptions()) + + msg_queue = asyncio.Queue() + self.create_task(self.data_source.listen_for_trades(self.async_loop, msg_queue)) + self.async_run_with_timeout(msg_queue.get()) + + self.assertTrue( + self.is_logged( + "WARNING", re.compile(r"^Invalid public derivative trade event format \(.*") + ) + ) + + def test_listen_for_trades_successful(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + quote_decimals = derivative_markets_response[0]["quoteTokenMeta"]["decimals"] + + trade_data = { + "orderHash": "0x86a2f3c8aba313569ae1c985e1ec155a77434c0c8d2b1feb629ebdf9d0b2515b", # noqa: mock + "subaccountId": "0x85123cdf535f83345417918d3a78e6a5ca07b9f0000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "sell", + "executionPrice": "8205874.039333444390458155", + "executionQuantity": "4942.2013", + "executionMargin": "0" + }, + "payout": "20495725066.893133760410882059", + "fee": "36499573.210347000000000001", + "executedAt": "1689008963214", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13492005_801_0", + "executionSide": "taker" + } + self.query_executor._public_derivative_trade_updates.put_nowait(trade_data) + + self.async_run_with_timeout(self.data_source.listen_for_subscriptions()) + + msg_queue = asyncio.Queue() + self.create_task(self.data_source.listen_for_trades(self.async_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(OrderBookMessageType.TRADE, msg.type) + self.assertEqual(trade_data["tradeId"], msg.trade_id) + self.assertEqual(int(trade_data["executedAt"]) * 1e-3, msg.timestamp) + expected_price = Decimal(trade_data["positionDelta"]["executionPrice"]) * Decimal(f"1e{-quote_decimals}") + expected_amount = Decimal(trade_data["positionDelta"]["executionQuantity"]) + self.assertEqual(expected_amount, msg.content["amount"]) + self.assertEqual(expected_price, msg.content["price"]) + self.assertEqual(self.trading_pair, msg.content["trading_pair"]) + self.assertEqual(float(TradeType.SELL.value), msg.content["trade_type"]) + + def test_listen_for_order_book_diffs_cancelled(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[self.data_source._diff_messages_queue_key] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.async_run_with_timeout(self.data_source.listen_for_order_book_diffs(self.async_loop, msg_queue)) + + def test_listen_for_order_book_diffs_logs_exception(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + self.query_executor._derivative_order_book_updates.put_nowait({}) + order_book_data = { + "marketId": self.market_id, + "sequence": "7734169", + "buys": [ + { + "price": "0.000000000007684", + "quantity": "4578787000000000000000", + "isActive": True, + "timestamp": "1687889315683" + }, + { + "price": "0.000000000007685", + "quantity": "4412340000000000000000", + "isActive": True, + "timestamp": "1687889316000" + } + ], + "sells": [ + { + "price": "0.000000000007723", + "quantity": "3478787000000000000000", + "isActive": True, + "timestamp": "1687889315683" + } + ], + "updatedAt": "1687889315683", + } + self.query_executor._derivative_order_book_updates.put_nowait(order_book_data) + + self.async_run_with_timeout(self.data_source.listen_for_subscriptions(), timeout=5) + + msg_queue: asyncio.Queue = asyncio.Queue() + self.create_task(self.data_source.listen_for_order_book_diffs(self.async_loop, msg_queue)) + + self.async_run_with_timeout(msg_queue.get()) + + self.assertTrue( + self.is_logged( + "WARNING", re.compile(r"^Invalid derivative order book event format \(.*") + ) + ) + + @patch( + "hummingbot.connector.exchange.injective_v2.data_sources.injective_grantee_data_source.InjectiveGranteeDataSource._initialize_timeout_height") + def test_listen_for_order_book_diffs_successful(self, _): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + quote_decimals = derivative_markets_response[0]["quoteTokenMeta"]["decimals"] + + order_book_data = { + "marketId": self.market_id, + "sequence": "7734169", + "buys": [ + { + "price": "0.000000000007684", + "quantity": "4578787000000000000000", + "isActive": True, + "timestamp": "1687889315683" + }, + { + "price": "0.000000000007685", + "quantity": "4412340000000000000000", + "isActive": True, + "timestamp": "1687889316000" + } + ], + "sells": [ + { + "price": "0.000000000007723", + "quantity": "3478787000000000000000", + "isActive": True, + "timestamp": "1687889315683" + } + ], + "updatedAt": "1687889315683", + } + self.query_executor._derivative_order_book_updates.put_nowait(order_book_data) + + self.async_run_with_timeout(self.data_source.listen_for_subscriptions()) + + msg_queue: asyncio.Queue = asyncio.Queue() + self.create_task(self.data_source.listen_for_order_book_diffs(self.async_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(OrderBookMessageType.DIFF, msg.type) + self.assertEqual(-1, msg.trade_id) + self.assertEqual(int(order_book_data["updatedAt"]) * 1e-3, msg.timestamp) + expected_update_id = int(order_book_data["sequence"]) + self.assertEqual(expected_update_id, msg.update_id) + + bids = msg.bids + asks = msg.asks + self.assertEqual(2, len(bids)) + first_bid_price = Decimal(order_book_data["buys"][0]["price"]) * Decimal(f"1e{-quote_decimals}") + first_bid_quantity = Decimal(order_book_data["buys"][0]["quantity"]) + self.assertEqual(float(first_bid_price), bids[0].price) + self.assertEqual(float(first_bid_quantity), bids[0].amount) + self.assertEqual(expected_update_id, bids[0].update_id) + self.assertEqual(1, len(asks)) + first_ask_price = Decimal(order_book_data["sells"][0]["price"]) * Decimal(f"1e{-quote_decimals}") + first_ask_quantity = Decimal(order_book_data["sells"][0]["quantity"]) + self.assertEqual(float(first_ask_price), asks[0].price) + self.assertEqual(float(first_ask_quantity), asks[0].amount) + self.assertEqual(expected_update_id, asks[0].update_id) + + def test_listen_for_funding_info_cancelled_when_listening(self): + mock_queue = MagicMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[self.data_source._funding_info_messages_queue_key] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.async_run_with_timeout(self.data_source.listen_for_funding_info(msg_queue)) + + def test_listen_for_funding_info_logs_exception(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + funding_rate = { + "fundingRates": [], + "paging": { + "total": "2370" + } + } + self.query_executor._funding_rates_responses.put_nowait(funding_rate) + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": "0.000004", + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": "29423.16356086" + } + self.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": "9084900", + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.query_executor._derivative_trades_responses.put_nowait(trades) + + self.query_executor._derivative_market_responses.put_nowait(derivative_markets_response[0]) + + oracle_price_event = { + "price": "29430.23874999", + "timestamp": "1690467421160" + } + self.query_executor._oracle_prices_updates.put_nowait(oracle_price_event) + self.query_executor._oracle_prices_updates.put_nowait(oracle_price_event) + + self.async_run_with_timeout(self.data_source.listen_for_subscriptions(), timeout=5) + + msg_queue: asyncio.Queue = asyncio.Queue() + self.create_task(self.data_source.listen_for_funding_info(msg_queue)) + + self.async_run_with_timeout(msg_queue.get()) + + self.assertTrue( + self.is_logged( + "WARNING", re.compile(r"^Invalid funding info event format \(.*") + ) + ) + + def test_listen_for_funding_info_successful(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + quote_decimals = derivative_markets_response[0]["quoteTokenMeta"]["decimals"] + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": "0.000004", + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": "29423.16356086" + } + self.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": "9084900", + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.query_executor._derivative_trades_responses.put_nowait(trades) + + self.query_executor._derivative_market_responses.put_nowait(derivative_markets_response[0]) + + oracle_price_event = { + "price": "29430.23874999", + "timestamp": "1690467421160" + } + self.query_executor._oracle_prices_updates.put_nowait(oracle_price_event) + + self.async_run_with_timeout(self.data_source.listen_for_subscriptions()) + + msg_queue: asyncio.Queue = asyncio.Queue() + self.create_task(self.data_source.listen_for_funding_info(msg_queue)) + + funding_info: FundingInfoUpdate = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(self.trading_pair, funding_info.trading_pair) + self.assertEqual( + Decimal(trades["trades"][0]["positionDelta"]["executionPrice"]) * Decimal(f"1e{-quote_decimals}"), + funding_info.index_price) + self.assertEqual(Decimal(oracle_price["price"]), funding_info.mark_price) + self.assertEqual( + int(derivative_markets_response[0]["perpetualMarketInfo"]["nextFundingTimestamp"]), + funding_info.next_funding_utc_timestamp) + self.assertEqual(Decimal(funding_rate["fundingRates"][0]["rate"]), funding_info.rate) + + def test_get_funding_info(self): + spot_markets_response = self._spot_markets_response() + self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + derivative_markets_response = self._derivative_markets_response() + self.query_executor._derivative_markets_responses.put_nowait(derivative_markets_response) + + quote_decimals = derivative_markets_response[0]["quoteTokenMeta"]["decimals"] + + funding_rate = { + "fundingRates": [ + { + "marketId": self.market_id, + "rate": "0.000004", + "timestamp": "1690426800493" + }, + ], + "paging": { + "total": "2370" + } + } + self.query_executor._funding_rates_responses.put_nowait(funding_rate) + + oracle_price = { + "price": "29423.16356086" + } + self.query_executor._oracle_prices_responses.put_nowait(oracle_price) + + trades = { + "trades": [ + { + "orderHash": "0xbe1db35669028d9c7f45c23d31336c20003e4f8879721bcff35fc6f984a6481a", # noqa: mock + "subaccountId": "0x16aef18dbaa341952f1af1795cb49960f68dfee3000000000000000000000000", # noqa: mock + "marketId": self.market_id, + "tradeExecutionType": "market", + "positionDelta": { + "tradeDirection": "buy", + "executionPrice": "9084900", + "executionQuantity": "3", + "executionMargin": "5472660" + }, + "payout": "0", + "fee": "81764.1", + "executedAt": "1689423842613", + "feeRecipient": "inj1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3t5qxqh", + "tradeId": "13659264_800_0", + "executionSide": "taker" + } + ], + "paging": { + "total": "1000", + "from": 1, + "to": 1 + } + } + self.query_executor._derivative_trades_responses.put_nowait(trades) + + self.query_executor._derivative_market_responses.put_nowait(derivative_markets_response[0]) + + funding_info: FundingInfo = self.async_run_with_timeout( + self.data_source.get_funding_info(self.trading_pair) + ) + + self.assertEqual(self.trading_pair, funding_info.trading_pair) + self.assertEqual( + Decimal(trades["trades"][0]["positionDelta"]["executionPrice"]) * Decimal(f"1e{-quote_decimals}"), + funding_info.index_price) + self.assertEqual(Decimal(oracle_price["price"]), funding_info.mark_price) + self.assertEqual( + int(derivative_markets_response[0]["perpetualMarketInfo"]["nextFundingTimestamp"]), + funding_info.next_funding_utc_timestamp) + self.assertEqual(Decimal(funding_rate["fundingRates"][0]["rate"]), funding_info.rate) + + def _spot_markets_response(self): + return [{ + "marketId": "0x0611780ba69656949525013d947713300f56c37b6175e02f26bffa495c3208fe", # noqa: mock + "marketStatus": "active", + "ticker": self.ex_trading_pair, + "baseDenom": "inj", + "baseTokenMeta": { + "name": "Base Asset", + "address": "0xe28b3B32B6c345A34Ff64674606124Dd5Aceca30", # noqa: mock + "symbol": self.base_asset, + "logo": "https://static.alchemyapi.io/images/assets/7226.png", + "decimals": 18, + "updatedAt": "1687190809715" + }, + "quoteDenom": "peggy0x87aB3B4C8661e07D6372361211B96ed4Dc36B1B5", # noqa: mock + "quoteTokenMeta": { + "name": "Quote Asset", + "address": "0x0000000000000000000000000000000000000000", + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": 6, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0001", + "takerFeeRate": "0.001", + "serviceProviderFee": "0.4", + "minPriceTickSize": "0.000000000000001", + "minQuantityTickSize": "1000000000000000" + }] + + def _derivative_markets_response(self): + return [{ + "marketId": self.market_id, + "marketStatus": "active", + "ticker": f"{self.ex_trading_pair} PERP", + "oracleBase": "0x2d9315a88f3019f8efa88dfe9c0f0843712da0bac814461e27733f6b83eb51b3", # noqa: mock + "oracleQuote": "0x1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588", # noqa: mock + "oracleType": "pyth", + "oracleScaleFactor": 6, + "initialMarginRatio": "0.195", + "maintenanceMarginRatio": "0.05", + "quoteDenom": "peggy0x87aB3B4C8661e07D6372361211B96ed4Dc36B1B5", # noqa: mock + "quoteTokenMeta": { + "name": "Quote Asset", + "address": "0x0000000000000000000000000000000000000000", + "symbol": self.quote_asset, + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": 6, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0003", + "takerFeeRate": "0.003", + "serviceProviderFee": "0.4", + "isPerpetual": True, + "minPriceTickSize": "100", + "minQuantityTickSize": "0.0001", + "perpetualMarketInfo": { + "hourlyFundingRateCap": "0.000625", + "hourlyInterestRate": "0.00000416666", + "nextFundingTimestamp": "1690318800", + "fundingInterval": "3600" + }, + "perpetualMarketFunding": { + "cumulativeFunding": "81363.592243119007273334", + "cumulativePrice": "1.432536051546776736", + "lastTimestamp": "1689423842" + } + }] diff --git a/test/hummingbot/connector/exchange/gate_io/test_gate_io_exchange.py b/test/hummingbot/connector/exchange/gate_io/test_gate_io_exchange.py index 75ebca6bdf..507cb38bcf 100644 --- a/test/hummingbot/connector/exchange/gate_io/test_gate_io_exchange.py +++ b/test/hummingbot/connector/exchange/gate_io/test_gate_io_exchange.py @@ -19,6 +19,8 @@ from hummingbot.core.data_type.cancellation_result import CancellationResult from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_row import OrderBookRow from hummingbot.core.data_type.trade_fee import TokenAmount from hummingbot.core.event.event_logger import EventLogger from hummingbot.core.event.events import ( @@ -573,10 +575,10 @@ def test_create_market_order(self, mock_api, get_price_mock): @aioresponses() @patch("hummingbot.connector.exchange.gate_io.gate_io_exchange.GateIoExchange.get_price") - @patch("hummingbot.connector.exchange.gate_io.gate_io_exchange.GateIoExchange.get_price_by_type") - def test_create_market_order_price_is_nan(self, mock_api, get_price_mock, get_price_by_type_mock): + @patch("hummingbot.connector.exchange.gate_io.gate_io_exchange.GateIoExchange.get_price_for_volume") + def test_create_market_order_price_is_nan(self, mock_api, get_price_mock, get_price_for_volume_mock): get_price_mock.return_value = None - get_price_by_type_mock.return_value = Decimal("5.1") + get_price_for_volume_mock.return_value = Decimal("5.1") self._simulate_trading_rules_initialized() self.exchange._set_current_timestamp(1640780000) url = f"{CONSTANTS.REST_URL}/{CONSTANTS.ORDER_CREATE_PATH_URL}" @@ -616,6 +618,41 @@ def test_create_market_order_price_is_nan(self, mock_api, get_price_mock, get_pr self.assertEqual(order_id, create_event.order_id) self.assertEqual(resp["id"], create_event.exchange_order_id) + @aioresponses() + @patch("hummingbot.connector.exchange.gate_io.gate_io_exchange.GateIoExchange.get_price") + # @patch("hummingbot.connector.exchange.gate_io.gate_io_exchange.GateIoExchange.get_price_for_volume") + def test_place_order_price_is_nan(self, mock_api, get_price_mock): + get_price_mock.return_value = None + # get_price_for_volume_mock.return_value = Decimal("5.1") + self._simulate_trading_rules_initialized() + self.exchange._set_current_timestamp(1640780000) + url = f"{CONSTANTS.REST_URL}/{CONSTANTS.ORDER_CREATE_PATH_URL}" + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + resp = self.get_order_create_response_mock() + mock_api.post(regex_url, body=json.dumps(resp), status=201) + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[], + asks=[OrderBookRow(price=5.1, amount=20, update_id=1)], + update_id=1, + ) + order_id = "someId" + self.async_run_with_timeout( + coroutine=self.exchange._place_order( + trade_type=TradeType.BUY, + order_id=order_id, + trading_pair=self.trading_pair, + amount=Decimal("1"), + order_type=OrderType.MARKET, + price=Decimal("nan"), + ) + ) + order_request = next(((key, value) for key, value in mock_api.requests.items() + if key[1].human_repr().startswith(url))) + request_data = json.loads(order_request[1][0].kwargs["data"]) + self.assertEqual(Decimal("1") * Decimal("5.1"), Decimal(request_data["amount"])) + @aioresponses() def test_create_order_when_order_is_instantly_closed(self, mock_api): self._simulate_trading_rules_initialized() diff --git a/test/hummingbot/connector/exchange/injective_v2/data_sources/test_injective_data_source.py b/test/hummingbot/connector/exchange/injective_v2/data_sources/test_injective_data_source.py index 186f15b7ea..b2f39b1f28 100644 --- a/test/hummingbot/connector/exchange/injective_v2/data_sources/test_injective_data_source.py +++ b/test/hummingbot/connector/exchange/injective_v2/data_sources/test_injective_data_source.py @@ -10,6 +10,7 @@ from pyinjective.constant import Network from pyinjective.wallet import Address, PrivateKey +from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS from hummingbot.connector.exchange.injective_v2.data_sources.injective_grantee_data_source import ( InjectiveGranteeDataSource, ) @@ -41,7 +42,8 @@ def setUp(self, _) -> None: subaccount_index=0, granter_address=Address(bytes.fromhex(granter_private_key.to_public_key().to_hex())).to_acc_bech32(), granter_subaccount_index=0, - network=Network.testnet(), + network=Network.testnet(node="sentry"), + rate_limits=CONSTANTS.PUBLIC_NODE_RATE_LIMITS, ) self.query_executor = ProgrammableQueryExecutor() @@ -100,10 +102,11 @@ def is_logged(self, log_level: str, message: Union[str, re.Pattern]) -> bool: def test_market_and_tokens_construction(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) market_info = self._inj_usdt_market_info() inj_usdt_market: InjectiveSpotMarket = self.async_run_with_timeout( - self.data_source.market_info_for_id(market_info["marketId"]) + self.data_source.spot_market_info_for_id(market_info["marketId"]) ) inj_token = inj_usdt_market.base_token usdt_token = inj_usdt_market.quote_token @@ -124,7 +127,7 @@ def test_market_and_tokens_construction(self): market_info = self._usdc_solana_usdc_eth_market_info() usdc_solana_usdc_eth_market: InjectiveSpotMarket = self.async_run_with_timeout( - self.data_source.market_info_for_id(market_info["marketId"]) + self.data_source.spot_market_info_for_id(market_info["marketId"]) ) usdc_solana_token = usdc_solana_usdc_eth_market.base_token usdc_eth_token = usdc_solana_usdc_eth_market.quote_token @@ -146,6 +149,7 @@ def test_market_and_tokens_construction(self): def test_markets_initialization_generates_unique_trading_pairs_for_tokens_with_same_symbol(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) inj_usdt_trading_pair = self.async_run_with_timeout( self.data_source.trading_pair_for_market(market_id=self._inj_usdt_market_info()["marketId"]) @@ -167,6 +171,7 @@ def test_markets_initialization_generates_unique_trading_pairs_for_tokens_with_s def test_markets_initialization_adds_different_tokens_having_same_symbol(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) self.async_run_with_timeout(self.data_source.update_markets()) @@ -207,18 +212,19 @@ def test_markets_initialization_adds_different_tokens_having_same_symbol(self): def test_markets_initialization_creates_one_instance_per_token(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) inj_usdt_market: InjectiveSpotMarket = self.async_run_with_timeout( - self.data_source.market_info_for_id(self._inj_usdt_market_info()["marketId"]) + self.data_source.spot_market_info_for_id(self._inj_usdt_market_info()["marketId"]) ) usdt_usdc_market: InjectiveSpotMarket = self.async_run_with_timeout( - self.data_source.market_info_for_id(self._usdt_usdc_market_info()["marketId"]) + self.data_source.spot_market_info_for_id(self._usdt_usdc_market_info()["marketId"]) ) usdt_usdc_eth_market: InjectiveSpotMarket = self.async_run_with_timeout( - self.data_source.market_info_for_id(self._usdt_usdc_eth_market_info()["marketId"]) + self.data_source.spot_market_info_for_id(self._usdt_usdc_eth_market_info()["marketId"]) ) usdc_solana_usdc_eth_market: InjectiveSpotMarket = self.async_run_with_timeout( - self.data_source.market_info_for_id(self._usdc_solana_usdc_eth_market_info()["marketId"]) + self.data_source.spot_market_info_for_id(self._usdc_solana_usdc_eth_market_info()["marketId"]) ) self.assertEqual(inj_usdt_market.quote_token, usdt_usdc_market.base_token) @@ -377,8 +383,9 @@ def setUp(self, _) -> None: subaccount_index=0, vault_contract_address=self._vault_address, vault_subaccount_index=1, - network=Network.testnet(), + network=Network.testnet(node="sentry"), use_secure_connection=True, + rate_limits=CONSTANTS.PUBLIC_NODE_RATE_LIMITS, ) self.query_executor = ProgrammableQueryExecutor() @@ -407,6 +414,7 @@ def create_task(self, coroutine: Awaitable) -> asyncio.Task: def test_order_creation_message_generation(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) orders = [] order = GatewayInFlightOrder( @@ -420,21 +428,24 @@ def test_order_creation_message_generation(self): ) orders.append(order) - message, order_hashes = self.async_run_with_timeout( - self.data_source._order_creation_message(spot_orders_to_create=orders) + messages, spot_order_hashes, derivative_order_hashes = self.async_run_with_timeout( + self.data_source._order_creation_messages( + spot_orders_to_create=orders, + derivative_orders_to_create=[], + ) ) pub_key = self._grantee_private_key.to_public_key() address = pub_key.to_address() - self.assertEqual(0, len(order_hashes)) - self.assertEqual(address.to_acc_bech32(), message.sender) - self.assertEqual(self._vault_address, message.contract) + self.assertEqual(0, len(spot_order_hashes)) + self.assertEqual(address.to_acc_bech32(), messages[0].sender) + self.assertEqual(self._vault_address, messages[0].contract) market = self._inj_usdt_market_info() base_token_decimals = market["baseTokenMeta"]["decimals"] quote_token_meta = market["quoteTokenMeta"]["decimals"] - message_data = json.loads(message.msg.decode()) + message_data = json.loads(messages[0].msg.decode()) message_price = (order.price * Decimal(f"1e{quote_token_meta-base_token_decimals}")).normalize() message_quantity = (order.amount * Decimal(f"1e{base_token_decimals}")).normalize() @@ -480,6 +491,7 @@ def test_order_creation_message_generation(self): def test_order_cancel_message_generation(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) market = self._inj_usdt_market_info() orders_data = [] @@ -492,7 +504,10 @@ def test_order_cancel_message_generation(self): ) orders_data.append(order_data) - message = self.data_source._order_cancel_message(spot_orders_to_cancel=orders_data) + message = self.data_source._order_cancel_message( + spot_orders_to_cancel=orders_data, + derivative_orders_to_cancel=[], + ) pub_key = self._grantee_private_key.to_public_key() address = pub_key.to_address() diff --git a/test/hummingbot/connector/exchange/injective_v2/programmable_query_executor.py b/test/hummingbot/connector/exchange/injective_v2/programmable_query_executor.py index 9553b5f083..0474ddf6dc 100644 --- a/test/hummingbot/connector/exchange/injective_v2/programmable_query_executor.py +++ b/test/hummingbot/connector/exchange/injective_v2/programmable_query_executor.py @@ -9,19 +9,33 @@ class ProgrammableQueryExecutor(BaseInjectiveQueryExecutor): def __init__(self): self._ping_responses = asyncio.Queue() self._spot_markets_responses = asyncio.Queue() + self._derivative_market_responses = asyncio.Queue() + self._derivative_markets_responses = asyncio.Queue() self._spot_order_book_responses = asyncio.Queue() + self._derivative_order_book_responses = asyncio.Queue() self._transaction_by_hash_responses = asyncio.Queue() self._account_portfolio_responses = asyncio.Queue() self._simulate_transaction_responses = asyncio.Queue() self._send_transaction_responses = asyncio.Queue() self._spot_trades_responses = asyncio.Queue() + self._derivative_trades_responses = asyncio.Queue() self._historical_spot_orders_responses = asyncio.Queue() + self._historical_derivative_orders_responses = asyncio.Queue() self._transaction_block_height_responses = asyncio.Queue() + self._funding_rates_responses = asyncio.Queue() + self._oracle_prices_responses = asyncio.Queue() + self._funding_payments_responses = asyncio.Queue() + self._derivative_positions_responses = asyncio.Queue() self._spot_order_book_updates = asyncio.Queue() self._public_spot_trade_updates = asyncio.Queue() + self._derivative_order_book_updates = asyncio.Queue() + self._public_derivative_trade_updates = asyncio.Queue() + self._oracle_prices_updates = asyncio.Queue() + self._subaccount_positions_events = asyncio.Queue() self._subaccount_balance_events = asyncio.Queue() self._historical_spot_order_events = asyncio.Queue() + self._historical_derivative_order_events = asyncio.Queue() self._transaction_events = asyncio.Queue() async def ping(self): @@ -32,10 +46,22 @@ async def spot_markets(self, status: str) -> Dict[str, Any]: response = await self._spot_markets_responses.get() return response + async def derivative_markets(self, status: str) -> Dict[str, Any]: + response = await self._derivative_markets_responses.get() + return response + + async def derivative_market(self, market_id: str) -> Dict[str, Any]: + response = await self._derivative_market_responses.get() + return response + async def get_spot_orderbook(self, market_id: str) -> Dict[str, Any]: response = await self._spot_order_book_responses.get() return response + async def get_derivative_orderbook(self, market_id: str) -> Dict[str, Any]: + response = await self._derivative_order_book_responses.get() + return response + async def get_tx_by_hash(self, tx_hash: str) -> Dict[str, Any]: response = await self._transaction_by_hash_responses.get() return response @@ -67,6 +93,17 @@ async def get_spot_trades( response = await self._spot_trades_responses.get() return response + async def get_derivative_trades( + self, + market_ids: List[str], + subaccount_id: Optional[str] = None, + start_time: Optional[int] = None, + skip: Optional[int] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + response = await self._derivative_trades_responses.get() + return response + async def get_historical_spot_orders( self, market_ids: List[str], @@ -77,6 +114,38 @@ async def get_historical_spot_orders( response = await self._historical_spot_orders_responses.get() return response + async def get_historical_derivative_orders( + self, + market_ids: List[str], + subaccount_id: str, + start_time: int, + skip: int, + ) -> Dict[str, Any]: + response = await self._historical_derivative_orders_responses.get() + return response + + async def get_funding_rates(self, market_id: str, limit: int) -> Dict[str, Any]: + response = await self._funding_rates_responses.get() + return response + + async def get_funding_payments(self, subaccount_id: str, market_id: str, limit: int) -> Dict[str, Any]: + response = await self._funding_payments_responses.get() + return response + + async def get_derivative_positions(self, subaccount_id: str, skip: int) -> Dict[str, Any]: + response = await self._derivative_positions_responses.get() + return response + + async def get_oracle_prices( + self, + base_symbol: str, + quote_symbol: str, + oracle_type: str, + oracle_scale_factor: int, + ) -> Dict[str, Any]: + response = await self._oracle_prices_responses.get() + return response + async def spot_order_book_updates_stream(self, market_ids: List[str]): while True: next_ob_update = await self._spot_order_book_updates.get() @@ -87,6 +156,26 @@ async def public_spot_trades_stream(self, market_ids: List[str]): next_trade = await self._public_spot_trade_updates.get() yield next_trade + async def derivative_order_book_updates_stream(self, market_ids: List[str]): + while True: + next_ob_update = await self._derivative_order_book_updates.get() + yield next_ob_update + + async def public_derivative_trades_stream(self, market_ids: List[str]): + while True: + next_trade = await self._public_derivative_trade_updates.get() + yield next_trade + + async def oracle_prices_stream(self, oracle_base: str, oracle_quote: str, oracle_type: str): + while True: + next_update = await self._oracle_prices_updates.get() + yield next_update + + async def subaccount_positions_stream(self, subaccount_id: str): + while True: + next_event = await self._subaccount_positions_events.get() + yield next_event + async def subaccount_balance_stream(self, subaccount_id: str): while True: next_event = await self._subaccount_balance_events.get() @@ -99,6 +188,13 @@ async def subaccount_historical_spot_orders_stream( next_event = await self._historical_spot_order_events.get() yield next_event + async def subaccount_historical_derivative_orders_stream( + self, market_id: str, subaccount_id: str + ): + while True: + next_event = await self._historical_derivative_order_events.get() + yield next_event + async def transactions_stream(self,): while True: next_event = await self._transaction_events.get() diff --git a/test/hummingbot/connector/exchange/injective_v2/test_injective_market.py b/test/hummingbot/connector/exchange/injective_v2/test_injective_market.py index de1afe5a36..41cb0e8801 100644 --- a/test/hummingbot/connector/exchange/injective_v2/test_injective_market.py +++ b/test/hummingbot/connector/exchange/injective_v2/test_injective_market.py @@ -1,7 +1,11 @@ from decimal import Decimal from unittest import TestCase -from hummingbot.connector.exchange.injective_v2.injective_market import InjectiveSpotMarket, InjectiveToken +from hummingbot.connector.exchange.injective_v2.injective_market import ( + InjectiveDerivativeMarket, + InjectiveSpotMarket, + InjectiveToken, +) class InjectiveSpotMarketTests(TestCase): @@ -90,6 +94,108 @@ def test_min_quantity_tick_size(self): self.assertEqual(expected_value, market.min_quantity_tick_size()) +class InjectiveDerivativeMarketTests(TestCase): + + def setUp(self) -> None: + super().setUp() + + self._usdt_token = InjectiveToken( + denom="peggy0x87aB3B4C8661e07D6372361211B96ed4Dc36B1B5", # noqa: mock + symbol="USDT", + unique_symbol="USDT", + name="Tether", + decimals=6, + ) + + self._inj_usdt_derivative_market = InjectiveDerivativeMarket( + market_id="0x17ef48032cb24375ba7c2e39f384e56433bcab20cbee9a7357e4cba2eb00abe6", # noqa: mock + quote_token=self._usdt_token, + market_info={ + "marketId": "0x17ef48032cb24375ba7c2e39f384e56433bcab20cbee9a7357e4cba2eb00abe6", # noqa: mock + "marketStatus": "active", + "ticker": "INJ/USDT PERP", + "oracleBase": "0x2d9315a88f3019f8efa88dfe9c0f0843712da0bac814461e27733f6b83eb51b3", # noqa: mock + "oracleQuote": "0x1fc18861232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588", # noqa: mock + "oracleType": "pyth", + "oracleScaleFactor": 6, + "initialMarginRatio": "0.195", + "maintenanceMarginRatio": "0.05", + "quoteDenom": "peggy0x87aB3B4C8661e07D6372361211B96ed4Dc36B1B5", + "quoteTokenMeta": { + "name": "Testnet Tether USDT", + "address": "0x0000000000000000000000000000000000000000", + "symbol": "USDT", + "logo": "https://static.alchemyapi.io/images/assets/825.png", + "decimals": 6, + "updatedAt": "1687190809716" + }, + "makerFeeRate": "-0.0003", + "takerFeeRate": "0.003", + "serviceProviderFee": "0.4", + "isPerpetual": True, + "minPriceTickSize": "100", + "minQuantityTickSize": "0.0001", + "perpetualMarketInfo": { + "hourlyFundingRateCap": "0.000625", + "hourlyInterestRate": "0.00000416666", + "nextFundingTimestamp": "1690318800", + "fundingInterval": "3600" + }, + "perpetualMarketFunding": { + "cumulativeFunding": "81363.592243119007273334", + "cumulativePrice": "1.432536051546776736", + "lastTimestamp": "1689423842" + } + } + ) + + def test_trading_pair(self): + self.assertEqual("INJ-USDT", self._inj_usdt_derivative_market.trading_pair()) + + def test_convert_quantity_from_chain_format(self): + expected_quantity = Decimal("1234") + chain_quantity = expected_quantity + converted_quantity = self._inj_usdt_derivative_market.quantity_from_chain_format(chain_quantity=chain_quantity) + + self.assertEqual(expected_quantity, converted_quantity) + + def test_convert_price_from_chain_format(self): + expected_price = Decimal("15.43") + chain_price = expected_price * Decimal(f"1e{self._usdt_token.decimals}") + converted_price = self._inj_usdt_derivative_market.price_from_chain_format(chain_price=chain_price) + + self.assertEqual(expected_price, converted_price) + + def test_min_price_tick_size(self): + market = self._inj_usdt_derivative_market + expected_value = market.price_from_chain_format(chain_price=Decimal(market.market_info["minPriceTickSize"])) + + self.assertEqual(expected_value, market.min_price_tick_size()) + + def test_min_quantity_tick_size(self): + market = self._inj_usdt_derivative_market + expected_value = market.quantity_from_chain_format( + chain_quantity=Decimal(market.market_info["minQuantityTickSize"]) + ) + + self.assertEqual(expected_value, market.min_quantity_tick_size()) + + def test_get_oracle_info(self): + market = self._inj_usdt_derivative_market + + self.assertEqual(market.market_info["oracleBase"], market.oracle_base()) + self.assertEqual(market.market_info["oracleQuote"], market.oracle_quote()) + self.assertEqual(market.market_info["oracleType"], market.oracle_type()) + + def test_next_funding_timestamp(self): + market = self._inj_usdt_derivative_market + + self.assertEqual( + int(market.market_info["perpetualMarketInfo"]["nextFundingTimestamp"]), + market.next_funding_timestamp() + ) + + class InjectiveTokenTests(TestCase): def test_convert_value_from_chain_format(self): diff --git a/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_api_order_book_data_source.py b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_api_order_book_data_source.py index 4e6bdb50ff..869648fe42 100644 --- a/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_api_order_book_data_source.py +++ b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_api_order_book_data_source.py @@ -50,7 +50,7 @@ def setUp(self, _) -> None: _, grantee_private_key = PrivateKey.generate() _, granter_private_key = PrivateKey.generate() - network_config = InjectiveTestnetNetworkMode() + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") account_config = InjectiveDelegatedAccountMode( private_key=grantee_private_key.to_hex(), @@ -142,6 +142,7 @@ def is_logged(self, log_level: str, message: Union[str, re.Pattern]) -> bool: def test_get_new_order_book_successful(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) base_decimals = spot_markets_response[0]["baseTokenMeta"]["decimals"] quote_decimals = spot_markets_response[0]["quoteTokenMeta"]["decimals"] @@ -187,6 +188,7 @@ def test_listen_for_trades_cancelled_when_listening(self): def test_listen_for_trades_logs_exception(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) self.query_executor._public_spot_trade_updates.put_nowait({}) trade_data = { @@ -216,13 +218,14 @@ def test_listen_for_trades_logs_exception(self): self.assertTrue( self.is_logged( - "WARNING", re.compile(r"^Invalid public trade event format \(.*") + "WARNING", re.compile(r"^Invalid public spot trade event format \(.*") ) ) def test_listen_for_trades_successful(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) base_decimals = spot_markets_response[0]["baseTokenMeta"]["decimals"] quote_decimals = spot_markets_response[0]["quoteTokenMeta"]["decimals"] @@ -275,6 +278,7 @@ def test_listen_for_order_book_diffs_cancelled(self): def test_listen_for_order_book_diffs_logs_exception(self): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) self.query_executor._spot_order_book_updates.put_nowait({}) order_book_data = { @@ -315,7 +319,7 @@ def test_listen_for_order_book_diffs_logs_exception(self): self.assertTrue( self.is_logged( - "WARNING", re.compile(r"^Invalid orderbook diff event format \(.*") + "WARNING", re.compile(r"^Invalid spot order book event format \(.*") ) ) @@ -323,6 +327,7 @@ def test_listen_for_order_book_diffs_logs_exception(self): def test_listen_for_order_book_diffs_successful(self, _): spot_markets_response = self._spot_markets_response() self.query_executor._spot_markets_responses.put_nowait(spot_markets_response) + self.query_executor._derivative_markets_responses.put_nowait([]) base_decimals = spot_markets_response[0]["baseTokenMeta"]["decimals"] quote_decimals = spot_markets_response[0]["quoteTokenMeta"]["decimals"] diff --git a/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_delegated_account.py b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_delegated_account.py index 7fce222c60..c4d528bec9 100644 --- a/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_delegated_account.py +++ b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_delegated_account.py @@ -29,6 +29,9 @@ from hummingbot.core.data_type.common import OrderType, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.market_order import MarketOrder +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_row import OrderBookRow from hummingbot.core.data_type.trade_fee import AddedToCostTradeFee, TokenAmount, TradeFeeBase from hummingbot.core.event.events import ( BuyOrderCompletedEvent, @@ -290,7 +293,7 @@ def expected_latest_price(self): @property def expected_supported_order_types(self) -> List[OrderType]: - return [OrderType.LIMIT, OrderType.LIMIT_MAKER] + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] @property def expected_trading_rule(self): @@ -324,7 +327,7 @@ def is_order_fill_http_update_included_in_status_update(self) -> bool: @property def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: - raise NotImplementedError + return False @property def expected_partial_fill_price(self) -> Decimal: @@ -380,7 +383,7 @@ def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: def create_exchange_instance(self): client_config_map = ClientConfigAdapter(ClientConfigMap()) - network_config = InjectiveTestnetNetworkMode() + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") account_config = InjectiveDelegatedAccountMode( private_key=self.trading_account_private_key, @@ -401,7 +404,8 @@ def create_exchange_instance(self): ) exchange._data_source._query_executor = ProgrammableQueryExecutor() - exchange._data_source._market_and_trading_pair_map = bidict({self.market_id: self.trading_pair}) + exchange._data_source._spot_market_and_trading_pair_map = bidict({self.market_id: self.trading_pair}) + exchange._data_source._derivative_market_and_trading_pair_map = bidict() return exchange def validate_auth_credentials_present(self, request_call: RequestCall): @@ -424,6 +428,7 @@ def configure_all_symbols_response( ) -> str: all_markets_mock_response = self.all_markets_mock_response self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(all_markets_mock_response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) return "" def configure_trading_rules_response( @@ -442,8 +447,8 @@ def configure_erroneous_trading_rules_response( ) -> List[str]: response = self.trading_rules_request_erroneous_mock_response - self.exchange._data_source._query_executor._spot_markets_responses = asyncio.Queue() self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) return "" def configure_successful_cancelation_response(self, order: InFlightOrder, mock_api: aioresponses, @@ -671,7 +676,7 @@ def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): @aioresponses() def test_all_trading_pairs_does_not_raise_exception(self, mock_api): self.exchange._set_trading_pair_symbol_map(None) - self.exchange._data_source._market_and_trading_pair_map = None + self.exchange._data_source._spot_market_and_trading_pair_map = None queue_mock = AsyncMock() queue_mock.get.side_effect = Exception("Test error") self.exchange._data_source._query_executor._spot_markets_responses = queue_mock @@ -775,6 +780,115 @@ def test_batch_order_create(self): self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].creation_transaction_hash ) + def test_batch_order_create_with_one_market_order(self): + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=["hash1", "hash2"], derivative=[] + ) + + # Configure all symbols response to initialize the trading rules + self.configure_all_symbols_response(mock_api=None) + self.async_run_with_timeout(self.exchange._update_trading_rules()) + + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[OrderBookRow(price=5000, amount=20, update_id=1)], + asks=[], + update_id=1, + ) + + buy_order_to_create = LimitOrder( + client_order_id="", + trading_pair=self.trading_pair, + is_buy=True, + base_currency=self.base_asset, + quote_currency=self.quote_asset, + price=Decimal("10"), + quantity=Decimal("2"), + ) + sell_order_to_create = MarketOrder( + order_id="", + trading_pair=self.trading_pair, + is_buy=False, + base_asset=self.base_asset, + quote_asset=self.quote_asset, + amount=3, + timestamp=self.exchange.current_timestamp, + ) + orders_to_create = [buy_order_to_create, sell_order_to_create] + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + expected_price_for_volume = self.exchange.get_price_for_volume( + trading_pair=self.trading_pair, + is_buy=True, + volume=Decimal(str(sell_order_to_create.amount)), + ).result_price + + orders: List[LimitOrder] = self.exchange.batch_order_create(orders_to_create=orders_to_create) + + buy_order_to_create_in_flight = GatewayInFlightOrder( + client_order_id=orders[0].client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=1640780000, + price=orders[0].price, + amount=orders[0].quantity, + exchange_order_id="hash1", + creation_transaction_hash=response["txhash"] + ) + sell_order_to_create_in_flight = GatewayInFlightOrder( + client_order_id=orders[1].order_id, + trading_pair=self.trading_pair, + order_type=OrderType.MARKET, + trade_type=TradeType.SELL, + creation_timestamp=1640780000, + price=expected_price_for_volume, + amount=orders[1].quantity, + exchange_order_id="hash2", + creation_transaction_hash=response["txhash"] + ) + + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(2, len(orders)) + self.assertEqual(2, len(self.exchange.in_flight_orders)) + + self.assertIn(buy_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + self.assertIn(sell_order_to_create_in_flight.client_order_id, self.exchange.in_flight_orders) + + self.assertEqual( + buy_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + buy_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[buy_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + self.assertEqual( + sell_order_to_create_in_flight.exchange_order_id, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].exchange_order_id + ) + self.assertEqual( + sell_order_to_create_in_flight.creation_transaction_hash, + self.exchange.in_flight_orders[sell_order_to_create_in_flight.client_order_id].creation_transaction_hash + ) + @aioresponses() def test_create_buy_limit_order_successfully(self, mock_api): self._simulate_trading_rules_initialized() @@ -843,6 +957,106 @@ def test_create_sell_limit_order_successfully(self, mock_api): self.assertEqual("hash1", order.exchange_order_id) self.assertEqual(response["txhash"], order.creation_transaction_hash) + @aioresponses() + def test_create_buy_market_order_successfully(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=["hash1"], derivative=[] + ) + + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[], + asks=[OrderBookRow(price=5000, amount=20, update_id=1)], + update_id=1, + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_amount = Decimal(1) + expected_price_for_volume = self.exchange.get_price_for_volume( + trading_pair=self.trading_pair, + is_buy=True, + volume=order_amount + ).result_price + + order_id = self.place_buy_order(amount=order_amount, price=None, order_type=OrderType.MARKET) + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + self.assertEqual(expected_price_for_volume, order.price) + + @aioresponses() + def test_create_sell_market_order_successfully(self, mock_api): + self._simulate_trading_rules_initialized() + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) + self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( + spot=["hash1"], derivative=[] + ) + + order_book = OrderBook() + self.exchange.order_book_tracker._order_books[self.trading_pair] = order_book + order_book.apply_snapshot( + bids=[OrderBookRow(price=5000, amount=20, update_id=1)], + asks=[], + update_id=1, + ) + + transaction_simulation_response = self._msg_exec_simulation_mock_response() + self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( + transaction_simulation_response) + + response = self.order_creation_request_successful_mock_response + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, + callback=lambda args, kwargs: request_sent_event.set(), + response=response + ) + self.exchange._data_source._query_executor._send_transaction_responses = mock_queue + + order_amount = Decimal(1) + expected_price_for_volume = self.exchange.get_price_for_volume( + trading_pair=self.trading_pair, + is_buy=False, + volume=order_amount + ).result_price + + order_id = self.place_sell_order(amount=order_amount, price=None, order_type=OrderType.MARKET) + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertEqual(1, len(self.exchange.in_flight_orders)) + self.assertIn(order_id, self.exchange.in_flight_orders) + + order = self.exchange.in_flight_orders[order_id] + + self.assertEqual("hash1", order.exchange_order_id) + self.assertEqual(response["txhash"], order.creation_transaction_hash) + self.assertEqual(expected_price_for_volume, order.price) + @aioresponses() def test_create_order_fails_and_raises_failure_event(self, mock_api): self._simulate_trading_rules_initialized() @@ -1046,7 +1260,7 @@ def test_order_not_found_in_its_creating_transaction_marked_as_failed_during_ord { "market_id": self.market_id, "order_info": { - "subaccount_id": self.portfolio_account_subaccount_index, + "subaccount_id": self.portfolio_account_subaccount_id, "fee_recipient": self.portfolio_account_injective_address, "price": str(order.price * Decimal(f"1e{self.quote_decimals - self.base_decimals}")), "quantity": str((order.amount + Decimal(1)) * Decimal(f"1e{self.base_decimals}")) @@ -1180,7 +1394,7 @@ def test_order_creation_check_waits_for_originating_transaction_to_be_mined(self { "market_id": self.market_id, "order_info": { - "subaccount_id": self.portfolio_account_subaccount_index, + "subaccount_id": self.portfolio_account_subaccount_id, "fee_recipient": self.portfolio_account_injective_address, "price": str( hash_not_matching_order.price * Decimal( @@ -1261,9 +1475,145 @@ def test_order_creation_check_waits_for_originating_transaction_to_be_mined(self mock_queue.get.assert_called() + def test_order_creating_transactions_identify_correctly_market_orders(self): + self.configure_all_symbols_response(mock_api=None) + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=None, + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "2", + exchange_order_id=None, + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("4500"), + amount=Decimal("20"), + order_type=OrderType.MARKET, + ) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + self.assertIn(self.client_order_id_prefix + "2", self.exchange.in_flight_orders) + limit_order: GatewayInFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + market_order: GatewayInFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "2"] + limit_order.update_creation_transaction_hash(creation_transaction_hash="66A360DA2FD6884B53B5C019F1A2B5BED7C7C8FC07E83A9C36AD3362EDE096AE") # noqa: mock + market_order.update_creation_transaction_hash( + creation_transaction_hash="66A360DA2FD6884B53B5C019F1A2B5BED7C7C8FC07E83A9C36AD3362EDE096AE") # noqa: mock + + expected_hash_1 = "0xc5d66f56942e1ae407c01eedccd0471deb8e202a514cde3bae56a8307e376cd1" # noqa: mock + expected_hash_2 = "0x115975551b4f86188eee6b93d789fcc78df6e89e40011b929299b6e142f53515" # noqa: mock + + transaction_data = ('\x12\xd1\x01\n8/injective.exchange.v1beta1.MsgBatchUpdateOrdersResponse' + '\x12\x94\x01\n\x02\x00\x00\x12\x02\x00\x00\x1aB' + f'{expected_hash_1}' + '\x1aB' + f'{expected_hash_2}' + f'"\x00"\x00').encode() + transaction_messages = [ + { + "type": "/cosmos.authz.v1beta1.MsgExec", + "value": { + "grantee": PrivateKey.from_hex(self.trading_account_private_key).to_public_key().to_acc_bech32(), + "msgs": [ + { + "@type": "/injective.exchange.v1beta1.MsgCreateSpotMarketOrder", + "sender": self.portfolio_account_injective_address, + "order": { + "market_id": self.market_id, + "order_info": { + "subaccount_id": self.portfolio_account_subaccount_id, + "fee_recipient": self.portfolio_account_injective_address, + "price": str( + market_order.price * Decimal(f"1e{self.quote_decimals - self.base_decimals}")), + "quantity": str(market_order.amount * Decimal(f"1e{self.base_decimals}")) + }, + "order_type": "BUY", + "trigger_price": "0.000000000000000000" + } + }, + { + "@type": "/injective.exchange.v1beta1.MsgBatchUpdateOrders", + "sender": self.portfolio_account_injective_address, + "subaccount_id": "", + "spot_market_ids_to_cancel_all": [], + "derivative_market_ids_to_cancel_all": [], + "spot_orders_to_cancel": [], + "derivative_orders_to_cancel": [], + "spot_orders_to_create": [ + { + "market_id": self.market_id, + "order_info": { + "subaccount_id": self.portfolio_account_subaccount_id, + "fee_recipient": self.portfolio_account_injective_address, + "price": str(limit_order.price * Decimal(f"1e{self.quote_decimals - self.base_decimals}")), + "quantity": str(limit_order.amount * Decimal(f"1e{self.base_decimals}")) + }, + "order_type": limit_order.trade_type.name, + "trigger_price": "0.000000000000000000" + } + ], + "derivative_orders_to_create": [], + "binary_options_orders_to_cancel": [], + "binary_options_market_ids_to_cancel_all": [], + "binary_options_orders_to_create": [] + } + ] + } + } + ] + transaction_response = { + "s": "ok", + "data": { + "blockNumber": "13302254", + "blockTimestamp": "2023-07-05 13:55:09.94 +0000 UTC", + "hash": "0x66a360da2fd6884b53b5c019f1a2b5bed7c7c8fc07e83a9c36ad3362ede096ae", # noqa: mock + "data": base64.b64encode(transaction_data).decode(), + "gasWanted": "168306", + "gasUsed": "167769", + "gasFee": { + "amount": [ + { + "denom": "inj", + "amount": "84153000000000" + } + ], + "gasLimit": "168306", + "payer": "inj1hkhdaj2a2clmq5jq6mspsggqs32vynpk228q3r" # noqa: mock + }, + "txType": "injective", + "messages": base64.b64encode(json.dumps(transaction_messages).encode()).decode(), + "signatures": [ + { + "pubkey": "035ddc4d5642b9383e2f087b2ee88b7207f6286ebc9f310e9df1406eccc2c31813", # noqa: mock + "address": "inj1hkhdaj2a2clmq5jq6mspsggqs32vynpk228q3r", # noqa: mock + "sequence": "16450", + "signature": "S9atCwiVg9+8vTpbciuwErh54pJOAry3wHvbHT2fG8IumoE+7vfuoP7mAGDy2w9am+HHa1yv60VSWo3cRhWC9g==" + } + ], + "txNumber": "13182", + "blockUnixTimestamp": "1688565309940", + "logs": "W3sibXNnX2luZGV4IjowLCJldmVudHMiOlt7InR5cGUiOiJtZXNzYWdlIiwiYXR0cmlidXRlcyI6W3sia2V5IjoiYWN0aW9uIiwidmFsdWUiOiIvaW5qZWN0aXZlLmV4Y2hhbmdlLnYxYmV0YTEuTXNnQmF0Y2hVcGRhdGVPcmRlcnMifSx7ImtleSI6InNlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJtb2R1bGUiLCJ2YWx1ZSI6ImV4Y2hhbmdlIn1dfSx7InR5cGUiOiJjb2luX3NwZW50IiwiYXR0cmlidXRlcyI6W3sia2V5Ijoic3BlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJhbW91bnQiLCJ2YWx1ZSI6IjE2NTE2NTAwMHBlZ2d5MHg4N2FCM0I0Qzg2NjFlMDdENjM3MjM2MTIxMUI5NmVkNERjMzZCMUI1In1dfSx7InR5cGUiOiJjb2luX3JlY2VpdmVkIiwiYXR0cmlidXRlcyI6W3sia2V5IjoicmVjZWl2ZXIiLCJ2YWx1ZSI6ImluajE0dm5tdzJ3ZWUzeHRyc3FmdnBjcWczNWpnOXY3ajJ2ZHB6eDBrayJ9LHsia2V5IjoiYW1vdW50IiwidmFsdWUiOiIxNjUxNjUwMDBwZWdneTB4ODdhQjNCNEM4NjYxZTA3RDYzNzIzNjEyMTFCOTZlZDREYzM2QjFCNSJ9XX0seyJ0eXBlIjoidHJhbnNmZXIiLCJhdHRyaWJ1dGVzIjpbeyJrZXkiOiJyZWNpcGllbnQiLCJ2YWx1ZSI6ImluajE0dm5tdzJ3ZWUzeHRyc3FmdnBjcWczNWpnOXY3ajJ2ZHB6eDBrayJ9LHsia2V5Ijoic2VuZGVyIiwidmFsdWUiOiJpbmoxaGtoZGFqMmEyY2xtcTVqcTZtc3BzZ2dxczMydnlucGsyMjhxM3IifSx7ImtleSI6ImFtb3VudCIsInZhbHVlIjoiMTY1MTY1MDAwcGVnZ3kweDg3YUIzQjRDODY2MWUwN0Q2MzcyMzYxMjExQjk2ZWQ0RGMzNkIxQjUifV19LHsidHlwZSI6Im1lc3NhZ2UiLCJhdHRyaWJ1dGVzIjpbeyJrZXkiOiJzZW5kZXIiLCJ2YWx1ZSI6ImluajFoa2hkYWoyYTJjbG1xNWpxNm1zcHNnZ3FzMzJ2eW5wazIyOHEzciJ9XX0seyJ0eXBlIjoiY29pbl9zcGVudCIsImF0dHJpYnV0ZXMiOlt7ImtleSI6InNwZW5kZXIiLCJ2YWx1ZSI6ImluajFoa2hkYWoyYTJjbG1xNWpxNm1zcHNnZ3FzMzJ2eW5wazIyOHEzciJ9LHsia2V5IjoiYW1vdW50IiwidmFsdWUiOiI1NTAwMDAwMDAwMDAwMDAwMDAwMGluaiJ9XX0seyJ0eXBlIjoiY29pbl9yZWNlaXZlZCIsImF0dHJpYnV0ZXMiOlt7ImtleSI6InJlY2VpdmVyIiwidmFsdWUiOiJpbmoxNHZubXcyd2VlM3h0cnNxZnZwY3FnMzVqZzl2N2oydmRwengwa2sifSx7ImtleSI6ImFtb3VudCIsInZhbHVlIjoiNTUwMDAwMDAwMDAwMDAwMDAwMDBpbmoifV19LHsidHlwZSI6InRyYW5zZmVyIiwiYXR0cmlidXRlcyI6W3sia2V5IjoicmVjaXBpZW50IiwidmFsdWUiOiJpbmoxNHZubXcyd2VlM3h0cnNxZnZwY3FnMzVqZzl2N2oydmRwengwa2sifSx7ImtleSI6InNlbmRlciIsInZhbHVlIjoiaW5qMWhraGRhajJhMmNsbXE1anE2bXNwc2dncXMzMnZ5bnBrMjI4cTNyIn0seyJrZXkiOiJhbW91bnQiLCJ2YWx1ZSI6IjU1MDAwMDAwMDAwMDAwMDAwMDAwaW5qIn1dfSx7InR5cGUiOiJtZXNzYWdlIiwiYXR0cmlidXRlcyI6W3sia2V5Ijoic2VuZGVyIiwidmFsdWUiOiJpbmoxaGtoZGFqMmEyY2xtcTVqcTZtc3BzZ2dxczMydnlucGsyMjhxM3IifV19XX1d" # noqa: mock + } + } + self.exchange._data_source._query_executor._transaction_by_hash_responses.put_nowait(transaction_response) + + self.async_run_with_timeout(self.exchange._check_orders_creation_transactions()) + + self.assertEquals(2, len(self.buy_order_created_logger.event_log)) + self.assertEquals(0, len(self.order_failure_logger.event_log)) + + self.assertEquals(expected_hash_1, market_order.exchange_order_id) + self.assertEquals(expected_hash_2, limit_order.exchange_order_id) + def test_user_stream_balance_update(self): client_config_map = ClientConfigAdapter(ClientConfigMap()) - network_config = InjectiveTestnetNetworkMode() + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") account_config = InjectiveDelegatedAccountMode( private_key=self.trading_account_private_key, @@ -1336,7 +1686,7 @@ def test_user_stream_update_for_new_order(self): try: self.async_run_with_timeout( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) except asyncio.CancelledError: pass @@ -1383,7 +1733,7 @@ def test_user_stream_update_for_canceled_order(self): try: self.async_run_with_timeout( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) except asyncio.CancelledError: pass @@ -1442,10 +1792,10 @@ def test_user_stream_update_for_order_full_fill(self, mock_api): tasks = [ asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_public_trades(market_ids=[self.market_id]) + self.exchange._data_source._listen_to_public_spot_trades(market_ids=[self.market_id]) ), asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) ] try: @@ -1528,7 +1878,7 @@ def test_lost_order_removed_after_cancel_status_user_event_received(self): try: self.async_run_with_timeout( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) except asyncio.CancelledError: pass @@ -1587,10 +1937,10 @@ def test_lost_order_user_stream_full_fill_events_are_processed(self, mock_api): tasks = [ asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_public_trades(market_ids=[self.market_id]) + self.exchange._data_source._listen_to_public_spot_trades(market_ids=[self.market_id]) ), asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) ] try: @@ -1623,6 +1973,7 @@ def test_invalid_trading_pair_not_in_all_trading_pairs(self, mock_api): invalid_pair, response = self.all_symbols_including_invalid_pair_mock_response self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) all_trading_pairs = self.async_run_with_timeout(coroutine=self.exchange.all_trading_pairs()) @@ -1669,6 +2020,8 @@ def test_get_last_trade_prices(self, mock_api): self.assertEqual(self.expected_latest_price, latest_prices[self.trading_pair]) def test_get_fee(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None self.configure_all_symbols_response(mock_api=None) self.async_run_with_timeout(self.exchange._update_trading_fees()) @@ -1777,6 +2130,7 @@ def _configure_balance_response( ) -> str: all_markets_mock_response = self.all_markets_mock_response self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(all_markets_mock_response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) self.exchange._data_source._query_executor._account_portfolio_responses.put_nowait(response) return "" diff --git a/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_offchain_vault.py b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_offchain_vault.py index f1706e1f17..2304caa53a 100644 --- a/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_offchain_vault.py +++ b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_exchange_for_offchain_vault.py @@ -6,13 +6,12 @@ from functools import partial from test.hummingbot.connector.exchange.injective_v2.programmable_query_executor import ProgrammableQueryExecutor from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock from aioresponses import aioresponses from aioresponses.core import RequestCall from bidict import bidict from grpc import RpcError -from pyinjective.orderhash import OrderHashManager, OrderHashResponse from pyinjective.wallet import Address, PrivateKey from hummingbot.client.config.client_config_map import ClientConfigMap @@ -376,7 +375,7 @@ def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: def create_exchange_instance(self): client_config_map = ClientConfigAdapter(ClientConfigMap()) - network_config = InjectiveTestnetNetworkMode() + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") account_config = InjectiveVaultAccountMode( private_key=self.trading_account_private_key, @@ -396,7 +395,8 @@ def create_exchange_instance(self): ) exchange._data_source._query_executor = ProgrammableQueryExecutor() - exchange._data_source._market_and_trading_pair_map = bidict({self.market_id: self.trading_pair}) + exchange._data_source._spot_market_and_trading_pair_map = bidict({self.market_id: self.trading_pair}) + exchange._data_source._derivative_market_and_trading_pair_map = bidict() return exchange def validate_auth_credentials_present(self, request_call: RequestCall): @@ -419,6 +419,7 @@ def configure_all_symbols_response( ) -> str: all_markets_mock_response = self.all_markets_mock_response self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(all_markets_mock_response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) return "" def configure_trading_rules_response( @@ -439,6 +440,7 @@ def configure_erroneous_trading_rules_response( response = self.trading_rules_request_erroneous_mock_response self.exchange._data_source._query_executor._spot_markets_responses = asyncio.Queue() self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) return "" def configure_successful_cancelation_response(self, order: InFlightOrder, mock_api: aioresponses, @@ -666,7 +668,7 @@ def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): @aioresponses() def test_all_trading_pairs_does_not_raise_exception(self, mock_api): self.exchange._set_trading_pair_symbol_map(None) - self.exchange._data_source._market_and_trading_pair_map = None + self.exchange._data_source._spot_market_and_trading_pair_map = None queue_mock = AsyncMock() queue_mock.get.side_effect = Exception("Test error") self.exchange._data_source._query_executor._spot_markets_responses = queue_mock @@ -926,10 +928,6 @@ def test_create_order_fails_and_raises_failure_event(self, mock_api): self._simulate_trading_rules_initialized() request_sent_event = asyncio.Event() self.exchange._set_current_timestamp(1640780000) - self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) - self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( - spot=["hash1"], derivative=[] - ) transaction_simulation_response = self._msg_exec_simulation_mock_response() self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( @@ -973,11 +971,6 @@ def test_create_order_fails_when_trading_rule_error_and_raises_failure_event(sel order_id_for_invalid_order = self.place_buy_order( amount=Decimal("0.0001"), price=Decimal("0.0001") ) - # The second order is used only to have the event triggered and avoid using timeouts for tests - self.exchange._data_source._order_hash_manager = MagicMock(spec=OrderHashManager) - self.exchange._data_source._order_hash_manager.compute_order_hashes.return_value = OrderHashResponse( - spot=["hash1"], derivative=[] - ) transaction_simulation_response = self._msg_exec_simulation_mock_response() self.exchange._data_source._query_executor._simulate_transaction_responses.put_nowait( @@ -1134,7 +1127,7 @@ def test_user_stream_update_for_new_order(self): try: self.async_run_with_timeout( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) except asyncio.CancelledError: pass @@ -1181,7 +1174,7 @@ def test_user_stream_update_for_canceled_order(self): try: self.async_run_with_timeout( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) except asyncio.CancelledError: pass @@ -1240,10 +1233,10 @@ def test_user_stream_update_for_order_full_fill(self, mock_api): tasks = [ asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_public_trades(market_ids=[self.market_id]) + self.exchange._data_source._listen_to_public_spot_trades(market_ids=[self.market_id]) ), asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) ] try: @@ -1326,7 +1319,7 @@ def test_lost_order_removed_after_cancel_status_user_event_received(self): try: self.async_run_with_timeout( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) except asyncio.CancelledError: pass @@ -1385,10 +1378,10 @@ def test_lost_order_user_stream_full_fill_events_are_processed(self, mock_api): tasks = [ asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_public_trades(market_ids=[self.market_id]) + self.exchange._data_source._listen_to_public_spot_trades(market_ids=[self.market_id]) ), asyncio.get_event_loop().create_task( - self.exchange._data_source._listen_to_subaccount_order_updates(market_id=self.market_id) + self.exchange._data_source._listen_to_subaccount_spot_order_updates(market_id=self.market_id) ) ] try: @@ -1467,6 +1460,8 @@ def test_get_last_trade_prices(self, mock_api): self.assertEqual(self.expected_latest_price, latest_prices[self.trading_pair]) def test_get_fee(self): + self.exchange._data_source._spot_market_and_trading_pair_map = None + self.exchange._data_source._derivative_market_and_trading_pair_map = None self.configure_all_symbols_response(mock_api=None) self.async_run_with_timeout(self.exchange._update_trading_fees()) @@ -1575,6 +1570,7 @@ def _configure_balance_response( ) -> str: all_markets_mock_response = self.all_markets_mock_response self.exchange._data_source._query_executor._spot_markets_responses.put_nowait(all_markets_mock_response) + self.exchange._data_source._query_executor._derivative_markets_responses.put_nowait([]) self.exchange._data_source._query_executor._account_portfolio_responses.put_nowait(response) return "" diff --git a/test/hummingbot/connector/exchange/injective_v2/tests_injective_v2_utils.py b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_utils.py similarity index 90% rename from test/hummingbot/connector/exchange/injective_v2/tests_injective_v2_utils.py rename to test/hummingbot/connector/exchange/injective_v2/test_injective_v2_utils.py index 958e1824d8..6b8063b6e1 100644 --- a/test/hummingbot/connector/exchange/injective_v2/tests_injective_v2_utils.py +++ b/test/hummingbot/connector/exchange/injective_v2/test_injective_v2_utils.py @@ -4,6 +4,7 @@ from pyinjective.constant import Network import hummingbot.connector.exchange.injective_v2.injective_v2_utils as utils +from hummingbot.connector.exchange.injective_v2 import injective_constants as CONSTANTS from hummingbot.connector.exchange.injective_v2.data_sources.injective_grantee_data_source import ( InjectiveGranteeDataSource, ) @@ -59,10 +60,10 @@ def test_mainnet_network_config_creation_fails_with_wrong_node(self): ) def test_testnet_network_config_creation(self): - network_config = InjectiveTestnetNetworkMode() + network_config = InjectiveTestnetNetworkMode(testnet_node="sentry") network = network_config.network() - expected_network = Network.testnet() + expected_network = Network.testnet(node="sentry") self.assertEqual(expected_network.string(), network.string()) self.assertEqual(expected_network.lcd_endpoint, network.lcd_endpoint) @@ -113,7 +114,11 @@ def test_injective_delegate_account_config_creation(self): granter_subaccount_index=0, ) - data_source = config.create_data_source(network=Network.testnet(), use_secure_connection=True) + data_source = config.create_data_source( + network=Network.testnet(node="sentry"), + use_secure_connection=True, + rate_limits=CONSTANTS.PUBLIC_NODE_RATE_LIMITS, + ) self.assertEqual(InjectiveGranteeDataSource, type(data_source)) @@ -127,7 +132,11 @@ def test_injective_vault_account_config_creation(self): bytes.fromhex(private_key.to_public_key().to_hex())).to_acc_bech32(), ) - data_source = config.create_data_source(network=Network.testnet(), use_secure_connection=True) + data_source = config.create_data_source( + network=Network.testnet(node="sentry"), + use_secure_connection=True, + rate_limits=CONSTANTS.PUBLIC_NODE_RATE_LIMITS, + ) self.assertEqual(InjectiveVaultsDataSource, type(data_source)) diff --git a/test/hummingbot/connector/exchange/mexc/__init__.py b/test/hummingbot/connector/exchange/mexc/__init__.py index f9664561e7..e69de29bb2 100644 --- a/test/hummingbot/connector/exchange/mexc/__init__.py +++ b/test/hummingbot/connector/exchange/mexc/__init__.py @@ -1,2 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_api_order_book_data_source.py b/test/hummingbot/connector/exchange/mexc/test_mexc_api_order_book_data_source.py index 5a57ee68cf..25360f13db 100644 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_api_order_book_data_source.py +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_api_order_book_data_source.py @@ -2,21 +2,20 @@ import json import re import unittest -from collections import deque -from typing import Any, Awaitable, Dict -from unittest.mock import AsyncMock, patch +from typing import Awaitable +from unittest.mock import AsyncMock, MagicMock, patch -import ujson -from aioresponses import aioresponses +from aioresponses.core import aioresponses +from bidict import bidict -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS, mexc_web_utils as web_utils from hummingbot.connector.exchange.mexc.mexc_api_order_book_data_source import MexcAPIOrderBookDataSource -from hummingbot.connector.exchange.mexc.mexc_utils import convert_to_exchange_trading_pair +from hummingbot.connector.exchange.mexc.mexc_exchange import MexcExchange from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler from hummingbot.core.data_type.order_book import OrderBook -from hummingbot.core.data_type.order_book_message import OrderBookMessageType -from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.data_type.order_book_message import OrderBookMessage class MexcAPIOrderBookDataSourceUnitTests(unittest.TestCase): @@ -26,205 +25,391 @@ class MexcAPIOrderBookDataSourceUnitTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: super().setUpClass() - cls.ev_loop = asyncio.get_event_loop() - cls.base_asset = "BTC" - cls.quote_asset = "USDT" + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" - cls.instrument_id = 1 + cls.ex_trading_pair = cls.base_asset + cls.quote_asset + cls.domain = "com" def setUp(self) -> None: super().setUp() self.log_records = [] self.listening_task = None + self.mocking_assistant = NetworkMockingAssistant() - self.throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) - self.data_source = MexcAPIOrderBookDataSource(throttler=self.throttler, trading_pairs=[self.trading_pair]) + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = MexcExchange( + client_config_map=client_config_map, + mexc_api_key="", + mexc_api_secret="", + trading_pairs=[], + trading_required=False, + domain=self.domain) + self.data_source = MexcAPIOrderBookDataSource(trading_pairs=[self.trading_pair], + connector=self.connector, + api_factory=self.connector._web_assistants_factory, + domain=self.domain) self.data_source.logger().setLevel(1) self.data_source.logger().addHandler(self) - self.mocking_assistant = NetworkMockingAssistant() + self._original_full_order_book_reset_time = self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS + self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS = -1 + + self.resume_test_event = asyncio.Event() + + self.connector._set_trading_pair_symbol_map(bidict({self.ex_trading_pair: self.trading_pair})) def tearDown(self) -> None: self.listening_task and self.listening_task.cancel() + self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS = self._original_full_order_book_reset_time super().tearDown() def handle(self, record): self.log_records.append(record) - def _raise_exception(self, exception_class): - raise exception_class - def _is_logged(self, log_level: str, message: str) -> bool: return any(record.levelname == log_level and record.getMessage() == message for record in self.log_records) + def _create_exception_and_unlock_test_with_event(self, exception): + self.resume_test_event.set() + raise exception + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) return ret + def _successfully_subscribed_event(self): + resp = { + "code": None, + "id": 1 + } + return resp + + def _trade_update_event(self): + resp = { + "c": "spot@public.deals.v3.api@BTCUSDT", + "d": { + "deals": [{ + "S": 2, + "p": "0.001", + "t": 1661927587825, + "v": "100"}], + "e": "spot@public.deals.v3.api"}, + "s": self.ex_trading_pair, + "t": 1661927587836 + } + return resp + + def _order_diff_event(self): + resp = { + "c": "spot@public.increase.depth.v3.api@BTCUSDT", + "d": { + "asks": [{ + "p": "0.0026", + "v": "100"}], + "bids": [{ + "p": "0.0024", + "v": "10"}], + "e": "spot@public.increase.depth.v3.api", + "r": "3407459756"}, + "s": self.ex_trading_pair, + "t": 1661932660144 + } + return resp + + def _snapshot_response(self): + resp = { + "lastUpdateId": 1027024, + "bids": [ + [ + "4.00000000", + "431.00000000" + ] + ], + "asks": [ + [ + "4.00000200", + "12.00000000" + ] + ] + } + return resp + @aioresponses() - def test_get_last_traded_prices(self, mock_api): - mock_response: Dict[Any] = {"code": 200, "data": [ - {"symbol": "BTC_USDT", "volume": "1076.002782", "high": "59387.98", "low": "57009", "bid": "57920.98", - "ask": "57921.03", "open": "57735.92", "last": "57902.52", "time": 1637898900000, - "change_rate": "0.00288555"}]} - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_TICKERS_URL + def test_get_new_order_book_successful(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get(regex_url, body=json.dumps(mock_response)) - results = self.async_run_with_timeout( - asyncio.gather(self.data_source.get_last_traded_prices([self.trading_pair]))) - results: Dict[str, Any] = results[0] + resp = self._snapshot_response() + + mock_api.get(regex_url, body=json.dumps(resp)) + + order_book: OrderBook = self.async_run_with_timeout( + self.data_source.get_new_order_book(self.trading_pair) + ) - self.assertEqual(results[self.trading_pair], 57902.52) + expected_update_id = resp["lastUpdateId"] + + self.assertEqual(expected_update_id, order_book.snapshot_uid) + bids = list(order_book.bid_entries()) + asks = list(order_book.ask_entries()) + self.assertEqual(1, len(bids)) + self.assertEqual(4, bids[0].price) + self.assertEqual(431, bids[0].amount) + self.assertEqual(expected_update_id, bids[0].update_id) + self.assertEqual(1, len(asks)) + self.assertEqual(4.000002, asks[0].price) + self.assertEqual(12, asks[0].amount) + self.assertEqual(expected_update_id, asks[0].update_id) - # @unittest.skip("Test with error") @aioresponses() - def test_fetch_trading_pairs_with_error_status_in_response(self, mock_api): - mock_response = {} - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_SYMBOL_URL + def test_get_new_order_book_raises_exception(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get(regex_url, body=json.dumps(mock_response), status=100) - result = self.async_run_with_timeout(self.data_source.fetch_trading_pairs()) - self.assertEqual(0, len(result)) + mock_api.get(regex_url, status=400) + with self.assertRaises(IOError): + self.async_run_with_timeout( + self.data_source.get_new_order_book(self.trading_pair) + ) - @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_api_order_book_data_source.microseconds") - def test_get_order_book_data(self, mock_api, ms_mock): - ms_mock.return_value = 1 - mock_response = {"code": 200, "data": {"asks": [{"price": "57974.06", "quantity": "0.247421"}], - "bids": [{"price": "57974.01", "quantity": "0.201635"}], - "ts": 1, - "version": "562370278"}} - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, body=json.dumps(mock_response)) - - results = self.async_run_with_timeout( - asyncio.gather(self.data_source.get_snapshot(self.data_source._shared_client, self.trading_pair))) - result = results[0] - - self.assertTrue("asks" in result) - self.assertGreaterEqual(len(result), 0) - self.assertEqual(mock_response.get("data"), result) + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_subscriptions_subscribes_to_trades_and_order_diffs(self, ws_connect_mock): + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + result_subscribe_trades = { + "code": None, + "id": 1 + } + result_subscribe_diffs = { + "code": None, + "id": 2 + } + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_trades)) + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_diffs)) + + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + + sent_subscription_messages = self.mocking_assistant.json_messages_sent_through_websocket( + websocket_mock=ws_connect_mock.return_value) + + self.assertEqual(2, len(sent_subscription_messages)) + expected_trade_subscription = { + "method": "SUBSCRIPTION", + "params": [f"spot@public.deals.v3.api@{self.ex_trading_pair}"], + "id": 1} + self.assertEqual(expected_trade_subscription, sent_subscription_messages[0]) + expected_diff_subscription = { + "method": "SUBSCRIPTION", + "params": [f"spot@public.increase.depth.v3.api@{self.ex_trading_pair}"], + "id": 2} + self.assertEqual(expected_diff_subscription, sent_subscription_messages[1]) + + self.assertTrue(self._is_logged( + "INFO", + "Subscribed to public order book and trade channels..." + )) + + @patch("hummingbot.core.data_type.order_book_tracker_data_source.OrderBookTrackerDataSource._sleep") + @patch("aiohttp.ClientSession.ws_connect") + def test_listen_for_subscriptions_raises_cancel_exception(self, mock_ws, _: AsyncMock): + mock_ws.side_effect = asyncio.CancelledError - @aioresponses() - def test_get_order_book_data_raises_exception_when_response_has_error_code(self, mock_api): - mock_response = "Erroneous response" - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, body=json.dumps(mock_response), status=100) + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + self.async_run_with_timeout(self.listening_task) - with self.assertRaises(IOError) as context: - self.async_run_with_timeout(self.data_source.get_snapshot(self.data_source._shared_client, self.trading_pair)) + @patch("hummingbot.core.data_type.order_book_tracker_data_source.OrderBookTrackerDataSource._sleep") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_subscriptions_logs_exception_details(self, mock_ws, sleep_mock): + mock_ws.side_effect = Exception("TEST ERROR.") + sleep_mock.side_effect = lambda _: self._create_exception_and_unlock_test_with_event(asyncio.CancelledError()) - self.assertEqual(str(context.exception), - f'Error fetching MEXC market snapshot for {self.trading_pair.replace("-", "_")}. ' - f'HTTP status is {100}.') + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) - @aioresponses() - def test_get_new_order_book(self, mock_api): - mock_response = {"code": 200, "data": {"asks": [{"price": "57974.06", "quantity": "0.247421"}], - "bids": [{"price": "57974.01", "quantity": "0.201635"}], - "version": "562370278"}} - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, body=json.dumps(mock_response)) + self.async_run_with_timeout(self.resume_test_event.wait()) - results = self.async_run_with_timeout( - asyncio.gather(self.data_source.get_new_order_book(self.trading_pair))) - result: OrderBook = results[0] + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error occurred when listening to order book streams. Retrying in 5 seconds...")) - self.assertTrue(type(result) == OrderBook) + def test_subscribe_channels_raises_cancel_exception(self): + mock_ws = MagicMock() + mock_ws.send.side_effect = asyncio.CancelledError - @aioresponses() - def test_listen_for_snapshots_cancelled_when_fetching_snapshot(self, mock_api): - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, exception=asyncio.CancelledError) + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task(self.data_source._subscribe_channels(mock_ws)) + self.async_run_with_timeout(self.listening_task) + + def test_subscribe_channels_raises_exception_and_logs_error(self): + mock_ws = MagicMock() + mock_ws.send.side_effect = Exception("Test Error") + + with self.assertRaises(Exception): + self.listening_task = self.ev_loop.create_task(self.data_source._subscribe_channels(mock_ws)) + self.async_run_with_timeout(self.listening_task) + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error occurred subscribing to order book trading and delta streams...") + ) + + def test_listen_for_trades_cancelled_when_listening(self): + mock_queue = MagicMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue msg_queue: asyncio.Queue = asyncio.Queue() + with self.assertRaises(asyncio.CancelledError): self.listening_task = self.ev_loop.create_task( - self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + self.data_source.listen_for_trades(self.ev_loop, msg_queue) ) self.async_run_with_timeout(self.listening_task) - self.assertEqual(msg_queue.qsize(), 0) + def test_listen_for_trades_logs_exception(self): + incomplete_resp = { + "m": 1, + "i": 2, + } - @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_api_order_book_data_source.MexcAPIOrderBookDataSource._sleep") - def test_listen_for_snapshots_successful(self, mock_api, mock_sleep): - # the queue and the division by zero error are used just to synchronize the test - sync_queue = deque() - sync_queue.append(1) - - mock_response = {"code": 200, "data": {"asks": [{"price": "57974.06", "quantity": "0.247421"}], - "bids": [{"price": "57974.01", "quantity": "0.201635"}], - "version": "562370278"}} - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, body=json.dumps(mock_response)) - - mock_sleep.side_effect = lambda delay: 1 / 0 if len(sync_queue) == 0 else sync_queue.pop() + mock_queue = AsyncMock() + mock_queue.get.side_effect = [incomplete_resp, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue msg_queue: asyncio.Queue = asyncio.Queue() - with self.assertRaises(ZeroDivisionError): - self.listening_task = self.ev_loop.create_task( - self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue)) - self.async_run_with_timeout(self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue)) - self.assertEqual(msg_queue.qsize(), 1) + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue) + ) - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_listen_for_subscriptions_cancelled_when_subscribing(self, mock_ws): - mock_ws.return_value = self.mocking_assistant.create_websocket_mock() - mock_ws.return_value.send_str.side_effect = asyncio.CancelledError() + try: + self.async_run_with_timeout(self.listening_task) + except asyncio.CancelledError: + pass + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error when processing public trade updates from exchange")) + + def test_listen_for_trades_successful(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = [self._trade_update_event(), asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(1661927587825, msg.trade_id) - self.mocking_assistant.add_websocket_aiohttp_message(mock_ws.return_value, {'channel': 'push.personal.order'}) + def test_listen_for_order_book_diffs_cancelled(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() with self.assertRaises(asyncio.CancelledError): self.listening_task = self.ev_loop.create_task( - self.data_source.listen_for_subscriptions() + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue) ) self.async_run_with_timeout(self.listening_task) - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_listen_for_order_book_diffs_cancelled_when_listening(self, mock_ws): + def test_listen_for_order_book_diffs_logs_exception(self): + incomplete_resp = { + "m": 1, + "i": 2, + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [incomplete_resp, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue) + ) + + try: + self.async_run_with_timeout(self.listening_task) + except asyncio.CancelledError: + pass + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error when processing public order book updates from exchange")) + + def test_listen_for_order_book_diffs_successful(self): + mock_queue = AsyncMock() + diff_event = self._order_diff_event() + mock_queue.get.side_effect = [diff_event, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + msg_queue: asyncio.Queue = asyncio.Queue() - mock_ws.return_value = self.mocking_assistant.create_websocket_mock() - data = {'symbol': 'MX_USDT', - 'data': {'version': '44000093', 'bids': [{'p': '2.9311', 'q': '0.00', 'a': '0.00000000'}], - 'asks': [{'p': '2.9311', 'q': '22720.37', 'a': '66595.6765'}]}, - 'channel': 'push.depth'} - self.mocking_assistant.add_websocket_aiohttp_message(mock_ws.return_value, ujson.dumps(data)) - safe_ensure_future(self.data_source.listen_for_subscriptions()) self.listening_task = self.ev_loop.create_task( self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue)) - first_msg = self.async_run_with_timeout(msg_queue.get()) - self.assertTrue(first_msg.type == OrderBookMessageType.DIFF) + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_websocket_connection_creation_raises_cancel_exception(self, mock_ws): - mock_ws.side_effect = asyncio.CancelledError + self.assertEqual(int(diff_event["d"]["r"]), msg.update_id) + + @aioresponses() + def test_listen_for_order_book_snapshots_cancelled_when_fetching_snapshot(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, exception=asyncio.CancelledError, repeat=True) with self.assertRaises(asyncio.CancelledError): - self.async_run_with_timeout(self.data_source._create_websocket_connection()) + self.async_run_with_timeout( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, asyncio.Queue()) + ) - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_websocket_connection_creation_raises_exception_after_loging(self, mock_ws): - mock_ws.side_effect = Exception + @aioresponses() + @patch("hummingbot.connector.exchange.mexc.mexc_api_order_book_data_source" + ".MexcAPIOrderBookDataSource._sleep") + def test_listen_for_order_book_snapshots_log_exception(self, mock_api, sleep_mock): + msg_queue: asyncio.Queue = asyncio.Queue() + sleep_mock.side_effect = lambda _: self._create_exception_and_unlock_test_with_event(asyncio.CancelledError()) - with self.assertRaises(Exception): - self.async_run_with_timeout(self.data_source._create_websocket_connection()) + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, exception=Exception, repeat=True) + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged("ERROR", f"Unexpected error fetching order book snapshot for {self.trading_pair}.")) + + @aioresponses() + def test_listen_for_order_book_snapshots_successful(self, mock_api, ): + msg_queue: asyncio.Queue = asyncio.Queue() + url = web_utils.public_rest_url(path_url=CONSTANTS.SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, body=json.dumps(self._snapshot_response())) + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + ) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) - self.assertTrue(self._is_logged("NETWORK", 'Unexpected error occured connecting to mexc WebSocket API. ()')) + self.assertEqual(1027024, msg.update_id) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_api_user_stream_data_source.py b/test/hummingbot/connector/exchange/mexc/test_mexc_api_user_stream_data_source.py deleted file mode 100644 index a3173e4927..0000000000 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_api_user_stream_data_source.py +++ /dev/null @@ -1,80 +0,0 @@ -import asyncio -from typing import Awaitable -from unittest import TestCase -from unittest.mock import AsyncMock, patch - -import ujson - -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS -from hummingbot.connector.exchange.mexc.mexc_api_user_stream_data_source import MexcAPIUserStreamDataSource -from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth -from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler - - -class MexcAPIUserStreamDataSourceTests(TestCase): - # the level is required to receive logs from the data source loger - level = 0 - - def setUp(self) -> None: - super().setUp() - self.uid = '001' - self.api_key = 'testAPIKey' - self.secret = 'testSecret' - self.account_id = 528 - self.username = 'hbot' - self.oms_id = 1 - self.log_records = [] - self.listening_task = None - self.ev_loop = asyncio.get_event_loop() - - throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - auth_assistant = MexcAuth(api_key=self.api_key, - secret_key=self.secret) - self.data_source = MexcAPIUserStreamDataSource(throttler, auth_assistant) - self.data_source.logger().setLevel(1) - self.data_source.logger().addHandler(self) - - self.mocking_assistant = NetworkMockingAssistant() - - def tearDown(self) -> None: - self.listening_task and self.listening_task.cancel() - super().tearDown() - - def handle(self, record): - self.log_records.append(record) - - def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): - ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) - return ret - - def _is_logged(self, log_level: str, message: str) -> bool: - return any(record.levelname == log_level and record.getMessage() == message - for record in self.log_records) - - def _raise_exception(self, exception_class): - raise exception_class - - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_listening_process_authenticates_and_subscribes_to_events(self, ws_connect_mock): - messages = asyncio.Queue() - ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() - - self.listening_task = asyncio.get_event_loop().create_task( - self.data_source.listen_for_user_stream(messages)) - # Add a dummy message for the websocket to read and include in the "messages" queue - self.mocking_assistant.add_websocket_aiohttp_message(ws_connect_mock.return_value, - ujson.dumps({'channel': 'push.personal.order'})) - - first_received_message = self.async_run_with_timeout(messages.get()) - self.assertEqual({'channel': 'push.personal.order'}, first_received_message) - - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_listening_process_canceled_when_cancel_exception_during_initialization(self, ws_connect_mock): - messages = asyncio.Queue() - ws_connect_mock.side_effect = asyncio.CancelledError - - with self.assertRaises(asyncio.CancelledError): - self.listening_task = asyncio.get_event_loop().create_task( - self.data_source.listen_for_user_stream(messages)) - self.async_run_with_timeout(self.listening_task) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_auth.py b/test/hummingbot/connector/exchange/mexc/test_mexc_auth.py index ebe79bf832..3f543d9aa2 100644 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_auth.py +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_auth.py @@ -1,23 +1,51 @@ -import unittest -from unittest import mock +import asyncio +import hashlib +import hmac +from copy import copy +from unittest import TestCase +from unittest.mock import MagicMock + +from typing_extensions import Awaitable from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + + +class MexcAuthTests(TestCase): + + def setUp(self) -> None: + self._api_key = "testApiKey" + self._secret = "testSecret" + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = asyncio.get_event_loop().run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret -class TestAuth(unittest.TestCase): + def test_rest_authenticate(self): + now = 1234567890.000 + mock_time_provider = MagicMock() + mock_time_provider.time.return_value = now - @property - def api_key(self): - return 'MEXC_API_KEY_mock' + params = { + "symbol": "LTCBTC", + "side": "BUY", + "type": "LIMIT", + "timeInForce": "GTC", + "quantity": 1, + "price": "0.1", + } + full_params = copy(params) - @property - def secret_key(self): - return 'MEXC_SECRET_KEY_mock' + auth = MexcAuth(api_key=self._api_key, secret_key=self._secret, time_provider=mock_time_provider) + request = RESTRequest(method=RESTMethod.GET, params=params, is_auth_required=True) + configured_request = self.async_run_with_timeout(auth.rest_authenticate(request)) - @mock.patch('hummingbot.connector.exchange.mexc.mexc_utils.seconds', mock.MagicMock(return_value=1635249347)) - def test_auth_without_params(self): - self.auth = MexcAuth(self.api_key, self.secret_key) - headers = self.auth.add_auth_to_params('GET', "/open/api/v2/market/coin/list", - {'api_key': self.api_key}, True) - self.assertIn("api_key=MEXC_API_KEY_mock&req_time=1635249347" - "&sign=8dc59c2b7f0ad6da9e8844bb5478595a4f83126cb607524d767586437bae8d68", headers) # noqa: mock + full_params.update({"timestamp": 1234567890000}) + encoded_params = "&".join([f"{key}={value}" for key, value in full_params.items()]) + expected_signature = hmac.new( + self._secret.encode("utf-8"), + encoded_params.encode("utf-8"), + hashlib.sha256).hexdigest() + self.assertEqual(now * 1e3, configured_request.params["timestamp"]) + self.assertEqual(expected_signature, configured_request.params["signature"]) + self.assertEqual({"X-MEXC-APIKEY": self._api_key}, configured_request.headers) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_exchange.py b/test/hummingbot/connector/exchange/mexc/test_mexc_exchange.py index 98b55728db..f7947ff5b5 100644 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_exchange.py +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_exchange.py @@ -1,1108 +1,1279 @@ import asyncio -import functools import json import re -import time from decimal import Decimal -from typing import Any, Awaitable, Callable, Dict, List -from unittest import TestCase -from unittest.mock import AsyncMock, PropertyMock, patch +from typing import Any, Callable, Dict, List, Optional, Tuple +from unittest.mock import patch -import pandas as pd -import ujson from aioresponses import aioresponses +from aioresponses.core import RequestCall -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS from hummingbot.client.config.client_config_map import ClientConfigMap from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS, mexc_web_utils as web_utils from hummingbot.connector.exchange.mexc.mexc_exchange import MexcExchange -from hummingbot.connector.exchange.mexc.mexc_in_flight_order import MexcInFlightOrder -from hummingbot.connector.exchange.mexc.mexc_order_book import MexcOrderBook -from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.connector.test_support.exchange_connector_test import AbstractExchangeConnectorTests from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import get_new_client_order_id from hummingbot.core.data_type.common import OrderType, TradeType -from hummingbot.core.event.events import OrderCancelledEvent, SellOrderCompletedEvent -from hummingbot.core.network_iterator import NetworkStatus -from hummingbot.core.utils.async_utils import safe_ensure_future - - -class MexcExchangeTests(TestCase): - # the level is required to receive logs from the data source loger - level = 0 - - start_timestamp: float = pd.Timestamp("2021-01-01", tz="UTC").timestamp() - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.base_asset = "MX" - cls.quote_asset = "USDT" - cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" - cls.ev_loop = asyncio.get_event_loop() - - def setUp(self) -> None: - super().setUp() - - self.tracker_task = None - self.exchange_task = None - self.log_records = [] - self.resume_test_event = asyncio.Event() - self._account_name = "hbot" - self.client_config_map = ClientConfigAdapter(ClientConfigMap()) - - self.exchange = MexcExchange( - client_config_map=self.client_config_map, - mexc_api_key='testAPIKey', - mexc_secret_key='testSecret', - trading_pairs=[self.trading_pair]) - - self.exchange.logger().setLevel(1) - self.exchange.logger().addHandler(self) - self.exchange._account_id = 1 - - self.mocking_assistant = NetworkMockingAssistant() - self.mock_done_event = asyncio.Event() - - def tearDown(self) -> None: - self.tracker_task and self.tracker_task.cancel() - self.exchange_task and self.exchange_task.cancel() - super().tearDown() - - def handle(self, record): - self.log_records.append(record) - - def _is_logged(self, log_level: str, message: str) -> bool: - return any(record.levelname == log_level and record.getMessage() == message - for record in self.log_records) - - def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): - ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) - return ret - - def _return_calculation_and_set_done_event(self, calculation: Callable, *args, **kwargs): - if self.resume_test_event.is_set(): - raise asyncio.CancelledError - self.resume_test_event.set() - return calculation(*args, **kwargs) - - def _create_exception_and_unlock_test_with_event(self, exception): - self.resume_test_event.set() - raise exception - - def _mock_responses_done_callback(self, *_, **__): - self.mock_done_event.set() - - def _simulate_reset_poll_notifier(self): - self.exchange._poll_notifier.clear() - - def _simulate_ws_message_received(self, timestamp: float): - self.exchange._user_stream_tracker._data_source._last_recv_time = timestamp - - def _simulate_trading_rules_initialized(self): - self.exchange._trading_rules = { - self.trading_pair: TradingRule( - trading_pair=self.trading_pair, - min_order_size=4, - min_price_increment=Decimal(str(0.0001)), - min_base_amount_increment=2, - min_notional_size=Decimal(str(5)) - ) - } +from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState +from hummingbot.core.data_type.trade_fee import DeductedFromReturnsTradeFee, TokenAmount, TradeFeeBase +from hummingbot.core.event.events import MarketOrderFailureEvent, OrderFilledEvent - @property - def order_book_data(self): - _data = {"code": 200, "data": { - "asks": [{"price": "56454.0", "quantity": "0.799072"}, {"price": "56455.28", "quantity": "0.008663"}], - "bids": [{"price": "56451.0", "quantity": "0.008663"}, {"price": "56449.99", "quantity": "0.173078"}], - "version": "547878563"}} - return _data - - def _simulate_create_order(self, - trade_type: TradeType, - order_id: str, - trading_pair: str, - amount: Decimal, - price: Decimal = Decimal("0"), - order_type: OrderType = OrderType.MARKET): - future = safe_ensure_future( - self.exchange.execute_buy(order_id, trading_pair, amount, order_type, price) - ) - self.exchange.start_tracking_order( - order_id, None, self.trading_pair, TradeType.BUY, Decimal(10.0), Decimal(1.0), OrderType.LIMIT - ) - return future - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_user_event_queue_error_is_logged(self, ws_connect_mock): - ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() +class MexcExchangeTests(AbstractExchangeConnectorTests.ExchangeConnectorTests): - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._user_stream_event_listener()) + @property + def all_symbols_url(self): + return web_utils.public_rest_url(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, domain=self.exchange._domain) - dummy_user_stream = AsyncMock() - dummy_user_stream.get.side_effect = lambda: self._create_exception_and_unlock_test_with_event( - Exception("Dummy test error")) - self.exchange._user_stream_tracker._user_stream = dummy_user_stream + @property + def latest_prices_url(self): + url = web_utils.public_rest_url(path_url=CONSTANTS.TICKER_PRICE_CHANGE_PATH_URL, domain=self.exchange._domain) + url = f"{url}?symbol={self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset)}" + return url - # Add a dummy message for the websocket to read and include in the "messages" queue - self.mocking_assistant.add_websocket_text_message(ws_connect_mock, - ujson.dumps({'channel': 'push.personal.order'})) - self.async_run_with_timeout(self.resume_test_event.wait()) - self.resume_test_event.clear() + @property + def network_status_url(self): + url = web_utils.private_rest_url(CONSTANTS.PING_PATH_URL, domain=self.exchange._domain) + return url - try: - self.exchange_task.cancel() - self.async_run_with_timeout(self.exchange_task) - except asyncio.CancelledError: - pass - except Exception: - pass + @property + def trading_rules_url(self): + url = web_utils.private_rest_url(CONSTANTS.EXCHANGE_INFO_PATH_URL, domain=self.exchange._domain) + return url - self.assertTrue(self._is_logged('ERROR', "Unknown error. Retrying after 1 second. Dummy test error")) + @property + def order_creation_url(self): + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL, domain=self.exchange._domain) + return url - def test_user_event_queue_notifies_cancellations(self): - self.tracker_task = asyncio.get_event_loop().create_task( - self.exchange._user_stream_event_listener()) + @property + def balance_url(self): + url = web_utils.private_rest_url(CONSTANTS.ACCOUNTS_PATH_URL, domain=self.exchange._domain) + return url - dummy_user_stream = AsyncMock() - dummy_user_stream.get.side_effect = lambda: self._create_exception_and_unlock_test_with_event( - asyncio.CancelledError()) - self.exchange._user_stream_tracker._user_stream = dummy_user_stream + @property + def all_symbols_request_mock_response(self): + return { + "timezone": "UTC", + "serverTime": 1639598493658, + "rateLimits": [], + "exchangeFilters": [], + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "ENABLED", + "baseAsset": self.base_asset, + "baseSizePrecision": 1e-8, + "quotePrecision": 8, + "baseAssetPrecision": 8, + "quoteAmountPrecision": 8, + "quoteAsset": self.quote_asset, + "quoteAssetPrecision": 8, + "baseCommissionPrecision": 8, + "quoteCommissionPrecision": 8, + "orderTypes": [ + "LIMIT", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT_LIMIT" + ], + "icebergAllowed": True, + "ocoAllowed": True, + "quoteOrderQtyMarketAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [], + "permissions": [ + "SPOT", + "MARGIN" + ] + }, + ] + } - with self.assertRaises(asyncio.CancelledError): - self.async_run_with_timeout(self.tracker_task) + @property + def latest_prices_request_mock_response(self): + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "priceChange": "-94.99999800", + "priceChangePercent": "-95.960", + "weightedAvgPrice": "0.29628482", + "prevClosePrice": "0.10002000", + "lastPrice": str(self.expected_latest_price), + "lastQty": "200.00000000", + "bidPrice": "4.00000000", + "bidQty": "100.00000000", + "askPrice": "4.00000200", + "askQty": "100.00000000", + "openPrice": "99.00000000", + "highPrice": "100.00000000", + "lowPrice": "0.10000000", + "volume": "8913.30000000", + "quoteVolume": "15.30000000", + "openTime": 1499783499040, + "closeTime": 1499869899040, + "firstId": 28385, + "lastId": 28460, + "count": 76, + } - def test_exchange_logs_unknown_event_message(self): - payload = {'channel': 'test'} - mock_user_stream = AsyncMock() - mock_user_stream.get.side_effect = functools.partial(self._return_calculation_and_set_done_event, - lambda: payload) + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + response = { + "timezone": "UTC", + "serverTime": 1639598493658, + "rateLimits": [], + "exchangeFilters": [], + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "ENABLED", + "baseAsset": self.base_asset, + "baseSizePrecision": 1e-8, + "quotePrecision": 8, + "baseAssetPrecision": 8, + "quoteAsset": self.quote_asset, + "quoteAssetPrecision": 8, + "baseCommissionPrecision": 8, + "quoteAmountPrecision": 8, + "quoteCommissionPrecision": 8, + "orderTypes": [ + "LIMIT", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT_LIMIT" + ], + "icebergAllowed": True, + "ocoAllowed": True, + "quoteOrderQtyMarketAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [], + "permissions": [ + "MARGIN" + ] + }, + { + "symbol": self.exchange_symbol_for_tokens("INVALID", "PAIR"), + "status": "ENABLED", + "baseAsset": "INVALID", + "baseSizePrecision": 1e-8, + "quotePrecision": 8, + "baseAssetPrecision": 8, + "quoteAmountPrecision": 8, + "quoteAsset": "PAIR", + "quoteAssetPrecision": 8, + "baseCommissionPrecision": 8, + "quoteCommissionPrecision": 8, + "orderTypes": [ + "LIMIT", + "LIMIT_MAKER", + "MARKET", + "STOP_LOSS_LIMIT", + "TAKE_PROFIT_LIMIT" + ], + "icebergAllowed": True, + "ocoAllowed": True, + "quoteOrderQtyMarketAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "filters": [], + "permissions": [ + "MARGIN" + ] + }, + ] + } - self.exchange._user_stream_tracker._user_stream = mock_user_stream - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._user_stream_event_listener()) - self.async_run_with_timeout(self.resume_test_event.wait()) + return "INVALID-PAIR", response - self.assertTrue(self._is_logged('DEBUG', f"Unknown event received from the connector ({payload})")) + @property + def network_status_request_successful_mock_response(self): + return {} @property - def balances_mock_data(self): + def trading_rules_request_mock_response(self): return { - "code": 200, - "data": { - "MX": { - "frozen": "30.9863", - "available": "450.0137" + "timezone": "UTC", + "serverTime": 1565246363776, + "rateLimits": [{}], + "exchangeFilters": [], + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "ENABLED", + "baseAsset": self.base_asset, + "baseSizePrecision": 1e-8, + "quotePrecision": 8, + "baseAssetPrecision": 8, + "quoteAmountPrecision": 8, + "quoteAsset": self.quote_asset, + "quoteAssetPrecision": 8, + "orderTypes": ["LIMIT", "LIMIT_MAKER"], + "icebergAllowed": True, + "ocoAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "200000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "MIN_NOTIONAL", + "minNotional": "0.00200000" + } + ], + "permissions": [ + "SPOT", + "MARGIN" + ] } - } + ] } @property - def user_stream_data(self): + def trading_rules_request_erroneous_mock_response(self): return { - 'symbol': 'MX_USDT', - 'data': { - 'price': 3.1504, - 'quantity': 2, - 'amount': 6.3008, - 'remainAmount': 6.3008, - 'remainQuantity': 2, - 'remainQ': 2, - 'id': '40728558ead64032a676e6f0a4afc4ca', - 'status': 4, - 'tradeType': 2, - 'createTime': 1638156451000, - 'symbolDisplay': 'MX_USDT', - 'clientOrderId': 'sell-MX-USDT-1638156451005305'}, - 'channel': 'push.personal.order', 'symbol_display': 'MX_USDT'} - - @aioresponses() - def test_order_event_with_cancel_status_cancels_in_flight_order(self, mock_api): - mock_response = self.balances_mock_data - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_BALANCE_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get( - regex_url, - body=json.dumps(mock_response), - ) - - self.exchange.start_tracking_order(order_id="sell-MX-USDT-1638156451005305", - exchange_order_id="40728558ead64032a676e6f0a4afc4ca", - trading_pair="MX-USDT", - trade_type=TradeType.SELL, - price=Decimal("3.1504"), - amount=Decimal("6.3008"), - order_type=OrderType.LIMIT) - - inflight_order = self.exchange.in_flight_orders["sell-MX-USDT-1638156451005305"] - - mock_user_stream = AsyncMock() - mock_user_stream.get.side_effect = [self.user_stream_data, asyncio.CancelledError] - - self.exchange._user_stream_tracker._user_stream = mock_user_stream - - try: - self.async_run_with_timeout(self.exchange._user_stream_event_listener(), 1000000) - except asyncio.CancelledError: - pass - - self.assertEqual("CANCELED", inflight_order.last_state) - self.assertTrue(inflight_order.is_cancelled) - self.assertFalse(inflight_order.client_order_id in self.exchange.in_flight_orders) - self.assertTrue(self._is_logged("INFO", f"Order {inflight_order.client_order_id} " - f"has been canceled according to order delta websocket API.")) - self.assertEqual(1, len(self.exchange.event_logs)) - cancel_event = self.exchange.event_logs[0] - self.assertEqual(OrderCancelledEvent, type(cancel_event)) - self.assertEqual(inflight_order.client_order_id, cancel_event.order_id) - - @aioresponses() - def test_order_event_with_rejected_status_makes_in_flight_order_fail(self, mock_api): - mock_response = self.balances_mock_data - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_BALANCE_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get( - regex_url, - body=json.dumps(mock_response), - ) - self.exchange.start_tracking_order(order_id="sell-MX-USDT-1638156451005305", - exchange_order_id="40728558ead64032a676e6f0a4afc4ca", - trading_pair="MX-USDT", - trade_type=TradeType.SELL, - price=Decimal("3.1504"), - amount=Decimal("6.3008"), - order_type=OrderType.LIMIT) - - inflight_order = self.exchange.in_flight_orders["sell-MX-USDT-1638156451005305"] - stream_data = self.user_stream_data - stream_data.get("data")["status"] = 5 - mock_user_stream = AsyncMock() - mock_user_stream.get.side_effect = [stream_data, asyncio.CancelledError] - self.exchange._user_stream_tracker._user_stream = mock_user_stream - try: - self.async_run_with_timeout(self.exchange._user_stream_event_listener(), 1000000) - except asyncio.CancelledError: - pass - - self.assertEqual("PARTIALLY_CANCELED", inflight_order.last_state) - self.assertTrue(inflight_order.is_failure) - self.assertFalse(inflight_order.client_order_id in self.exchange.in_flight_orders) - self.assertTrue(self._is_logged("INFO", f"Order {inflight_order.client_order_id} " - f"has been canceled according to order delta websocket API.")) - self.assertEqual(1, len(self.exchange.event_logs)) - failure_event = self.exchange.event_logs[0] - self.assertEqual(OrderCancelledEvent, type(failure_event)) - self.assertEqual(inflight_order.client_order_id, failure_event.order_id) - - @aioresponses() - def test_trade_event_fills_and_completes_buy_in_flight_order(self, mock_api): - fee_mock_data = {'code': 200, 'data': [{'id': 'c85b7062f69c4bf1b6c153dca5c0318a', - 'symbol': 'MX_USDT', 'quantity': '2', - 'price': '3.1265', 'amount': '6.253', - 'fee': '0.012506', 'trade_type': 'BID', - 'order_id': '95c4ce45fdd34cf99bfd1e1378eb38ae', - 'is_taker': False, 'fee_currency': 'USDT', - 'create_time': 1638177115000}]} - mock_response = self.balances_mock_data - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_BALANCE_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get( - regex_url, - body=json.dumps(mock_response), - ) - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_DEAL_DETAIL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get( - regex_url, - body=json.dumps(fee_mock_data), - ) - self.exchange.start_tracking_order(order_id="sell-MX-USDT-1638156451005305", - exchange_order_id="40728558ead64032a676e6f0a4afc4ca", - trading_pair="MX-USDT", - trade_type=TradeType.SELL, - price=Decimal("3.1504"), - amount=Decimal("6.3008"), - order_type=OrderType.LIMIT) - inflight_order = self.exchange.in_flight_orders["sell-MX-USDT-1638156451005305"] - _user_stream = self.user_stream_data - _user_stream.get("data")["status"] = 2 - mock_user_stream = AsyncMock() - mock_user_stream.get.side_effect = [_user_stream, asyncio.CancelledError] - - self.exchange._user_stream_tracker._user_stream = mock_user_stream - try: - self.async_run_with_timeout(self.exchange._user_stream_event_listener(), 1000000) - except asyncio.CancelledError: - pass - - self.assertEqual("FILLED", inflight_order.last_state) - self.assertEqual(Decimal(0), inflight_order.executed_amount_base) - self.assertEqual(Decimal(0), inflight_order.executed_amount_quote) - self.assertEqual(1, len(self.exchange.event_logs)) - fill_event = self.exchange.event_logs[0] - self.assertEqual(SellOrderCompletedEvent, type(fill_event)) - self.assertEqual(inflight_order.client_order_id, fill_event.order_id) - self.assertEqual(inflight_order.trading_pair, f'{fill_event.base_asset}-{fill_event.quote_asset}') - - def test_tick_initial_tick_successful(self): - start_ts: float = time.time() * 1e3 - - self.exchange.tick(start_ts) - self.assertEqual(start_ts, self.exchange._last_timestamp) - self.assertTrue(self.exchange._poll_notifier.is_set()) - - @patch("time.time") - def test_tick_subsequent_tick_within_short_poll_interval(self, mock_ts): - # Assumes user stream tracker has NOT been receiving messages, Hence SHORT_POLL_INTERVAL in use - start_ts: float = self.start_timestamp - next_tick: float = start_ts + (self.exchange.SHORT_POLL_INTERVAL - 1) - - mock_ts.return_value = start_ts - self.exchange.tick(start_ts) - self.assertEqual(start_ts, self.exchange._last_timestamp) - self.assertTrue(self.exchange._poll_notifier.is_set()) - - self._simulate_reset_poll_notifier() - - mock_ts.return_value = next_tick - self.exchange.tick(next_tick) - self.assertEqual(next_tick, self.exchange._last_timestamp) - self.assertTrue(self.exchange._poll_notifier.is_set()) - - @patch("time.time") - def test_tick_subsequent_tick_exceed_short_poll_interval(self, mock_ts): - # Assumes user stream tracker has NOT been receiving messages, Hence SHORT_POLL_INTERVAL in use - start_ts: float = self.start_timestamp - next_tick: float = start_ts + (self.exchange.SHORT_POLL_INTERVAL + 1) - - mock_ts.return_value = start_ts - self.exchange.tick(start_ts) - self.assertEqual(start_ts, self.exchange._last_timestamp) - self.assertTrue(self.exchange._poll_notifier.is_set()) - - self._simulate_reset_poll_notifier() - - mock_ts.return_value = next_tick - self.exchange.tick(next_tick) - self.assertEqual(next_tick, self.exchange._last_timestamp) - self.assertTrue(self.exchange._poll_notifier.is_set()) - - @aioresponses() - def test_update_balances(self, mock_api): - self.assertEqual(0, len(self.exchange._account_balances)) - self.assertEqual(0, len(self.exchange._account_available_balances)) - - mock_response = self.balances_mock_data - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_BALANCE_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get( - regex_url, - body=json.dumps(mock_response), - ) - - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._update_balances() - ) - self.async_run_with_timeout(self.exchange_task) + "timezone": "UTC", + "serverTime": 1565246363776, + "rateLimits": [{}], + "exchangeFilters": [], + "symbols": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "ENABLED", + "baseAsset": self.base_asset, + "baseAssetPrecision": 8, + "quoteAsset": self.quote_asset, + "quotePrecision": 8, + "quoteAssetPrecision": 8, + "orderTypes": ["LIMIT", "LIMIT_MAKER"], + "icebergAllowed": True, + "ocoAllowed": True, + "isSpotTradingAllowed": True, + "isMarginTradingAllowed": True, + "permissions": [ + "SPOT", + "MARGIN" + ] + } + ] + } - self.assertEqual(Decimal(str(481.0)), self.exchange.get_balance(self.base_asset)) + @property + def order_creation_request_successful_mock_response(self): + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": self.expected_exchange_order_id, + "orderListId": -1, + "clientOrderId": "OID1", + "transactTime": 1507725176595 + } - @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.current_timestamp", new_callable=PropertyMock) - def test_update_order_status(self, mock_api, mock_ts): - # Simulates order being tracked - order: MexcInFlightOrder = MexcInFlightOrder( - "0", - "2628", - self.trading_pair, - OrderType.LIMIT, - TradeType.SELL, - Decimal(str(41720.83)), - Decimal("1"), - 1640001112.0, - "Working", - ) - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - self.exchange._last_poll_timestamp = 10 - ts: float = time.time() - mock_ts.return_value = ts - self.exchange._current_timestamp = ts - self.assertTrue(1, len(self.exchange.in_flight_orders)) - - # Add TradeHistory API Response - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_ORDER_DETAILS_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = { - "code": 200, - "data": [ + @property + def balance_request_mock_response_for_base_and_quote(self): + return { + "makerCommission": 15, + "takerCommission": 15, + "buyerCommission": 0, + "sellerCommission": 0, + "canTrade": True, + "canWithdraw": True, + "canDeposit": True, + "updateTime": 123456789, + "accountType": "SPOT", + "balances": [ + { + "asset": self.base_asset, + "free": "10.0", + "locked": "5.0" + }, { - "id": "504feca6ba6349e39c82262caf0be3f4", - "symbol": "MX_USDT", - "price": "3.001", - "quantity": "30", - "state": "CANCELED", - "type": "BID", - "deal_quantity": "0", - "deal_amount": "0", - "create_time": 1573117266000 + "asset": self.quote_asset, + "free": "2000", + "locked": "0.00000000" } + ], + "permissions": [ + "SPOT" ] } - mock_api.get(regex_url, body=json.dumps(mock_response)) - self.async_run_with_timeout(self.exchange._update_order_status()) - self.assertEqual(0, len(self.exchange.in_flight_orders)) - - @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.current_timestamp", new_callable=PropertyMock) - def test_update_order_status_error_response(self, mock_api, mock_ts): - - # Simulates order being tracked - order: MexcInFlightOrder = MexcInFlightOrder( - "0", - "2628", - self.trading_pair, - OrderType.LIMIT, - TradeType.SELL, - Decimal(str(41720.83)), - Decimal("1"), - creation_timestamp=1640001112.0) - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - self.assertTrue(1, len(self.exchange.in_flight_orders)) - - ts: float = time.time() - mock_ts.return_value = ts - self.exchange._current_timestamp = ts - - # Add TradeHistory API Response - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_ORDER_DETAILS_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = { - "result": False, - "errormsg": "Invalid Request", - "errorcode": 100, - "detail": None + @property + def balance_request_mock_response_only_base(self): + return { + "makerCommission": 15, + "takerCommission": 15, + "buyerCommission": 0, + "sellerCommission": 0, + "canTrade": True, + "canWithdraw": True, + "canDeposit": True, + "updateTime": 123456789, + "accountType": "SPOT", + "balances": [{"asset": self.base_asset, "free": "10.0", "locked": "5.0"}], + "permissions": ["SPOT"], } - mock_api.get(regex_url, body=json.dumps(mock_response)) - self.async_run_with_timeout(self.exchange._update_order_status()) - self.assertEqual(1, len(self.exchange.in_flight_orders)) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_balances", new_callable=AsyncMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_order_status", new_callable=AsyncMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.current_timestamp", new_callable=PropertyMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._reset_poll_notifier") - def test_status_polling_loop(self, _, mock_ts, mock_update_order_status, mock_balances): - mock_balances.return_value = None - mock_update_order_status.return_value = None + @property + def balance_event_websocket_update(self): + return { + "c": "spot@private.account.v3.api", + "d": { + "a": self.base_asset, + "c": 1564034571105, + "f": "10", + "fd": "-4.990689704", + "l": "5", + "ld": "4.990689704", + "o": "ENTRUST_PLACE" + }, + "t": 1564034571073 + } - ts: float = time.time() - mock_ts.return_value = ts - self.exchange._current_timestamp = ts + @property + def expected_latest_price(self): + return 9999.9 - with self.assertRaises(asyncio.TimeoutError): - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._status_polling_loop() - ) - self.exchange._poll_notifier.set() + @property + def expected_supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] - self.async_run_with_timeout(asyncio.wait_for(self.exchange_task, 2.0)) + @property + def expected_trading_rule(self): + return TradingRule( + trading_pair=self.trading_pair, + min_order_size=Decimal(self.trading_rules_request_mock_response["symbols"][0]["baseSizePrecision"]), + min_price_increment=Decimal( + f'1e-{self.trading_rules_request_mock_response["symbols"][0]["quotePrecision"]}'), + min_base_amount_increment=Decimal( + f'1e-{self.trading_rules_request_mock_response["symbols"][0]["baseAssetPrecision"]}'), + min_notional_size=Decimal(self.trading_rules_request_mock_response["symbols"][0]["quoteAmountPrecision"]), + ) - self.assertEqual(ts, self.exchange._last_poll_timestamp) + @property + def expected_logged_error_for_erroneous_trading_rule(self): + erroneous_rule = self.trading_rules_request_erroneous_mock_response["symbols"][0] + return f"Error parsing the trading pair rule {erroneous_rule}. Skipping." - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.current_timestamp", new_callable=PropertyMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._reset_poll_notifier") - @aioresponses() - def test_status_polling_loop_cancels(self, _, mock_ts, mock_api): - url = CONSTANTS.MEXC_BASE_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get(regex_url, exception=asyncio.CancelledError) - - ts: float = time.time() - mock_ts.return_value = ts - self.exchange._current_timestamp = ts - - with self.assertRaises(asyncio.CancelledError): - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._status_polling_loop() - ) - self.exchange._poll_notifier.set() - - self.async_run_with_timeout(self.exchange_task) - - self.assertEqual(0, self.exchange._last_poll_timestamp) - - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_balances", new_callable=AsyncMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_order_status", new_callable=AsyncMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.current_timestamp", new_callable=PropertyMock) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._reset_poll_notifier") - def test_status_polling_loop_exception_raised(self, _, mock_ts, mock_update_order_status, mock_balances): - mock_balances.side_effect = lambda: self._create_exception_and_unlock_test_with_event( - Exception("Dummy test error")) - mock_update_order_status.side_effect = lambda: self._create_exception_and_unlock_test_with_event( - Exception("Dummy test error")) - - ts: float = time.time() - mock_ts.return_value = ts - self.exchange._current_timestamp = ts - - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._status_polling_loop() - ) + @property + def expected_exchange_order_id(self): + return 28 - self.exchange._poll_notifier.set() + @property + def is_order_fill_http_update_included_in_status_update(self) -> bool: + return True - self.async_run_with_timeout(self.resume_test_event.wait()) + @property + def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: + return False - self.assertEqual(0, self.exchange._last_poll_timestamp) - self._is_logged("ERROR", "Unexpected error while in status polling loop. Error: ") + @property + def expected_partial_fill_price(self) -> Decimal: + return Decimal(10500) - def test_format_trading_rules_success(self): - instrument_info: List[Dict[str, Any]] = [{ - "symbol": f"{self.base_asset}_{self.quote_asset}", - "price_scale": 3, - "quantity_scale": 3, - "min_amount": "1", - }] + @property + def expected_partial_fill_amount(self) -> Decimal: + return Decimal("0.5") - result: List[str, TradingRule] = self.exchange._format_trading_rules(instrument_info) - self.assertTrue(self.trading_pair == result[0].trading_pair) + @property + def expected_fill_fee(self) -> TradeFeeBase: + return DeductedFromReturnsTradeFee( + percent_token=self.quote_asset, + flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("30"))]) - def test_format_trading_rules_failure(self): - # Simulate invalid API response - instrument_info: List[Dict[str, Any]] = [{}] + @property + def expected_fill_trade_id(self) -> str: + return str(30000) + + def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: + return f"{base_token}{quote_token}" + + def create_exchange_instance(self): + client_config_map = ClientConfigAdapter(ClientConfigMap()) + return MexcExchange( + client_config_map=client_config_map, + mexc_api_key="testAPIKey", + mexc_api_secret="testSecret", + trading_pairs=[self.trading_pair], + ) - result: Dict[str, TradingRule] = self.exchange._format_trading_rules(instrument_info) - self.assertTrue(self.trading_pair not in result) - self.assertTrue(self._is_logged("ERROR", 'Error parsing the trading pair rule {}. Skipping.')) + def validate_auth_credentials_present(self, request_call: RequestCall): + self._validate_auth_credentials_taking_parameters_from_argument( + request_call_tuple=request_call, + params=request_call.kwargs["params"] or request_call.kwargs["data"] + ) - @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.current_timestamp", new_callable=PropertyMock) - def test_update_trading_rules(self, mock_api, mock_ts): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_SYMBOL_URL + def validate_order_creation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = dict(request_call.kwargs["data"]) + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_data["symbol"]) + self.assertEqual(order.trade_type.name.upper(), request_data["side"]) + self.assertEqual(MexcExchange.mexc_order_type(OrderType.LIMIT), request_data["type"]) + self.assertEqual(Decimal("100"), Decimal(request_data["quantity"])) + self.assertEqual(Decimal("10000"), Decimal(request_data["price"])) + self.assertEqual(order.client_order_id, request_data["newClientOrderId"]) + + def validate_order_cancelation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = dict(request_call.kwargs["params"]) + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + request_data["symbol"]) + self.assertEqual(order.client_order_id, request_data["origClientOrderId"]) + + def validate_order_status_request(self, order: InFlightOrder, request_call: RequestCall): + request_params = request_call.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + request_params["symbol"]) + self.assertEqual(order.client_order_id, request_params["origClientOrderId"]) + + def validate_trades_request(self, order: InFlightOrder, request_call: RequestCall): + request_params = request_call.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + request_params["symbol"]) + self.assertEqual(order.exchange_order_id, str(request_params["orderId"])) + + def configure_successful_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = { - "code": 200, - "data": [ - { - "symbol": "MX_USDT", - "state": "ENABLED", - "price_scale": 4, - "quantity_scale": 2, - "min_amount": "5", - "max_amount": "5000000", - "maker_fee_rate": "0.002", - "taker_fee_rate": "0.002", - "limited": False, - "etf_mark": 0, - "symbol_partition": "MAIN" - } - ] + response = self._order_cancelation_request_successful_mock_response(order=order) + mock_api.delete(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_erroneous_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.delete(regex_url, status=400, callback=callback) + return url + + def configure_order_not_found_error_cancelation_response( + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2011, "msg": "Unknown order sent."} + mock_api.delete(regex_url, status=400, body=json.dumps(response), callback=callback) + return url + + def configure_one_successful_one_erroneous_cancel_all_response( + self, + successful_order: InFlightOrder, + erroneous_order: InFlightOrder, + mock_api: aioresponses) -> List[str]: + """ + :return: a list of all configured URLs for the cancelations + """ + all_urls = [] + url = self.configure_successful_cancelation_response(order=successful_order, mock_api=mock_api) + all_urls.append(url) + url = self.configure_erroneous_cancelation_response(order=erroneous_order, mock_api=mock_api) + all_urls.append(url) + return all_urls + + def configure_completely_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_completely_filled_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_canceled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_canceled_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_erroneous_http_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(path_url=CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(url + r"\?.*") + mock_api.get(regex_url, status=400, callback=callback) + return url + + def configure_open_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + """ + :return: the URL configured + """ + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_open_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_http_error_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get(regex_url, status=401, callback=callback) + return url + + def configure_partially_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = self._order_status_request_partially_filled_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_order_not_found_error_order_status_response( + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> List[str]: + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2013, "msg": "Order does not exist."} + mock_api.get(regex_url, body=json.dumps(response), status=400, callback=callback) + return [url] + + def configure_partial_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(path_url=CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(url + r"\?.*") + response = self._order_fills_request_partial_fill_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def configure_full_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.private_rest_url(path_url=CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(url + r"\?.*") + response = self._order_fills_request_full_fill_mock_response(order=order) + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + return url + + def order_event_for_new_order_websocket_update(self, order: InFlightOrder): + return { + "c": "spot@private.orders.v3.api", + "d": { + "A": 8.0, + "O": 1661938138000, + "S": 1, + "V": 10, + "a": 8, + "c": order.client_order_id, + "i": order.exchange_order_id, + "m": 0, + "o": 1, + "p": order.price, + "s": 1, + "v": order.amount, + "ap": 0, + "cv": 0, + "ca": 0 + }, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "t": 1499405658657 } - mock_api.get(regex_url, body=json.dumps(mock_response)) - self.exchange._last_poll_timestamp = 10 - ts: float = time.time() - mock_ts.return_value = ts - self.exchange._current_timestamp = ts - task = asyncio.get_event_loop().create_task( - self.exchange._update_trading_rules() - ) - self.async_run_with_timeout(task) + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return { + "c": "spot@private.orders.v3.api", + "d": { + "A": 8.0, + "O": 1661938138000, + "S": 1, + "V": 10, + "a": 8, + "c": order.client_order_id, + "i": order.exchange_order_id, + "m": 0, + "o": 1, + "p": order.price, + "s": 4, + "v": order.amount, + "ap": 0, + "cv": 0, + "ca": 0 + }, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "t": 1499405658657 + } - self.assertTrue(self.trading_pair in self.exchange.trading_rules) + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "c": "spot@private.orders.v3.api", + "d": { + "A": 8.0, + "O": 1661938138000, + "S": 1, + "V": 10, + "a": 8, + "c": order.client_order_id, + "i": order.exchange_order_id, + "m": 0, + "o": 1, + "p": order.price, + "s": 2, + "v": order.amount, + "ap": 0, + "cv": 0, + "ca": 0 + }, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "t": 1499405658657 + } - self.exchange.trading_rules[self.trading_pair] + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "c": "spot@private.deals.v3.api", + "d": { + "p": order.price, + "v": order.amount, + "a": order.price * order.amount, + "S": 1, + "T": 1678901086198, + "t": "5bbb6ad8b4474570b155610e3960cd", + "c": order.client_order_id, + "i": order.exchange_order_id, + "m": 0, + "st": 0, + "n": Decimal(self.expected_fill_fee.flat_fees[0].amount), + "N": self.quote_asset + }, + "s": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "t": 1661938980285 + } - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_trading_rules", - new_callable=AsyncMock) - def test_trading_rules_polling_loop(self, mock_update): - # No Side Effects expected - mock_update.return_value = None - with self.assertRaises(asyncio.TimeoutError): - self.exchange_task = asyncio.get_event_loop().create_task(self.exchange._trading_rules_polling_loop()) + @aioresponses() + @patch("hummingbot.connector.time_synchronizer.TimeSynchronizer._current_seconds_counter") + def test_update_time_synchronizer_successfully(self, mock_api, seconds_counter_mock): + request_sent_event = asyncio.Event() + seconds_counter_mock.side_effect = [0, 0, 0] - self.async_run_with_timeout( - asyncio.wait_for(self.exchange_task, 1.0) - ) + self.exchange._time_synchronizer.clear_time_offset_ms_samples() + url = web_utils.private_rest_url(CONSTANTS.SERVER_TIME_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_trading_rules", - new_callable=AsyncMock) - def test_trading_rules_polling_loop_cancels(self, mock_update): - mock_update.side_effect = asyncio.CancelledError + response = {"serverTime": 1640000003000} - with self.assertRaises(asyncio.CancelledError): - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._trading_rules_polling_loop() - ) + mock_api.get(regex_url, + body=json.dumps(response), + callback=lambda *args, **kwargs: request_sent_event.set()) - self.async_run_with_timeout(self.exchange_task) + self.async_run_with_timeout(self.exchange._update_time_synchronizer()) - self.assertEqual(0, self.exchange._last_poll_timestamp) + self.assertEqual(response["serverTime"] * 1e-3, self.exchange._time_synchronizer.time()) - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange._update_trading_rules", - new_callable=AsyncMock) - def test_trading_rules_polling_loop_exception_raised(self, mock_update): - mock_update.side_effect = lambda: self._create_exception_and_unlock_test_with_event( - Exception("Dummy test error")) + @aioresponses() + def test_update_time_synchronizer_failure_is_logged(self, mock_api): + request_sent_event = asyncio.Event() - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._trading_rules_polling_loop() - ) + url = web_utils.private_rest_url(CONSTANTS.SERVER_TIME_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = {"code": -1121, "msg": "Dummy error"} - self.async_run_with_timeout(self.resume_test_event.wait()) + mock_api.get(regex_url, + body=json.dumps(response), + callback=lambda *args, **kwargs: request_sent_event.set()) - self._is_logged("ERROR", "Unexpected error while fetching trading rules. Error: ") + self.async_run_with_timeout(self.exchange._update_time_synchronizer()) + + self.assertTrue(self.is_logged("NETWORK", "Error getting server time.")) @aioresponses() - def test_check_network_succeeds_when_ping_replies_pong(self, mock_api): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_PING_URL + def test_update_time_synchronizer_raises_cancelled_error(self, mock_api): + url = web_utils.private_rest_url(CONSTANTS.SERVER_TIME_PATH_URL) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = {"code": 200} - mock_api.get(regex_url, body=json.dumps(mock_response)) - result = self.async_run_with_timeout(self.exchange.check_network()) + mock_api.get(regex_url, + exception=asyncio.CancelledError) - self.assertEqual(NetworkStatus.CONNECTED, result) + self.assertRaises( + asyncio.CancelledError, + self.async_run_with_timeout, self.exchange._update_time_synchronizer()) @aioresponses() - def test_check_network_fails_when_ping_does_not_reply_pong(self, mock_api): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_PING_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = {"code": 100} - mock_api.get(regex_url, body=json.dumps(mock_response)) + def test_update_order_fills_from_trades_triggers_filled_event(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) - result = self.async_run_with_timeout(self.exchange.check_network()) - self.assertEqual(NetworkStatus.NOT_CONNECTED, result) + self.exchange.start_tracking_order( + order_id="OID1", + exchange_order_id="100234", + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + order = self.exchange.in_flight_orders["OID1"] - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_PING_URL + url = web_utils.private_rest_url(CONSTANTS.MY_TRADES_PATH_URL) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = {} - mock_api.get(regex_url, body=json.dumps(mock_response)) - result = self.async_run_with_timeout(self.exchange.check_network()) - self.assertEqual(NetworkStatus.NOT_CONNECTED, result) + trade_fill = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "id": 28457, + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "price": "9999", + "qty": "1", + "quoteQty": "48.000012", + "commission": "10.10000000", + "commissionAsset": self.quote_asset, + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } - @aioresponses() - def test_check_network_fails_when_ping_returns_error_code(self, mock_api): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_PING_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_response = {"code": 100} - mock_api.get(regex_url, body=json.dumps(mock_response), status=404) - - result = self.async_run_with_timeout(self.exchange.check_network()) - - self.assertEqual(NetworkStatus.NOT_CONNECTED, result) - - def test_get_order_book_for_valid_trading_pair(self): - dummy_order_book = MexcOrderBook() - self.exchange.order_book_tracker.order_books["BTC-USDT"] = dummy_order_book - self.assertEqual(dummy_order_book, self.exchange.get_order_book("BTC-USDT")) - - def test_get_order_book_for_invalid_trading_pair_raises_error(self): - self.assertRaisesRegex(ValueError, - "No order book exists for 'BTC-USDT'", - self.exchange.get_order_book, - "BTC-USDT") - - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.execute_buy", new_callable=AsyncMock) - def test_buy(self, mock_create): - mock_create.side_effect = None - order_details = [ - self.trading_pair, - Decimal(1.0), - Decimal(10.0), - OrderType.LIMIT, - ] + trade_fill_non_tracked_order = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "id": 30000, + "orderId": 99999, + "orderListId": -1, + "price": "4.00000100", + "qty": "12.00000000", + "quoteQty": "48.000012", + "commission": "10.10000000", + "commissionAsset": "BNB", + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } - # Note: BUY simply returns immediately with the client order id. - order_id: str = self.exchange.buy(*order_details) + mock_response = [trade_fill, trade_fill_non_tracked_order] + mock_api.get(regex_url, body=json.dumps(mock_response)) - # Order ID is simply a timestamp. The assertion below checks if it is created within 1 sec - self.assertTrue(len(order_id) > 0) + self.exchange.add_exchange_order_ids_from_market_recorder( + {str(trade_fill_non_tracked_order["orderId"]): "OID99"}) + + self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) + + request = self._all_executed_requests(mock_api, url)[0] + self.validate_auth_credentials_present(request) + request_params = request.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(Decimal(trade_fill["price"]), fill_event.price) + self.assertEqual(Decimal(trade_fill["qty"]), fill_event.amount) + self.assertEqual(0.0, fill_event.trade_fee.percent) + self.assertEqual([TokenAmount(trade_fill["commissionAsset"], Decimal(trade_fill["commission"]))], + fill_event.trade_fee.flat_fees) + + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[1] + self.assertEqual(float(trade_fill_non_tracked_order["time"]) * 1e-3, fill_event.timestamp) + self.assertEqual("OID99", fill_event.order_id) + self.assertEqual(self.trading_pair, fill_event.trading_pair) + self.assertEqual(TradeType.BUY, fill_event.trade_type) + self.assertEqual(OrderType.LIMIT, fill_event.order_type) + self.assertEqual(Decimal(trade_fill_non_tracked_order["price"]), fill_event.price) + self.assertEqual(Decimal(trade_fill_non_tracked_order["qty"]), fill_event.amount) + self.assertEqual(0.0, fill_event.trade_fee.percent) + self.assertEqual([ + TokenAmount( + trade_fill_non_tracked_order["commissionAsset"], + Decimal(trade_fill_non_tracked_order["commission"]))], + fill_event.trade_fee.flat_fees) + self.assertTrue(self.is_logged( + "INFO", + f"Recreating missing trade in TradeFill: {trade_fill_non_tracked_order}" + )) - def test_sell(self): - order_details = [ - self.trading_pair, - Decimal(1.0), - Decimal(10.0), - OrderType.LIMIT, - ] + @aioresponses() + def test_update_order_fills_request_parameters(self, mock_api): + self.exchange._set_current_timestamp(0) + self.exchange._last_poll_timestamp = -1 - # Note: SELL simply returns immediately with the client order id. - order_id: str = self.exchange.buy(*order_details) + url = web_utils.private_rest_url(CONSTANTS.MY_TRADES_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - # Order ID is simply a timestamp. The assertion below checks if it is created within 1 sec - self.assertTrue(len(order_id) > 0) + mock_response = [] + mock_api.get(regex_url, body=json.dumps(mock_response)) - @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.quantize_order_amount") - def test_create_limit_order(self, mock_post, amount_mock): - amount_mock.return_value = Decimal("1") - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_PLACE_ORDER - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - expected_response = {"code": 200, "data": "123"} - mock_post.post(regex_url, body=json.dumps(expected_response)) - - self._simulate_trading_rules_initialized() - - order_details = [ - TradeType.BUY, - str(1), - self.trading_pair, - Decimal(1.0), - Decimal(10.0), - OrderType.LIMIT, - ] + self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) - self.assertEqual(0, len(self.exchange.in_flight_orders)) - future = self._simulate_create_order(*order_details) - self.async_run_with_timeout(future) + request = self._all_executed_requests(mock_api, url)[0] + self.validate_auth_credentials_present(request) + request_params = request.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + self.assertNotIn("startTime", request_params) - self.assertEqual(1, len(self.exchange.in_flight_orders)) - self._is_logged("INFO", - f"Created {OrderType.LIMIT.name} {TradeType.BUY.name} order {123} for {Decimal(1.0)} {self.trading_pair}") + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + self.exchange._last_trades_poll_mexc_timestamp = 10 + self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) - tracked_order: MexcInFlightOrder = self.exchange.in_flight_orders["1"] - self.assertEqual(tracked_order.client_order_id, "1") - self.assertEqual(tracked_order.exchange_order_id, "123") - self.assertEqual(tracked_order.last_state, "NEW") - self.assertEqual(tracked_order.trading_pair, self.trading_pair) - self.assertEqual(tracked_order.price, Decimal(10.0)) - self.assertEqual(tracked_order.amount, Decimal(1.0)) - self.assertEqual(tracked_order.trade_type, TradeType.BUY) + request = self._all_executed_requests(mock_api, url)[1] + self.validate_auth_credentials_present(request) + request_params = request.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + self.assertEqual(10 * 1e3, request_params["startTime"]) @aioresponses() - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.quantize_order_amount") - def test_create_market_order(self, mock_post, amount_mock): - amount_mock.return_value = Decimal("1") - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_PLACE_ORDER + def test_update_order_fills_from_trades_with_repeated_fill_triggers_only_one_event(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + + url = web_utils.private_rest_url(CONSTANTS.MY_TRADES_PATH_URL) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - expected_response = {"code": 200, "data": "123"} - mock_post.post(regex_url, body=json.dumps(expected_response)) - - self._simulate_trading_rules_initialized() - - order_details = [ - TradeType.BUY, - str(1), - self.trading_pair, - Decimal(1.0), - Decimal(10.0), - OrderType.LIMIT_MAKER, - ] - self.assertEqual(0, len(self.exchange.in_flight_orders)) - future = self._simulate_create_order(*order_details) - self.async_run_with_timeout(future) + trade_fill_non_tracked_order = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "id": 30000, + "orderId": 99999, + "orderListId": -1, + "price": "4.00000100", + "qty": "12.00000000", + "quoteQty": "48.000012", + "commission": "10.10000000", + "commissionAsset": "BNB", + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } - self.assertEqual(1, len(self.exchange.in_flight_orders)) - self._is_logged("INFO", - f"Created {OrderType.LIMIT.name} {TradeType.BUY.name} order {123} for {Decimal(1.0)} {self.trading_pair}") + mock_response = [trade_fill_non_tracked_order, trade_fill_non_tracked_order] + mock_api.get(regex_url, body=json.dumps(mock_response)) - tracked_order: MexcInFlightOrder = self.exchange.in_flight_orders["1"] - self.assertEqual(tracked_order.client_order_id, "1") - self.assertEqual(tracked_order.exchange_order_id, "123") - self.assertEqual(tracked_order.last_state, "NEW") - self.assertEqual(tracked_order.trading_pair, self.trading_pair) - self.assertEqual(tracked_order.amount, Decimal(1.0)) - self.assertEqual(tracked_order.trade_type, TradeType.BUY) + self.exchange.add_exchange_order_ids_from_market_recorder( + {str(trade_fill_non_tracked_order["orderId"]): "OID99"}) + + self.async_run_with_timeout(self.exchange._update_order_fills_from_trades()) + + request = self._all_executed_requests(mock_api, url)[0] + self.validate_auth_credentials_present(request) + request_params = request.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + + self.assertEqual(1, len(self.order_filled_logger.event_log)) + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(float(trade_fill_non_tracked_order["time"]) * 1e-3, fill_event.timestamp) + self.assertEqual("OID99", fill_event.order_id) + self.assertEqual(self.trading_pair, fill_event.trading_pair) + self.assertEqual(TradeType.BUY, fill_event.trade_type) + self.assertEqual(OrderType.LIMIT, fill_event.order_type) + self.assertEqual(Decimal(trade_fill_non_tracked_order["price"]), fill_event.price) + self.assertEqual(Decimal(trade_fill_non_tracked_order["qty"]), fill_event.amount) + self.assertEqual(0.0, fill_event.trade_fee.percent) + self.assertEqual([ + TokenAmount(trade_fill_non_tracked_order["commissionAsset"], + Decimal(trade_fill_non_tracked_order["commission"]))], + fill_event.trade_fee.flat_fees) + self.assertTrue(self.is_logged( + "INFO", + f"Recreating missing trade in TradeFill: {trade_fill_non_tracked_order}" + )) @aioresponses() - def test_detect_created_order_server_acknowledgement(self, mock_api): - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_BALANCE_URL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_api.get(regex_url, body=json.dumps(self.balances_mock_data)) - - self.exchange.start_tracking_order(order_id="sell-MX-USDT-1638156451005305", - exchange_order_id="40728558ead64032a676e6f0a4afc4ca", - trading_pair="MX-USDT", - trade_type=TradeType.SELL, - price=Decimal("3.1504"), - amount=Decimal("6.3008"), - order_type=OrderType.LIMIT) - _user_data = self.user_stream_data - _user_data.get("data")["status"] = 2 - mock_user_stream = AsyncMock() - mock_user_stream.get.side_effect = functools.partial(self._return_calculation_and_set_done_event, - lambda: _user_data) - self.exchange._user_stream_tracker._user_stream = mock_user_stream - self.exchange_task = asyncio.get_event_loop().create_task( - self.exchange._user_stream_event_listener()) - self.async_run_with_timeout(self.resume_test_event.wait()) - - self.assertEqual(1, len(self.exchange.in_flight_orders)) - tracked_order: MexcInFlightOrder = self.exchange.in_flight_orders["sell-MX-USDT-1638156451005305"] - self.assertEqual(tracked_order.last_state, "NEW") + def test_update_order_status_when_failed(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) - @aioresponses() - def test_execute_cancel_success(self, mock_cancel): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", + self.exchange.start_tracking_order( + order_id="OID1", + exchange_order_id="100234", trading_pair=self.trading_pair, order_type=OrderType.LIMIT, trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0, - initial_state="Working", + price=Decimal("10000"), + amount=Decimal("1"), ) + order = self.exchange.in_flight_orders["OID1"] - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - - mock_response = { - "code": 200, - "data": {"123": "success"} - } - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_ORDER_CANCEL + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_cancel.delete(regex_url, body=json.dumps(mock_response)) - self.mocking_assistant.configure_http_request_mock(mock_cancel) - self.mocking_assistant.add_http_response(mock_cancel, 200, mock_response, "") + order_status = { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": "10000.0", + "origQty": "1.0", + "executedQty": "0.0", + "cummulativeQuoteQty": "0.0", + "status": "REJECTED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY", + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": "10000.000000" + } - result = self.async_run_with_timeout( - self.exchange.execute_cancel(self.trading_pair, order.client_order_id) - ) - self.assertIsNone(result) + mock_response = order_status + mock_api.get(regex_url, body=json.dumps(mock_response)) - @aioresponses() - def test_execute_cancel_all_success(self, mock_post_request): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", - trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0) - - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - - mock_response = { - "code": 200, - "data": { - "0": "success" - } - } - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_ORDER_CANCEL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_post_request.delete(regex_url, body=json.dumps(mock_response)) + self.async_run_with_timeout(self.exchange._update_order_status()) - cancellation_results = self.async_run_with_timeout( - self.exchange.cancel_all(10) + request = self._all_executed_requests(mock_api, url)[0] + self.validate_auth_credentials_present(request) + request_params = request.kwargs["params"] + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_params["symbol"]) + self.assertEqual(order.client_order_id, request_params["origClientOrderId"]) + + failure_event: MarketOrderFailureEvent = self.order_failure_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, failure_event.timestamp) + self.assertEqual(order.client_order_id, failure_event.order_id) + self.assertEqual(order.order_type, failure_event.order_type) + self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue( + self.is_logged( + "INFO", + f"Order {order.client_order_id} has failed. Order Update: OrderUpdate(trading_pair='{self.trading_pair}'," + f" update_timestamp={order_status['updateTime'] * 1e-3}, new_state={repr(OrderState.FAILED)}, " + f"client_order_id='{order.client_order_id}', exchange_order_id='{order.exchange_order_id}', " + "misc_updates=None)") ) - self.assertEqual(1, len(cancellation_results)) - self.assertEqual("0", cancellation_results[0].order_id) - self.assertTrue(cancellation_results[0].success) + @patch("hummingbot.connector.utils.get_tracking_nonce") + def test_client_order_id_on_order(self, mocked_nonce): + mocked_nonce.return_value = 7 - @aioresponses() - @patch("hummingbot.client.hummingbot_application.HummingbotApplication") - def test_execute_cancel_fail(self, mock_cancel, mock_main_app): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", + result = self.exchange.buy( trading_pair=self.trading_pair, + amount=Decimal("1"), order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0, - initial_state="Working", + price=Decimal("2"), ) - - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - mock_response = { - "code": 100, - "data": {"123": "success"} - } - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_ORDER_CANCEL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_cancel.delete(regex_url, body=json.dumps(mock_response)) - - self.async_run_with_timeout( - self.exchange.execute_cancel(self.trading_pair, order.client_order_id) + expected_client_order_id = get_new_client_order_id( + is_buy=True, + trading_pair=self.trading_pair, + hbot_order_id_prefix=CONSTANTS.HBOT_ORDER_ID_PREFIX, + max_id_len=CONSTANTS.MAX_ORDER_ID_LEN, ) - self._is_logged("NETWORK", "Failed to cancel order 0 : MexcAPIError('Order could not be canceled')") + self.assertEqual(result, expected_client_order_id) - @aioresponses() - def test_execute_cancel_cancels(self, mock_cancel): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", + result = self.exchange.sell( trading_pair=self.trading_pair, + amount=Decimal("1"), order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0, - initial_state="Working", + price=Decimal("2"), ) - - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - url = CONSTANTS.MEXC_BASE_URL + CONSTANTS.MEXC_ORDER_CANCEL - regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) - mock_cancel.delete(regex_url, exception=asyncio.CancelledError) - - with self.assertRaises(asyncio.CancelledError): - self.async_run_with_timeout( - self.exchange.execute_cancel(self.trading_pair, order.client_order_id) - ) - - @patch("hummingbot.connector.exchange.mexc.mexc_exchange.MexcExchange.execute_cancel", new_callable=AsyncMock) - def test_cancel(self, mock_cancel): - mock_cancel.return_value = None - - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", + expected_client_order_id = get_new_client_order_id( + is_buy=False, trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0) - - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) - - # Note: BUY simply returns immediately with the client order id. - return_val: str = self.exchange.cancel(self.trading_pair, order.client_order_id) - - # Order ID is simply a timestamp. The assertion below checks if it is created within 1 sec - self.assertTrue(order.client_order_id, return_val) - - def test_ready_trading_required_all_ready(self): - self.exchange._trading_required = True - - # Simulate all components initialized - self.exchange._account_id = 1 - self.exchange.order_book_tracker._order_books_initialized.set() - self.exchange._account_balances = { - self.base_asset: Decimal(str(10.0)) - } - self._simulate_trading_rules_initialized() - self.exchange._user_stream_tracker.data_source._last_recv_time = 1 - - self.assertTrue(self.exchange.ready) - - def test_ready_trading_required_not_ready(self): - self.exchange._trading_required = True - - # Simulate all components but account_id not initialized - self.exchange._account_id = None - self.exchange.order_book_tracker._order_books_initialized.set() - self.exchange._account_balances = {} - self._simulate_trading_rules_initialized() - self.exchange._user_stream_tracker.data_source._last_recv_time = 0 + hbot_order_id_prefix=CONSTANTS.HBOT_ORDER_ID_PREFIX, + max_id_len=CONSTANTS.MAX_ORDER_ID_LEN, + ) - self.assertFalse(self.exchange.ready) + self.assertEqual(result, expected_client_order_id) - def test_ready_trading_not_required_ready(self): - self.exchange._trading_required = False + def test_time_synchronizer_related_request_error_detection(self): + exception = IOError("Error executing request POST https://api.mexc.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1021,'msg':'Timestamp for this request is outside of the recvWindow.'}") + self.assertTrue(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) - # Simulate all components but account_id not initialized - self.exchange._account_id = None - self.exchange.order_book_tracker._order_books_initialized.set() - self.exchange._account_balances = {} - self._simulate_trading_rules_initialized() - self.exchange._user_stream_tracker.data_source._last_recv_time = 0 + exception = IOError("Error executing request POST https://api.mexc.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1021,'msg':'Timestamp for this request was 1000ms ahead of the server's " + "time.'}") + self.assertTrue(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) - self.assertTrue(self.exchange.ready) + exception = IOError("Error executing request POST https://api.mexc.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1022,'msg':'Timestamp for this request was 1000ms ahead of the server's " + "time.'}") + self.assertFalse(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) - def test_ready_trading_not_required_not_ready(self): - self.exchange._trading_required = False - self.assertFalse(self.exchange.ready) + exception = IOError("Error executing request POST https://api.mexc.com/api/v3/order. HTTP status is 400. " + "Error: {'code':-1021,'msg':'Other error.'}") + self.assertFalse(self.exchange._is_request_exception_related_to_time_synchronizer(exception)) - def test_limit_orders(self): - self.assertEqual(0, len(self.exchange.limit_orders)) + @aioresponses() + def test_place_order_manage_server_overloaded_error_unkown_order(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_response = {"code": -1003, "msg": "Unknown error, please check your request or try again later."} + mock_api.post(regex_url, body=json.dumps(mock_response), status=503) - # Simulate orders being placed and tracked - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", + o_id, transact_time = self.async_run_with_timeout(self.exchange._place_order( + order_id="test_order_id", trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, + amount=Decimal("1"), trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0) - - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) + order_type=OrderType.LIMIT, + price=Decimal("2"), + )) + self.assertEqual(o_id, "UNKNOWN") - self.assertEqual(1, len(self.exchange.limit_orders)) + @aioresponses() + def test_place_order_manage_server_overloaded_error_failure(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + self.exchange._last_poll_timestamp = (self.exchange.current_timestamp - + self.exchange.UPDATE_ORDER_STATUS_MIN_INTERVAL - 1) - def test_tracking_states_order_not_done(self): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", - trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0) + url = web_utils.private_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_response = {"code": -1003, "msg": "Service Unavailable."} + mock_api.post(regex_url, body=json.dumps(mock_response), status=503) + + self.assertRaises( + IOError, + self.async_run_with_timeout, + self.exchange._place_order( + order_id="test_order_id", + trading_pair=self.trading_pair, + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("2"), + )) + + mock_response = {"code": -1003, "msg": "Internal error; unable to process your request. Please try again."} + mock_api.post(regex_url, body=json.dumps(mock_response), status=503) + + self.assertRaises( + IOError, + self.async_run_with_timeout, + self.exchange._place_order( + order_id="test_order_id", + trading_pair=self.trading_pair, + amount=Decimal("1"), + trade_type=TradeType.BUY, + order_type=OrderType.LIMIT, + price=Decimal("2"), + )) + + def test_format_trading_rules__min_notional_present(self): + trading_rules = [{ + "symbol": "COINALPHAHBOT", + "baseSizePrecision": 1e-8, + "quotePrecision": 8, + "baseAssetPrecision": 8, + "status": "ENABLED", + "quoteAmountPrecision": "0.001", + "orderTypes": ["LIMIT", "MARKET"], + "filters": [ + { + "filterType": "PRICE_FILTER", + "minPrice": "0.00000100", + "maxPrice": "100000.00000000", + "tickSize": "0.00000100" + }, { + "filterType": "LOT_SIZE", + "minQty": "0.00100000", + "maxQty": "100000.00000000", + "stepSize": "0.00100000" + }, { + "filterType": "MIN_NOTIONAL", + "minNotional": "0.00300000" + } + ], + "permissions": [ + "SPOT" + ] + }] + exchange_info = {"symbols": trading_rules} - order_json = order.to_json() + result = self.async_run_with_timeout(self.exchange._format_trading_rules(exchange_info)) - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) + self.assertEqual(result[0].min_notional_size, Decimal("0.00100000")) - self.assertEqual(1, len(self.exchange.tracking_states)) - self.assertEqual(order_json, self.exchange.tracking_states[order.client_order_id]) + def _validate_auth_credentials_taking_parameters_from_argument(self, + request_call_tuple: RequestCall, + params: Dict[str, Any]): + self.assertIn("timestamp", params) + self.assertIn("signature", params) + request_headers = request_call_tuple.kwargs["headers"] + self.assertIn("X-MEXC-APIKEY", request_headers) + self.assertEqual("testAPIKey", request_headers["X-MEXC-APIKEY"]) - def test_tracking_states_order_done(self): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", - trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0, - initial_state="FILLED" - ) + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "origClientOrderId": order.exchange_order_id or "dummyOrdId", + "orderId": 4, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(Decimal("0")), + "cummulativeQuoteQty": str(Decimal("0")), + "status": "NEW", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY" + } - self.exchange._in_flight_orders.update({ - order.client_order_id: order - }) + def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(order.amount), + "cummulativeQuoteQty": str(order.price + Decimal(2)), + "status": "FILLED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "BUY", + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } - self.assertEqual(0, len(self.exchange.tracking_states)) + def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": "0.0", + "cummulativeQuoteQty": "10000.0", + "status": "CANCELED", + "timeInForce": "GTC", + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } - def test_restore_tracking_states(self): - order: MexcInFlightOrder = MexcInFlightOrder( - client_order_id="0", - exchange_order_id="123", - trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal(10.0), - amount=Decimal(1.0), - creation_timestamp=1640001112.0) + def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": "0.0", + "cummulativeQuoteQty": "10000.0", + "status": "NEW", + "timeInForce": "GTC", + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } - order_json = order.to_json() + def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "orderId": order.exchange_order_id, + "orderListId": -1, + "clientOrderId": order.client_order_id, + "price": str(order.price), + "origQty": str(order.amount), + "executedQty": str(order.amount), + "cummulativeQuoteQty": str(self.expected_partial_fill_amount * order.price), + "status": "PARTIALLY_FILLED", + "timeInForce": "GTC", + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "stopPrice": "0.0", + "icebergQty": "0.0", + "time": 1499827319559, + "updateTime": 1499827319559, + "isWorking": True, + "origQuoteOrderQty": str(order.price * order.amount) + } - self.exchange.restore_tracking_states({order.client_order_id: order_json}) + def _order_fills_request_partial_fill_mock_response(self, order: InFlightOrder): + return [ + { + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "id": self.expected_fill_trade_id, + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "price": str(self.expected_partial_fill_price), + "qty": str(self.expected_partial_fill_amount), + "quoteQty": str(self.expected_partial_fill_amount * self.expected_partial_fill_price), + "commission": str(self.expected_fill_fee.flat_fees[0].amount), + "commissionAsset": self.expected_fill_fee.flat_fees[0].token, + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + ] - self.assertEqual(1, len(self.exchange.in_flight_orders)) - self.assertEqual(str(self.exchange.in_flight_orders[order.client_order_id]), str(order)) + def _order_fills_request_full_fill_mock_response(self, order: InFlightOrder): + return [ + { + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "id": self.expected_fill_trade_id, + "orderId": int(order.exchange_order_id), + "orderListId": -1, + "price": str(order.price), + "qty": str(order.amount), + "quoteQty": str(order.amount * order.price), + "commission": str(self.expected_fill_fee.flat_fees[0].amount), + "commissionAsset": self.expected_fill_fee.flat_fees[0].token, + "time": 1499865549590, + "isBuyer": True, + "isMaker": False, + "isBestMatch": True + } + ] diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_in_flight_order.py b/test/hummingbot/connector/exchange/mexc/test_mexc_in_flight_order.py deleted file mode 100644 index 8dad02e89d..0000000000 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_in_flight_order.py +++ /dev/null @@ -1,98 +0,0 @@ -from decimal import Decimal -from unittest import TestCase - -from hummingbot.connector.exchange.mexc.mexc_in_flight_order import MexcInFlightOrder -from hummingbot.core.data_type.common import OrderType, TradeType - - -class MexcInFlightOrderTests(TestCase): - - def _example_json(self): - return {"client_order_id": "C1", - "exchange_order_id": "1", - "trading_pair": "BTC-USDT", - "order_type": "LIMIT", - "trade_type": "BUY", - "price": "35000", - "amount": "1.1", - "creation_timestamp": 1640001112.0, - "last_state": "Working", - "executed_amount_base": "0.5", - "executed_amount_quote": "15000", - "fee_asset": "BTC", - "fee_paid": "0"} - - def test_instance_creation(self): - order = MexcInFlightOrder(client_order_id="C1", - exchange_order_id="1", - trading_pair="BTC-USDT", - order_type=OrderType.LIMIT, - trade_type=TradeType.SELL, - price=Decimal("35000"), - amount=Decimal("1.1"), - creation_timestamp=1640001112.0) - - self.assertEqual("C1", order.client_order_id) - self.assertEqual("1", order.exchange_order_id) - self.assertEqual("BTC-USDT", order.trading_pair) - self.assertEqual(OrderType.LIMIT, order.order_type) - self.assertEqual(TradeType.SELL, order.trade_type) - self.assertEqual(Decimal("35000"), order.price) - self.assertEqual(Decimal("1.1"), order.amount) - self.assertEqual(Decimal("0"), order.executed_amount_base) - self.assertEqual(Decimal("0"), order.executed_amount_quote) - self.assertEqual(order.quote_asset, order.fee_asset) - self.assertEqual(Decimal("0"), order.fee_paid) - - def test_create_from_json(self): - order = MexcInFlightOrder.from_json(self._example_json()) - - self.assertEqual("C1", order.client_order_id) - self.assertEqual("1", order.exchange_order_id) - self.assertEqual("BTC-USDT", order.trading_pair) - self.assertEqual(OrderType.LIMIT, order.order_type) - self.assertEqual(TradeType.BUY, order.trade_type) - self.assertEqual(Decimal("35000"), order.price) - self.assertEqual(Decimal("1.1"), order.amount) - self.assertEqual(Decimal("0.5"), order.executed_amount_base) - self.assertEqual(Decimal("15000"), order.executed_amount_quote) - self.assertEqual(order.base_asset, order.fee_asset) - self.assertEqual(Decimal("0"), order.fee_paid) - self.assertEqual("Working", order.last_state) - - def test_is_done(self): - order = MexcInFlightOrder.from_json(self._example_json()) - - self.assertFalse(order.is_done) - - for status in ["FILLED", "CANCELED", "PARTIALLY_CANCELED"]: - order.last_state = status - self.assertTrue(order.is_done) - - def test_is_failure(self): - order = MexcInFlightOrder.from_json(self._example_json()) - - for status in ["NEW", "PARTIALLY_FILLED"]: - order.last_state = status - self.assertFalse(order.is_failure) - - # order.last_state = "Rejected" - # self.assertTrue(order.is_failure) - - def test_is_cancelled(self): - order = MexcInFlightOrder.from_json(self._example_json()) - - for status in ["Working", "FullyExecuted", "Rejected"]: - order.last_state = status - self.assertFalse(order.is_cancelled) - - def test_mark_as_filled(self): - order = MexcInFlightOrder.from_json(self._example_json()) - - order.mark_as_filled() - self.assertEqual("FILLED", order.last_state) - - def test_to_json(self): - order = MexcInFlightOrder.from_json(self._example_json()) - - self.assertEqual(self._example_json(), order.to_json()) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_order_book.py b/test/hummingbot/connector/exchange/mexc/test_mexc_order_book.py new file mode 100644 index 0000000000..2ccf88f095 --- /dev/null +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_order_book.py @@ -0,0 +1,90 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.mexc.mexc_order_book import MexcOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessageType + + +class MexcOrderBookTests(TestCase): + + def test_snapshot_message_from_exchange(self): + snapshot_message = MexcOrderBook.snapshot_message_from_exchange( + msg={ + "lastUpdateId": 1, + "bids": [ + ["4.00000000", "431.00000000"] + ], + "asks": [ + ["4.00000200", "12.00000000"] + ] + }, + timestamp=1640000000.0, + metadata={"trading_pair": "COINALPHA-HBOT"} + ) + + self.assertEqual("COINALPHA-HBOT", snapshot_message.trading_pair) + self.assertEqual(OrderBookMessageType.SNAPSHOT, snapshot_message.type) + self.assertEqual(1640000000.0, snapshot_message.timestamp) + self.assertEqual(1, snapshot_message.update_id) + self.assertEqual(-1, snapshot_message.trade_id) + self.assertEqual(1, len(snapshot_message.bids)) + self.assertEqual(4.0, snapshot_message.bids[0].price) + self.assertEqual(431.0, snapshot_message.bids[0].amount) + self.assertEqual(1, snapshot_message.bids[0].update_id) + self.assertEqual(1, len(snapshot_message.asks)) + self.assertEqual(4.000002, snapshot_message.asks[0].price) + self.assertEqual(12.0, snapshot_message.asks[0].amount) + self.assertEqual(1, snapshot_message.asks[0].update_id) + + def test_diff_message_from_exchange(self): + diff_msg = MexcOrderBook.diff_message_from_exchange( + msg={ + "c": "spot@public.increase.depth.v3.api@BTCUSDT", + "d": { + "asks": [{ + "p": "0.0026", + "v": "100"}], + "bids": [{ + "p": "0.0024", + "v": "10"}], + "e": "spot@public.increase.depth.v3.api", + "r": "3407459756"}, + "s": "COINALPHAHBOT", + "t": 1661932660144 + }, + timestamp=1640000000000, + metadata={"trading_pair": "COINALPHA-HBOT"} + ) + + self.assertEqual("COINALPHA-HBOT", diff_msg.trading_pair) + self.assertEqual(OrderBookMessageType.DIFF, diff_msg.type) + self.assertEqual(1640000000.0, diff_msg.timestamp) + self.assertEqual(3407459756, diff_msg.update_id) + self.assertEqual(-1, diff_msg.trade_id) + self.assertEqual(1, len(diff_msg.bids)) + self.assertEqual(0.0024, diff_msg.bids[0].price) + self.assertEqual(10.0, diff_msg.bids[0].amount) + self.assertEqual(3407459756, diff_msg.bids[0].update_id) + self.assertEqual(1, len(diff_msg.asks)) + self.assertEqual(0.0026, diff_msg.asks[0].price) + self.assertEqual(100.0, diff_msg.asks[0].amount) + self.assertEqual(3407459756, diff_msg.asks[0].update_id) + + def test_trade_message_from_exchange(self): + trade_update = { + "S": 2, + "p": "0.001", + "t": 1661927587825, + "v": "100" + } + + trade_message = MexcOrderBook.trade_message_from_exchange( + msg=trade_update, + metadata={"trading_pair": "COINALPHA-HBOT"}, + timestamp=1661927587836 + ) + + self.assertEqual("COINALPHA-HBOT", trade_message.trading_pair) + self.assertEqual(OrderBookMessageType.TRADE, trade_message.type) + self.assertEqual(1661927587.836, trade_message.timestamp) + self.assertEqual(-1, trade_message.update_id) + self.assertEqual(1661927587825, trade_message.trade_id) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_order_book_message.py b/test/hummingbot/connector/exchange/mexc/test_mexc_order_book_message.py deleted file mode 100644 index 556e30dfc0..0000000000 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_order_book_message.py +++ /dev/null @@ -1,67 +0,0 @@ -from unittest import TestCase - -from hummingbot.connector.exchange.mexc.mexc_order_book_message import MexcOrderBookMessage -from hummingbot.core.data_type.order_book_message import OrderBookMessageType - - -class MexcOrderBookMessageTests(TestCase): - - @property - def get_content(self): - return { - "trading_pair": "MX-USDT", - "update_id": 1637654307737, - "bids": [{"price": "2.7548", "quantity": "28.18"}], - "asks": [{"price": "2.7348", "quantity": "18.18"}] - } - - def test_equality_based_on_type_and_timestamp(self): - message = MexcOrderBookMessage(message_type=OrderBookMessageType.SNAPSHOT, - content={"data": []}, - timestamp=10000000) - equal_message = MexcOrderBookMessage(message_type=OrderBookMessageType.SNAPSHOT, - content={"data": []}, - timestamp=10000000) - message_with_different_type = MexcOrderBookMessage(message_type=OrderBookMessageType.DIFF, - content={"data": []}, - timestamp=10000000) - message_with_different_timestamp = MexcOrderBookMessage(message_type=OrderBookMessageType.SNAPSHOT, - content={"data": []}, - timestamp=90000000) - - self.assertEqual(message, message) - self.assertEqual(message, equal_message) - self.assertNotEqual(message, message_with_different_type) - self.assertNotEqual(message, message_with_different_timestamp) - - def test_equal_messages_have_equal_hash(self): - message = MexcOrderBookMessage(message_type=OrderBookMessageType.SNAPSHOT, - content={"data": []}, - timestamp=10000000) - equal_message = MexcOrderBookMessage(message_type=OrderBookMessageType.SNAPSHOT, - content={"data": []}, - timestamp=10000000) - - self.assertEqual(hash(message), hash(equal_message)) - - def test_delete_buy_order_book_entry_always_has_zero_amount(self): - message = MexcOrderBookMessage(message_type=OrderBookMessageType.DIFF, - content=self.get_content, - timestamp=1637654307737) - bids = message.bids - - self.assertEqual(1, len(bids)) - self.assertEqual(2.7548, bids[0].price) - self.assertEqual(28.18, bids[0].amount) - self.assertEqual(1637654307737000, bids[0].update_id) - - def test_delete_sell_order_book_entry_always_has_zero_amount(self): - message = MexcOrderBookMessage(message_type=OrderBookMessageType.DIFF, - content=self.get_content, - timestamp=1637654307737) - asks = message.asks - - self.assertEqual(1, len(asks)) - self.assertEqual(2.7348, asks[0].price) - self.assertEqual(18.18, asks[0].amount) - self.assertEqual(1637654307737000, asks[0].update_id) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_order_book_tracker.py b/test/hummingbot/connector/exchange/mexc/test_mexc_order_book_tracker.py deleted file mode 100644 index 41c4da5a18..0000000000 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_order_book_tracker.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python -import asyncio -import json -import unittest -from decimal import Decimal -from typing import Any, Awaitable -from unittest.mock import AsyncMock - -from aioresponses import aioresponses - -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS -from hummingbot.connector.exchange.mexc.mexc_order_book import MexcOrderBook -from hummingbot.connector.exchange.mexc.mexc_order_book_message import MexcOrderBookMessage -from hummingbot.connector.exchange.mexc.mexc_order_book_tracker import MexcOrderBookTracker -from hummingbot.connector.exchange.mexc.mexc_utils import convert_to_exchange_trading_pair -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler -from hummingbot.core.data_type.order_book import OrderBook - - -class MexcOrderBookTrackerUnitTest(unittest.TestCase): - - @property - def content(self): - return {"asks": [{"price": "37751.0", "quantity": "0.015"}], - "bids": [{"price": "37750.0", "quantity": "0.015"}]} - - @property - def mock_data(self): - _data = {"code": 200, "data": { - "asks": [{"price": "56454.0", "quantity": "0.799072"}, {"price": "56455.28", "quantity": "0.008663"}], - "bids": [{"price": "56451.0", "quantity": "0.008663"}, {"price": "56449.99", "quantity": "0.173078"}], - "version": "547878563"}} - return _data - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.base_asset = "COINALPHA" - cls.quote_asset = "HBOT" - cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" - cls.instrument_id = 1 - - cls.ev_loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() - - def setUp(self) -> None: - super().setUp() - throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - self.tracker: MexcOrderBookTracker = MexcOrderBookTracker(throttler=throttler, - trading_pairs=[self.trading_pair]) - self.tracking_task = None - - # Simulate start() - self.tracker._order_books[self.trading_pair] = MexcOrderBook() - self.tracker._tracking_message_queues[self.trading_pair] = asyncio.Queue() - self.tracker._order_books_initialized.set() - - def tearDown(self) -> None: - self.tracking_task and self.tracking_task.cancel() - if len(self.tracker._tracking_tasks) > 0: - for task in self.tracker._tracking_tasks.values(): - task.cancel() - super().tearDown() - - @staticmethod - def set_mock_response(mock_api, status: int, json_data: Any): - mock_api.return_value.__aenter__.return_value.status = status - mock_api.return_value.__aenter__.return_value.json = AsyncMock(return_value=json_data) - - def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): - ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) - return ret - - def simulate_queue_order_book_messages(self, message: MexcOrderBookMessage): - message_queue = self.tracker._tracking_message_queues[self.trading_pair] - message_queue.put_nowait(message) - - def test_exchange_name(self): - self.assertEqual(self.tracker.exchange_name, CONSTANTS.EXCHANGE_NAME) - - def test_track_single_book_apply_snapshot(self): - snapshot_msg = MexcOrderBook.snapshot_message_from_exchange( - msg=self.content, - timestamp=1626788175000, - trading_pair=self.trading_pair - ) - self.simulate_queue_order_book_messages(snapshot_msg) - - with self.assertRaises(asyncio.TimeoutError): - # Allow 5 seconds for tracker to process some messages. - self.tracking_task = self.ev_loop.create_task(asyncio.wait_for( - self.tracker._track_single_book(self.trading_pair), - 2.0 - )) - self.async_run_with_timeout(self.tracking_task) - - self.assertEqual(1626788175000000, self.tracker.order_books[self.trading_pair].snapshot_uid) - - @aioresponses() - def test_init_order_books(self, mock_api): - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, body=json.dumps(self.mock_data)) - - self.tracker._order_books_initialized.clear() - self.tracker._tracking_message_queues.clear() - self.tracker._tracking_tasks.clear() - self.tracker._order_books.clear() - - self.assertEqual(0, len(self.tracker.order_books)) - self.assertEqual(0, len(self.tracker._tracking_message_queues)) - self.assertEqual(0, len(self.tracker._tracking_tasks)) - self.assertFalse(self.tracker._order_books_initialized.is_set()) - - init_order_books_task = self.ev_loop.create_task( - self.tracker._init_order_books() - ) - - self.async_run_with_timeout(init_order_books_task) - - self.assertIsInstance(self.tracker.order_books[self.trading_pair], OrderBook) - self.assertTrue(self.tracker._order_books_initialized.is_set()) - - @aioresponses() - def test_can_get_price_after_order_book_init(self, mock_api): - trading_pair = convert_to_exchange_trading_pair(self.trading_pair) - tick_url = CONSTANTS.MEXC_DEPTH_URL.format(trading_pair=trading_pair) - url = CONSTANTS.MEXC_BASE_URL + tick_url - mock_api.get(url, body=json.dumps(self.mock_data)) - - init_order_books_task = self.ev_loop.create_task( - self.tracker._init_order_books() - ) - self.async_run_with_timeout(init_order_books_task) - - ob = self.tracker.order_books[self.trading_pair] - ask_price = ob.get_price(True) - self.assertEqual(Decimal("56454.0"), ask_price) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_user_stream_data_source.py b/test/hummingbot/connector/exchange/mexc/test_mexc_user_stream_data_source.py new file mode 100644 index 0000000000..adc2b597e7 --- /dev/null +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_user_stream_data_source.py @@ -0,0 +1,318 @@ +import asyncio +import json +import re +import unittest +from typing import Any, Awaitable, Dict, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +from aioresponses import aioresponses +from bidict import bidict + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.mexc import mexc_constants as CONSTANTS, mexc_web_utils as web_utils +from hummingbot.connector.exchange.mexc.mexc_api_user_stream_data_source import MexcAPIUserStreamDataSource +from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth +from hummingbot.connector.exchange.mexc.mexc_exchange import MexcExchange +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + +class MexcUserStreamDataSourceUnitTests(unittest.TestCase): + # the level is required to receive logs from the data source logger + level = 0 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.ev_loop = asyncio.get_event_loop() + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = cls.base_asset + cls.quote_asset + cls.domain = "com" + + cls.listen_key = "TEST_LISTEN_KEY" + + def setUp(self) -> None: + super().setUp() + self.log_records = [] + self.listening_task: Optional[asyncio.Task] = None + self.mocking_assistant = NetworkMockingAssistant() + + self.throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) + self.mock_time_provider = MagicMock() + self.mock_time_provider.time.return_value = 1000 + self.auth = MexcAuth(api_key="TEST_API_KEY", secret_key="TEST_SECRET", time_provider=self.mock_time_provider) + self.time_synchronizer = TimeSynchronizer() + self.time_synchronizer.add_time_offset_ms_sample(0) + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = MexcExchange( + client_config_map=client_config_map, + mexc_api_key="", + mexc_api_secret="", + trading_pairs=[], + trading_required=False, + domain=self.domain) + self.connector._web_assistants_factory._auth = self.auth + + self.data_source = MexcAPIUserStreamDataSource( + auth=self.auth, + trading_pairs=[self.trading_pair], + connector=self.connector, + api_factory=self.connector._web_assistants_factory, + domain=self.domain + ) + + self.data_source.logger().setLevel(1) + self.data_source.logger().addHandler(self) + + self.resume_test_event = asyncio.Event() + + self.connector._set_trading_pair_symbol_map(bidict({self.ex_trading_pair: self.trading_pair})) + + def tearDown(self) -> None: + self.listening_task and self.listening_task.cancel() + super().tearDown() + + def handle(self, record): + self.log_records.append(record) + + def _is_logged(self, log_level: str, message: str) -> bool: + return any(record.levelname == log_level and record.getMessage() == message + for record in self.log_records) + + def _raise_exception(self, exception_class): + raise exception_class + + def _create_exception_and_unlock_test_with_event(self, exception): + self.resume_test_event.set() + raise exception + + def _create_return_value_and_unlock_test_with_event(self, value): + self.resume_test_event.set() + return value + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def _error_response(self) -> Dict[str, Any]: + resp = { + "code": "ERROR CODE", + "msg": "ERROR MESSAGE" + } + + return resp + + def _user_update_event(self): + # Balance Update + resp = { + "c": "spot@private.account.v3.api", + "d": { + "a": "BTC", + "c": 1678185928428, + "f": "302.185113007893322435", + "fd": "-4.990689704", + "l": "4.990689704", + "ld": "4.990689704", + "o": "ENTRUST_PLACE" + }, + "t": 1678185928435 + } + return json.dumps(resp) + + def _successfully_subscribed_event(self): + resp = { + "result": None, + "id": 1 + } + return resp + + @aioresponses() + def test_get_listen_key_log_exception(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.post(regex_url, status=400, body=json.dumps(self._error_response())) + + with self.assertRaises(IOError): + self.async_run_with_timeout(self.data_source._get_listen_key()) + + @aioresponses() + def test_get_listen_key_successful(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + result: str = self.async_run_with_timeout(self.data_source._get_listen_key()) + + self.assertEqual(self.listen_key, result) + + @aioresponses() + def test_ping_listen_key_log_exception(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.put(regex_url, status=400, body=json.dumps(self._error_response())) + + self.data_source._current_listen_key = self.listen_key + result: bool = self.async_run_with_timeout(self.data_source._ping_listen_key()) + + self.assertTrue(self._is_logged("WARNING", f"Failed to refresh the listen key {self.listen_key}: " + f"{self._error_response()}")) + self.assertFalse(result) + + @aioresponses() + def test_ping_listen_key_successful(self, mock_api): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.put(regex_url, body=json.dumps({})) + + self.data_source._current_listen_key = self.listen_key + result: bool = self.async_run_with_timeout(self.data_source._ping_listen_key()) + self.assertTrue(result) + + @patch("hummingbot.connector.exchange.mexc.mexc_api_user_stream_data_source.MexcAPIUserStreamDataSource" + "._ping_listen_key", + new_callable=AsyncMock) + def test_manage_listen_key_task_loop_keep_alive_failed(self, mock_ping_listen_key): + mock_ping_listen_key.side_effect = (lambda *args, **kwargs: + self._create_return_value_and_unlock_test_with_event(False)) + + self.data_source._current_listen_key = self.listen_key + + # Simulate LISTEN_KEY_KEEP_ALIVE_INTERVAL reached + self.data_source._last_listen_key_ping_ts = 0 + + self.listening_task = self.ev_loop.create_task(self.data_source._manage_listen_key_task_loop()) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue(self._is_logged("ERROR", "Error occurred renewing listen key ...")) + self.assertIsNone(self.data_source._current_listen_key) + self.assertFalse(self.data_source._listen_key_initialized_event.is_set()) + + @patch("hummingbot.connector.exchange.mexc.mexc_api_user_stream_data_source.MexcAPIUserStreamDataSource." + "_ping_listen_key", + new_callable=AsyncMock) + def test_manage_listen_key_task_loop_keep_alive_successful(self, mock_ping_listen_key): + mock_ping_listen_key.side_effect = (lambda *args, **kwargs: + self._create_return_value_and_unlock_test_with_event(True)) + + # Simulate LISTEN_KEY_KEEP_ALIVE_INTERVAL reached + self.data_source._current_listen_key = self.listen_key + self.data_source._listen_key_initialized_event.set() + self.data_source._last_listen_key_ping_ts = 0 + + self.listening_task = self.ev_loop.create_task(self.data_source._manage_listen_key_task_loop()) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue(self._is_logged("INFO", f"Refreshed listen key {self.listen_key}.")) + self.assertGreater(self.data_source._last_listen_key_ping_ts, 0) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_get_listen_key_successful_with_user_update_event(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + self.mocking_assistant.add_websocket_aiohttp_message(mock_ws.return_value, self._user_update_event()) + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + msg = self.async_run_with_timeout(msg_queue.get()) + self.assertEqual(json.loads(self._user_update_event()), msg) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_does_not_queue_empty_payload(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + self.mocking_assistant.add_websocket_aiohttp_message(mock_ws.return_value, "") + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(mock_ws.return_value) + + self.assertEqual(0, msg_queue.qsize()) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_connection_failed(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + mock_ws.side_effect = lambda *arg, **kwars: self._create_exception_and_unlock_test_with_event( + Exception("TEST ERROR.")) + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged("ERROR", + "Unexpected error while listening to user stream. Retrying after 5 seconds...")) + + @aioresponses() + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_iter_message_throws_exception(self, mock_api, mock_ws): + url = web_utils.private_rest_url(path_url=CONSTANTS.MEXC_USER_STREAM_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_response = { + "listenKey": self.listen_key + } + mock_api.post(regex_url, body=json.dumps(mock_response)) + + msg_queue: asyncio.Queue = asyncio.Queue() + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + mock_ws.return_value.receive.side_effect = (lambda *args, **kwargs: + self._create_exception_and_unlock_test_with_event( + Exception("TEST ERROR"))) + mock_ws.close.return_value = None + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error while listening to user stream. Retrying after 5 seconds...")) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_user_stream_tracker.py b/test/hummingbot/connector/exchange/mexc/test_mexc_user_stream_tracker.py deleted file mode 100644 index bfcdae88e3..0000000000 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_user_stream_tracker.py +++ /dev/null @@ -1,50 +0,0 @@ -import asyncio -from typing import Awaitable -from unittest import TestCase -from unittest.mock import AsyncMock, patch - -import ujson - -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS -from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth -from hummingbot.connector.exchange.mexc.mexc_user_stream_tracker import MexcUserStreamTracker -from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler - - -class MexcUserStreamTrackerTests(TestCase): - - def setUp(self) -> None: - super().setUp() - self.ws_sent_messages = [] - self.ws_incoming_messages = asyncio.Queue() - self.listening_task = None - - throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - auth_assistant = MexcAuth(api_key='testAPIKey', - secret_key='testSecret', ) - self.tracker = MexcUserStreamTracker(throttler=throttler, mexc_auth=auth_assistant) - - self.mocking_assistant = NetworkMockingAssistant() - self.ev_loop = asyncio.get_event_loop() - - def tearDown(self) -> None: - self.listening_task and self.listening_task.cancel() - super().tearDown() - - def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): - ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) - return ret - - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_listening_process_authenticates_and_subscribes_to_events(self, ws_connect_mock): - ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() - - self.mocking_assistant.add_websocket_aiohttp_message(ws_connect_mock.return_value, - ujson.dumps({'channel': 'push.personal.order'})) - self.listening_task = asyncio.get_event_loop().create_task( - self.tracker.start()) - - first_received_message = self.async_run_with_timeout(self.tracker.user_stream.get()) - - self.assertEqual({'channel': 'push.personal.order'}, first_received_message) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_utils.py b/test/hummingbot/connector/exchange/mexc/test_mexc_utils.py new file mode 100644 index 0000000000..0c8632c443 --- /dev/null +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_utils.py @@ -0,0 +1,44 @@ +import unittest + +from hummingbot.connector.exchange.mexc import mexc_utils as utils + + +class MexcUtilTestCases(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.hb_trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}{cls.quote_asset}" + + def test_is_exchange_information_valid(self): + invalid_info_1 = { + "status": "BREAK", + "permissions": ["MARGIN"], + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_1)) + + invalid_info_2 = { + "status": "BREAK", + "permissions": ["SPOT"], + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_2)) + + invalid_info_3 = { + "status": "ENABLED", + "permissions": ["MARGIN"], + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_3)) + + invalid_info_4 = { + "status": "ENABLED", + "permissions": ["SPOT"], + } + + self.assertTrue(utils.is_exchange_information_valid(invalid_info_4)) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_web_utils.py b/test/hummingbot/connector/exchange/mexc/test_mexc_web_utils.py new file mode 100644 index 0000000000..51ba43474f --- /dev/null +++ b/test/hummingbot/connector/exchange/mexc/test_mexc_web_utils.py @@ -0,0 +1,19 @@ +import unittest + +import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS +from hummingbot.connector.exchange.mexc import mexc_web_utils as web_utils + + +class MexcUtilTestCases(unittest.TestCase): + + def test_public_rest_url(self): + path_url = "/TEST_PATH" + domain = "com" + expected_url = CONSTANTS.REST_URL.format(domain) + CONSTANTS.PUBLIC_API_VERSION + path_url + self.assertEqual(expected_url, web_utils.public_rest_url(path_url, domain)) + + def test_private_rest_url(self): + path_url = "/TEST_PATH" + domain = "com" + expected_url = CONSTANTS.REST_URL.format(domain) + CONSTANTS.PRIVATE_API_VERSION + path_url + self.assertEqual(expected_url, web_utils.private_rest_url(path_url, domain)) diff --git a/test/hummingbot/connector/exchange/mexc/test_mexc_websocket_adaptor.py b/test/hummingbot/connector/exchange/mexc/test_mexc_websocket_adaptor.py deleted file mode 100644 index 44aa4bbba7..0000000000 --- a/test/hummingbot/connector/exchange/mexc/test_mexc_websocket_adaptor.py +++ /dev/null @@ -1,108 +0,0 @@ -import asyncio -import unittest -from typing import Awaitable, Optional -from unittest.mock import AsyncMock, patch - -import hummingbot.connector.exchange.mexc.mexc_constants as CONSTANTS -from hummingbot.connector.exchange.mexc.mexc_auth import MexcAuth -from hummingbot.connector.exchange.mexc.mexc_websocket_adaptor import MexcWebSocketAdaptor -from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant -from hummingbot.core.api_throttler.async_throttler import AsyncThrottler - - -class MexcWebSocketUnitTests(unittest.TestCase): - # the level is required to receive logs from the data source logger - level = 0 - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls.ev_loop = asyncio.get_event_loop() - cls.trading_pairs = ["COINALPHA-HBOT"] - - cls.api_key = "someKey" - cls.secret_key = "someSecretKey" - cls.auth = MexcAuth(api_key=cls.api_key, secret_key=cls.secret_key) - - def setUp(self) -> None: - super().setUp() - self.log_records = [] - throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - - self.websocket = MexcWebSocketAdaptor(throttler) - self.websocket.logger().setLevel(1) - self.websocket.logger().addHandler(self) - - self.mocking_assistant = NetworkMockingAssistant() - self.async_task: Optional[asyncio.Task] = None - - self.resume_test_event = asyncio.Event() - - def tearDown(self) -> None: - self.async_run_with_timeout(self.websocket.disconnect()) - self.async_task and self.async_task.cancel() - super().tearDown() - - def handle(self, record): - self.log_records.append(record) - - def _is_logged(self, log_level: str, message: str) -> bool: - return any(record.levelname == log_level and record.getMessage() == message for record in self.log_records) - - def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): - ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) - return ret - - def resume_test_callback(self): - self.resume_test_event.set() - - async def _iter_message(self): - async for _ in self.websocket.iter_messages(): - self.resume_test_callback() - self.async_task.cancel() - - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_connect_raises_exception(self, ws_connect_mock): - throttler = AsyncThrottler(CONSTANTS.RATE_LIMITS) - ws_connect_mock.side_effect = Exception("TEST ERROR") - - self.websocket = MexcWebSocketAdaptor(throttler) - - with self.assertRaisesRegex(Exception, "TEST ERROR"): - self.async_run_with_timeout(self.websocket.connect()) - - self.assertTrue(self._is_logged("ERROR", "Websocket error: 'TEST ERROR'")) - - def test_disconnect(self): - ws = AsyncMock() - self.websocket._websocket = ws - - self.async_run_with_timeout(self.websocket.disconnect()) - - self.assertEqual(1, ws.close.await_count) - - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_subscribe_to_order_book_streams_raises_cancelled_exception(self, ws_connect_mock): - ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() - - self.async_run_with_timeout(self.websocket.connect()) - - ws_connect_mock.return_value.send_str.side_effect = asyncio.CancelledError - - with self.assertRaises(asyncio.CancelledError): - self.async_run_with_timeout(self.websocket.subscribe_to_order_book_streams(self.trading_pairs)) - - @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) - def test_subscribe_to_order_book_streams_logs_exception(self, ws_connect_mock): - ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() - - self.async_run_with_timeout(self.websocket.connect()) - - ws_connect_mock.return_value.send_str.side_effect = Exception("TEST ERROR") - - with self.assertRaisesRegex(Exception, "TEST ERROR"): - self.async_run_with_timeout(self.websocket.subscribe_to_order_book_streams(self.trading_pairs)) - - self.assertTrue(self._is_logged( - "ERROR", "Unexpected error occurred subscribing to order book trading and delta streams..." - )) diff --git a/test/hummingbot/connector/exchange/polkadex/programmable_query_executor.py b/test/hummingbot/connector/exchange/polkadex/programmable_query_executor.py index a3d5beaea0..e0ea09828e 100644 --- a/test/hummingbot/connector/exchange/polkadex/programmable_query_executor.py +++ b/test/hummingbot/connector/exchange/polkadex/programmable_query_executor.py @@ -16,6 +16,8 @@ def __init__(self): self._cancel_order_responses = asyncio.Queue() self._order_history_responses = asyncio.Queue() self._order_responses = asyncio.Queue() + self._list_orders_responses = asyncio.Queue() + self._order_fills_responses = asyncio.Queue() self._order_book_update_events = asyncio.Queue() self._public_trades_update_events = asyncio.Queue() @@ -52,6 +54,7 @@ async def cancel_order( self, order_id: str, market_symbol: str, + main_address: str, proxy_address: str, signature: Dict[str, Any], ) -> Dict[str, Any]: @@ -68,6 +71,16 @@ async def find_order_by_main_account(self, main_account: str, market_symbol: str response = await self._order_responses.get() return response + async def list_open_orders_by_main_account(self, main_account: str) -> Dict[str, Any]: + response = await self._list_orders_responses.get() + return response + + async def get_order_fills_by_main_account( + self, from_timestamp: float, to_timestamp: float, main_account: str + ) -> Dict[str, Any]: + response = await self._order_fills_responses.get() + return response + async def listen_to_orderbook_updates(self, events_handler: Callable, market_symbol: str): while True: event = await self._order_book_update_events.get() diff --git a/test/hummingbot/connector/exchange/polkadex/test_polkadex_api_order_book_data_source.py b/test/hummingbot/connector/exchange/polkadex/test_polkadex_api_order_book_data_source.py index b47fd04456..099fc0fbb5 100644 --- a/test/hummingbot/connector/exchange/polkadex/test_polkadex_api_order_book_data_source.py +++ b/test/hummingbot/connector/exchange/polkadex/test_polkadex_api_order_book_data_source.py @@ -111,15 +111,15 @@ def is_logged(self, log_level: str, message: Union[str, re.Pattern]) -> bool: def test_get_new_order_book_successful(self): data = [ - {"side": "Ask", "p": 9487.5, "q": 522147, "s": "Ask"}, - {"side": "Bid", "p": 9487, "q": 336241, "s": "Bid"}, + {"side": "Ask", "p": 9487.5, "q": 522147, "s": "Ask", "stid": 1}, + {"side": "Bid", "p": 9487, "q": 336241, "s": "Bid", "stid": 1}, ] order_book_snapshot = {"getOrderbook": {"items": data}} self.data_source._data_source._query_executor._order_book_snapshots.put_nowait(order_book_snapshot) order_book = self.async_run_with_timeout(self.data_source.get_new_order_book(self.trading_pair)) - expected_update_id = -1 + expected_update_id = 1 self.assertEqual(expected_update_id, order_book.snapshot_uid) bids = list(order_book.bid_entries()) @@ -144,10 +144,8 @@ def test_listen_for_trades_cancelled_when_listening(self): self.async_run_with_timeout(self.data_source.listen_for_trades(self.async_loop, msg_queue)) def test_listen_for_trades_logs_exception(self): - incorrect_message = {} - mock_queue = AsyncMock() - mock_queue.get.side_effect = [incorrect_message, asyncio.CancelledError()] + mock_queue.get.side_effect = [Exception("some error"), asyncio.CancelledError()] self.data_source._message_queue[self.data_source._trade_messages_queue_key] = mock_queue msg_queue: asyncio.Queue = asyncio.Queue() @@ -164,15 +162,16 @@ def test_listen_for_trades_logs_exception(self): ) def test_listen_for_trades_successful(self): + expected_trade_id = "1664193952989" trade_data = { "type": "TradeFormat", "m": self.ex_trading_pair, + "m_side": "Ask", + "trade_id": expected_trade_id, "p": "1718.5", - "vq": "17185", "q": "10", - "tid": "111", "t": 1664193952989, - "sid": "16", + "stid": "16", } trade_event = {"websocket_streams": {"data": json.dumps(trade_data)}} @@ -186,7 +185,7 @@ def test_listen_for_trades_successful(self): msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) self.assertEqual(OrderBookMessageType.TRADE, msg.type) - self.assertEqual(trade_data["tid"], msg.trade_id) + self.assertEqual(expected_trade_id, msg.trade_id) self.assertEqual(trade_data["t"] * 1e-3, msg.timestamp) expected_price = Decimal(trade_data["p"]) expected_amount = Decimal(trade_data["q"]) @@ -206,10 +205,8 @@ def test_listen_for_order_book_diffs_cancelled(self): self.async_run_with_timeout(self.data_source.listen_for_order_book_diffs(self.async_loop, msg_queue)) def test_listen_for_order_book_diffs_logs_exception(self): - incorrect_message = {} - mock_queue = AsyncMock() - mock_queue.get.side_effect = [incorrect_message, asyncio.CancelledError()] + mock_queue.get.side_effect = [Exception("some error"), asyncio.CancelledError()] self.data_source._message_queue[self.data_source._diff_messages_queue_key] = mock_queue msg_queue: asyncio.Queue = asyncio.Queue() @@ -230,12 +227,14 @@ def test_listen_for_order_book_diffs_successful(self, time_mock): time_mock.return_value = 1640001112.223 order_book_data = { - "type": "IncOb", - "changes": [ - ["Bid", "2999", "8", 4299950], - ["Bid", "1.671", "52.952", 4299951], - ["Ask", "3001", "0", 4299952], - ], + "i": 1, + "a": { + "3001": "0", + }, + "b": { + "2999": "8", + "1.671": "52.952", + }, } order_book_event = {"websocket_streams": {"data": json.dumps(order_book_data)}} @@ -252,7 +251,7 @@ def test_listen_for_order_book_diffs_successful(self, time_mock): self.assertEqual(OrderBookMessageType.DIFF, msg.type) self.assertEqual(-1, msg.trade_id) self.assertEqual(time_mock.return_value, msg.timestamp) - expected_update_id = order_book_data["changes"][-1][-1] + expected_update_id = 1 self.assertEqual(expected_update_id, msg.update_id) bids = msg.bids diff --git a/test/hummingbot/connector/exchange/polkadex/test_polkadex_exchange.py b/test/hummingbot/connector/exchange/polkadex/test_polkadex_exchange.py index 2f741e6920..ea07f2ec11 100644 --- a/test/hummingbot/connector/exchange/polkadex/test_polkadex_exchange.py +++ b/test/hummingbot/connector/exchange/polkadex/test_polkadex_exchange.py @@ -9,6 +9,7 @@ from aioresponses import aioresponses from aioresponses.core import RequestCall from bidict import bidict +from gql.transport.exceptions import TransportQueryError from substrateinterface import SubstrateInterface from hummingbot.client.config.client_config_map import ClientConfigMap @@ -31,6 +32,9 @@ class PolkadexExchangeTests(AbstractExchangeConnectorTests.ExchangeConnectorTests): + client_order_id_prefix = "0x" + exchange_order_id_prefix = "0x" + @classmethod def setUpClass(cls) -> None: super().setUpClass() @@ -47,6 +51,8 @@ def setUp(self) -> None: self.exchange._data_source.logger().setLevel(1) self.exchange._data_source.logger().addHandler(self) self.exchange._set_trading_pair_symbol_map(bidict({self.exchange_trading_pair: self.trading_pair})) + exchange_base, exchange_quote = self.trading_pair.split("-") + self.exchange._data_source._assets_map = {exchange_base: self.base_asset, "1": self.quote_asset} def tearDown(self) -> None: super().tearDown() @@ -196,7 +202,7 @@ def trading_rules_request_erroneous_mock_response(self): @property def order_creation_request_successful_mock_response(self): - return {"place_order": self.expected_exchange_order_id} + return {"place_order": json.dumps({"is_success": True, "body": self.expected_exchange_order_id})} @property def balance_request_mock_response_for_base_and_quote(self): @@ -291,7 +297,7 @@ def expected_exchange_order_id(self): @property def is_order_fill_http_update_included_in_status_update(self) -> bool: - return False + return True @property def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: @@ -308,13 +314,25 @@ def expected_partial_fill_amount(self) -> Decimal: @property def expected_partial_fill_fee(self) -> TradeFeeBase: return AddedToCostTradeFee( - percent_token=self.quote_asset, flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("10"))] + percent_token=self.quote_asset, + flat_fees=[ + TokenAmount( + token=self.quote_asset, + amount=Decimal("0"), # according to Polkadex team, fees will be zero for the foreseeable future + ), + ], ) @property def expected_fill_fee(self) -> TradeFeeBase: return AddedToCostTradeFee( - percent_token=self.quote_asset, flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("30"))] + percent_token=self.quote_asset, + flat_fees=[ + TokenAmount( + token=self.quote_asset, + amount=Decimal("0"), # according to Polkadex team, fees will be zero for the foreseeable future + ), + ], ) @property @@ -394,7 +412,28 @@ def configure_order_not_found_error_cancelation_response( "0x1b99cba5555ad0ba890756fe16e499cb884b46a165b89bdce77ee8913b55ffff" # noqa: mock '\\"}","errorType":"Lambda:Handled"}', } - not_found_exception = IOError(str(not_found_error)) + not_found_exception = TransportQueryError(str(not_found_error)) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial( + self._callback_wrapper_with_response, callback=callback, response=not_found_exception + ) + self.exchange._data_source._query_executor._cancel_order_responses = mock_queue + return "" + + def configure_order_not_active_error_cancelation_response( + self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + not_found_error = { + "path": ["cancel_order"], + "data": None, + "errorType": "Lambda:Unhandled", + "errorInfo": None, + "locations": [{"line": 2, "column": 3, "sourceName": None}], + "message": '{"errorMessage":"{\\"code\\":-32000,\\"message\\":\\"Order is not active: ' + "0x1b99cba5555ad0ba890756fe16e499cb884b46a165b89bdce77ee8913b55ffff" # noqa: mock + '\\"}","errorType":"Lambda:Handled"}', + } + not_found_exception = TransportQueryError(str(not_found_error)) mock_queue = AsyncMock() mock_queue.get.side_effect = partial( self._callback_wrapper_with_response, callback=callback, response=not_found_exception @@ -424,6 +463,7 @@ def configure_completely_filled_order_status_response( def configure_canceled_order_status_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: + self.configure_no_fills_trade_response() order_history_response = {"listOrderHistorybyMainAccount": {"items": []}} self.exchange._data_source._query_executor._order_history_responses.put_nowait(order_history_response) response = self._order_status_request_canceled_mock_response(order=order) @@ -435,6 +475,7 @@ def configure_canceled_order_status_response( def configure_open_order_status_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: + self.configure_no_fills_trade_response() order_history_response = {"listOrderHistorybyMainAccount": {"items": []}} self.exchange._data_source._query_executor._order_history_responses.put_nowait(order_history_response) response = self._order_status_request_open_mock_response(order=order) @@ -446,6 +487,7 @@ def configure_open_order_status_response( def configure_http_error_order_status_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: + self.configure_no_fills_trade_response() order_history_response = {"listOrderHistorybyMainAccount": {"items": []}} self.exchange._data_source._query_executor._order_history_responses.put_nowait(order_history_response) mock_queue = AsyncMock() @@ -467,6 +509,7 @@ def configure_partially_filled_order_status_response( def configure_order_not_found_error_order_status_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> List[str]: + self.configure_no_fills_trade_response() order_history_response = {"listOrderHistorybyMainAccount": {"items": []}} self.exchange._data_source._query_executor._order_history_responses.put_nowait(order_history_response) response = {"findOrderByMainAccount": None} @@ -478,17 +521,68 @@ def configure_order_not_found_error_order_status_response( def configure_partial_fill_trade_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: - raise NotImplementedError + order_fills_response = { + "listTradesByMainAccount": { + "items": [ + { + "m": self.exchange_trading_pair, + "p": str(self.expected_partial_fill_price), + "q": str(self.expected_partial_fill_amount), + "m_id": order.exchange_order_id, + "trade_id": self.expected_fill_trade_id, + "t": str(int(self.exchange.current_timestamp * 1e3)), + } + ] + } + } + self.exchange._data_source._query_executor._order_fills_responses.put_nowait(order_fills_response) + return "" def configure_erroneous_http_fill_trade_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: - raise NotImplementedError + error = { + "path": ["listTradesByMainAccount"], + "data": None, + "errorType": "DynamoDB:DynamoDbException", + "errorInfo": None, + "locations": [{"line": 2, "column": 3, "sourceName": None}], + "message": ( + "Invalid KeyConditionExpression: The BETWEEN operator requires upper bound to be greater than or" + " equal to lower bound; lower bound operand: AttributeValue: {N:1691691033195}, upper bound operand:" + " AttributeValue: {N:1691691023195} (Service: DynamoDb, Status Code: 400, Request ID:" + " F314JNSTC7U56DMFAFEPAGCM9VVV4KQNSO5AEMVJF66Q9ASUAAJG)" + ), + } + response = TransportQueryError(error) + mock_queue = AsyncMock() + mock_queue.get.side_effect = partial(self._callback_wrapper_with_response, callback=callback, response=response) + self.exchange._data_source._query_executor._order_fills_responses = mock_queue + return "" def configure_full_fill_trade_response( self, order: InFlightOrder, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: - raise NotImplementedError + order_fills_response = { + "listTradesByMainAccount": { + "items": [ + { + "m": self.exchange_trading_pair, + "p": str(order.price), + "q": str(order.amount), + "m_id": order.exchange_order_id, + "trade_id": self.expected_fill_trade_id, + "t": str(int(self.exchange.current_timestamp * 1e3)), + } + ] + } + } + self.exchange._data_source._query_executor._order_fills_responses.put_nowait(order_fills_response) + return "" + + def configure_no_fills_trade_response(self): + order_fills_response = {"listTradesByMainAccount": {"items": []}} + self.exchange._data_source._query_executor._order_fills_responses.put_nowait(order_fills_response) def configure_all_symbols_response( self, mock_api: aioresponses, callback: Optional[Callable] = lambda *args, **kwargs: None @@ -513,7 +607,7 @@ def configure_successful_creation_order_status_response( def configure_erroneous_creation_order_status_response( self, callback: Optional[Callable] = lambda *args, **kwargs: None ) -> str: - creation_response = {"place_order": None} + creation_response = {"place_order": json.dumps({"is_success": False, "error": "some error"})} mock_queue = AsyncMock() mock_queue.get.side_effect = partial( self._callback_wrapper_with_response, callback=callback, response=creation_response @@ -522,82 +616,128 @@ def configure_erroneous_creation_order_status_response( return "" def order_event_for_new_order_websocket_update(self, order: InFlightOrder): - data = { - "type": "Order", - "snapshot_number": 50133, - "event_id": 4300054, - "client_order_id": "0x" + order.client_order_id.encode("utf-8").hex(), - "avg_filled_price": "0", - "fee": "0", - "filled_quantity": "0", - "status": "OPEN", - "id": order.exchange_order_id, - "user": "5EqHNNKJWA4U6dyZDvUSkKPQCt6PGgrAxiSBRvC6wqz2xKXU", - "main_account": "5C5ZpV7Hunb7yG2CwDtnhxaYc3aug4UTLxRvu6HERxJqrtJY", - "pair": {"base_asset": self.base_asset, "quote_asset": "1"}, - "side": "Bid" if order.trade_type == TradeType.BUY else "Ask", - "order_type": "MARKET" if order.order_type == OrderType.MARKET else "LIMIT", - "qty": str(order.amount), - "price": str(order.price), - "quote_order_qty": str(order.amount * order.price), - "timestamp": 1682480373, - "overall_unreserved_volume": "0", - } + data = self.build_order_event_websocket_update( + order=order, + filled_quantity=Decimal("0"), + filled_price=Decimal("0"), + fee=Decimal("0"), + status="OPEN", + ) + return data - return {"websocket_streams": {"data": json.dumps(data)}} + def order_event_for_partially_filled_websocket_update(self, order: InFlightOrder): + data = self.build_order_event_websocket_update( + order=order, + filled_quantity=self.expected_partial_fill_amount, + filled_price=self.expected_partial_fill_price, + fee=Decimal("0"), + status="OPEN", + ) + return data + + def order_event_for_partially_canceled_websocket_update(self, order: InFlightOrder): + data = self.build_order_event_websocket_update( + order=order, + filled_quantity=self.expected_partial_fill_amount, + filled_price=self.expected_partial_fill_price, + fee=Decimal("0"), + status="CANCELLED", + ) + return data def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + data = self.build_order_event_websocket_update( + order=order, + filled_quantity=Decimal("0"), + filled_price=Decimal("0"), + fee=Decimal("0"), + status="CANCELLED", + ) + return data + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + data = self.build_order_event_websocket_update( + order=order, + filled_quantity=order.amount, + filled_price=order.price, + fee=Decimal("0"), + status="CLOSED", + ) + return data + + def build_order_event_websocket_update( + self, + order: InFlightOrder, + filled_quantity: Decimal, + filled_price: Decimal, + fee: Decimal, + status: str, + ): data = { "type": "Order", - "snapshot_number": 50133, - "event_id": 4300054, - "client_order_id": "0x" + order.client_order_id.encode("utf-8").hex(), - "avg_filled_price": "0", - "fee": "0", - "filled_quantity": "0", - "status": "CANCELLED", + "stid": 50133, + "client_order_id": order.client_order_id, + "avg_filled_price": str(filled_price), + "fee": str(fee), + "filled_quantity": str(filled_quantity), + "status": status, "id": order.exchange_order_id, - "user": "5EqHNNKJWA4U6dyZDvUSkKPQCt6PGgrAxiSBRvC6wqz2xKXU", - "main_account": "5C5ZpV7Hunb7yG2CwDtnhxaYc3aug4UTLxRvu6HERxJqrtJY", - "pair": {"base_asset": self.base_asset, "quote_asset": "1"}, + "user": "5EqHNNKJWA4U6dyZDvUSkKPQCt6PGgrAxiSBRvC6wqz2xKXU", # noqa: mock + "pair": {"base": {"asset": self.base_asset}, "quote": {"asset": "1"}}, "side": "Bid" if order.trade_type == TradeType.BUY else "Ask", "order_type": "MARKET" if order.order_type == OrderType.MARKET else "LIMIT", "qty": str(order.amount), "price": str(order.price), - "quote_order_qty": str(order.amount * order.price), "timestamp": 1682480373, - "overall_unreserved_volume": "0", } return {"websocket_streams": {"data": json.dumps(data)}} - def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + data = self.build_trade_event_websocket_update( + order=order, + filled_quantity=order.amount, + filled_price=order.price, + ) + return data + + def trade_event_for_partial_fill_websocket_update(self, order: InFlightOrder): + data = self.build_trade_event_websocket_update( + order=order, + filled_quantity=self.expected_partial_fill_amount, + filled_price=self.expected_partial_fill_price, + ) + return data + + def build_trade_event_websocket_update( + self, + order: InFlightOrder, + filled_quantity: Decimal, + filled_price: Decimal, + ) -> Dict[str, Any]: data = { - "type": "Order", - "snapshot_number": 50133, - "event_id": int(self.expected_fill_trade_id), - "client_order_id": "0x" + order.client_order_id.encode("utf-8").hex(), - "avg_filled_price": str(order.price), - "fee": str(self.expected_fill_fee.flat_fees[0].amount), - "filled_quantity": str(order.amount), - "status": "CLOSED", - "id": order.exchange_order_id, - "user": "5EqHNNKJWA4U6dyZDvUSkKPQCt6PGgrAxiSBRvC6wqz2xKXU", # noqa: mock - "main_account": "5C5ZpV7Hunb7yG2CwDtnhxaYc3aug4UTLxRvu6HERxJqrtJY", # noqa: mock - "pair": {"base_asset": self.base_asset, "quote_asset": "1"}, - "side": "Bid" if order.trade_type == TradeType.BUY else "Ask", - "order_type": "MARKET" if order.order_type == OrderType.MARKET else "LIMIT", - "qty": str(order.amount), - "price": str(order.price), - "quote_order_qty": str(order.amount * order.price), - "timestamp": 1682480373, - "overall_unreserved_volume": "0", + "type": "TradeFormat", + "stid": 50133, + "p": str(filled_price), + "q": str(filled_quantity), + "m": self.exchange_trading_pair, + "t": str(self.exchange.current_timestamp), + "cid": str(order.client_order_id), + "order_id": str(order.exchange_order_id), + "s": "Bid" if order.trade_type == TradeType.BUY else "Ask", + "trade_id": self.expected_fill_trade_id, } return {"websocket_streams": {"data": json.dumps(data)}} - def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): - raise NotImplementedError + @aioresponses() + def test_check_network_success(self, mock_api): + all_assets_mock_response = self.all_assets_mock_response + self.exchange._data_source._query_executor._all_assets_responses.put_nowait(all_assets_mock_response) + + network_status = self.async_run_with_timeout(coroutine=self.exchange.check_network()) + + self.assertEqual(NetworkStatus.CONNECTED, network_status) @aioresponses() def test_check_network_failure(self, mock_api): @@ -614,33 +754,22 @@ def test_check_network_raises_cancel_exception(self, mock_api): mock_queue.get.side_effect = asyncio.CancelledError self.exchange._data_source._query_executor._all_assets_responses = mock_queue - self.assertRaises( - asyncio.CancelledError, self.async_run_with_timeout, self.exchange.check_network(), 2 - ) - - @aioresponses() - def test_check_network_success(self, mock_api): - all_assets_mock_response = self.all_assets_mock_response - self.exchange._data_source._query_executor._all_assets_responses.put_nowait(all_assets_mock_response) - - network_status = self.async_run_with_timeout(coroutine=self.exchange.check_network()) - - self.assertEqual(NetworkStatus.CONNECTED, network_status) + self.assertRaises(asyncio.CancelledError, self.async_run_with_timeout, self.exchange.check_network()) @aioresponses() - def test_all_trading_pairs_does_not_raise_exception(self, mock_api): - self.exchange._set_trading_pair_symbol_map(None) - queue_mock = AsyncMock() - queue_mock.get.side_effect = Exception - self.exchange._data_source._query_executor._all_assets_responses = queue_mock + def test_get_last_trade_prices(self, mock_api): + response = self.latest_prices_request_mock_response + self.exchange._data_source._query_executor._recent_trades_responses.put_nowait(response) - result: List[str] = self.async_run_with_timeout(self.exchange.all_trading_pairs()) + latest_prices: Dict[str, float] = self.async_run_with_timeout( + self.exchange.get_last_traded_prices(trading_pairs=[self.trading_pair]) + ) - self.assertEqual(0, len(result)) + self.assertEqual(1, len(latest_prices)) + self.assertEqual(self.expected_latest_price, latest_prices[self.trading_pair]) @aioresponses() def test_invalid_trading_pair_not_in_all_trading_pairs(self, mock_api): - self.exchange._set_trading_pair_symbol_map(None) all_assets_mock_response = self.all_assets_mock_response self.exchange._data_source._query_executor._all_assets_responses.put_nowait(all_assets_mock_response) invalid_pair, response = self.all_symbols_including_invalid_pair_mock_response @@ -651,16 +780,16 @@ def test_invalid_trading_pair_not_in_all_trading_pairs(self, mock_api): self.assertNotIn(invalid_pair, all_trading_pairs) @aioresponses() - def test_get_last_trade_prices(self, mock_api): - response = self.latest_prices_request_mock_response - self.exchange._data_source._query_executor._recent_trades_responses.put_nowait(response) + def test_all_trading_pairs_does_not_raise_exception(self, mock_api): + self.exchange._set_trading_pair_symbol_map(None) + self.exchange._data_source._assets_map = None + queue_mock = AsyncMock() + queue_mock.get.side_effect = Exception + self.exchange._data_source._query_executor._all_assets_responses = queue_mock - latest_prices: Dict[str, float] = self.async_run_with_timeout( - self.exchange.get_last_traded_prices(trading_pairs=[self.trading_pair]) - ) + result: List[str] = self.async_run_with_timeout(self.exchange.all_trading_pairs()) - self.assertEqual(1, len(latest_prices)) - self.assertEqual(self.expected_latest_price, latest_prices[self.trading_pair]) + self.assertEqual(0, len(result)) @aioresponses() def test_create_buy_limit_order_successfully(self, mock_api): @@ -826,6 +955,48 @@ def test_cancel_order_successfully(self, mock_api): self.assertIn(order.client_order_id, self.exchange.in_flight_orders) self.assertTrue(order.is_pending_cancel_confirmation) + @aioresponses() + @patch("hummingbot.connector.exchange.polkadex.polkadex_data_source.PolkadexDataSource._build_substrate_interface") + @patch("hummingbot.connector.exchange.polkadex.polkadex_data_source.Keypair.sign") + def test_cancel_order_retries_on_substrate_broken_pipe( + self, mock_api: aioresponses, sign_mock: MagicMock, _: MagicMock + ): + sign_mock.hex.return_value = "0x1234adf" + request_sent_event = asyncio.Event() + self.exchange._set_current_timestamp(1640780000) + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=self.exchange_order_id_prefix + "1", + trading_pair=self.trading_pair, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("100"), + order_type=OrderType.LIMIT, + ) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + create_scale_object_mock = MagicMock( + "substrateinterface.base.SubstrateInterface.create_scale_object", autospec=True + ) + self.exchange._data_source._substrate_interface.create_scale_object.return_value = create_scale_object_mock + create_scale_object_mock.encode.side_effect = [ + BrokenPipeError, + "0x1b99cba5555ad0ba890756fe16e499cb884b46a165b89bdce77ee8913b55fff1", # noqa: mock + ] + self.configure_successful_cancelation_response( + order=order, mock_api=mock_api, callback=lambda *args, **kwargs: request_sent_event.set() + ) + + self.exchange.cancel(trading_pair=order.trading_pair, client_order_id=order.client_order_id) + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertIn(order.client_order_id, self.exchange.in_flight_orders) + self.assertTrue(order.is_pending_cancel_confirmation) + self.assertTrue(self.is_logged(log_level="ERROR", message="Rebuilding the substrate interface.")) + @aioresponses() def test_cancel_order_raises_failure_event_when_request_fails(self, mock_api): request_sent_event = asyncio.Event() @@ -891,6 +1062,37 @@ def test_cancel_order_not_found_in_the_exchange(self, mock_api): self.assertIn(order.client_order_id, self.exchange._order_tracker.all_updatable_orders) self.assertEqual(1, self.exchange._order_tracker._order_not_found_records[order.client_order_id]) + @aioresponses() + def test_cancel_order_no_longer_active(self, mock_api): + self.exchange._set_current_timestamp(1640780000) + request_sent_event = asyncio.Event() + + self.exchange.start_tracking_order( + order_id=self.client_order_id_prefix + "1", + exchange_order_id=str(self.expected_exchange_order_id), + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=Decimal("10000"), + amount=Decimal("1"), + ) + + self.assertIn(self.client_order_id_prefix + "1", self.exchange.in_flight_orders) + order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] + + self.configure_order_not_active_error_cancelation_response( + order=order, mock_api=mock_api, callback=lambda *args, **kwargs: request_sent_event.set() + ) + + self.exchange.cancel(trading_pair=self.trading_pair, client_order_id=self.client_order_id_prefix + "1") + self.async_run_with_timeout(request_sent_event.wait()) + + self.assertTrue(order.is_done) + self.assertFalse(order.is_failure) + self.assertTrue(order.is_cancelled) + + self.assertNotIn(order.client_order_id, self.exchange._order_tracker.all_updatable_orders) + @aioresponses() def test_update_balances(self, mock_api): response = self.balance_request_mock_response_for_base_and_quote @@ -919,58 +1121,6 @@ def test_update_balances(self, mock_api): self.assertEqual(Decimal("10"), available_balances[self.base_asset]) self.assertEqual(Decimal("15"), total_balances[self.base_asset]) - @aioresponses() - def test_update_order_status_when_filled(self, mock_api): - self.exchange._set_current_timestamp(1640780000) - request_sent_event = asyncio.Event() - - self.exchange.start_tracking_order( - order_id=self.client_order_id_prefix + "1", - exchange_order_id=str(self.expected_exchange_order_id), - trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal("10000"), - amount=Decimal("1"), - ) - order: InFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] - - # to allow the ClientOrderTracker to process the last status update - order.completely_filled_event.set() - - self.configure_completely_filled_order_status_response( - order=order, mock_api=mock_api, callback=lambda *args, **kwargs: request_sent_event.set() - ) - - self.async_run_with_timeout(self.exchange._update_order_status()) - # Execute one more synchronization to ensure the async task that processes the update is finished - self.async_run_with_timeout(request_sent_event.wait()) - - self.async_run_with_timeout(order.wait_until_completely_filled()) - self.assertTrue(order.is_done) - - buy_event: BuyOrderCompletedEvent = self.buy_order_completed_logger.event_log[0] - self.assertEqual(self.exchange.current_timestamp, buy_event.timestamp) - self.assertEqual(order.client_order_id, buy_event.order_id) - self.assertEqual(order.base_asset, buy_event.base_asset) - self.assertEqual(order.quote_asset, buy_event.quote_asset) - self.assertEqual( - order.amount if self.is_order_fill_http_update_included_in_status_update else Decimal("0"), - buy_event.base_asset_amount, - ) - self.assertEqual( - order.amount * order.price - if self.is_order_fill_http_update_included_in_status_update - else Decimal("0"), - buy_event.quote_asset_amount, - ) - self.assertEqual(order.order_type, buy_event.order_type) - self.assertEqual(order.exchange_order_id, buy_event.exchange_order_id) - self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) - self.assertTrue( - self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.") - ) - @aioresponses() def test_update_order_status_when_request_fails_marks_order_as_not_found(self, mock_api): self.exchange._set_current_timestamp(1640780000) @@ -998,30 +1148,6 @@ def test_update_order_status_when_request_fails_marks_order_as_not_found(self, m self.assertEqual(1, self.exchange._order_tracker._order_not_found_records[order.client_order_id]) - @aioresponses() - def test_update_order_status_when_order_has_not_changed_and_one_partial_fill(self, mock_api): - self.exchange._set_current_timestamp(1640780000) - - self.exchange.start_tracking_order( - order_id=self.client_order_id_prefix + "1", - exchange_order_id=str(self.expected_exchange_order_id), - trading_pair=self.trading_pair, - order_type=OrderType.LIMIT, - trade_type=TradeType.BUY, - price=Decimal("10000"), - amount=Decimal("1"), - ) - order: InFlightOrder = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] - - self.configure_partially_filled_order_status_response(order=order, mock_api=mock_api) - - self.assertTrue(order.is_open) - - self.async_run_with_timeout(self.exchange._update_order_status()) - - self.assertTrue(order.is_open) - self.assertEqual(OrderState.PARTIALLY_FILLED, order.current_state) - @aioresponses() def test_cancel_lost_order_successfully(self, mock_api): request_sent_event = asyncio.Event() @@ -1059,7 +1185,6 @@ def test_cancel_lost_order_successfully(self, mock_api): @aioresponses() def test_cancel_lost_order_raises_failure_event_when_request_fails(self, mock_api): - request_sent_event = asyncio.Event() self.exchange._set_current_timestamp(1640780000) self.exchange.start_tracking_order( @@ -1081,13 +1206,9 @@ def test_cancel_lost_order_raises_failure_event_when_request_fails(self, mock_ap self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) - self.configure_erroneous_cancelation_response( - order=order, - mock_api=mock_api, - callback=lambda *args, **kwargs: request_sent_event.set()) + self.exchange._data_source._query_executor._cancel_order_responses.put_nowait({}) self.async_run_with_timeout(self.exchange._cancel_lost_orders()) - self.async_run_with_timeout(request_sent_event.wait()) self.assertIn(order.client_order_id, self.exchange._order_tracker.lost_orders) self.assertEquals(0, len(self.order_cancelled_logger.event_log)) @@ -1152,9 +1273,12 @@ def test_lost_order_user_stream_full_fill_events_are_processed(self, mock_api): self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) order_event = self.order_event_for_full_fill_websocket_update(order=order) + trade_event = self.trade_event_for_full_fill_websocket_update(order=order) + self.reset_log_event() self.exchange._data_source._process_private_event(event=order_event) + self.exchange._data_source._process_private_event(event=trade_event) self.async_run_with_timeout(self.wait_for_a_log()) # Execute one more synchronization to ensure the async task that processes the update is finished @@ -1271,9 +1395,11 @@ def test_user_stream_update_for_order_full_fill(self, mock_api): order = self.exchange.in_flight_orders[self.client_order_id_prefix + "1"] order_event = self.order_event_for_full_fill_websocket_update(order=order) + trade_event = self.trade_event_for_full_fill_websocket_update(order=order) self.reset_log_event() self.exchange._data_source._process_private_event(event=order_event) + self.exchange._data_source._process_private_event(event=trade_event) self.async_run_with_timeout(self.wait_for_a_log()) # Execute one more synchronization to ensure the async task that processes the update is finished @@ -1303,7 +1429,9 @@ def test_user_stream_update_for_order_full_fill(self, mock_api): self.assertTrue(order.is_filled) self.assertTrue(order.is_done) - self.assertTrue(self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.")) + self.assertTrue( + self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.") + ) def test_user_stream_logs_errors(self): # This test does not apply to Polkadex because it handles private events in its own data source @@ -1336,25 +1464,36 @@ def test_lost_order_included_in_order_fills_update_and_not_in_order_status_updat self.assertNotIn(order.client_order_id, self.exchange.in_flight_orders) - order.completely_filled_event.set() - request_sent_event.set() + self.configure_full_fill_trade_response( + order=order, mock_api=mock_api, callback=lambda *args, **kwargs: request_sent_event.set() + ) self.async_run_with_timeout(self.exchange._update_order_status()) - # Execute one more synchronization to ensure the async task that processes the update is finished - self.async_run_with_timeout(request_sent_event.wait()) self.async_run_with_timeout(order.wait_until_completely_filled()) self.assertTrue(order.is_done) self.assertTrue(order.is_failure) + fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0] + self.assertEqual(self.exchange.current_timestamp, fill_event.timestamp) + self.assertEqual(order.client_order_id, fill_event.order_id) + self.assertEqual(order.trading_pair, fill_event.trading_pair) + self.assertEqual(order.trade_type, fill_event.trade_type) + self.assertEqual(order.order_type, fill_event.order_type) + self.assertEqual(order.price, fill_event.price) + self.assertEqual(order.amount, fill_event.amount) + self.assertEqual(self.expected_fill_fee, fill_event.trade_fee) + self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) self.assertIn(order.client_order_id, self.exchange._order_tracker.all_fillable_orders) - self.assertFalse( - self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.") - ) + self.assertFalse(self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.")) request_sent_event.clear() + self.configure_full_fill_trade_response( + order=order, mock_api=mock_api, callback=lambda *args, **kwargs: request_sent_event.set() + ) + self.configure_completely_filled_order_status_response( order=order, mock_api=mock_api, callback=lambda *args, **kwargs: request_sent_event.set() ) @@ -1366,11 +1505,11 @@ def test_lost_order_included_in_order_fills_update_and_not_in_order_status_updat self.assertTrue(order.is_done) self.assertTrue(order.is_failure) + if self.is_order_fill_http_update_included_in_status_update: + self.assertEqual(1, len(self.order_filled_logger.event_log)) self.assertEqual(0, len(self.buy_order_completed_logger.event_log)) self.assertNotIn(order.client_order_id, self.exchange._order_tracker.all_fillable_orders) - self.assertFalse( - self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.") - ) + self.assertFalse(self.is_logged("INFO", f"BUY order {order.client_order_id} completely filled.")) def test_initial_status_dict(self): self.exchange._set_trading_pair_symbol_map(None) @@ -1466,7 +1605,52 @@ def _configure_balance_response( def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Any: return {"cancel_order": True} + def _all_trading_pairs_mock_response(self, orders_count: int, symbol: str) -> Any: + return { + "listOpenOrdersByMainAccount": { + "items": [ + { + "afp": "0", + "cid": f"0x48424f544250584354356663383135646636666166313531306165623366376{i}", + "fee": "0", + "fq": "0", + "id": f"0x541a3a1be1ad69cc0d325103ca54e4e12c8035d9474a96539af3323cae681fa{i}", + "m": symbol, + "ot": "LIMIT", + "p": f"1.51{i}", + "q": f"0.06{i}", + "s": "Bid", + "st": "OPEN", + "t": self.exchange.current_timestamp, + } + for i in range(orders_count) + ], + }, + } + def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: + return {"findOrderByMainAccount": self._orders_status_response(order=order)} + + def _orders_status_response(self, order: InFlightOrder) -> Any: + return { + "afp": "0", + "cid": "0x" + order.client_order_id.encode("utf-8").hex(), + "fee": "0", + "fq": "0", + "id": order.exchange_order_id, + "isReverted": False, + "m": self.exchange_trading_pair, + "ot": "MARKET" if order.order_type == OrderType.MARKET else "LIMIT", + "p": str(order.price), + "q": str(order.amount), + "s": "Bid" if order.trade_type == TradeType.BUY else "Ask", + "sid": 1, + "st": "OPEN", + "t": 160001112.223, + "u": "", + } + + def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: return { "findOrderByMainAccount": { "afp": "0", @@ -1481,19 +1665,19 @@ def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: "q": str(order.amount), "s": "Bid" if order.trade_type == TradeType.BUY else "Ask", "sid": 1, - "st": "OPEN", + "st": "CANCELLED", "t": 160001112.223, "u": "", } } - def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: + def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: return { "findOrderByMainAccount": { - "afp": "0", + "afp": str(order.price), "cid": "0x" + order.client_order_id.encode("utf-8").hex(), - "fee": "0", - "fq": "0", + "fee": str(self.expected_fill_fee.flat_fees[0].amount), + "fq": str(order.amount), "id": order.exchange_order_id, "isReverted": False, "m": self.exchange_trading_pair, @@ -1501,20 +1685,20 @@ def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> "p": str(order.price), "q": str(order.amount), "s": "Bid" if order.trade_type == TradeType.BUY else "Ask", - "sid": 1, - "st": "CANCELLED", + "sid": int(self.expected_fill_trade_id), + "st": "CLOSED", "t": 160001112.223, "u": "", } } - def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: + def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: return { "findOrderByMainAccount": { - "afp": str(order.price), + "afp": str(self.expected_partial_fill_price), "cid": "0x" + order.client_order_id.encode("utf-8").hex(), - "fee": str(self.expected_fill_fee.flat_fees[0].amount), - "fq": str(order.amount), + "fee": str(self.expected_partial_fill_fee.flat_fees[0].amount), + "fq": str(self.expected_partial_fill_amount), "id": order.exchange_order_id, "isReverted": False, "m": self.exchange_trading_pair, @@ -1523,13 +1707,13 @@ def _order_status_request_completely_filled_mock_response(self, order: InFlightO "q": str(order.amount), "s": "Bid" if order.trade_type == TradeType.BUY else "Ask", "sid": int(self.expected_fill_trade_id), - "st": "CLOSED", + "st": "OPEN", "t": 160001112.223, "u": "", } } - def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: + def _order_status_request_partially_canceled_mock_response(self, order: InFlightOrder) -> Any: return { "findOrderByMainAccount": { "afp": str(self.expected_partial_fill_price), @@ -1544,7 +1728,7 @@ def _order_status_request_partially_filled_mock_response(self, order: InFlightOr "q": str(order.amount), "s": "Bid" if order.trade_type == TradeType.BUY else "Ask", "sid": int(self.expected_fill_trade_id), - "st": "OPEN", + "st": "CANCELLED", "t": 160001112.223, "u": "", } diff --git a/test/hummingbot/connector/exchange/woo_x/__init__.py b/test/hummingbot/connector/exchange/woo_x/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_api_order_book_data_source.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_api_order_book_data_source.py new file mode 100644 index 0000000000..797376993d --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_api_order_book_data_source.py @@ -0,0 +1,464 @@ +import asyncio +import json +import re +import unittest +from typing import Awaitable +from unittest.mock import AsyncMock, MagicMock, patch + +from aioresponses.core import aioresponses +from bidict import bidict + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS, woo_x_web_utils as web_utils +from hummingbot.connector.exchange.woo_x.woo_x_api_order_book_data_source import WooXAPIOrderBookDataSource +from hummingbot.connector.exchange.woo_x.woo_x_exchange import WooXExchange +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.core.data_type.order_book import OrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessage + + +class WooXAPIOrderBookDataSourceUnitTests(unittest.TestCase): + # logging.Level required to receive logs from the data source logger + level = 0 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.ev_loop = asyncio.get_event_loop() + cls.base_asset = "BTC" + cls.quote_asset = "USDT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"SPOT_{cls.base_asset}_{cls.quote_asset}" + cls.domain = "woo_x" + + def setUp(self) -> None: + super().setUp() + + self.log_records = [] + + self.listening_task = None + + self.mocking_assistant = NetworkMockingAssistant() + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + + self.connector = WooXExchange( + client_config_map=client_config_map, + public_api_key="", + secret_api_key="", + application_id="", + trading_pairs=[], + trading_required=False, + domain=self.domain + ) + + self.data_source = WooXAPIOrderBookDataSource( + trading_pairs=[self.trading_pair], + connector=self.connector, + api_factory=self.connector._web_assistants_factory, + domain=self.domain + ) + + self.data_source.logger().setLevel(1) + + self.data_source.logger().addHandler(self) + + self._original_full_order_book_reset_time = self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS + + self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS = -1 + + self.resume_test_event = asyncio.Event() + + self.connector._set_trading_pair_symbol_map(bidict({self.ex_trading_pair: self.trading_pair})) + + def tearDown(self) -> None: + self.listening_task and self.listening_task.cancel() + + self.data_source.FULL_ORDER_BOOK_RESET_DELTA_SECONDS = self._original_full_order_book_reset_time + + super().tearDown() + + def handle(self, record): + self.log_records.append(record) + + def _is_logged(self, log_level: str, message: str) -> bool: + return any(record.levelname == log_level and record.getMessage() == message + for record in self.log_records) + + def _create_exception_and_unlock_test_with_event(self, exception): + self.resume_test_event.set() + raise exception + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def _successfully_subscribed_event(self): + resp = { + "result": None, + "id": 1 + } + return resp + + def _trade_update_event(self): + resp = { + "topic": "SPOT_BTC_USDT@trade", + "ts": 1618820361552, + "data": { + "symbol": "SPOT_BTC_USDT", + "price": 56749.15, + "size": 3.92864, + "side": "BUY", + "source": 0 + } + } + + return resp + + def _order_diff_event(self): + resp = { + "topic": "SPOT_BTC_USDT@orderbookupdate", + "ts": 1618826337580, + "data": { + "symbol": "SPOT_BTC_USDT", + "prevTs": 1618826337380, + "asks": [ + [ + 56749.15, + 3.92864 + ], + [ + 56749.8, + 0 + ], + ], + "bids": [ + [ + 56745.2, + 1.03895025 + ], + [ + 56744.6, + 1.0807 + ], + ] + } + } + + return resp + + def _snapshot_response(self): + return { + "success": True, + "bids": [ + { + "price": 4, + "quantity": 431 + } + ], + "asks": [ + { + "price": 4.000002, + "quantity": 12 + } + ], + "timestamp": 1686211049066 + } + + @aioresponses() + def test_get_new_order_book_successful(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + resp = self._snapshot_response() + + mock_api.get(regex_url, body=json.dumps(resp)) + + order_book: OrderBook = self.async_run_with_timeout( + self.data_source.get_new_order_book(self.trading_pair) + ) + + expected_update_id = resp["timestamp"] + + self.assertEqual(expected_update_id, order_book.snapshot_uid) + bids = list(order_book.bid_entries()) + asks = list(order_book.ask_entries()) + self.assertEqual(1, len(bids)) + self.assertEqual(4, bids[0].price) + self.assertEqual(431, bids[0].amount) + self.assertEqual(expected_update_id, bids[0].update_id) + self.assertEqual(1, len(asks)) + self.assertEqual(4.000002, asks[0].price) + self.assertEqual(12, asks[0].amount) + self.assertEqual(expected_update_id, asks[0].update_id) + + @aioresponses() + def test_get_new_order_book_raises_exception(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL, domain=self.domain) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, status=400) + + with self.assertRaises(IOError): + self.async_run_with_timeout( + self.data_source.get_new_order_book(self.trading_pair) + ) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_subscriptions_subscribes_to_trades_and_order_diffs(self, ws_connect_mock): + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + result_subscribe_trades = { + "id": "0", + "event": "subscribe", + "success": True, + "ts": 1609924478533 + } + + result_subscribe_diffs = { + "id": "1", + "event": "subscribe", + "success": True, + "ts": 1609924478533 + } + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_trades) + ) + + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=ws_connect_mock.return_value, + message=json.dumps(result_subscribe_diffs) + ) + + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + + sent_subscription_messages = self.mocking_assistant.json_messages_sent_through_websocket( + websocket_mock=ws_connect_mock.return_value + ) + + self.assertEqual(2, len(sent_subscription_messages)) + + expected_trade_subscription = { + "id": "0", + "topic": f"{self.ex_trading_pair}@trade", + "event": "subscribe", + } + + self.assertEqual(expected_trade_subscription, sent_subscription_messages[0]) + + expected_diff_subscription = { + "id": "1", + "topic": f"{self.ex_trading_pair}@orderbookupdate", + "event": "subscribe", + } + + self.assertEqual(expected_diff_subscription, sent_subscription_messages[1]) + + self.assertTrue(self._is_logged( + "INFO", + "Subscribed to public order book and trade channels..." + )) + + @patch("hummingbot.core.data_type.order_book_tracker_data_source.OrderBookTrackerDataSource._sleep") + @patch("aiohttp.ClientSession.ws_connect") + def test_listen_for_subscriptions_raises_cancel_exception(self, mock_ws, _: AsyncMock): + mock_ws.side_effect = asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + self.async_run_with_timeout(self.listening_task) + + @patch("hummingbot.core.data_type.order_book_tracker_data_source.OrderBookTrackerDataSource._sleep") + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_subscriptions_logs_exception_details(self, mock_ws, sleep_mock): + mock_ws.side_effect = Exception("TEST ERROR.") + sleep_mock.side_effect = lambda _: self._create_exception_and_unlock_test_with_event(asyncio.CancelledError()) + + self.listening_task = self.ev_loop.create_task(self.data_source.listen_for_subscriptions()) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error occurred when listening to order book streams. Retrying in 5 seconds...")) + + def test_subscribe_channels_raises_cancel_exception(self): + mock_ws = MagicMock() + mock_ws.send.side_effect = asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task(self.data_source._subscribe_channels(mock_ws)) + self.async_run_with_timeout(self.listening_task) + + def test_subscribe_channels_raises_exception_and_logs_error(self): + mock_ws = MagicMock() + mock_ws.send.side_effect = Exception("Test Error") + + with self.assertRaises(Exception): + self.listening_task = self.ev_loop.create_task(self.data_source._subscribe_channels(mock_ws)) + self.async_run_with_timeout(self.listening_task) + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error occurred subscribing to order book trading and delta streams...") + ) + + def test_listen_for_trades_cancelled_when_listening(self): + mock_queue = MagicMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.listening_task) + + def test_listen_for_trades_logs_exception(self): + incomplete_resp = { + "m": 1, + "i": 2, + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [incomplete_resp, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue) + ) + + try: + self.async_run_with_timeout(self.listening_task) + except asyncio.CancelledError: + pass + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error when processing public trade updates from exchange")) + + def test_listen_for_trades_successful(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = [self._trade_update_event(), asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.TRADE_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_trades(self.ev_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(1618820361552, msg.trade_id) + + def test_listen_for_order_book_diffs_cancelled(self): + mock_queue = AsyncMock() + mock_queue.get.side_effect = asyncio.CancelledError() + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.listening_task) + + def test_listen_for_order_book_diffs_logs_exception(self): + incomplete_resp = { + "m": 1, + "i": 2, + } + + mock_queue = AsyncMock() + mock_queue.get.side_effect = [incomplete_resp, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue) + ) + + try: + self.async_run_with_timeout(self.listening_task) + except asyncio.CancelledError: + pass + + self.assertTrue( + self._is_logged("ERROR", "Unexpected error when processing public order book updates from exchange")) + + def test_listen_for_order_book_diffs_successful(self): + mock_queue = AsyncMock() + diff_event = self._order_diff_event() + mock_queue.get.side_effect = [diff_event, asyncio.CancelledError()] + self.data_source._message_queue[CONSTANTS.DIFF_EVENT_TYPE] = mock_queue + + msg_queue: asyncio.Queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_diffs(self.ev_loop, msg_queue)) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(diff_event["ts"], msg.update_id) + + @aioresponses() + def test_listen_for_order_book_snapshots_cancelled_when_fetching_snapshot(self, mock_api): + url = web_utils.public_rest_url(path_url=CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL, domain=self.domain) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, exception=asyncio.CancelledError, repeat=True) + + with self.assertRaises(asyncio.CancelledError): + self.async_run_with_timeout( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, asyncio.Queue()) + ) + + @aioresponses() + @patch("hummingbot.connector.exchange.woo_x.woo_x_api_order_book_data_source" + ".WooXAPIOrderBookDataSource._sleep") + def test_listen_for_order_book_snapshots_log_exception(self, mock_api, sleep_mock): + msg_queue: asyncio.Queue = asyncio.Queue() + sleep_mock.side_effect = lambda _: self._create_exception_and_unlock_test_with_event(asyncio.CancelledError()) + + url = web_utils.public_rest_url(path_url=CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL, domain=self.domain) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, exception=Exception, repeat=True) + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + ) + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged("ERROR", f"Unexpected error fetching order book snapshot for {self.trading_pair}.")) + + @aioresponses() + def test_listen_for_order_book_snapshots_successful(self, mock_api, ): + msg_queue: asyncio.Queue = asyncio.Queue() + + url = web_utils.public_rest_url(path_url=CONSTANTS.ORDERBOOK_SNAPSHOT_PATH_URL, domain=self.domain) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + mock_api.get(regex_url, body=json.dumps(self._snapshot_response())) + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_order_book_snapshots(self.ev_loop, msg_queue) + ) + + msg: OrderBookMessage = self.async_run_with_timeout(msg_queue.get()) + + self.assertEqual(1686211049066, msg.update_id) diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_api_user_stream_data_source.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_api_user_stream_data_source.py new file mode 100644 index 0000000000..4308017ec8 --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_api_user_stream_data_source.py @@ -0,0 +1,273 @@ +import asyncio +import json +import unittest +from typing import Any, Awaitable, Dict, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +from bidict import bidict + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS +from hummingbot.connector.exchange.woo_x.woo_x_api_user_stream_data_source import WooXAPIUserStreamDataSource +from hummingbot.connector.exchange.woo_x.woo_x_auth import WooXAuth +from hummingbot.connector.exchange.woo_x.woo_x_exchange import WooXExchange +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.connector.time_synchronizer import TimeSynchronizer +from hummingbot.core.api_throttler.async_throttler import AsyncThrottler + + +class WooXUserStreamDataSourceUnitTests(unittest.TestCase): + # the level is required to receive logs from the data source logger + level = 0 + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.ev_loop = asyncio.get_event_loop() + cls.base_asset = "COINALPHA" + cls.quote_asset = "HBOT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = cls.base_asset + cls.quote_asset + cls.domain = "woo_x" + + cls.listen_key = "TEST_LISTEN_KEY" + + def setUp(self) -> None: + super().setUp() + self.log_records = [] + self.listening_task: Optional[asyncio.Task] = None + self.mocking_assistant = NetworkMockingAssistant() + + self.throttler = AsyncThrottler(rate_limits=CONSTANTS.RATE_LIMITS) + self.mock_time_provider = MagicMock() + self.mock_time_provider.time.return_value = 1000 + self.auth = WooXAuth(api_key="TEST_API_KEY", secret_key="TEST_SECRET", time_provider=self.mock_time_provider) + self.time_synchronizer = TimeSynchronizer() + self.time_synchronizer.add_time_offset_ms_sample(0) + + client_config_map = ClientConfigAdapter(ClientConfigMap()) + self.connector = WooXExchange( + client_config_map=client_config_map, + public_api_key="", + secret_api_key="", + application_id="", + trading_pairs=[], + trading_required=False, + domain=self.domain) + self.connector._web_assistants_factory._auth = self.auth + + self.data_source = WooXAPIUserStreamDataSource( + auth=self.auth, + trading_pairs=[self.trading_pair], + connector=self.connector, + api_factory=self.connector._web_assistants_factory, + domain=self.domain + ) + + self.data_source.logger().setLevel(1) + self.data_source.logger().addHandler(self) + + self.resume_test_event = asyncio.Event() + + self.connector._set_trading_pair_symbol_map(bidict({self.ex_trading_pair: self.trading_pair})) + + def tearDown(self) -> None: + self.listening_task and self.listening_task.cancel() + super().tearDown() + + def handle(self, record): + self.log_records.append(record) + + def _is_logged(self, log_level: str, message: str) -> bool: + return any(record.levelname == log_level and record.getMessage() == message + for record in self.log_records) + + def _raise_exception(self, exception_class): + raise exception_class + + def _create_exception_and_unlock_test_with_event(self, exception): + self.resume_test_event.set() + raise exception + + def _create_return_value_and_unlock_test_with_event(self, value): + self.resume_test_event.set() + return value + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = self.ev_loop.run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def _error_response(self) -> Dict[str, Any]: + resp = { + "code": "ERROR CODE", + "msg": "ERROR MESSAGE" + } + + return resp + + def _user_update_event(self): + # Balance Update + resp = { + "e": "balanceUpdate", + "E": 1573200697110, + "a": "BTC", + "d": "100.00000000", + "T": 1573200697068 + } + return json.dumps(resp) + + def _successfully_subscribed_event(self): + resp = { + "result": None, + "id": 1 + } + return resp + + def _authentication_response(self, success: bool) -> str: + return json.dumps({ + "id": "auth", + "event": "auth", + "success": success, + "ts": 1686526749230, + **({} if success else {"errorMsg": "sample error message"}) + }) + + def _subscription_response(self, success: bool, channel: str) -> str: + return json.dumps({ + 'id': channel, + 'event': 'subscribe', + 'success': success, + 'ts': 1686527628871 + }) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listening_process_authenticates_and_subscribes_to_events(self, ws_connect_mock): + messages = asyncio.Queue() + + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + initial_last_recv_time = self.data_source.last_recv_time + + # Add the authentication response for the websocket + self.mocking_assistant.add_websocket_aiohttp_message( + ws_connect_mock.return_value, + self._authentication_response(True) + ) + + self.mocking_assistant.add_websocket_aiohttp_message( + ws_connect_mock.return_value, + self._subscription_response( + True, + 'executionreport' + ) + ) + + self.mocking_assistant.add_websocket_aiohttp_message( + ws_connect_mock.return_value, + self._subscription_response( + True, + 'balance' + ) + ) + + self.listening_task = asyncio.get_event_loop().create_task( + self.data_source.listen_for_user_stream(messages) + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + + self.assertTrue( + self._is_logged("INFO", "Subscribed to private account and orders channels...") + ) + + sent_messages = self.mocking_assistant.json_messages_sent_through_websocket(ws_connect_mock.return_value) + + self.assertEqual(3, len(sent_messages)) + + for n, id in enumerate(['auth', 'executionreport', 'balance']): + self.assertEqual(sent_messages[n]['id'], id) + + self.assertGreater(self.data_source.last_recv_time, initial_last_recv_time) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_authentication_failure(self, ws_connect_mock): + messages = asyncio.Queue() + + ws_connect_mock.return_value = self.mocking_assistant.create_websocket_mock() + + self.mocking_assistant.add_websocket_aiohttp_message( + ws_connect_mock.return_value, + self._authentication_response(False) + ) + + self.listening_task = asyncio.get_event_loop().create_task( + self.data_source.listen_for_user_stream(messages) + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(ws_connect_mock.return_value) + + self.assertTrue( + self._is_logged( + "ERROR", + f"Error authenticating the private websocket connection: {self._authentication_response(False)}" + ) + ) + + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error while listening to user stream. Retrying after 5 seconds..." + ) + ) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_does_not_queue_empty_payload(self, mock_ws): + mock_ws.return_value = self.mocking_assistant.create_websocket_mock() + + self.mocking_assistant.add_websocket_aiohttp_message( + mock_ws.return_value, "" + ) + + msg_queue = asyncio.Queue() + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(mock_ws.return_value) + + self.assertEqual(0, msg_queue.qsize()) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listen_for_user_stream_connection_failed(self, mock_ws): + mock_ws.side_effect = lambda *arg, **kwars: self._create_exception_and_unlock_test_with_event( + Exception("TEST ERROR.") + ) + + msg_queue = asyncio.Queue() + + self.listening_task = self.ev_loop.create_task( + self.data_source.listen_for_user_stream(msg_queue) + ) + + self.async_run_with_timeout(self.resume_test_event.wait()) + + self.assertTrue( + self._is_logged( + "ERROR", + "Unexpected error while listening to user stream. Retrying after 5 seconds..." + ) + ) + + @patch("aiohttp.ClientSession.ws_connect", new_callable=AsyncMock) + def test_listening_process_canceled_on_cancel_exception(self, mock_ws): + messages = asyncio.Queue() + + mock_ws.side_effect = asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + self.listening_task = asyncio.get_event_loop().create_task( + self.data_source.listen_for_user_stream(messages) + ) + + self.async_run_with_timeout(self.listening_task) diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_auth.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_auth.py new file mode 100644 index 0000000000..26bbc8c51e --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_auth.py @@ -0,0 +1,67 @@ +import asyncio +import hashlib +import hmac +import json +from unittest import TestCase +from unittest.mock import MagicMock + +from typing_extensions import Awaitable + +from hummingbot.connector.exchange.woo_x.woo_x_auth import WooXAuth +from hummingbot.core.web_assistant.connections.data_types import RESTMethod, RESTRequest + + +class WooXAuthTests(TestCase): + def setUp(self) -> None: + self._api_key = "testApiKey" + self._secret = "testSecret" + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1): + ret = asyncio.get_event_loop().run_until_complete(asyncio.wait_for(coroutine, timeout)) + + return ret + + def test_rest_authenticate(self): + mock_time_provider = MagicMock() + + mock_time_provider.time.return_value = 1686452155.0 + + data = { + "symbol": "SPOT_BTC_USDT", + "order_type": "LIMIT", + "side": "BUY", + "order_price": 20000, + "order_quantity": 1, + } + + timestamp = str(int(mock_time_provider.time.return_value * 1e3)) + + auth = WooXAuth(api_key=self._api_key, secret_key=self._secret, time_provider=mock_time_provider) + + request = RESTRequest(method=RESTMethod.POST, data=json.dumps(data), is_auth_required=True) + + configured_request = self.async_run_with_timeout(auth.rest_authenticate(request)) + + signable = '&'.join([f"{key}={value}" for key, value in sorted(data.items())]) + f"|{timestamp}" + + signature = ( + hmac.new( + bytes(self._secret, "utf-8"), + bytes(signable, "utf-8"), + hashlib.sha256 + ).hexdigest().upper() + ) + + headers = { + 'x-api-key': self._api_key, + 'x-api-signature': signature, + 'x-api-timestamp': timestamp, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + } + + self.assertEqual(timestamp, configured_request.headers['x-api-timestamp']) + + self.assertEqual(signature, configured_request.headers['x-api-signature']) + + self.assertEqual(headers, configured_request.headers) diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_exchange.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_exchange.py new file mode 100644 index 0000000000..64852d7a4a --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_exchange.py @@ -0,0 +1,920 @@ +import json +import logging +import re +import secrets +from decimal import Decimal +from typing import Any, Callable, List, Optional, Tuple +from unittest.mock import patch + +from aioresponses import aioresponses +from aioresponses.core import RequestCall + +from hummingbot.client.config.client_config_map import ClientConfigMap +from hummingbot.client.config.config_helpers import ClientConfigAdapter +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS, woo_x_web_utils as web_utils +from hummingbot.connector.exchange.woo_x.woo_x_exchange import WooXExchange +from hummingbot.connector.test_support.exchange_connector_test import AbstractExchangeConnectorTests +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.common import OrderType +from hummingbot.core.data_type.in_flight_order import InFlightOrder +from hummingbot.core.data_type.trade_fee import DeductedFromReturnsTradeFee, TokenAmount, TradeFeeBase + + +class WooXExchangeTests(AbstractExchangeConnectorTests.ExchangeConnectorTests): + @property + def all_symbols_url(self): + return web_utils.public_rest_url(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, domain=self.exchange._domain) + + @property + def latest_prices_url(self): + params = { + 'symbol': self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset) + } + + query = ('?' + '&'.join([f"{key}={value}" for key, value in sorted(params.items())])) if len( + params) != 0 else '' + + url = web_utils.public_rest_url(path_url=CONSTANTS.MARKET_TRADES_PATH, domain=self.exchange._domain) + query + + return url + + @property + def network_status_url(self): + raise NotImplementedError + + @property + def trading_rules_url(self): + return web_utils.public_rest_url(CONSTANTS.EXCHANGE_INFO_PATH_URL, domain=self.exchange._domain) + + @property + def order_creation_url(self): + return web_utils.public_rest_url(CONSTANTS.ORDER_PATH_URL, domain=self.exchange._domain) + + @property + def balance_url(self): + return web_utils.private_rest_url(CONSTANTS.ACCOUNTS_PATH_URL, domain=self.exchange._domain) + + @property + def all_symbols_request_mock_response(self): + return { + "rows": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "quote_min": 0, + "quote_max": 200000, + "quote_tick": 0.01, + "base_min": 0.00001, + "base_max": 300, + "base_tick": 0.00000001, + "min_notional": 1, + "price_range": 0.1, + "price_scope": None, + "created_time": "1571824137.000", + "updated_time": "1686530374.000", + "is_stable": 0, + "precisions": [ + 1, + 10, + 100, + 500, + 1000, + 10000 + ] + } + ], + "success": True + } + + @property + def latest_prices_request_mock_response(self): + return { + "success": True, + "rows": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "side": "BUY", + "source": 0, + "executed_price": self.expected_latest_price, + "executed_quantity": 0.00025, + "executed_timestamp": "1567411795.000" + } + ] + } + + @property + def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: + mock_response = self.all_symbols_request_mock_response + + return None, mock_response + + @property + def network_status_request_successful_mock_response(self): + return {} + + @property + def trading_rules_request_mock_response(self): + return { + "rows": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "quote_min": 0, + "quote_max": 200000, + "quote_tick": 0.01, + "base_min": 0.00001, + "base_max": 300, + "base_tick": 0.00000001, + "min_notional": 1, + "price_range": 0.1, + "price_scope": None, + "created_time": "1571824137.000", + "updated_time": "1686530374.000", + "is_stable": 0, + "precisions": [ + 1, + 10, + 100, + 500, + 1000, + 10000 + ] + } + ], + "success": None + } + + @property + def trading_rules_request_erroneous_mock_response(self): + return { + "rows": [ + { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "min_notional": 1, + "price_range": 0.1, + "price_scope": None, + "created_time": "1571824137.000", + "updated_time": "1686530374.000", + "is_stable": 0, + "precisions": [ + 1, + 10, + 100, + 500, + 1000, + 10000 + ] + } + ], + "success": None + } + + @property + def order_creation_request_successful_mock_response(self): + return { + "success": True, + "timestamp": "1686537643.701", + "order_id": self.expected_exchange_order_id, + "order_type": "LIMIT", + "order_price": 20000, + "order_quantity": 0.001, + "order_amount": None, + "client_order_id": 0 + } + + @property + def balance_request_mock_response_for_base_and_quote(self): + return { + "holding": [{ + "token": self.base_asset, + "holding": 10, + "frozen": 5, + "interest": 0.0, + "outstanding_holding": -0.00080, + "pending_exposure": 0.0, + "opening_cost": -126.36839957, + "holding_cost": -125.69703515, + "realised_pnl": 73572.86125165, + "settled_pnl": 73573.5326161, + "fee_24_h": 0.01432411, + "settled_pnl_24_h": 0.67528081, + "updated_time": "1675220398" + }, { + "token": self.quote_asset, + "holding": 2000, + "frozen": 0, + "interest": 0.0, + "outstanding_holding": -0.00080, + "pending_exposure": 0.0, + "opening_cost": -126.36839957, + "holding_cost": -125.69703515, + "realised_pnl": 73572.86125165, + "settled_pnl": 73573.5326161, + "fee_24_h": 0.01432411, + "settled_pnl_24_h": 0.67528081, + "updated_time": "1675220398" + }], + "success": True + } + + @property + def balance_request_mock_response_only_base(self): + return { + "holding": [{ + "token": self.base_asset, + "holding": 10, + "frozen": 5, + "interest": 0.0, + "outstanding_holding": -0.00080, + "pending_exposure": 0.0, + "opening_cost": -126.36839957, + "holding_cost": -125.69703515, + "realised_pnl": 73572.86125165, + "settled_pnl": 73573.5326161, + "fee_24_h": 0.01432411, + "settled_pnl_24_h": 0.67528081, + "updated_time": "1675220398" + }], + "success": True + } + + @property + def balance_event_websocket_update(self): + return { + "topic": "balance", + "ts": 1686539285351, + "data": { + "balances": { + self.base_asset: { + "holding": 10, + "frozen": 5, + "interest": 0.0, + "pendingShortQty": 0.0, + "pendingExposure": 0.0, + "pendingLongQty": 0.004, + "pendingLongExposure": 0.0, + "version": 9, + "staked": 0.0, + "unbonding": 0.0, + "vault": 0.0, + "averageOpenPrice": 0.0, + "pnl24H": 0.0, + "fee24H": 0.00773214, + "markPrice": 25772.05, + "pnl24HPercentage": 0.0 + } + } + } + } + + @property + def expected_latest_price(self): + return 9999.9 + + @property + def expected_supported_order_types(self): + return [OrderType.LIMIT, OrderType.LIMIT_MAKER, OrderType.MARKET] + + @property + def expected_trading_rule(self): + return TradingRule( + trading_pair=self.trading_pair, + min_order_size=Decimal(str(self.trading_rules_request_mock_response["rows"][0]["base_min"])), + min_price_increment=Decimal(str(self.trading_rules_request_mock_response["rows"][0]["quote_tick"])), + min_base_amount_increment=Decimal(str(self.trading_rules_request_mock_response["rows"][0]['base_tick'])), + min_notional_size=Decimal(str(self.trading_rules_request_mock_response["rows"][0]["min_notional"])) + ) + + @property + def expected_logged_error_for_erroneous_trading_rule(self): + erroneous_rule = self.trading_rules_request_erroneous_mock_response["rows"][0] + return f"Error parsing the trading pair rule {erroneous_rule}. Skipping." + + @property + def expected_exchange_order_id(self): + return 28 + + @property + def is_order_fill_http_update_included_in_status_update(self) -> bool: + return True + + @property + def is_order_fill_http_update_executed_during_websocket_order_event_processing(self) -> bool: + return False + + @property + def expected_partial_fill_price(self) -> Decimal: + return Decimal(10500) + + @property + def expected_partial_fill_amount(self) -> Decimal: + return Decimal("0.5") + + @property + def expected_fill_fee(self) -> TradeFeeBase: + return DeductedFromReturnsTradeFee( + percent_token=self.quote_asset, + flat_fees=[TokenAmount(token=self.quote_asset, amount=Decimal("30"))] + ) + + @property + def expected_fill_trade_id(self) -> str: + return str(30000) + + def exchange_symbol_for_tokens(self, base_token: str, quote_token: str) -> str: + return f"SPOT_{base_token}_{quote_token}" + + def create_exchange_instance(self): + client_config_map = ClientConfigAdapter(ClientConfigMap()) + + return WooXExchange( + client_config_map=client_config_map, + public_api_key="testAPIKey", + secret_api_key="testSecret", + application_id="applicationId", + trading_pairs=[self.trading_pair], + ) + + def validate_auth_credentials_present(self, request_call: RequestCall): + self._validate_auth_credentials_taking_parameters_from_argument(request_call) + + def validate_order_creation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = dict(request_call.kwargs["data"]) + self.assertEqual(self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), request_data["symbol"]) + self.assertEqual(order.trade_type.name.upper(), request_data["side"]) + self.assertEqual(WooXExchange.woo_x_order_type(OrderType.LIMIT), request_data["order_type"]) + self.assertEqual(Decimal("100"), Decimal(request_data["order_quantity"])) + self.assertEqual(Decimal("10000"), Decimal(request_data["order_price"])) + self.assertEqual(order.client_order_id, request_data["client_order_id"]) + + def validate_order_cancelation_request(self, order: InFlightOrder, request_call: RequestCall): + request_data = dict(request_call.kwargs["params"]) + + self.assertEqual( + self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + request_data["symbol"] + ) + + self.assertEqual(order.client_order_id, request_data["client_order_id"]) + + def validate_order_status_request(self, order: InFlightOrder, request_call: RequestCall): + return True + # request_params = request_call.kwargs["params"] + # + # + # logging.info(f"request params: {request_params}") + # logging.info(f"request: {request_call}") + # + # self.assertEqual(order.exchange_order_id, request_params["order_id"]) + + def validate_trades_request(self, order: InFlightOrder, request_call: RequestCall): + return True + + def configure_successful_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + params = { + "client_order_id": order.client_order_id, + 'symbol': self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset) + } + + query = ('?' + '&'.join([f"{key}={value}" for key, value in sorted(params.items())])) if len( + params) != 0 else '' + + url = web_utils.public_rest_url(CONSTANTS.CANCEL_ORDER_PATH_URL) + query + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = self._order_cancelation_request_successful_mock_response(order=order) + + mock_api.delete(regex_url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def configure_erroneous_cancelation_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + params = { + "client_order_id": order.client_order_id, + 'symbol': self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset) + } + + query = ('?' + '&'.join([f"{key}={value}" for key, value in sorted(params.items())])) if len( + params) != 0 else '' + + url = web_utils.public_rest_url(CONSTANTS.CANCEL_ORDER_PATH_URL) + query + + response = {"status": "CANCEL_FAILED"} + + mock_api.delete(url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def configure_order_not_found_error_cancelation_response( + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.public_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2011, "msg": "Unknown order sent."} + mock_api.delete(regex_url, status=400, body=json.dumps(response), callback=callback) + return url + + def configure_one_successful_one_erroneous_cancel_all_response( + self, + successful_order: InFlightOrder, + erroneous_order: InFlightOrder, + mock_api: aioresponses) -> List[str]: + """ + :return: a list of all configured URLs for the cancelations + """ + all_urls = [] + url = self.configure_successful_cancelation_response(order=successful_order, mock_api=mock_api) + all_urls.append(url) + url = self.configure_erroneous_cancelation_response(order=erroneous_order, mock_api=mock_api) + all_urls.append(url) + return all_urls + + def configure_completely_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.public_rest_url(CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = self._order_status_request_completely_filled_mock_response(order=order) + + mock_api.get(regex_url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def configure_canceled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.public_rest_url(CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = self._order_status_request_canceled_mock_response(order=order) + + mock_api.get(regex_url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def configure_erroneous_http_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.public_rest_url( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(url + r"\?.*") + + mock_api.get(regex_url, status=400, callback=callback) + + return url + + def configure_open_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + """ + :return: the URL configured + """ + url = web_utils.public_rest_url( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = self._order_status_request_open_mock_response(order=order) + + mock_api.get(regex_url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def configure_http_error_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None) -> str: + url = web_utils.public_rest_url( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get(regex_url, status=401, callback=callback, repeat=True) + return url + + def configure_partially_filled_order_status_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.public_rest_url( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + + response = self._order_status_request_partially_filled_mock_response(order=order) + + mock_api.get(regex_url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def configure_order_not_found_error_order_status_response( + self, order: InFlightOrder, mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> List[str]: + url = web_utils.public_rest_url(CONSTANTS.ORDER_PATH_URL) + regex_url = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + response = {"code": -2013, "msg": "Order does not exist."} + mock_api.get(regex_url, body=json.dumps(response), status=400, callback=callback) + return [url] + + def configure_partial_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.public_rest_url( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(url + r"\?.*") + + response = self._order_fills_request_partial_fill_mock_response(order=order) + + mock_api.get(regex_url, body=json.dumps(response), callback=callback) + + return url + + def configure_full_fill_trade_response( + self, + order: InFlightOrder, + mock_api: aioresponses, + callback: Optional[Callable] = lambda *args, **kwargs: None + ) -> str: + url = web_utils.public_rest_url( + path_url=CONSTANTS.GET_ORDER_BY_CLIENT_ORDER_ID_PATH.format(order.client_order_id)) + + regex_url = re.compile(url + r"\?.*") + + response = self._order_fills_request_full_fill_mock_response(order=order) + + mock_api.get(regex_url, body=json.dumps(response), callback=callback, repeat=True) + + return url + + def order_event_for_new_order_websocket_update(self, order: InFlightOrder): + return { + "topic": "executionreport", + "ts": 1686588154387, + "data": { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "clientOrderId": int(order.client_order_id), + "orderId": int(order.exchange_order_id), + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "quantity": float(order.amount), + "price": float(order.price), + "tradeId": 0, + "executedPrice": 0.0, + "executedQuantity": 0.0, + "fee": 0.0, + "feeAsset": "BTC", + "totalExecutedQuantity": 0.0, + "status": "NEW", + "reason": "", + "orderTag": "default", + "totalFee": 0.0, + "visible": 0.001, + "timestamp": 1686588154387, + "reduceOnly": False, + "maker": False + } + } + + def order_event_for_canceled_order_websocket_update(self, order: InFlightOrder): + return { + "topic": "executionreport", + "ts": 1686588270140, + "data": { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "clientOrderId": int(order.client_order_id), + "orderId": int(order.exchange_order_id), + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "quantity": float(order.amount), + "price": float(order.price), + "tradeId": 0, + "executedPrice": 0.0, + "executedQuantity": 0.0, + "fee": 0.0, + "feeAsset": "BTC", + "totalExecutedQuantity": 0.0, + "status": "CANCELLED", + "reason": "", + "orderTag": "default", + "totalFee": 0.0, + "visible": 0.001, + "timestamp": 1686588270140, + "reduceOnly": False, + "maker": False + } + } + + def order_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return { + "topic": "executionreport", + "ts": 1686588450683, + "data": { + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "clientOrderId": int(order.client_order_id), + "orderId": 199270655, + "type": order.order_type.name.upper(), + "side": order.trade_type.name.upper(), + "quantity": float(order.amount), + "price": float(order.price), + "tradeId": 250106703, + "executedPrice": float(order.price), + "executedQuantity": float(order.amount), + "fee": float(self.expected_fill_fee.flat_fees[0].amount), + "feeAsset": self.expected_fill_fee.flat_fees[0].token, + "totalExecutedQuantity": float(order.amount), + "avgPrice": float(order.price), + "status": "FILLED", + "reason": "", + "orderTag": "default", + "totalFee": 0.00000030, + "visible": 0.001, + "timestamp": 1686588450683, + "reduceOnly": False, + "maker": True + } + } + + def trade_event_for_full_fill_websocket_update(self, order: InFlightOrder): + return None + + @patch("secrets.randbelow") + def test_client_order_id_on_order(self, mocked_secret): + mocked_secret.return_value = 10 + + result = self.exchange.buy( + trading_pair=self.trading_pair, + amount=Decimal("1"), + order_type=OrderType.LIMIT, + price=Decimal("2"), + ) + + expected_client_order_id = str(secrets.randbelow(9223372036854775807)) + + logging.error(expected_client_order_id) + + self.assertEqual(result, expected_client_order_id) + + mocked_secret.return_value = 20 + + expected_client_order_id = str(secrets.randbelow(9223372036854775807)) + + result = self.exchange.sell( + trading_pair=self.trading_pair, + amount=Decimal("1"), + order_type=OrderType.LIMIT, + price=Decimal("2"), + ) + + self.assertEqual(result, expected_client_order_id) + + @aioresponses() + def test_cancel_order_not_found_in_the_exchange(self, mock_api): + # Disabling this test because the connector has not been updated yet to validate + # order not found during cancellation (check _is_order_not_found_during_cancelation_error) + pass + + @aioresponses() + def test_lost_order_removed_if_not_found_during_order_status_update(self, mock_api): + # Disabling this test because the connector has not been updated yet to validate + # order not found during status update (check _is_order_not_found_during_status_update_error) + pass + + @aioresponses() + def test_check_network_failure(self, mock_api): + # Disabling this test because Woo X does not have an endpoint to check health. + pass + + @aioresponses() + def test_check_network_raises_cancel_exception(self, mock_api): + # Disabling this test because Woo X does not have an endpoint to check health. + pass + + @aioresponses() + def test_check_network_success(self, mock_api): + # Disabling this test because Woo X does not have an endpoint to check health. + pass + + @aioresponses() + def test_update_order_status_when_filled_correctly_processed_even_when_trade_fill_update_fails(self, mock_api): + pass + + def _validate_auth_credentials_taking_parameters_from_argument(self, request_call: RequestCall): + headers = request_call.kwargs["headers"] + + self.assertIn("x-api-key", headers) + self.assertIn("x-api-signature", headers) + self.assertIn("x-api-timestamp", headers) + + self.assertEqual("testAPIKey", headers["x-api-key"]) + + def _order_cancelation_request_successful_mock_response(self, order: InFlightOrder) -> Any: + return { + "success": True, + "status": "CANCEL_SENT" + } + + def _order_status_request_completely_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "success": True, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "FILLED", + "side": "BUY", + "created_time": "1686558570.495", + "order_id": int(order.exchange_order_id), + "order_tag": "default", + "price": float(order.price), + "type": "LIMIT", + "quantity": float(order.amount), + "amount": None, + "visible": float(order.amount), + "executed": float(order.amount), + "total_fee": 3e-07, + "fee_asset": "BTC", + "client_order_id": int(order.client_order_id), + "reduce_only": False, + "realized_pnl": None, + "average_executed_price": 10500, + "Transactions": [ + { + "id": self.expected_fill_trade_id, + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "order_id": int(order.exchange_order_id), + "fee": float(self.expected_fill_fee.flat_fees[0].amount), + "side": "BUY", + "executed_timestamp": "1686558583.434", + "executed_price": float(order.price), + "executed_quantity": float(order.amount), + "fee_asset": self.expected_fill_fee.flat_fees[0].token, + "is_maker": 1, + "realized_pnl": None + } + ] + } + + def _order_status_request_canceled_mock_response(self, order: InFlightOrder) -> Any: + return { + "success": True, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "CANCELLED", + "side": order.trade_type.name.upper(), + "created_time": "1686558863.782", + "order_id": int(order.exchange_order_id), + "order_tag": "default", + "price": float(order.price), + "type": order.order_type.name.upper(), + "quantity": float(order.amount), + "amount": None, + "visible": float(order.amount), + "executed": 0, + "total_fee": 0, + "fee_asset": "BTC", + "client_order_id": int(order.client_order_id), + "reduce_only": False, + "realized_pnl": None, + "average_executed_price": None, + "Transactions": [] + } + + def _order_status_request_open_mock_response(self, order: InFlightOrder) -> Any: + return { + "success": True, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "NEW", + "side": order.trade_type.name.upper(), + "created_time": "1686559699.983", + "order_id": int(order.exchange_order_id), + "order_tag": "default", + "price": float(order.price), + "type": order.order_type.name.upper(), + "quantity": float(order.amount), + "amount": None, + "visible": float(order.amount), + "executed": 0, + "total_fee": 0, + "fee_asset": "BTC", + "client_order_id": int(order.client_order_id), + "reduce_only": False, + "realized_pnl": None, + "average_executed_price": None, + "Transactions": [] + } + + def _order_status_request_partially_filled_mock_response(self, order: InFlightOrder) -> Any: + return { + "success": True, + "symbol": self.exchange_symbol_for_tokens(self.base_asset, self.quote_asset), + "status": "PARTIAL_FILLED", + "side": "BUY", + "created_time": "1686558570.495", + "order_id": order.exchange_order_id, + "order_tag": "default", + "price": float(order.price), + "type": "LIMIT", + "quantity": float(order.amount), + "amount": None, + "visible": float(order.amount), + "executed": float(order.amount), + "total_fee": 3e-07, + "fee_asset": "BTC", + "client_order_id": order.client_order_id, + "reduce_only": False, + "realized_pnl": None, + "average_executed_price": 10500, + "Transactions": [ + { + "id": self.expected_fill_trade_id, + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "order_id": int(order.exchange_order_id), + "fee": float(self.expected_fill_fee.flat_fees[0].amount), + "side": "BUY", + "executed_timestamp": "1686558583.434", + "executed_price": float(self.expected_partial_fill_price), + "executed_quantity": float(self.expected_partial_fill_amount), + "fee_asset": self.expected_fill_fee.flat_fees[0].token, + "is_maker": 1, + "realized_pnl": None + } + ] + } + + def _order_fills_request_partial_fill_mock_response(self, order: InFlightOrder): + return { + "success": True, + "meta": { + "total": 65, + "records_per_page": 100, + "current_page": 1 + }, + "rows": [ + { + "id": self.expected_fill_trade_id, + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "fee": float(self.expected_fill_fee.flat_fees[0].amount), + "side": "BUY", + "executed_timestamp": "1686585723.908", + "order_id": int(order.exchange_order_id), + "order_tag": "default", + "executed_price": float(self.expected_partial_fill_price), + "executed_quantity": float(self.expected_partial_fill_amount), + "fee_asset": self.expected_fill_fee.flat_fees[0].token, + "is_maker": 0, + "realized_pnl": None + } + ] + } + + def _order_fills_request_full_fill_mock_response(self, order: InFlightOrder): + return { + "success": True, + "meta": { + "total": 65, + "records_per_page": 100, + "current_page": 1 + }, + "rows": [ + { + "id": self.expected_fill_trade_id, + "symbol": self.exchange_symbol_for_tokens(order.base_asset, order.quote_asset), + "fee": float(self.expected_fill_fee.flat_fees[0].amount), + "side": "BUY", + "executed_timestamp": "1686585723.908", + "order_id": int(order.exchange_order_id), + "order_tag": "default", + "executed_price": float(order.price), + "executed_quantity": float(order.amount), + "fee_asset": self.expected_fill_fee.flat_fees[0].token, + "is_maker": 0, + "realized_pnl": None + } + ] + } diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_order_book.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_order_book.py new file mode 100644 index 0000000000..0d61e320a9 --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_order_book.py @@ -0,0 +1,105 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.woo_x.woo_x_order_book import WooXOrderBook +from hummingbot.core.data_type.order_book_message import OrderBookMessageType + + +class WooXOrderBookTests(TestCase): + + def test_snapshot_message_from_exchange(self): + snapshot_message = WooXOrderBook.snapshot_message_from_exchange( + msg={ + "success": True, + "asks": [ + { + "price": 10669.4, + "quantity": 1.56263218 + }, + ], + "bids": [ + { + "price": 10669.3, + "quantity": 0.88159988 + }, + ], + "timestamp": 1564710591905 + }, + timestamp=1564710591905, + metadata={"trading_pair": "COINALPHA-HBOT"} + ) + + self.assertEqual(OrderBookMessageType.SNAPSHOT, snapshot_message.type) + self.assertEqual(1564710591905, snapshot_message.timestamp) + self.assertEqual(1564710591905, snapshot_message.update_id) + self.assertEqual(-1, snapshot_message.trade_id) + self.assertEqual(1, len(snapshot_message.bids)) + self.assertEqual(10669.3, snapshot_message.bids[0].price) + self.assertEqual(0.88159988, snapshot_message.bids[0].amount) + self.assertEqual(1564710591905, snapshot_message.bids[0].update_id) + self.assertEqual(1, len(snapshot_message.asks)) + self.assertEqual(10669.4, snapshot_message.asks[0].price) + self.assertEqual(1.56263218, snapshot_message.asks[0].amount) + self.assertEqual(1564710591905, snapshot_message.asks[0].update_id) + + def test_diff_message_from_exchange(self): + diff_msg = WooXOrderBook.diff_message_from_exchange( + msg={ + "topic": "SPOT_BTC_USDT@orderbookupdate", + "ts": 1618826337580, + "data": { + "symbol": "SPOT_BTC_USDT", + "prevTs": 1618826337380, + "asks": [ + [ + 56749.15, + 3.92864 + ], + ], + "bids": [ + [ + 56745.2, + 1.03895025 + ], + ] + } + }, + metadata={"trading_pair": "BTC-USDT"} + ) + + self.assertEqual(1618826337580, diff_msg.timestamp) + self.assertEqual(1618826337580, diff_msg.update_id) + self.assertEqual(1618826337580, diff_msg.first_update_id) + self.assertEqual(-1, diff_msg.trade_id) + self.assertEqual(1, len(diff_msg.bids)) + self.assertEqual(56745.2, diff_msg.bids[0].price) + self.assertEqual(1.03895025, diff_msg.bids[0].amount) + self.assertEqual(1618826337580, diff_msg.bids[0].update_id) + self.assertEqual(1, len(diff_msg.asks)) + self.assertEqual(56749.15, diff_msg.asks[0].price) + self.assertEqual(3.92864, diff_msg.asks[0].amount) + self.assertEqual(1618826337580, diff_msg.asks[0].update_id) + + def test_trade_message_from_exchange(self): + trade_update = { + "topic": "SPOT_ADA_USDT@trade", + "ts": 1618820361552, + "data": { + "symbol": "SPOT_ADA_USDT", + "price": 1.27988, + "size": 300, + "side": "BUY", + "source": 0 + } + } + + trade_message = WooXOrderBook.trade_message_from_exchange( + msg=trade_update, + metadata={"trading_pair": "ADA-USDT"} + ) + + self.assertEqual("ADA-USDT", trade_message.trading_pair) + self.assertEqual(OrderBookMessageType.TRADE, trade_message.type) + self.assertEqual(1618820361.552, trade_message.timestamp) + self.assertEqual(-1, trade_message.update_id) + self.assertEqual(-1, trade_message.first_update_id) + self.assertEqual(1618820361552, trade_message.trade_id) diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_utils.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_utils.py new file mode 100644 index 0000000000..b658fbf8b5 --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_utils.py @@ -0,0 +1,40 @@ +import unittest + +from hummingbot.connector.exchange.woo_x import woo_x_utils as utils + + +class WooXUtilTestCases(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.base_asset = "BTC" + cls.quote_asset = "USDT" + cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.hb_trading_pair = f"{cls.base_asset}-{cls.quote_asset}" + cls.ex_trading_pair = f"{cls.base_asset}{cls.quote_asset}" + + def test_is_exchange_information_valid(self): + invalid_info_1 = { + "symbol": "MARGIN_BTC_USDT", + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_1)) + + invalid_info_2 = { + "symbol": "PERP_BTC_ETH", + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_2)) + + invalid_info_3 = { + "symbol": "BTC-USDT", + } + + self.assertFalse(utils.is_exchange_information_valid(invalid_info_3)) + + valid_info_4 = { + "symbol": f"SPOT_{self.base_asset}_{self.quote_asset}", + } + + self.assertTrue(utils.is_exchange_information_valid(valid_info_4)) diff --git a/test/hummingbot/connector/exchange/woo_x/test_woo_x_web_utils.py b/test/hummingbot/connector/exchange/woo_x/test_woo_x_web_utils.py new file mode 100644 index 0000000000..4f067178d2 --- /dev/null +++ b/test/hummingbot/connector/exchange/woo_x/test_woo_x_web_utils.py @@ -0,0 +1,11 @@ +from unittest import TestCase + +from hummingbot.connector.exchange.woo_x import woo_x_constants as CONSTANTS, woo_x_web_utils as web_utils + + +class WebUtilsTests(TestCase): + def test_rest_url(self): + url = web_utils.public_rest_url(path_url=CONSTANTS.MARKET_TRADES_PATH, domain=CONSTANTS.DEFAULT_DOMAIN) + self.assertEqual('https://api.woo.org/v1/public/market_trades', url) + url = web_utils.public_rest_url(path_url=CONSTANTS.MARKET_TRADES_PATH, domain='woo_x_testnet') + self.assertEqual('https://api.staging.woo.org/v1/public/market_trades', url) diff --git a/test/hummingbot/connector/gateway/clob_spot/data_sources/kujira/__init__.py b/test/hummingbot/connector/gateway/clob_spot/data_sources/kujira/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/hummingbot/connector/gateway/clob_spot/data_sources/kujira/test_kujira_api_data_source.py b/test/hummingbot/connector/gateway/clob_spot/data_sources/kujira/test_kujira_api_data_source.py new file mode 100644 index 0000000000..7e0a2acd15 --- /dev/null +++ b/test/hummingbot/connector/gateway/clob_spot/data_sources/kujira/test_kujira_api_data_source.py @@ -0,0 +1,713 @@ +import asyncio +import importlib +from typing import Any, Dict, List, Union +from unittest.mock import patch + +from _decimal import Decimal +from bidict import bidict +from dotmap import DotMap + +from hummingbot.connector.gateway.clob_spot.data_sources.kujira.kujira_helpers import ( + convert_hb_trading_pair_to_market_name, + convert_market_name_to_hb_trading_pair, + generate_hash, +) +from hummingbot.connector.gateway.clob_spot.data_sources.kujira.kujira_types import ( + OrderSide as KujiraOrderSide, + OrderStatus as KujiraOrderStatus, + OrderType as KujiraOrderType, +) +from hummingbot.connector.gateway.gateway_in_flight_order import GatewayInFlightOrder +from hummingbot.connector.test_support.gateway_clob_api_data_source_test import AbstractGatewayCLOBAPIDataSourceTests +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.data_type.common import OrderType, TradeType +from hummingbot.core.data_type.in_flight_order import OrderState, OrderUpdate, TradeUpdate +from hummingbot.core.data_type.order_book_message import OrderBookMessage +from hummingbot.core.data_type.trade_fee import ( + DeductedFromReturnsTradeFee, + MakerTakerExchangeFeeRates, + TokenAmount, + TradeFeeBase, +) +from hummingbot.core.network_iterator import NetworkStatus + +module_3 = importlib.import_module("hummingbot.connector.gateway.clob_spot.data_sources.kujira.kujira_constants") +module_3.NUMBER_OF_RETRIES = 0 +module_3.DELAY_BETWEEN_RETRIES = 0 +module_3.TIMEOUT = None + +from hummingbot.connector.gateway.clob_spot.data_sources.kujira.kujira_api_data_source import ( # noqa: E402 + KujiraAPIDataSource, +) + + +class KujiraAPIDataSourceTest(AbstractGatewayCLOBAPIDataSourceTests.GatewayCLOBAPIDataSourceTests): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.chain = "kujira" # noqa: mock + cls.network = "mainnet" + cls.base = "KUJI" # noqa: mock + cls.quote = "USK" + cls.trading_pair = combine_to_hb_trading_pair(base=cls.base, quote=cls.quote) + cls.owner_address = "kujira1yrensec9gzl7y3t3duz44efzgwj2qv6gwayrn7" # noqa: mock + + def setUp(self) -> None: + super().setUp() + + self.configure_asyncio_sleep() + self.data_source._gateway = self.gateway_instance_mock + self.configure_get_market() + + def tearDown(self) -> None: + super().tearDown() + + def build_api_data_source(self, with_api_key: bool = True) -> Any: + connector_spec = { + "chain": self.chain, + "network": self.network, + "wallet_address": self.owner_address, + } + + data_source = KujiraAPIDataSource( + trading_pairs=[self.trading_pair], + connector_spec=connector_spec, + client_config_map=self.client_config_map, + ) + + return data_source + + @patch("hummingbot.core.gateway.gateway_http_client.GatewayHttpClient.ping_gateway") + def test_gateway_ping_gateway(self, *_args): + self.data_source._gateway.ping_gateway.return_value = True + + result = self.async_run_with_timeout( + coro=self.data_source._gateway_ping_gateway() + ) + + expected = True + + self.assertEqual(expected, result) + + @patch("hummingbot.core.gateway.gateway_http_client.GatewayHttpClient.ping_gateway") + def test_check_network_status_with_gateway_connected(self, *_args): + self.data_source._gateway.ping_gateway.return_value = True + + result = self.async_run_with_timeout( + coro=self.data_source.check_network_status() + ) + + expected = NetworkStatus.CONNECTED + + self.assertEqual(expected, result) + + @patch("hummingbot.core.gateway.gateway_http_client.GatewayHttpClient.ping_gateway") + def test_check_network_status_with_gateway_not_connected(self, *_args): + self.data_source._gateway.ping_gateway.return_value = False + + result = self.async_run_with_timeout( + coro=self.data_source.check_network_status() + ) + + expected = NetworkStatus.NOT_CONNECTED + + self.assertEqual(expected, result) + + @patch("hummingbot.core.gateway.gateway_http_client.GatewayHttpClient.ping_gateway") + def test_check_network_status_with_gateway_exception(self, *_args): + self.configure_asyncio_sleep() + self.data_source._gateway.ping_gateway.side_effect = RuntimeError("Unknown error") + + result = self.async_run_with_timeout( + coro=self.data_source.check_network_status() + ) + + expected = NetworkStatus.NOT_CONNECTED + + self.assertEqual(expected, result) + + @staticmethod + def configure_asyncio_sleep(): + async def sleep(*_args, **_kwargs): + pass + + patch.object(asyncio, "sleep", new_callable=sleep) + + @patch("hummingbot.core.gateway.gateway_http_client.GatewayHttpClient.get_clob_markets") + def configure_get_market(self, *_args): + self.data_source._gateway.get_clob_markets.return_value = self.configure_gateway_get_clob_markets_response() + + def configure_place_order_response( + self, + timestamp: float, + transaction_hash: str, + exchange_order_id: str, + trade_type: TradeType, + price: Decimal, + size: Decimal, + ): + super().configure_place_order_response( + timestamp, + transaction_hash, + exchange_order_id, + trade_type, + price, + size, + ) + self.gateway_instance_mock.clob_place_order.return_value["id"] = "1" + + def configure_place_order_failure_response(self): + super().configure_place_order_failure_response() + self.gateway_instance_mock.clob_place_order.return_value["id"] = "1" + + def configure_batch_order_create_response( + self, + timestamp: float, + transaction_hash: str, + created_orders: List[GatewayInFlightOrder], + ): + super().configure_batch_order_create_response( + timestamp=self.initial_timestamp, + transaction_hash=self.expected_transaction_hash, + created_orders=created_orders, + ) + self.gateway_instance_mock.clob_batch_order_modify.return_value["ids"] = ["1", "2"] + + @property + def expected_buy_client_order_id(self) -> str: + return "03719e91d18db65ec3bf5554d678e5b4" + + @property + def expected_sell_client_order_id(self) -> str: + return "02719e91d18db65ec3bf5554d678e5b2" + + @property + def expected_buy_exchange_order_id(self) -> str: + return "1" + + @property + def expected_sell_exchange_order_id(self) -> str: + return "2" + + @property + def exchange_base(self) -> str: + return self.base + + @property + def exchange_quote(self) -> str: + return self.quote + + @property + def expected_quote_decimals(self) -> int: + return 6 + + @property + def expected_base_decimals(self) -> int: + return 6 + + def exchange_symbol_for_tokens( + self, + base_token: str, + quote_token: str + ) -> str: + return f"{base_token}-{quote_token}" + + def get_trading_pairs_info_response(self) -> List[Dict[str, Any]]: + response = self.configure_gateway_get_clob_markets_response() + + market = response.markets[list(response.markets.keys())[0]] + + market_name = convert_market_name_to_hb_trading_pair(market.name) + + return [{"market_name": market_name, "market": market}] + + def get_order_status_response( + self, + timestamp: float, + trading_pair: str, + exchange_order_id: str, + client_order_id: str, + status: OrderState + ) -> List[Dict[str, Any]]: + return [DotMap({ + "id": exchange_order_id, + "orderHash": "", + "marketId": "kujira193dzcmy7lwuj4eda3zpwwt9ejal00xva0vawcvhgsyyp5cfh6jyq66wfrf", # noqa: mock + "active": "", + "subaccountId": "", # noqa: mock + "executionType": "", + "orderType": "LIMIT", + "price": "0.616", + "triggerPrice": "", + "quantity": "0.24777", + "filledQuantity": "", + "state": KujiraOrderStatus.from_hummingbot(status).name, + "createdAt": timestamp, + "updatedAt": "", + "direction": "BUY" + })] + + def get_clob_ticker_response( + self, + trading_pair: str, + last_traded_price: Decimal + ) -> Dict[str, Any]: + market = ( + self.configure_gateway_get_clob_markets_response() + ).markets[trading_pair] + + return { + "KUJI-USK": { # noqa: mock + "market": market, + "ticker": { + "price": "0.641" + }, + "price": "0.641", + "timestamp": 1694631135095 + } + } + + def configure_account_balances_response( + self, + base_total_balance: Decimal, + base_available_balance: Decimal, + quote_total_balance: Decimal, + quote_available_balance: Decimal + ): + self.gateway_instance_mock.get_balances.return_value = self.configure_gateway_get_balances_response() + + def configure_empty_order_fills_response(self): + pass + + def configure_trade_fill_response( + self, + timestamp: float, + exchange_order_id: str, + price: Decimal, + size: Decimal, + fee: TradeFeeBase, trade_id: Union[str, int], is_taker: bool + ): + pass + + @staticmethod + def configure_gateway_get_clob_markets_response(): + return DotMap({ + "network": "mainnet", + "timestamp": 1694561843115, + "latency": 0.001, + "markets": { + "KUJI-USK": { # noqa: mock + "id": "kujira193dzcmy7lwuj4eda3zpwwt9ejal00xva0vawcvhgsyyp5cfh6jyq66wfrf", # noqa: mock + "name": "KUJI/USK", # noqa: mock + "baseToken": { + "id": "ukuji", # noqa: mock + "name": "KUJI", # noqa: mock + "symbol": "KUJI", # noqa: mock + "decimals": 6 + }, + "quoteToken": { + "id": "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", + # noqa: mock + "name": "USK", + "symbol": "USK", + "decimals": 6 + }, + "precision": 3, + "minimumOrderSize": "0.001", + "minimumPriceIncrement": "0.001", + "minimumBaseAmountIncrement": "0.001", + "minimumQuoteAmountIncrement": "0.001", + "fees": { + "maker": "0.075", + "taker": "0.15", + "serviceProvider": "0" + }, + "deprecated": False, + "connectorMarket": { + "address": "kujira193dzcmy7lwuj4eda3zpwwt9ejal00xva0vawcvhgsyyp5cfh6jyq66wfrf", # noqa: mock + "denoms": [ # noqa: mock + { + "reference": "ukuji", # noqa: mock + "decimals": 6, + "symbol": "KUJI" # noqa: mock + }, + { + "reference": "factory/kujira1qk00h5atutpsv900x202pxx42npjr9thg58dnqpa72f2p7m2luase444a7/uusk", + # noqa: mock + "decimals": 6, + "symbol": "USK" + } + ], + "precision": { + "decimal_places": 3 + }, + "decimalDelta": 0, + "multiswap": True, # noqa: mock + "pool": "kujira1g9xcvvh48jlckgzw8ajl6dkvhsuqgsx2g8u3v0a6fx69h7f8hffqaqu36t", # noqa: mock + "calc": "kujira1e6fjnq7q20sh9cca76wdkfg69esha5zn53jjewrtjgm4nktk824stzyysu" # noqa: mock + } + } + } + }, _dynamic=False) + + def configure_gateway_get_balances_response(self): + return { + "balances": { + "USK": "3.522325", + "axlUSDC": "1.999921", + "KUJI": "6.355439" + } + } + + @property + def expected_maker_taker_fee_rates(self) -> MakerTakerExchangeFeeRates: + return MakerTakerExchangeFeeRates( + maker=Decimal("0.075"), + taker=Decimal("0.15"), + maker_flat_fees=[], + taker_flat_fees=[], + ) + + @property + def expected_min_price_increment(self): + return Decimal("0.001") + + @property + def expected_last_traded_price(self) -> Decimal: + return Decimal("0.641") + + @property + def expected_base_total_balance(self) -> Decimal: + return Decimal("6.355439") + + @property + def expected_base_available_balance(self) -> Decimal: + return Decimal("6.355439") + + @property + def expected_quote_total_balance(self) -> Decimal: + return Decimal("3.522325") + + @property + def expected_quote_available_balance(self) -> Decimal: + return Decimal("3.522325") + + @property + def expected_fill_price(self) -> Decimal: + return Decimal("11") + + @property + def expected_fill_size(self) -> Decimal: + return Decimal("3") + + @property + def expected_fill_fee_amount(self) -> Decimal: + return Decimal("0.15") + + @property + def expected_fill_fee(self) -> TradeFeeBase: + return DeductedFromReturnsTradeFee( + flat_fees=[TokenAmount(token=self.expected_fill_fee_token, amount=self.expected_fill_fee_amount)] + ) + + def test_batch_order_cancel(self): + super().test_batch_order_cancel() + + def test_batch_order_create(self): + super().test_batch_order_create() + + def test_cancel_order(self): + super().test_cancel_order() + + def test_cancel_order_transaction_fails(self): + order = GatewayInFlightOrder( + client_order_id=self.expected_buy_client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + price=self.expected_buy_order_price, + amount=self.expected_buy_order_size, + creation_timestamp=self.initial_timestamp, + exchange_order_id=self.expected_buy_exchange_order_id, + creation_transaction_hash="someCreationHash", + ) + self.data_source.gateway_order_tracker.start_tracking_order(order=order) + self.configure_cancel_order_failure_response() + + result = self.async_run_with_timeout(coro=self.data_source.cancel_order(order=order)) + + self.assertEqual(False, result[0]) + self.assertEqual(DotMap({}), result[1]) + + def test_check_network_status(self): + super().test_check_network_status() + + def test_delivers_balance_events(self): + super().test_delivers_balance_events() + + def test_delivers_order_book_snapshot_events(self): + pass + + def test_get_account_balances(self): + super().test_get_account_balances() + + def test_get_all_order_fills(self): + asyncio.get_event_loop().run_until_complete( + self.data_source._update_markets() + ) + creation_transaction_hash = "0x7cb2eafc389349f86da901cdcbfd9119425a2ea84d61c17b6ded778b6fd2g81d" # noqa: mock + in_flight_order = GatewayInFlightOrder( + initial_state=OrderState.PENDING_CREATE, + client_order_id=self.expected_sell_client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.SELL, + creation_timestamp=self.initial_timestamp - 10, + price=self.expected_sell_order_price, + amount=self.expected_sell_order_size, + exchange_order_id=self.expected_sell_exchange_order_id, + ) + self.data_source.gateway_order_tracker.active_orders[in_flight_order.client_order_id] = in_flight_order + self.enqueue_order_status_response( + timestamp=self.initial_timestamp + 1, + trading_pair=in_flight_order.trading_pair, + exchange_order_id=self.expected_buy_exchange_order_id, + client_order_id=in_flight_order.client_order_id, + status=OrderState.FILLED, + ) + + trade_updates: List[TradeUpdate] = self.async_run_with_timeout( + coro=self.data_source.get_all_order_fills(in_flight_order=in_flight_order), + ) + + self.assertEqual(1, len(trade_updates)) + + trade_update = trade_updates[0] + + self.assertIsNotNone(trade_update.trade_id) + self.assertEqual(self.expected_sell_client_order_id, trade_update.client_order_id) + self.assertEqual(self.expected_sell_exchange_order_id, trade_update.exchange_order_id) + self.assertEqual(self.trading_pair, trade_update.trading_pair) + self.assertLess(float(0), trade_update.fill_timestamp) + self.assertEqual(self.expected_fill_price, trade_update.fill_price) + self.assertEqual(self.expected_fill_size, trade_update.fill_base_amount) + self.assertEqual(self.expected_fill_size * self.expected_fill_price, trade_update.fill_quote_amount) + self.assertEqual(self.expected_fill_fee, trade_update.fee) + self.assertTrue(trade_update.is_taker) + + def test_get_all_order_fills_no_fills(self): + super().test_get_all_order_fills_no_fills() + + def test_get_last_traded_price(self): + self.configure_last_traded_price( + trading_pair=self.trading_pair, last_traded_price=self.expected_last_traded_price + ) + last_trade_price = self.async_run_with_timeout( + coro=self.data_source.get_last_traded_price(trading_pair=self.trading_pair) + ) + + self.assertEqual(self.expected_last_traded_price, last_trade_price) + + def test_get_order_book_snapshot(self): + self.configure_orderbook_snapshot( + timestamp=self.initial_timestamp, bids=[[9, 1], [8, 2]], asks=[[11, 3]] + ) + order_book_snapshot: OrderBookMessage = self.async_run_with_timeout( + coro=self.data_source.get_order_book_snapshot(trading_pair=self.trading_pair) + ) + + self.assertLess(float(0), order_book_snapshot.timestamp) + self.assertEqual(2, len(order_book_snapshot.bids)) + self.assertEqual(9, order_book_snapshot.bids[0].price) + self.assertEqual(1, order_book_snapshot.bids[0].amount) + self.assertEqual(1, len(order_book_snapshot.asks)) + self.assertEqual(11, order_book_snapshot.asks[0].price) + self.assertEqual(3, order_book_snapshot.asks[0].amount) + + def test_get_order_status_update(self): + creation_transaction_hash = "0x7cb2eafc389349f86da901cdcbfd9119425a2ea84d61c17b6ded778b6fd2g81d" # noqa: mock + in_flight_order = GatewayInFlightOrder( + client_order_id=self.expected_buy_client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=self.initial_timestamp, + price=self.expected_buy_order_price, + amount=self.expected_buy_order_size, + creation_transaction_hash=creation_transaction_hash, + exchange_order_id=self.expected_buy_exchange_order_id, + ) + self.data_source.gateway_order_tracker.active_orders[in_flight_order.client_order_id] = in_flight_order + self.enqueue_order_status_response( + timestamp=self.initial_timestamp + 1, + trading_pair=in_flight_order.trading_pair, + exchange_order_id=self.expected_buy_exchange_order_id, + client_order_id=in_flight_order.client_order_id, + status=OrderState.PENDING_CREATE, + ) + + status_update: OrderUpdate = self.async_run_with_timeout( + coro=self.data_source.get_order_status_update(in_flight_order=in_flight_order) + ) + + self.assertEqual(self.trading_pair, status_update.trading_pair) + self.assertLess(self.initial_timestamp, status_update.update_timestamp) + self.assertEqual(OrderState.PENDING_CREATE, status_update.new_state) + self.assertEqual(in_flight_order.client_order_id, status_update.client_order_id) + self.assertEqual(self.expected_buy_exchange_order_id, status_update.exchange_order_id) + + def test_get_order_status_update_with_no_update(self): + creation_transaction_hash = "0x7cb2eafc389349f86da901cdcbfd9119425a2ea84d61c17b6ded778b6fd2g81d" # noqa: mock + in_flight_order = GatewayInFlightOrder( + client_order_id=self.expected_buy_client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=self.initial_timestamp, + price=self.expected_buy_order_price, + amount=self.expected_buy_order_size, + creation_transaction_hash=creation_transaction_hash, + exchange_order_id=self.expected_buy_exchange_order_id, + ) + self.enqueue_order_status_response( + timestamp=self.initial_timestamp + 1, + trading_pair=in_flight_order.trading_pair, + exchange_order_id=self.expected_buy_exchange_order_id, + client_order_id=in_flight_order.client_order_id, + status=OrderState.PENDING_CREATE, + ) + + status_update: OrderUpdate = self.async_run_with_timeout( + coro=self.data_source.get_order_status_update(in_flight_order=in_flight_order) + ) + + self.assertEqual(self.trading_pair, status_update.trading_pair) + self.assertLess(self.initial_timestamp, status_update.update_timestamp) + self.assertEqual(OrderState.PENDING_CREATE, status_update.new_state) + self.assertEqual(in_flight_order.client_order_id, status_update.client_order_id) + self.assertEqual(self.expected_buy_exchange_order_id, status_update.exchange_order_id) + + def test_update_order_status(self): + creation_transaction_hash = "0x7cb2eafc389349f86da901cdcbfd9119425a2ea84d61c17b6ded778b6fd2g81d" # noqa: mock + in_flight_order = GatewayInFlightOrder( + client_order_id=self.expected_buy_client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=self.initial_timestamp, + price=self.expected_buy_order_price, + amount=self.expected_buy_order_size, + creation_transaction_hash=creation_transaction_hash, + exchange_order_id=self.expected_buy_exchange_order_id, + ) + self.data_source.gateway_order_tracker.active_orders[in_flight_order.client_order_id] = in_flight_order + self.enqueue_order_status_response( + timestamp=self.initial_timestamp + 1, + trading_pair=in_flight_order.trading_pair, + exchange_order_id=self.expected_buy_exchange_order_id, + client_order_id=in_flight_order.client_order_id, + status=OrderState.PENDING_CREATE, + ) + + self.async_run_with_timeout( + coro=self.data_source._update_order_status() + ) + + def test_get_symbol_map(self): + symbol_map = self.async_run_with_timeout(coro=self.data_source.get_symbol_map()) + + self.assertIsInstance(symbol_map, bidict) + self.assertEqual(1, len(symbol_map)) + self.assertIn(self.exchange_trading_pair, symbol_map.inverse) + + def test_get_trading_fees(self): + super().test_get_trading_fees() + + def test_get_trading_rules(self): + trading_rules = self.async_run_with_timeout(coro=self.data_source.get_trading_rules()) + + self.assertEqual(1, len(trading_rules)) + self.assertIn(self.trading_pair, trading_rules) + + trading_rule: TradingRule = trading_rules[self.trading_pair] + + self.assertEqual(self.trading_pair, trading_rule.trading_pair) + self.assertEqual(self.expected_min_price_increment, trading_rule.min_price_increment) + + def test_maximum_delay_between_requests_for_snapshot_events(self): + pass + + def test_minimum_delay_between_requests_for_snapshot_events(self): + pass + + def test_place_order(self): + super().test_place_order() + + def test_place_order_transaction_fails(self): + self.configure_place_order_failure_response() + + order = GatewayInFlightOrder( + client_order_id=self.expected_buy_client_order_id, + trading_pair=self.trading_pair, + order_type=OrderType.LIMIT, + trade_type=TradeType.BUY, + creation_timestamp=self.initial_timestamp, + price=self.expected_buy_order_price, + amount=self.expected_buy_order_size, + ) + + with self.assertRaises(Exception): + self.async_run_with_timeout( + coro=self.data_source.place_order(order=order) + ) + + def test_generate_hash(self): + actual = generate_hash("test") + + self.assertIsNotNone(actual) + + def test_convert_hb_trading_pair_to_market_name(self): + expected = "KUJI/USK" + + actual = convert_hb_trading_pair_to_market_name("KUJI-USK") + + self.assertEqual(expected, actual) + + def test_order_status_methods(self): + for item in KujiraOrderStatus: + if item == KujiraOrderStatus.UNKNOWN: + continue + + hummingbot_status = KujiraOrderStatus.to_hummingbot(item) + kujira_status = KujiraOrderStatus.from_hummingbot(hummingbot_status) + kujira_status_from_name = KujiraOrderStatus.from_name(kujira_status.name) + + self.assertEqual(item, kujira_status) + self.assertEqual(item, kujira_status_from_name) + + def test_order_sides(self): + for item in KujiraOrderSide: + hummingbot_side = KujiraOrderSide.to_hummingbot(item) + kujira_side = KujiraOrderSide.from_hummingbot(hummingbot_side) + kujira_side_from_name = KujiraOrderSide.from_name(kujira_side.name) + + self.assertEqual(item, kujira_side) + self.assertEqual(item, kujira_side_from_name) + + def test_order_types(self): + for item in KujiraOrderType: + if item != KujiraOrderType.MARKET: + hummingbot_type = KujiraOrderType.to_hummingbot(item) + kujira_type = KujiraOrderType.from_hummingbot(hummingbot_type) + kujira_type_from_name = KujiraOrderType.from_name(kujira_type.name) + + self.assertEqual(item, kujira_type) + self.assertEqual(item, kujira_type_from_name) + else: + with self.assertRaises(ValueError) as context: + KujiraOrderType.to_hummingbot(item) + + self.assertEqual(str(context.exception), 'Unrecognized order type "OrderType.MARKET".') diff --git a/test/hummingbot/core/rate_oracle/sources/test_coin_cap_rate_source.py b/test/hummingbot/core/rate_oracle/sources/test_coin_cap_rate_source.py new file mode 100644 index 0000000000..c01fc1e968 --- /dev/null +++ b/test/hummingbot/core/rate_oracle/sources/test_coin_cap_rate_source.py @@ -0,0 +1,274 @@ +import asyncio +import json +import re +import unittest +from decimal import Decimal +from typing import Awaitable, Optional +from unittest.mock import AsyncMock, patch + +from aioresponses import aioresponses + +from hummingbot.connector.test_support.network_mocking_assistant import NetworkMockingAssistant +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.rate_oracle.sources.coin_cap_rate_source import CoinCapRateSource +from hummingbot.data_feed.coin_cap_data_feed import coin_cap_constants as CONSTANTS + + +class CoinCapRateSourceTest(unittest.TestCase): + level = 0 + target_token: str + target_asset_id: str + global_token: str + trading_pair: str + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.target_token = "COINALPHA" + cls.target_asset_id = "some CoinAlpha ID" + cls.global_token = CONSTANTS.UNIVERSAL_QUOTE_TOKEN + cls.trading_pair = combine_to_hb_trading_pair(base=cls.target_token, quote=cls.global_token) + + def setUp(self) -> None: + super().setUp() + self.log_records = [] + self.rate_source = CoinCapRateSource(assets_map={}, api_key="") + self.rate_source._coin_cap_data_feed.logger().setLevel(1) + self.rate_source._coin_cap_data_feed.logger().addHandler(self) + self.mocking_assistant = NetworkMockingAssistant() + self.rate_source._coin_cap_data_feed._get_api_factory() + self._web_socket_mock = self.mocking_assistant.configure_web_assistants_factory( + web_assistants_factory=self.rate_source._coin_cap_data_feed._api_factory + ) + + def handle(self, record): + self.log_records.append(record) + + @staticmethod + def async_run_with_timeout(coroutine: Awaitable, timeout: int = 1): + ret = asyncio.get_event_loop().run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def get_coin_cap_assets_data_mock( + self, + asset_symbol: str, + asset_price: Decimal, + asset_id: Optional[str] = None, + ): + data = { + "data": [ + { + "id": asset_id or self.target_asset_id, + "rank": "1", + "symbol": asset_symbol, + "name": "Bitcoin", + "supply": "19351375.0000000000000000", + "maxSupply": "21000000.0000000000000000", + "marketCapUsd": "560124156928.7894433300126125", + "volumeUsd24Hr": "8809682089.3591086933779149", + "priceUsd": str(asset_price), + "changePercent24Hr": "-3.7368339984395858", + "vwap24Hr": "29321.6954689987292113", + "explorer": "https://blockchain.info/", + }, + { + "id": "bitcoin-bep2", + "rank": "36", + "symbol": "BTCB", + "name": "Bitcoin BEP2", + "supply": "53076.5813160500000000", + "maxSupply": None, + "marketCapUsd": "1535042933.7400446414478907", + "volumeUsd24Hr": "545107668.1789385958198549", + "priceUsd": "28921.2849749962704851", + "changePercent24Hr": "-3.6734367191141411", + "vwap24Hr": "29306.9911285134523131", + "explorer": "https://explorer.binance.org/asset/BTCB-1DE", + }, + ], + "timestamp": 1681975911184, + } + return data + + @aioresponses() + def test_get_prices(self, mock_api: aioresponses): + expected_rate = Decimal("20") + + data = self.get_coin_cap_assets_data_mock(asset_symbol=self.target_token, asset_price=expected_rate) + url = f"{CONSTANTS.BASE_REST_URL}{CONSTANTS.ALL_ASSETS_ENDPOINT}" + url_regex = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get( + url=url_regex, + body=json.dumps(data), + headers={ + "X-Ratelimit-Remaining": str(CONSTANTS.NO_KEY_LIMIT - 1), + "X-Ratelimit-Limit": str(CONSTANTS.NO_KEY_LIMIT), + }, + ) + + prices = self.async_run_with_timeout(self.rate_source.get_prices(quote_token="SOMETOKEN")) + + self.assertEqual(prices, {}) + + prices = self.async_run_with_timeout( + coroutine=self.rate_source.get_prices(quote_token=self.global_token) + ) + + self.assertIn(self.trading_pair, prices) + self.assertEqual(expected_rate, prices[self.trading_pair]) + + @aioresponses() + def test_check_network(self, mock_api: aioresponses): + url = f"{CONSTANTS.BASE_REST_URL}{CONSTANTS.HEALTH_CHECK_ENDPOINT}" + mock_api.get(url, exception=Exception()) + + status = self.async_run_with_timeout(coroutine=self.rate_source.check_network()) + self.assertEqual(NetworkStatus.NOT_CONNECTED, status) + + mock_api.get( + url, + body=json.dumps({}), + headers={ + "X-Ratelimit-Remaining": str(CONSTANTS.NO_KEY_LIMIT - 1), + "X-Ratelimit-Limit": str(CONSTANTS.NO_KEY_LIMIT), + }, + ) + + status = self.async_run_with_timeout(coroutine=self.rate_source.check_network()) + self.assertEqual(NetworkStatus.CONNECTED, status) + + @aioresponses() + def test_ws_stream_prices(self, mock_api: aioresponses): + # initial request + rest_rate = Decimal("20") + data = self.get_coin_cap_assets_data_mock(asset_symbol=self.target_token, asset_price=rest_rate) + assets_map = { + asset_data["symbol"]: asset_data["id"] for asset_data in data["data"] + } + rate_source = CoinCapRateSource(assets_map=assets_map, api_key="") + rate_source._coin_cap_data_feed._get_api_factory() + web_socket_mock = self.mocking_assistant.configure_web_assistants_factory( + web_assistants_factory=rate_source._coin_cap_data_feed._api_factory + ) + url = f"{CONSTANTS.BASE_REST_URL}{CONSTANTS.ALL_ASSETS_ENDPOINT}" + url_regex = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get( + url=url_regex, + body=json.dumps(data), + headers={ + "X-Ratelimit-Remaining": str(CONSTANTS.NO_KEY_LIMIT - 1), + "X-Ratelimit-Limit": str(CONSTANTS.NO_KEY_LIMIT), + }, + repeat=True, + ) + + self.async_run_with_timeout(coroutine=rate_source.start_network()) + + prices = self.async_run_with_timeout( + coroutine=rate_source.get_prices(quote_token=self.global_token) + ) + + self.assertIn(self.trading_pair, prices) + self.assertEqual(rest_rate, prices[self.trading_pair]) + + streamed_rate = rest_rate + Decimal("1") + stream_response = {self.target_asset_id: str(streamed_rate)} + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=web_socket_mock, message=json.dumps(stream_response) + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(websocket_mock=web_socket_mock) + + prices = self.async_run_with_timeout( + coroutine=rate_source.get_prices(quote_token=self.global_token) + ) + + self.assertIn(self.trading_pair, prices) + self.assertEqual(streamed_rate, prices[self.trading_pair]) + + self.async_run_with_timeout(coroutine=rate_source.stop_network()) + + prices = self.async_run_with_timeout( + coroutine=rate_source.get_prices(quote_token=self.global_token) + ) + + self.assertIn(self.trading_pair, prices) + self.assertEqual(rest_rate, prices[self.trading_pair]) # rest requests are used once again + + @aioresponses() + @patch("hummingbot.data_feed.coin_cap_data_feed.coin_cap_data_feed.CoinCapDataFeed._sleep") + def test_ws_stream_logs_exceptions_and_restarts(self, mock_api: aioresponses, sleep_mock: AsyncMock): + continue_event = asyncio.Event() + + async def _continue_event_wait(*_, **__): + await continue_event.wait() + continue_event.clear() + + sleep_mock.side_effect = _continue_event_wait + + # initial request + rest_rate = Decimal("20") + data = self.get_coin_cap_assets_data_mock(asset_symbol=self.target_token, asset_price=rest_rate) + assets_map = { + asset_data["symbol"]: asset_data["id"] for asset_data in data["data"] + } + rate_source = CoinCapRateSource(assets_map=assets_map, api_key="") + rate_source._coin_cap_data_feed._get_api_factory() + web_socket_mock = self.mocking_assistant.configure_web_assistants_factory( + web_assistants_factory=rate_source._coin_cap_data_feed._api_factory + ) + url = f"{CONSTANTS.BASE_REST_URL}{CONSTANTS.ALL_ASSETS_ENDPOINT}" + url_regex = re.compile(f"^{url}".replace(".", r"\.").replace("?", r"\?")) + mock_api.get( + url=url_regex, + body=json.dumps(data), + headers={ + "X-Ratelimit-Remaining": str(CONSTANTS.NO_KEY_LIMIT - 1), + "X-Ratelimit-Limit": str(CONSTANTS.NO_KEY_LIMIT), + }, + repeat=True, + ) + + self.async_run_with_timeout(coroutine=rate_source.start_network()) + + streamed_rate = rest_rate + Decimal("1") + stream_response = {self.target_asset_id: str(streamed_rate)} + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=web_socket_mock, message=json.dumps(stream_response) + ) + self.mocking_assistant.add_websocket_aiohttp_exception( + websocket_mock=web_socket_mock, exception=Exception("test exception") + ) + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(websocket_mock=web_socket_mock) + + prices = self.async_run_with_timeout( + coroutine=rate_source.get_prices(quote_token=self.global_token) + ) + + self.assertIn(self.trading_pair, prices) + self.assertEqual(streamed_rate, prices[self.trading_pair]) + log_level = "NETWORK" + message = "Unexpected error while streaming prices. Restarting the stream." + any( + record.levelname == log_level and message == record.getMessage() is not None + for record in self.log_records + ) + + streamed_rate = rest_rate + Decimal("2") + stream_response = {self.target_asset_id: str(streamed_rate)} + self.mocking_assistant.add_websocket_aiohttp_message( + websocket_mock=web_socket_mock, message=json.dumps(stream_response) + ) + + continue_event.set() + + self.mocking_assistant.run_until_all_aiohttp_messages_delivered(websocket_mock=web_socket_mock) + + prices = self.async_run_with_timeout( + coroutine=rate_source.get_prices(quote_token=self.global_token) + ) + + self.assertIn(self.trading_pair, prices) + self.assertEqual(streamed_rate, prices[self.trading_pair]) diff --git a/test/hummingbot/strategy/test_market_trading_pair_tuple.py b/test/hummingbot/strategy/test_market_trading_pair_tuple.py index 8aa7bea6b1..d1a1ef2696 100644 --- a/test/hummingbot/strategy/test_market_trading_pair_tuple.py +++ b/test/hummingbot/strategy/test_market_trading_pair_tuple.py @@ -275,14 +275,14 @@ def test_get_price_by_type(self): def test_vwap_for_volume(self): # Check VWAP on BUY sell - order_volume: Decimal = Decimal("15") + order_volume = 15 filled_orders: List[OrderBookRow] = self.market.get_order_book(self.trading_pair).simulate_buy(order_volume) expected_vwap: Decimal = sum([Decimal(o.price) * Decimal(o.amount) for o in filled_orders]) / order_volume self.assertAlmostEqual(expected_vwap, self.market_info.get_vwap_for_volume(True, order_volume).result_price, 3) # Check VWAP on SELL side - order_volume: Decimal = Decimal("15") + order_volume = 15 filled_orders: List[OrderBookRow] = self.market.get_order_book(self.trading_pair).simulate_sell(order_volume) expected_vwap: Decimal = sum([Decimal(o.price) * Decimal(o.amount) for o in filled_orders]) / order_volume @@ -290,14 +290,14 @@ def test_vwap_for_volume(self): def test_get_price_for_volume(self): # Check price on BUY sell - order_volume: Decimal = Decimal("15") + order_volume = 15 filled_orders: List[OrderBookRow] = self.market.get_order_book(self.trading_pair).simulate_buy(order_volume) expected_buy_price: Decimal = max([Decimal(o.price) for o in filled_orders]) self.assertAlmostEqual(expected_buy_price, self.market_info.get_price_for_volume(True, order_volume).result_price, 3) # Check price on SELL side - order_volume: Decimal = Decimal("15") + order_volume = 15 filled_orders: List[OrderBookRow] = self.market.get_order_book(self.trading_pair).simulate_sell(order_volume) expected_sell_price: Decimal = min([Decimal(o.price) for o in filled_orders]) diff --git a/test/test_logger_mixin_for_test.py b/test/test_logger_mixin_for_test.py index a94d81cfb5..6e076c28a3 100644 --- a/test/test_logger_mixin_for_test.py +++ b/test/test_logger_mixin_for_test.py @@ -8,7 +8,17 @@ class TestTestLoggerMixin(unittest.TestCase): def setUp(self): + super().setUp() self.logger = LoggerMixinForTest() + self._original_async_loop = asyncio.get_event_loop() + self.async_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.async_loop) + + def tearDown(self) -> None: + super().tearDown() + self.async_loop.stop() + self.async_loop.close() + asyncio.set_event_loop(self._original_async_loop) def test_handle(self): self.logger.log_records = []