From ccddb7124e79c469ef6d4aa74c5f6a0cabf6a966 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 29 Sep 2026 20:21:38 +0700 Subject: [PATCH 1/4] A perpetual account's position mode is the exchange's, and a switch is reported only once the exchange makes it Connectors are created with no trading pairs, so the HEDGE forced at init could not reach the exchange, and bybit/bitget flipped only their local mode to HEDGE. On one-way accounts their orders then went out with the wrong position side. Init now reads the account's mode instead, and bitget, which needs a pair to read it, reads it again when its first market is added. The position-mode endpoint takes an optional trading_pair to register first, refuses when no pair is registered, and awaits the exchange call, returning 502 when the exchange keeps the old mode instead of reporting success for a switch that never happened. Fixes #210. Co-Authored-By: Claude Opus 5.5 --- models/accounts.py | 9 +- routers/trading.py | 7 +- services/accounts_service.py | 6 +- services/perpetual_trading_service.py | 59 ++++++++---- services/trading_service.py | 9 ++ services/unified_connector_service.py | 10 ++- test/test_position_mode_sync.py | 124 ++++++++++++++++++++++++++ 7 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 test/test_position_mode_sync.py diff --git a/models/accounts.py b/models/accounts.py index 4f7b8c2a..edf1c318 100644 --- a/models/accounts.py +++ b/models/accounts.py @@ -1,5 +1,6 @@ +from typing import Any, Dict, Optional + from pydantic import BaseModel, Field -from typing import Dict, Any class LeverageRequest(BaseModel): @@ -11,8 +12,12 @@ class LeverageRequest(BaseModel): class PositionModeRequest(BaseModel): """Request model for setting position mode on perpetual connectors""" position_mode: str = Field(description="Position mode (HEDGE or ONEWAY)") + trading_pair: Optional[str] = Field( + default=None, + description="Pair to register on the connector before switching. Connectors apply the " + "switch through their registered pairs, so one is required if none is registered yet.") class CredentialRequest(BaseModel): """Request model for adding connector credentials""" - credentials: Dict[str, Any] = Field(description="Connector credentials dictionary") \ No newline at end of file + credentials: Dict[str, Any] = Field(description="Connector credentials dictionary") diff --git a/routers/trading.py b/routers/trading.py index f622a4b9..32314a87 100644 --- a/routers/trading.py +++ b/routers/trading.py @@ -424,17 +424,20 @@ async def set_position_mode( account_name: Name of the account connector_name: Name of the perpetual connector position_mode: Position mode to set (HEDGE or ONEWAY) + trading_pair: Pair to register first, required if the connector has none registered Returns: Success message with status Raises: - HTTPException: 400 if not a perpetual connector or invalid position mode + HTTPException: 400 if not a perpetual connector, invalid position mode or no registered + trading pair; 502 if the exchange did not accept the switch """ try: # Convert string to PositionMode enum mode = PositionMode[request.position_mode.upper()] - result = await accounts_service.set_position_mode(account_name, connector_name, mode) + result = await accounts_service.set_position_mode( + account_name, connector_name, mode, trading_pair=request.trading_pair) return result except KeyError: raise HTTPException( diff --git a/services/accounts_service.py b/services/accounts_service.py index e1ce570f..2e91bbf5 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -1061,12 +1061,14 @@ async def set_leverage(self, account_name: str, connector_name: str, return await self.perpetual_trading_service.set_leverage(account_name, connector_name, trading_pair, leverage) async def set_position_mode(self, account_name: str, connector_name: str, - position_mode: PositionMode) -> Dict[str, str]: + position_mode: PositionMode, + trading_pair: Optional[str] = None) -> Dict[str, str]: """ Set position mode for a perpetual connector. Delegates to PerpetualTradingService. """ - return await self.perpetual_trading_service.set_position_mode(account_name, connector_name, position_mode) + return await self.perpetual_trading_service.set_position_mode( + account_name, connector_name, position_mode, trading_pair=trading_pair) async def get_position_mode(self, account_name: str, connector_name: str) -> Dict[str, str]: """ diff --git a/services/perpetual_trading_service.py b/services/perpetual_trading_service.py index b0b24485..86ec9fae 100644 --- a/services/perpetual_trading_service.py +++ b/services/perpetual_trading_service.py @@ -1,6 +1,5 @@ -import asyncio import logging -from typing import Any, Awaitable, Callable, Dict, List +from typing import Any, Awaitable, Callable, Dict, List, Optional from fastapi import HTTPException from hummingbot.core.data_type.common import PositionMode @@ -77,7 +76,8 @@ async def set_leverage(self, account_name: str, connector_name: str, raise HTTPException(status_code=500, detail=f"Failed to set leverage: {str(e)}") async def set_position_mode(self, account_name: str, connector_name: str, - position_mode: PositionMode) -> Dict[str, str]: + position_mode: PositionMode, + trading_pair: Optional[str] = None) -> Dict[str, str]: """ Set position mode for a perpetual connector. @@ -85,16 +85,39 @@ async def set_position_mode(self, account_name: str, connector_name: str, account_name: Name of the account connector_name: Name of the connector (must be perpetual) position_mode: PositionMode.HEDGE or PositionMode.ONEWAY + trading_pair: Pair to register on the connector first. Connectors apply the + switch through their registered pairs, so at least one is required. Returns: Dictionary with success status and message Raises: - HTTPException: If account/connector not found, not perpetual, or operation fails + HTTPException: 400 if no pair is registered, the pair is unknown or the mode is + unsupported; 502 if the exchange did not accept the switch """ connector = await self._get_perpetual_connector(account_name, connector_name) - # Check if the requested position mode is supported + # Register the pair before validating: bybit's supported_position_modes() depends on + # the registered pairs and returns both modes when there are none. + if trading_pair and trading_pair not in connector.trading_pairs: + try: + await connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) + except KeyError: + raise HTTPException( + status_code=400, + detail=f"Trading pair '{trading_pair}' is not listed on {connector_name}" + ) + connector._trading_pairs.append(trading_pair) + + # With no registered pair the connector cannot apply the switch: the base + # implementation logs a warning and returns, bybit/bitget flip only the local mode. + if not connector.trading_pairs: + raise HTTPException( + status_code=400, + detail=f"No trading pairs registered on {connector_name}; pass trading_pair " + f"so the position mode switch can be applied on the exchange" + ) + supported_modes = connector.supported_position_modes() if position_mode not in supported_modes: supported_values = [mode.value for mode in supported_modes] @@ -104,20 +127,26 @@ async def set_position_mode(self, account_name: str, connector_name: str, ) try: - # Try to call the method - it might be sync or async - result = connector.set_position_mode(position_mode) - # If it's a coroutine, await it - if asyncio.iscoroutine(result): - await result - - message = f"Position mode set to {position_mode.value} on {connector_name}" - logger.info(f"Set position mode to {position_mode.value} on {connector_name} (Account: {account_name})") - return {"status": "success", "message": message} - + # connector.set_position_mode() runs this in the background and cannot report a + # rejection (e.g. Binance -4068 with open positions). Awaiting it directly does, since + # the local mode only changes once the exchange confirms. + await connector._execute_set_position_mode(position_mode) except Exception as e: logger.error(f"Failed to set position mode to {position_mode.value}: {e}") raise HTTPException(status_code=500, detail=f"Failed to set position mode: {str(e)}") + if connector.position_mode != position_mode: + raise HTTPException( + status_code=502, + detail=f"{connector_name} did not accept position mode {position_mode.value} " + f"(still {connector.position_mode.value}); check for open positions or orders " + f"and the connector logs" + ) + + message = f"Position mode set to {position_mode.value} on {connector_name}" + logger.info(f"Set position mode to {position_mode.value} on {connector_name} (Account: {account_name})") + return {"status": "success", "message": message} + async def get_position_mode(self, account_name: str, connector_name: str) -> Dict[str, str]: """ Get current position mode for a perpetual connector. diff --git a/services/trading_service.py b/services/trading_service.py index 11041d61..fe13889f 100644 --- a/services/trading_service.py +++ b/services/trading_service.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set from hummingbot.connector.connector_base import ConnectorBase +from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import OrderType, PositionAction if TYPE_CHECKING: @@ -171,8 +172,16 @@ async def add_market( logger.info(f"Order book initialized successfully for {connector_name}/{trading_pair}") # Register trading pair with connector + first_pair = not connector._trading_pairs self._register_trading_pair_with_connector(connector, trading_pair) + # Some connectors can only read the account's position mode once a pair is + # registered (bitget), so the read at connector init found nothing. Read it again + # now, before any order is placed under the ONEWAY default. + if (first_pair and isinstance(connector, PerpetualDerivativePyBase) + and len(connector.supported_position_modes()) > 1): + await connector._initialize_position_mode() + # Update balances to include tokens from new trading pair if hasattr(connector, '_update_balances'): try: diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index 403e7d48..5283ee11 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -24,7 +24,7 @@ from hummingbot.connector.exchange_py_base import ExchangePyBase from hummingbot.connector.gateway.gateway import Gateway from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase -from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType +from hummingbot.core.data_type.common import OrderType, PositionAction, TradeType from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState from hummingbot.core.utils.async_utils import safe_ensure_future @@ -684,8 +684,12 @@ async def _create_and_initialize_trading_connector( # Perpetual-specific setup if self._is_perpetual_connector(connector): - if PositionMode.HEDGE in connector.supported_position_modes(): - connector.set_position_mode(PositionMode.HEDGE) + # Adopt the exchange's current mode rather than forcing one: with no pairs + # registered yet a switch cannot reach the exchange, and bybit/bitget would + # only flip the local mode to HEDGE. start_network() does the same, but the API + # runs the polling tasks itself and never calls it for CEX connectors. + if len(connector.supported_position_modes()) > 1: + await connector._initialize_position_mode() await connector._update_positions() # Load existing orders from database diff --git a/test/test_position_mode_sync.py b/test/test_position_mode_sync.py new file mode 100644 index 00000000..412bf073 --- /dev/null +++ b/test/test_position_mode_sync.py @@ -0,0 +1,124 @@ +"""The API's position mode is the exchange's, and a switch it reports is one the exchange made. + +The API creates perpetual connectors with no trading pairs. With none registered, a +position-mode switch cannot reach the exchange: bitget's and bybit's implementations loop +over the empty pair list and flip only the local mode, and bitget cannot even read the +account's mode until it has a pair to query with. So the API used to report HEDGE on +one-way bitget accounts, the switch endpoint reported success for switches that never +happened, and orders went out with the wrong position side. #210. + +These drive the real bitget connector with only its HTTP layer replaced by a fake account. +""" +from unittest.mock import AsyncMock, MagicMock + +import pytest +from bidict import bidict +from fastapi import HTTPException +from hummingbot.connector.derivative.bitget_perpetual import bitget_perpetual_constants as CONSTANTS +from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import BitgetPerpetualDerivative +from hummingbot.core.data_type.common import PositionMode + +from services.perpetual_trading_service import PerpetualTradingService +from services.trading_service import AccountTradingInterface + + +class FakeBitgetAccount: + def __init__(self, pos_mode: str, accept_switch: bool = True): + self.pos_mode = pos_mode + self.accept_switch = accept_switch + self.switch_requests = [] + + async def get(self, path_url, params=None, is_auth_required=False, **kwargs): + assert path_url == CONSTANTS.ACCOUNT_INFO_ENDPOINT + return {"code": CONSTANTS.RET_CODE_OK, "data": {"posMode": self.pos_mode}} + + async def post(self, path_url, data=None, is_auth_required=False, **kwargs): + assert path_url == CONSTANTS.SET_POSITION_MODE_ENDPOINT + self.switch_requests.append(data["posMode"]) + if not self.accept_switch: + return {"code": "40920", "msg": "position exists"} + self.pos_mode = data["posMode"] + return {"code": CONSTANTS.RET_CODE_OK, "data": {}} + + +def _bitget(account: FakeBitgetAccount) -> BitgetPerpetualDerivative: + connector = BitgetPerpetualDerivative( + bitget_perpetual_api_key="k", + bitget_perpetual_secret_key="s", + bitget_perpetual_passphrase="p", + trading_pairs=[], + ) + connector._set_trading_pair_symbol_map(bidict({"BTCUSDT": "BTC-USDT"})) + connector._api_get = account.get + connector._api_post = account.post + return connector + + +def _service(connector) -> PerpetualTradingService: + async def provider(account_name, connector_name): + return connector + return PerpetualTradingService(provider) + + +async def test_first_pair_adopts_the_bitget_accounts_hedge_mode(): + account = FakeBitgetAccount("hedge_mode") + connector = _bitget(account) + market_data = MagicMock() + market_data.initialize_order_book = AsyncMock(return_value=True) + connector_service = MagicMock() + connector_service.get_trading_connector = AsyncMock(return_value=connector) + connector_service.get_account_connectors.return_value = {"bitget_perpetual": connector} + interface = AccountTradingInterface(connector_service, market_data, "master") + + await interface.add_market("bitget_perpetual", "BTC-USDT") + + assert connector.position_mode == PositionMode.HEDGE + assert account.switch_requests == [] + + +async def test_switch_without_a_registered_pair_is_refused(): + account = FakeBitgetAccount("one_way_mode") + connector = _bitget(account) + + with pytest.raises(HTTPException) as e: + await _service(connector).set_position_mode("master", "bitget_perpetual", PositionMode.HEDGE) + + assert e.value.status_code == 400 + assert connector.position_mode == PositionMode.ONEWAY + assert account.switch_requests == [] + + +async def test_switch_with_an_unlisted_pair_is_refused_without_registering_it(): + connector = _bitget(FakeBitgetAccount("one_way_mode")) + + with pytest.raises(HTTPException) as e: + await _service(connector).set_position_mode( + "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="NOPE-USDT") + + assert e.value.status_code == 400 + assert connector.trading_pairs == [] + + +async def test_switch_reaches_the_exchange_through_the_given_pair(): + account = FakeBitgetAccount("one_way_mode") + connector = _bitget(account) + + result = await _service(connector).set_position_mode( + "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDT") + + assert result["status"] == "success" + assert account.pos_mode == "hedge_mode" + assert connector.position_mode == PositionMode.HEDGE + + +async def test_switch_the_exchange_rejects_is_reported_as_rejected(): + account = FakeBitgetAccount("one_way_mode", accept_switch=False) + connector = _bitget(account) + + with pytest.raises(HTTPException) as e: + await _service(connector).set_position_mode( + "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDT") + + assert e.value.status_code == 502 + assert account.switch_requests == ["hedge_mode"] + assert connector.position_mode == PositionMode.ONEWAY From ea55b40e4ba942c4391613016d4d653be94bdc4b Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 29 Sep 2026 21:22:03 +0700 Subject: [PATCH 2/4] A direct perpetual order reads the account's position mode first, as an executor's does POST /trading/orders never registered its pair on the connector, so on bitget, which can only read the account's position mode with a pair registered, a connector that only took direct orders stayed at the ONEWAY default and sent hedge-mode accounts orders without tradeSide. It also left bitget's position refresh with no product type to query. place_trade now registers the pair on perpetual connectors through the same helper as add_market and the position-mode endpoint, which reads the mode on the first pair. Co-Authored-By: Claude Opus 5.5 --- services/accounts_service.py | 10 +++++++-- services/perpetual_trading_service.py | 19 +++++++++++++++- services/trading_service.py | 30 +++----------------------- test/test_position_mode_sync.py | 31 ++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 31 deletions(-) diff --git a/services/accounts_service.py b/services/accounts_service.py index 2e91bbf5..b1fd488b 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -7,6 +7,7 @@ from fastapi import HTTPException from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger +from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType from config import settings @@ -14,7 +15,7 @@ from services.gateway_client import GatewayClient from services.gateway_transaction_poller import GatewayTransactionPoller from services.gateway_wallet_service import GatewayWalletService, balance_entry -from services.perpetual_trading_service import PerpetualTradingService +from services.perpetual_trading_service import PerpetualTradingService, register_trading_pair from services.portfolio_analytics_service import PortfolioAnalyticsService from utils.file_system import fs_util from utils.gateway_certs import build_client_ssl_context @@ -921,7 +922,12 @@ async def place_trade(self, account_name: str, connector_name: str, trading_pair ) trading_rule = connector.trading_rules[trading_pair] - + + # A perpetual order goes out under the connector's position mode, which some + # connectors can only read from the account once a pair is registered (bitget). + if isinstance(connector, PerpetualDerivativePyBase): + await register_trading_pair(connector, trading_pair) + # Validate order type is supported if order_type not in connector.supported_order_types(): supported_types = [ot.name for ot in connector.supported_order_types()] diff --git a/services/perpetual_trading_service.py b/services/perpetual_trading_service.py index 86ec9fae..400cf0b3 100644 --- a/services/perpetual_trading_service.py +++ b/services/perpetual_trading_service.py @@ -2,12 +2,29 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional from fastapi import HTTPException +from hummingbot.connector.connector_base import ConnectorBase +from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import PositionMode # Create module-specific logger logger = logging.getLogger(__name__) +async def register_trading_pair(connector: ConnectorBase, trading_pair: str) -> None: + """Register a pair on a connector, reading the account's position mode on a perpetual's first pair. + + Some connectors can only read the account's position mode once a pair is registered + (bitget queries it per pair), so the read at connector init found nothing and the local + mode sits at the ONEWAY default. Read it again before any order is placed under it. + """ + first_pair = not connector._trading_pairs + if trading_pair not in connector._trading_pairs: + connector._trading_pairs.append(trading_pair) + if (first_pair and isinstance(connector, PerpetualDerivativePyBase) + and len(connector.supported_position_modes()) > 1): + await connector._initialize_position_mode() + + class PerpetualTradingService: """ Perpetual-specific trading operations: leverage, position mode and position queries. @@ -107,7 +124,7 @@ async def set_position_mode(self, account_name: str, connector_name: str, status_code=400, detail=f"Trading pair '{trading_pair}' is not listed on {connector_name}" ) - connector._trading_pairs.append(trading_pair) + await register_trading_pair(connector, trading_pair) # With no registered pair the connector cannot apply the switch: the base # implementation logs a warning and returns, bybit/bitget flip only the local mode. diff --git a/services/trading_service.py b/services/trading_service.py index fe13889f..bcc311c6 100644 --- a/services/trading_service.py +++ b/services/trading_service.py @@ -10,9 +10,10 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set from hummingbot.connector.connector_base import ConnectorBase -from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import OrderType, PositionAction +from services.perpetual_trading_service import register_trading_pair + if TYPE_CHECKING: from services.market_data_service import MarketDataService from services.unified_connector_service import UnifiedConnectorService @@ -171,16 +172,7 @@ async def add_market( logger.info(f"Order book initialized successfully for {connector_name}/{trading_pair}") - # Register trading pair with connector - first_pair = not connector._trading_pairs - self._register_trading_pair_with_connector(connector, trading_pair) - - # Some connectors can only read the account's position mode once a pair is - # registered (bitget), so the read at connector init found nothing. Read it again - # now, before any order is placed under the ONEWAY default. - if (first_pair and isinstance(connector, PerpetualDerivativePyBase) - and len(connector.supported_position_modes()) > 1): - await connector._initialize_position_mode() + await register_trading_pair(connector, trading_pair) # Update balances to include tokens from new trading pair if hasattr(connector, '_update_balances'): @@ -226,22 +218,6 @@ async def remove_market( logger.info(f"Removed market {connector_name}/{trading_pair}") - def _register_trading_pair_with_connector( - self, - connector: ConnectorBase, - trading_pair: str - ): - """ - Register a trading pair with the connector's internal structures. - - Args: - connector: The connector instance (ExchangePyBase) - trading_pair: Trading pair to register - """ - if trading_pair not in connector._trading_pairs: - connector._trading_pairs.append(trading_pair) - logger.debug(f"Registered {trading_pair} with connector {type(connector).__name__}") - # ======================================== # ScriptStrategyBase-compatible methods # These are called by executors via self._strategy.method() diff --git a/test/test_position_mode_sync.py b/test/test_position_mode_sync.py index 412bf073..73850420 100644 --- a/test/test_position_mode_sync.py +++ b/test/test_position_mode_sync.py @@ -9,6 +9,8 @@ These drive the real bitget connector with only its HTTP layer replaced by a fake account. """ +import asyncio +from decimal import Decimal from unittest.mock import AsyncMock, MagicMock import pytest @@ -16,8 +18,10 @@ from fastapi import HTTPException from hummingbot.connector.derivative.bitget_perpetual import bitget_perpetual_constants as CONSTANTS from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import BitgetPerpetualDerivative -from hummingbot.core.data_type.common import PositionMode +from hummingbot.connector.trading_rule import TradingRule +from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType +from services.accounts_service import AccountsService from services.perpetual_trading_service import PerpetualTradingService from services.trading_service import AccountTradingInterface @@ -27,12 +31,16 @@ def __init__(self, pos_mode: str, accept_switch: bool = True): self.pos_mode = pos_mode self.accept_switch = accept_switch self.switch_requests = [] + self.orders = [] async def get(self, path_url, params=None, is_auth_required=False, **kwargs): assert path_url == CONSTANTS.ACCOUNT_INFO_ENDPOINT return {"code": CONSTANTS.RET_CODE_OK, "data": {"posMode": self.pos_mode}} async def post(self, path_url, data=None, is_auth_required=False, **kwargs): + if path_url == CONSTANTS.PLACE_ORDER_ENDPOINT: + self.orders.append(data) + return {"code": CONSTANTS.RET_CODE_OK, "data": {"orderId": "1"}} assert path_url == CONSTANTS.SET_POSITION_MODE_ENDPOINT self.switch_requests.append(data["posMode"]) if not self.accept_switch: @@ -54,6 +62,27 @@ def _bitget(account: FakeBitgetAccount) -> BitgetPerpetualDerivative: return connector +async def test_direct_order_on_a_fresh_connector_goes_out_in_the_accounts_hedge_mode(): + account = FakeBitgetAccount("hedge_mode") + connector = _bitget(account) + connector._trading_rules["BTC-USDT"] = TradingRule( + "BTC-USDT", min_order_size=Decimal("0.001"), min_price_increment=Decimal("0.1"), + min_base_amount_increment=Decimal("0.001"), min_notional_size=Decimal("5")) + service = AccountsService.__new__(AccountsService) + service.list_accounts = lambda: ["master"] + service._connector_service = MagicMock() + service._connector_service.get_trading_connector = AsyncMock(return_value=connector) + + await service.place_trade("master", "bitget_perpetual", "BTC-USDT", TradeType.BUY, Decimal("0.01"), + OrderType.LIMIT, Decimal("60000"), PositionAction.OPEN) + for _ in range(100): + if account.orders: + break + await asyncio.sleep(0.01) + + assert account.orders[0]["tradeSide"] == "open" + + def _service(connector) -> PerpetualTradingService: async def provider(account_name, connector_name): return connector From 1b66e0b10c56b95ada25b855d4a8dc2e4d7e510a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 30 Sep 2026 18:08:38 +0700 Subject: [PATCH 3/4] An executor's first perpetual pair reads the account's position mode, as a direct order's does add_market set up the order book before registering the pair, and the order book tracker shares the connector's pair list, so the pair was already there and the first-pair read of the account's mode never ran. On a bitget hedge account the local mode stayed ONEWAY and every executor order was refused. add_market now notes whether the pair is the first before the order book registers it. Co-Authored-By: Claude Opus 5.5 --- services/perpetual_trading_service.py | 9 +++++++-- services/trading_service.py | 6 +++++- test/test_position_mode_sync.py | 7 ++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/services/perpetual_trading_service.py b/services/perpetual_trading_service.py index 400cf0b3..5e0f8634 100644 --- a/services/perpetual_trading_service.py +++ b/services/perpetual_trading_service.py @@ -10,14 +10,19 @@ logger = logging.getLogger(__name__) -async def register_trading_pair(connector: ConnectorBase, trading_pair: str) -> None: +async def register_trading_pair(connector: ConnectorBase, trading_pair: str, + first_pair: Optional[bool] = None) -> None: """Register a pair on a connector, reading the account's position mode on a perpetual's first pair. Some connectors can only read the account's position mode once a pair is registered (bitget queries it per pair), so the read at connector init found nothing and the local mode sits at the ONEWAY default. Read it again before any order is placed under it. + + A caller that has already set up the pair's order book passes first_pair as it was before: + the order book tracker shares the connector's pair list, so the pair is already in it. """ - first_pair = not connector._trading_pairs + if first_pair is None: + first_pair = not connector._trading_pairs if trading_pair not in connector._trading_pairs: connector._trading_pairs.append(trading_pair) if (first_pair and isinstance(connector, PerpetualDerivativePyBase) diff --git a/services/trading_service.py b/services/trading_service.py index bcc311c6..9527d4bb 100644 --- a/services/trading_service.py +++ b/services/trading_service.py @@ -158,6 +158,10 @@ async def add_market( if not connector: raise ValueError(f"Connector {connector_name} not available. Check credentials.") + # The order book tracker shares the connector's pair list, so note whether this is the + # connector's first pair before the order book registers it + first_pair = not connector._trading_pairs + # Initialize order book via MarketDataService (uses best available connector) logger.info(f"Initializing order book for {connector_name}/{trading_pair}") success = await self._market_data_service.initialize_order_book( @@ -172,7 +176,7 @@ async def add_market( logger.info(f"Order book initialized successfully for {connector_name}/{trading_pair}") - await register_trading_pair(connector, trading_pair) + await register_trading_pair(connector, trading_pair, first_pair=first_pair) # Update balances to include tokens from new trading pair if hasattr(connector, '_update_balances'): diff --git a/test/test_position_mode_sync.py b/test/test_position_mode_sync.py index 73850420..820807fb 100644 --- a/test/test_position_mode_sync.py +++ b/test/test_position_mode_sync.py @@ -92,8 +92,13 @@ async def provider(account_name, connector_name): async def test_first_pair_adopts_the_bitget_accounts_hedge_mode(): account = FakeBitgetAccount("hedge_mode") connector = _bitget(account) + + async def initialize_order_book(connector_name, trading_pair, **kwargs): + # The tracker's pair list is the connector's own, so the order book registers the pair + connector.order_book_tracker._trading_pairs.append(trading_pair) + return True market_data = MagicMock() - market_data.initialize_order_book = AsyncMock(return_value=True) + market_data.initialize_order_book = initialize_order_book connector_service = MagicMock() connector_service.get_trading_connector = AsyncMock(return_value=connector) connector_service.get_account_connectors.return_value = {"bitget_perpetual": connector} From c8ca1155446fb615e2b1118590f2e52209021c48 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 1 Oct 2026 15:59:26 +0700 Subject: [PATCH 4/4] The API supplies the exchange context for a position mode call, instead of asking the caller for a pair hummingbot reaches the account through the connector's registered pairs: the base switch returns early with none, and bitget derives its product type from the first. The API creates connectors with no pairs, so it now lends the connector a listed pair for the call and takes it back. - the mode is read at connector creation, so bitget is right before any market exists - the switch needs no trading_pair; it stays as an optional scope (bitget product type, bybit symbol) and is required only on bybit, whose mode is per symbol - GET position-mode re-reads the exchange, and adding a market does too - direct orders and the switch no longer leave a pair in the list the order book tracker subscribes to Co-Authored-By: Claude Opus 5.5 --- models/accounts.py | 4 +- routers/trading.py | 4 +- services/accounts_service.py | 8 +- services/perpetual_trading_service.py | 118 +++++++++++------ services/trading_service.py | 28 +++- services/unified_connector_service.py | 4 +- test/test_position_mode_sync.py | 177 +++++++++++++++++++++----- 7 files changed, 247 insertions(+), 96 deletions(-) diff --git a/models/accounts.py b/models/accounts.py index edf1c318..a5ed7f57 100644 --- a/models/accounts.py +++ b/models/accounts.py @@ -14,8 +14,8 @@ class PositionModeRequest(BaseModel): position_mode: str = Field(description="Position mode (HEDGE or ONEWAY)") trading_pair: Optional[str] = Field( default=None, - description="Pair to register on the connector before switching. Connectors apply the " - "switch through their registered pairs, so one is required if none is registered yet.") + description="Optional. Only for exchanges whose position mode is not account-wide: selects " + "the product type on bitget (USDT by default) or the symbol on bybit.") class CredentialRequest(BaseModel): diff --git a/routers/trading.py b/routers/trading.py index 32314a87..f32e0ae7 100644 --- a/routers/trading.py +++ b/routers/trading.py @@ -424,13 +424,13 @@ async def set_position_mode( account_name: Name of the account connector_name: Name of the perpetual connector position_mode: Position mode to set (HEDGE or ONEWAY) - trading_pair: Pair to register first, required if the connector has none registered + trading_pair: Optional scope where the mode is not account-wide (bitget product type, bybit symbol) Returns: Success message with status Raises: - HTTPException: 400 if not a perpetual connector, invalid position mode or no registered + HTTPException: 400 if not a perpetual connector, invalid position mode or unknown trading pair; 502 if the exchange did not accept the switch """ try: diff --git a/services/accounts_service.py b/services/accounts_service.py index b1fd488b..0e38216f 100644 --- a/services/accounts_service.py +++ b/services/accounts_service.py @@ -7,7 +7,6 @@ from fastapi import HTTPException from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger -from hummingbot.connector.perpetual_derivative_py_base import PerpetualDerivativePyBase from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType from config import settings @@ -15,7 +14,7 @@ from services.gateway_client import GatewayClient from services.gateway_transaction_poller import GatewayTransactionPoller from services.gateway_wallet_service import GatewayWalletService, balance_entry -from services.perpetual_trading_service import PerpetualTradingService, register_trading_pair +from services.perpetual_trading_service import PerpetualTradingService from services.portfolio_analytics_service import PortfolioAnalyticsService from utils.file_system import fs_util from utils.gateway_certs import build_client_ssl_context @@ -923,11 +922,6 @@ async def place_trade(self, account_name: str, connector_name: str, trading_pair trading_rule = connector.trading_rules[trading_pair] - # A perpetual order goes out under the connector's position mode, which some - # connectors can only read from the account once a pair is registered (bitget). - if isinstance(connector, PerpetualDerivativePyBase): - await register_trading_pair(connector, trading_pair) - # Validate order type is supported if order_type not in connector.supported_order_types(): supported_types = [ot.name for ot in connector.supported_order_types()] diff --git a/services/perpetual_trading_service.py b/services/perpetual_trading_service.py index 5e0f8634..7f18549f 100644 --- a/services/perpetual_trading_service.py +++ b/services/perpetual_trading_service.py @@ -1,4 +1,5 @@ import logging +from contextlib import asynccontextmanager from typing import Any, Awaitable, Callable, Dict, List, Optional from fastapi import HTTPException @@ -10,24 +11,57 @@ logger = logging.getLogger(__name__) -async def register_trading_pair(connector: ConnectorBase, trading_pair: str, - first_pair: Optional[bool] = None) -> None: - """Register a pair on a connector, reading the account's position mode on a perpetual's first pair. +def _reads_position_mode(connector: PerpetualDerivativePyBase) -> bool: + """Whether the connector can ask the exchange for the account's position mode.""" + return (type(connector)._fetch_account_position_mode + is not PerpetualDerivativePyBase._fetch_account_position_mode) - Some connectors can only read the account's position mode once a pair is registered - (bitget queries it per pair), so the read at connector init found nothing and the local - mode sits at the ONEWAY default. Read it again before any order is placed under it. - A caller that has already set up the pair's order book passes first_pair as it was before: - the order book tracker shares the connector's pair list, so the pair is already in it. +def _switches_pair_by_pair(connector: PerpetualDerivativePyBase) -> bool: + """Whether the mode lives on each symbol rather than on the account (bybit). + + Such a connector loops over its registered pairs to switch and cannot report a mode, + so there is no account-level default the API could address on its own. + """ + return (type(connector)._execute_set_position_mode + is not PerpetualDerivativePyBase._execute_set_position_mode + and not _reads_position_mode(connector)) + + +@asynccontextmanager +async def _exchange_context(connector: PerpetualDerivativePyBase, trading_pair: Optional[str] = None): + """Lend the connector a listed pair for the duration of an account-level call. + + hummingbot addresses the account through the connector's registered pairs: the base + switch returns early with none, and bitget derives its product type from the first one. + The API creates connectors with no pairs, so it supplies one here and takes it back, + rather than asking the caller for it. The pair list is shared with the order book + tracker, so the pair must not stay registered without an order book. + + With no trading_pair given, a USDT pair is borrowed only if none is registered. """ - if first_pair is None: - first_pair = not connector._trading_pairs - if trading_pair not in connector._trading_pairs: - connector._trading_pairs.append(trading_pair) - if (first_pair and isinstance(connector, PerpetualDerivativePyBase) - and len(connector.supported_position_modes()) > 1): - await connector._initialize_position_mode() + pairs = connector._trading_pairs + pair = trading_pair + if pair is None and not pairs: + rules = connector.trading_rules + pair = next((p for p in rules if p.endswith("-USDT")), next(iter(rules), None)) + borrowed = pair is not None and pair not in pairs + if borrowed: + pairs.insert(0, pair) + try: + yield + finally: + if borrowed and pair in pairs: + pairs.remove(pair) + + +async def sync_position_mode(connector: ConnectorBase) -> None: + """Adopt the exchange's position mode as the connector's local mode, if it can be read.""" + if not isinstance(connector, PerpetualDerivativePyBase) or not _reads_position_mode(connector): + return + async with _exchange_context(connector): + if len(connector.supported_position_modes()) > 1: + await connector._initialize_position_mode() class PerpetualTradingService: @@ -107,20 +141,18 @@ async def set_position_mode(self, account_name: str, connector_name: str, account_name: Name of the account connector_name: Name of the connector (must be perpetual) position_mode: PositionMode.HEDGE or PositionMode.ONEWAY - trading_pair: Pair to register on the connector first. Connectors apply the - switch through their registered pairs, so at least one is required. + trading_pair: Optional scope for exchanges whose mode is not account-wide: the + product type on bitget (USDT by default), the symbol on bybit. Never registered. Returns: Dictionary with success status and message Raises: - HTTPException: 400 if no pair is registered, the pair is unknown or the mode is - unsupported; 502 if the exchange did not accept the switch + HTTPException: 400 if the pair is unknown, the mode is unsupported or the exchange + switches per symbol and none is given; 502 if the exchange did not accept the switch """ connector = await self._get_perpetual_connector(account_name, connector_name) - # Register the pair before validating: bybit's supported_position_modes() depends on - # the registered pairs and returns both modes when there are none. if trading_pair and trading_pair not in connector.trading_pairs: try: await connector.exchange_symbol_associated_to_pair(trading_pair=trading_pair) @@ -129,33 +161,33 @@ async def set_position_mode(self, account_name: str, connector_name: str, status_code=400, detail=f"Trading pair '{trading_pair}' is not listed on {connector_name}" ) - await register_trading_pair(connector, trading_pair) - # With no registered pair the connector cannot apply the switch: the base - # implementation logs a warning and returns, bybit/bitget flip only the local mode. - if not connector.trading_pairs: + if not trading_pair and not connector.trading_pairs and _switches_pair_by_pair(connector): raise HTTPException( status_code=400, - detail=f"No trading pairs registered on {connector_name}; pass trading_pair " - f"so the position mode switch can be applied on the exchange" + detail=f"{connector_name} sets the position mode per symbol; pass trading_pair " + f"to say which one to switch" ) - supported_modes = connector.supported_position_modes() - if position_mode not in supported_modes: - supported_values = [mode.value for mode in supported_modes] - raise HTTPException( - status_code=400, - detail=f"Position mode '{position_mode.value}' not supported. Supported modes: {supported_values}" - ) + async with _exchange_context(connector, trading_pair): + # Validated with the pair in place: bybit's supported_position_modes() depends on + # the pairs and returns both modes when there are none. + supported_modes = connector.supported_position_modes() + if position_mode not in supported_modes: + supported_values = [mode.value for mode in supported_modes] + raise HTTPException( + status_code=400, + detail=f"Position mode '{position_mode.value}' not supported. Supported modes: {supported_values}" + ) - try: - # connector.set_position_mode() runs this in the background and cannot report a - # rejection (e.g. Binance -4068 with open positions). Awaiting it directly does, since - # the local mode only changes once the exchange confirms. - await connector._execute_set_position_mode(position_mode) - except Exception as e: - logger.error(f"Failed to set position mode to {position_mode.value}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to set position mode: {str(e)}") + try: + # connector.set_position_mode() runs this in the background and cannot report a + # rejection (e.g. Binance -4068 with open positions). Awaiting it directly does, + # since the local mode only changes once the exchange confirms. + await connector._execute_set_position_mode(position_mode) + except Exception as e: + logger.error(f"Failed to set position mode to {position_mode.value}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to set position mode: {str(e)}") if connector.position_mode != position_mode: raise HTTPException( @@ -189,6 +221,8 @@ async def get_position_mode(self, account_name: str, connector_name: str) -> Dic raise HTTPException(status_code=400, detail=f"Connector '{connector_name}' does not support position mode") try: + # Report the exchange's mode, not a cached one: it may have been changed elsewhere + await sync_position_mode(connector) current_mode = connector.position_mode return { "position_mode": current_mode.value if current_mode else "UNKNOWN", diff --git a/services/trading_service.py b/services/trading_service.py index 9527d4bb..4620157f 100644 --- a/services/trading_service.py +++ b/services/trading_service.py @@ -12,7 +12,7 @@ from hummingbot.connector.connector_base import ConnectorBase from hummingbot.core.data_type.common import OrderType, PositionAction -from services.perpetual_trading_service import register_trading_pair +from services.perpetual_trading_service import sync_position_mode if TYPE_CHECKING: from services.market_data_service import MarketDataService @@ -158,10 +158,6 @@ async def add_market( if not connector: raise ValueError(f"Connector {connector_name} not available. Check credentials.") - # The order book tracker shares the connector's pair list, so note whether this is the - # connector's first pair before the order book registers it - first_pair = not connector._trading_pairs - # Initialize order book via MarketDataService (uses best available connector) logger.info(f"Initializing order book for {connector_name}/{trading_pair}") success = await self._market_data_service.initialize_order_book( @@ -176,7 +172,11 @@ async def add_market( logger.info(f"Order book initialized successfully for {connector_name}/{trading_pair}") - await register_trading_pair(connector, trading_pair, first_pair=first_pair) + # Register trading pair with connector + self._register_trading_pair_with_connector(connector, trading_pair) + + # Cheap re-read: picks up a mode changed on the exchange, or a read that failed at init + await sync_position_mode(connector) # Update balances to include tokens from new trading pair if hasattr(connector, '_update_balances'): @@ -222,6 +222,22 @@ async def remove_market( logger.info(f"Removed market {connector_name}/{trading_pair}") + def _register_trading_pair_with_connector( + self, + connector: ConnectorBase, + trading_pair: str + ): + """ + Register a trading pair with the connector's internal structures. + + Args: + connector: The connector instance (ExchangePyBase) + trading_pair: Trading pair to register + """ + if trading_pair not in connector._trading_pairs: + connector._trading_pairs.append(trading_pair) + logger.debug(f"Registered {trading_pair} with connector {type(connector).__name__}") + # ======================================== # ScriptStrategyBase-compatible methods # These are called by executors via self._strategy.method() diff --git a/services/unified_connector_service.py b/services/unified_connector_service.py index 5283ee11..02ab4753 100644 --- a/services/unified_connector_service.py +++ b/services/unified_connector_service.py @@ -28,6 +28,7 @@ from hummingbot.core.data_type.in_flight_order import InFlightOrder, OrderState from hummingbot.core.utils.async_utils import safe_ensure_future +from services.perpetual_trading_service import sync_position_mode from utils.file_system import fs_util from utils.hummingbot_api_config_adapter import HummingbotAPIConfigAdapter from utils.security import BackendAPISecurity @@ -688,8 +689,7 @@ async def _create_and_initialize_trading_connector( # registered yet a switch cannot reach the exchange, and bybit/bitget would # only flip the local mode to HEDGE. start_network() does the same, but the API # runs the polling tasks itself and never calls it for CEX connectors. - if len(connector.supported_position_modes()) > 1: - await connector._initialize_position_mode() + await sync_position_mode(connector) await connector._update_positions() # Load existing orders from database diff --git a/test/test_position_mode_sync.py b/test/test_position_mode_sync.py index 820807fb..4bb28eac 100644 --- a/test/test_position_mode_sync.py +++ b/test/test_position_mode_sync.py @@ -7,7 +7,10 @@ one-way bitget accounts, the switch endpoint reported success for switches that never happened, and orders went out with the wrong position side. #210. -These drive the real bitget connector with only its HTTP layer replaced by a fake account. +hummingbot reaches the account through the connector's registered pairs, so the API lends +the connector a listed pair for the call and takes it back; the caller is not asked for one. + +These drive the real connectors with only their HTTP layer replaced by a fake account. """ import asyncio from decimal import Decimal @@ -16,13 +19,15 @@ import pytest from bidict import bidict from fastapi import HTTPException +from hummingbot.connector.derivative.binance_perpetual.binance_perpetual_derivative import BinancePerpetualDerivative from hummingbot.connector.derivative.bitget_perpetual import bitget_perpetual_constants as CONSTANTS from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import BitgetPerpetualDerivative +from hummingbot.connector.derivative.bybit_perpetual.bybit_perpetual_derivative import BybitPerpetualDerivative from hummingbot.connector.trading_rule import TradingRule from hummingbot.core.data_type.common import OrderType, PositionAction, PositionMode, TradeType from services.accounts_service import AccountsService -from services.perpetual_trading_service import PerpetualTradingService +from services.perpetual_trading_service import PerpetualTradingService, sync_position_mode from services.trading_service import AccountTradingInterface @@ -31,10 +36,12 @@ def __init__(self, pos_mode: str, accept_switch: bool = True): self.pos_mode = pos_mode self.accept_switch = accept_switch self.switch_requests = [] + self.reads = [] self.orders = [] async def get(self, path_url, params=None, is_auth_required=False, **kwargs): assert path_url == CONSTANTS.ACCOUNT_INFO_ENDPOINT + self.reads.append(params["productType"]) return {"code": CONSTANTS.RET_CODE_OK, "data": {"posMode": self.pos_mode}} async def post(self, path_url, data=None, is_auth_required=False, **kwargs): @@ -49,25 +56,54 @@ async def post(self, path_url, data=None, is_auth_required=False, **kwargs): return {"code": CONSTANTS.RET_CODE_OK, "data": {}} +def _rule(trading_pair: str) -> TradingRule: + return TradingRule( + trading_pair, min_order_size=Decimal("0.001"), min_price_increment=Decimal("0.1"), + min_base_amount_increment=Decimal("0.001"), min_notional_size=Decimal("5")) + + def _bitget(account: FakeBitgetAccount) -> BitgetPerpetualDerivative: + """A connector as the API creates it: symbols and rules loaded, no pair registered.""" connector = BitgetPerpetualDerivative( bitget_perpetual_api_key="k", bitget_perpetual_secret_key="s", bitget_perpetual_passphrase="p", trading_pairs=[], ) - connector._set_trading_pair_symbol_map(bidict({"BTCUSDT": "BTC-USDT"})) + connector._set_trading_pair_symbol_map(bidict({"BTCUSDT": "BTC-USDT", "BTCPERP": "BTC-USDC"})) + connector._trading_rules.update({"BTC-USDC": _rule("BTC-USDC"), "BTC-USDT": _rule("BTC-USDT")}) connector._api_get = account.get connector._api_post = account.post return connector +def _service(connector) -> PerpetualTradingService: + async def provider(account_name, connector_name): + return connector + return PerpetualTradingService(provider) + + +def _no_pair_left_behind(connector) -> bool: + # The order book tracker subscribes to this same list + return connector.trading_pairs == [] and connector.order_book_tracker._trading_pairs == [] + + +async def test_hedge_account_is_adopted_with_no_pair_registered(): + account = FakeBitgetAccount("hedge_mode") + connector = _bitget(account) + + await sync_position_mode(connector) + + assert connector.position_mode == PositionMode.HEDGE + assert account.reads == ["USDT-FUTURES"] + assert account.switch_requests == [] + assert _no_pair_left_behind(connector) + + async def test_direct_order_on_a_fresh_connector_goes_out_in_the_accounts_hedge_mode(): account = FakeBitgetAccount("hedge_mode") connector = _bitget(account) - connector._trading_rules["BTC-USDT"] = TradingRule( - "BTC-USDT", min_order_size=Decimal("0.001"), min_price_increment=Decimal("0.1"), - min_base_amount_increment=Decimal("0.001"), min_notional_size=Decimal("5")) + await sync_position_mode(connector) # as connector creation does service = AccountsService.__new__(AccountsService) service.list_accounts = lambda: ["master"] service._connector_service = MagicMock() @@ -81,17 +117,16 @@ async def test_direct_order_on_a_fresh_connector_goes_out_in_the_accounts_hedge_ await asyncio.sleep(0.01) assert account.orders[0]["tradeSide"] == "open" + assert _no_pair_left_behind(connector) -def _service(connector) -> PerpetualTradingService: - async def provider(account_name, connector_name): - return connector - return PerpetualTradingService(provider) - - -async def test_first_pair_adopts_the_bitget_accounts_hedge_mode(): +async def test_adding_a_market_reads_the_mode_again_after_a_read_that_failed(): account = FakeBitgetAccount("hedge_mode") connector = _bitget(account) + connector._api_get = AsyncMock(side_effect=IOError("timeout")) + await sync_position_mode(connector) + assert connector.position_mode == PositionMode.ONEWAY + connector._api_get = account.get async def initialize_order_book(connector_name, trading_pair, **kwargs): # The tracker's pair list is the connector's own, so the order book registers the pair @@ -107,42 +142,56 @@ async def initialize_order_book(connector_name, trading_pair, **kwargs): await interface.add_market("bitget_perpetual", "BTC-USDT") assert connector.position_mode == PositionMode.HEDGE - assert account.switch_requests == [] + assert connector.trading_pairs == ["BTC-USDT"] -async def test_switch_without_a_registered_pair_is_refused(): +async def test_get_reports_the_exchanges_mode_not_a_cached_one(): account = FakeBitgetAccount("one_way_mode") connector = _bitget(account) + await sync_position_mode(connector) + account.pos_mode = "hedge_mode" # switched in the exchange UI - with pytest.raises(HTTPException) as e: - await _service(connector).set_position_mode("master", "bitget_perpetual", PositionMode.HEDGE) + result = await _service(connector).get_position_mode("master", "bitget_perpetual") - assert e.value.status_code == 400 - assert connector.position_mode == PositionMode.ONEWAY - assert account.switch_requests == [] + assert result["position_mode"] == "HEDGE" + assert _no_pair_left_behind(connector) -async def test_switch_with_an_unlisted_pair_is_refused_without_registering_it(): - connector = _bitget(FakeBitgetAccount("one_way_mode")) +async def test_switch_reaches_the_exchange_with_no_pair_registered_or_given(): + account = FakeBitgetAccount("one_way_mode") + connector = _bitget(account) - with pytest.raises(HTTPException) as e: - await _service(connector).set_position_mode( - "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="NOPE-USDT") + result = await _service(connector).set_position_mode("master", "bitget_perpetual", PositionMode.HEDGE) - assert e.value.status_code == 400 - assert connector.trading_pairs == [] + assert result["status"] == "success" + assert account.pos_mode == "hedge_mode" + assert connector.position_mode == PositionMode.HEDGE + assert _no_pair_left_behind(connector) -async def test_switch_reaches_the_exchange_through_the_given_pair(): +async def test_a_given_pair_selects_the_product_type_and_is_not_registered(): account = FakeBitgetAccount("one_way_mode") connector = _bitget(account) - result = await _service(connector).set_position_mode( - "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDT") + await _service(connector).set_position_mode( + "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDC") - assert result["status"] == "success" - assert account.pos_mode == "hedge_mode" - assert connector.position_mode == PositionMode.HEDGE + assert account.reads == ["USDC-FUTURES"] + assert account.switch_requests == ["hedge_mode"] + assert _no_pair_left_behind(connector) + + +async def test_switch_with_an_unlisted_pair_is_refused(): + account = FakeBitgetAccount("one_way_mode") + connector = _bitget(account) + + with pytest.raises(HTTPException) as e: + await _service(connector).set_position_mode( + "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="NOPE-USDT") + + assert e.value.status_code == 400 + assert account.switch_requests == [] + assert _no_pair_left_behind(connector) async def test_switch_the_exchange_rejects_is_reported_as_rejected(): @@ -150,9 +199,67 @@ async def test_switch_the_exchange_rejects_is_reported_as_rejected(): connector = _bitget(account) with pytest.raises(HTTPException) as e: - await _service(connector).set_position_mode( - "master", "bitget_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDT") + await _service(connector).set_position_mode("master", "bitget_perpetual", PositionMode.HEDGE) assert e.value.status_code == 502 assert account.switch_requests == ["hedge_mode"] assert connector.position_mode == PositionMode.ONEWAY + assert _no_pair_left_behind(connector) + + +async def test_account_wide_exchange_switches_with_no_pair(): + connector = BinancePerpetualDerivative( + binance_perpetual_api_key="k", binance_perpetual_api_secret="s", trading_pairs=[]) + connector._trading_rules["BTC-USDT"] = _rule("BTC-USDT") + exchange = {"dualSidePosition": False} + + async def get(path_url, **kwargs): + return dict(exchange) + + async def post(path_url, data=None, **kwargs): + exchange.update(data) + return {"msg": "success", "code": 200} + connector._api_get, connector._api_post = get, post + + await _service(connector).set_position_mode("master", "binance_perpetual", PositionMode.HEDGE) + + assert exchange["dualSidePosition"] is True + assert connector.position_mode == PositionMode.HEDGE + assert _no_pair_left_behind(connector) + + +def _bybit(switched: list) -> BybitPerpetualDerivative: + connector = BybitPerpetualDerivative( + bybit_perpetual_api_key="k", bybit_perpetual_secret_key="s", trading_pairs=[]) + connector._set_trading_pair_symbol_map(bidict({"BTCUSDT": "BTC-USDT"})) + connector._trading_rules["BTC-USDT"] = _rule("BTC-USDT") + + async def post(path_url, data=None, **kwargs): + switched.append(data["symbol"]) + return {"retCode": 0, "retMsg": "OK"} + connector._api_post = post + return connector + + +async def test_per_symbol_exchange_is_not_switched_through_a_symbol_nobody_named(): + switched = [] + connector = _bybit(switched) + + with pytest.raises(HTTPException) as e: + await _service(connector).set_position_mode("master", "bybit_perpetual", PositionMode.HEDGE) + + assert e.value.status_code == 400 + assert switched == [] + assert connector.position_mode == PositionMode.ONEWAY + + +async def test_per_symbol_exchange_switches_the_symbol_it_is_given(): + switched = [] + connector = _bybit(switched) + + await _service(connector).set_position_mode( + "master", "bybit_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDT") + + assert switched == ["BTCUSDT"] + assert connector.position_mode == PositionMode.HEDGE + assert _no_pair_left_behind(connector)