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.
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/PKG-INFO +9 -2
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/README.md +8 -1
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/events.md +19 -2
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/getting-started.md +10 -3
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/pyproject.toml +1 -1
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/__init__.py +2 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/__init__.py +2 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/manager.py +133 -26
- ctrader_api_client-0.5.0/src/ctrader_api_client/auth/trigger.py +17 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/client.py +38 -18
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/heartbeat.py +3 -3
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/protocol.py +1 -1
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/transport.py +1 -1
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/router.py +2 -2
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/types.py +13 -4
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/auth/test_manager.py +187 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/test_client.py +60 -2
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/uv.lock +1 -1
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.gitignore +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/.python-version +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/Justfile +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/LICENSE +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/client.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/models.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/docs/index.md +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/mkdocs.yml +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/SOURCE +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/VERSION +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/update.sh +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/exceptions.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_messages.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/_internal/test_serialization.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/conftest.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_accounts.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_market_data_api.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_symbols.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/api/test_trading.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/auth/test_credentials.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/connection/test_protocol.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/connection/test_transport.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/events/test_emitter.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/events/test_router.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/events/test_types.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_account.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_deal.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_market_data.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_order.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_position.py +0 -0
- {ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/tests/unit/models/test_requests.py +0 -0
- {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.
|
|
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.2 → 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
|
|
|
@@ -159,7 +191,7 @@ class AuthManager:
|
|
|
159
191
|
APIError: If authentication fails.
|
|
160
192
|
CTraderConnectionTimeoutError: If request times out.
|
|
161
193
|
"""
|
|
162
|
-
logger.
|
|
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.
|
|
210
|
+
logger.debug("Application authenticated successfully")
|
|
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.
|
|
237
|
+
if trigger is AuthTrigger.INITIAL:
|
|
238
|
+
logger.debug("Authenticating account %d", credentials.account_id)
|
|
204
239
|
else:
|
|
205
|
-
logger.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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."""
|
|
@@ -417,7 +524,7 @@ class AuthManager:
|
|
|
417
524
|
continue
|
|
418
525
|
|
|
419
526
|
if credentials.expires_soon(self._refresh_buffer):
|
|
420
|
-
logger.
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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,
|
|
365
|
+
await self._auth.authenticate_account(credentials, trigger=AuthTrigger.RECONNECT)
|
|
346
366
|
restored.append(account_id)
|
|
347
|
-
logger.
|
|
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)))
|
{ctrader_api_client-0.4.2 → ctrader_api_client-0.5.0}/src/ctrader_api_client/connection/heartbeat.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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."""
|