Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions models/accounts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any, Dict, Optional

from pydantic import BaseModel, Field
from typing import Dict, Any


class LeverageRequest(BaseModel):
Expand All @@ -11,8 +12,13 @@ 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. Position-mode "
"implementations apply the switch through the connector's trading "
"pairs, so at least one registered pair is required.")


class CredentialRequest(BaseModel):
"""Request model for adding connector credentials"""
credentials: Dict[str, Any] = Field(description="Connector credentials dictionary")
credentials: Dict[str, Any] = Field(description="Connector credentials dictionary")
3 changes: 2 additions & 1 deletion routers/trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ async def set_position_mode(
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(
Expand Down
6 changes: 4 additions & 2 deletions services/accounts_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1063,12 +1063,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]:
"""
Expand Down
59 changes: 51 additions & 8 deletions services/perpetual_trading_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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
Expand Down Expand Up @@ -83,14 +83,18 @@ 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.

Args:
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 before switching. Position-mode
implementations apply the switch through the connector's trading
pairs, so at least one registered pair is required.

Returns:
Dictionary with success status and message
Expand All @@ -100,7 +104,28 @@ async def set_position_mode(self, account_name: str, connector_name: str,
"""
connector = await self._get_perpetual_connector(account_name, connector_name)

# Check if the requested position mode is supported
# Register the provided pair FIRST. Position-mode implementations apply
# the switch through the connector's trading pairs (with an empty list the
# base implementation warns and returns; bybit/bitget overrides vacuously
# "succeed" without any exchange call), and supported_position_modes() on
# e.g. bybit depends on the registered pair list — validating against it
# before registration would vacuously pass modes the actual pair cannot
# support. Unknown pairs are rejected with 400 and never registered.
if trading_pair:
from services.unified_connector_service import UnifiedConnectorService
try:
await UnifiedConnectorService.sync_pair_derived_state(
connector, trading_pair, refresh_rules=False)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not getattr(connector, "trading_pairs", None):
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"
)

# Validate AFTER registration, against the real pair set
supported_modes = connector.supported_position_modes()
if position_mode not in supported_modes:
supported_values = [mode.value for mode in supported_modes]
Expand All @@ -110,16 +135,34 @@ 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
# Await the actual exchange call. connector.set_position_mode() is
# fire-and-forget (it spawns _execute_set_position_mode in the
# background and returns immediately), which would report success
# before the exchange ever responds — and cannot report a rejection
# (e.g. Binance -4068 with open positions). _execute_set_position_mode
# updates the local trait only on confirmed success, so the local mode
# is the truth test.
execute = getattr(connector, "_execute_set_position_mode", None)
if execute is not None:
await execute(position_mode)
else:
result = connector.set_position_mode(position_mode)
if asyncio.iscoroutine(result):
await result

if getattr(connector, "position_mode", position_mode) != position_mode:
raise HTTPException(
status_code=502,
detail=f"Exchange did not accept position mode {position_mode.value} on "
f"{connector_name} — check for open positions/orders and 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}

except HTTPException:
raise
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)}")
Expand Down
64 changes: 61 additions & 3 deletions services/unified_connector_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -412,6 +412,56 @@ def _is_tracker_running(self, tracker) -> bool:
return True
return False

@staticmethod
async def _adopt_exchange_position_mode(connector: ConnectorBase) -> None:
"""Adopt the exchange account's actual position mode as the local truth.

The previous init behavior called ``connector.set_position_mode(HEDGE)``
with ``trading_pairs=[]``, and what that did depended on the connector:
base-class implementations warned and returned (local trait stayed at its
ONEWAY default), while bybit's and bitget's per-pair overrides iterated
the empty list, vacuously "succeeded", and flipped the LOCAL trait to
HEDGE — in every case without a single exchange call. Local state was
guesswork either way; the exchange (account default or last manual
setting) is the source of truth.

So instead of mutating the exchange, read its current mode and mirror it
locally so ``connector.position_mode``, executors, and the position-mode
endpoints report reality. Exchanges that cannot be queried
(``_fetch_account_position_mode`` returns None — e.g. bybit has no
override) keep the local default and operators set the mode explicitly
through the endpoint. Some fetch implementations need a registered pair
(bitget returns None with an empty pair list), so adoption is retried
once on the first pair registration — see sync_pair_derived_state. #210.
"""
fetch = getattr(connector, "_fetch_account_position_mode", None)
perpetual_trading = getattr(connector, "_perpetual_trading", None)
if fetch is None or perpetual_trading is None:
return
try:
exchange_mode = await fetch()
except Exception as e:
logger.warning(
f"Could not fetch position mode from exchange for "
f"{type(connector).__name__}: {e}"
)
return
if exchange_mode is None or exchange_mode == connector.position_mode:
return
supported = getattr(connector, "supported_position_modes", None)
if supported is not None and exchange_mode not in supported():
logger.warning(
f"Exchange reports position mode {exchange_mode} but "
f"{type(connector).__name__} does not support it locally — keeping "
f"{connector.position_mode}"
)
return
perpetual_trading.set_position_mode(exchange_mode)
logger.info(
f"Adopted exchange position mode {exchange_mode} for "
f"{type(connector).__name__} (local default was stale)"
)

@staticmethod
async def sync_pair_derived_state(
connector: ConnectorBase,
Expand Down Expand Up @@ -473,6 +523,15 @@ async def sync_pair_derived_state(
elif trading_pair not in pairs:
pairs.append(trading_pair)

# 1b. One-shot position-mode adoption retry (#210): some fetch
# implementations need a registered pair (bitget returns None with an
# empty pair list), so adoption at connector init cannot work for them.
# Retry exactly once now that a pair exists.
perpetual_trading = getattr(connector, "_perpetual_trading", None)
if perpetual_trading is not None and not getattr(connector, "_position_mode_adoption_done", False):
await UnifiedConnectorService._adopt_exchange_position_mode(connector)
connector._position_mode_adoption_done = True

# 2. Throttler rate limits (#207). add_rate_limits() skips known limit_ids.
# Synced on data connectors too — their REST fetches go through the same
# throttler and can hit pair-templated limit_ids. rate_limits_rules is
Expand Down Expand Up @@ -731,8 +790,7 @@ 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)
await self._adopt_exchange_position_mode(connector)
await connector._update_positions()

# Load existing orders from database
Expand Down
Loading