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,475 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, TypeVar, overload
6
+
7
+ from .api import AccountsAPI, MarketDataAPI, SymbolsAPI, TradingAPI
8
+ from .auth import AuthManager
9
+ from .config import ClientConfig
10
+ from .connection import HeartbeatManager, Protocol, Transport
11
+ from .events import (
12
+ AccountDisconnectEvent,
13
+ ClientDisconnectEvent,
14
+ DepthEvent,
15
+ Event,
16
+ EventEmitter,
17
+ EventRouter,
18
+ ExecutionEvent,
19
+ MarginCallTriggerEvent,
20
+ MarginChangeEvent,
21
+ OrderErrorEvent,
22
+ PnLChangeEvent,
23
+ ReadyEvent,
24
+ ReconnectedEvent,
25
+ SpotEvent,
26
+ SymbolChangedEvent,
27
+ TokenInvalidatedEvent,
28
+ TraderUpdateEvent,
29
+ TrailingStopChangedEvent,
30
+ )
31
+
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ T = TypeVar("T", bound=Event)
36
+ EventHandler = Callable[[T], Awaitable[None]]
37
+
38
+ # Constrained TypeVars for overloaded on() method
39
+ # Events that support both account_id and symbol_id filters
40
+ T_BothFilters = TypeVar("T_BothFilters", SpotEvent, ExecutionEvent, DepthEvent)
41
+
42
+ # Events that support only account_id filter
43
+ T_AccountIdOnly = TypeVar(
44
+ "T_AccountIdOnly",
45
+ ReadyEvent,
46
+ OrderErrorEvent,
47
+ TraderUpdateEvent,
48
+ MarginChangeEvent,
49
+ AccountDisconnectEvent,
50
+ SymbolChangedEvent,
51
+ TrailingStopChangedEvent,
52
+ MarginCallTriggerEvent,
53
+ PnLChangeEvent,
54
+ )
55
+
56
+ # Events that support no filters
57
+ T_NoFilters = TypeVar(
58
+ "T_NoFilters",
59
+ ReconnectedEvent,
60
+ ClientDisconnectEvent,
61
+ TokenInvalidatedEvent,
62
+ )
63
+
64
+
65
+ class CTraderClient:
66
+ """Unified cTrader API client.
67
+
68
+ Provides access to all API operations through namespaced interfaces
69
+ and supports decorator-based event registration.
70
+
71
+ Example:
72
+ ```python
73
+ from ctrader_api_client import CTraderClient, ClientConfig
74
+ from ctrader_api_client.events import SpotEvent
75
+
76
+ config = ClientConfig(
77
+ client_id="your_client_id",
78
+ client_secret="your_client_secret",
79
+ )
80
+
81
+ client = CTraderClient(config)
82
+
83
+
84
+ @client.on(SpotEvent, symbol_id=270)
85
+ async def on_eurusd(event: SpotEvent) -> None:
86
+ print(f"EURUSD: {event.bid}/{event.ask}")
87
+
88
+
89
+ async with client:
90
+ await client.auth.authenticate_app()
91
+ creds = await client.auth.authenticate_by_trader_login(
92
+ trader_login=17091452,
93
+ access_token="...",
94
+ refresh_token="...",
95
+ expires_at=1778617423,
96
+ )
97
+ await client.market_data.subscribe_spots(creds.account_id, [270])
98
+
99
+ await asyncio.Event().wait() # Run forever
100
+ ```
101
+
102
+ Attributes:
103
+ auth: Authentication operations (app auth, account auth, token refresh).
104
+ accounts: Account information operations.
105
+ symbols: Symbol lookup and search.
106
+ trading: Order and position operations.
107
+ market_data: Market data subscriptions and historical data.
108
+ protocol: Low-level protocol access for advanced usage.
109
+ """
110
+
111
+ def __init__(self, config: ClientConfig) -> None:
112
+ """Initialize the client.
113
+
114
+ Args:
115
+ config: Client configuration including credentials and settings.
116
+ """
117
+ self._config = config
118
+
119
+ # Connection layer
120
+ self._transport = Transport(
121
+ host=config.host,
122
+ port=config.port,
123
+ use_ssl=config.use_ssl,
124
+ )
125
+ self._protocol = Protocol(
126
+ transport=self._transport,
127
+ reconnect_attempts=config.reconnect_attempts,
128
+ reconnect_min_wait=config.reconnect_min_wait,
129
+ reconnect_max_wait=config.reconnect_max_wait,
130
+ )
131
+ self._heartbeat = HeartbeatManager(
132
+ protocol=self._protocol,
133
+ interval=config.heartbeat_interval,
134
+ timeout=config.heartbeat_timeout,
135
+ )
136
+
137
+ # Event system
138
+ self._emitter = EventEmitter()
139
+ self._router = EventRouter(
140
+ protocol=self._protocol,
141
+ emitter=self._emitter,
142
+ )
143
+
144
+ # Auth manager
145
+ self._auth = AuthManager(
146
+ protocol=self._protocol,
147
+ client_id=config.client_id,
148
+ client_secret=config.client_secret,
149
+ on_account_ready=self._emit_ready_event,
150
+ )
151
+
152
+ # API namespaces
153
+ self._accounts = AccountsAPI(
154
+ protocol=self._protocol,
155
+ default_timeout=config.request_timeout,
156
+ )
157
+ self._symbols = SymbolsAPI(
158
+ protocol=self._protocol,
159
+ default_timeout=config.request_timeout,
160
+ )
161
+ self._trading = TradingAPI(
162
+ protocol=self._protocol,
163
+ default_timeout=config.request_timeout,
164
+ )
165
+ self._market_data = MarketDataAPI(
166
+ protocol=self._protocol,
167
+ default_timeout=config.request_timeout,
168
+ )
169
+
170
+ self._connected = False
171
+
172
+ # Set up reconnection handler
173
+ self._protocol._on_reconnect = self._handle_reconnect
174
+
175
+ # -------------------------------------------------------------------------
176
+ # Properties
177
+ # -------------------------------------------------------------------------
178
+
179
+ @property
180
+ def auth(self) -> AuthManager:
181
+ """Authentication operations.
182
+
183
+ Provides methods for:
184
+ - Application authentication
185
+ - Account authentication
186
+ - Token refresh management
187
+ - Account discovery
188
+ """
189
+ return self._auth
190
+
191
+ @property
192
+ def accounts(self) -> AccountsAPI:
193
+ """Account information operations.
194
+
195
+ Provides methods to retrieve account/trader details.
196
+ """
197
+ return self._accounts
198
+
199
+ @property
200
+ def symbols(self) -> SymbolsAPI:
201
+ """Symbol lookup and search.
202
+
203
+ Provides methods to list, retrieve, and search trading symbols.
204
+ """
205
+ return self._symbols
206
+
207
+ @property
208
+ def trading(self) -> TradingAPI:
209
+ """Order and position operations.
210
+
211
+ Provides methods for:
212
+ - Placing orders (market, limit, stop)
213
+ - Modifying orders
214
+ - Canceling orders
215
+ - Closing positions
216
+ - Querying positions and orders
217
+ """
218
+ return self._trading
219
+
220
+ @property
221
+ def market_data(self) -> MarketDataAPI:
222
+ """Market data subscriptions and historical data.
223
+
224
+ Provides methods for:
225
+ - Subscribing to spot prices
226
+ - Subscribing to trendbars (candles)
227
+ - Subscribing to depth of market
228
+ - Retrieving historical data
229
+ """
230
+ return self._market_data
231
+
232
+ @property
233
+ def is_connected(self) -> bool:
234
+ """Whether the client is connected to the server."""
235
+ return self._connected and self._transport.is_connected
236
+
237
+ @property
238
+ def protocol(self) -> Protocol:
239
+ """Direct access to the protocol layer.
240
+
241
+ For advanced usage when you need to send raw protobuf messages
242
+ or handle responses not covered by the high-level API.
243
+ """
244
+ return self._protocol
245
+
246
+ # -------------------------------------------------------------------------
247
+ # Lifecycle
248
+ # -------------------------------------------------------------------------
249
+
250
+ async def connect(self) -> None:
251
+ """Connect to the cTrader server.
252
+
253
+ Establishes the TCP/SSL connection, starts the protocol reader,
254
+ heartbeat monitor, and event router.
255
+
256
+ Raises:
257
+ CTraderConnectionFailedError: If connection cannot be established.
258
+ """
259
+ if self._connected:
260
+ return
261
+
262
+ logger.info("Connecting to %s:%d", self._config.host, self._config.port)
263
+
264
+ await self._transport.connect()
265
+ await self._protocol.start()
266
+ await self._heartbeat.start()
267
+ self._router.start()
268
+
269
+ self._connected = True
270
+ logger.info("Connected successfully")
271
+
272
+ async def close(self) -> None:
273
+ """Close the connection and clean up resources.
274
+
275
+ Stops the event router, heartbeat monitor, protocol reader,
276
+ and closes the transport.
277
+ """
278
+ if not self._connected:
279
+ return
280
+
281
+ logger.info("Closing connection")
282
+
283
+ self._router.stop()
284
+ await self._auth.stop()
285
+ await self._heartbeat.stop()
286
+ await self._protocol.stop()
287
+ await self._transport.close()
288
+
289
+ self._connected = False
290
+ logger.info("Connection closed")
291
+
292
+ async def _emit_ready_event(self, account_id: int, is_reconnect: bool) -> None:
293
+ """Emit ReadyEvent when an account is authenticated.
294
+
295
+ Called by AuthManager after successful account authentication.
296
+
297
+ Args:
298
+ account_id: The authenticated account ID.
299
+ is_reconnect: True if this is a re-authentication after reconnection.
300
+ """
301
+ await self._emitter.emit(ReadyEvent(account_id=account_id, is_reconnect=is_reconnect))
302
+
303
+ async def _handle_reconnect(self) -> None:
304
+ """Handle automatic reconnection by re-authenticating.
305
+
306
+ Called by Protocol after successful reconnection. Re-authenticates
307
+ the app and all previously authenticated accounts, then emits
308
+ a ReconnectedEvent so users can restore subscriptions.
309
+ """
310
+ logger.info("Connection restored, re-authenticating...")
311
+
312
+ # Restart heartbeat monitoring
313
+ await self._heartbeat.restart()
314
+
315
+ restored: list[int] = []
316
+ failed: list[tuple[int, str]] = []
317
+
318
+ # Re-authenticate app
319
+ try:
320
+ await self._auth.authenticate_app()
321
+ app_auth_restored = True
322
+ logger.info("App re-authenticated successfully")
323
+ except Exception as e:
324
+ logger.error("Failed to re-authenticate app after reconnect: %s", e)
325
+ app_auth_restored = False
326
+ # Emit event with failure - user must handle this
327
+ await self._emitter.emit(
328
+ ReconnectedEvent(
329
+ app_auth_restored=False,
330
+ restored_accounts=(),
331
+ failed_accounts=(),
332
+ )
333
+ )
334
+ return
335
+
336
+ # Re-authenticate all previously authenticated accounts
337
+ for account_id, credentials in list(self._auth._accounts.items()):
338
+ try:
339
+ await self._auth.authenticate_account(credentials, reauth=True)
340
+ restored.append(account_id)
341
+ logger.info("Re-authenticated account %d", account_id)
342
+ except Exception as e:
343
+ logger.error("Failed to re-authenticate account %d: %s", account_id, e)
344
+ failed.append((account_id, str(e)))
345
+
346
+ # Emit event for user to handle subscriptions
347
+ await self._emitter.emit(
348
+ ReconnectedEvent(
349
+ app_auth_restored=app_auth_restored,
350
+ restored_accounts=tuple(restored),
351
+ failed_accounts=tuple(failed),
352
+ )
353
+ )
354
+
355
+ async def __aenter__(self) -> CTraderClient:
356
+ """Async context manager entry.
357
+
358
+ Connects to the server automatically.
359
+
360
+ Returns:
361
+ The client instance.
362
+ """
363
+ await self.connect()
364
+ return self
365
+
366
+ async def __aexit__(
367
+ self,
368
+ _exc_type: type[BaseException] | None,
369
+ _exc_val: BaseException | None,
370
+ _exc_tb: Any,
371
+ ) -> None:
372
+ """Async context manager exit.
373
+
374
+ Closes the connection automatically.
375
+ """
376
+ await self.close()
377
+
378
+ # -------------------------------------------------------------------------
379
+ # Event Registration
380
+ # -------------------------------------------------------------------------
381
+
382
+ @overload
383
+ def on(
384
+ self,
385
+ event_type: type[T_BothFilters],
386
+ *,
387
+ account_id: int | None = ...,
388
+ symbol_id: int | None = ...,
389
+ ) -> Callable[[EventHandler[T_BothFilters]], EventHandler[T_BothFilters]]: ...
390
+
391
+ @overload
392
+ def on(
393
+ self,
394
+ event_type: type[T_AccountIdOnly],
395
+ *,
396
+ account_id: int | None = ...,
397
+ ) -> Callable[[EventHandler[T_AccountIdOnly]], EventHandler[T_AccountIdOnly]]: ...
398
+
399
+ @overload
400
+ def on(
401
+ self,
402
+ event_type: type[T_NoFilters],
403
+ ) -> Callable[[EventHandler[T_NoFilters]], EventHandler[T_NoFilters]]: ...
404
+
405
+ def on(
406
+ self,
407
+ event_type: type[T],
408
+ *,
409
+ account_id: int | None = None,
410
+ symbol_id: int | None = None,
411
+ ) -> Callable[[EventHandler[T]], EventHandler[T]]:
412
+ """Decorator to register an event handler.
413
+
414
+ Handlers are called when events of the specified type arrive.
415
+ Optional filters can be used to only receive events for specific
416
+ accounts or symbols. The event must have the corresponding account_id or symbol_id attributes
417
+ for filtering to work. Else this will raise ValueError at registration time.
418
+
419
+ Args:
420
+ event_type: The event class to listen for.
421
+ account_id: Only receive events for this account (optional).
422
+ symbol_id: Only receive events for this symbol (optional).
423
+
424
+ Returns:
425
+ Decorator function that registers the handler.
426
+
427
+ Example:
428
+ ```python
429
+ @client.on(SpotEvent, symbol_id=270)
430
+ async def on_eurusd(event: SpotEvent) -> None:
431
+ print(f"EURUSD: {event.bid}/{event.ask}")
432
+
433
+
434
+ @client.on(ExecutionEvent)
435
+ async def on_execution(event: ExecutionEvent) -> None:
436
+ print(f"Order {event.order_id}: {event.execution_type}")
437
+ ```
438
+ """
439
+
440
+ def decorator(handler: EventHandler[T]) -> EventHandler[T]:
441
+ self._emitter.subscribe(
442
+ event_type,
443
+ handler,
444
+ account_id=account_id,
445
+ symbol_id=symbol_id,
446
+ )
447
+ return handler
448
+
449
+ return decorator
450
+
451
+ def off(
452
+ self,
453
+ event_type: type[T],
454
+ handler: EventHandler[T],
455
+ ) -> bool:
456
+ """Unregister an event handler.
457
+
458
+ Args:
459
+ event_type: The event class.
460
+ handler: The handler function to remove.
461
+
462
+ Returns:
463
+ True if handler was found and removed, False otherwise.
464
+
465
+ Example:
466
+ ```python
467
+ @client.on(SpotEvent)
468
+ async def handler(event: SpotEvent) -> None: ...
469
+
470
+
471
+ # Later, unregister
472
+ client.off(SpotEvent, handler)
473
+ ```
474
+ """
475
+ return self._emitter.unsubscribe(event_type, handler)
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class ClientConfig(BaseModel):
7
+ """Configuration for CTraderClient.
8
+
9
+ Attributes:
10
+ host: cTrader API server hostname.
11
+ port: cTrader API server port.
12
+ use_ssl: Whether to use SSL/TLS encryption.
13
+ client_id: OAuth application client ID.
14
+ client_secret: OAuth application client secret.
15
+ heartbeat_interval: Seconds between heartbeat sends.
16
+ heartbeat_timeout: Seconds without server heartbeat before disconnect. Set to 0 to disable.
17
+ request_timeout: Default timeout for API requests in seconds.
18
+ reconnect_attempts: Max reconnection attempts (0 to disable).
19
+ reconnect_min_wait: Initial wait between reconnection attempts.
20
+ reconnect_max_wait: Maximum wait between reconnection attempts.
21
+
22
+ Example:
23
+ ```python
24
+ config = ClientConfig(client_id="your_client_id", client_secret="your_client_secret")
25
+
26
+ # For demo server
27
+ demo_config = ClientConfig(
28
+ host="demo.ctraderapi.com",
29
+ client_id="your_client_id",
30
+ client_secret="your_client_secret",
31
+ )
32
+ ```
33
+ """
34
+
35
+ # Connection settings
36
+ host: str = "live.ctraderapi.com"
37
+ port: int = 5035
38
+ use_ssl: bool = True
39
+
40
+ # OAuth credentials
41
+ client_id: str
42
+ client_secret: str
43
+
44
+ # Heartbeat settings
45
+ heartbeat_interval: float = Field(default=10.0, gt=0)
46
+ heartbeat_timeout: float = Field(default=30.0, ge=0)
47
+
48
+ # Request settings
49
+ request_timeout: float = Field(default=30.0, gt=0)
50
+
51
+ # Reconnection settings
52
+ reconnect_attempts: int = Field(default=5, ge=0)
53
+ reconnect_min_wait: float = Field(default=1.0, gt=0)
54
+ reconnect_max_wait: float = Field(default=60.0, gt=0)
55
+
56
+ model_config = ConfigDict(frozen=True)
@@ -0,0 +1,16 @@
1
+ """Connection layer for cTrader API.
2
+
3
+ This module provides TCP/SSL transport, message protocol handling,
4
+ and heartbeat management for maintaining connections to cTrader servers.
5
+ """
6
+
7
+ from .heartbeat import HeartbeatManager
8
+ from .protocol import Protocol
9
+ from .transport import Transport
10
+
11
+
12
+ __all__ = [
13
+ "HeartbeatManager",
14
+ "Protocol",
15
+ "Transport",
16
+ ]
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+
6
+ import anyio
7
+ import anyio.abc
8
+
9
+ from .._internal.proto import ProtoHeartbeatEvent
10
+ from .protocol import Protocol
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class HeartbeatManager:
17
+ """Manages heartbeat send/receive for keep-alive.
18
+
19
+ Sends periodic heartbeats to the server and monitors for incoming
20
+ heartbeats to detect connection loss.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ protocol: Protocol,
26
+ interval: float = 10.0,
27
+ timeout: float = 30.0,
28
+ ) -> None:
29
+ """Initialize the heartbeat manager.
30
+
31
+ Args:
32
+ protocol: The protocol instance to send/receive through.
33
+ interval: Seconds between heartbeat sends.
34
+ timeout: Seconds without server heartbeat before triggering disconnect.
35
+ """
36
+ self._protocol = protocol
37
+ self._interval = interval
38
+ self._timeout = timeout
39
+ self._last_received: float = 0.0
40
+ self._task_scope: anyio.CancelScope | None = None
41
+ self._task_group: anyio.abc.TaskGroup | None = None
42
+
43
+ async def start(self) -> None:
44
+ """Start heartbeat monitoring.
45
+
46
+ Registers an event handler for incoming heartbeats and starts
47
+ the heartbeat send loop.
48
+ """
49
+ # Register handler for incoming heartbeats
50
+ self._protocol.on_event(ProtoHeartbeatEvent, self._on_heartbeat)
51
+ self._last_received = time.monotonic()
52
+
53
+ # Start heartbeat loop in background
54
+ self._task_group = anyio.create_task_group()
55
+ await self._task_group.__aenter__()
56
+ self._task_group.start_soon(self._heartbeat_loop)
57
+
58
+ async def stop(self) -> None:
59
+ """Stop heartbeat monitoring.
60
+
61
+ Cancels the heartbeat loop and removes the event handler.
62
+ """
63
+ if self._task_scope is not None:
64
+ self._task_scope.cancel()
65
+
66
+ if self._task_group is not None:
67
+ self._task_group.cancel_scope.cancel()
68
+ try:
69
+ await self._task_group.__aexit__(None, None, None)
70
+ except Exception:
71
+ pass
72
+ self._task_group = None
73
+
74
+ self._protocol.remove_handler(ProtoHeartbeatEvent, self._on_heartbeat)
75
+
76
+ async def restart(self) -> None:
77
+ """Restart heartbeat monitoring after reconnection.
78
+
79
+ Resets the heartbeat timer and spawns a new heartbeat loop.
80
+ Should be called after the protocol has reconnected.
81
+ """
82
+ self._last_received = time.monotonic()
83
+ if self._task_group is not None:
84
+ self._task_group.start_soon(self._heartbeat_loop)
85
+
86
+ async def _on_heartbeat(self, _event: ProtoHeartbeatEvent) -> None:
87
+ """Handler called when heartbeat received from server.
88
+
89
+ Args:
90
+ _event: The heartbeat event from the server (unused).
91
+ """
92
+ self._last_received = time.monotonic()
93
+ logger.debug("Heartbeat received from server")
94
+
95
+ async def _heartbeat_loop(self) -> None:
96
+ """Periodically send heartbeats and check for timeout."""
97
+ with anyio.CancelScope() as scope:
98
+ self._task_scope = scope
99
+ while True:
100
+ await anyio.sleep(self._interval)
101
+
102
+ # Check if server heartbeat received recently
103
+ elapsed = time.monotonic() - self._last_received
104
+ if 0 < self._timeout < elapsed:
105
+ logger.warning(
106
+ "Heartbeat timeout: no heartbeat received in %.1f seconds",
107
+ elapsed,
108
+ )
109
+ # Heartbeat timeout - trigger disconnect handling
110
+ await self._protocol.handle_disconnect()
111
+ return
112
+
113
+ # Send client heartbeat
114
+ try:
115
+ await self._protocol.send_event(ProtoHeartbeatEvent())
116
+ logger.debug("Heartbeat sent to server")
117
+ except Exception as e:
118
+ logger.warning("Failed to send heartbeat: %s", e)
119
+ # Protocol will handle reconnection
120
+ return