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,254 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any, TypeVar
7
+
8
+ from .types import Event
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ T = TypeVar("T", bound=Event)
14
+ EventHandler = Callable[[Any], Awaitable[None]]
15
+ ErrorHandler = Callable[[Event, EventHandler, Exception], Awaitable[None]]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Subscription:
20
+ """Internal subscription record."""
21
+
22
+ handler: EventHandler
23
+ account_id: int | None = None
24
+ symbol_id: int | None = None
25
+
26
+
27
+ class EventEmitter:
28
+ """Pub/sub event emitter with filtering.
29
+
30
+ Allows subscribing to typed events with optional account_id and symbol_id
31
+ filters. Handlers are called concurrently when an event is emitted, and exceptions are logged without
32
+ affecting other handlers.
33
+
34
+ If ordered execution of handlers is required, implement that logic within a single handler to ensure determinism.
35
+
36
+ Example:
37
+ ```python
38
+ emitter = EventEmitter()
39
+
40
+
41
+ async def on_spot(event: SpotEvent):
42
+ print(f"Price: {event.bid}/{event.ask}")
43
+
44
+
45
+ emitter.subscribe(SpotEvent, on_spot, symbol_id=1)
46
+
47
+ # Later, when event arrives:
48
+ await emitter.emit(spot_event)
49
+ ```
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ on_handler_error: ErrorHandler | None = None,
55
+ ) -> None:
56
+ """Initialize the event emitter.
57
+
58
+ Args:
59
+ on_handler_error: Optional async callback invoked when a handler
60
+ raises an exception. Receives the event, handler, and exception.
61
+ Called after logging the error.
62
+ """
63
+ self._subscriptions: dict[type[Event], list[Subscription]] = {}
64
+ self._on_handler_error = on_handler_error
65
+
66
+ def subscribe(
67
+ self,
68
+ event_type: type[T],
69
+ handler: Callable[[T], Awaitable[None]],
70
+ *,
71
+ account_id: int | None = None,
72
+ symbol_id: int | None = None,
73
+ ) -> None:
74
+ """Subscribe to an event type.
75
+
76
+ Args:
77
+ event_type: The event class to subscribe to.
78
+ handler: Async function to call when event occurs.
79
+ account_id: Only receive events for this account (optional).
80
+ Raises ValueError if event_type doesn't have an account_id field.
81
+ symbol_id: Only receive events for this symbol (optional).
82
+ Raises ValueError if event_type doesn't have a symbol_id field.
83
+
84
+ Raises:
85
+ ValueError: If a filter is provided but the event type doesn't have that field.
86
+ """
87
+ # Validate that event type supports the requested filters
88
+ if account_id is not None:
89
+ self._validate_filter(event_type, "account_id")
90
+ if symbol_id is not None:
91
+ self._validate_filter(event_type, "symbol_id")
92
+
93
+ if event_type not in self._subscriptions:
94
+ self._subscriptions[event_type] = []
95
+
96
+ subscription = Subscription(
97
+ handler=handler,
98
+ account_id=account_id,
99
+ symbol_id=symbol_id,
100
+ )
101
+ self._subscriptions[event_type].append(subscription)
102
+
103
+ logger.debug(
104
+ "Subscribed %s to %s (account=%s, symbol=%s)",
105
+ getattr(handler, "__name__", repr(handler)),
106
+ event_type.__name__,
107
+ account_id,
108
+ symbol_id,
109
+ )
110
+
111
+ def unsubscribe(
112
+ self,
113
+ event_type: type[T],
114
+ handler: Callable[[T], Awaitable[None]],
115
+ ) -> bool:
116
+ """Unsubscribe a handler from an event type.
117
+
118
+ Removes the first matching subscription for this handler.
119
+
120
+ Args:
121
+ event_type: The event class to unsubscribe from.
122
+ handler: The handler function to remove.
123
+
124
+ Returns:
125
+ True if handler was found and removed, False otherwise.
126
+ """
127
+ if event_type not in self._subscriptions:
128
+ return False
129
+
130
+ subscriptions = self._subscriptions[event_type]
131
+ for i, sub in enumerate(subscriptions):
132
+ if sub.handler is handler:
133
+ subscriptions.pop(i)
134
+ logger.debug(
135
+ "Unsubscribed %s from %s",
136
+ getattr(handler, "__name__", repr(handler)),
137
+ event_type.__name__,
138
+ )
139
+ return True
140
+
141
+ return False
142
+
143
+ def unsubscribe_all(
144
+ self,
145
+ event_type: type[T] | None = None,
146
+ ) -> int:
147
+ """Unsubscribe all handlers.
148
+
149
+ Args:
150
+ event_type: If provided, only unsubscribe handlers for this type.
151
+ If None, unsubscribe all handlers for all types.
152
+
153
+ Returns:
154
+ Number of subscriptions removed.
155
+ """
156
+ if event_type is not None:
157
+ count = len(self._subscriptions.get(event_type, []))
158
+ self._subscriptions[event_type] = []
159
+ logger.debug("Unsubscribed all handlers from %s", event_type.__name__)
160
+ return count
161
+
162
+ count = sum(len(subs) for subs in self._subscriptions.values())
163
+ self._subscriptions.clear()
164
+ logger.debug("Unsubscribed all handlers from all events")
165
+ return count
166
+
167
+ async def emit(self, event: Event) -> None:
168
+ """Emit an event to all matching subscribers.
169
+
170
+ Handlers are executed concurrently to prevent deadlocks when handlers
171
+ make API calls or other blocking I/O operations. If you need ordered operations,
172
+ implement that logic within a single handler.
173
+
174
+ If a handler raises, the error is logged and the optional error callback
175
+ is invoked. Other handlers continue unaffected.
176
+
177
+ Args:
178
+ event: The event to emit.
179
+ """
180
+ event_type = type(event)
181
+ subscriptions = self._subscriptions.get(event_type, [])
182
+
183
+ for sub in subscriptions:
184
+ if not self._matches_filter(event, sub):
185
+ continue
186
+
187
+ try:
188
+ await sub.handler(event)
189
+ except Exception as e:
190
+ logger.error(
191
+ "Handler %s raised %s for %s: %s",
192
+ getattr(sub.handler, "__name__", repr(sub.handler)),
193
+ type(e).__name__,
194
+ event_type.__name__,
195
+ e,
196
+ )
197
+ if self._on_handler_error is not None:
198
+ try:
199
+ await self._on_handler_error(event, sub.handler, e)
200
+ except Exception as callback_error:
201
+ logger.error(
202
+ "Error callback raised %s: %s",
203
+ type(callback_error).__name__,
204
+ callback_error,
205
+ )
206
+
207
+ @staticmethod
208
+ def _validate_filter(event_type: type, field_name: str) -> None:
209
+ """Validate that an event type supports filtering by a field.
210
+
211
+ Args:
212
+ event_type: The event class to check.
213
+ field_name: The field name to filter by.
214
+
215
+ Raises:
216
+ ValueError: If the event type doesn't have the specified field.
217
+ """
218
+ dataclass_fields = getattr(event_type, "__dataclass_fields__", {})
219
+ if field_name not in dataclass_fields:
220
+ raise ValueError(
221
+ f"{event_type.__name__} does not have a '{field_name}' field "
222
+ f"and cannot be filtered by it. Remove the {field_name} parameter."
223
+ )
224
+
225
+ @staticmethod
226
+ def _matches_filter(event: Event, sub: Subscription) -> bool:
227
+ """Check if event matches subscription filters."""
228
+ # Check account_id filter
229
+ if sub.account_id is not None:
230
+ event_account_id = getattr(event, "account_id", None)
231
+ if event_account_id != sub.account_id:
232
+ return False
233
+
234
+ # Check symbol_id filter
235
+ if sub.symbol_id is not None:
236
+ event_symbol_id = getattr(event, "symbol_id", None)
237
+ if event_symbol_id != sub.symbol_id:
238
+ return False
239
+
240
+ return True
241
+
242
+ def subscription_count(self, event_type: type[T] | None = None) -> int:
243
+ """Get the number of active subscriptions.
244
+
245
+ Args:
246
+ event_type: If provided, count only for this type.
247
+ If None, count all subscriptions.
248
+
249
+ Returns:
250
+ Number of active subscriptions.
251
+ """
252
+ if event_type is not None:
253
+ return len(self._subscriptions.get(event_type, []))
254
+ return sum(len(subs) for subs in self._subscriptions.values())
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import UTC, datetime
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .._internal.proto import (
9
+ ProtoOAAccountDisconnectEvent,
10
+ ProtoOAAccountsTokenInvalidatedEvent,
11
+ ProtoOAClientDisconnectEvent,
12
+ ProtoOADepthEvent,
13
+ ProtoOAExecutionEvent,
14
+ ProtoOAExecutionType,
15
+ ProtoOAMarginCallTriggerEvent,
16
+ ProtoOAMarginChangedEvent,
17
+ ProtoOAOrderErrorEvent,
18
+ ProtoOASpotEvent,
19
+ ProtoOASymbolChangedEvent,
20
+ ProtoOATraderUpdatedEvent,
21
+ ProtoOATrailingSLChangedEvent,
22
+ ProtoOAv1PnLChangeEvent,
23
+ )
24
+ from ..enums import ExecutionType, OrderSide
25
+ from .emitter import EventEmitter
26
+ from .types import (
27
+ AccountDisconnectEvent,
28
+ ClientDisconnectEvent,
29
+ DepthEvent,
30
+ DepthQuote,
31
+ ExecutionEvent,
32
+ MarginCallTriggerEvent,
33
+ MarginChangeEvent,
34
+ OrderErrorEvent,
35
+ PnLChangeEvent,
36
+ SpotEvent,
37
+ SymbolChangedEvent,
38
+ TokenInvalidatedEvent,
39
+ TraderUpdateEvent,
40
+ TrailingStopChangedEvent,
41
+ )
42
+
43
+
44
+ if TYPE_CHECKING:
45
+ from ..connection.protocol import Protocol
46
+
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ # Map ProtoOAExecutionType enum values to our ExecutionType
52
+ _EXECUTION_TYPE_MAP: dict[int, ExecutionType] = {
53
+ ProtoOAExecutionType.ORDER_ACCEPTED: ExecutionType.ORDER_ACCEPTED,
54
+ ProtoOAExecutionType.ORDER_FILLED: ExecutionType.ORDER_FILLED,
55
+ ProtoOAExecutionType.ORDER_REPLACED: ExecutionType.ORDER_REPLACED,
56
+ ProtoOAExecutionType.ORDER_CANCELLED: ExecutionType.ORDER_CANCELLED,
57
+ ProtoOAExecutionType.ORDER_EXPIRED: ExecutionType.ORDER_EXPIRED,
58
+ ProtoOAExecutionType.ORDER_REJECTED: ExecutionType.ORDER_REJECTED,
59
+ ProtoOAExecutionType.ORDER_CANCEL_REJECTED: ExecutionType.ORDER_CANCEL_REJECTED,
60
+ ProtoOAExecutionType.SWAP: ExecutionType.SWAP,
61
+ ProtoOAExecutionType.DEPOSIT_WITHDRAW: ExecutionType.DEPOSIT_WITHDRAW,
62
+ ProtoOAExecutionType.ORDER_PARTIAL_FILL: ExecutionType.ORDER_PARTIAL_FILL,
63
+ ProtoOAExecutionType.BONUS_DEPOSIT_WITHDRAW: ExecutionType.BONUS_DEPOSIT_WITHDRAW,
64
+ }
65
+
66
+
67
+ class EventRouter:
68
+ """Routes proto events from Protocol to typed events on EventEmitter.
69
+
70
+ Registers handlers for all relevant proto event types with the Protocol,
71
+ converts them to typed event dataclasses, and emits them through the
72
+ EventEmitter.
73
+
74
+ Example:
75
+ ```python
76
+ router = EventRouter(protocol, emitter)
77
+ router.start()
78
+
79
+ # Now proto events will be converted and emitted
80
+ # ...
81
+
82
+ router.stop()
83
+ ```
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ protocol: Protocol,
89
+ emitter: EventEmitter,
90
+ ) -> None:
91
+ """Initialize the event router.
92
+
93
+ Args:
94
+ protocol: The protocol instance to receive proto events from.
95
+ emitter: The event emitter to publish typed events to.
96
+ """
97
+ self._protocol = protocol
98
+ self._emitter = emitter
99
+ self._started = False
100
+
101
+ @property
102
+ def is_started(self) -> bool:
103
+ """Whether the router is currently started."""
104
+ return self._started
105
+
106
+ def start(self) -> None:
107
+ """Idempotent. Register handlers for all proto event types."""
108
+ if self._started:
109
+ return
110
+
111
+ self._protocol.on_event(ProtoOASpotEvent, self._handle_spot)
112
+ self._protocol.on_event(ProtoOAExecutionEvent, self._handle_execution)
113
+ self._protocol.on_event(ProtoOAOrderErrorEvent, self._handle_order_error)
114
+ self._protocol.on_event(ProtoOATraderUpdatedEvent, self._handle_trader_update)
115
+ self._protocol.on_event(ProtoOAMarginChangedEvent, self._handle_margin_change)
116
+ self._protocol.on_event(ProtoOADepthEvent, self._handle_depth)
117
+ self._protocol.on_event(
118
+ ProtoOAAccountsTokenInvalidatedEvent,
119
+ self._handle_token_invalidated,
120
+ )
121
+ self._protocol.on_event(
122
+ ProtoOAClientDisconnectEvent,
123
+ self._handle_client_disconnect,
124
+ )
125
+ self._protocol.on_event(
126
+ ProtoOAAccountDisconnectEvent,
127
+ self._handle_account_disconnect,
128
+ )
129
+ self._protocol.on_event(ProtoOASymbolChangedEvent, self._handle_symbol_changed)
130
+ self._protocol.on_event(
131
+ ProtoOATrailingSLChangedEvent,
132
+ self._handle_trailing_stop_changed,
133
+ )
134
+ self._protocol.on_event(
135
+ ProtoOAMarginCallTriggerEvent,
136
+ self._handle_margin_call_trigger,
137
+ )
138
+ self._protocol.on_event(ProtoOAv1PnLChangeEvent, self._handle_pnl_change)
139
+
140
+ self._started = True
141
+ logger.debug("Event router started")
142
+
143
+ def stop(self) -> None:
144
+ """Idempotent. Unregister all proto event handlers."""
145
+ if not self._started:
146
+ return
147
+
148
+ self._protocol.remove_handler(ProtoOASpotEvent, self._handle_spot)
149
+ self._protocol.remove_handler(ProtoOAExecutionEvent, self._handle_execution)
150
+ self._protocol.remove_handler(ProtoOAOrderErrorEvent, self._handle_order_error)
151
+ self._protocol.remove_handler(ProtoOATraderUpdatedEvent, self._handle_trader_update)
152
+ self._protocol.remove_handler(ProtoOAMarginChangedEvent, self._handle_margin_change)
153
+ self._protocol.remove_handler(ProtoOADepthEvent, self._handle_depth)
154
+ self._protocol.remove_handler(
155
+ ProtoOAAccountsTokenInvalidatedEvent,
156
+ self._handle_token_invalidated,
157
+ )
158
+ self._protocol.remove_handler(
159
+ ProtoOAClientDisconnectEvent,
160
+ self._handle_client_disconnect,
161
+ )
162
+ self._protocol.remove_handler(
163
+ ProtoOAAccountDisconnectEvent,
164
+ self._handle_account_disconnect,
165
+ )
166
+ self._protocol.remove_handler(ProtoOASymbolChangedEvent, self._handle_symbol_changed)
167
+ self._protocol.remove_handler(
168
+ ProtoOATrailingSLChangedEvent,
169
+ self._handle_trailing_stop_changed,
170
+ )
171
+ self._protocol.remove_handler(
172
+ ProtoOAMarginCallTriggerEvent,
173
+ self._handle_margin_call_trigger,
174
+ )
175
+ self._protocol.remove_handler(ProtoOAv1PnLChangeEvent, self._handle_pnl_change)
176
+
177
+ self._started = False
178
+ logger.debug("Event router stopped")
179
+
180
+ # -------------------------------------------------------------------------
181
+ # Proto to Event Converters
182
+ # -------------------------------------------------------------------------
183
+
184
+ async def _handle_spot(self, proto: ProtoOASpotEvent) -> None:
185
+ """Convert ProtoOASpotEvent to SpotEvent."""
186
+ event = SpotEvent(
187
+ account_id=proto.ctid_trader_account_id,
188
+ symbol_id=proto.symbol_id,
189
+ bid=proto.bid if proto.bid else None,
190
+ ask=proto.ask if proto.ask else None,
191
+ timestamp=self._timestamp_to_datetime(proto.timestamp) if proto.timestamp else datetime.now(UTC),
192
+ )
193
+ await self._emitter.emit(event)
194
+
195
+ async def _handle_execution(self, proto: ProtoOAExecutionEvent) -> None:
196
+ """Convert ProtoOAExecutionEvent to ExecutionEvent."""
197
+ # Map execution type
198
+ exec_type = _EXECUTION_TYPE_MAP.get(proto.execution_type)
199
+
200
+ if exec_type is None:
201
+ logger.warning("Unknown execution type %s in ProtoOAExecutionEvent", proto.execution_type)
202
+ return
203
+
204
+ # Map order side
205
+ side = OrderSide.BUY
206
+ if proto.order and proto.order.trade_data:
207
+ # ProtoOATradeSide: BUY=1, SELL=2
208
+ if proto.order.trade_data.trade_side == 2:
209
+ side = OrderSide.SELL
210
+
211
+ # Extract order details
212
+ order_id = proto.order.order_id if proto.order else 0
213
+ position_id = proto.position.position_id if proto.position else None
214
+ symbol_id = proto.order.trade_data.symbol_id if proto.order and proto.order.trade_data else 0
215
+
216
+ # Extract deal details
217
+ filled_volume = None
218
+ fill_price = None
219
+ timestamp = datetime.now(UTC)
220
+
221
+ if proto.deal:
222
+ filled_volume = proto.deal.filled_volume if proto.deal.filled_volume else None
223
+ if proto.deal.execution_price:
224
+ fill_price = Decimal(str(proto.deal.execution_price))
225
+ if proto.deal.execution_timestamp:
226
+ timestamp = self._timestamp_to_datetime(proto.deal.execution_timestamp)
227
+
228
+ event = ExecutionEvent(
229
+ account_id=proto.ctid_trader_account_id,
230
+ execution_type=exec_type,
231
+ order_id=order_id,
232
+ position_id=position_id,
233
+ symbol_id=symbol_id,
234
+ side=side,
235
+ filled_volume=filled_volume,
236
+ fill_price=fill_price,
237
+ timestamp=timestamp,
238
+ is_server_event=proto.is_server_event if proto.is_server_event else False,
239
+ error_code=proto.error_code if proto.error_code else None,
240
+ )
241
+ await self._emitter.emit(event)
242
+
243
+ async def _handle_order_error(self, proto: ProtoOAOrderErrorEvent) -> None:
244
+ """Convert ProtoOAOrderErrorEvent to OrderErrorEvent."""
245
+ event = OrderErrorEvent(
246
+ account_id=proto.ctid_trader_account_id,
247
+ order_id=proto.order_id if proto.order_id else None,
248
+ position_id=proto.position_id if proto.position_id else None,
249
+ error_code=proto.error_code,
250
+ description=proto.description or "",
251
+ )
252
+ await self._emitter.emit(event)
253
+
254
+ async def _handle_trader_update(self, proto: ProtoOATraderUpdatedEvent) -> None:
255
+ """Convert ProtoOATraderUpdatedEvent to TraderUpdateEvent."""
256
+ trader = proto.trader
257
+ if not trader:
258
+ return
259
+
260
+ event = TraderUpdateEvent(
261
+ account_id=proto.ctid_trader_account_id,
262
+ balance=trader.balance,
263
+ leverage_in_cents=trader.leverage_in_cents if trader.leverage_in_cents else None,
264
+ money_digits=trader.money_digits if trader.money_digits else 2,
265
+ )
266
+ await self._emitter.emit(event)
267
+
268
+ async def _handle_margin_change(self, proto: ProtoOAMarginChangedEvent) -> None:
269
+ """Convert ProtoOAMarginChangedEvent to MarginChangeEvent."""
270
+ event = MarginChangeEvent(
271
+ account_id=proto.ctid_trader_account_id,
272
+ position_id=proto.position_id,
273
+ used_margin=proto.used_margin,
274
+ money_digits=proto.money_digits if proto.money_digits else 2,
275
+ )
276
+ await self._emitter.emit(event)
277
+
278
+ async def _handle_depth(self, proto: ProtoOADepthEvent) -> None:
279
+ """Convert ProtoOADepthEvent to DepthEvent."""
280
+ # Convert depth quotes
281
+ new_quotes: list[DepthQuote] = []
282
+ for q in proto.new_quotes:
283
+ # Each quote has either bid or ask set, not both
284
+ if q.bid:
285
+ new_quotes.append(
286
+ DepthQuote(
287
+ quote_id=q.id,
288
+ price=q.bid,
289
+ size=q.size,
290
+ is_bid=True,
291
+ )
292
+ )
293
+ elif q.ask:
294
+ new_quotes.append(
295
+ DepthQuote(
296
+ quote_id=q.id,
297
+ price=q.ask,
298
+ size=q.size,
299
+ is_bid=False,
300
+ )
301
+ )
302
+
303
+ event = DepthEvent(
304
+ account_id=proto.ctid_trader_account_id,
305
+ symbol_id=proto.symbol_id,
306
+ new_quotes=tuple(new_quotes),
307
+ deleted_quote_ids=tuple(proto.deleted_quotes),
308
+ )
309
+ await self._emitter.emit(event)
310
+
311
+ async def _handle_token_invalidated(
312
+ self,
313
+ proto: ProtoOAAccountsTokenInvalidatedEvent,
314
+ ) -> None:
315
+ """Convert ProtoOAAccountsTokenInvalidatedEvent to TokenInvalidatedEvent."""
316
+ event = TokenInvalidatedEvent(
317
+ account_ids=tuple(proto.ctid_trader_account_ids),
318
+ reason=proto.reason or "Unknown",
319
+ )
320
+ await self._emitter.emit(event)
321
+
322
+ async def _handle_client_disconnect(
323
+ self,
324
+ proto: ProtoOAClientDisconnectEvent,
325
+ ) -> None:
326
+ """Convert ProtoOAClientDisconnectEvent to ClientDisconnectEvent."""
327
+ event = ClientDisconnectEvent(
328
+ reason=proto.reason or "Unknown",
329
+ )
330
+ await self._emitter.emit(event)
331
+
332
+ async def _handle_account_disconnect(
333
+ self,
334
+ proto: ProtoOAAccountDisconnectEvent,
335
+ ) -> None:
336
+ """Convert ProtoOAAccountDisconnectEvent to AccountDisconnectEvent."""
337
+ event = AccountDisconnectEvent(
338
+ account_id=proto.ctid_trader_account_id,
339
+ )
340
+ await self._emitter.emit(event)
341
+
342
+ async def _handle_symbol_changed(
343
+ self,
344
+ proto: ProtoOASymbolChangedEvent,
345
+ ) -> None:
346
+ """Convert ProtoOASymbolChangedEvent to SymbolChangedEvent."""
347
+ event = SymbolChangedEvent(
348
+ account_id=proto.ctid_trader_account_id,
349
+ symbol_ids=tuple(proto.symbol_id),
350
+ )
351
+ await self._emitter.emit(event)
352
+
353
+ async def _handle_trailing_stop_changed(
354
+ self,
355
+ proto: ProtoOATrailingSLChangedEvent,
356
+ ) -> None:
357
+ """Convert ProtoOATrailingSLChangedEvent to TrailingStopChangedEvent."""
358
+ event = TrailingStopChangedEvent(
359
+ account_id=proto.ctid_trader_account_id,
360
+ position_id=proto.position_id,
361
+ order_id=proto.order_id,
362
+ stop_price=Decimal(str(proto.stop_price)),
363
+ timestamp=self._timestamp_to_datetime(proto.utc_last_update_timestamp),
364
+ )
365
+ await self._emitter.emit(event)
366
+
367
+ async def _handle_margin_call_trigger(
368
+ self,
369
+ proto: ProtoOAMarginCallTriggerEvent,
370
+ ) -> None:
371
+ """Convert ProtoOAMarginCallTriggerEvent to MarginCallTriggerEvent."""
372
+ margin_call = proto.margin_call
373
+ event = MarginCallTriggerEvent(
374
+ account_id=proto.ctid_trader_account_id,
375
+ margin_call_type=margin_call.margin_call_type,
376
+ margin_level_threshold=Decimal(str(margin_call.margin_level_threshold)),
377
+ )
378
+ await self._emitter.emit(event)
379
+
380
+ async def _handle_pnl_change(
381
+ self,
382
+ proto: ProtoOAv1PnLChangeEvent,
383
+ ) -> None:
384
+ """Convert ProtoOAv1PnLChangeEvent to PnLChangeEvent."""
385
+ event = PnLChangeEvent(
386
+ account_id=proto.ctid_trader_account_id,
387
+ gross_unrealized_pnl=proto.gross_unrealized_pn_l,
388
+ net_unrealized_pnl=proto.net_unrealized_pn_l,
389
+ money_digits=proto.money_digits if proto.money_digits else 2,
390
+ )
391
+ await self._emitter.emit(event)
392
+
393
+ # -------------------------------------------------------------------------
394
+ # Helpers
395
+ # -------------------------------------------------------------------------
396
+
397
+ @staticmethod
398
+ def _timestamp_to_datetime(timestamp_ms: int) -> datetime:
399
+ """Convert millisecond timestamp to datetime."""
400
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)