ctrader-api-client 0.7.3__tar.gz → 0.8.1__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.

Potentially problematic release.


This version of ctrader-api-client might be problematic. Click here for more details.

Files changed (125) hide show
  1. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/PKG-INFO +20 -4
  2. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/README.md +17 -2
  3. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/exceptions.md +26 -0
  4. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/models.md +20 -0
  5. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/symbols.md +58 -0
  6. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/getting-started.md +11 -2
  7. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/pyproject.toml +4 -1
  8. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/__init__.py +12 -0
  9. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/composition.py +1 -0
  10. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/config.py +8 -2
  11. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/connection/protocol.py +61 -15
  12. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/connection/transport.py +30 -12
  13. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/exceptions.py +55 -0
  14. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/__init__.py +5 -0
  15. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/symbol.py +7 -0
  16. ctrader_api_client-0.8.1/src/ctrader_api_client/models/trading_hours.py +506 -0
  17. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/test_reconnection.py +219 -1
  18. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/test_symbol_conversion.py +19 -1
  19. ctrader_api_client-0.8.1/tests/unit/test_trading_hours.py +387 -0
  20. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/uv.lock +12 -1
  21. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/.claude/settings.local.json +0 -0
  22. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/.github/workflows/docs.yml +0 -0
  23. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/.gitignore +0 -0
  24. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/.pre-commit-config.yaml +0 -0
  25. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/.python-version +0 -0
  26. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/Justfile +0 -0
  27. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/LICENSE +0 -0
  28. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/accounts.md +0 -0
  29. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/client.md +0 -0
  30. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/enums.md +0 -0
  31. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/events.md +0 -0
  32. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/market-data.md +0 -0
  33. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/api/trading.md +0 -0
  34. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/docs/index.md +0 -0
  35. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/mkdocs.yml +0 -0
  36. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/SOURCE +0 -0
  37. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/VERSION +0 -0
  38. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/update.sh +0 -0
  39. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  40. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  41. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/vendor/OpenApiMessages.proto +0 -0
  42. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/protos/vendor/OpenApiModelMessages.proto +0 -0
  43. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/scripts/fix_proto_imports.py +0 -0
  44. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/__init__.py +0 -0
  45. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/clock.py +0 -0
  46. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/conversions.py +0 -0
  47. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/messages.py +0 -0
  48. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  49. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  50. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  51. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  52. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  53. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/_internal/serialization.py +0 -0
  54. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/api/__init__.py +0 -0
  55. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/api/_base.py +0 -0
  56. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/api/accounts.py +0 -0
  57. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/api/market_data.py +0 -0
  58. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/api/symbols.py +0 -0
  59. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/api/trading.py +0 -0
  60. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/__init__.py +0 -0
  61. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/_recovery.py +0 -0
  62. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/_refresh.py +0 -0
  63. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/_session.py +0 -0
  64. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/credentials.py +0 -0
  65. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/manager.py +0 -0
  66. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/policy.py +0 -0
  67. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/auth/store.py +0 -0
  68. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/client.py +0 -0
  69. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/connection/__init__.py +0 -0
  70. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  71. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/connection/listener.py +0 -0
  72. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/connection/supervisor.py +0 -0
  73. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/enums.py +0 -0
  74. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/events/__init__.py +0 -0
  75. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/events/_execution.py +0 -0
  76. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/events/emitter.py +0 -0
  77. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/events/publisher.py +0 -0
  78. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/events/router.py +0 -0
  79. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/events/types.py +0 -0
  80. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/_base.py +0 -0
  81. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/account.py +0 -0
  82. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/deal.py +0 -0
  83. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/market_data.py +0 -0
  84. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/order.py +0 -0
  85. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/position.py +0 -0
  86. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/models/requests.py +0 -0
  87. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/src/ctrader_api_client/py.typed +0 -0
  88. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/__init__.py +0 -0
  89. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/__init__.py +0 -0
  90. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/api/__init__.py +0 -0
  91. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/api/conftest.py +0 -0
  92. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/api/test_market_data.py +0 -0
  93. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/api/test_reference_data.py +0 -0
  94. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/api/test_subscription_restore.py +0 -0
  95. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/api/test_trading.py +0 -0
  96. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/auth/__init__.py +0 -0
  97. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/auth/conftest.py +0 -0
  98. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/auth/test_authentication.py +0 -0
  99. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/auth/test_token_lifecycle.py +0 -0
  100. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/__init__.py +0 -0
  101. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/conftest.py +0 -0
  102. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/test_event_dispatch.py +0 -0
  103. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/test_framing.py +0 -0
  104. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/test_heartbeat.py +0 -0
  105. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/connection/test_request_response.py +0 -0
  106. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/events/__init__.py +0 -0
  107. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/events/conftest.py +0 -0
  108. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/events/test_routing.py +0 -0
  109. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/events/test_subscriptions.py +0 -0
  110. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/behavior/test_client.py +0 -0
  111. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/conftest.py +0 -0
  112. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/__init__.py +0 -0
  113. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/clock.py +0 -0
  114. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/factories.py +0 -0
  115. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/recorder.py +0 -0
  116. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/server.py +0 -0
  117. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/signals.py +0 -0
  118. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/stub_protocol.py +0 -0
  119. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/harness/wire.py +0 -0
  120. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/__init__.py +0 -0
  121. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/test_api_errors.py +0 -0
  122. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/test_market_data_conversion.py +0 -0
  123. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/test_request_conversion.py +0 -0
  124. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/test_trading_records.py +0 -0
  125. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.1}/tests/unit/test_wire_format.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ctrader-api-client
3
- Version: 0.7.3
3
+ Version: 0.8.1
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
@@ -15,6 +15,7 @@ Requires-Dist: anyio>=4.13.0
15
15
  Requires-Dist: betterproto[compiler]>=1.2.5
16
16
  Requires-Dist: pydantic>=2.12.5
17
17
  Requires-Dist: tenacity>=9.1.4
18
+ Requires-Dist: tzdata>=2025.2
18
19
  Description-Content-Type: text/markdown
19
20
 
20
21
  # cTrader API Client
@@ -301,6 +302,17 @@ results = await client.symbols.search(account_id, "EUR")
301
302
 
302
303
  # Get specific symbol
303
304
  symbol = await client.symbols.get_by_id(account_id, symbol_id)
305
+
306
+ # Trading hours arrive with the symbol, so this costs no extra request
307
+ hours = symbol.trading_hours
308
+ if hours.is_open():
309
+ print(f"Open, closes at {hours.next_close()}")
310
+ else:
311
+ print(f"Closed, opens at {hours.next_open()}")
312
+
313
+ # Sessions for a given day, with broker holidays already applied
314
+ for session in hours.sessions_on(date(2026, 6, 19)):
315
+ print(session.start, "->", session.end)
304
316
  ```
305
317
 
306
318
  ### Account Information
@@ -354,10 +366,14 @@ config = ClientConfig(
354
366
  heartbeat_timeout=30.0, # Or 0 to disable server heartbeat checks
355
367
  request_timeout=30.0,
356
368
 
357
- # Reconnection
358
- reconnect_attempts=5,
369
+ # Reconnection: None retries for as long as the client is open (the
370
+ # default), an integer caps the attempts, 0 disables reconnection. A
371
+ # finite budget that runs out raises CTraderReconnectAbandonedError out
372
+ # of the `async with client:` block.
373
+ reconnect_attempts=None,
359
374
  reconnect_min_wait=1.0,
360
375
  reconnect_max_wait=60.0,
376
+ connect_timeout=30.0,
361
377
 
362
378
  # Token refresh: when to refresh access tokens and how hard to retry
363
379
  refresh_policy=RefreshPolicy(
@@ -282,6 +282,17 @@ results = await client.symbols.search(account_id, "EUR")
282
282
 
283
283
  # Get specific symbol
284
284
  symbol = await client.symbols.get_by_id(account_id, symbol_id)
285
+
286
+ # Trading hours arrive with the symbol, so this costs no extra request
287
+ hours = symbol.trading_hours
288
+ if hours.is_open():
289
+ print(f"Open, closes at {hours.next_close()}")
290
+ else:
291
+ print(f"Closed, opens at {hours.next_open()}")
292
+
293
+ # Sessions for a given day, with broker holidays already applied
294
+ for session in hours.sessions_on(date(2026, 6, 19)):
295
+ print(session.start, "->", session.end)
285
296
  ```
286
297
 
287
298
  ### Account Information
@@ -335,10 +346,14 @@ config = ClientConfig(
335
346
  heartbeat_timeout=30.0, # Or 0 to disable server heartbeat checks
336
347
  request_timeout=30.0,
337
348
 
338
- # Reconnection
339
- reconnect_attempts=5,
349
+ # Reconnection: None retries for as long as the client is open (the
350
+ # default), an integer caps the attempts, 0 disables reconnection. A
351
+ # finite budget that runs out raises CTraderReconnectAbandonedError out
352
+ # of the `async with client:` block.
353
+ reconnect_attempts=None,
340
354
  reconnect_min_wait=1.0,
341
355
  reconnect_max_wait=60.0,
356
+ connect_timeout=30.0,
342
357
 
343
358
  # Token refresh: when to refresh access tokens and how hard to retry
344
359
  refresh_policy=RefreshPolicy(
@@ -153,6 +153,32 @@ other handlers for that event.
153
153
  options:
154
154
  show_source: false
155
155
 
156
+ ::: ctrader_api_client.CTraderReconnectAbandonedError
157
+ options:
158
+ show_source: false
159
+
160
+ This is the one connection exception that reaches you without your having called
161
+ anything: it is raised out of the `async with client:` block itself. Reconnection
162
+ is unbounded by default, so seeing it means either that `reconnect_attempts` was
163
+ set to a finite number and every one was spent, or that something failed which
164
+ the reconnection logic did not anticipate.
165
+
166
+ ```python
167
+ while True:
168
+ try:
169
+ async with client:
170
+ await asyncio.Event().wait()
171
+ except CTraderReconnectAbandonedError:
172
+ logger.exception("Client abandoned its connection; starting a new one")
173
+ await asyncio.sleep(30)
174
+ ```
175
+
176
+ Catching it is only worthwhile if you intend to build a fresh client, since the
177
+ one that raised it is finished. Letting it propagate and having a process
178
+ supervisor restart you is an equally good answer, and the reason it is raised
179
+ rather than logged: a client that has stopped reconnecting has no reader and no
180
+ heartbeat left to notice anything, so nothing short of a restart brings it back.
181
+
156
182
  ### Authentication
157
183
 
158
184
  ::: ctrader_api_client.AuthenticationError
@@ -130,6 +130,26 @@ partial_close = ClosePositionRequest(
130
130
  show_source: false
131
131
 
132
132
 
133
+ ::: ctrader_api_client.models.TradingHours
134
+ options:
135
+ show_source: false
136
+
137
+
138
+ ::: ctrader_api_client.models.TradingSession
139
+ options:
140
+ show_source: false
141
+
142
+
143
+ ::: ctrader_api_client.models.ScheduleInterval
144
+ options:
145
+ show_source: false
146
+
147
+
148
+ ::: ctrader_api_client.models.Holiday
149
+ options:
150
+ show_source: false
151
+
152
+
133
153
  ::: ctrader_api_client.models.Account
134
154
  options:
135
155
  show_source: false
@@ -58,6 +58,64 @@ for sym in eur_pairs:
58
58
  print(sym.name)
59
59
  ```
60
60
 
61
+ ## Trading Hours
62
+
63
+ Every `Symbol` arrives with its trading calendar attached, so asking when a symbol is open
64
+ costs no extra request:
65
+
66
+ ```python
67
+ from datetime import date
68
+
69
+ symbol = await client.symbols.get_by_id(account_id, 270)
70
+ hours = symbol.trading_hours
71
+
72
+ if hours.is_open():
73
+ print("open now, closes at", hours.next_close())
74
+ else:
75
+ print("closed, opens at", hours.next_open())
76
+
77
+ for session in hours.sessions_on(date(2026, 6, 19)):
78
+ print(session.start, "->", session.end, f"({session.duration})")
79
+ ```
80
+
81
+ Sessions are reported in the symbol's own `schedule_timezone` and are never merged, so a
82
+ schedule with a nightly maintenance break reports one session either side of it.
83
+
84
+ ### Holidays
85
+
86
+ Holidays are closed windows that cut into the weekly schedule rather than replacing a whole
87
+ day. A broker publishing an "early close" sends a window covering the rest of the day, which
88
+ brings the close forward:
89
+
90
+ ```python
91
+ # Regular Friday: 01:05 - 23:50. With a 20:00 early close holiday:
92
+ hours.sessions_on(date(2026, 6, 19)) # -> one session, 01:05 - 20:00
93
+
94
+ hours.holidays_on(date(2026, 6, 19)) # -> the holiday responsible
95
+ ```
96
+
97
+ A holiday carries **its own** timezone, which is not always the symbol's. A closure defined in
98
+ a timezone an hour behind the symbol's straddles two of the symbol's calendar days, and both
99
+ are shortened accordingly.
100
+
101
+ A holiday whose window is empty (`start_second >= end_second`, which brokers send as `0`/`0`)
102
+ closes the entire day.
103
+
104
+ ### Trading mode is separate
105
+
106
+ `is_open()` answers the calendar question only. A symbol can be closed to trading by its
107
+ `trading_mode` while the schedule says it is open, so check both:
108
+
109
+ ```python
110
+ tradable = hours.is_open() and symbol.trading_mode == TradingMode.ENABLED
111
+ ```
112
+
113
+ ### Staleness
114
+
115
+ Schedules arrive only with a symbol fetch, so a long-lived `Symbol` can drift. Subscribe to
116
+ `SymbolChangedEvent` and re-fetch when the server reports a change. The API exposes no
117
+ historical schedules — what you receive is the current configuration.
118
+
61
119
  ## Volume Conversion
62
120
 
63
121
  The cTrader API uses volume in "cents" (smallest volume units). The relationship between lots and volume depends on the symbol's `lot_size`:
@@ -312,13 +312,22 @@ config = ClientConfig(
312
312
  heartbeat_timeout=0, # 0 to disable server heartbeat checks (default)
313
313
  request_timeout=30.0,
314
314
 
315
- # Reconnection
316
- reconnect_attempts=5,
315
+ # Reconnection: None retries for as long as the client is open (the
316
+ # default), an integer caps the attempts, 0 disables reconnection.
317
+ reconnect_attempts=None,
317
318
  reconnect_min_wait=1.0,
318
319
  reconnect_max_wait=60.0,
320
+ connect_timeout=30.0,
319
321
  )
320
322
  ```
321
323
 
324
+ Reconnection is unbounded by default because a finite budget that runs out
325
+ leaves a client that can never recover: there is no reader and no heartbeat left
326
+ to notice anything, so it stays offline until the process restarts. If you do
327
+ set a finite `reconnect_attempts`, spending it raises
328
+ `CTraderReconnectAbandonedError` out of the `async with client:` block rather
329
+ than leaving the client alive and permanently disconnected.
330
+
322
331
  ## Next Steps
323
332
 
324
333
  - [API Reference - Client](api/client.md) - Full client documentation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.3"
3
+ version = "0.8.1"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -20,6 +20,9 @@ dependencies = [
20
20
  "betterproto[compiler]>=1.2.5",
21
21
  "pydantic>=2.12.5",
22
22
  "tenacity>=9.1.4",
23
+ # Windows ships no system IANA database, which ZoneInfo needs to resolve the
24
+ # broker-supplied timezones that trading schedules are expressed in.
25
+ "tzdata>=2025.2",
23
26
  ]
24
27
 
25
28
  [build-system]
@@ -92,12 +92,14 @@ from .exceptions import (
92
92
  CTraderConnectionFailedError,
93
93
  CTraderConnectionTimeoutError,
94
94
  CTraderError,
95
+ CTraderReconnectAbandonedError,
95
96
  DeserializationError,
96
97
  FramingError,
97
98
  ProtocolError,
98
99
  TokenExpiredError,
99
100
  TokenRefreshError,
100
101
  UnknownPayloadTypeError,
102
+ UnknownTimezoneError,
101
103
  )
102
104
  from .models import (
103
105
  Account,
@@ -107,13 +109,17 @@ from .models import (
107
109
  CloseDetail,
108
110
  ClosePositionRequest,
109
111
  Deal,
112
+ Holiday,
110
113
  NewOrderRequest,
111
114
  Order,
112
115
  Position,
113
116
  PositionUnrealizedPnL,
117
+ ScheduleInterval,
114
118
  Symbol,
115
119
  SymbolInfo,
116
120
  TickData,
121
+ TradingHours,
122
+ TradingSession,
117
123
  Trendbar,
118
124
  )
119
125
 
@@ -139,6 +145,7 @@ __all__ = [
139
145
  "CTraderConnectionFailedError",
140
146
  "CTraderConnectionTimeoutError",
141
147
  "CTraderError",
148
+ "CTraderReconnectAbandonedError",
142
149
  "ClientConfig",
143
150
  "ClientDisconnectEvent",
144
151
  "CloseDetail",
@@ -152,6 +159,7 @@ __all__ = [
152
159
  "ExecutionEvent",
153
160
  "ExecutionType",
154
161
  "FramingError",
162
+ "Holiday",
155
163
  "MarginCallTriggerEvent",
156
164
  "MarginChangeEvent",
157
165
  "NewOrderRequest",
@@ -168,6 +176,7 @@ __all__ = [
168
176
  "ReauthPolicy",
169
177
  "ReconnectedEvent",
170
178
  "RefreshPolicy",
179
+ "ScheduleInterval",
171
180
  "SpotEvent",
172
181
  "StopTriggerMethod",
173
182
  "SubscriptionRestoreFailedEvent",
@@ -182,9 +191,12 @@ __all__ = [
182
191
  "TokenRefreshFailedEvent",
183
192
  "TokenStore",
184
193
  "TraderUpdateEvent",
194
+ "TradingHours",
185
195
  "TradingMode",
196
+ "TradingSession",
186
197
  "TrailingStopChangedEvent",
187
198
  "Trendbar",
188
199
  "TrendbarPeriod",
189
200
  "UnknownPayloadTypeError",
201
+ "UnknownTimezoneError",
190
202
  ]
@@ -78,6 +78,7 @@ def build_graph(
78
78
  host=config.host,
79
79
  port=config.port,
80
80
  use_ssl=config.use_ssl,
81
+ connect_timeout=config.connect_timeout,
81
82
  )
82
83
  protocol = Protocol(
83
84
  transport=transport,
@@ -17,7 +17,12 @@ class ClientConfig(BaseModel):
17
17
  heartbeat_interval: Seconds between heartbeat sends.
18
18
  heartbeat_timeout: Seconds without server-initiated messages before disconnect. Set to 0 to disable.
19
19
  request_timeout: Default timeout for API requests in seconds.
20
- reconnect_attempts: Max reconnection attempts (0 to disable).
20
+ connect_timeout: Seconds allowed for the TCP connection and TLS
21
+ handshake together, after which the attempt is failed and retried.
22
+ reconnect_attempts: Max reconnection attempts. None (the default)
23
+ retries for as long as the client is open. 0 disables reconnection.
24
+ A finite budget that runs out abandons the connection and raises
25
+ CTraderReconnectAbandonedError out of the `async with` block.
21
26
  reconnect_min_wait: Initial wait between reconnection attempts.
22
27
  reconnect_max_wait: Maximum wait between reconnection attempts.
23
28
  refresh_policy: When to refresh access tokens and how hard to try.
@@ -51,9 +56,10 @@ class ClientConfig(BaseModel):
51
56
 
52
57
  # Request settings
53
58
  request_timeout: float = Field(default=30.0, gt=0)
59
+ connect_timeout: float = Field(default=30.0, gt=0)
54
60
 
55
61
  # Reconnection settings
56
- reconnect_attempts: int = Field(default=5, ge=0)
62
+ reconnect_attempts: int | None = Field(default=None, ge=0)
57
63
  reconnect_min_wait: float = Field(default=1.0, gt=0)
58
64
  reconnect_max_wait: float = Field(default=60.0, gt=0)
59
65
 
@@ -12,6 +12,7 @@ from tenacity import (
12
12
  AsyncRetrying,
13
13
  retry_if_exception_type,
14
14
  stop_after_attempt,
15
+ stop_never,
15
16
  wait_exponential,
16
17
  )
17
18
 
@@ -29,6 +30,7 @@ from ..exceptions import (
29
30
  CTraderConnectionClosedError,
30
31
  CTraderConnectionFailedError,
31
32
  CTraderConnectionTimeoutError,
33
+ CTraderReconnectAbandonedError,
32
34
  FramingError,
33
35
  )
34
36
  from .listener import ConnectionListener
@@ -73,7 +75,7 @@ class Protocol:
73
75
  def __init__(
74
76
  self,
75
77
  transport: Transport,
76
- reconnect_attempts: int = 5,
78
+ reconnect_attempts: int | None = None,
77
79
  reconnect_min_wait: float = 1.0,
78
80
  reconnect_max_wait: float = 60.0,
79
81
  ) -> None:
@@ -81,7 +83,10 @@ class Protocol:
81
83
 
82
84
  Args:
83
85
  transport: The underlying transport for sending/receiving data.
84
- reconnect_attempts: Maximum reconnection attempts (0 to disable).
86
+ reconnect_attempts: Maximum reconnection attempts. None retries for
87
+ as long as the client is open, which is the default: an outage
88
+ that outlasts a finite budget otherwise leaves a client that
89
+ can never recover. 0 disables reconnection entirely.
85
90
  reconnect_min_wait: Initial wait between attempts (seconds).
86
91
  reconnect_max_wait: Maximum wait between attempts (seconds).
87
92
  """
@@ -500,6 +505,11 @@ class Protocol:
500
505
  working — reported through `handle_disconnect` as `_redo_requested`, the
501
506
  only route left once the reader has exited — is reconnected too instead
502
507
  of being stranded.
508
+
509
+ Raises:
510
+ CTraderReconnectAbandonedError: If reconnection is given up on. This
511
+ is deliberately allowed to escape into the protocol task group
512
+ and tear the client down: see :meth:`_abandon`.
503
513
  """
504
514
  try:
505
515
  while True:
@@ -535,18 +545,46 @@ class Protocol:
535
545
  await self._notify_connection_lost()
536
546
  except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
537
547
  logger.error("Reconnection failed, giving up: %s", e)
538
- self._running = False
539
- # Wake all pending requests so callers fail fast instead of hanging.
540
- self._fail_pending("Connection lost and reconnection failed")
541
- except Exception:
542
- # Defensive: never let the reconnection task crash the task group.
548
+ self._abandon("connection lost and reconnection failed", e)
549
+ except Exception as e:
550
+ # Nothing here is expected to fail this way — the retry loop below
551
+ # absorbs every failure connecting — so treat it as a defect rather
552
+ # than a state to sit quietly in.
543
553
  logger.exception("Unexpected error while reconnecting")
544
- self._running = False
545
- self._fail_pending("Reconnection error")
554
+ self._abandon("unexpected error while reconnecting", e)
546
555
  finally:
547
556
  self._reconnecting = False
548
557
  self._redo_requested = False
549
558
 
559
+ def _abandon(self, reason: str, cause: BaseException) -> None:
560
+ """Stop the protocol and make giving up impossible to miss.
561
+
562
+ A client that has stopped reconnecting cannot recover on its own: the
563
+ reader has exited, the heartbeat loop returns as soon as its next write
564
+ fails, and `handle_disconnect` refuses everything once `_running` is
565
+ False. Nothing is left that could try again. Staying alive in that state
566
+ only means a consumer polling `is_connected` sees a link that is down
567
+ and assumes, reasonably and wrongly, that something is working on it.
568
+
569
+ So the failure is raised instead of recorded. It escapes into the
570
+ protocol task group, through the supervisor's, and out of the
571
+ `async with client:` block, where a process supervisor will see it.
572
+
573
+ Raises:
574
+ CTraderReconnectAbandonedError: Always, unless reconnection was
575
+ disabled by configuration, which is a choice rather than a
576
+ failure and keeps the previous behaviour of a client that
577
+ rejects further requests.
578
+ """
579
+ self._running = False
580
+ # Wake all pending requests so callers fail fast instead of hanging.
581
+ self._fail_pending(reason)
582
+
583
+ if self._reconnect_attempts == 0:
584
+ return
585
+
586
+ raise CTraderReconnectAbandonedError(reason, cause) from cause
587
+
550
588
  def _fail_pending(self, reason: str) -> None:
551
589
  """Resolve every in-flight request with a connection error."""
552
590
  for pending in self._pending.values():
@@ -556,27 +594,35 @@ class Protocol:
556
594
  async def _reconnect(self) -> None:
557
595
  """Attempt reconnection with exponential backoff.
558
596
 
597
+ Retries on *any* failure rather than on an enumerated set of exception
598
+ types. The budget exists to outlast a server that cannot be reached,
599
+ and an unrecognised exception is no evidence that it can be: a
600
+ whitelist here meant an exception nobody had thought of skipped the
601
+ retries entirely and went straight to being fatal.
602
+
559
603
  Raises:
560
- CTraderConnectionClosedError: If reconnection is disabled or all attempts fail.
561
- CTraderConnectionFailedError: If connection cannot be established.
604
+ CTraderConnectionClosedError: If reconnection is disabled.
605
+ CTraderConnectionFailedError: If every attempt in the budget failed.
562
606
  """
563
607
  if self._reconnect_attempts == 0:
564
608
  raise CTraderConnectionClosedError("Connection lost and reconnection disabled")
565
609
 
610
+ budget = "unlimited" if self._reconnect_attempts is None else self._reconnect_attempts
611
+
566
612
  async for attempt in AsyncRetrying(
567
- stop=stop_after_attempt(self._reconnect_attempts),
613
+ stop=stop_never if self._reconnect_attempts is None else stop_after_attempt(self._reconnect_attempts),
568
614
  wait=wait_exponential(
569
615
  min=self._reconnect_min_wait,
570
616
  max=self._reconnect_max_wait,
571
617
  ),
572
- retry=retry_if_exception_type(CTraderConnectionFailedError),
618
+ retry=retry_if_exception_type(Exception),
573
619
  reraise=True,
574
620
  ):
575
621
  with attempt:
576
622
  logger.debug(
577
- "Reconnection attempt %d/%d",
623
+ "Reconnection attempt %d/%s",
578
624
  attempt.retry_state.attempt_number,
579
- self._reconnect_attempts,
625
+ budget,
580
626
  )
581
627
  await self._transport.connect()
582
628
 
@@ -18,17 +18,21 @@ class Transport:
18
18
  Handles raw socket connections without knowledge of protobuf or message semantics.
19
19
  """
20
20
 
21
- def __init__(self, host: str, port: int, use_ssl: bool = True) -> None:
21
+ def __init__(self, host: str, port: int, use_ssl: bool = True, connect_timeout: float = 30.0) -> None:
22
22
  """Initialize transport configuration.
23
23
 
24
24
  Args:
25
25
  host: The server hostname to connect to.
26
26
  port: The server port to connect to.
27
27
  use_ssl: Whether to use SSL/TLS encryption. Defaults to True.
28
+ connect_timeout: Seconds to allow for the TCP connection and the
29
+ TLS handshake together. Bounds the one failure mode a caller
30
+ cannot retry: a handshake that neither completes nor fails.
28
31
  """
29
32
  self._host = host
30
33
  self._port = port
31
34
  self._ssl = use_ssl
35
+ self._connect_timeout = connect_timeout
32
36
  self._stream: ByteStream | None = None
33
37
 
34
38
  @property
@@ -62,6 +66,12 @@ class Transport:
62
66
 
63
67
  If already connected, this method returns immediately.
64
68
 
69
+ Either returns with a usable stream or raises
70
+ `CTraderConnectionFailedError`. There is deliberately no third outcome:
71
+ anything that goes wrong on the way to a connection *is* a failure to
72
+ connect, whatever its type, so callers can retry on one exception
73
+ rather than on a list of them that has to be kept complete.
74
+
65
75
  Raises:
66
76
  CTraderConnectionFailedError: If connection cannot be established.
67
77
  """
@@ -69,17 +79,25 @@ class Transport:
69
79
  return # Already connected
70
80
 
71
81
  try:
72
- if self._ssl:
73
- ssl_context = ssl.create_default_context()
74
- self._stream = await anyio.connect_tcp(
75
- self._host,
76
- self._port,
77
- ssl_context=ssl_context,
78
- tls_standard_compatible=True,
79
- )
80
- else:
81
- self._stream = await anyio.connect_tcp(self._host, self._port)
82
- except OSError as e:
82
+ # Every failure is translated, not an enumerated set of them. A TLS
83
+ # handshake cut short by the peer surfaces as anyio's
84
+ # BrokenResourceError, which is not an OSError and so used to
85
+ # escape untranslated — past the retry loop that only knew
86
+ # CTraderConnectionFailedError, and into the handler that reads an
87
+ # unrecognised exception as fatal. Cancellation still passes
88
+ # through: anyio raises it from BaseException, not Exception.
89
+ with anyio.fail_after(self._connect_timeout):
90
+ if self._ssl:
91
+ ssl_context = ssl.create_default_context()
92
+ self._stream = await anyio.connect_tcp(
93
+ self._host,
94
+ self._port,
95
+ ssl_context=ssl_context,
96
+ tls_standard_compatible=True,
97
+ )
98
+ else:
99
+ self._stream = await anyio.connect_tcp(self._host, self._port)
100
+ except Exception as e:
83
101
  raise CTraderConnectionFailedError(self._host, self._port, e) from e
84
102
 
85
103
  if self._ssl:
@@ -68,6 +68,39 @@ class CTraderConnectionTimeoutError(CTraderConnectionError):
68
68
  super().__init__(f"{operation} timed out after {timeout_seconds}s")
69
69
 
70
70
 
71
+ class CTraderReconnectAbandonedError(CTraderConnectionError):
72
+ """The client tried to restore a dropped link and gave up.
73
+
74
+ Raised out of the `async with client:` block, tearing the client down
75
+ rather than leaving it alive but permanently offline. A client that has
76
+ stopped reconnecting can never recover on its own — there is no reader and
77
+ no heartbeat left to notice anything — so the only useful thing it can do
78
+ is say so where a supervisor will see it.
79
+
80
+ Reaching this is not expected. Reconnection is unbounded by default, so a
81
+ server that stays unreachable produces retries rather than this error. It
82
+ means either that `reconnect_attempts` was set to a finite number and every
83
+ one was used, or that something went wrong that the reconnection logic did
84
+ not anticipate.
85
+
86
+ Not raised when reconnection is disabled outright with
87
+ `reconnect_attempts=0`: not reconnecting is the documented outcome there,
88
+ not a failure.
89
+
90
+ Attributes:
91
+ reason: What was being attempted when it was abandoned.
92
+ cause: The final underlying failure, also set as `__cause__`.
93
+ """
94
+
95
+ def __init__(self, reason: str, cause: BaseException | None = None) -> None:
96
+ self.reason = reason
97
+ self.cause = cause
98
+ message = f"Reconnection abandoned: {reason}"
99
+ if cause:
100
+ message += f" ({cause})"
101
+ super().__init__(message)
102
+
103
+
71
104
  # =============================================================================
72
105
  # Authentication Errors
73
106
  # =============================================================================
@@ -222,6 +255,28 @@ class APIError(CTraderError):
222
255
  )
223
256
 
224
257
 
258
+ # =============================================================================
259
+ # Data Errors
260
+ # =============================================================================
261
+
262
+
263
+ class UnknownTimezoneError(CTraderError):
264
+ """A timezone named by the server is not in the IANA database.
265
+
266
+ Trading schedules are broker-supplied wall clock time, so a timezone that
267
+ cannot be resolved leaves every session boundary meaningless. This should
268
+ never happen: if it does, the broker has published a symbol whose schedule
269
+ timezone is misconfigured, and the schedule cannot be interpreted at all.
270
+ """
271
+
272
+ def __init__(self, name: str) -> None:
273
+ self.name = name
274
+ super().__init__(
275
+ f"Unknown schedule timezone {name!r}. This should never happen; it means the broker "
276
+ f"published a symbol whose schedule timezone is not a valid IANA name."
277
+ )
278
+
279
+
225
280
  # =============================================================================
226
281
  # Protocol Errors
227
282
  # =============================================================================
@@ -30,6 +30,7 @@ from .order import Order
30
30
  from .position import Position, PositionUnrealizedPnL
31
31
  from .requests import AmendOrderRequest, AmendPositionRequest, ClosePositionRequest, NewOrderRequest
32
32
  from .symbol import Symbol, SymbolInfo
33
+ from .trading_hours import Holiday, ScheduleInterval, TradingHours, TradingSession
33
34
 
34
35
 
35
36
  __all__ = [
@@ -40,12 +41,16 @@ __all__ = [
40
41
  "CloseDetail",
41
42
  "ClosePositionRequest",
42
43
  "Deal",
44
+ "Holiday",
43
45
  "NewOrderRequest",
44
46
  "Order",
45
47
  "Position",
46
48
  "PositionUnrealizedPnL",
49
+ "ScheduleInterval",
47
50
  "Symbol",
48
51
  "SymbolInfo",
49
52
  "TickData",
53
+ "TradingHours",
54
+ "TradingSession",
50
55
  "Trendbar",
51
56
  ]