├── x10 ├── __init__.py ├── utils │ ├── __init__.py │ ├── log.py │ ├── nonce.py │ ├── string.py │ ├── date.py │ ├── model.py │ └── http.py ├── perpetual │ ├── __init__.py │ ├── user_client │ │ ├── __init__.py │ │ ├── l1_signing.py │ │ ├── onboarding.py │ │ └── user_client.py │ ├── stream_client │ │ ├── __init__.py │ │ ├── stream_client.py │ │ └── perpetual_stream_connection.py │ ├── trading_client │ │ ├── __init__.py │ │ ├── info_module.py │ │ ├── base_module.py │ │ ├── testnet_module.py │ │ ├── order_management_module.py │ │ ├── markets_information_module.py │ │ └── trading_client.py │ ├── clients.py │ ├── bridges.py │ ├── balances.py │ ├── fees.py │ ├── funding_rates.py │ ├── withdrawals.py │ ├── orderbooks.py │ ├── candles.py │ ├── transfers.py │ ├── trades.py │ ├── positions.py │ ├── amounts.py │ ├── configuration.py │ ├── assets.py │ ├── accounts.py │ ├── withdrawal_object.py │ ├── transfer_object.py │ ├── markets.py │ ├── order_object_settlement.py │ ├── orders.py │ ├── order_object.py │ ├── orderbook.py │ └── simple_client │ │ └── simple_trading_client.py ├── errors.py └── config.py ├── examples ├── __init__.py ├── logger.yml ├── utils.py ├── withdrawal_example.py ├── init_env.py ├── 05_bridged_withdrawal.py ├── onboarding_example.py ├── 03_subscribe_to_stream.py ├── 01_create_limit_order.py ├── simple_client_example.py ├── 04_create_limit_order_with_builder.py ├── 02_create_limit_order_with_partial_tpsl.py ├── placed_order_example_simple.py ├── market_maker_example.py └── placed_order_example_advanced.py ├── .flake8 ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── build-release.yml │ └── code-checks.yml ├── tox.ini ├── Makefile ├── .gitignore ├── tests ├── fixtures │ ├── candles.py │ ├── orderbook.py │ ├── assets.py │ ├── accounts.py │ └── markets.py ├── perpetual │ ├── test_l2_key_derivation.py │ ├── test_onboarding_payload.py │ ├── test_transfer_object.py │ ├── test_stream_client.py │ ├── test_trading_client.py │ └── test_orderbook_price_impact.py ├── utils │ ├── test_date.py │ ├── test_model.py │ └── test_http.py └── conftest.py ├── LICENSE ├── .devcontainer └── devcontainer.json └── pyproject.toml /x10/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /x10/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /x10/perpetual/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /x10/perpetual/user_client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /x10/perpetual/user_client/l1_signing.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /x10/errors.py: -------------------------------------------------------------------------------- 1 | class X10Error(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default owners 2 | * @dmitrykrasovskih @alexex10 @ex10ded 3 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Changes 2 | - [Describe your changes here] 3 | - ... 4 | -------------------------------------------------------------------------------- /x10/utils/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def get_logger(name: str): 5 | return logging.getLogger(name) 6 | -------------------------------------------------------------------------------- /x10/perpetual/stream_client/__init__.py: -------------------------------------------------------------------------------- 1 | from x10.perpetual.stream_client.stream_client import ( # noqa: F401 2 | PerpetualStreamClient, 3 | ) 4 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/__init__.py: -------------------------------------------------------------------------------- 1 | from x10.perpetual.trading_client.trading_client import ( # noqa: F401 2 | PerpetualTradingClient, 3 | ) 4 | -------------------------------------------------------------------------------- /x10/utils/nonce.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | def generate_nonce() -> int: 5 | """ 6 | Generates a nonce for use in StarkEx transactions. 7 | 8 | Returns: 9 | int: A random nonce. 10 | """ 11 | return random.randint(0, 2**32 - 1) 12 | -------------------------------------------------------------------------------- /x10/perpetual/clients.py: -------------------------------------------------------------------------------- 1 | from x10.utils.model import X10BaseModel 2 | 3 | 4 | class ClientModel(X10BaseModel): 5 | id: int 6 | evm_wallet_address: str | None = None 7 | starknet_wallet_address: str | None = None 8 | referral_link_code: str | None = None 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | 4 | [testenv] 5 | skip_install = true 6 | allowlist_externals = poetry 7 | commands_pre = 8 | poetry install 9 | commands = 10 | poetry run pytest --cov=x10 --cov-fail-under=70 --forked tests/ --import-mode importlib 11 | -------------------------------------------------------------------------------- /x10/utils/string.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | 4 | def is_hex_string(s: str, check_prefix: bool = True): 5 | if check_prefix and not s.startswith("0x"): 6 | return False 7 | 8 | string_to_check = s if not check_prefix else s[2:] 9 | 10 | return s.isalnum() and all(c in string.hexdigits for c in string_to_check) 11 | -------------------------------------------------------------------------------- /x10/config.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | BTC_USD_MARKET = "BTC-USD" 4 | SOL_USD_MARKET = "SOL-USD" 5 | ADA_USD_MARKET = "ADA-USD" 6 | ETH_USD_MARKET = "ETH-USD" 7 | 8 | DEFAULT_REQUEST_TIMEOUT_SECONDS = 500 9 | SDK_VERSION = importlib.metadata.version("x10-python-trading-starknet") 10 | USER_AGENT = f"X10PythonTradingClient/{SDK_VERSION}" 11 | -------------------------------------------------------------------------------- /x10/perpetual/bridges.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.utils.model import X10BaseModel 4 | 5 | 6 | class ChainConfig(X10BaseModel): 7 | chain: str 8 | contractAddress: str 9 | 10 | 11 | class BridgesConfig(X10BaseModel): 12 | chains: list[ChainConfig] 13 | 14 | 15 | class Quote(X10BaseModel): 16 | id: str 17 | fee: Decimal 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | isort --profile black ./examples ./tests ./x10 3 | black --target-version py310 --line-length 120 ./examples ./tests ./x10 4 | 5 | lint: 6 | black --check --diff --target-version py310 --line-length 120 ./examples ./tests ./x10 7 | flake8 ./examples ./tests ./x10 8 | mypy ./x10 ./examples 9 | 10 | test: 11 | tox 12 | 13 | bump: 14 | poetry version patch 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | dist/ 11 | 12 | # Unit test / coverage reports 13 | .tox/ 14 | .coverage 15 | .coverage.* 16 | 17 | # Visual Studio Code project settings 18 | .vscode 19 | 20 | # JetBrains IDE 21 | .idea/ 22 | 23 | # MacOS 24 | .DS_Store 25 | 26 | *.env -------------------------------------------------------------------------------- /x10/utils/date.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import datetime, timezone 3 | 4 | 5 | def utc_now(): 6 | return datetime.now(tz=timezone.utc) 7 | 8 | 9 | def to_epoch_millis(value: datetime): 10 | assert value.tzinfo == timezone.utc, "`value` must be in UTC" 11 | 12 | # Use ceiling to match the hash_order logic which uses math.ceil 13 | return int(math.ceil(value.timestamp() * 1000)) 14 | -------------------------------------------------------------------------------- /x10/perpetual/balances.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.utils.model import X10BaseModel 4 | 5 | 6 | class BalanceModel(X10BaseModel): 7 | collateral_name: str 8 | balance: Decimal 9 | equity: Decimal 10 | available_for_trade: Decimal 11 | available_for_withdrawal: Decimal 12 | unrealised_pnl: Decimal 13 | initial_margin: Decimal 14 | margin_ratio: Decimal 15 | updated_time: int 16 | -------------------------------------------------------------------------------- /examples/logger.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | formatters: 4 | standard: 5 | format: "[%(asctime)s %(process)s %(thread)s %(levelname)s] %(message)s" 6 | 7 | handlers: 8 | console: 9 | class: logging.StreamHandler 10 | level: DEBUG 11 | formatter: standard 12 | stream: ext://sys.stdout 13 | 14 | root: 15 | level: INFO 16 | handlers: [console] 17 | propagate: yes 18 | 19 | loggers: 20 | x10: 21 | level: DEBUG 22 | -------------------------------------------------------------------------------- /x10/perpetual/fees.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.utils.model import X10BaseModel 4 | 5 | 6 | class TradingFeeModel(X10BaseModel): 7 | market: str 8 | maker_fee_rate: Decimal 9 | taker_fee_rate: Decimal 10 | builder_fee_rate: Decimal 11 | 12 | 13 | DEFAULT_FEES = TradingFeeModel( 14 | market="BTC-USD", 15 | maker_fee_rate=(Decimal("2") / Decimal("10000")), 16 | taker_fee_rate=(Decimal("5") / Decimal("10000")), 17 | builder_fee_rate=Decimal("0"), 18 | ) 19 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/info_module.py: -------------------------------------------------------------------------------- 1 | from x10.perpetual.trading_client.base_module import BaseModule 2 | from x10.utils.http import send_get_request 3 | from x10.utils.model import X10BaseModel 4 | 5 | 6 | class _SettingsModel(X10BaseModel): 7 | stark_ex_contract_address: str 8 | 9 | 10 | class InfoModule(BaseModule): 11 | async def get_settings(self): 12 | url = self._get_url("/info/settings") 13 | return await send_get_request(await self.get_session(), url, _SettingsModel) 14 | -------------------------------------------------------------------------------- /x10/perpetual/funding_rates.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from pydantic import AliasChoices, Field 4 | 5 | from x10.utils.model import X10BaseModel 6 | 7 | 8 | class FundingRateModel(X10BaseModel): 9 | market: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") 10 | funding_rate: Decimal = Field(validation_alias=AliasChoices("funding_rate", "f"), serialization_alias="f") 11 | timestamp: int = Field(validation_alias=AliasChoices("timestamp", "T"), serialization_alias="T") 12 | -------------------------------------------------------------------------------- /tests/fixtures/candles.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from x10.perpetual.candles import CandleModel 4 | from x10.utils.http import WrappedStreamResponse 5 | 6 | 7 | def create_candle_stream_message(): 8 | return WrappedStreamResponse[List[CandleModel]]( 9 | data=[ 10 | CandleModel( 11 | open="3458.64", low="3399.07", high="3476.89", close="3414.85", volume="3.938", timestamp=1721106000000 12 | ) 13 | ], 14 | ts=1721283121979, 15 | seq=1, 16 | ) 17 | -------------------------------------------------------------------------------- /x10/perpetual/withdrawals.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.utils.model import HexValue, SettlementSignatureModel, X10BaseModel 4 | 5 | 6 | class Timestamp(X10BaseModel): 7 | seconds: int 8 | 9 | 10 | class StarkWithdrawalSettlement(X10BaseModel): 11 | recipient: HexValue 12 | position_id: int 13 | collateral_id: HexValue 14 | amount: int 15 | expiration: Timestamp 16 | salt: int 17 | signature: SettlementSignatureModel 18 | 19 | 20 | class WithdrawalRequest(X10BaseModel): 21 | account_id: int 22 | amount: Decimal 23 | description: str | None 24 | settlement: StarkWithdrawalSettlement 25 | chain_id: str 26 | quote_id: str | None = None 27 | asset: str 28 | -------------------------------------------------------------------------------- /tests/perpetual/test_l2_key_derivation.py: -------------------------------------------------------------------------------- 1 | from eth_account import Account 2 | 3 | 4 | def test_known_l2_accounts(): 5 | from x10.perpetual.user_client.onboarding import get_l2_keys_from_l1_account 6 | 7 | known_private_key = "50c8e358cc974aaaa6e460641e53f78bdc550fd372984aa78ef8fd27c751e6f4" 8 | known_l2_private_key = "0x7dbb2c8651cc40e1d0d60b45eb52039f317a8aa82798bda52eee272136c0c44" 9 | known_l2_public_key = "0x78298687996aff29a0bbcb994e1305db082d084f85ec38bb78c41e6787740ec" 10 | 11 | derived_keys = get_l2_keys_from_l1_account(Account.from_key(known_private_key), 0, signing_domain="x10.exchange") 12 | assert derived_keys.private_hex == known_l2_private_key 13 | assert derived_keys.public_hex == known_l2_public_key 14 | -------------------------------------------------------------------------------- /tests/utils/test_date.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from hamcrest import assert_that, equal_to, raises 4 | 5 | from x10.utils.date import to_epoch_millis 6 | 7 | 8 | def test_convert_datetime_to_epoch_millis(): 9 | dt = datetime.fromisoformat("2024-01-08 11:35:20.447+00:00") 10 | 11 | assert_that(to_epoch_millis(dt), equal_to(1704713720447)) 12 | 13 | 14 | def test_throw_on_non_utc_timezone(): 15 | dt1 = datetime.fromisoformat("2024-01-08 11:35:20.447") 16 | dt2 = datetime.fromisoformat("2024-01-08 11:35:20.447+02:00") 17 | 18 | assert_that(lambda: to_epoch_millis(dt1), raises(AssertionError, "`value` must be in UTC")) 19 | assert_that(lambda: to_epoch_millis(dt2), raises(AssertionError, "`value` must be in UTC")) 20 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Package 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.10' 21 | 22 | - name: Install Poetry 23 | run: | 24 | pip install --upgrade pip==25.0 25 | pip install poetry==1.8.3 26 | 27 | - name: Build and Publish 28 | run: | 29 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 30 | poetry build 31 | poetry publish 32 | -------------------------------------------------------------------------------- /tests/fixtures/orderbook.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | 4 | def create_orderbook_message(): 5 | from x10.perpetual.orderbooks import OrderbookQuantityModel, OrderbookUpdateModel 6 | from x10.utils.http import WrappedStreamResponse 7 | 8 | return WrappedStreamResponse[OrderbookUpdateModel]( 9 | type="SNAPSHOT", 10 | data=OrderbookUpdateModel( 11 | market="BTC-USD", 12 | bid=[ 13 | OrderbookQuantityModel(qty=Decimal("0.008"), price=Decimal("43547.00")), 14 | OrderbookQuantityModel(qty=Decimal("0.007000"), price=Decimal("43548.00")), 15 | ], 16 | ask=[OrderbookQuantityModel(qty=Decimal("0.008"), price=Decimal("43546.00"))], 17 | ), 18 | ts=1704798222748, 19 | seq=570, 20 | ) 21 | -------------------------------------------------------------------------------- /x10/perpetual/orderbooks.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import List 3 | 4 | from pydantic import AliasChoices, Field 5 | 6 | from x10.utils.model import X10BaseModel 7 | 8 | 9 | class OrderbookQuantityModel(X10BaseModel): 10 | qty: Decimal = Field(validation_alias=AliasChoices("qty", "q"), serialization_alias="q") 11 | price: Decimal = Field(validation_alias=AliasChoices("price", "p"), serialization_alias="p") 12 | 13 | 14 | class OrderbookUpdateModel(X10BaseModel): 15 | market: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") 16 | bid: List[OrderbookQuantityModel] = Field(validation_alias=AliasChoices("bid", "b"), serialization_alias="b") 17 | ask: List[OrderbookQuantityModel] = Field(validation_alias=AliasChoices("ask", "a"), serialization_alias="a") 18 | -------------------------------------------------------------------------------- /examples/utils.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from logging import Logger 3 | 4 | from x10.perpetual.markets import TradingConfigModel 5 | from x10.perpetual.trading_client import PerpetualTradingClient 6 | 7 | 8 | def get_adjust_price_by_pct(config: TradingConfigModel): 9 | def adjust_price_by_pct(price: Decimal, pct: int): 10 | return config.round_price(price + price * Decimal(pct) / 100) 11 | 12 | return adjust_price_by_pct 13 | 14 | 15 | async def find_order_and_cancel(*, trading_client: PerpetualTradingClient, logger: Logger, order_id: str): 16 | open_order = await trading_client.account.get_order_by_id(order_id) 17 | 18 | logger.info("Found placed order: %s", open_order.to_pretty_json()) 19 | logger.info("Cancelling placed order...") 20 | 21 | await trading_client.orders.cancel_order(order_id) 22 | 23 | logger.info("Placed order is cancelled") 24 | -------------------------------------------------------------------------------- /tests/fixtures/assets.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.perpetual.assets import AssetOperationModel 4 | 5 | 6 | def create_asset_operations(): 7 | return [ 8 | AssetOperationModel( 9 | id="1816814506626514944", 10 | type="TRANSFER", 11 | status="COMPLETED", 12 | amount=Decimal("-100.0000000000000000"), 13 | fee=Decimal("0"), 14 | asset=1, 15 | time=1721997307818, 16 | account_id=3004, 17 | counterparty_account_id=7349, 18 | ), 19 | AssetOperationModel( 20 | id="1813548171448147968", 21 | type="CLAIM", 22 | status="COMPLETED", 23 | amount=Decimal("100000.0000000000000000"), 24 | fee=Decimal("0"), 25 | asset=1, 26 | time=1721218552833, 27 | account_id=3004, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /.github/workflows/code-checks.yml: -------------------------------------------------------------------------------- 1 | name: Code checks 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [ starknet ] 7 | types: [ opened, reopened, synchronize, ready_for_review ] 8 | 9 | jobs: 10 | code-checks: 11 | if: github.event.pull_request.draft == false 12 | timeout-minutes: 10 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ['3.10', '3.11', '3.12', '3.13'] 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-python@v6 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Setup poetry 25 | run: | 26 | pip install --upgrade pip==25.0 27 | pip install poetry==1.8.3 28 | poetry config virtualenvs.create false 29 | 30 | - name: Install deps 31 | run: poetry install --no-root 32 | 33 | - name: Run linter 34 | run: make lint 35 | 36 | - name: Run unit-tests 37 | run: make test 38 | -------------------------------------------------------------------------------- /x10/perpetual/candles.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Literal, Optional 3 | 4 | from pydantic import AliasChoices, Field 5 | 6 | from x10.utils.model import X10BaseModel 7 | 8 | CandleType = Literal["trades", "mark-prices", "index-prices"] 9 | CandleInterval = Literal["PT1M", "PT5M", "PT15M", "PT30M", "PT1H", "PT2H", "PT4H", "P1D"] 10 | 11 | 12 | class CandleModel(X10BaseModel): 13 | open: Decimal = Field(validation_alias=AliasChoices("open", "o"), serialization_alias="o") 14 | low: Decimal = Field(validation_alias=AliasChoices("low", "l"), serialization_alias="l") 15 | high: Decimal = Field(validation_alias=AliasChoices("high", "h"), serialization_alias="h") 16 | close: Decimal = Field(validation_alias=AliasChoices("close", "c"), serialization_alias="c") 17 | volume: Optional[Decimal] = Field( 18 | validation_alias=AliasChoices("volume", "v"), serialization_alias="v", default=None 19 | ) 20 | timestamp: int = Field(validation_alias=AliasChoices("timestamp", "T"), serialization_alias="T") 21 | -------------------------------------------------------------------------------- /examples/withdrawal_example.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from decimal import Decimal 3 | 4 | from x10.perpetual.accounts import StarkPerpetualAccount 5 | from x10.perpetual.configuration import MAINNET_CONFIG 6 | from x10.perpetual.trading_client import PerpetualTradingClient 7 | 8 | 9 | async def setup_and_run(): 10 | stark_account = StarkPerpetualAccount( 11 | vault=200027, 12 | private_key="<>", 13 | public_key="<>", 14 | api_key="<>", 15 | ) 16 | trading_client = PerpetualTradingClient( 17 | endpoint_config=MAINNET_CONFIG, 18 | stark_account=stark_account, 19 | ) 20 | 21 | resp = await trading_client.account.withdraw( 22 | amount=Decimal("10"), 23 | stark_address="0x037D9c8bBf6DE8b08F0C4072eBfAE9D1E890d094b9d117bABFCb3D41379B63ce".lower(), 24 | nonce=123, 25 | ) 26 | 27 | print("Withdrawal response:") 28 | print(resp) 29 | 30 | print("Withdrawal complete") 31 | print("press enter to continue") 32 | input() 33 | 34 | 35 | if __name__ == "__main__": 36 | run(main=setup_and_run()) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, X10 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Python 3", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", 7 | "customizations": { 8 | "vscode": { 9 | "extensions": [ 10 | "github.vscode-github-actions" 11 | ] 12 | } 13 | } 14 | 15 | // Features to add to the dev container. More info: https://containers.dev/features. 16 | // "features": {}, 17 | 18 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 19 | // "forwardPorts": [], 20 | 21 | // Use 'postCreateCommand' to run commands after the container is created. 22 | // "postCreateCommand": "pip3 install --user -r requirements.txt", 23 | 24 | // Configure tool-specific properties. 25 | // "customizations": {}, 26 | 27 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 28 | // "remoteUser": "root" 29 | } 30 | -------------------------------------------------------------------------------- /x10/perpetual/transfers.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.perpetual.orders import SettlementSignatureModel 4 | from x10.utils.model import HexValue, X10BaseModel 5 | 6 | 7 | class StarkTransferSettlement(X10BaseModel): 8 | amount: int 9 | asset_id: HexValue 10 | expiration_timestamp: int 11 | nonce: int 12 | receiver_position_id: int 13 | receiver_public_key: HexValue 14 | sender_position_id: int 15 | sender_public_key: HexValue 16 | signature: SettlementSignatureModel 17 | 18 | 19 | class PerpetualTransferModel(X10BaseModel): 20 | from_account: int 21 | to_account: int 22 | amount: Decimal 23 | transferred_asset: str 24 | settlement: StarkTransferSettlement 25 | 26 | 27 | class OnChainPerpetualTransferModel(X10BaseModel): 28 | from_vault: int 29 | to_vault: int 30 | amount: Decimal 31 | settlement: StarkTransferSettlement 32 | transferred_asset: str 33 | 34 | 35 | class TransferResponseModel(X10BaseModel): 36 | valid_signature: bool 37 | id: int | None = None 38 | hash_calculated: str | None = None 39 | stark_ex_representation: dict | None = None 40 | -------------------------------------------------------------------------------- /tests/utils/test_model.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | from hamcrest import assert_that, equal_to, raises 5 | from pydantic import ValidationError 6 | 7 | from x10.utils.model import X10BaseModel 8 | 9 | 10 | class _TestModel(X10BaseModel): 11 | market: str 12 | order_type: Optional[str] = "LIMIT" 13 | created_time: int 14 | expiry_time: Optional[int] = None 15 | 16 | 17 | def test_model_should_parse_json_with_missing_optional_fields(): 18 | model = _TestModel.model_validate_json('{"market": "BTC-USD", "createdTime": 0}') 19 | 20 | assert_that(model, equal_to(_TestModel(market="BTC-USD", created_time=0))) 21 | assert_that(model.order_type, equal_to("LIMIT")) 22 | assert_that(model.expiry_time, equal_to(None)) 23 | 24 | 25 | def test_model_should_parse_json(): 26 | model = _TestModel.model_validate_json('{"market": "BTC-USD", "createdTime": 0, "expiryTime": 1}') 27 | 28 | assert_that(model, equal_to(_TestModel(market="BTC-USD", created_time=0, expiry_time=1))) 29 | 30 | 31 | def test_model_should_throw_error_when_field_is_modified(): 32 | test_model = _TestModel(market="BTC-USD", created_time=0) 33 | 34 | def try_to_modify_field(): 35 | test_model.market = "ETH-USD" 36 | 37 | assert_that(try_to_modify_field, raises(ValidationError, pattern=re.compile("Instance is frozen"))) 38 | -------------------------------------------------------------------------------- /x10/perpetual/trades.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from pydantic import AliasChoices, Field 4 | from strenum import StrEnum 5 | 6 | from x10.perpetual.orders import OrderSide 7 | from x10.utils.model import X10BaseModel 8 | 9 | 10 | class TradeType(StrEnum): 11 | TRADE = "TRADE" 12 | LIQUIDATION = "LIQUIDATION" 13 | DELEVERAGE = "DELEVERAGE" 14 | 15 | 16 | class PublicTradeModel(X10BaseModel): 17 | id: int = Field(validation_alias=AliasChoices("id", "i"), serialization_alias="i") 18 | market: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") 19 | side: OrderSide = Field(validation_alias=AliasChoices("side", "S"), serialization_alias="S") 20 | trade_type: TradeType = Field(validation_alias=AliasChoices("trade_type", "tT"), serialization_alias="tT") 21 | timestamp: int = Field(validation_alias=AliasChoices("timestamp", "T"), serialization_alias="T") 22 | price: Decimal = Field(validation_alias=AliasChoices("price", "p"), serialization_alias="p") 23 | qty: Decimal = Field(validation_alias=AliasChoices("qty", "q"), serialization_alias="q") 24 | 25 | 26 | class AccountTradeModel(X10BaseModel): 27 | id: int 28 | account_id: int 29 | market: str 30 | order_id: int 31 | side: OrderSide 32 | price: Decimal 33 | qty: Decimal 34 | value: Decimal 35 | fee: Decimal 36 | is_taker: bool 37 | trade_type: TradeType 38 | created_time: int 39 | -------------------------------------------------------------------------------- /tests/utils/test_http.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, equal_to, raises 2 | from strenum import StrEnum 3 | 4 | from x10.utils.http import get_url 5 | 6 | 7 | class _QueryParamValueEnum(StrEnum): 8 | VALUE_1 = "VALUE_1" 9 | VALUE_2 = "VALUE_2" 10 | 11 | 12 | def test_generate_valid_url_from_template(): 13 | assert_that( 14 | get_url( 15 | "/info/candles", 16 | query={ 17 | "param1": "value1", 18 | "param2": ["value2_1", "value2_2"], 19 | "param3": None, 20 | "param4": 0, 21 | "param5": False, 22 | "param6": _QueryParamValueEnum.VALUE_1, 23 | "param7": [_QueryParamValueEnum.VALUE_1, _QueryParamValueEnum.VALUE_2], 24 | }, 25 | ), 26 | equal_to( 27 | "/info/candles?param1=value1¶m2=value2_1¶m2=value2_2¶m4=0¶m5=False¶m6=VALUE_1¶m7=VALUE_1¶m7=VALUE_2" # noqa: E501 28 | ), 29 | ) 30 | assert_that(get_url("/info/candles/", market="BTC-USD"), equal_to("/info/candles/BTC-USD")) 31 | assert_that( 32 | get_url("/info/candles//", market="BTC-USD", candle_type="trades"), 33 | equal_to("/info/candles/BTC-USD/trades"), 34 | ) 35 | assert_that(lambda: get_url("/info/candles/"), raises(KeyError)) 36 | assert_that(get_url("/info/candles/"), equal_to("/info/candles")) 37 | assert_that(get_url("/info/candles/", market="BTC-USD"), equal_to("/info/candles/BTC-USD")) 38 | assert_that(get_url("/info/candles/", market=None), equal_to("/info/candles")) 39 | -------------------------------------------------------------------------------- /examples/init_env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import logging.handlers 4 | import os 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | 8 | import yaml 9 | from dotenv import load_dotenv 10 | 11 | 12 | @dataclass 13 | class EnvConfig: 14 | api_key: str | None = None 15 | public_key: str | None = None 16 | private_key: str | None = None 17 | vault_id: int | None = None 18 | builder_id: int | None = None 19 | 20 | 21 | def init_env(require_private_api: bool = True): 22 | load_dotenv() 23 | 24 | config_as_str = Path(__file__).parent.joinpath("./logger.yml").read_text() 25 | config = yaml.safe_load(config_as_str) 26 | logging.config.dictConfig(config) 27 | 28 | api_key = os.getenv("X10_API_KEY") 29 | public_key = os.getenv("X10_PUBLIC_KEY") 30 | private_key = os.getenv("X10_PRIVATE_KEY") 31 | vault_id = os.getenv("X10_VAULT_ID") 32 | builder_id = os.getenv("X10_BUILDER_ID") 33 | 34 | if require_private_api: 35 | assert api_key, "X10_API_KEY is not set" 36 | assert public_key, "X10_PUBLIC_KEY is not set" 37 | assert private_key, "X10_PRIVATE_KEY is not set" 38 | assert vault_id, "X10_VAULT_ID is not set" 39 | 40 | assert public_key.startswith("0x"), "X10_PUBLIC_KEY must be a hex string" 41 | assert private_key.startswith("0x"), "X10_PRIVATE_KEY must be a hex string" 42 | 43 | return EnvConfig( 44 | api_key=api_key, 45 | public_key=public_key, 46 | private_key=private_key, 47 | vault_id=int(vault_id) if vault_id else None, 48 | builder_id=int(builder_id) if builder_id else None, 49 | ) 50 | -------------------------------------------------------------------------------- /examples/05_bridged_withdrawal.py: -------------------------------------------------------------------------------- 1 | import logging.handlers 2 | from asyncio import run 3 | from decimal import Decimal 4 | 5 | from examples.init_env import init_env 6 | from x10.perpetual.accounts import StarkPerpetualAccount 7 | from x10.perpetual.configuration import MAINNET_CONFIG 8 | from x10.perpetual.trading_client import PerpetualTradingClient 9 | 10 | LOGGER = logging.getLogger() 11 | ENDPOINT_CONFIG = MAINNET_CONFIG 12 | 13 | 14 | # Bridged withdrawal example. Bridge disabled on sepolia, example works only on mainnet 15 | async def run_example(): 16 | env_config = init_env() 17 | amount = 5 18 | target_chain = "ETH" 19 | 20 | stark_account = StarkPerpetualAccount( 21 | api_key=env_config.api_key, 22 | public_key=env_config.public_key, 23 | private_key=env_config.private_key, 24 | vault=env_config.vault_id, 25 | ) 26 | trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) 27 | LOGGER.info("Getting quote") 28 | quote = (await trading_client.account.get_bridge_quote(chain_in="STRK", chain_out=target_chain, amount=amount)).data 29 | if quote.fee > Decimal(2): 30 | LOGGER.info("Fee %s is too high", quote.fee) 31 | return 32 | LOGGER.info("Commiting quote") 33 | await trading_client.account.commit_bridge_quote(quote.id) 34 | LOGGER.info("Requesting withdrawal") 35 | withdrawal_id = ( 36 | await trading_client.account.withdraw( 37 | amount=Decimal(amount), 38 | chain_id=target_chain, 39 | quote_id=quote.id, 40 | ) 41 | ).data 42 | 43 | LOGGER.info("Withdrawal %s requested", withdrawal_id) 44 | 45 | 46 | if __name__ == "__main__": 47 | run(main=run_example()) 48 | -------------------------------------------------------------------------------- /x10/perpetual/positions.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Optional 3 | 4 | from strenum import StrEnum 5 | 6 | from x10.utils.model import X10BaseModel 7 | 8 | 9 | class ExitType(StrEnum): 10 | TRADE = "TRADE" 11 | LIQUIDATION = "LIQUIDATION" 12 | ADL = "ADL" 13 | 14 | 15 | class PositionSide(StrEnum): 16 | LONG = "LONG" 17 | SHORT = "SHORT" 18 | 19 | 20 | class PositionStatus(StrEnum): 21 | OPENED = "OPENED" 22 | CLOSED = "CLOSED" 23 | 24 | 25 | class PositionModel(X10BaseModel): 26 | id: int 27 | account_id: int 28 | market: str 29 | status: PositionStatus 30 | side: PositionSide 31 | leverage: Decimal 32 | size: Decimal 33 | value: Decimal 34 | open_price: Decimal 35 | mark_price: Decimal 36 | liquidation_price: Optional[Decimal] = None 37 | unrealised_pnl: Decimal 38 | realised_pnl: Decimal 39 | tp_price: Optional[Decimal] = None 40 | sl_price: Optional[Decimal] = None 41 | adl: Optional[int] = None 42 | created_at: int 43 | updated_at: int 44 | 45 | 46 | class RealisedPnlBreakdownModel(X10BaseModel): 47 | trade_pnl: Decimal 48 | funding_fees: Decimal 49 | open_fees: Decimal 50 | close_fees: Decimal 51 | 52 | 53 | class PositionHistoryModel(X10BaseModel): 54 | id: int 55 | account_id: int 56 | market: str 57 | side: PositionSide 58 | size: Decimal 59 | max_position_size: Decimal 60 | leverage: Decimal 61 | open_price: Decimal 62 | exit_price: Optional[Decimal] = None 63 | realised_pnl: Decimal 64 | realised_pnl_breakdown: RealisedPnlBreakdownModel 65 | created_time: int 66 | exit_type: Optional[ExitType] = None 67 | closed_time: Optional[int] = None 68 | -------------------------------------------------------------------------------- /examples/onboarding_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from eth_account import Account 4 | from eth_account.signers.local import LocalAccount 5 | 6 | from x10.perpetual.accounts import StarkPerpetualAccount 7 | from x10.perpetual.configuration import TESTNET_CONFIG 8 | from x10.perpetual.trading_client.trading_client import PerpetualTradingClient 9 | from x10.perpetual.user_client.user_client import UserClient 10 | 11 | 12 | async def on_board_example(): 13 | environment_config = TESTNET_CONFIG 14 | eth_account_1: LocalAccount = Account.from_key("YOUR_ETH_PRIVATE_KEY") 15 | onboarding_client = UserClient(endpoint_config=environment_config, l1_private_key=eth_account_1.key.hex) 16 | root_account = await onboarding_client.onboard() 17 | 18 | trading_key = await onboarding_client.create_account_api_key(root_account.account, "trading_key") 19 | 20 | root_trading_client = PerpetualTradingClient( 21 | environment_config, 22 | StarkPerpetualAccount( 23 | vault=root_account.account.l2_vault, 24 | private_key=root_account.l2_key_pair.private_hex, 25 | public_key=root_account.l2_key_pair.public_hex, 26 | api_key=trading_key, 27 | ), 28 | ) 29 | 30 | print(f"User 1 v: {root_account.account.l2_vault}") 31 | print(f"User 1 pub: {root_account.l2_key_pair.public_hex}") 32 | print(f"User 1 priv: {root_account.l2_key_pair.private_hex}") 33 | claim_response = await root_trading_client.testnet.claim_testing_funds() 34 | claim_id = claim_response.data.id if claim_response.data else None 35 | print(f"Claim ID: {claim_id}") 36 | 37 | resp = await root_trading_client.account.asset_operations(id=claim_id) 38 | print(f"Asset Operations: {resp.data}") 39 | 40 | 41 | asyncio.run(on_board_example()) 42 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def create_accounts(): 6 | from tests.fixtures.accounts import create_accounts as _create_accounts 7 | 8 | return _create_accounts 9 | 10 | 11 | @pytest.fixture 12 | def create_trading_account(): 13 | from tests.fixtures.accounts import ( 14 | create_trading_account as _create_trading_account, 15 | ) 16 | 17 | return _create_trading_account 18 | 19 | 20 | @pytest.fixture 21 | def btc_usd_market_json_data(): 22 | from tests.fixtures.markets import get_btc_usd_market_json_data 23 | 24 | return get_btc_usd_market_json_data() 25 | 26 | 27 | @pytest.fixture 28 | def create_btc_usd_market(btc_usd_market_json_data): 29 | from tests.fixtures.markets import create_btc_usd_market as _create_btc_usd_market 30 | 31 | return lambda: _create_btc_usd_market(btc_usd_market_json_data) 32 | 33 | 34 | @pytest.fixture 35 | def create_orderbook_message(): 36 | from tests.fixtures.orderbook import ( 37 | create_orderbook_message as _create_orderbook_message, 38 | ) 39 | 40 | return _create_orderbook_message 41 | 42 | 43 | @pytest.fixture 44 | def create_account_update_trade_message(): 45 | from tests.fixtures.accounts import ( 46 | create_account_update_trade_message as _create_account_update_trade_message, 47 | ) 48 | 49 | return _create_account_update_trade_message 50 | 51 | 52 | @pytest.fixture 53 | def create_account_update_unknown_message(): 54 | from tests.fixtures.accounts import ( 55 | create_account_update_unknown_message as _create_account_update_unknown_message, 56 | ) 57 | 58 | return _create_account_update_unknown_message 59 | 60 | 61 | @pytest.fixture 62 | def create_asset_operations(): 63 | from tests.fixtures.assets import ( 64 | create_asset_operations as _create_asset_operations, 65 | ) 66 | 67 | return _create_asset_operations 68 | -------------------------------------------------------------------------------- /tests/perpetual/test_onboarding_payload.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from eth_account import Account 4 | 5 | from x10.perpetual.user_client.onboarding import get_l2_keys_from_l1_account 6 | 7 | 8 | def test_onboarding_object_generation(): 9 | # all known values from authentication service tests 10 | from x10.perpetual.user_client.onboarding import get_onboarding_payload 11 | 12 | known_private_key = "50c8e358cc974aaaa6e460641e53f78bdc550fd372984aa78ef8fd27c751e6f4" 13 | known_l2_public_key = "0x78298687996aff29a0bbcb994e1305db082d084f85ec38bb78c41e6787740ec" 14 | 15 | l1_account = Account.from_key(known_private_key) 16 | key_pair = get_l2_keys_from_l1_account(l1_account=l1_account, account_index=0, signing_domain="x10.exchange") 17 | 18 | payload = get_onboarding_payload( 19 | account=l1_account, 20 | time=datetime.datetime( 21 | year=2024, 22 | month=7, 23 | day=30, 24 | hour=16, 25 | minute=1, 26 | second=2, 27 | tzinfo=datetime.timezone.utc, 28 | ), 29 | host="host", 30 | key_pair=key_pair, 31 | signing_domain="x10.exchange", 32 | ).to_json() 33 | 34 | assert ( 35 | "0x" + payload["l1Signature"] 36 | == "0x9a59eb699eb58f2ec975455f33dd7205c8a569f7b6d7647c25b71e7ab7eec3d30f2b8c9038f06f077167eb90e0c002602e4ecbab180fad4b2c91d2259883e6571c" # noqa: E501 37 | ) 38 | 39 | assert payload["l2Key"] == known_l2_public_key 40 | assert payload["l2Signature"]["r"] == "0x70881694c59c7212b1a47fbbc07df4d32678f0326f778861ec3a2a5dbc09157" 41 | assert payload["l2Signature"]["s"] == "0x558805193faa5d780719cba5f699ae1c888eec1fee23da4215fdd94a744d2cb" 42 | assert payload["accountCreation"]["time"] == "2024-07-30T16:01:02Z" 43 | assert payload["accountCreation"]["action"] == "REGISTER" 44 | assert payload["accountCreation"]["tosAccepted"] is True 45 | -------------------------------------------------------------------------------- /x10/perpetual/amounts.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | from dataclasses import dataclass 3 | from decimal import Decimal 4 | 5 | from x10.perpetual.assets import Asset 6 | 7 | ROUNDING_SELL_CONTEXT = decimal.Context(rounding=decimal.ROUND_DOWN) 8 | ROUNDING_BUY_CONTEXT = decimal.Context(rounding=decimal.ROUND_UP) 9 | ROUNDING_FEE_CONTEXT = decimal.Context(rounding=decimal.ROUND_UP) 10 | 11 | 12 | @dataclass 13 | class HumanReadableAmount: 14 | value: Decimal 15 | asset: Asset 16 | 17 | def to_l1_amount(self) -> "L1Amount": 18 | converted_value = self.asset.convert_internal_quantity_to_l1_quantity(self.value) 19 | return L1Amount(converted_value, self.asset) 20 | 21 | def to_stark_amount(self, rounding_context: decimal.Context) -> "StarkAmount": 22 | converted_value = self.asset.convert_human_readable_to_stark_quantity(self.value, rounding_context) 23 | return StarkAmount(converted_value, self.asset) 24 | 25 | 26 | @dataclass 27 | class L1Amount: 28 | value: int 29 | asset: Asset 30 | 31 | def to_internal_amount(self) -> HumanReadableAmount: 32 | converted_value = self.asset.convert_l1_quantity_to_internal_quantity(self.value) 33 | return HumanReadableAmount(converted_value, self.asset) 34 | 35 | 36 | @dataclass 37 | class StarkAmount: 38 | value: int 39 | asset: Asset 40 | 41 | def to_internal_amount(self) -> HumanReadableAmount: 42 | converted_value = self.asset.convert_stark_to_internal_quantity(self.value) 43 | return HumanReadableAmount(converted_value, self.asset) 44 | 45 | def negate(self) -> "StarkAmount": 46 | return StarkAmount(-self.value, self.asset) 47 | 48 | 49 | @dataclass 50 | class StarkOrderAmounts: 51 | collateral_amount_internal: HumanReadableAmount 52 | synthetic_amount_internal: HumanReadableAmount 53 | fee_amount_internal: HumanReadableAmount 54 | fee_rate: Decimal 55 | rounding_context: decimal.Context 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.7.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | 6 | [tool.poetry] 7 | name = "x10-python-trading-starknet" 8 | version = "0.0.17" 9 | description = "Python client for X10 API" 10 | authors = ["X10 "] 11 | repository = "https://github.com/x10xchange/python_sdk" 12 | documentation = "https://api.docs.extended.exchange/" 13 | readme = "README.md" 14 | classifiers = [ 15 | "Intended Audience :: Developers", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.10", 20 | "Topic :: Software Development :: Libraries :: Python Modules", 21 | ] 22 | packages = [{ include = "x10" }] 23 | 24 | [tool.poetry.dependencies] 25 | aiohttp = ">=3.10.11" 26 | eth-account = ">=0.12.0" 27 | fast-stark-crypto = "==0.3.8" 28 | pydantic = ">=2.9.0" 29 | python = "^3.10" 30 | pyyaml = ">=6.0.1" 31 | sortedcontainers = ">=2.4.0" 32 | strenum = "^0.4.15" 33 | tenacity = "^9.1.2" 34 | websockets = ">=12.0,<14.0" 35 | 36 | [tool.poetry.group.dev.dependencies] 37 | black = "==23.12.0" 38 | flake8 = "==6.1.0" 39 | flake8-bugbear = "==24.12.12" 40 | freezegun = "==1.5.5" 41 | isort = "==5.13.2" 42 | mypy = "==1.18.2" 43 | mypy-extensions = "==1.0.0" 44 | PyHamcrest = "2.1.0" 45 | pytest = "==7.4.3" 46 | pytest-aiohttp = "==1.0.5" 47 | pytest-asyncio = "==0.23.3" 48 | pytest-cov = "==4.1.0" 49 | pytest-forked = "==1.6.0" 50 | pytest-mock = "==3.12.0" 51 | python-dotenv = "==1.0.1" 52 | safety = "==3.5.1" 53 | tox = "==4.11.4" 54 | types-pyyaml = "==6.0.12.12" 55 | typing-extensions = ">=4.9.0" 56 | 57 | 58 | [tool.mypy] 59 | packages = ["examples", "tests", "x10"] 60 | plugins = ["pydantic.mypy"] 61 | follow_untyped_imports = true 62 | check_untyped_defs = true 63 | 64 | [tool.pydantic-mypy] 65 | init_forbid_extra = true 66 | init_typed = true 67 | warn_required_dynamic_aliases = true 68 | -------------------------------------------------------------------------------- /x10/perpetual/configuration.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class StarknetDomain: 6 | name: str 7 | version: str 8 | chain_id: str 9 | revision: str 10 | 11 | 12 | @dataclass 13 | class EndpointConfig: 14 | chain_rpc_url: str 15 | api_base_url: str 16 | stream_url: str 17 | onboarding_url: str 18 | signing_domain: str 19 | collateral_asset_contract: str 20 | asset_operations_contract: str 21 | collateral_asset_on_chain_id: str 22 | collateral_decimals: int 23 | collateral_asset_id: str 24 | starknet_domain: StarknetDomain 25 | 26 | 27 | TESTNET_CONFIG = EndpointConfig( 28 | chain_rpc_url="https://rpc.sepolia.org", 29 | api_base_url="https://api.starknet.sepolia.extended.exchange/api/v1", 30 | stream_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v1", 31 | onboarding_url="https://api.starknet.sepolia.extended.exchange", 32 | signing_domain="starknet.sepolia.extended.exchange", 33 | collateral_asset_contract="0x31857064564ed0ff978e687456963cba09c2c6985d8f9300a1de4962fafa054", 34 | asset_operations_contract="", 35 | collateral_asset_on_chain_id="0x1", 36 | collateral_decimals=6, 37 | collateral_asset_id="0x1", 38 | starknet_domain=StarknetDomain(name="Perpetuals", version="v0", chain_id="SN_SEPOLIA", revision="1"), 39 | ) 40 | 41 | MAINNET_CONFIG = EndpointConfig( 42 | chain_rpc_url="", 43 | api_base_url="https://api.starknet.extended.exchange/api/v1", 44 | stream_url="wss://api.starknet.extended.exchange/stream.extended.exchange/v1", 45 | onboarding_url="https://api.starknet.extended.exchange", 46 | signing_domain="extended.exchange", 47 | collateral_asset_contract="", 48 | asset_operations_contract="", 49 | collateral_asset_on_chain_id="0x1", 50 | collateral_decimals=6, 51 | collateral_asset_id="0x1", 52 | starknet_domain=StarknetDomain(name="Perpetuals", version="v0", chain_id="SN_MAIN", revision="1"), 53 | ) 54 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/base_module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | import aiohttp 4 | 5 | from x10.errors import X10Error 6 | from x10.perpetual.accounts import StarkPerpetualAccount 7 | from x10.perpetual.configuration import EndpointConfig 8 | from x10.utils.http import CLIENT_TIMEOUT, get_url 9 | 10 | 11 | class BaseModule: 12 | __endpoint_config: EndpointConfig 13 | __api_key: Optional[str] 14 | __stark_account: Optional[StarkPerpetualAccount] 15 | __session: Optional[aiohttp.ClientSession] 16 | 17 | def __init__( 18 | self, 19 | endpoint_config: EndpointConfig, 20 | *, 21 | api_key: Optional[str] = None, 22 | stark_account: Optional[StarkPerpetualAccount] = None, 23 | ): 24 | super().__init__() 25 | self.__endpoint_config = endpoint_config 26 | self.__api_key = api_key 27 | self.__stark_account = stark_account 28 | self.__session = None 29 | 30 | def _get_url(self, path: str, *, query: Optional[Dict] = None, **path_params) -> str: 31 | return get_url(f"{self.__endpoint_config.api_base_url}{path}", query=query, **path_params) 32 | 33 | def _get_endpoint_config(self) -> EndpointConfig: 34 | return self.__endpoint_config 35 | 36 | def _get_api_key(self): 37 | if not self.__api_key: 38 | raise X10Error("API key is not set") 39 | 40 | return self.__api_key 41 | 42 | def _get_stark_account(self): 43 | if not self.__stark_account: 44 | raise X10Error("Stark account is not set") 45 | 46 | return self.__stark_account 47 | 48 | async def get_session(self) -> aiohttp.ClientSession: 49 | if self.__session is None: 50 | created_session = aiohttp.ClientSession(timeout=CLIENT_TIMEOUT) 51 | self.__session = created_session 52 | 53 | return self.__session 54 | 55 | async def close_session(self): 56 | if self.__session: 57 | await self.__session.close() 58 | self.__session = None 59 | -------------------------------------------------------------------------------- /examples/03_subscribe_to_stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from asyncio import run 4 | from signal import SIGINT, SIGTERM 5 | 6 | from examples.init_env import init_env 7 | from x10.config import ETH_USD_MARKET 8 | from x10.perpetual.configuration import MAINNET_CONFIG 9 | from x10.perpetual.stream_client import PerpetualStreamClient 10 | 11 | LOGGER = logging.getLogger() 12 | MARKET_NAME = ETH_USD_MARKET 13 | ENDPOINT_CONFIG = MAINNET_CONFIG 14 | 15 | 16 | async def subscribe_to_streams(stop_event: asyncio.Event): 17 | env_config = init_env() 18 | stream_client = PerpetualStreamClient(api_url=ENDPOINT_CONFIG.stream_url) 19 | 20 | async def subscribe_to_orderbook(): 21 | async with stream_client.subscribe_to_orderbooks(MARKET_NAME) as orderbook_stream: 22 | while not stop_event.is_set(): 23 | try: 24 | msg = await asyncio.wait_for(orderbook_stream.recv(), timeout=1) 25 | LOGGER.info("Orderbook: %s#%s", msg.type, msg.seq) 26 | except asyncio.TimeoutError: 27 | pass 28 | 29 | async def subscribe_to_account(): 30 | async with stream_client.subscribe_to_account_updates(env_config.api_key) as account_stream: 31 | while not stop_event.is_set(): 32 | try: 33 | msg = await asyncio.wait_for(account_stream.recv(), timeout=1) 34 | LOGGER.info("Account: %s#%s", msg.type, msg.seq) 35 | except asyncio.TimeoutError: 36 | pass 37 | 38 | LOGGER.info("Press Ctrl+C to stop") 39 | 40 | await asyncio.gather(subscribe_to_orderbook(), subscribe_to_account()) 41 | 42 | 43 | async def run_example(): 44 | stop_event = asyncio.Event() 45 | loop = asyncio.get_running_loop() 46 | 47 | def signal_handler(): 48 | LOGGER.info("Signal received, stopping...") 49 | stop_event.set() 50 | 51 | loop.add_signal_handler(SIGINT, signal_handler) 52 | loop.add_signal_handler(SIGTERM, signal_handler) 53 | 54 | await subscribe_to_streams(stop_event) 55 | 56 | 57 | if __name__ == "__main__": 58 | run(main=run_example()) 59 | -------------------------------------------------------------------------------- /tests/perpetual/test_transfer_object.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | from freezegun import freeze_time 5 | from hamcrest import assert_that, equal_to 6 | from pytest_mock import MockerFixture 7 | 8 | from x10.perpetual.configuration import TESTNET_CONFIG 9 | 10 | FROZEN_NONCE = 1473459052 11 | 12 | 13 | @freeze_time("2024-01-05 01:08:56.860694") 14 | @pytest.mark.asyncio 15 | async def test_create_transfer(mocker: MockerFixture, create_trading_account, create_accounts, create_btc_usd_market): 16 | mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) 17 | 18 | from x10.perpetual.transfer_object import create_transfer_object 19 | 20 | trading_account = create_trading_account() 21 | accounts = create_accounts() 22 | transfer_obj = create_transfer_object( 23 | from_vault=trading_account.vault, 24 | to_vault=int(accounts[1].l2_vault), 25 | to_l2_key=accounts[1].l2_key, 26 | amount=Decimal("1.1"), 27 | stark_account=trading_account, 28 | config=TESTNET_CONFIG, 29 | nonce=FROZEN_NONCE, 30 | ) 31 | assert_that( 32 | transfer_obj.to_api_request_json(), 33 | equal_to( 34 | { 35 | "fromVault": trading_account.vault, 36 | "toVault": int(accounts[1].l2_vault), 37 | "amount": "1.1", 38 | "transferredAsset": "0x1", 39 | "settlement": { 40 | "amount": 1100000, 41 | "assetId": "0x1", 42 | "expirationTimestamp": 1706231337, 43 | "nonce": 1473459052, 44 | "receiverPositionId": int(accounts[1].l2_vault), 45 | "receiverPublicKey": accounts[1].l2_key, 46 | "senderPositionId": trading_account.vault, 47 | "senderPublicKey": f"{hex(trading_account.public_key)}", 48 | "signature": { 49 | "r": "0x21f353080b04ab862474d0d2985f4d223087a89193a3a8bdea3de320f845cf8", 50 | "s": "0x6f70daa9e65037d97ccf0667cc6f1368b7b01a93d0ededf929b53be3f177d96", 51 | }, 52 | }, 53 | } 54 | ), 55 | ) 56 | -------------------------------------------------------------------------------- /examples/01_create_limit_order.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from asyncio import run 3 | 4 | from examples.init_env import init_env 5 | from examples.utils import find_order_and_cancel, get_adjust_price_by_pct 6 | from x10.config import ETH_USD_MARKET 7 | from x10.perpetual.accounts import StarkPerpetualAccount 8 | from x10.perpetual.configuration import MAINNET_CONFIG 9 | from x10.perpetual.order_object import create_order_object 10 | from x10.perpetual.orders import OrderSide, TimeInForce 11 | from x10.perpetual.trading_client import PerpetualTradingClient 12 | 13 | LOGGER = logging.getLogger() 14 | MARKET_NAME = ETH_USD_MARKET 15 | ENDPOINT_CONFIG = MAINNET_CONFIG 16 | 17 | 18 | async def run_example(): 19 | env_config = init_env() 20 | stark_account = StarkPerpetualAccount( 21 | api_key=env_config.api_key, 22 | public_key=env_config.public_key, 23 | private_key=env_config.private_key, 24 | vault=env_config.vault_id, 25 | ) 26 | trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) 27 | markets_dict = await trading_client.markets_info.get_markets_dict() 28 | 29 | market = markets_dict[MARKET_NAME] 30 | adjust_price_by_pct = get_adjust_price_by_pct(market.trading_config) 31 | 32 | order_size = market.trading_config.min_order_size 33 | order_price = adjust_price_by_pct(market.market_stats.bid_price, -10.0) 34 | 35 | LOGGER.info("Creating LIMIT order object for market: %s", market.name) 36 | 37 | new_order = create_order_object( 38 | account=stark_account, 39 | starknet_domain=ENDPOINT_CONFIG.starknet_domain, 40 | market=market, 41 | side=OrderSide.BUY, 42 | amount_of_synthetic=order_size, 43 | price=market.trading_config.round_price(order_price), 44 | time_in_force=TimeInForce.GTT, 45 | reduce_only=False, 46 | post_only=True, 47 | ) 48 | 49 | LOGGER.info("Placing order...") 50 | 51 | placed_order = await trading_client.orders.place_order(order=new_order) 52 | 53 | LOGGER.info("Order is placed: %s", placed_order.to_pretty_json()) 54 | 55 | await find_order_and_cancel(trading_client=trading_client, logger=LOGGER, order_id=placed_order.data.id) 56 | 57 | 58 | if __name__ == "__main__": 59 | run(main=run_example()) 60 | -------------------------------------------------------------------------------- /x10/perpetual/assets.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from decimal import Context, Decimal 3 | from typing import Optional 4 | 5 | from strenum import StrEnum 6 | 7 | from x10.utils.model import HexValue, X10BaseModel 8 | 9 | 10 | @dataclass 11 | class Asset: 12 | id: int 13 | name: str 14 | precision: int 15 | active: bool 16 | is_collateral: bool 17 | settlement_external_id: str 18 | settlement_resolution: int 19 | l1_external_id: str 20 | l1_resolution: int 21 | 22 | def convert_human_readable_to_stark_quantity(self, internal: Decimal, rounding_context: Context) -> int: 23 | return int( 24 | rounding_context.multiply(internal, Decimal(self.settlement_resolution)).to_integral( 25 | context=rounding_context 26 | ) 27 | ) 28 | 29 | def convert_stark_to_internal_quantity(self, stark: int) -> Decimal: 30 | return Decimal(stark) / Decimal(self.settlement_resolution) 31 | 32 | def convert_l1_quantity_to_internal_quantity(self, l1: int) -> Decimal: 33 | return Decimal(l1) / Decimal(self.l1_resolution) 34 | 35 | def convert_internal_quantity_to_l1_quantity(self, internal: Decimal) -> int: 36 | if not self.is_collateral: 37 | raise ValueError("Only collateral assets have an L1 representation") 38 | return int(internal * Decimal(self.l1_resolution)) 39 | 40 | 41 | class AssetOperationType(StrEnum): 42 | CLAIM = "CLAIM" 43 | DEPOSIT = "DEPOSIT" 44 | FAST_WITHDRAWAL = "FAST_WITHDRAWAL" 45 | SLOW_WITHDRAWAL = "SLOW_WITHDRAWAL" 46 | TRANSFER = "TRANSFER" 47 | 48 | 49 | class AssetOperationStatus(StrEnum): 50 | # Technical status 51 | UNKNOWN = "UNKNOWN" 52 | 53 | CREATED = "CREATED" 54 | IN_PROGRESS = "IN_PROGRESS" 55 | REJECTED = "REJECTED" 56 | READY_FOR_CLAIM = "READY_FOR_CLAIM" 57 | COMPLETED = "COMPLETED" 58 | 59 | 60 | class AssetOperationModel(X10BaseModel): 61 | id: str 62 | type: AssetOperationType 63 | status: AssetOperationStatus 64 | amount: Decimal 65 | fee: Decimal 66 | asset: int 67 | time: int 68 | account_id: int 69 | 70 | # When operation type is `TRANSFER` 71 | counterparty_account_id: Optional[int] = None 72 | transaction_hash: Optional[HexValue] = None 73 | -------------------------------------------------------------------------------- /examples/simple_client_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from decimal import Decimal 3 | 4 | from x10.perpetual.accounts import StarkPerpetualAccount 5 | from x10.perpetual.configuration import MAINNET_CONFIG 6 | from x10.perpetual.orderbook import OrderBook 7 | from x10.perpetual.orders import OrderSide, TimeInForce 8 | from x10.perpetual.simple_client.simple_trading_client import BlockingTradingClient 9 | 10 | 11 | async def setup_and_run(): 12 | api_key = "" 13 | public_key = "" 14 | private_key = "" 15 | vault = 100001 16 | 17 | stark_account = StarkPerpetualAccount( 18 | vault=vault, 19 | private_key=private_key, 20 | public_key=public_key, 21 | api_key=api_key, 22 | ) 23 | 24 | client = await BlockingTradingClient.create(endpoint_config=MAINNET_CONFIG, account=stark_account) 25 | market = (await client.get_markets())["EDEN-USD"] 26 | best_ask_condition = asyncio.Condition() 27 | slippage = Decimal("0.0005") 28 | 29 | async def best_ask_initialised(best_ask): 30 | async with best_ask_condition: 31 | best_ask_condition.notify_all() 32 | 33 | orderbook = await OrderBook.create( 34 | MAINNET_CONFIG, 35 | market_name=market.name, 36 | start=True, 37 | best_ask_change_callback=best_ask_initialised, 38 | best_bid_change_callback=None, 39 | ) 40 | 41 | async with best_ask_condition: 42 | await best_ask_condition.wait() 43 | 44 | best_ask_price = orderbook.best_ask() 45 | if best_ask_price is None: 46 | raise ValueError("Best ask price is None after initialization") 47 | order_price = market.trading_config.round_price(best_ask_price.price * (1 + slippage)) 48 | print(f"Best ask price: {best_ask_price}") 49 | print(f"Placing market order on {market.name} for {market.trading_config.min_order_size} at {order_price}") 50 | 51 | placed_order = await client.create_and_place_order( 52 | amount_of_synthetic=market.trading_config.min_order_size * Decimal("10"), 53 | price=order_price, 54 | market_name=market.name, 55 | side=OrderSide.BUY, 56 | post_only=False, 57 | time_in_force=TimeInForce.IOC, 58 | ) 59 | 60 | print(f"Placed order result: {placed_order}") 61 | await client.close() 62 | await orderbook.close() 63 | 64 | 65 | if __name__ == "__main__": 66 | asyncio.run(main=setup_and_run()) 67 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/testnet_module.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import tenacity 4 | 5 | from x10.perpetual.assets import AssetOperationModel, AssetOperationStatus 6 | from x10.perpetual.configuration import EndpointConfig 7 | from x10.perpetual.trading_client.account_module import AccountModule 8 | from x10.perpetual.trading_client.base_module import BaseModule 9 | from x10.utils.http import WrappedApiResponse, send_post_request 10 | from x10.utils.model import X10BaseModel 11 | 12 | 13 | class ClaimResponseModel(X10BaseModel): 14 | id: int 15 | 16 | 17 | class TestnetModule(BaseModule): 18 | def __init__( 19 | self, 20 | endpoint_config: EndpointConfig, 21 | api_key: Optional[str] = None, 22 | account_module: Optional[AccountModule] = None, 23 | ): 24 | super().__init__(endpoint_config, api_key=api_key) 25 | self._account_module = account_module 26 | 27 | async def claim_testing_funds( 28 | self, 29 | ) -> WrappedApiResponse[ClaimResponseModel]: 30 | url = self._get_url("/user/claim") 31 | resp = await send_post_request( 32 | await self.get_session(), 33 | url, 34 | ClaimResponseModel, 35 | json={}, 36 | api_key=self._get_api_key(), 37 | ) 38 | 39 | if resp.error: 40 | return resp 41 | if self._account_module and resp.data: 42 | account_module = self._account_module 43 | claim_to_check = resp.data.id 44 | 45 | @tenacity.retry( 46 | stop=tenacity.stop_after_delay(10), 47 | wait=tenacity.wait_fixed(1), 48 | retry=tenacity.retry_if_result( 49 | lambda asset_ops: not ( 50 | asset_ops 51 | and len(asset_ops) > 0 52 | and ( 53 | asset_ops[0].status == AssetOperationStatus.COMPLETED 54 | or asset_ops[0].status == AssetOperationStatus.REJECTED 55 | ) 56 | ) 57 | ), 58 | reraise=False, 59 | ) 60 | async def wait_for_claim_to_complete() -> List[AssetOperationModel]: 61 | asset_ops = (await account_module.asset_operations(id=claim_to_check)).data 62 | return asset_ops or [] 63 | 64 | try: 65 | await wait_for_claim_to_complete() 66 | except tenacity.RetryError: 67 | pass 68 | return resp 69 | -------------------------------------------------------------------------------- /x10/utils/model.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | from pydantic.alias_generators import to_camel 5 | from pydantic.fields import AliasChoices, FieldInfo 6 | from pydantic.functional_serializers import PlainSerializer 7 | from pydantic.functional_validators import BeforeValidator 8 | from typing_extensions import Annotated 9 | 10 | 11 | class X10BaseModel(BaseModel): 12 | model_config = ConfigDict(frozen=True, extra="ignore", use_enum_values=True) 13 | 14 | # Read this to get more context why `alias_generator` can't be used: 15 | # https://github.com/pydantic/pydantic/discussions/7877 16 | # `AliasChoices` is used to support both "from json" (e.g. `Model.model_validate_json(...)` -- camel case required) 17 | # and "manual" (e.g. `Model(...)` -- snake case required) models creation. 18 | def __init_subclass__(cls, **kwargs) -> None: 19 | super().__init_subclass__(**kwargs) 20 | 21 | for key in inspect.get_annotations(cls): 22 | key_alias = to_camel(key) 23 | 24 | if key_alias == key: 25 | continue 26 | 27 | try: 28 | attr = getattr(cls, key) 29 | except AttributeError: 30 | field_info = Field(validation_alias=AliasChoices(key, key_alias), serialization_alias=key_alias) 31 | setattr(cls, key, field_info) 32 | else: 33 | if isinstance(attr, FieldInfo): 34 | if attr.validation_alias is None: 35 | attr.validation_alias = AliasChoices(key, key_alias) 36 | if attr.serialization_alias is None: 37 | attr.serialization_alias = key_alias 38 | else: 39 | field_info = Field( 40 | default=attr, 41 | validation_alias=AliasChoices(key, key_alias), 42 | serialization_alias=key_alias, 43 | ) 44 | setattr(cls, key, field_info) 45 | 46 | def to_pretty_json(self): 47 | return self.model_dump_json(indent=4) 48 | 49 | def to_api_request_json(self, *, exclude_none: bool = False): 50 | return self.model_dump(mode="json", by_alias=True, exclude_none=exclude_none) 51 | 52 | 53 | HexValue = Annotated[ 54 | int, 55 | PlainSerializer(lambda x: hex(x), return_type=str, when_used="json"), 56 | BeforeValidator(lambda x: int(x, 16) if not isinstance(x, int) else x), 57 | ] 58 | 59 | 60 | class EmptyModel(X10BaseModel): 61 | pass 62 | 63 | 64 | class SettlementSignatureModel(X10BaseModel): 65 | r: HexValue 66 | s: HexValue 67 | -------------------------------------------------------------------------------- /examples/04_create_limit_order_with_builder.py: -------------------------------------------------------------------------------- 1 | import logging.handlers 2 | from asyncio import run 3 | 4 | from examples.init_env import init_env 5 | from examples.utils import find_order_and_cancel, get_adjust_price_by_pct 6 | from x10.config import ETH_USD_MARKET 7 | from x10.perpetual.accounts import StarkPerpetualAccount 8 | from x10.perpetual.configuration import MAINNET_CONFIG 9 | from x10.perpetual.order_object import create_order_object 10 | from x10.perpetual.orders import OrderSide, TimeInForce 11 | from x10.perpetual.trading_client import PerpetualTradingClient 12 | 13 | LOGGER = logging.getLogger() 14 | MARKET_NAME = ETH_USD_MARKET 15 | ENDPOINT_CONFIG = MAINNET_CONFIG 16 | 17 | 18 | async def run_example(): 19 | env_config = init_env() 20 | 21 | assert env_config.builder_id, "X10_BUILDER_ID is not set" 22 | 23 | stark_account = StarkPerpetualAccount( 24 | api_key=env_config.api_key, 25 | public_key=env_config.public_key, 26 | private_key=env_config.private_key, 27 | vault=env_config.vault_id, 28 | ) 29 | trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) 30 | markets_dict = await trading_client.markets_info.get_markets_dict() 31 | fees = await trading_client.account.get_fees(market_names=[MARKET_NAME], builder_id=env_config.builder_id) 32 | builder_fee = fees.data[0].builder_fee_rate 33 | 34 | market = markets_dict[ETH_USD_MARKET] 35 | adjust_price_by_pct = get_adjust_price_by_pct(market.trading_config) 36 | 37 | order_size = market.trading_config.min_order_size 38 | order_price = adjust_price_by_pct(market.market_stats.bid_price, -10.0) 39 | 40 | LOGGER.info("Builder: id=%s, fee=%s", env_config.builder_id, builder_fee) 41 | LOGGER.info("Creating LIMIT order object for market: %s", market.name) 42 | 43 | new_order = create_order_object( 44 | account=stark_account, 45 | starknet_domain=ENDPOINT_CONFIG.starknet_domain, 46 | market=market, 47 | side=OrderSide.BUY, 48 | amount_of_synthetic=order_size, 49 | price=market.trading_config.round_price(order_price), 50 | time_in_force=TimeInForce.GTT, 51 | reduce_only=False, 52 | post_only=True, 53 | builder_id=env_config.builder_id, 54 | builder_fee=builder_fee, 55 | ) 56 | 57 | LOGGER.info("Placing order...") 58 | 59 | placed_order = await trading_client.orders.place_order(order=new_order) 60 | 61 | LOGGER.info("Order is placed: %s", placed_order.to_pretty_json()) 62 | 63 | await find_order_and_cancel(trading_client=trading_client, logger=LOGGER, order_id=placed_order.data.id) 64 | 65 | 66 | if __name__ == "__main__": 67 | run(main=run_example()) 68 | -------------------------------------------------------------------------------- /x10/perpetual/accounts.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Dict, List, Optional, Tuple 3 | 4 | from fast_stark_crypto import sign 5 | from pydantic import AliasChoices, Field 6 | 7 | from x10.perpetual.balances import BalanceModel 8 | from x10.perpetual.fees import TradingFeeModel 9 | from x10.perpetual.orders import OpenOrderModel 10 | from x10.perpetual.positions import PositionModel 11 | from x10.perpetual.trades import AccountTradeModel 12 | from x10.utils.model import X10BaseModel 13 | from x10.utils.string import is_hex_string 14 | 15 | 16 | class StarkPerpetualAccount: 17 | __vault: int 18 | __private_key: int 19 | __public_key: int 20 | __trading_fee: Dict[str, TradingFeeModel] 21 | 22 | def __init__(self, vault: int | str, private_key: str, public_key: str, api_key: str): 23 | assert is_hex_string(private_key) 24 | assert is_hex_string(public_key) 25 | 26 | if isinstance(vault, str): 27 | vault = int(vault) 28 | elif isinstance(vault, int): 29 | self.__vault = vault 30 | else: 31 | raise ValueError("Invalid vault type") 32 | 33 | self.__vault = vault 34 | self.__private_key = int(private_key, base=16) 35 | self.__public_key = int(public_key, base=16) 36 | self.__api_key = api_key 37 | self.__trading_fee = {} 38 | 39 | @property 40 | def vault(self): 41 | return self.__vault 42 | 43 | @property 44 | def public_key(self): 45 | return self.__public_key 46 | 47 | @property 48 | def api_key(self): 49 | return self.__api_key 50 | 51 | @property 52 | def trading_fee(self): 53 | return self.__trading_fee 54 | 55 | def sign(self, msg_hash: int) -> Tuple[int, int]: 56 | return sign(private_key=self.__private_key, msg_hash=msg_hash) 57 | 58 | 59 | class AccountStreamDataModel(X10BaseModel): 60 | orders: Optional[List[OpenOrderModel]] = None 61 | positions: Optional[List[PositionModel]] = None 62 | trades: Optional[List[AccountTradeModel]] = None 63 | balance: Optional[BalanceModel] = None 64 | 65 | 66 | class AccountLeverage(X10BaseModel): 67 | market: str 68 | leverage: Decimal 69 | 70 | 71 | class AccountModel(X10BaseModel): 72 | id: int = Field(validation_alias=AliasChoices("accountId", "id"), serialization_alias="id") 73 | description: str 74 | account_index: int 75 | status: str 76 | l2_key: str 77 | l2_vault: int 78 | bridge_starknet_address: Optional[str] = None 79 | api_keys: Optional[List[str]] = None 80 | 81 | 82 | class ApiKeyResponseModel(X10BaseModel): 83 | key: str 84 | 85 | 86 | class ApiKeyRequestModel(X10BaseModel): 87 | description: str 88 | -------------------------------------------------------------------------------- /tests/fixtures/accounts.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from x10.perpetual.accounts import AccountModel 4 | 5 | 6 | def create_accounts(): 7 | return [ 8 | AccountModel( 9 | status="ACTIVE", 10 | l2_key="0x6970ac7180192cb58070d639064408610d0fbfd3b16c6b2c6219b9d91aa456f", 11 | l2_vault="10001", 12 | account_index=0, 13 | id=1001, 14 | description="Account 1", 15 | api_keys=[], 16 | ), 17 | AccountModel( 18 | status="ACTIVE", 19 | l2_key="0x3895139a98a6168dc8b0db251bcd0e6dcf97fd1e96f7a87d9bd3f341753a844", 20 | l2_vault="10002", 21 | account_index=1, 22 | id=1002, 23 | description="Account 2", 24 | api_keys=[], 25 | ), 26 | ] 27 | 28 | 29 | def create_trading_account(): 30 | from x10.perpetual.accounts import StarkPerpetualAccount 31 | 32 | return StarkPerpetualAccount( 33 | vault=10002, 34 | private_key="0x7a7ff6fd3cab02ccdcd4a572563f5976f8976899b03a39773795a3c486d4986", 35 | public_key="0x61c5e7e8339b7d56f197f54ea91b776776690e3232313de0f2ecbd0ef76f466", 36 | api_key="dummy_api_key", 37 | ) 38 | 39 | 40 | def create_account_update_trade_message(): 41 | from x10.perpetual.accounts import AccountStreamDataModel 42 | from x10.perpetual.trades import AccountTradeModel 43 | from x10.utils.http import WrappedStreamResponse 44 | 45 | return WrappedStreamResponse[AccountStreamDataModel]( 46 | type="TRADE", 47 | data=AccountStreamDataModel( 48 | trades=[ 49 | AccountTradeModel( 50 | id=1811328331296018432, 51 | account_id=3004, 52 | market="BTC-USD", 53 | order_id=1811328331287359488, 54 | side="BUY", 55 | price=Decimal("58249.8000000000000000"), 56 | qty=Decimal("0.0010000000000000"), 57 | value=Decimal("58.2498000000000000"), 58 | fee=Decimal("0.0291240000000000"), 59 | is_taker=True, 60 | trade_type="TRADE", 61 | created_time=1720689301691, 62 | ) 63 | ] 64 | ), 65 | ts=1704798222748, 66 | seq=570, 67 | ) 68 | 69 | 70 | def create_account_update_unknown_message(): 71 | from x10.perpetual.accounts import AccountStreamDataModel 72 | from x10.utils.http import WrappedStreamResponse 73 | 74 | return WrappedStreamResponse[AccountStreamDataModel]( 75 | type="UNEXPECTED", 76 | data=None, 77 | ts=1704798222748, 78 | seq=570, 79 | ) 80 | -------------------------------------------------------------------------------- /x10/perpetual/withdrawal_object.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import timedelta 3 | from decimal import Decimal 4 | 5 | from fast_stark_crypto import get_withdrawal_msg_hash 6 | 7 | from x10.perpetual.accounts import StarkPerpetualAccount 8 | from x10.perpetual.configuration import EndpointConfig, StarknetDomain 9 | from x10.perpetual.withdrawals import ( 10 | StarkWithdrawalSettlement, 11 | Timestamp, 12 | WithdrawalRequest, 13 | ) 14 | from x10.utils.date import utc_now 15 | from x10.utils.model import SettlementSignatureModel 16 | from x10.utils.nonce import generate_nonce 17 | 18 | 19 | def calc_expiration_timestamp(): 20 | expire_time = utc_now() 21 | expire_time_with_buffer = expire_time + timedelta(days=15) 22 | expire_time_with_buffer_seconds = math.ceil(expire_time_with_buffer.timestamp()) 23 | return expire_time_with_buffer_seconds 24 | 25 | 26 | def create_withdrawal_object( 27 | amount: Decimal, 28 | recipient_stark_address: str, 29 | stark_account: StarkPerpetualAccount, 30 | config: EndpointConfig, 31 | account_id: int, 32 | chain_id: str, 33 | description: str | None = None, 34 | nonce: int | None = None, 35 | quote_id: str | None = None, 36 | ) -> WithdrawalRequest: 37 | expiration_timestamp = calc_expiration_timestamp() 38 | scaled_amount = amount.scaleb(config.collateral_decimals) 39 | stark_amount = scaled_amount.to_integral_exact() 40 | starknet_domain: StarknetDomain = config.starknet_domain 41 | if nonce is None: 42 | nonce = generate_nonce() 43 | 44 | withdrawal_hash = get_withdrawal_msg_hash( 45 | recipient_hex=recipient_stark_address, 46 | position_id=stark_account.vault, 47 | amount=int(stark_amount), 48 | expiration=expiration_timestamp, 49 | salt=nonce, 50 | user_public_key=stark_account.public_key, 51 | domain_name=starknet_domain.name, 52 | domain_version=starknet_domain.version, 53 | domain_chain_id=starknet_domain.chain_id, 54 | domain_revision=starknet_domain.revision, 55 | collateral_id=int(config.collateral_asset_on_chain_id, base=16), 56 | ) 57 | 58 | (transfer_signature_r, transfer_signature_s) = stark_account.sign(withdrawal_hash) 59 | 60 | settlement = StarkWithdrawalSettlement( 61 | recipient=int(recipient_stark_address, 16), 62 | position_id=stark_account.vault, 63 | collateral_id=int(config.collateral_asset_on_chain_id, base=16), 64 | amount=int(stark_amount), 65 | expiration=Timestamp(seconds=expiration_timestamp), 66 | salt=nonce, 67 | signature=SettlementSignatureModel( 68 | r=transfer_signature_r, 69 | s=transfer_signature_s, 70 | ), 71 | ) 72 | 73 | return WithdrawalRequest( 74 | account_id=account_id, 75 | amount=amount, 76 | description=description, 77 | settlement=settlement, 78 | chain_id=chain_id, 79 | quote_id=quote_id, 80 | asset="USD", 81 | ) 82 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/order_management_module.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from x10.perpetual.orders import NewOrderModel, PlacedOrderModel 4 | from x10.perpetual.trading_client.base_module import BaseModule 5 | from x10.utils.http import send_delete_request, send_post_request 6 | from x10.utils.log import get_logger 7 | from x10.utils.model import EmptyModel, X10BaseModel 8 | 9 | LOGGER = get_logger(__name__) 10 | 11 | 12 | class _MassCancelRequestModel(X10BaseModel): 13 | order_ids: Optional[List[int]] = None 14 | external_order_ids: Optional[List[str]] = None 15 | markets: Optional[List[str]] = None 16 | cancel_all: Optional[bool] = None 17 | 18 | 19 | class OrderManagementModule(BaseModule): 20 | async def place_order(self, order: NewOrderModel): 21 | """ 22 | Placed new order on the exchange. 23 | 24 | :param order: Order object created by `create_order_object` method. 25 | 26 | https://api.docs.extended.exchange/#create-order 27 | """ 28 | LOGGER.debug("Placing an order: id=%s", order.id) 29 | 30 | url = self._get_url("/user/order") 31 | response = await send_post_request( 32 | await self.get_session(), 33 | url, 34 | PlacedOrderModel, 35 | json=order.to_api_request_json(exclude_none=True), 36 | api_key=self._get_api_key(), 37 | ) 38 | return response 39 | 40 | async def cancel_order(self, order_id: int): 41 | """ 42 | https://api.docs.extended.exchange/#cancel-order 43 | """ 44 | 45 | url = self._get_url("/user/order/", order_id=order_id) 46 | return await send_delete_request(await self.get_session(), url, EmptyModel, api_key=self._get_api_key()) 47 | 48 | async def cancel_order_by_external_id(self, order_external_id: str): 49 | """ 50 | https://api.docs.extended.exchange/#cancel-order 51 | """ 52 | 53 | url = self._get_url("/user/order", query={"externalId": order_external_id}) 54 | return await send_delete_request(await self.get_session(), url, EmptyModel, api_key=self._get_api_key()) 55 | 56 | async def mass_cancel( 57 | self, 58 | *, 59 | order_ids: Optional[List[int]] = None, 60 | external_order_ids: Optional[List[str]] = None, 61 | markets: Optional[List[str]] = None, 62 | cancel_all: Optional[bool] = False, 63 | ): 64 | """ 65 | https://api.docs.extended.exchange/#mass-cancel 66 | """ 67 | 68 | url = self._get_url("/user/order/massCancel") 69 | request_model = _MassCancelRequestModel( 70 | order_ids=order_ids, 71 | external_order_ids=external_order_ids, 72 | markets=markets, 73 | cancel_all=cancel_all, 74 | ) 75 | return await send_post_request( 76 | await self.get_session(), 77 | url, 78 | EmptyModel, 79 | json=request_model.to_api_request_json(exclude_none=True), 80 | api_key=self._get_api_key(), 81 | ) 82 | -------------------------------------------------------------------------------- /x10/perpetual/transfer_object.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import timedelta 3 | from decimal import Decimal 4 | from typing import List 5 | 6 | from fast_stark_crypto import get_transfer_msg_hash 7 | 8 | from x10.perpetual.accounts import AccountModel, StarkPerpetualAccount 9 | from x10.perpetual.configuration import EndpointConfig, StarknetDomain 10 | from x10.perpetual.transfers import ( 11 | OnChainPerpetualTransferModel, 12 | StarkTransferSettlement, 13 | ) 14 | from x10.utils.date import utc_now 15 | from x10.utils.model import SettlementSignatureModel 16 | from x10.utils.nonce import generate_nonce 17 | 18 | ASSET_ID_FEE = 0 19 | 20 | 21 | def find_account_by_id(accounts: List[AccountModel], account_id: int): 22 | return next((account for account in accounts if account.id == account_id), None) 23 | 24 | 25 | def calc_expiration_timestamp(): 26 | expire_time = utc_now() + timedelta(days=7) 27 | expire_time_with_buffer = expire_time + timedelta(days=14) 28 | expire_time_with_buffer_seconds = math.ceil(expire_time_with_buffer.timestamp()) 29 | return expire_time_with_buffer_seconds 30 | 31 | 32 | def create_transfer_object( 33 | from_vault: int, 34 | to_vault: int, 35 | to_l2_key: int, 36 | amount: Decimal, 37 | config: EndpointConfig, 38 | stark_account: StarkPerpetualAccount, 39 | nonce: int | None = None, 40 | ) -> OnChainPerpetualTransferModel: 41 | expiration_timestamp = calc_expiration_timestamp() 42 | scaled_amount = amount.scaleb(config.collateral_decimals) 43 | stark_amount = scaled_amount.to_integral_exact() 44 | starknet_domain: StarknetDomain = config.starknet_domain 45 | if nonce is None: 46 | nonce = generate_nonce() 47 | transfer_hash = get_transfer_msg_hash( 48 | recipient_position_id=to_vault, 49 | sender_position_id=from_vault, 50 | amount=int(stark_amount), 51 | expiration=expiration_timestamp, 52 | salt=nonce, 53 | user_public_key=stark_account.public_key, 54 | domain_name=starknet_domain.name, 55 | domain_version=starknet_domain.version, 56 | domain_chain_id=starknet_domain.chain_id, 57 | domain_revision=starknet_domain.revision, 58 | collateral_id=int(config.collateral_asset_on_chain_id, base=16), 59 | ) 60 | 61 | (transfer_signature_r, transfer_signature_s) = stark_account.sign(transfer_hash) 62 | settlement = StarkTransferSettlement( 63 | amount=int(stark_amount), 64 | asset_id=int(config.collateral_asset_on_chain_id, base=16), 65 | expiration_timestamp=expiration_timestamp, 66 | nonce=nonce, 67 | receiver_position_id=to_vault, 68 | receiver_public_key=to_l2_key, 69 | sender_position_id=from_vault, 70 | sender_public_key=stark_account.public_key, 71 | signature=SettlementSignatureModel(r=transfer_signature_r, s=transfer_signature_s), 72 | ) 73 | 74 | return OnChainPerpetualTransferModel( 75 | from_vault=from_vault, 76 | to_vault=to_vault, 77 | amount=amount, 78 | settlement=settlement, 79 | transferred_asset=config.collateral_asset_id, 80 | ) 81 | -------------------------------------------------------------------------------- /examples/02_create_limit_order_with_partial_tpsl.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from asyncio import run 3 | 4 | from examples.init_env import init_env 5 | from examples.utils import find_order_and_cancel, get_adjust_price_by_pct 6 | from x10.config import ETH_USD_MARKET 7 | from x10.perpetual.accounts import StarkPerpetualAccount 8 | from x10.perpetual.configuration import MAINNET_CONFIG 9 | from x10.perpetual.order_object import OrderTpslTriggerParam, create_order_object 10 | from x10.perpetual.orders import ( 11 | OrderPriceType, 12 | OrderSide, 13 | OrderTpslType, 14 | OrderTriggerPriceType, 15 | TimeInForce, 16 | ) 17 | from x10.perpetual.trading_client import PerpetualTradingClient 18 | 19 | LOGGER = logging.getLogger() 20 | MARKET_NAME = ETH_USD_MARKET 21 | ENDPOINT_CONFIG = MAINNET_CONFIG 22 | 23 | 24 | async def run_example(): 25 | env_config = init_env() 26 | stark_account = StarkPerpetualAccount( 27 | api_key=env_config.api_key, 28 | public_key=env_config.public_key, 29 | private_key=env_config.private_key, 30 | vault=env_config.vault_id, 31 | ) 32 | trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) 33 | markets_dict = await trading_client.markets_info.get_markets_dict() 34 | 35 | market = markets_dict[MARKET_NAME] 36 | adjust_price_by_pct = get_adjust_price_by_pct(market.trading_config) 37 | 38 | order_size = market.trading_config.min_order_size 39 | 40 | order_price = adjust_price_by_pct(market.market_stats.bid_price, -10.0) 41 | tp_trigger_price = adjust_price_by_pct(order_price, 0.5) 42 | tp_price = adjust_price_by_pct(order_price, 1.0) 43 | sl_trigger_price = adjust_price_by_pct(order_price, -0.5) 44 | sl_price = adjust_price_by_pct(order_price, -1.0) 45 | 46 | LOGGER.info("Creating LIMIT order object with TPSL for market: %s", market.name) 47 | 48 | new_order = create_order_object( 49 | account=stark_account, 50 | starknet_domain=ENDPOINT_CONFIG.starknet_domain, 51 | market=market, 52 | side=OrderSide.BUY, 53 | amount_of_synthetic=order_size, 54 | price=market.trading_config.round_price(order_price), 55 | time_in_force=TimeInForce.GTT, 56 | reduce_only=False, 57 | post_only=True, 58 | tp_sl_type=OrderTpslType.ORDER, 59 | take_profit=OrderTpslTriggerParam( 60 | trigger_price=tp_trigger_price, 61 | trigger_price_type=OrderTriggerPriceType.LAST, 62 | price=tp_price, 63 | price_type=OrderPriceType.LIMIT, 64 | ), 65 | stop_loss=OrderTpslTriggerParam( 66 | trigger_price=sl_trigger_price, 67 | trigger_price_type=OrderTriggerPriceType.LAST, 68 | price=sl_price, 69 | price_type=OrderPriceType.LIMIT, 70 | ), 71 | ) 72 | 73 | LOGGER.info("Placing order...") 74 | 75 | placed_order = await trading_client.orders.place_order(order=new_order) 76 | 77 | LOGGER.info(f"Order is placed: {placed_order.to_pretty_json()}") 78 | 79 | await find_order_and_cancel(trading_client=trading_client, logger=LOGGER, order_id=placed_order.data.id) 80 | 81 | 82 | if __name__ == "__main__": 83 | run(main=run_example()) 84 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/markets_information_module.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from x10.perpetual.candles import CandleInterval, CandleModel, CandleType 5 | from x10.perpetual.funding_rates import FundingRateModel 6 | from x10.perpetual.markets import MarketModel, MarketStatsModel 7 | from x10.perpetual.orderbooks import OrderbookUpdateModel 8 | from x10.perpetual.trading_client.base_module import BaseModule 9 | from x10.utils.date import to_epoch_millis 10 | from x10.utils.http import send_get_request 11 | 12 | 13 | class MarketsInformationModule(BaseModule): 14 | async def get_markets(self, *, market_names: Optional[List[str]] = None): 15 | """ 16 | https://api.docs.extended.exchange/#get-markets 17 | """ 18 | 19 | url = self._get_url("/info/markets", query={"market": market_names}) 20 | return await send_get_request(await self.get_session(), url, List[MarketModel]) 21 | 22 | async def get_markets_dict(self): 23 | markets = await self.get_markets() 24 | return {m.name: m for m in markets.data} 25 | 26 | async def get_market_statistics(self, *, market_name: str): 27 | """ 28 | https://api.docs.extended.exchange/#get-market-statistics 29 | """ 30 | 31 | url = self._get_url("/info/markets//stats", market=market_name) 32 | return await send_get_request(await self.get_session(), url, MarketStatsModel) 33 | 34 | async def get_candles_history( 35 | self, 36 | *, 37 | market_name: str, 38 | candle_type: CandleType, 39 | interval: CandleInterval, 40 | limit: Optional[int] = None, 41 | end_time: Optional[datetime] = None, 42 | ): 43 | """ 44 | https://api.docs.extended.exchange/#get-candles-history 45 | """ 46 | 47 | url = self._get_url( 48 | "/info/candles//", 49 | market=market_name, 50 | candle_type=candle_type, 51 | query={ 52 | "interval": interval, 53 | "limit": limit, 54 | "endTime": to_epoch_millis(end_time) if end_time else None, 55 | }, 56 | ) 57 | return await send_get_request(await self.get_session(), url, List[CandleModel]) 58 | 59 | async def get_funding_rates_history(self, *, market_name: str, start_time: datetime, end_time: datetime): 60 | """ 61 | https://api.docs.extended.exchange/#get-funding-rates-history 62 | """ 63 | 64 | url = self._get_url( 65 | "/info//funding", 66 | market=market_name, 67 | query={ 68 | "startTime": to_epoch_millis(start_time), 69 | "endTime": to_epoch_millis(end_time), 70 | }, 71 | ) 72 | return await send_get_request(await self.get_session(), url, List[FundingRateModel]) 73 | 74 | async def get_orderbook_snapshot(self, *, market_name: str): 75 | """ 76 | https://api.docs.extended.exchange/#get-market-order-book 77 | """ 78 | 79 | url = self._get_url("/info/markets//orderbook", market=market_name) 80 | return await send_get_request(await self.get_session(), url, OrderbookUpdateModel) 81 | -------------------------------------------------------------------------------- /x10/perpetual/stream_client/stream_client.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Type 2 | 3 | from x10.perpetual.accounts import AccountStreamDataModel 4 | from x10.perpetual.candles import CandleInterval, CandleModel, CandleType 5 | from x10.perpetual.funding_rates import FundingRateModel 6 | from x10.perpetual.orderbooks import OrderbookUpdateModel 7 | from x10.perpetual.stream_client.perpetual_stream_connection import ( 8 | PerpetualStreamConnection, 9 | StreamMsgResponseType, 10 | ) 11 | from x10.perpetual.trades import PublicTradeModel 12 | from x10.utils.http import WrappedStreamResponse, get_url 13 | 14 | 15 | class PerpetualStreamClient: 16 | """ 17 | X10 Perpetual Stream Client for the X10 WebSocket v1. 18 | """ 19 | 20 | __api_url: str 21 | 22 | def __init__(self, *, api_url: str): 23 | super().__init__() 24 | 25 | self.__api_url = api_url 26 | 27 | def subscribe_to_orderbooks(self, market_name: Optional[str] = None, depth: int | None = None): 28 | """ 29 | https://api.docs.extended.exchange/#orderbooks-stream 30 | """ 31 | 32 | url = self.__get_url("/orderbooks/" + (f"?depth={depth}" if depth else ""), market=market_name) 33 | return self.__connect(url, WrappedStreamResponse[OrderbookUpdateModel]) 34 | 35 | def subscribe_to_public_trades(self, market_name: Optional[str] = None): 36 | """ 37 | https://api.docs.extended.exchange/#trades-stream 38 | """ 39 | 40 | url = self.__get_url("/publicTrades/", market=market_name) 41 | return self.__connect(url, WrappedStreamResponse[List[PublicTradeModel]]) 42 | 43 | def subscribe_to_funding_rates(self, market_name: Optional[str] = None): 44 | """ 45 | https://api.docs.extended.exchange/#funding-rates-stream 46 | """ 47 | 48 | url = self.__get_url("/funding/", market=market_name) 49 | return self.__connect(url, WrappedStreamResponse[FundingRateModel]) 50 | 51 | def subscribe_to_candles(self, market_name: str, candle_type: CandleType, interval: CandleInterval): 52 | """ 53 | https://api.docs.extended.exchange/#candles-stream 54 | """ 55 | 56 | url = self.__get_url( 57 | "/candles//", 58 | market=market_name, 59 | candle_type=candle_type, 60 | query={ 61 | "interval": interval, 62 | }, 63 | ) 64 | return self.__connect(url, WrappedStreamResponse[List[CandleModel]]) 65 | 66 | def subscribe_to_account_updates(self, api_key: str): 67 | """ 68 | https://api.docs.extended.exchange/#account-updates-stream 69 | """ 70 | 71 | url = self.__get_url("/account") 72 | return self.__connect(url, WrappedStreamResponse[AccountStreamDataModel], api_key) 73 | 74 | def __get_url(self, path: str, *, query: Optional[Dict[str, str | List[str]]] = None, **path_params) -> str: 75 | return get_url(f"{self.__api_url}{path}", query=query, **path_params) 76 | 77 | @staticmethod 78 | def __connect( 79 | stream_url: str, 80 | msg_model_class: Type[StreamMsgResponseType], 81 | api_key: Optional[str] = None, 82 | ) -> PerpetualStreamConnection[StreamMsgResponseType]: 83 | return PerpetualStreamConnection(stream_url, msg_model_class, api_key) 84 | -------------------------------------------------------------------------------- /x10/perpetual/stream_client/perpetual_stream_connection.py: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import AsyncIterator, Generic, Optional, Type, TypeVar 3 | 4 | import websockets 5 | from websockets import WebSocketClientProtocol 6 | 7 | from x10.config import USER_AGENT 8 | from x10.utils.http import RequestHeader 9 | from x10.utils.log import get_logger 10 | from x10.utils.model import X10BaseModel 11 | 12 | LOGGER = get_logger(__name__) 13 | 14 | StreamMsgResponseType = TypeVar("StreamMsgResponseType", bound=X10BaseModel) 15 | 16 | 17 | class PerpetualStreamConnection(Generic[StreamMsgResponseType]): 18 | __stream_url: str 19 | __msg_model_class: Type[StreamMsgResponseType] 20 | __api_key: Optional[str] 21 | __msgs_count: int 22 | __websocket: Optional[WebSocketClientProtocol] 23 | 24 | def __init__( 25 | self, 26 | stream_url: str, 27 | msg_model_class: Type[StreamMsgResponseType], 28 | api_key: Optional[str], 29 | ): 30 | super().__init__() 31 | 32 | self.__stream_url = stream_url 33 | self.__msg_model_class = msg_model_class 34 | self.__api_key = api_key 35 | self.__msgs_count = 0 36 | self.__websocket = None 37 | 38 | async def send(self, data): 39 | assert self.__websocket is not None 40 | await self.__websocket.send(data) 41 | 42 | async def recv(self) -> StreamMsgResponseType: 43 | assert self.__websocket is not None 44 | return await self.__receive() 45 | 46 | async def close(self): 47 | assert self.__websocket is not None 48 | if not self.__websocket.closed: 49 | await self.__websocket.close() 50 | LOGGER.debug("Stream closed: %s", self.__stream_url) 51 | 52 | @property 53 | def msgs_count(self): 54 | return self.__msgs_count 55 | 56 | @property 57 | def closed(self): 58 | assert self.__websocket is not None 59 | 60 | return self.__websocket.closed 61 | 62 | def __aiter__(self) -> AsyncIterator[StreamMsgResponseType]: 63 | return self 64 | 65 | async def __anext__(self) -> StreamMsgResponseType: 66 | assert self.__websocket is not None 67 | 68 | if self.__websocket.closed: 69 | raise StopAsyncIteration 70 | try: 71 | return await self.__receive() 72 | except websockets.ConnectionClosed: 73 | raise StopAsyncIteration from None 74 | 75 | async def __receive(self) -> StreamMsgResponseType: 76 | assert self.__websocket is not None 77 | 78 | data = await self.__websocket.recv() 79 | self.__msgs_count += 1 80 | 81 | return self.__msg_model_class.model_validate_json(data) 82 | 83 | def __await__(self): 84 | return self.__await_impl__().__await__() 85 | 86 | async def __aenter__(self): 87 | # Calls `self.__await__()` implicitly 88 | return await self 89 | 90 | async def __aexit__( 91 | self, 92 | exc_type: Optional[Type[BaseException]], 93 | exc_value: Optional[BaseException], 94 | traceback: Optional[TracebackType], 95 | ): 96 | await self.close() 97 | 98 | async def __await_impl__(self): 99 | extra_headers: dict[str, str] = { 100 | RequestHeader.USER_AGENT: USER_AGENT, 101 | } 102 | 103 | if self.__api_key is not None: 104 | extra_headers[RequestHeader.API_KEY] = self.__api_key 105 | 106 | self.__websocket = await websockets.connect(self.__stream_url, extra_headers=extra_headers) 107 | 108 | LOGGER.debug("Connected to stream: %s", self.__stream_url) 109 | 110 | return self 111 | -------------------------------------------------------------------------------- /examples/placed_order_example_simple.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import logging.config 4 | import logging.handlers 5 | import os 6 | import random 7 | from asyncio import run 8 | from decimal import Decimal 9 | 10 | from dotenv import load_dotenv 11 | 12 | from x10.perpetual.accounts import StarkPerpetualAccount 13 | from x10.perpetual.configuration import TESTNET_CONFIG 14 | from x10.perpetual.orderbook import OrderBook 15 | from x10.perpetual.orders import OrderSide 16 | from x10.perpetual.simple_client.simple_trading_client import BlockingTradingClient 17 | from x10.perpetual.trading_client import PerpetualTradingClient 18 | 19 | NUM_PRICE_LEVELS = 1 20 | 21 | load_dotenv() 22 | 23 | API_KEY = os.getenv("X10_API_KEY") 24 | PUBLIC_KEY = os.getenv("X10_PUBLIC_KEY") 25 | PRIVATE_KEY = os.getenv("X10_PRIVATE_KEY") 26 | VAULT_ID = int(os.environ["X10_VAULT_ID"]) 27 | 28 | 29 | async def clean_it(trading_client: PerpetualTradingClient): 30 | logger = logging.getLogger("placed_order_example") 31 | positions = await trading_client.account.get_positions() 32 | logger.info("Positions: %s", positions.to_pretty_json()) 33 | balance = await trading_client.account.get_balance() 34 | logger.info("Balance: %s", balance.to_pretty_json()) 35 | open_orders = await trading_client.account.get_open_orders() 36 | await trading_client.orders.mass_cancel(order_ids=[order.id for order in open_orders.data]) 37 | 38 | 39 | async def setup_and_run(): 40 | assert API_KEY is not None 41 | assert PUBLIC_KEY is not None 42 | assert PRIVATE_KEY is not None 43 | assert VAULT_ID is not None 44 | 45 | stark_account = StarkPerpetualAccount( 46 | vault=VAULT_ID, 47 | private_key=PRIVATE_KEY, 48 | public_key=PUBLIC_KEY, 49 | api_key=API_KEY, 50 | ) 51 | trading_client = PerpetualTradingClient( 52 | endpoint_config=TESTNET_CONFIG, 53 | stark_account=stark_account, 54 | ) 55 | positions = await trading_client.account.get_positions() 56 | for position in positions.data: 57 | print( 58 | f"market: {position.market} \ 59 | side: {position.side} \ 60 | size: {position.size} \ 61 | mark_price: ${position.mark_price} \ 62 | leverage: {position.leverage}" 63 | ) 64 | print(f"consumed im: ${round((position.size * position.mark_price) / position.leverage, 2)}") 65 | 66 | await clean_it(trading_client) 67 | 68 | blocking_client = BlockingTradingClient( 69 | endpoint_config=TESTNET_CONFIG, 70 | account=stark_account, 71 | ) 72 | 73 | orderbook = await OrderBook.create( 74 | endpoint_config=TESTNET_CONFIG, 75 | market_name="BTC-USD", 76 | ) 77 | 78 | await orderbook.start_orderbook() 79 | 80 | def order_loop(idx: int, side: OrderSide) -> asyncio.Task: 81 | offset = (Decimal("-1") if side == OrderSide.BUY else Decimal("1")) * Decimal(idx + 1) 82 | 83 | async def inner(): 84 | while True: 85 | baseline_price = orderbook.best_bid() if side == OrderSide.BUY else orderbook.best_ask() 86 | if baseline_price: 87 | order_price = round( 88 | baseline_price.price + offset * baseline_price.price * Decimal("0.002"), 89 | 1, 90 | ) 91 | external_id = str(random.randint(1, 10000000000000000000000000000000000000000000000000000000000)) 92 | placed_order = await blocking_client.create_and_place_order( 93 | market_name="BTC-USD", 94 | amount_of_synthetic=Decimal("0.01"), 95 | price=order_price, 96 | side=side, 97 | post_only=True, 98 | external_id=external_id, 99 | ) 100 | print(f"baseline: {baseline_price.price}, order: {order_price}, id: {placed_order.id}") 101 | await blocking_client.cancel_order(order_external_id=external_id) 102 | await asyncio.sleep(0) 103 | else: 104 | await asyncio.sleep(1) 105 | 106 | return asyncio.get_running_loop().create_task(inner()) 107 | 108 | sell_tasks = list(map(lambda idx: order_loop(idx, OrderSide.SELL), range(NUM_PRICE_LEVELS))) 109 | buy_tasks = list(map(lambda idx: order_loop(idx, OrderSide.BUY), range(NUM_PRICE_LEVELS))) 110 | 111 | for task in sell_tasks: 112 | print(await task) 113 | for task in buy_tasks: 114 | print(await task) 115 | 116 | 117 | if __name__ == "__main__": 118 | run(main=setup_and_run()) 119 | -------------------------------------------------------------------------------- /x10/perpetual/markets.py: -------------------------------------------------------------------------------- 1 | from decimal import ROUND_CEILING, Decimal 2 | from functools import cached_property 3 | from typing import List 4 | 5 | from x10.perpetual.assets import Asset 6 | from x10.utils.model import X10BaseModel 7 | 8 | 9 | class RiskFactorConfig(X10BaseModel): 10 | upper_bound: Decimal 11 | risk_factor: Decimal 12 | 13 | @cached_property 14 | def max_leverage(self) -> Decimal: 15 | return round(Decimal(1) / self.risk_factor, 2) 16 | 17 | 18 | class MarketStatsModel(X10BaseModel): 19 | daily_volume: Decimal 20 | daily_volume_base: Decimal 21 | daily_price_change: Decimal 22 | daily_low: Decimal 23 | daily_high: Decimal 24 | last_price: Decimal 25 | ask_price: Decimal 26 | bid_price: Decimal 27 | mark_price: Decimal 28 | index_price: Decimal 29 | funding_rate: Decimal 30 | next_funding_rate: int 31 | open_interest: Decimal 32 | open_interest_base: Decimal 33 | 34 | 35 | class TradingConfigModel(X10BaseModel): 36 | min_order_size: Decimal 37 | min_order_size_change: Decimal 38 | min_price_change: Decimal 39 | max_market_order_value: Decimal 40 | max_limit_order_value: Decimal 41 | max_position_value: Decimal 42 | max_leverage: Decimal 43 | max_num_orders: int 44 | limit_price_cap: Decimal 45 | limit_price_floor: Decimal 46 | risk_factor_config: List[RiskFactorConfig] 47 | 48 | @cached_property 49 | def price_precision(self) -> int: 50 | return abs(int(self.min_price_change.log10().to_integral_exact(ROUND_CEILING))) 51 | 52 | @cached_property 53 | def quantity_precision(self) -> int: 54 | return abs(int(self.min_order_size_change.log10().to_integral_exact(ROUND_CEILING))) 55 | 56 | def max_leverage_for_position_value(self, position_value: Decimal) -> Decimal: 57 | filtered = [x for x in self.risk_factor_config if x.upper_bound >= position_value] 58 | return filtered[0].max_leverage if filtered else Decimal(0) 59 | 60 | def max_position_value_for_leverage(self, leverage: Decimal) -> Decimal: 61 | filtered = [x for x in self.risk_factor_config if x.max_leverage >= leverage] 62 | return filtered[-1].upper_bound if filtered else Decimal(0) 63 | 64 | def round_order_size(self, order_size: Decimal, rounding_direction: str = ROUND_CEILING) -> Decimal: 65 | order_size = (order_size / self.min_order_size_change).to_integral_exact( 66 | rounding_direction 67 | ) * self.min_order_size_change 68 | return order_size 69 | 70 | def calculate_order_size_from_value( 71 | self, order_value: Decimal, order_price: Decimal, rounding_direction: str = ROUND_CEILING 72 | ) -> Decimal: 73 | order_size = order_value / order_price 74 | if order_size > 0: 75 | return self.round_order_size(order_size, rounding_direction=rounding_direction) 76 | else: 77 | return Decimal(0) 78 | 79 | def round_price(self, price: Decimal, rounding_direction: str = ROUND_CEILING) -> Decimal: 80 | return price.quantize(self.min_price_change, rounding=rounding_direction) 81 | 82 | 83 | class L2ConfigModel(X10BaseModel): 84 | type: str 85 | collateral_id: str 86 | collateral_resolution: int 87 | synthetic_id: str 88 | synthetic_resolution: int 89 | 90 | 91 | class MarketModel(X10BaseModel): 92 | name: str 93 | asset_name: str 94 | asset_precision: int 95 | collateral_asset_name: str 96 | collateral_asset_precision: int 97 | active: bool 98 | market_stats: MarketStatsModel 99 | trading_config: TradingConfigModel 100 | l2_config: L2ConfigModel 101 | 102 | @cached_property 103 | def synthetic_asset(self) -> Asset: 104 | return Asset( 105 | id=1, 106 | name=self.asset_name, 107 | precision=self.asset_precision, 108 | active=self.active, 109 | is_collateral=False, 110 | settlement_external_id=self.l2_config.synthetic_id, 111 | settlement_resolution=self.l2_config.synthetic_resolution, 112 | l1_external_id="", 113 | l1_resolution=0, 114 | ) 115 | 116 | @cached_property 117 | def collateral_asset(self) -> Asset: 118 | return Asset( 119 | id=2, 120 | name=self.collateral_asset_name, 121 | precision=self.collateral_asset_precision, 122 | active=self.active, 123 | is_collateral=True, 124 | settlement_external_id=self.l2_config.collateral_id, 125 | settlement_resolution=self.l2_config.collateral_resolution, 126 | l1_external_id="", 127 | l1_resolution=0, 128 | ) 129 | -------------------------------------------------------------------------------- /x10/perpetual/order_object_settlement.py: -------------------------------------------------------------------------------- 1 | import math 2 | from dataclasses import dataclass 3 | from datetime import datetime, timedelta 4 | from decimal import Decimal 5 | from typing import Callable, Optional, Tuple 6 | 7 | from fast_stark_crypto import get_order_msg_hash 8 | 9 | from x10.perpetual.amounts import ( 10 | ROUNDING_BUY_CONTEXT, 11 | ROUNDING_FEE_CONTEXT, 12 | ROUNDING_SELL_CONTEXT, 13 | HumanReadableAmount, 14 | StarkAmount, 15 | ) 16 | from x10.perpetual.configuration import StarknetDomain 17 | from x10.perpetual.fees import TradingFeeModel 18 | from x10.perpetual.markets import MarketModel 19 | from x10.perpetual.orders import ( 20 | OrderSide, 21 | StarkDebuggingOrderAmountsModel, 22 | StarkSettlementModel, 23 | ) 24 | from x10.utils.model import SettlementSignatureModel 25 | 26 | 27 | @dataclass(kw_only=True) 28 | class OrderSettlementData: 29 | synthetic_amount_human: HumanReadableAmount 30 | order_hash: int 31 | settlement: StarkSettlementModel 32 | debugging_amounts: StarkDebuggingOrderAmountsModel 33 | 34 | 35 | @dataclass(kw_only=True) 36 | class SettlementDataCtx: 37 | market: MarketModel 38 | fees: TradingFeeModel 39 | builder_fee: Optional[Decimal] 40 | nonce: int 41 | collateral_position_id: int 42 | expire_time: datetime 43 | signer: Callable[[int], Tuple[int, int]] 44 | public_key: int 45 | starknet_domain: StarknetDomain 46 | 47 | 48 | def __calc_settlement_expiration(expiration_timestamp: datetime): 49 | expire_time_with_buffer = expiration_timestamp + timedelta(days=14) 50 | expire_time_as_seconds = math.ceil(expire_time_with_buffer.timestamp()) 51 | 52 | return expire_time_as_seconds 53 | 54 | 55 | def hash_order( 56 | amount_synthetic: StarkAmount, 57 | amount_collateral: StarkAmount, 58 | max_fee: StarkAmount, 59 | nonce: int, 60 | position_id: int, 61 | expiration_timestamp: datetime, 62 | public_key: int, 63 | starknet_domain: StarknetDomain, 64 | ) -> int: 65 | synthetic_asset = amount_synthetic.asset 66 | collateral_asset = amount_collateral.asset 67 | 68 | return get_order_msg_hash( 69 | position_id=position_id, 70 | base_asset_id=int(synthetic_asset.settlement_external_id, 16), 71 | base_amount=amount_synthetic.value, 72 | quote_asset_id=int(collateral_asset.settlement_external_id, 16), 73 | quote_amount=amount_collateral.value, 74 | fee_amount=max_fee.value, 75 | fee_asset_id=int(collateral_asset.settlement_external_id, 16), 76 | expiration=__calc_settlement_expiration(expiration_timestamp), 77 | salt=nonce, 78 | user_public_key=public_key, 79 | domain_name=starknet_domain.name, 80 | domain_version=starknet_domain.version, 81 | domain_chain_id=starknet_domain.chain_id, 82 | domain_revision=starknet_domain.revision, 83 | ) 84 | 85 | 86 | def create_order_settlement_data( 87 | *, 88 | side: OrderSide, 89 | synthetic_amount: Decimal, 90 | price: Decimal, 91 | ctx: SettlementDataCtx, 92 | ): 93 | is_buying_synthetic = side == OrderSide.BUY 94 | rounding_context = ROUNDING_BUY_CONTEXT if is_buying_synthetic else ROUNDING_SELL_CONTEXT 95 | 96 | synthetic_amount_human = HumanReadableAmount(synthetic_amount, ctx.market.synthetic_asset) 97 | collateral_amount_human = HumanReadableAmount(synthetic_amount * price, ctx.market.collateral_asset) 98 | total_fee = ctx.fees.taker_fee_rate + (ctx.builder_fee if ctx.builder_fee is not None else 0) 99 | fee_amount_human = HumanReadableAmount( 100 | total_fee * collateral_amount_human.value, 101 | ctx.market.collateral_asset, 102 | ) 103 | 104 | stark_collateral_amount: StarkAmount = collateral_amount_human.to_stark_amount(rounding_context=rounding_context) 105 | stark_synthetic_amount: StarkAmount = synthetic_amount_human.to_stark_amount(rounding_context=rounding_context) 106 | stark_fee_amount: StarkAmount = fee_amount_human.to_stark_amount(rounding_context=ROUNDING_FEE_CONTEXT) 107 | 108 | if is_buying_synthetic: 109 | stark_collateral_amount = stark_collateral_amount.negate() 110 | else: 111 | stark_synthetic_amount = stark_synthetic_amount.negate() 112 | 113 | debugging_amounts = StarkDebuggingOrderAmountsModel( 114 | collateral_amount=Decimal(stark_collateral_amount.value), 115 | fee_amount=Decimal(stark_fee_amount.value), 116 | synthetic_amount=Decimal(stark_synthetic_amount.value), 117 | ) 118 | 119 | order_hash = hash_order( 120 | amount_synthetic=stark_synthetic_amount, 121 | amount_collateral=stark_collateral_amount, 122 | max_fee=stark_fee_amount, 123 | nonce=ctx.nonce, 124 | position_id=ctx.collateral_position_id, 125 | expiration_timestamp=ctx.expire_time, 126 | public_key=ctx.public_key, 127 | starknet_domain=ctx.starknet_domain, 128 | ) 129 | 130 | (order_signature_r, order_signature_s) = ctx.signer(order_hash) 131 | settlement = StarkSettlementModel( 132 | signature=SettlementSignatureModel(r=order_signature_r, s=order_signature_s), 133 | stark_key=ctx.public_key, 134 | collateral_position=Decimal(ctx.collateral_position_id), 135 | ) 136 | 137 | return OrderSettlementData( 138 | synthetic_amount_human=synthetic_amount_human, 139 | order_hash=order_hash, 140 | settlement=settlement, 141 | debugging_amounts=debugging_amounts, 142 | ) 143 | -------------------------------------------------------------------------------- /x10/perpetual/trading_client/trading_client.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from decimal import Decimal 3 | from typing import Dict, Optional 4 | 5 | from x10.perpetual.accounts import StarkPerpetualAccount 6 | from x10.perpetual.configuration import EndpointConfig 7 | from x10.perpetual.markets import MarketModel 8 | from x10.perpetual.order_object import OrderTpslTriggerParam, create_order_object 9 | from x10.perpetual.orders import ( 10 | OrderSide, 11 | OrderTpslType, 12 | PlacedOrderModel, 13 | SelfTradeProtectionLevel, 14 | TimeInForce, 15 | ) 16 | from x10.perpetual.trading_client.account_module import AccountModule 17 | from x10.perpetual.trading_client.info_module import InfoModule 18 | from x10.perpetual.trading_client.markets_information_module import ( 19 | MarketsInformationModule, 20 | ) 21 | from x10.perpetual.trading_client.order_management_module import OrderManagementModule 22 | from x10.perpetual.trading_client.testnet_module import TestnetModule 23 | from x10.utils.date import utc_now 24 | from x10.utils.http import WrappedApiResponse 25 | from x10.utils.log import get_logger 26 | 27 | LOGGER = get_logger(__name__) 28 | 29 | 30 | class PerpetualTradingClient: 31 | """ 32 | X10 Perpetual Trading Client for the X10 REST API v1. 33 | """ 34 | 35 | __markets: Dict[str, MarketModel] | None 36 | __stark_account: StarkPerpetualAccount 37 | 38 | __info_module: InfoModule 39 | __markets_info_module: MarketsInformationModule 40 | __account_module: AccountModule 41 | __order_management_module: OrderManagementModule 42 | __testnet_module: TestnetModule 43 | __config: EndpointConfig 44 | 45 | async def place_order( 46 | self, 47 | market_name: str, 48 | amount_of_synthetic: Decimal, 49 | price: Decimal, 50 | side: OrderSide, 51 | post_only: bool = False, 52 | previous_order_id=None, 53 | expire_time: Optional[datetime] = None, 54 | time_in_force: TimeInForce = TimeInForce.GTT, 55 | self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, 56 | external_id: Optional[str] = None, 57 | builder_fee: Optional[Decimal] = None, 58 | builder_id: Optional[int] = None, 59 | reduce_only: bool = False, 60 | tp_sl_type: Optional[OrderTpslType] = None, 61 | take_profit: Optional[OrderTpslTriggerParam] = None, 62 | stop_loss: Optional[OrderTpslTriggerParam] = None, 63 | ) -> WrappedApiResponse[PlacedOrderModel]: 64 | if not self.__stark_account: 65 | raise ValueError("Stark account is not set") 66 | 67 | if not self.__markets: 68 | self.__markets = await self.__markets_info_module.get_markets_dict() 69 | 70 | market = self.__markets.get(market_name) 71 | 72 | if not market: 73 | raise ValueError(f"Market {market_name} not found") 74 | 75 | if expire_time is None: 76 | expire_time = utc_now() + timedelta(hours=1) 77 | 78 | order = create_order_object( 79 | account=self.__stark_account, 80 | market=market, 81 | amount_of_synthetic=amount_of_synthetic, 82 | price=price, 83 | side=side, 84 | post_only=post_only, 85 | previous_order_external_id=previous_order_id, 86 | expire_time=expire_time, 87 | time_in_force=time_in_force, 88 | self_trade_protection_level=self_trade_protection_level, 89 | starknet_domain=self.__config.starknet_domain, 90 | order_external_id=external_id, 91 | builder_fee=builder_fee, 92 | builder_id=builder_id, 93 | reduce_only=reduce_only, 94 | tp_sl_type=tp_sl_type, 95 | take_profit=take_profit, 96 | stop_loss=stop_loss, 97 | ) 98 | return await self.__order_management_module.place_order(order) 99 | 100 | async def close(self): 101 | await self.__markets_info_module.close_session() 102 | await self.__account_module.close_session() 103 | await self.__order_management_module.close_session() 104 | 105 | def __init__(self, endpoint_config: EndpointConfig, stark_account: StarkPerpetualAccount | None = None): 106 | api_key = stark_account.api_key if stark_account else None 107 | 108 | self.__markets = None 109 | 110 | if stark_account: 111 | self.__stark_account = stark_account 112 | 113 | self.__info_module = InfoModule(endpoint_config) 114 | self.__markets_info_module = MarketsInformationModule(endpoint_config, api_key=api_key) 115 | self.__account_module = AccountModule(endpoint_config, api_key=api_key, stark_account=stark_account) 116 | self.__order_management_module = OrderManagementModule(endpoint_config, api_key=api_key) 117 | self.__testnet_module = TestnetModule(endpoint_config, api_key=api_key, account_module=self.__account_module) 118 | self.__config = endpoint_config 119 | 120 | @property 121 | def info(self): 122 | return self.__info_module 123 | 124 | @property 125 | def markets_info(self): 126 | return self.__markets_info_module 127 | 128 | @property 129 | def account(self): 130 | return self.__account_module 131 | 132 | @property 133 | def orders(self): 134 | return self.__order_management_module 135 | 136 | @property 137 | def testnet(self): 138 | return self.__testnet_module 139 | -------------------------------------------------------------------------------- /x10/perpetual/orders.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Optional 3 | 4 | from strenum import StrEnum 5 | 6 | from x10.utils.model import HexValue, SettlementSignatureModel, X10BaseModel 7 | 8 | 9 | class TimeInForce(StrEnum): 10 | GTT = "GTT" 11 | IOC = "IOC" 12 | FOK = "FOK" 13 | 14 | 15 | class OrderSide(StrEnum): 16 | BUY = "BUY" 17 | SELL = "SELL" 18 | 19 | 20 | class OrderType(StrEnum): 21 | LIMIT = "LIMIT" 22 | CONDITIONAL = "CONDITIONAL" 23 | MARKET = "MARKET" 24 | TPSL = "TPSL" 25 | 26 | 27 | class OrderTpslType(StrEnum): 28 | ORDER = "ORDER" 29 | POSITION = "POSITION" 30 | 31 | 32 | class OrderStatus(StrEnum): 33 | # Technical status 34 | UNKNOWN = "UNKNOWN" 35 | 36 | NEW = "NEW" 37 | UNTRIGGERED = "UNTRIGGERED" 38 | PARTIALLY_FILLED = "PARTIALLY_FILLED" 39 | FILLED = "FILLED" 40 | CANCELLED = "CANCELLED" 41 | EXPIRED = "EXPIRED" 42 | REJECTED = "REJECTED" 43 | 44 | 45 | class OrderStatusReason(StrEnum): 46 | # Technical status 47 | UNKNOWN = "UNKNOWN" 48 | 49 | NONE = "NONE" 50 | UNKNOWN_MARKET = "UNKNOWN_MARKET" 51 | DISABLED_MARKET = "DISABLED_MARKET" 52 | NOT_ENOUGH_FUNDS = "NOT_ENOUGH_FUNDS" 53 | NO_LIQUIDITY = "NO_LIQUIDITY" 54 | INVALID_FEE = "INVALID_FEE" 55 | INVALID_QTY = "INVALID_QTY" 56 | INVALID_PRICE = "INVALID_PRICE" 57 | INVALID_VALUE = "INVALID_VALUE" 58 | UNKNOWN_ACCOUNT = "UNKNOWN_ACCOUNT" 59 | SELF_TRADE_PROTECTION = "SELF_TRADE_PROTECTION" 60 | POST_ONLY_FAILED = "POST_ONLY_FAILED" 61 | REDUCE_ONLY_FAILED = "REDUCE_ONLY_FAILED" 62 | INVALID_EXPIRE_TIME = "INVALID_EXPIRE_TIME" 63 | POSITION_TPSL_CONFLICT = "POSITION_TPSL_CONFLICT" 64 | INVALID_LEVERAGE = "INVALID_LEVERAGE" 65 | PREV_ORDER_NOT_FOUND = "PREV_ORDER_NOT_FOUND" 66 | PREV_ORDER_TRIGGERED = "PREV_ORDER_TRIGGERED" 67 | TPSL_OTHER_SIDE_FILLED = "TPSL_OTHER_SIDE_FILLED" 68 | PREV_ORDER_CONFLICT = "PREV_ORDER_CONFLICT" 69 | ORDER_REPLACED = "ORDER_REPLACED" 70 | POST_ONLY_MODE = "POST_ONLY_MODE" 71 | REDUCE_ONLY_MODE = "REDUCE_ONLY_MODE" 72 | TRADING_OFF_MODE = "TRADING_OFF_MODE" 73 | 74 | 75 | class OrderTriggerPriceType(StrEnum): 76 | # Technical status 77 | UNKNOWN = "UNKNOWN" 78 | 79 | MARK = "MARK" 80 | INDEX = "INDEX" 81 | LAST = "LAST" 82 | 83 | 84 | class OrderTriggerDirection(StrEnum): 85 | # Technical status 86 | UNKNOWN = "UNKNOWN" 87 | 88 | UP = "UP" 89 | DOWN = "DOWN" 90 | 91 | 92 | class OrderPriceType(StrEnum): 93 | # Technical status 94 | UNKNOWN = "UNKNOWN" 95 | 96 | MARKET = "MARKET" 97 | LIMIT = "LIMIT" 98 | 99 | 100 | class SelfTradeProtectionLevel(StrEnum): 101 | DISABLED = "DISABLED" 102 | ACCOUNT = "ACCOUNT" 103 | CLIENT = "CLIENT" 104 | 105 | 106 | class StarkSettlementModel(X10BaseModel): 107 | signature: SettlementSignatureModel 108 | stark_key: HexValue 109 | collateral_position: Decimal 110 | 111 | 112 | class StarkDebuggingOrderAmountsModel(X10BaseModel): 113 | collateral_amount: Decimal 114 | fee_amount: Decimal 115 | synthetic_amount: Decimal 116 | 117 | 118 | class CreateOrderConditionalTriggerModel(X10BaseModel): 119 | trigger_price: Decimal 120 | trigger_price_type: OrderTriggerPriceType 121 | direction: OrderTriggerDirection 122 | execution_price_type: OrderPriceType 123 | 124 | 125 | class CreateOrderTpslTriggerModel(X10BaseModel): 126 | trigger_price: Decimal 127 | trigger_price_type: OrderTriggerPriceType 128 | price: Decimal 129 | price_type: OrderPriceType 130 | settlement: StarkSettlementModel 131 | debugging_amounts: Optional[StarkDebuggingOrderAmountsModel] = None 132 | 133 | 134 | class NewOrderModel(X10BaseModel): 135 | id: str 136 | market: str 137 | type: OrderType 138 | side: OrderSide 139 | qty: Decimal 140 | price: Decimal 141 | reduce_only: bool = False 142 | post_only: bool = False 143 | time_in_force: TimeInForce 144 | expiry_epoch_millis: int 145 | fee: Decimal 146 | nonce: Decimal 147 | self_trade_protection_level: SelfTradeProtectionLevel 148 | cancel_id: Optional[str] = None 149 | settlement: Optional[StarkSettlementModel] = None 150 | trigger: Optional[CreateOrderConditionalTriggerModel] = None 151 | tp_sl_type: Optional[OrderTpslType] = None 152 | take_profit: Optional[CreateOrderTpslTriggerModel] = None 153 | stop_loss: Optional[CreateOrderTpslTriggerModel] = None 154 | debugging_amounts: Optional[StarkDebuggingOrderAmountsModel] = None 155 | builderFee: Optional[Decimal] = None 156 | builderId: Optional[int] = None 157 | 158 | 159 | class PlacedOrderModel(X10BaseModel): 160 | id: int 161 | external_id: str 162 | 163 | 164 | class OpenOrderTpslTriggerModel(X10BaseModel): 165 | trigger_price: Decimal 166 | trigger_price_type: OrderTriggerPriceType 167 | price: Decimal 168 | price_type: OrderPriceType 169 | status: Optional[OrderStatus] = None 170 | 171 | 172 | class OpenOrderModel(X10BaseModel): 173 | id: int 174 | account_id: int 175 | external_id: str 176 | market: str 177 | type: OrderType 178 | side: OrderSide 179 | status: OrderStatus 180 | status_reason: Optional[OrderStatusReason] = None 181 | price: Decimal 182 | average_price: Optional[Decimal] = None 183 | qty: Decimal 184 | filled_qty: Optional[Decimal] = None 185 | reduce_only: bool 186 | post_only: bool 187 | payed_fee: Optional[Decimal] = None 188 | created_time: int 189 | updated_time: int 190 | expiry_time: Optional[int] = None 191 | time_in_force: TimeInForce 192 | tp_sl_type: Optional[OrderTpslType] = None 193 | take_profit: Optional[OpenOrderTpslTriggerModel] = None 194 | stop_loss: Optional[OpenOrderTpslTriggerModel] = None 195 | -------------------------------------------------------------------------------- /examples/market_maker_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import random 4 | from decimal import Decimal 5 | from typing import Dict 6 | 7 | from x10.perpetual.accounts import StarkPerpetualAccount 8 | from x10.perpetual.configuration import MAINNET_CONFIG 9 | from x10.perpetual.orderbook import OrderBook 10 | from x10.perpetual.orders import OrderSide 11 | from x10.perpetual.trading_client.trading_client import PerpetualTradingClient 12 | 13 | 14 | async def build_markets_cache(trading_client: PerpetualTradingClient): 15 | markets = await trading_client.markets_info.get_markets() 16 | assert markets.data is not None 17 | return {m.name: m for m in markets.data} 18 | 19 | 20 | # flake8: noqa 21 | async def on_board_example(): 22 | environment_config = MAINNET_CONFIG 23 | 24 | root_trading_client = PerpetualTradingClient( 25 | environment_config, 26 | StarkPerpetualAccount( 27 | vault=200027, 28 | private_key="<>", 29 | public_key="<>", 30 | api_key="<>", 31 | ), 32 | ) 33 | 34 | markets = await build_markets_cache(root_trading_client) 35 | market = markets["APEX-USD"] 36 | 37 | best_ask_condition = asyncio.Condition() 38 | best_bid_condition = asyncio.Condition() 39 | 40 | async def react_to_best_ask_change(best_ask): 41 | async with best_ask_condition: 42 | print(f"Best ask changed: {best_ask}") 43 | best_ask_condition.notify_all() 44 | 45 | async def react_to_best_bid_change(best_bid): 46 | async with best_bid_condition: 47 | print(f"Best bid changed: {best_bid}") 48 | best_bid_condition.notify_all() 49 | 50 | order_book = await OrderBook.create( 51 | MAINNET_CONFIG, 52 | market.name, 53 | start=True, 54 | best_ask_change_callback=react_to_best_ask_change, 55 | best_bid_change_callback=react_to_best_bid_change, 56 | ) 57 | 58 | tasks = [] 59 | price_offset_per_level_percent = Decimal("0.3") 60 | num_of_price_levels = 2 61 | 62 | cancelled_orders: Dict[str, datetime.datetime] = {} 63 | 64 | for i in range(num_of_price_levels): 65 | 66 | async def task(i: int, side: OrderSide): 67 | price_offset_for_level_percent = price_offset_per_level_percent * Decimal(i + 1) 68 | prev_order_id: int | None = None 69 | prev_order_price: Decimal | None = None 70 | 71 | while True: 72 | if side == OrderSide.SELL.value: 73 | async with best_ask_condition: 74 | await best_ask_condition.wait() 75 | current_best = order_book.best_ask() 76 | else: 77 | async with best_bid_condition: 78 | await best_bid_condition.wait() 79 | current_best = order_book.best_bid() 80 | 81 | if current_best is None: 82 | continue 83 | 84 | offset_direction = Decimal(1 if side == OrderSide.SELL else -1) 85 | 86 | current_price = current_best.price 87 | target_price = market.trading_config.round_price( 88 | current_price + offset_direction * current_price * (price_offset_for_level_percent / Decimal("100")) 89 | ) 90 | 91 | actual_delta = ( 92 | abs(((prev_order_price - current_price) / current_price)) if prev_order_price is not None else 0 93 | ) 94 | 95 | target_delta = price_offset_for_level_percent / Decimal("100") 96 | 97 | max_delta_allowed = target_delta + target_delta * price_offset_per_level_percent / ( 98 | Decimal(1) + Decimal(i) / Decimal(num_of_price_levels) 99 | ) 100 | 101 | min_delta_required = target_delta - target_delta * price_offset_per_level_percent * ( 102 | Decimal(1) + Decimal(i) / Decimal(num_of_price_levels) 103 | ) 104 | 105 | if prev_order_price is None or (actual_delta < min_delta_required or actual_delta > max_delta_allowed): 106 | print(f"Repricing {side} order from {prev_order_price} to {target_price}, price level {i}") 107 | if prev_order_id is not None: 108 | print(f"Cancelling previous order {prev_order_id}") 109 | asyncio.create_task( 110 | root_trading_client.orders.cancel_order_by_external_id(order_external_id=str(prev_order_id)) 111 | ) 112 | new_id = random.randint(0, 10000000000000000000000000) 113 | print(f"Placing {side} order {new_id} at {target_price}, price level {i}") 114 | try: 115 | await root_trading_client.place_order( 116 | market_name=market.name, 117 | amount_of_synthetic=market.trading_config.min_order_size, 118 | price=target_price, 119 | side=side, 120 | external_id=str(new_id), 121 | post_only=True, 122 | ) 123 | except Exception as e: 124 | print(f"Error placing order {new_id} at {target_price}, price level {i}: {e}") 125 | continue 126 | prev_order_id = new_id 127 | prev_order_price = target_price 128 | else: 129 | pass 130 | 131 | tasks.append(asyncio.create_task(task(i=i, side=OrderSide.SELL))) 132 | tasks.append(asyncio.create_task(task(i=i, side=OrderSide.BUY))) 133 | 134 | while True: 135 | try: 136 | await asyncio.gather(*tasks) 137 | await asyncio.sleep(30) 138 | except Exception as e: 139 | print(f"Error: {e}") 140 | 141 | 142 | asyncio.run(on_board_example()) 143 | -------------------------------------------------------------------------------- /tests/perpetual/test_stream_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import websockets 3 | from hamcrest import assert_that, equal_to 4 | from websockets import WebSocketServer 5 | 6 | 7 | def get_url_from_server(server: WebSocketServer): 8 | host, port = server.sockets[0].getsockname() # type: ignore[index] 9 | return f"ws://{host}:{port}" 10 | 11 | 12 | def serve_message(message): 13 | async def _serve_message(websocket): 14 | await websocket.send(message) 15 | 16 | return _serve_message 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_orderbook_stream(create_orderbook_message): 21 | from x10.perpetual.stream_client import PerpetualStreamClient 22 | 23 | message_model = create_orderbook_message() 24 | 25 | async with websockets.serve(serve_message(message_model.model_dump_json()), "127.0.0.1", 0) as server: 26 | stream_client = PerpetualStreamClient(api_url=get_url_from_server(server)) 27 | stream = await stream_client.subscribe_to_orderbooks() 28 | msg = await stream.recv() 29 | await stream.close() 30 | 31 | assert_that( 32 | msg.to_api_request_json(), 33 | equal_to( 34 | { 35 | "type": "SNAPSHOT", 36 | "data": { 37 | "m": message_model.data.market, 38 | "b": [{"q": "0.008", "p": "43547.00"}, {"q": "0.007000", "p": "43548.00"}], 39 | "a": [{"q": "0.008", "p": "43546.00"}], 40 | }, 41 | "error": None, 42 | "ts": 1704798222748, 43 | "seq": 570, 44 | } 45 | ), 46 | ) 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_account_update_trade_stream(create_account_update_trade_message): 51 | from x10.perpetual.stream_client import PerpetualStreamClient 52 | 53 | api_key = "dummy_api_key" 54 | message_model = create_account_update_trade_message() 55 | 56 | async with websockets.serve(serve_message(message_model.model_dump_json()), "127.0.0.1", 0) as server: 57 | stream_client = PerpetualStreamClient(api_url=get_url_from_server(server)) 58 | stream = await stream_client.subscribe_to_account_updates(api_key) 59 | msg = await stream.recv() 60 | await stream.close() 61 | 62 | assert_that( 63 | msg.to_api_request_json(), 64 | equal_to( 65 | { 66 | "type": "TRADE", 67 | "data": { 68 | "orders": None, 69 | "positions": None, 70 | "trades": [ 71 | { 72 | "id": 1811328331296018432, 73 | "accountId": 3004, 74 | "market": "BTC-USD", 75 | "orderId": 1811328331287359488, 76 | "side": "BUY", 77 | "price": "58249.8000000000000000", 78 | "qty": "0.0010000000000000", 79 | "value": "58.2498000000000000", 80 | "fee": "0.0291240000000000", 81 | "isTaker": True, 82 | "tradeType": "TRADE", 83 | "createdTime": 1720689301691, 84 | } 85 | ], 86 | "balance": None, 87 | }, 88 | "error": None, 89 | "ts": 1704798222748, 90 | "seq": 570, 91 | } 92 | ), 93 | ) 94 | 95 | 96 | @pytest.mark.asyncio 97 | async def test_account_update_stream_with_unexpected_type(create_account_update_unknown_message): 98 | from x10.perpetual.stream_client import PerpetualStreamClient 99 | 100 | api_key = "dummy_api_key" 101 | message_model = create_account_update_unknown_message() 102 | 103 | async with websockets.serve(serve_message(message_model.model_dump_json()), "127.0.0.1", 0) as server: 104 | stream_client = PerpetualStreamClient(api_url=get_url_from_server(server)) 105 | stream = await stream_client.subscribe_to_account_updates(api_key) 106 | msg = await stream.recv() 107 | await stream.close() 108 | 109 | assert_that( 110 | msg.to_api_request_json(), 111 | equal_to( 112 | { 113 | "type": "UNKNOWN", 114 | "data": None, 115 | "error": None, 116 | "ts": 1704798222748, 117 | "seq": 570, 118 | } 119 | ), 120 | ) 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_candle_stream(): 125 | from tests.fixtures.candles import create_candle_stream_message 126 | from x10.perpetual.stream_client import PerpetualStreamClient 127 | 128 | message_model = create_candle_stream_message() 129 | 130 | async with websockets.serve(serve_message(message_model.model_dump_json()), "127.0.0.1", 0) as server: 131 | stream_client = PerpetualStreamClient(api_url=get_url_from_server(server)) 132 | stream = await stream_client.subscribe_to_candles("ETH-USD", "trades", "PT1M") 133 | msg = await stream.recv() 134 | await stream.close() 135 | 136 | assert_that( 137 | msg.to_api_request_json(), 138 | equal_to( 139 | { 140 | "type": None, 141 | "data": [ 142 | { 143 | "o": "3458.64", 144 | "l": "3399.07", 145 | "h": "3476.89", 146 | "c": "3414.85", 147 | "v": "3.938", 148 | "T": 1721106000000, 149 | } 150 | ], 151 | "error": None, 152 | "ts": 1721283121979, 153 | "seq": 1, 154 | } 155 | ), 156 | ) 157 | -------------------------------------------------------------------------------- /examples/placed_order_example_advanced.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import logging.config 4 | import logging.handlers 5 | from asyncio import run 6 | from collections.abc import Awaitable 7 | from decimal import Decimal 8 | from typing import Dict, Optional, Tuple 9 | 10 | from x10.config import ADA_USD_MARKET 11 | from x10.perpetual.accounts import StarkPerpetualAccount 12 | from x10.perpetual.configuration import TESTNET_CONFIG 13 | from x10.perpetual.markets import MarketModel 14 | from x10.perpetual.order_object import create_order_object 15 | from x10.perpetual.orders import OrderSide, PlacedOrderModel 16 | from x10.perpetual.stream_client.perpetual_stream_connection import ( 17 | PerpetualStreamConnection, 18 | ) 19 | from x10.perpetual.stream_client.stream_client import PerpetualStreamClient 20 | from x10.perpetual.trading_client import PerpetualTradingClient 21 | from x10.utils.http import WrappedApiResponse 22 | from x10.utils.model import EmptyModel 23 | 24 | NUM_ORDERS_PER_PRICE_LEVEL = 100 25 | NUM_PRICE_LEVELS = 80 26 | 27 | API_KEY = "" 28 | PRIVATE_KEY = "" 29 | PUBLIC_KEY = " Tuple[str, WrappedApiResponse[PlacedOrderModel]]: 104 | should_buy = i % 2 == 0 105 | price = Decimal("0.660") - Decimal("0.00" + str(i)) if should_buy else Decimal("0.6601") + Decimal("0.00" + str(i)) 106 | order_side = OrderSide.BUY if should_buy else OrderSide.SELL 107 | market = markets_cache[ADA_USD_MARKET] 108 | new_order = create_order_object( 109 | stark_account, market, Decimal("100"), price, order_side, starknet_domain=TESTNET_CONFIG.starknet_domain 110 | ) 111 | order_condtions[new_order.id] = asyncio.Condition() 112 | return new_order.id, await trading_client.orders.place_order(order=new_order) 113 | 114 | 115 | async def clean_it(): 116 | logger = logging.getLogger("placed_order_example") 117 | trading_client = PerpetualTradingClient(TESTNET_CONFIG, stark_account) 118 | positions = await trading_client.account.get_positions() 119 | logger.info("Positions: %s", positions.to_pretty_json()) 120 | balance = await trading_client.account.get_balance() 121 | logger.info("Balance: %s", balance.to_pretty_json()) 122 | open_orders = await trading_client.account.get_open_orders(market_names=[ADA_USD_MARKET]) 123 | 124 | def __cancel_order(order_id: int) -> Awaitable[WrappedApiResponse[EmptyModel]]: 125 | return trading_client.orders.cancel_order(order_id=order_id) 126 | 127 | cancel_futures = list(map(__cancel_order, [order.id for order in open_orders.data])) 128 | await asyncio.gather(*cancel_futures) 129 | 130 | 131 | async def setup_and_run(): 132 | await clean_it() 133 | print("Press enter to start load test") 134 | input() 135 | 136 | trading_client = PerpetualTradingClient(TESTNET_CONFIG, stark_account) 137 | markets_cache = await build_markets_cache(trading_client) 138 | stream_future = asyncio.create_task(order_stream()) 139 | 140 | def __create_order_loop(i: int): 141 | return asyncio.create_task( 142 | order_loop( 143 | i, 144 | trading_client=trading_client, 145 | markets_cache=markets_cache, 146 | ) 147 | ) 148 | 149 | order_loop_futures = map(__create_order_loop, range(NUM_PRICE_LEVELS)) 150 | await asyncio.gather(*order_loop_futures) 151 | print("Load Test Complete") 152 | global order_loop_finished 153 | order_loop_finished = True 154 | if stream: 155 | await stream.close() 156 | await stream_future 157 | await clean_it() 158 | 159 | 160 | if __name__ == "__main__": 161 | run(main=setup_and_run()) 162 | -------------------------------------------------------------------------------- /x10/perpetual/user_client/onboarding.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime, timezone 3 | 4 | from eth_account.messages import SignableMessage, encode_typed_data 5 | from eth_account.signers.local import LocalAccount 6 | from fast_stark_crypto import generate_keypair_from_eth_signature, pedersen_hash 7 | from fast_stark_crypto import sign as stark_sign 8 | 9 | from x10.perpetual.accounts import AccountModel 10 | from x10.utils.model import X10BaseModel 11 | 12 | register_action = "REGISTER" 13 | sub_account_action = "CREATE_SUB_ACCOUNT" 14 | 15 | 16 | class OnboardedClientModel(X10BaseModel): 17 | l1_address: str 18 | default_account: AccountModel 19 | 20 | 21 | @dataclass 22 | class StarkKeyPair: 23 | private: int 24 | public: int 25 | 26 | @property 27 | def public_hex(self) -> str: 28 | return hex(self.public) 29 | 30 | @property 31 | def private_hex(self) -> str: 32 | return hex(self.private) 33 | 34 | 35 | @dataclass 36 | class AccountRegistration: 37 | account_index: int 38 | wallet: str 39 | tos_accepted: bool 40 | time: datetime 41 | action: str 42 | host: str 43 | 44 | def __post_init__(self): 45 | self.time_string = self.time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") 46 | 47 | def to_signable_message(self, signing_domain) -> SignableMessage: 48 | domain = {"name": signing_domain} 49 | 50 | message = { 51 | "accountIndex": self.account_index, 52 | "wallet": self.wallet, 53 | "tosAccepted": self.tos_accepted, 54 | "time": self.time_string, 55 | "action": self.action, 56 | "host": self.host, 57 | } 58 | types = { 59 | "EIP712Domain": [ 60 | {"name": "name", "type": "string"}, 61 | ], 62 | "AccountRegistration": [ 63 | {"name": "accountIndex", "type": "int8"}, 64 | {"name": "wallet", "type": "address"}, 65 | {"name": "tosAccepted", "type": "bool"}, 66 | {"name": "time", "type": "string"}, 67 | {"name": "action", "type": "string"}, 68 | {"name": "host", "type": "string"}, 69 | ], 70 | } 71 | primary_type = "AccountRegistration" 72 | structured_data = { 73 | "types": types, 74 | "domain": domain, 75 | "primaryType": primary_type, 76 | "message": message, 77 | } 78 | return encode_typed_data(full_message=structured_data) 79 | 80 | def to_json(self): 81 | return { 82 | "accountIndex": self.account_index, 83 | "wallet": self.wallet, 84 | "tosAccepted": self.tos_accepted, 85 | "time": self.time_string, 86 | "action": self.action, 87 | "host": self.host, 88 | } 89 | 90 | 91 | @dataclass 92 | class SubAccountOnboardingPayload: 93 | l2_key: int 94 | l2_r: int 95 | l2_s: int 96 | account_registration: AccountRegistration 97 | description: str 98 | 99 | def to_json(self): 100 | return { 101 | "l2Key": hex(self.l2_key), 102 | "l2Signature": { 103 | "r": hex(self.l2_r), 104 | "s": hex(self.l2_s), 105 | }, 106 | "accountCreation": self.account_registration.to_json(), 107 | "description": self.description, 108 | } 109 | 110 | 111 | @dataclass 112 | class OnboardingPayLoad: 113 | l1_signature: str 114 | l2_key: int 115 | l2_r: int 116 | l2_s: int 117 | account_registration: AccountRegistration 118 | referral_code: str | None = None 119 | 120 | def to_json(self): 121 | return { 122 | "l1Signature": self.l1_signature, 123 | "l2Key": hex(self.l2_key), 124 | "l2Signature": { 125 | "r": hex(self.l2_r), 126 | "s": hex(self.l2_s), 127 | }, 128 | "accountCreation": self.account_registration.to_json(), 129 | "referralCode": self.referral_code, 130 | } 131 | 132 | 133 | def get_registration_struct_to_sign( 134 | account_index: int, address: str, timestamp: datetime, action: str, host: str 135 | ) -> AccountRegistration: 136 | return AccountRegistration( 137 | account_index=account_index, 138 | wallet=address, 139 | tos_accepted=True, 140 | time=timestamp, 141 | action=action, 142 | host=host, 143 | ) 144 | 145 | 146 | def get_key_derivation_struct_to_sign(account_index: int, address: str, signing_domain: str) -> SignableMessage: 147 | primary_type = "AccountCreation" 148 | domain = {"name": signing_domain} 149 | message = { 150 | "accountIndex": account_index, 151 | "wallet": address, 152 | "tosAccepted": True, 153 | } 154 | types = { 155 | "EIP712Domain": [ 156 | {"name": "name", "type": "string"}, 157 | ], 158 | "AccountCreation": [ 159 | {"name": "accountIndex", "type": "int8"}, 160 | {"name": "wallet", "type": "address"}, 161 | {"name": "tosAccepted", "type": "bool"}, 162 | ], 163 | } 164 | structured_data = { 165 | "types": types, 166 | "domain": domain, 167 | "primaryType": primary_type, 168 | "message": message, 169 | } 170 | return encode_typed_data(full_message=structured_data) 171 | 172 | 173 | def get_l2_keys_from_l1_account(l1_account: LocalAccount, account_index: int, signing_domain: str) -> StarkKeyPair: 174 | struct = get_key_derivation_struct_to_sign( 175 | account_index=account_index, 176 | address=l1_account.address, 177 | signing_domain=signing_domain, 178 | ) 179 | s = l1_account.sign_message(struct) 180 | (private, public) = generate_keypair_from_eth_signature(s.signature.hex()) 181 | return StarkKeyPair(private=private, public=public) 182 | 183 | 184 | def get_onboarding_payload( 185 | account: LocalAccount, 186 | signing_domain: str, 187 | key_pair: StarkKeyPair, 188 | host: str, 189 | time: datetime | None = None, 190 | referral_code: str | None = None, 191 | ) -> OnboardingPayLoad: 192 | if time is None: 193 | time = datetime.now(timezone.utc) 194 | 195 | registration_payload = get_registration_struct_to_sign( 196 | account_index=0, address=account.address, timestamp=time, action=register_action, host=host 197 | ) 198 | payload = registration_payload.to_signable_message(signing_domain=signing_domain) 199 | l1_signature = account.sign_message(payload).signature.hex() 200 | 201 | l2_message = pedersen_hash(int(account.address, 16), key_pair.public) 202 | l2_r, l2_s = stark_sign(msg_hash=l2_message, private_key=key_pair.private) 203 | 204 | onboarding_payload = OnboardingPayLoad( 205 | l1_signature=l1_signature, 206 | l2_key=key_pair.public, 207 | l2_r=l2_r, 208 | l2_s=l2_s, 209 | account_registration=registration_payload, 210 | referral_code=referral_code, 211 | ) 212 | return onboarding_payload 213 | 214 | 215 | def get_sub_account_creation_payload( 216 | account_index: int, 217 | l1_address: str, 218 | key_pair: StarkKeyPair, 219 | description: str, 220 | host: str, 221 | time: datetime | None = None, 222 | ): 223 | if time is None: 224 | time = datetime.now(timezone.utc) 225 | 226 | registration_payload = get_registration_struct_to_sign( 227 | account_index=account_index, address=l1_address, timestamp=time, action=sub_account_action, host=host 228 | ) 229 | 230 | l2_message = pedersen_hash(int(l1_address, 16), key_pair.public) 231 | l2_r, l2_s = stark_sign(msg_hash=l2_message, private_key=key_pair.private) 232 | 233 | return SubAccountOnboardingPayload( 234 | l2_key=key_pair.public, 235 | l2_r=l2_r, 236 | l2_s=l2_s, 237 | account_registration=registration_payload, 238 | description=description, 239 | ) 240 | -------------------------------------------------------------------------------- /tests/perpetual/test_trading_client.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import List 3 | 4 | import pytest 5 | from aiohttp import web 6 | from hamcrest import assert_that, equal_to, has_length 7 | 8 | from x10.perpetual.assets import AssetOperationModel 9 | from x10.perpetual.configuration import TESTNET_CONFIG 10 | from x10.perpetual.markets import MarketModel 11 | from x10.utils.http import WrappedApiResponse 12 | 13 | 14 | def serve_data(data): 15 | async def _serve_data(_request): 16 | return web.Response(text=data) 17 | 18 | return _serve_data 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_get_markets(aiohttp_server, create_btc_usd_market): 23 | from x10.perpetual.trading_client import PerpetualTradingClient 24 | 25 | expected_market = create_btc_usd_market() 26 | expected_markets = WrappedApiResponse[List[MarketModel]].model_validate( 27 | {"status": "OK", "data": [expected_market.model_dump()]} 28 | ) 29 | 30 | app = web.Application() 31 | app.router.add_get("/info/markets", serve_data(expected_markets.model_dump_json())) 32 | 33 | server = await aiohttp_server(app) 34 | url = f"http://{server.host}:{server.port}" 35 | 36 | endpoint_config = dataclasses.replace(TESTNET_CONFIG, api_base_url=url) 37 | trading_client = PerpetualTradingClient(endpoint_config=endpoint_config) 38 | markets = await trading_client.markets_info.get_markets() 39 | 40 | assert_that(markets.status, equal_to("OK")) 41 | assert_that(markets.data, has_length(1)) 42 | assert_that( 43 | markets.data[0].to_api_request_json(), 44 | equal_to( 45 | { 46 | "name": "BTC-USD", 47 | "assetName": "BTC", 48 | "assetPrecision": 5, 49 | "collateralAssetName": "USD", 50 | "collateralAssetPrecision": 6, 51 | "active": True, 52 | "marketStats": { 53 | "dailyVolume": "2410800.768021", 54 | "dailyVolumeBase": "37.94502", 55 | "dailyPriceChange": "969.9", 56 | "dailyLow": "62614.8", 57 | "dailyHigh": "64421.1", 58 | "lastPrice": "64280.0", 59 | "askPrice": "64268.2", 60 | "bidPrice": "64235.9", 61 | "markPrice": "64267.380482593245", 62 | "indexPrice": "64286.409493065992", 63 | "fundingRate": "-0.000034", 64 | "nextFundingRate": 1715072400000, 65 | "openInterest": "150629.886375", 66 | "openInterestBase": "2.34380", 67 | }, 68 | "tradingConfig": { 69 | "minOrderSize": "0.0001", 70 | "minOrderSizeChange": "0.00001", 71 | "minPriceChange": "0.1", 72 | "maxMarketOrderValue": "1000000", 73 | "maxLimitOrderValue": "5000000", 74 | "maxPositionValue": "10000000", 75 | "maxLeverage": "50.00", 76 | "maxNumOrders": 200, 77 | "limitPriceCap": "0.05", 78 | "limitPriceFloor": "0.05", 79 | "riskFactorConfig": [ 80 | {"upperBound": "400000", "riskFactor": "0.02"}, 81 | {"upperBound": "800000", "riskFactor": "0.04"}, 82 | {"upperBound": "1200000", "riskFactor": "0.06"}, 83 | {"upperBound": "1600000", "riskFactor": "0.08"}, 84 | {"upperBound": "2000000", "riskFactor": "0.1"}, 85 | {"upperBound": "2400000", "riskFactor": "0.12"}, 86 | {"upperBound": "2800000", "riskFactor": "0.14"}, 87 | {"upperBound": "3200000", "riskFactor": "0.16"}, 88 | {"upperBound": "3600000", "riskFactor": "0.18"}, 89 | {"upperBound": "4000000", "riskFactor": "0.2"}, 90 | {"upperBound": "4400000", "riskFactor": "0.22"}, 91 | {"upperBound": "4800000", "riskFactor": "0.24"}, 92 | {"upperBound": "5200000", "riskFactor": "0.26"}, 93 | {"upperBound": "5600000", "riskFactor": "0.28"}, 94 | {"upperBound": "6000000", "riskFactor": "0.3"}, 95 | {"upperBound": "6400000", "riskFactor": "0.32"}, 96 | {"upperBound": "6800000", "riskFactor": "0.34"}, 97 | {"upperBound": "7200000", "riskFactor": "0.36"}, 98 | {"upperBound": "7600000", "riskFactor": "0.38"}, 99 | {"upperBound": "8000000", "riskFactor": "0.4"}, 100 | {"upperBound": "8400000", "riskFactor": "0.42"}, 101 | {"upperBound": "8800000", "riskFactor": "0.44"}, 102 | {"upperBound": "9200000", "riskFactor": "0.46"}, 103 | {"upperBound": "9600000", "riskFactor": "0.48"}, 104 | {"upperBound": "10000000", "riskFactor": "0.5"}, 105 | {"upperBound": "1000000000", "riskFactor": "1"}, 106 | ], 107 | }, 108 | "l2Config": { 109 | "type": "STARKX", 110 | "collateralId": "0x31857064564ed0ff978e687456963cba09c2c6985d8f9300a1de4962fafa054", 111 | "collateralResolution": 1000000, 112 | "syntheticId": "0x4254432d3600000000000000000000", 113 | "syntheticResolution": 1000000, 114 | }, 115 | } 116 | ), 117 | ) 118 | 119 | 120 | @pytest.mark.asyncio 121 | async def test_get_asset_operations(aiohttp_server, create_asset_operations, create_trading_account): 122 | from x10.perpetual.trading_client import PerpetualTradingClient 123 | 124 | expected_operations = create_asset_operations() 125 | expected_response = WrappedApiResponse[List[AssetOperationModel]].model_validate( 126 | {"status": "OK", "data": [op.model_dump() for op in expected_operations]} 127 | ) 128 | 129 | app = web.Application() 130 | app.router.add_get("/user/assetOperations", serve_data(expected_response.model_dump_json())) 131 | 132 | server = await aiohttp_server(app) 133 | url = f"http://{server.host}:{server.port}" 134 | 135 | stark_account = create_trading_account() 136 | endpoint_config = endpoint_config = dataclasses.replace(TESTNET_CONFIG, api_base_url=url) 137 | trading_client = PerpetualTradingClient(endpoint_config=endpoint_config, stark_account=stark_account) 138 | operations = await trading_client.account.asset_operations() 139 | 140 | assert_that(operations.status, equal_to("OK")) 141 | assert_that(operations.data, has_length(2)) 142 | assert_that( 143 | [op.to_api_request_json() for op in operations.data], 144 | equal_to( 145 | [ 146 | { 147 | "id": "1816814506626514944", 148 | "type": "TRANSFER", 149 | "status": "COMPLETED", 150 | "amount": "-100.0000000000000000", 151 | "fee": "0", 152 | "asset": 1, 153 | "time": 1721997307818, 154 | "accountId": 3004, 155 | "counterpartyAccountId": 7349, 156 | "transactionHash": None, 157 | }, 158 | { 159 | "id": "1813548171448147968", 160 | "type": "CLAIM", 161 | "status": "COMPLETED", 162 | "amount": "100000.0000000000000000", 163 | "fee": "0", 164 | "asset": 1, 165 | "time": 1721218552833, 166 | "accountId": 3004, 167 | "counterpartyAccountId": None, 168 | "transactionHash": None, 169 | }, 170 | ] 171 | ), 172 | ) 173 | -------------------------------------------------------------------------------- /tests/fixtures/markets.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | def get_btc_usd_market_json_data(): 5 | return """ 6 | { 7 | "status": "OK", 8 | "data": [ 9 | { 10 | "name": "BTC-USD", 11 | "category": "L1", 12 | "assetName": "BTC", 13 | "assetPrecision": 5, 14 | "collateralAssetName": "USD", 15 | "collateralAssetPrecision": 6, 16 | "active": true, 17 | "marketStats": { 18 | "dailyVolume": "2410800.768021", 19 | "dailyVolumeBase": "37.94502", 20 | "dailyPriceChange": "969.9", 21 | "dailyPriceChangePercentage": "0.02", 22 | "dailyLow": "62614.8", 23 | "dailyHigh": "64421.1", 24 | "lastPrice": "64280.0", 25 | "askPrice": "64268.2", 26 | "bidPrice": "64235.9", 27 | "markPrice": "64267.380482593245", 28 | "indexPrice": "64286.409493065992", 29 | "fundingRate": "-0.000034", 30 | "nextFundingRate": 1715072400000, 31 | "openInterest": "150629.886375", 32 | "openInterestBase": "2.34380", 33 | "deleverageLevels": { 34 | "shortPositions": [ 35 | { 36 | "level": 1, 37 | "rankingLowerBound": "-5919.3176" 38 | }, 39 | { 40 | "level": 2, 41 | "rankingLowerBound": "-1.8517" 42 | } 43 | ], 44 | "longPositions": [ 45 | { 46 | "level": 1, 47 | "rankingLowerBound": "0.0000" 48 | }, 49 | { 50 | "level": 2, 51 | "rankingLowerBound": "0.0000" 52 | } 53 | ] 54 | } 55 | }, 56 | "tradingConfig": { 57 | "minOrderSize": "0.0001", 58 | "minOrderSizeChange": "0.00001", 59 | "minPriceChange": "0.1", 60 | "maxMarketOrderValue": "1000000", 61 | "maxLimitOrderValue": "5000000", 62 | "maxPositionValue": "10000000", 63 | "maxLeverage": "50.00", 64 | "maxNumOrders": "200", 65 | "limitPriceCap": "0.05", 66 | "limitPriceFloor": "0.05", 67 | "riskFactorConfig": [ 68 | { 69 | "upperBound": "400000", 70 | "riskFactor": "0.02" 71 | }, 72 | { 73 | "upperBound": "800000", 74 | "riskFactor": "0.04" 75 | }, 76 | { 77 | "upperBound": "1200000", 78 | "riskFactor": "0.06" 79 | }, 80 | { 81 | "upperBound": "1600000", 82 | "riskFactor": "0.08" 83 | }, 84 | { 85 | "upperBound": "2000000", 86 | "riskFactor": "0.1" 87 | }, 88 | { 89 | "upperBound": "2400000", 90 | "riskFactor": "0.12" 91 | }, 92 | { 93 | "upperBound": "2800000", 94 | "riskFactor": "0.14" 95 | }, 96 | { 97 | "upperBound": "3200000", 98 | "riskFactor": "0.16" 99 | }, 100 | { 101 | "upperBound": "3600000", 102 | "riskFactor": "0.18" 103 | }, 104 | { 105 | "upperBound": "4000000", 106 | "riskFactor": "0.2" 107 | }, 108 | { 109 | "upperBound": "4400000", 110 | "riskFactor": "0.22" 111 | }, 112 | { 113 | "upperBound": "4800000", 114 | "riskFactor": "0.24" 115 | }, 116 | { 117 | "upperBound": "5200000", 118 | "riskFactor": "0.26" 119 | }, 120 | { 121 | "upperBound": "5600000", 122 | "riskFactor": "0.28" 123 | }, 124 | { 125 | "upperBound": "6000000", 126 | "riskFactor": "0.3" 127 | }, 128 | { 129 | "upperBound": "6400000", 130 | "riskFactor": "0.32" 131 | }, 132 | { 133 | "upperBound": "6800000", 134 | "riskFactor": "0.34" 135 | }, 136 | { 137 | "upperBound": "7200000", 138 | "riskFactor": "0.36" 139 | }, 140 | { 141 | "upperBound": "7600000", 142 | "riskFactor": "0.38" 143 | }, 144 | { 145 | "upperBound": "8000000", 146 | "riskFactor": "0.4" 147 | }, 148 | { 149 | "upperBound": "8400000", 150 | "riskFactor": "0.42" 151 | }, 152 | { 153 | "upperBound": "8800000", 154 | "riskFactor": "0.44" 155 | }, 156 | { 157 | "upperBound": "9200000", 158 | "riskFactor": "0.46" 159 | }, 160 | { 161 | "upperBound": "9600000", 162 | "riskFactor": "0.48" 163 | }, 164 | { 165 | "upperBound": "10000000", 166 | "riskFactor": "0.5" 167 | }, 168 | { 169 | "upperBound": "1000000000", 170 | "riskFactor": "1" 171 | } 172 | ] 173 | }, 174 | "l2Config": { 175 | "type": "STARKX", 176 | "collateralId": "0x31857064564ed0ff978e687456963cba09c2c6985d8f9300a1de4962fafa054", 177 | "syntheticId": "0x4254432d3600000000000000000000", 178 | "syntheticResolution": 1000000, 179 | "collateralResolution": 1000000 180 | } 181 | } 182 | ] 183 | } 184 | """ 185 | 186 | 187 | def create_btc_usd_market(json_data: str): 188 | from x10.perpetual.markets import MarketModel 189 | from x10.utils.http import WrappedApiResponse 190 | 191 | result = WrappedApiResponse[List[MarketModel]].model_validate_json(json_data) 192 | 193 | assert result.data 194 | 195 | return result.data[0] 196 | -------------------------------------------------------------------------------- /x10/perpetual/order_object.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime, timedelta 3 | from decimal import Decimal 4 | from typing import Callable, Optional, Tuple 5 | 6 | from x10.perpetual.accounts import StarkPerpetualAccount 7 | from x10.perpetual.configuration import StarknetDomain 8 | from x10.perpetual.fees import DEFAULT_FEES, TradingFeeModel 9 | from x10.perpetual.markets import MarketModel 10 | from x10.perpetual.order_object_settlement import ( 11 | OrderSettlementData, 12 | SettlementDataCtx, 13 | create_order_settlement_data, 14 | ) 15 | from x10.perpetual.orders import ( 16 | CreateOrderTpslTriggerModel, 17 | NewOrderModel, 18 | OrderPriceType, 19 | OrderSide, 20 | OrderTpslType, 21 | OrderTriggerPriceType, 22 | OrderType, 23 | SelfTradeProtectionLevel, 24 | TimeInForce, 25 | ) 26 | from x10.utils.date import to_epoch_millis, utc_now 27 | from x10.utils.nonce import generate_nonce 28 | 29 | 30 | @dataclass(kw_only=True) 31 | class OrderTpslTriggerParam: 32 | trigger_price: Decimal 33 | trigger_price_type: OrderTriggerPriceType 34 | price: Decimal 35 | price_type: OrderPriceType 36 | 37 | 38 | def create_order_object( 39 | account: StarkPerpetualAccount, 40 | market: MarketModel, 41 | amount_of_synthetic: Decimal, 42 | price: Decimal, 43 | side: OrderSide, 44 | starknet_domain: StarknetDomain, 45 | post_only: bool = False, 46 | previous_order_external_id: Optional[str] = None, 47 | expire_time: Optional[datetime] = None, 48 | order_external_id: Optional[str] = None, 49 | time_in_force: TimeInForce = TimeInForce.GTT, 50 | self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, 51 | nonce: Optional[int] = None, 52 | builder_fee: Optional[Decimal] = None, 53 | builder_id: Optional[int] = None, 54 | reduce_only: bool = False, 55 | tp_sl_type: Optional[OrderTpslType] = None, 56 | take_profit: Optional[OrderTpslTriggerParam] = None, 57 | stop_loss: Optional[OrderTpslTriggerParam] = None, 58 | ) -> NewOrderModel: 59 | """ 60 | Creates an order object to be placed on the exchange using the `place_order` method. 61 | """ 62 | 63 | if expire_time is None: 64 | expire_time = utc_now() + timedelta(hours=1) 65 | 66 | fees = account.trading_fee.get(market.name, DEFAULT_FEES) 67 | 68 | return __create_order_object( 69 | market=market, 70 | synthetic_amount=amount_of_synthetic, 71 | price=price, 72 | side=side, 73 | collateral_position_id=account.vault, 74 | fees=fees, 75 | signer=account.sign, 76 | public_key=account.public_key, 77 | exact_only=False, 78 | expire_time=expire_time, 79 | post_only=post_only, 80 | previous_order_external_id=previous_order_external_id, 81 | order_external_id=order_external_id, 82 | time_in_force=time_in_force, 83 | self_trade_protection_level=self_trade_protection_level, 84 | starknet_domain=starknet_domain, 85 | nonce=nonce, 86 | builder_fee=builder_fee, 87 | builder_id=builder_id, 88 | reduce_only=reduce_only, 89 | tp_sl_type=tp_sl_type, 90 | take_profit=take_profit, 91 | stop_loss=stop_loss, 92 | ) 93 | 94 | 95 | def __create_order_tpsl_trigger_model(trigger_param: OrderTpslTriggerParam, settlement_data: OrderSettlementData): 96 | return CreateOrderTpslTriggerModel( 97 | trigger_price=trigger_param.trigger_price, 98 | trigger_price_type=trigger_param.trigger_price_type, 99 | price=trigger_param.price, 100 | price_type=trigger_param.price_type, 101 | settlement=settlement_data.settlement, 102 | debugging_amounts=settlement_data.debugging_amounts, 103 | ) 104 | 105 | 106 | def __get_opposite_side(side: OrderSide) -> OrderSide: 107 | return OrderSide.BUY if side == OrderSide.SELL else OrderSide.SELL 108 | 109 | 110 | def __create_order_object( 111 | *, 112 | market: MarketModel, 113 | synthetic_amount: Decimal, 114 | price: Decimal, 115 | side: OrderSide, 116 | collateral_position_id: int, 117 | fees: TradingFeeModel, 118 | signer: Callable[[int], Tuple[int, int]], 119 | public_key: int, 120 | starknet_domain: StarknetDomain, 121 | exact_only: bool = False, 122 | expire_time: Optional[datetime] = None, 123 | post_only: bool = False, 124 | previous_order_external_id: Optional[str] = None, 125 | order_external_id: Optional[str] = None, 126 | time_in_force: TimeInForce = TimeInForce.GTT, 127 | self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, 128 | nonce: Optional[int] = None, 129 | builder_fee: Optional[Decimal] = None, 130 | builder_id: Optional[int] = None, 131 | reduce_only: bool = False, 132 | tp_sl_type: Optional[OrderTpslType] = None, 133 | take_profit: Optional[OrderTpslTriggerParam] = None, 134 | stop_loss: Optional[OrderTpslTriggerParam] = None, 135 | ) -> NewOrderModel: 136 | if side not in OrderSide: 137 | raise ValueError(f"Unexpected order side value: {side}") 138 | 139 | if time_in_force not in TimeInForce or time_in_force == TimeInForce.FOK: 140 | raise ValueError(f"Unexpected time in force value: {time_in_force}") 141 | 142 | if expire_time is None: 143 | raise ValueError("`expire_time` must be provided") 144 | 145 | if exact_only: 146 | raise NotImplementedError("`exact_only` option is not supported yet") 147 | 148 | if tp_sl_type == OrderTpslType.POSITION: 149 | raise NotImplementedError("`POSITION` TPSL type is not supported yet") 150 | 151 | if (take_profit and take_profit.price_type == OrderPriceType.MARKET) or ( 152 | stop_loss and stop_loss.price_type == OrderPriceType.MARKET 153 | ): 154 | raise NotImplementedError("TPSL `MARKET` price type is not supported yet") 155 | 156 | if nonce is None: 157 | nonce = generate_nonce() 158 | 159 | fee_rate = fees.taker_fee_rate 160 | 161 | settlement_data_ctx = SettlementDataCtx( 162 | market=market, 163 | fees=fees, 164 | builder_fee=builder_fee, 165 | nonce=nonce, 166 | collateral_position_id=collateral_position_id, 167 | expire_time=expire_time, 168 | signer=signer, 169 | public_key=public_key, 170 | starknet_domain=starknet_domain, 171 | ) 172 | settlement_data = create_order_settlement_data( 173 | side=side, synthetic_amount=synthetic_amount, price=price, ctx=settlement_data_ctx 174 | ) 175 | tp_trigger_model = ( 176 | __create_order_tpsl_trigger_model( 177 | take_profit, 178 | create_order_settlement_data( 179 | side=__get_opposite_side(side), 180 | synthetic_amount=synthetic_amount, 181 | price=take_profit.price, 182 | ctx=settlement_data_ctx, 183 | ), 184 | ) 185 | if take_profit 186 | else None 187 | ) 188 | sl_trigger_model = ( 189 | __create_order_tpsl_trigger_model( 190 | stop_loss, 191 | create_order_settlement_data( 192 | side=__get_opposite_side(side), 193 | synthetic_amount=synthetic_amount, 194 | price=stop_loss.price, 195 | ctx=settlement_data_ctx, 196 | ), 197 | ) 198 | if stop_loss 199 | else None 200 | ) 201 | 202 | order_id = str(settlement_data.order_hash) if order_external_id is None else order_external_id 203 | order = NewOrderModel( 204 | id=order_id, 205 | market=market.name, 206 | type=OrderType.LIMIT, 207 | side=side, 208 | qty=settlement_data.synthetic_amount_human.value, 209 | price=price, 210 | post_only=post_only, 211 | time_in_force=time_in_force, 212 | expiry_epoch_millis=to_epoch_millis(expire_time), 213 | fee=fee_rate, 214 | self_trade_protection_level=self_trade_protection_level, 215 | nonce=Decimal(nonce), 216 | cancel_id=previous_order_external_id, 217 | settlement=settlement_data.settlement, 218 | tp_sl_type=tp_sl_type, 219 | take_profit=tp_trigger_model, 220 | stop_loss=sl_trigger_model, 221 | debugging_amounts=settlement_data.debugging_amounts, 222 | builderFee=builder_fee, 223 | builderId=builder_id, 224 | reduce_only=reduce_only, 225 | ) 226 | 227 | return order 228 | -------------------------------------------------------------------------------- /x10/perpetual/user_client/user_client.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime, timezone 3 | from typing import Callable, Dict, List, Optional 4 | 5 | import aiohttp 6 | from eth_account import Account 7 | from eth_account.messages import encode_defunct 8 | from eth_account.signers.local import LocalAccount 9 | 10 | from x10.errors import X10Error 11 | from x10.perpetual.accounts import AccountModel, ApiKeyRequestModel, ApiKeyResponseModel 12 | from x10.perpetual.configuration import EndpointConfig 13 | from x10.perpetual.user_client.onboarding import ( 14 | OnboardedClientModel, 15 | StarkKeyPair, 16 | get_l2_keys_from_l1_account, 17 | get_onboarding_payload, 18 | get_sub_account_creation_payload, 19 | ) 20 | from x10.utils.http import ( # WrappedApiResponse,; send_get_request,; send_patch_request, 21 | CLIENT_TIMEOUT, 22 | get_url, 23 | send_get_request, 24 | send_post_request, 25 | ) 26 | 27 | L1_AUTH_SIGNATURE_HEADER = "L1_SIGNATURE" 28 | L1_MESSAGE_TIME_HEADER = "L1_MESSAGE_TIME" 29 | ACTIVE_ACCOUNT_HEADER = "X-X10-ACTIVE-ACCOUNT" 30 | 31 | 32 | class SubAccountExists(X10Error): 33 | pass 34 | 35 | 36 | @dataclass 37 | class OnBoardedAccount: 38 | account: AccountModel 39 | l2_key_pair: StarkKeyPair 40 | 41 | 42 | class UserClient: 43 | __endpoint_config: EndpointConfig 44 | __l1_private_key: Callable[[], str] 45 | __session: Optional[aiohttp.ClientSession] = None 46 | 47 | def __init__( 48 | self, 49 | endpoint_config: EndpointConfig, 50 | l1_private_key: Callable[[], str], 51 | ): 52 | super().__init__() 53 | self.__endpoint_config = endpoint_config 54 | self.__l1_private_key = l1_private_key 55 | 56 | def _get_url(self, base_url: str, path: str, *, query: Optional[Dict] = None, **path_params) -> str: 57 | return get_url(f"{base_url}{path}", query=query, **path_params) 58 | 59 | async def get_session(self) -> aiohttp.ClientSession: 60 | if self.__session is None: 61 | created_session = aiohttp.ClientSession(timeout=CLIENT_TIMEOUT) 62 | self.__session = created_session 63 | 64 | return self.__session 65 | 66 | async def close_session(self): 67 | if self.__session: 68 | await self.__session.close() 69 | self.__session = None 70 | 71 | async def onboard(self, referral_code: Optional[str] = None): 72 | signing_account: LocalAccount = Account.from_key(self.__l1_private_key()) 73 | key_pair = get_l2_keys_from_l1_account( 74 | l1_account=signing_account, account_index=0, signing_domain=self.__endpoint_config.signing_domain 75 | ) 76 | payload = get_onboarding_payload( 77 | signing_account, 78 | signing_domain=self.__endpoint_config.signing_domain, 79 | key_pair=key_pair, 80 | referral_code=referral_code, 81 | host=self.__endpoint_config.onboarding_url, 82 | ) 83 | url = self._get_url(self.__endpoint_config.onboarding_url, path="/auth/onboard") 84 | onboarding_response = await send_post_request( 85 | await self.get_session(), url, OnboardedClientModel, json=payload.to_json() 86 | ) 87 | 88 | onboarded_client = onboarding_response.data 89 | if onboarded_client is None: 90 | raise ValueError("No account data returned from onboarding") 91 | 92 | return OnBoardedAccount(account=onboarded_client.default_account, l2_key_pair=key_pair) 93 | 94 | async def onboard_subaccount(self, account_index: int, description: str | None = None): 95 | request_path = "/auth/onboard/subaccount" 96 | if description is None: 97 | description = f"Subaccount {account_index}" 98 | 99 | signing_account: LocalAccount = Account.from_key(self.__l1_private_key()) 100 | time = datetime.now(timezone.utc) 101 | auth_time_string = time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") 102 | l1_message = f"{request_path}@{auth_time_string}".encode(encoding="utf-8") 103 | signable_message = encode_defunct(l1_message) 104 | l1_signature = signing_account.sign_message(signable_message) 105 | key_pair = get_l2_keys_from_l1_account( 106 | l1_account=signing_account, 107 | account_index=account_index, 108 | signing_domain=self.__endpoint_config.signing_domain, 109 | ) 110 | payload = get_sub_account_creation_payload( 111 | account_index=account_index, 112 | l1_address=signing_account.address, 113 | key_pair=key_pair, 114 | description=description, 115 | host=self.__endpoint_config.onboarding_url, 116 | ) 117 | headers = { 118 | L1_AUTH_SIGNATURE_HEADER: l1_signature.signature.hex(), 119 | L1_MESSAGE_TIME_HEADER: auth_time_string, 120 | } 121 | url = self._get_url(self.__endpoint_config.onboarding_url, path=request_path) 122 | 123 | try: 124 | onboarding_response = await send_post_request( 125 | await self.get_session(), 126 | url, 127 | AccountModel, 128 | json=payload.to_json(), 129 | request_headers=headers, 130 | response_code_to_exception={409: SubAccountExists}, 131 | ) 132 | onboarded_account = onboarding_response.data 133 | except SubAccountExists: 134 | client_accounts = await self.get_accounts() 135 | account_with_index = [ 136 | account for account in client_accounts if account.account.account_index == account_index 137 | ] 138 | if not account_with_index: 139 | raise SubAccountExists("Subaccount already exists but not found in client accounts") 140 | onboarded_account = account_with_index[0].account 141 | if onboarded_account is None: 142 | raise ValueError("No account data returned from onboarding") 143 | return OnBoardedAccount(account=onboarded_account, l2_key_pair=key_pair) 144 | 145 | async def get_accounts(self) -> List[OnBoardedAccount]: 146 | request_path = "/api/v1/user/accounts" 147 | signing_account: LocalAccount = Account.from_key(self.__l1_private_key()) 148 | time = datetime.now(timezone.utc) 149 | auth_time_string = time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") 150 | l1_message = f"{request_path}@{auth_time_string}".encode(encoding="utf-8") 151 | signable_message = encode_defunct(l1_message) 152 | l1_signature = signing_account.sign_message(signable_message) 153 | headers = { 154 | L1_AUTH_SIGNATURE_HEADER: l1_signature.signature.hex(), 155 | L1_MESSAGE_TIME_HEADER: auth_time_string, 156 | } 157 | url = self._get_url(self.__endpoint_config.onboarding_url, path=request_path) 158 | response = await send_get_request(await self.get_session(), url, List[AccountModel], request_headers=headers) 159 | accounts = response.data or [] 160 | 161 | return [ 162 | OnBoardedAccount( 163 | account=account, 164 | l2_key_pair=get_l2_keys_from_l1_account( 165 | l1_account=signing_account, 166 | account_index=account.account_index, 167 | signing_domain=self.__endpoint_config.signing_domain, 168 | ), 169 | ) 170 | for account in accounts 171 | ] 172 | 173 | async def create_account_api_key(self, account: AccountModel, description: str | None) -> str: 174 | request_path = "/api/v1/user/account/api-key" 175 | if description is None: 176 | description = "trading api key for account {}".format(account.id) 177 | 178 | signing_account: LocalAccount = Account.from_key(self.__l1_private_key()) 179 | time = datetime.now(timezone.utc) 180 | auth_time_string = time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") 181 | l1_message = f"{request_path}@{auth_time_string}".encode(encoding="utf-8") 182 | signable_message = encode_defunct(l1_message) 183 | l1_signature = signing_account.sign_message(signable_message) 184 | headers = { 185 | L1_AUTH_SIGNATURE_HEADER: l1_signature.signature.hex(), 186 | L1_MESSAGE_TIME_HEADER: auth_time_string, 187 | ACTIVE_ACCOUNT_HEADER: str(account.id), 188 | } 189 | url = self._get_url(self.__endpoint_config.onboarding_url, path=request_path) 190 | request = ApiKeyRequestModel(description=description) 191 | response = await send_post_request( 192 | await self.get_session(), 193 | url, 194 | ApiKeyResponseModel, 195 | json=request.to_api_request_json(), 196 | request_headers=headers, 197 | ) 198 | response_data = response.data 199 | if response_data is None: 200 | raise ValueError("No API key data returned from onboarding") 201 | return response_data.key 202 | -------------------------------------------------------------------------------- /tests/perpetual/test_orderbook_price_impact.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import decimal 3 | from unittest import TestCase 4 | 5 | from x10.perpetual.configuration import TESTNET_CONFIG 6 | from x10.perpetual.orderbook import OrderBook 7 | from x10.perpetual.orderbooks import OrderbookUpdateModel 8 | 9 | 10 | class TestOrderBook(TestCase): 11 | def setUp(self): 12 | self.endpoint_config = TESTNET_CONFIG 13 | self.market_name = "dummy-market" 14 | self.orderbook = OrderBook( 15 | self.endpoint_config, 16 | self.market_name, 17 | best_ask_change_callback=None, 18 | best_bid_change_callback=None, 19 | ) 20 | asyncio.run(self.populate_dummy_data()) 21 | 22 | async def populate_dummy_data(self): 23 | dummy_data = OrderbookUpdateModel( 24 | market=self.market_name, 25 | bid=[ 26 | {"price": decimal.Decimal("100"), "qty": decimal.Decimal("1")}, 27 | {"price": decimal.Decimal("99"), "qty": decimal.Decimal("2")}, 28 | {"price": decimal.Decimal("98"), "qty": decimal.Decimal("1")}, 29 | ], 30 | ask=[ 31 | {"price": decimal.Decimal("101"), "qty": decimal.Decimal("1")}, 32 | {"price": decimal.Decimal("102"), "qty": decimal.Decimal("2")}, 33 | {"price": decimal.Decimal("103"), "qty": decimal.Decimal("1")}, 34 | ], 35 | ) 36 | await self.orderbook.update_orderbook(dummy_data) 37 | 38 | def test_calculate_impact_partial_buy(self): 39 | notional = decimal.Decimal("105") 40 | expected_amount = decimal.Decimal("1") + decimal.Decimal("4") / decimal.Decimal("102") 41 | expected_average_price = notional / expected_amount 42 | result = self.orderbook.calculate_price_impact_notional(notional, "BUY") 43 | self.assertEqual(result.amount, expected_amount) 44 | self.assertEqual(result.price, expected_average_price) 45 | 46 | def test_calculate_impact_partial_sell(self): 47 | notional = decimal.Decimal("110") 48 | expected_amount = decimal.Decimal(1) + decimal.Decimal("10") / decimal.Decimal("99") 49 | expected_average_price = notional / expected_amount 50 | result = self.orderbook.calculate_price_impact_notional(notional, "SELL") 51 | self.assertEqual(result.amount, expected_amount) 52 | self.assertEqual(result.price, expected_average_price) 53 | 54 | def test_calculate_price_impact_total_match_sell(self): 55 | notional = decimal.Decimal("199") 56 | expected_amount = decimal.Decimal("2") 57 | expected_average_price = notional / expected_amount 58 | result = self.orderbook.calculate_price_impact_notional(notional, "SELL") 59 | self.assertEqual(result.amount, expected_amount) 60 | self.assertEqual(result.price, expected_average_price) 61 | 62 | def test_calculate_price_impact_total_match_buy(self): 63 | notional = decimal.Decimal("101") + decimal.Decimal("2") * decimal.Decimal("102") + decimal.Decimal("103") 64 | expected_amount = decimal.Decimal("4") 65 | expected_average_price = notional / expected_amount 66 | result = self.orderbook.calculate_price_impact_notional(notional, "BUY") 67 | self.assertEqual(result.amount, expected_amount) 68 | self.assertEqual(result.price, expected_average_price) 69 | 70 | def test_calculate_price_impact_insufficient_liquidity_bid(self): 71 | notional = decimal.Decimal("1000") 72 | result = self.orderbook.calculate_price_impact_notional(notional, "SELL") 73 | self.assertIsNone(result) 74 | 75 | def test_calculate_price_impact_insufficient_liquidity_ask(self): 76 | notional = decimal.Decimal("1000") 77 | result = self.orderbook.calculate_price_impact_notional(notional, "BUY") 78 | self.assertIsNone(result) 79 | 80 | def test_calculate_price_impact_invalid_notional(self): 81 | notional = decimal.Decimal("-10") 82 | result = self.orderbook.calculate_price_impact_notional(notional, "SELL") 83 | self.assertIsNone(result) 84 | 85 | def test_calculate_price_impact_invalid_side(self): 86 | notional = decimal.Decimal("100") 87 | result = self.orderbook.calculate_price_impact_notional(notional, "invalid") 88 | self.assertIsNone(result) 89 | 90 | def test_calculate_qty_impact_partial_buy(self): 91 | """ 92 | Buy a partial quantity that spans multiple ask levels. 93 | For example: buying 2 units: 94 | - 1 unit at price 101 95 | - 1 unit at price 102 96 | total cost = 101 + 102 = 203 97 | average price = 203 / 2 = 101.5 98 | """ 99 | qty = decimal.Decimal("2") 100 | result = self.orderbook.calculate_price_impact_qty(qty, "BUY") 101 | 102 | self.assertIsNotNone(result, "Result should not be None for partial fill.") 103 | self.assertEqual(result.amount, qty, "Filled amount should match requested qty.") 104 | 105 | expected_average_price = decimal.Decimal("101.5") 106 | self.assertEqual(result.price, expected_average_price) 107 | 108 | def test_calculate_qty_impact_partial_sell(self): 109 | """ 110 | Sell a partial quantity that spans multiple bid levels. 111 | For example: selling 2 units: 112 | - 1 unit at price 100 113 | - 1 unit at price 99 114 | total received = 100 + 99 = 199 115 | average price = 199 / 2 = 99.5 116 | """ 117 | qty = decimal.Decimal("2") 118 | result = self.orderbook.calculate_price_impact_qty(qty, "SELL") 119 | 120 | self.assertIsNotNone(result, "Result should not be None for partial fill.") 121 | self.assertEqual(result.amount, qty, "Filled amount should match requested qty.") 122 | 123 | expected_average_price = decimal.Decimal("99.5") 124 | self.assertEqual(result.price, expected_average_price) 125 | 126 | def test_calculate_qty_impact_total_match_buy(self): 127 | """ 128 | Buy all available ask liquidity: total ask qty = 1 + 2 + 1 = 4 129 | Fill: 130 | - 1 @101 => cost 101 131 | - 2 @102 => cost 204 132 | - 1 @103 => cost 103 133 | total = 101 + 204 + 103 = 408 134 | average = 408 / 4 = 102 135 | """ 136 | qty = decimal.Decimal("4") 137 | result = self.orderbook.calculate_price_impact_qty(qty, "BUY") 138 | 139 | self.assertIsNotNone(result, "Result should not be None when liquidity matches exactly.") 140 | self.assertEqual(result.amount, qty, "Filled amount should match requested qty.") 141 | 142 | expected_average_price = decimal.Decimal("102") 143 | self.assertEqual(result.price, expected_average_price) 144 | 145 | def test_calculate_qty_impact_total_match_sell(self): 146 | """ 147 | Sell all available bid liquidity: total bid qty = 1 + 2 + 1 = 4 148 | Fill: 149 | - 1 @100 => 100 150 | - 2 @99 => 198 151 | - 1 @98 => 98 152 | total = 100 + 198 + 98 = 396 153 | average = 396 / 4 = 99 154 | """ 155 | qty = decimal.Decimal("4") 156 | result = self.orderbook.calculate_price_impact_qty(qty, "SELL") 157 | 158 | self.assertIsNotNone(result, "Result should not be None when liquidity matches exactly.") 159 | self.assertEqual(result.amount, qty) 160 | 161 | expected_average_price = decimal.Decimal("99") 162 | self.assertEqual(result.price, expected_average_price) 163 | 164 | def test_calculate_qty_impact_insufficient_liquidity_buy(self): 165 | """ 166 | Request a qty larger than available on the ask side (4 total). 167 | Asking for 5 => insufficient => should return None. 168 | """ 169 | qty = decimal.Decimal("5") 170 | result = self.orderbook.calculate_price_impact_qty(qty, "BUY") 171 | self.assertIsNone(result, "Result should be None when there's insufficient ask liquidity.") 172 | 173 | def test_calculate_qty_impact_insufficient_liquidity_sell(self): 174 | """ 175 | Request a qty larger than available on the bid side (4 total). 176 | Asking for 5 => insufficient => should return None. 177 | """ 178 | qty = decimal.Decimal("5") 179 | result = self.orderbook.calculate_price_impact_qty(qty, "SELL") 180 | self.assertIsNone(result, "Result should be None when there's insufficient bid liquidity.") 181 | 182 | def test_calculate_qty_impact_invalid_qty(self): 183 | """ 184 | Negative or zero qty should return None. 185 | """ 186 | qty = decimal.Decimal("-1") 187 | result = self.orderbook.calculate_price_impact_qty(qty, "BUY") 188 | self.assertIsNone(result, "Result should be None for invalid qty (negative).") 189 | 190 | qty_zero = decimal.Decimal("0") 191 | result_zero = self.orderbook.calculate_price_impact_qty(qty_zero, "SELL") 192 | self.assertIsNone(result_zero, "Result should be None for invalid qty (zero).") 193 | 194 | def test_calculate_qty_impact_invalid_side(self): 195 | """ 196 | Any side not 'BUY' or 'SELL' should yield None. 197 | """ 198 | qty = decimal.Decimal("1") 199 | result = self.orderbook.calculate_price_impact_qty(qty, "INVALID_SIDE") 200 | self.assertIsNone(result, "Result should be None for invalid side.") 201 | -------------------------------------------------------------------------------- /x10/utils/http.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | from typing import Any, Dict, Generic, List, Optional, Sequence, Type, TypeVar, Union 4 | 5 | import aiohttp 6 | from aiohttp import ClientResponse, ClientTimeout 7 | from pydantic import GetCoreSchemaHandler 8 | from pydantic_core import CoreSchema, core_schema 9 | from strenum import StrEnum 10 | 11 | from x10.config import DEFAULT_REQUEST_TIMEOUT_SECONDS, USER_AGENT 12 | from x10.errors import X10Error 13 | from x10.utils.log import get_logger 14 | from x10.utils.model import X10BaseModel 15 | 16 | LOGGER = get_logger(__name__) 17 | CLIENT_TIMEOUT = ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS) 18 | 19 | ApiResponseType = TypeVar("ApiResponseType", bound=Union[int, X10BaseModel, Sequence[X10BaseModel]]) 20 | 21 | 22 | class RateLimitException(X10Error): 23 | pass 24 | 25 | 26 | class NotAuthorizedException(X10Error): 27 | pass 28 | 29 | 30 | class RequestHeader(StrEnum): 31 | ACCEPT = "Accept" 32 | API_KEY = "X-Api-Key" 33 | CONTENT_TYPE = "Content-Type" 34 | USER_AGENT = "User-Agent" 35 | 36 | 37 | class ResponseStatus(StrEnum): 38 | OK = "OK" 39 | ERROR = "ERROR" 40 | 41 | 42 | class ResponseError(X10BaseModel): 43 | code: int 44 | message: str 45 | debug_info: Optional[str] = None 46 | 47 | 48 | class Pagination(X10BaseModel): 49 | cursor: Optional[int] = None 50 | count: int 51 | 52 | 53 | class WrappedApiResponse(X10BaseModel, Generic[ApiResponseType]): 54 | status: ResponseStatus 55 | data: Optional[ApiResponseType] = None 56 | error: Optional[ResponseError] = None 57 | pagination: Optional[Pagination] = None 58 | 59 | 60 | class StreamDataType(StrEnum): 61 | # Technical status 62 | UNKNOWN = "UNKNOWN" 63 | 64 | BALANCE = "BALANCE" 65 | DELTA = "DELTA" 66 | DEPOSIT = "DEPOSIT" 67 | ORDER = "ORDER" 68 | POSITION = "POSITION" 69 | SNAPSHOT = "SNAPSHOT" 70 | TRADE = "TRADE" 71 | TRANSFER = "TRANSFER" 72 | WITHDRAWAL = "WITHDRAWAL" 73 | 74 | @classmethod 75 | def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: GetCoreSchemaHandler) -> CoreSchema: 76 | return core_schema.no_info_plain_validator_function(lambda v: v if v in cls._value2member_map_ else cls.UNKNOWN) 77 | 78 | 79 | class WrappedStreamResponse(X10BaseModel, Generic[ApiResponseType]): 80 | type: Optional[StreamDataType] = None 81 | data: Optional[ApiResponseType] = None 82 | error: Optional[str] = None 83 | ts: int 84 | seq: int 85 | 86 | 87 | def parse_response_to_model( 88 | response_text: str, model_class: Type[ApiResponseType] 89 | ) -> WrappedApiResponse[ApiResponseType]: 90 | # Read this to get more context re the type ignore: 91 | # https://github.com/python/mypy/issues/13619 92 | return WrappedApiResponse[model_class].model_validate_json(response_text) # type: ignore[valid-type] 93 | 94 | 95 | def get_url(template: str, *, query: Optional[Dict[str, str | List[str]]] = None, **path_params): 96 | def replace_path_param(match: re.Match[str]): 97 | matched_value = match.group(1) 98 | is_param_optional = matched_value.endswith("?") 99 | param_key = matched_value[:-1] if is_param_optional else matched_value 100 | param_value = path_params.get(param_key, "") if is_param_optional else path_params[param_key] 101 | 102 | return str(param_value) if param_value is not None else "" 103 | 104 | def serialize_query_param(param_key: str, param_value: Union[str, List[str]]): 105 | if isinstance(param_value, list): 106 | return itertools.chain.from_iterable( 107 | [serialize_query_param(param_key, item) for item in param_value if item is not None] 108 | ) 109 | elif isinstance(param_value, StrEnum): 110 | return [f"{param_key}={param_value}"] 111 | elif param_value is not None: 112 | return [f"{param_key}={param_value}"] 113 | else: 114 | return [] 115 | 116 | template = re.sub(r"<(\??[^<>]+)>", replace_path_param, template) 117 | template = template.rstrip("/") 118 | 119 | if query: 120 | query_parts = [] 121 | 122 | for key, value in query.items(): 123 | query_parts.extend(serialize_query_param(key, value)) 124 | 125 | template += "?" + "&".join(query_parts) 126 | 127 | return template 128 | 129 | 130 | async def send_get_request( 131 | session: aiohttp.ClientSession, 132 | url: str, 133 | model_class: Type[ApiResponseType], 134 | *, 135 | api_key: Optional[str] = None, 136 | request_headers: Optional[Dict[str, str]] = None, 137 | response_code_to_exception: Optional[Dict[int, Type[Exception]]] = None, 138 | ) -> WrappedApiResponse[ApiResponseType]: 139 | headers = __get_headers(api_key=api_key, request_headers=request_headers) 140 | 141 | LOGGER.debug("Sending GET %s", url) 142 | 143 | async with session.get(url, headers=headers) as response: 144 | response_text = await response.text() 145 | handle_known_errors(url, response_code_to_exception, response, response_text) 146 | return parse_response_to_model(response_text, model_class) 147 | 148 | 149 | async def send_post_request( 150 | session: aiohttp.ClientSession, 151 | url: str, 152 | model_class: Type[ApiResponseType], 153 | *, 154 | json: Any = None, 155 | api_key: Optional[str] = None, 156 | request_headers: Optional[Dict[str, str]] = None, 157 | response_code_to_exception: Optional[Dict[int, Type[Exception]]] = None, 158 | ) -> WrappedApiResponse[ApiResponseType]: 159 | headers = __get_headers(api_key=api_key, request_headers=request_headers) 160 | 161 | LOGGER.debug("Sending POST %s, headers=%s", url, headers) 162 | 163 | async with session.post(url, json=json, headers=headers) as response: 164 | response_text = await response.text() 165 | handle_known_errors(url, response_code_to_exception, response, response_text) 166 | response_model = parse_response_to_model(response_text, model_class) 167 | 168 | if (response_model.status != ResponseStatus.OK) or (response_model.error is not None): 169 | LOGGER.error("Error response from POST %s: %s", url, response_model.error) 170 | raise ValueError(f"Error response from POST {url}: {response_model.error}") 171 | 172 | return response_model 173 | 174 | 175 | async def send_patch_request( 176 | session: aiohttp.ClientSession, 177 | url: str, 178 | model_class: Type[ApiResponseType], 179 | *, 180 | json: Any = None, 181 | api_key: Optional[str] = None, 182 | request_headers: Optional[Dict[str, str]] = None, 183 | response_code_to_exception: Optional[Dict[int, Type[Exception]]] = None, 184 | ) -> WrappedApiResponse[ApiResponseType]: 185 | headers = __get_headers(api_key=api_key, request_headers=request_headers) 186 | 187 | LOGGER.debug("Sending PATCH %s, headers=%s, data=%s", url, headers, json) 188 | 189 | async with session.patch(url, json=json, headers=headers) as response: 190 | response_text = await response.text() 191 | 192 | if response_text == "": 193 | LOGGER.error("Empty HTTP %s response from PATCH %s", response.status, url) 194 | response_text = '{"status": "OK"}' 195 | 196 | handle_known_errors(url, response_code_to_exception, response, response_text) 197 | return parse_response_to_model(response_text, model_class) 198 | 199 | 200 | async def send_delete_request( 201 | session: aiohttp.ClientSession, 202 | url: str, 203 | model_class: Type[ApiResponseType], 204 | *, 205 | api_key: Optional[str] = None, 206 | request_headers: Optional[Dict[str, str]] = None, 207 | response_code_to_exception: Optional[Dict[int, Type[Exception]]] = None, 208 | ): 209 | headers = __get_headers(api_key=api_key, request_headers=request_headers) 210 | 211 | LOGGER.debug("Sending DELETE %s, headers=%s", url, headers) 212 | 213 | async with session.delete(url, headers=headers) as response: 214 | response_text = await response.text() 215 | handle_known_errors(url, response_code_to_exception, response, response_text) 216 | return parse_response_to_model(response_text, model_class) 217 | 218 | 219 | def handle_known_errors( 220 | url, response_code_handler: Optional[Dict[int, Type[Exception]]], response: ClientResponse, response_text: str 221 | ): 222 | if response.status == 401: 223 | LOGGER.error("Unauthorized response from POST %s: %s", url, response_text) 224 | raise NotAuthorizedException(f"Unauthorized response from POST {url}: {response_text}") 225 | 226 | if response.status == 429: 227 | LOGGER.error("Rate limited response from POST %s: %s", url, response_text) 228 | raise RateLimitException(f"Rate limited response from POST {url}: {response}") 229 | 230 | if response_code_handler and response.status in response_code_handler: 231 | raise response_code_handler[response.status](response_text) 232 | 233 | if response.status > 299: 234 | LOGGER.error("Error response from %s: %s", url, response_text) 235 | raise ValueError(f"Error response from {url}: code {response.status} - {response_text}") 236 | 237 | 238 | def __get_headers(*, api_key: Optional[str] = None, request_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: 239 | headers: dict[str, str] = { 240 | RequestHeader.ACCEPT: "application/json", 241 | RequestHeader.CONTENT_TYPE: "application/json", 242 | RequestHeader.USER_AGENT: USER_AGENT, 243 | } 244 | 245 | if api_key: 246 | headers[RequestHeader.API_KEY] = api_key 247 | 248 | if request_headers: 249 | headers.update(request_headers) 250 | 251 | return headers 252 | -------------------------------------------------------------------------------- /x10/perpetual/orderbook.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import decimal 4 | from collections.abc import Awaitable 5 | from typing import Callable, Iterable, Tuple 6 | 7 | from sortedcontainers import SortedDict 8 | 9 | from x10.perpetual.configuration import EndpointConfig 10 | from x10.perpetual.orderbooks import OrderbookUpdateModel 11 | from x10.perpetual.stream_client.stream_client import PerpetualStreamClient 12 | from x10.utils.http import StreamDataType 13 | 14 | 15 | @dataclasses.dataclass 16 | class OrderBookEntry: 17 | price: decimal.Decimal 18 | amount: decimal.Decimal 19 | 20 | def __repr__(self) -> str: 21 | return f"OrderBookEntry(price={self.price}, amount={self.amount})" 22 | 23 | 24 | @dataclasses.dataclass 25 | class ImpactDetails: 26 | price: decimal.Decimal 27 | amount: decimal.Decimal 28 | 29 | 30 | class OrderBook: 31 | @staticmethod 32 | async def create( 33 | endpoint_config: EndpointConfig, 34 | market_name: str, 35 | best_ask_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, 36 | best_bid_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, 37 | start=False, 38 | depth: int | None = None, 39 | ) -> "OrderBook": 40 | ob = OrderBook(endpoint_config, market_name, best_ask_change_callback, best_bid_change_callback, depth) 41 | if start: 42 | await ob.start_orderbook() 43 | return ob 44 | 45 | def __init__( 46 | self, 47 | endpoint_config: EndpointConfig, 48 | market_name: str, 49 | best_ask_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, 50 | best_bid_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, 51 | depth: int | None = None, 52 | ) -> None: 53 | self.__stream_client = PerpetualStreamClient(api_url=endpoint_config.stream_url) 54 | self.__market_name = market_name 55 | self.__task: asyncio.Task | None = None 56 | self._bid_prices: "SortedDict[decimal.Decimal, OrderBookEntry]" = SortedDict() # type: ignore 57 | self._ask_prices: "SortedDict[decimal.Decimal, OrderBookEntry]" = SortedDict() # type: ignore 58 | self.best_ask_change_callback = best_ask_change_callback 59 | self.best_bid_change_callback = best_bid_change_callback 60 | self.depth = depth 61 | 62 | async def update_orderbook(self, data: OrderbookUpdateModel): 63 | best_bid_before_update = self.best_bid() 64 | for bid in data.bid: 65 | if bid.price in self._bid_prices: 66 | existing_bid_entry: OrderBookEntry = self._bid_prices[bid.price] 67 | existing_bid_entry.amount = existing_bid_entry.amount + bid.qty 68 | if existing_bid_entry.amount == 0: 69 | del self._bid_prices[bid.price] 70 | else: 71 | self._bid_prices[bid.price] = OrderBookEntry( 72 | price=bid.price, 73 | amount=bid.qty, 74 | ) 75 | now_best_bid = self.best_bid() 76 | if best_bid_before_update != now_best_bid: 77 | if self.best_bid_change_callback: 78 | await self.best_bid_change_callback(now_best_bid) 79 | 80 | best_ask_before_update = self.best_ask() 81 | for ask in data.ask: 82 | if ask.price in self._ask_prices: 83 | existing_ask_entry: OrderBookEntry = self._ask_prices[ask.price] 84 | existing_ask_entry.amount = existing_ask_entry.amount + ask.qty 85 | if existing_ask_entry.amount == 0: 86 | del self._ask_prices[ask.price] 87 | else: 88 | self._ask_prices[ask.price] = OrderBookEntry( 89 | price=ask.price, 90 | amount=ask.qty, 91 | ) 92 | now_best_ask = self.best_ask() 93 | if best_ask_before_update != now_best_ask: 94 | if self.best_ask_change_callback: 95 | await self.best_ask_change_callback(now_best_ask) 96 | 97 | async def init_orderbook(self, data: OrderbookUpdateModel): 98 | self._bid_prices.clear() 99 | self._ask_prices.clear() 100 | 101 | best_bid_before_update = self.best_bid() 102 | for bid in data.bid: 103 | self._bid_prices[bid.price] = OrderBookEntry( 104 | price=bid.price, 105 | amount=bid.qty, 106 | ) 107 | now_best_bid = self.best_bid() 108 | if best_bid_before_update != now_best_bid: 109 | if self.best_bid_change_callback: 110 | await self.best_bid_change_callback(now_best_bid) 111 | 112 | best_ask_before_update = self.best_ask() 113 | for ask in data.ask: 114 | self._ask_prices[ask.price] = OrderBookEntry( 115 | price=ask.price, 116 | amount=ask.qty, 117 | ) 118 | now_best_ask = self.best_ask() 119 | if best_ask_before_update != now_best_ask: 120 | if self.best_ask_change_callback: 121 | await self.best_ask_change_callback(now_best_ask) 122 | 123 | async def start_orderbook(self) -> asyncio.Task: 124 | loop = asyncio.get_running_loop() 125 | 126 | async def inner(): 127 | while True: 128 | async with self.__stream_client.subscribe_to_orderbooks(self.__market_name, depth=self.depth) as stream: 129 | async for event in stream: 130 | if event.type == StreamDataType.SNAPSHOT: 131 | if not event.data: 132 | continue 133 | await self.init_orderbook(event.data) 134 | elif event.type == StreamDataType.DELTA: 135 | if not event.data: 136 | continue 137 | await self.update_orderbook(event.data) 138 | await asyncio.sleep(1) 139 | 140 | self.__task = loop.create_task(inner()) 141 | return self.__task 142 | 143 | def stop_orderbook(self): 144 | if self.__task: 145 | self.__task.cancel() 146 | self.__task = None 147 | 148 | def best_bid(self) -> OrderBookEntry | None: 149 | try: 150 | entry = self._bid_prices.peekitem(-1) 151 | return entry[1] 152 | except IndexError: 153 | return None 154 | 155 | def best_ask(self) -> OrderBookEntry | None: 156 | try: 157 | entry = self._ask_prices.peekitem(0) 158 | return entry[1] 159 | except IndexError: 160 | return None 161 | 162 | def __price_impact_notional( 163 | self, notional: decimal.Decimal, levels: Iterable[Tuple[decimal.Decimal, OrderBookEntry]] 164 | ): 165 | remaining_to_spend = notional 166 | total_amount = decimal.Decimal(0) 167 | weighted_sum = decimal.Decimal(0) 168 | for price, entry in levels: 169 | available_at_price = entry.amount 170 | amount_to_purchase = min(remaining_to_spend / price, available_at_price) 171 | if remaining_to_spend <= 0: 172 | break 173 | if available_at_price <= 0: 174 | continue 175 | take = amount_to_purchase 176 | spent = take * price 177 | weighted_sum += take * price 178 | total_amount += take 179 | remaining_to_spend -= spent 180 | 181 | if remaining_to_spend > 0: 182 | return None 183 | average_price = weighted_sum / total_amount 184 | return ImpactDetails(price=average_price, amount=total_amount) 185 | 186 | def __price_impact_qty(self, qty: decimal.Decimal, levels: Iterable[Tuple[decimal.Decimal, OrderBookEntry]]): 187 | remaining_qty = qty 188 | total_amount = decimal.Decimal(0) 189 | total_spent = decimal.Decimal(0) 190 | for price, entry in levels: 191 | available_at_price = entry.amount 192 | take = min(remaining_qty, available_at_price) 193 | if remaining_qty <= 0: 194 | break 195 | if available_at_price <= 0: 196 | continue 197 | total_spent += take * price 198 | total_amount += take 199 | remaining_qty -= take 200 | 201 | if remaining_qty > 0: 202 | return None 203 | average_price = total_spent / total_amount 204 | return ImpactDetails(price=average_price, amount=total_amount) 205 | 206 | def calculate_price_impact_notional(self, notional: decimal.Decimal, side: str) -> ImpactDetails | None: 207 | if notional <= 0: 208 | return None 209 | if side == "SELL": 210 | if not self._bid_prices: 211 | return None 212 | return self.__price_impact_notional(notional, reversed(self._bid_prices.items())) 213 | elif side == "BUY": 214 | if not self._ask_prices: 215 | return None 216 | return self.__price_impact_notional(notional, self._ask_prices.items()) 217 | return None 218 | 219 | def calculate_price_impact_qty(self, qty: decimal.Decimal, side: str) -> ImpactDetails | None: 220 | if qty <= 0: 221 | return None 222 | if side == "SELL": 223 | if not self._bid_prices: 224 | return None 225 | return self.__price_impact_qty(qty, reversed(self._bid_prices.items())) 226 | elif side == "BUY": 227 | if not self._ask_prices: 228 | return None 229 | return self.__price_impact_qty(qty, self._ask_prices.items()) 230 | return None 231 | 232 | async def close(self): 233 | self.stop_orderbook() 234 | -------------------------------------------------------------------------------- /x10/perpetual/simple_client/simple_trading_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import time 4 | from decimal import Decimal 5 | from typing import Awaitable, Dict, Union, cast 6 | 7 | from x10.perpetual.accounts import AccountStreamDataModel, StarkPerpetualAccount 8 | from x10.perpetual.configuration import EndpointConfig 9 | from x10.perpetual.markets import MarketModel 10 | from x10.perpetual.order_object import create_order_object 11 | from x10.perpetual.orders import ( 12 | NewOrderModel, 13 | OpenOrderModel, 14 | OrderSide, 15 | OrderStatus, 16 | TimeInForce, 17 | ) 18 | from x10.perpetual.stream_client.perpetual_stream_connection import ( 19 | PerpetualStreamConnection, 20 | ) 21 | from x10.perpetual.stream_client.stream_client import PerpetualStreamClient 22 | from x10.perpetual.trading_client.markets_information_module import ( 23 | MarketsInformationModule, 24 | ) 25 | from x10.perpetual.trading_client.order_management_module import OrderManagementModule 26 | from x10.utils.http import WrappedStreamResponse 27 | 28 | 29 | def condition_to_awaitable(condition: asyncio.Condition) -> Awaitable: 30 | async def __inner(): 31 | async with condition: 32 | await condition.wait() 33 | 34 | return __inner() 35 | 36 | 37 | class TimedOpenOrderModel(OpenOrderModel): 38 | start_nanos: int 39 | end_nanos: int 40 | operation_ms: float 41 | 42 | def __init__(self, start_nanos: int, end_nanos: int, open_order: OpenOrderModel): 43 | super().__init__( 44 | **dict( 45 | open_order.model_dump(), 46 | **{ 47 | "start_nanos": start_nanos, 48 | "end_nanos": end_nanos, 49 | "operation_ms": (end_nanos - start_nanos) / 1_000_000, 50 | }, 51 | ) 52 | ) 53 | 54 | 55 | @dataclasses.dataclass 56 | class TimedCancel: 57 | start_nanos: int 58 | end_nanos: int 59 | operation_ms: float 60 | 61 | 62 | @dataclasses.dataclass 63 | class OrderWaiter: 64 | condition: asyncio.Condition 65 | open_order: None | TimedOpenOrderModel 66 | start_nanos: int 67 | 68 | 69 | @dataclasses.dataclass 70 | class CancelWaiter: 71 | condition: asyncio.Condition 72 | start_nanos: int 73 | end_nanos: int | None 74 | 75 | 76 | class BlockingTradingClient: 77 | def __init__(self, endpoint_config: EndpointConfig, account: StarkPerpetualAccount): 78 | if not asyncio.get_event_loop().is_running(): 79 | raise RuntimeError( 80 | "BlockingTradingClient must be initialized from an async function, use BlockingTradingClient.create()" 81 | ) 82 | self.__endpoint_config = endpoint_config 83 | self.__account = account 84 | self.__market_module = MarketsInformationModule(endpoint_config, api_key=account.api_key) 85 | self.__orders_module = OrderManagementModule(endpoint_config, api_key=account.api_key) 86 | self.__markets: Union[None, Dict[str, MarketModel]] = None 87 | self.__stream_client: PerpetualStreamClient = PerpetualStreamClient(api_url=endpoint_config.stream_url) 88 | self.__account_stream: Union[ 89 | None, 90 | PerpetualStreamConnection[WrappedStreamResponse[AccountStreamDataModel]], 91 | ] = None 92 | self.__order_waiters: Dict[str, OrderWaiter] = {} 93 | self.__cancel_waiters: Dict[str, CancelWaiter] = {} 94 | self.__stream_task = asyncio.create_task(self.___order_stream()) 95 | 96 | @staticmethod 97 | async def create(endpoint_config: EndpointConfig, account: StarkPerpetualAccount) -> "BlockingTradingClient": 98 | client = BlockingTradingClient(endpoint_config, account) 99 | await client.__stream_client.subscribe_to_account_updates(account.api_key) 100 | return client 101 | 102 | async def __handle_cancel(self, order_external_id: str): 103 | if order_external_id not in self.__cancel_waiters: 104 | return 105 | cancel_waiter = self.__cancel_waiters.get(order_external_id) 106 | if not cancel_waiter: 107 | return 108 | if cancel_waiter.condition: 109 | async with cancel_waiter.condition: 110 | cancel_waiter.end_nanos = time.time_ns() 111 | cancel_waiter.condition.notify_all() 112 | 113 | async def __handle_update(self, order: OpenOrderModel): 114 | order_waiter: OrderWaiter | None = self.__order_waiters.get(order.external_id) 115 | if not order_waiter: 116 | return 117 | async with order_waiter.condition: 118 | order_waiter.open_order = TimedOpenOrderModel( 119 | start_nanos=order_waiter.start_nanos, 120 | end_nanos=time.time_ns(), 121 | open_order=order, 122 | ) 123 | order_waiter.condition.notify_all() 124 | 125 | async def __handle_order(self, order: OpenOrderModel): 126 | if order.status == OrderStatus.CANCELLED: 127 | await self.__handle_cancel(order.external_id) 128 | else: 129 | await self.__handle_update(order) 130 | 131 | async def ___order_stream(self): 132 | self.__account_stream = await self.__stream_client.subscribe_to_account_updates(self.__account.api_key) 133 | async for event in self.__account_stream: 134 | if not (event.data and event.data.orders): 135 | continue 136 | for order in event.data.orders: 137 | await self.__handle_order(order) 138 | print("Order stream closed, reconnecting...") 139 | await self.___order_stream() 140 | 141 | async def cancel_order(self, order_external_id: str) -> TimedCancel: 142 | awaitable: Awaitable 143 | if order_external_id in self.__cancel_waiters: 144 | awaitable = condition_to_awaitable(self.__cancel_waiters[order_external_id].condition) 145 | else: 146 | self.__cancel_waiters[order_external_id] = CancelWaiter( 147 | asyncio.Condition(), start_nanos=time.time_ns(), end_nanos=None 148 | ) 149 | cancel_task = asyncio.create_task(self.__orders_module.cancel_order_by_external_id(order_external_id)) 150 | awaitable = asyncio.gather( 151 | cancel_task, 152 | asyncio.wait_for(condition_to_awaitable(self.__cancel_waiters[order_external_id].condition), 5), 153 | return_exceptions=False, 154 | ) 155 | 156 | cancel_waiter = self.__cancel_waiters[order_external_id] 157 | end_nanos = None 158 | if cancel_waiter.end_nanos: 159 | end_nanos = cancel_waiter.end_nanos 160 | else: 161 | await awaitable 162 | end_nanos = self.__cancel_waiters[order_external_id].end_nanos 163 | del self.__cancel_waiters[order_external_id] 164 | end_nanos = cast(int, end_nanos) 165 | return TimedCancel( 166 | start_nanos=cancel_waiter.start_nanos, 167 | end_nanos=end_nanos, 168 | operation_ms=(end_nanos - cancel_waiter.start_nanos) / 1_000_000, 169 | ) 170 | 171 | async def get_markets(self) -> Dict[str, MarketModel]: 172 | if not self.__markets: 173 | markets = await self.__market_module.get_markets() 174 | market_data = markets.data 175 | if not market_data: 176 | raise ValueError("Core market data is empty, check your connection or API key.") 177 | self.__markets = {m.name: m for m in market_data} 178 | return self.__markets 179 | 180 | async def mass_cancel( 181 | self, 182 | order_ids: list[int] | None = None, 183 | external_order_ids: list[str] | None = None, 184 | markets: list[str] | None = None, 185 | cancel_all: bool = False, 186 | ) -> None: 187 | await self.__orders_module.mass_cancel( 188 | order_ids=order_ids, 189 | external_order_ids=external_order_ids, 190 | markets=markets, 191 | cancel_all=cancel_all, 192 | ) 193 | 194 | async def create_and_place_order( 195 | self, 196 | market_name: str, 197 | amount_of_synthetic: Decimal, 198 | price: Decimal, 199 | side: OrderSide, 200 | post_only: bool = False, 201 | previous_order_external_id: str | None = None, 202 | external_id: str | None = None, 203 | builder_fee: Decimal | None = None, 204 | builder_id: int | None = None, 205 | time_in_force: TimeInForce = TimeInForce.GTT, 206 | ) -> TimedOpenOrderModel: 207 | market = (await self.get_markets()).get(market_name) 208 | if not market: 209 | raise ValueError(f"Market '{market_name}' not found.") 210 | 211 | order: NewOrderModel = create_order_object( 212 | account=self.__account, 213 | market=market, 214 | amount_of_synthetic=amount_of_synthetic, 215 | price=price, 216 | side=side, 217 | post_only=post_only, 218 | previous_order_external_id=previous_order_external_id, 219 | starknet_domain=self.__endpoint_config.starknet_domain, 220 | order_external_id=external_id, 221 | builder_fee=builder_fee, 222 | builder_id=builder_id, 223 | time_in_force=time_in_force, 224 | ) 225 | 226 | if order.id in self.__order_waiters: 227 | raise ValueError(f"order with {order.id} hash already placed") 228 | 229 | self.__order_waiters[order.id] = OrderWaiter(asyncio.Condition(), None, start_nanos=time.time_ns()) 230 | placed_order_task = asyncio.create_task(self.__orders_module.place_order(order)) 231 | order_waiter = self.__order_waiters[order.id] 232 | if order_waiter.open_order: 233 | return order_waiter.open_order 234 | async with order_waiter.condition: 235 | await asyncio.gather( 236 | placed_order_task, 237 | asyncio.wait_for(order_waiter.condition.wait(), 5), 238 | return_exceptions=False, 239 | ) 240 | open_model = self.__order_waiters[order.id].open_order 241 | del self.__order_waiters[order.id] 242 | if not open_model: 243 | raise ValueError("No Fill or Placement received for order") 244 | return open_model 245 | 246 | async def close(self): 247 | if self.__stream_task: 248 | self.__stream_task.cancel() 249 | if self.__account_stream: 250 | await self.__account_stream.close() 251 | --------------------------------------------------------------------------------