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,506 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from decimal import Decimal
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._internal.proto import (
8
+ ProtoOACancelOrderReq,
9
+ ProtoOADealListByPositionIdReq,
10
+ ProtoOADealListByPositionIdRes,
11
+ ProtoOADealListReq,
12
+ ProtoOADealListRes,
13
+ ProtoOAExecutionEvent,
14
+ ProtoOAExecutionType,
15
+ ProtoOAOrderErrorEvent,
16
+ ProtoOAOrderListReq,
17
+ ProtoOAOrderListRes,
18
+ ProtoOAReconcileReq,
19
+ ProtoOAReconcileRes,
20
+ )
21
+ from ..enums import ExecutionType, OrderSide
22
+ from ..events import ExecutionEvent
23
+ from ..exceptions import APIError
24
+ from ..models import Deal, Order, Position
25
+ from ..models.requests import (
26
+ AmendOrderRequest,
27
+ AmendPositionRequest,
28
+ ClosePositionRequest,
29
+ NewOrderRequest,
30
+ )
31
+
32
+
33
+ if TYPE_CHECKING:
34
+ from ..connection import Protocol
35
+
36
+
37
+ def _raise_if_order_error(response: object) -> None:
38
+ """Raise APIError if response is an order error event.
39
+
40
+ Args:
41
+ response: The response to check.
42
+
43
+ Raises:
44
+ APIError: If response is a ProtoOAOrderErrorEvent.
45
+ """
46
+ if isinstance(response, ProtoOAOrderErrorEvent):
47
+ raise APIError(
48
+ error_code=response.error_code or "ORDER_ERROR",
49
+ description=response.description or None,
50
+ ctid_trader_account_id=response.ctid_trader_account_id or None,
51
+ )
52
+
53
+
54
+ # Map ProtoOAExecutionType enum values to our ExecutionType
55
+ _EXECUTION_TYPE_MAP: dict[int, ExecutionType] = {
56
+ ProtoOAExecutionType.ORDER_ACCEPTED: ExecutionType.ORDER_ACCEPTED,
57
+ ProtoOAExecutionType.ORDER_FILLED: ExecutionType.ORDER_FILLED,
58
+ ProtoOAExecutionType.ORDER_REPLACED: ExecutionType.ORDER_REPLACED,
59
+ ProtoOAExecutionType.ORDER_CANCELLED: ExecutionType.ORDER_CANCELLED,
60
+ ProtoOAExecutionType.ORDER_EXPIRED: ExecutionType.ORDER_EXPIRED,
61
+ ProtoOAExecutionType.ORDER_REJECTED: ExecutionType.ORDER_REJECTED,
62
+ ProtoOAExecutionType.ORDER_CANCEL_REJECTED: ExecutionType.ORDER_CANCEL_REJECTED,
63
+ ProtoOAExecutionType.SWAP: ExecutionType.SWAP,
64
+ ProtoOAExecutionType.DEPOSIT_WITHDRAW: ExecutionType.DEPOSIT_WITHDRAW,
65
+ ProtoOAExecutionType.ORDER_PARTIAL_FILL: ExecutionType.ORDER_PARTIAL_FILL,
66
+ ProtoOAExecutionType.BONUS_DEPOSIT_WITHDRAW: ExecutionType.BONUS_DEPOSIT_WITHDRAW,
67
+ }
68
+
69
+
70
+ def _proto_to_execution_event(proto: ProtoOAExecutionEvent) -> ExecutionEvent:
71
+ """Convert ProtoOAExecutionEvent to ExecutionEvent.
72
+
73
+ Args:
74
+ proto: The proto message to convert.
75
+
76
+ Returns:
77
+ ExecutionEvent instance.
78
+
79
+ Raises:
80
+ APIError: If execution type is unknown.
81
+ """
82
+ exec_type = _EXECUTION_TYPE_MAP.get(proto.execution_type)
83
+ if exec_type is None:
84
+ raise APIError(
85
+ error_code="UNKNOWN_EXECUTION_TYPE",
86
+ description=f"Unknown execution type: {proto.execution_type}",
87
+ )
88
+
89
+ # Map order side
90
+ side = OrderSide.BUY
91
+ if proto.order and proto.order.trade_data:
92
+ if proto.order.trade_data.trade_side == 2:
93
+ side = OrderSide.SELL
94
+
95
+ # Extract order details
96
+ order_id = proto.order.order_id if proto.order else 0
97
+ position_id = proto.position.position_id if proto.position else None
98
+ symbol_id = proto.order.trade_data.symbol_id if proto.order and proto.order.trade_data else 0
99
+
100
+ # Extract deal details
101
+ filled_volume = None
102
+ fill_price = None
103
+ timestamp = datetime.now(UTC)
104
+
105
+ if proto.deal:
106
+ filled_volume = proto.deal.filled_volume if proto.deal.filled_volume else None
107
+ if proto.deal.execution_price:
108
+ fill_price = Decimal(str(proto.deal.execution_price))
109
+ if proto.deal.execution_timestamp:
110
+ timestamp = datetime.fromtimestamp(proto.deal.execution_timestamp / 1000, tz=UTC)
111
+
112
+ return ExecutionEvent(
113
+ account_id=proto.ctid_trader_account_id,
114
+ execution_type=exec_type,
115
+ order_id=order_id,
116
+ position_id=position_id,
117
+ symbol_id=symbol_id,
118
+ side=side,
119
+ filled_volume=filled_volume,
120
+ fill_price=fill_price,
121
+ timestamp=timestamp,
122
+ is_server_event=proto.is_server_event if proto.is_server_event else False,
123
+ error_code=proto.error_code if proto.error_code else None,
124
+ )
125
+
126
+
127
+ class TradingAPI:
128
+ """Trading operations: orders and positions.
129
+
130
+ Provides methods for order placement, modification, cancellation,
131
+ and position management.
132
+
133
+ Example:
134
+ ```python
135
+ from ctrader_api_client.models import NewOrderRequest
136
+ from ctrader_api_client.enums import OrderSide, OrderType
137
+
138
+ # Place a market order
139
+ request = NewOrderRequest(
140
+ symbol_id=270,
141
+ side=OrderSide.BUY,
142
+ volume=100, # 0.01 lots
143
+ order_type=OrderType.MARKET,
144
+ )
145
+ execution = await client.trading.place_order(account_id, request)
146
+ print(f"Order {execution.order_id}: {execution.execution_type}")
147
+
148
+ # Get open positions
149
+ positions = await client.trading.get_open_positions(account_id)
150
+ for pos in positions:
151
+ print(f"Position {pos.position_id}: {pos.volume} @ {pos.entry_price}")
152
+ ```
153
+ """
154
+
155
+ def __init__(self, protocol: Protocol, default_timeout: float = 30.0) -> None:
156
+ """Initialize the trading API.
157
+
158
+ Args:
159
+ protocol: The protocol instance for sending requests.
160
+ default_timeout: Default request timeout in seconds.
161
+ """
162
+ self._protocol = protocol
163
+ self._default_timeout = default_timeout
164
+
165
+ async def place_order(
166
+ self,
167
+ account_id: int,
168
+ request: NewOrderRequest,
169
+ timeout: float | None = None,
170
+ ) -> ExecutionEvent:
171
+ """Place a new order.
172
+
173
+ Args:
174
+ account_id: The cTID trader account ID.
175
+ request: Order parameters.
176
+ timeout: Request timeout (uses default if None).
177
+
178
+ Returns:
179
+ ExecutionEvent with order details.
180
+
181
+ Note:
182
+ For market orders, the order may be immediately filled.
183
+ Check execution_type to determine the outcome:
184
+ - ORDER_ACCEPTED: Pending order created
185
+ - ORDER_FILLED: Order fully executed
186
+ - ORDER_PARTIAL_FILL: Order partially executed
187
+ - ORDER_REJECTED: Order was rejected
188
+
189
+ Raises:
190
+ APIError: If request fails.
191
+ CTraderConnectionTimeoutError: If request times out.
192
+ """
193
+ proto_request = request.to_proto(account_id)
194
+
195
+ response = await self._protocol.send_request(
196
+ proto_request,
197
+ timeout=timeout or self._default_timeout,
198
+ )
199
+
200
+ _raise_if_order_error(response)
201
+
202
+ if not isinstance(response, ProtoOAExecutionEvent):
203
+ raise APIError(
204
+ error_code="UNEXPECTED_RESPONSE",
205
+ description=f"Expected ProtoOAExecutionEvent, got {type(response).__name__}",
206
+ )
207
+
208
+ return _proto_to_execution_event(response)
209
+
210
+ async def amend_order(
211
+ self,
212
+ account_id: int,
213
+ request: AmendOrderRequest,
214
+ timeout: float | None = None,
215
+ ) -> ExecutionEvent:
216
+ """Modify a pending order.
217
+
218
+ Args:
219
+ account_id: The cTID trader account ID.
220
+ request: Amendment parameters (order_id required).
221
+ timeout: Request timeout (uses default if None).
222
+
223
+ Returns:
224
+ ExecutionEvent confirming the amendment.
225
+
226
+ Raises:
227
+ APIError: If request fails or order not found.
228
+ CTraderConnectionTimeoutError: If request times out.
229
+ """
230
+ proto_request = request.to_proto(account_id)
231
+
232
+ response = await self._protocol.send_request(
233
+ proto_request,
234
+ timeout=timeout or self._default_timeout,
235
+ )
236
+
237
+ _raise_if_order_error(response)
238
+
239
+ if not isinstance(response, ProtoOAExecutionEvent):
240
+ raise APIError(
241
+ error_code="UNEXPECTED_RESPONSE",
242
+ description=f"Expected ProtoOAExecutionEvent, got {type(response).__name__}",
243
+ )
244
+
245
+ return _proto_to_execution_event(response)
246
+
247
+ async def cancel_order(
248
+ self,
249
+ account_id: int,
250
+ order_id: int,
251
+ timeout: float | None = None,
252
+ ) -> ExecutionEvent:
253
+ """Cancel a pending order.
254
+
255
+ Args:
256
+ account_id: The cTID trader account ID.
257
+ order_id: The order to cancel.
258
+ timeout: Request timeout (uses default if None).
259
+
260
+ Returns:
261
+ ExecutionEvent confirming cancellation.
262
+
263
+ Raises:
264
+ APIError: If request fails or order not found.
265
+ CTraderConnectionTimeoutError: If request times out.
266
+ """
267
+ request = ProtoOACancelOrderReq(
268
+ ctid_trader_account_id=account_id,
269
+ order_id=order_id,
270
+ )
271
+
272
+ response = await self._protocol.send_request(
273
+ request,
274
+ timeout=timeout or self._default_timeout,
275
+ )
276
+
277
+ _raise_if_order_error(response)
278
+
279
+ if not isinstance(response, ProtoOAExecutionEvent):
280
+ raise APIError(
281
+ error_code="UNEXPECTED_RESPONSE",
282
+ description=f"Expected ProtoOAExecutionEvent, got {type(response).__name__}",
283
+ )
284
+
285
+ return _proto_to_execution_event(response)
286
+
287
+ async def close_position(
288
+ self,
289
+ account_id: int,
290
+ request: ClosePositionRequest,
291
+ timeout: float | None = None,
292
+ ) -> ExecutionEvent:
293
+ """Close a position (fully or partially).
294
+
295
+ Args:
296
+ account_id: The cTID trader account ID.
297
+ request: Close parameters (position_id and volume required).
298
+ timeout: Request timeout (uses default if None).
299
+
300
+ Returns:
301
+ ExecutionEvent with closing details.
302
+
303
+ Raises:
304
+ APIError: If request fails or position not found.
305
+ CTraderConnectionTimeoutError: If request times out.
306
+ """
307
+ proto_request = request.to_proto(account_id)
308
+
309
+ response = await self._protocol.send_request(
310
+ proto_request,
311
+ timeout=timeout or self._default_timeout,
312
+ )
313
+
314
+ _raise_if_order_error(response)
315
+
316
+ if not isinstance(response, ProtoOAExecutionEvent):
317
+ raise APIError(
318
+ error_code="UNEXPECTED_RESPONSE",
319
+ description=f"Expected ProtoOAExecutionEvent, got {type(response).__name__}",
320
+ )
321
+
322
+ return _proto_to_execution_event(response)
323
+
324
+ async def amend_position(
325
+ self,
326
+ account_id: int,
327
+ request: AmendPositionRequest,
328
+ timeout: float | None = None,
329
+ ) -> ExecutionEvent:
330
+ """Modify position stop loss and take profit.
331
+
332
+ Args:
333
+ account_id: The cTID trader account ID.
334
+ request: Amendment parameters (position_id required).
335
+ timeout: Request timeout (uses default if None).
336
+
337
+ Returns:
338
+ ExecutionEvent confirming the amendment.
339
+
340
+ Raises:
341
+ APIError: If request fails or position not found.
342
+ CTraderConnectionTimeoutError: If request times out.
343
+ """
344
+ proto_request = request.to_proto(account_id)
345
+
346
+ response = await self._protocol.send_request(
347
+ proto_request,
348
+ timeout=timeout or self._default_timeout,
349
+ )
350
+
351
+ _raise_if_order_error(response)
352
+
353
+ if not isinstance(response, ProtoOAExecutionEvent):
354
+ raise APIError(
355
+ error_code="UNEXPECTED_RESPONSE",
356
+ description=f"Expected ProtoOAExecutionEvent, got {type(response).__name__}",
357
+ )
358
+
359
+ return _proto_to_execution_event(response)
360
+
361
+ async def get_open_positions(
362
+ self,
363
+ account_id: int,
364
+ timeout: float | None = None,
365
+ ) -> list[Position]:
366
+ """Get all open positions.
367
+
368
+ Args:
369
+ account_id: The cTID trader account ID.
370
+ timeout: Request timeout (uses default if None).
371
+
372
+ Returns:
373
+ List of open Position objects.
374
+
375
+ Raises:
376
+ APIError: If request fails.
377
+ CTraderConnectionTimeoutError: If request times out.
378
+ """
379
+ request = ProtoOAReconcileReq(ctid_trader_account_id=account_id)
380
+
381
+ response = await self._protocol.send_request(
382
+ request,
383
+ timeout=timeout or self._default_timeout,
384
+ )
385
+
386
+ if not isinstance(response, ProtoOAReconcileRes):
387
+ raise APIError(
388
+ error_code="UNEXPECTED_RESPONSE",
389
+ description=f"Expected ProtoOAReconcileRes, got {type(response).__name__}",
390
+ )
391
+
392
+ return [Position.from_proto(p) for p in response.position]
393
+
394
+ async def get_pending_orders(
395
+ self,
396
+ account_id: int,
397
+ timeout: float | None = None,
398
+ ) -> list[Order]:
399
+ """Get all pending orders.
400
+
401
+ Args:
402
+ account_id: The cTID trader account ID.
403
+ timeout: Request timeout (uses default if None).
404
+
405
+ Returns:
406
+ List of pending Order objects.
407
+
408
+ Raises:
409
+ APIError: If request fails.
410
+ CTraderConnectionTimeoutError: If request times out.
411
+ """
412
+ request = ProtoOAOrderListReq(ctid_trader_account_id=account_id)
413
+
414
+ response = await self._protocol.send_request(
415
+ request,
416
+ timeout=timeout or self._default_timeout,
417
+ )
418
+
419
+ if not isinstance(response, ProtoOAOrderListRes):
420
+ raise APIError(
421
+ error_code="UNEXPECTED_RESPONSE",
422
+ description=f"Expected ProtoOAOrderListRes, got {type(response).__name__}",
423
+ )
424
+
425
+ return [Order.from_proto(o) for o in response.order]
426
+
427
+ async def get_deals_by_position_id(
428
+ self,
429
+ account_id: int,
430
+ position_id: int,
431
+ ) -> list[Deal]:
432
+ """Get all deals for a specific position.
433
+
434
+ Args:
435
+ account_id: The cTID trader account ID.
436
+ position_id: The position ID to filter deals by.
437
+
438
+ Returns:
439
+ List of Deal objects associated with the position.
440
+
441
+ Raises:
442
+ APIError: If request fails.
443
+ CTraderConnectionTimeoutError: If request times out.
444
+ """
445
+ request = ProtoOADealListByPositionIdReq(
446
+ ctid_trader_account_id=account_id,
447
+ position_id=position_id,
448
+ )
449
+
450
+ response = await self._protocol.send_request(
451
+ request,
452
+ timeout=self._default_timeout,
453
+ )
454
+
455
+ if not isinstance(response, ProtoOADealListByPositionIdRes):
456
+ raise APIError(
457
+ error_code="UNEXPECTED_RESPONSE",
458
+ description=f"Expected ProtoOADealListRes, got {type(response).__name__}",
459
+ )
460
+
461
+ return [Deal.from_proto(d) for d in response.deal]
462
+
463
+ async def get_deals(
464
+ self,
465
+ account_id: int,
466
+ from_timestamp: datetime,
467
+ to_timestamp: datetime,
468
+ timeout: float | None = None,
469
+ ) -> list[Deal]:
470
+ """Get historical deals.
471
+
472
+ Args:
473
+ account_id: The cTID trader account ID.
474
+ from_timestamp: Start of time range (inclusive).
475
+ to_timestamp: End of time range (inclusive).
476
+ timeout: Request timeout (uses default if None).
477
+
478
+ Returns:
479
+ List of Deal objects in the time range.
480
+
481
+ Note:
482
+ The maximum time range may be limited by the server.
483
+ For large ranges, consider paginating with smaller windows.
484
+
485
+ Raises:
486
+ APIError: If request fails.
487
+ CTraderConnectionTimeoutError: If request times out.
488
+ """
489
+ request = ProtoOADealListReq(
490
+ ctid_trader_account_id=account_id,
491
+ from_timestamp=int(from_timestamp.timestamp() * 1000),
492
+ to_timestamp=int(to_timestamp.timestamp() * 1000),
493
+ )
494
+
495
+ response = await self._protocol.send_request(
496
+ request,
497
+ timeout=timeout or self._default_timeout,
498
+ )
499
+
500
+ if not isinstance(response, ProtoOADealListRes):
501
+ raise APIError(
502
+ error_code="UNEXPECTED_RESPONSE",
503
+ description=f"Expected ProtoOADealListRes, got {type(response).__name__}",
504
+ )
505
+
506
+ return [Deal.from_proto(d) for d in response.deal]
@@ -0,0 +1,14 @@
1
+ """Authentication layer for cTrader API.
2
+
3
+ This module provides application and account authentication,
4
+ with automatic token refresh management.
5
+ """
6
+
7
+ from .credentials import AccountCredentials
8
+ from .manager import AuthManager
9
+
10
+
11
+ __all__ = [
12
+ "AccountCredentials",
13
+ "AuthManager",
14
+ ]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class AccountCredentials:
9
+ """Stores authentication credentials for a trading account.
10
+
11
+ Attributes:
12
+ account_id: The cTID trader account ID.
13
+ access_token: OAuth access token for API authentication.
14
+ refresh_token: OAuth refresh token for obtaining new access tokens.
15
+ expires_at: Unix epoch timestamp when the access token expires.
16
+ """
17
+
18
+ account_id: int
19
+ access_token: str
20
+ refresh_token: str
21
+ expires_at: float
22
+
23
+ def expires_soon(self, buffer_seconds: float = 300.0) -> bool:
24
+ """Check if the access token expires within the buffer period.
25
+
26
+ Args:
27
+ buffer_seconds: Number of seconds before expiry to consider "soon".
28
+ Defaults to 300 (5 minutes).
29
+
30
+ Returns:
31
+ True if the token expires within buffer_seconds from now.
32
+ """
33
+ return time.time() >= (self.expires_at - buffer_seconds)
34
+
35
+ def is_expired(self) -> bool:
36
+ """Check if the access token has already expired.
37
+
38
+ Returns:
39
+ True if the token has expired.
40
+ """
41
+ return time.time() >= self.expires_at
42
+
43
+ def time_until_expiry(self) -> float:
44
+ """Get seconds remaining until token expires.
45
+
46
+ Returns:
47
+ Seconds until expiry. Negative if already expired.
48
+ """
49
+ return self.expires_at - time.time()
50
+
51
+ def with_refreshed_tokens(
52
+ self,
53
+ access_token: str,
54
+ refresh_token: str,
55
+ expires_in: int,
56
+ ) -> AccountCredentials:
57
+ """Create a new instance with refreshed tokens.
58
+
59
+ Args:
60
+ access_token: The new access token.
61
+ refresh_token: The new refresh token.
62
+ expires_in: Seconds until the new access token expires.
63
+
64
+ Returns:
65
+ A new AccountCredentials instance with updated tokens.
66
+ """
67
+ return AccountCredentials(
68
+ account_id=self.account_id,
69
+ access_token=access_token,
70
+ refresh_token=refresh_token,
71
+ expires_at=time.time() + expires_in,
72
+ )