ctrader-api-client 0.2.1__tar.gz → 0.2.3__tar.gz

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 (93) hide show
  1. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/.pre-commit-config.yaml +8 -0
  2. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/Justfile +1 -0
  3. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/PKG-INFO +1 -1
  4. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/pyproject.toml +1 -1
  5. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/api/market_data.py +1 -1
  6. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/config.py +2 -2
  7. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/connection/heartbeat.py +12 -10
  8. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/connection/protocol.py +10 -7
  9. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/market_data.py +11 -11
  10. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/connection/test_heartbeat.py +40 -14
  11. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/connection/test_protocol.py +23 -0
  12. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_market_data.py +2 -2
  13. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/uv.lock +1 -1
  14. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/.claude/settings.local.json +0 -0
  15. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/.github/workflows/docs.yml +0 -0
  16. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/.gitignore +0 -0
  17. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/.python-version +0 -0
  18. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/LICENSE +0 -0
  19. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/README.md +0 -0
  20. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/accounts.md +0 -0
  21. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/client.md +0 -0
  22. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/enums.md +0 -0
  23. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/events.md +0 -0
  24. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/market-data.md +0 -0
  25. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/models.md +0 -0
  26. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/symbols.md +0 -0
  27. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/api/trading.md +0 -0
  28. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/getting-started.md +0 -0
  29. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/docs/index.md +0 -0
  30. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/mkdocs.yml +0 -0
  31. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/SOURCE +0 -0
  32. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/VERSION +0 -0
  33. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/update.sh +0 -0
  34. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/vendor/.gitkeep +0 -0
  35. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  36. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  37. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/vendor/OpenApiMessages.proto +0 -0
  38. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/protos/vendor/OpenApiModelMessages.proto +0 -0
  39. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/scripts/fix_proto_imports.py +0 -0
  40. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/__init__.py +0 -0
  41. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/__init__.py +0 -0
  42. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/messages.py +0 -0
  43. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  44. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  45. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  46. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  47. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  48. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/_internal/serialization.py +0 -0
  49. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/api/__init__.py +0 -0
  50. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/api/accounts.py +0 -0
  51. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/api/symbols.py +0 -0
  52. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/api/trading.py +0 -0
  53. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/auth/__init__.py +0 -0
  54. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/auth/credentials.py +0 -0
  55. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/auth/manager.py +0 -0
  56. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/client.py +0 -0
  57. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/connection/__init__.py +0 -0
  58. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/connection/transport.py +0 -0
  59. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/enums.py +0 -0
  60. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/events/__init__.py +0 -0
  61. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/events/emitter.py +0 -0
  62. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/events/router.py +0 -0
  63. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/events/types.py +0 -0
  64. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/exceptions.py +0 -0
  65. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/__init__.py +0 -0
  66. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/_base.py +0 -0
  67. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/account.py +0 -0
  68. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/deal.py +0 -0
  69. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/order.py +0 -0
  70. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/position.py +0 -0
  71. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/requests.py +0 -0
  72. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/models/symbol.py +0 -0
  73. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/src/ctrader_api_client/py.typed +0 -0
  74. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/_internal/test_messages.py +0 -0
  75. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/_internal/test_serialization.py +0 -0
  76. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/api/conftest.py +0 -0
  77. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/api/test_accounts.py +0 -0
  78. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/api/test_market_data_api.py +0 -0
  79. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/api/test_symbols.py +0 -0
  80. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/api/test_trading.py +0 -0
  81. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/auth/test_credentials.py +0 -0
  82. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/auth/test_manager.py +0 -0
  83. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/connection/test_transport.py +0 -0
  84. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/events/test_emitter.py +0 -0
  85. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/events/test_router.py +0 -0
  86. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/events/test_types.py +0 -0
  87. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_account.py +0 -0
  88. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_deal.py +0 -0
  89. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_order.py +0 -0
  90. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_position.py +0 -0
  91. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_requests.py +0 -0
  92. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/models/test_symbol.py +0 -0
  93. {ctrader_api_client-0.2.1 → ctrader_api_client-0.2.3}/tests/unit/test_client.py +0 -0
@@ -1,4 +1,12 @@
1
1
  repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: local-ci
5
+ name: Local CI Checks
6
+ entry: just ci
7
+ language: system
8
+ pass_filenames: false
9
+
2
10
  # Ruff for linting & formatting
3
11
  - repo: https://github.com/astral-sh/ruff-pre-commit
4
12
  rev: v0.12.9
@@ -15,6 +15,7 @@ ci directory='':
15
15
  @just lint {{directory}}
16
16
  @just fmt {{directory}}
17
17
  @just type-check {{directory}}
18
+ @just test {{directory}}
18
19
 
19
20
  # Lint the codebase using ruff, optionally specifying a directory to lint.
20
21
  lint directory='':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: API Client to interact with the cTrader Open API spec
5
5
  Author-email: Elio <elioachukri@pm.me>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -372,7 +372,7 @@ class MarketDataAPI:
372
372
  description=f"Expected ProtoOAGetTrendbarsRes, got {type(response).__name__}",
373
373
  )
374
374
 
375
- return [Trendbar.from_proto(t) for t in response.trendbar]
375
+ return [Trendbar.from_proto(t, historical=True) for t in response.trendbar]
376
376
 
377
377
  async def get_tick_data(
378
378
  self,
@@ -13,7 +13,7 @@ class ClientConfig(BaseModel):
13
13
  client_id: OAuth application client ID.
14
14
  client_secret: OAuth application client secret.
15
15
  heartbeat_interval: Seconds between heartbeat sends.
16
- heartbeat_timeout: Seconds without server heartbeat before disconnect. Set to 0 to disable.
16
+ heartbeat_timeout: Seconds without server-initiated messages before disconnect. Set to 0 to disable.
17
17
  request_timeout: Default timeout for API requests in seconds.
18
18
  reconnect_attempts: Max reconnection attempts (0 to disable).
19
19
  reconnect_min_wait: Initial wait between reconnection attempts.
@@ -43,7 +43,7 @@ class ClientConfig(BaseModel):
43
43
 
44
44
  # Heartbeat settings
45
45
  heartbeat_interval: float = Field(default=10.0, gt=0)
46
- heartbeat_timeout: float = Field(default=0, ge=0)
46
+ heartbeat_timeout: float = Field(default=60.0, ge=0)
47
47
 
48
48
  # Request settings
49
49
  request_timeout: float = Field(default=30.0, gt=0)
@@ -5,6 +5,7 @@ import time
5
5
 
6
6
  import anyio
7
7
  import anyio.abc
8
+ import betterproto
8
9
 
9
10
  from .._internal.proto import ProtoHeartbeatEvent
10
11
  from .protocol import Protocol
@@ -43,10 +44,11 @@ class HeartbeatManager:
43
44
  async def start(self) -> None:
44
45
  """Start heartbeat monitoring.
45
46
 
46
- Registers an event handler for incoming heartbeats and starts
47
- the heartbeat send loop.
47
+ Registers event handlers and starts the heartbeat send loop.
48
48
  """
49
- # Register handler for incoming heartbeats
49
+ # Track activity on any server message, not just heartbeats
50
+ self._protocol.on_event(betterproto.Message, self._record_activity)
51
+ # Keep heartbeat handler for debug logging
50
52
  self._protocol.on_event(ProtoHeartbeatEvent, self._on_heartbeat)
51
53
  self._last_received = time.monotonic()
52
54
 
@@ -58,7 +60,7 @@ class HeartbeatManager:
58
60
  async def stop(self) -> None:
59
61
  """Stop heartbeat monitoring.
60
62
 
61
- Cancels the heartbeat loop and removes the event handler.
63
+ Cancels the heartbeat loop and removes event handlers.
62
64
  """
63
65
  if self._task_scope is not None:
64
66
  self._task_scope.cancel()
@@ -71,6 +73,7 @@ class HeartbeatManager:
71
73
  pass
72
74
  self._task_group = None
73
75
 
76
+ self._protocol.remove_handler(betterproto.Message, self._record_activity)
74
77
  self._protocol.remove_handler(ProtoHeartbeatEvent, self._on_heartbeat)
75
78
 
76
79
  async def restart(self) -> None:
@@ -83,13 +86,12 @@ class HeartbeatManager:
83
86
  if self._task_group is not None:
84
87
  self._task_group.start_soon(self._heartbeat_loop)
85
88
 
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
- """
89
+ async def _record_activity(self, _message: betterproto.Message) -> None:
90
+ """Reset the inactivity timer on any received server message."""
92
91
  self._last_received = time.monotonic()
92
+
93
+ async def _on_heartbeat(self, _event: ProtoHeartbeatEvent) -> None:
94
+ """Handler called when an explicit heartbeat is received from the server."""
93
95
  logger.debug("Heartbeat received from server")
94
96
 
95
97
  async def _heartbeat_loop(self) -> None:
@@ -276,17 +276,20 @@ class Protocol:
276
276
 
277
277
  Handlers are spawned as concurrent tasks to prevent deadlocks if
278
278
  handlers perform some blocking I/O calls that require responses from the reader loop.
279
+ Walks the MRO so handlers registered for a base class (e.g. betterproto.Message)
280
+ are also called for all subclass messages.
279
281
 
280
282
  Args:
281
283
  message: The event message to dispatch.
282
284
  """
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)
285
+ for cls in type(message).__mro__:
286
+ handlers = self._event_handlers.get(cls, [])
287
+ for handler in handlers:
288
+ if self._task_group is not None:
289
+ self._task_group.start_soon(self._call_handler_safe, handler, message)
290
+ else:
291
+ # Fallback if task group not available (shouldn't happen in normal operation)
292
+ await self._call_handler_safe(handler, message)
290
293
 
291
294
  @staticmethod
292
295
  async def _call_handler_safe(
@@ -56,17 +56,15 @@ class Trendbar(FrozenModel):
56
56
  volume: int
57
57
 
58
58
  @classmethod
59
- def from_proto(cls, proto: ProtoOATrendbar, bid_price: float | None = None) -> Trendbar:
60
- """Create Trendbar from proto message.
59
+ def from_proto(cls, proto: ProtoOATrendbar, bid_price: float | None = None, historical: bool = False) -> Trendbar:
60
+ """Create a Trendbar from a proto message.
61
61
 
62
62
  Args:
63
- proto: The proto message.
64
- bid_price: Optional bid price to use for specifying the close price since the API never provides delta_close
65
- on live trendbar updates.
66
-
67
- Returns:
68
- A new Trendbar instance.
63
+ proto: Source proto message.
64
+ bid_price: Bid price used as close when the API omits delta_close on live updates.
65
+ historical: If False, raises on delta_close == 0 instead of silently returning low == close.
69
66
  """
67
+
70
68
  # Calculate timestamp from utc_timestamp_in_minutes
71
69
  ts = datetime.now(UTC)
72
70
  if proto.utc_timestamp_in_minutes:
@@ -80,12 +78,14 @@ class Trendbar(FrozenModel):
80
78
  if bid_price is not None:
81
79
  close = bid_price
82
80
  else:
83
- if proto.delta_close == 0:
84
- # If delta_close is 0, it means the API did not provide a close price (e.g. for live updates).
81
+ if not historical and proto.delta_close == 0:
82
+ # If delta_close is 0 on a live bar, it means the API did not provide a close price.
85
83
  # In this case, we must raise instead of silently returning an incorrect close price.
84
+ # If historical is True, we allow delta_close to be 0 since historical bars can have a close price equal
85
+ # to the low price.
86
86
  raise RuntimeError(
87
87
  "delta_close missing from Trendbar proto and no bid_price was provided to use as fallback."
88
- "Returning error as this is safer than silently returning an incorrect close price of low + 0."
88
+ " Returning error as this is safer than silently returning an incorrect close price of low + 0."
89
89
  )
90
90
  close = low + proto.delta_close
91
91
 
@@ -6,9 +6,10 @@ import time
6
6
  from unittest.mock import AsyncMock, MagicMock
7
7
 
8
8
  import anyio
9
+ import betterproto
9
10
  import pytest
10
11
 
11
- from ctrader_api_client._internal.proto import ProtoHeartbeatEvent
12
+ from ctrader_api_client._internal.proto import ProtoHeartbeatEvent, ProtoOAVersionRes
12
13
  from ctrader_api_client.connection.heartbeat import HeartbeatManager
13
14
  from ctrader_api_client.connection.protocol import Protocol
14
15
 
@@ -55,10 +56,10 @@ class TestHeartbeatStart:
55
56
  await heartbeat.start()
56
57
 
57
58
  try:
58
- # Check that on_event was called with ProtoHeartbeatEvent
59
- mock_protocol.on_event.assert_called_once()
60
- call_args = mock_protocol.on_event.call_args
61
- assert call_args[0][0] is ProtoHeartbeatEvent
59
+ assert mock_protocol.on_event.call_count == 2
60
+ registered_types = [call[0][0] for call in mock_protocol.on_event.call_args_list]
61
+ assert betterproto.Message in registered_types
62
+ assert ProtoHeartbeatEvent in registered_types
62
63
  finally:
63
64
  await heartbeat.stop()
64
65
 
@@ -86,9 +87,10 @@ class TestHeartbeatStop:
86
87
  await heartbeat.start()
87
88
  await heartbeat.stop()
88
89
 
89
- mock_protocol.remove_handler.assert_called_once()
90
- call_args = mock_protocol.remove_handler.call_args
91
- assert call_args[0][0] is ProtoHeartbeatEvent
90
+ assert mock_protocol.remove_handler.call_count == 2
91
+ removed_types = [call[0][0] for call in mock_protocol.remove_handler.call_args_list]
92
+ assert betterproto.Message in removed_types
93
+ assert ProtoHeartbeatEvent in removed_types
92
94
 
93
95
  @pytest.mark.anyio
94
96
  async def test_stop_cancels_loop(self, mock_protocol: MagicMock) -> None:
@@ -105,16 +107,27 @@ class TestHeartbeatReceived:
105
107
  """Tests for heartbeat event handling."""
106
108
 
107
109
  @pytest.mark.anyio
108
- async def test_heartbeat_updates_last_received(self, mock_protocol: MagicMock) -> None:
110
+ async def test_record_activity_updates_last_received(self, mock_protocol: MagicMock) -> None:
109
111
  heartbeat = HeartbeatManager(mock_protocol)
110
112
 
111
113
  initial_time = time.monotonic() - 100 # Set to past
112
114
  heartbeat._last_received = initial_time
113
115
 
114
- await heartbeat._on_heartbeat(ProtoHeartbeatEvent())
116
+ await heartbeat._record_activity(ProtoHeartbeatEvent())
115
117
 
116
118
  assert heartbeat._last_received > initial_time
117
119
 
120
+ @pytest.mark.anyio
121
+ async def test_on_heartbeat_does_not_update_last_received(self, mock_protocol: MagicMock) -> None:
122
+ heartbeat = HeartbeatManager(mock_protocol)
123
+
124
+ initial_time = time.monotonic() - 100
125
+ heartbeat._last_received = initial_time
126
+
127
+ await heartbeat._on_heartbeat(ProtoHeartbeatEvent())
128
+
129
+ assert heartbeat._last_received == initial_time
130
+
118
131
 
119
132
  class TestHeartbeatSend:
120
133
  """Tests for heartbeat sending."""
@@ -163,15 +176,28 @@ class TestHeartbeatTimeout:
163
176
  await heartbeat.start()
164
177
 
165
178
  try:
166
- # Simulate receiving heartbeats
167
179
  for _ in range(3):
168
180
  await anyio.sleep(0.03)
169
- await heartbeat._on_heartbeat(ProtoHeartbeatEvent())
181
+ await heartbeat._record_activity(ProtoHeartbeatEvent())
182
+ finally:
183
+ await heartbeat.stop()
184
+
185
+ mock_protocol.handle_disconnect.assert_not_called()
186
+
187
+ @pytest.mark.anyio
188
+ async def test_no_timeout_when_non_heartbeat_messages_received(self, mock_protocol: MagicMock) -> None:
189
+ heartbeat = HeartbeatManager(mock_protocol, interval=0.02, timeout=0.1)
190
+
191
+ await heartbeat.start()
192
+
193
+ try:
194
+ for _ in range(3):
195
+ await anyio.sleep(0.03)
196
+ await heartbeat._record_activity(ProtoOAVersionRes())
170
197
  finally:
171
198
  await heartbeat.stop()
172
199
 
173
- # Disconnect should not have been triggered
174
- mock_protocol._handle_disconnect.assert_not_called()
200
+ mock_protocol.handle_disconnect.assert_not_called()
175
201
 
176
202
 
177
203
  class TestHeartbeatSendFailure:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from unittest.mock import AsyncMock, MagicMock
6
6
 
7
7
  import anyio
8
+ import betterproto
8
9
  import pytest
9
10
 
10
11
  from ctrader_api_client._internal.proto import (
@@ -348,6 +349,28 @@ class TestMessageDispatch:
348
349
 
349
350
  assert len(received) == 1
350
351
 
352
+ @pytest.mark.anyio
353
+ async def test_dispatch_triggers_base_class_handler(self, mock_transport: MagicMock) -> None:
354
+ protocol = Protocol(mock_transport)
355
+
356
+ received: list[betterproto.Message] = []
357
+
358
+ async def catch_all(message: betterproto.Message) -> None:
359
+ received.append(message)
360
+
361
+ protocol.on_event(betterproto.Message, catch_all)
362
+
363
+ proto_msg = ProtoMessage(
364
+ payload_type=ProtoPayloadType.HEARTBEAT_EVENT,
365
+ client_msg_id="",
366
+ )
367
+ inner = ProtoHeartbeatEvent()
368
+
369
+ await protocol._dispatch_message(proto_msg, inner)
370
+
371
+ assert len(received) == 1
372
+ assert isinstance(received[0], ProtoHeartbeatEvent)
373
+
351
374
 
352
375
  class TestReconnect:
353
376
  """Tests for reconnection logic."""
@@ -62,7 +62,7 @@ class TestTrendbarFromProto:
62
62
 
63
63
  for proto_value, expected_period in test_cases:
64
64
  proto.period = proto_value
65
- bar = Trendbar.from_proto(proto)
65
+ bar = Trendbar.from_proto(proto, bid_price=10)
66
66
  assert bar.period == expected_period
67
67
 
68
68
  def test_from_proto_converts_timestamp(self) -> None:
@@ -77,7 +77,7 @@ class TestTrendbarFromProto:
77
77
  proto.delta_close = 0
78
78
  proto.volume = 1000
79
79
 
80
- bar = Trendbar.from_proto(proto)
80
+ bar = Trendbar.from_proto(proto, bid_price=10)
81
81
 
82
82
  assert bar.timestamp == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
83
83
 
@@ -201,7 +201,7 @@ wheels = [
201
201
 
202
202
  [[package]]
203
203
  name = "ctrader-api-client"
204
- version = "0.2.1"
204
+ version = "0.2.3"
205
205
  source = { editable = "." }
206
206
  dependencies = [
207
207
  { name = "anyio" },