nexusquant-cli 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. nexusquant_cli-0.4.0/README.md → nexusquant_cli-0.6.0/PKG-INFO +21 -0
  2. nexusquant_cli-0.4.0/PKG-INFO → nexusquant_cli-0.6.0/README.md +7 -14
  3. nexusquant_cli-0.6.0/nexusquant_cli/__init__.py +1 -0
  4. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/api_client.py +34 -4
  5. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/config.py +7 -0
  6. nexusquant_cli-0.6.0/nexusquant_cli/data/signal_codes.json +223 -0
  7. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/embedded.py +12 -5
  8. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/identity.py +7 -1
  9. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/admin.py +53 -0
  10. nexusquant_cli-0.6.0/nexusquant_cli/roles/auth.py +157 -0
  11. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/investor.py +33 -0
  12. nexusquant_cli-0.6.0/nexusquant_cli/signal_explain.py +108 -0
  13. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/pyproject.toml +1 -1
  14. nexusquant_cli-0.6.0/scripts/gen_signal_codes.cjs +75 -0
  15. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/tests/test_nexus_cli.py +72 -1
  16. nexusquant_cli-0.6.0/tests/test_signal_explain.py +164 -0
  17. nexusquant_cli-0.4.0/nexusquant_cli/__init__.py +0 -1
  18. nexusquant_cli-0.4.0/nexusquant_cli/roles/auth.py +0 -91
  19. nexusquant_cli-0.4.0/uv.lock +0 -345
  20. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/.gitignore +0 -0
  21. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/CLAUDE.md +0 -0
  22. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/docs/investor-operations.md +0 -0
  23. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/__main__.py +0 -0
  24. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/auth_pkce.py +0 -0
  25. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/__init__.py +0 -0
  26. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/_util.py +0 -0
  27. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
  28. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
  29. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/policy/__init__.py +0 -0
  30. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/policy/cmd.py +0 -0
  31. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
  32. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/strategy/cmd.py +0 -0
  33. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/main.py +0 -0
  34. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/nexus.py +0 -0
  35. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/__init__.py +0 -0
  36. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/_agent.py +0 -0
  37. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/provider.py +0 -0
  38. {nexusquant_cli-0.4.0 → nexusquant_cli-0.6.0}/tests/__init__.py +0 -0
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: nexusquant-cli
3
+ Version: 0.6.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.4.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.6.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
- if _is_stale_tier_403(response) and token_override() is None:
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:
@@ -216,9 +232,11 @@ def list_signal_logs(
216
232
  status: str | None = None,
217
233
  limit: int = 50,
218
234
  offset: int = 0,
235
+ signal_id: str | None = None,
219
236
  ) -> Any:
220
237
  """GET /strategy-signal/logs/ — signals that arrived while the caller was
221
- listening, with the caller's own broker result per signal."""
238
+ listening, with the caller's own broker result per signal. `signal_id` = just that one
239
+ (still only if the caller can see it)."""
222
240
  params = _drop_none(
223
241
  {
224
242
  "strategy_id": strategy_id,
@@ -226,6 +244,7 @@ def list_signal_logs(
226
244
  "status": status,
227
245
  "limit": limit,
228
246
  "offset": offset,
247
+ "id": signal_id,
229
248
  }
230
249
  )
231
250
  return get_json("/strategy-signal/logs/", params=params)
@@ -381,6 +400,17 @@ def admin_get_agent_session(session: str, *, after_seq: int | None = None, limit
381
400
  return get_json(f"/admin/nexus-ai/sessions/{session}/", params=_drop_none({"after_seq": after_seq, "limit": limit}))
382
401
 
383
402
 
403
+ def admin_list_signal_logs(**filters: Any) -> Any:
404
+ """GET /admin/strategy-signal/logs/ — every user's signal rows (strategy_id, user_id, direction, status,
405
+ limit ≤ 200, offset). `status` is the shared row's status."""
406
+ return get_json("/admin/strategy-signal/logs/", params=_drop_none(filters))
407
+
408
+
409
+ def admin_list_containers(*, active_only: bool = False) -> Any:
410
+ """GET /admin/users/containers/ — every trading environment with its broker connection status."""
411
+ return get_json("/admin/users/containers/", params={"active_only": "true"} if active_only else None)
412
+
413
+
384
414
  def admin_list_flags() -> Any:
385
415
  """GET /admin/feature-flags/ — every flag/switch with its user list."""
386
416
  return get_json("/admin/feature-flags/")
@@ -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}
@@ -0,0 +1,223 @@
1
+ {
2
+ "_source": "nexus-frontend-platform (generated by nexusquant-cli/scripts/gen_signal_codes.cjs)",
3
+ "RISK_CODE_LABELS": {
4
+ "POSITION_OVER_ALLOCATION": {
5
+ "zh": "超出分配额度",
6
+ "en": "Over allocation"
7
+ },
8
+ "INSUFFICIENT_CASH": {
9
+ "zh": "现金不足",
10
+ "en": "Insufficient cash"
11
+ },
12
+ "BELOW_MIN_CASH_RESERVE": {
13
+ "zh": "低于最低现金保有量",
14
+ "en": "Below minimum cash reserve"
15
+ },
16
+ "INSUFFICIENT_BUYING_POWER": {
17
+ "zh": "购买力不足",
18
+ "en": "Insufficient buying power"
19
+ },
20
+ "CASH_QUERY_FAILED": {
21
+ "zh": "资金查询失败",
22
+ "en": "Cash query failed"
23
+ },
24
+ "BUYING_POWER_QUERY_FAILED": {
25
+ "zh": "购买力查询失败",
26
+ "en": "Buying power query failed"
27
+ },
28
+ "INSUFFICIENT_POSITION": {
29
+ "zh": "持仓不足",
30
+ "en": "Insufficient position"
31
+ },
32
+ "POSITION_QUERY_FAILED": {
33
+ "zh": "持仓查询失败",
34
+ "en": "Position query failed"
35
+ },
36
+ "RESULT_UNKNOWN": {
37
+ "zh": "结果未知 · 请到券商核对",
38
+ "en": "Result unknown · Please check with your broker"
39
+ },
40
+ "GATEWAY_UNREACHABLE": {
41
+ "zh": "券商未连接",
42
+ "en": "Broker not connected"
43
+ },
44
+ "BROKER_RECONNECT_FAILED": {
45
+ "zh": "券商连接异常",
46
+ "en": "Broker connection error"
47
+ },
48
+ "BROKER_AUTH_EXPIRED": {
49
+ "zh": "券商授权已失效",
50
+ "en": "Broker authorization invalid"
51
+ },
52
+ "BROKER_NOT_CONNECTED": {
53
+ "zh": "券商未连接",
54
+ "en": "Broker not connected"
55
+ },
56
+ "SYMBOLS_ALLOWLIST": {
57
+ "zh": "不在允许交易列表",
58
+ "en": "Not in allowed symbols"
59
+ },
60
+ "UNLOCK_REQUIRED": {
61
+ "zh": "交易未解锁",
62
+ "en": "Trading not unlocked"
63
+ },
64
+ "MAX_ORDER_CASH_USD": {
65
+ "zh": "单笔金额上限过低",
66
+ "en": "Max order amount too low"
67
+ }
68
+ },
69
+ "RISK_CODE_DETAILS": {
70
+ "CASH_QUERY_FAILED": {
71
+ "zh": "下单前未能查询到账户可用美元现金,为避免误单,已跳过本次买入。通常是券商连接中断,请在「我的策略」中检查券商连接。",
72
+ "en": "Couldn't check your account's available USD cash before ordering, so this buy was skipped to avoid a wrong order. This is usually a dropped broker connection. Please check the broker connection in My strategies."
73
+ },
74
+ "POSITION_QUERY_FAILED": {
75
+ "zh": "下单前未能查询到该品种的持仓市值,为避免超出分配额度,已跳过本次买入。通常是券商连接中断,请在「我的策略」中检查券商连接。",
76
+ "en": "Couldn't check this symbol's position value before ordering, so this buy was skipped to avoid exceeding your allocation. This is usually a dropped broker connection. Please check the broker connection in My strategies."
77
+ },
78
+ "BUYING_POWER_QUERY_FAILED": {
79
+ "zh": "下单前未能查询到账户购买力,为避免误单,已跳过本次买入。通常是券商连接中断,请在「我的策略」中检查券商连接。",
80
+ "en": "Couldn't check your account's buying power before ordering, so this buy was skipped to avoid a wrong order. This is usually a dropped broker connection. Please check the broker connection in My strategies."
81
+ },
82
+ "BROKER_AUTH_EXPIRED": {
83
+ "zh": "券商授权已失效,订单未提交。请在「我的策略」中点击「重新授权」。",
84
+ "en": "Your broker authorization is no longer valid, so the order wasn't submitted. Please click \"Re-authorize\" in My strategies."
85
+ }
86
+ },
87
+ "CONFIG_SKIP_COPY": {
88
+ "config_below_one_share": {
89
+ "zh": "按当前配置,本次交易不足 1 股,已跳过。如需参与,请调整策略配置。",
90
+ "en": "Under your current settings, this order is less than 1 share, so it was skipped. To take part, please adjust the strategy settings."
91
+ },
92
+ "config_not_dispatched": {
93
+ "zh": "本次信号已按当前配置跳过。如需参与,请调整策略配置。",
94
+ "en": "This signal was skipped under your current settings. To take part, please adjust the strategy settings."
95
+ },
96
+ "config_zero_quantity": {
97
+ "zh": "本次信号已按当前配置跳过。如需参与,请调整策略配置。",
98
+ "en": "This signal was skipped under your current settings. To take part, please adjust the strategy settings."
99
+ }
100
+ },
101
+ "UNCONFIRMED_COPY": {
102
+ "label": {
103
+ "zh": "未确认 · 请到券商 App 核对",
104
+ "en": "Unconfirmed · Please check your broker app"
105
+ },
106
+ "detail": {
107
+ "zh": "该订单的最终结果未能确认,请在券商 App 中核对。",
108
+ "en": "The final result of this order couldn't be confirmed. Please check it in your broker app."
109
+ }
110
+ },
111
+ "FAILURE_KIND_COPY": {
112
+ "opposite_pending_order": {
113
+ "label": {
114
+ "zh": "未执行 · 有反向挂单",
115
+ "en": "Not executed · Opposite pending order"
116
+ },
117
+ "detail": {
118
+ "zh": "该品种有相反方向的未成交挂单,您的券商不支持交叉交易。请先撤销反向挂单后重试。",
119
+ "en": "This symbol has an unfilled order in the opposite direction, and your broker doesn't support cross trading. Please cancel the opposite order first, then try again."
120
+ }
121
+ },
122
+ "insufficient_buying_power": {
123
+ "label": {
124
+ "zh": "购买力不足",
125
+ "en": "Insufficient buying power"
126
+ },
127
+ "detail": {
128
+ "zh": "下单时券商提示购买力不足,订单被拒绝(未成交)。请在券商 App 存入资金或换汇后重试。",
129
+ "en": "Your broker rejected the order for insufficient buying power (not filled). Please deposit funds or exchange currency in your broker app, then try again."
130
+ }
131
+ },
132
+ "timeout_not_sent": {
133
+ "label": {
134
+ "zh": "未执行 · 券商未响应",
135
+ "en": "Not executed · No response from broker"
136
+ },
137
+ "detail": {
138
+ "zh": "等待券商响应超时,订单没有发出(未成交)。如持续出现,请在「我的策略」中检查券商连接。",
139
+ "en": "The broker didn't respond in time, so the order was not sent (not filled). If this keeps happening, please check the broker connection in My strategies."
140
+ }
141
+ },
142
+ "broker_not_connected": {
143
+ "label": {
144
+ "zh": "未执行 · 券商未连接",
145
+ "en": "Not executed · Broker not connected"
146
+ },
147
+ "detail": {
148
+ "zh": "下单时无法连接券商。请在「我的策略」中检查券商连接并完成验证。",
149
+ "en": "Couldn't reach your broker when placing the order. Please check the broker connection in My strategies and complete verification."
150
+ }
151
+ },
152
+ "broker_gateway_down": {
153
+ "label": {
154
+ "zh": "未执行 · 券商未连接",
155
+ "en": "Not executed · Broker not connected"
156
+ },
157
+ "detail": {
158
+ "zh": "下单时无法连接券商。请在「我的策略」中检查券商连接并完成验证。",
159
+ "en": "Couldn't reach your broker when placing the order. Please check the broker connection in My strategies and complete verification."
160
+ }
161
+ },
162
+ "broker_auth_expired": {
163
+ "label": {
164
+ "zh": "未执行 · 券商授权已失效",
165
+ "en": "Not executed · Broker authorization invalid"
166
+ },
167
+ "detail": {
168
+ "zh": "券商授权已失效,订单未提交。请在「我的策略」中点击「重新授权」。",
169
+ "en": "Your broker authorization is no longer valid, so the order wasn't submitted. Please click \"Re-authorize\" in My strategies."
170
+ }
171
+ },
172
+ "pdt_restriction": {
173
+ "label": {
174
+ "zh": "未执行 · 触发日内交易限制",
175
+ "en": "Not executed · Pattern day trader limit"
176
+ },
177
+ "detail": {
178
+ "zh": "该订单将使您的账户被认定为日内交易者(Pattern Day Trader)。按 NYSE / FINRA 规则,此类账户需维持不低于 $25,000 的账户权益,券商因此拒绝了该订单。请在券商 App 查看账户权益,或减少日内往返交易后重试。",
179
+ "en": "This order would classify your account as a pattern day trader. Under NYSE/FINRA rules, such accounts must keep at least $25,000 in equity, so your broker rejected the order. Please check your account equity in the broker app, or make fewer day trades, then try again."
180
+ }
181
+ },
182
+ "account_action_required": {
183
+ "label": {
184
+ "zh": "被券商拒绝 · 账户待处理",
185
+ "en": "Rejected by broker · Account needs attention"
186
+ },
187
+ "detail": {
188
+ "zh": "券商账户有待处理事项,订单被拒绝。请在券商 App 查看拒单原因。",
189
+ "en": "Your broker account has an item that needs attention, so the order was rejected. Please check the reason in your broker app."
190
+ }
191
+ },
192
+ "unlock_required": {
193
+ "label": {
194
+ "zh": "交易未解锁",
195
+ "en": "Trading not unlocked"
196
+ },
197
+ "detail": {
198
+ "zh": "券商要求先解锁交易才能下单。请在券商 App 完成交易解锁后重试。",
199
+ "en": "Your broker requires trading to be unlocked before placing orders. Please unlock trading in the broker app, then try again."
200
+ }
201
+ },
202
+ "non_trading_hours": {
203
+ "label": {
204
+ "zh": "未执行 · 非交易时段",
205
+ "en": "Not executed · Outside trading hours"
206
+ },
207
+ "detail": {
208
+ "zh": "下单时不在该品种的可交易时段,券商未受理。请在交易时段内重试。",
209
+ "en": "The order was placed outside this symbol's trading hours, so the broker didn't accept it. Please try again during trading hours."
210
+ }
211
+ },
212
+ "not_supported": {
213
+ "label": {
214
+ "zh": "未执行 · 券商不支持该操作",
215
+ "en": "Not executed · Not supported by your broker"
216
+ },
217
+ "detail": {
218
+ "zh": "您的券商不支持该下单方式。",
219
+ "en": "Your broker doesn't support this type of order."
220
+ }
221
+ }
222
+ }
223
+ }
@@ -15,7 +15,9 @@ as the user who is chatting, without any credentials on disk:
15
15
 
16
16
  The token never appears in argv, the environment, or a file.
17
17
 
18
- Read commands (`investor list|read …`, plus `ping broker`) always run. Commands that change state run only when the
18
+ Read commands (`investor list|read …`, plus `ping broker`) always run. `admin list|read …` also runs —
19
+ the bridge offers it only in admin (operator) sessions, and the backend checks the token is an admin's;
20
+ admin commands that change anything are never available here. Commands that change state run only when the
19
21
  caller sets NEXUS_CLI_WRITE_CONFIRMED=1 — the bridge does so only after the user clicked "allow"
20
22
  for that exact call — and only from the WRITE_COMMANDS allowlist; they get --yes because the
21
23
  confirmation already happened. Everything else is refused before a token is read.
@@ -68,6 +70,8 @@ def is_write(argv: list[str]) -> bool:
68
70
 
69
71
  def validate(argv: list[str], *, write_confirmed: bool = False) -> str | None:
70
72
  """None if argv may run; otherwise why not."""
73
+ if len(argv) >= 3 and argv[0] == "admin":
74
+ return None if argv[1] in READ_VERBS else "only `admin list|read …` is available here"
71
75
  if len(argv) < 3 or argv[0] != "investor":
72
76
  return "only `investor <verb> <resource> …` is available"
73
77
  if argv[1] in READ_VERBS or argv[2] in UNCONFIRMED_ACTIONS.get(argv[1], set()):
@@ -97,15 +101,18 @@ def main(argv: list[str] | None = None) -> int:
97
101
  from nexusquant_cli.config import set_token_override
98
102
 
99
103
  set_token_override(token)
100
- # Import only the investor commands: the full app also loads provider/policy, which needs the SDK.
101
- from nexusquant_cli.roles.investor import investor_app
104
+ # Import only the role that runs: the full app also loads provider/policy, which needs the SDK.
105
+ if argv[0] == "admin":
106
+ from nexusquant_cli.roles.admin import admin_app as app
107
+ else:
108
+ from nexusquant_cli.roles.investor import investor_app as app
102
109
 
103
110
  if "--json" not in argv:
104
111
  argv.append("--json")
105
- if write and "--yes" not in argv and "--yes" in _options(investor_app, argv[1], argv[2]):
112
+ if write and "--yes" not in argv and "--yes" in _options(app, argv[1], argv[2]):
106
113
  argv.append("--yes") # confirmed in the web UI already; a prompt here would block on stdin
107
114
  try:
108
- investor_app(argv[1:], prog_name="nexus-cli investor", standalone_mode=False)
115
+ app(argv[1:], prog_name=f"nexus-cli {argv[0]}", standalone_mode=False)
109
116
  except SystemExit as e: # typer.Exit / click exceptions
110
117
  return int(e.code or 0) if isinstance(e.code, int) else 1
111
118
  except Exception as e: # noqa: BLE001 — usage errors etc.; message only, never the token
@@ -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"]
@@ -204,6 +204,59 @@ def list_agent_logs(
204
204
  console.print(f"[dim]More: --before {d.get('next_before')} --before-id {d.get('next_before_id')}[/dim]")
205
205
 
206
206
 
207
+ @list_app.command("signals", help="Signal rows of every user (ops: find failures). --status filters the shared row status.")
208
+ def list_signals(
209
+ user_id: UserIdOpt = None,
210
+ strategy_id: Annotated[str | None, typer.Option("--strategy-id")] = None,
211
+ direction: Annotated[str | None, typer.Option("--direction", help="buy / sell")] = None,
212
+ status: Annotated[str | None, typer.Option("--status", help="completed / failed / skipped / sent_to_broker")] = None,
213
+ limit: Annotated[int, typer.Option("--limit", min=1, max=200)] = 50,
214
+ offset: Annotated[int, typer.Option("--offset", min=0)] = 0,
215
+ as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False,
216
+ ) -> None:
217
+ try:
218
+ resp = api_client.admin_list_signal_logs(user_id=user_id, strategy_id=strategy_id, direction=direction,
219
+ status=status, limit=limit, offset=offset)
220
+ except Exception as e: # noqa: BLE001
221
+ fail(console, e)
222
+ d = (resp or {}).get("data") or {}
223
+ if as_json:
224
+ console.print_json(data=d)
225
+ return
226
+ table = Table(show_header=True, header_style="dim")
227
+ for col in ("time", "user", "strategy", "ticker", "side", "price", "status", "error"):
228
+ table.add_column(col, overflow="fold")
229
+ for r in d.get("logs") or []:
230
+ table.add_row(str(r.get("received_at"))[:19], str(r.get("user_email") or r.get("user_id") or ""),
231
+ str(r.get("strategy_id")), str(r.get("ticker")), str(r.get("direction")), str(r.get("price")),
232
+ str(r.get("status")), str(r.get("error_message") or "")[:80])
233
+ console.print(table)
234
+
235
+
236
+ @list_app.command("containers", help="Every trading environment with broker and connection status (ops).")
237
+ def list_containers(
238
+ active_only: Annotated[bool, typer.Option("--active-only", help="Only running / active ones.")] = False,
239
+ as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False,
240
+ ) -> None:
241
+ try:
242
+ resp = api_client.admin_list_containers(active_only=active_only)
243
+ except Exception as e: # noqa: BLE001
244
+ fail(console, e)
245
+ d = (resp or {}).get("data", resp) if isinstance(resp, dict) else {}
246
+ rows = (d or {}).get("containers") or []
247
+ if as_json:
248
+ console.print_json(data=d)
249
+ return
250
+ table = Table(show_header=True, header_style="dim")
251
+ for col in ("user", "broker", "status", "connection", "login", "version", "strategies"):
252
+ table.add_column(col, overflow="fold")
253
+ for r in rows:
254
+ table.add_row(str(r.get("user_email") or r.get("username") or ""), str(r.get("broker_id")),
255
+ str(r.get("status")), str(r.get("connection_status")), str(r.get("opend_login_status") or ""),
256
+ str(r.get("vm_version") or ""), str(r.get("strategy_name") or ""))
257
+ console.print(table)
258
+
259
+
207
260
  @list_app.command("agent-access", help="Which non-admin users may use Nexus AI (flag NEXUS_AI_USERS).")
208
261
  def list_agent_access(as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False) -> None:
209
262
  try: