ctrader-api-client 0.4.3__tar.gz → 0.5.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 (93) hide show
  1. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/PKG-INFO +9 -2
  2. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/README.md +8 -1
  3. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/events.md +19 -2
  4. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/getting-started.md +10 -3
  5. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/pyproject.toml +1 -1
  6. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/__init__.py +2 -0
  7. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/__init__.py +2 -0
  8. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/manager.py +126 -19
  9. ctrader_api_client-0.5.0/src/ctrader_api_client/auth/trigger.py +17 -0
  10. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/client.py +31 -11
  11. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/types.py +13 -4
  12. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/auth/test_manager.py +187 -0
  13. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/test_client.py +60 -2
  14. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/uv.lock +1 -1
  15. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.claude/settings.local.json +0 -0
  16. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.github/workflows/docs.yml +0 -0
  17. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.gitignore +0 -0
  18. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.pre-commit-config.yaml +0 -0
  19. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.python-version +0 -0
  20. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/Justfile +0 -0
  21. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/LICENSE +0 -0
  22. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/accounts.md +0 -0
  23. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/client.md +0 -0
  24. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/enums.md +0 -0
  25. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/market-data.md +0 -0
  26. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/models.md +0 -0
  27. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/symbols.md +0 -0
  28. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/trading.md +0 -0
  29. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/index.md +0 -0
  30. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/mkdocs.yml +0 -0
  31. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/SOURCE +0 -0
  32. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/VERSION +0 -0
  33. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/update.sh +0 -0
  34. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  35. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  36. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiMessages.proto +0 -0
  37. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  38. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/scripts/fix_proto_imports.py +0 -0
  39. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  40. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  41. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  42. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  43. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  44. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  45. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  46. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  47. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/__init__.py +0 -0
  48. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/accounts.py +0 -0
  49. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/market_data.py +0 -0
  50. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/symbols.py +0 -0
  51. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/trading.py +0 -0
  52. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  53. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/config.py +0 -0
  54. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  55. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  56. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/protocol.py +0 -0
  57. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/transport.py +0 -0
  58. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/enums.py +0 -0
  59. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/__init__.py +0 -0
  60. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/emitter.py +0 -0
  61. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/router.py +0 -0
  62. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/exceptions.py +0 -0
  63. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/__init__.py +0 -0
  64. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/_base.py +0 -0
  65. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/account.py +0 -0
  66. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/deal.py +0 -0
  67. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/market_data.py +0 -0
  68. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/order.py +0 -0
  69. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/position.py +0 -0
  70. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/requests.py +0 -0
  71. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/symbol.py +0 -0
  72. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/py.typed +0 -0
  73. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_messages.py +0 -0
  74. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_serialization.py +0 -0
  75. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/conftest.py +0 -0
  76. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_accounts.py +0 -0
  77. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_market_data_api.py +0 -0
  78. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_symbols.py +0 -0
  79. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_trading.py +0 -0
  80. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/auth/test_credentials.py +0 -0
  81. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_heartbeat.py +0 -0
  82. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_protocol.py +0 -0
  83. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_transport.py +0 -0
  84. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/events/test_emitter.py +0 -0
  85. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/events/test_router.py +0 -0
  86. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/events/test_types.py +0 -0
  87. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_account.py +0 -0
  88. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_deal.py +0 -0
  89. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_market_data.py +0 -0
  90. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_order.py +0 -0
  91. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_position.py +0 -0
  92. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_requests.py +0 -0
  93. {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_symbol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.4.3
3
+ Version: 0.5.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
@@ -183,7 +183,7 @@ async def on_spot(event: SpotEvent):
183
183
  async def on_execution(event: ExecutionEvent):
184
184
  print(f"Order {event.order_id}: {event.execution_type}")
185
185
 
186
- # Account ready (fires on initial auth and after reconnection)
186
+ # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
187
187
  @client.on(ReadyEvent)
188
188
  async def on_ready(event: ReadyEvent):
189
189
  # Set up subscriptions here
@@ -225,6 +225,13 @@ The client automatically handles connection drops:
225
225
  3. Emits `ReadyEvent` for each restored account (for resubscribing to market data)
226
226
  4. Emits `ReconnectedEvent` with summary of restored/failed accounts
227
227
 
228
+ It also handles **server-side account disconnects** (e.g. a broker dropping the
229
+ account session over the weekend while the connection stays up): the account is
230
+ re-authenticated on the existing connection with backoff until it succeeds, then
231
+ a `ReadyEvent` is emitted so subscriptions can be restored. Account
232
+ authorization is observable via `client.is_account_authorized(account_id)`,
233
+ which is distinct from the transport-level `client.is_connected`.
234
+
228
235
  Use `ReadyEvent` to set up subscriptions that persist across reconnections.
229
236
 
230
237
  ## Configuration
@@ -164,7 +164,7 @@ async def on_spot(event: SpotEvent):
164
164
  async def on_execution(event: ExecutionEvent):
165
165
  print(f"Order {event.order_id}: {event.execution_type}")
166
166
 
167
- # Account ready (fires on initial auth and after reconnection)
167
+ # Account ready (fires on initial auth, after reconnection, and after account-disconnect recovery)
168
168
  @client.on(ReadyEvent)
169
169
  async def on_ready(event: ReadyEvent):
170
170
  # Set up subscriptions here
@@ -206,6 +206,13 @@ The client automatically handles connection drops:
206
206
  3. Emits `ReadyEvent` for each restored account (for resubscribing to market data)
207
207
  4. Emits `ReconnectedEvent` with summary of restored/failed accounts
208
208
 
209
+ It also handles **server-side account disconnects** (e.g. a broker dropping the
210
+ account session over the weekend while the connection stays up): the account is
211
+ re-authenticated on the existing connection with backoff until it succeeds, then
212
+ a `ReadyEvent` is emitted so subscriptions can be restored. Account
213
+ authorization is observable via `client.is_account_authorized(account_id)`,
214
+ which is distinct from the transport-level `client.is_connected`.
215
+
209
216
  Use `ReadyEvent` to set up subscriptions that persist across reconnections.
210
217
 
211
218
  ## Configuration
@@ -85,7 +85,9 @@ async def on_spot(event: SpotEvent):
85
85
  options:
86
86
  show_source: false
87
87
 
88
- **Use this to set up subscriptions that persist across reconnections:**
88
+ **Use this to set up subscriptions that persist across reconnections.** It fires
89
+ on initial auth, after a transport reconnection, and after recovery from a
90
+ server-side account disconnect — `is_reconnect` is `True` for the latter two:
89
91
 
90
92
  ```python
91
93
  @client.on(ReadyEvent)
@@ -93,9 +95,19 @@ async def on_ready(event: ReadyEvent):
93
95
  await client.market_data.subscribe_spots(event.account_id, [270])
94
96
 
95
97
  if event.is_reconnect:
96
- print("Connection restored!")
98
+ print("Session restored!")
97
99
  ```
98
100
 
101
+ `ReadyEvent` emission is driven by why authentication occurred, expressed as an
102
+ `AuthTrigger`. It is emitted for `INITIAL`, `RECONNECT`, and `ACCOUNT_REAUTH`
103
+ (all of which lose or re-establish subscriptions), and suppressed for
104
+ `TOKEN_REFRESH` (session intact).
105
+
106
+ ::: ctrader_api_client.auth.AuthTrigger
107
+ options:
108
+ show_source: false
109
+ members: true
110
+
99
111
  ::: ctrader_api_client.events.TraderUpdateEvent
100
112
  options:
101
113
  show_source: false
@@ -137,6 +149,11 @@ async def on_reconnected(event: ReconnectedEvent):
137
149
  options:
138
150
  show_source: false
139
151
 
152
+ The client recovers from this automatically — it re-authenticates the account on
153
+ the existing connection with backoff and emits a `ReadyEvent` on success. The
154
+ event is informational; subscribe to it only if you want to observe or log the
155
+ drop. Check current authorization with `client.is_account_authorized(account_id)`.
156
+
140
157
  ::: ctrader_api_client.events.TokenInvalidatedEvent
141
158
  options:
142
159
  show_source: false
@@ -167,19 +167,26 @@ if __name__ == "__main__":
167
167
 
168
168
  ## Handling Reconnections
169
169
 
170
- The client automatically reconnects when the connection drops. Use `ReadyEvent` to restore subscriptions:
170
+ The client automatically reconnects when the connection drops, and also recovers
171
+ from server-side account disconnects (where the account session is dropped but
172
+ the connection stays up). In both cases it re-authenticates and emits a
173
+ `ReadyEvent`. Use `ReadyEvent` to restore subscriptions:
171
174
 
172
175
  ```python
173
176
  @client.on(ReadyEvent)
174
177
  async def on_ready(event: ReadyEvent):
175
- """Called on initial auth AND after reconnection."""
178
+ """Called on initial auth, after reconnection, and after account recovery."""
176
179
  # Set up subscriptions here - they persist across reconnects
177
180
  await client.market_data.subscribe_spots(event.account_id, [270, 271, 272])
178
181
 
179
182
  if event.is_reconnect:
180
- print("Connection restored!")
183
+ print("Session restored!")
181
184
  ```
182
185
 
186
+ You can check whether an account currently has a live, authorized session with
187
+ `client.is_account_authorized(account_id)` — distinct from the transport-level
188
+ `client.is_connected`.
189
+
183
190
  For additional reconnection information:
184
191
 
185
192
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.4.3"
3
+ version = "0.5.0"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -38,6 +38,7 @@ Example:
38
38
  ```
39
39
  """
40
40
 
41
+ from .auth import AuthTrigger
41
42
  from .client import CTraderClient
42
43
  from .config import ClientConfig
43
44
 
@@ -54,6 +55,7 @@ from .enums import (
54
55
 
55
56
  __all__ = [
56
57
  "AccountType",
58
+ "AuthTrigger",
57
59
  "CTraderClient",
58
60
  "ClientConfig",
59
61
  "ExecutionType",
@@ -6,9 +6,11 @@ with automatic token refresh management.
6
6
 
7
7
  from .credentials import AccountCredentials
8
8
  from .manager import AuthManager
9
+ from .trigger import AuthTrigger
9
10
 
10
11
 
11
12
  __all__ = [
12
13
  "AccountCredentials",
13
14
  "AuthManager",
15
+ "AuthTrigger",
14
16
  ]
@@ -30,6 +30,7 @@ from ..exceptions import (
30
30
  )
31
31
  from ..models import AccountSummary
32
32
  from .credentials import AccountCredentials
33
+ from .trigger import AuthTrigger
33
34
 
34
35
 
35
36
  if TYPE_CHECKING:
@@ -39,7 +40,7 @@ if TYPE_CHECKING:
39
40
  logger = logging.getLogger(__name__)
40
41
 
41
42
  TokenRefreshCallback = Callable[[AccountCredentials], Awaitable[None]]
42
- AccountReadyCallback = Callable[[int, bool, bool], Awaitable[None]] # (account_id, is_reconnect, is_reauth)
43
+ AccountReadyCallback = Callable[[int, AuthTrigger], Awaitable[None]] # (account_id, trigger)
43
44
 
44
45
 
45
46
  class AuthManager:
@@ -77,6 +78,8 @@ class AuthManager:
77
78
  refresh_retry_attempts: int = 3,
78
79
  refresh_retry_min_wait: float = 1.0,
79
80
  refresh_retry_max_wait: float = 30.0,
81
+ reauth_retry_min_wait: float = 1.0,
82
+ reauth_retry_max_wait: float = 60.0,
80
83
  on_tokens_refreshed: TokenRefreshCallback | None = None,
81
84
  on_account_ready: AccountReadyCallback | None = None,
82
85
  ) -> None:
@@ -96,10 +99,14 @@ class AuthManager:
96
99
  Defaults to 1.0.
97
100
  refresh_retry_max_wait: Maximum wait between retries (seconds).
98
101
  Defaults to 30.0.
102
+ reauth_retry_min_wait: Initial backoff between account recovery
103
+ re-auth attempts (seconds). Defaults to 1.0.
104
+ reauth_retry_max_wait: Maximum backoff between account recovery
105
+ re-auth attempts (seconds). Defaults to 60.0.
99
106
  on_tokens_refreshed: Async callback invoked when tokens are refreshed.
100
107
  Receives the new AccountCredentials. Use this to persist tokens.
101
108
  on_account_ready: Async callback invoked when an account is authenticated.
102
- Receives (account_id, is_reconnect). Use this to perform any initial client setup.
109
+ Receives (account_id, trigger). Use this to perform any initial client setup.
103
110
  """
104
111
  self._protocol = protocol
105
112
  self._client_id = client_id
@@ -109,12 +116,21 @@ class AuthManager:
109
116
  self._retry_attempts = refresh_retry_attempts
110
117
  self._retry_min_wait = refresh_retry_min_wait
111
118
  self._retry_max_wait = refresh_retry_max_wait
119
+ self._reauth_retry_min_wait = reauth_retry_min_wait
120
+ self._reauth_retry_max_wait = reauth_retry_max_wait
112
121
  self._on_tokens_refreshed = on_tokens_refreshed
113
122
  self._on_account_ready = on_account_ready
114
123
 
115
124
  # Account storage
116
125
  self._accounts: dict[int, AccountCredentials] = {}
117
126
 
127
+ # Accounts with a live, authorized server-side session
128
+ self._authorized_accounts: set[int] = set()
129
+
130
+ # Accounts awaiting recovery re-auth after a server-side disconnect
131
+ self._pending_reauth: set[int] = set()
132
+ self._reauth_signal = anyio.Event()
133
+
118
134
  # Background task management
119
135
  self._task_group: anyio.abc.TaskGroup | None = None
120
136
  self._task_scope: anyio.CancelScope | None = None
@@ -130,9 +146,25 @@ class AuthManager:
130
146
 
131
147
  @property
132
148
  def authenticated_accounts(self) -> list[int]:
133
- """List of authenticated account IDs."""
149
+ """List of account IDs the manager holds credentials for."""
134
150
  return list(self._accounts.keys())
135
151
 
152
+ @property
153
+ def authorized_accounts(self) -> list[int]:
154
+ """List of account IDs with a live, authorized server-side session."""
155
+ return list(self._authorized_accounts)
156
+
157
+ def is_account_authorized(self, account_id: int) -> bool:
158
+ """Whether the account currently has a live, authorized session.
159
+
160
+ Returns False after a server-side account disconnect until recovery
161
+ re-authentication succeeds.
162
+
163
+ Args:
164
+ account_id: The cTID trader account ID.
165
+ """
166
+ return account_id in self._authorized_accounts
167
+
136
168
  def get_credentials(self, account_id: int) -> AccountCredentials | None:
137
169
  """Get credentials for an account.
138
170
 
@@ -179,7 +211,10 @@ class AuthManager:
179
211
  return response
180
212
 
181
213
  async def authenticate_account(
182
- self, credentials: AccountCredentials, timeout: float = 30.0, reauth: bool = False, reconnect: bool = False
214
+ self,
215
+ credentials: AccountCredentials,
216
+ timeout: float = 30.0,
217
+ trigger: AuthTrigger = AuthTrigger.INITIAL,
183
218
  ) -> ProtoOAAccountAuthRes:
184
219
  """Authenticate a trading account.
185
220
 
@@ -188,9 +223,9 @@ class AuthManager:
188
223
  Args:
189
224
  credentials: The account credentials including tokens.
190
225
  timeout: Request timeout in seconds.
191
- reauth: Whether this is a re-authentication (token refresh) or initial auth.
192
- reconnect: Whether this authentication is happening during a connection reconnect. This can be used
193
- to differentiate between a token refresh and a full reconnect scenario in the account ready callback.
226
+ trigger: Why the authentication is happening. Threaded through to
227
+ the account-ready callback so subscription restoration can be
228
+ driven correctly (see AuthTrigger).
194
229
 
195
230
  Returns:
196
231
  The authentication response from the server.
@@ -199,10 +234,10 @@ class AuthManager:
199
234
  APIError: If authentication fails.
200
235
  CTraderConnectionTimeoutError: If request times out.
201
236
  """
202
- if reauth:
203
- logger.debug("Re-authenticating account %d", credentials.account_id)
204
- else:
237
+ if trigger is AuthTrigger.INITIAL:
205
238
  logger.debug("Authenticating account %d", credentials.account_id)
239
+ else:
240
+ logger.debug("Re-authenticating account %d (%s)", credentials.account_id, trigger.value)
206
241
 
207
242
  request = ProtoOAAccountAuthReq(
208
243
  ctid_trader_account_id=credentials.account_id,
@@ -217,14 +252,16 @@ class AuthManager:
217
252
  description=f"Expected ProtoOAAccountAuthRes, got {type(response).__name__}",
218
253
  )
219
254
 
220
- # Store credentials for refresh monitoring
255
+ # Store credentials for refresh monitoring and mark the session live
221
256
  self._accounts[credentials.account_id] = credentials
257
+ self._authorized_accounts.add(credentials.account_id)
258
+ self._pending_reauth.discard(credentials.account_id)
222
259
  logger.debug("Account %d authenticated successfully", credentials.account_id)
223
260
 
224
261
  # Notify callback
225
262
  if self._on_account_ready is not None:
226
263
  try:
227
- await self._on_account_ready(credentials.account_id, reconnect, reauth)
264
+ await self._on_account_ready(credentials.account_id, trigger)
228
265
  except Exception as e:
229
266
  logger.warning(
230
267
  "Account ready callback failed for account %d: %s",
@@ -368,15 +405,42 @@ class AuthManager:
368
405
  """
369
406
  if account_id in self._accounts:
370
407
  del self._accounts[account_id]
408
+ self._authorized_accounts.discard(account_id)
409
+ self._pending_reauth.discard(account_id)
371
410
  logger.debug("Account %d removed from auth manager", account_id)
372
411
  return True
373
412
  return False
374
413
 
414
+ def handle_account_disconnect(self, account_id: int) -> None:
415
+ """Handle a server-side account disconnect.
416
+
417
+ Marks the account's session as no longer authorized and schedules
418
+ recovery re-authentication on the existing connection. Idempotent while
419
+ a recovery is already pending for the account.
420
+
421
+ Args:
422
+ account_id: The cTID trader account ID reported as disconnected.
423
+ """
424
+ if account_id not in self._accounts:
425
+ return
426
+
427
+ self._authorized_accounts.discard(account_id)
428
+
429
+ if account_id in self._pending_reauth:
430
+ return
431
+
432
+ self._pending_reauth.add(account_id)
433
+ self._reauth_signal.set()
434
+ logger.warning(
435
+ "Account %d disconnected by server; scheduling re-authentication",
436
+ account_id,
437
+ )
438
+
375
439
  async def start(self) -> None:
376
- """Start the token refresh monitor.
440
+ """Start the background monitors.
377
441
 
378
- This runs a background task that periodically checks for expiring
379
- tokens and refreshes them automatically.
442
+ Runs the token refresh monitor and the account recovery monitor, which
443
+ re-authenticates accounts dropped by a server-side disconnect.
380
444
  """
381
445
  if self._running:
382
446
  return
@@ -385,10 +449,11 @@ class AuthManager:
385
449
  self._task_group = anyio.create_task_group()
386
450
  await self._task_group.__aenter__()
387
451
  self._task_group.start_soon(self._refresh_loop)
388
- logger.debug("Token refresh monitor started")
452
+ self._task_group.start_soon(self._reauth_loop)
453
+ logger.debug("Auth monitors started")
389
454
 
390
455
  async def stop(self) -> None:
391
- """Stop the token refresh monitor."""
456
+ """Stop the background monitors."""
392
457
  self._running = False
393
458
 
394
459
  if self._task_scope is not None:
@@ -402,7 +467,49 @@ class AuthManager:
402
467
  pass
403
468
  self._task_group = None
404
469
 
405
- logger.debug("Token refresh monitor stopped")
470
+ self._authorized_accounts.clear()
471
+ self._pending_reauth.clear()
472
+
473
+ logger.debug("Auth monitors stopped")
474
+
475
+ async def _reauth_loop(self) -> None:
476
+ """Recover accounts dropped by a server-side account disconnect.
477
+
478
+ Waits for accounts flagged by handle_account_disconnect, then
479
+ re-authenticates them on the existing connection with capped
480
+ exponential backoff, retrying indefinitely until each succeeds or the
481
+ manager stops. A successful re-auth restores authorized state and emits
482
+ a ReadyEvent so subscriptions can be restored.
483
+ """
484
+ while self._running:
485
+ await self._reauth_signal.wait()
486
+ self._reauth_signal = anyio.Event()
487
+
488
+ backoff = self._reauth_retry_min_wait
489
+ while self._running and self._pending_reauth:
490
+ recovered_any = False
491
+ for account_id in list(self._pending_reauth):
492
+ credentials = self._accounts.get(account_id)
493
+ if credentials is None:
494
+ self._pending_reauth.discard(account_id)
495
+ continue
496
+ try:
497
+ await self.authenticate_account(credentials, trigger=AuthTrigger.ACCOUNT_REAUTH)
498
+ recovered_any = True
499
+ except Exception as e:
500
+ logger.warning(
501
+ "Recovery re-authentication for account %d failed, will retry: %s",
502
+ account_id,
503
+ e,
504
+ )
505
+
506
+ if not self._pending_reauth:
507
+ break
508
+
509
+ if recovered_any:
510
+ backoff = self._reauth_retry_min_wait
511
+ await anyio.sleep(backoff)
512
+ backoff = min(backoff * 2, self._reauth_retry_max_wait)
406
513
 
407
514
  async def _refresh_loop(self) -> None:
408
515
  """Periodically check and refresh expiring tokens."""
@@ -488,7 +595,7 @@ class AuthManager:
488
595
  )
489
596
 
490
597
  # Re-authenticate the account with the new token
491
- await self.authenticate_account(new_credentials, reauth=True)
598
+ await self.authenticate_account(new_credentials, trigger=AuthTrigger.TOKEN_REFRESH)
492
599
 
493
600
  # Notify callback
494
601
  if self._on_tokens_refreshed is not None:
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class AuthTrigger(Enum):
7
+ """Reason an account authentication was performed.
8
+
9
+ Threaded through account authentication so downstream logic can
10
+ distinguish cases where the server-side session (and its subscriptions)
11
+ is lost from cases where it is preserved.
12
+ """
13
+
14
+ INITIAL = "INITIAL"
15
+ RECONNECT = "RECONNECT"
16
+ TOKEN_REFRESH = "TOKEN_REFRESH"
17
+ ACCOUNT_REAUTH = "ACCOUNT_REAUTH"
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
5
5
  from typing import Any, TypeVar, overload
6
6
 
7
7
  from .api import AccountsAPI, MarketDataAPI, SymbolsAPI, TradingAPI
8
- from .auth import AuthManager
8
+ from .auth import AuthManager, AuthTrigger
9
9
  from .config import ClientConfig
10
10
  from .connection import HeartbeatManager, Protocol, Transport
11
11
  from .events import (
@@ -170,6 +170,9 @@ class CTraderClient:
170
170
  # Set up reconnection handler
171
171
  self._protocol._on_reconnect = self._handle_reconnect
172
172
 
173
+ # Recover accounts dropped by a server-side disconnect
174
+ self._emitter.subscribe(AccountDisconnectEvent, self._handle_account_disconnect)
175
+
173
176
  # -------------------------------------------------------------------------
174
177
  # Properties
175
178
  # -------------------------------------------------------------------------
@@ -229,9 +232,21 @@ class CTraderClient:
229
232
 
230
233
  @property
231
234
  def is_connected(self) -> bool:
232
- """Whether the client is connected to the server."""
235
+ """Whether the client is connected to the server (transport level)."""
233
236
  return self._connected and self._transport.is_connected
234
237
 
238
+ def is_account_authorized(self, account_id: int) -> bool:
239
+ """Whether the account currently has a live, authorized session.
240
+
241
+ Returns False after a server-side account disconnect until recovery
242
+ re-authentication succeeds. Distinct from is_connected, which only
243
+ reflects the transport-level connection.
244
+
245
+ Args:
246
+ account_id: The cTID trader account ID.
247
+ """
248
+ return self._auth.is_account_authorized(account_id)
249
+
235
250
  @property
236
251
  def protocol(self) -> Protocol:
237
252
  """Direct access to the protocol layer.
@@ -288,24 +303,29 @@ class CTraderClient:
288
303
  self._connected = False
289
304
  logger.debug("Connection closed")
290
305
 
291
- async def _emit_ready_event(self, account_id: int, is_reconnect: bool, is_reauth: bool) -> None:
306
+ async def _emit_ready_event(self, account_id: int, trigger: AuthTrigger) -> None:
292
307
  """Emit ReadyEvent when an account is authenticated.
293
308
 
294
- Called by AuthManager after successful account authentication.
295
-
296
- Does NOT emit if this is a re-auth with no reconnection (e.g. token refresh), since subscriptions
297
- are not lost in that case and users don't need to restore them.
309
+ Called by AuthManager after successful account authentication. Emitted
310
+ for every authentication that (re)establishes a server-side session and
311
+ therefore requires subscriptions to be (re)applied: INITIAL, RECONNECT,
312
+ and ACCOUNT_REAUTH. Suppressed for TOKEN_REFRESH, where the session and
313
+ its subscriptions remain intact.
298
314
 
299
315
  Args:
300
316
  account_id: The authenticated account ID.
301
- is_reconnect: True if this is a re-authentication after reconnection.
317
+ trigger: Why the authentication occurred.
302
318
  """
303
- if is_reauth and not is_reconnect:
304
- # Don't emit ReadyEvent for token refresh re-auth, since subscriptions are not lost
319
+ if trigger is AuthTrigger.TOKEN_REFRESH:
305
320
  return
306
321
 
322
+ is_reconnect = trigger in (AuthTrigger.RECONNECT, AuthTrigger.ACCOUNT_REAUTH)
307
323
  await self._emitter.emit(ReadyEvent(account_id=account_id, is_reconnect=is_reconnect))
308
324
 
325
+ async def _handle_account_disconnect(self, event: AccountDisconnectEvent) -> None:
326
+ """Route a server-side account disconnect into recovery re-auth."""
327
+ self._auth.handle_account_disconnect(event.account_id)
328
+
309
329
  async def _handle_reconnect(self) -> None:
310
330
  """Handle automatic reconnection by re-authenticating.
311
331
 
@@ -342,7 +362,7 @@ class CTraderClient:
342
362
  # Re-authenticate all previously authenticated accounts
343
363
  for account_id, credentials in list(self._auth._accounts.items()):
344
364
  try:
345
- await self._auth.authenticate_account(credentials, reconnect=True)
365
+ await self._auth.authenticate_account(credentials, trigger=AuthTrigger.RECONNECT)
346
366
  restored.append(account_id)
347
367
  logger.debug("Re-authenticated account %d", account_id)
348
368
  except Exception as e:
@@ -197,7 +197,11 @@ class ClientDisconnectEvent:
197
197
  class AccountDisconnectEvent:
198
198
  """Account disconnect event.
199
199
 
200
- Emitted when a specific account session is terminated.
200
+ Emitted when a specific account session is terminated by the server while
201
+ the underlying connection stays up. The client handles recovery
202
+ automatically: it re-authenticates the account on the existing connection
203
+ with backoff until it succeeds, then emits a ReadyEvent. This event is
204
+ informational; no user action is required to restore the session.
201
205
 
202
206
  Attributes:
203
207
  account_id: The cTID trader account ID.
@@ -287,12 +291,17 @@ class ReconnectedEvent:
287
291
  class ReadyEvent:
288
292
  """Emitted when an account is authenticated and ready for use.
289
293
 
290
- Fired after both initial authentication and reconnection re-authentication.
291
- Use this to set up subscriptions that should persist across reconnections.
294
+ Fired whenever a server-side session is (re)established and subscriptions
295
+ must be (re)applied: initial authentication, re-authentication after a
296
+ transport reconnection, and recovery after a server-side account
297
+ disconnect. It is not fired for token-refresh re-auth, where the session
298
+ and its subscriptions remain intact. Use this to set up subscriptions that
299
+ should persist across reconnections.
292
300
 
293
301
  Attributes:
294
302
  account_id: The cTID trader account ID that is now ready.
295
- is_reconnect: True if this follows a reconnection, False for initial auth.
303
+ is_reconnect: True if this follows a reconnection or account-disconnect
304
+ recovery, False for initial authentication.
296
305
  """
297
306
 
298
307
  account_id: int
@@ -18,6 +18,7 @@ from ctrader_api_client._internal.proto import (
18
18
  )
19
19
  from ctrader_api_client.auth.credentials import AccountCredentials
20
20
  from ctrader_api_client.auth.manager import AuthManager
21
+ from ctrader_api_client.auth.trigger import AuthTrigger
21
22
  from ctrader_api_client.connection.protocol import Protocol
22
23
  from ctrader_api_client.exceptions import AccountNotFoundError, APIError, TokenRefreshError
23
24
 
@@ -773,3 +774,189 @@ class TestAuthenticateByTraderLogin:
773
774
  assert mock_protocol.send_request.call_count == 2
774
775
  for call in mock_protocol.send_request.call_args_list:
775
776
  assert call[1]["timeout"] == 45.0
777
+
778
+
779
+ class TestAuthorizedState:
780
+ """Tests for account authorization tracking."""
781
+
782
+ @pytest.mark.anyio
783
+ async def test_authenticate_marks_account_authorized(
784
+ self,
785
+ mock_protocol: MagicMock,
786
+ sample_credentials: AccountCredentials,
787
+ ) -> None:
788
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(
789
+ ctid_trader_account_id=sample_credentials.account_id
790
+ )
791
+
792
+ auth = AuthManager(protocol=mock_protocol, client_id="test", client_secret="test")
793
+
794
+ await auth.authenticate_account(sample_credentials)
795
+
796
+ assert auth.is_account_authorized(sample_credentials.account_id) is True
797
+ assert sample_credentials.account_id in auth.authorized_accounts
798
+
799
+ @pytest.mark.anyio
800
+ async def test_handle_disconnect_deauthorizes_known_account(
801
+ self,
802
+ mock_protocol: MagicMock,
803
+ sample_credentials: AccountCredentials,
804
+ ) -> None:
805
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(
806
+ ctid_trader_account_id=sample_credentials.account_id
807
+ )
808
+
809
+ auth = AuthManager(protocol=mock_protocol, client_id="test", client_secret="test")
810
+ await auth.authenticate_account(sample_credentials)
811
+
812
+ auth.handle_account_disconnect(sample_credentials.account_id)
813
+
814
+ assert auth.is_account_authorized(sample_credentials.account_id) is False
815
+ assert sample_credentials.account_id in auth._pending_reauth
816
+
817
+ def test_handle_disconnect_ignores_unknown_account(self, mock_protocol: MagicMock) -> None:
818
+ auth = AuthManager(protocol=mock_protocol, client_id="test", client_secret="test")
819
+
820
+ auth.handle_account_disconnect(99999)
821
+
822
+ assert 99999 not in auth._pending_reauth
823
+
824
+ @pytest.mark.anyio
825
+ async def test_handle_disconnect_is_idempotent(
826
+ self,
827
+ mock_protocol: MagicMock,
828
+ sample_credentials: AccountCredentials,
829
+ ) -> None:
830
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(
831
+ ctid_trader_account_id=sample_credentials.account_id
832
+ )
833
+
834
+ auth = AuthManager(protocol=mock_protocol, client_id="test", client_secret="test")
835
+ await auth.authenticate_account(sample_credentials)
836
+
837
+ auth.handle_account_disconnect(sample_credentials.account_id)
838
+ auth.handle_account_disconnect(sample_credentials.account_id)
839
+
840
+ assert auth._pending_reauth == {sample_credentials.account_id}
841
+
842
+ @pytest.mark.anyio
843
+ async def test_reconnect_reauth_clears_pending_recovery(
844
+ self,
845
+ mock_protocol: MagicMock,
846
+ sample_credentials: AccountCredentials,
847
+ ) -> None:
848
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(
849
+ ctid_trader_account_id=sample_credentials.account_id
850
+ )
851
+
852
+ auth = AuthManager(protocol=mock_protocol, client_id="test", client_secret="test")
853
+ await auth.authenticate_account(sample_credentials)
854
+ auth.handle_account_disconnect(sample_credentials.account_id)
855
+
856
+ assert sample_credentials.account_id in auth._pending_reauth
857
+ assert auth.is_account_authorized(sample_credentials.account_id) is False
858
+
859
+ # The transport-reconnect path re-authenticates the account directly.
860
+ await auth.authenticate_account(sample_credentials, trigger=AuthTrigger.RECONNECT)
861
+
862
+ assert sample_credentials.account_id not in auth._pending_reauth
863
+ assert auth.is_account_authorized(sample_credentials.account_id) is True
864
+
865
+
866
+ class TestReauthLoop:
867
+ """Tests for the account recovery re-auth loop."""
868
+
869
+ @pytest.mark.anyio
870
+ async def test_recovers_disconnected_account(self, mock_protocol: MagicMock) -> None:
871
+ ready_calls: list[tuple[int, AuthTrigger]] = []
872
+
873
+ async def on_ready(account_id: int, trigger: AuthTrigger) -> None:
874
+ ready_calls.append((account_id, trigger))
875
+
876
+ auth = AuthManager(
877
+ protocol=mock_protocol,
878
+ client_id="test",
879
+ client_secret="test",
880
+ reauth_retry_min_wait=0.01,
881
+ reauth_retry_max_wait=0.02,
882
+ on_account_ready=on_ready,
883
+ )
884
+ creds = AccountCredentials(1, "token", "refresh", time.time() + 3600)
885
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(ctid_trader_account_id=1)
886
+
887
+ await auth.authenticate_account(creds)
888
+ await auth.start()
889
+ try:
890
+ auth.handle_account_disconnect(1)
891
+ assert auth.is_account_authorized(1) is False
892
+
893
+ with anyio.fail_after(1):
894
+ while not auth.is_account_authorized(1):
895
+ await anyio.sleep(0.01)
896
+
897
+ assert auth.is_account_authorized(1) is True
898
+ assert 1 not in auth._pending_reauth
899
+ finally:
900
+ await auth.stop()
901
+
902
+ assert (1, AuthTrigger.ACCOUNT_REAUTH) in ready_calls
903
+
904
+ @pytest.mark.anyio
905
+ async def test_recovery_retries_until_success(self, mock_protocol: MagicMock) -> None:
906
+ auth = AuthManager(
907
+ protocol=mock_protocol,
908
+ client_id="test",
909
+ client_secret="test",
910
+ reauth_retry_min_wait=0.01,
911
+ reauth_retry_max_wait=0.02,
912
+ )
913
+ creds = AccountCredentials(1, "token", "refresh", time.time() + 3600)
914
+
915
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(ctid_trader_account_id=1)
916
+ await auth.authenticate_account(creds)
917
+
918
+ await auth.start()
919
+ try:
920
+ # Two failures, then a successful recovery re-auth.
921
+ mock_protocol.send_request.side_effect = [
922
+ APIError("CONNECTION_CLOSED"),
923
+ APIError("CONNECTION_CLOSED"),
924
+ ProtoOAAccountAuthRes(ctid_trader_account_id=1),
925
+ ]
926
+ auth.handle_account_disconnect(1)
927
+
928
+ with anyio.fail_after(1):
929
+ while not auth.is_account_authorized(1):
930
+ await anyio.sleep(0.01)
931
+
932
+ assert auth.is_account_authorized(1) is True
933
+ # Two failed attempts plus the successful one.
934
+ assert mock_protocol.send_request.call_count >= 3
935
+ finally:
936
+ await auth.stop()
937
+
938
+ @pytest.mark.anyio
939
+ async def test_stop_clears_state_while_recovery_pending(self, mock_protocol: MagicMock) -> None:
940
+ auth = AuthManager(
941
+ protocol=mock_protocol,
942
+ client_id="test",
943
+ client_secret="test",
944
+ reauth_retry_min_wait=5.0,
945
+ reauth_retry_max_wait=5.0,
946
+ )
947
+ creds = AccountCredentials(1, "token", "refresh", time.time() + 3600)
948
+
949
+ mock_protocol.send_request.return_value = ProtoOAAccountAuthRes(ctid_trader_account_id=1)
950
+ await auth.authenticate_account(creds)
951
+
952
+ # Recovery re-auth keeps failing, so the account stays pending.
953
+ mock_protocol.send_request.side_effect = APIError("CONNECTION_CLOSED")
954
+
955
+ await auth.start()
956
+ auth.handle_account_disconnect(1)
957
+ await anyio.sleep(0.02) # allow one failed attempt before the long backoff
958
+ await auth.stop()
959
+
960
+ assert auth._running is False
961
+ assert auth._authorized_accounts == set()
962
+ assert auth._pending_reauth == set()
@@ -8,9 +8,15 @@ import pytest
8
8
 
9
9
  from ctrader_api_client import ClientConfig, CTraderClient
10
10
  from ctrader_api_client.api import AccountsAPI, MarketDataAPI, SymbolsAPI, TradingAPI
11
- from ctrader_api_client.auth import AuthManager
11
+ from ctrader_api_client.auth import AuthManager, AuthTrigger
12
12
  from ctrader_api_client.connection import HeartbeatManager, Protocol, Transport
13
- from ctrader_api_client.events import EventEmitter, EventRouter, SpotEvent
13
+ from ctrader_api_client.events import (
14
+ AccountDisconnectEvent,
15
+ EventEmitter,
16
+ EventRouter,
17
+ ReadyEvent,
18
+ SpotEvent,
19
+ )
14
20
 
15
21
 
16
22
  @pytest.fixture
@@ -489,3 +495,55 @@ class TestCTraderClientEventOff:
489
495
  result = client.off(SpotEvent, my_handler)
490
496
 
491
497
  assert result is False
498
+
499
+
500
+ class TestEmitReadyEvent:
501
+ """Test CTraderClient._emit_ready_event() trigger handling."""
502
+
503
+ @pytest.mark.anyio
504
+ async def test_token_refresh_suppresses_ready_event(self, config: ClientConfig):
505
+ client = CTraderClient(config)
506
+ client._emitter.emit = AsyncMock()
507
+
508
+ await client._emit_ready_event(1, AuthTrigger.TOKEN_REFRESH)
509
+
510
+ client._emitter.emit.assert_not_called()
511
+
512
+ @pytest.mark.anyio
513
+ @pytest.mark.parametrize(
514
+ ("trigger", "expected_is_reconnect"),
515
+ [
516
+ (AuthTrigger.INITIAL, False),
517
+ (AuthTrigger.RECONNECT, True),
518
+ (AuthTrigger.ACCOUNT_REAUTH, True),
519
+ ],
520
+ )
521
+ async def test_emits_ready_event_with_expected_flag(
522
+ self,
523
+ config: ClientConfig,
524
+ trigger: AuthTrigger,
525
+ expected_is_reconnect: bool,
526
+ ):
527
+ client = CTraderClient(config)
528
+ client._emitter.emit = AsyncMock()
529
+
530
+ await client._emit_ready_event(7, trigger)
531
+
532
+ client._emitter.emit.assert_called_once()
533
+ event = client._emitter.emit.call_args[0][0]
534
+ assert isinstance(event, ReadyEvent)
535
+ assert event.account_id == 7
536
+ assert event.is_reconnect is expected_is_reconnect
537
+
538
+
539
+ class TestAccountDisconnectRouting:
540
+ """Test that account disconnect events drive recovery re-auth."""
541
+
542
+ @pytest.mark.anyio
543
+ async def test_account_disconnect_event_routes_to_auth(self, config: ClientConfig):
544
+ client = CTraderClient(config)
545
+ client._auth.handle_account_disconnect = MagicMock()
546
+
547
+ await client._emitter.emit(AccountDisconnectEvent(account_id=555))
548
+
549
+ client._auth.handle_account_disconnect.assert_called_once_with(555)
@@ -201,7 +201,7 @@ wheels = [
201
201
 
202
202
  [[package]]
203
203
  name = "ctrader-api-client"
204
- version = "0.4.3"
204
+ version = "0.5.0"
205
205
  source = { editable = "." }
206
206
  dependencies = [
207
207
  { name = "anyio" },