Skip to content
Merged
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
9 changes: 7 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,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="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):
"""Request model for adding connector credentials"""
credentials: Dict[str, Any] = Field(description="Connector credentials dictionary")
credentials: Dict[str, Any] = Field(description="Connector credentials dictionary")
7 changes: 5 additions & 2 deletions routers/trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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 or invalid position mode
HTTPException: 400 if not a perpetual connector, invalid position mode or unknown
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(
Expand Down
8 changes: 5 additions & 3 deletions services/accounts_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,7 @@ async def place_trade(self, account_name: str, connector_name: str, trading_pair
)

trading_rule = connector.trading_rules[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()]
Expand Down Expand Up @@ -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]:
"""
Expand Down
129 changes: 107 additions & 22 deletions services/perpetual_trading_service.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,69 @@
import asyncio
import logging
from typing import Any, Awaitable, Callable, Dict, List
from contextlib import asynccontextmanager
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__)


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)


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.
"""
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)
Comment on lines +45 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Bitget selects the wrong product When Bitget already has a registered market, a switch without trading_pair uses that market’s product type rather than the documented USDT default. An explicit pair that is already registered behind another market can likewise leave the other market first. Because Bitget uses the first pair to select its product type, the request can switch a different product type than the caller intended.

try:
yield
finally:
if borrowed and pair in pairs:
pairs.remove(pair)
Comment on lines +48 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Borrowing can remove market registration If a mode read or switch borrows pair P while add_market initializes P, market registration sees P in the shared list and does not add it. The borrower then removes P on exit. The market remains initialized but disappears from the connector and order-book tracker’s pair list, so its order book can stop receiving updates.



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:
"""
Perpetual-specific trading operations: leverage, position mode and position queries.
Expand Down Expand Up @@ -77,46 +132,74 @@ 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: 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: If account/connector not found, not perpetual, or operation fails
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)

# Check if the requested position mode is supported
supported_modes = connector.supported_position_modes()
if position_mode not in supported_modes:
supported_values = [mode.value for mode in supported_modes]
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}"
)

if not trading_pair and not connector.trading_pairs and _switches_pair_by_pair(connector):
raise HTTPException(
status_code=400,
detail=f"Position mode '{position_mode.value}' not supported. Supported modes: {supported_values}"
detail=f"{connector_name} sets the position mode per symbol; pass trading_pair "
f"to say which one to switch"
)

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}
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)
Comment on lines +172 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Bybit switches extra symbols When other markets are already registered, passing a trading_pair adds the requested symbol to the existing list rather than limiting the call to that symbol. Bybit switches each registered symbol, so a request scoped to one symbol can change the position mode of the other registered symbols too.

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"
)

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)}")
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]:
"""
Expand All @@ -138,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",
Expand Down
5 changes: 5 additions & 0 deletions services/trading_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from hummingbot.connector.connector_base import ConnectorBase
from hummingbot.core.data_type.common import OrderType, PositionAction

from services.perpetual_trading_service import sync_position_mode

if TYPE_CHECKING:
from services.market_data_service import MarketDataService
from services.unified_connector_service import UnifiedConnectorService
Expand Down Expand Up @@ -173,6 +175,9 @@ async def add_market(
# 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'):
try:
Expand Down
10 changes: 7 additions & 3 deletions services/unified_connector_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
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

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
Expand Down Expand Up @@ -684,8 +685,11 @@ 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.
await sync_position_mode(connector)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mode read can abort setup A transient exchange error during this new, unguarded mode read aborts connector creation before positions are loaded. The same unguarded read in add_market occurs after the market and pair are registered, so it can abort executor preparation while leaving market setup incomplete. A failed mode refresh should not leave either setup path in that state.

await connector._update_positions()

# Load existing orders from database
Expand Down
Loading
Loading