nexusquant-cli 0.3.0__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.
- nexusquant_cli-0.3.0/README.md → nexusquant_cli-0.5.0/PKG-INFO +21 -0
- nexusquant_cli-0.3.0/PKG-INFO → nexusquant_cli-0.5.0/README.md +7 -14
- nexusquant_cli-0.5.0/nexusquant_cli/__init__.py +1 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/api_client.py +66 -3
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/config.py +7 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/embedded.py +47 -9
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/identity.py +7 -1
- nexusquant_cli-0.5.0/nexusquant_cli/roles/auth.py +157 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/investor.py +212 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/pyproject.toml +1 -1
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/tests/test_nexus_cli.py +219 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/uv.lock +1 -1
- nexusquant_cli-0.3.0/nexusquant_cli/__init__.py +0 -1
- nexusquant_cli-0.3.0/nexusquant_cli/roles/auth.py +0 -91
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/.gitignore +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/CLAUDE.md +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/docs/investor-operations.md +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/__main__.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/auth_pkce.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/__init__.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/_util.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/policy/__init__.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/policy/cmd.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/strategy/cmd.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/main.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/nexus.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/__init__.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/_agent.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/admin.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/provider.py +0 -0
- {nexusquant_cli-0.3.0 → nexusquant_cli-0.5.0}/tests/__init__.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nexusquant-cli
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: NexusQuant CLI for users, strategy providers and admins
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: nexusquant-sdk>=0.2
|
|
8
|
+
Requires-Dist: platformdirs>=4.2
|
|
9
|
+
Requires-Dist: rich>=13.7
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# nexusquant-cli
|
|
2
16
|
|
|
3
17
|
Command line tool for NexusQuant investors, strategy providers and admins.
|
|
@@ -22,8 +36,15 @@ pip install nexusquant-cli
|
|
|
22
36
|
```bash
|
|
23
37
|
nexus-cli auth login # browser login; one login covers every role
|
|
24
38
|
nexus-cli auth whoami # account, userType, Wall Pro tier, usable roles
|
|
39
|
+
nexus-cli auth login --device # no local browser (server, SSH): approve a link + code from any browser
|
|
25
40
|
```
|
|
26
41
|
|
|
42
|
+
`--device` gives you a **Nexus AI token** instead of a Cognito login:
|
|
43
|
+
- It works for investor commands only; provider and admin commands are refused by the server.
|
|
44
|
+
- It is valid for 90 days and can be revoked at any time.
|
|
45
|
+
- `auth logout` revokes it on the server.
|
|
46
|
+
- The same token kind is what the Nexus AI Discord bot uses.
|
|
47
|
+
|
|
27
48
|
The role is not picked at login — it is the account's `custom:userType` (or the `admin` Cognito group), and the server checks it on every call. `whoami` reads it from the local token; on a 403 the CLI prints which account type is logged in.
|
|
28
49
|
|
|
29
50
|
Every read takes `--json` for scripting; the default output is a table.
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: nexusquant-cli
|
|
3
|
-
Version: 0.3.0
|
|
4
|
-
Summary: NexusQuant CLI for users, strategy providers and admins
|
|
5
|
-
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: httpx>=0.27
|
|
7
|
-
Requires-Dist: nexusquant-sdk>=0.2
|
|
8
|
-
Requires-Dist: platformdirs>=4.2
|
|
9
|
-
Requires-Dist: rich>=13.7
|
|
10
|
-
Requires-Dist: typer>=0.12
|
|
11
|
-
Provides-Extra: dev
|
|
12
|
-
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# nexusquant-cli
|
|
16
2
|
|
|
17
3
|
Command line tool for NexusQuant investors, strategy providers and admins.
|
|
@@ -36,8 +22,15 @@ pip install nexusquant-cli
|
|
|
36
22
|
```bash
|
|
37
23
|
nexus-cli auth login # browser login; one login covers every role
|
|
38
24
|
nexus-cli auth whoami # account, userType, Wall Pro tier, usable roles
|
|
25
|
+
nexus-cli auth login --device # no local browser (server, SSH): approve a link + code from any browser
|
|
39
26
|
```
|
|
40
27
|
|
|
28
|
+
`--device` gives you a **Nexus AI token** instead of a Cognito login:
|
|
29
|
+
- It works for investor commands only; provider and admin commands are refused by the server.
|
|
30
|
+
- It is valid for 90 days and can be revoked at any time.
|
|
31
|
+
- `auth logout` revokes it on the server.
|
|
32
|
+
- The same token kind is what the Nexus AI Discord bot uses.
|
|
33
|
+
|
|
41
34
|
The role is not picked at login — it is the account's `custom:userType` (or the `admin` Cognito group), and the server checks it on every call. `whoami` reads it from the local token; on a 403 the CLI prints which account type is logged in.
|
|
42
35
|
|
|
43
36
|
Every read takes `--json` for scripting; the default output is a table.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
8
|
from nexusquant_cli.auth_pkce import ensure_fresh_id_token
|
|
9
|
-
from nexusquant_cli.config import api_base_url, token_override
|
|
9
|
+
from nexusquant_cli.config import api_base_url, stored_nexus_ai_token, token_override
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ApiError(RuntimeError):
|
|
@@ -20,7 +20,7 @@ class ApiError(RuntimeError):
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _headers(*, force_refresh: bool = False) -> dict[str, str]:
|
|
23
|
-
token = token_override() or ensure_fresh_id_token(force_refresh=force_refresh)
|
|
23
|
+
token = token_override() or stored_nexus_ai_token() or ensure_fresh_id_token(force_refresh=force_refresh)
|
|
24
24
|
return {
|
|
25
25
|
"Authorization": f"Bearer {token}",
|
|
26
26
|
"Accept": "application/json",
|
|
@@ -58,7 +58,8 @@ def request(
|
|
|
58
58
|
kwargs["json"] = body
|
|
59
59
|
with httpx.Client(timeout=timeout) as client:
|
|
60
60
|
response = client.request(method, url, headers=_headers(), **kwargs)
|
|
61
|
-
|
|
61
|
+
# Only a Cognito login can be refreshed; a Nexus AI token's tier is checked live by the server.
|
|
62
|
+
if _is_stale_tier_403(response) and token_override() is None and stored_nexus_ai_token() is None:
|
|
62
63
|
response = client.request(
|
|
63
64
|
method, url, headers=_headers(force_refresh=True), **kwargs
|
|
64
65
|
)
|
|
@@ -68,6 +69,21 @@ def request(
|
|
|
68
69
|
return response.json()
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def public_post(path: str, body: Any, *, timeout: float = 30.0) -> tuple[int, Any]:
|
|
73
|
+
"""POST without any credentials (device login). Returns (status, json) instead of raising."""
|
|
74
|
+
with httpx.Client(timeout=timeout) as client:
|
|
75
|
+
response = client.post(f"{api_base_url()}{path}", json=body, headers={"Accept": "application/json"})
|
|
76
|
+
try:
|
|
77
|
+
return response.status_code, response.json()
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
return response.status_code, {"error": response.text[:200]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def revoke_current_token() -> Any:
|
|
83
|
+
"""DELETE /nexus-ai/tokens/current/ — revoke the Nexus AI token this CLI is using."""
|
|
84
|
+
return request("DELETE", "/nexus-ai/tokens/current/")
|
|
85
|
+
|
|
86
|
+
|
|
71
87
|
def get_json(
|
|
72
88
|
path: str, *, params: dict[str, Any] | None = None, timeout: float = 60.0
|
|
73
89
|
) -> Any:
|
|
@@ -192,6 +208,23 @@ def list_my_strategies() -> Any:
|
|
|
192
208
|
return get_json("/user/strategy/list/")
|
|
193
209
|
|
|
194
210
|
|
|
211
|
+
def update_strategy_config(strategy_id: str, body: dict[str, Any]) -> Any:
|
|
212
|
+
"""PUT /user/strategy/config/{id}/ — {strategy_params} replaces that slice, {execution} merges.
|
|
213
|
+
|
|
214
|
+
After the first save, every save switches listening OFF (the server closes the window).
|
|
215
|
+
"""
|
|
216
|
+
return put_json(f"/user/strategy/config/{strategy_id}/", body)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def set_listening(strategy_id: str, on: bool) -> Any:
|
|
220
|
+
"""POST /user/strategy/subscribe|unsubscribe/{id}/ — arm / disarm auto-trading for one strategy.
|
|
221
|
+
|
|
222
|
+
ON means future signals from this strategy place REAL orders in the user's broker account.
|
|
223
|
+
"""
|
|
224
|
+
action = "subscribe" if on else "unsubscribe"
|
|
225
|
+
return post_json(f"/user/strategy/{action}/{strategy_id}/", {})
|
|
226
|
+
|
|
227
|
+
|
|
195
228
|
def list_signal_logs(
|
|
196
229
|
*,
|
|
197
230
|
strategy_id: str | None = None,
|
|
@@ -229,6 +262,36 @@ def get_container_status(container_id: str) -> Any:
|
|
|
229
262
|
return get_json(f"/container/{container_id}/status/")
|
|
230
263
|
|
|
231
264
|
|
|
265
|
+
def get_broker_token_status(container_id: str) -> Any:
|
|
266
|
+
"""GET /container/{cid}/broker_token_status/ — token / session time left and how to renew."""
|
|
267
|
+
return get_json(f"/container/{container_id}/broker_token_status/")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_broker_disconnect_notice(container_id: str) -> Any:
|
|
271
|
+
"""GET /container/{cid}/broker-disconnect-notice/ — {notice: "competing_session"|null, broker_id}."""
|
|
272
|
+
return get_json(f"/container/{container_id}/broker-disconnect-notice/")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def ping_broker(container_id: str) -> Any:
|
|
276
|
+
"""POST /container/{cid}/ping/ — the web app's「检查连接」.
|
|
277
|
+
|
|
278
|
+
Not a pure read: it asks the broker (e.g. get_funds), updates the stored connection
|
|
279
|
+
status, and may self-heal (Schwab status, a forced Webull reconnect) unless the user
|
|
280
|
+
disconnected on purpose. Writes a broker_ping activity row.
|
|
281
|
+
"""
|
|
282
|
+
return post_json(f"/container/{container_id}/ping/", {})
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def disconnect_broker(container_id: str) -> Any:
|
|
286
|
+
"""POST /container/{cid}/disconnect/ — stop the broker session; no orders until reconnected."""
|
|
287
|
+
return post_json(f"/container/{container_id}/disconnect/", {})
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def reconnect_ibkr(container_id: str) -> Any:
|
|
291
|
+
"""POST /container/{cid}/connect/ step=gateway_reconnect — IBKR re-login; sends a 2FA push to the phone."""
|
|
292
|
+
return post_json(f"/container/{container_id}/connect/", {"step": "gateway_reconnect", "broker_id": "ibkr"})
|
|
293
|
+
|
|
294
|
+
|
|
232
295
|
def list_brokers() -> Any:
|
|
233
296
|
"""GET /container/brokers/ — supported broker catalog (public)."""
|
|
234
297
|
return get_json("/container/brokers/")
|
|
@@ -67,6 +67,13 @@ def token_override() -> str | None:
|
|
|
67
67
|
return _TOKEN_OVERRIDE
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def stored_nexus_ai_token() -> str | None:
|
|
71
|
+
"""A Nexus AI token saved by `auth login --device` (investor commands only)."""
|
|
72
|
+
creds = load_credentials() or {}
|
|
73
|
+
tok = creds.get("nexus_ai_token")
|
|
74
|
+
return str(tok) if tok and not creds.get("id_token") else None
|
|
75
|
+
|
|
76
|
+
|
|
70
77
|
def load_credentials() -> dict[str, Any] | None:
|
|
71
78
|
if _TOKEN_OVERRIDE is not None:
|
|
72
79
|
return {"id_token": _TOKEN_OVERRIDE}
|
|
@@ -13,8 +13,12 @@ as the user who is chatting, without any credentials on disk:
|
|
|
13
13
|
write the user's ID token to stdin ──▶ read one line, set as the only token
|
|
14
14
|
run the command with --json, print JSON, exit
|
|
15
15
|
|
|
16
|
-
The token never appears in argv, the environment, or a file.
|
|
17
|
-
|
|
16
|
+
The token never appears in argv, the environment, or a file.
|
|
17
|
+
|
|
18
|
+
Read commands (`investor list|read …`, plus `ping broker`) always run. Commands that change state run only when the
|
|
19
|
+
caller sets NEXUS_CLI_WRITE_CONFIRMED=1 — the bridge does so only after the user clicked "allow"
|
|
20
|
+
for that exact call — and only from the WRITE_COMMANDS allowlist; they get --yes because the
|
|
21
|
+
confirmation already happened. Everything else is refused before a token is read.
|
|
18
22
|
"""
|
|
19
23
|
|
|
20
24
|
from __future__ import annotations
|
|
@@ -23,6 +27,18 @@ import sys
|
|
|
23
27
|
|
|
24
28
|
READY = "NEXUS_CLI_READY"
|
|
25
29
|
READ_VERBS = {"list", "read"}
|
|
30
|
+
# Not pure reads, but no confirmation (owner 2026-10-09): the bridge rate-limits them instead.
|
|
31
|
+
UNCONFIRMED_ACTIONS = {"ping": {"broker"}}
|
|
32
|
+
# verb → resources. No broker connect, no container lifecycle: credentials / destructive.
|
|
33
|
+
WRITE_COMMANDS = {
|
|
34
|
+
"subscribe": {"strategy"},
|
|
35
|
+
"unsubscribe": {"strategy"},
|
|
36
|
+
"update": {"config", "listening", "settings", "agent-access"},
|
|
37
|
+
"delete": {"agent-session"},
|
|
38
|
+
"disconnect": {"broker"},
|
|
39
|
+
"reconnect": {"broker"}, # IBKR only (2FA push); the command itself refuses other brokers
|
|
40
|
+
}
|
|
41
|
+
WRITE_CONFIRMED_ENV = "NEXUS_CLI_WRITE_CONFIRMED"
|
|
26
42
|
|
|
27
43
|
|
|
28
44
|
def _harden() -> None:
|
|
@@ -35,18 +51,38 @@ def _harden() -> None:
|
|
|
35
51
|
raise SystemExit(f"prctl(PR_SET_DUMPABLE, 0) failed: errno {ctypes.get_errno()}")
|
|
36
52
|
|
|
37
53
|
|
|
38
|
-
def
|
|
39
|
-
"""
|
|
54
|
+
def _options(app, verb: str, resource: str) -> set[str]:
|
|
55
|
+
"""Option names of `investor <verb> <resource>` (empty if it does not exist)."""
|
|
56
|
+
import typer.main
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
cmd = typer.main.get_command(app).commands[verb].commands[resource]
|
|
60
|
+
except (KeyError, AttributeError):
|
|
61
|
+
return set()
|
|
62
|
+
return {opt for param in cmd.params for opt in getattr(param, "opts", [])}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_write(argv: list[str]) -> bool:
|
|
66
|
+
return len(argv) >= 3 and argv[0] == "investor" and argv[2] in WRITE_COMMANDS.get(argv[1], set())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate(argv: list[str], *, write_confirmed: bool = False) -> str | None:
|
|
70
|
+
"""None if argv may run; otherwise why not."""
|
|
40
71
|
if len(argv) < 3 or argv[0] != "investor":
|
|
41
|
-
return "only `investor <
|
|
42
|
-
if argv[1]
|
|
43
|
-
return
|
|
44
|
-
|
|
72
|
+
return "only `investor <verb> <resource> …` is available"
|
|
73
|
+
if argv[1] in READ_VERBS or argv[2] in UNCONFIRMED_ACTIONS.get(argv[1], set()):
|
|
74
|
+
return None
|
|
75
|
+
if is_write(argv):
|
|
76
|
+
return None if write_confirmed else "this command changes your account and needs your confirmation first"
|
|
77
|
+
return f"`investor {argv[1]} {argv[2]}` is not available here"
|
|
45
78
|
|
|
46
79
|
|
|
47
80
|
def main(argv: list[str] | None = None) -> int:
|
|
81
|
+
import os
|
|
82
|
+
|
|
48
83
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
49
|
-
|
|
84
|
+
write = is_write(argv)
|
|
85
|
+
why = validate(argv, write_confirmed=os.environ.get(WRITE_CONFIRMED_ENV) == "1")
|
|
50
86
|
if why:
|
|
51
87
|
print(why, file=sys.stderr)
|
|
52
88
|
return 2
|
|
@@ -66,6 +102,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
66
102
|
|
|
67
103
|
if "--json" not in argv:
|
|
68
104
|
argv.append("--json")
|
|
105
|
+
if write and "--yes" not in argv and "--yes" in _options(investor_app, argv[1], argv[2]):
|
|
106
|
+
argv.append("--yes") # confirmed in the web UI already; a prompt here would block on stdin
|
|
69
107
|
try:
|
|
70
108
|
investor_app(argv[1:], prog_name="nexus-cli investor", standalone_mode=False)
|
|
71
109
|
except SystemExit as e: # typer.Exit / click exceptions
|
|
@@ -21,6 +21,10 @@ def _decode_segment(segment: str) -> dict[str, Any]:
|
|
|
21
21
|
|
|
22
22
|
def token_claims() -> dict[str, Any] | None:
|
|
23
23
|
creds = load_credentials()
|
|
24
|
+
if creds and creds.get("nexus_ai_token") and not creds.get("id_token"):
|
|
25
|
+
# Device login: not a JWT. Investor-only by construction; tier is checked live by the server.
|
|
26
|
+
return {"email": creds.get("email"), "sub": creds.get("sub"), "nexus_ai_token": True,
|
|
27
|
+
"nexus_ai_expires_at": creds.get("expires_at")}
|
|
24
28
|
token = (creds or {}).get("id_token")
|
|
25
29
|
if not token:
|
|
26
30
|
return None
|
|
@@ -43,7 +47,9 @@ def user_type(claims: dict[str, Any] | None) -> str:
|
|
|
43
47
|
|
|
44
48
|
def roles_for(claims: dict[str, Any] | None) -> list[str]:
|
|
45
49
|
"""Role groups this login can use. Mirrors the server: admin passes provider
|
|
46
|
-
checks too; every login is an investor."""
|
|
50
|
+
checks too; every login is an investor. A Nexus AI token is investor-only."""
|
|
51
|
+
if (claims or {}).get("nexus_ai_token"):
|
|
52
|
+
return ["investor"]
|
|
47
53
|
t = user_type(claims)
|
|
48
54
|
if t == "admin":
|
|
49
55
|
return ["admin", "provider", "investor"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""`nexus-cli auth …` — one login for every role.
|
|
2
|
+
|
|
3
|
+
The role is not chosen at login: it is ``custom:userType`` on the account, and the
|
|
4
|
+
server checks it per request. ``whoami`` shows which role groups apply.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from nexusquant_cli.auth_pkce import ensure_fresh_id_token, login_and_save
|
|
15
|
+
from nexusquant_cli.commands._util import fail
|
|
16
|
+
from nexusquant_cli.config import CREDENTIALS_PATH, clear_credentials, load_credentials
|
|
17
|
+
from nexusquant_cli.identity import has_wall_pro, roles_for, token_claims, user_type
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
auth_app = typer.Typer(
|
|
22
|
+
help="Login / logout. One login covers every role; the account's userType decides "
|
|
23
|
+
"which of admin / provider / investor commands the server accepts.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@auth_app.command(
|
|
29
|
+
"login",
|
|
30
|
+
help="Log in. Default: open the browser on this machine (Cognito). --device: show a link and code to "
|
|
31
|
+
"approve from any browser (for servers / no local browser); gets a Nexus AI token that works for "
|
|
32
|
+
"investor commands only, valid 90 days.",
|
|
33
|
+
)
|
|
34
|
+
def login(
|
|
35
|
+
device: bool = typer.Option(False, "--device", help="Device login: approve in any browser."),
|
|
36
|
+
label: str = typer.Option("", "--label", help="Name shown when you approve, e.g. this machine's name."),
|
|
37
|
+
) -> None:
|
|
38
|
+
if device:
|
|
39
|
+
_device_login(label)
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
login_and_save()
|
|
43
|
+
except Exception as e: # noqa: BLE001
|
|
44
|
+
fail(console, e)
|
|
45
|
+
console.print("[green]Logged in.[/green]")
|
|
46
|
+
whoami(as_json=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _device_login(label: str) -> None:
|
|
50
|
+
import socket
|
|
51
|
+
|
|
52
|
+
from nexusquant_cli import api_client
|
|
53
|
+
from nexusquant_cli.config import save_credentials
|
|
54
|
+
|
|
55
|
+
code, body = api_client.public_post("/nexus-ai/device/start/", {"client": "cli", "label": label or socket.gethostname()})
|
|
56
|
+
if code != 200:
|
|
57
|
+
fail(console, RuntimeError(f"HTTP {code}: {body}"))
|
|
58
|
+
d = body.get("data") or {}
|
|
59
|
+
console.print(f"Open [bold]{d.get('verification_uri')}[/bold]")
|
|
60
|
+
console.print(f"and approve code [bold cyan]{d.get('user_code')}[/bold cyan] (expires in {int(d.get('expires_in', 600)) // 60} min).")
|
|
61
|
+
deadline = time.monotonic() + float(d.get("expires_in") or 600)
|
|
62
|
+
interval = max(2.0, float(d.get("interval") or 5))
|
|
63
|
+
with console.status("Waiting for approval…"):
|
|
64
|
+
while time.monotonic() < deadline:
|
|
65
|
+
time.sleep(interval)
|
|
66
|
+
code, body = api_client.public_post("/nexus-ai/device/token/", {"device_code": d.get("device_code")})
|
|
67
|
+
err = (body or {}).get("error_code")
|
|
68
|
+
if code == 200:
|
|
69
|
+
data = body.get("data") or {}
|
|
70
|
+
save_credentials({
|
|
71
|
+
"nexus_ai_token": data["access_token"],
|
|
72
|
+
"expires_at": data.get("expires_at"),
|
|
73
|
+
"sub": (data.get("user") or {}).get("sub"),
|
|
74
|
+
"email": (data.get("user") or {}).get("email"),
|
|
75
|
+
"saved_at": time.time(),
|
|
76
|
+
})
|
|
77
|
+
console.print("[green]Logged in with a Nexus AI token[/green] (investor commands only).")
|
|
78
|
+
whoami(as_json=False)
|
|
79
|
+
return
|
|
80
|
+
if err != "AUTHORIZATION_PENDING":
|
|
81
|
+
fail(console, RuntimeError(f"{err or code}: {(body or {}).get('error')}"))
|
|
82
|
+
fail(console, RuntimeError("Timed out waiting for approval. Run `nexus-cli auth login --device` again."))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@auth_app.command("logout", help="Delete locally saved tokens (a Nexus AI token is also revoked on the server).")
|
|
86
|
+
def logout() -> None:
|
|
87
|
+
creds = load_credentials() or {}
|
|
88
|
+
if creds.get("nexus_ai_token") and not creds.get("id_token"):
|
|
89
|
+
from nexusquant_cli import api_client
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
api_client.revoke_current_token()
|
|
93
|
+
console.print("[dim]Nexus AI token revoked on the server.[/dim]")
|
|
94
|
+
except Exception as e: # noqa: BLE001 — still clear locally
|
|
95
|
+
console.print(f"[yellow]Could not revoke on the server ({e}); clearing locally anyway.[/yellow]")
|
|
96
|
+
clear_credentials()
|
|
97
|
+
console.print("[green]Logged out (local tokens removed).[/green]")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@auth_app.command("refresh", help="Force-refresh the ID token (e.g. right after a Wall Pro upgrade).")
|
|
101
|
+
def refresh() -> None:
|
|
102
|
+
try:
|
|
103
|
+
ensure_fresh_id_token(force_refresh=True)
|
|
104
|
+
except Exception as e: # noqa: BLE001
|
|
105
|
+
fail(console, e)
|
|
106
|
+
console.print("[green]Token refreshed.[/green]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@auth_app.command("status", help="Whether tokens are saved on this machine (no network call).")
|
|
110
|
+
def status() -> None:
|
|
111
|
+
creds = load_credentials()
|
|
112
|
+
if creds and creds.get("id_token"):
|
|
113
|
+
console.print(f"[green]Logged in.[/green] Credentials: {CREDENTIALS_PATH}")
|
|
114
|
+
else:
|
|
115
|
+
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@auth_app.command(
|
|
120
|
+
"whoami",
|
|
121
|
+
help="Show the logged-in account, its userType, Wall Pro tier, and usable role groups. "
|
|
122
|
+
"Read from the local token; the server remains the authority.",
|
|
123
|
+
)
|
|
124
|
+
def whoami(
|
|
125
|
+
as_json: bool = typer.Option(False, "--json", help="Print raw claims subset as JSON."),
|
|
126
|
+
) -> None:
|
|
127
|
+
claims = token_claims()
|
|
128
|
+
if claims is None:
|
|
129
|
+
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
exp = claims.get("exp")
|
|
132
|
+
if claims.get("nexus_ai_token"):
|
|
133
|
+
info = {"email": claims.get("email"), "login": "nexus_ai_token", "roles": ["investor"],
|
|
134
|
+
"expires_at": claims.get("nexus_ai_expires_at")}
|
|
135
|
+
if as_json:
|
|
136
|
+
console.print_json(data=info)
|
|
137
|
+
return
|
|
138
|
+
console.print(f"[bold]{info['email']}[/bold]")
|
|
139
|
+
console.print("login Nexus AI token (device login) — investor commands only")
|
|
140
|
+
console.print(f"expires {info['expires_at']}")
|
|
141
|
+
return
|
|
142
|
+
info = {
|
|
143
|
+
"email": claims.get("email"),
|
|
144
|
+
"user_type": user_type(claims),
|
|
145
|
+
"wall_pro": has_wall_pro(claims),
|
|
146
|
+
"roles": roles_for(claims),
|
|
147
|
+
"token_expires_in_s": int(exp - time.time()) if isinstance(exp, (int, float)) else None,
|
|
148
|
+
}
|
|
149
|
+
if as_json:
|
|
150
|
+
console.print_json(data=info)
|
|
151
|
+
return
|
|
152
|
+
console.print(f"[bold]{info['email']}[/bold]")
|
|
153
|
+
console.print(f"userType {info['user_type']}")
|
|
154
|
+
console.print(f"Wall Pro {'[green]yes[/green]' if info['wall_pro'] else '[yellow]no[/yellow]'}")
|
|
155
|
+
console.print(f"roles {' / '.join(info['roles'])}")
|
|
156
|
+
if info["token_expires_in_s"] is not None and info["token_expires_in_s"] < 0:
|
|
157
|
+
console.print("[dim]ID token expired; it is refreshed automatically on the next call.[/dim]")
|
|
@@ -213,6 +213,7 @@ def subscribe_strategy(
|
|
|
213
213
|
def unsubscribe_strategy(
|
|
214
214
|
strategy_id: Annotated[str, typer.Argument(help="Strategy id.")],
|
|
215
215
|
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
216
|
+
as_json: JsonOpt = False,
|
|
216
217
|
) -> None:
|
|
217
218
|
if not yes:
|
|
218
219
|
mine = (_call(api_client.list_my_strategies) or {}).get("strategies") or []
|
|
@@ -227,6 +228,9 @@ def unsubscribe_strategy(
|
|
|
227
228
|
)
|
|
228
229
|
typer.confirm(f"Remove {strategy_id} from My Strategies?{warn}", abort=True)
|
|
229
230
|
resp = _call(lambda: api_client.market_unsubscribe(strategy_id)) or {}
|
|
231
|
+
if as_json:
|
|
232
|
+
console.print_json(data=resp)
|
|
233
|
+
return
|
|
230
234
|
console.print(f"[green]{resp.get('message') or 'Removed'}[/green] {strategy_id}")
|
|
231
235
|
|
|
232
236
|
|
|
@@ -402,16 +406,109 @@ def read_settings(as_json: JsonOpt = False) -> None:
|
|
|
402
406
|
console.print(f"daily report {'on' if d.get('receive_daily_report') else 'off'}")
|
|
403
407
|
|
|
404
408
|
|
|
409
|
+
def _kv_pairs(pairs: list[str] | None, label: str) -> dict[str, Any]:
|
|
410
|
+
"""KEY=VALUE → {KEY: value}; value parsed as JSON when it is a number / bool / null, else kept as text."""
|
|
411
|
+
import json as _json
|
|
412
|
+
|
|
413
|
+
out: dict[str, Any] = {}
|
|
414
|
+
for raw in pairs or []:
|
|
415
|
+
key, sep, value = raw.partition("=")
|
|
416
|
+
if not sep or not key.strip():
|
|
417
|
+
raise typer.BadParameter(f"{label} must look like KEY=VALUE, got {raw!r}")
|
|
418
|
+
try:
|
|
419
|
+
parsed = _json.loads(value)
|
|
420
|
+
out[key.strip()] = parsed if not isinstance(parsed, (dict, list)) else value
|
|
421
|
+
except ValueError:
|
|
422
|
+
out[key.strip()] = value
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@update_app.command(
|
|
427
|
+
"config",
|
|
428
|
+
help="Change your personal config for a subscribed strategy. --param sets strategy parameters "
|
|
429
|
+
"(others keep their current value); --execution sets execution limits such as max_order_cash_usd. "
|
|
430
|
+
"Saving switches listening OFF if it was on — switch it back on with `update listening`.",
|
|
431
|
+
)
|
|
432
|
+
def update_config(
|
|
433
|
+
strategy_id: Annotated[str, typer.Argument(help="Strategy id.")],
|
|
434
|
+
param: Annotated[list[str] | None, typer.Option("--param", help="KEY=VALUE, repeatable.")] = None,
|
|
435
|
+
execution: Annotated[list[str] | None, typer.Option("--execution", help="KEY=VALUE, repeatable.")] = None,
|
|
436
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
437
|
+
as_json: JsonOpt = False,
|
|
438
|
+
) -> None:
|
|
439
|
+
params, execs = _kv_pairs(param, "--param"), _kv_pairs(execution, "--execution")
|
|
440
|
+
if not params and not execs:
|
|
441
|
+
raise typer.BadParameter("Nothing to change. Use --param KEY=VALUE and/or --execution KEY=VALUE.")
|
|
442
|
+
row = next((x for x in _my_strategies() if str(x.get("id")) == strategy_id), None)
|
|
443
|
+
if row is None:
|
|
444
|
+
console.print(f"[red]{strategy_id} is not in My Strategies.[/red]")
|
|
445
|
+
raise typer.Exit(1)
|
|
446
|
+
body: dict[str, Any] = {}
|
|
447
|
+
if params:
|
|
448
|
+
current = dict(((row.get("personal_config") or {}).get("strategy_params")) or {})
|
|
449
|
+
body["strategy_params"] = {**current, **params} # the server replaces this slice: keep the others
|
|
450
|
+
if execs:
|
|
451
|
+
body["execution"] = execs # the server merges this slice
|
|
452
|
+
if not yes:
|
|
453
|
+
warn = " Listening is ON and will be switched OFF by saving." if row.get("signal_listening") else ""
|
|
454
|
+
typer.confirm(f"Save config for {strategy_id}: {params or ''} {execs or ''}?{warn}", abort=True)
|
|
455
|
+
d = _call(lambda: api_client.update_strategy_config(strategy_id, body)) or {}
|
|
456
|
+
out = {"strategy_id": strategy_id, "message": d.get("message"), "personal_config": d.get("personal_config"),
|
|
457
|
+
"effective_config": d.get("effective_config"),
|
|
458
|
+
"listening_switched_off": bool(row.get("signal_listening") and row.get("config_acknowledged_at"))}
|
|
459
|
+
if as_json:
|
|
460
|
+
console.print_json(data=out)
|
|
461
|
+
return
|
|
462
|
+
console.print(f"[green]Saved[/green] {strategy_id}")
|
|
463
|
+
if out["listening_switched_off"]:
|
|
464
|
+
console.print("[yellow]Listening was switched OFF by this save.[/yellow]")
|
|
465
|
+
console.print_json(data=out["effective_config"] or {})
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@update_app.command(
|
|
469
|
+
"listening",
|
|
470
|
+
help="Switch auto-trading for a subscribed strategy ON or OFF. ON means this strategy's future signals "
|
|
471
|
+
"place REAL orders in your broker account, sized by your effective config.",
|
|
472
|
+
)
|
|
473
|
+
def update_listening(
|
|
474
|
+
strategy_id: Annotated[str, typer.Argument(help="Strategy id.")],
|
|
475
|
+
on: Annotated[bool, typer.Option("--on/--off", help="Arm or disarm auto-trading.")],
|
|
476
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
477
|
+
as_json: JsonOpt = False,
|
|
478
|
+
) -> None:
|
|
479
|
+
if not yes:
|
|
480
|
+
row = next((x for x in _my_strategies() if str(x.get("id")) == strategy_id), None)
|
|
481
|
+
if row is None:
|
|
482
|
+
console.print(f"[red]{strategy_id} is not in My Strategies.[/red]")
|
|
483
|
+
raise typer.Exit(1)
|
|
484
|
+
what = ("REAL orders will be placed for every new signal from this strategy" if on
|
|
485
|
+
else "no new orders will be placed for this strategy")
|
|
486
|
+
typer.confirm(f"Turn listening {'ON' if on else 'OFF'} for {strategy_id}? {what}.", abort=True)
|
|
487
|
+
d = _call(lambda: api_client.set_listening(strategy_id, on)) or {}
|
|
488
|
+
out = {"strategy_id": strategy_id, "listening": on, "message": d.get("message"),
|
|
489
|
+
"warning": d.get("warning"), "failed_containers": d.get("failed_containers")}
|
|
490
|
+
if as_json:
|
|
491
|
+
console.print_json(data=out)
|
|
492
|
+
return
|
|
493
|
+
console.print(f"[green]Listening {'ON' if on else 'OFF'}[/green] {strategy_id} [dim]{d.get('message') or ''}[/dim]")
|
|
494
|
+
if d.get("warning"):
|
|
495
|
+
console.print(f"[yellow]{d['warning']}[/yellow]")
|
|
496
|
+
|
|
497
|
+
|
|
405
498
|
@update_app.command("settings", help="Change notification settings.")
|
|
406
499
|
def update_settings(
|
|
407
500
|
daily_report: Annotated[
|
|
408
501
|
bool | None,
|
|
409
502
|
typer.Option("--daily-report/--no-daily-report", help="Receive the daily trading report email."),
|
|
410
503
|
] = None,
|
|
504
|
+
as_json: JsonOpt = False,
|
|
411
505
|
) -> None:
|
|
412
506
|
if daily_report is None:
|
|
413
507
|
raise typer.BadParameter("Nothing to change. Use --daily-report or --no-daily-report.")
|
|
414
508
|
d = _call(lambda: api_client.update_user_settings({"receive_daily_report": daily_report})) or {}
|
|
509
|
+
if as_json:
|
|
510
|
+
console.print_json(data=d)
|
|
511
|
+
return
|
|
415
512
|
console.print(f"[green]daily report {'on' if d.get('receive_daily_report') else 'off'}[/green]")
|
|
416
513
|
|
|
417
514
|
|
|
@@ -448,6 +545,114 @@ def read_container(as_json: JsonOpt = False) -> None:
|
|
|
448
545
|
console.print(f"[dim]{status['opend_status_message']}[/dim]")
|
|
449
546
|
|
|
450
547
|
|
|
548
|
+
def _container() -> dict[str, Any]:
|
|
549
|
+
health = _call(api_client.get_container_health) or {}
|
|
550
|
+
cont = health.get("container") or {}
|
|
551
|
+
if not health.get("has_container") or not cont.get("id"):
|
|
552
|
+
console.print("[yellow]No trading environment yet.[/yellow] Connect a broker in the web app first.")
|
|
553
|
+
raise typer.Exit(1)
|
|
554
|
+
return {"health": health, "id": str(cont["id"])}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
ping_app = typer.Typer(help="Check something live.", no_args_is_help=True)
|
|
558
|
+
disconnect_app = typer.Typer(help="Disconnect something.", no_args_is_help=True)
|
|
559
|
+
reconnect_app = typer.Typer(help="Re-authorize something.", no_args_is_help=True)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@read_app.command(
|
|
563
|
+
"broker",
|
|
564
|
+
help="Broker connection in one view: login state and selected account, token / session time left, "
|
|
565
|
+
"and whether another login knocked this one off.",
|
|
566
|
+
)
|
|
567
|
+
def read_broker(as_json: JsonOpt = False) -> None:
|
|
568
|
+
c = _container()
|
|
569
|
+
status = (_call(lambda: api_client.get_container_status(c["id"])) or {}).get("container") or {}
|
|
570
|
+
token = _call(lambda: api_client.get_broker_token_status(c["id"])) or {}
|
|
571
|
+
notice = _call(lambda: api_client.get_broker_disconnect_notice(c["id"])) or {}
|
|
572
|
+
acc = status.get("selected_account") or {}
|
|
573
|
+
out = {
|
|
574
|
+
"broker_id": status.get("broker_id"),
|
|
575
|
+
"running": c["health"].get("is_running"),
|
|
576
|
+
"login_status": status.get("opend_login_status"),
|
|
577
|
+
"needs_verification": status.get("opend_needs_verification"),
|
|
578
|
+
"status_message": status.get("opend_status_message"),
|
|
579
|
+
"account": {k: acc.get(k) for k in ("acc_id", "market", "trd_env", "is_simulation")} if acc else None,
|
|
580
|
+
"token": token,
|
|
581
|
+
"disconnect_notice": notice.get("notice"),
|
|
582
|
+
}
|
|
583
|
+
if as_json:
|
|
584
|
+
console.print_json(data=out)
|
|
585
|
+
return
|
|
586
|
+
console.print(f"broker {out['broker_id'] or '—'} · running {_yes(out['running'])} · login {out['login_status'] or '—'}")
|
|
587
|
+
if out["disconnect_notice"]:
|
|
588
|
+
console.print(f"[red]Knocked off: {out['disconnect_notice']}[/red] (another login used the same account)")
|
|
589
|
+
if out["status_message"]:
|
|
590
|
+
console.print(f"[dim]{out['status_message']}[/dim]")
|
|
591
|
+
console.print_json(data=token)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@ping_app.command(
|
|
595
|
+
"broker",
|
|
596
|
+
help="Check the broker connection now (the web app's 「检查连接」). Asks the broker live, updates the "
|
|
597
|
+
"stored status, and may repair a dropped connection unless you disconnected on purpose.",
|
|
598
|
+
)
|
|
599
|
+
def ping_broker(as_json: JsonOpt = False) -> None:
|
|
600
|
+
c = _container()
|
|
601
|
+
d = _call(lambda: api_client.ping_broker(c["id"])) or {}
|
|
602
|
+
if as_json:
|
|
603
|
+
console.print_json(data=d)
|
|
604
|
+
return
|
|
605
|
+
ok = d.get("connected")
|
|
606
|
+
console.print(("[green]connected[/green]" if ok else "[red]not connected[/red]") + f" {d.get('message') or ''}")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@disconnect_app.command(
|
|
610
|
+
"broker",
|
|
611
|
+
help="Disconnect your broker. No orders are placed for any strategy until you connect again in the web app.",
|
|
612
|
+
)
|
|
613
|
+
def disconnect_broker(
|
|
614
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
615
|
+
as_json: JsonOpt = False,
|
|
616
|
+
) -> None:
|
|
617
|
+
c = _container()
|
|
618
|
+
if not yes:
|
|
619
|
+
typer.confirm("Disconnect your broker? No orders will be placed until you reconnect in the web app.", abort=True)
|
|
620
|
+
d = _call(lambda: api_client.disconnect_broker(c["id"])) or {}
|
|
621
|
+
if as_json:
|
|
622
|
+
console.print_json(data=d)
|
|
623
|
+
return
|
|
624
|
+
console.print(f"[green]Disconnected[/green] {d.get('message') or ''}")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@reconnect_app.command(
|
|
628
|
+
"broker",
|
|
629
|
+
help="Re-authorize an IBKR connection: sends a 2FA push to your phone — approve it there. Other brokers "
|
|
630
|
+
"re-authorize in the web app (Schwab needs the redirect URL pasted into the web form, never into a chat).",
|
|
631
|
+
)
|
|
632
|
+
def reconnect_broker(
|
|
633
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
634
|
+
as_json: JsonOpt = False,
|
|
635
|
+
) -> None:
|
|
636
|
+
c = _container()
|
|
637
|
+
status = (_call(lambda: api_client.get_container_status(c["id"])) or {}).get("container") or {}
|
|
638
|
+
broker = str(status.get("broker_id") or "").lower()
|
|
639
|
+
if broker != "ibkr":
|
|
640
|
+
msg = (f"Re-authorizing {broker or 'this broker'} is done in the web app "
|
|
641
|
+
"(My Strategies → broker connection). It needs a browser login or a code that must not go through a chat.")
|
|
642
|
+
if as_json:
|
|
643
|
+
console.print_json(data={"supported": False, "broker_id": broker, "message": msg})
|
|
644
|
+
else:
|
|
645
|
+
console.print(f"[yellow]{msg}[/yellow]")
|
|
646
|
+
raise typer.Exit(1)
|
|
647
|
+
if not yes:
|
|
648
|
+
typer.confirm("Re-login IBKR now? A 2FA push will be sent to your phone.", abort=True)
|
|
649
|
+
d = _call(lambda: api_client.reconnect_ibkr(c["id"])) or {}
|
|
650
|
+
if as_json:
|
|
651
|
+
console.print_json(data=d)
|
|
652
|
+
return
|
|
653
|
+
console.print(f"[green]Requested[/green] — approve the IBKR push on your phone. {d.get('message') or ''}")
|
|
654
|
+
|
|
655
|
+
|
|
451
656
|
@list_app.command("brokers", help="Brokers the platform supports and how each one logs in.")
|
|
452
657
|
def list_brokers(as_json: JsonOpt = False) -> None:
|
|
453
658
|
rows = (_call(api_client.list_brokers) or {}).get("brokers") or []
|
|
@@ -593,10 +798,14 @@ def read_agent_session(
|
|
|
593
798
|
def delete_agent_session(
|
|
594
799
|
session: Annotated[str, typer.Argument(help="Session id from `list agent-sessions`.")],
|
|
595
800
|
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
801
|
+
as_json: JsonOpt = False,
|
|
596
802
|
) -> None:
|
|
597
803
|
if not yes:
|
|
598
804
|
typer.confirm(f"Delete conversation {session} from your history?", abort=True)
|
|
599
805
|
d = (_call(lambda: api_client.delete_agent_session(session)) or {}).get("data") or {}
|
|
806
|
+
if as_json:
|
|
807
|
+
console.print_json(data=d)
|
|
808
|
+
return
|
|
600
809
|
console.print(f"[green]Deleted[/green] {session} [dim]({d.get('deleted_at')})[/dim]")
|
|
601
810
|
|
|
602
811
|
|
|
@@ -635,3 +844,6 @@ investor_app.add_typer(update_app, name="update")
|
|
|
635
844
|
investor_app.add_typer(subscribe_app, name="subscribe")
|
|
636
845
|
investor_app.add_typer(unsubscribe_app, name="unsubscribe")
|
|
637
846
|
investor_app.add_typer(delete_app, name="delete")
|
|
847
|
+
investor_app.add_typer(ping_app, name="ping")
|
|
848
|
+
investor_app.add_typer(disconnect_app, name="disconnect")
|
|
849
|
+
investor_app.add_typer(reconnect_app, name="reconnect")
|
|
@@ -492,3 +492,222 @@ def test_investor_commands_import_without_the_sdk() -> None:
|
|
|
492
492
|
r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
|
|
493
493
|
assert r.returncode == 0, r.stderr
|
|
494
494
|
assert r.stdout.strip() == "ok"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ── confirmed writes (agent asks the user first) ────────────────────────────
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _stdin(token="user-token-abc"):
|
|
501
|
+
return type("S", (), {"readline": lambda self: token + "\n", "close": lambda self: None})()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@pytest.mark.parametrize(
|
|
505
|
+
"argv, confirmed, allowed",
|
|
506
|
+
[
|
|
507
|
+
(["investor", "update", "listening", "s1", "--on"], False, False),
|
|
508
|
+
(["investor", "update", "listening", "s1", "--on"], True, True),
|
|
509
|
+
(["investor", "update", "config", "s1", "--param", "a=1"], True, True),
|
|
510
|
+
(["investor", "unsubscribe", "strategy", "s1"], True, True),
|
|
511
|
+
(["investor", "update", "nope"], True, False), # not in the write allowlist
|
|
512
|
+
(["investor", "connect", "broker"], True, False),
|
|
513
|
+
(["admin", "review", "strategy", "s1"], True, False),
|
|
514
|
+
],
|
|
515
|
+
)
|
|
516
|
+
def test_embedded_write_gate(argv, confirmed, allowed) -> None:
|
|
517
|
+
from nexusquant_cli import embedded
|
|
518
|
+
|
|
519
|
+
assert (embedded.validate(argv, write_confirmed=confirmed) is None) is allowed
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_embedded_confirmed_write_runs_with_yes_only_where_supported(http: Recorder, monkeypatch, capsys) -> None:
|
|
523
|
+
from nexusquant_cli import config, embedded
|
|
524
|
+
|
|
525
|
+
monkeypatch.setenv(embedded.WRITE_CONFIRMED_ENV, "1")
|
|
526
|
+
monkeypatch.setattr(embedded.sys, "stdin", _stdin())
|
|
527
|
+
http.on("POST", "/strategy/unsubscribe/s1/", {"message": "ok"})
|
|
528
|
+
http.on("POST", "/strategy/market/checkout/", {"results": [{"strategy_id": "s2", "ok": True}]})
|
|
529
|
+
try:
|
|
530
|
+
assert embedded.main(["investor", "unsubscribe", "strategy", "s1"]) == 0 # has --yes → no prompt
|
|
531
|
+
assert embedded.main(["investor", "subscribe", "strategy", "s2"]) == 0 # no --yes option → none appended
|
|
532
|
+
finally:
|
|
533
|
+
config.set_token_override(None)
|
|
534
|
+
assert [c[:2] for c in http.calls] == [("POST", "/strategy/unsubscribe/s1/"), ("POST", "/strategy/market/checkout/")]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_embedded_unconfirmed_write_never_reads_token(monkeypatch) -> None:
|
|
538
|
+
from nexusquant_cli import embedded
|
|
539
|
+
|
|
540
|
+
monkeypatch.delenv(embedded.WRITE_CONFIRMED_ENV, raising=False)
|
|
541
|
+
read = []
|
|
542
|
+
monkeypatch.setattr(embedded.sys, "stdin", type("S", (), {"readline": lambda self: read.append(1) or "t\n",
|
|
543
|
+
"close": lambda self: None})())
|
|
544
|
+
assert embedded.main(["investor", "update", "listening", "s1", "--on"]) == 2
|
|
545
|
+
assert read == []
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_update_config_keeps_other_params(http: Recorder) -> None:
|
|
549
|
+
http.on("GET", "/user/strategy/list/", {"strategies": [
|
|
550
|
+
{"id": "s1", "signal_listening": False, "config_acknowledged_at": "2026-10-01T00:00:00Z",
|
|
551
|
+
"personal_config": {"strategy_params": {"A": 1, "B": 2}}}]})
|
|
552
|
+
http.on("PUT", "/user/strategy/config/s1/", {"message": "ok", "effective_config": {}})
|
|
553
|
+
result = runner.invoke(app, ["investor", "update", "config", "s1", "--param", "B=5",
|
|
554
|
+
"--execution", "max_order_cash_usd=1000", "--yes", "--json"])
|
|
555
|
+
assert result.exit_code == 0, result.output
|
|
556
|
+
method, path, _, body = http.calls[-1]
|
|
557
|
+
assert (method, path) == ("PUT", "/user/strategy/config/s1/")
|
|
558
|
+
assert body == {"strategy_params": {"A": 1, "B": 5}, "execution": {"max_order_cash_usd": 1000}}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@pytest.mark.parametrize("flag, path", [("--on", "/user/strategy/subscribe/s1/"), ("--off", "/user/strategy/unsubscribe/s1/")])
|
|
562
|
+
def test_update_listening(http: Recorder, flag, path) -> None:
|
|
563
|
+
result = runner.invoke(app, ["investor", "update", "listening", "s1", flag, "--yes", "--json"])
|
|
564
|
+
assert result.exit_code == 0, result.output
|
|
565
|
+
assert http.calls[-1][:2] == ("POST", path)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def test_update_listening_on_asks_first(http: Recorder) -> None:
|
|
569
|
+
http.on("GET", "/user/strategy/list/", {"strategies": [{"id": "s1"}]})
|
|
570
|
+
result = runner.invoke(app, ["investor", "update", "listening", "s1", "--on"], input="n\n")
|
|
571
|
+
assert result.exit_code != 0
|
|
572
|
+
assert "REAL orders" in result.output
|
|
573
|
+
assert [c[0] for c in http.calls] == ["GET"]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ── broker: read / ping (no confirmation) / disconnect & IBKR reconnect (confirmed) ──
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _with_container(http: Recorder, broker="schwab") -> None:
|
|
580
|
+
http.on("GET", "/user/container/health/", {"has_container": True, "is_running": True, "container": {"id": "c1"}})
|
|
581
|
+
http.on("GET", "/container/c1/status/", {"container": {"broker_id": broker, "opend_login_status": "logged_in"}})
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def test_read_broker_combines_three_reads(http: Recorder) -> None:
|
|
585
|
+
_with_container(http)
|
|
586
|
+
http.on("GET", "/container/c1/broker_token_status/", {"status": {"seconds_left": 100}})
|
|
587
|
+
http.on("GET", "/container/c1/broker-disconnect-notice/", {"notice": "competing_session", "broker_id": "schwab"})
|
|
588
|
+
result = runner.invoke(app, ["investor", "read", "broker", "--json"])
|
|
589
|
+
assert result.exit_code == 0, result.output
|
|
590
|
+
out = json.loads(result.output)
|
|
591
|
+
assert out["broker_id"] == "schwab" and out["disconnect_notice"] == "competing_session"
|
|
592
|
+
assert all(c[0] == "GET" for c in http.calls)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def test_ping_disconnect_reconnect_routes(http: Recorder) -> None:
|
|
596
|
+
_with_container(http, broker="ibkr")
|
|
597
|
+
for argv, path, body in (
|
|
598
|
+
(["investor", "ping", "broker", "--json"], "/container/c1/ping/", {}),
|
|
599
|
+
(["investor", "disconnect", "broker", "--yes", "--json"], "/container/c1/disconnect/", {}),
|
|
600
|
+
(["investor", "reconnect", "broker", "--yes", "--json"], "/container/c1/connect/",
|
|
601
|
+
{"step": "gateway_reconnect", "broker_id": "ibkr"}),
|
|
602
|
+
):
|
|
603
|
+
result = runner.invoke(app, argv)
|
|
604
|
+
assert result.exit_code == 0, result.output
|
|
605
|
+
assert http.calls[-1][0] == "POST" and http.calls[-1][1] == path and http.calls[-1][3] == body
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def test_reconnect_refuses_non_ibkr_without_calling_connect(http: Recorder) -> None:
|
|
609
|
+
_with_container(http, broker="schwab")
|
|
610
|
+
result = runner.invoke(app, ["investor", "reconnect", "broker", "--yes"])
|
|
611
|
+
assert result.exit_code == 1
|
|
612
|
+
assert "web app" in result.output
|
|
613
|
+
assert not [c for c in http.calls if c[0] == "POST"]
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def test_disconnect_asks_first(http: Recorder) -> None:
|
|
617
|
+
_with_container(http)
|
|
618
|
+
result = runner.invoke(app, ["investor", "disconnect", "broker"], input="n\n")
|
|
619
|
+
assert result.exit_code != 0
|
|
620
|
+
assert not [c for c in http.calls if c[0] == "POST"]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_broker_commands_without_container(http: Recorder) -> None:
|
|
624
|
+
http.on("GET", "/user/container/health/", {"has_container": False})
|
|
625
|
+
result = runner.invoke(app, ["investor", "ping", "broker"])
|
|
626
|
+
assert result.exit_code == 1
|
|
627
|
+
assert [c[1] for c in http.calls] == ["/user/container/health/"]
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@pytest.mark.parametrize(
|
|
631
|
+
"argv, confirmed, allowed",
|
|
632
|
+
[
|
|
633
|
+
(["investor", "ping", "broker"], False, True), # no confirmation (bridge rate-limits)
|
|
634
|
+
(["investor", "disconnect", "broker"], False, False),
|
|
635
|
+
(["investor", "disconnect", "broker"], True, True),
|
|
636
|
+
(["investor", "reconnect", "broker"], True, True),
|
|
637
|
+
(["investor", "connect", "broker"], True, False), # first-time connect: never
|
|
638
|
+
],
|
|
639
|
+
)
|
|
640
|
+
def test_embedded_broker_gate(argv, confirmed, allowed) -> None:
|
|
641
|
+
from nexusquant_cli import embedded
|
|
642
|
+
|
|
643
|
+
assert (embedded.validate(argv, write_confirmed=confirmed) is None) is allowed
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ── device login (Nexus AI token) ───────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@pytest.fixture
|
|
650
|
+
def creds_dir(tmp_path, monkeypatch):
|
|
651
|
+
from nexusquant_cli import config
|
|
652
|
+
|
|
653
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
654
|
+
monkeypatch.setattr(config, "CREDENTIALS_PATH", tmp_path / "credentials.json")
|
|
655
|
+
return tmp_path / "credentials.json"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _device_routes(http: Recorder, outcomes):
|
|
659
|
+
http.on("POST", "/nexus-ai/device/start/", {"data": {
|
|
660
|
+
"device_code": "nxdc_x", "user_code": "BCDF-GHJK", "verification_uri": "https://nexusquant.co/link?code=BCDF-GHJK",
|
|
661
|
+
"expires_in": 600, "interval": 5}})
|
|
662
|
+
seq = iter(outcomes)
|
|
663
|
+
http.on("POST", "/nexus-ai/device/token/", lambda r: next(seq))
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def test_device_login_saves_token_and_uses_it(http: Recorder, creds_dir, monkeypatch) -> None:
|
|
667
|
+
from nexusquant_cli.roles import auth as auth_mod
|
|
668
|
+
|
|
669
|
+
monkeypatch.setattr(auth_mod.time, "sleep", lambda s: None)
|
|
670
|
+
_device_routes(http, [
|
|
671
|
+
httpx.Response(400, json={"error_code": "AUTHORIZATION_PENDING"}),
|
|
672
|
+
httpx.Response(200, json={"data": {"access_token": "nxat_secret_123", "expires_at": "2027-01-08T00:00:00Z",
|
|
673
|
+
"user": {"sub": "sub-1", "email": "a@x.com"}}}),
|
|
674
|
+
])
|
|
675
|
+
result = runner.invoke(app, ["auth", "login", "--device", "--label", "box"])
|
|
676
|
+
assert result.exit_code == 0, result.output
|
|
677
|
+
assert "BCDF-GHJK" in result.output and "https://nexusquant.co/link?code=BCDF-GHJK" in result.output
|
|
678
|
+
assert "investor commands only" in result.output
|
|
679
|
+
saved = json.loads(creds_dir.read_text())
|
|
680
|
+
assert saved["nexus_ai_token"] == "nxat_secret_123" and "id_token" not in saved
|
|
681
|
+
assert http.calls[0][3] == {"client": "cli", "label": "box"}
|
|
682
|
+
assert "nxat_secret_123" not in result.output
|
|
683
|
+
|
|
684
|
+
# 之后的请求用这个 token(http 夹具把 Cognito 登录换成了 "tok",这里必须不是它)
|
|
685
|
+
seen = []
|
|
686
|
+
|
|
687
|
+
def stats(request: httpx.Request) -> httpx.Response:
|
|
688
|
+
seen.append(request.headers["Authorization"])
|
|
689
|
+
return httpx.Response(200, json={"data": {}})
|
|
690
|
+
|
|
691
|
+
http.on("GET", "/strategy-signal/statistics/", stats)
|
|
692
|
+
assert runner.invoke(app, ["investor", "read", "stats", "--json"]).exit_code == 0
|
|
693
|
+
assert seen == ["Bearer nxat_secret_123"]
|
|
694
|
+
|
|
695
|
+
who = runner.invoke(app, ["auth", "whoami", "--json"])
|
|
696
|
+
assert json.loads(who.output)["roles"] == ["investor"]
|
|
697
|
+
|
|
698
|
+
http.on("DELETE", "/nexus-ai/tokens/current/", {"data": {}})
|
|
699
|
+
out = runner.invoke(app, ["auth", "logout"])
|
|
700
|
+
assert out.exit_code == 0, out.output
|
|
701
|
+
assert http.calls[-1][:2] == ("DELETE", "/nexus-ai/tokens/current/")
|
|
702
|
+
assert not creds_dir.exists()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def test_device_login_denied(http: Recorder, creds_dir, monkeypatch) -> None:
|
|
706
|
+
from nexusquant_cli.roles import auth as auth_mod
|
|
707
|
+
|
|
708
|
+
monkeypatch.setattr(auth_mod.time, "sleep", lambda s: None)
|
|
709
|
+
_device_routes(http, [httpx.Response(400, json={"error_code": "ACCESS_DENIED", "error": "用户拒绝了授权"})])
|
|
710
|
+
result = runner.invoke(app, ["auth", "login", "--device"])
|
|
711
|
+
assert result.exit_code == 1
|
|
712
|
+
assert "ACCESS_DENIED" in result.output
|
|
713
|
+
assert not creds_dir.exists()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"""`nexus-cli auth …` — one login for every role.
|
|
2
|
-
|
|
3
|
-
The role is not chosen at login: it is ``custom:userType`` on the account, and the
|
|
4
|
-
server checks it per request. ``whoami`` shows which role groups apply.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import time
|
|
10
|
-
|
|
11
|
-
import typer
|
|
12
|
-
from rich.console import Console
|
|
13
|
-
|
|
14
|
-
from nexusquant_cli.auth_pkce import ensure_fresh_id_token, login_and_save
|
|
15
|
-
from nexusquant_cli.commands._util import fail
|
|
16
|
-
from nexusquant_cli.config import CREDENTIALS_PATH, clear_credentials, load_credentials
|
|
17
|
-
from nexusquant_cli.identity import has_wall_pro, roles_for, token_claims, user_type
|
|
18
|
-
|
|
19
|
-
console = Console()
|
|
20
|
-
|
|
21
|
-
auth_app = typer.Typer(
|
|
22
|
-
help="Login / logout. One login covers every role; the account's userType decides "
|
|
23
|
-
"which of admin / provider / investor commands the server accepts.",
|
|
24
|
-
no_args_is_help=True,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@auth_app.command("login", help="Open the browser for Cognito login and save tokens locally.")
|
|
29
|
-
def login() -> None:
|
|
30
|
-
try:
|
|
31
|
-
login_and_save()
|
|
32
|
-
except Exception as e: # noqa: BLE001
|
|
33
|
-
fail(console, e)
|
|
34
|
-
console.print("[green]Logged in.[/green]")
|
|
35
|
-
whoami(as_json=False)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@auth_app.command("logout", help="Delete locally saved tokens.")
|
|
39
|
-
def logout() -> None:
|
|
40
|
-
clear_credentials()
|
|
41
|
-
console.print("[green]Logged out (local tokens removed).[/green]")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@auth_app.command("refresh", help="Force-refresh the ID token (e.g. right after a Wall Pro upgrade).")
|
|
45
|
-
def refresh() -> None:
|
|
46
|
-
try:
|
|
47
|
-
ensure_fresh_id_token(force_refresh=True)
|
|
48
|
-
except Exception as e: # noqa: BLE001
|
|
49
|
-
fail(console, e)
|
|
50
|
-
console.print("[green]Token refreshed.[/green]")
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@auth_app.command("status", help="Whether tokens are saved on this machine (no network call).")
|
|
54
|
-
def status() -> None:
|
|
55
|
-
creds = load_credentials()
|
|
56
|
-
if creds and creds.get("id_token"):
|
|
57
|
-
console.print(f"[green]Logged in.[/green] Credentials: {CREDENTIALS_PATH}")
|
|
58
|
-
else:
|
|
59
|
-
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
60
|
-
raise typer.Exit(1)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@auth_app.command(
|
|
64
|
-
"whoami",
|
|
65
|
-
help="Show the logged-in account, its userType, Wall Pro tier, and usable role groups. "
|
|
66
|
-
"Read from the local token; the server remains the authority.",
|
|
67
|
-
)
|
|
68
|
-
def whoami(
|
|
69
|
-
as_json: bool = typer.Option(False, "--json", help="Print raw claims subset as JSON."),
|
|
70
|
-
) -> None:
|
|
71
|
-
claims = token_claims()
|
|
72
|
-
if claims is None:
|
|
73
|
-
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
74
|
-
raise typer.Exit(1)
|
|
75
|
-
exp = claims.get("exp")
|
|
76
|
-
info = {
|
|
77
|
-
"email": claims.get("email"),
|
|
78
|
-
"user_type": user_type(claims),
|
|
79
|
-
"wall_pro": has_wall_pro(claims),
|
|
80
|
-
"roles": roles_for(claims),
|
|
81
|
-
"token_expires_in_s": int(exp - time.time()) if isinstance(exp, (int, float)) else None,
|
|
82
|
-
}
|
|
83
|
-
if as_json:
|
|
84
|
-
console.print_json(data=info)
|
|
85
|
-
return
|
|
86
|
-
console.print(f"[bold]{info['email']}[/bold]")
|
|
87
|
-
console.print(f"userType {info['user_type']}")
|
|
88
|
-
console.print(f"Wall Pro {'[green]yes[/green]' if info['wall_pro'] else '[yellow]no[/yellow]'}")
|
|
89
|
-
console.print(f"roles {' / '.join(info['roles'])}")
|
|
90
|
-
if info["token_expires_in_s"] is not None and info["token_expires_in_s"] < 0:
|
|
91
|
-
console.print("[dim]ID token expired; it is refreshed automatically on the next call.[/dim]")
|
|
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
|
|
File without changes
|