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,366 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import anyio.abc
|
|
9
|
+
import betterproto
|
|
10
|
+
from tenacity import (
|
|
11
|
+
AsyncRetrying,
|
|
12
|
+
retry_if_exception_type,
|
|
13
|
+
stop_after_attempt,
|
|
14
|
+
wait_exponential,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .._internal import (
|
|
18
|
+
ClientMessageIdGenerator,
|
|
19
|
+
deserialize_proto_message,
|
|
20
|
+
encode_with_length_prefix,
|
|
21
|
+
read_framed_message,
|
|
22
|
+
unwrap_message,
|
|
23
|
+
wrap_message,
|
|
24
|
+
)
|
|
25
|
+
from .._internal.proto import ProtoMessage, ProtoOAErrorRes
|
|
26
|
+
from ..exceptions import (
|
|
27
|
+
APIError,
|
|
28
|
+
CTraderConnectionClosedError,
|
|
29
|
+
CTraderConnectionFailedError,
|
|
30
|
+
CTraderConnectionTimeoutError,
|
|
31
|
+
FramingError,
|
|
32
|
+
)
|
|
33
|
+
from .transport import Transport
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T", bound=betterproto.Message)
|
|
39
|
+
EventHandler = Callable[[T], Awaitable[None]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Protocol:
|
|
43
|
+
"""Message-level protocol handling with correlation and dispatch.
|
|
44
|
+
|
|
45
|
+
Manages the reader loop, request/response correlation, event dispatch,
|
|
46
|
+
and automatic reconnection with exponential backoff.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
transport: Transport,
|
|
52
|
+
reconnect_attempts: int = 5,
|
|
53
|
+
reconnect_min_wait: float = 1.0,
|
|
54
|
+
reconnect_max_wait: float = 60.0,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initialize the protocol handler.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
transport: The underlying transport for sending/receiving data.
|
|
60
|
+
reconnect_attempts: Maximum reconnection attempts (0 to disable).
|
|
61
|
+
reconnect_min_wait: Initial wait between attempts (seconds).
|
|
62
|
+
reconnect_max_wait: Maximum wait between attempts (seconds).
|
|
63
|
+
"""
|
|
64
|
+
self._transport = transport
|
|
65
|
+
self._id_generator = ClientMessageIdGenerator()
|
|
66
|
+
|
|
67
|
+
# Request correlation
|
|
68
|
+
self._pending: dict[str, anyio.Event] = {}
|
|
69
|
+
self._results: dict[str, betterproto.Message] = {}
|
|
70
|
+
self._errors: dict[str, Exception] = {}
|
|
71
|
+
|
|
72
|
+
# Event dispatch
|
|
73
|
+
self._event_handlers: dict[type, list[EventHandler]] = {}
|
|
74
|
+
|
|
75
|
+
# Concurrency control
|
|
76
|
+
self._write_lock: anyio.Lock = anyio.Lock()
|
|
77
|
+
self._reader_scope: anyio.CancelScope | None = None
|
|
78
|
+
self._task_group: anyio.abc.TaskGroup | None = None
|
|
79
|
+
self._running = False
|
|
80
|
+
|
|
81
|
+
# Reconnection config
|
|
82
|
+
self._reconnect_attempts = reconnect_attempts
|
|
83
|
+
self._reconnect_min_wait = reconnect_min_wait
|
|
84
|
+
self._reconnect_max_wait = reconnect_max_wait
|
|
85
|
+
|
|
86
|
+
# Reconnection callback (set by CTraderClient)
|
|
87
|
+
self._on_reconnect: Callable[[], Awaitable[None]] | None = None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_connected(self) -> bool:
|
|
91
|
+
"""Whether protocol is connected and reader is running."""
|
|
92
|
+
return self._transport.is_connected and self._running
|
|
93
|
+
|
|
94
|
+
async def start(self) -> None:
|
|
95
|
+
"""Start the reader task.
|
|
96
|
+
|
|
97
|
+
Call this after transport.connect() to begin reading messages.
|
|
98
|
+
"""
|
|
99
|
+
if self._running:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
self._running = True
|
|
103
|
+
self._task_group = anyio.create_task_group()
|
|
104
|
+
await self._task_group.__aenter__()
|
|
105
|
+
self._task_group.start_soon(self._reader_loop)
|
|
106
|
+
|
|
107
|
+
async def stop(self) -> None:
|
|
108
|
+
"""Stop the reader task gracefully."""
|
|
109
|
+
self._running = False
|
|
110
|
+
|
|
111
|
+
if self._reader_scope is not None:
|
|
112
|
+
self._reader_scope.cancel()
|
|
113
|
+
|
|
114
|
+
if self._task_group is not None:
|
|
115
|
+
self._task_group.cancel_scope.cancel()
|
|
116
|
+
try:
|
|
117
|
+
await self._task_group.__aexit__(None, None, None)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
self._task_group = None
|
|
121
|
+
|
|
122
|
+
# Signal all pending requests to wake up
|
|
123
|
+
for event in self._pending.values():
|
|
124
|
+
event.set()
|
|
125
|
+
|
|
126
|
+
async def send_request(
|
|
127
|
+
self,
|
|
128
|
+
message: betterproto.Message,
|
|
129
|
+
timeout: float = 30.0,
|
|
130
|
+
) -> betterproto.Message:
|
|
131
|
+
"""Send request and wait for correlated response.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
message: The protobuf message to send.
|
|
135
|
+
timeout: Timeout in seconds for waiting for a response.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The response message from the server.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
CTraderConnectionClosedError: If not connected and reconnection fails.
|
|
142
|
+
CTraderConnectionTimeoutError: If response not received within timeout.
|
|
143
|
+
APIError: If server returns ProtoOAErrorRes.
|
|
144
|
+
"""
|
|
145
|
+
if not self._running:
|
|
146
|
+
raise CTraderConnectionClosedError("Protocol not running")
|
|
147
|
+
|
|
148
|
+
msg_id = self._id_generator.next_id()
|
|
149
|
+
wrapped = wrap_message(message, client_msg_id=msg_id)
|
|
150
|
+
|
|
151
|
+
# Create event for this request
|
|
152
|
+
event = anyio.Event()
|
|
153
|
+
self._pending[msg_id] = event
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
# Lock write and send message
|
|
157
|
+
async with self._write_lock:
|
|
158
|
+
encoded = encode_with_length_prefix(wrapped)
|
|
159
|
+
await self._transport.send(encoded)
|
|
160
|
+
|
|
161
|
+
# Wait for response
|
|
162
|
+
with anyio.fail_after(timeout):
|
|
163
|
+
await event.wait()
|
|
164
|
+
|
|
165
|
+
# Check if we were stopped
|
|
166
|
+
if not self._running:
|
|
167
|
+
raise CTraderConnectionClosedError("Protocol stopped while waiting for response")
|
|
168
|
+
|
|
169
|
+
# Check for error response
|
|
170
|
+
if msg_id in self._errors:
|
|
171
|
+
raise self._errors.pop(msg_id)
|
|
172
|
+
|
|
173
|
+
return self._results.pop(msg_id)
|
|
174
|
+
|
|
175
|
+
except TimeoutError:
|
|
176
|
+
raise CTraderConnectionTimeoutError(timeout, "request") from None
|
|
177
|
+
finally:
|
|
178
|
+
self._pending.pop(msg_id, None)
|
|
179
|
+
self._results.pop(msg_id, None)
|
|
180
|
+
self._errors.pop(msg_id, None)
|
|
181
|
+
|
|
182
|
+
async def send_event(self, message: betterproto.Message) -> None:
|
|
183
|
+
"""Send message without expecting response (e.g., heartbeat).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
message: The protobuf message to send.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
CTraderConnectionClosedError: If not connected.
|
|
190
|
+
"""
|
|
191
|
+
if not self._running:
|
|
192
|
+
raise CTraderConnectionClosedError("Protocol not running")
|
|
193
|
+
|
|
194
|
+
wrapped = wrap_message(message)
|
|
195
|
+
|
|
196
|
+
async with self._write_lock:
|
|
197
|
+
encoded = encode_with_length_prefix(wrapped)
|
|
198
|
+
await self._transport.send(encoded)
|
|
199
|
+
|
|
200
|
+
def on_event(self, message_type: type[T], handler: EventHandler[T]) -> None:
|
|
201
|
+
"""Register async handler for event type.
|
|
202
|
+
|
|
203
|
+
Multiple handlers can be registered for the same event type.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
message_type: The protobuf message type to handle.
|
|
207
|
+
handler: Async callable that receives the message.
|
|
208
|
+
"""
|
|
209
|
+
if message_type not in self._event_handlers:
|
|
210
|
+
self._event_handlers[message_type] = []
|
|
211
|
+
self._event_handlers[message_type].append(handler)
|
|
212
|
+
|
|
213
|
+
def remove_handler(self, message_type: type[T], handler: EventHandler[T]) -> None:
|
|
214
|
+
"""Remove previously registered handler.
|
|
215
|
+
|
|
216
|
+
Fails silently if handler not found.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
message_type: The protobuf message type.
|
|
220
|
+
handler: The handler to remove.
|
|
221
|
+
"""
|
|
222
|
+
if message_type in self._event_handlers:
|
|
223
|
+
try:
|
|
224
|
+
self._event_handlers[message_type].remove(handler)
|
|
225
|
+
except ValueError:
|
|
226
|
+
pass # Handler not found
|
|
227
|
+
|
|
228
|
+
async def _reader_loop(self) -> None:
|
|
229
|
+
"""Continuously read and dispatch messages until stopped."""
|
|
230
|
+
with anyio.CancelScope() as scope:
|
|
231
|
+
self._reader_scope = scope
|
|
232
|
+
while self._running:
|
|
233
|
+
try:
|
|
234
|
+
raw = await read_framed_message(self._transport.stream)
|
|
235
|
+
proto_msg = deserialize_proto_message(raw)
|
|
236
|
+
inner = unwrap_message(proto_msg)
|
|
237
|
+
await self._dispatch_message(proto_msg, inner)
|
|
238
|
+
except (FramingError, anyio.ClosedResourceError, anyio.EndOfStream):
|
|
239
|
+
# Connection closed or corrupted
|
|
240
|
+
if self._running:
|
|
241
|
+
await self.handle_disconnect()
|
|
242
|
+
break
|
|
243
|
+
except anyio.get_cancelled_exc_class():
|
|
244
|
+
break
|
|
245
|
+
except Exception as e:
|
|
246
|
+
# Log but continue - don't crash reader on single message errors
|
|
247
|
+
logger.warning("Error processing message: %s", e)
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
async def _dispatch_message(
|
|
251
|
+
self,
|
|
252
|
+
proto_msg: ProtoMessage,
|
|
253
|
+
inner: betterproto.Message,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Route message to pending request or event handlers.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
proto_msg: The wrapper message containing client_msg_id.
|
|
259
|
+
inner: The unwrapped inner message.
|
|
260
|
+
"""
|
|
261
|
+
msg_id = proto_msg.client_msg_id
|
|
262
|
+
|
|
263
|
+
# Check if this is a response to a pending request
|
|
264
|
+
if msg_id and msg_id in self._pending:
|
|
265
|
+
if isinstance(inner, ProtoOAErrorRes):
|
|
266
|
+
self._errors[msg_id] = APIError.from_proto(inner)
|
|
267
|
+
else:
|
|
268
|
+
self._results[msg_id] = inner
|
|
269
|
+
self._pending[msg_id].set()
|
|
270
|
+
else:
|
|
271
|
+
# Server-initiated event
|
|
272
|
+
await self._dispatch_event(inner)
|
|
273
|
+
|
|
274
|
+
async def _dispatch_event(self, message: betterproto.Message) -> None:
|
|
275
|
+
"""Spawn tasks for registered handlers of this event type.
|
|
276
|
+
|
|
277
|
+
Handlers are spawned as concurrent tasks to prevent deadlocks if
|
|
278
|
+
handlers perform some blocking I/O calls that require responses from the reader loop.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
message: The event message to dispatch.
|
|
282
|
+
"""
|
|
283
|
+
handlers = self._event_handlers.get(type(message), [])
|
|
284
|
+
for handler in handlers:
|
|
285
|
+
if self._task_group is not None:
|
|
286
|
+
self._task_group.start_soon(self._call_handler_safe, handler, message)
|
|
287
|
+
else:
|
|
288
|
+
# Fallback if task group not available (shouldn't happen in normal operation)
|
|
289
|
+
await self._call_handler_safe(handler, message)
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
async def _call_handler_safe(
|
|
293
|
+
handler: EventHandler,
|
|
294
|
+
message: betterproto.Message,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Call an event handler with exception safety.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
handler: The handler to call.
|
|
300
|
+
message: The message to pass to the handler.
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
await handler(message)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
# Log but don't crash - other handlers should still run
|
|
306
|
+
logger.warning("Event handler error: %s", e)
|
|
307
|
+
|
|
308
|
+
async def handle_disconnect(self) -> None:
|
|
309
|
+
"""Handle unexpected disconnection and attempt reconnection."""
|
|
310
|
+
logger.info("Connection lost, attempting to reconnect...")
|
|
311
|
+
|
|
312
|
+
# Cancel the reader loop first to prevent race conditions
|
|
313
|
+
if self._reader_scope is not None:
|
|
314
|
+
self._reader_scope.cancel()
|
|
315
|
+
self._reader_scope = None
|
|
316
|
+
|
|
317
|
+
# Close the transport
|
|
318
|
+
await self._transport.close()
|
|
319
|
+
|
|
320
|
+
# Attempt reconnection
|
|
321
|
+
try:
|
|
322
|
+
await self._reconnect()
|
|
323
|
+
logger.info("Reconnection successful")
|
|
324
|
+
except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
|
|
325
|
+
logger.error("Reconnection failed: %s", e)
|
|
326
|
+
self._running = False
|
|
327
|
+
# Signal all pending requests to wake up with error
|
|
328
|
+
for msg_id in list(self._pending.keys()):
|
|
329
|
+
self._errors[msg_id] = CTraderConnectionClosedError("Connection lost and reconnection failed")
|
|
330
|
+
self._pending[msg_id].set()
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
async def _reconnect(self) -> None:
|
|
334
|
+
"""Attempt reconnection with exponential backoff.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
CTraderConnectionClosedError: If reconnection is disabled or all attempts fail.
|
|
338
|
+
CTraderConnectionFailedError: If connection cannot be established.
|
|
339
|
+
"""
|
|
340
|
+
if self._reconnect_attempts == 0:
|
|
341
|
+
raise CTraderConnectionClosedError("Connection lost and reconnection disabled")
|
|
342
|
+
|
|
343
|
+
async for attempt in AsyncRetrying(
|
|
344
|
+
stop=stop_after_attempt(self._reconnect_attempts),
|
|
345
|
+
wait=wait_exponential(
|
|
346
|
+
min=self._reconnect_min_wait,
|
|
347
|
+
max=self._reconnect_max_wait,
|
|
348
|
+
),
|
|
349
|
+
retry=retry_if_exception_type(CTraderConnectionFailedError),
|
|
350
|
+
reraise=True,
|
|
351
|
+
):
|
|
352
|
+
with attempt:
|
|
353
|
+
logger.info(
|
|
354
|
+
"Reconnection attempt %d/%d",
|
|
355
|
+
attempt.retry_state.attempt_number,
|
|
356
|
+
self._reconnect_attempts,
|
|
357
|
+
)
|
|
358
|
+
await self._transport.connect()
|
|
359
|
+
|
|
360
|
+
# Restart the reader loop
|
|
361
|
+
if self._task_group is not None:
|
|
362
|
+
self._task_group.start_soon(self._reader_loop)
|
|
363
|
+
|
|
364
|
+
# Notify callback for re-authentication
|
|
365
|
+
if self._on_reconnect is not None:
|
|
366
|
+
await self._on_reconnect()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ssl
|
|
4
|
+
|
|
5
|
+
import anyio
|
|
6
|
+
from anyio.abc import ByteReceiveStream, ByteStream
|
|
7
|
+
|
|
8
|
+
from ..exceptions import CTraderConnectionClosedError, CTraderConnectionFailedError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Transport:
|
|
12
|
+
"""Low-level TCP/SSL transport.
|
|
13
|
+
|
|
14
|
+
Handles raw socket connections without knowledge of protobuf or message semantics.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, host: str, port: int, use_ssl: bool = True) -> None:
|
|
18
|
+
"""Initialize transport configuration.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
host: The server hostname to connect to.
|
|
22
|
+
port: The server port to connect to.
|
|
23
|
+
use_ssl: Whether to use SSL/TLS encryption. Defaults to True.
|
|
24
|
+
"""
|
|
25
|
+
self._host = host
|
|
26
|
+
self._port = port
|
|
27
|
+
self._ssl = use_ssl
|
|
28
|
+
self._stream: ByteStream | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def host(self) -> str:
|
|
32
|
+
"""The host this transport connects to."""
|
|
33
|
+
return self._host
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def port(self) -> int:
|
|
37
|
+
"""The port this transport connects to."""
|
|
38
|
+
return self._port
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_connected(self) -> bool:
|
|
42
|
+
"""Whether transport has an active connection."""
|
|
43
|
+
return self._stream is not None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def stream(self) -> ByteReceiveStream:
|
|
47
|
+
"""Get the underlying stream for reading.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
CTraderConnectionClosedError: If not connected.
|
|
51
|
+
"""
|
|
52
|
+
if self._stream is None:
|
|
53
|
+
raise CTraderConnectionClosedError("Not connected")
|
|
54
|
+
return self._stream
|
|
55
|
+
|
|
56
|
+
async def connect(self) -> None:
|
|
57
|
+
"""Establish TCP/SSL connection.
|
|
58
|
+
|
|
59
|
+
If already connected, this method returns immediately.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
CTraderConnectionFailedError: If connection cannot be established.
|
|
63
|
+
"""
|
|
64
|
+
if self._stream is not None:
|
|
65
|
+
return # Already connected
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
if self._ssl:
|
|
69
|
+
ssl_context = ssl.create_default_context()
|
|
70
|
+
self._stream = await anyio.connect_tcp(
|
|
71
|
+
self._host,
|
|
72
|
+
self._port,
|
|
73
|
+
ssl_context=ssl_context,
|
|
74
|
+
tls_standard_compatible=True,
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
self._stream = await anyio.connect_tcp(self._host, self._port)
|
|
78
|
+
except OSError as e:
|
|
79
|
+
raise CTraderConnectionFailedError(self._host, self._port, e) from e
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
"""Close the connection gracefully.
|
|
83
|
+
|
|
84
|
+
This method is idempotent - calling it multiple times is safe.
|
|
85
|
+
Handles already-closed streams gracefully.
|
|
86
|
+
"""
|
|
87
|
+
if self._stream is not None:
|
|
88
|
+
stream = self._stream
|
|
89
|
+
self._stream = None # Clear reference first to prevent re-entry
|
|
90
|
+
try:
|
|
91
|
+
await stream.aclose()
|
|
92
|
+
except (OSError, anyio.ClosedResourceError):
|
|
93
|
+
# Stream already closed or in bad state - ignore
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
async def send(self, data: bytes) -> None:
|
|
97
|
+
"""Send raw bytes over the connection.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
data: The bytes to send.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
CTraderConnectionClosedError: If not connected.
|
|
104
|
+
"""
|
|
105
|
+
if self._stream is None:
|
|
106
|
+
raise CTraderConnectionClosedError("Not connected")
|
|
107
|
+
await self._stream.send(data)
|
|
108
|
+
|
|
109
|
+
async def receive(self, max_bytes: int) -> bytes:
|
|
110
|
+
"""Receive up to max_bytes from the connection.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
max_bytes: Maximum number of bytes to receive.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The received bytes, or empty bytes on EOF.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
CTraderConnectionClosedError: If not connected.
|
|
120
|
+
"""
|
|
121
|
+
if self._stream is None:
|
|
122
|
+
raise CTraderConnectionClosedError("Not connected")
|
|
123
|
+
return await self._stream.receive(max_bytes)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum, StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Environment(StrEnum):
|
|
7
|
+
"""Trading environment."""
|
|
8
|
+
|
|
9
|
+
DEMO = "DEMO"
|
|
10
|
+
LIVE = "LIVE"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExecutionType(Enum):
|
|
14
|
+
"""Type of execution event."""
|
|
15
|
+
|
|
16
|
+
ORDER_ACCEPTED = "ORDER_ACCEPTED"
|
|
17
|
+
ORDER_FILLED = "ORDER_FILLED"
|
|
18
|
+
ORDER_REPLACED = "ORDER_REPLACED"
|
|
19
|
+
ORDER_CANCELLED = "ORDER_CANCELLED"
|
|
20
|
+
ORDER_EXPIRED = "ORDER_EXPIRED"
|
|
21
|
+
ORDER_REJECTED = "ORDER_REJECTED"
|
|
22
|
+
ORDER_CANCEL_REJECTED = "ORDER_CANCEL_REJECTED"
|
|
23
|
+
ORDER_PARTIAL_FILL = "ORDER_PARTIAL_FILL"
|
|
24
|
+
SWAP = "SWAP"
|
|
25
|
+
DEPOSIT_WITHDRAW = "DEPOSIT_WITHDRAW"
|
|
26
|
+
BONUS_DEPOSIT_WITHDRAW = "BONUS_DEPOSIT_WITHDRAW"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OrderSide(Enum):
|
|
30
|
+
"""Order side (direction)."""
|
|
31
|
+
|
|
32
|
+
BUY = "BUY"
|
|
33
|
+
SELL = "SELL"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OrderType(Enum):
|
|
37
|
+
"""Order type."""
|
|
38
|
+
|
|
39
|
+
MARKET = "MARKET"
|
|
40
|
+
LIMIT = "LIMIT"
|
|
41
|
+
STOP = "STOP"
|
|
42
|
+
STOP_LIMIT = "STOP_LIMIT"
|
|
43
|
+
MARKET_RANGE = "MARKET_RANGE"
|
|
44
|
+
STOP_LOSS_TAKE_PROFIT = "STOP_LOSS_TAKE_PROFIT"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OrderStatus(Enum):
|
|
48
|
+
"""Order status."""
|
|
49
|
+
|
|
50
|
+
ACCEPTED = "ACCEPTED"
|
|
51
|
+
FILLED = "FILLED"
|
|
52
|
+
REJECTED = "REJECTED"
|
|
53
|
+
EXPIRED = "EXPIRED"
|
|
54
|
+
CANCELLED = "CANCELLED"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PositionStatus(Enum):
|
|
58
|
+
"""Position status."""
|
|
59
|
+
|
|
60
|
+
OPEN = "OPEN"
|
|
61
|
+
CLOSED = "CLOSED"
|
|
62
|
+
CREATED = "CREATED"
|
|
63
|
+
ERROR = "ERROR"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TimeInForce(Enum):
|
|
67
|
+
"""Order time in force."""
|
|
68
|
+
|
|
69
|
+
GOOD_TILL_CANCEL = "GTC"
|
|
70
|
+
GOOD_TILL_DATE = "GTD"
|
|
71
|
+
IMMEDIATE_OR_CANCEL = "IOC"
|
|
72
|
+
FILL_OR_KILL = "FOK"
|
|
73
|
+
MARKET_ON_OPEN = "MOO"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DealStatus(Enum):
|
|
77
|
+
"""Deal execution status."""
|
|
78
|
+
|
|
79
|
+
FILLED = "FILLED"
|
|
80
|
+
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
|
81
|
+
REJECTED = "REJECTED"
|
|
82
|
+
INTERNALLY_REJECTED = "INTERNALLY_REJECTED"
|
|
83
|
+
ERROR = "ERROR"
|
|
84
|
+
MISSED = "MISSED"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AccessRights(Enum):
|
|
88
|
+
"""Account access rights."""
|
|
89
|
+
|
|
90
|
+
FULL_ACCESS = "FULL_ACCESS"
|
|
91
|
+
CLOSE_ONLY = "CLOSE_ONLY"
|
|
92
|
+
NO_TRADING = "NO_TRADING"
|
|
93
|
+
NO_LOGIN = "NO_LOGIN"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AccountType(Enum):
|
|
97
|
+
"""Account type."""
|
|
98
|
+
|
|
99
|
+
HEDGED = "HEDGED"
|
|
100
|
+
NETTED = "NETTED"
|
|
101
|
+
SPREAD_BETTING = "SPREAD_BETTING"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TradingMode(Enum):
|
|
105
|
+
"""Symbol trading mode."""
|
|
106
|
+
|
|
107
|
+
ENABLED = "ENABLED"
|
|
108
|
+
DISABLED_WITHOUT_PENDINGS_EXECUTION = "DISABLED_WITHOUT_PENDINGS"
|
|
109
|
+
DISABLED_WITH_PENDINGS_EXECUTION = "DISABLED_WITH_PENDINGS"
|
|
110
|
+
CLOSE_ONLY = "CLOSE_ONLY"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TrendbarPeriod(Enum):
|
|
114
|
+
"""Trendbar/candle period."""
|
|
115
|
+
|
|
116
|
+
M1 = "M1"
|
|
117
|
+
M2 = "M2"
|
|
118
|
+
M3 = "M3"
|
|
119
|
+
M4 = "M4"
|
|
120
|
+
M5 = "M5"
|
|
121
|
+
M10 = "M10"
|
|
122
|
+
M15 = "M15"
|
|
123
|
+
M30 = "M30"
|
|
124
|
+
H1 = "H1"
|
|
125
|
+
H4 = "H4"
|
|
126
|
+
H12 = "H12"
|
|
127
|
+
D1 = "D1"
|
|
128
|
+
W1 = "W1"
|
|
129
|
+
MN1 = "MN1"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class StopTriggerMethod(Enum):
|
|
133
|
+
"""Method for triggering stop orders."""
|
|
134
|
+
|
|
135
|
+
TRADE = "TRADE"
|
|
136
|
+
OPPOSITE = "OPPOSITE"
|
|
137
|
+
DOUBLE_TRADE = "DOUBLE_TRADE"
|
|
138
|
+
DOUBLE_OPPOSITE = "DOUBLE_OPPOSITE"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Event system for cTrader API client.
|
|
2
|
+
|
|
3
|
+
This module provides a pub/sub mechanism for handling async events from
|
|
4
|
+
the cTrader server, such as price updates, order executions, and account
|
|
5
|
+
changes.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
```python
|
|
9
|
+
from ctrader_api_client.events import EventEmitter, SpotEvent
|
|
10
|
+
|
|
11
|
+
emitter = EventEmitter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def on_price(event: SpotEvent):
|
|
15
|
+
print(f"{event.symbol_id}: {event.bid}/{event.ask}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
emitter.subscribe(SpotEvent, on_price, symbol_id=1)
|
|
19
|
+
```
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .emitter import EventEmitter
|
|
23
|
+
from .router import EventRouter
|
|
24
|
+
from .types import (
|
|
25
|
+
AccountDisconnectEvent,
|
|
26
|
+
ClientDisconnectEvent,
|
|
27
|
+
DepthEvent,
|
|
28
|
+
DepthQuote,
|
|
29
|
+
Event,
|
|
30
|
+
ExecutionEvent,
|
|
31
|
+
MarginCallTriggerEvent,
|
|
32
|
+
MarginChangeEvent,
|
|
33
|
+
OrderErrorEvent,
|
|
34
|
+
PnLChangeEvent,
|
|
35
|
+
ReadyEvent,
|
|
36
|
+
ReconnectedEvent,
|
|
37
|
+
SpotEvent,
|
|
38
|
+
SymbolChangedEvent,
|
|
39
|
+
TokenInvalidatedEvent,
|
|
40
|
+
TraderUpdateEvent,
|
|
41
|
+
TrailingStopChangedEvent,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AccountDisconnectEvent",
|
|
47
|
+
"ClientDisconnectEvent",
|
|
48
|
+
"DepthEvent",
|
|
49
|
+
"DepthQuote",
|
|
50
|
+
"Event",
|
|
51
|
+
"EventEmitter",
|
|
52
|
+
"EventRouter",
|
|
53
|
+
"ExecutionEvent",
|
|
54
|
+
"MarginCallTriggerEvent",
|
|
55
|
+
"MarginChangeEvent",
|
|
56
|
+
"OrderErrorEvent",
|
|
57
|
+
"PnLChangeEvent",
|
|
58
|
+
"ReadyEvent",
|
|
59
|
+
"ReconnectedEvent",
|
|
60
|
+
"SpotEvent",
|
|
61
|
+
"SymbolChangedEvent",
|
|
62
|
+
"TokenInvalidatedEvent",
|
|
63
|
+
"TraderUpdateEvent",
|
|
64
|
+
"TrailingStopChangedEvent",
|
|
65
|
+
]
|