nexusquant-cli 0.5.0__tar.gz → 0.6.1__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 (37) hide show
  1. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/PKG-INFO +1 -1
  2. nexusquant_cli-0.6.1/nexusquant_cli/__init__.py +1 -0
  3. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/api_client.py +15 -1
  4. nexusquant_cli-0.6.1/nexusquant_cli/data/signal_codes.json +233 -0
  5. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/embedded.py +12 -5
  6. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/roles/admin.py +53 -0
  7. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/roles/investor.py +49 -8
  8. nexusquant_cli-0.6.1/nexusquant_cli/signal_explain.py +119 -0
  9. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/pyproject.toml +1 -1
  10. nexusquant_cli-0.6.1/scripts/gen_signal_codes.cjs +75 -0
  11. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/tests/test_nexus_cli.py +2 -1
  12. nexusquant_cli-0.6.1/tests/test_signal_explain.py +183 -0
  13. nexusquant_cli-0.5.0/nexusquant_cli/__init__.py +0 -1
  14. nexusquant_cli-0.5.0/uv.lock +0 -345
  15. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/.gitignore +0 -0
  16. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/CLAUDE.md +0 -0
  17. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/README.md +0 -0
  18. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/docs/investor-operations.md +0 -0
  19. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/__main__.py +0 -0
  20. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/auth_pkce.py +0 -0
  21. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/__init__.py +0 -0
  22. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/_util.py +0 -0
  23. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/auth/__init__.py +0 -0
  24. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/auth/cmd.py +0 -0
  25. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/policy/__init__.py +0 -0
  26. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/policy/cmd.py +0 -0
  27. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/strategy/__init__.py +0 -0
  28. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/commands/strategy/cmd.py +0 -0
  29. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/config.py +0 -0
  30. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/identity.py +0 -0
  31. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/main.py +0 -0
  32. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/nexus.py +0 -0
  33. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/roles/__init__.py +0 -0
  34. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/roles/_agent.py +0 -0
  35. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/roles/auth.py +0 -0
  36. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/nexusquant_cli/roles/provider.py +0 -0
  37. {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.1}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nexusquant-cli
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: NexusQuant CLI for users, strategy providers and admins
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
@@ -0,0 +1 @@
1
+ __version__ = "0.6.1"
@@ -232,9 +232,11 @@ def list_signal_logs(
232
232
  status: str | None = None,
233
233
  limit: int = 50,
234
234
  offset: int = 0,
235
+ signal_id: str | None = None,
235
236
  ) -> Any:
236
237
  """GET /strategy-signal/logs/ — signals that arrived while the caller was
237
- 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)."""
238
240
  params = _drop_none(
239
241
  {
240
242
  "strategy_id": strategy_id,
@@ -242,6 +244,7 @@ def list_signal_logs(
242
244
  "status": status,
243
245
  "limit": limit,
244
246
  "offset": offset,
247
+ "id": signal_id,
245
248
  }
246
249
  )
247
250
  return get_json("/strategy-signal/logs/", params=params)
@@ -397,6 +400,17 @@ def admin_get_agent_session(session: str, *, after_seq: int | None = None, limit
397
400
  return get_json(f"/admin/nexus-ai/sessions/{session}/", params=_drop_none({"after_seq": after_seq, "limit": limit}))
398
401
 
399
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
+
400
414
  def admin_list_flags() -> Any:
401
415
  """GET /admin/feature-flags/ — every flag/switch with its user list."""
402
416
  return get_json("/admin/feature-flags/")
@@ -0,0 +1,233 @@
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
+ "in_doubt_absent": {
143
+ "label": {
144
+ "zh": "未执行 · 券商查无此单",
145
+ "en": "Not executed · Broker has no such order"
146
+ },
147
+ "detail": {
148
+ "zh": "下单结果当时未能确认,之后向券商核实,券商没有这笔订单(未成交)。本次信号不会补下。",
149
+ "en": "The result of this order couldn't be confirmed at the time. The broker later confirmed it has no such order (not filled). This signal will not be placed again."
150
+ }
151
+ },
152
+ "broker_not_connected": {
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_gateway_down": {
163
+ "label": {
164
+ "zh": "未执行 · 券商未连接",
165
+ "en": "Not executed · Broker not connected"
166
+ },
167
+ "detail": {
168
+ "zh": "下单时无法连接券商。请在「我的策略」中检查券商连接并完成验证。",
169
+ "en": "Couldn't reach your broker when placing the order. Please check the broker connection in My strategies and complete verification."
170
+ }
171
+ },
172
+ "broker_auth_expired": {
173
+ "label": {
174
+ "zh": "未执行 · 券商授权已失效",
175
+ "en": "Not executed · Broker authorization invalid"
176
+ },
177
+ "detail": {
178
+ "zh": "券商授权已失效,订单未提交。请在「我的策略」中点击「重新授权」。",
179
+ "en": "Your broker authorization is no longer valid, so the order wasn't submitted. Please click \"Re-authorize\" in My strategies."
180
+ }
181
+ },
182
+ "pdt_restriction": {
183
+ "label": {
184
+ "zh": "未执行 · 触发日内交易限制",
185
+ "en": "Not executed · Pattern day trader limit"
186
+ },
187
+ "detail": {
188
+ "zh": "该订单将使您的账户被认定为日内交易者(Pattern Day Trader)。按 NYSE / FINRA 规则,此类账户需维持不低于 $25,000 的账户权益,券商因此拒绝了该订单。请在券商 App 查看账户权益,或减少日内往返交易后重试。",
189
+ "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."
190
+ }
191
+ },
192
+ "account_action_required": {
193
+ "label": {
194
+ "zh": "被券商拒绝 · 账户待处理",
195
+ "en": "Rejected by broker · Account needs attention"
196
+ },
197
+ "detail": {
198
+ "zh": "券商账户有待处理事项,订单被拒绝。请在券商 App 查看拒单原因。",
199
+ "en": "Your broker account has an item that needs attention, so the order was rejected. Please check the reason in your broker app."
200
+ }
201
+ },
202
+ "unlock_required": {
203
+ "label": {
204
+ "zh": "交易未解锁",
205
+ "en": "Trading not unlocked"
206
+ },
207
+ "detail": {
208
+ "zh": "券商要求先解锁交易才能下单。请在券商 App 完成交易解锁后重试。",
209
+ "en": "Your broker requires trading to be unlocked before placing orders. Please unlock trading in the broker app, then try again."
210
+ }
211
+ },
212
+ "non_trading_hours": {
213
+ "label": {
214
+ "zh": "未执行 · 非交易时段",
215
+ "en": "Not executed · Outside trading hours"
216
+ },
217
+ "detail": {
218
+ "zh": "下单时不在该品种的可交易时段,券商未受理。请在交易时段内重试。",
219
+ "en": "The order was placed outside this symbol's trading hours, so the broker didn't accept it. Please try again during trading hours."
220
+ }
221
+ },
222
+ "not_supported": {
223
+ "label": {
224
+ "zh": "未执行 · 券商不支持该操作",
225
+ "en": "Not executed · Not supported by your broker"
226
+ },
227
+ "detail": {
228
+ "zh": "您的券商不支持该下单方式。",
229
+ "en": "Your broker doesn't support this type of order."
230
+ }
231
+ }
232
+ }
233
+ }
@@ -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
@@ -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:
@@ -344,15 +344,11 @@ def list_signals(
344
344
  for col in ("time", "strategy", "ticker", "side", "price", "status", "your result"):
345
345
  table.add_column(col, overflow="fold")
346
346
  for s in logs:
347
- res = s.get("viewer_broker_result") or {}
348
- if res.get("error"):
349
- detail = f"[red]{res.get('error')}[/red]"
350
- elif res.get("skipped"):
351
- detail = f"[yellow]skipped[/yellow] {res.get('risk_code') or ''}"
352
- elif res.get("is_filled"):
353
- detail = "[green]filled[/green]"
347
+ results = s.get("viewer_broker_results") or []
348
+ if len({r.get("account_tail") or "" for r in results}) > 1:
349
+ detail = "\n".join(f"{r.get('account_tail')} {_result_detail(s, r)}" for r in results)
354
350
  else:
355
- detail = str(s.get("viewer_no_result_reason") or "")
351
+ detail = _result_detail(s, s.get("viewer_broker_result") or {})
356
352
  table.add_row(
357
353
  str(s.get("signal_time") or s.get("received_at"))[:19],
358
354
  str(s.get("strategy_id")), str(s.get("ticker")), str(s.get("direction")),
@@ -366,6 +362,51 @@ def list_signals(
366
362
  )
367
363
 
368
364
 
365
+ def _result_detail(signal: dict, res: dict) -> str:
366
+ if res.get("error"):
367
+ return f"[red]{res.get('error')}[/red]"
368
+ if res.get("skipped"):
369
+ return f"[yellow]skipped[/yellow] {res.get('risk_code') or ''}"
370
+ if res.get("is_filled"):
371
+ return "[green]filled[/green]"
372
+ return str(signal.get("viewer_no_result_reason") or "")
373
+
374
+
375
+ @read_app.command("signal", help="One signal you received, and why it did or didn't place an order.")
376
+ def read_signal(
377
+ signal_id: Annotated[str, typer.Argument(help="Signal id (from `list signals`).")],
378
+ as_json: JsonOpt = False,
379
+ ) -> None:
380
+ from nexusquant_cli.signal_explain import explain
381
+
382
+ resp = _call(lambda: api_client.list_signal_logs(signal_id=signal_id, limit=1)) or {}
383
+ logs = (resp.get("data") or {}).get("logs") or []
384
+ if not logs:
385
+ console.print(f"[red]No signal {signal_id} in your signal history.[/red]")
386
+ raise typer.Exit(1)
387
+ row = logs[0]
388
+ out = {"signal": row, "explain": explain(row)}
389
+ if as_json:
390
+ console.print_json(data=out)
391
+ return
392
+ ex = out["explain"]
393
+ console.print(f"[bold]{row.get('ticker')} {row.get('direction')} @ {row.get('price')}[/bold] "
394
+ f"{row.get('strategy_name')} · {str(row.get('signal_time'))[:19]}")
395
+ console.print(f"outcome {ex['outcome']} (listening at signal time: {ex.get('listening_at_signal_time')})")
396
+ for key, val in ex.get("codes", {}).items():
397
+ console.print(f"{key:<14}{val}")
398
+ for key, val in ex.get("copy", {}).items():
399
+ if isinstance(val, dict) and "zh" in val:
400
+ console.print(f"[dim]{key}[/dim] {val['zh']}")
401
+ elif isinstance(val, dict):
402
+ for sub, txt in val.items():
403
+ console.print(f"[dim]{key}.{sub}[/dim] {txt.get('zh') if isinstance(txt, dict) else txt}")
404
+ if ex.get("broker_message"):
405
+ console.print(f"[dim]broker[/dim] {ex['broker_message']}")
406
+ for acct in ex.get("accounts", []):
407
+ console.print(f"account {acct['account']} {acct['outcome']}")
408
+
409
+
369
410
  @read_app.command("stats", help="Your signal counts and broker success rate.")
370
411
  def read_stats(as_json: JsonOpt = False) -> None:
371
412
  resp = _call(api_client.get_signal_statistics) or {}
@@ -0,0 +1,119 @@
1
+ """Why a signal did or didn't trade — facts + the web app's own wording.
2
+
3
+ Used by `nexus-cli investor read signal <id>` (and so by Nexus AI when a user asks
4
+ "why didn't this signal place an order?").
5
+
6
+ The CLI does NOT re-derive the order status: the web app's deriveOrderStatus has dozens
7
+ of branches and a second copy would drift. Instead it returns
8
+ * `outcome`: one coarse bucket computed from the viewer fields the backend already sends,
9
+ * `codes`: the raw error_code / risk_code / failure_kind / submit_class,
10
+ * `copy`: the exact zh/en strings the web app shows for those codes, looked up in
11
+ data/signal_codes.json, which is generated from the frontend source by
12
+ scripts/gen_signal_codes.cjs (tests/test_signal_codes.py keeps them in sync).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from functools import lru_cache
19
+ from importlib import resources
20
+ from typing import Any
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def glossary() -> dict[str, Any]:
25
+ text = resources.files("nexusquant_cli").joinpath("data/signal_codes.json").read_text(encoding="utf-8")
26
+ return json.loads(text)
27
+
28
+
29
+ # Plain facts for the branches that have no code to look up (mirrors the first branches of the
30
+ # web app's deriveOrderStatus: not listening / no broker report / still waiting).
31
+ _NO_RESULT = {
32
+ "not_listening": {
33
+ "zh": "信号到达时,你没有开启这个策略的监听(自动交易),所以不会下单。",
34
+ "en": "Listening (auto-trading) for this strategy was off when the signal arrived, so no order was placed.",
35
+ },
36
+ "no_broker_report": {
37
+ "zh": "监听是开着的,但超过一小时没有收到券商的执行回报,可能当时券商未连接或执行超时。",
38
+ "en": "Listening was on, but no execution report came back from the broker for over an hour. "
39
+ "The broker may have been disconnected, or execution timed out.",
40
+ },
41
+ "awaiting": {
42
+ "zh": "还在等券商的下单回报。",
43
+ "en": "Still waiting for the broker's order report.",
44
+ },
45
+ }
46
+
47
+
48
+ def _upper_lookup(table: dict[str, Any], code: str | None) -> Any:
49
+ if not code:
50
+ return None
51
+ return table.get(code) or table.get(code.upper())
52
+
53
+
54
+ def explain(row: dict[str, Any]) -> dict[str, Any]:
55
+ """The account the backend picked (a failed one, if any failed), plus every account when there are several."""
56
+ out = _explain_one(row)
57
+ results = row.get("viewer_broker_results") or []
58
+ if len({r.get("account_tail") or "" for r in results}) > 1:
59
+ out["accounts"] = [
60
+ {"account": r.get("account_tail"), **_explain_one({**row, "viewer_broker_result": r})} for r in results
61
+ ]
62
+ return out
63
+
64
+
65
+ def _explain_one(row: dict[str, Any]) -> dict[str, Any]:
66
+ g = glossary()
67
+ vbr = row.get("viewer_broker_result") or None
68
+ listening = row.get("viewer_was_listening_at_signal_time")
69
+ out: dict[str, Any] = {"listening_at_signal_time": listening, "codes": {}, "copy": {}}
70
+
71
+ if not vbr:
72
+ if listening is False:
73
+ out["outcome"] = "not_listening"
74
+ elif row.get("viewer_no_result_reason") == "no_broker_stale":
75
+ out["outcome"] = "no_broker_report"
76
+ else:
77
+ out["outcome"] = "awaiting"
78
+ out["copy"]["summary"] = _NO_RESULT[out["outcome"]]
79
+ return out
80
+
81
+ order_info = vbr.get("order_info") if isinstance(vbr.get("order_info"), dict) else {}
82
+ if vbr.get("success") is True:
83
+ if vbr.get("is_filled"):
84
+ outcome = "filled"
85
+ elif vbr.get("is_cancelled"):
86
+ outcome = "rejected_by_broker" if order_info.get("reject_reason") else "cancelled_unfilled"
87
+ else:
88
+ outcome = "submitted"
89
+ elif vbr.get("skipped") is True:
90
+ outcome = "skipped"
91
+ else:
92
+ outcome = "unconfirmed" if vbr.get("submit_class") == "in_doubt" else "failed"
93
+ out["outcome"] = outcome
94
+
95
+ codes = {k: vbr.get(k) for k in ("error_code", "risk_code", "failure_kind", "submit_class") if vbr.get(k)}
96
+ if order_info.get("reject_reason"):
97
+ codes["reject_reason"] = str(order_info["reject_reason"])[:300]
98
+ out["codes"] = codes
99
+
100
+ copy = out["copy"]
101
+ if codes.get("error_code") in g["CONFIG_SKIP_COPY"]:
102
+ copy["config_skip"] = g["CONFIG_SKIP_COPY"][codes["error_code"]]
103
+ for key in ("risk_code", "error_code"):
104
+ label = _upper_lookup(g["RISK_CODE_LABELS"], codes.get(key))
105
+ if label and "label" not in copy:
106
+ copy["label"] = label
107
+ detail = _upper_lookup(g["RISK_CODE_DETAILS"], codes.get(key))
108
+ if detail:
109
+ copy["detail"] = detail
110
+ kind = codes.get("failure_kind")
111
+ if kind and kind in g["FAILURE_KIND_COPY"]:
112
+ copy["failure_kind"] = g["FAILURE_KIND_COPY"][kind]
113
+ if outcome == "unconfirmed":
114
+ copy["unconfirmed"] = g["UNCONFIRMED_COPY"]
115
+ # The broker / VM's own text: the web app falls back to it when no copy matches.
116
+ raw = vbr.get("error_zh") or vbr.get("error")
117
+ if raw and not copy:
118
+ out["broker_message"] = str(raw)[:500]
119
+ return out
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexusquant-cli"
7
- version = "0.5.0"
7
+ version = "0.6.1"
8
8
  description = "NexusQuant CLI for users, strategy providers and admins"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"