Skip to content
Closed
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
14 changes: 11 additions & 3 deletions services/accounts_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,13 @@ async def place_trade(self, account_name: str, connector_name: str, trading_pair
# Validate price for limit orders
if order_type in [OrderType.LIMIT, OrderType.LIMIT_MAKER] and price is None:
raise HTTPException(status_code=400, detail="Price is required for LIMIT and LIMIT_MAKER orders")


# Register the pair and sync pair-derived state (throttler limits, per-pair
# trading rules) — without this, per-pair-rules connectors (e.g. XRPL) 503
# forever on the empty-rules check below and 400 on the pair check after it,
# because rules are only built for pairs known at connector init (#207/#208).
await self._connector_service.sync_pair_derived_state(connector, trading_pair)

# Check if trading rules are loaded
if not connector.trading_rules:
raise HTTPException(
Expand Down Expand Up @@ -1061,12 +1067,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
30 changes: 28 additions & 2 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 @@ -66,6 +66,12 @@ async def set_leverage(self, account_name: str, connector_name: str,
if not hasattr(connector, '_execute_set_leverage'):
raise HTTPException(status_code=400, detail=f"Connector '{connector_name}' does not support leverage setting")

# Set-leverage endpoints can be pair-scoped (e.g. bybit's
# v5/position/set-leverage-{PAIR}); register the pair so the throttler
# learns its rate limit before the request — see issue #207.
from services.unified_connector_service import UnifiedConnectorService
await UnifiedConnectorService.sync_pair_derived_state(connector, trading_pair)

try:
await connector._execute_set_leverage(trading_pair, leverage)
message = f"Leverage for {trading_pair} set to {leverage} on {connector_name}"
Expand All @@ -77,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 @@ -103,6 +113,22 @@ async def set_position_mode(self, account_name: str, connector_name: str,
detail=f"Position mode '{position_mode.value}' not supported. Supported modes: {supported_values}"
)

# Position-mode implementations apply the switch through the connector's
# trading pairs (the py-base default and e.g. bybit both log a warning and
# return when the list is empty), and API connectors are created with
# trading_pairs=[] — so without a registered pair the exchange is never
# called while this endpoint would report success. Register the provided
# pair, then refuse to proceed with an empty list rather than lie.
if trading_pair:
from services.unified_connector_service import UnifiedConnectorService
await UnifiedConnectorService.sync_pair_derived_state(connector, trading_pair)
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"
)

try:
# Try to call the method - it might be sync or async
result = connector.set_position_mode(position_mode)
Expand Down
30 changes: 19 additions & 11 deletions services/trading_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ async def add_market(
"""
await self.ensure_connector(connector_name)

connector = self.connectors.get(connector_name)
if not connector:
raise ValueError(f"Connector {connector_name} not available. Check credentials.")

# Sync pair-derived state (throttler limits, per-pair trading rules) BEFORE
# the early return below: a no-op when already synced, and it retries a
# previously failed sync even when the market is already tracked with a
# healthy order book — otherwise a transient failure (e.g. node outage
# during the rules fetch) would stick until restart (#207/#208).
await self._register_trading_pair_with_connector(connector, trading_pair)

if connector_name not in self._markets:
self._markets[connector_name] = set()

Expand All @@ -151,11 +162,6 @@ async def add_market(

self._markets[connector_name].add(trading_pair)

# Get connector from our account's connectors
connector = self.connectors.get(connector_name)
if not connector:
raise ValueError(f"Connector {connector_name} not available. Check credentials.")

# 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(
Expand All @@ -170,9 +176,6 @@ async def add_market(

logger.info(f"Order book initialized successfully for {connector_name}/{trading_pair}")

# Register trading pair with connector
self._register_trading_pair_with_connector(connector, trading_pair)

# Update balances to include tokens from new trading pair
if hasattr(connector, '_update_balances'):
try:
Expand Down Expand Up @@ -217,20 +220,25 @@ async def remove_market(

logger.info(f"Removed market {connector_name}/{trading_pair}")

def _register_trading_pair_with_connector(
async def _register_trading_pair_with_connector(
self,
connector: ConnectorBase,
trading_pair: str
):
"""
Register a trading pair with the connector's internal structures.

Delegates to UnifiedConnectorService.sync_pair_derived_state so that state
built from the pair list at connector init (throttler pair-templated rate
limits, per-pair trading rules) is refreshed too — see issues #207 / #208.

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)
already_registered = trading_pair in (getattr(connector, "_trading_pairs", None) or [])
await self._connector_service.sync_pair_derived_state(connector, trading_pair)
if not already_registered:
logger.debug(f"Registered {trading_pair} with connector {type(connector).__name__}")

# ========================================
Expand Down
78 changes: 75 additions & 3 deletions services/unified_connector_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,8 @@ async def ensure_data_connector_started(
connector = self.get_data_connector(connector_name)

try:
# Add trading pair before starting network
if trading_pair not in connector._trading_pairs:
connector._trading_pairs.append(trading_pair)
# Add trading pair and sync pair-derived state before starting network
await self.sync_pair_derived_state(connector, trading_pair)

# Start network
await connector.start_network()
Expand Down Expand Up @@ -412,6 +411,75 @@ def _is_tracker_running(self, tracker) -> bool:
return True
return False

@staticmethod
async def sync_pair_derived_state(connector: ConnectorBase, trading_pair: str) -> None:
"""Sync connector state that was derived from the trading-pair list at init.

Connectors are created with ``trading_pairs=[]`` and pairs are registered
dynamically, but several pieces of state are built FROM that list during
``__init__`` and never refreshed afterwards:

- ``AsyncThrottler`` pair-templated rate limits (issue #207): connectors like
bybit_perpetual build ``rate_limits_rules`` from ``trading_pairs``, so a
pair added later has no rate limit and pair-scoped requests crash with
``AttributeError: 'NoneType' object has no attribute 'weight'``. The
throttler must be mutated in place — ``WebAssistantsFactory`` captured the
instance at connector init, so reassigning ``connector._throttler`` has no
effect.
- Trading rules on connectors that build them per pair (issue #208): XRPL
fetches rules on-ledger for each pair in ``_trading_pairs``, so a pair
added later has no trading rule and any executor for it dies at startup
with ``KeyError`` before placing an order.

Idempotent — safe to call on every dynamic pair registration, including
pairs that arrive via the data-connector bootstrap path.
"""
# 1. The pair list itself — everything below derives from it.
pairs = getattr(connector, "_trading_pairs", None)
if pairs is None:
connector._trading_pairs = [trading_pair]
elif trading_pair not in pairs:
pairs.append(trading_pair)

# 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.
throttler = getattr(connector, "_throttler", None)
if (
throttler is not None
and hasattr(throttler, "add_rate_limits")
and hasattr(connector, "rate_limits_rules")
):
try:
throttler.add_rate_limits(connector.rate_limits_rules)
except Exception as e:
logger.warning(
f"Could not sync throttler rate limits for {trading_pair} on "
f"{type(connector).__name__}: {e}"
)

# 3. Trading rules (#208). Trading connectors only — data connectors never
# place orders, and for per-pair-rules connectors a refresh is a real
# (possibly on-chain) fetch that would add latency and warnings to every
# order-book bootstrap. Only refresh when the pair has no rule yet.
if not getattr(connector, "is_trading_required", False):
return
try:
rules = getattr(connector, "trading_rules", None)
if rules is not None and trading_pair not in rules and hasattr(connector, "_update_trading_rules"):
await connector._update_trading_rules()
if trading_pair not in connector.trading_rules:
logger.warning(
f"Trading rules still missing for {trading_pair} on "
f"{type(connector).__name__} after refresh — orders for this "
f"pair will fail"
)
except Exception as e:
logger.warning(
f"Could not refresh trading rules for {trading_pair} on "
f"{type(connector).__name__}: {e}"
)

async def _add_trading_pair_to_tracker(
self,
connector: ExchangePyBase,
Expand All @@ -428,6 +496,10 @@ async def _add_trading_pair_to_tracker(
2. Otherwise, register the pair and start the tracker
"""
try:
# Sync pair-derived state (throttler limits, trading rules) regardless of
# which path below registers the order book — see sync_pair_derived_state.
await self.sync_pair_derived_state(connector, trading_pair)

# Safety check - gateway/AMM connectors don't have order book trackers
if not hasattr(connector, 'order_book_tracker') or connector.order_book_tracker is None:
logger.debug(f"Connector {type(connector).__name__} doesn't have order book tracker")
Expand Down
Loading