ctrader-api-client 0.10.0__tar.gz → 0.12.0__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 (139) hide show
  1. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/PKG-INFO +13 -3
  2. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/README.md +12 -2
  3. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/client.md +3 -1
  4. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/events.md +29 -2
  5. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/exceptions.md +6 -4
  6. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/models.md +5 -0
  7. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/symbols.md +28 -0
  8. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/getting-started.md +8 -1
  9. ctrader_api_client-0.12.0/justfile +88 -0
  10. ctrader_api_client-0.12.0/plans/nested-event-models.md +187 -0
  11. ctrader_api_client-0.12.0/plans/symbol-swaps.md +209 -0
  12. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/pyproject.toml +1 -1
  13. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/__init__.py +6 -0
  14. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/trading.py +1 -1
  15. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/_maintain.py +5 -40
  16. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/manager.py +66 -12
  17. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/client.py +3 -2
  18. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/composition.py +1 -2
  19. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/enums.py +28 -0
  20. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/_execution.py +11 -34
  21. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/router.py +4 -6
  22. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/types.py +15 -22
  23. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/exceptions.py +2 -2
  24. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/__init__.py +2 -0
  25. ctrader_api_client-0.12.0/src/ctrader_api_client/models/swap.py +109 -0
  26. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/symbol.py +5 -7
  27. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_trading.py +21 -13
  28. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/conftest.py +16 -8
  29. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/test_authentication.py +41 -2
  30. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/test_token_lifecycle.py +34 -14
  31. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/events/test_routing.py +97 -34
  32. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_enum_mapping.py +10 -0
  33. ctrader_api_client-0.12.0/tests/unit/test_swap_conversion.py +155 -0
  34. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_symbol_conversion.py +0 -6
  35. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/uv.lock +1 -1
  36. ctrader_api_client-0.10.0/Justfile +0 -56
  37. ctrader_api_client-0.10.0/plans/connection-auth-refactor.md +0 -99
  38. ctrader_api_client-0.10.0/plans/connection-research.md +0 -43
  39. ctrader_api_client-0.10.0/plans/strict-enum-mapping.md +0 -65
  40. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.claude/settings.local.json +0 -0
  41. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.complexipy_cache/.gitignore +0 -0
  42. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.complexipy_cache/CACHEDIR.TAG +0 -0
  43. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.complexipy_cache/README.md +0 -0
  44. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.complexipy_cache/v/cache/functions +0 -0
  45. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.github/workflows/docs.yml +0 -0
  46. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.gitignore +0 -0
  47. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.pre-commit-config.yaml +0 -0
  48. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/.python-version +0 -0
  49. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/LICENSE +0 -0
  50. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/accounts.md +0 -0
  51. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/enums.md +0 -0
  52. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/market-data.md +0 -0
  53. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/api/trading.md +0 -0
  54. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/docs/index.md +0 -0
  55. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/mkdocs.yml +0 -0
  56. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/SOURCE +0 -0
  57. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/VERSION +0 -0
  58. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/update.sh +0 -0
  59. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  60. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  61. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiMessages.proto +0 -0
  62. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  63. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/scripts/fix_proto_imports.py +0 -0
  64. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  65. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/backoff.py +0 -0
  66. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/clock.py +0 -0
  67. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
  68. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  69. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  70. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  71. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  72. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  73. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  74. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  75. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/__init__.py +0 -0
  76. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/_base.py +0 -0
  77. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/accounts.py +0 -0
  78. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/market_data.py +0 -0
  79. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/symbols.py +0 -0
  80. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/__init__.py +0 -0
  81. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/_session.py +0 -0
  82. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  83. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/policy.py +0 -0
  84. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/store.py +0 -0
  85. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/config.py +0 -0
  86. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  87. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  88. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/protocol.py +0 -0
  89. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
  90. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/transport.py +0 -0
  91. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/__init__.py +0 -0
  92. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/emitter.py +0 -0
  93. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/publisher.py +0 -0
  94. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/_base.py +0 -0
  95. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/account.py +0 -0
  96. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/commission.py +0 -0
  97. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/deal.py +0 -0
  98. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/market_data.py +0 -0
  99. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/order.py +0 -0
  100. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/position.py +0 -0
  101. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/requests.py +0 -0
  102. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/trading_hours.py +0 -0
  103. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/py.typed +0 -0
  104. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/__init__.py +0 -0
  105. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/__init__.py +0 -0
  106. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/api/__init__.py +0 -0
  107. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/api/conftest.py +0 -0
  108. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_market_data.py +0 -0
  109. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_reference_data.py +0 -0
  110. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_subscription_restore.py +0 -0
  111. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/__init__.py +0 -0
  112. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/__init__.py +0 -0
  113. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/conftest.py +0 -0
  114. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
  115. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_framing.py +0 -0
  116. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_heartbeat.py +0 -0
  117. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_reconnection.py +0 -0
  118. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_request_response.py +0 -0
  119. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/events/__init__.py +0 -0
  120. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/events/conftest.py +0 -0
  121. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/events/test_subscriptions.py +0 -0
  122. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/behavior/test_client.py +0 -0
  123. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/conftest.py +0 -0
  124. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/__init__.py +0 -0
  125. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/clock.py +0 -0
  126. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/factories.py +0 -0
  127. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/recorder.py +0 -0
  128. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/server.py +0 -0
  129. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/signals.py +0 -0
  130. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/stub_protocol.py +0 -0
  131. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/harness/wire.py +0 -0
  132. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/__init__.py +0 -0
  133. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_api_errors.py +0 -0
  134. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_commission_conversion.py +0 -0
  135. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_market_data_conversion.py +0 -0
  136. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_request_conversion.py +0 -0
  137. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_trading_hours.py +0 -0
  138. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_trading_records.py +0 -0
  139. {ctrader_api_client-0.10.0 → ctrader_api_client-0.12.0}/tests/unit/test_wire_format.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrader-api-client
3
- Version: 0.10.0
3
+ Version: 0.12.0
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
@@ -133,6 +133,9 @@ await client.auth.authenticate_trader(
133
133
  # Tokens are automatically refreshed before expiry. A refresh that fails is
134
134
  # retried on the next check rather than dropping the session, and surfaces as a
135
135
  # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
136
+ # An access token that has already expired is refreshed before the account is
137
+ # authenticated, so a process that starts after the expiry needs no new token.
138
+ # Only a refresh token the server refuses raises TokenExpiredError.
136
139
  ```
137
140
 
138
141
  ### Persisting Rotated Tokens
@@ -249,6 +252,7 @@ await client.trading.close_position(account_id, close_position)
249
252
  ```python
250
253
  from ctrader_api_client import (
251
254
  ExecutionEvent,
255
+ ExecutionType,
252
256
  ReadyEvent,
253
257
  ReconnectedEvent,
254
258
  SpotEvent,
@@ -261,10 +265,16 @@ from ctrader_api_client import (
261
265
  async def on_spot(event: SpotEvent):
262
266
  print(f"{event.bid}/{event.ask}")
263
267
 
264
- # Order executions
268
+ # Order executions. The event carries the Order, Position and Deal models
269
+ # that the trading API returns. Each one is None when the server sent none.
265
270
  @client.on(ExecutionEvent, account_id=account_id)
266
271
  async def on_execution(event: ExecutionEvent):
267
- print(f"Order {event.order_id}: {event.execution_type}")
272
+ if event.order is None:
273
+ return # A swap or a deposit has no order.
274
+ print(f"Order {event.order.order_id}: {event.execution_type}")
275
+ if event.execution_type is ExecutionType.ORDER_FILLED and event.order.is_closing_order:
276
+ # The order type says why the position closed.
277
+ print(f"Closed by {event.order.order_type.name}, stop-out={event.order.is_stop_out}")
268
278
 
269
279
  # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
270
280
  # Subscriptions are already restored by this point; use it to reconcile your own state.
@@ -114,6 +114,9 @@ await client.auth.authenticate_trader(
114
114
  # Tokens are automatically refreshed before expiry. A refresh that fails is
115
115
  # retried on the next check rather than dropping the session, and surfaces as a
116
116
  # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
117
+ # An access token that has already expired is refreshed before the account is
118
+ # authenticated, so a process that starts after the expiry needs no new token.
119
+ # Only a refresh token the server refuses raises TokenExpiredError.
117
120
  ```
118
121
 
119
122
  ### Persisting Rotated Tokens
@@ -230,6 +233,7 @@ await client.trading.close_position(account_id, close_position)
230
233
  ```python
231
234
  from ctrader_api_client import (
232
235
  ExecutionEvent,
236
+ ExecutionType,
233
237
  ReadyEvent,
234
238
  ReconnectedEvent,
235
239
  SpotEvent,
@@ -242,10 +246,16 @@ from ctrader_api_client import (
242
246
  async def on_spot(event: SpotEvent):
243
247
  print(f"{event.bid}/{event.ask}")
244
248
 
245
- # Order executions
249
+ # Order executions. The event carries the Order, Position and Deal models
250
+ # that the trading API returns. Each one is None when the server sent none.
246
251
  @client.on(ExecutionEvent, account_id=account_id)
247
252
  async def on_execution(event: ExecutionEvent):
248
- print(f"Order {event.order_id}: {event.execution_type}")
253
+ if event.order is None:
254
+ return # A swap or a deposit has no order.
255
+ print(f"Order {event.order.order_id}: {event.execution_type}")
256
+ if event.execution_type is ExecutionType.ORDER_FILLED and event.order.is_closing_order:
257
+ # The order type says why the position closed.
258
+ print(f"Closed by {event.order.order_type.name}, stop-out={event.order.is_stop_out}")
249
259
 
250
260
  # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
251
261
  # Subscriptions are already restored by this point; use it to reconcile your own state.
@@ -70,7 +70,9 @@ Token-refresh and session-recovery timing, passed via
70
70
 
71
71
  The `client.auth` property provides access to authentication operations. The
72
72
  application is authenticated as the client connects, so what is left here is
73
- authenticating trading accounts and asking after the sessions they hold.
73
+ authenticating trading accounts and asking after the sessions they hold. An
74
+ account whose access token has expired is refreshed before it is authenticated,
75
+ so the refresh token in the credentials is what a restart depends on.
74
76
 
75
77
  ::: ctrader_api_client.auth.AuthManager
76
78
  options:
@@ -14,7 +14,7 @@ async def on_spot(event: SpotEvent):
14
14
 
15
15
  @client.on(ExecutionEvent, account_id=12345) # Filter by account
16
16
  async def on_execution(event: ExecutionEvent):
17
- print(f"Order {event.order_id}: {event.execution_type}")
17
+ print(f"Order {event.order.order_id}: {event.execution_type}")
18
18
  ```
19
19
 
20
20
  ### Filtering
@@ -24,7 +24,7 @@ Different events support different filters:
24
24
  | Event | `account_id` | `symbol_id` |
25
25
  |-------|--------------|-------------|
26
26
  | SpotEvent | Yes | Yes |
27
- | ExecutionEvent | Yes | Yes |
27
+ | ExecutionEvent | Yes | No |
28
28
  | DepthEvent | Yes | Yes |
29
29
  | ReadyEvent | Yes | No |
30
30
  | OrderErrorEvent | Yes | No |
@@ -72,6 +72,30 @@ async def on_spot(event: SpotEvent):
72
72
 
73
73
  ## Trading Events
74
74
 
75
+ An execution event carries the `Order`, `Position` and `Deal` models that the
76
+ trading API returns. Each one is `None` when the server sent no such message.
77
+ A swap or a deposit has no order. An accepted order has no position and no deal
78
+ yet.
79
+
80
+ The order says why a position closed:
81
+
82
+ ```python
83
+ from ctrader_api_client import ExecutionEvent, ExecutionType, OrderType
84
+
85
+ @client.on(ExecutionEvent, account_id=12345)
86
+ async def on_execution(event: ExecutionEvent):
87
+ if event.execution_type is not ExecutionType.ORDER_FILLED or event.order is None:
88
+ return
89
+ if not event.order.is_closing_order:
90
+ return # An entry fill.
91
+ if event.order.is_stop_out:
92
+ print("Closed by a margin stop-out")
93
+ elif event.order.order_type is OrderType.STOP_LOSS_TAKE_PROFIT:
94
+ print("Closed by a stop loss or a take profit")
95
+ else:
96
+ print("Closed by a market order")
97
+ ```
98
+
75
99
  ::: ctrader_api_client.events.ExecutionEvent
76
100
  options:
77
101
  show_source: false
@@ -113,6 +137,9 @@ suppressed for `TOKEN_REFRESH` (session intact).
113
137
  show_source: false
114
138
  members: true
115
139
 
140
+ A trader update carries the `Account` model that the accounts API returns.
141
+ Its balance is a `Decimal` in account currency units.
142
+
116
143
  ::: ctrader_api_client.events.TraderUpdateEvent
117
144
  options:
118
145
  show_source: false
@@ -19,7 +19,7 @@ CTraderError
19
19
  ├── AuthenticationError
20
20
  │ ├── ApplicationAuthError the server rejected client_id/secret
21
21
  │ ├── AccountAuthError the server rejected the account
22
- │ ├── TokenExpiredError the access token is expired or invalid
22
+ │ ├── TokenExpiredError the server refused the access or refresh token
23
23
  │ ├── TokenRefreshError a refresh exhausted its retries
24
24
  │ └── AccountNotFoundError no account matches that trader login
25
25
  ├── APIError an error response to a request
@@ -69,9 +69,11 @@ except AccountAuthError as e:
69
69
  print(f"Account {e.ctid_trader_account_id} rejected: {e.error_code}")
70
70
  ```
71
71
 
72
- `TokenExpiredError` covers both a token the client knows has expired and one the
73
- server rejects as invalid. Neither can be retried back to life — it needs a new
74
- token.
72
+ `TokenExpiredError` means the server refused a token: the access token at
73
+ authentication, or the refresh token when the client refreshed an expired access
74
+ token first. Neither can be retried back to life. It needs a new OAuth
75
+ authorization. An access token that has merely expired does not raise, because
76
+ the client refreshes it before it authenticates the account.
75
77
 
76
78
  ### Requests
77
79
 
@@ -190,6 +190,11 @@ symbol's quote currency, or in the currency that `minimum_asset` names. A `minim
190
190
  means the broker sets no minimum.
191
191
 
192
192
 
193
+ ::: ctrader_api_client.models.Swap
194
+ options:
195
+ show_source: false
196
+
197
+
193
198
  ::: ctrader_api_client.models.TradingHours
194
199
  options:
195
200
  show_source: false
@@ -87,6 +87,34 @@ charge lands in USD for the first two types, and in the symbol's quote currency
87
87
  [Models](models.md#ctrader_api_client.models.Commission) for the arithmetic of each type,
88
88
  and for how to apply `minimum`.
89
89
 
90
+ ## Swap
91
+
92
+ A broker states a swap as two rates and a type. The type says what the rates count, so
93
+ you must read them together. The same rate of `-1.5` is pips per lot for one symbol, and
94
+ an annual percent for another. A negative rate is a charge.
95
+
96
+ ```python
97
+ from ctrader_api_client import SwapCalculationType
98
+
99
+ symbol = await client.symbols.get_by_id(account_id, 270)
100
+ swap = symbol.swap
101
+
102
+ print(f"{swap.long} / {swap.short} ({swap.calculation_type.value})")
103
+ print(f"Tripled on {swap.triple_day.value}")
104
+ print(f"Charged every {swap.period_hours} hours from {swap.charge_time} UTC")
105
+ ```
106
+
107
+ | Type | Meaning of the rates |
108
+ |------|----------------------|
109
+ | `PIPS` | Pips per lot, per charge period. |
110
+ | `PERCENTAGE` | Annual percent of the position value. |
111
+ | `POINTS` | Points per lot, per charge period. |
112
+
113
+ The broker charges the swap once per `period_hours`, starting at `charge_time` UTC. On
114
+ `triple_day` the charge is three times the rate, to cover the weekend. `period_hours` is
115
+ `None` when the broker does not publish it. See
116
+ [Models](models.md#ctrader_api_client.models.Swap) for every field.
117
+
90
118
  ## Trading Hours
91
119
 
92
120
  Every `Symbol` arrives with its trading calendar attached, so asking when a symbol is open
@@ -146,7 +146,8 @@ async def on_price(event: SpotEvent):
146
146
  @client.on(ExecutionEvent)
147
147
  async def on_execution(event: ExecutionEvent):
148
148
  """Called when orders are executed."""
149
- print(f"Execution: {event.execution_type} for order {event.order_id}")
149
+ if event.order is not None:
150
+ print(f"Execution: {event.execution_type} for order {event.order.order_id}")
150
151
 
151
152
 
152
153
  async def main():
@@ -235,6 +236,12 @@ The contract is write-only. Reading back at startup is yours, since only you kno
235
236
  which accounts a given process is responsible for. See
236
237
  [TokenStore](api/client.md#tokenstore) for the full contract.
237
238
 
239
+ A stored pair whose access token has expired still works. `authenticate_trader`
240
+ refreshes it first and saves the new pair through the store, so a process that
241
+ starts after the expiry needs no new token from the outside. Only a refresh token
242
+ that the server refuses raises `TokenExpiredError`, and that needs a new OAuth
243
+ authorization.
244
+
238
245
  ## Handling Errors
239
246
 
240
247
  Everything the library raises derives from `CTraderError`. Bringing the client up
@@ -0,0 +1,88 @@
1
+ # cTrader API client justfile
2
+ set shell := ["bash", "-euo", "pipefail", "-c"]
3
+ set script-interpreter := ["bash", "-euo", "pipefail"]
4
+ set positional-arguments
5
+
6
+ # Variables
7
+ proto_path := "protos/vendor"
8
+ python_out_path := "src/ctrader_api_client/_internal/proto"
9
+
10
+ # Default recipe. List all available recipes.
11
+ [private]
12
+ default:
13
+ @just --list
14
+
15
+ # Verify that the required toolchains are present
16
+ [group('build')]
17
+ [script]
18
+ check-deps:
19
+ if ! command -v uv &> /dev/null; then
20
+ echo "uv not found. Install from https://docs.astral.sh/uv/getting-started/installation/" >&2
21
+ exit 1
22
+ fi
23
+ echo "==> All required toolchains are present"
24
+
25
+ # Install the pre-commit hooks, then build the project. Extra arguments go to `uv sync`.
26
+ [group('build')]
27
+ [script]
28
+ setup *args: check-deps
29
+ echo "==> Install the pre-commit hooks"
30
+ uv tool install prek
31
+ uv tool run prek install --hook-type pre-commit
32
+ echo "==> Build the project"
33
+ just build "$@"
34
+
35
+ # Sync the Python dependencies. Extra arguments go to `uv sync`.
36
+ [group('build')]
37
+ build *args:
38
+ uv sync "$@"
39
+
40
+ # Format the code. Extra arguments go to `ruff format`.
41
+ [group('dev')]
42
+ fmt *args:
43
+ uv run ruff format "$@"
44
+
45
+ # Lint the code. Extra arguments go to `ruff check`.
46
+ [group('dev')]
47
+ lint *args:
48
+ uv run ruff check --fix "$@"
49
+
50
+ # Type-check the code. Extra arguments go to `zuban check`.
51
+ [group('dev')]
52
+ type-check *args:
53
+ uv run zuban check "$@"
54
+
55
+ # Run the tests. Extra arguments go to pytest.
56
+ [group('dev')]
57
+ test *args:
58
+ uv run pytest "$@"
59
+
60
+ # Run the whole CI pipeline
61
+ [group('dev')]
62
+ @ci:
63
+ just fmt
64
+ just lint
65
+ just type-check
66
+ just test
67
+
68
+ # Serve the documentation locally. Extra arguments go to `mkdocs serve`.
69
+ [group('tools')]
70
+ documentation *args:
71
+ uv run mkdocs serve "$@"
72
+
73
+ # Fetch the .proto files at the given version into the vendor directory
74
+ [group('tools')]
75
+ update-proto version='main':
76
+ ./protos/update.sh "$1"
77
+
78
+ # Generate the Python code from the .proto files. Run this after `update-proto`.
79
+ [group('tools')]
80
+ [script]
81
+ compile-proto:
82
+ echo "==> Compile the .proto files from {{ proto_path }}/ into {{ python_out_path }}/"
83
+ uv run protoc -I={{ proto_path }} --python_betterproto_out={{ python_out_path }} {{ proto_path }}/*.proto
84
+ echo "==> Fix the cross-module imports"
85
+ uv run python scripts/fix_proto_imports.py
86
+ echo "==> Format the generated code"
87
+ uv run ruff check --fix {{ python_out_path }}
88
+ uv run ruff format {{ python_out_path }}
@@ -0,0 +1,187 @@
1
+ # Nested event models
2
+
3
+ Status: steps 1 to 3 done on 2026-10-21. The version bump to 0.12.0 waits for another change that ships with it.
4
+
5
+ ## Problem
6
+
7
+ A consumer cannot tell why a position closed from the live event stream.
8
+
9
+ - A close arrives as a `ProtoOAExecutionEvent` of type `ORDER_FILLED`. Its
10
+ embedded `ProtoOAOrder` says why: `order_type` is `STOP_LOSS_TAKE_PROFIT` for
11
+ a protection close and `MARKET` for a manual close, `closing_order` marks a
12
+ close, and `is_stop_out` marks a margin close.
13
+ - `execution_event_from_proto` keeps only the side, the order id and the symbol
14
+ id from the order. It keeps only the filled volume, the price and the
15
+ timestamp from the deal. It keeps only the position id from the position.
16
+ - The only public path to the order type is `TradingAPI.get_orders`, which is a
17
+ second round trip that races the next event.
18
+
19
+ The same shape repeats in `TraderUpdateEvent`. The server sends a full
20
+ `ProtoOATrader`, and the router keeps three of its thirty-two fields.
21
+
22
+ The library already has a faithful model for each embedded message: `Order`,
23
+ `Position`, `Deal` and `Account`. The events translate the same data a second
24
+ time, and translate less of it.
25
+
26
+ ## Facts from the source
27
+
28
+ - `events/_execution.py`: `execution_event_from_proto` reads presence of the
29
+ order, the position and the deal with `betterproto.serialized_on_wire`. It
30
+ falls back to `datetime.now(UTC)` for the timestamp when the event has no
31
+ deal. Both `TradingAPI` and `EventRouter` call it.
32
+ - `models/order.py`, `models/position.py`: `from_proto` tolerates an absent
33
+ `trade_data`. Both require a known `order_type`, `order_status` or
34
+ `position_status`; an unset enum raises `UnknownEnumValueError`.
35
+ - `models/deal.py`: `from_proto` requires a known `trade_side` and
36
+ `deal_status`. It reads the close detail only when its balance is positive.
37
+ - `models/account.py`: `Account.from_proto` scales `balance` by
38
+ `money_divisor(money_digits)`, which reads 0 as cents. `TraderUpdateEvent`
39
+ carries the raw integer plus `money_digits` instead, so a live balance and a
40
+ fetched balance are in different units.
41
+ - `events/router.py`: `_handle_trader_update` drops the event when the trader
42
+ is absent. `_handle_execution` logs `event.order_id`.
43
+ - `events/emitter.py`: `_validate_filter` checks `__dataclass_fields__` for
44
+ `account_id` and `symbol_id`. `_matches_filter` reads them with `getattr`.
45
+ - `client.py`: `T_BothFilters` names `SpotEvent`, `ExecutionEvent` and
46
+ `DepthEvent` as the events that accept a `symbol_id` filter. No test pins the
47
+ symbol filter on `ExecutionEvent`.
48
+ - Tests that construct or read the two events: `tests/behavior/events/test_routing.py`,
49
+ `tests/behavior/api/test_trading.py`, `tests/unit/test_enum_mapping.py`. The
50
+ routing tests build orders without an order type or status, and deals without
51
+ a side or status.
52
+ - Docs that show `event.order_id`: `README.md`, `docs/getting-started.md`,
53
+ `docs/api/events.md`, and the docstrings of `Client.on` and `TradingAPI`.
54
+
55
+ ## Design
56
+
57
+ 1. `ExecutionEvent` becomes:
58
+
59
+ ```python
60
+ account_id: int
61
+ execution_type: ExecutionType
62
+ order: Order | None
63
+ position: Position | None
64
+ deal: Deal | None
65
+ is_server_event: bool = False
66
+ error_code: str | None = None
67
+ ```
68
+
69
+ Each nested field is `None` when the sub-message was not on the wire. The
70
+ converter calls the three existing `from_proto` methods. `order_id`,
71
+ `position_id`, `symbol_id`, `side`, `filled_volume`, `fill_price` and
72
+ `timestamp` leave. The execution timestamp lives on `deal.execution_timestamp`,
73
+ and an event with no deal has no timestamp instead of a fabricated one.
74
+
75
+ 2. `TraderUpdateEvent` becomes:
76
+
77
+ ```python
78
+ account_id: int
79
+ account: Account
80
+ ```
81
+
82
+ The router already drops an event with no trader, so `account` is not
83
+ optional. `balance`, `leverage_in_cents` and `money_digits` leave. The
84
+ balance is a scaled `Decimal` from the one converter that owns the scaling.
85
+
86
+ 3. `ExecutionEvent` loses the `symbol_id` filter. The symbol lives on the nested
87
+ order, and a swap or a deposit event has no order at all. `T_BothFilters`
88
+ drops `ExecutionEvent`, and the filter table in `docs/api/events.md` says
89
+ `No`. A consumer that wants one symbol reads `event.order.symbol_id` in the
90
+ handler.
91
+
92
+ 4. The reason for a close reads from one event:
93
+
94
+ ```python
95
+ if event.execution_type is ExecutionType.ORDER_FILLED and event.order.is_closing_order:
96
+ ... # event.order.order_type, event.order.is_stop_out
97
+ ```
98
+
99
+ ### What does not change
100
+
101
+ - An execution type the client does not know still raises from the converter.
102
+ The API layer lets it reach the caller, and the router drops the event. A
103
+ nested message with an unknown enum now takes the same path, because the
104
+ model converters raise the same error.
105
+ - `account_id` stays on both events. It is the routing key that the account
106
+ filter reads, and every event in `events/types.py` carries it.
107
+ - `MarginCallTriggerEvent` keeps its flat shape. There is no `MarginCall` model,
108
+ and no consumer asks for the fields the event drops.
109
+ - `DepthEvent` keeps `DepthQuote` in `events/types.py`. No other message uses it.
110
+ - `bonus_deposit_withdraw` and `deposit_withdraw` stay unread. There is no
111
+ model for them and no consumer asks for them.
112
+ - The `Order`, `Position`, `Deal` and `Account` models do not change.
113
+
114
+ ### Attribute counts
115
+
116
+ - `ExecutionEvent`: 10 today, 7 after.
117
+ - `TraderUpdateEvent`: 4 today, 2 after.
118
+
119
+ ## Steps
120
+
121
+ One step per turn. Each step leaves the suite green.
122
+
123
+ ### Step 1: `ExecutionEvent` nests `Order`, `Position` and `Deal`
124
+
125
+ - `events/types.py`: the new field list. The docstring states that each nested
126
+ field is `None` when the server sent no such message.
127
+ - `events/_execution.py`: call `Order.from_proto`, `Position.from_proto` and
128
+ `Deal.from_proto` behind the existing presence checks. Remove the timestamp
129
+ fallback and the `Decimal` and `order_side` imports.
130
+ - `events/router.py`: the debug line reads the order id through
131
+ `event.order`, and logs `None` when there is no order.
132
+ - `client.py`: `T_BothFilters` drops `ExecutionEvent`. The `Client.on`
133
+ docstring example reads `event.order.order_id`.
134
+ - `api/trading.py`: the class docstring example reads `execution.order.order_id`.
135
+ - `docs/api/events.md`: the filter table marks `ExecutionEvent` as `No` for
136
+ `symbol_id`. The code example reads `event.order.order_id`.
137
+ - `tests/behavior/events/test_routing.py`: the `order()` helper carries an
138
+ order type and a status. Deals carry a side and a status. Positions carry a
139
+ status. The assertions read through `event.order`, `event.position` and
140
+ `event.deal`. Replace the four tests on the flat fields with:
141
+ 1. A fill carries its order, position and deal: `event.order.order_id`,
142
+ `event.position.position_id`, `event.deal.filled_volume`,
143
+ `event.deal.execution_price` and `event.deal.execution_timestamp`.
144
+ 2. A sell execution reports the sell side through `event.order.side`.
145
+ 3. An accepted order has no position and no deal: both are `None`.
146
+ 4. A swap has no order: `event.order is None`.
147
+ 5. A close reports its reason: an `ORDER_FILLED` event whose order has
148
+ `order_type=STOP_LOSS_TAKE_PROFIT` and `closing_order=True` yields
149
+ `event.order.is_closing_order` and `event.order.order_type is
150
+ OrderType.STOP_LOSS_TAKE_PROFIT`.
151
+ - `tests/behavior/api/test_trading.py`: the two tests that read the flat
152
+ fields of a placed order read through the nested models instead.
153
+ - `tests/unit/test_enum_mapping.py`: `test_an_unknown_execution_type_is_an_error`
154
+ passes unchanged.
155
+
156
+ ### Step 2: `TraderUpdateEvent` nests `Account`
157
+
158
+ - `events/types.py`: the new field list.
159
+ - `events/router.py`: `_handle_trader_update` builds `Account.from_proto(trader)`.
160
+ Remove the `DEFAULT_MONEY_DIGITS` use from this handler only; the margin
161
+ handler keeps it.
162
+ - `tests/behavior/events/test_routing.py`: replace the two trader update tests
163
+ with:
164
+ 1. A trader update carries the account: `event.account.balance ==
165
+ Decimal("10000.00")` for a wire balance of `1_000_000` at two money
166
+ digits, and `event.account.leverage_in_cents == 10_000`.
167
+ 2. A trader update without money digits scales as cents: a wire balance of
168
+ `1_000_000` with no `money_digits` yields `Decimal("10000.00")`.
169
+
170
+ ### Step 3: docs and version
171
+
172
+ - `README.md` and `docs/getting-started.md`: the execution handler example
173
+ reads `event.order.order_id`. Add a short example that reads the reason for a
174
+ close from `event.order`.
175
+ - `docs/api/events.md`: the `ExecutionEvent` section states that the three
176
+ nested models are the same types that `TradingAPI` returns.
177
+ - `pyproject.toml`: 0.12.0. Deferred: another change ships in the same bump.
178
+
179
+ ## Consumer follow-up
180
+
181
+ Out of scope for this repository. Recorded so the two changes stay aligned.
182
+
183
+ Each consumer bumps its pin to 0.12.0 and reads `event.order`, `event.position`
184
+ and `event.deal` instead of the flat fields. A handler on `ExecutionEvent` that
185
+ filtered by `symbol_id` moves the check into the handler body. A handler on
186
+ `TraderUpdateEvent` reads `event.account.balance` as a `Decimal` in account
187
+ currency units and drops its own scaling.