Skip to content

Commit 903e52d

Browse files
fengtalityclaude
andcommitted
(fix) adversarial-review fixes: validate after registration, await the exchange call
- Register the pair BEFORE validating supported_position_modes: bybit's implementation depends on the registered pair list and returns both modes vacuously when it is empty, so pre-registration validation fake-accepted HEDGE for an inverse pair and then silently did nothing — recreating the exact lie this PR removes. Unknown pairs are rejected 400 and never registered (validation from the base of the stack). - Await _execute_set_position_mode instead of the fire-and-forget connector.set_position_mode (spawns a background task, cannot report rejection, e.g. Binance -4068 with open positions), and verify the local trait afterwards — 502 when the exchange did not accept the switch. - Retry position-mode adoption once on first pair registration: bitget's _fetch_account_position_mode returns None with an empty pair list, so adoption at connector init was dead code for it. - Guard adoption with supported_position_modes (future-proofing) and correct the narrative in comments/docstrings: with empty pairs the old init call left base-class connectors at local ONEWAY but vacuously flipped bybit/bitget local state to HEDGE — in every case without any exchange call. flake8 skipped: pre-existing violations in touched files on main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ed2eec3 commit 903e52d

2 files changed

Lines changed: 148 additions & 22 deletions

File tree

services/perpetual_trading_service.py

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -104,42 +104,65 @@ async def set_position_mode(self, account_name: str, connector_name: str,
104104
"""
105105
connector = await self._get_perpetual_connector(account_name, connector_name)
106106

107-
# Check if the requested position mode is supported
108-
supported_modes = connector.supported_position_modes()
109-
if position_mode not in supported_modes:
110-
supported_values = [mode.value for mode in supported_modes]
111-
raise HTTPException(
112-
status_code=400,
113-
detail=f"Position mode '{position_mode.value}' not supported. Supported modes: {supported_values}"
114-
)
115-
116-
# Position-mode implementations apply the switch through the connector's
117-
# trading pairs (the py-base default and e.g. bybit both log a warning and
118-
# return when the list is empty), and API connectors are created with
119-
# trading_pairs=[] — so without a registered pair the exchange is never
120-
# called while this endpoint would report success. Register the provided
121-
# pair, then refuse to proceed with an empty list rather than lie.
107+
# Register the provided pair FIRST. Position-mode implementations apply
108+
# the switch through the connector's trading pairs (with an empty list the
109+
# base implementation warns and returns; bybit/bitget overrides vacuously
110+
# "succeed" without any exchange call), and supported_position_modes() on
111+
# e.g. bybit depends on the registered pair list — validating against it
112+
# before registration would vacuously pass modes the actual pair cannot
113+
# support. Unknown pairs are rejected with 400 and never registered.
122114
if trading_pair:
123115
from services.unified_connector_service import UnifiedConnectorService
124-
await UnifiedConnectorService.sync_pair_derived_state(connector, trading_pair)
116+
try:
117+
await UnifiedConnectorService.sync_pair_derived_state(
118+
connector, trading_pair, refresh_rules=False)
119+
except ValueError as e:
120+
raise HTTPException(status_code=400, detail=str(e))
125121
if not getattr(connector, "trading_pairs", None):
126122
raise HTTPException(
127123
status_code=400,
128124
detail=f"No trading pairs registered on {connector_name}; pass trading_pair "
129125
f"so the position mode switch can be applied on the exchange"
130126
)
131127

128+
# Validate AFTER registration, against the real pair set
129+
supported_modes = connector.supported_position_modes()
130+
if position_mode not in supported_modes:
131+
supported_values = [mode.value for mode in supported_modes]
132+
raise HTTPException(
133+
status_code=400,
134+
detail=f"Position mode '{position_mode.value}' not supported. Supported modes: {supported_values}"
135+
)
136+
132137
try:
133-
# Try to call the method - it might be sync or async
134-
result = connector.set_position_mode(position_mode)
135-
# If it's a coroutine, await it
136-
if asyncio.iscoroutine(result):
137-
await result
138+
# Await the actual exchange call. connector.set_position_mode() is
139+
# fire-and-forget (it spawns _execute_set_position_mode in the
140+
# background and returns immediately), which would report success
141+
# before the exchange ever responds — and cannot report a rejection
142+
# (e.g. Binance -4068 with open positions). _execute_set_position_mode
143+
# updates the local trait only on confirmed success, so the local mode
144+
# is the truth test.
145+
execute = getattr(connector, "_execute_set_position_mode", None)
146+
if execute is not None:
147+
await execute(position_mode)
148+
else:
149+
result = connector.set_position_mode(position_mode)
150+
if asyncio.iscoroutine(result):
151+
await result
152+
153+
if getattr(connector, "position_mode", position_mode) != position_mode:
154+
raise HTTPException(
155+
status_code=502,
156+
detail=f"Exchange did not accept position mode {position_mode.value} on "
157+
f"{connector_name} — check for open positions/orders and connector logs"
158+
)
138159

139160
message = f"Position mode set to {position_mode.value} on {connector_name}"
140161
logger.info(f"Set position mode to {position_mode.value} on {connector_name} (Account: {account_name})")
141162
return {"status": "success", "message": message}
142163

164+
except HTTPException:
165+
raise
143166
except Exception as e:
144167
logger.error(f"Failed to set position mode to {position_mode.value}: {e}")
145168
raise HTTPException(status_code=500, detail=f"Failed to set position mode: {str(e)}")

test/test_sync_pair_derived_state.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ def __init__(self):
249249
def trading_pairs(self):
250250
return self._trading_pairs or []
251251

252+
@property
253+
def position_mode(self):
254+
return self.mode_set
255+
252256
def supported_position_modes(self):
253257
return [PositionMode.HEDGE, PositionMode.ONEWAY]
254258

@@ -270,14 +274,113 @@ async def provider(account_name, connector_name):
270274
assert e.status_code == 400
271275
assert connector.mode_set is None
272276

273-
# Pair provided -> registered (with rules/throttler synced), then switched
277+
# Pair provided -> registered (with throttler synced), then switched
274278
result = _run(service.set_position_mode(
275279
"master", "bybit_perpetual", PositionMode.HEDGE, trading_pair="BTC-USDT"))
276280
assert connector._trading_pairs == ["BTC-USDT"]
277281
assert connector.mode_set == PositionMode.HEDGE
278282
assert result["status"] == "success"
279283

280284

285+
def test_set_position_mode_validates_against_post_registration_pair_set():
286+
"""supported_position_modes() on bybit depends on the registered pair list
287+
and returns BOTH modes vacuously when it is empty. Validating before
288+
registration would fake-accept HEDGE for an inverse pair; the mode check
289+
must run against the post-registration pair set."""
290+
from fastapi import HTTPException
291+
from hummingbot.core.data_type.common import PositionMode
292+
293+
from services.perpetual_trading_service import PerpetualTradingService
294+
295+
class BybitLikePerp(FakePairLimitsConnector):
296+
INVERSE = {"BTC-USD"}
297+
298+
def __init__(self):
299+
super().__init__(trading_pairs=[])
300+
self.mode_set = None
301+
302+
@property
303+
def trading_pairs(self):
304+
return self._trading_pairs or []
305+
306+
@property
307+
def position_mode(self):
308+
return self.mode_set
309+
310+
def supported_position_modes(self):
311+
# Faithful to bybit: vacuous both-modes on empty; inverse pairs
312+
# restrict to ONEWAY.
313+
if not self.trading_pairs:
314+
return [PositionMode.ONEWAY, PositionMode.HEDGE]
315+
if any(p in self.INVERSE for p in self.trading_pairs):
316+
return [PositionMode.ONEWAY]
317+
return [PositionMode.ONEWAY, PositionMode.HEDGE]
318+
319+
def set_position_mode(self, mode):
320+
self.mode_set = mode
321+
322+
connector = BybitLikePerp()
323+
324+
async def provider(account_name, connector_name):
325+
return connector
326+
327+
service = PerpetualTradingService(provider)
328+
329+
# HEDGE + inverse pair: pre-fix this vacuously passed validation and then
330+
# silently did nothing; now the mode check runs after registration -> 400.
331+
try:
332+
_run(service.set_position_mode(
333+
"master", "bybit_perpetual", PositionMode.HEDGE, trading_pair="BTC-USD"))
334+
raise AssertionError("expected HTTPException for unsupported mode")
335+
except HTTPException as e:
336+
assert e.status_code == 400
337+
assert "not supported" in e.detail
338+
assert connector.mode_set is None # exchange never touched
339+
340+
341+
def test_set_position_mode_surfaces_exchange_rejection():
342+
"""The endpoint must await the real exchange call and fail loudly when the
343+
exchange rejects the switch (e.g. open positions) — previously the call was
344+
fire-and-forget and always reported success."""
345+
from fastapi import HTTPException
346+
from hummingbot.core.data_type.common import PositionMode
347+
348+
from services.perpetual_trading_service import PerpetualTradingService
349+
350+
class RejectingPerp(FakePairLimitsConnector):
351+
def __init__(self):
352+
super().__init__(trading_pairs=["BTC-USDT"])
353+
self.execute_calls = 0
354+
355+
@property
356+
def trading_pairs(self):
357+
return self._trading_pairs or []
358+
359+
@property
360+
def position_mode(self):
361+
return PositionMode.ONEWAY # local trait never updated: rejection
362+
363+
def supported_position_modes(self):
364+
return [PositionMode.ONEWAY, PositionMode.HEDGE]
365+
366+
async def _execute_set_position_mode(self, mode):
367+
self.execute_calls += 1 # exchange rejected; trait stays ONEWAY
368+
369+
connector = RejectingPerp()
370+
371+
async def provider(account_name, connector_name):
372+
return connector
373+
374+
service = PerpetualTradingService(provider)
375+
376+
try:
377+
_run(service.set_position_mode("master", "bybit_perpetual", PositionMode.HEDGE))
378+
raise AssertionError("expected HTTPException for rejected switch")
379+
except HTTPException as e:
380+
assert e.status_code == 502
381+
assert connector.execute_calls == 1 # the exchange call was actually awaited
382+
383+
281384
def test_adopt_exchange_position_mode():
282385
"""At init the exchange is the source of truth for position mode: its current
283386
mode is mirrored locally (the old HEDGE-forcing call never reached any

0 commit comments

Comments
 (0)