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 +55 -0
- topstep/auth.py +50 -0
- topstep/client.py +118 -0
- topstep/endpoints/__init__.py +17 -0
- topstep/endpoints/accounts.py +18 -0
- topstep/endpoints/contracts.py +31 -0
- topstep/endpoints/history.py +48 -0
- topstep/endpoints/orders.py +75 -0
- topstep/endpoints/positions.py +33 -0
- topstep/endpoints/trades.py +31 -0
- topstep/exceptions.py +32 -0
- topstep/http.py +102 -0
- topstep/models/__init__.py +31 -0
- topstep/models/account.py +13 -0
- topstep/models/bar.py +26 -0
- topstep/models/contract.py +15 -0
- topstep/models/order.py +82 -0
- topstep/models/position.py +24 -0
- topstep/models/trade.py +22 -0
- topstep/py.typed +0 -0
- topstep/realtime/__init__.py +6 -0
- topstep/realtime/market_hub.py +236 -0
- topstep/realtime/user_hub.py +257 -0
- topstep_client_py-0.1.0.dist-info/METADATA +233 -0
- topstep_client_py-0.1.0.dist-info/RECORD +28 -0
- topstep_client_py-0.1.0.dist-info/WHEEL +5 -0
- topstep_client_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- topstep_client_py-0.1.0.dist-info/top_level.txt +1 -0
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")
|