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,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)
|