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.
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/PKG-INFO +9 -2
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/README.md +8 -1
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/events.md +19 -2
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/getting-started.md +10 -3
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/pyproject.toml +1 -1
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/__init__.py +2 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/__init__.py +2 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/manager.py +126 -19
- ctrader_api_client-0.5.0/src/ctrader_api_client/auth/trigger.py +17 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/client.py +31 -11
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/types.py +13 -4
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/auth/test_manager.py +187 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/test_client.py +60 -2
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/uv.lock +1 -1
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.gitignore +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/.python-version +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/Justfile +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/LICENSE +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/client.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/models.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/docs/index.md +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/mkdocs.yml +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/SOURCE +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/VERSION +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/update.sh +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/protocol.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/transport.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/router.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/exceptions.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_messages.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_serialization.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/conftest.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_accounts.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_market_data_api.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_symbols.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_trading.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/auth/test_credentials.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_protocol.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_transport.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/events/test_emitter.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/events/test_router.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/events/test_types.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_account.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_deal.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_market_data.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_order.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_position.py +0 -0
- {ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/models/test_requests.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
@@ -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",
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/manager.py
RENAMED
|
@@ -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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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,
|
|
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
|
|
440
|
+
"""Start the background monitors.
|
|
377
441
|
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
317
|
+
trigger: Why the authentication occurred.
|
|
302
318
|
"""
|
|
303
|
-
if
|
|
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,
|
|
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:
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/types.py
RENAMED
|
@@ -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
|
|
291
|
-
|
|
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
|
|
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
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonMessages.proto
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonModelMessages.proto
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiModelMessages.proto
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/messages.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/accounts.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/market_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/credentials.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/heartbeat.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/protocol.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/transport.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/emitter.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/router.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/_base.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/account.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/market_data.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/order.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/position.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/requests.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/symbol.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/api/test_market_data_api.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_heartbeat.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_protocol.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.4.3 → ctrader_api_client-0.5.0}/tests/unit/connection/test_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|