ctrader-api-client 0.6.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (135) hide show
  1. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/.claude/settings.local.json +2 -1
  2. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/PKG-INFO +125 -34
  3. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/README.md +124 -33
  4. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/accounts.md +8 -4
  5. ctrader_api_client-0.7.1/docs/api/client.md +133 -0
  6. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/enums.md +1 -7
  7. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/events.md +34 -12
  8. ctrader_api_client-0.7.1/docs/api/exceptions.md +213 -0
  9. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/market-data.md +26 -15
  10. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/models.md +2 -3
  11. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/trading.md +3 -4
  12. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/getting-started.md +132 -35
  13. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/index.md +14 -13
  14. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/mkdocs.yml +1 -0
  15. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/pyproject.toml +1 -1
  16. ctrader_api_client-0.7.1/src/ctrader_api_client/__init__.py +190 -0
  17. ctrader_api_client-0.7.1/src/ctrader_api_client/api/accounts.py +134 -0
  18. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/market_data.py +88 -0
  19. ctrader_api_client-0.7.1/src/ctrader_api_client/auth/__init__.py +26 -0
  20. ctrader_api_client-0.7.1/src/ctrader_api_client/auth/_recovery.py +299 -0
  21. ctrader_api_client-0.7.1/src/ctrader_api_client/auth/_refresh.py +203 -0
  22. ctrader_api_client-0.7.1/src/ctrader_api_client/auth/_session.py +180 -0
  23. ctrader_api_client-0.7.1/src/ctrader_api_client/auth/manager.py +259 -0
  24. ctrader_api_client-0.7.1/src/ctrader_api_client/auth/store.py +35 -0
  25. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/client.py +143 -241
  26. ctrader_api_client-0.7.1/src/ctrader_api_client/composition.py +165 -0
  27. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/__init__.py +4 -0
  28. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/heartbeat.py +31 -24
  29. ctrader_api_client-0.7.1/src/ctrader_api_client/connection/listener.py +17 -0
  30. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/protocol.py +93 -69
  31. ctrader_api_client-0.7.1/src/ctrader_api_client/connection/supervisor.py +124 -0
  32. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/enums.py +12 -5
  33. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/__init__.py +4 -0
  34. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/emitter.py +2 -22
  35. ctrader_api_client-0.7.1/src/ctrader_api_client/events/publisher.py +19 -0
  36. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/router.py +47 -69
  37. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/types.py +41 -9
  38. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/exceptions.py +26 -0
  39. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/api/conftest.py +8 -3
  40. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_reference_data.py +100 -1
  41. ctrader_api_client-0.7.1/tests/behavior/api/test_subscription_restore.py +206 -0
  42. ctrader_api_client-0.7.1/tests/behavior/auth/conftest.py +120 -0
  43. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_authentication.py +59 -149
  44. ctrader_api_client-0.7.1/tests/behavior/auth/test_token_lifecycle.py +465 -0
  45. ctrader_api_client-0.7.1/tests/behavior/connection/conftest.py +24 -0
  46. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_framing.py +20 -14
  47. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_heartbeat.py +42 -11
  48. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_reconnection.py +59 -16
  49. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_request_response.py +8 -8
  50. ctrader_api_client-0.7.1/tests/behavior/events/conftest.py +32 -0
  51. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_routing.py +79 -3
  52. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_subscriptions.py +0 -47
  53. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/test_client.py +124 -28
  54. ctrader_api_client-0.7.1/tests/conftest.py +160 -0
  55. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/__init__.py +12 -1
  56. ctrader_api_client-0.7.1/tests/harness/recorder.py +163 -0
  57. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/stub_protocol.py +35 -1
  58. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/uv.lock +1 -1
  59. ctrader_api_client-0.6.0/docs/api/client.md +0 -65
  60. ctrader_api_client-0.6.0/src/ctrader_api_client/__init__.py +0 -66
  61. ctrader_api_client-0.6.0/src/ctrader_api_client/api/accounts.py +0 -50
  62. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/__init__.py +0 -19
  63. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/_session.py +0 -59
  64. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/manager.py +0 -713
  65. ctrader_api_client-0.6.0/src/ctrader_api_client/auth/trigger.py +0 -17
  66. ctrader_api_client-0.6.0/tests/behavior/auth/conftest.py +0 -52
  67. ctrader_api_client-0.6.0/tests/behavior/auth/test_token_lifecycle.py +0 -345
  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.1}/.github/workflows/docs.yml +0 -0
  72. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/.gitignore +0 -0
  73. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/.pre-commit-config.yaml +0 -0
  74. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/.python-version +0 -0
  75. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/Justfile +0 -0
  76. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/LICENSE +0 -0
  77. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/docs/api/symbols.md +0 -0
  78. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/SOURCE +0 -0
  79. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/VERSION +0 -0
  80. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/update.sh +0 -0
  81. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  82. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  83. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiMessages.proto +0 -0
  84. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiModelMessages.proto +0 -0
  85. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/scripts/fix_proto_imports.py +0 -0
  86. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/__init__.py +0 -0
  87. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/clock.py +0 -0
  88. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/conversions.py +0 -0
  89. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/messages.py +0 -0
  90. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  91. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  92. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  93. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  94. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  95. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/serialization.py +0 -0
  96. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/__init__.py +0 -0
  97. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/_base.py +0 -0
  98. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/symbols.py +0 -0
  99. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/trading.py +0 -0
  100. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/credentials.py +0 -0
  101. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/policy.py +0 -0
  102. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/config.py +0 -0
  103. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/transport.py +0 -0
  104. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/_execution.py +0 -0
  105. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/__init__.py +0 -0
  106. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/_base.py +0 -0
  107. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/account.py +0 -0
  108. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/deal.py +0 -0
  109. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/market_data.py +0 -0
  110. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/order.py +0 -0
  111. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/position.py +0 -0
  112. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/requests.py +0 -0
  113. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/symbol.py +0 -0
  114. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/py.typed +0 -0
  115. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/__init__.py +0 -0
  116. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/__init__.py +0 -0
  117. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/api/__init__.py +0 -0
  118. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_market_data.py +0 -0
  119. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_trading.py +0 -0
  120. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/__init__.py +0 -0
  121. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/__init__.py +0 -0
  122. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_event_dispatch.py +0 -0
  123. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/behavior/events/__init__.py +0 -0
  124. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/clock.py +0 -0
  125. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/factories.py +0 -0
  126. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/server.py +0 -0
  127. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/signals.py +0 -0
  128. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/harness/wire.py +0 -0
  129. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/unit/__init__.py +0 -0
  130. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/unit/test_api_errors.py +0 -0
  131. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/unit/test_market_data_conversion.py +0 -0
  132. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/unit/test_request_conversion.py +0 -0
  133. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/unit/test_symbol_conversion.py +0 -0
  134. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/tests/unit/test_trading_records.py +0 -0
  135. {ctrader_api_client-0.6.0 → ctrader_api_client-0.7.1}/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
  }
@@ -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.1
4
4
  Summary: API Client to interact with the cTrader Open API spec
5
5
  Author-email: Elio <elioachukri@pm.me>
6
6
  License-File: LICENSE
@@ -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",
@@ -32,8 +32,13 @@ pip install ctrader-api-client
32
32
 
33
33
  ```python
34
34
  import asyncio
35
- from ctrader_api_client import CTraderClient, ClientConfig
36
- from ctrader_api_client.events import ReadyEvent, SpotEvent
35
+ from ctrader_api_client import (
36
+ AccountCredentials,
37
+ ClientConfig,
38
+ CTraderClient,
39
+ ReadyEvent,
40
+ SpotEvent,
41
+ )
37
42
 
38
43
  config = ClientConfig(
39
44
  client_id="your_client_id",
@@ -48,21 +53,23 @@ async def on_price(event: SpotEvent):
48
53
  print(f"Price update: {event.bid}/{event.ask}")
49
54
 
50
55
 
51
- @client.on(ReadyEvent)
52
- async def on_ready(event: ReadyEvent):
53
- """Called when account is authenticated and ready."""
54
- await client.market_data.subscribe_spots(event.account_id, [270])
55
-
56
-
57
56
  async def main():
58
57
  async with client:
59
- await client.auth.authenticate_app()
60
- await client.auth.authenticate_by_trader_login(
58
+ account_id = await client.accounts.resolve_account_id(
59
+ "your_access_token",
61
60
  trader_login=12345678,
62
- access_token="your_access_token",
63
- refresh_token="your_refresh_token",
64
- expires_at=1778617423,
65
61
  )
62
+ await client.auth.authenticate_trader(
63
+ AccountCredentials(
64
+ account_id=account_id,
65
+ access_token="your_access_token",
66
+ refresh_token="your_refresh_token",
67
+ expires_at=1778617423,
68
+ )
69
+ )
70
+
71
+ # Subscribe once. The client re-applies this after any reconnection.
72
+ await client.market_data.subscribe_spots(account_id, [270])
66
73
 
67
74
  # Keep running to receive events
68
75
  await asyncio.Event().wait()
@@ -89,15 +96,19 @@ For production applications, implement the OAuth flow according to the [cTrader
89
96
  ### Authentication
90
97
 
91
98
  ```python
92
- # Authenticate the application
93
- await client.auth.authenticate_app()
99
+ # The application is authenticated as the client connects.
100
+
101
+ # Discover the account behind a trader login
102
+ account_id = await client.accounts.resolve_account_id("...", trader_login=12345678)
94
103
 
95
104
  # Authenticate a trading account
96
- creds = await client.auth.authenticate_by_trader_login(
97
- trader_login=12345678,
98
- access_token="...",
99
- refresh_token="...",
100
- expires_at=1778617423,
105
+ await client.auth.authenticate_trader(
106
+ AccountCredentials(
107
+ account_id=account_id,
108
+ access_token="...",
109
+ refresh_token="...",
110
+ expires_at=1778617423,
111
+ )
101
112
  )
102
113
 
103
114
  # Tokens are automatically refreshed before expiry. A refresh that fails is
@@ -105,6 +116,75 @@ creds = await client.auth.authenticate_by_trader_login(
105
116
  # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
106
117
  ```
107
118
 
119
+ ### Persisting Rotated Tokens
120
+
121
+ Every refresh issues a new access **and** refresh token, and invalidates the old
122
+ pair immediately. Pass a `TokenStore` and the client writes each new pair through
123
+ as it is issued:
124
+
125
+ ```python
126
+ from ctrader_api_client import AccountCredentials, CTraderClient, TokenStore
127
+
128
+
129
+ class PostgresTokenStore(TokenStore):
130
+ def __init__(self, pool):
131
+ self._pool = pool
132
+
133
+ async def save(self, credentials: AccountCredentials) -> None:
134
+ await self._pool.execute(
135
+ """
136
+ INSERT INTO ctrader_tokens (account_id, access_token, refresh_token, expires_at)
137
+ VALUES ($1, $2, $3, $4)
138
+ ON CONFLICT (account_id) DO UPDATE SET
139
+ access_token = EXCLUDED.access_token,
140
+ refresh_token = EXCLUDED.refresh_token,
141
+ expires_at = EXCLUDED.expires_at
142
+ """,
143
+ credentials.account_id,
144
+ credentials.access_token,
145
+ credentials.refresh_token,
146
+ credentials.expires_at,
147
+ )
148
+
149
+ # Not part of the protocol. The client never reads the store; you do.
150
+ async def load(self, account_id: int) -> AccountCredentials | None:
151
+ row = await self._pool.fetchrow(
152
+ "SELECT access_token, refresh_token, expires_at FROM ctrader_tokens WHERE account_id = $1",
153
+ account_id,
154
+ )
155
+ if row is None:
156
+ return None
157
+ return AccountCredentials(account_id=account_id, **dict(row))
158
+
159
+
160
+ client = CTraderClient(config, token_store=PostgresTokenStore(pool))
161
+ ```
162
+
163
+ The save happens before the new token is put to use. A save that raises aborts
164
+ that refresh, which is reported as a `TokenRefreshFailedEvent` and retried on the
165
+ next check interval, so a transient storage outage recovers on its own.
166
+
167
+ The protocol is write-only, because writing is the half the client has to do for
168
+ you: rotation happens mid-session, at a moment you cannot observe. Reading back is
169
+ yours, since only you know which accounts a given process is responsible for:
170
+
171
+ ```python
172
+ store = PostgresTokenStore(pool)
173
+ client = CTraderClient(config, token_store=store)
174
+
175
+ async with client:
176
+ stored = await store.load(account_id)
177
+ if stored is None:
178
+ stored = AccountCredentials(
179
+ account_id=account_id,
180
+ access_token="your_access_token",
181
+ refresh_token="your_refresh_token",
182
+ expires_at=1778617423,
183
+ )
184
+
185
+ await client.auth.authenticate_trader(stored)
186
+ ```
187
+
108
188
  ### Market Data
109
189
 
110
190
  ```python
@@ -123,8 +203,7 @@ bars = await client.market_data.get_trendbars(
123
203
  ### Trading
124
204
 
125
205
  ```python
126
- from ctrader_api_client.models import NewOrderRequest, ClosePositionRequest
127
- from ctrader_api_client.enums import OrderType, OrderSide
206
+ from ctrader_api_client import ClosePositionRequest, NewOrderRequest, OrderSide, OrderType
128
207
 
129
208
  # Place a market order
130
209
  request = NewOrderRequest(
@@ -149,11 +228,12 @@ await client.trading.close_position(account_id, close_position)
149
228
  ### Event Handling
150
229
 
151
230
  ```python
152
- from ctrader_api_client.events import (
153
- SpotEvent,
231
+ from ctrader_api_client import (
154
232
  ExecutionEvent,
155
233
  ReadyEvent,
156
234
  ReconnectedEvent,
235
+ SpotEvent,
236
+ SubscriptionRestoreFailedEvent,
157
237
  TokenRefreshFailedEvent,
158
238
  )
159
239
 
@@ -168,10 +248,11 @@ async def on_execution(event: ExecutionEvent):
168
248
  print(f"Order {event.order_id}: {event.execution_type}")
169
249
 
170
250
  # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
251
+ # Subscriptions are already restored by this point; use it to reconcile your own state.
171
252
  @client.on(ReadyEvent)
172
253
  async def on_ready(event: ReadyEvent):
173
- # Set up subscriptions here
174
- await client.market_data.subscribe_spots(event.account_id, symbols)
254
+ if event.is_reconnect:
255
+ positions = await client.trading.get_open_positions(event.account_id)
175
256
 
176
257
  # Connection restored
177
258
  @client.on(ReconnectedEvent)
@@ -183,6 +264,11 @@ async def on_reconnected(event: ReconnectedEvent):
183
264
  @client.on(TokenRefreshFailedEvent, account_id=account_id)
184
265
  async def on_refresh_failed(event: TokenRefreshFailedEvent):
185
266
  print(f"Token refresh failed for {event.account_id}: {event.error}")
267
+
268
+ # Market data could not be re-applied after a reconnection (retried on the next one)
269
+ @client.on(SubscriptionRestoreFailedEvent, account_id=account_id)
270
+ async def on_restore_failed(event: SubscriptionRestoreFailedEvent):
271
+ print(f"Account {event.account_id} is missing market data: {event.error}")
186
272
  ```
187
273
 
188
274
  ### Symbols
@@ -212,23 +298,28 @@ The client automatically handles connection drops:
212
298
 
213
299
  1. Reconnects with exponential backoff
214
300
  2. Re-authenticates the app and all accounts
215
- 3. Emits `ReadyEvent` for each restored account (for resubscribing to market data)
216
- 4. Emits `ReconnectedEvent` with summary of restored/failed accounts
301
+ 3. Re-applies each account's market data subscriptions
302
+ 4. Emits `ReadyEvent` for each restored account
303
+ 5. Emits `ReconnectedEvent` with summary of restored/failed accounts
217
304
 
218
305
  It also handles **server-side account disconnects** (e.g. a broker dropping the
219
306
  account session over the weekend while the connection stays up): the account is
220
- re-authenticated on the existing connection with backoff until it succeeds, then
221
- a `ReadyEvent` is emitted so subscriptions can be restored. Account
307
+ re-authenticated on the existing connection with backoff until it succeeds, its
308
+ subscriptions are re-applied, and then a `ReadyEvent` is emitted. Account
222
309
  authorization is observable via `client.is_account_authorized(account_id)`,
223
310
  which is distinct from the transport-level `client.is_connected`.
224
311
 
225
- Use `ReadyEvent` to set up subscriptions that persist across reconnections.
312
+ Subscribe once, when you first authenticate. The client remembers what each
313
+ account asked for and re-applies it before announcing the account as ready, so
314
+ do not re-subscribe from a `ReadyEvent` handler — the server rejects a duplicate
315
+ subscription. If restoration fails it stops at the first failure and emits a
316
+ `SubscriptionRestoreFailedEvent`, keeping the intent so the next reconnection
317
+ tries again.
226
318
 
227
319
  ## Configuration
228
320
 
229
321
  ```python
230
- from ctrader_api_client import ClientConfig
231
- from ctrader_api_client.auth import ReauthPolicy, RefreshPolicy
322
+ from ctrader_api_client import ClientConfig, ReauthPolicy, RefreshPolicy
232
323
 
233
324
  config = ClientConfig(
234
325
  client_id="your_client_id",
@@ -11,6 +11,8 @@ Access via `client.accounts`.
11
11
  show_source: false
12
12
  members:
13
13
  - get_trader
14
+ - list_by_token
15
+ - resolve_account_id
14
16
 
15
17
  ## Usage Examples
16
18
 
@@ -27,15 +29,17 @@ print(f"Broker name: {account.broker_name}")
27
29
 
28
30
  ## Account Discovery
29
31
 
30
- To discover available accounts for an access token, use the auth manager:
32
+ The accounts an access token covers can be listed without authenticating any of
33
+ them:
31
34
 
32
35
  ```python
33
36
  # Get all accounts associated with a token
34
- accounts = await client.auth.get_accounts(access_token)
35
-
36
- for acc in accounts:
37
+ for acc in await client.accounts.list_by_token(access_token):
37
38
  print(f"Login: {acc.trader_login}, Account ID: {acc.account_id}")
38
39
  print(f" Live: {acc.is_live}, Broker: {acc.broker_name}")
40
+
41
+ # Or resolve a single login straight to its account ID
42
+ account_id = await client.accounts.resolve_account_id(access_token, trader_login=12345678)
39
43
  ```
40
44
 
41
45
  ## Related
@@ -0,0 +1,133 @@
1
+ # Client
2
+
3
+ The main entry point for interacting with the cTrader API.
4
+
5
+ A client is used inside `async with`. The block owns the connection and the
6
+ background tasks that keep it alive, so leaving it — normally or by exception —
7
+ winds them down, and a background task that dies is raised at the end of the
8
+ block rather than discovered later.
9
+
10
+ ## CTraderClient
11
+
12
+ ::: ctrader_api_client.CTraderClient
13
+ options:
14
+ show_source: false
15
+ members:
16
+ - __init__
17
+ - from_graph
18
+ - "on"
19
+ - "off"
20
+ - auth
21
+ - accounts
22
+ - symbols
23
+ - trading
24
+ - market_data
25
+ - is_connected
26
+ - protocol
27
+
28
+ ## Composition
29
+
30
+ `CTraderClient(config)` assembles its own collaborators, which is all most
31
+ callers need. When you have to substitute one of them, assemble the graph
32
+ yourself and hand it over:
33
+
34
+ ```python
35
+ from ctrader_api_client import CTraderClient
36
+ from ctrader_api_client.composition import build_graph
37
+
38
+ graph = build_graph(config, clock=my_clock)
39
+ client = CTraderClient.from_graph(graph)
40
+ ```
41
+
42
+ ::: ctrader_api_client.composition.build_graph
43
+ options:
44
+ show_source: false
45
+
46
+ ::: ctrader_api_client.composition.ClientGraph
47
+ options:
48
+ show_source: false
49
+
50
+ ## ClientConfig
51
+
52
+ ::: ctrader_api_client.ClientConfig
53
+ options:
54
+ show_source: false
55
+
56
+ ## Authentication Policies
57
+
58
+ Token-refresh and session-recovery timing, passed via
59
+ `ClientConfig(refresh_policy=..., reauth_policy=...)`.
60
+
61
+ ::: ctrader_api_client.auth.RefreshPolicy
62
+ options:
63
+ show_source: false
64
+
65
+ ::: ctrader_api_client.auth.ReauthPolicy
66
+ options:
67
+ show_source: false
68
+
69
+ ## Authentication
70
+
71
+ The `client.auth` property provides access to authentication operations. The
72
+ application is authenticated as the client connects, so what is left here is
73
+ authenticating trading accounts and asking after the sessions they hold.
74
+
75
+ ::: ctrader_api_client.auth.AuthManager
76
+ options:
77
+ show_source: false
78
+ members:
79
+ - authenticate_trader
80
+ - is_account_authorized
81
+ - get_credentials
82
+ - all_credentials
83
+ - remove_account
84
+ - is_app_authenticated
85
+ - authenticated_accounts
86
+ - authorized_accounts
87
+
88
+ ## AccountCredentials
89
+
90
+ ::: ctrader_api_client.auth.AccountCredentials
91
+ options:
92
+ show_source: false
93
+
94
+ ## TokenStore
95
+
96
+ Passed as `CTraderClient(config, token_store=...)`. cTrader rotates both tokens
97
+ on every refresh and invalidates the old pair immediately, so a process that
98
+ restarts holding the pair it was originally given can no longer authenticate.
99
+
100
+ ::: ctrader_api_client.auth.TokenStore
101
+ options:
102
+ show_source: false
103
+ members: true
104
+
105
+ The contract is write-only, because writing is the half the client has to do for
106
+ you: rotation happens mid-session, at a moment you cannot observe. Reading back at
107
+ startup is yours, since only you know which accounts a given process is
108
+ responsible for. Nothing stops the same class from doing both:
109
+
110
+ ```python
111
+ class PostgresTokenStore(TokenStore):
112
+ async def save(self, credentials: AccountCredentials) -> None:
113
+ ... # required by the protocol, called by the client
114
+
115
+ async def load(self, account_id: int) -> AccountCredentials | None:
116
+ ... # not part of the protocol, called by you at startup
117
+
118
+
119
+ store = PostgresTokenStore(pool)
120
+ client = CTraderClient(config, token_store=store)
121
+
122
+ async with client:
123
+ stored = await store.load(account_id)
124
+ if stored is None:
125
+ stored = AccountCredentials(
126
+ account_id=account_id,
127
+ access_token="your_access_token",
128
+ refresh_token="your_refresh_token",
129
+ expires_at=1778617423,
130
+ )
131
+
132
+ await client.auth.authenticate_trader(stored)
133
+ ```
@@ -3,7 +3,7 @@
3
3
  Enumeration types used throughout the API.
4
4
 
5
5
  ```python
6
- from ctrader_api_client.enums import OrderType, OrderSide, ExecutionType
6
+ from ctrader_api_client import ExecutionType, OrderSide, OrderType
7
7
  ```
8
8
 
9
9
  ## Trading Enums
@@ -51,12 +51,6 @@ from ctrader_api_client.enums import OrderType, OrderSide, ExecutionType
51
51
  ## Account Enums
52
52
 
53
53
 
54
- ::: ctrader_api_client.enums.Environment
55
- options:
56
- show_source: false
57
- members: true
58
-
59
-
60
54
  ::: ctrader_api_client.enums.AccessRights
61
55
  options:
62
56
  show_source: false