diff --git a/examples/basic_twap_order.py b/examples/basic_twap_order.py new file mode 100644 index 00000000..c8d07649 --- /dev/null +++ b/examples/basic_twap_order.py @@ -0,0 +1,71 @@ +import json +import time + +import example_utils + +from hyperliquid.utils import constants + +PURR = "PURR/USDC" + + +def main(): + address, info, exchange = example_utils.setup(base_url=constants.TESTNET_API_URL, skip_ws=True) + + # Get the user state and print out spot balance information + spot_user_state = info.spot_user_state(address) + if len(spot_user_state["balances"]) > 0: + print("spot balances:") + for balance in spot_user_state["balances"]: + print(json.dumps(balance, indent=2)) + else: + print("no available token balances") + + # Place a TWAP order + # TWAP orders spread the total size over the specified duration in minutes + # is_buy=True for buy, False for sell + # sz=total size to execute over the duration + # minutes=duration in minutes to spread the order + # reduce_only=False (can only reduce position if True) + # randomize=False (randomize execution timing if True) + twap_result = exchange.twap_order(PURR, True, 20, 5, reduce_only=False, randomize=False) + print("TWAP order result:") + print(json.dumps(twap_result, indent=2)) + + # Extract TWAP ID from the response + twap_id = None + if twap_result["status"] == "ok": + response_data = twap_result.get("response", {}).get("data", {}) + status_info = response_data.get("status", {}) + if "running" in status_info: + twap_id = status_info["running"]["twapId"] + print(f"\n✅ TWAP order placed successfully! TWAP ID: {twap_id}") + print(f"Order will execute 20 {PURR} over 5 minutes") + else: + print(f"\n⚠️ Unexpected TWAP status: {status_info}") + else: + print(f"\n❌ TWAP order failed: {twap_result}") + return + + # Wait a moment to let the TWAP start executing + print("\nWaiting 15 seconds before cancelling...") + time.sleep(15) + + # Cancel the TWAP order + if twap_id is not None: + print(f"\nCancelling TWAP order ID: {twap_id}") + cancel_result = exchange.cancel_twap(PURR, twap_id) + print("TWAP cancel result:") + print(json.dumps(cancel_result, indent=2)) + + if cancel_result["status"] == "ok": + response_data = cancel_result.get("response", {}).get("data", {}) + if response_data.get("status") == "success": + print(f"\n✅ TWAP order {twap_id} cancelled successfully!") + else: + print(f"\n⚠️ TWAP cancel response: {response_data}") + else: + print(f"\n❌ TWAP cancel failed: {cancel_result}") + + +if __name__ == "__main__": + main() diff --git a/hyperliquid/exchange.py b/hyperliquid/exchange.py index d677e5f7..469570cf 100644 --- a/hyperliquid/exchange.py +++ b/hyperliquid/exchange.py @@ -32,6 +32,7 @@ sign_usd_class_transfer_action, sign_usd_transfer_action, sign_withdraw_from_bridge_action, + twap_request_to_twap_wire, ) from hyperliquid.utils.types import ( Any, @@ -353,6 +354,95 @@ def schedule_cancel(self, time: Optional[int]) -> Any: timestamp, ) + def twap_order( + self, + name: str, + is_buy: bool, + sz: float, + minutes: int, + reduce_only: bool = False, + randomize: bool = False, + ) -> Any: + """Place a TWAP (Time-Weighted Average Price) order. + + Args: + name: Asset name (e.g., "BTC", "ETH", "PURR/USDC") + is_buy: True for buy, False for sell + sz: Total size to execute over the duration + minutes: Duration in minutes to spread the order + reduce_only: If True, order will only reduce position + randomize: If True, randomize execution timing + + Returns: + Response containing twapId in response.data.status.running.twapId + """ + timestamp = get_timestamp_ms() + + twap_wire = twap_request_to_twap_wire( + { + "coin": name, + "is_buy": is_buy, + "sz": sz, + "reduce_only": reduce_only, + "minutes": minutes, + "randomize": randomize, + }, + self.info.name_to_asset(name), + ) + + twap_action = { + "type": "twapOrder", + "twap": twap_wire, + } + + signature = sign_l1_action( + self.wallet, + twap_action, + self.vault_address, + timestamp, + self.expires_after, + self.base_url == MAINNET_API_URL, + ) + + return self._post_action( + twap_action, + signature, + timestamp, + ) + + def cancel_twap(self, name: str, twap_id: int) -> Any: + """Cancel a TWAP order. + + Args: + name: Asset name (e.g., "BTC", "ETH", "PURR/USDC") + twap_id: The TWAP order ID to cancel + + Returns: + Response with success/error status + """ + timestamp = get_timestamp_ms() + + twap_cancel_action = { + "type": "twapCancel", + "a": self.info.name_to_asset(name), + "t": twap_id, + } + + signature = sign_l1_action( + self.wallet, + twap_cancel_action, + self.vault_address, + timestamp, + self.expires_after, + self.base_url == MAINNET_API_URL, + ) + + return self._post_action( + twap_cancel_action, + signature, + timestamp, + ) + def update_leverage(self, leverage: int, name: str, is_cross: bool = True) -> Any: timestamp = get_timestamp_ms() update_leverage_action = { diff --git a/hyperliquid/utils/signing.py b/hyperliquid/utils/signing.py index c0b3a0a3..8b720537 100644 --- a/hyperliquid/utils/signing.py +++ b/hyperliquid/utils/signing.py @@ -39,6 +39,18 @@ ) CancelRequest = TypedDict("CancelRequest", {"coin": str, "oid": int}) CancelByCloidRequest = TypedDict("CancelByCloidRequest", {"coin": str, "cloid": Cloid}) +TwapRequest = TypedDict( + "TwapRequest", + { + "coin": str, + "is_buy": bool, + "sz": float, + "reduce_only": bool, + "minutes": int, + "randomize": bool, + }, +) +TwapCancelRequest = TypedDict("TwapCancelRequest", {"coin": str, "twap_id": int}) Grouping = Union[Literal["na"], Literal["normalTpsl"], Literal["positionTpsl"]] Order = TypedDict( @@ -67,6 +79,18 @@ }, ) +TwapWire = TypedDict( + "TwapWire", + { + "a": int, + "b": bool, + "s": str, + "r": bool, + "m": int, + "t": bool, + }, +) + ScheduleCancelAction = TypedDict( "ScheduleCancelAction", { @@ -479,6 +503,17 @@ def order_request_to_order_wire(order: OrderRequest, asset: int) -> OrderWire: return order_wire +def twap_request_to_twap_wire(twap: TwapRequest, asset: int) -> TwapWire: + return { + "a": asset, + "b": twap["is_buy"], + "s": float_to_wire(twap["sz"]), + "r": twap["reduce_only"], + "m": twap["minutes"], + "t": twap["randomize"], + } + + def order_wires_to_order_action(order_wires, builder=None): action = { "type": "order", diff --git a/tests/signing_test.py b/tests/signing_test.py index 060c0800..f3a4cb16 100644 --- a/tests/signing_test.py +++ b/tests/signing_test.py @@ -5,6 +5,7 @@ from hyperliquid.utils.signing import ( OrderRequest, ScheduleCancelAction, + TwapRequest, action_hash, construct_phantom_agent, float_to_int_for_hashing, @@ -13,6 +14,7 @@ sign_l1_action, sign_usd_transfer_action, sign_withdraw_from_bridge_action, + twap_request_to_twap_wire, ) from hyperliquid.utils.types import Cloid @@ -271,3 +273,97 @@ def test_schedule_cancel_action(): assert signature_testnet["r"] == "0x4e4f2dbd4107c69783e251b7e1057d9f2b9d11cee213441ccfa2be63516dc5bc" assert signature_testnet["s"] == "0x706c656b23428c8ba356d68db207e11139ede1670481a9e01ae2dfcdb0e1a678" assert signature_testnet["v"] == 27 + + +def test_twap_request_to_twap_wire(): + twap_request: TwapRequest = { + "coin": "ETH", + "is_buy": True, + "sz": 100.5, + "reduce_only": False, + "minutes": 5, + "randomize": False, + } + twap_wire = twap_request_to_twap_wire(twap_request, 1) + assert twap_wire["a"] == 1 + assert twap_wire["b"] is True + assert twap_wire["s"] == "100.5" + assert twap_wire["r"] is False + assert twap_wire["m"] == 5 + assert twap_wire["t"] is False + + +def test_twap_order_action_signing(): + wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123") + twap_request: TwapRequest = { + "coin": "BTC", + "is_buy": True, + "sz": 1.5, + "reduce_only": False, + "minutes": 10, + "randomize": False, + } + twap_wire = twap_request_to_twap_wire(twap_request, 0) + twap_action = { + "type": "twapOrder", + "twap": twap_wire, + } + timestamp = 0 + + signature_mainnet = sign_l1_action( + wallet, + twap_action, + None, + timestamp, + None, + True, + ) + assert signature_mainnet["r"] == "0x459e5dd4a30e60252536cf864a946be1a69da80d937f7841382a1bb471a6ccef" + assert signature_mainnet["s"] == "0x37229416621aaab0b74e4377e87a01e88cb5f946c0eeed4581b640aa859a9bfc" + assert signature_mainnet["v"] == 27 + + signature_testnet = sign_l1_action( + wallet, + twap_action, + None, + timestamp, + None, + False, + ) + assert signature_testnet["r"] == "0x8c99939356b65ea42731d12d5dcecf9c750ab49c9f5112e5356f13d6b94077c2" + assert signature_testnet["s"] == "0x1523831a22a41779ae772cdba6206eaba1cef79081d965bf912a16fd9a6363ab" + assert signature_testnet["v"] == 28 + + +def test_twap_cancel_action_signing(): + wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123") + twap_cancel_action = { + "type": "twapCancel", + "a": 10001, # Spot asset (10000 + index 1) + "t": 12345, # TWAP ID + } + timestamp = 0 + + signature_mainnet = sign_l1_action( + wallet, + twap_cancel_action, + None, + timestamp, + None, + True, + ) + assert signature_mainnet["r"] == "0x459fdfa693f77f07defb7e4bf9302f5f887234d31129cdd0bb9894e0888af54d" + assert signature_mainnet["s"] == "0x46c4f00b90a53c9b51d8825e899188eab52671206a2cb39bcb1e3fade009a1da" + assert signature_mainnet["v"] == 27 + + signature_testnet = sign_l1_action( + wallet, + twap_cancel_action, + None, + timestamp, + None, + False, + ) + assert signature_testnet["r"] == "0x638e0b4fa50bef42ce84ef765423670c6f25da43a9af1c9cfb9f367d628b3226" + assert signature_testnet["s"] == "0x7ffb43b336be19ada86138bd79cde5c6680e97bb479ab222f6e3055daa83bb9e" + assert signature_testnet["v"] == 28