ctrader-api-client 0.6.0__tar.gz → 0.7.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 (135) hide show
  1. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/.claude/settings.local.json +2 -1
  2. ctrader_api_client-0.7.0/HARDENING.md +251 -0
  3. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/PKG-INFO +125 -34
  4. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/README.md +124 -33
  5. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/accounts.md +8 -4
  6. ctrader_api_client-0.7.0/docs/api/client.md +133 -0
  7. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/enums.md +1 -7
  8. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/events.md +27 -12
  9. ctrader_api_client-0.7.0/docs/api/exceptions.md +213 -0
  10. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/market-data.md +26 -15
  11. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/models.md +2 -3
  12. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/trading.md +3 -4
  13. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/getting-started.md +132 -35
  14. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/index.md +14 -13
  15. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/mkdocs.yml +1 -0
  16. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/pyproject.toml +1 -1
  17. ctrader_api_client-0.7.0/src/ctrader_api_client/__init__.py +190 -0
  18. ctrader_api_client-0.7.0/src/ctrader_api_client/api/accounts.py +134 -0
  19. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/api/market_data.py +88 -0
  20. ctrader_api_client-0.7.0/src/ctrader_api_client/auth/__init__.py +26 -0
  21. ctrader_api_client-0.7.0/src/ctrader_api_client/auth/_recovery.py +238 -0
  22. ctrader_api_client-0.7.0/src/ctrader_api_client/auth/_refresh.py +203 -0
  23. ctrader_api_client-0.7.0/src/ctrader_api_client/auth/_session.py +180 -0
  24. ctrader_api_client-0.7.0/src/ctrader_api_client/auth/manager.py +259 -0
  25. ctrader_api_client-0.7.0/src/ctrader_api_client/auth/store.py +35 -0
  26. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/client.py +143 -241
  27. ctrader_api_client-0.7.0/src/ctrader_api_client/composition.py +165 -0
  28. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/connection/__init__.py +4 -0
  29. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/connection/heartbeat.py +31 -24
  30. ctrader_api_client-0.7.0/src/ctrader_api_client/connection/listener.py +17 -0
  31. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/connection/protocol.py +93 -69
  32. ctrader_api_client-0.7.0/src/ctrader_api_client/connection/supervisor.py +124 -0
  33. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/enums.py +12 -5
  34. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/events/__init__.py +4 -0
  35. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/events/emitter.py +2 -22
  36. ctrader_api_client-0.7.0/src/ctrader_api_client/events/publisher.py +19 -0
  37. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/events/router.py +39 -62
  38. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/events/types.py +30 -4
  39. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/exceptions.py +17 -0
  40. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/api/conftest.py +8 -3
  41. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/api/test_reference_data.py +100 -1
  42. ctrader_api_client-0.7.0/tests/behavior/api/test_subscription_restore.py +206 -0
  43. ctrader_api_client-0.7.0/tests/behavior/auth/conftest.py +120 -0
  44. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/auth/test_authentication.py +59 -149
  45. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/auth/test_token_lifecycle.py +139 -68
  46. ctrader_api_client-0.7.0/tests/behavior/connection/conftest.py +24 -0
  47. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/connection/test_framing.py +20 -14
  48. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/connection/test_heartbeat.py +42 -11
  49. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/connection/test_reconnection.py +59 -16
  50. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/connection/test_request_response.py +8 -8
  51. ctrader_api_client-0.7.0/tests/behavior/events/conftest.py +32 -0
  52. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/events/test_routing.py +76 -1
  53. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/events/test_subscriptions.py +0 -47
  54. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/test_client.py +90 -27
  55. ctrader_api_client-0.7.0/tests/conftest.py +160 -0
  56. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/__init__.py +12 -1
  57. ctrader_api_client-0.7.0/tests/harness/recorder.py +163 -0
  58. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/stub_protocol.py +35 -1
  59. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/uv.lock +1 -1
  60. ctrader_api_client-0.6.0/docs/api/client.md +0 -65
  61. ctrader_api_client-0.6.0/src/ctrader_api_client/__init__.py +0 -66
  62. ctrader_api_client-0.6.0/src/ctrader_api_client/api/accounts.py +0 -50
  63. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/__init__.py +0 -19
  64. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/_session.py +0 -59
  65. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/manager.py +0 -713
  66. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/trigger.py +0 -17
  67. ctrader_api_client-0.6.0/tests/behavior/auth/conftest.py +0 -52
  68. ctrader_api_client-0.6.0/tests/behavior/events/conftest.py +0 -35
  69. ctrader_api_client-0.6.0/tests/conftest.py +0 -102
  70. ctrader_api_client-0.6.0/tests/harness/recorder.py +0 -73
  71. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/.github/workflows/docs.yml +0 -0
  72. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/.gitignore +0 -0
  73. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/.pre-commit-config.yaml +0 -0
  74. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/.python-version +0 -0
  75. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/Justfile +0 -0
  76. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/LICENSE +0 -0
  77. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/docs/api/symbols.md +0 -0
  78. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/SOURCE +0 -0
  79. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/VERSION +0 -0
  80. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/update.sh +0 -0
  81. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  82. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  83. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/vendor/OpenApiMessages.proto +0 -0
  84. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  85. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/scripts/fix_proto_imports.py +0 -0
  86. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  87. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/clock.py +0 -0
  88. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
  89. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  90. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  91. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  92. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  93. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  94. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  95. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  96. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/api/__init__.py +0 -0
  97. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/api/_base.py +0 -0
  98. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/api/symbols.py +0 -0
  99. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/api/trading.py +0 -0
  100. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  101. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/auth/policy.py +0 -0
  102. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/config.py +0 -0
  103. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/connection/transport.py +0 -0
  104. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/events/_execution.py +0 -0
  105. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/__init__.py +0 -0
  106. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/_base.py +0 -0
  107. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/account.py +0 -0
  108. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/deal.py +0 -0
  109. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/market_data.py +0 -0
  110. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/order.py +0 -0
  111. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/position.py +0 -0
  112. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/requests.py +0 -0
  113. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/models/symbol.py +0 -0
  114. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/src/ctrader_api_client/py.typed +0 -0
  115. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/__init__.py +0 -0
  116. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/__init__.py +0 -0
  117. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/api/__init__.py +0 -0
  118. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/api/test_market_data.py +0 -0
  119. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/api/test_trading.py +0 -0
  120. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/auth/__init__.py +0 -0
  121. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/connection/__init__.py +0 -0
  122. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
  123. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/behavior/events/__init__.py +0 -0
  124. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/clock.py +0 -0
  125. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/factories.py +0 -0
  126. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/server.py +0 -0
  127. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/signals.py +0 -0
  128. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/harness/wire.py +0 -0
  129. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/__init__.py +0 -0
  130. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/test_api_errors.py +0 -0
  131. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/test_market_data_conversion.py +0 -0
  132. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/test_request_conversion.py +0 -0
  133. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/test_symbol_conversion.py +0 -0
  134. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/test_trading_records.py +0 -0
  135. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.0}/tests/unit/test_wire_format.py +0 -0
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(git -C \"/home/tek/Desktop/Algo Trading/Live Trading/cTrader/ctrader-api-client\" log --oneline -10)"
4
+ "Bash(git -C \"/home/tek/Desktop/Algo Trading/Live Trading/cTrader/ctrader-api-client\" log --oneline -10)",
5
+ "Bash(git add *)"
5
6
  ]
6
7
  }
7
8
  }
@@ -0,0 +1,251 @@
1
+ # Architecture hardening
2
+
3
+ Working notes for the `harden-architecture` branch. Everything below is against
4
+ `master` at `8f649f2`.
5
+
6
+ ## Done
7
+
8
+ Each item is one commit, in order.
9
+
10
+ ### Connection and auth lifecycle tests — `de41c4d`
11
+
12
+ Edge cases the existing suite did not reach, added before any of the
13
+ refactoring below so the refactoring had something to fail against: framing,
14
+ reconnection, token lifecycle and client startup.
15
+
16
+ ### Typed request correlation — `bb66230`
17
+
18
+ `Protocol` tracked in-flight requests across three parallel dicts keyed by
19
+ message id. They could disagree. Replaced by a single object holding the event,
20
+ the outcome and the expected response type, so a pending request is one thing
21
+ that either exists or does not.
22
+
23
+ ### Event handler registration — `4131424`
24
+
25
+ Registering a handler and unregistering it were written out separately at every
26
+ call site. `on_event` now returns a disposer, and callers hold that instead of
27
+ remembering how to undo their own registration.
28
+
29
+ ### Pluggable token storage — `b6dc7df`
30
+
31
+ Added the `TokenStore` protocol and the option to pass one to the client. The
32
+ client writes through it whenever tokens rotate, so a process that restarts can
33
+ pick up where it left off rather than re-authenticating from credentials.
34
+
35
+ Two things moved in passing, both breaking:
36
+
37
+ - `AuthTrigger` moved from `auth/trigger.py`, which is gone, to `enums.py`. It
38
+ describes why a session was established, which the event types need and the
39
+ auth package should not own alone.
40
+ - `ReadyEvent.is_reconnect` is now a property over a `trigger: AuthTrigger`
41
+ field. Reading it is unchanged; constructing a `ReadyEvent` is not.
42
+
43
+ ### Dead code and a start() guard — `de21183`
44
+
45
+ Removed `on_handler_error()`, which nothing called. Made
46
+ `HeartbeatManager.start()` idempotent: a second call used to overwrite the
47
+ nursery reference and leave the first loop unreachable, so `stop()` could never
48
+ find it.
49
+
50
+ ### Automatic subscription restore — `e0a3273`
51
+
52
+ Subscriptions were restored by an explicit call after reconnection, which meant
53
+ every reconnection path had to remember to make it. `MarketDataAPI` is now
54
+ registered as a restorer and the reconnect path drives it.
55
+
56
+ ### TokenStore documentation — `2f17ac3`
57
+
58
+ No code change. `TokenStore` is write-only by design — the library never reads
59
+ it back, because only the caller knows which accounts a given process is
60
+ responsible for. The name suggested otherwise, so the docstrings, the README
61
+ example and `docs/api/client.md` now say so and show the startup branch the
62
+ caller is expected to write.
63
+
64
+ Deliberately **not** done: adding `load()` to the protocol. The library would
65
+ have no reason to call it.
66
+
67
+ ### Composition root — `b3e6fb5`
68
+
69
+ `CTraderClient.__init__` built eleven collaborators and wired them together,
70
+ which made the client impossible to construct with a substitute for any one of
71
+ them. That wiring moved verbatim to `composition.py` as `build_graph()`
72
+ returning a frozen `ClientGraph`, and `CTraderClient.from_graph()` adopts one.
73
+
74
+ Two ordering constraints in the wiring are load-bearing and are noted in the
75
+ source: market data is built before auth because it is the restorer, and auth
76
+ before the router because it is the recovery.
77
+
78
+ Deliberately **not** done: a `build_client()` convenience wrapper.
79
+ `build_graph` plus `from_graph` already covers it.
80
+
81
+ ### Lifecycle inversion — `bd92f44` — **breaking**
82
+
83
+ `Protocol`, `HeartbeatManager` and `AuthManager` each held a task group handed
84
+ to them by a `start()` call and exited by a later `stop()` call from a different
85
+ task. `start()` replaced by `serve()`, which owns its nursery lexically and
86
+ suspends inside it; `stop()` reduced to cancelling the scope, which is allowed
87
+ from any task.
88
+
89
+ `CTraderClient.connect()` and `.close()` are **gone**. `async with client:` is
90
+ the only way to bring a client up. The nursery lives in the `async with` block,
91
+ so a background loop that dies is raised out of it rather than discovered at
92
+ shutdown.
93
+
94
+ Three things fell out of this:
95
+
96
+ - Every `except Exception: pass` in a `stop()` is gone; there is nothing left
97
+ to swallow.
98
+ - Cleanup moved into each `serve()`'s `finally`, so cancellation now cleans up
99
+ as thoroughly as an orderly stop.
100
+ - `task_group.start` rather than `start_soon` in the client means the reader is
101
+ provably live before anything is sent. That ordering used to be implicit.
102
+
103
+ Test-side, `connected` and `serving` fixtures give the *test* ownership of the
104
+ task group, for the same reason: a loop that dies mid-test surfaces at the end
105
+ of it instead of vanishing.
106
+
107
+ ### B1 — connection supervisor — `9df080b` — **breaking**
108
+
109
+ `Protocol` reported drops and recoveries through two callbacks the client set
110
+ on it by hand, and `CTraderClient._lifecycle` knew the order transport,
111
+ protocol, heartbeat and auth had to come up in. Both are gone.
112
+
113
+ `ConnectionSupervisor` owns the transport, the protocol and the heartbeat.
114
+ `serving()` is an async context manager rather than a `serve()`: connecting
115
+ happens before the block is entered, so a server that is not there still raises
116
+ `CTraderConnectionFailedError` instead of the `ExceptionGroup` a task group
117
+ would make of it. `_lifecycle` is now the supervisor's block with auth and the
118
+ router inside it.
119
+
120
+ The callbacks became one port. `ConnectionListener` — `on_connection_lost` and
121
+ `on_connection_restored` — is what `Protocol` reports to and what the
122
+ supervisor fans out to. The supervisor puts the heartbeat back itself before
123
+ notifying, because that is its own machinery rather than a listener's concern.
124
+
125
+ The listener is `AuthManager`, not the client. Re-authenticating the
126
+ application and every account after a reconnect, and reporting the outcome as
127
+ `ReconnectedEvent`, is session work; it only lived on the client because the
128
+ callback did. That takes ~70 lines off `client.py`, and puts the logic where
129
+ B3's `SessionRecovery` would want it.
130
+
131
+ Breaking: `Protocol.set_reconnect_handler` and `set_disconnect_handler` are
132
+ replaced by `Protocol.set_listener`, and `AuthManager.handle_connection_lost`
133
+ is now the private `_forget_sessions`, reached through the port.
134
+
135
+ Test-side, a `make_graph` fixture hands tests the graph before the client
136
+ adopts it, which is how the listener fan-out is exercised.
137
+
138
+ ### Exception shape at the client boundary — `ff6137a`
139
+
140
+ Found while placing automatic application authentication. Since the lifecycle
141
+ inversion, the task group holding the background tasks surrounds the `yield` in
142
+ `_lifecycle`, so *every* exception leaving `async with client:` passed through
143
+ it — and anyio wraps a lone exception as readily as a group. A caller's own
144
+ `ValueError` came back as `ExceptionGroup: unhandled errors in a TaskGroup (1
145
+ sub-exception)`, and `except CTraderConnectionFailedError` would have stopped
146
+ matching anything the machinery raised.
147
+
148
+ `__aenter__` and `__aexit__` now peel single-exception groups, recursively,
149
+ since the supervisor's task group nests inside the client's. Several failures
150
+ still arrive as a group, because that is what they are, and the block's own
151
+ exception is returned to the caller untouched rather than re-raised.
152
+
153
+ Not a break against `master`: this restores the shape callers had before the
154
+ branch.
155
+
156
+ ### B3 — split AuthManager — `651da82` — **breaking**
157
+
158
+ `auth/manager.py` was 751 lines doing four unrelated jobs. It is now four
159
+ pieces, each with one:
160
+
161
+ - `auth/_session.py` — `SessionStore`, joining the session state types already
162
+ there. It owns the dict, its invalidation and every query over it, so the
163
+ distinction between credentials (which outlive a link) and sessions (which do
164
+ not) is stated once instead of being re-derived by each collaborator.
165
+ - `auth/_refresh.py` — `TokenRefresher`, the refresh loop, its retry policy and
166
+ the `TokenStore` write-through.
167
+ - `auth/_recovery.py` — `SessionRecovery`, the reauth loop and the
168
+ `ConnectionListener` methods. It is also the `SessionRecovery` port the event
169
+ router already typed its collaborator as, so the names finally line up.
170
+ - `auth/manager.py` — `AuthManager`, down to turning credentials into live
171
+ sessions.
172
+
173
+ The refresher and the recovery monitor each serve themselves, so `_lifecycle`
174
+ starts two tasks where it started one, and `AuthManager` no longer has a
175
+ lifecycle of its own.
176
+
177
+ The public surface was cut at the same time, on the rule that nothing returning
178
+ a bare `ProtoOA` message should be public:
179
+
180
+ - `authenticate_app` is called by the client as it connects, and on every
181
+ reconnect. A client that cannot authenticate does not open, and the failure
182
+ arrives as `ApplicationAuthError` thanks to the unwrapping above.
183
+ - `authenticate_account` became `authenticate_trader`, returning nothing. The
184
+ `trigger` argument moved to the internal `establish`, where the refresher and
185
+ the recovery monitor use it.
186
+ - `get_accounts` and `resolve_account_id` moved to `AccountsAPI` as
187
+ `list_by_token` and `resolve_account_id`. They are account queries, not auth.
188
+ - `authenticate_by_trader_login` is gone with no replacement. Resolving a login
189
+ and authenticating an account are two steps, and the convenience of joining
190
+ them cost a second way to do the same thing:
191
+ `await client.auth.authenticate_trader(await store.load(account_id))` is now
192
+ the only shape, restart or not.
193
+
194
+ `APIError.is_token_failure()` replaces the module-level helper, since three
195
+ modules now need to ask.
196
+
197
+ ### Flat public surface — `6cc397c`
198
+
199
+ The package exported `CTraderClient`, `ClientConfig` and seven of the fourteen
200
+ enums "for easier access". The seven were an arbitrary subset: a caller reading
201
+ `order.status`, `order.time_in_force`, `deal.status`, `symbol.trading_mode` or
202
+ `account.access_rights` had to switch to `ctrader_api_client.enums` halfway down
203
+ the file. `AccountCredentials`, the one type nobody can avoid constructing, sat
204
+ two modules deep.
205
+
206
+ The top level now carries what a caller names — what you construct, what you
207
+ receive, what you catch — and the subpackages keep what the composition root
208
+ wires. Sixty-eight names: the client and its config, the credentials and the two
209
+ policies and the `TokenStore` protocol, every enum, every model, every event
210
+ type, every exception. `AuthManager`, `SessionStore`, `TokenRefresher`,
211
+ `SessionRecovery`, the emitter/publisher/router and everything under `api/`,
212
+ `connection/` and `_internal/` stay where they are, since the client hands them
213
+ to you already assembled.
214
+
215
+ The result models are exported despite `Account`, `Order`, `Position`, `Symbol`
216
+ and `Event` being names a trading codebase may well use itself. A name you
217
+ receive is a name you annotate, and `import ctrader_api_client as ct` is there
218
+ for anyone with a conflict.
219
+
220
+ Additive: the subpackage paths still work. Importing costs nothing extra either,
221
+ since `from .client import CTraderClient` already pulled the whole graph in.
222
+
223
+ The `Environment` enum was deleted in passing — declared, documented, and
224
+ referenced nowhere. `ClientConfig` takes a `host` string.
225
+
226
+ ## Open
227
+
228
+ ### Version bump and changelog
229
+
230
+ The lifecycle inversion removes `connect()` and `close()` from the public API,
231
+ and the connection supervisor replaces `Protocol.set_reconnect_handler` /
232
+ `set_disconnect_handler` with `set_listener` and drops
233
+ `AuthManager.handle_connection_lost`. The AuthManager split then removes
234
+ `authenticate_app`, `authenticate_account`, `authenticate_by_trader_login`,
235
+ `get_accounts` and `resolve_account_id` from `client.auth`, adding
236
+ `authenticate_trader` in their place and moving the two queries to
237
+ `client.accounts`. The `TokenStore` commit moved `AuthTrigger` out of
238
+ `auth.trigger` into `enums`, and turned `ReadyEvent.is_reconnect` into a
239
+ property over a new `trigger` field, which breaks anyone constructing the event
240
+ themselves. This needs a bump from `0.6.0` and a note saying so.
241
+
242
+ The flat surface is additive and breaks nobody, but the `Environment` enum it
243
+ deleted was public, so it belongs in the same note. Nothing else on the branch
244
+ breaks a caller.
245
+
246
+ ### Smaller things noticed but not acted on
247
+
248
+ - `client.py` is down to 501 lines, nearly all delegation and docstrings.
249
+ - `protocol.py` is 550 lines and does framing, correlation, dispatch and
250
+ reconnection. Splitting it was never discussed; noting it as an observation
251
+ rather than a proposal.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -51,8 +51,13 @@ pip install ctrader-api-client
51
51
 
52
52
  ```python
53
53
  import asyncio
54
- from ctrader_api_client import CTraderClient, ClientConfig
55
- from ctrader_api_client.events import ReadyEvent, SpotEvent
54
+ from ctrader_api_client import (
55
+ AccountCredentials,
56
+ ClientConfig,
57
+ CTraderClient,
58
+ ReadyEvent,
59
+ SpotEvent,
60
+ )
56
61
 
57
62
  config = ClientConfig(
58
63
  client_id="your_client_id",
@@ -67,21 +72,23 @@ async def on_price(event: SpotEvent):
67
72
  print(f"Price update: {event.bid}/{event.ask}")
68
73
 
69
74
 
70
- @client.on(ReadyEvent)
71
- async def on_ready(event: ReadyEvent):
72
- """Called when account is authenticated and ready."""
73
- await client.market_data.subscribe_spots(event.account_id, [270])
74
-
75
-
76
75
  async def main():
77
76
  async with client:
78
- await client.auth.authenticate_app()
79
- await client.auth.authenticate_by_trader_login(
77
+ account_id = await client.accounts.resolve_account_id(
78
+ "your_access_token",
80
79
  trader_login=12345678,
81
- access_token="your_access_token",
82
- refresh_token="your_refresh_token",
83
- expires_at=1778617423,
84
80
  )
81
+ await client.auth.authenticate_trader(
82
+ AccountCredentials(
83
+ account_id=account_id,
84
+ access_token="your_access_token",
85
+ refresh_token="your_refresh_token",
86
+ expires_at=1778617423,
87
+ )
88
+ )
89
+
90
+ # Subscribe once. The client re-applies this after any reconnection.
91
+ await client.market_data.subscribe_spots(account_id, [270])
85
92
 
86
93
  # Keep running to receive events
87
94
  await asyncio.Event().wait()
@@ -108,15 +115,19 @@ For production applications, implement the OAuth flow according to the [cTrader
108
115
  ### Authentication
109
116
 
110
117
  ```python
111
- # Authenticate the application
112
- await client.auth.authenticate_app()
118
+ # The application is authenticated as the client connects.
119
+
120
+ # Discover the account behind a trader login
121
+ account_id = await client.accounts.resolve_account_id("...", trader_login=12345678)
113
122
 
114
123
  # Authenticate a trading account
115
- creds = await client.auth.authenticate_by_trader_login(
116
- trader_login=12345678,
117
- access_token="...",
118
- refresh_token="...",
119
- expires_at=1778617423,
124
+ await client.auth.authenticate_trader(
125
+ AccountCredentials(
126
+ account_id=account_id,
127
+ access_token="...",
128
+ refresh_token="...",
129
+ expires_at=1778617423,
130
+ )
120
131
  )
121
132
 
122
133
  # Tokens are automatically refreshed before expiry. A refresh that fails is
@@ -124,6 +135,75 @@ creds = await client.auth.authenticate_by_trader_login(
124
135
  # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
125
136
  ```
126
137
 
138
+ ### Persisting Rotated Tokens
139
+
140
+ Every refresh issues a new access **and** refresh token, and invalidates the old
141
+ pair immediately. Pass a `TokenStore` and the client writes each new pair through
142
+ as it is issued:
143
+
144
+ ```python
145
+ from ctrader_api_client import AccountCredentials, CTraderClient, TokenStore
146
+
147
+
148
+ class PostgresTokenStore(TokenStore):
149
+ def __init__(self, pool):
150
+ self._pool = pool
151
+
152
+ async def save(self, credentials: AccountCredentials) -> None:
153
+ await self._pool.execute(
154
+ """
155
+ INSERT INTO ctrader_tokens (account_id, access_token, refresh_token, expires_at)
156
+ VALUES ($1, $2, $3, $4)
157
+ ON CONFLICT (account_id) DO UPDATE SET
158
+ access_token = EXCLUDED.access_token,
159
+ refresh_token = EXCLUDED.refresh_token,
160
+ expires_at = EXCLUDED.expires_at
161
+ """,
162
+ credentials.account_id,
163
+ credentials.access_token,
164
+ credentials.refresh_token,
165
+ credentials.expires_at,
166
+ )
167
+
168
+ # Not part of the protocol. The client never reads the store; you do.
169
+ async def load(self, account_id: int) -> AccountCredentials | None:
170
+ row = await self._pool.fetchrow(
171
+ "SELECT access_token, refresh_token, expires_at FROM ctrader_tokens WHERE account_id = $1",
172
+ account_id,
173
+ )
174
+ if row is None:
175
+ return None
176
+ return AccountCredentials(account_id=account_id, **dict(row))
177
+
178
+
179
+ client = CTraderClient(config, token_store=PostgresTokenStore(pool))
180
+ ```
181
+
182
+ The save happens before the new token is put to use. A save that raises aborts
183
+ that refresh, which is reported as a `TokenRefreshFailedEvent` and retried on the
184
+ next check interval, so a transient storage outage recovers on its own.
185
+
186
+ The protocol is write-only, because writing is the half the client has to do for
187
+ you: rotation happens mid-session, at a moment you cannot observe. Reading back is
188
+ yours, since only you know which accounts a given process is responsible for:
189
+
190
+ ```python
191
+ store = PostgresTokenStore(pool)
192
+ client = CTraderClient(config, token_store=store)
193
+
194
+ async with client:
195
+ stored = await store.load(account_id)
196
+ if stored is None:
197
+ stored = AccountCredentials(
198
+ account_id=account_id,
199
+ access_token="your_access_token",
200
+ refresh_token="your_refresh_token",
201
+ expires_at=1778617423,
202
+ )
203
+
204
+ await client.auth.authenticate_trader(stored)
205
+ ```
206
+
127
207
  ### Market Data
128
208
 
129
209
  ```python
@@ -142,8 +222,7 @@ bars = await client.market_data.get_trendbars(
142
222
  ### Trading
143
223
 
144
224
  ```python
145
- from ctrader_api_client.models import NewOrderRequest, ClosePositionRequest
146
- from ctrader_api_client.enums import OrderType, OrderSide
225
+ from ctrader_api_client import ClosePositionRequest, NewOrderRequest, OrderSide, OrderType
147
226
 
148
227
  # Place a market order
149
228
  request = NewOrderRequest(
@@ -168,11 +247,12 @@ await client.trading.close_position(account_id, close_position)
168
247
  ### Event Handling
169
248
 
170
249
  ```python
171
- from ctrader_api_client.events import (
172
- SpotEvent,
250
+ from ctrader_api_client import (
173
251
  ExecutionEvent,
174
252
  ReadyEvent,
175
253
  ReconnectedEvent,
254
+ SpotEvent,
255
+ SubscriptionRestoreFailedEvent,
176
256
  TokenRefreshFailedEvent,
177
257
  )
178
258
 
@@ -187,10 +267,11 @@ async def on_execution(event: ExecutionEvent):
187
267
  print(f"Order {event.order_id}: {event.execution_type}")
188
268
 
189
269
  # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
270
+ # Subscriptions are already restored by this point; use it to reconcile your own state.
190
271
  @client.on(ReadyEvent)
191
272
  async def on_ready(event: ReadyEvent):
192
- # Set up subscriptions here
193
- await client.market_data.subscribe_spots(event.account_id, symbols)
273
+ if event.is_reconnect:
274
+ positions = await client.trading.get_open_positions(event.account_id)
194
275
 
195
276
  # Connection restored
196
277
  @client.on(ReconnectedEvent)
@@ -202,6 +283,11 @@ async def on_reconnected(event: ReconnectedEvent):
202
283
  @client.on(TokenRefreshFailedEvent, account_id=account_id)
203
284
  async def on_refresh_failed(event: TokenRefreshFailedEvent):
204
285
  print(f"Token refresh failed for {event.account_id}: {event.error}")
286
+
287
+ # Market data could not be re-applied after a reconnection (retried on the next one)
288
+ @client.on(SubscriptionRestoreFailedEvent, account_id=account_id)
289
+ async def on_restore_failed(event: SubscriptionRestoreFailedEvent):
290
+ print(f"Account {event.account_id} is missing market data: {event.error}")
205
291
  ```
206
292
 
207
293
  ### Symbols
@@ -231,23 +317,28 @@ The client automatically handles connection drops:
231
317
 
232
318
  1. Reconnects with exponential backoff
233
319
  2. Re-authenticates the app and all accounts
234
- 3. Emits `ReadyEvent` for each restored account (for resubscribing to market data)
235
- 4. Emits `ReconnectedEvent` with summary of restored/failed accounts
320
+ 3. Re-applies each account's market data subscriptions
321
+ 4. Emits `ReadyEvent` for each restored account
322
+ 5. Emits `ReconnectedEvent` with summary of restored/failed accounts
236
323
 
237
324
  It also handles **server-side account disconnects** (e.g. a broker dropping the
238
325
  account session over the weekend while the connection stays up): the account is
239
- re-authenticated on the existing connection with backoff until it succeeds, then
240
- a `ReadyEvent` is emitted so subscriptions can be restored. Account
326
+ re-authenticated on the existing connection with backoff until it succeeds, its
327
+ subscriptions are re-applied, and then a `ReadyEvent` is emitted. Account
241
328
  authorization is observable via `client.is_account_authorized(account_id)`,
242
329
  which is distinct from the transport-level `client.is_connected`.
243
330
 
244
- Use `ReadyEvent` to set up subscriptions that persist across reconnections.
331
+ Subscribe once, when you first authenticate. The client remembers what each
332
+ account asked for and re-applies it before announcing the account as ready, so
333
+ do not re-subscribe from a `ReadyEvent` handler — the server rejects a duplicate
334
+ subscription. If restoration fails it stops at the first failure and emits a
335
+ `SubscriptionRestoreFailedEvent`, keeping the intent so the next reconnection
336
+ tries again.
245
337
 
246
338
  ## Configuration
247
339
 
248
340
  ```python
249
- from ctrader_api_client import ClientConfig
250
- from ctrader_api_client.auth import ReauthPolicy, RefreshPolicy
341
+ from ctrader_api_client import ClientConfig, ReauthPolicy, RefreshPolicy
251
342
 
252
343
  config = ClientConfig(
253
344
  client_id="your_client_id",