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.
Files changed (43) hide show
  1. ctrader_api_client/__init__.py +64 -0
  2. ctrader_api_client/_internal/__init__.py +26 -0
  3. ctrader_api_client/_internal/messages.py +348 -0
  4. ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +42 -0
  5. ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +30 -0
  6. ctrader_api_client/_internal/proto/OpenApiMessages.py +1112 -0
  7. ctrader_api_client/_internal/proto/OpenApiModelMessages.py +802 -0
  8. ctrader_api_client/_internal/proto/__init__.py +320 -0
  9. ctrader_api_client/_internal/serialization.py +84 -0
  10. ctrader_api_client/api/__init__.py +21 -0
  11. ctrader_api_client/api/accounts.py +71 -0
  12. ctrader_api_client/api/market_data.py +424 -0
  13. ctrader_api_client/api/symbols.py +171 -0
  14. ctrader_api_client/api/trading.py +506 -0
  15. ctrader_api_client/auth/__init__.py +14 -0
  16. ctrader_api_client/auth/credentials.py +72 -0
  17. ctrader_api_client/auth/manager.py +511 -0
  18. ctrader_api_client/client.py +475 -0
  19. ctrader_api_client/config.py +56 -0
  20. ctrader_api_client/connection/__init__.py +16 -0
  21. ctrader_api_client/connection/heartbeat.py +120 -0
  22. ctrader_api_client/connection/protocol.py +366 -0
  23. ctrader_api_client/connection/transport.py +123 -0
  24. ctrader_api_client/enums.py +138 -0
  25. ctrader_api_client/events/__init__.py +65 -0
  26. ctrader_api_client/events/emitter.py +254 -0
  27. ctrader_api_client/events/router.py +400 -0
  28. ctrader_api_client/events/types.py +340 -0
  29. ctrader_api_client/exceptions.py +231 -0
  30. ctrader_api_client/models/__init__.py +50 -0
  31. ctrader_api_client/models/_base.py +19 -0
  32. ctrader_api_client/models/account.py +177 -0
  33. ctrader_api_client/models/deal.py +242 -0
  34. ctrader_api_client/models/market_data.py +192 -0
  35. ctrader_api_client/models/order.py +262 -0
  36. ctrader_api_client/models/position.py +209 -0
  37. ctrader_api_client/models/requests.py +299 -0
  38. ctrader_api_client/models/symbol.py +194 -0
  39. ctrader_api_client/py.typed +0 -0
  40. ctrader_api_client-0.1.0.dist-info/METADATA +252 -0
  41. ctrader_api_client-0.1.0.dist-info/RECORD +43 -0
  42. ctrader_api_client-0.1.0.dist-info/WHEEL +4 -0
  43. ctrader_api_client-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,511 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import TYPE_CHECKING
6
+
7
+ import anyio
8
+ import anyio.abc
9
+ from tenacity import (
10
+ AsyncRetrying,
11
+ retry_if_exception_type,
12
+ stop_after_attempt,
13
+ wait_exponential,
14
+ )
15
+
16
+ from .._internal.proto import (
17
+ ProtoOAAccountAuthReq,
18
+ ProtoOAAccountAuthRes,
19
+ ProtoOAApplicationAuthReq,
20
+ ProtoOAApplicationAuthRes,
21
+ ProtoOAGetAccountListByAccessTokenReq,
22
+ ProtoOAGetAccountListByAccessTokenRes,
23
+ ProtoOARefreshTokenReq,
24
+ ProtoOARefreshTokenRes,
25
+ )
26
+ from ..exceptions import (
27
+ AccountNotFoundError,
28
+ APIError,
29
+ TokenRefreshError,
30
+ )
31
+ from ..models import AccountSummary
32
+ from .credentials import AccountCredentials
33
+
34
+
35
+ if TYPE_CHECKING:
36
+ from ..connection.protocol import Protocol
37
+
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ TokenRefreshCallback = Callable[[AccountCredentials], Awaitable[None]]
42
+ AccountReadyCallback = Callable[[int, bool], Awaitable[None]] # (account_id, is_reconnect)
43
+
44
+
45
+ class AuthManager:
46
+ """Manages authentication for cTrader API connections.
47
+
48
+ Handles application authentication, account authentication for multiple
49
+ trading accounts, and automatic token refresh before expiry.
50
+
51
+ Example:
52
+ ```python
53
+ auth = AuthManager(
54
+ protocol=protocol,
55
+ client_id="your_client_id",
56
+ client_secret="your_client_secret",
57
+ on_tokens_refreshed=save_tokens_to_storage,
58
+ )
59
+
60
+ await auth.authenticate_app()
61
+ await auth.authenticate_account(credentials)
62
+ await auth.start() # Start refresh monitor
63
+
64
+ # ... trading operations ...
65
+
66
+ await auth.stop()
67
+ ```
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ protocol: Protocol,
73
+ client_id: str,
74
+ client_secret: str,
75
+ refresh_buffer_seconds: float = 300.0,
76
+ refresh_check_interval: float = 60.0,
77
+ refresh_retry_attempts: int = 3,
78
+ refresh_retry_min_wait: float = 1.0,
79
+ refresh_retry_max_wait: float = 30.0,
80
+ on_tokens_refreshed: TokenRefreshCallback | None = None,
81
+ on_account_ready: AccountReadyCallback | None = None,
82
+ ) -> None:
83
+ """Initialize the authentication manager.
84
+
85
+ Args:
86
+ protocol: The protocol instance for sending auth requests.
87
+ client_id: OAuth client ID for the application.
88
+ client_secret: OAuth client secret for the application.
89
+ refresh_buffer_seconds: Refresh tokens this many seconds before expiry.
90
+ Defaults to 300 (5 minutes).
91
+ refresh_check_interval: How often to check for expiring tokens (seconds).
92
+ Defaults to 60.
93
+ refresh_retry_attempts: Max retry attempts for token refresh.
94
+ Defaults to 3.
95
+ refresh_retry_min_wait: Initial wait between retries (seconds).
96
+ Defaults to 1.0.
97
+ refresh_retry_max_wait: Maximum wait between retries (seconds).
98
+ Defaults to 30.0.
99
+ on_tokens_refreshed: Async callback invoked when tokens are refreshed.
100
+ Receives the new AccountCredentials. Use this to persist tokens.
101
+ on_account_ready: Async callback invoked when an account is authenticated.
102
+ Receives (account_id, is_reconnect). Use this to perform any initial client setup.
103
+ """
104
+ self._protocol = protocol
105
+ self._client_id = client_id
106
+ self._client_secret = client_secret
107
+ self._refresh_buffer = refresh_buffer_seconds
108
+ self._check_interval = refresh_check_interval
109
+ self._retry_attempts = refresh_retry_attempts
110
+ self._retry_min_wait = refresh_retry_min_wait
111
+ self._retry_max_wait = refresh_retry_max_wait
112
+ self._on_tokens_refreshed = on_tokens_refreshed
113
+ self._on_account_ready = on_account_ready
114
+
115
+ # Account storage
116
+ self._accounts: dict[int, AccountCredentials] = {}
117
+
118
+ # Background task management
119
+ self._task_group: anyio.abc.TaskGroup | None = None
120
+ self._task_scope: anyio.CancelScope | None = None
121
+ self._running = False
122
+
123
+ # Track app authentication state
124
+ self._app_authenticated = False
125
+
126
+ @property
127
+ def is_app_authenticated(self) -> bool:
128
+ """Whether the application has been authenticated."""
129
+ return self._app_authenticated
130
+
131
+ @property
132
+ def authenticated_accounts(self) -> list[int]:
133
+ """List of authenticated account IDs."""
134
+ return list(self._accounts.keys())
135
+
136
+ def get_credentials(self, account_id: int) -> AccountCredentials | None:
137
+ """Get credentials for an account.
138
+
139
+ Args:
140
+ account_id: The cTID trader account ID.
141
+
142
+ Returns:
143
+ The account credentials, or None if not authenticated.
144
+ """
145
+ return self._accounts.get(account_id)
146
+
147
+ async def authenticate_app(self, timeout: float = 30.0) -> ProtoOAApplicationAuthRes:
148
+ """Authenticate the application with cTrader.
149
+
150
+ This must be called before authenticating any accounts.
151
+
152
+ Args:
153
+ timeout: Request timeout in seconds.
154
+
155
+ Returns:
156
+ The authentication response from the server.
157
+
158
+ Raises:
159
+ APIError: If authentication fails.
160
+ CTraderConnectionTimeoutError: If request times out.
161
+ """
162
+ logger.info("Authenticating application")
163
+
164
+ request = ProtoOAApplicationAuthReq(
165
+ client_id=self._client_id,
166
+ client_secret=self._client_secret,
167
+ )
168
+
169
+ response = await self._protocol.send_request(request, timeout=timeout)
170
+
171
+ if not isinstance(response, ProtoOAApplicationAuthRes):
172
+ raise APIError(
173
+ error_code="UNEXPECTED_RESPONSE",
174
+ description=f"Expected ProtoOAApplicationAuthRes, got {type(response).__name__}",
175
+ )
176
+
177
+ self._app_authenticated = True
178
+ logger.info("Application authenticated successfully")
179
+ return response
180
+
181
+ async def authenticate_account(
182
+ self,
183
+ credentials: AccountCredentials,
184
+ timeout: float = 30.0,
185
+ reauth: bool = False,
186
+ ) -> ProtoOAAccountAuthRes:
187
+ """Authenticate a trading account.
188
+
189
+ The account credentials are stored for automatic token refresh.
190
+
191
+ Args:
192
+ credentials: The account credentials including tokens.
193
+ timeout: Request timeout in seconds.
194
+ reauth: Whether this is a re-authentication (token refresh) or initial auth.
195
+
196
+ Returns:
197
+ The authentication response from the server.
198
+
199
+ Raises:
200
+ APIError: If authentication fails.
201
+ CTraderConnectionTimeoutError: If request times out.
202
+ """
203
+ if reauth:
204
+ logger.info("Re-authenticating account %d", credentials.account_id)
205
+ else:
206
+ logger.info("Authenticating account %d", credentials.account_id)
207
+
208
+ request = ProtoOAAccountAuthReq(
209
+ ctid_trader_account_id=credentials.account_id,
210
+ access_token=credentials.access_token,
211
+ )
212
+
213
+ response = await self._protocol.send_request(request, timeout=timeout)
214
+
215
+ if not isinstance(response, ProtoOAAccountAuthRes):
216
+ raise APIError(
217
+ error_code="UNEXPECTED_RESPONSE",
218
+ description=f"Expected ProtoOAAccountAuthRes, got {type(response).__name__}",
219
+ )
220
+
221
+ # Store credentials for refresh monitoring
222
+ self._accounts[credentials.account_id] = credentials
223
+ logger.info("Account %d authenticated successfully", credentials.account_id)
224
+
225
+ # Notify callback
226
+ if self._on_account_ready is not None:
227
+ try:
228
+ await self._on_account_ready(credentials.account_id, reauth)
229
+ except Exception as e:
230
+ logger.warning(
231
+ "Account ready callback failed for account %d: %s",
232
+ credentials.account_id,
233
+ e,
234
+ )
235
+
236
+ return response
237
+
238
+ async def get_accounts(
239
+ self,
240
+ access_token: str,
241
+ timeout: float = 30.0,
242
+ ) -> list[AccountSummary]:
243
+ """Get all trading accounts associated with an access token.
244
+
245
+ This retrieves the list of accounts without authenticating them.
246
+ Useful for discovering available accounts or letting users select
247
+ which account to use.
248
+
249
+ Args:
250
+ access_token: OAuth access token.
251
+ timeout: Request timeout in seconds.
252
+
253
+ Returns:
254
+ List of account summaries (lightweight account info).
255
+
256
+ Raises:
257
+ APIError: If the request fails.
258
+ CTraderConnectionTimeoutError: If request times out.
259
+ """
260
+ logger.debug("Fetching accounts for access token")
261
+
262
+ request = ProtoOAGetAccountListByAccessTokenReq(
263
+ access_token=access_token,
264
+ )
265
+
266
+ response = await self._protocol.send_request(request, timeout=timeout)
267
+
268
+ if not isinstance(response, ProtoOAGetAccountListByAccessTokenRes):
269
+ raise APIError(
270
+ error_code="UNEXPECTED_RESPONSE",
271
+ description=f"Expected ProtoOAGetAccountListByAccessTokenRes, got {type(response).__name__}",
272
+ )
273
+
274
+ accounts = [AccountSummary.from_proto(acc) for acc in response.ctid_trader_account]
275
+ logger.debug("Found %d accounts", len(accounts))
276
+ return accounts
277
+
278
+ async def resolve_account_id(
279
+ self,
280
+ access_token: str,
281
+ trader_login: int,
282
+ timeout: float = 30.0,
283
+ ) -> int:
284
+ """Resolve a trader login to its cTID trader account ID.
285
+
286
+ Args:
287
+ access_token: OAuth access token.
288
+ trader_login: The trader login number (visible in cTrader app).
289
+ timeout: Request timeout in seconds.
290
+
291
+ Returns:
292
+ The cTID trader account ID (used for API calls).
293
+
294
+ Raises:
295
+ AccountNotFoundError: If no account matches the trader login.
296
+ APIError: If the request fails.
297
+ CTraderConnectionTimeoutError: If request times out.
298
+ """
299
+ accounts = await self.get_accounts(access_token, timeout=timeout)
300
+
301
+ for account in accounts:
302
+ if account.trader_login == trader_login:
303
+ logger.debug(
304
+ "Resolved trader login %d to account ID %d",
305
+ trader_login,
306
+ account.account_id,
307
+ )
308
+ return account.account_id
309
+
310
+ available_logins = [acc.trader_login for acc in accounts]
311
+ raise AccountNotFoundError(trader_login, available_logins)
312
+
313
+ async def authenticate_by_trader_login(
314
+ self,
315
+ trader_login: int,
316
+ access_token: str,
317
+ refresh_token: str,
318
+ expires_at: float,
319
+ timeout: float = 30.0,
320
+ ) -> AccountCredentials:
321
+ """Authenticate an account using trader login (discovers cTID automatically).
322
+
323
+ This is a convenience method that:
324
+ 1. Resolves the trader login to the cTID trader account ID
325
+ 2. Creates AccountCredentials with the resolved ID
326
+ 3. Authenticates the account
327
+
328
+ Args:
329
+ trader_login: The trader login number (visible in cTrader app).
330
+ access_token: OAuth access token.
331
+ refresh_token: OAuth refresh token.
332
+ expires_at: Unix timestamp when access token expires.
333
+ timeout: Request timeout in seconds.
334
+
335
+ Returns:
336
+ AccountCredentials with the resolved account_id, ready for use.
337
+
338
+ Raises:
339
+ AccountNotFoundError: If no account matches the trader login.
340
+ APIError: If authentication fails.
341
+ CTraderConnectionTimeoutError: If request times out.
342
+ """
343
+ logger.info("Authenticating by trader login %d", trader_login)
344
+
345
+ # Resolve trader_login to account_id
346
+ account_id = await self.resolve_account_id(access_token, trader_login, timeout=timeout)
347
+
348
+ # Create credentials with resolved ID
349
+ credentials = AccountCredentials(
350
+ account_id=account_id,
351
+ access_token=access_token,
352
+ refresh_token=refresh_token,
353
+ expires_at=expires_at,
354
+ )
355
+
356
+ # Authenticate the account
357
+ await self.authenticate_account(credentials, timeout=timeout)
358
+
359
+ return credentials
360
+
361
+ def remove_account(self, account_id: int) -> bool:
362
+ """Remove an account from refresh monitoring.
363
+
364
+ Args:
365
+ account_id: The cTID trader account ID.
366
+
367
+ Returns:
368
+ True if the account was removed, False if it wasn't registered.
369
+ """
370
+ if account_id in self._accounts:
371
+ del self._accounts[account_id]
372
+ logger.info("Account %d removed from auth manager", account_id)
373
+ return True
374
+ return False
375
+
376
+ async def start(self) -> None:
377
+ """Start the token refresh monitor.
378
+
379
+ This runs a background task that periodically checks for expiring
380
+ tokens and refreshes them automatically.
381
+ """
382
+ if self._running:
383
+ return
384
+
385
+ self._running = True
386
+ self._task_group = anyio.create_task_group()
387
+ await self._task_group.__aenter__()
388
+ self._task_group.start_soon(self._refresh_loop)
389
+ logger.debug("Token refresh monitor started")
390
+
391
+ async def stop(self) -> None:
392
+ """Stop the token refresh monitor."""
393
+ self._running = False
394
+
395
+ if self._task_scope is not None:
396
+ self._task_scope.cancel()
397
+
398
+ if self._task_group is not None:
399
+ self._task_group.cancel_scope.cancel()
400
+ try:
401
+ await self._task_group.__aexit__(None, None, None)
402
+ except Exception:
403
+ pass
404
+ self._task_group = None
405
+
406
+ logger.debug("Token refresh monitor stopped")
407
+
408
+ async def _refresh_loop(self) -> None:
409
+ """Periodically check and refresh expiring tokens."""
410
+ with anyio.CancelScope() as scope:
411
+ self._task_scope = scope
412
+ while self._running:
413
+ await anyio.sleep(self._check_interval)
414
+
415
+ for account_id in list(self._accounts.keys()):
416
+ credentials = self._accounts.get(account_id)
417
+ if credentials is None:
418
+ continue
419
+
420
+ if credentials.expires_soon(self._refresh_buffer):
421
+ logger.info(
422
+ "Token for account %d expires soon (%.0fs remaining), refreshing",
423
+ account_id,
424
+ credentials.time_until_expiry(),
425
+ )
426
+ try:
427
+ await self._refresh_account(account_id)
428
+ except TokenRefreshError as e:
429
+ logger.error("Failed to refresh token for account %d: %s", account_id, e)
430
+ raise
431
+
432
+ async def _refresh_account(self, account_id: int) -> None:
433
+ """Refresh tokens for an account with retry logic.
434
+
435
+ Args:
436
+ account_id: The cTID trader account ID.
437
+
438
+ Raises:
439
+ TokenRefreshError: If refresh fails after all retries.
440
+ """
441
+ credentials = self._accounts.get(account_id)
442
+ if credentials is None:
443
+ return
444
+
445
+ last_error: Exception | None = None
446
+
447
+ try:
448
+ async for attempt in AsyncRetrying(
449
+ stop=stop_after_attempt(self._retry_attempts),
450
+ wait=wait_exponential(
451
+ min=self._retry_min_wait,
452
+ max=self._retry_max_wait,
453
+ ),
454
+ retry=retry_if_exception_type(APIError),
455
+ reraise=True,
456
+ ):
457
+ with attempt:
458
+ logger.debug(
459
+ "Token refresh attempt %d/%d for account %d",
460
+ attempt.retry_state.attempt_number,
461
+ self._retry_attempts,
462
+ account_id,
463
+ )
464
+
465
+ # Send refresh request
466
+ request = ProtoOARefreshTokenReq(
467
+ refresh_token=credentials.refresh_token,
468
+ )
469
+ response = await self._protocol.send_request(request)
470
+
471
+ if not isinstance(response, ProtoOARefreshTokenRes):
472
+ raise APIError(
473
+ error_code="UNEXPECTED_RESPONSE",
474
+ description=f"Expected ProtoOARefreshTokenRes, got {type(response).__name__}",
475
+ )
476
+
477
+ # Update credentials
478
+ new_credentials = credentials.with_refreshed_tokens(
479
+ access_token=response.access_token,
480
+ refresh_token=response.refresh_token,
481
+ expires_in=response.expires_in,
482
+ )
483
+ self._accounts[account_id] = new_credentials
484
+
485
+ logger.info(
486
+ "Token refreshed for account %d, new expiry in %ds",
487
+ account_id,
488
+ response.expires_in,
489
+ )
490
+
491
+ # Re-authenticate the account with the new token
492
+ await self.authenticate_account(new_credentials, reauth=True)
493
+
494
+ # Notify callback
495
+ if self._on_tokens_refreshed is not None:
496
+ try:
497
+ await self._on_tokens_refreshed(new_credentials)
498
+ except Exception as e:
499
+ logger.warning(
500
+ "Token refresh callback failed for account %d: %s",
501
+ account_id,
502
+ e,
503
+ )
504
+
505
+ except APIError as e:
506
+ last_error = e
507
+ except Exception as e:
508
+ last_error = e
509
+
510
+ if last_error is not None:
511
+ raise TokenRefreshError(account_id, last_error)