diff --git a/examples/create_partial_tpsl_order.py b/examples/create_partial_tpsl_order.py new file mode 100644 index 0000000..a1476b3 --- /dev/null +++ b/examples/create_partial_tpsl_order.py @@ -0,0 +1,86 @@ +import logging +from asyncio import run +from decimal import Decimal + +from examples.init_env import init_env +from examples.utils import find_order_and_cancel, get_adjust_price_by_pct +from x10.config import BTC_USD_MARKET +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import TESTNET_CONFIG +from x10.perpetual.order_object import OrderTpslTriggerParam, create_order_object +from x10.perpetual.orders import ( + OrderPriceType, + OrderSide, + OrderTpslType, + OrderTriggerPriceType, + OrderType, + TimeInForce, +) +from x10.perpetual.trading_client import PerpetualTradingClient + +LOGGER = logging.getLogger() +MARKET_NAME = BTC_USD_MARKET +ENDPOINT_CONFIG = TESTNET_CONFIG + + +async def run_example(): + env_config = init_env() + stark_account = StarkPerpetualAccount( + api_key=env_config.api_key, + public_key=env_config.public_key, + private_key=env_config.private_key, + vault=env_config.vault_id, + ) + trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) + markets_dict = await trading_client.markets_info.get_markets_dict() + + market = markets_dict[MARKET_NAME] + adjust_price_by_pct = get_adjust_price_by_pct(market.trading_config) + + order_size = market.trading_config.min_order_size + + last_price = market.market_stats.last_price + tp_trigger_price = adjust_price_by_pct(last_price, -5) + tp_price = adjust_price_by_pct(last_price, -10) + sl_trigger_price = adjust_price_by_pct(last_price, 5) + sl_price = adjust_price_by_pct(last_price, 10) + + LOGGER.info("Creating partial TPSL order object for market: %s", market.name) + + new_order = create_order_object( + account=stark_account, + starknet_domain=ENDPOINT_CONFIG.starknet_domain, + market=market, + order_type=OrderType.TPSL, + side=OrderSide.SELL, + amount_of_synthetic=order_size, + price=Decimal(0), + time_in_force=TimeInForce.GTT, + reduce_only=True, + post_only=False, + tp_sl_type=OrderTpslType.ORDER, + take_profit=OrderTpslTriggerParam( + trigger_price=tp_trigger_price, + trigger_price_type=OrderTriggerPriceType.LAST, + price=tp_price, + price_type=OrderPriceType.LIMIT, + ), + stop_loss=OrderTpslTriggerParam( + trigger_price=sl_trigger_price, + trigger_price_type=OrderTriggerPriceType.LAST, + price=sl_price, + price_type=OrderPriceType.LIMIT, + ), + ) + + LOGGER.info("Placing order...") + + placed_order = await trading_client.orders.place_order(order=new_order) + + LOGGER.info(f"Order is placed: {placed_order.to_pretty_json()}") + + await find_order_and_cancel(trading_client=trading_client, logger=LOGGER, order_id=placed_order.data.id) + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/examples/create_position_tpsl_order.py b/examples/create_position_tpsl_order.py new file mode 100644 index 0000000..01a3f29 --- /dev/null +++ b/examples/create_position_tpsl_order.py @@ -0,0 +1,84 @@ +import logging +from asyncio import run +from decimal import Decimal + +from examples.init_env import init_env +from examples.utils import find_order_and_cancel, get_adjust_price_by_pct +from x10.config import BTC_USD_MARKET +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import TESTNET_CONFIG +from x10.perpetual.order_object import OrderTpslTriggerParam, create_order_object +from x10.perpetual.orders import ( + OrderPriceType, + OrderSide, + OrderTpslType, + OrderTriggerPriceType, + OrderType, + TimeInForce, +) +from x10.perpetual.trading_client import PerpetualTradingClient + +LOGGER = logging.getLogger() +MARKET_NAME = BTC_USD_MARKET +ENDPOINT_CONFIG = TESTNET_CONFIG + + +async def run_example(): + env_config = init_env() + stark_account = StarkPerpetualAccount( + api_key=env_config.api_key, + public_key=env_config.public_key, + private_key=env_config.private_key, + vault=env_config.vault_id, + ) + trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) + markets_dict = await trading_client.markets_info.get_markets_dict() + + market = markets_dict[MARKET_NAME] + adjust_price_by_pct = get_adjust_price_by_pct(market.trading_config) + + last_price = market.market_stats.last_price + tp_trigger_price = adjust_price_by_pct(last_price, -5) + tp_price = adjust_price_by_pct(last_price, -10) + sl_trigger_price = adjust_price_by_pct(last_price, 5) + sl_price = adjust_price_by_pct(last_price, 10) + + LOGGER.info("Creating entire position TPSL order object for market: %s", market.name) + + new_order = create_order_object( + account=stark_account, + starknet_domain=ENDPOINT_CONFIG.starknet_domain, + market=market, + order_type=OrderType.TPSL, + side=OrderSide.SELL, + amount_of_synthetic=Decimal(0), + price=Decimal(0), + time_in_force=TimeInForce.GTT, + reduce_only=True, + post_only=False, + tp_sl_type=OrderTpslType.POSITION, + take_profit=OrderTpslTriggerParam( + trigger_price=tp_trigger_price, + trigger_price_type=OrderTriggerPriceType.LAST, + price=tp_price, + price_type=OrderPriceType.LIMIT, + ), + stop_loss=OrderTpslTriggerParam( + trigger_price=sl_trigger_price, + trigger_price_type=OrderTriggerPriceType.LAST, + price=sl_price, + price_type=OrderPriceType.LIMIT, + ), + ) + + LOGGER.info("Placing order...") + + placed_order = await trading_client.orders.place_order(order=new_order) + + LOGGER.info(f"Order is placed: {placed_order.to_pretty_json()}") + + await find_order_and_cancel(trading_client=trading_client, logger=LOGGER, order_id=placed_order.data.id) + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/examples/placed_order_example_advanced.py b/examples/placed_order_example_advanced.py index fcea625..8f4faf1 100644 --- a/examples/placed_order_example_advanced.py +++ b/examples/placed_order_example_advanced.py @@ -106,7 +106,12 @@ async def place_order( order_side = OrderSide.BUY if should_buy else OrderSide.SELL market = markets_cache[ADA_USD_MARKET] new_order = create_order_object( - stark_account, market, Decimal("100"), price, order_side, starknet_domain=TESTNET_CONFIG.starknet_domain + account=stark_account, + market=market, + amount_of_synthetic=Decimal("100"), + price=price, + side=order_side, + starknet_domain=TESTNET_CONFIG.starknet_domain, ) order_condtions[new_order.id] = asyncio.Condition() return new_order.id, await trading_client.orders.place_order(order=new_order) diff --git a/pyproject.toml b/pyproject.toml index c80b80a..5a8895c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "x10-python-trading-starknet" -version = "0.0.17" +version = "1.0.0" description = "Python client for X10 API" authors = ["X10 "] repository = "https://github.com/x10xchange/python_sdk" diff --git a/tests/perpetual/test_order_object.py b/tests/perpetual/test_order_object.py index b6a48c6..9b7affb 100644 --- a/tests/perpetual/test_order_object.py +++ b/tests/perpetual/test_order_object.py @@ -12,6 +12,7 @@ OrderSide, OrderTpslType, OrderTriggerPriceType, + OrderType, SelfTradeProtectionLevel, ) from x10.utils.date import utc_now @@ -411,6 +412,202 @@ async def test_create_buy_order_with_position_tpsl( ) +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_create_buy_partial_tpsl_order(mocker: MockerFixture, create_trading_account, create_btc_usd_market): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.order_object import OrderTpslTriggerParam, create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + order_type=OrderType.TPSL, + amount_of_synthetic=btc_usd_market.trading_config.min_order_size, + price=Decimal("0"), + side=OrderSide.SELL, + reduce_only=True, + expire_time=utc_now() + timedelta(days=14), + self_trade_protection_level=SelfTradeProtectionLevel.CLIENT, + starknet_domain=TESTNET_CONFIG.starknet_domain, + tp_sl_type=OrderTpslType.ORDER, + take_profit=OrderTpslTriggerParam( + trigger_price=Decimal("49000"), + trigger_price_type=OrderTriggerPriceType.MARK, + price=Decimal("50000"), + price_type=OrderPriceType.LIMIT, + ), + stop_loss=OrderTpslTriggerParam( + trigger_price=Decimal("40000"), + trigger_price_type=OrderTriggerPriceType.MARK, + price=Decimal("39000"), + price_type=OrderPriceType.LIMIT, + ), + ) + + assert_that( + order_obj.to_api_request_json(), + equal_to( + { + "id": "2302927936354168859785231329337424926774195696889032018790365367779325970821", + "market": "BTC-USD", + "type": "TPSL", + "side": "SELL", + "qty": "0.0001", + "price": "0", + "reduceOnly": True, + "postOnly": False, + "timeInForce": "GTT", + "expiryEpochMillis": 1705626536861, + "fee": "0.0005", + "nonce": "1473459052", + "selfTradeProtectionLevel": "CLIENT", + "cancelId": None, + "settlement": None, + "trigger": None, + "tpSlType": "ORDER", + "takeProfit": { + "triggerPrice": "49000", + "triggerPriceType": "MARK", + "price": "50000", + "priceType": "LIMIT", + "settlement": { + "signature": { + "r": "0x513b8c6540b171c6e85e2992046ce697b6056793ace2ea0e46c04cba1b72aa0", + "s": "0x25684458e72e276bbaa87a6787bfab11ed4b117c4f6ffa7cea2781c13648667", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "debuggingAmounts": {"collateralAmount": "5000000", "feeAmount": "2500", "syntheticAmount": "-100"}, + }, + "stopLoss": { + "triggerPrice": "40000", + "triggerPriceType": "MARK", + "price": "39000", + "priceType": "LIMIT", + "settlement": { + "signature": { + "r": "0x30227b00607a0f671db245c734a9d9152c58af0ec0fbc83ac8c50d41b6dc2e5", + "s": "0xb355fcce280b7c82c8e6b23106df57d98aba5b3616a791a4db226063693b5d", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "debuggingAmounts": {"collateralAmount": "3900000", "feeAmount": "1950", "syntheticAmount": "-100"}, + }, + "debuggingAmounts": {"collateralAmount": "0", "feeAmount": "0", "syntheticAmount": "-100"}, + "builderFee": None, + "builderId": None, + } + ), + ) + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_create_buy_position_tpsl_order(mocker: MockerFixture, create_trading_account, create_btc_usd_market): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.order_object import OrderTpslTriggerParam, create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + order_type=OrderType.TPSL, + amount_of_synthetic=Decimal("0"), + price=Decimal("0"), + side=OrderSide.SELL, + reduce_only=True, + expire_time=utc_now() + timedelta(days=14), + self_trade_protection_level=SelfTradeProtectionLevel.CLIENT, + starknet_domain=TESTNET_CONFIG.starknet_domain, + tp_sl_type=OrderTpslType.POSITION, + take_profit=OrderTpslTriggerParam( + trigger_price=Decimal("49000"), + trigger_price_type=OrderTriggerPriceType.MARK, + price=Decimal("50000"), + price_type=OrderPriceType.LIMIT, + ), + stop_loss=OrderTpslTriggerParam( + trigger_price=Decimal("40000"), + trigger_price_type=OrderTriggerPriceType.MARK, + price=Decimal("39000"), + price_type=OrderPriceType.LIMIT, + ), + ) + + assert_that( + order_obj.to_api_request_json(), + equal_to( + { + "id": "2740618205716882280724217633173981437193188033910023585411792989580464995593", + "market": "BTC-USD", + "type": "TPSL", + "side": "SELL", + "qty": "0", + "price": "0", + "reduceOnly": True, + "postOnly": False, + "timeInForce": "GTT", + "expiryEpochMillis": 1705626536861, + "fee": "0.0005", + "nonce": "1473459052", + "selfTradeProtectionLevel": "CLIENT", + "cancelId": None, + "settlement": None, + "trigger": None, + "tpSlType": "POSITION", + "takeProfit": { + "triggerPrice": "49000", + "triggerPriceType": "MARK", + "price": "50000", + "priceType": "LIMIT", + "settlement": { + "signature": { + "r": "0x9ec78c951d0397e31873bb1f5ede330dffc05a6be918007fac3299a122bc37", + "s": "0x1962207a67b6b6d77216d363af31ca5ea37d371ac762aa13bd5366634337b86", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "debuggingAmounts": { + "collateralAmount": "500000000000000", + "feeAmount": "250000000000", + "syntheticAmount": "-10000000000", + }, + }, + "stopLoss": { + "triggerPrice": "40000", + "triggerPriceType": "MARK", + "price": "39000", + "priceType": "LIMIT", + "settlement": { + "signature": { + "r": "0x4b5a05de5d0c07956398de50b846037250dee8cd85c35944fd97f0b3dad3e5b", + "s": "0x76aa8c382b73c0f516c8398e4d648d2dad411e8b6a9a7e81fbae3656bed6d78", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "debuggingAmounts": { + "collateralAmount": "499999999980000", + "feeAmount": "249999999990", + "syntheticAmount": "-12820512820", + }, + }, + "debuggingAmounts": {"collateralAmount": "0", "feeAmount": "0", "syntheticAmount": "0"}, + "builderFee": None, + "builderId": None, + } + ), + ) + + @freeze_time("2024-01-05 01:08:56.860694") @pytest.mark.asyncio async def test_cancel_previous_order(mocker: MockerFixture, create_trading_account, create_btc_usd_market): diff --git a/x10/perpetual/order_object.py b/x10/perpetual/order_object.py index 8508ead..653021c 100644 --- a/x10/perpetual/order_object.py +++ b/x10/perpetual/order_object.py @@ -36,12 +36,14 @@ class OrderTpslTriggerParam: def create_order_object( + *, account: StarkPerpetualAccount, market: MarketModel, amount_of_synthetic: Decimal, price: Decimal, side: OrderSide, starknet_domain: StarknetDomain, + order_type: OrderType = OrderType.LIMIT, post_only: bool = False, previous_order_external_id: Optional[str] = None, expire_time: Optional[datetime] = None, @@ -67,6 +69,7 @@ def create_order_object( return __create_order_object( market=market, + order_type=order_type, synthetic_amount=amount_of_synthetic, price=price, side=side, @@ -95,6 +98,7 @@ def create_order_object( def __create_order_tpsl_trigger_model( *, trigger_param: OrderTpslTriggerParam, + order_type: OrderType, side: OrderSide, synthetic_amount: Decimal, tp_sl_type: OrderTpslType, @@ -111,7 +115,7 @@ def __create_order_tpsl_trigger_model( ) ) settlement_data = create_order_settlement_data( - side=__get_opposite_side(side), + side=side if order_type == OrderType.TPSL else __get_opposite_side(side), synthetic_amount=settlement_synthetic_amount, price=trigger_param.price, ctx=settlement_data_ctx, @@ -134,6 +138,7 @@ def __get_opposite_side(side: OrderSide) -> OrderSide: def __create_order_object( *, market: MarketModel, + order_type: OrderType, synthetic_amount: Decimal, price: Decimal, side: OrderSide, @@ -157,8 +162,11 @@ def __create_order_object( take_profit: Optional[OrderTpslTriggerParam] = None, stop_loss: Optional[OrderTpslTriggerParam] = None, ) -> NewOrderModel: - if side not in OrderSide: - raise ValueError(f"Unexpected order side value: {side}") + if order_type not in [OrderType.LIMIT, OrderType.TPSL]: + raise NotImplementedError(f"{order_type} order type is not supported yet") + + if exact_only: + raise NotImplementedError("`exact_only` option is not supported yet") if time_in_force not in TimeInForce or time_in_force == TimeInForce.FOK: raise ValueError(f"Unexpected time in force value: {time_in_force}") @@ -166,8 +174,18 @@ def __create_order_object( if expire_time is None: raise ValueError("`expire_time` must be provided") - if exact_only: - raise NotImplementedError("`exact_only` option is not supported yet") + if order_type == OrderType.TPSL: + if not reduce_only: + raise ValueError("TPSL orders must be reduce-only") + + if post_only: + raise ValueError("TPSL orders must not be post-only") + + if tp_sl_type == OrderTpslType.POSITION and synthetic_amount != Decimal(0): + raise ValueError("`amount_of_synthetic` must be 0 for entire position TPSL orders") + + if price != Decimal(0): + raise ValueError("`price` must be 0 for TPSL orders") if nonce is None: nonce = generate_nonce() @@ -201,6 +219,7 @@ def create_tpsl_trigger_model(trigger_param: OrderTpslTriggerParam | None): return __create_order_tpsl_trigger_model( trigger_param=trigger_param, + order_type=order_type, side=side, synthetic_amount=synthetic_amount, tp_sl_type=tp_sl_type, @@ -212,7 +231,7 @@ def create_tpsl_trigger_model(trigger_param: OrderTpslTriggerParam | None): order = NewOrderModel( id=order_id, market=market.name, - type=OrderType.LIMIT, + type=order_type, side=side, qty=settlement_data.synthetic_amount_human.value, price=price, @@ -223,7 +242,7 @@ def create_tpsl_trigger_model(trigger_param: OrderTpslTriggerParam | None): self_trade_protection_level=self_trade_protection_level, nonce=Decimal(nonce), cancel_id=previous_order_external_id, - settlement=settlement_data.settlement, + settlement=settlement_data.settlement if order_type != OrderType.TPSL else None, tp_sl_type=tp_sl_type, take_profit=create_tpsl_trigger_model(take_profit), stop_loss=create_tpsl_trigger_model(stop_loss), diff --git a/x10/perpetual/orders.py b/x10/perpetual/orders.py index 146b253..3dc8a4a 100644 --- a/x10/perpetual/orders.py +++ b/x10/perpetual/orders.py @@ -170,6 +170,11 @@ class OpenOrderTpslTriggerModel(X10BaseModel): class OpenOrderModel(X10BaseModel): + """ + Attributes: + price: Price of the order. If it's a TP/SL order, it will be null. + """ + id: int account_id: int external_id: str @@ -178,10 +183,11 @@ class OpenOrderModel(X10BaseModel): side: OrderSide status: OrderStatus status_reason: Optional[OrderStatusReason] = None - price: Decimal + price: Optional[Decimal] = None average_price: Optional[Decimal] = None qty: Decimal filled_qty: Optional[Decimal] = None + cancelled_qty: Optional[Decimal] = None reduce_only: bool post_only: bool payed_fee: Optional[Decimal] = None