Skip to content

feat: cancel orders(limit)#770

Merged
miohtama merged 34 commits intomasterfrom
feat/gmx-cancel-limit-orders
Feb 28, 2026
Merged

feat: cancel orders(limit)#770
miohtama merged 34 commits intomasterfrom
feat/gmx-cancel-limit-orders

Conversation

@Aviksaikat
Copy link
Collaborator

ref: #661

@Aviksaikat Aviksaikat self-assigned this Feb 25, 2026
@Aviksaikat Aviksaikat added documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers labels Feb 25, 2026
@Aviksaikat Aviksaikat force-pushed the feat/gmx-cancel-limit-orders branch 2 times, most recently from 5a9ff35 to f717679 Compare February 26, 2026 08:30
@Aviksaikat Aviksaikat changed the title feat: first draft of the cancel orders feat: cancel orders(limit) Feb 26, 2026
@Aviksaikat Aviksaikat force-pushed the feat/gmx-cancel-limit-orders branch from 1b04747 to a06a624 Compare February 27, 2026 09:26
@Aviksaikat
Copy link
Collaborator Author

@claude review this PR

@claude
Copy link

claude bot commented Feb 27, 2026

Claude finished @Aviksaikat's task in 3m 8s —— View job


PR #770 review: feat: cancel orders (limit)

  • Gather context and read PR diff
  • Review core order cancellation logic
  • Review CCXT exchange changes
  • Review trading module changes
  • Review Freqtrade integration changes
  • Review GraphQL client changes
  • Review test files
  • Review scripts and remaining files
  • Post final review

Summary

This PR adds cancellation support for pending limit orders (stop loss, take profit, limit increase) on GMX V2. It spans the full stack: low-level transaction building, CCXT adapter integration, Freqtrade exchange class, tests, and example scripts. It also includes several bug fixes (nonce recovery, gas pricing, Subsquid endpoint updates, execution buffer adjustment, liquidation guard).

The architecture is well thought out — CancelOrder builds unsigned transactions, pending_orders.py reads from the DataStore, and the CCXT layer wraps everything in CCXT-compatible dicts. The approach of batching cancels via multicall is gas-efficient and correct.

Issues

1. Duplicate code: cancel_order / cancel_orders logic repeated in sync and async CCXT

eth_defi/gmx/ccxt/exchange.py and eth_defi/gmx/ccxt/async_support/exchange.py contain near-identical implementations of cancel_order(), cancel_orders(), and the tx-hash → order-key resolution logic. This is ~500 lines of copy-pasted code that will be a maintenance burden. Consider extracting the shared logic (order key resolution, validation, result formatting) into a private helper or mixin that both sync and async classes delegate to.

Fix this →

2. _pending_order_to_ccxt uses "stop" instead of "stop_loss" to avoid Telegram parse errors

In eth_defi/gmx/ccxt/exchange.py:3492-3496, the stop_loss type is remapped to "stop" with a comment about Telegram MarkdownV1. This silently diverges from the CCXT standard "stop_loss" type. A better fix would be to escape the underscore at the Telegram notification layer rather than changing the order type at the data model level. This could cause subtle bugs for code checking order.get("type") == "stop_loss".

3. cancel_orders calls config.get_chain() inside the loop

In eth_defi/gmx/ccxt/exchange.py (sync cancel_orders() and async variant), chain = self.config.get_chain() is called inside the for raw_id in ids: loop. This is a minor inefficiency — the chain doesn't change per order. Move it before the loop.

Fix this →

4. Three example scripts with significant overlap

scripts/gmx/cancel_gmx_orders.py (321 lines), gmx_ccxt_limit_order_cancel.py (315 lines), and gmx_limit_order_cancel.py (373 lines) total 1009 lines with substantial overlap. Consider keeping only the most polished one (cancel_gmx_orders.py) as the canonical example and referencing it from the others, or consolidating them.

5. debug_ccxt_cancel_order.py is 630 lines — should it be in the repo?

tests/gmx/debug_ccxt_cancel_order.py is a large debug script, not a pytest test. It's useful for development but adds 630 lines to the test directory. Consider either converting it to proper pytest tests or moving it to scripts/.

6. _send_signed_tx_with_nonce_recovery mutates transaction dict

In eth_defi/gmx/ccxt/exchange.py, _send_signed_tx_with_nonce_recovery() does del transaction["nonce"] on the caller's dict. The callers already do del transaction["nonce"] before calling this helper, but if the nonce recovery path fires, it mutates the same dict again. This is fragile — consider working on a copy.

7. PendingOrder.trigger_price_usd assumes 18-decimal tokens

eth_defi/gmx/order/pending_orders.py:121 hardcodes division by 10**12:

return self.trigger_price / 10**12

This is only correct for 18-decimal index tokens (ETH). For BTC (8 decimals), the divisor should be 10**22. The comment acknowledges this but the code doesn't handle it. Consider accepting token_decimals or documenting the limitation explicitly in the property name (e.g., trigger_price_usd_18dec).

Fix this →

Suggestions (non-blocking)

8. Freqtrade gmx_exchange.py module docstring is outdated

The docstring at lines 20-23 says "No limit orders (all orders execute immediately or revert)" and "No order cancellation (orders execute atomically)". This is no longer true after this PR — limit orders and cancellation are now supported.

Fix this →

9. GraphQL _GMX_ORDER_TYPE_NAMES duplicates OrderType enum

eth_defi/gmx/graphql/client.py defines _GMX_ORDER_TYPE_NAMES as a plain dict mapping ints to strings. This duplicates the OrderType(IntEnum) from eth_defi/gmx/constants.py. Consider using OrderType(value).name.lower() instead.

10. order/__init__.py now has no re-exports

After this PR, eth_defi/gmx/order/__init__.py only contains the module docstring — no re-exports. The old from eth_defi.gmx.order import SLTPEntry, SLTPOrder, SLTPParams import path is broken (though all callers were updated). Consider adding re-exports for the new public types (CancelOrder, PendingOrder, fetch_pending_orders, etc.) for discoverability, or documenting the explicit submodule import pattern.

11. Comment in graphql/client.py mentions "ChatGPT"

eth_defi/gmx/graphql/client.py:29:

# Just Random values ChatGPT gave

This should be cleaned up — either provide real references for the threshold values or remove them if they're placeholders.

What looks good

  • Core design: CancelOrder + PendingOrder dataclasses with slots=True follow project conventions. Clean separation between transaction building and signing.
  • Gas pricing fix: Using web3.eth.gas_price instead of estimate_gas_fees().max_fee_per_gas for cancel transactions avoids the 100x overestimation on Arbitrum. Well-documented reasoning.
  • Batch cancellation: multicall batching with linear gas scaling (CANCEL_ORDER_GAS_LIMIT * N) is efficient and correct.
  • Nonce recovery: The _send_signed_tx_with_nonce_recovery helper handles both "nonce too high" and "nonce too low" cases, which addresses a real production issue.
  • Test coverage: Lifecycle tests (open → SL → verify → cancel → verify gone) cover the happy path well, and edge cases (empty account, nonexistent order) are tested.
  • Execution buffer reduction: DEFAULT_SLTP_EXECUTION_FEE_BUFFER from 3.0 → 1.0 to match GMX UI is a good fix.
  • Liquidation guard: Returning 0.0 for negative max_loss in calculate_estimated_liquidation_price prevents nonsensical prices.
  • Subsquid position close lookup: Using real Subsquid data instead of synthetic orders when a position was closed by SL is a significant improvement for accuracy.

Verdict

Solid feature addition. The core cancellation implementation is correct and well-tested. The main concerns are code duplication between sync/async CCXT adapters (issue #1), the trigger_price_usd hardcoded decimal assumption (issue #7), and the three overlapping scripts (issue #4). I'd suggest addressing issues #1-3 and #7 before merge.

@claude
Copy link

claude bot commented Feb 27, 2026

PR Review: feat: cancel orders(limit)

Good work on a substantial feature. The cancel order lifecycle is well thought out with proper CCXT compatibility, batch support, and freqtrade integration. Here is detailed feedback:


Strengths

  • Well-structured architecture: Clean separation between CancelOrder (transaction builder), PendingOrder (DataStore reader), and the CCXT/freqtrade layers.
  • Batch cancellation: The cancel_orders() multicall approach is gas-efficient and well-designed.
  • Defensive programming: The duplicate SL guard in create_stoploss(), the nonce recovery helper, and the gas-critical pause mechanism all address real operational edge cases.
  • Good test coverage: Integration tests cover the full lifecycle (create → fetch → cancel → verify), empty account, and nonexistent order cases.
  • Thorough documentation: Docstrings are detailed with Sphinx-compatible RST format, parameter descriptions, and cross-references.

Issues to address

1. Code duplication between sync and async cancel_order / cancel_orders

The async cancel_order() and cancel_orders() in async_support/exchange.py duplicate ~90% of the sync versions in exchange.py. The id-resolution logic, validation, CCXT response construction, and error handling are copied verbatim. Consider extracting the shared logic (id resolution, validation, response building) into private helper methods on a mixin or the sync class, and have the async class call into them with only the async I/O parts differing.

2. _pending_order_to_ccxt: hardcoded price divisor 10**12

In PendingOrder.trigger_price_usd, the conversion divides by 10**12, which is correct only for 18-decimal index tokens. For tokens with different decimals (e.g., WBTC with 8 decimals), this would produce wrong prices. Consider accepting token decimals or using the PRECISION constant more explicitly:

# Currently:
return self.trigger_price / 10**12
# Should be:
return self.trigger_price / 10**(PRECISION - token_decimals)

At minimum, document this limitation clearly.

3. _pending_order_to_ccxt maps stop_loss"stop" but test_ccxt_fetch_orders_after_sl_creation asserts type == "stop_loss"

In _pending_order_to_ccxt (exchange.py line ~3497), stop-loss orders are mapped to order_type = "stop" (with a comment about Telegram MarkdownV1). But the test test_ccxt_fetch_orders_after_sl_creation asserts sl.get("type") == "stop_loss". The _pending_order_to_ccxt returns "stop" not "stop_loss", so this assertion should fail. Please reconcile — either the mapping or the test assertion needs updating.

4. Deprecated asyncio.get_event_loop() usage

In async_support/exchange.py, cancel_order() and cancel_orders() use asyncio.get_event_loop() which is deprecated since Python 3.10. Use asyncio.get_running_loop() instead:

# Before:
loop = asyncio.get_event_loop()
# After:
loop = asyncio.get_running_loop()

5. cancel_orders validates each order sequentially with RPC calls

In both cancel_order() and cancel_orders(), each order key is validated via is_order_pending() which makes an RPC call. For batch cancellation of N orders, this means N sequential RPC calls before the batch transaction is even built. Consider:

  • Batching the validation into a single multicall read, or
  • At minimum, document that batch cancel has O(N) RPC overhead for validation.

6. _send_signed_tx_with_nonce_recovery mutates the input transaction dict

The helper deletes transaction["nonce"] on the retry path, mutating the caller's dict. While the callers currently use dict(result.transaction) copies, this is fragile. Consider making the copy inside the helper:

def _send_signed_tx_with_nonce_recovery(self, transaction, signed_tx, context="transaction"):
    # ...on retry:
    retry_tx = {k: v for k, v in transaction.items() if k != "nonce"}
    signed_tx = self.wallet.sign_transaction_with_new_nonce(retry_tx)

7. order/__init__.py — breaking change removing re-exports

The __init__.py of eth_defi/gmx/order/ previously re-exported all order classes (IncreaseOrder, DecreaseOrder, SLTPOrder, OrderResult, etc.) and now exports nothing. Any downstream code doing from eth_defi.gmx.order import OrderResult will break. The fix in order.py (from eth_defi.gmx.order.base_order import OrderResult) and lagoon/test_gmx_lagoon_integration.py confirm this is intentional, but:

  • Are there other consumers not updated? A grep for from eth_defi.gmx.order import across the codebase would be prudent.
  • Consider a deprecation path — keep the re-exports with a deprecation warning for one release cycle.

8. execution_buffer parameter inconsistency in async vs sync cancel_order

The async cancel_order reads execution_buffer from params or self.options, while the sync version uses self.execution_buffer. These should be consistent.

9. time.sleep() in get_latest_position_close polling loop

The get_latest_position_close method uses time.sleep(poll_interval) in a polling loop. Since this is called from the sync CCXT exchange, it blocks the thread for up to max_wait_seconds (default 10s). This is acceptable for the sync path but should be documented as blocking.

10. Test test_ccxt_cancel_order_lifecycle depends on type == "stop_loss" from _pending_order_to_ccxt

As noted in point 3, the test at line 3956 (assert sl.get("type") == "stop_loss") appears inconsistent with the "stop" mapping in _pending_order_to_ccxt. The debug script debug_ccxt_cancel_order.py line 4886 also asserts type == "stop_loss". These should all be aligned.


Minor / style

  • UK English: The code consistently uses "cancelled" (UK spelling) — good.
  • Emoji in log strings: Lines like "⛽ Gas pause active for %s" and "🚫 Gas critical" use emoji in logger calls. While this aids readability in Telegram alerts, it is unusual in structured log output. Consider keeping emoji only in the Telegram message strings.
  • _strip_json_comments in scripts: The regex ^\s*//.*$ only strips full-line comments. Inline trailing comments like "key": "value", // comment are not handled. This is fine given the documented scope but worth a brief comment noting the limitation.
  • cancel_gmx_orders.py uses rich as a hard dependency — consider guarding the import since rich is not in the main project dependencies.
  • Decimal import removed from trading.py: Verify it is not used elsewhere in the file.

Summary

The core cancel order feature is solid and well-tested. The main concerns are:

  1. Type inconsistency between _pending_order_to_ccxt ("stop") and test assertions ("stop_loss") — this looks like a bug.
  2. Breaking change in order/__init__.py re-exports — needs verification that all consumers are updated.
  3. Code duplication between sync/async cancel implementations — could be reduced.

The rest are improvements that can be addressed in follow-up PRs. Good work overall.

@Aviksaikat Aviksaikat force-pushed the feat/gmx-cancel-limit-orders branch from aa4bd32 to c071707 Compare February 27, 2026 18:33
@Aviksaikat
Copy link
Collaborator Author

@claude review now

@claude
Copy link

claude bot commented Feb 27, 2026

Claude finished @Aviksaikat's task in 4m 48s —— View job


PR #770 review: feat: cancel orders (limit)

  • Gather context and read PR diff
  • Review core order modules (cancel_order.py, pending_orders.py)
  • Review CCXT exchange changes (sync and async)
  • Review trading module and freqtrade integration
  • Review GraphQL client changes
  • Review tests and scripts
  • Post final review

Summary

This PR adds cancellation support for pending limit orders (stop loss, take profit, limit increase) on GMX V2 across the full stack: low-level transaction building, CCXT adapter, Freqtrade exchange class, GraphQL client, tests, and example scripts. It also bundles several important fixes: nonce recovery, gas pricing, execution buffer correction, Subsquid endpoint swap, and a liquidation guard.

The core architecture is solid — CancelOrder builds unsigned transactions, PendingOrder reads from the DataStore, the CCXT layer wraps everything in CCXT-compatible dicts, and multicall batching is gas-efficient and correct. The stoploss_adjust() change enabling trailing stop-losses is a significant functional improvement.


Issues

1. _send_signed_tx_with_nonce_recovery mutates the caller's transaction dict

eth_defi/gmx/ccxt/exchange.py:6607-6609 — The helper does del transaction["nonce"] on the retry path, mutating the caller's dict. Although callers currently pass a dict(result.transaction) copy, this is fragile — future callers may not. The helper should work on its own copy:

retry_tx = {k: v for k, v in transaction.items() if k != "nonce"}
signed_tx = self.wallet.sign_transaction_with_new_nonce(retry_tx)

Fix this →

2. cancel_orders calls config.get_chain() inside the loop

eth_defi/gmx/ccxt/exchange.py:6809 and eth_defi/gmx/ccxt/async_support/exchange.py:1429chain = self.config.get_chain() is called inside the for raw_id in ids: loop. The chain never changes between iterations; move it before the loop.

Fix this →

3. trigger_price_usd hardcodes 18-decimal assumption

eth_defi/gmx/order/pending_orders.py:121self.trigger_price / 10**12 is only correct for 18-decimal index tokens (ETH, most alts). For tokens like WBTC (8 decimals), the divisor should be 10**22. The docstring acknowledges this but the property silently returns wrong values for non-18-decimal tokens. Consider either:

  • Accepting token_decimals in the constructor and using 10**(PRECISION - token_decimals), or
  • Renaming to trigger_price_usd_18dec to make the assumption explicit.

Fix this →

4. Deprecated asyncio.get_event_loop() usage

eth_defi/gmx/ccxt/async_support/exchange.py:1320,1457asyncio.get_event_loop() is deprecated since Python 3.10. Use asyncio.get_running_loop() instead, which is the correct call within an async function:

# Before:
loop = asyncio.get_event_loop()
# After:
loop = asyncio.get_running_loop()

Fix this →

5. Code duplication between sync and async cancel implementations

The async cancel_order() and cancel_orders() in async_support/exchange.py duplicate ~90% of the sync versions — id resolution, validation, response building, error handling are near-identical (~500 lines duplicated). The only async-specific parts are the run_in_executor calls. Consider extracting shared logic (id resolution, validation, CCXT response construction) into a common mixin or private helper module.

6. order/__init__.py now exports nothing — potential downstream breakage

eth_defi/gmx/order/__init__.py previously re-exported IncreaseOrder, DecreaseOrder, SLTPOrder, OrderResult, etc. It now only has a module docstring. I verified that from eth_defi.gmx.order import is only used in a commented-out test line (tests/gmx/test_order.py:11), so internal consumers appear updated. But any external code using from eth_defi.gmx.order import OrderResult will break. Consider adding re-exports for the most common types (CancelOrder, PendingOrder, fetch_pending_orders) for discoverability.

7. Three overlapping example scripts

scripts/gmx/cancel_gmx_orders.py (321 lines), gmx_ccxt_limit_order_cancel.py (315 lines), and gmx_limit_order_cancel.py (373 lines) total 1009 lines with significant overlap. Consider keeping only the most polished one (cancel_gmx_orders.py) and removing or substantially trimming the others.


Suggestions (non-blocking)

8. Freqtrade module docstring is outdated

eth_defi/gmx/freqtrade/gmx_exchange.py:20-21 still says "No limit orders (all orders execute immediately or revert)" and "No order cancellation (orders execute atomically)". This PR makes both statements false.

Fix this →

9. _GMX_ORDER_TYPE_NAMES duplicates OrderType enum

eth_defi/gmx/graphql/client.py:979-988 defines a plain dict mapping ints to strings that mirrors OrderType(IntEnum) from eth_defi/gmx/constants.py. Consider using OrderType(value).name.lower() to avoid maintaining parallel definitions.

10. Comment references "ChatGPT"

eth_defi/gmx/graphql/client.py:28: # Just Random values ChatGPT gave — should be cleaned up with a proper reference or removed if the values are placeholders.

11. cancel_orders validates each order with sequential RPC calls

In both sync and async cancel_orders(), each order key is validated via is_order_pending() which makes an individual RPC call. For a batch of N orders, this means N sequential RPC calls before the batch transaction is even built. For large batches, consider batching validation into a single multicall read.

12. debug_ccxt_cancel_order.py is 630 lines — belongs in scripts/

tests/gmx/debug_ccxt_cancel_order.py is a development/debug script, not a pytest test. Consider moving it to scripts/gmx/ to keep the test directory focused on automated tests.


What looks good

  • Core design: CancelOrder + PendingOrder dataclasses with slots=True follow project conventions. Clean separation between transaction building and signing.
  • Gas pricing fix: Using web3.eth.gas_price instead of estimate_gas_fees().max_fee_per_gas for both cancel and SL/TP transactions avoids the 100x overestimation on Arbitrum. Well-documented reasoning in both cancel_order.py:258-262 and sltp_order.py.
  • DEFAULT_SLTP_EXECUTION_FEE_BUFFER 3.0 → 1.0: Aligns with GMX UI behaviour — justified change.
  • Batch cancellation: multicall batching with linear gas scaling is efficient and correct.
  • Nonce recovery: Handles both "nonce too high" and "nonce too low" cases with appropriate use of "latest" vs "pending" counts.
  • stoploss_adjust(): Now properly enables trailing stop-loss by returning True when the new SL tightens the stop. Clean implementation with clear direction logic.
  • Duplicate SL guard in create_stoploss(): Prevents wasting ETH on duplicate keeper execution fees.
  • Gas-critical pause mechanism: Exponential backoff with Telegram alerts is a well-designed production safety feature.
  • Telegram utils extraction: Clean, reusable, non-fatal wrapper.
  • Test coverage: Lifecycle tests (open → SL → verify → cancel → verify gone) cover the happy path; edge cases (empty account, nonexistent order) are tested.
  • Liquidation guard: Returning 0.0 for negative max_loss prevents nonsensical prices.
  • "stopLoss" type: Using camelCase consistently throughout tests and code avoids the Telegram MarkdownV1 underscore-as-italic issue. Tests assert type == "stopLoss" which matches _pending_order_to_ccxt.

Verdict

Solid feature addition with good test coverage and thoughtful production hardening. The main items I'd address before merge are:

  1. Issue This Front Run Bot working good #2 (get_chain() inside loop) — trivial fix
  2. Issue Investigate Uniswap v3 and getting historical price data in good format #4 (deprecated asyncio.get_event_loop()) — trivial fix
  3. Issue Uniswap v3: price #8 (outdated docstring) — trivial fix

Issues #1, #3, #5, and #6 are worth tracking but can be addressed in follow-up PRs.


View job

@claude
Copy link

claude bot commented Feb 27, 2026

Code review: feat: cancel orders(limit) (#770)

Overall this is a substantial feature addition (~4300 lines added) that implements GMX limit order cancellation across the core order module, CCXT wrappers (sync/async), freqtrade integration, a new GraphQL/Subsquid client, and several scripts/tests. Below are the findings.


Potential bugs

1. is_long logic may be inverted in position close detection
In eth_defi/gmx/ccxt/exchange.py, the Subsquid close-detection path has:

is_long = side != "sell"  # we were trying to close a long (sell side)

The comment says "we were trying to close a long (sell side)" — but that logic sets is_long = True when side != "sell". If we are selling to close a long, then the original position was long, so is_long should be side == "sell". The comment and code are contradictory. Please verify which is correct.

2. asyncio.get_event_loop() deprecated
In eth_defi/gmx/ccxt/async_support/exchange.py, cancel_order and cancel_orders use:

loop = asyncio.get_event_loop()

This is deprecated since Python 3.10 and raises DeprecationWarning in some contexts. Since these are async methods with a running loop, use asyncio.get_running_loop() instead.

3. Timestamp inconsistency in cancel_order return dict
In eth_defi/gmx/ccxt/exchange.py, the sync cancel_order calls self.milliseconds() twice:

"timestamp": self.milliseconds(),
"datetime": self.iso8601(self.milliseconds()),

These could yield slightly different values. Capture once in a local variable (as is already done correctly in cancel_orders).

4. trigger_price_usd assumes 18-decimal tokens
In eth_defi/gmx/order/pending_orders.py:

return self.trigger_price / 10**12

The docstring acknowledges this is for "standard 18-decimal index tokens", but for BTC (8 decimals) the divisor should be 10**22. Since PendingOrder doesn't store the index token's decimal count, this will silently produce incorrect prices for non-18-decimal tokens. Consider either storing the decimal count or adding a more prominent warning.

5. Race condition in nonce recovery
_send_signed_tx_with_nonce_recovery() directly sets self.wallet.current_nonce. In a threaded environment (which the project prefers), two concurrent calls detecting nonce errors could both reset to the same on-chain nonce and sign with the same value, causing one to fail again. Consider adding a lock around nonce recovery.


Code quality

6. Significant duplication between sync and async exchange
cancel_order() and cancel_orders() are copy-pasted (~260 lines each) between exchange.py and async_support/exchange.py. A shared mixin or helper would reduce the maintenance burden and risk of the two drifting apart.

7. Inconsistent execution_buffer handling
The async cancel_order reads execution_buffer from params:

execution_buffer = params.get("execution_buffer", self.options.get("executionBuffer", 2.2))

The sync version uses self.execution_buffer directly, ignoring params. These should be consistent.

8. Inconsistent order_type naming in _pending_order_to_ccxt

order_type = "stopLoss"      # camelCase
order_type = "take_profit"   # snake_case

The comment explains camelCase for stop-loss is to avoid Telegram MarkdownV1 escaping issues, but this inconsistency forces downstream code to check for both "stopLoss" and "stop_loss" variants (which it already does). Handle the Telegram escaping at the Telegram output layer instead.

9. __init__.py removal is a breaking change
eth_defi/gmx/order/__init__.py had all re-exports removed. Any external consumer importing from eth_defi.gmx.order directly will break. This should be documented as a breaking change.

10. chain = self.config.get_chain() called inside loop
In both cancel_orders() methods, self.config.get_chain() is called on every iteration but always returns the same value. Move it before the loop.

11. Duplicated utility functions across scripts
_extract_order_keys() and _fetch_eth_spot_price() are duplicated across gmx_ccxt_limit_order_cancel.py and gmx_limit_order_cancel.py. Factor these into a shared utility module.


Security

12. Telegram token could leak in error logs
In eth_defi/gmx/freqtrade/telegram_utils.py, if the urllib request fails, the exception message may include the full URL containing the bot token. The except Exception as err: logger.warning(...) handler could log it. Sanitise the URL in the error handler.

13. Direct nonce manipulation bypasses wallet safeguards
_send_signed_tx_with_nonce_recovery sets self.wallet.current_nonce directly, bypassing sync_nonce(). The comment acknowledges this, but if sync_nonce gains additional side effects in future, this could be dangerous. Consider using the wallet's public API.


Performance

14. _market_address_to_symbol is O(n) per call
This linear scan over all markets is called for every pending order in _pending_order_to_ccxt. Build a reverse-lookup dict ({market_token_lower: symbol}) during load_markets() instead.

15. is_order_pending called per order in batch cancel
Each order in cancel_orders() makes a separate RPC call to is_order_pending(). For large batches, consider fetching all pending order keys once and checking membership locally.


Test coverage gaps

16. No tests for async cancel_order/cancel_orders — Given the duplicated code, bugs in one version could easily exist in the other undetected.

17. No tests for nonce recovery logic_send_signed_tx_with_nonce_recovery directly manipulates wallet state with different behaviour for "nonce too high" vs "nonce too low". This critical code path needs targeted tests.

18. No tests for gas-critical throttling — The sliding window, pause mechanism, and Telegram alerting in gmx_exchange.py are complex state machine logic that should have tests.

19. No tests for get_latest_position_close — The Subsquid polling and response parsing are untested.

20. No tests for stoploss_adjust new logic — The old implementation always returned False; the new directional logic (long vs short) should have unit tests.

21. Test removed without replacementtest_fetch_ticker_raise_exception_action was removed from test_ccxt_price_sanity.py. If the underlying functionality is still supported, its coverage should be preserved.


Style issues (per CLAUDE.md conventions)

22. Missing type hints

  • _send_signed_tx_with_nonce_recovery: signed_tx parameter has no type hint (should be SignedTransaction)
  • _market_address_to_symbol: market_address should use HexAddress not str
  • get_latest_position_close: account and market_address should use HexAddress

23. Emojis in logger calls
gmx_exchange.py uses emojis in logger.debug(), logger.info(), and logger.error() calls. Per project conventions, emojis should only be used when explicitly requested. Move emoji usage to the Telegram message layer only.

24. cancel_gmx_orders.py uses sys.argv instead of environment variable
Per project conventions, command-line scripts should "always use environment variables" rather than command-line argument parsers.

25. DEFAULT_SLTP_EXECUTION_FEE_BUFFER changed from 3.0 to 1.0
This is a significant behavioural change that will affect existing users. Should be documented as a potential breaking change and ideally mentioned in the changelog.


Summary

The core cancellation logic looks solid and well-structured. The main concerns are:

  1. The possible is_long inversion bug (finding Figure out API doc issues #1) needs immediate verification
  2. The sync/async code duplication creates maintenance risk
  3. Several new code paths lack test coverage (nonce recovery, gas throttling, async cancel, Subsquid polling)
  4. A few style/convention issues per project standards

Nice work on the comprehensive implementation — the cancel order flow, pending order fetching, and freqtrade integration are well thought out. The test fixtures for Anvil fork-based testing are well designed.

@Aviksaikat Aviksaikat force-pushed the feat/gmx-cancel-limit-orders branch 2 times, most recently from 6398e74 to 8fe2048 Compare February 28, 2026 14:58
Aviksaikat and others added 13 commits February 28, 2026 20:59
Expands the warning docstring with the root cause confirmed via Tenderly
transaction debugger: during executeOrder, GMX's settlement logic
transfers the wallet's entire ETH balance to a settlement contract
because the wallet is impersonated (anvil_impersonateAccount) via
unlocked_addresses. Includes the exact Tenderly balance-change trace
as inline evidence and confirms this is a fork-only artefact safe in
production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The test assumed a live GMX API call would always produce a price
deviation large enough to trigger PriceSanityException, but
check_price_sanity has a fail-open design: if either the oracle or
ticker price extraction fails (network timeout, rate limit, etc.) it
returns passed=True. This silently suppressed the exception in CI,
causing the pytest.raises assertion to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
createLimitBuyOrder and createLimitSellOrder delegate to the
implemented create_limit_order() so advertise them as True.
fetchClosedOrders explicitly raises NotSupported so mark it False
instead of None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The :prod alias for gmx-synthetics-arbitrum stopped serving data queries
(introspection returns 200 but all data queries hang). GMX interface now
uses the versioned hash @5acc9d; updated primary endpoint accordingly.

The old :prod URL is retained as a backup in case it is restored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
**Nonce recovery (exchange.py)**

Add `_send_signed_tx_with_nonce_recovery()` helper that wraps
`send_raw_transaction` with a single auto-retry on nonce mismatch:

- "nonce too high": resyncs `wallet.current_nonce` from
  `get_transaction_count(address)` (latest confirmed) and retries.
- "nonce too low": resyncs from `get_transaction_count(address, "pending")`
  (confirmed + mempool, avoids reusing a queued nonce) and retries.
- All other `Web3RPCError` re-raised unchanged.
- Retry failure re-raised with full context for easier production debugging.

Wire the helper into all three affected call sites:
- `create_order`: replaced the old "nonce too high"-only handler.
- `cancel_order`: previously had no nonce error handling at all.
- `cancel_orders`: previously had no nonce error handling at all.

**Gas-pause overhaul (gmx_exchange.py)**

The old `count >= threshold` condition fired a Telegram alert on every
bot loop after the threshold (count=3, 4, 5 … 8 seen in production).

Changes:
- Three module-level constants: `_GAS_CRITICAL_MAX_RETRIES=3`,
  `_GAS_CRITICAL_WINDOW_SECS=300`, `_GAS_CRITICAL_PAUSE_SECS=900`.
- `_gas_critical_attempts` state extended to a 3-tuple
  `(count, window_start, paused_until)`.
- Pre-flight guard at the top of `create_order` (before
  `super().create_order()`): raises `InsufficientFundsError` immediately
  when the pair is within its pause window — no GMX API call, no Telegram.
  Clears state and resumes automatically once the pause expires.
- `count == threshold`: sets `paused_until = now + 900s`, sends Telegram
  **once**, raises.
- `count > threshold`: silent debug log + raise (pre-flight handles
  subsequent loops).
- Telegram message updated to say "paused for 15 minutes" (now accurate).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…close, liquidation guard

**cancel_order execution_buffer (cancel_order.py, trading.py, async_support/exchange.py)**

- `cancel_order()` and `cancel_orders()` now accept an `execution_buffer`
  param (default `DEFAULT_EXECUTION_BUFFER`) threaded through
  `CancelOrder._build_cancel_transaction()` and `GMXTrading`.
- `cancel_all_pending_orders()` also exposes the param.
- Async `cancel_order` resolves tx_hash → order_key via the orders cache
  (same pattern as the sync version), and logs `execution_buffer`.
- Async `cancel_orders` batch method added (was sync-only before).

**Gas price fix for cancels and SL/TP orders (cancel_order.py, sltp_order.py, execution_buffer.py)**

- Cancel transactions now price gas as `web3.eth.gas_price * execution_buffer`
  instead of `estimate_gas_fees()`, which returns an inflated
  `maxPriorityFeePerGas` (~1.5 gwei vs ~0.01 gwei base fee on Arbitrum) and
  was inflating cancel costs ~100x.
- `SLTPOrder` calculation uses `web3.eth.gas_price` for the same reason.
- `DEFAULT_SLTP_EXECUTION_FEE_BUFFER` lowered 3.0 → 1.0: the compound
  multiplier caused SL/TP fees to be ~5x higher than the GMX UI; setting it
  to 1.0 applies a single execution buffer consistent with all other order types.

**Subsquid position-close query (graphql/client.py)**

- `GMXSubsquidClient.get_latest_position_close()` polls Subsquid for the most
  recent decrease event for a given account/market. Used when freqtrade tries
  to close a position that was already closed (stop-loss or liquidation) so the
  actual execution price, fees, and close reason can be returned instead of
  synthetic guesses.

**Liquidation price guard and constant (utils.py, constants.py)**

- `GMX_MIN_LIQUIDATION_COLLATERAL_USD = 5.0` extracted as a named constant.
- `calculate_estimated_liquidation_price()` now returns `0.0` when
  `max_loss <= 0` (collateral below the $5 liquidation threshold) instead of
  producing a nonsensical price above entry (long) or below entry (short).
  A `0.0` sentinel is treated as falsy by freqtrade and falls back to the
  strategy's stop-loss price.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the inline Telegram HTTP logic out of _send_gas_critical_telegram_alert()
into a standalone send_freqtrade_telegram_message(config, message) function in
eth_defi/gmx/freqtrade/telegram_utils.py.

The new utility can be imported and called by any future alert (liquidation
warning, nonce exhaustion, etc.) with a single line, keeping message-specific
logic in the callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'stop_loss' has underscores that Telegram MarkdownV1 treats as italic
markers, causing 'Can't parse entities' errors. 'stopLoss' avoids this
while being more descriptive than the generic 'stop'.

Updated everywhere that reads the fetched type:
- _format_pending_order(): "stop" → "stopLoss"
- exchange.py cancel recovery guard: "stop" → "stopLoss"
- gmx_exchange.py duplicate-SL guard: "stop" → "stopLoss"
- test_ccxt_cancel_order.py: assertions updated
- debug_ccxt_cancel_order.py: assertion updated

Input types for create_order dispatch (type="stop_loss") are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, docs

Concrete bug fixes:
- _send_signed_tx_with_nonce_recovery: make internal retry_tx copy instead of
  mutating the caller's transaction dict (issue 1)
- cancel_orders (sync + async): move config.get_chain() before the loop —
  it never changes per iteration (issue 2)
- async cancel_order/cancel_orders: replace deprecated asyncio.get_event_loop()
  with asyncio.get_running_loop() (correct inside async functions, issue 4)

Documentation / clarity fixes:
- PendingOrder.trigger_price_usd: prominent warning that it assumes 18-decimal
  tokens; add trigger_price_usd_for_decimals(token_decimals) helper (issue 3)
- cancel_orders: document O(N) sequential RPC validation overhead (issue 11)
- get_latest_position_close: document that it blocks the calling thread (issue 9)
- _GMX_ORDER_TYPE_NAMES: explain why OrderType enum can't replace it directly
- _strip_json_comments: note that inline trailing comments are not handled
- graphql/client.py: remove "ChatGPT" comment, replace with neutral wording
- gmx_exchange.py module docstring: update to reflect SL/TP cancel support

Re-exports:
- eth_defi/gmx/order/__init__.py: restore common re-exports (OrderResult,
  CancelOrder, PendingOrder, etc.) so from eth_defi.gmx.order import X works

Scripts:
- cancel_gmx_orders.py: guard rich import with clear ImportError message
- tests/gmx/lagoon: add @flaky to all 4 integration tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Aviksaikat and others added 21 commits February 28, 2026 20:59
… first

Both cancel_order and cancel_orders in the async GMX exchange class were
reading execution_buffer only from params then self.options, skipping the
instance attribute set at construction time. This meant passing
execution_buffer=1.5 at init was silently ignored by the async cancel path.

Fix uses getattr(self, "execution_buffer", fallback) to probe the instance
attribute before falling back to self.options, matching the sync behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s effective

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s are valid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… fix test classes

- Add _get_logs_adaptive() (sync) and _get_logs_adaptive_async() (async) helpers that
  catch 'logs matched by query exceeds limit' ExtraValueError and recursively bisect
  the block range down to _MIN_LOG_CHUNK_BLOCKS=100 before re-raising
- Use helpers in _scan_logs_chunked_for_trade_action and async variant so busy
  EventEmitter blocks are never silently skipped
- Convert test_cancel_helpers.py test classes to standalone functions (CLAUDE.md rule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_utils.py

Both gmx_ccxt_limit_order_cancel.py and gmx_limit_order_cancel.py contained
an identical private _fetch_eth_spot_price() function. Extract it into a new
shared module scripts/gmx/script_utils.py and update both scripts to import
from there. Also add __init__.py to scripts/ and scripts/gmx/ to support the
package import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ls docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…c signature to multi-line

Adds trailing commas so ruff format keeps these expanded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ckup

:prod is the official GMX docs URL; @5acc9d versioned hash is retained
as the fallback in case :prod becomes unresponsive again.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When running against an Anvil fork, Subsquid (a live-chain indexer) may
return a transaction that matches an order key prefix from mainnet history
but does not exist on the fork.  _build_result_from_subsquid_action()
previously caught the TransactionNotFound exception and still returned a
non-None OrderStatusResult(execution_receipt=None), which caused
check_order_status() to short-circuit and skip the on-chain RPC log scan
fallback.

As a result fetch_order() kept the cached "open" status instead of
detecting the cancellation, causing test_ccxt_fetch_order_detects_cancelled
to fail with "Expected status 'cancelled', got: open".

Fix: return None from _build_result_from_subsquid_action() when the receipt
fetch raises an exception.  A None return causes check_order_status() to
fall through to _scan_event_emitter_logs_chunked(), which correctly finds
the OrderCancelled event in the fork's EventEmitter logs.

This also improves production robustness: transient receipt-fetch failures
no longer leave an order permanently stuck as "open".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move all module-level constants that were spread across the GMX module
into the central eth_defi/gmx/constants.py:

- Gas-critical pause constants (_GAS_CRITICAL_MAX_RETRIES,
  _GAS_CRITICAL_WINDOW_SECS, _GAS_CRITICAL_PAUSE_SECS) from
  freqtrade/gmx_exchange.py
- Execution buffer thresholds (DEFAULT_EXECUTION_BUFFER,
  DEFAULT_SLTP_EXECUTION_BUFFER, DEFAULT_SLTP_EXECUTION_FEE_BUFFER,
  EXECUTION_BUFFER_CRITICAL_THRESHOLD, EXECUTION_BUFFER_WARNING_THRESHOLD,
  EXECUTION_BUFFER_RECOMMENDED_MIN, EXECUTION_BUFFER_RECOMMENDED_MAX) from
  execution_buffer.py
- _MIN_LOG_CHUNK_BLOCKS (was duplicated in both ccxt/exchange.py and
  ccxt/async_support/exchange.py)

execution_buffer.py re-exports all constants via import so existing
callers (trading.py, order/*.py) are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cancel_helpers.py: minor fix
- cancel_order.py: execution buffer import cleanup
- cancel_gmx_orders.py: script improvements
- test_cancel_helpers.py: additional coverage
- test_ccxt_price_sanity.py: test cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add all missing public types and functions to the order module's
__init__.py so callers can import everything from one place:

New exports: SwapOrder, BaseOrder, OrderParams, CancelOrderResult,
BatchCancelOrderResult, SLTPEntry, SLTPParams, SLTPOrderResult,
OrderArgumentParser, fetch_pending_order_count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move validate_execution_buffer() and apply_execution_buffer() into
gas_utils.py alongside the other gas calculation utilities. The constants
were already in constants.py; the functions were the only remaining
content.

Update all 7 import sites:
- DEFAULT_EXECUTION_BUFFER / DEFAULT_SLTP_EXECUTION_FEE_BUFFER
  now imported from eth_defi.gmx.constants
- apply_execution_buffer / validate_execution_buffer
  now imported from eth_defi.gmx.gas_utils

Remove eth_defi.gmx.execution_buffer entry from Sphinx docs index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_gm_prices

GMX Reader's getMarketTokenPrice returns a signed int256 and does not revert
when pool value is negative. The OM/MANTRA pool went insolvent after the ~90%
crash in April 2025, making both checks incorrect:

- `!= 0` wrongly treated a bug as valid (negative is not non-zero by accident)
- `pytest.approx(rel=0.01)` cross-type comparison fails because FOR_TRADERS,
  FOR_DEPOSITS and FOR_WITHDRAWALS caps produce legitimately different prices
  for distressed pools (OM traders≈-27.04 vs deposits≈-26.55, ~1.8% spread)

The only invariant that must hold is that each price is a finite float, so
all value-range assertions are replaced with math.isfinite() checks.
Well-known healthy markets (ETH/BTC/ARB/LINK) in test_get_gm_prices_specific_markets
still assert price > 0, as a negative price there would indicate a real problem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Aviksaikat Aviksaikat force-pushed the feat/gmx-cancel-limit-orders branch from d57597a to a70da39 Compare February 28, 2026 15:30
@miohtama miohtama merged commit b4e5a6b into master Feb 28, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants