ctrader-api-client 0.5.0__tar.gz → 0.6.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.

Potentially problematic release.


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

Files changed (137) hide show
  1. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/.gitignore +4 -1
  2. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/PKG-INFO +31 -4
  3. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/README.md +30 -3
  4. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/client.md +13 -0
  5. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/events.md +9 -0
  6. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/pyproject.toml +27 -1
  7. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/__init__.py +11 -4
  8. ctrader_api_client-0.6.0/src/ctrader_api_client/_internal/clock.py +36 -0
  9. ctrader_api_client-0.6.0/src/ctrader_api_client/_internal/conversions.py +41 -0
  10. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/messages.py +13 -43
  11. ctrader_api_client-0.6.0/src/ctrader_api_client/api/_base.py +25 -0
  12. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/api/accounts.py +5 -26
  13. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/api/market_data.py +26 -81
  14. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/api/symbols.py +8 -34
  15. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/api/trading.py +57 -199
  16. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/auth/__init__.py +3 -0
  17. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/_session.py +59 -0
  18. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/auth/manager.py +234 -138
  19. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/policy.py +47 -0
  20. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/client.py +42 -3
  21. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/config.py +8 -0
  22. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/connection/heartbeat.py +21 -7
  23. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/connection/protocol.py +171 -19
  24. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/events/__init__.py +2 -0
  25. ctrader_api_client-0.6.0/src/ctrader_api_client/events/_execution.py +92 -0
  26. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/events/emitter.py +49 -30
  27. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/events/router.py +17 -76
  28. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/events/types.py +21 -0
  29. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/account.py +6 -11
  30. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/deal.py +7 -13
  31. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/order.py +23 -16
  32. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/position.py +20 -19
  33. ctrader_api_client-0.6.0/tests/__init__.py +1 -0
  34. ctrader_api_client-0.6.0/tests/behavior/__init__.py +1 -0
  35. ctrader_api_client-0.6.0/tests/behavior/api/__init__.py +1 -0
  36. ctrader_api_client-0.6.0/tests/behavior/api/conftest.py +29 -0
  37. ctrader_api_client-0.6.0/tests/behavior/api/test_market_data.py +348 -0
  38. ctrader_api_client-0.6.0/tests/behavior/api/test_reference_data.py +196 -0
  39. ctrader_api_client-0.6.0/tests/behavior/api/test_trading.py +492 -0
  40. ctrader_api_client-0.6.0/tests/behavior/auth/__init__.py +1 -0
  41. ctrader_api_client-0.6.0/tests/behavior/auth/conftest.py +52 -0
  42. ctrader_api_client-0.6.0/tests/behavior/auth/test_authentication.py +355 -0
  43. ctrader_api_client-0.6.0/tests/behavior/auth/test_token_lifecycle.py +345 -0
  44. ctrader_api_client-0.6.0/tests/behavior/connection/__init__.py +1 -0
  45. ctrader_api_client-0.6.0/tests/behavior/connection/test_event_dispatch.py +141 -0
  46. ctrader_api_client-0.6.0/tests/behavior/connection/test_framing.py +116 -0
  47. ctrader_api_client-0.6.0/tests/behavior/connection/test_heartbeat.py +99 -0
  48. ctrader_api_client-0.6.0/tests/behavior/connection/test_reconnection.py +118 -0
  49. ctrader_api_client-0.6.0/tests/behavior/connection/test_request_response.py +107 -0
  50. ctrader_api_client-0.6.0/tests/behavior/events/__init__.py +1 -0
  51. ctrader_api_client-0.6.0/tests/behavior/events/conftest.py +35 -0
  52. ctrader_api_client-0.6.0/tests/behavior/events/test_routing.py +622 -0
  53. ctrader_api_client-0.6.0/tests/behavior/events/test_subscriptions.py +365 -0
  54. ctrader_api_client-0.6.0/tests/behavior/test_client.py +375 -0
  55. ctrader_api_client-0.6.0/tests/conftest.py +102 -0
  56. ctrader_api_client-0.6.0/tests/harness/__init__.py +33 -0
  57. ctrader_api_client-0.6.0/tests/harness/clock.py +99 -0
  58. ctrader_api_client-0.6.0/tests/harness/factories.py +130 -0
  59. ctrader_api_client-0.6.0/tests/harness/recorder.py +73 -0
  60. ctrader_api_client-0.6.0/tests/harness/server.py +268 -0
  61. ctrader_api_client-0.6.0/tests/harness/signals.py +58 -0
  62. ctrader_api_client-0.6.0/tests/harness/stub_protocol.py +158 -0
  63. ctrader_api_client-0.6.0/tests/harness/wire.py +94 -0
  64. ctrader_api_client-0.6.0/tests/unit/__init__.py +1 -0
  65. ctrader_api_client-0.6.0/tests/unit/test_api_errors.py +71 -0
  66. ctrader_api_client-0.6.0/tests/unit/test_market_data_conversion.py +127 -0
  67. ctrader_api_client-0.6.0/tests/unit/test_request_conversion.py +221 -0
  68. ctrader_api_client-0.6.0/tests/unit/test_symbol_conversion.py +144 -0
  69. ctrader_api_client-0.6.0/tests/unit/test_trading_records.py +302 -0
  70. ctrader_api_client-0.6.0/tests/unit/test_wire_format.py +199 -0
  71. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/uv.lock +100 -1
  72. ctrader_api_client-0.5.0/tests/unit/_internal/test_messages.py +0 -270
  73. ctrader_api_client-0.5.0/tests/unit/_internal/test_serialization.py +0 -172
  74. ctrader_api_client-0.5.0/tests/unit/api/conftest.py +0 -13
  75. ctrader_api_client-0.5.0/tests/unit/api/test_accounts.py +0 -116
  76. ctrader_api_client-0.5.0/tests/unit/api/test_market_data_api.py +0 -220
  77. ctrader_api_client-0.5.0/tests/unit/api/test_symbols.py +0 -281
  78. ctrader_api_client-0.5.0/tests/unit/api/test_trading.py +0 -434
  79. ctrader_api_client-0.5.0/tests/unit/auth/test_credentials.py +0 -177
  80. ctrader_api_client-0.5.0/tests/unit/auth/test_manager.py +0 -962
  81. ctrader_api_client-0.5.0/tests/unit/connection/test_heartbeat.py +0 -219
  82. ctrader_api_client-0.5.0/tests/unit/connection/test_protocol.py +0 -419
  83. ctrader_api_client-0.5.0/tests/unit/connection/test_transport.py +0 -244
  84. ctrader_api_client-0.5.0/tests/unit/events/test_emitter.py +0 -352
  85. ctrader_api_client-0.5.0/tests/unit/events/test_router.py +0 -581
  86. ctrader_api_client-0.5.0/tests/unit/events/test_types.py +0 -320
  87. ctrader_api_client-0.5.0/tests/unit/models/test_account.py +0 -204
  88. ctrader_api_client-0.5.0/tests/unit/models/test_deal.py +0 -244
  89. ctrader_api_client-0.5.0/tests/unit/models/test_market_data.py +0 -159
  90. ctrader_api_client-0.5.0/tests/unit/models/test_order.py +0 -310
  91. ctrader_api_client-0.5.0/tests/unit/models/test_position.py +0 -175
  92. ctrader_api_client-0.5.0/tests/unit/models/test_requests.py +0 -380
  93. ctrader_api_client-0.5.0/tests/unit/models/test_symbol.py +0 -236
  94. ctrader_api_client-0.5.0/tests/unit/test_client.py +0 -549
  95. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/.claude/settings.local.json +0 -0
  96. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/.github/workflows/docs.yml +0 -0
  97. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/.pre-commit-config.yaml +0 -0
  98. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/.python-version +0 -0
  99. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/Justfile +0 -0
  100. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/LICENSE +0 -0
  101. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/accounts.md +0 -0
  102. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/enums.md +0 -0
  103. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/market-data.md +0 -0
  104. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/models.md +0 -0
  105. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/symbols.md +0 -0
  106. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/api/trading.md +0 -0
  107. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/getting-started.md +0 -0
  108. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/docs/index.md +0 -0
  109. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/mkdocs.yml +0 -0
  110. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/SOURCE +0 -0
  111. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/VERSION +0 -0
  112. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/update.sh +0 -0
  113. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  114. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  115. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/vendor/OpenApiMessages.proto +0 -0
  116. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  117. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/scripts/fix_proto_imports.py +0 -0
  118. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/__init__.py +0 -0
  119. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  120. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  121. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  122. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  123. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  124. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  125. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/api/__init__.py +0 -0
  126. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  127. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/auth/trigger.py +0 -0
  128. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  129. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/connection/transport.py +0 -0
  130. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/enums.py +0 -0
  131. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/exceptions.py +0 -0
  132. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/__init__.py +0 -0
  133. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/_base.py +0 -0
  134. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/market_data.py +0 -0
  135. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/requests.py +0 -0
  136. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/models/symbol.py +0 -0
  137. {ctrader_api_client-0.5.0 → ctrader_api_client-0.6.0}/src/ctrader_api_client/py.typed +0 -0
@@ -11,4 +11,7 @@ wheels/
11
11
 
12
12
  # IDE-specific files
13
13
  .idea/
14
- .vscode/
14
+ .vscode/
15
+ # Test artifacts
16
+ .coverage
17
+ .pytest_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -119,7 +119,9 @@ creds = await client.auth.authenticate_by_trader_login(
119
119
  expires_at=1778617423,
120
120
  )
121
121
 
122
- # Tokens are automatically refreshed before expiry
122
+ # Tokens are automatically refreshed before expiry. A refresh that fails is
123
+ # retried on the next check rather than dropping the session, and surfaces as a
124
+ # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
123
125
  ```
124
126
 
125
127
  ### Market Data
@@ -150,10 +152,10 @@ request = NewOrderRequest(
150
152
  side=OrderSide.BUY,
151
153
  volume=100000, # 1 lot in cents
152
154
  )
153
- result = await client.trading.create_order(account_id, request)
155
+ result = await client.trading.place_order(account_id, request)
154
156
 
155
157
  # Get open positions
156
- positions = await client.trading.get_positions(account_id)
158
+ positions = await client.trading.get_open_positions(account_id)
157
159
 
158
160
  # Close a position
159
161
  close_position = ClosePositionRequest(
@@ -171,6 +173,7 @@ from ctrader_api_client.events import (
171
173
  ExecutionEvent,
172
174
  ReadyEvent,
173
175
  ReconnectedEvent,
176
+ TokenRefreshFailedEvent,
174
177
  )
175
178
 
176
179
  # Price updates
@@ -193,6 +196,12 @@ async def on_ready(event: ReadyEvent):
193
196
  @client.on(ReconnectedEvent)
194
197
  async def on_reconnected(event: ReconnectedEvent):
195
198
  print(f"Reconnected, restored accounts: {event.restored_accounts}")
199
+
200
+ # Token refresh failed (retried automatically; a repeating event means the
201
+ # refresh token is no longer usable and the account must be re-authorized)
202
+ @client.on(TokenRefreshFailedEvent, account_id=account_id)
203
+ async def on_refresh_failed(event: TokenRefreshFailedEvent):
204
+ print(f"Token refresh failed for {event.account_id}: {event.error}")
196
205
  ```
197
206
 
198
207
  ### Symbols
@@ -237,6 +246,9 @@ Use `ReadyEvent` to set up subscriptions that persist across reconnections.
237
246
  ## Configuration
238
247
 
239
248
  ```python
249
+ from ctrader_api_client import ClientConfig
250
+ from ctrader_api_client.auth import ReauthPolicy, RefreshPolicy
251
+
240
252
  config = ClientConfig(
241
253
  client_id="your_client_id",
242
254
  client_secret="your_client_secret",
@@ -255,5 +267,20 @@ config = ClientConfig(
255
267
  reconnect_attempts=5,
256
268
  reconnect_min_wait=1.0,
257
269
  reconnect_max_wait=60.0,
270
+
271
+ # Token refresh: when to refresh access tokens and how hard to retry
272
+ refresh_policy=RefreshPolicy(
273
+ buffer_seconds=300.0, # refresh this long before expiry
274
+ check_interval=60.0, # how often to check for expiring tokens
275
+ retry_attempts=3,
276
+ retry_min_wait=1.0,
277
+ retry_max_wait=30.0,
278
+ ),
279
+
280
+ # Session recovery: backoff for re-establishing sessions the server dropped
281
+ reauth_policy=ReauthPolicy(
282
+ min_wait=1.0,
283
+ max_wait=60.0,
284
+ ),
258
285
  )
259
286
  ```
@@ -100,7 +100,9 @@ creds = await client.auth.authenticate_by_trader_login(
100
100
  expires_at=1778617423,
101
101
  )
102
102
 
103
- # Tokens are automatically refreshed before expiry
103
+ # Tokens are automatically refreshed before expiry. A refresh that fails is
104
+ # retried on the next check rather than dropping the session, and surfaces as a
105
+ # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
104
106
  ```
105
107
 
106
108
  ### Market Data
@@ -131,10 +133,10 @@ request = NewOrderRequest(
131
133
  side=OrderSide.BUY,
132
134
  volume=100000, # 1 lot in cents
133
135
  )
134
- result = await client.trading.create_order(account_id, request)
136
+ result = await client.trading.place_order(account_id, request)
135
137
 
136
138
  # Get open positions
137
- positions = await client.trading.get_positions(account_id)
139
+ positions = await client.trading.get_open_positions(account_id)
138
140
 
139
141
  # Close a position
140
142
  close_position = ClosePositionRequest(
@@ -152,6 +154,7 @@ from ctrader_api_client.events import (
152
154
  ExecutionEvent,
153
155
  ReadyEvent,
154
156
  ReconnectedEvent,
157
+ TokenRefreshFailedEvent,
155
158
  )
156
159
 
157
160
  # Price updates
@@ -174,6 +177,12 @@ async def on_ready(event: ReadyEvent):
174
177
  @client.on(ReconnectedEvent)
175
178
  async def on_reconnected(event: ReconnectedEvent):
176
179
  print(f"Reconnected, restored accounts: {event.restored_accounts}")
180
+
181
+ # Token refresh failed (retried automatically; a repeating event means the
182
+ # refresh token is no longer usable and the account must be re-authorized)
183
+ @client.on(TokenRefreshFailedEvent, account_id=account_id)
184
+ async def on_refresh_failed(event: TokenRefreshFailedEvent):
185
+ print(f"Token refresh failed for {event.account_id}: {event.error}")
177
186
  ```
178
187
 
179
188
  ### Symbols
@@ -218,6 +227,9 @@ Use `ReadyEvent` to set up subscriptions that persist across reconnections.
218
227
  ## Configuration
219
228
 
220
229
  ```python
230
+ from ctrader_api_client import ClientConfig
231
+ from ctrader_api_client.auth import ReauthPolicy, RefreshPolicy
232
+
221
233
  config = ClientConfig(
222
234
  client_id="your_client_id",
223
235
  client_secret="your_client_secret",
@@ -236,5 +248,20 @@ config = ClientConfig(
236
248
  reconnect_attempts=5,
237
249
  reconnect_min_wait=1.0,
238
250
  reconnect_max_wait=60.0,
251
+
252
+ # Token refresh: when to refresh access tokens and how hard to retry
253
+ refresh_policy=RefreshPolicy(
254
+ buffer_seconds=300.0, # refresh this long before expiry
255
+ check_interval=60.0, # how often to check for expiring tokens
256
+ retry_attempts=3,
257
+ retry_min_wait=1.0,
258
+ retry_max_wait=30.0,
259
+ ),
260
+
261
+ # Session recovery: backoff for re-establishing sessions the server dropped
262
+ reauth_policy=ReauthPolicy(
263
+ min_wait=1.0,
264
+ max_wait=60.0,
265
+ ),
239
266
  )
240
267
  ```
@@ -27,6 +27,19 @@ The main entry point for interacting with the cTrader API.
27
27
  options:
28
28
  show_source: false
29
29
 
30
+ ## Authentication Policies
31
+
32
+ Token-refresh and session-recovery timing, passed via
33
+ `ClientConfig(refresh_policy=..., reauth_policy=...)`.
34
+
35
+ ::: ctrader_api_client.auth.RefreshPolicy
36
+ options:
37
+ show_source: false
38
+
39
+ ::: ctrader_api_client.auth.ReauthPolicy
40
+ options:
41
+ show_source: false
42
+
30
43
  ## Authentication
31
44
 
32
45
  The `client.auth` property provides access to authentication operations.
@@ -158,6 +158,15 @@ drop. Check current authorization with `client.is_account_authorized(account_id)
158
158
  options:
159
159
  show_source: false
160
160
 
161
+ ::: ctrader_api_client.events.TokenRefreshFailedEvent
162
+ options:
163
+ show_source: false
164
+
165
+ The client keeps the existing credentials and retries on the next refresh check,
166
+ so a single event usually means a transient outage. If the event keeps repeating,
167
+ the refresh token is no longer usable and the account has to be re-authorized out
168
+ of band.
169
+
161
170
  ## Symbol Events
162
171
 
163
172
  ::: ctrader_api_client.events.SymbolChangedEvent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -32,10 +32,36 @@ dev = [
32
32
  "mkdocs-material>=9.7.6",
33
33
  "mkdocstrings[python]>=1.0.3",
34
34
  "pytest>=9.0.3",
35
+ "pytest-cov>=7.0.0",
36
+ "pytest-timeout>=2.4.0",
35
37
  "ruff>=0.15.9",
36
38
  "zuban>=0.7.0",
37
39
  ]
38
40
 
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ addopts = "--strict-markers --strict-config"
44
+ # Every coroutine test runs under anyio without a per-test marker. Marking them
45
+ # later (in a collection hook) makes anyio rebuild the item and silently drop
46
+ # any pytest.mark.parametrize arguments.
47
+ anyio_mode = "auto"
48
+ # No test should ever hang: a stuck reader loop or an unresolved request must
49
+ # fail the run rather than block it.
50
+ timeout = 30
51
+ filterwarnings = [
52
+ "error",
53
+ # stringcase, a transitive betterproto dependency, is unmaintained and has
54
+ # invalid escape sequences that newer Pythons flag on import.
55
+ "ignore::SyntaxWarning",
56
+ ]
57
+
58
+ [tool.coverage.run]
59
+ source = ["ctrader_api_client"]
60
+ omit = ["*/_internal/proto/*"]
61
+
62
+ [tool.coverage.report]
63
+ exclude_also = ["if TYPE_CHECKING:", "@overload", "\\.\\.\\."]
64
+
39
65
  [tool.ruff]
40
66
  line-length = 120
41
67
 
@@ -1,8 +1,10 @@
1
+ from .clock import Clock, MonotonicClock
2
+ from .conversions import DEFAULT_MONEY_DIGITS, money_divisor, timestamp_to_datetime
1
3
  from .messages import (
2
4
  ClientMessageIdGenerator,
3
- MessageRegistry,
4
5
  deserialize_proto_message,
5
- get_registry,
6
+ get_class,
7
+ get_payload_type,
6
8
  unwrap_message,
7
9
  wrap_message,
8
10
  )
@@ -14,13 +16,18 @@ from .serialization import (
14
16
 
15
17
 
16
18
  __all__ = [
19
+ "DEFAULT_MONEY_DIGITS",
17
20
  "ClientMessageIdGenerator",
18
- "MessageRegistry",
21
+ "Clock",
22
+ "MonotonicClock",
19
23
  "deserialize_proto_message",
20
24
  "encode_with_length_prefix",
21
- "get_registry",
25
+ "get_class",
26
+ "get_payload_type",
27
+ "money_divisor",
22
28
  "read_exact",
23
29
  "read_framed_message",
30
+ "timestamp_to_datetime",
24
31
  "unwrap_message",
25
32
  "wrap_message",
26
33
  ]
@@ -0,0 +1,36 @@
1
+ """Time source abstraction for timer-driven components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Protocol as TypingProtocol
7
+
8
+ import anyio
9
+
10
+
11
+ class Clock(TypingProtocol):
12
+ """A source of monotonic time and delays.
13
+
14
+ Injected into components whose behaviour is driven by elapsed time so that
15
+ those timings can be controlled deterministically instead of waited on.
16
+ """
17
+
18
+ def now(self) -> float:
19
+ """Return the current value of a monotonic clock, in seconds."""
20
+ ...
21
+
22
+ async def sleep(self, seconds: float) -> None:
23
+ """Suspend the calling task for the given number of seconds."""
24
+ ...
25
+
26
+
27
+ class MonotonicClock:
28
+ """Default clock backed by :func:`time.monotonic` and :func:`anyio.sleep`."""
29
+
30
+ def now(self) -> float:
31
+ """Return the current monotonic time in seconds."""
32
+ return time.monotonic()
33
+
34
+ async def sleep(self, seconds: float) -> None:
35
+ """Suspend the calling task for the given number of seconds."""
36
+ await anyio.sleep(seconds)
@@ -0,0 +1,41 @@
1
+ """Conversions between wire representations and Python types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+
8
+ # The scale the server uses for monetary amounts when it does not say otherwise.
9
+ # money_digits is a plain proto3 scalar, so a message that omits it parses as 0,
10
+ # which would scale by 1 and overstate every amount by two orders of magnitude.
11
+ DEFAULT_MONEY_DIGITS = 2
12
+
13
+
14
+ def money_divisor(money_digits: int) -> int:
15
+ """Return the divisor that turns a scaled money integer into its real value.
16
+
17
+ Monetary amounts arrive as integers scaled by ``10 ** money_digits``. A
18
+ ``money_digits`` of 0 is read as absent rather than as "no scaling", because
19
+ the wire format cannot distinguish the two and no account quotes money in
20
+ whole units.
21
+
22
+ Args:
23
+ money_digits: The exponent reported alongside the amount, or 0 if the
24
+ server omitted it.
25
+
26
+ Returns:
27
+ The divisor to apply to the scaled integer.
28
+ """
29
+ return 10 ** (money_digits or DEFAULT_MONEY_DIGITS)
30
+
31
+
32
+ def timestamp_to_datetime(timestamp_ms: int) -> datetime:
33
+ """Convert a millisecond UTC timestamp to an aware datetime.
34
+
35
+ Args:
36
+ timestamp_ms: Milliseconds since the Unix epoch, as sent by the server.
37
+
38
+ Returns:
39
+ The equivalent timezone-aware datetime in UTC.
40
+ """
41
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import threading
6
- from dataclasses import dataclass, field
7
6
 
8
7
  import betterproto
9
8
 
@@ -205,25 +204,21 @@ _PAYLOAD_TYPE_TO_CLASS: dict[int, type[betterproto.Message]] = {
205
204
  }
206
205
 
207
206
 
208
- @dataclass
209
- class MessageRegistry:
210
- """Bidirectional mapping between payload_type and message class."""
207
+ # The reverse direction, for wrapping outbound messages. Every class above maps
208
+ # from exactly one payload type, so inverting the mapping loses nothing.
209
+ _CLASS_TO_PAYLOAD_TYPE: dict[type[betterproto.Message], int] = {
210
+ cls: payload_type for payload_type, cls in _PAYLOAD_TYPE_TO_CLASS.items()
211
+ }
211
212
 
212
- payload_type_to_class: dict[int, type[betterproto.Message]] = field(default_factory=dict)
213
- class_to_payload_type: dict[type[betterproto.Message], int] = field(default_factory=dict)
214
213
 
215
- def get_class(self, payload_type: int) -> type[betterproto.Message] | None:
216
- """Get the message class for a payload type."""
217
- return self.payload_type_to_class.get(payload_type)
214
+ def get_class(payload_type: int) -> type[betterproto.Message] | None:
215
+ """Get the message class for a payload type, or None if unregistered."""
216
+ return _PAYLOAD_TYPE_TO_CLASS.get(payload_type)
218
217
 
219
- def get_payload_type(self, cls: type[betterproto.Message]) -> int | None:
220
- """Get the payload type for a message class."""
221
- return self.class_to_payload_type.get(cls)
222
218
 
223
- def register(self, payload_type: int, cls: type[betterproto.Message]) -> None:
224
- """Register a bidirectional mapping."""
225
- self.payload_type_to_class[payload_type] = cls
226
- self.class_to_payload_type[cls] = payload_type
219
+ def get_payload_type(cls: type[betterproto.Message]) -> int | None:
220
+ """Get the payload type for a message class, or None if unregistered."""
221
+ return _CLASS_TO_PAYLOAD_TYPE.get(cls)
227
222
 
228
223
 
229
224
  class ClientMessageIdGenerator:
@@ -240,29 +235,6 @@ class ClientMessageIdGenerator:
240
235
  return str(self._counter)
241
236
 
242
237
 
243
- # Module-level singleton
244
- _registry: MessageRegistry | None = None
245
- _registry_lock = threading.Lock()
246
-
247
-
248
- def _build_registry() -> MessageRegistry:
249
- """Build the message registry from the explicit mapping."""
250
- registry = MessageRegistry()
251
- for payload_type, cls in _PAYLOAD_TYPE_TO_CLASS.items():
252
- registry.register(payload_type, cls)
253
- return registry
254
-
255
-
256
- def get_registry() -> MessageRegistry:
257
- """Get the lazily-initialized singleton registry."""
258
- global _registry
259
- if _registry is None:
260
- with _registry_lock:
261
- if _registry is None:
262
- _registry = _build_registry()
263
- return _registry
264
-
265
-
266
238
  def wrap_message(inner: betterproto.Message, client_msg_id: str | None = None) -> ProtoMessage:
267
239
  """Wrap an inner message into a ProtoMessage for transmission.
268
240
 
@@ -276,8 +248,7 @@ def wrap_message(inner: betterproto.Message, client_msg_id: str | None = None) -
276
248
  Raises:
277
249
  UnknownPayloadTypeError: If the inner message type is not registered.
278
250
  """
279
- registry = get_registry()
280
- payload_type = registry.get_payload_type(type(inner))
251
+ payload_type = get_payload_type(type(inner))
281
252
 
282
253
  if payload_type is None:
283
254
  raise UnknownPayloadTypeError(payload_type=-1)
@@ -302,8 +273,7 @@ def unwrap_message(proto_message: ProtoMessage) -> betterproto.Message:
302
273
  UnknownPayloadTypeError: If the payload type is not registered.
303
274
  DeserializationError: If deserialization fails.
304
275
  """
305
- registry = get_registry()
306
- cls = registry.get_class(proto_message.payload_type)
276
+ cls = get_class(proto_message.payload_type)
307
277
 
308
278
  if cls is None:
309
279
  raise UnknownPayloadTypeError(proto_message.payload_type)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+
6
+ if TYPE_CHECKING:
7
+ from ..connection import Protocol
8
+
9
+
10
+ class BaseAPI:
11
+ """Shared construction and timeout handling for API namespaces."""
12
+
13
+ def __init__(self, protocol: Protocol, default_timeout: float = 30.0) -> None:
14
+ """Initialize the API namespace.
15
+
16
+ Args:
17
+ protocol: The protocol instance for sending requests.
18
+ default_timeout: Default request timeout in seconds.
19
+ """
20
+ self._protocol = protocol
21
+ self._default_timeout = default_timeout
22
+
23
+ def _timeout(self, timeout: float | None) -> float:
24
+ """Resolve a per-call timeout against the configured default."""
25
+ return timeout or self._default_timeout
@@ -1,20 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
4
-
5
3
  from .._internal.proto import (
6
4
  ProtoOATraderReq,
7
5
  ProtoOATraderRes,
8
6
  )
9
- from ..exceptions import APIError
10
7
  from ..models import Account
8
+ from ._base import BaseAPI
11
9
 
12
10
 
13
- if TYPE_CHECKING:
14
- from ..connection import Protocol
15
-
16
-
17
- class AccountsAPI:
11
+ class AccountsAPI(BaseAPI):
18
12
  """Account information operations.
19
13
 
20
14
  Provides methods to retrieve account/trader details.
@@ -27,16 +21,6 @@ class AccountsAPI:
27
21
  ```
28
22
  """
29
23
 
30
- def __init__(self, protocol: Protocol, default_timeout: float = 30.0) -> None:
31
- """Initialize the accounts API.
32
-
33
- Args:
34
- protocol: The protocol instance for sending requests.
35
- default_timeout: Default request timeout in seconds.
36
- """
37
- self._protocol = protocol
38
- self._default_timeout = default_timeout
39
-
40
24
  async def get_trader(
41
25
  self,
42
26
  account_id: int,
@@ -57,15 +41,10 @@ class AccountsAPI:
57
41
  """
58
42
  request = ProtoOATraderReq(ctid_trader_account_id=account_id)
59
43
 
60
- response = await self._protocol.send_request(
44
+ response = await self._protocol.request(
61
45
  request,
62
- timeout=timeout or self._default_timeout,
46
+ ProtoOATraderRes,
47
+ timeout=self._timeout(timeout),
63
48
  )
64
49
 
65
- if not isinstance(response, ProtoOATraderRes):
66
- raise APIError(
67
- error_code="UNEXPECTED_RESPONSE",
68
- description=f"Expected ProtoOATraderRes, got {type(response).__name__}",
69
- )
70
-
71
50
  return Account.from_proto(response.trader)