From 8c786bd3416cc278f6690efbbdc31b96c9c4c934 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 16 Mar 2026 15:52:39 +0400 Subject: [PATCH 1/2] Add entire position TPSL support --- .../create_limit_order_with_position_tpsl.py | 83 +++++++++++++ .../test_calc_entire_position_size.py | 16 +++ .../{ => order_object}/test_order_object.py | 115 +++++++++++++++++- x10/perpetual/order_object.py | 83 +++++++------ x10/utils/tpsl.py | 17 +++ 5 files changed, 275 insertions(+), 39 deletions(-) create mode 100644 examples/create_limit_order_with_position_tpsl.py create mode 100644 tests/perpetual/order_object/test_calc_entire_position_size.py rename tests/perpetual/{ => order_object}/test_order_object.py (73%) create mode 100644 x10/utils/tpsl.py diff --git a/examples/create_limit_order_with_position_tpsl.py b/examples/create_limit_order_with_position_tpsl.py new file mode 100644 index 0000000..12b6ae5 --- /dev/null +++ b/examples/create_limit_order_with_position_tpsl.py @@ -0,0 +1,83 @@ +import logging +from asyncio import run + +from examples.init_env import init_env +from examples.utils import find_order_and_cancel, get_adjust_price_by_pct +from x10.config import ETH_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, + TimeInForce, +) +from x10.perpetual.trading_client import PerpetualTradingClient + +LOGGER = logging.getLogger() +MARKET_NAME = ETH_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 + + order_price = adjust_price_by_pct(market.market_stats.bid_price, -10.0) + tp_trigger_price = adjust_price_by_pct(order_price, 0.5) + tp_price = adjust_price_by_pct(order_price, 1.0) + sl_trigger_price = adjust_price_by_pct(order_price, -0.5) + sl_price = adjust_price_by_pct(order_price, -1.0) + + LOGGER.info("Creating LIMIT order object with TPSL for market: %s", market.name) + + new_order = create_order_object( + account=stark_account, + starknet_domain=ENDPOINT_CONFIG.starknet_domain, + market=market, + side=OrderSide.BUY, + amount_of_synthetic=order_size, + price=market.trading_config.round_price(order_price), + time_in_force=TimeInForce.GTT, + reduce_only=False, + post_only=True, + 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/tests/perpetual/order_object/test_calc_entire_position_size.py b/tests/perpetual/order_object/test_calc_entire_position_size.py new file mode 100644 index 0000000..ac1e016 --- /dev/null +++ b/tests/perpetual/order_object/test_calc_entire_position_size.py @@ -0,0 +1,16 @@ +from decimal import Decimal + +from hamcrest import assert_that, equal_to + +from x10.utils.tpsl import calc_entire_position_size + + +def test_calc_entire_position_size(): + assert_that( + calc_entire_position_size( + price=Decimal("24580.3412"), + quantity_precision=4, + max_position_value=Decimal("10000000"), + ), + equal_to(Decimal("20341.4588")), + ) diff --git a/tests/perpetual/test_order_object.py b/tests/perpetual/order_object/test_order_object.py similarity index 73% rename from tests/perpetual/test_order_object.py rename to tests/perpetual/order_object/test_order_object.py index cc6c4b3..b6a48c6 100644 --- a/tests/perpetual/test_order_object.py +++ b/tests/perpetual/order_object/test_order_object.py @@ -10,6 +10,7 @@ from x10.perpetual.orders import ( OrderPriceType, OrderSide, + OrderTpslType, OrderTriggerPriceType, SelfTradeProtectionLevel, ) @@ -196,7 +197,7 @@ async def test_create_buy_order(mocker: MockerFixture, create_trading_account, c @freeze_time("2024-01-05 01:08:56.860694") @pytest.mark.asyncio -async def test_create_buy_order_with_tpsl(mocker: MockerFixture, create_trading_account, create_btc_usd_market): +async def test_create_buy_order_with_order_tpsl(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 @@ -212,6 +213,7 @@ async def test_create_buy_order_with_tpsl(mocker: MockerFixture, create_trading_ 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, @@ -253,7 +255,7 @@ async def test_create_buy_order_with_tpsl(mocker: MockerFixture, create_trading_ "collateralPosition": "10002", }, "trigger": None, - "tpSlType": None, + "tpSlType": "ORDER", "takeProfit": { "triggerPrice": "49000", "triggerPriceType": "MARK", @@ -300,6 +302,115 @@ async def test_create_buy_order_with_tpsl(mocker: MockerFixture, create_trading_ ) +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_create_buy_order_with_position_tpsl( + 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, + amount_of_synthetic=Decimal("0.00100000"), + price=Decimal("43445.11680000"), + side=OrderSide.BUY, + 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": "2495374044666992118771096772295242242651427695217815113349321039194683172848", + "market": "BTC-USD", + "type": "LIMIT", + "side": "BUY", + "qty": "0.00100000", + "price": "43445.11680000", + "reduceOnly": False, + "postOnly": False, + "timeInForce": "GTT", + "expiryEpochMillis": 1705626536861, + "fee": "0.0005", + "nonce": "1473459052", + "selfTradeProtectionLevel": "CLIENT", + "cancelId": None, + "settlement": { + "signature": { + "r": "0xa55625c7d5f1b85bed22556fc805224b8363074979cf918091d9ddb1403e13", + "s": "0x504caf634d859e643569743642ccf244434322859b2421d76f853af43ae7a46", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "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": "-43445117", "feeAmount": "21723", "syntheticAmount": "1000"}, + "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 7ab7207..8508ead 100644 --- a/x10/perpetual/order_object.py +++ b/x10/perpetual/order_object.py @@ -8,7 +8,6 @@ from x10.perpetual.fees import DEFAULT_FEES, TradingFeeModel from x10.perpetual.markets import MarketModel from x10.perpetual.order_object_settlement import ( - OrderSettlementData, SettlementDataCtx, create_order_settlement_data, ) @@ -25,6 +24,7 @@ ) from x10.utils.date import to_epoch_millis, utc_now from x10.utils.nonce import generate_nonce +from x10.utils.tpsl import calc_entire_position_size @dataclass(kw_only=True) @@ -92,7 +92,31 @@ def create_order_object( ) -def __create_order_tpsl_trigger_model(trigger_param: OrderTpslTriggerParam, settlement_data: OrderSettlementData): +def __create_order_tpsl_trigger_model( + *, + trigger_param: OrderTpslTriggerParam, + side: OrderSide, + synthetic_amount: Decimal, + tp_sl_type: OrderTpslType, + market: MarketModel, + settlement_data_ctx: SettlementDataCtx, +): + settlement_synthetic_amount = ( + synthetic_amount + if tp_sl_type == OrderTpslType.ORDER + else calc_entire_position_size( + price=trigger_param.price, + quantity_precision=market.trading_config.quantity_precision, + max_position_value=market.trading_config.max_position_value, + ) + ) + settlement_data = create_order_settlement_data( + side=__get_opposite_side(side), + synthetic_amount=settlement_synthetic_amount, + price=trigger_param.price, + ctx=settlement_data_ctx, + ) + return CreateOrderTpslTriggerModel( trigger_price=trigger_param.trigger_price, trigger_price_type=trigger_param.trigger_price_type, @@ -145,14 +169,6 @@ def __create_order_object( if exact_only: raise NotImplementedError("`exact_only` option is not supported yet") - if tp_sl_type == OrderTpslType.POSITION: - raise NotImplementedError("`POSITION` TPSL type is not supported yet") - - if (take_profit and take_profit.price_type == OrderPriceType.MARKET) or ( - stop_loss and stop_loss.price_type == OrderPriceType.MARKET - ): - raise NotImplementedError("TPSL `MARKET` price type is not supported yet") - if nonce is None: nonce = generate_nonce() @@ -172,32 +188,25 @@ def __create_order_object( settlement_data = create_order_settlement_data( side=side, synthetic_amount=synthetic_amount, price=price, ctx=settlement_data_ctx ) - tp_trigger_model = ( - __create_order_tpsl_trigger_model( - take_profit, - create_order_settlement_data( - side=__get_opposite_side(side), - synthetic_amount=synthetic_amount, - price=take_profit.price, - ctx=settlement_data_ctx, - ), - ) - if take_profit - else None - ) - sl_trigger_model = ( - __create_order_tpsl_trigger_model( - stop_loss, - create_order_settlement_data( - side=__get_opposite_side(side), - synthetic_amount=synthetic_amount, - price=stop_loss.price, - ctx=settlement_data_ctx, - ), + + def create_tpsl_trigger_model(trigger_param: OrderTpslTriggerParam | None): + if not trigger_param: + return None + + if tp_sl_type is None: + raise ValueError("`tp_sl_type` must be provided if `take_profit` or `stop_loss` is specified") + + if trigger_param.price_type == OrderPriceType.MARKET: + raise NotImplementedError("TPSL `MARKET` price type is not supported yet") + + return __create_order_tpsl_trigger_model( + trigger_param=trigger_param, + side=side, + synthetic_amount=synthetic_amount, + tp_sl_type=tp_sl_type, + market=market, + settlement_data_ctx=settlement_data_ctx, ) - if stop_loss - else None - ) order_id = str(settlement_data.order_hash) if order_external_id is None else order_external_id order = NewOrderModel( @@ -216,8 +225,8 @@ def __create_order_object( cancel_id=previous_order_external_id, settlement=settlement_data.settlement, tp_sl_type=tp_sl_type, - take_profit=tp_trigger_model, - stop_loss=sl_trigger_model, + take_profit=create_tpsl_trigger_model(take_profit), + stop_loss=create_tpsl_trigger_model(stop_loss), debugging_amounts=settlement_data.debugging_amounts, builderFee=builder_fee, builderId=builder_id, diff --git a/x10/utils/tpsl.py b/x10/utils/tpsl.py new file mode 100644 index 0000000..6a1b196 --- /dev/null +++ b/x10/utils/tpsl.py @@ -0,0 +1,17 @@ +from decimal import ROUND_FLOOR, Decimal + + +def calc_entire_position_size( + *, + price: Decimal, + quantity_precision: int, + max_position_value: Decimal, +): + """ + This calculation is required to avoid a case when the position at + the time of TPSL execution has a bigger size than a signed TPSL order size. + """ + + assert price > 0, "`price` must be greater than 0" + + return (max_position_value * 50 / price).quantize(Decimal(10) ** -quantity_precision, rounding=ROUND_FLOOR) From 1e1f8c5852fa299c8afcd4af25d5e3afc7bac857 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 16 Mar 2026 15:56:57 +0400 Subject: [PATCH 2/2] Add entire position TPSL support --- tests/perpetual/{order_object => }/test_order_object.py | 0 .../test_calc_entire_position_size.py => utils/test_tpsl.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/perpetual/{order_object => }/test_order_object.py (100%) rename tests/{perpetual/order_object/test_calc_entire_position_size.py => utils/test_tpsl.py} (100%) diff --git a/tests/perpetual/order_object/test_order_object.py b/tests/perpetual/test_order_object.py similarity index 100% rename from tests/perpetual/order_object/test_order_object.py rename to tests/perpetual/test_order_object.py diff --git a/tests/perpetual/order_object/test_calc_entire_position_size.py b/tests/utils/test_tpsl.py similarity index 100% rename from tests/perpetual/order_object/test_calc_entire_position_size.py rename to tests/utils/test_tpsl.py