Skip to content

(fix) perpetual position mode follows the exchange; switch endpoint reports only what the exchange did - #213

Open
fengtality wants to merge 4 commits into
mainfrom
fix/210-position-mode-truth
Open

fengtality wants to merge 4 commits into
mainfrom
fix/210-position-mode-truth

Conversation

@fengtality

@fengtality fengtality commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #210. Rebuilt on main (was stacked on the closed #212; no longer depends on #211).

Init: read the account's mode instead of forcing HEDGE

_create_and_initialize_trading_connector now calls hummingbot's own _initialize_position_mode() (read the exchange, adopt it) instead of set_position_mode(HEDGE) with no pairs registered. On binance/okx/gate_io the result is the same as before. On bybit and bitget local no longer flips to HEDGE regardless of the account.

bitget can only read the mode once a pair is registered, so a perpetual connector reads it again when it gets its first pair. Pairs are registered through one helper, register_trading_pair, from all three places that register them: add_market (executors), place_trade (POST /trading/orders), and the position-mode endpoint. So the mode is read before the first order either way.

place_trade didn't register pairs before. On bitget that also meant _update_positions had no product type to query for a connector used only for direct orders.

Switch endpoint

  • PositionModeRequest takes an optional trading_pair, registered first (unlisted pair → 400, not registered). It's registered before supported_position_modes() is checked, because bybit's answer depends on the registered pairs.
  • No pair registered → 400 saying to pass trading_pair, instead of a false success.
  • Awaits _execute_set_position_mode instead of the fire-and-forget set_position_mode, then checks the local mode: 502 when the exchange kept the old mode (open positions, -4068…).

Behavior change

  • bybit accounts in HEDGE used to work by accident (local was always HEDGE). bybit can't report its mode, so local now starts ONEWAY and these accounts need one explicit POST position-mode {"position_mode": "HEDGE", "trading_pair": ...}.
  • Direct perpetual orders now register their pair on the connector, the same as executors do. On bitget this narrows the balance refresh to that pair's product type, as it already did after any executor ran.
  • Clients calling the endpoint on a fresh connector with no pair now get a 400 instead of a false success. hummingbot-api-client needs the optional trading_pair field.

Tests

test/test_position_mode_sync.py drives the real BitgetPerpetualDerivative with only _api_get/_api_post replaced by a fake account: first pair adopts the account's HEDGE; no-pair switch → 400 with no exchange call; unlisted pair → 400, not registered; switch through a pair reaches the exchange; exchange rejection → 502; a direct order on a fresh connector for a hedge account goes out with tradeSide. The first two and the direct-order test fail on main for the reasons above. Full suite: 933 passed, 1 skipped.

QA

Please test with bitget_perpetual — see the checklist comment below.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Changes how perpetual position mode is read and switched at startup.

The PR appears safe to merge, with no blocking failure remaining in the follow-up review scope.

Summary

The PR synchronizes perpetual connectors with the exchange’s position mode instead of forcing HEDGE and makes position-mode changes report whether the exchange actually accepted them.

  • Registers the first perpetual trading pair before mode-sensitive direct orders and executor activity.
  • Adds an optional trading_pair to position-mode requests and rejects switches without a registered pair.
  • Awaits exchange mode changes and returns an error when the local mode does not reflect the requested result.
  • Adds Bitget-backed coverage for initialization, switching, rejection, and direct hedge-mode orders.
Diagram
sequenceDiagram
    participant Client
    participant API as Trading API
    participant Service as Perpetual Trading Service
    participant Connector
    participant Exchange

    Client->>API: Set mode(mode, optional pair)
    API->>Service: set_position_mode(...)
    alt Pair supplied and not registered
        Service->>Connector: Validate and register pair
        Connector->>Exchange: Read account mode
        Exchange-->>Connector: Current mode
    end
    Service->>Connector: Execute mode change
    Connector->>Exchange: Set position mode
    Exchange-->>Connector: Accepted or rejected
    Service->>Connector: Check local mode
    alt Requested mode observed
        Service-->>API: Success
        API-->>Client: 200
    else No registered pair
        Service-->>API: Client error
        API-->>Client: 400
    else Exchange kept old mode
        Service-->>API: Upstream failure
        API-->>Client: 502
    end
Loading

Reviews (5) · Last reviewed commit: "The API logs at INFO, as configured, ins..."

@fengtality
fengtality force-pushed the fix/208-per-pair-trading-rules branch from 2db2cee to 698e01e Compare August 7, 2026 06:50
@fengtality
fengtality force-pushed the fix/210-position-mode-truth branch from 524626f to 903e52d Compare August 7, 2026 06:54
@fengtality

Copy link
Copy Markdown
Contributor Author

Adversarial review — the harshest of the three; the PR's original narrative was factually wrong and the endpoint had three residual lies. All fixed in the latest commit; the PR body has been rewritten accordingly.

  1. False claim (now corrected): "the old code left local mode ONEWAY" was only true for base-class connectors. bybit and bitget override _execute_set_position_mode; with empty pairs their loop is vacuous, all_success stays true, and the old code flipped LOCAL state to HEDGE without any exchange call — so this PR changes live behavior on exactly those two exchanges. The body now states this and why the direction is right (exchange truth over blind local HEDGE).
  2. Validation ran BEFORE registration, recreating the fake-success: bybit's supported_position_modes() is vacuous on an empty pair list, so HEDGE + an inverse pair passed validation and then silently did nothing. Fixed: register → then validate against the real pair set. Test with a bybit-faithful vacuous-modes fake.
  3. The endpoint still lied on real exchange rejections: connector.set_position_mode() is fire-and-forget — success was reported before the exchange responded (e.g. Binance -4068 with open positions). Fixed: the service awaits _execute_set_position_mode and verifies the local trait after — 502 when the exchange refused. Test proves the call is actually awaited.
  4. bitget's adoption was dead code at init (its fetch returns None with an empty pair list, which is always the case at init). Fixed: adoption retries once on the first pair registration. Also added a supported_position_modes guard on adoption (future-proofing).

Compat (unchanged, documented): the 400-on-empty is external-breaking until hummingbot-api-client ships the optional trading_pair field; interim workaround is leverage-first ordering (already applied to condor's MCP tool). Verified clean: no position-mode event listeners exist to skip, CancelledError propagates correctly, request model is backwards compatible.

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

  • git switch --detach origin/fix/207-throttler-pair-limits
  • git switch --detach origin/fix/208-per-pair-trading-rules
  • git switch --detach origin/fix/210-position-mode-truth
  • Setup with bitget_perpetual
  • Empty-pair position-mode request returned HTTP 400.
  • Valid BTC-USDT pair registration and HEDGE switch succeeded.
  • Position query reported HEDGE, matching the connector and exchange.
  • Unsupported BTC-USDS returned HTTP 400 using ONEWAY without changing the valid pair’s state.
  • An active Bitget order caused the expected HTTP 502 when switching to ONEWAY.
    • The test order was cancelled and confirmed inactive.
  • After restart, Bitget’s exchange mode remained HEDGE. Once BTC-USDT was registered, the local connector adopted HEDGE and matched the exchange.

@fengtality
fengtality force-pushed the fix/210-position-mode-truth branch from 903e52d to d276034 Compare September 29, 2026 13: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>
@fengtality fengtality changed the title (fix) position mode: adopt exchange truth at init, fail loudly on empty-pair switch (fix) perpetual position mode follows the exchange; switch endpoint reports only what the exchange did Sep 29, 2026
@fengtality
fengtality changed the base branch from fix/208-per-pair-trading-rules to main September 29, 2026 13:22
@fengtality
fengtality requested a review from rapcmia September 29, 2026 13:23
@fengtality

Copy link
Copy Markdown
Contributor Author

@rapcmia could you QA this with bitget_perpetual? Ideally on an account you can flip between one-way and hedge. Small sizes are fine; one pair, e.g. BTC-USDT.

Restart the API between cases so the connector is created fresh. Check the local mode with GET /trading/{account}/bitget_perpetual/position-mode.

1. One-way account, init. Set the account to one-way in the bitget UI → restart → GET position-mode.
Expect ONEWAY. (main reports HEDGE here.)

2. Hedge account, executor adds the market. Set the account to hedge → restart → GET shows ONEWAY (bitget can't be read without a pair) → start a small position executor on BTC-USDT via POST /executors/ → GET position-mode again.
Expect HEDGE before the executor's first order, the order is accepted, and the log has Position mode initialized to PositionMode.HEDGE from exchange.

3. Switch with no pair registered. Restart → POST /trading/{account}/bitget_perpetual/position-mode {"position_mode": "HEDGE"} before touching any market.
Expect 400 "No trading pairs registered … pass trading_pair". Nothing changes on bitget.

4. Switch through a pair. POST … {"position_mode": "HEDGE", "trading_pair": "BTC-USDT"} on a one-way account with no open positions.
Expect 200, the bitget UI shows hedge mode, and GET returns HEDGE. Then with an open position, switch back to ONEWAY: expect 502, and the mode stays unchanged in both bitget and GET. Also try "trading_pair": "NOPE-USDT": expect 400.

5. Known gap: direct orders only. Hedge account → restart → POST /trading/orders for BTC-USDT with no executor and no position-mode call first.
I expect this to be rejected (local ONEWAY). Please confirm what bitget returns. That decides whether direct orders should also read the mode.

If you have a bybit account: repeat case 1. Expect ONEWAY (main says HEDGE). A bybit account in hedge now needs one explicit case-4 call.

…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>
@fengtality

Copy link
Copy Markdown
Contributor Author

@rapcmia update to the QA checklist: the direct-order gap is fixed in ea55b40, so case 5 now expects success.

5. Direct orders only. Hedge account → restart → POST /trading/orders for BTC-USDT, with no executor and no position-mode call first.
Expect the order to be accepted and to open a long in hedge mode on bitget. GET position-mode should return HEDGE afterwards, and the log should show Position mode initialized to PositionMode.HEDGE from exchange. just before the order.

@rapcmia

rapcmia commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
  • Setup PR with docker, make setup; make deploy
  • Setup bitget_perpetual only, use SOL-USDT

Tested on ccddb71 with bitget_perpetual, using the API routes.

Passed:

  • No pair registered: the HEDGE request returned 400, and the mode didn't change. ✅
  • Valid pair: the HEDGE switch with BTC-USDT succeeded, and GET showed HEDGE. ✅
  • Unsupported pair: returned 400, and the mode stayed HEDGE. ✅
  • Open position: the ONEWAY switch returned 502, and the mode stayed HEDGE. ✅
  • One-way account: after a restart, GET showed ONEWAY. ✅
  • Direct order on a hedge account: after a restart, Bitget refused the order as expected for this known gap, and nothing was left open. ✅

Failed:

  • Executor on a hedge account after a restart: the mode stayed ONEWAY, all open orders were refused, and the executor ended FAILED. The first-pair check (services/trading_service.py#L175) runs after the order book has already added the pair, so the mode is never read. ❌
    #### confirm the fresh connector starts with the no-pair fallback
    curl -s -u XXX:XXX http://localhost:8000/trading/master_account/bitget_perpetual/position-mode | jq
    {
      "position_mode": "ONEWAY",
      "connector": "bitget_perpetual",
      "account": "master_account"
    }
    
    #### create the SOL-USDT position executor
    curl -s -u admin1:admin2 -H "Content-Type: application/json" -X POST http://localhost:8000/executors/ -d '
    {
      "account_name": "master_account",
      "controller_id": "main",
      "executor_config": {
        "type": "position_executor",
        "connector_name": "bitget_perpetual",
        "trading_pair": "SOL-USDT",
        "side": 1,
        "amount": "0.08",
        "leverage": 5,
        "triple_barrier_config": {
          "stop_loss": "0.05",
          "take_profit": "0.05",
          "time_limit": 300,
          "open_order_type": 1
        }
      }
    }' | jq
    {
      "executor_id": "A8HBdb7en2EtfQirMcYLiVRo9kjFQGtZCymcLGemGAnW",
      "executor_type": "position_executor",
      "connector_name": "bitget_perpetual",
      "trading_pair": "SOL-USDT",
      "status": "RUNNING"
    }
    
    #### check position mode after executor market setup
    curl -s -u XXX:XXX http://localhost:8000/trading/master_account/bitget_perpetual/position-mode | jq
    {
      "position_mode": "ONEWAY",
      "connector": "bitget_perpetual",
      "account": "master_account"
    }
    
    #### inspect the failed executor
    curl -s -u XXX:XXX http://localhost:8000/executors/A8HBdb7en2EtfQirMcYLiVRo9kjFQGtZCymcLGemGAnW | jq
    {
      "status": "TERMINATED",
      "close_type": "FAILED",
      "error_count": 11,
      "last_error": "Open order failed. Retrying 10/10",
      "filled_amount_quote": 0.0
    }
    
    #### confirm the failed executor left no position
    curl -s -u XXX:XXX -H "Content-Type: application/json" -X POST http://localhost:8000/trading/positions -d '
    {
      "account_names": ["master_account"],
      "connector_names": ["bitget_perpetual"]
    }' | jq
    {
      "data": [],
      "pagination": {"total_count": 0}
    }
    
    
    • Restarted the API after the earlier live test confirmed Bitget remained in HEDGE mode.
    • Confirmed the fresh connector initially reported its expected no-pair ONEWAY fallback and had no open position.
    • Created a SOL-USDT position executor for 0.1 SOL at 5x after correcting the route's numeric side value.
    • The first-pair adoption failed: the local mode remained ONEWAY, and the executor terminated FAILED after 11 open-order errors without opening a position.
      • To reproduce:
        1. Keep Bitget in HEDGE, restart HAPI, confirm GET reports ONEWAY.
        2. Create a fresh Bitget position executor for SOL-USDT, then observe that GET remains ONEWAY and the executor terminates after its open orders fail.
    • Confirmed no Bitget position or active SOL-USDT order remained after termination.

Tested on ccddb71 with bitget_perpetual, using the API routes.

Passed:

  • No pair registered: returned 400, and the mode didn't change. ✅
  • Valid pair: same result as above. ✅
  • Unsupported pair: returned 400, and the mode stayed HEDGE. ✅
  • Open position: the ONEWAY switch returned 502, and the mode stayed HEDGE. ✅
  • One-way account: after a restart, GET showed ONEWAY. ✅
  • Direct order on a hedge account: after a restart, Bitget refused the order as expected for this known gap, and nothing was left open. ✅

Failed:

  • Executor on a hedge account after a restart: same result as above. ❌

@fengtality I tested with bitget_perpetual on ea55b40, using only the API routes and restarting the API between cases:

  • I switched modes through the position-mode route, sent orders through POST /trading/orders, created executors through POST /executors/, and checked results with GET position-mode, positions, and portfolio state.
  • Cases 1, 3, 4 and the updated case 5 work as expected.
  • For case 5, the first direct order after a restart on a hedge account was accepted, the long opened, GET showed HEDGE, and the balance stayed correct.

Case 2 still fails. After a restart on a hedge account, a new position executor leaves the mode on ONEWAY, all its open orders are refused, and it ends FAILED. It looks like add_market sets up the order book before register_trading_pair runs (services/trading_service.py#L163-L175), so the pair is already registered and the mode is never read.

The "Position mode initialized" log line doesn't show at the default log level, so I checked with GET position-mode instead.

fengtality and others added 2 commits September 30, 2026 18:08
… 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>
pyinjective configures the root logger when imported, so main's basicConfig was a no-op and
every INFO line was dropped, including the connector's position-mode adoption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

  • Position-mode guardrails, rejected a mode change with no registered pair or an unsupported pair, while preserving the valid connector mode.
  • Valid-pair mode changes, set and read Bitget Perpetual HEDGE mode successfully with a supported trading pair.
  • Open-position protection, rejected a switch to ONEWAY while a controlled SOL-USDT position was open and kept HEDGE active until cleanup.
  • One-way initialization and direct orders, confirmed the expected ONEWAY fallback after restart and verified that first-pair registration supports direct SOL-USDT trading.
  • Position-mode adoption after restart, started with the local ONEWAY fallback, registered SOL-USDT, adopted Bitget's HEDGE mode, opened a 0.1 SOL position at 5x, and closed it without leaving a position or active order.
  • API INFO logging, confirmed the HEDGE initialization and executor-creation messages appear in the HAPI container logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Under Review

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

2 participants