ctrader-api-client 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.
- ctrader_api_client/__init__.py +64 -0
- ctrader_api_client/_internal/__init__.py +26 -0
- ctrader_api_client/_internal/messages.py +348 -0
- ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +42 -0
- ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +30 -0
- ctrader_api_client/_internal/proto/OpenApiMessages.py +1112 -0
- ctrader_api_client/_internal/proto/OpenApiModelMessages.py +802 -0
- ctrader_api_client/_internal/proto/__init__.py +320 -0
- ctrader_api_client/_internal/serialization.py +84 -0
- ctrader_api_client/api/__init__.py +21 -0
- ctrader_api_client/api/accounts.py +71 -0
- ctrader_api_client/api/market_data.py +424 -0
- ctrader_api_client/api/symbols.py +171 -0
- ctrader_api_client/api/trading.py +506 -0
- ctrader_api_client/auth/__init__.py +14 -0
- ctrader_api_client/auth/credentials.py +72 -0
- ctrader_api_client/auth/manager.py +511 -0
- ctrader_api_client/client.py +475 -0
- ctrader_api_client/config.py +56 -0
- ctrader_api_client/connection/__init__.py +16 -0
- ctrader_api_client/connection/heartbeat.py +120 -0
- ctrader_api_client/connection/protocol.py +366 -0
- ctrader_api_client/connection/transport.py +123 -0
- ctrader_api_client/enums.py +138 -0
- ctrader_api_client/events/__init__.py +65 -0
- ctrader_api_client/events/emitter.py +254 -0
- ctrader_api_client/events/router.py +400 -0
- ctrader_api_client/events/types.py +340 -0
- ctrader_api_client/exceptions.py +231 -0
- ctrader_api_client/models/__init__.py +50 -0
- ctrader_api_client/models/_base.py +19 -0
- ctrader_api_client/models/account.py +177 -0
- ctrader_api_client/models/deal.py +242 -0
- ctrader_api_client/models/market_data.py +192 -0
- ctrader_api_client/models/order.py +262 -0
- ctrader_api_client/models/position.py +209 -0
- ctrader_api_client/models/requests.py +299 -0
- ctrader_api_client/models/symbol.py +194 -0
- ctrader_api_client/py.typed +0 -0
- ctrader_api_client-0.1.0.dist-info/METADATA +252 -0
- ctrader_api_client-0.1.0.dist-info/RECORD +43 -0
- ctrader_api_client-0.1.0.dist-info/WHEEL +4 -0
- ctrader_api_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
from .api import AccountsAPI, MarketDataAPI, SymbolsAPI, TradingAPI
|
|
8
|
+
from .auth import AuthManager
|
|
9
|
+
from .config import ClientConfig
|
|
10
|
+
from .connection import HeartbeatManager, Protocol, Transport
|
|
11
|
+
from .events import (
|
|
12
|
+
AccountDisconnectEvent,
|
|
13
|
+
ClientDisconnectEvent,
|
|
14
|
+
DepthEvent,
|
|
15
|
+
Event,
|
|
16
|
+
EventEmitter,
|
|
17
|
+
EventRouter,
|
|
18
|
+
ExecutionEvent,
|
|
19
|
+
MarginCallTriggerEvent,
|
|
20
|
+
MarginChangeEvent,
|
|
21
|
+
OrderErrorEvent,
|
|
22
|
+
PnLChangeEvent,
|
|
23
|
+
ReadyEvent,
|
|
24
|
+
ReconnectedEvent,
|
|
25
|
+
SpotEvent,
|
|
26
|
+
SymbolChangedEvent,
|
|
27
|
+
TokenInvalidatedEvent,
|
|
28
|
+
TraderUpdateEvent,
|
|
29
|
+
TrailingStopChangedEvent,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T", bound=Event)
|
|
36
|
+
EventHandler = Callable[[T], Awaitable[None]]
|
|
37
|
+
|
|
38
|
+
# Constrained TypeVars for overloaded on() method
|
|
39
|
+
# Events that support both account_id and symbol_id filters
|
|
40
|
+
T_BothFilters = TypeVar("T_BothFilters", SpotEvent, ExecutionEvent, DepthEvent)
|
|
41
|
+
|
|
42
|
+
# Events that support only account_id filter
|
|
43
|
+
T_AccountIdOnly = TypeVar(
|
|
44
|
+
"T_AccountIdOnly",
|
|
45
|
+
ReadyEvent,
|
|
46
|
+
OrderErrorEvent,
|
|
47
|
+
TraderUpdateEvent,
|
|
48
|
+
MarginChangeEvent,
|
|
49
|
+
AccountDisconnectEvent,
|
|
50
|
+
SymbolChangedEvent,
|
|
51
|
+
TrailingStopChangedEvent,
|
|
52
|
+
MarginCallTriggerEvent,
|
|
53
|
+
PnLChangeEvent,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Events that support no filters
|
|
57
|
+
T_NoFilters = TypeVar(
|
|
58
|
+
"T_NoFilters",
|
|
59
|
+
ReconnectedEvent,
|
|
60
|
+
ClientDisconnectEvent,
|
|
61
|
+
TokenInvalidatedEvent,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CTraderClient:
|
|
66
|
+
"""Unified cTrader API client.
|
|
67
|
+
|
|
68
|
+
Provides access to all API operations through namespaced interfaces
|
|
69
|
+
and supports decorator-based event registration.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
```python
|
|
73
|
+
from ctrader_api_client import CTraderClient, ClientConfig
|
|
74
|
+
from ctrader_api_client.events import SpotEvent
|
|
75
|
+
|
|
76
|
+
config = ClientConfig(
|
|
77
|
+
client_id="your_client_id",
|
|
78
|
+
client_secret="your_client_secret",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
client = CTraderClient(config)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@client.on(SpotEvent, symbol_id=270)
|
|
85
|
+
async def on_eurusd(event: SpotEvent) -> None:
|
|
86
|
+
print(f"EURUSD: {event.bid}/{event.ask}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async with client:
|
|
90
|
+
await client.auth.authenticate_app()
|
|
91
|
+
creds = await client.auth.authenticate_by_trader_login(
|
|
92
|
+
trader_login=17091452,
|
|
93
|
+
access_token="...",
|
|
94
|
+
refresh_token="...",
|
|
95
|
+
expires_at=1778617423,
|
|
96
|
+
)
|
|
97
|
+
await client.market_data.subscribe_spots(creds.account_id, [270])
|
|
98
|
+
|
|
99
|
+
await asyncio.Event().wait() # Run forever
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
auth: Authentication operations (app auth, account auth, token refresh).
|
|
104
|
+
accounts: Account information operations.
|
|
105
|
+
symbols: Symbol lookup and search.
|
|
106
|
+
trading: Order and position operations.
|
|
107
|
+
market_data: Market data subscriptions and historical data.
|
|
108
|
+
protocol: Low-level protocol access for advanced usage.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
112
|
+
"""Initialize the client.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
config: Client configuration including credentials and settings.
|
|
116
|
+
"""
|
|
117
|
+
self._config = config
|
|
118
|
+
|
|
119
|
+
# Connection layer
|
|
120
|
+
self._transport = Transport(
|
|
121
|
+
host=config.host,
|
|
122
|
+
port=config.port,
|
|
123
|
+
use_ssl=config.use_ssl,
|
|
124
|
+
)
|
|
125
|
+
self._protocol = Protocol(
|
|
126
|
+
transport=self._transport,
|
|
127
|
+
reconnect_attempts=config.reconnect_attempts,
|
|
128
|
+
reconnect_min_wait=config.reconnect_min_wait,
|
|
129
|
+
reconnect_max_wait=config.reconnect_max_wait,
|
|
130
|
+
)
|
|
131
|
+
self._heartbeat = HeartbeatManager(
|
|
132
|
+
protocol=self._protocol,
|
|
133
|
+
interval=config.heartbeat_interval,
|
|
134
|
+
timeout=config.heartbeat_timeout,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Event system
|
|
138
|
+
self._emitter = EventEmitter()
|
|
139
|
+
self._router = EventRouter(
|
|
140
|
+
protocol=self._protocol,
|
|
141
|
+
emitter=self._emitter,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Auth manager
|
|
145
|
+
self._auth = AuthManager(
|
|
146
|
+
protocol=self._protocol,
|
|
147
|
+
client_id=config.client_id,
|
|
148
|
+
client_secret=config.client_secret,
|
|
149
|
+
on_account_ready=self._emit_ready_event,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# API namespaces
|
|
153
|
+
self._accounts = AccountsAPI(
|
|
154
|
+
protocol=self._protocol,
|
|
155
|
+
default_timeout=config.request_timeout,
|
|
156
|
+
)
|
|
157
|
+
self._symbols = SymbolsAPI(
|
|
158
|
+
protocol=self._protocol,
|
|
159
|
+
default_timeout=config.request_timeout,
|
|
160
|
+
)
|
|
161
|
+
self._trading = TradingAPI(
|
|
162
|
+
protocol=self._protocol,
|
|
163
|
+
default_timeout=config.request_timeout,
|
|
164
|
+
)
|
|
165
|
+
self._market_data = MarketDataAPI(
|
|
166
|
+
protocol=self._protocol,
|
|
167
|
+
default_timeout=config.request_timeout,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
self._connected = False
|
|
171
|
+
|
|
172
|
+
# Set up reconnection handler
|
|
173
|
+
self._protocol._on_reconnect = self._handle_reconnect
|
|
174
|
+
|
|
175
|
+
# -------------------------------------------------------------------------
|
|
176
|
+
# Properties
|
|
177
|
+
# -------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def auth(self) -> AuthManager:
|
|
181
|
+
"""Authentication operations.
|
|
182
|
+
|
|
183
|
+
Provides methods for:
|
|
184
|
+
- Application authentication
|
|
185
|
+
- Account authentication
|
|
186
|
+
- Token refresh management
|
|
187
|
+
- Account discovery
|
|
188
|
+
"""
|
|
189
|
+
return self._auth
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def accounts(self) -> AccountsAPI:
|
|
193
|
+
"""Account information operations.
|
|
194
|
+
|
|
195
|
+
Provides methods to retrieve account/trader details.
|
|
196
|
+
"""
|
|
197
|
+
return self._accounts
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def symbols(self) -> SymbolsAPI:
|
|
201
|
+
"""Symbol lookup and search.
|
|
202
|
+
|
|
203
|
+
Provides methods to list, retrieve, and search trading symbols.
|
|
204
|
+
"""
|
|
205
|
+
return self._symbols
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def trading(self) -> TradingAPI:
|
|
209
|
+
"""Order and position operations.
|
|
210
|
+
|
|
211
|
+
Provides methods for:
|
|
212
|
+
- Placing orders (market, limit, stop)
|
|
213
|
+
- Modifying orders
|
|
214
|
+
- Canceling orders
|
|
215
|
+
- Closing positions
|
|
216
|
+
- Querying positions and orders
|
|
217
|
+
"""
|
|
218
|
+
return self._trading
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def market_data(self) -> MarketDataAPI:
|
|
222
|
+
"""Market data subscriptions and historical data.
|
|
223
|
+
|
|
224
|
+
Provides methods for:
|
|
225
|
+
- Subscribing to spot prices
|
|
226
|
+
- Subscribing to trendbars (candles)
|
|
227
|
+
- Subscribing to depth of market
|
|
228
|
+
- Retrieving historical data
|
|
229
|
+
"""
|
|
230
|
+
return self._market_data
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_connected(self) -> bool:
|
|
234
|
+
"""Whether the client is connected to the server."""
|
|
235
|
+
return self._connected and self._transport.is_connected
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def protocol(self) -> Protocol:
|
|
239
|
+
"""Direct access to the protocol layer.
|
|
240
|
+
|
|
241
|
+
For advanced usage when you need to send raw protobuf messages
|
|
242
|
+
or handle responses not covered by the high-level API.
|
|
243
|
+
"""
|
|
244
|
+
return self._protocol
|
|
245
|
+
|
|
246
|
+
# -------------------------------------------------------------------------
|
|
247
|
+
# Lifecycle
|
|
248
|
+
# -------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
async def connect(self) -> None:
|
|
251
|
+
"""Connect to the cTrader server.
|
|
252
|
+
|
|
253
|
+
Establishes the TCP/SSL connection, starts the protocol reader,
|
|
254
|
+
heartbeat monitor, and event router.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
CTraderConnectionFailedError: If connection cannot be established.
|
|
258
|
+
"""
|
|
259
|
+
if self._connected:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
logger.info("Connecting to %s:%d", self._config.host, self._config.port)
|
|
263
|
+
|
|
264
|
+
await self._transport.connect()
|
|
265
|
+
await self._protocol.start()
|
|
266
|
+
await self._heartbeat.start()
|
|
267
|
+
self._router.start()
|
|
268
|
+
|
|
269
|
+
self._connected = True
|
|
270
|
+
logger.info("Connected successfully")
|
|
271
|
+
|
|
272
|
+
async def close(self) -> None:
|
|
273
|
+
"""Close the connection and clean up resources.
|
|
274
|
+
|
|
275
|
+
Stops the event router, heartbeat monitor, protocol reader,
|
|
276
|
+
and closes the transport.
|
|
277
|
+
"""
|
|
278
|
+
if not self._connected:
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
logger.info("Closing connection")
|
|
282
|
+
|
|
283
|
+
self._router.stop()
|
|
284
|
+
await self._auth.stop()
|
|
285
|
+
await self._heartbeat.stop()
|
|
286
|
+
await self._protocol.stop()
|
|
287
|
+
await self._transport.close()
|
|
288
|
+
|
|
289
|
+
self._connected = False
|
|
290
|
+
logger.info("Connection closed")
|
|
291
|
+
|
|
292
|
+
async def _emit_ready_event(self, account_id: int, is_reconnect: bool) -> None:
|
|
293
|
+
"""Emit ReadyEvent when an account is authenticated.
|
|
294
|
+
|
|
295
|
+
Called by AuthManager after successful account authentication.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
account_id: The authenticated account ID.
|
|
299
|
+
is_reconnect: True if this is a re-authentication after reconnection.
|
|
300
|
+
"""
|
|
301
|
+
await self._emitter.emit(ReadyEvent(account_id=account_id, is_reconnect=is_reconnect))
|
|
302
|
+
|
|
303
|
+
async def _handle_reconnect(self) -> None:
|
|
304
|
+
"""Handle automatic reconnection by re-authenticating.
|
|
305
|
+
|
|
306
|
+
Called by Protocol after successful reconnection. Re-authenticates
|
|
307
|
+
the app and all previously authenticated accounts, then emits
|
|
308
|
+
a ReconnectedEvent so users can restore subscriptions.
|
|
309
|
+
"""
|
|
310
|
+
logger.info("Connection restored, re-authenticating...")
|
|
311
|
+
|
|
312
|
+
# Restart heartbeat monitoring
|
|
313
|
+
await self._heartbeat.restart()
|
|
314
|
+
|
|
315
|
+
restored: list[int] = []
|
|
316
|
+
failed: list[tuple[int, str]] = []
|
|
317
|
+
|
|
318
|
+
# Re-authenticate app
|
|
319
|
+
try:
|
|
320
|
+
await self._auth.authenticate_app()
|
|
321
|
+
app_auth_restored = True
|
|
322
|
+
logger.info("App re-authenticated successfully")
|
|
323
|
+
except Exception as e:
|
|
324
|
+
logger.error("Failed to re-authenticate app after reconnect: %s", e)
|
|
325
|
+
app_auth_restored = False
|
|
326
|
+
# Emit event with failure - user must handle this
|
|
327
|
+
await self._emitter.emit(
|
|
328
|
+
ReconnectedEvent(
|
|
329
|
+
app_auth_restored=False,
|
|
330
|
+
restored_accounts=(),
|
|
331
|
+
failed_accounts=(),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
# Re-authenticate all previously authenticated accounts
|
|
337
|
+
for account_id, credentials in list(self._auth._accounts.items()):
|
|
338
|
+
try:
|
|
339
|
+
await self._auth.authenticate_account(credentials, reauth=True)
|
|
340
|
+
restored.append(account_id)
|
|
341
|
+
logger.info("Re-authenticated account %d", account_id)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.error("Failed to re-authenticate account %d: %s", account_id, e)
|
|
344
|
+
failed.append((account_id, str(e)))
|
|
345
|
+
|
|
346
|
+
# Emit event for user to handle subscriptions
|
|
347
|
+
await self._emitter.emit(
|
|
348
|
+
ReconnectedEvent(
|
|
349
|
+
app_auth_restored=app_auth_restored,
|
|
350
|
+
restored_accounts=tuple(restored),
|
|
351
|
+
failed_accounts=tuple(failed),
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
async def __aenter__(self) -> CTraderClient:
|
|
356
|
+
"""Async context manager entry.
|
|
357
|
+
|
|
358
|
+
Connects to the server automatically.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The client instance.
|
|
362
|
+
"""
|
|
363
|
+
await self.connect()
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
async def __aexit__(
|
|
367
|
+
self,
|
|
368
|
+
_exc_type: type[BaseException] | None,
|
|
369
|
+
_exc_val: BaseException | None,
|
|
370
|
+
_exc_tb: Any,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""Async context manager exit.
|
|
373
|
+
|
|
374
|
+
Closes the connection automatically.
|
|
375
|
+
"""
|
|
376
|
+
await self.close()
|
|
377
|
+
|
|
378
|
+
# -------------------------------------------------------------------------
|
|
379
|
+
# Event Registration
|
|
380
|
+
# -------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
@overload
|
|
383
|
+
def on(
|
|
384
|
+
self,
|
|
385
|
+
event_type: type[T_BothFilters],
|
|
386
|
+
*,
|
|
387
|
+
account_id: int | None = ...,
|
|
388
|
+
symbol_id: int | None = ...,
|
|
389
|
+
) -> Callable[[EventHandler[T_BothFilters]], EventHandler[T_BothFilters]]: ...
|
|
390
|
+
|
|
391
|
+
@overload
|
|
392
|
+
def on(
|
|
393
|
+
self,
|
|
394
|
+
event_type: type[T_AccountIdOnly],
|
|
395
|
+
*,
|
|
396
|
+
account_id: int | None = ...,
|
|
397
|
+
) -> Callable[[EventHandler[T_AccountIdOnly]], EventHandler[T_AccountIdOnly]]: ...
|
|
398
|
+
|
|
399
|
+
@overload
|
|
400
|
+
def on(
|
|
401
|
+
self,
|
|
402
|
+
event_type: type[T_NoFilters],
|
|
403
|
+
) -> Callable[[EventHandler[T_NoFilters]], EventHandler[T_NoFilters]]: ...
|
|
404
|
+
|
|
405
|
+
def on(
|
|
406
|
+
self,
|
|
407
|
+
event_type: type[T],
|
|
408
|
+
*,
|
|
409
|
+
account_id: int | None = None,
|
|
410
|
+
symbol_id: int | None = None,
|
|
411
|
+
) -> Callable[[EventHandler[T]], EventHandler[T]]:
|
|
412
|
+
"""Decorator to register an event handler.
|
|
413
|
+
|
|
414
|
+
Handlers are called when events of the specified type arrive.
|
|
415
|
+
Optional filters can be used to only receive events for specific
|
|
416
|
+
accounts or symbols. The event must have the corresponding account_id or symbol_id attributes
|
|
417
|
+
for filtering to work. Else this will raise ValueError at registration time.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
event_type: The event class to listen for.
|
|
421
|
+
account_id: Only receive events for this account (optional).
|
|
422
|
+
symbol_id: Only receive events for this symbol (optional).
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Decorator function that registers the handler.
|
|
426
|
+
|
|
427
|
+
Example:
|
|
428
|
+
```python
|
|
429
|
+
@client.on(SpotEvent, symbol_id=270)
|
|
430
|
+
async def on_eurusd(event: SpotEvent) -> None:
|
|
431
|
+
print(f"EURUSD: {event.bid}/{event.ask}")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@client.on(ExecutionEvent)
|
|
435
|
+
async def on_execution(event: ExecutionEvent) -> None:
|
|
436
|
+
print(f"Order {event.order_id}: {event.execution_type}")
|
|
437
|
+
```
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
def decorator(handler: EventHandler[T]) -> EventHandler[T]:
|
|
441
|
+
self._emitter.subscribe(
|
|
442
|
+
event_type,
|
|
443
|
+
handler,
|
|
444
|
+
account_id=account_id,
|
|
445
|
+
symbol_id=symbol_id,
|
|
446
|
+
)
|
|
447
|
+
return handler
|
|
448
|
+
|
|
449
|
+
return decorator
|
|
450
|
+
|
|
451
|
+
def off(
|
|
452
|
+
self,
|
|
453
|
+
event_type: type[T],
|
|
454
|
+
handler: EventHandler[T],
|
|
455
|
+
) -> bool:
|
|
456
|
+
"""Unregister an event handler.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
event_type: The event class.
|
|
460
|
+
handler: The handler function to remove.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
True if handler was found and removed, False otherwise.
|
|
464
|
+
|
|
465
|
+
Example:
|
|
466
|
+
```python
|
|
467
|
+
@client.on(SpotEvent)
|
|
468
|
+
async def handler(event: SpotEvent) -> None: ...
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# Later, unregister
|
|
472
|
+
client.off(SpotEvent, handler)
|
|
473
|
+
```
|
|
474
|
+
"""
|
|
475
|
+
return self._emitter.unsubscribe(event_type, handler)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClientConfig(BaseModel):
|
|
7
|
+
"""Configuration for CTraderClient.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
host: cTrader API server hostname.
|
|
11
|
+
port: cTrader API server port.
|
|
12
|
+
use_ssl: Whether to use SSL/TLS encryption.
|
|
13
|
+
client_id: OAuth application client ID.
|
|
14
|
+
client_secret: OAuth application client secret.
|
|
15
|
+
heartbeat_interval: Seconds between heartbeat sends.
|
|
16
|
+
heartbeat_timeout: Seconds without server heartbeat before disconnect. Set to 0 to disable.
|
|
17
|
+
request_timeout: Default timeout for API requests in seconds.
|
|
18
|
+
reconnect_attempts: Max reconnection attempts (0 to disable).
|
|
19
|
+
reconnect_min_wait: Initial wait between reconnection attempts.
|
|
20
|
+
reconnect_max_wait: Maximum wait between reconnection attempts.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
```python
|
|
24
|
+
config = ClientConfig(client_id="your_client_id", client_secret="your_client_secret")
|
|
25
|
+
|
|
26
|
+
# For demo server
|
|
27
|
+
demo_config = ClientConfig(
|
|
28
|
+
host="demo.ctraderapi.com",
|
|
29
|
+
client_id="your_client_id",
|
|
30
|
+
client_secret="your_client_secret",
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Connection settings
|
|
36
|
+
host: str = "live.ctraderapi.com"
|
|
37
|
+
port: int = 5035
|
|
38
|
+
use_ssl: bool = True
|
|
39
|
+
|
|
40
|
+
# OAuth credentials
|
|
41
|
+
client_id: str
|
|
42
|
+
client_secret: str
|
|
43
|
+
|
|
44
|
+
# Heartbeat settings
|
|
45
|
+
heartbeat_interval: float = Field(default=10.0, gt=0)
|
|
46
|
+
heartbeat_timeout: float = Field(default=30.0, ge=0)
|
|
47
|
+
|
|
48
|
+
# Request settings
|
|
49
|
+
request_timeout: float = Field(default=30.0, gt=0)
|
|
50
|
+
|
|
51
|
+
# Reconnection settings
|
|
52
|
+
reconnect_attempts: int = Field(default=5, ge=0)
|
|
53
|
+
reconnect_min_wait: float = Field(default=1.0, gt=0)
|
|
54
|
+
reconnect_max_wait: float = Field(default=60.0, gt=0)
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(frozen=True)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Connection layer for cTrader API.
|
|
2
|
+
|
|
3
|
+
This module provides TCP/SSL transport, message protocol handling,
|
|
4
|
+
and heartbeat management for maintaining connections to cTrader servers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .heartbeat import HeartbeatManager
|
|
8
|
+
from .protocol import Protocol
|
|
9
|
+
from .transport import Transport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"HeartbeatManager",
|
|
14
|
+
"Protocol",
|
|
15
|
+
"Transport",
|
|
16
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import anyio
|
|
7
|
+
import anyio.abc
|
|
8
|
+
|
|
9
|
+
from .._internal.proto import ProtoHeartbeatEvent
|
|
10
|
+
from .protocol import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HeartbeatManager:
|
|
17
|
+
"""Manages heartbeat send/receive for keep-alive.
|
|
18
|
+
|
|
19
|
+
Sends periodic heartbeats to the server and monitors for incoming
|
|
20
|
+
heartbeats to detect connection loss.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
protocol: Protocol,
|
|
26
|
+
interval: float = 10.0,
|
|
27
|
+
timeout: float = 30.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Initialize the heartbeat manager.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
protocol: The protocol instance to send/receive through.
|
|
33
|
+
interval: Seconds between heartbeat sends.
|
|
34
|
+
timeout: Seconds without server heartbeat before triggering disconnect.
|
|
35
|
+
"""
|
|
36
|
+
self._protocol = protocol
|
|
37
|
+
self._interval = interval
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._last_received: float = 0.0
|
|
40
|
+
self._task_scope: anyio.CancelScope | None = None
|
|
41
|
+
self._task_group: anyio.abc.TaskGroup | None = None
|
|
42
|
+
|
|
43
|
+
async def start(self) -> None:
|
|
44
|
+
"""Start heartbeat monitoring.
|
|
45
|
+
|
|
46
|
+
Registers an event handler for incoming heartbeats and starts
|
|
47
|
+
the heartbeat send loop.
|
|
48
|
+
"""
|
|
49
|
+
# Register handler for incoming heartbeats
|
|
50
|
+
self._protocol.on_event(ProtoHeartbeatEvent, self._on_heartbeat)
|
|
51
|
+
self._last_received = time.monotonic()
|
|
52
|
+
|
|
53
|
+
# Start heartbeat loop in background
|
|
54
|
+
self._task_group = anyio.create_task_group()
|
|
55
|
+
await self._task_group.__aenter__()
|
|
56
|
+
self._task_group.start_soon(self._heartbeat_loop)
|
|
57
|
+
|
|
58
|
+
async def stop(self) -> None:
|
|
59
|
+
"""Stop heartbeat monitoring.
|
|
60
|
+
|
|
61
|
+
Cancels the heartbeat loop and removes the event handler.
|
|
62
|
+
"""
|
|
63
|
+
if self._task_scope is not None:
|
|
64
|
+
self._task_scope.cancel()
|
|
65
|
+
|
|
66
|
+
if self._task_group is not None:
|
|
67
|
+
self._task_group.cancel_scope.cancel()
|
|
68
|
+
try:
|
|
69
|
+
await self._task_group.__aexit__(None, None, None)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
self._task_group = None
|
|
73
|
+
|
|
74
|
+
self._protocol.remove_handler(ProtoHeartbeatEvent, self._on_heartbeat)
|
|
75
|
+
|
|
76
|
+
async def restart(self) -> None:
|
|
77
|
+
"""Restart heartbeat monitoring after reconnection.
|
|
78
|
+
|
|
79
|
+
Resets the heartbeat timer and spawns a new heartbeat loop.
|
|
80
|
+
Should be called after the protocol has reconnected.
|
|
81
|
+
"""
|
|
82
|
+
self._last_received = time.monotonic()
|
|
83
|
+
if self._task_group is not None:
|
|
84
|
+
self._task_group.start_soon(self._heartbeat_loop)
|
|
85
|
+
|
|
86
|
+
async def _on_heartbeat(self, _event: ProtoHeartbeatEvent) -> None:
|
|
87
|
+
"""Handler called when heartbeat received from server.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
_event: The heartbeat event from the server (unused).
|
|
91
|
+
"""
|
|
92
|
+
self._last_received = time.monotonic()
|
|
93
|
+
logger.debug("Heartbeat received from server")
|
|
94
|
+
|
|
95
|
+
async def _heartbeat_loop(self) -> None:
|
|
96
|
+
"""Periodically send heartbeats and check for timeout."""
|
|
97
|
+
with anyio.CancelScope() as scope:
|
|
98
|
+
self._task_scope = scope
|
|
99
|
+
while True:
|
|
100
|
+
await anyio.sleep(self._interval)
|
|
101
|
+
|
|
102
|
+
# Check if server heartbeat received recently
|
|
103
|
+
elapsed = time.monotonic() - self._last_received
|
|
104
|
+
if 0 < self._timeout < elapsed:
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Heartbeat timeout: no heartbeat received in %.1f seconds",
|
|
107
|
+
elapsed,
|
|
108
|
+
)
|
|
109
|
+
# Heartbeat timeout - trigger disconnect handling
|
|
110
|
+
await self._protocol.handle_disconnect()
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Send client heartbeat
|
|
114
|
+
try:
|
|
115
|
+
await self._protocol.send_event(ProtoHeartbeatEvent())
|
|
116
|
+
logger.debug("Heartbeat sent to server")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning("Failed to send heartbeat: %s", e)
|
|
119
|
+
# Protocol will handle reconnection
|
|
120
|
+
return
|