Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions examples/placed_market_order_example_simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import asyncio
import logging
import logging.config
import logging.handlers
import os
import random
from asyncio import run
from decimal import ROUND_DOWN, Decimal

from dotenv import load_dotenv

from x10.perpetual.accounts import StarkPerpetualAccount
from x10.perpetual.configuration import TESTNET_CONFIG
from x10.perpetual.orderbook import OrderBook
from x10.perpetual.orders import OrderSide, OrderType, TimeInForce
from x10.perpetual.trading_client import PerpetualTradingClient

load_dotenv()
MARKET_NAME = "BTC-USD"
ORDER_QTY = Decimal("0.0001")

API_KEY = os.getenv("X10_API_KEY")
PUBLIC_KEY = os.getenv("X10_PUBLIC_KEY")
PRIVATE_KEY = os.getenv("X10_PRIVATE_KEY")
VAULT_ID = int(os.environ["X10_VAULT_ID"])


def round_to_step(value: Decimal, step: Decimal) -> Decimal:
"""
Round a Decimal down to the nearest multiple of `step`.
This matches typical "tick size" / "min price change" and size step constraints.
"""
if step <= 0:
return value
return (value / step).to_integral_value(rounding=ROUND_DOWN) * step


def marketable_price(side: OrderSide, best_bid: Decimal, best_ask: Decimal) -> Decimal:
"""
Create a marketable price using a fixed offset from best bid/ask.
Extended requires a price field even for MARKET+IOC.
"""
if side == OrderSide.BUY:
return best_ask * (Decimal("1") + Decimal("0.002"))
return best_bid * (Decimal("1") - Decimal("0.002"))


async def clean_it(trading_client: PerpetualTradingClient):
logger = logging.getLogger("placed_order_example")
positions = await trading_client.account.get_positions()
logger.info("Positions: %s", positions.to_pretty_json())
balance = await trading_client.account.get_balance()
logger.info("Balance: %s", balance.to_pretty_json())
open_orders = await trading_client.account.get_open_orders()
await trading_client.orders.mass_cancel(order_ids=[order.id for order in open_orders.data])


async def setup_and_run():
assert API_KEY is not None
assert PUBLIC_KEY is not None
assert PRIVATE_KEY is not None
assert VAULT_ID is not None

stark_account = StarkPerpetualAccount(
vault=VAULT_ID,
private_key=PRIVATE_KEY,
public_key=PUBLIC_KEY,
api_key=API_KEY,
)
trading_client = PerpetualTradingClient(
endpoint_config=TESTNET_CONFIG,
stark_account=stark_account,
)
positions = await trading_client.account.get_positions()
for position in positions.data:
print(
f"market: {position.market} \
side: {position.side} \
size: {position.size} \
mark_price: ${position.mark_price} \
leverage: {position.leverage}"
)
print(f"consumed im: ${round((position.size * position.mark_price) / position.leverage, 2)}")

await clean_it(trading_client)

markets = await trading_client.markets_info.get_markets_dict()
market = markets.get(MARKET_NAME)
tick_size = market.trading_config.min_price_change
size_step = market.trading_config.min_order_size_change

orderbook = await OrderBook.create(endpoint_config=TESTNET_CONFIG, market_name=MARKET_NAME)

await orderbook.start_orderbook()

# Place a single MARKET+IOC order (venue requirement) using a marketable price.
# Note: this can open a real position on the venue. Use tiny size and close manually if needed.
while True:
bid = orderbook.best_bid()
ask = orderbook.best_ask()
if bid and ask:
best_bid = bid.price
best_ask = ask.price
break
await asyncio.sleep(0.5)

side = OrderSide.BUY
raw_price = marketable_price(side=side, best_bid=best_bid, best_ask=best_ask)
price = round_to_step(raw_price, tick_size)
qty = round_to_step(ORDER_QTY, size_step)
if qty <= 0:
raise ValueError(f"Order qty rounds to 0 (ORDER_QTY={ORDER_QTY}, step={size_step})")

external_id = str(random.randint(1, 10**40))
placed = await trading_client.place_order(
market_name=MARKET_NAME,
amount_of_synthetic=qty,
price=price,
side=side,
order_type=OrderType.MARKET,
time_in_force=TimeInForce.IOC,
post_only=False,
external_id=external_id,
)
placed_id = placed.data.id if placed.data is not None else None
print(
f"placed: market={MARKET_NAME} side={side.value} qty={qty} price={price} "
f"tif=IOC type=MARKET external_id={external_id} => id={placed_id}"
)

positions = await trading_client.account.get_positions()
print("positions:", positions.to_pretty_json())


if __name__ == "__main__":
run(main=setup_and_run())
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pyyaml = ">=6.0.1"
sortedcontainers = ">=2.4.0"
strenum = "^0.4.15"
tenacity = "^9.1.2"
websockets = ">=12.0,<14.0"
websockets = ">=12.0,<16.0"

[tool.poetry.group.dev.dependencies]
black = "==23.12.0"
Expand Down
8 changes: 7 additions & 1 deletion x10/perpetual/order_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def create_order_object(
price: Decimal,
side: OrderSide,
starknet_domain: StarknetDomain,
order_type: OrderType = OrderType.LIMIT,
post_only: bool = False,
previous_order_external_id: Optional[str] = None,
expire_time: Optional[datetime] = None,
Expand Down Expand Up @@ -76,6 +77,7 @@ def create_order_object(
public_key=account.public_key,
exact_only=False,
expire_time=expire_time,
order_type=order_type,
post_only=post_only,
previous_order_external_id=previous_order_external_id,
order_external_id=order_external_id,
Expand Down Expand Up @@ -120,6 +122,7 @@ def __create_order_object(
starknet_domain: StarknetDomain,
exact_only: bool = False,
expire_time: Optional[datetime] = None,
order_type: OrderType = OrderType.LIMIT,
post_only: bool = False,
previous_order_external_id: Optional[str] = None,
order_external_id: Optional[str] = None,
Expand All @@ -139,6 +142,9 @@ def __create_order_object(
if time_in_force not in TimeInForce or time_in_force == TimeInForce.FOK:
raise ValueError(f"Unexpected time in force value: {time_in_force}")

if order_type not in OrderType:
raise ValueError(f"Unexpected order type value: {order_type}")

if expire_time is None:
raise ValueError("`expire_time` must be provided")

Expand Down Expand Up @@ -203,7 +209,7 @@ def __create_order_object(
order = NewOrderModel(
id=order_id,
market=market.name,
type=OrderType.LIMIT,
type=order_type,
side=side,
qty=settlement_data.synthetic_amount_human.value,
price=price,
Expand Down
3 changes: 3 additions & 0 deletions x10/perpetual/simple_client/simple_trading_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
OpenOrderModel,
OrderSide,
OrderStatus,
OrderType,
TimeInForce,
)
from x10.perpetual.stream_client.perpetual_stream_connection import (
Expand Down Expand Up @@ -197,6 +198,7 @@ async def create_and_place_order(
amount_of_synthetic: Decimal,
price: Decimal,
side: OrderSide,
order_type: OrderType = OrderType.LIMIT,
post_only: bool = False,
previous_order_external_id: str | None = None,
external_id: str | None = None,
Expand All @@ -214,6 +216,7 @@ async def create_and_place_order(
amount_of_synthetic=amount_of_synthetic,
price=price,
side=side,
order_type=order_type,
post_only=post_only,
previous_order_external_id=previous_order_external_id,
starknet_domain=self.__endpoint_config.starknet_domain,
Expand Down
47 changes: 38 additions & 9 deletions x10/perpetual/stream_client/perpetual_stream_connection.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from types import TracebackType
from typing import AsyncIterator, Generic, Optional, Type, TypeVar
from typing import AsyncIterator, Generic, Optional, Type, TypeVar, Union

import websockets
from websockets import WebSocketClientProtocol
Expand All @@ -13,6 +13,31 @@

StreamMsgResponseType = TypeVar("StreamMsgResponseType", bound=X10BaseModel)

# Check websockets version for API compatibility
_WS_VERSION = tuple(int(x) for x in websockets.__version__.split(".")[:2])
_WS_14_PLUS = _WS_VERSION >= (14, 0)

# Import the correct connection type based on version
if _WS_14_PLUS:
from websockets.asyncio.client import ClientConnection as WebSocketConnection
else:
WebSocketConnection = WebSocketClientProtocol


def _is_ws_closed(ws: Union[WebSocketClientProtocol, "WebSocketConnection"]) -> bool:
"""Check if websocket connection is closed (compatible with both ws 13 and 14+)."""
if _WS_14_PLUS:
# websockets 14+ uses state enum
try:
from websockets.protocol import State
return ws.state == State.CLOSED
except (ImportError, AttributeError):
# Fallback: try to check if close() was called
return getattr(ws, '_closed', False)
else:
# websockets 13 and earlier use .closed property
return ws.closed


class PerpetualStreamConnection(Generic[StreamMsgResponseType]):
__stream_url: str
Expand Down Expand Up @@ -45,7 +70,7 @@ async def recv(self) -> StreamMsgResponseType:

async def close(self):
assert self.__websocket is not None
if not self.__websocket.closed:
if not _is_ws_closed(self.__websocket):
await self.__websocket.close()
LOGGER.debug("Stream closed: %s", self.__stream_url)

Expand All @@ -56,16 +81,15 @@ def msgs_count(self):
@property
def closed(self):
assert self.__websocket is not None

return self.__websocket.closed
return _is_ws_closed(self.__websocket)

def __aiter__(self) -> AsyncIterator[StreamMsgResponseType]:
return self

async def __anext__(self) -> StreamMsgResponseType:
assert self.__websocket is not None

if self.__websocket.closed:
if _is_ws_closed(self.__websocket):
raise StopAsyncIteration
try:
return await self.__receive()
Expand Down Expand Up @@ -96,14 +120,19 @@ async def __aexit__(
await self.close()

async def __await_impl__(self):
extra_headers: dict[str, str] = {
headers: dict[str, str] = {
RequestHeader.USER_AGENT: USER_AGENT,
}

if self.__api_key is not None:
extra_headers[RequestHeader.API_KEY] = self.__api_key

self.__websocket = await websockets.connect(self.__stream_url, extra_headers=extra_headers)
headers[RequestHeader.API_KEY] = self.__api_key

# websockets 14+ renamed extra_headers to additional_headers
ws_version = tuple(int(x) for x in websockets.__version__.split(".")[:2])
if ws_version >= (14, 0):
self.__websocket = await websockets.connect(self.__stream_url, additional_headers=headers)
else:
self.__websocket = await websockets.connect(self.__stream_url, extra_headers=headers)

LOGGER.debug("Connected to stream: %s", self.__stream_url)

Expand Down
3 changes: 3 additions & 0 deletions x10/perpetual/trading_client/trading_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from x10.perpetual.orders import (
OrderSide,
OrderTpslType,
OrderType,
PlacedOrderModel,
SelfTradeProtectionLevel,
TimeInForce,
Expand Down Expand Up @@ -48,6 +49,7 @@ async def place_order(
amount_of_synthetic: Decimal,
price: Decimal,
side: OrderSide,
order_type: OrderType = OrderType.LIMIT,
post_only: bool = False,
previous_order_id=None,
expire_time: Optional[datetime] = None,
Expand Down Expand Up @@ -81,6 +83,7 @@ async def place_order(
amount_of_synthetic=amount_of_synthetic,
price=price,
side=side,
order_type=order_type,
post_only=post_only,
previous_order_external_id=previous_order_id,
expire_time=expire_time,
Expand Down