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,340 @@
1
+ """
2
+ Not yet implemented:
3
+ - ProtoOAMarginCallUpdateEvent: Margin call threshold configuration changed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from decimal import Decimal
11
+
12
+ from ..enums import ExecutionType, OrderSide
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SpotEvent:
17
+ """Price tick event.
18
+
19
+ Emitted when bid/ask prices update for a subscribed symbol.
20
+
21
+ Note: Prices are raw integer values from the server. To convert to
22
+ actual prices, divide by 10^5 (e.g., 123000 = 1.23000). The exact
23
+ decimal places depend on symbol configuration.
24
+
25
+ Attributes:
26
+ account_id: The cTID trader account ID.
27
+ symbol_id: The symbol identifier.
28
+ bid: Current bid price as raw integer, or None if unchanged.
29
+ ask: Current ask price as raw integer, or None if unchanged.
30
+ timestamp: Server timestamp of the tick.
31
+ """
32
+
33
+ account_id: int
34
+ symbol_id: int
35
+ bid: int | None
36
+ ask: int | None
37
+ timestamp: datetime
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ExecutionEvent:
42
+ """Order execution event.
43
+
44
+ Emitted when an order is accepted, filled, modified, canceled, etc.
45
+
46
+ Attributes:
47
+ account_id: The cTID trader account ID.
48
+ execution_type: Type of execution (fill, cancel, etc.).
49
+ order_id: The order identifier.
50
+ position_id: The position identifier (if applicable).
51
+ symbol_id: The symbol identifier.
52
+ side: Order side (buy/sell).
53
+ filled_volume: Volume filled in this execution (in cents).
54
+ fill_price: Execution price (if filled).
55
+ timestamp: Server timestamp of the execution.
56
+ is_server_event: True if generated by server (e.g., stop-out).
57
+ error_code: Error code if execution failed.
58
+ """
59
+
60
+ account_id: int
61
+ execution_type: ExecutionType
62
+ order_id: int
63
+ position_id: int | None
64
+ symbol_id: int
65
+ side: OrderSide
66
+ filled_volume: int | None
67
+ fill_price: Decimal | None
68
+ timestamp: datetime
69
+ is_server_event: bool = False
70
+ error_code: str | None = None
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class OrderErrorEvent:
75
+ """Order error event.
76
+
77
+ Emitted when an order operation fails.
78
+
79
+ Attributes:
80
+ account_id: The cTID trader account ID.
81
+ order_id: The order identifier (if available).
82
+ position_id: The position identifier (if available).
83
+ error_code: The error code from the server.
84
+ description: Human-readable error description.
85
+ """
86
+
87
+ account_id: int
88
+ order_id: int | None
89
+ position_id: int | None
90
+ error_code: str
91
+ description: str
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class TraderUpdateEvent:
96
+ """Trader account update event.
97
+
98
+ Emitted when account information changes (balance, leverage, etc.).
99
+
100
+ Attributes:
101
+ account_id: The cTID trader account ID.
102
+ balance: Current account balance (raw, divide by 10^moneyDigits).
103
+ leverage_in_cents: Account leverage in cents (5000 = 1:50).
104
+ money_digits: Exponent for monetary values.
105
+ """
106
+
107
+ account_id: int
108
+ balance: int
109
+ leverage_in_cents: int | None
110
+ money_digits: int
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class MarginChangeEvent:
115
+ """Position margin change event.
116
+
117
+ Emitted when the margin allocated to a position changes.
118
+
119
+ Attributes:
120
+ account_id: The cTID trader account ID.
121
+ position_id: The position identifier.
122
+ used_margin: New margin value (raw, divide by 10^moneyDigits).
123
+ money_digits: Exponent for monetary values.
124
+ """
125
+
126
+ account_id: int
127
+ position_id: int
128
+ used_margin: int
129
+ money_digits: int
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class DepthQuote:
134
+ """
135
+ Single depth of market quote.
136
+
137
+ Attributes:
138
+ quote_id: Unique identifier for the quote (used for updates/deletions).
139
+ price: Quote price (raw integer, divide by 10^priceDigits).
140
+ size: Quote size (volume in cents).
141
+ is_bid: True if this is a bid quote, False if it's an ask quote.
142
+ """
143
+
144
+ quote_id: int
145
+ price: int
146
+ size: int
147
+ is_bid: bool
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class DepthEvent:
152
+ """Market depth (order book) event.
153
+
154
+ Emitted when the order book updates for a subscribed symbol.
155
+
156
+ Attributes:
157
+ account_id: The cTID trader account ID.
158
+ symbol_id: The symbol identifier.
159
+ new_quotes: New or updated quotes.
160
+ deleted_quote_ids: IDs of quotes that were removed.
161
+ """
162
+
163
+ account_id: int
164
+ symbol_id: int
165
+ new_quotes: tuple[DepthQuote, ...]
166
+ deleted_quote_ids: tuple[int, ...]
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class TokenInvalidatedEvent:
171
+ """Token invalidated event.
172
+
173
+ Emitted when access tokens are revoked or expired.
174
+
175
+ Attributes:
176
+ account_ids: List of affected account IDs.
177
+ reason: Reason for invalidation.
178
+ """
179
+
180
+ account_ids: tuple[int, ...]
181
+ reason: str
182
+
183
+
184
+ @dataclass(frozen=True, slots=True)
185
+ class ClientDisconnectEvent:
186
+ """Client disconnect event.
187
+
188
+ Emitted when the server terminates the client connection.
189
+
190
+ Attributes:
191
+ reason: Reason for disconnection.
192
+ """
193
+
194
+ reason: str
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class AccountDisconnectEvent:
199
+ """Account disconnect event.
200
+
201
+ Emitted when a specific account session is terminated.
202
+
203
+ Attributes:
204
+ account_id: The cTID trader account ID.
205
+ """
206
+
207
+ account_id: int
208
+
209
+
210
+ @dataclass(frozen=True, slots=True)
211
+ class SymbolChangedEvent:
212
+ """Symbol configuration changed event.
213
+
214
+ Emitted when a symbol's configuration is updated (trading hours,
215
+ margin requirements, spreads, etc.).
216
+
217
+ Attributes:
218
+ account_id: The cTID trader account ID.
219
+ symbol_ids: List of symbol IDs that changed.
220
+ """
221
+
222
+ account_id: int
223
+ symbol_ids: tuple[int, ...]
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class TrailingStopChangedEvent:
228
+ """Trailing stop loss level changed event.
229
+
230
+ Emitted when a trailing stop loss price is updated due to
231
+ favorable price movement.
232
+
233
+ Attributes:
234
+ account_id: The cTID trader account ID.
235
+ position_id: The position identifier.
236
+ order_id: The stop loss order identifier.
237
+ stop_price: New stop loss price.
238
+ timestamp: Server timestamp of the update.
239
+ """
240
+
241
+ account_id: int
242
+ position_id: int
243
+ order_id: int
244
+ stop_price: Decimal
245
+ timestamp: datetime
246
+
247
+
248
+ @dataclass(frozen=True, slots=True)
249
+ class MarginCallTriggerEvent:
250
+ """Margin call triggered event.
251
+
252
+ Emitted when account margin level reaches a configured threshold.
253
+ Sent at most once every 10 minutes per threshold to avoid spam.
254
+
255
+ Attributes:
256
+ account_id: The cTID trader account ID.
257
+ margin_call_type: Type of margin call (1, 2, or 3 for different thresholds).
258
+ margin_level_threshold: The threshold that was breached (percentage).
259
+ """
260
+
261
+ account_id: int
262
+ margin_call_type: int
263
+ margin_level_threshold: Decimal
264
+
265
+
266
+ @dataclass(frozen=True, slots=True)
267
+ class PnLChangeEvent:
268
+ """Unrealized PnL change event.
269
+
270
+ Emitted when unrealized profit/loss changes due to market movement.
271
+ Requires subscription via ProtoOAv1PnLChangeSubscribeReq.
272
+
273
+ Attributes:
274
+ account_id: The cTID trader account ID.
275
+ gross_unrealized_pnl: Gross unrealized PnL (raw, divide by 10^moneyDigits).
276
+ net_unrealized_pnl: Net unrealized PnL (raw, divide by 10^moneyDigits).
277
+ money_digits: Exponent for monetary values.
278
+ """
279
+
280
+ account_id: int
281
+ gross_unrealized_pnl: int
282
+ net_unrealized_pnl: int
283
+ money_digits: int
284
+
285
+
286
+ @dataclass(frozen=True, slots=True)
287
+ class ReconnectedEvent:
288
+ """Emitted after automatic reconnection and re-authentication.
289
+
290
+ When a connection is lost and automatically restored, the client
291
+ re-authenticates the app and all previously authenticated accounts.
292
+ Subscriptions (spots, trendbars, depth) are NOT automatically restored
293
+ and should be handled using ReadyEvent instead.
294
+ Use this event for any custom logic that depends on reconnection, such as logging or alerting.
295
+
296
+ Attributes:
297
+ app_auth_restored: Whether app authentication succeeded.
298
+ restored_accounts: Account IDs that were successfully re-authenticated.
299
+ failed_accounts: Accounts that failed, as (account_id, error_message) tuples.
300
+ """
301
+
302
+ app_auth_restored: bool
303
+ restored_accounts: tuple[int, ...]
304
+ failed_accounts: tuple[tuple[int, str], ...]
305
+
306
+
307
+ @dataclass(frozen=True, slots=True)
308
+ class ReadyEvent:
309
+ """Emitted when an account is authenticated and ready for use.
310
+
311
+ Fired after both initial authentication and reconnection re-authentication.
312
+ Use this to set up subscriptions that should persist across reconnections.
313
+
314
+ Attributes:
315
+ account_id: The cTID trader account ID that is now ready.
316
+ is_reconnect: True if this follows a reconnection, False for initial auth.
317
+ """
318
+
319
+ account_id: int
320
+ is_reconnect: bool
321
+
322
+
323
+ # Type alias for any event type
324
+ type Event = (
325
+ SpotEvent
326
+ | ExecutionEvent
327
+ | OrderErrorEvent
328
+ | TraderUpdateEvent
329
+ | MarginChangeEvent
330
+ | DepthEvent
331
+ | TokenInvalidatedEvent
332
+ | ClientDisconnectEvent
333
+ | AccountDisconnectEvent
334
+ | SymbolChangedEvent
335
+ | TrailingStopChangedEvent
336
+ | MarginCallTriggerEvent
337
+ | PnLChangeEvent
338
+ | ReconnectedEvent
339
+ | ReadyEvent
340
+ )
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ._internal.proto import ProtoOAErrorCode
6
+
7
+
8
+ if TYPE_CHECKING:
9
+ from ._internal.proto import ProtoOAErrorRes
10
+
11
+
12
+ class CTraderError(Exception):
13
+ """Base exception for all cTrader API errors."""
14
+
15
+
16
+ # =============================================================================
17
+ # Connection Errors
18
+ # =============================================================================
19
+
20
+
21
+ class CTraderConnectionError(CTraderError):
22
+ """Base exception for connection-related errors."""
23
+
24
+
25
+ class CTraderConnectionFailedError(CTraderConnectionError):
26
+ """Failed to establish connection to the server."""
27
+
28
+ def __init__(self, host: str, port: int, cause: Exception | None = None) -> None:
29
+ self.host = host
30
+ self.port = port
31
+ self.cause = cause
32
+ message = f"Failed to connect to {host}:{port}"
33
+ if cause:
34
+ message += f": {cause}"
35
+ super().__init__(message)
36
+
37
+
38
+ class CTraderConnectionClosedError(CTraderConnectionError):
39
+ """Connection was closed unexpectedly."""
40
+
41
+ def __init__(self, reason: str | None = None, was_clean: bool = False) -> None:
42
+ self.reason = reason
43
+ self.was_clean = was_clean
44
+ message = "Connection closed"
45
+ if was_clean:
46
+ message += " cleanly"
47
+ if reason:
48
+ message += f": {reason}"
49
+ super().__init__(message)
50
+
51
+
52
+ class CTraderConnectionTimeoutError(CTraderConnectionError):
53
+ """Connection operation timed out."""
54
+
55
+ def __init__(self, timeout_seconds: float, operation: str = "operation") -> None:
56
+ self.timeout_seconds = timeout_seconds
57
+ self.operation = operation
58
+ super().__init__(f"{operation} timed out after {timeout_seconds}s")
59
+
60
+
61
+ # =============================================================================
62
+ # Authentication Errors
63
+ # =============================================================================
64
+
65
+
66
+ class AuthenticationError(CTraderError):
67
+ """Base exception for authentication-related errors."""
68
+
69
+
70
+ class ApplicationAuthError(AuthenticationError):
71
+ """Application authentication failed."""
72
+
73
+ def __init__(self, error_code: str, description: str | None = None) -> None:
74
+ self.error_code = error_code
75
+ self.description = description
76
+ message = f"Application authentication failed: {error_code}"
77
+ if description:
78
+ message += f" - {description}"
79
+ super().__init__(message)
80
+
81
+
82
+ class AccountAuthError(AuthenticationError):
83
+ """Account authentication failed."""
84
+
85
+ def __init__(
86
+ self,
87
+ error_code: str,
88
+ description: str | None = None,
89
+ ctid_trader_account_id: int | None = None,
90
+ ) -> None:
91
+ self.error_code = error_code
92
+ self.description = description
93
+ self.ctid_trader_account_id = ctid_trader_account_id
94
+ message = f"Account authentication failed: {error_code}"
95
+ if ctid_trader_account_id:
96
+ message += f" (account: {ctid_trader_account_id})"
97
+ if description:
98
+ message += f" - {description}"
99
+ super().__init__(message)
100
+
101
+
102
+ class TokenExpiredError(AuthenticationError):
103
+ """Access token has expired."""
104
+
105
+ def __init__(self, ctid_trader_account_id: int | None = None) -> None:
106
+ self.ctid_trader_account_id = ctid_trader_account_id
107
+ message = "Access token has expired"
108
+ if ctid_trader_account_id:
109
+ message += f" for account {ctid_trader_account_id}"
110
+ super().__init__(message)
111
+
112
+
113
+ class TokenRefreshError(AuthenticationError):
114
+ """Failed to refresh access token."""
115
+
116
+ def __init__(
117
+ self,
118
+ ctid_trader_account_id: int | None = None,
119
+ cause: Exception | None = None,
120
+ ) -> None:
121
+ self.ctid_trader_account_id = ctid_trader_account_id
122
+ self.cause = cause
123
+ message = "Failed to refresh access token"
124
+ if ctid_trader_account_id:
125
+ message += f" for account {ctid_trader_account_id}"
126
+ if cause:
127
+ message += f": {cause}"
128
+ super().__init__(message)
129
+
130
+
131
+ class AccountNotFoundError(AuthenticationError):
132
+ """No account found matching the given criteria."""
133
+
134
+ def __init__(
135
+ self,
136
+ trader_login: int,
137
+ available_logins: list[int] | None = None,
138
+ ) -> None:
139
+ self.trader_login = trader_login
140
+ self.available_logins = available_logins
141
+ message = f"No account found with trader login {trader_login}"
142
+ if available_logins:
143
+ message += f". Available logins: {available_logins}"
144
+ super().__init__(message)
145
+
146
+
147
+ # =============================================================================
148
+ # API Errors
149
+ # =============================================================================
150
+
151
+
152
+ class APIError(CTraderError):
153
+ """Error response from the cTrader API."""
154
+
155
+ def __init__(
156
+ self,
157
+ error_code: str,
158
+ description: str | None = None,
159
+ ctid_trader_account_id: int | None = None,
160
+ maintenance_end_timestamp: int | None = None,
161
+ retry_after: int | None = None,
162
+ ) -> None:
163
+ self.error_code = error_code
164
+ self.description = description
165
+ self.ctid_trader_account_id = ctid_trader_account_id
166
+ self.maintenance_end_timestamp = maintenance_end_timestamp
167
+ self.retry_after = retry_after
168
+
169
+ message = f"API error: {error_code}"
170
+ if ctid_trader_account_id:
171
+ message += f" (account: {ctid_trader_account_id})"
172
+ if description:
173
+ message += f" - {description}"
174
+ super().__init__(message)
175
+
176
+ @classmethod
177
+ def from_proto(cls, error_res: ProtoOAErrorRes) -> APIError:
178
+ """Create an APIError from a ProtoOAErrorRes message."""
179
+ return cls(
180
+ error_code=error_res.error_code,
181
+ description=error_res.description or None,
182
+ ctid_trader_account_id=error_res.ctid_trader_account_id or None,
183
+ maintenance_end_timestamp=error_res.maintenance_end_timestamp or None,
184
+ retry_after=error_res.retry_after or None,
185
+ )
186
+
187
+ def is_rate_limited(self) -> bool:
188
+ """Check if this error indicates rate limiting."""
189
+ return self.error_code == ProtoOAErrorCode.REQUEST_FREQUENCY_EXCEEDED.name or self.retry_after is not None
190
+
191
+ def is_maintenance(self) -> bool:
192
+ """Check if this error indicates server maintenance."""
193
+ return (
194
+ self.error_code == ProtoOAErrorCode.SERVER_IS_UNDER_MAINTENANCE.name
195
+ or self.maintenance_end_timestamp is not None
196
+ )
197
+
198
+
199
+ # =============================================================================
200
+ # Protocol Errors
201
+ # =============================================================================
202
+
203
+
204
+ class ProtocolError(CTraderError):
205
+ """Base exception for protocol-level errors."""
206
+
207
+
208
+ class FramingError(ProtocolError):
209
+ """Error in wire protocol framing."""
210
+
211
+ def __init__(self, expected_bytes: int, received_bytes: int) -> None:
212
+ self.expected_bytes = expected_bytes
213
+ self.received_bytes = received_bytes
214
+ super().__init__(f"Framing error: expected {expected_bytes} bytes, received {received_bytes}")
215
+
216
+
217
+ class DeserializationError(ProtocolError):
218
+ """Failed to deserialize a protobuf message."""
219
+
220
+ def __init__(self, payload_type: int, raw_data: bytes) -> None:
221
+ self.payload_type = payload_type
222
+ self.raw_data = raw_data
223
+ super().__init__(f"Failed to deserialize message with payload type {payload_type} ({len(raw_data)} bytes)")
224
+
225
+
226
+ class UnknownPayloadTypeError(ProtocolError):
227
+ """Received a message with an unknown payload type."""
228
+
229
+ def __init__(self, payload_type: int) -> None:
230
+ self.payload_type = payload_type
231
+ super().__init__(f"Unknown payload type: {payload_type}")
@@ -0,0 +1,50 @@
1
+ """High-level Pydantic models for cTrader API.
2
+
3
+ This module provides Pythonic wrappers over raw protobuf types,
4
+ with conversion methods and price/volume utilities.
5
+
6
+ Example:
7
+ ```python
8
+ from ctrader_api_client.models import (
9
+ Account,
10
+ Symbol,
11
+ Position,
12
+ NewOrderRequest,
13
+ )
14
+ from ctrader_api_client.enums import OrderType, OrderSide
15
+
16
+ # Create an order request
17
+ request = NewOrderRequest(
18
+ symbol_id=1,
19
+ side=OrderSide.BUY,
20
+ volume=100, # 0.01 lots
21
+ order_type=OrderType.MARKET,
22
+ )
23
+ ```
24
+ """
25
+
26
+ from .account import Account, AccountSummary
27
+ from .deal import CloseDetail, Deal
28
+ from .market_data import TickData, Trendbar
29
+ from .order import Order
30
+ from .position import Position
31
+ from .requests import AmendOrderRequest, AmendPositionRequest, ClosePositionRequest, NewOrderRequest
32
+ from .symbol import Symbol, SymbolInfo
33
+
34
+
35
+ __all__ = [
36
+ "Account",
37
+ "AccountSummary",
38
+ "AmendOrderRequest",
39
+ "AmendPositionRequest",
40
+ "CloseDetail",
41
+ "ClosePositionRequest",
42
+ "Deal",
43
+ "NewOrderRequest",
44
+ "Order",
45
+ "Position",
46
+ "Symbol",
47
+ "SymbolInfo",
48
+ "TickData",
49
+ "Trendbar",
50
+ ]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class FrozenModel(BaseModel):
7
+ """Base class for all immutable models.
8
+
9
+ All models derived from this class are:
10
+ - Frozen (immutable after creation)
11
+ - Strict (no type coercion)
12
+ - Forbid extra fields
13
+ """
14
+
15
+ model_config = ConfigDict(
16
+ frozen=True,
17
+ strict=True,
18
+ extra="forbid",
19
+ )