diff --git a/.github/workflows/common_check.yaml b/.github/workflows/common_check.yaml index 40af8071..02697627 100644 --- a/.github/workflows/common_check.yaml +++ b/.github/workflows/common_check.yaml @@ -19,7 +19,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.10" - "3.11" poetry-version: ["2.0.1"] os: [ubuntu-22.04,] diff --git a/derive_client/_bridge/client.py b/derive_client/_bridge/client.py index e7d2d191..2c7e88fa 100644 --- a/derive_client/_bridge/client.py +++ b/derive_client/_bridge/client.py @@ -123,8 +123,18 @@ def _get_min_fees( class BridgeClient: + """ + Synchronous constructor that performs minimal, non-blocking setup. + + Args: + env: Environment to connect to (only PROD supported for bridging) + account: Account object containing the private key of the owner of the smart contract funding account + wallet: Address of the smart contract funding account + logger: Logger instance for logging + + """ + def __init__(self, env: Environment, account: Account, wallet: Address, logger: Logger): - """Synchronous constructor that performs minimal, non-blocking setup.""" if not env == Environment.PROD: raise RuntimeError(f"Bridging is not supported in the {env.name} environment.") diff --git a/derive_client/cli.py b/derive_client/cli.py index 4494f4c9..6cb63687 100644 --- a/derive_client/cli.py +++ b/derive_client/cli.py @@ -752,5 +752,62 @@ def create_order(ctx, instrument_name, side, price, amount, order_type, instrume print(result) +@positions.command("transfer") +@click.pass_context +@click.option( + "--instrument-name", + "-i", + type=str, + required=True, + help="Name of the instrument to transfer", +) +@click.option( + "--amount", + "-a", + type=float, + required=True, + help="Amount to transfer (absolute value)", +) +@click.option( + "--limit-price", + "-p", + type=float, + required=True, + help="Limit price for the transfer", +) +@click.option( + "--from-subaccount", + "-f", + type=int, + required=True, + help="Subaccount ID to transfer from", +) +@click.option( + "--to-subaccount", + "-t", + type=int, + required=True, + help="Subaccount ID to transfer to", +) +@click.option( + "--position-amount", + type=float, + default=None, + help="Original position amount (if not provided, will be fetched)", +) +def transfer_position(ctx, instrument_name, amount, limit_price, from_subaccount, to_subaccount, position_amount): + """Transfer a single position between subaccounts.""" + client: DeriveClient = ctx.obj["client"] + result = client.transfer_position( + instrument_name=instrument_name, + amount=amount, + limit_price=limit_price, + from_subaccount_id=from_subaccount, + to_subaccount_id=to_subaccount, + position_amount=position_amount, + ) + print(result) + + if __name__ == "__main__": cli() # pylint: disable=no-value-for-parameter diff --git a/derive_client/clients/base_client.py b/derive_client/clients/base_client.py index d779fd67..bc78d1b3 100644 --- a/derive_client/clients/base_client.py +++ b/derive_client/clients/base_client.py @@ -4,6 +4,7 @@ import json import random +import time from decimal import Decimal from logging import Logger, LoggerAdapter from time import sleep @@ -12,10 +13,15 @@ import requests from derive_action_signing.module_data import ( DepositModuleData, + MakerTransferPositionModuleData, + MakerTransferPositionsModuleData, RecipientTransferERC20ModuleData, SenderTransferERC20ModuleData, + TakerTransferPositionModuleData, + TakerTransferPositionsModuleData, TradeModuleData, TransferERC20Details, + TransferPositionsDetails, WithdrawModuleData, ) from derive_action_signing.signed_action import SignedAction @@ -41,6 +47,9 @@ OrderSide, OrderStatus, OrderType, + PositionSpec, + PositionsTransfer, + PositionTransfer, RfqStatus, SessionKey, SubaccountType, @@ -88,7 +97,11 @@ def __init__( self.signer = self.web3_client.eth.account.from_key(private_key) self.wallet = wallet self._verify_wallet(wallet) - self.subaccount_id = self._determine_subaccount_id(subaccount_id) + self.subaccount_ids = self.fetch_subaccounts().get("subaccount_ids", []) + if subaccount_id is not None and subaccount_id not in self.subaccount_ids: + msg = f"Provided subaccount {subaccount_id} not among retrieved aubaccounts: {self.subaccounts!r}" + raise ValueError(msg) + self.subaccount_id = subaccount_id or self.subaccount_ids[0] @property def account(self): @@ -114,16 +127,6 @@ def _verify_wallet(self, wallet: Address): msg = f"{self.signer.address} is not among registered session keys for wallet {wallet}." raise ValueError(msg) - def _determine_subaccount_id(self, subaccount_id: int | None) -> int: - subaccounts = self.fetch_subaccounts() - if not (subaccount_ids := subaccounts.get("subaccount_ids", [])): - raise ValueError(f"No subaccounts found for {self.wallet}. Please create one on Derive first.") - if subaccount_id is not None and subaccount_id not in subaccount_ids: - raise ValueError(f"Provided subaccount {subaccount_id} not among retrieved aubaccounts: {subaccounts!r}") - subaccount_id = subaccount_id or subaccount_ids[0] - self.logger.debug(f"Selected subaccount_id: {subaccount_id}") - return subaccount_id - def create_account(self, wallet): """Call the create account endpoint.""" payload = {"wallet": wallet} @@ -147,7 +150,7 @@ def fetch_instruments( ): """ Return the tickers. - First fetch all instrucments + First fetch all instruments Then get the ticket for all instruments. """ url = self.endpoints.public.get_instruments @@ -250,8 +253,8 @@ def create_order( **signed_action.to_json(), } - response = self.submit_order(order) - return response + url = self.endpoints.private.order + return self._send_request(url, json=order)["order"] def _generate_signed_action( self, @@ -342,6 +345,16 @@ def fetch_tickers( instruments = self.fetch_instruments(instrument_type=instrument_type, currency=currency) return {inst["instrument_name"]: self.fetch_ticker(inst["instrument_name"]) for inst in instruments} + def get_order(self, order_id: str) -> dict: + url = self.endpoints.private.get_order + headers = self._create_signature_headers() + payload = { + "order_id": order_id, + "subaccount_id": self.subaccount_id, + } + response = requests.post(url, json=payload, headers=headers) + return response.json()["result"] + def fetch_orders( self, instrument_name: str = None, @@ -781,3 +794,251 @@ def transfer_from_subaccount_to_funding(self, amount: int, asset_name: str, suba condition=_is_final_tx, transaction_id=withdraw_result.transaction_id, ) + + def transfer_position( + self, + instrument_name: str, + amount: float, + to_subaccount_id: int, + ) -> PositionTransfer: + """ + Transfer a position from the current subaccount to another subaccount. + + Parameters: + instrument_name (str): Instrument to transfer (e.g. 'ETH-PERP'). + amount (float): Amount to transfer. Positive for a long, negative for a short. + to_subaccount_id (int): Destination subaccount id (must be different and present in self.subaccount_ids). + + Returns: + PositionTransfer: Result containing maker/taker order and trade details. + """ + if to_subaccount_id == self.subaccount_id: + raise ValueError("Target subaccount is self") + if to_subaccount_id not in self.subaccount_ids: + raise ValueError(f"Subaccount id {to_subaccount_id} not in {self.subaccount_ids}") + + url = self.endpoints.private.transfer_position + positions = [p for p in self.get_positions() if p["instrument_name"] == instrument_name] + if not len(positions) == 1: + raise ValueError(f"Expected to find a position for {instrument_name}, found: {positions}") + + position = positions[0] + position_amount = float(position["amount"]) + if abs(position_amount) < abs(amount): + raise ValueError(f"Position {position_amount} not sufficient for transfer {amount}") + + ticker = self.fetch_ticker(instrument_name=instrument_name) + mark_price = Decimal(ticker["mark_price"]).quantize(Decimal(ticker["tick_size"])) + base_asset_address = ticker["base_asset_address"] + base_asset_sub_id = int(ticker["base_asset_sub_id"]) + + transfer_amount = Decimal(str(abs(amount))) + original_position_amount = Decimal(str(position_amount)) + + maker_action = SignedAction( + subaccount_id=self.subaccount_id, + owner=self.wallet, + signer=self.signer.address, + signature_expiry_sec=MAX_INT_32, + nonce=get_action_nonce(), + module_address=self.config.contracts.TRADE_MODULE, + module_data=MakerTransferPositionModuleData( + asset_address=base_asset_address, + sub_id=base_asset_sub_id, + limit_price=mark_price, + amount=transfer_amount, + recipient_id=self.subaccount_id, + position_amount=original_position_amount, + ), + DOMAIN_SEPARATOR=self.config.DOMAIN_SEPARATOR, + ACTION_TYPEHASH=self.config.ACTION_TYPEHASH, + ) + + # Small delay to ensure different nonces + time.sleep(0.001) + + taker_action = SignedAction( + subaccount_id=to_subaccount_id, + owner=self.wallet, + signer=self.signer.address, + signature_expiry_sec=MAX_INT_32, + nonce=get_action_nonce(), + module_address=self.config.contracts.TRADE_MODULE, + module_data=TakerTransferPositionModuleData( + asset_address=base_asset_address, + sub_id=base_asset_sub_id, + limit_price=mark_price, + amount=transfer_amount, + recipient_id=to_subaccount_id, + position_amount=original_position_amount, + ), + DOMAIN_SEPARATOR=self.config.DOMAIN_SEPARATOR, + ACTION_TYPEHASH=self.config.ACTION_TYPEHASH, + ) + + maker_action.sign(self.signer.key) + taker_action.sign(self.signer.key) + + maker_params = { + "direction": maker_action.module_data.get_direction(), + "instrument_name": instrument_name, + **maker_action.to_json(), + } + taker_params = { + "direction": taker_action.module_data.get_direction(), + "instrument_name": instrument_name, + **taker_action.to_json(), + } + + payload = { + "wallet": self.wallet, + "maker_params": maker_params, + "taker_params": taker_params, + } + + response_data = self._send_request(url, json=payload) + position_transfer = PositionTransfer(**response_data) + + return position_transfer + + def get_position_amount(self, instrument_name: str, subaccount_id: int) -> float: + """ + Get the current position amount for a specific instrument in a subaccount. + + This is a helper method for getting position amounts to use with transfer_position(). + + Parameters: + instrument_name (str): The name of the instrument. + subaccount_id (int): The subaccount ID to check. + + Returns: + float: The current position amount. + + Raises: + ValueError: If no position found for the instrument in the subaccount. + """ + positions = self.get_positions() + # get_positions() returns a list directly + position_list = positions if isinstance(positions, list) else positions.get("positions", []) + for pos in position_list: + if pos["instrument_name"] == instrument_name: + return float(pos["amount"]) + + raise ValueError(f"No position found for {instrument_name} in subaccount {subaccount_id}") + + def transfer_positions( + self, + positions: list[PositionSpec], # amount, instrument_name + to_subaccount_id: int, + direction: OrderSide, # TransferDirection + ) -> PositionsTransfer: + """ + Transfer multiple positions between subaccounts using RFQ system. + Parameters: + positions (list[TransferPosition]): list of TransferPosition objects containing: + - instrument_name (str): Name of the instrument + - amount (float): Amount to transfer (must be positive) + - limit_price (float): Limit price for the transfer (must be positive) + from_subaccount_id (int): The subaccount ID to transfer from. + to_subaccount_id (int): The subaccount ID to transfer to. + global_direction (str): Global direction for the transfer ("buy" or "sell"). + Returns: + DeriveTxResult: The result of the transfer transaction. + Raises: + ValueError: If positions list is empty, invalid global_direction, or if any instrument not found. + """ + + if to_subaccount_id == self.subaccount_id: + raise ValueError("Target subaccount is self") + if to_subaccount_id not in self.subaccount_ids: + raise ValueError(f"Subaccount id {to_subaccount_id} not in {self.subaccount_ids}") + + url = self.endpoints.private.transfer_positions + current_positions = {p["instrument_name"]: p for p in self.get_positions()} + + transfer_details = [] + for position in positions: + amount = Decimal(str(position.amount)) + instrument_name = position.instrument_name + + if (current_position := current_positions.get(instrument_name)) is None: + available = ", ".join(sorted(current_positions.keys())) or "none" + msg = f"No position for {instrument_name} (available: {available})" + raise ValueError(msg) + + current_amount = Decimal(current_position["amount"]).quantize(Decimal(current_position["amount_step"])) + if abs(current_amount) < abs(amount): + msg = f"Insufficient position for {instrument_name}: have {current_amount}, need {amount}" + raise ValueError(msg) + + ticker = self.fetch_ticker(instrument_name=instrument_name) + mark_price = Decimal(ticker["mark_price"]).quantize(Decimal(ticker["tick_size"])) + base_asset_address = ticker["base_asset_address"] + base_asset_sub_id = int(ticker["base_asset_sub_id"]) + + transfer_amount = Decimal(str(abs(amount))) + + leg_direction = "sell" if amount > 0 else "buy" + transfer_details.append( + TransferPositionsDetails( + instrument_name=instrument_name, + direction=leg_direction, + asset_address=base_asset_address, + sub_id=base_asset_sub_id, + price=mark_price, + amount=transfer_amount, + ) + ) + + # Derive RPC -32602: Invalid params [data=['Legs must be sorted by instrument name']] + transfer_details.sort(key=lambda x: x.instrument_name) + + # Create maker action (sender) - USING RFQ_MODULE, not TRADE_MODULE + maker_action = SignedAction( + subaccount_id=self.subaccount_id, + owner=self.wallet, + signer=self.signer.address, + signature_expiry_sec=MAX_INT_32, + nonce=get_action_nonce(), # maker_nonce + module_address=self.config.contracts.RFQ_MODULE, + module_data=MakerTransferPositionsModuleData( + global_direction=direction.value, + positions=transfer_details, + ), + DOMAIN_SEPARATOR=self.config.DOMAIN_SEPARATOR, + ACTION_TYPEHASH=self.config.ACTION_TYPEHASH, + ) + + # Small delay to ensure different nonces + time.sleep(0.001) + + # Create taker action (recipient) - USING RFQ_MODULE, not TRADE_MODULE + opposite_direction = "sell" if direction.value == "buy" else "buy" + taker_action = SignedAction( + subaccount_id=to_subaccount_id, + owner=self.wallet, + signer=self.signer.address, + signature_expiry_sec=MAX_INT_32, + nonce=get_action_nonce(), + module_address=self.config.contracts.RFQ_MODULE, + module_data=TakerTransferPositionsModuleData( + global_direction=opposite_direction, + positions=transfer_details, + ), + DOMAIN_SEPARATOR=self.config.DOMAIN_SEPARATOR, + ACTION_TYPEHASH=self.config.ACTION_TYPEHASH, + ) + + maker_action.sign(self.signer.key) + taker_action.sign(self.signer.key) + + payload = { + "wallet": self.wallet, + "maker_params": maker_action.to_json(), + "taker_params": taker_action.to_json(), + } + + response_data = self._send_request(url, json=payload) + positions_transfer = PositionsTransfer(**response_data) + + return positions_transfer diff --git a/derive_client/constants.py b/derive_client/constants.py index 26ee9501..23e1ab4e 100644 --- a/derive_client/constants.py +++ b/derive_client/constants.py @@ -15,6 +15,7 @@ class ContractAddresses(BaseModel, frozen=True): ETH_OPTION: str BTC_OPTION: str TRADE_MODULE: str + RFQ_MODULE: str STANDARD_RISK_MANAGER: str BTC_PORTFOLIO_RISK_MANAGER: str ETH_PORTFOLIO_RISK_MANAGER: str @@ -61,6 +62,7 @@ class EnvConfig(BaseModel, frozen=True): ETH_OPTION="0xBcB494059969DAaB460E0B5d4f5c2366aab79aa1", BTC_OPTION="0xAeB81cbe6b19CeEB0dBE0d230CFFE35Bb40a13a7", TRADE_MODULE="0x87F2863866D85E3192a35A73b388BD625D83f2be", + RFQ_MODULE="0x4E4DD8Be1e461913D9A5DBC4B830e67a8694ebCa", STANDARD_RISK_MANAGER="0x28bE681F7bEa6f465cbcA1D25A2125fe7533391C", BTC_PORTFOLIO_RISK_MANAGER="0xbaC0328cd4Af53d52F9266Cdbd5bf46720320A20", ETH_PORTFOLIO_RISK_MANAGER="0xDF448056d7bf3f9Ca13d713114e17f1B7470DeBF", @@ -84,6 +86,7 @@ class EnvConfig(BaseModel, frozen=True): ETH_OPTION="0x4BB4C3CDc7562f08e9910A0C7D8bB7e108861eB4", BTC_OPTION="0xd0711b9eBE84b778483709CDe62BacFDBAE13623", TRADE_MODULE="0xB8D20c2B7a1Ad2EE33Bc50eF10876eD3035b5e7b", + RFQ_MODULE="0x9371352CCef6f5b36EfDFE90942fFE622Ab77F1D", STANDARD_RISK_MANAGER="0x28c9ddF9A3B29c2E6a561c1BC520954e5A33de5D", BTC_PORTFOLIO_RISK_MANAGER="0x45DA02B9cCF384d7DbDD7b2b13e705BADB43Db0D", ETH_PORTFOLIO_RISK_MANAGER="0xe7cD9370CdE6C9b5eAbCe8f86d01822d3de205A0", diff --git a/derive_client/data/rpc_endpoints.yaml b/derive_client/data/rpc_endpoints.yaml index adb3658a..aed473e8 100644 --- a/derive_client/data/rpc_endpoints.yaml +++ b/derive_client/data/rpc_endpoints.yaml @@ -35,7 +35,7 @@ ARBITRUM: - https://arb1.lava.build - https://arbitrum.rpc.subquery.network/public - https://arb-pokt.nodies.app - - https://arbitrum.api.onfinality.io/public + # - https://arbitrum.api.onfinality.io/public DERIVE: - https://rpc.derive.xyz/ diff --git a/derive_client/data_types/__init__.py b/derive_client/data_types/__init__.py index 337b446b..5635777e 100644 --- a/derive_client/data_types/__init__.py +++ b/derive_client/data_types/__init__.py @@ -43,10 +43,14 @@ ManagerAddress, MintableTokenData, NonMintableTokenData, + PositionSpec, + PositionsTransfer, + PositionTransfer, PreparedBridgeTx, PSignedTransaction, RPCEndpoints, SessionKey, + TransferPosition, TxResult, Wei, WithdrawResult, @@ -96,8 +100,12 @@ "DeriveTxResult", "SocketAddress", "RPCEndpoints", + "TransferPosition", "BridgeTxDetails", + "PositionSpec", "PreparedBridgeTx", "PSignedTransaction", "Wei", + "PositionTransfer", + "PositionsTransfer", ] diff --git a/derive_client/data_types/enums.py b/derive_client/data_types/enums.py index 508e2330..c4e9b644 100644 --- a/derive_client/data_types/enums.py +++ b/derive_client/data_types/enums.py @@ -20,6 +20,13 @@ class DeriveTxStatus(Enum): TIMED_OUT = "timed_out" +class QuoteStatus(Enum): + OPEN = "open" + FILLED = "filled" + CANCELLED = "cancelled" + EXPIRED = "expired" + + class BridgeType(Enum): SOCKET = "socket" LAYERZERO = "layerzero" @@ -187,6 +194,11 @@ class OrderStatus(Enum): EXPIRED = "expired" +class LiquidityRole(Enum): + MAKER = "maker" + TAKER = "taker" + + class TimeInForce(Enum): """Time in force.""" diff --git a/derive_client/data_types/models.py b/derive_client/data_types/models.py index c01102e5..58cbfa6a 100644 --- a/derive_client/data_types/models.py +++ b/derive_client/data_types/models.py @@ -8,7 +8,16 @@ from eth_account.datastructures import SignedTransaction from eth_utils import is_0x_prefixed, is_address, is_hex, to_checksum_address from hexbytes import HexBytes -from pydantic import BaseModel, ConfigDict, Field, GetCoreSchemaHandler, GetJsonSchemaHandler, HttpUrl, RootModel +from pydantic import ( + BaseModel, + ConfigDict, + Field, + GetCoreSchemaHandler, + GetJsonSchemaHandler, + HttpUrl, + PositiveFloat, + RootModel, +) from pydantic.dataclasses import dataclass from pydantic_core import core_schema from web3 import AsyncWeb3, Web3 @@ -24,9 +33,14 @@ Currency, DeriveTxStatus, GasPriority, + LiquidityRole, MainnetCurrency, MarginType, + OrderSide, + OrderStatus, + QuoteStatus, SessionKeyScope, + TimeInForce, TxStatus, ) @@ -399,6 +413,15 @@ class WithdrawResult(BaseModel): transaction_id: str +class TransferPosition(BaseModel): + """Model for position transfer data.""" + + # Ref: https://docs.pydantic.dev/2.3/usage/types/number_types/#constrained-types + instrument_name: str + amount: PositiveFloat + limit_price: PositiveFloat + + class DeriveTxResult(BaseModel): data: dict # Data used to create transaction status: DeriveTxStatus @@ -446,3 +469,106 @@ def __getitem__(self, key: GasPriority): def items(self): return self.root.items() + + +class Order(BaseModel): + amount: float + average_price: float + cancel_reason: str + creation_timestamp: int + direction: OrderSide + filled_amount: float + instrument_name: str + is_transfer: bool + label: str + last_update_timestamp: int + limit_price: float + max_fee: float + mmp: bool + nonce: int + order_fee: float + order_id: str + order_status: OrderStatus + order_type: str + quote_id: None + replaced_order_id: str | None + signature: str + signature_expiry_sec: int + signer: str + subaccount_id: int + time_in_force: TimeInForce + trigger_price: float | None + trigger_price_type: str | None + trigger_reject_message: str | None + trigger_type: str | None + + +class Trade(BaseModel): + direction: OrderSide + expected_rebate: float + index_price: float + instrument_name: str + is_transfer: bool + label: str + liquidity_role: LiquidityRole + mark_price: float + order_id: str + quote_id: None + realized_pnl: float + realized_pnl_excl_fees: float + subaccount_id: int + timestamp: int + trade_amount: float + trade_fee: float + trade_id: str + trade_price: float + transaction_id: str + tx_hash: str | None + tx_status: DeriveTxStatus + + +class PositionSpec(BaseModel): + amount: float # negative allowed to indicate direction + instrument_name: str + + +class PositionTransfer(BaseModel): + maker_order: Order + taker_order: Order + maker_trade: Trade + taker_trade: Trade + + +class Leg(BaseModel): + amount: float + direction: OrderSide # TODO: PositionSide + instrument_name: str + price: float + + +class Quote(BaseModel): + cancel_reason: str + creation_timestamp: int + direction: OrderSide + fee: float + fill_pct: int + is_transfer: bool + label: str + last_update_timestamp: int + legs: list[Leg] + legs_hash: str + liquidity_role: LiquidityRole + max_fee: float + mmp: bool + nonce: int + quote_id: str + rfq_id: str + signature: str + signature_expiry_sec: int + signer: Address + status: QuoteStatus + + +class PositionsTransfer(BaseModel): + maker_quote: Quote + taker_quote: Quote diff --git a/derive_client/endpoints.py b/derive_client/endpoints.py index 1276ab6a..da02fb98 100644 --- a/derive_client/endpoints.py +++ b/derive_client/endpoints.py @@ -34,11 +34,14 @@ def __init__(self, base_url: str): session_keys = Endpoint("private", "session_keys") get_subaccount = Endpoint("private", "get_subaccount") get_subaccounts = Endpoint("private", "get_subaccounts") + get_order = Endpoint("private", "get_order") get_orders = Endpoint("private", "get_orders") get_positions = Endpoint("private", "get_positions") get_collaterals = Endpoint("private", "get_collaterals") create_subaccount = Endpoint("private", "create_subaccount") transfer_erc20 = Endpoint("private", "transfer_erc20") + transfer_position = Endpoint("private", "transfer_position") + transfer_positions = Endpoint("private", "transfer_positions") get_mmp_config = Endpoint("private", "get_mmp_config") set_mmp_config = Endpoint("private", "set_mmp_config") send_rfq = Endpoint("private", "send_rfq") diff --git a/examples/transfer_position.py b/examples/transfer_position.py new file mode 100644 index 00000000..a545d94a --- /dev/null +++ b/examples/transfer_position.py @@ -0,0 +1,149 @@ +""" +Example: Transfer a single position using derive_client + +This example shows how to use the derive_client to transfer a single position +between subaccounts using the transfer_position method. +""" + +import time + +from rich import print + +from derive_client.data_types import Environment, InstrumentType, OrderSide, OrderType, UnderlyingCurrency +from derive_client.derive import DeriveClient + +# Configuration - update these values for your setup +WALLET = "0xA419f70C696a4b449a4A24F92e955D91482d44e9" +PRIVATE_KEY = "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd" +ENVIRONMENT = Environment.TEST + + +def main(): + """Example of transferring a single position between subaccounts.""" + print("[blue]=== Single Position Transfer Example ===[/blue]\n") + + # Initialize client + client = DeriveClient( + wallet=WALLET, + private_key=PRIVATE_KEY, + env=ENVIRONMENT, + ) + + # Get subaccounts + subaccounts = client.fetch_subaccounts() + subaccount_ids = subaccounts.get("subaccount_ids", []) + + if len(subaccount_ids) < 2: + print("Error: Need at least 2 subaccounts for transfer") + return + + from_subaccount_id = subaccount_ids[0] + to_subaccount_id = subaccount_ids[1] + + print(f"Using subaccounts: {from_subaccount_id} -> {to_subaccount_id}") + + # Find an active instrument + instruments = client.fetch_instruments( + instrument_type=InstrumentType.PERP, currency=UnderlyingCurrency.ETH, expired=False + ) + + active_instruments = [inst for inst in instruments if inst.get("is_active", True)] + if not active_instruments: + print("No active instruments found") + return + + instrument_name = active_instruments[0]["instrument_name"] + print(f"Using instrument: {instrument_name}") + + # Check if we have a position to transfer + client.subaccount_id = from_subaccount_id + try: + position_amount = client.get_position_amount(instrument_name, from_subaccount_id) + print(f"Found existing position: {position_amount}") + except ValueError: + # Create a small position for demonstration + print("No existing position - creating one for demo...") + + ticker = client.fetch_ticker(instrument_name) + mark_price = float(ticker["mark_price"]) + trade_price = round(mark_price, 2) + + # Create a small short position + order_result = client.create_order( + price=trade_price, + amount=1.0, + instrument_name=instrument_name, + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + instrument_type=InstrumentType.PERP, + ) + print(f"Created order: {order_result['order_id']}") + + time.sleep(3) # Wait for fill + + try: + position_amount = client.get_position_amount(instrument_name, from_subaccount_id) + print(f"Position after trade: {position_amount}") + except ValueError: + print("Failed to create position") + return + + if abs(position_amount) < 0.01: + print("No meaningful position to transfer") + return + + # Get current market price for transfer + ticker = client.fetch_ticker(instrument_name) + transfer_price = float(ticker["mark_price"]) + # beacuse of `must not have more than 2 decimal places` error from the derive API + transfer_price = round(transfer_price, 2) + + print("\nTransferring position...") + print(f" Amount: {abs(position_amount)}") + print(f" Price: {transfer_price}") + print(f" From: {from_subaccount_id}") + print(f" To: {to_subaccount_id}") + + # Execute the transfer + transfer_result = client.transfer_position( + instrument_name=instrument_name, + amount=abs(position_amount), + limit_price=transfer_price, + from_subaccount_id=from_subaccount_id, + to_subaccount_id=to_subaccount_id, + position_amount=position_amount, + instrument_type=InstrumentType.PERP, + currency=UnderlyingCurrency.ETH, + ) + + print("\nTransfer completed!") + print(f"Transaction ID: {transfer_result.transaction_id}") + print(f"Status: {transfer_result.status.value}") + + # Wait for settlement + time.sleep(3) + + # Verify the transfer + print("\nVerifying transfer...") + + # Check source position + client.subaccount_id = from_subaccount_id + try: + source_position = client.get_position_amount(instrument_name, from_subaccount_id) + print(f"Source position: {source_position}") + except ValueError: + print("Source position: 0") + + # Check target position + client.subaccount_id = to_subaccount_id + try: + target_position = client.get_position_amount(instrument_name, to_subaccount_id) + print(f"Target position: {target_position}") + except ValueError: + print("Target position: 0") + + print("\nTransfer example completed!") + + +if __name__ == "__main__": + main() diff --git a/examples/transfer_positions.py b/examples/transfer_positions.py new file mode 100644 index 00000000..d5915d33 --- /dev/null +++ b/examples/transfer_positions.py @@ -0,0 +1,260 @@ +""" +Example demonstrating multiple position transfers using transfer_positions method. + +This script shows how to transfer multiple positions between subaccounts in a single transaction. +Based on the working debug_position_lifecycle patterns. +""" + +import time + +from rich import print + +from derive_client.data_types import ( + DeriveTxResult, + DeriveTxStatus, + Environment, + InstrumentType, + OrderSide, + OrderType, + TransferPosition, + UnderlyingCurrency, +) +from derive_client.derive import DeriveClient + +# Configuration - update these values for your setup +WALLET = "0xA419f70C696a4b449a4A24F92e955D91482d44e9" +PRIVATE_KEY = "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd" +ENVIRONMENT = Environment.TEST + + +def create_guaranteed_position( + client, instrument_name, instrument_type, from_subaccount_id, to_subaccount_id, target_amount +): + """Create a position using guaranteed fill strategy.""" + ticker = client.fetch_ticker(instrument_name) + mark_price = float(ticker["mark_price"]) + trade_price = round(mark_price, 2) + + print(f"Creating {target_amount} position in {instrument_name} at {trade_price}") + + # Create counterparty order first + client.subaccount_id = to_subaccount_id + counterparty_side = OrderSide.BUY if target_amount < 0 else OrderSide.SELL + + counterparty_order = client.create_order( + price=trade_price, + amount=abs(target_amount), + instrument_name=instrument_name, + side=counterparty_side, + order_type=OrderType.LIMIT, + instrument_type=instrument_type, + ) + print(f"Counterparty order: {counterparty_order['order_id']}") + time.sleep(1.0) + + # Create main order + client.subaccount_id = from_subaccount_id + main_side = OrderSide.SELL if target_amount < 0 else OrderSide.BUY + + main_order = client.create_order( + price=trade_price, + amount=abs(target_amount), + instrument_name=instrument_name, + side=main_side, + order_type=OrderType.LIMIT, + instrument_type=instrument_type, + ) + print(f"Main order: {main_order['order_id']}") + time.sleep(2.0) + + # Check position + try: + position = client.get_position_amount(instrument_name, from_subaccount_id) + print(f"Position created: {position}") + return position, trade_price + except ValueError: + print(f"Failed to create position in {instrument_name}") + return 0, trade_price + + +def main(): + """Example of transferring multiple positions between subaccounts.""" + print("[yellow]=== Multiple Position Transfer Example ===\n") + + # Initialize client + client = DeriveClient( + wallet=WALLET, + private_key=PRIVATE_KEY, + env=ENVIRONMENT, + ) + + # Get subaccounts + subaccounts = client.fetch_subaccounts() + subaccount_ids = subaccounts.get("subaccount_ids", []) + + if len(subaccount_ids) < 2: + print("Error: Need at least 2 subaccounts for transfer") + return + + from_subaccount_id = subaccount_ids[0] + to_subaccount_id = subaccount_ids[1] + + print(f"Using subaccounts: {from_subaccount_id} -> {to_subaccount_id}") + + # Find active instruments - focus on ETH-PERP first + try: + eth_instruments = client.fetch_instruments( + instrument_type=InstrumentType.PERP, currency=UnderlyingCurrency.ETH, expired=False + ) + eth_active = [inst for inst in eth_instruments if inst.get("is_active", True)] + + if not eth_active: + print("No active ETH instruments found") + return + + # Use ETH-PERP as primary instrument + primary_instrument = eth_active[0]["instrument_name"] + print(f"Using primary instrument: {primary_instrument}") + + except Exception as e: + print(f"Error fetching instruments: {e}") + return + + # Check for existing positions first + client.subaccount_id = from_subaccount_id + existing_positions = [] + + try: + eth_position = client.get_position_amount(primary_instrument, from_subaccount_id) + if abs(eth_position) > 0.1: # Meaningful position + existing_positions.append( + {'instrument_name': primary_instrument, 'amount': eth_position, 'instrument_type': InstrumentType.PERP} + ) + print(f"Found existing position in {primary_instrument}: {eth_position}") + except ValueError: + pass + + # If no existing positions, create one using guaranteed fill + if not existing_positions: + print("No existing positions - creating one for demonstration...") + target_amount = -1.5 # Short position + + position_amount, trade_price = create_guaranteed_position( + client, primary_instrument, InstrumentType.PERP, from_subaccount_id, to_subaccount_id, target_amount + ) + + if abs(position_amount) > 0.01: + existing_positions.append( + { + 'instrument_name': primary_instrument, + 'amount': position_amount, + 'instrument_type': InstrumentType.PERP, + } + ) + + if not existing_positions: + print("No positions available for transfer") + return + + print(f"\nPreparing to transfer {len(existing_positions)} positions:") + for pos in existing_positions: + print(f" {pos['instrument_name']}: {pos['amount']}") + + # Create transfer list + transfer_list = [] + for pos in existing_positions: + ticker = client.fetch_ticker(pos['instrument_name']) + transfer_price = float(ticker["mark_price"]) + + transfer_position = TransferPosition( + instrument_name=pos['instrument_name'], + amount=abs(pos['amount']), + limit_price=transfer_price, + ) + transfer_list.append(transfer_position) + print(f"Transfer: {pos['instrument_name']} amount={abs(pos['amount'])} price={transfer_price}") + + # Determine global direction based on first position + # For short positions (negative), use "buy" direction when transferring + first_position = existing_positions[0] + global_direction = "buy" if first_position['amount'] < 0 else "sell" + + print(f"\nExecuting transfer with global_direction='{global_direction}'...") + print("(For short positions, we use 'buy' direction as we're covering/buying back the short)") + + try: + # Execute the transfer + transfer_result = client.transfer_positions( + positions=transfer_list, + from_subaccount_id=from_subaccount_id, + to_subaccount_id=to_subaccount_id, + global_direction=global_direction, + ) + + print("Transfer completed!") + print(f"Transaction ID: {transfer_result.transaction_id}") + print(f"Status: {transfer_result.status.value}") + + except ValueError as e: + if "No valid transaction ID found in response" in str(e): + print(f"Warning: Transaction ID extraction failed: {e}") + print("This is a known issue with transfer_positions - continuing with verification...") + + # Create dummy result for verification + transfer_result = DeriveTxResult( + data={"note": "transaction_id_extraction_failed"}, + status=DeriveTxStatus.SETTLED, + error_log={}, + transaction_id="unknown", + transaction_hash=None, + ) + else: + print(f"Error during transfer: {e}") + print("This might be due to insufficient balance or invalid transfer direction") + return + except Exception as e: + print(f"Error during transfer: {e}") + return + + # Wait for settlement + time.sleep(4) + + # Verify transfers + print("\nVerifying transfers...") + + for pos in existing_positions: + instrument_name = pos['instrument_name'] + original_amount = pos['amount'] + + # Check source position + client.subaccount_id = from_subaccount_id + try: + source_position = client.get_position_amount(instrument_name, from_subaccount_id) + except ValueError: + source_position = 0 + + # Check target position + client.subaccount_id = to_subaccount_id + try: + target_position = client.get_position_amount(instrument_name, to_subaccount_id) + except ValueError: + target_position = 0 + + print(f"\n{instrument_name}:") + print(f" Original: {original_amount}") + print(f" Source after: {source_position}") + print(f" Target after: {target_position}") + + if abs(source_position) < abs(original_amount): + print(" Status: Transfer successful (source position reduced)") + elif abs(target_position) > 0: + print(" Status: Position found in target (may include existing positions)") + else: + print(" Status: Verification inconclusive") + + print("\nMultiple position transfer example completed!") + print("Note: Transfers add to existing positions in target account") + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index e43f54eb..66182f77 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -167,7 +167,7 @@ description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -772,14 +772,14 @@ cython = ["cython"] [[package]] name = "derive-action-signing" -version = "0.0.12" +version = "0.0.13" description = "Python package to sign on-chain self-custodial requests for orders, transfers, deposits, withdrawals and RFQs." optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "derive_action_signing-0.0.12-py3-none-any.whl", hash = "sha256:c7fbee46e8fc6abac9204bec6fee9922d22800f647fee4c44f0e15a72eecd187"}, - {file = "derive_action_signing-0.0.12.tar.gz", hash = "sha256:2ede7861234fd677abd05f88d2e0f27e27966753e02735e938a97be173bd277f"}, + {file = "derive_action_signing-0.0.13-py3-none-any.whl", hash = "sha256:b5fb8ad9d4888a09441da9fc97d66fc0c909d1d1268c54a65c4e8bbd141d6598"}, + {file = "derive_action_signing-0.0.13.tar.gz", hash = "sha256:753b3766e4c836d4cc4b36e076b14e4b9ebf28843a6192312459f718f0236d60"}, ] [package.dependencies] @@ -788,7 +788,7 @@ eth-account = ">=0.13.4" eth-typing = ">=4,<6" hexbytes = ">=0.3.1" setuptools = ">=75.8.0,<76.0.0" -web3 = ">=6.4.0,<7.0.0" +web3 = ">=6.4.0,<8.0.0" [[package]] name = "docopt" @@ -989,7 +989,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -2963,7 +2963,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -3035,7 +3035,7 @@ files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] -markers = {dev = "python_version < \"3.11\""} +markers = {dev = "python_version == \"3.10\""} [[package]] name = "typing-inspection" @@ -3389,4 +3389,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<=3.12" -content-hash = "87604146d8dc7947d3e2e6ae74575cee04664dd1d816ffd7815a6ca5da30c9c7" +content-hash = "46ddf29603fffbbb4d51c3017b93edbbf0c979ee1f444e99956843b028933a4d" diff --git a/pyproject.toml b/pyproject.toml index 4ae79ef4..b71292ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ rich-click = "^1.7.1" python-dotenv = ">=0.14.0,<2" pandas = ">=1,<=3" eth-account = ">=0.13" -derive-action-signing = "^0.0.12" +derive-action-signing = "^0.0.13" pydantic = "^2.11.3" aiolimiter = "^1.2.1" returns = "^0.26.0" diff --git a/tests/conftest.py b/tests/conftest.py index 701e00a9..3c848807 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,8 +12,9 @@ from derive_client.utils import get_logger TEST_WALLET = "0x8772185a1516f0d61fC1c2524926BfC69F95d698" +# this SESSION_KEY_PRIVATE_KEY is not the owner of the wallet TEST_PRIVATE_KEY = "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd" -SUBACCOUNT_ID = 30769 +# TEST_WALLET = "0xA419f70C696a4b449a4A24F92e955D91482d44e9" # SESSION_KEY_PRIVATE_KEY owns this def freeze_time(derive_client): @@ -29,7 +30,6 @@ def derive_client(): derive_client = DeriveClient( wallet=TEST_WALLET, private_key=TEST_PRIVATE_KEY, env=Environment.TEST, logger=get_logger() ) - derive_client.subaccount_id = SUBACCOUNT_ID yield derive_client derive_client.cancel_all() @@ -39,6 +39,5 @@ async def derive_async_client(): derive_client = AsyncClient( wallet=TEST_WALLET, private_key=TEST_PRIVATE_KEY, env=Environment.TEST, logger=get_logger() ) - derive_client.subaccount_id = SUBACCOUNT_ID yield derive_client await derive_client.cancel_all() diff --git a/tests/test_funding_transfers.py b/tests/test_funding_transfers.py index 36f9ebf9..5eaa85fa 100644 --- a/tests/test_funding_transfers.py +++ b/tests/test_funding_transfers.py @@ -19,8 +19,9 @@ ) def test_transfer_from_subaccount_to_funding(derive_client, asset, subaccount_id): """Test transfer from subaccount to funding.""" - # freeze_time(derive_client) + amount = 1 + derive_client.subaccount_id = subaccount_id result = derive_client.transfer_from_subaccount_to_funding( amount=amount, subaccount_id=subaccount_id, @@ -38,12 +39,12 @@ def test_transfer_from_subaccount_to_funding(derive_client, asset, subaccount_id ) def test_transfer_from_subaccount_to_sm_funding(derive_client, asset, subaccount_id): """Test transfer from subaccount to funding.""" - # freeze_time(derive_client) + amount = 1 - from_subaccount_id = derive_client.fetch_subaccounts()['subaccount_ids'][-1] + derive_client.subaccount_id = subaccount_id result = derive_client.transfer_from_funding_to_subaccount( amount=amount, - subaccount_id=from_subaccount_id, + subaccount_id=subaccount_id, asset_name=asset.name, ) assert result diff --git a/tests/test_main.py b/tests/test_main.py index 8d1755ff..3dc82c2c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -286,20 +286,18 @@ def test_transfer_collateral(derive_client): # freeze_time(derive_client) amount = 1 subaccounts = derive_client.fetch_subaccounts() - to = subaccounts['subaccount_ids'][0] + to = subaccounts['subaccount_ids'][1] asset = CollateralAsset.USDC result = derive_client.transfer_collateral(amount, to, asset) assert result -def test_transfer_collateral_steps( - derive_client, -): +def test_transfer_collateral_steps(derive_client): """Test transfer collateral.""" subaccounts = derive_client.fetch_subaccounts() - receiver = subaccounts['subaccount_ids'][0] - sender = subaccounts['subaccount_ids'][1] + receiver = subaccounts['subaccount_ids'][1] + sender = subaccounts['subaccount_ids'][0] pre_account_balance = float(derive_client.fetch_subaccount(sender)['collaterals_value']) asset = CollateralAsset.USDC diff --git a/tests/test_position_transfers.py b/tests/test_position_transfers.py new file mode 100644 index 00000000..9a7749e8 --- /dev/null +++ b/tests/test_position_transfers.py @@ -0,0 +1,320 @@ +""" +Tests for position transfer functionality (transfer_position and transfer_positions methods). +""" + +from decimal import Decimal + +import pytest + +from derive_client.clients.http_client import HttpClient +from derive_client.data_types import ( + DeriveTxResult, + DeriveTxStatus, + InstrumentType, + OrderSide, + OrderStatus, + OrderType, + PositionSpec, + TimeInForce, + UnderlyingCurrency, +) +from derive_client.utils import wait_until + + +def is_settled(res: DeriveTxResult) -> bool: + return res.status is DeriveTxStatus.SETTLED + + +def is_filled(order: dict) -> bool: + return order["order_status"] == OrderStatus.FILLED.value + + +def get_all_positions(derive_client: HttpClient) -> dict[str, list[dict]]: + + _subaccount_id = derive_client.subaccount_id + + def is_zero(position): + return position["amount"] == "0" + + positions = {} + for subaccount_id in derive_client.subaccount_ids: + derive_client.subaccount_id = subaccount_id + positions[subaccount_id] = list(filter(lambda p: not is_zero(p), derive_client.get_positions())) + + derive_client.subaccount_id = _subaccount_id + return positions + + +def close_all_positions(derive_client): + + _subaccount_id = derive_client.subaccount_id + all_positions = get_all_positions(derive_client) + for subaccount_id, positions in all_positions.items(): + derive_client.subaccount_id = subaccount_id # this is nasty + for position in positions: + amount = float(position["amount"]) + + side = OrderSide.SELL if amount > 0 else OrderSide.BUY + ticker = derive_client.fetch_ticker(instrument_name=position["instrument_name"]) + price = float(ticker["best_ask_price"]) if amount < 0 else float(ticker["best_bid_price"]) + price = price * 1.05 if side == OrderSide.BUY else price * 0.95 + price = float(Decimal(price).quantize(Decimal(ticker["tick_size"]))) + amount = abs(amount) + + derive_client.create_order( + price=price, + amount=amount, + instrument_name=position["instrument_name"], + reduce_only=True, + instrument_type=InstrumentType(position["instrument_type"]), + side=side, + order_type=OrderType.MARKET, + time_in_force=TimeInForce.FOK, + ) + + derive_client.subaccount_id = _subaccount_id + + +@pytest.fixture +def client_with_position(request, derive_client): + """Setup position for transfer""" + + currency, instrument_type, side = request.param + + assert len(derive_client.subaccount_ids) >= 2, "Need at least 2 subaccounts for position transfer tests" + + close_all_positions(derive_client) + positions = get_all_positions(derive_client) + if any(positions.values()): + raise ValueError(f"Pre-existing positions found: {positions}") + + instrument_name = f"{currency.name}-{instrument_type.name}" + + ticker = derive_client.fetch_ticker(instrument_name) + if not ticker["is_active"]: + raise RuntimeError(f"Instrument ticker status inactive: {instrument_name}: {ticker}") + + min_amount = float(ticker["minimum_amount"]) + best_price = ticker["best_ask_price"] if side == OrderSide.BUY else ticker["best_bid_price"] + + # Derive RPC 11013: Limit price X must not have more than Y decimal places + price = float(best_price) + + # TODO: balances ???? + collaterals = derive_client.get_collaterals() + assert len(collaterals) == 1, "Account collaterals assumption violated" + + collateral = collaterals.pop() + if float(collateral["mark_value"]) < min_amount * price: + msg = ( + f"Cannot afford minimum position size.\n" + f"Minimum: {min_amount}, Price: {price}, Total cost: {min_amount * price}\n" + f"Collateral market value: {collateral['mark_value']}" + ) + raise ValueError(msg) + + order = derive_client.create_order( + price=price, + amount=min_amount, + instrument_name=instrument_name, + side=side, + order_type=OrderType.MARKET, + instrument_type=instrument_type, + ) + + wait_until( + derive_client.get_order, + condition=is_filled, + order_id=order["order_id"], + ) + + yield derive_client + + close_all_positions(derive_client) + remaining_positions = get_all_positions(derive_client) + if any(remaining_positions.values()): + raise ValueError(f"Post-existing positions found: {remaining_positions}") + + +@pytest.mark.parametrize( + "client_with_position", + [ + (UnderlyingCurrency.ETH, InstrumentType.PERP, OrderSide.BUY), + (UnderlyingCurrency.ETH, InstrumentType.PERP, OrderSide.SELL), + ], + indirect=True, + ids=["eth-perp-buy", "eth-perp-sell"], +) +def test_single_position_transfer(client_with_position): + """Test single position transfer using transfer_position method""" + + derive_client = client_with_position + source_subaccount_id = derive_client.subaccount_ids[0] + target_subaccount_id = derive_client.subaccount_ids[1] + assert derive_client.subaccount_id == source_subaccount_id + + initial_positions = get_all_positions(derive_client) + + if len(source_positions := initial_positions[source_subaccount_id]) != 1: + raise ValueError(f"Expected one open position on source, found: {source_positions}") + if target_positions := initial_positions[target_subaccount_id]: + raise ValueError(f"Expected zero open position on target, found: {target_positions}") + + initial_position = source_positions[0] + amount = float(initial_position["amount"]) + instrument_name = initial_position["instrument_name"] + position_transfer = derive_client.transfer_position( + instrument_name=instrument_name, + amount=amount, + to_subaccount_id=target_subaccount_id, + ) + + assert position_transfer.maker_trade.transaction_id == position_transfer.taker_trade.transaction_id + + derive_tx_result = wait_until( + derive_client.get_transaction, + condition=is_settled, + transaction_id=position_transfer.maker_trade.transaction_id, + ) + + assert derive_tx_result.status == DeriveTxStatus.SETTLED + + action_data = derive_tx_result.data["action_data"] + assert position_transfer.taker_trade.subaccount_id == action_data["taker_account"] + assert position_transfer.maker_trade.subaccount_id == action_data["fill_details"][0]["filled_account"] + + final_positions = get_all_positions(derive_client) + assert len(final_positions[source_subaccount_id]) == 0 + assert len(final_positions[target_subaccount_id]) == 1 + + final_position = final_positions[target_subaccount_id][0] + assert final_position["instrument_name"] == initial_position["instrument_name"] + assert final_position["amount"] == initial_position["amount"] + + +@pytest.fixture +def client_with_positions(derive_client): + """Setup position for transfer""" + currency = UnderlyingCurrency.ETH + instruments = derive_client.fetch_instruments( + instrument_type=InstrumentType.OPTION, + currency=currency, + ) + active = [i for i in instruments if i.get("is_active")] + currency = derive_client.fetch_currency(currency.name) + spot = Decimal(currency["spot_price"]) + + groups = {} + for instrument in active: + option_details = instrument["option_details"] + expiry = option_details["expiry"] + strike = Decimal(option_details["strike"]) + option_type = option_details["option_type"] + key = (expiry, strike) + groups.setdefault(key, {})[option_type] = instrument + + candidates = [] + for (expiry, strike), pair in groups.items(): + if "C" in pair and "P" in pair: + call_ticker = derive_client.fetch_ticker(pair["C"]["instrument_name"]) + put_ticker = derive_client.fetch_ticker(pair["P"]["instrument_name"]) + + # select those that we cannot only open, but also close to cleanup test + call_has_liquidity = ( + Decimal(call_ticker["best_bid_amount"]) > 0 and Decimal(call_ticker["best_ask_amount"]) > 0 + ) + put_has_liquidity = ( + Decimal(put_ticker["best_bid_amount"]) > 0 and Decimal(put_ticker["best_ask_amount"]) > 0 + ) + if call_has_liquidity and put_has_liquidity: + dist = abs(strike - spot) + candidates.append((expiry, dist, strike, call_ticker, put_ticker)) + + # choose earliest expiry, then nearest strike (min dist) + candidates.sort(key=lambda t: (t[0], t[1])) + chosen_expiry, _, chosen_strike, call_ticker, put_ticker = candidates[0] + + call_amount = Decimal(call_ticker["minimum_amount"]) + put_amount = Decimal(put_ticker["minimum_amount"]) + + call_price = float(call_ticker["best_ask_price"]) * 1.05 + put_price = float(put_ticker["best_ask_price"]) * 1.05 + + # call leg + order = derive_client.create_order( + price=call_price, + amount=str(call_amount), + instrument_name=call_ticker["instrument_name"], + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + instrument_type=InstrumentType.OPTION, + time_in_force=TimeInForce.GTC, + ) + + wait_until( + derive_client.get_order, + condition=is_filled, + order_id=order["order_id"], + ) + + # put leg + derive_client.create_order( + price=put_price, + amount=str(put_amount), + instrument_name=put_ticker["instrument_name"], + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + instrument_type=InstrumentType.OPTION, + time_in_force=TimeInForce.GTC, + ) + + wait_until( + derive_client.get_order, + condition=is_filled, + order_id=order["order_id"], + ) + + yield derive_client + + close_all_positions(derive_client) + remaining_positions = get_all_positions(derive_client) + if any(remaining_positions.values()): + raise ValueError(f"Post-existing positions found: {remaining_positions}") + + +def test_transfer_positions(client_with_positions): + """Test transfering positions.""" + + derive_client = client_with_positions + + source_subaccount_id = derive_client.subaccount_ids[0] + target_subaccount_id = derive_client.subaccount_ids[1] + assert derive_client.subaccount_id == source_subaccount_id + + initial_positions = get_all_positions(derive_client) + + if len(source_positions := initial_positions[source_subaccount_id]) < 2: + raise ValueError(f"Expected at least two open position on source, found: {source_positions}") + if target_positions := initial_positions[target_subaccount_id]: + raise ValueError(f"Expected zero open position on target, found: {target_positions}") + + positions = [] + for position in source_positions: + position_spec = PositionSpec( + amount=position["amount"], + instrument_name=position["instrument_name"], + ) + positions.append(position_spec) + + positions_transfer = derive_client.transfer_positions( + positions=positions, + to_subaccount_id=target_subaccount_id, + direction=OrderSide.BUY, + ) + + assert positions_transfer # TODO + + final_positions = get_all_positions(derive_client) + + assert len(final_positions[source_subaccount_id]) == 0 + assert len(final_positions[target_subaccount_id]) > 1