ctrader-api-client 0.4.2__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.2 → ctrader_api_client-0.5.0}/PKG-INFO +9 -2
  2. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/README.md +8 -1
  3. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/events.md +19 -2
  4. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/getting-started.md +10 -3
  5. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/pyproject.toml +1 -1
  6. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/__init__.py +2 -0
  7. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/__init__.py +2 -0
  8. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/manager.py +133 -26
  9. ctrader_api_client-0.5.0/src/ctrader_api_client/auth/trigger.py +17 -0
  10. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/client.py +38 -18
  11. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/heartbeat.py +3 -3
  12. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/protocol.py +1 -1
  13. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/transport.py +1 -1
  14. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/router.py +2 -2
  15. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/types.py +13 -4
  16. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/auth/test_manager.py +187 -0
  17. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/test_client.py +60 -2
  18. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/uv.lock +1 -1
  19. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.claude/settings.local.json +0 -0
  20. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.github/workflows/docs.yml +0 -0
  21. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.gitignore +0 -0
  22. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.pre-commit-config.yaml +0 -0
  23. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.python-version +0 -0
  24. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/Justfile +0 -0
  25. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/LICENSE +0 -0
  26. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/accounts.md +0 -0
  27. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/client.md +0 -0
  28. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/enums.md +0 -0
  29. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/market-data.md +0 -0
  30. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/models.md +0 -0
  31. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/symbols.md +0 -0
  32. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/trading.md +0 -0
  33. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/index.md +0 -0
  34. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/mkdocs.yml +0 -0
  35. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/SOURCE +0 -0
  36. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/VERSION +0 -0
  37. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/update.sh +0 -0
  38. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  39. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  40. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiMessages.proto +0 -0
  41. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  42. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/scripts/fix_proto_imports.py +0 -0
  43. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  44. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  45. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  46. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  47. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  48. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  49. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  50. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  51. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/__init__.py +0 -0
  52. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/accounts.py +0 -0
  53. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/market_data.py +0 -0
  54. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/symbols.py +0 -0
  55. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/trading.py +0 -0
  56. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  57. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/config.py +0 -0
  58. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  59. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/enums.py +0 -0
  60. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/__init__.py +0 -0
  61. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/emitter.py +0 -0
  62. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/exceptions.py +0 -0
  63. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/__init__.py +0 -0
  64. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/_base.py +0 -0
  65. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/account.py +0 -0
  66. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/deal.py +0 -0
  67. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/market_data.py +0 -0
  68. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/order.py +0 -0
  69. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/position.py +0 -0
  70. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/requests.py +0 -0
  71. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/symbol.py +0 -0
  72. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/py.typed +0 -0
  73. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_messages.py +0 -0
  74. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_serialization.py +0 -0
  75. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/conftest.py +0 -0
  76. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_accounts.py +0 -0
  77. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_market_data_api.py +0 -0
  78. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_symbols.py +0 -0
  79. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_trading.py +0 -0
  80. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/auth/test_credentials.py +0 -0
  81. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/connection/test_heartbeat.py +0 -0
  82. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/connection/test_protocol.py +0 -0
  83. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/connection/test_transport.py +0 -0
  84. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/events/test_emitter.py +0 -0
  85. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/events/test_router.py +0 -0
  86. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/events/test_types.py +0 -0
  87. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_account.py +0 -0
  88. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_deal.py +0 -0
  89. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_market_data.py +0 -0
  90. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_order.py +0 -0
  91. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_position.py +0 -0
  92. {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_requests.py +0 -0
  93. {ctrader_api_client-0.4.2 → 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.2
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.2"
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
 
@@ -159,7 +191,7 @@ class AuthManager:
159
191
  APIError: If authentication fails.
160
192
  CTraderConnectionTimeoutError: If request times out.
161
193
  """
162
- logger.info("Authenticating application")
194
+ logger.debug("Authenticating application")
163
195
 
164
196
  request = ProtoOAApplicationAuthReq(
165
197
  client_id=self._client_id,
@@ -175,11 +207,14 @@ class AuthManager:
175
207
  )
176
208
 
177
209
  self._app_authenticated = True
178
- logger.info("Application authenticated successfully")
210
+ logger.debug("Application authenticated successfully")
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.info("Re-authenticating account %d", credentials.account_id)
237
+ if trigger is AuthTrigger.INITIAL:
238
+ logger.debug("Authenticating account %d", credentials.account_id)
204
239
  else:
205
- logger.info("Authenticating account %d", credentials.account_id)
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
222
- logger.info("Account %d authenticated successfully", credentials.account_id)
257
+ self._authorized_accounts.add(credentials.account_id)
258
+ self._pending_reauth.discard(credentials.account_id)
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",
@@ -339,7 +376,7 @@ class AuthManager:
339
376
  APIError: If authentication fails.
340
377
  CTraderConnectionTimeoutError: If request times out.
341
378
  """
342
- logger.info("Authenticating by trader login %d", trader_login)
379
+ logger.debug("Authenticating by trader login %d", trader_login)
343
380
 
344
381
  # Resolve trader_login to account_id
345
382
  account_id = await self.resolve_account_id(access_token, trader_login, timeout=timeout)
@@ -368,15 +405,42 @@ class AuthManager:
368
405
  """
369
406
  if account_id in self._accounts:
370
407
  del self._accounts[account_id]
371
- logger.info("Account %d removed from auth manager", account_id)
408
+ self._authorized_accounts.discard(account_id)
409
+ self._pending_reauth.discard(account_id)
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."""
@@ -417,7 +524,7 @@ class AuthManager:
417
524
  continue
418
525
 
419
526
  if credentials.expires_soon(self._refresh_buffer):
420
- logger.info(
527
+ logger.debug(
421
528
  "Token for account %d expires soon (%.0fs remaining), refreshing",
422
529
  account_id,
423
530
  credentials.time_until_expiry(),
@@ -481,14 +588,14 @@ class AuthManager:
481
588
  )
482
589
  self._accounts[account_id] = new_credentials
483
590
 
484
- logger.info(
591
+ logger.debug(
485
592
  "Token refreshed for account %d, new expiry in %ds",
486
593
  account_id,
487
594
  response.expires_in,
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.
@@ -257,7 +272,7 @@ class CTraderClient:
257
272
  if self._connected:
258
273
  return
259
274
 
260
- logger.info("Connecting to %s:%d", self._config.host, self._config.port)
275
+ logger.debug("Connecting to %s:%d", self._config.host, self._config.port)
261
276
 
262
277
  await self._transport.connect()
263
278
  await self._protocol.start()
@@ -266,7 +281,7 @@ class CTraderClient:
266
281
  self._router.start()
267
282
 
268
283
  self._connected = True
269
- logger.info("Connected successfully")
284
+ logger.info("Connected to cTrader server at %s:%d", self._config.host, self._config.port)
270
285
 
271
286
  async def close(self) -> None:
272
287
  """Close the connection and clean up resources.
@@ -277,7 +292,7 @@ class CTraderClient:
277
292
  if not self._connected:
278
293
  return
279
294
 
280
- logger.info("Closing connection")
295
+ logger.debug("Closing connection")
281
296
 
282
297
  self._router.stop()
283
298
  await self._auth.stop()
@@ -286,26 +301,31 @@ class CTraderClient:
286
301
  await self._transport.close()
287
302
 
288
303
  self._connected = False
289
- logger.info("Connection closed")
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
 
@@ -313,7 +333,7 @@ class CTraderClient:
313
333
  the app and all previously authenticated accounts, then emits
314
334
  a ReconnectedEvent so users can restore subscriptions.
315
335
  """
316
- logger.info("Connection restored, re-authenticating...")
336
+ logger.debug("Connection restored, re-authenticating...")
317
337
 
318
338
  # Restart heartbeat monitoring
319
339
  await self._heartbeat.restart()
@@ -325,7 +345,7 @@ class CTraderClient:
325
345
  try:
326
346
  await self._auth.authenticate_app()
327
347
  app_auth_restored = True
328
- logger.info("App re-authenticated successfully")
348
+ logger.debug("App re-authenticated successfully")
329
349
  except Exception as e:
330
350
  logger.error("Failed to re-authenticate app after reconnect: %s", e)
331
351
  app_auth_restored = False
@@ -342,9 +362,9 @@ 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
- logger.info("Re-authenticated account %d", account_id)
367
+ logger.debug("Re-authenticated account %d", account_id)
348
368
  except Exception as e:
349
369
  logger.error("Failed to re-authenticate account %d: %s", account_id, e)
350
370
  failed.append((account_id, str(e)))
@@ -56,7 +56,7 @@ class HeartbeatManager:
56
56
  self._task_group = anyio.create_task_group()
57
57
  await self._task_group.__aenter__()
58
58
  self._task_group.start_soon(self._heartbeat_loop)
59
- logger.info("Heartbeat monitor started (interval=%.1fs, timeout=%.1fs)", self._interval, self._timeout)
59
+ logger.debug("Heartbeat monitor started (interval=%.1fs, timeout=%.1fs)", self._interval, self._timeout)
60
60
 
61
61
  async def stop(self) -> None:
62
62
  """Stop heartbeat monitoring.
@@ -67,7 +67,7 @@ class HeartbeatManager:
67
67
  self._task_scope.cancel()
68
68
 
69
69
  if self._task_group is not None:
70
- logger.info("Heartbeat monitor stopped")
70
+ logger.debug("Heartbeat monitor stopped")
71
71
  self._task_group.cancel_scope.cancel()
72
72
  try:
73
73
  await self._task_group.__aexit__(None, None, None)
@@ -87,7 +87,7 @@ class HeartbeatManager:
87
87
  self._last_received = time.monotonic()
88
88
  if self._task_group is not None:
89
89
  self._task_group.start_soon(self._heartbeat_loop)
90
- logger.info("Heartbeat monitor restarted")
90
+ logger.debug("Heartbeat monitor restarted")
91
91
 
92
92
  async def _record_activity(self, _message: betterproto.Message) -> None:
93
93
  """Reset the inactivity timer on any received server message."""
@@ -358,7 +358,7 @@ class Protocol:
358
358
  reraise=True,
359
359
  ):
360
360
  with attempt:
361
- logger.info(
361
+ logger.debug(
362
362
  "Reconnection attempt %d/%d",
363
363
  attempt.retry_state.attempt_number,
364
364
  self._reconnect_attempts,