Repository navigation
(fix) perpetual position mode follows the exchange, without a trading pair in the request - #246
Conversation
…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>
|
| 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) |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
- 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.
Fixes #210. Alternative to #213: built on its first three commits, with one commit on top that removes the need for
trading_pairin the position-mode request.Why
#213 made the switch honest, but required callers to pass a
trading_pairwhen the connector had none registered. The exchanges don't need one: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
GET position-modeno longer shows a temporary ONEWAY on a hedge account after a restart.POST position-modeneeds notrading_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.GET position-modere-reads the exchange instead of returning the cached value. Adding a market re-reads too, which recovers from a read that failed at creation.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
main; (fix) perpetual position mode follows the exchange; switch endpoint reports only what the exchange did #213 covered this by registering the pair on each direct order.Tests
test/test_position_mode_sync.pydrives 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:
GET position-modebefore touching any market. ExpectHEDGEstraight away.POST position-mode {"position_mode": "HEDGE"}with notrading_pairand no market. Expect 200 and the mode changed on Bitget."trading_pair": "NOPE-USDT". Expect 400.POST /trading/orderswith no executor first. Expect the order accepted in hedge mode.GET position-mode. Expect the new mode.🤖 Generated with Claude Code