topstep-client-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
topstep/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """TopstepX Python Client — async SDK for the ProjectX Gateway API."""
2
+
3
+ from topstep.client import TopstepClient
4
+ from topstep.exceptions import (
5
+ APIError,
6
+ AuthenticationError,
7
+ HTTPError,
8
+ RateLimitError,
9
+ TopstepError,
10
+ )
11
+ from topstep.models import (
12
+ Account,
13
+ Bar,
14
+ BarUnit,
15
+ Bracket,
16
+ Contract,
17
+ Order,
18
+ OrderSide,
19
+ OrderStatus,
20
+ OrderType,
21
+ PlaceOrderRequest,
22
+ Position,
23
+ PositionType,
24
+ Trade,
25
+ )
26
+ from topstep.realtime import MarketHub, UserHub
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "TopstepClient",
32
+ # Exceptions
33
+ "TopstepError",
34
+ "AuthenticationError",
35
+ "APIError",
36
+ "HTTPError",
37
+ "RateLimitError",
38
+ # Models
39
+ "Account",
40
+ "Bar",
41
+ "BarUnit",
42
+ "Bracket",
43
+ "Contract",
44
+ "Order",
45
+ "OrderSide",
46
+ "OrderStatus",
47
+ "OrderType",
48
+ "PlaceOrderRequest",
49
+ "Position",
50
+ "PositionType",
51
+ "Trade",
52
+ # Realtime
53
+ "MarketHub",
54
+ "UserHub",
55
+ ]
topstep/auth.py ADDED
@@ -0,0 +1,50 @@
1
+ """Authentication and token management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from topstep.exceptions import AuthenticationError
6
+ from topstep.http import HTTPClient
7
+
8
+
9
+ async def login_key(http: HTTPClient, username: str, api_key: str) -> str:
10
+ """Authenticate with username + API key. Returns the session token."""
11
+ data = await http.post("/api/Auth/loginKey", {
12
+ "userName": username,
13
+ "apiKey": api_key,
14
+ })
15
+
16
+ token = data.get("token")
17
+ if not token:
18
+ raise AuthenticationError("Login succeeded but no token returned")
19
+
20
+ return token
21
+
22
+
23
+ async def login_app(
24
+ http: HTTPClient,
25
+ username: str,
26
+ password: str,
27
+ device_id: str,
28
+ app_id: str,
29
+ verify_key: str,
30
+ ) -> str:
31
+ """Authenticate as an authorized application. Returns the session token."""
32
+ data = await http.post("/api/Auth/loginApp", {
33
+ "userName": username,
34
+ "password": password,
35
+ "deviceId": device_id,
36
+ "appId": app_id,
37
+ "verifyKey": verify_key,
38
+ })
39
+
40
+ token = data.get("token")
41
+ if not token:
42
+ raise AuthenticationError("Login succeeded but no token returned")
43
+
44
+ return token
45
+
46
+
47
+ async def validate_token(http: HTTPClient) -> str | None:
48
+ """Validate the current token and return a refreshed one if available."""
49
+ data = await http.post("/api/Auth/validate")
50
+ return data.get("newToken") or data.get("token")
topstep/client.py ADDED
@@ -0,0 +1,118 @@
1
+ """TopstepX API client — the main entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from topstep import auth
6
+ from topstep.endpoints.accounts import AccountsEndpoint
7
+ from topstep.endpoints.contracts import ContractsEndpoint
8
+ from topstep.endpoints.history import HistoryEndpoint
9
+ from topstep.endpoints.orders import OrdersEndpoint
10
+ from topstep.endpoints.positions import PositionsEndpoint
11
+ from topstep.endpoints.trades import TradesEndpoint
12
+ from topstep.http import BASE_URL, HTTPClient
13
+ from topstep.realtime.market_hub import MarketHub
14
+ from topstep.realtime.user_hub import UserHub
15
+
16
+
17
+ class TopstepClient:
18
+ """Async client for the TopstepX / ProjectX Gateway API.
19
+
20
+ Usage::
21
+
22
+ async with await TopstepClient.create(
23
+ username="you@email.com",
24
+ api_key="your-key",
25
+ ) as client:
26
+ accounts = await client.accounts.search()
27
+ bars = await client.history.retrieve_bars(contract_id, start, end)
28
+ """
29
+
30
+ def __init__(self, http: HTTPClient) -> None:
31
+ # Use TopstepClient.create() instead of calling __init__ directly.
32
+ self._http = http
33
+
34
+ # Namespaced REST endpoints
35
+ self.accounts = AccountsEndpoint(http)
36
+ self.contracts = ContractsEndpoint(http)
37
+ self.history = HistoryEndpoint(http)
38
+ self.orders = OrdersEndpoint(http)
39
+ self.positions = PositionsEndpoint(http)
40
+ self.trades = TradesEndpoint(http)
41
+
42
+ # Realtime hubs (lazily connected)
43
+ self._market_hub: MarketHub | None = None
44
+ self._user_hub: UserHub | None = None
45
+
46
+ @classmethod
47
+ async def create(
48
+ cls,
49
+ username: str,
50
+ api_key: str,
51
+ base_url: str = BASE_URL,
52
+ timeout: float = 30.0,
53
+ ) -> TopstepClient:
54
+ """Create and authenticate a new client.
55
+
56
+ This is the primary way to instantiate the client since
57
+ authentication is an async operation.
58
+ """
59
+ http = HTTPClient(base_url=base_url, timeout=timeout)
60
+ try:
61
+ token = await auth.login_key(http, username, api_key)
62
+ except Exception:
63
+ await http.close()
64
+ raise
65
+
66
+ http.token = token
67
+ return cls(http)
68
+
69
+ @property
70
+ def token(self) -> str | None:
71
+ """Current session token."""
72
+ return self._http.token
73
+
74
+ async def refresh_token(self) -> None:
75
+ """Validate and refresh the session token."""
76
+ new_token = await auth.validate_token(self._http)
77
+ if new_token:
78
+ self._http.token = new_token
79
+ if self._market_hub is not None:
80
+ self._market_hub.set_token(new_token)
81
+ if self._user_hub is not None:
82
+ self._user_hub.set_token(new_token)
83
+
84
+ @property
85
+ def market(self) -> MarketHub:
86
+ """Real-time market data hub (quotes, trades, depth).
87
+
88
+ The connection is created on first access. Call
89
+ ``await client.market.connect()`` before subscribing.
90
+ """
91
+ if self._market_hub is None:
92
+ self._market_hub = MarketHub(self._http.token or "")
93
+ return self._market_hub
94
+
95
+ @property
96
+ def user(self) -> UserHub:
97
+ """Real-time user hub (accounts, orders, positions, trades).
98
+
99
+ The connection is created on first access. Call
100
+ ``await client.user.connect()`` before subscribing.
101
+ """
102
+ if self._user_hub is None:
103
+ self._user_hub = UserHub(self._http.token or "")
104
+ return self._user_hub
105
+
106
+ async def close(self) -> None:
107
+ """Close all connections (HTTP + WebSocket hubs)."""
108
+ if self._market_hub is not None:
109
+ await self._market_hub.stop()
110
+ if self._user_hub is not None:
111
+ await self._user_hub.stop()
112
+ await self._http.close()
113
+
114
+ async def __aenter__(self) -> TopstepClient:
115
+ return self
116
+
117
+ async def __aexit__(self, *args: object) -> None:
118
+ await self.close()
@@ -0,0 +1,17 @@
1
+ """Endpoint service classes."""
2
+
3
+ from topstep.endpoints.accounts import AccountsEndpoint
4
+ from topstep.endpoints.contracts import ContractsEndpoint
5
+ from topstep.endpoints.history import HistoryEndpoint
6
+ from topstep.endpoints.orders import OrdersEndpoint
7
+ from topstep.endpoints.positions import PositionsEndpoint
8
+ from topstep.endpoints.trades import TradesEndpoint
9
+
10
+ __all__ = [
11
+ "AccountsEndpoint",
12
+ "ContractsEndpoint",
13
+ "HistoryEndpoint",
14
+ "OrdersEndpoint",
15
+ "PositionsEndpoint",
16
+ "TradesEndpoint",
17
+ ]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from topstep.http import HTTPClient
4
+ from topstep.models.account import Account
5
+
6
+
7
+ class AccountsEndpoint:
8
+ """Operations on trading accounts."""
9
+
10
+ def __init__(self, http: HTTPClient) -> None:
11
+ self._http = http
12
+
13
+ async def search(self, only_active: bool = True) -> list[Account]:
14
+ """Search accounts. By default returns only active accounts."""
15
+ data = await self._http.post("/api/Account/search", {
16
+ "onlyActiveAccounts": only_active,
17
+ })
18
+ return [Account.model_validate(a) for a in data.get("accounts", [])]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from topstep.http import HTTPClient
4
+ from topstep.models.contract import Contract
5
+
6
+
7
+ class ContractsEndpoint:
8
+ """Operations on futures contracts."""
9
+
10
+ def __init__(self, http: HTTPClient) -> None:
11
+ self._http = http
12
+
13
+ async def available(self, live: bool = False) -> list[Contract]:
14
+ """List all available contracts."""
15
+ data = await self._http.post("/api/Contract/available", {"live": live})
16
+ return [Contract.model_validate(c) for c in data.get("contracts", [])]
17
+
18
+ async def search(self, text: str, live: bool = False) -> list[Contract]:
19
+ """Search contracts by name/description (max 20 results)."""
20
+ data = await self._http.post("/api/Contract/search", {
21
+ "searchText": text,
22
+ "live": live,
23
+ })
24
+ return [Contract.model_validate(c) for c in data.get("contracts", [])]
25
+
26
+ async def search_by_id(self, contract_id: str) -> Contract:
27
+ """Look up a single contract by its ID."""
28
+ data = await self._http.post("/api/Contract/searchById", {
29
+ "contractId": contract_id,
30
+ })
31
+ return Contract.model_validate(data["contract"])
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from topstep.http import HTTPClient
6
+ from topstep.models.bar import Bar, BarUnit
7
+
8
+
9
+ class HistoryEndpoint:
10
+ """Historical market data."""
11
+
12
+ def __init__(self, http: HTTPClient) -> None:
13
+ self._http = http
14
+
15
+ async def retrieve_bars(
16
+ self,
17
+ contract_id: str,
18
+ start: datetime,
19
+ end: datetime,
20
+ unit: BarUnit = BarUnit.MINUTE,
21
+ unit_number: int = 1,
22
+ limit: int = 20000,
23
+ include_partial_bar: bool = False,
24
+ ) -> list[Bar]:
25
+ """Retrieve historical OHLCV bars.
26
+
27
+ Args:
28
+ contract_id: The contract to fetch bars for.
29
+ start: Start of the time range (UTC).
30
+ end: End of the time range (UTC).
31
+ unit: Bar timeframe unit (second, minute, hour, etc.).
32
+ unit_number: Number of units per bar (e.g. 5 for 5-minute bars).
33
+ limit: Maximum number of bars to return (API max: 20000).
34
+ include_partial_bar: Whether to include the current incomplete bar.
35
+
36
+ Rate limit: 50 requests per 30 seconds.
37
+ """
38
+ data = await self._http.post("/api/History/retrieveBars", {
39
+ "contractId": contract_id,
40
+ "live": False,
41
+ "startTime": start.strftime("%Y-%m-%dT%H:%M:%SZ"),
42
+ "endTime": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
43
+ "unit": int(unit),
44
+ "unitNumber": unit_number,
45
+ "limit": limit,
46
+ "includePartialBar": include_partial_bar,
47
+ })
48
+ return [Bar.model_validate(b) for b in data.get("bars", [])]
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from topstep.http import HTTPClient
7
+ from topstep.models.order import Order, PlaceOrderRequest
8
+
9
+
10
+ class OrdersEndpoint:
11
+ """Operations on orders."""
12
+
13
+ def __init__(self, http: HTTPClient) -> None:
14
+ self._http = http
15
+
16
+ async def place(self, order: PlaceOrderRequest) -> int:
17
+ """Place a new order. Returns the order ID."""
18
+ data = await self._http.post("/api/Order/place", order.to_api_dict())
19
+ return data["orderId"]
20
+
21
+ async def modify(
22
+ self,
23
+ account_id: int,
24
+ order_id: int,
25
+ size: Optional[int] = None,
26
+ limit_price: Optional[float] = None,
27
+ stop_price: Optional[float] = None,
28
+ trail_price: Optional[float] = None,
29
+ ) -> None:
30
+ """Modify an open order."""
31
+ payload: dict = {
32
+ "accountId": account_id,
33
+ "orderId": order_id,
34
+ }
35
+ if size is not None:
36
+ payload["size"] = size
37
+ if limit_price is not None:
38
+ payload["limitPrice"] = limit_price
39
+ if stop_price is not None:
40
+ payload["stopPrice"] = stop_price
41
+ if trail_price is not None:
42
+ payload["trailPrice"] = trail_price
43
+
44
+ await self._http.post("/api/Order/modify", payload)
45
+
46
+ async def cancel(self, account_id: int, order_id: int) -> None:
47
+ """Cancel an open order."""
48
+ await self._http.post("/api/Order/cancel", {
49
+ "accountId": account_id,
50
+ "orderId": order_id,
51
+ })
52
+
53
+ async def search(
54
+ self,
55
+ account_id: int,
56
+ start: datetime,
57
+ end: Optional[datetime] = None,
58
+ ) -> list[Order]:
59
+ """Search orders by time range."""
60
+ payload: dict = {
61
+ "accountId": account_id,
62
+ "startTimestamp": start.strftime("%Y-%m-%dT%H:%M:%SZ"),
63
+ }
64
+ if end is not None:
65
+ payload["endTimestamp"] = end.strftime("%Y-%m-%dT%H:%M:%SZ")
66
+
67
+ data = await self._http.post("/api/Order/search", payload)
68
+ return [Order.model_validate(o) for o in data.get("orders", [])]
69
+
70
+ async def search_open(self, account_id: int) -> list[Order]:
71
+ """Get all currently open orders for an account."""
72
+ data = await self._http.post("/api/Order/searchOpen", {
73
+ "accountId": account_id,
74
+ })
75
+ return [Order.model_validate(o) for o in data.get("orders", [])]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from topstep.http import HTTPClient
4
+ from topstep.models.position import Position
5
+
6
+
7
+ class PositionsEndpoint:
8
+ """Operations on positions."""
9
+
10
+ def __init__(self, http: HTTPClient) -> None:
11
+ self._http = http
12
+
13
+ async def search_open(self, account_id: int) -> list[Position]:
14
+ """Get all open positions for an account."""
15
+ data = await self._http.post("/api/Position/searchOpen", {
16
+ "accountId": account_id,
17
+ })
18
+ return [Position.model_validate(p) for p in data.get("positions", [])]
19
+
20
+ async def close(self, account_id: int, contract_id: str) -> None:
21
+ """Close all positions for a contract."""
22
+ await self._http.post("/api/Position/closeContract", {
23
+ "accountId": account_id,
24
+ "contractId": contract_id,
25
+ })
26
+
27
+ async def partial_close(self, account_id: int, contract_id: str, size: int) -> None:
28
+ """Partially close a position (specify number of contracts to close)."""
29
+ await self._http.post("/api/Position/partialCloseContract", {
30
+ "accountId": account_id,
31
+ "contractId": contract_id,
32
+ "size": size,
33
+ })
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from topstep.http import HTTPClient
7
+ from topstep.models.trade import Trade
8
+
9
+
10
+ class TradesEndpoint:
11
+ """Operations on trade history."""
12
+
13
+ def __init__(self, http: HTTPClient) -> None:
14
+ self._http = http
15
+
16
+ async def search(
17
+ self,
18
+ account_id: int,
19
+ start: datetime,
20
+ end: Optional[datetime] = None,
21
+ ) -> list[Trade]:
22
+ """Search trades by time range."""
23
+ payload: dict = {
24
+ "accountId": account_id,
25
+ "startTimestamp": start.strftime("%Y-%m-%dT%H:%M:%SZ"),
26
+ }
27
+ if end is not None:
28
+ payload["endTimestamp"] = end.strftime("%Y-%m-%dT%H:%M:%SZ")
29
+
30
+ data = await self._http.post("/api/Trade/search", payload)
31
+ return [Trade.model_validate(t) for t in data.get("trades", [])]
topstep/exceptions.py ADDED
@@ -0,0 +1,32 @@
1
+ """Exception hierarchy for the TopstepX client."""
2
+
3
+
4
+ class TopstepError(Exception):
5
+ """Base exception for all TopstepX client errors."""
6
+
7
+
8
+ class AuthenticationError(TopstepError):
9
+ """Raised when authentication fails (invalid credentials, expired token)."""
10
+
11
+
12
+ class APIError(TopstepError):
13
+ """Raised when the API returns success=False in its response."""
14
+
15
+ def __init__(self, message: str, error_code: int = 0):
16
+ self.error_code = error_code
17
+ super().__init__(f"[{error_code}] {message}" if error_code else message)
18
+
19
+
20
+ class HTTPError(TopstepError):
21
+ """Raised for non-200 HTTP status codes."""
22
+
23
+ def __init__(self, status_code: int, detail: str = ""):
24
+ self.status_code = status_code
25
+ super().__init__(f"HTTP {status_code}: {detail}" if detail else f"HTTP {status_code}")
26
+
27
+
28
+ class RateLimitError(HTTPError):
29
+ """Raised when the API returns HTTP 429 (too many requests)."""
30
+
31
+ def __init__(self, detail: str = "Rate limit exceeded"):
32
+ super().__init__(status_code=429, detail=detail)
topstep/http.py ADDED
@@ -0,0 +1,102 @@
1
+ """Low-level async HTTP client with auth headers, retry, and error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from topstep.exceptions import APIError, AuthenticationError, HTTPError, RateLimitError
11
+
12
+ # TopstepX base URLs
13
+ BASE_URL = "https://api.topstepx.com"
14
+ WS_USER_HUB = "https://rtc.topstepx.com/hubs/user"
15
+ WS_MARKET_HUB = "https://rtc.topstepx.com/hubs/market"
16
+
17
+ # Default retry config
18
+ MAX_RETRIES = 3
19
+ RETRY_BACKOFF = 1.0 # seconds, doubles each retry
20
+
21
+
22
+ class HTTPClient:
23
+ """Async HTTP wrapper that handles auth headers, retries, and error parsing."""
24
+
25
+ def __init__(self, base_url: str = BASE_URL, timeout: float = 30.0):
26
+ self.base_url = base_url.rstrip("/")
27
+ self._token: str | None = None
28
+ self._client = httpx.AsyncClient(
29
+ base_url=self.base_url,
30
+ timeout=timeout,
31
+ headers={
32
+ "Accept": "application/json",
33
+ "Content-Type": "application/json",
34
+ },
35
+ )
36
+
37
+ @property
38
+ def token(self) -> str | None:
39
+ return self._token
40
+
41
+ @token.setter
42
+ def token(self, value: str | None) -> None:
43
+ self._token = value
44
+ if value:
45
+ self._client.headers["Authorization"] = f"Bearer {value}"
46
+ else:
47
+ self._client.headers.pop("Authorization", None)
48
+
49
+ async def post(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
50
+ """Make a POST request with retry logic and error handling.
51
+
52
+ Returns the parsed JSON response body (the API envelope is validated
53
+ and stripped — callers get the raw dict to pick out domain fields).
54
+ """
55
+ last_exc: Exception | None = None
56
+
57
+ for attempt in range(MAX_RETRIES):
58
+ try:
59
+ response = await self._client.post(path, json=payload or {})
60
+ except httpx.TimeoutException as exc:
61
+ last_exc = HTTPError(408, f"Request timed out: {exc}")
62
+ await asyncio.sleep(RETRY_BACKOFF * (2**attempt))
63
+ continue
64
+ except httpx.HTTPError as exc:
65
+ last_exc = HTTPError(0, str(exc))
66
+ await asyncio.sleep(RETRY_BACKOFF * (2**attempt))
67
+ continue
68
+
69
+ # Rate limit — wait and retry
70
+ if response.status_code == 429:
71
+ last_exc = RateLimitError()
72
+ await asyncio.sleep(RETRY_BACKOFF * (2**attempt))
73
+ continue
74
+
75
+ # Auth failure — no point retrying
76
+ if response.status_code == 401:
77
+ raise AuthenticationError("Unauthorized — invalid or expired token")
78
+
79
+ # Other HTTP errors
80
+ if response.status_code != 200:
81
+ raise HTTPError(response.status_code, response.text)
82
+
83
+ # Parse JSON envelope
84
+ data = response.json()
85
+ if not data.get("success", False):
86
+ error_code = data.get("errorCode", 0)
87
+ error_msg = data.get("errorMessage") or "Unknown API error"
88
+ raise APIError(error_msg, error_code)
89
+
90
+ return data
91
+
92
+ # All retries exhausted
93
+ raise last_exc # type: ignore[misc]
94
+
95
+ async def close(self) -> None:
96
+ await self._client.aclose()
97
+
98
+ async def __aenter__(self) -> HTTPClient:
99
+ return self
100
+
101
+ async def __aexit__(self, *args: object) -> None:
102
+ await self.close()
@@ -0,0 +1,31 @@
1
+ """Pydantic models for TopstepX API responses."""
2
+
3
+ from topstep.models.account import Account
4
+ from topstep.models.bar import Bar, BarUnit
5
+ from topstep.models.contract import Contract
6
+ from topstep.models.order import (
7
+ Bracket,
8
+ Order,
9
+ OrderSide,
10
+ OrderStatus,
11
+ OrderType,
12
+ PlaceOrderRequest,
13
+ )
14
+ from topstep.models.position import Position, PositionType
15
+ from topstep.models.trade import Trade
16
+
17
+ __all__ = [
18
+ "Account",
19
+ "Bar",
20
+ "BarUnit",
21
+ "Bracket",
22
+ "Contract",
23
+ "Order",
24
+ "OrderSide",
25
+ "OrderStatus",
26
+ "OrderType",
27
+ "PlaceOrderRequest",
28
+ "Position",
29
+ "PositionType",
30
+ "Trade",
31
+ ]
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class Account(BaseModel):
5
+ """A TopstepX trading account."""
6
+
7
+ model_config = {"populate_by_name": True}
8
+
9
+ id: int
10
+ name: str
11
+ balance: float
12
+ can_trade: bool = Field(alias="canTrade")
13
+ is_visible: bool = Field(alias="isVisible")