ctrader-api-client 0.11.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 (137) hide show
  1. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/PKG-INFO +10 -3
  2. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/README.md +9 -2
  3. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/events.md +29 -2
  4. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/models.md +5 -0
  5. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/symbols.md +28 -0
  6. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/getting-started.md +2 -1
  7. ctrader_api_client-0.12.0/justfile +88 -0
  8. ctrader_api_client-0.12.0/plans/nested-event-models.md +187 -0
  9. ctrader_api_client-0.12.0/plans/symbol-swaps.md +209 -0
  10. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/pyproject.toml +1 -1
  11. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/__init__.py +6 -0
  12. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/trading.py +1 -1
  13. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/client.py +3 -2
  14. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/enums.py +28 -0
  15. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/_execution.py +11 -34
  16. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/router.py +4 -6
  17. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/types.py +15 -22
  18. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/__init__.py +2 -0
  19. ctrader_api_client-0.12.0/src/ctrader_api_client/models/swap.py +109 -0
  20. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/symbol.py +5 -7
  21. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_trading.py +21 -13
  22. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/test_routing.py +97 -34
  23. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_enum_mapping.py +10 -0
  24. ctrader_api_client-0.12.0/tests/unit/test_swap_conversion.py +155 -0
  25. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_symbol_conversion.py +0 -6
  26. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/uv.lock +1 -1
  27. ctrader_api_client-0.11.0/Justfile +0 -56
  28. ctrader_api_client-0.11.0/plans/startup-token-refresh.md +0 -157
  29. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.claude/settings.local.json +0 -0
  30. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/.gitignore +0 -0
  31. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/CACHEDIR.TAG +0 -0
  32. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/README.md +0 -0
  33. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.complexipy_cache/v/cache/functions +0 -0
  34. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.github/workflows/docs.yml +0 -0
  35. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.gitignore +0 -0
  36. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.pre-commit-config.yaml +0 -0
  37. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/.python-version +0 -0
  38. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/LICENSE +0 -0
  39. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/accounts.md +0 -0
  40. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/client.md +0 -0
  41. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/enums.md +0 -0
  42. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/exceptions.md +0 -0
  43. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/market-data.md +0 -0
  44. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/api/trading.md +0 -0
  45. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/docs/index.md +0 -0
  46. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/mkdocs.yml +0 -0
  47. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/SOURCE +0 -0
  48. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/VERSION +0 -0
  49. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/update.sh +0 -0
  50. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  51. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  52. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiMessages.proto +0 -0
  53. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  54. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/scripts/fix_proto_imports.py +0 -0
  55. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  56. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/backoff.py +0 -0
  57. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/clock.py +0 -0
  58. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
  59. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  60. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  61. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  62. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  63. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  64. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  65. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  66. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/__init__.py +0 -0
  67. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/_base.py +0 -0
  68. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/accounts.py +0 -0
  69. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/market_data.py +0 -0
  70. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/api/symbols.py +0 -0
  71. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/__init__.py +0 -0
  72. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/_maintain.py +0 -0
  73. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/_session.py +0 -0
  74. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  75. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/manager.py +0 -0
  76. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/policy.py +0 -0
  77. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/auth/store.py +0 -0
  78. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/composition.py +0 -0
  79. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/config.py +0 -0
  80. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  81. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  82. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/protocol.py +0 -0
  83. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
  84. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/connection/transport.py +0 -0
  85. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/__init__.py +0 -0
  86. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/emitter.py +0 -0
  87. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/events/publisher.py +0 -0
  88. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/exceptions.py +0 -0
  89. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/_base.py +0 -0
  90. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/account.py +0 -0
  91. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/commission.py +0 -0
  92. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/deal.py +0 -0
  93. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/market_data.py +0 -0
  94. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/order.py +0 -0
  95. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/position.py +0 -0
  96. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/requests.py +0 -0
  97. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/models/trading_hours.py +0 -0
  98. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/src/ctrader_api_client/py.typed +0 -0
  99. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/__init__.py +0 -0
  100. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/__init__.py +0 -0
  101. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/__init__.py +0 -0
  102. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/conftest.py +0 -0
  103. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_market_data.py +0 -0
  104. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_reference_data.py +0 -0
  105. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/api/test_subscription_restore.py +0 -0
  106. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/__init__.py +0 -0
  107. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/conftest.py +0 -0
  108. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/test_authentication.py +0 -0
  109. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/auth/test_token_lifecycle.py +0 -0
  110. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/__init__.py +0 -0
  111. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/conftest.py +0 -0
  112. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
  113. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_framing.py +0 -0
  114. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_heartbeat.py +0 -0
  115. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_reconnection.py +0 -0
  116. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/connection/test_request_response.py +0 -0
  117. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/__init__.py +0 -0
  118. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/conftest.py +0 -0
  119. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/events/test_subscriptions.py +0 -0
  120. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/behavior/test_client.py +0 -0
  121. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/conftest.py +0 -0
  122. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/__init__.py +0 -0
  123. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/clock.py +0 -0
  124. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/factories.py +0 -0
  125. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/recorder.py +0 -0
  126. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/server.py +0 -0
  127. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/signals.py +0 -0
  128. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/stub_protocol.py +0 -0
  129. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/harness/wire.py +0 -0
  130. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/__init__.py +0 -0
  131. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_api_errors.py +0 -0
  132. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_commission_conversion.py +0 -0
  133. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_market_data_conversion.py +0 -0
  134. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_request_conversion.py +0 -0
  135. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_trading_hours.py +0 -0
  136. {ctrader_api_client-0.11.0 → ctrader_api_client-0.12.0}/tests/unit/test_trading_records.py +0 -0
  137. {ctrader_api_client-0.11.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.11.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
@@ -252,6 +252,7 @@ await client.trading.close_position(account_id, close_position)
252
252
  ```python
253
253
  from ctrader_api_client import (
254
254
  ExecutionEvent,
255
+ ExecutionType,
255
256
  ReadyEvent,
256
257
  ReconnectedEvent,
257
258
  SpotEvent,
@@ -264,10 +265,16 @@ from ctrader_api_client import (
264
265
  async def on_spot(event: SpotEvent):
265
266
  print(f"{event.bid}/{event.ask}")
266
267
 
267
- # 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.
268
270
  @client.on(ExecutionEvent, account_id=account_id)
269
271
  async def on_execution(event: ExecutionEvent):
270
- 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}")
271
278
 
272
279
  # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
273
280
  # Subscriptions are already restored by this point; use it to reconcile your own state.
@@ -233,6 +233,7 @@ await client.trading.close_position(account_id, close_position)
233
233
  ```python
234
234
  from ctrader_api_client import (
235
235
  ExecutionEvent,
236
+ ExecutionType,
236
237
  ReadyEvent,
237
238
  ReconnectedEvent,
238
239
  SpotEvent,
@@ -245,10 +246,16 @@ from ctrader_api_client import (
245
246
  async def on_spot(event: SpotEvent):
246
247
  print(f"{event.bid}/{event.ask}")
247
248
 
248
- # 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.
249
251
  @client.on(ExecutionEvent, account_id=account_id)
250
252
  async def on_execution(event: ExecutionEvent):
251
- 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}")
252
259
 
253
260
  # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
254
261
  # Subscriptions are already restored by this point; use it to reconcile your own state.
@@ -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
@@ -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():
@@ -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.
@@ -0,0 +1,209 @@
1
+ # Symbol swaps
2
+
3
+ Status: complete on 2026-10-21. Ships in the 0.12.0 bump with the nested
4
+ event models.
5
+
6
+ ## Problem
7
+
8
+ A consumer cannot compute what a position costs to hold overnight.
9
+
10
+ - `Symbol` keeps `swap_long` and `swap_short` as bare decimals. The server sends
11
+ them with `swapCalculationType`, which says whether a rate is pips per lot,
12
+ an annual percentage, or points. A rate read without its type is meaningless.
13
+ The commission module makes the same argument for the commission rate, and
14
+ keeps the rate with its type. The swap rate does not get that treatment.
15
+ - `Symbol.from_proto` drops `swapRollover3Days`, `swapPeriod`, `swapTime`,
16
+ `skipSWAPPeriods` and `chargeSwapAtWeekends`. A consumer that wants to know
17
+ when a swap is charged, and on which day it is tripled, has no path to it.
18
+
19
+ ## Facts from the source
20
+
21
+ - `models/commission.py`: `Commission.from_proto(proto: ProtoOASymbol)` reads a
22
+ group of related fields out of the symbol proto into one sub-model. `Symbol`
23
+ holds it as `commission: Commission = Field(default_factory=Commission)`.
24
+ This is the pattern to copy.
25
+ - `_internal/proto/OpenApiModelMessages.py`: `ProtoOASwapCalculationType` has
26
+ `PIPS = 0`, `PERCENTAGE = 1`, `POINTS = 2`. The vendor docs name only the
27
+ first two. `ProtoOADayOfWeek` has `NONE = 0` through `SUNDAY = 7`. The
28
+ generated name for `skipSWAPPeriods` is `skip_s_w_a_p_periods`.
29
+ - `protos/vendor/OpenApiModelMessages.proto`: `swapRollover3Days` and
30
+ `rolloverCommission3Days` declare `[default = MONDAY]`. `swapCalculationType`
31
+ declares `[default = PIPS]`. `swapPeriod`, `swapTime`, `skipSWAPPeriods` and
32
+ `chargeSwapAtWeekends` declare no default. The generated code applies no
33
+ proto2 default, so each of these reads as 0 when the broker omits it.
34
+ - `_internal/conversions.py`: `enum_member(mapping, value, unset=...)` reads 0
35
+ as the declared default when the caller passes one, and raises on 0 when the
36
+ caller passes none.
37
+ - `tests/unit/test_symbol_conversion.py`: `forex_symbol()` builds a `Symbol`
38
+ with `swap_long` and `swap_short`. `tests/unit/test_enum_mapping.py` lists
39
+ each optional enum with its declared default in `OPTIONAL`.
40
+ - `tests/unit/test_commission_conversion.py`: two tests pin values a live
41
+ broker sent, with the raw wire value and the expected reading in the
42
+ docstring. This is how a playground finding becomes a test.
43
+ - The playground script outside the repository holds the live credentials and
44
+ an editable install of this package. `client.protocol.request(...)` returns
45
+ raw protos. The account exposes 202 symbols.
46
+
47
+ ## Design
48
+
49
+ 1. A `Swap` sub-model in `models/swap.py`:
50
+
51
+ ```python
52
+ class Swap(FrozenModel):
53
+ calculation_type: SwapCalculationType = SwapCalculationType.PIPS
54
+ long: Decimal = Decimal(0)
55
+ short: Decimal = Decimal(0)
56
+ triple_day: DayOfWeek = DayOfWeek.MONDAY
57
+ period_hours: int | None = None
58
+ charge_time: time = time(0)
59
+ skip_periods: int = 0
60
+ charged_at_weekends: bool = False
61
+ ```
62
+
63
+ `Swap.from_proto(proto: ProtoOASymbol)` reads the eight fields. `Symbol`
64
+ gains `swap: Swap = Field(default_factory=Swap)`, and `swap_long` and
65
+ `swap_short` leave `Symbol`. The two rates stay as the server sends them.
66
+ The type says what they count, the same as the commission rate.
67
+
68
+ 2. Two enums in `enums.py`. `SwapCalculationType` has `PIPS`, `PERCENTAGE` and
69
+ `POINTS`. `DayOfWeek` has `NONE` and the seven days. The proto maps live in
70
+ `models/swap.py`, the same as the commission maps.
71
+
72
+ 3. How each unset value reads:
73
+ - `calculation_type`: 0 is `PIPS` on the wire and is also the declared
74
+ default, so there is nothing to decide.
75
+ - `triple_day`: 0 is `NONE` on the wire, and the declared default is
76
+ `MONDAY`. The wire cannot tell the two apart. Every other enum with a
77
+ declared default reads 0 as that default, so this one does too. Step 3
78
+ checks against the live broker whether this reading loses a real `NONE`.
79
+ - `period_hours`: 0 means the broker said nothing. The model reports `None`.
80
+ It does not guess 24, because the library never guesses at a value the
81
+ broker did not send.
82
+ - `charge_time`: 0 is midnight UTC, which is both the literal reading and
83
+ the sensible default. The model converts minutes from midnight to a
84
+ `datetime.time`.
85
+ - `skip_periods` and `charged_at_weekends`: 0 and `False` are the literal
86
+ readings and need no default.
87
+
88
+ 4. The playground check comes after the implementation, not before. The model
89
+ gives the check something to compare the raw protos against, and a finding
90
+ that contradicts the design changes the design in a later step.
91
+
92
+ ### What does not change
93
+
94
+ - The rollover fields stay unread: `rolloverCommission`, `skipRolloverDays`
95
+ and `rolloverCommission3Days`. They are the administrative fee that replaces
96
+ swaps on a swap-free account. The docs give no scale for the amount, and no
97
+ consumer account is swap-free. Step 3 records whether the live broker sends
98
+ them, so a later plan has the facts.
99
+ - `Account.swap_free` stays where it is.
100
+ - `Commission`, `TradingHours` and the rest of `Symbol` do not change.
101
+ - `distanceSetIn`, `gslDistance` and `gslCharge` stay out of scope. The stop
102
+ distances have the same shape of problem as the swap rate, a number without
103
+ its unit, and get their own plan.
104
+
105
+ ### Attribute counts
106
+
107
+ - `Symbol`: 20 today, 19 after. `swap_long` and `swap_short` leave, `swap`
108
+ arrives.
109
+ - `Swap`: 8.
110
+
111
+ ## Steps
112
+
113
+ One step per turn. Each step leaves the suite green.
114
+
115
+ ### Step 1: the `Swap` model
116
+
117
+ - `enums.py`: add `SwapCalculationType` and `DayOfWeek`.
118
+ - `models/swap.py`: the `Swap` model, the two proto maps, and `from_proto`.
119
+ The module docstring states why the rate travels with its type, in the
120
+ same words the commission module uses.
121
+ - `models/symbol.py`: `swap: Swap = Field(default_factory=Swap)` replaces the
122
+ two rates. `from_proto` calls `Swap.from_proto(proto)`. The class docstring
123
+ describes `swap` and drops the two rate lines.
124
+ - `models/__init__.py` and `__init__.py`: export `Swap`, `SwapCalculationType`
125
+ and `DayOfWeek`.
126
+ - `tests/unit/test_swap_conversion.py`, new:
127
+ 1. Each calculation type is translated, including `POINTS`.
128
+ 2. A calculation type the broker omits reads as `PIPS`.
129
+ 3. The long and short rates keep their decimal value.
130
+ 4. A triple day the broker omits reads as `MONDAY`.
131
+ 5. Each named day is translated.
132
+ 6. A swap period the broker omits reads as `None`, and a period of 8 reads
133
+ as 8.
134
+ 7. A charge time of 90 minutes reads as 01:30 UTC, and an omitted one reads
135
+ as midnight.
136
+ 8. The skip count and the weekend flag are carried over.
137
+ 9. A symbol without any swap field reports the default `Swap`.
138
+ - `tests/unit/test_symbol_conversion.py`: `forex_symbol()` drops the two rate
139
+ settings. Nothing in the file reads them.
140
+ - `tests/unit/test_enum_mapping.py`: `OPTIONAL` gains `swap_calculation_type`
141
+ with default `PIPS` and `swap_rollover3_days` with default `MONDAY`.
142
+
143
+ ### Step 2: docs
144
+
145
+ - `docs/api/symbols.md`: a `## Swap` section after `## Commission`. It states
146
+ that the rate and the type are read together, gives a table of the three
147
+ types, and shows `symbol.swap.long`, `symbol.swap.triple_day` and
148
+ `symbol.swap.period_hours`.
149
+ - `docs/api/models.md`: `::: ctrader_api_client.models.Swap` after
150
+ `Commission`.
151
+
152
+ ### Step 3: check the live broker
153
+
154
+ A script in the scratchpad directory imports the credentials from the
155
+ playground. It never copies them. It authenticates, requests
156
+ `ProtoOASymbolByIdReq` for a forex pair, a metal, an index and a crypto
157
+ symbol, and prints for each one:
158
+
159
+ - the raw wire values of the eight swap fields and the three rollover fields,
160
+ - the converted `Swap`.
161
+
162
+ The questions it answers, recorded in this file under `## Findings`:
163
+
164
+ 1. Does any symbol send `swapRollover3Days` as 0? If so, the `MONDAY` reading
165
+ is wrong for that broker, and the next step decides between a `NONE`
166
+ reading and a documented limit.
167
+ 2. What does `swapCalculationType` carry, and does any symbol send `POINTS`?
168
+ 3. What do `swapPeriod`, `swapTime` and `skipSWAPPeriods` carry? Does any
169
+ symbol omit `swapPeriod`?
170
+ 4. Does the broker send the three rollover fields at all, and at what scale?
171
+
172
+ ### Step 4: pin the findings
173
+
174
+ - For each finding that fixes a reading, a test in
175
+ `test_swap_conversion.py` with the raw wire value and the expected reading
176
+ in its docstring, the same as the two live commission tests.
177
+ - If finding 1 shows a real `NONE`, this plan gains a step that changes the
178
+ `triple_day` reading, and the version bump waits for it.
179
+
180
+ ## Findings
181
+
182
+ Checked on 2026-10-21 against the demo broker in the playground, over all 202
183
+ symbols the account exposes. The scratchpad script reads the playground
184
+ constants with `ast` so that it never runs the playground or copies a secret.
185
+
186
+ 1. **No symbol sends `swapRollover3Days` as 0.** Every symbol sends
187
+ `WEDNESDAY` (87, every forex pair and every metal) or `FRIDAY` (115, every
188
+ index, commodity, crypto and equity). The `MONDAY` reading of 0 is never
189
+ exercised by this broker, so it stays as the declared default. `DayOfWeek`
190
+ keeps no `NONE` member.
191
+ 2. **`swapCalculationType` is `PIPS` for 171 symbols and `PERCENTAGE` for
192
+ 31.** No symbol sends `POINTS`. Every `PERCENTAGE` symbol is a crypto pair
193
+ and sends `-30.0` for both rates: an annual charge of 30 percent either
194
+ way. A `PIPS` example is EURUSD at `-1.084` long and `0.051` short.
195
+ 3. **`swapPeriod` is 24, `swapTime` is 1259 and `skipSWAPPeriods` is 0 on
196
+ every symbol.** The broker charges once a day at 20:59 UTC. No symbol omits
197
+ the period, so the `None` reading is never exercised here. One symbol,
198
+ BRK.B, sets `chargeSwapAtWeekends`.
199
+ 4. **The broker sends the rollover schedule but no rollover fee.**
200
+ `rolloverCommission` is 0, `skipRolloverDays` is 3 and
201
+ `rolloverCommission3Days` is `WEDNESDAY` on every symbol. The account is
202
+ not swap-free, so the fee amount and its scale stay unknown. The rollover
203
+ fields stay out of the model.
204
+
205
+ Also seen: 34 exotic forex pairs send 0 for both rates with `PIPS`, which the
206
+ model reports as a zero swap, which is what the broker says.
207
+
208
+ No finding contradicts the design. Step 4 pins findings 2 and 3 as tests with
209
+ the raw wire values.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [