diff --git a/examples/create_market_order.py b/examples/create_market_order.py new file mode 100644 index 0000000..50d4f17 --- /dev/null +++ b/examples/create_market_order.py @@ -0,0 +1,68 @@ +import logging +from asyncio import run +from decimal import Decimal + +from examples.init_env import init_env +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 create_order_object +from x10.perpetual.orders import OrderSide, OrderType, TimeInForce +from x10.perpetual.trading_client import PerpetualTradingClient +from x10.utils.order import get_price_with_slippage + +LOGGER = logging.getLogger() +MARKET_NAME = BTC_USD_MARKET +ENDPOINT_CONFIG = TESTNET_CONFIG +SLIPPAGE = Decimal(0.0075) + + +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_stats = await trading_client.markets_info.get_market_statistics(market_name=MARKET_NAME) + + market = markets_dict[MARKET_NAME] + + order_side = OrderSide.SELL + order_size = market.trading_config.min_order_size + + best_market_price = market_stats.data.ask_price if order_side == OrderSide.BUY else market_stats.data.bid_price + order_price = get_price_with_slippage( + side=order_side, + price=best_market_price, + min_price_change=market.trading_config.min_price_change, + slippage=SLIPPAGE, + ) + + LOGGER.info("Creating MARKET order object for market: %s", market.name) + + new_order = create_order_object( + account=stark_account, + order_type=OrderType.MARKET, + starknet_domain=ENDPOINT_CONFIG.starknet_domain, + market=market, + side=order_side, + amount_of_synthetic=order_size, + price=order_price, + time_in_force=TimeInForce.IOC, + reduce_only=False, + post_only=False, + ) + + LOGGER.info("Placing order...") + + placed_order = await trading_client.orders.place_order(order=new_order) + + LOGGER.info("Order is placed: %s", placed_order.to_pretty_json()) + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/pyproject.toml b/pyproject.toml index 5a8895c..2446fe3 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 = "1.0.0" +version = "1.1.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/order_object/test_limit_order_object.py similarity index 61% rename from tests/perpetual/test_order_object.py rename to tests/perpetual/order_object/test_limit_order_object.py index 9b7affb..afea0b1 100644 --- a/tests/perpetual/test_order_object.py +++ b/tests/perpetual/order_object/test_limit_order_object.py @@ -3,7 +3,7 @@ import pytest from freezegun import freeze_time -from hamcrest import assert_that, equal_to, has_entries +from hamcrest import assert_that, equal_to from pytest_mock import MockerFixture from x10.perpetual.configuration import TESTNET_CONFIG @@ -12,7 +12,6 @@ OrderSide, OrderTpslType, OrderTriggerPriceType, - OrderType, SelfTradeProtectionLevel, ) from x10.utils.date import utc_now @@ -410,259 +409,3 @@ 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): - mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) - - from x10.perpetual.order_object import 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), - previous_order_external_id="previous_custom_id", - starknet_domain=TESTNET_CONFIG.starknet_domain, - ) - - assert_that( - order_obj.to_api_request_json(), - has_entries( - { - "cancelId": equal_to("previous_custom_id"), - } - ), - ) - - -@freeze_time("2024-01-05 01:08:56.860694") -@pytest.mark.asyncio -async def test_external_order_id(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 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), - order_external_id="custom_id", - starknet_domain=TESTNET_CONFIG.starknet_domain, - ) - - assert_that( - order_obj.to_api_request_json(), - has_entries( - { - "id": equal_to("custom_id"), - } - ), - ) diff --git a/tests/perpetual/order_object/test_market_order_object.py b/tests/perpetual/order_object/test_market_order_object.py new file mode 100644 index 0000000..6370fdf --- /dev/null +++ b/tests/perpetual/order_object/test_market_order_object.py @@ -0,0 +1,149 @@ +from datetime import timedelta +from decimal import Decimal + +import pytest +from freezegun import freeze_time +from hamcrest import assert_that, equal_to +from pytest_mock import MockerFixture + +from x10.perpetual.configuration import TESTNET_CONFIG +from x10.perpetual.orders import OrderSide, OrderType, TimeInForce +from x10.utils.date import utc_now +from x10.utils.order import get_price_with_slippage + +FROZEN_NONCE = 1473459052 +SLIPPAGE = Decimal("0.0075") + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_create_sell_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 create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_side = OrderSide.SELL + order_price = get_price_with_slippage( + side=order_side, + price=Decimal("50000"), + min_price_change=btc_usd_market.trading_config.min_price_change, + slippage=SLIPPAGE, + ) + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + order_type=OrderType.MARKET, + amount_of_synthetic=Decimal("0.00100000"), + price=order_price, + side=order_side, + expire_time=utc_now() + timedelta(days=14), + time_in_force=TimeInForce.IOC, + starknet_domain=TESTNET_CONFIG.starknet_domain, + nonce=FROZEN_NONCE, + ) + + assert_that( + order_obj.to_api_request_json(), + equal_to( + { + "id": "2580220688642480426946040763258220762106230673118492731878319591751617419967", + "market": "BTC-USD", + "type": "MARKET", + "side": "SELL", + "qty": "0.00100000", + "price": "49625.0", + "reduceOnly": False, + "postOnly": False, + "timeInForce": "IOC", + "expiryEpochMillis": 1705626536861, + "fee": "0.0005", + "nonce": "1473459052", + "selfTradeProtectionLevel": "ACCOUNT", + "cancelId": None, + "settlement": { + "signature": { + "r": "0x28af719b8c9619fadd151a0f9c269058b3240ae2e08ab14e6fa15b8ea081dc6", + "s": "0x78c518768fe71c8583aee78e756de66ffed2170171fec10da03edc5e9a3d241", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "trigger": None, + "tpSlType": None, + "takeProfit": None, + "stopLoss": None, + "debuggingAmounts": {"collateralAmount": "49625000", "feeAmount": "24813", "syntheticAmount": "-1000"}, + "builderFee": None, + "builderId": None, + } + ), + ) + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_create_buy_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 create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_side = OrderSide.BUY + order_price = get_price_with_slippage( + side=order_side, + price=Decimal("50000"), + min_price_change=btc_usd_market.trading_config.min_price_change, + slippage=SLIPPAGE, + ) + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + order_type=OrderType.MARKET, + amount_of_synthetic=Decimal("0.00100000"), + price=order_price, + side=order_side, + expire_time=utc_now() + timedelta(days=14), + time_in_force=TimeInForce.IOC, + starknet_domain=TESTNET_CONFIG.starknet_domain, + nonce=FROZEN_NONCE, + ) + + assert_that( + order_obj.to_api_request_json(), + equal_to( + { + "id": "3168487898969135762904713190835173941926260364920267758857147425797045990747", + "market": "BTC-USD", + "type": "MARKET", + "side": "BUY", + "qty": "0.00100000", + "price": "50375.0", + "reduceOnly": False, + "postOnly": False, + "timeInForce": "IOC", + "expiryEpochMillis": 1705626536861, + "fee": "0.0005", + "nonce": "1473459052", + "selfTradeProtectionLevel": "ACCOUNT", + "cancelId": None, + "settlement": { + "signature": { + "r": "0x4baa519bdf416e3563eb9c6d52d48d6adaf8926e4840a76d242c1ba62be6587", + "s": "0x87342653d415d4f47f876bcc34d11b2a16c9220b6a8e1dc7fb9225d9e7e9f9", + }, + "starkKey": "0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", + "collateralPosition": "10002", + }, + "trigger": None, + "tpSlType": None, + "takeProfit": None, + "stopLoss": None, + "debuggingAmounts": {"collateralAmount": "-50375000", "feeAmount": "25188", "syntheticAmount": "1000"}, + "builderFee": None, + "builderId": None, + } + ), + ) diff --git a/tests/perpetual/order_object/test_order_object_attrs.py b/tests/perpetual/order_object/test_order_object_attrs.py new file mode 100644 index 0000000..e898f72 --- /dev/null +++ b/tests/perpetual/order_object/test_order_object_attrs.py @@ -0,0 +1,73 @@ +from datetime import timedelta +from decimal import Decimal + +import pytest +from freezegun import freeze_time +from hamcrest import assert_that, equal_to, has_entries +from pytest_mock import MockerFixture + +from x10.perpetual.configuration import TESTNET_CONFIG +from x10.perpetual.orders import OrderSide +from x10.utils.date import utc_now + +FROZEN_NONCE = 1473459052 + + +@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): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.order_object import 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), + previous_order_external_id="previous_custom_id", + starknet_domain=TESTNET_CONFIG.starknet_domain, + ) + + assert_that( + order_obj.to_api_request_json(), + has_entries( + { + "cancelId": equal_to("previous_custom_id"), + } + ), + ) + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_external_order_id(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 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), + order_external_id="custom_id", + starknet_domain=TESTNET_CONFIG.starknet_domain, + ) + + assert_that( + order_obj.to_api_request_json(), + has_entries( + { + "id": equal_to("custom_id"), + } + ), + ) diff --git a/tests/perpetual/order_object/test_tpsl_order_object.py b/tests/perpetual/order_object/test_tpsl_order_object.py new file mode 100644 index 0000000..fa572c8 --- /dev/null +++ b/tests/perpetual/order_object/test_tpsl_order_object.py @@ -0,0 +1,216 @@ +from datetime import timedelta +from decimal import Decimal + +import pytest +from freezegun import freeze_time +from hamcrest import assert_that, equal_to +from pytest_mock import MockerFixture + +from x10.perpetual.configuration import TESTNET_CONFIG +from x10.perpetual.orders import ( + OrderPriceType, + OrderSide, + OrderTpslType, + OrderTriggerPriceType, + OrderType, + SelfTradeProtectionLevel, +) +from x10.utils.date import utc_now + +FROZEN_NONCE = 1473459052 + + +@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, + } + ), + ) diff --git a/tests/utils/test_order.py b/tests/utils/test_order.py new file mode 100644 index 0000000..b9085da --- /dev/null +++ b/tests/utils/test_order.py @@ -0,0 +1,54 @@ +from decimal import Decimal + +from hamcrest import assert_that, equal_to + +from x10.perpetual.orders import OrderSide +from x10.utils.order import calc_entire_position_size, get_price_with_slippage + + +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")), + ) + + +def test_get_price_with_slippage(): + # given + best_ask = Decimal("66841.6") + best_bid = Decimal("66774.7") + min_price_change = Decimal("0.1") + slippage = Decimal("0.0075") + + # then + assert_that( + get_price_with_slippage( + side=OrderSide.BUY, + price=best_ask, + min_price_change=min_price_change, + slippage=slippage, + ), + equal_to(Decimal("67343")), + ) + assert_that( + get_price_with_slippage( + side=OrderSide.SELL, + price=best_bid, + min_price_change=min_price_change, + slippage=slippage, + ), + equal_to(Decimal("66273.8")), + ) + assert_that( + get_price_with_slippage( + side=OrderSide.SELL, + price=min_price_change, + min_price_change=min_price_change, + slippage=slippage, + ), + equal_to(min_price_change), + ) diff --git a/tests/utils/test_tpsl.py b/tests/utils/test_tpsl.py deleted file mode 100644 index ac1e016..0000000 --- a/tests/utils/test_tpsl.py +++ /dev/null @@ -1,16 +0,0 @@ -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/x10/perpetual/markets.py b/x10/perpetual/markets.py index 640c32b..bae7d97 100644 --- a/x10/perpetual/markets.py +++ b/x10/perpetual/markets.py @@ -4,6 +4,7 @@ from x10.perpetual.assets import Asset from x10.utils.model import X10BaseModel +from x10.utils.order import round_price as round_order_price_util class RiskFactorConfig(X10BaseModel): @@ -77,7 +78,11 @@ def calculate_order_size_from_value( return Decimal(0) def round_price(self, price: Decimal, rounding_direction: str = ROUND_CEILING) -> Decimal: - return price.quantize(self.min_price_change, rounding=rounding_direction) + return round_order_price_util( + price=price, + min_price_change=self.min_price_change, + rounding_direction=rounding_direction, + ) class L2ConfigModel(X10BaseModel): diff --git a/x10/perpetual/order_object.py b/x10/perpetual/order_object.py index 653021c..6cbf1d6 100644 --- a/x10/perpetual/order_object.py +++ b/x10/perpetual/order_object.py @@ -24,7 +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 +from x10.utils.order import calc_entire_position_size @dataclass(kw_only=True) @@ -162,19 +162,14 @@ def __create_order_object( take_profit: Optional[OrderTpslTriggerParam] = None, stop_loss: Optional[OrderTpslTriggerParam] = None, ) -> NewOrderModel: - 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}") + def validate_market_order(): + if post_only: + raise ValueError("MARKET orders must not be post-only") - if expire_time is None: - raise ValueError("`expire_time` must be provided") + if time_in_force != TimeInForce.IOC: + raise ValueError("MARKET orders must have `time_in_force` set to IOC") - if order_type == OrderType.TPSL: + def validate_tpsl_order(): if not reduce_only: raise ValueError("TPSL orders must be reduce-only") @@ -182,11 +177,28 @@ def __create_order_object( 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") + raise ValueError("`synthetic_amount` must be 0 for entire position TPSL orders") if price != Decimal(0): raise ValueError("`price` must be 0 for TPSL orders") + if order_type not in [OrderType.LIMIT, OrderType.MARKET, 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 == TimeInForce.FOK: + raise ValueError("FOK `time_in_force` value is deprecated") + + if expire_time is None: + raise ValueError("`expire_time` must be provided") + + if order_type == OrderType.MARKET: + validate_market_order() + elif order_type == OrderType.TPSL: + validate_tpsl_order() + if nonce is None: nonce = generate_nonce() diff --git a/x10/utils/order.py b/x10/utils/order.py new file mode 100644 index 0000000..fa5eb74 --- /dev/null +++ b/x10/utils/order.py @@ -0,0 +1,40 @@ +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal + +from x10.perpetual.orders import OrderSide + + +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) + + +def round_price(*, price: Decimal, min_price_change: Decimal, rounding_direction: str = ROUND_CEILING) -> Decimal: + return price.quantize(min_price_change, rounding=rounding_direction) + + +def get_price_with_slippage( + *, side: OrderSide, price: Decimal, min_price_change: Decimal, slippage: Decimal +) -> Decimal: + slippage_collateral = price * slippage + price_with_slippage = price + slippage_collateral if side == OrderSide.BUY else price - slippage_collateral + rounding_direction = ROUND_CEILING if side == OrderSide.BUY else ROUND_FLOOR + + return Decimal.max( + min_price_change, + round_price( + price=price_with_slippage, + min_price_change=min_price_change, + rounding_direction=rounding_direction, + ), + ) diff --git a/x10/utils/tpsl.py b/x10/utils/tpsl.py deleted file mode 100644 index 6a1b196..0000000 --- a/x10/utils/tpsl.py +++ /dev/null @@ -1,17 +0,0 @@ -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)