Skip to content

(fix) perpetual position mode follows the exchange, without a trading pair in the request - #246

Merged
cardosofede merged 4 commits into
mainfrom
fix/210-position-mode-no-pair
Oct 15, 2026
Merged

cardosofede merged 4 commits into
mainfrom
fix/210-position-mode-no-pair

Conversation

@cardosofede

Copy link
Copy Markdown
Contributor

Fixes #210. Alternative to #213: built on its first three commits, with one commit on top that removes the need for trading_pair in the position-mode request.

Why

#213 made the switch honest, but required callers to pass a trading_pair when the connector had none registered. The exchanges don't need one:

Exchange Read the mode Switch the mode
Binance, OKX, Gate.io no pair no pair
Bitget per product type (USDT / USDC / coin) product type only
Bybit connector cannot read it per symbol

The pair requirement comes from hummingbot, which 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 one. The API creates connectors with no pairs, so the API now lends the connector a listed pair for the duration of the call and takes it back.

What changes versus #213

  • Mode is read at connector creation. Bitget is correct before any market exists, so the first-pair logic and its ordering dependency on order book init are gone. GET position-mode no longer shows a temporary ONEWAY on a hedge account after a restart.
  • POST position-mode needs no trading_pair. The field stays, optional, as a scope: product type on Bitget (USDT by default), symbol on Bybit. It is never registered on the connector.
  • Bybit is the one exception: its mode is per symbol, so a switch with no pair registered and none given is still a 400.
  • GET position-mode re-reads the exchange instead of returning the cached value. Adding a market re-reads too, which recovers from a read that failed at creation.
  • Direct orders and the switch no longer register a pair. The connector's pair list is the same object the order book tracker subscribes to, so a pair left there without an order book is avoided.

Kept from #213: no forced HEDGE at init, the switch is awaited and returns 502 when the exchange keeps the old mode, 400 for an unlisted pair.

Not included: the logging.basicConfig(force=True) commit from #213. It changes log levels globally and is better reviewed on its own.

Known gaps

Tests

test/test_position_mode_sync.py drives the real Bitget, Binance and Bybit connectors with only the HTTP layer faked (11 tests). Full suite: 939 passed.

The commit was made with the flake8 pre-commit hook skipped: it fails on 56 existing lines in services/accounts_service.py, none added here.

QA

@rapcmia could you rerun your #213 checklist on bitget_perpetual with these differences:

  1. Hedge account → restart → GET position-mode before touching any market. Expect HEDGE straight away.
  2. Restart → POST position-mode {"position_mode": "HEDGE"} with no trading_pair and no market. Expect 200 and the mode changed on Bitget.
  3. Same call with an open position, switching back. Expect 502, mode unchanged.
  4. "trading_pair": "NOPE-USDT". Expect 400.
  5. Hedge account → restart → position executor on SOL-USDT. Expect it to open and close cleanly.
  6. Hedge account → restart → POST /trading/orders with no executor first. Expect the order accepted in hedge mode.
  7. Change the mode in the Bitget UI while the API is running → GET position-mode. Expect the new mode.

🤖 Generated with Claude Code

fengtality and others added 4 commits September 29, 2026 20:21
…s 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…ad 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 <noreply@anthropic.com>
@cardosofede
cardosofede requested a review from rapcmia October 1, 2026 08:59
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

[High risk] Changes how perpetual position mode is synced with exchanges.

The PR is not safe to merge until scoped switches, pair-list ownership, and setup-time read failures are addressed.

Findings

  1. P1 Bitget selects the wrong product ▶
  2. P1 Bybit switches extra symbols ▶
  3. P1 Borrowing can remove market registration ▶
  4. P1 Mode read can abort setup ▶

Summary

This PR reads perpetual position mode from the exchange, awaits switches, and temporarily lends connectors a trading pair when a mode operation needs one.

  • The pair-selection context does not consistently honor Bitget’s requested product type or Bybit’s requested symbol once markets are registered.
  • Borrowing can race market registration, and an unsuccessful mode read can abort connector or market setup.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Position-mode request or refresh] --> B[Borrow pair in shared connector list]
  B --> C[Read or switch exchange mode]
  C --> D[Remove borrowed pair]
  M[Concurrent add_market] --> N[Initialize order book]
  N --> O[Register pair unless already in shared list]
  B --> O
  O --> D
Loading

Reviews (1) · Last reviewed commit: "The API supplies the exchange context fo..."

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

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.

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

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.

Comment on lines +48 to +55
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)

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.

# 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.

@rapcmia rapcmia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • Restarted Docker HAPI with Bitget in HEDGE mode. Checked the mode before creating any market, order, or executor; HAPI showed HEDGE.
  • Switched Bitget from HEDGE to ONEWAY and back without naming a trading pair. Both requests succeeded, and no positions or active orders remained.
  • Changed Bitget to ONEWAY in the exchange UI while HAPI kept running. HAPI showed the new mode without a restart.
  • Sent an invalid trading pair and got a 400 error without changing Bitget's mode. A switch with a SOL-USDT position open returned 502 and kept HEDGE; the position was then closed.
  • After a restart, opened a SOL-USDT position through an executor in HEDGE mode and stopped it cleanly. After another restart, a direct order filled in HEDGE mode. Both tests ended with no positions or active orders.
  • Asked Bybit to switch modes without a trading pair and got a 400 error. Named XRP-USDT, switched to ONEWAY, then restored HEDGE; both named requests were accepted, with no positions or active orders left.

@rapcmia rapcmia moved this to Hummingbot-API in Pull Request Board Oct 12, 2026
@cardosofede
cardosofede merged commit 7ae108f into main Oct 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Hummingbot-API

Development

Successfully merging this pull request may close these issues.

bybit/bitget perpetual accounts report HEDGE regardless of the exchange; position-mode switch reports success it can't deliver

3 participants