nexusquant-cli 0.5.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.
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/PKG-INFO +1 -1
- nexusquant_cli-0.6.0/nexusquant_cli/__init__.py +1 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/api_client.py +15 -1
- nexusquant_cli-0.6.0/nexusquant_cli/data/signal_codes.json +223 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/embedded.py +12 -5
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/admin.py +53 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/investor.py +33 -0
- nexusquant_cli-0.6.0/nexusquant_cli/signal_explain.py +108 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/pyproject.toml +1 -1
- nexusquant_cli-0.6.0/scripts/gen_signal_codes.cjs +75 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/tests/test_nexus_cli.py +2 -1
- nexusquant_cli-0.6.0/tests/test_signal_explain.py +164 -0
- nexusquant_cli-0.5.0/nexusquant_cli/__init__.py +0 -1
- nexusquant_cli-0.5.0/uv.lock +0 -345
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/.gitignore +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/CLAUDE.md +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/README.md +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/docs/investor-operations.md +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/__main__.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/auth_pkce.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/__init__.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/_util.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/policy/__init__.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/policy/cmd.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/commands/strategy/cmd.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/config.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/identity.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/main.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/nexus.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/__init__.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/_agent.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/auth.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/nexusquant_cli/roles/provider.py +0 -0
- {nexusquant_cli-0.5.0 → nexusquant_cli-0.6.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -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,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.
|
|
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
|
|
101
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
@@ -366,6 +366,39 @@ def list_signals(
|
|
|
366
366
|
)
|
|
367
367
|
|
|
368
368
|
|
|
369
|
+
@read_app.command("signal", help="One signal you received, and why it did or didn't place an order.")
|
|
370
|
+
def read_signal(
|
|
371
|
+
signal_id: Annotated[str, typer.Argument(help="Signal id (from `list signals`).")],
|
|
372
|
+
as_json: JsonOpt = False,
|
|
373
|
+
) -> None:
|
|
374
|
+
from nexusquant_cli.signal_explain import explain
|
|
375
|
+
|
|
376
|
+
resp = _call(lambda: api_client.list_signal_logs(signal_id=signal_id, limit=1)) or {}
|
|
377
|
+
logs = (resp.get("data") or {}).get("logs") or []
|
|
378
|
+
if not logs:
|
|
379
|
+
console.print(f"[red]No signal {signal_id} in your signal history.[/red]")
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
row = logs[0]
|
|
382
|
+
out = {"signal": row, "explain": explain(row)}
|
|
383
|
+
if as_json:
|
|
384
|
+
console.print_json(data=out)
|
|
385
|
+
return
|
|
386
|
+
ex = out["explain"]
|
|
387
|
+
console.print(f"[bold]{row.get('ticker')} {row.get('direction')} @ {row.get('price')}[/bold] "
|
|
388
|
+
f"{row.get('strategy_name')} · {str(row.get('signal_time'))[:19]}")
|
|
389
|
+
console.print(f"outcome {ex['outcome']} (listening at signal time: {ex.get('listening_at_signal_time')})")
|
|
390
|
+
for key, val in ex.get("codes", {}).items():
|
|
391
|
+
console.print(f"{key:<14}{val}")
|
|
392
|
+
for key, val in ex.get("copy", {}).items():
|
|
393
|
+
if isinstance(val, dict) and "zh" in val:
|
|
394
|
+
console.print(f"[dim]{key}[/dim] {val['zh']}")
|
|
395
|
+
elif isinstance(val, dict):
|
|
396
|
+
for sub, txt in val.items():
|
|
397
|
+
console.print(f"[dim]{key}.{sub}[/dim] {txt.get('zh') if isinstance(txt, dict) else txt}")
|
|
398
|
+
if ex.get("broker_message"):
|
|
399
|
+
console.print(f"[dim]broker[/dim] {ex['broker_message']}")
|
|
400
|
+
|
|
401
|
+
|
|
369
402
|
@read_app.command("stats", help="Your signal counts and broker success rate.")
|
|
370
403
|
def read_stats(as_json: JsonOpt = False) -> None:
|
|
371
404
|
resp = _call(api_client.get_signal_statistics) or {}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
g = glossary()
|
|
56
|
+
vbr = row.get("viewer_broker_result") or None
|
|
57
|
+
listening = row.get("viewer_was_listening_at_signal_time")
|
|
58
|
+
out: dict[str, Any] = {"listening_at_signal_time": listening, "codes": {}, "copy": {}}
|
|
59
|
+
|
|
60
|
+
if not vbr:
|
|
61
|
+
if listening is False:
|
|
62
|
+
out["outcome"] = "not_listening"
|
|
63
|
+
elif row.get("viewer_no_result_reason") == "no_broker_stale":
|
|
64
|
+
out["outcome"] = "no_broker_report"
|
|
65
|
+
else:
|
|
66
|
+
out["outcome"] = "awaiting"
|
|
67
|
+
out["copy"]["summary"] = _NO_RESULT[out["outcome"]]
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
order_info = vbr.get("order_info") if isinstance(vbr.get("order_info"), dict) else {}
|
|
71
|
+
if vbr.get("success") is True:
|
|
72
|
+
if vbr.get("is_filled"):
|
|
73
|
+
outcome = "filled"
|
|
74
|
+
elif vbr.get("is_cancelled"):
|
|
75
|
+
outcome = "rejected_by_broker" if order_info.get("reject_reason") else "cancelled_unfilled"
|
|
76
|
+
else:
|
|
77
|
+
outcome = "submitted"
|
|
78
|
+
elif vbr.get("skipped") is True:
|
|
79
|
+
outcome = "skipped"
|
|
80
|
+
else:
|
|
81
|
+
outcome = "unconfirmed" if vbr.get("submit_class") == "in_doubt" else "failed"
|
|
82
|
+
out["outcome"] = outcome
|
|
83
|
+
|
|
84
|
+
codes = {k: vbr.get(k) for k in ("error_code", "risk_code", "failure_kind", "submit_class") if vbr.get(k)}
|
|
85
|
+
if order_info.get("reject_reason"):
|
|
86
|
+
codes["reject_reason"] = str(order_info["reject_reason"])[:300]
|
|
87
|
+
out["codes"] = codes
|
|
88
|
+
|
|
89
|
+
copy = out["copy"]
|
|
90
|
+
if codes.get("error_code") in g["CONFIG_SKIP_COPY"]:
|
|
91
|
+
copy["config_skip"] = g["CONFIG_SKIP_COPY"][codes["error_code"]]
|
|
92
|
+
for key in ("risk_code", "error_code"):
|
|
93
|
+
label = _upper_lookup(g["RISK_CODE_LABELS"], codes.get(key))
|
|
94
|
+
if label and "label" not in copy:
|
|
95
|
+
copy["label"] = label
|
|
96
|
+
detail = _upper_lookup(g["RISK_CODE_DETAILS"], codes.get(key))
|
|
97
|
+
if detail:
|
|
98
|
+
copy["detail"] = detail
|
|
99
|
+
kind = codes.get("failure_kind")
|
|
100
|
+
if kind and kind in g["FAILURE_KIND_COPY"]:
|
|
101
|
+
copy["failure_kind"] = g["FAILURE_KIND_COPY"][kind]
|
|
102
|
+
if outcome == "unconfirmed":
|
|
103
|
+
copy["unconfirmed"] = g["UNCONFIRMED_COPY"]
|
|
104
|
+
# The broker / VM's own text: the web app falls back to it when no copy matches.
|
|
105
|
+
raw = vbr.get("error_zh") or vbr.get("error")
|
|
106
|
+
if raw and not copy:
|
|
107
|
+
out["broker_message"] = str(raw)[:500]
|
|
108
|
+
return out
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 从前端的对照表生成 nexusquant_cli/data/signal_codes.json —— 信号执行结果的说明文案只有前端一个来源。
|
|
2
|
+
//
|
|
3
|
+
// node scripts/gen_signal_codes.cjs [<nexus-frontend-platform 路径>] > nexusquant_cli/data/signal_codes.json
|
|
4
|
+
//
|
|
5
|
+
// 用前端自带的 typescript 解析 AST(不执行前端代码);只认 `tr("中文", "English")` 和字符串 `+` 拼接,
|
|
6
|
+
// 别的写法直接报错,免得悄悄生成一份残缺的表。tests/test_signal_codes.py 会重跑这个脚本,和提交的 JSON 比对。
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const fe = path.resolve(process.argv[2] || path.join(__dirname, "..", "..", "nexus-frontend-platform"));
|
|
11
|
+
const ts = require(path.join(fe, "node_modules", "typescript"));
|
|
12
|
+
|
|
13
|
+
const SOURCES = [
|
|
14
|
+
["src/utils/brokerResultLabels.ts", ["RISK_CODE_LABELS", "RISK_CODE_DETAILS", "CONFIG_SKIP_COPY"]],
|
|
15
|
+
["src/components/SignalHistoryModal/index.tsx", ["FAILURE_KIND_COPY", "UNCONFIRMED_COPY"]],
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
let CONSTS = new Map(); // 当前文件顶层 const 名 → 初始化表达式(条目可以引用同文件的另一个常量)
|
|
19
|
+
|
|
20
|
+
function resolve(node, where) {
|
|
21
|
+
if (ts.isIdentifier(node)) {
|
|
22
|
+
const init = CONSTS.get(node.text);
|
|
23
|
+
if (!init) throw new Error(`${where}: unknown identifier ${node.text}`);
|
|
24
|
+
return init;
|
|
25
|
+
}
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function str(node, where) {
|
|
30
|
+
node = resolve(node, where);
|
|
31
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
32
|
+
if (ts.isParenthesizedExpression(node)) return str(node.expression, where);
|
|
33
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken)
|
|
34
|
+
return str(node.left, where) + str(node.right, where);
|
|
35
|
+
throw new Error(`${where}: unsupported string expression ${ts.SyntaxKind[node.kind]}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function value(node, where) {
|
|
39
|
+
node = resolve(node, where);
|
|
40
|
+
if (ts.isCallExpression(node) && node.expression.getText() === "tr" && node.arguments.length === 2)
|
|
41
|
+
return { zh: str(node.arguments[0], where), en: str(node.arguments[1], where) };
|
|
42
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const p of node.properties) {
|
|
45
|
+
if (!ts.isPropertyAssignment(p)) throw new Error(`${where}: unsupported property ${p.getText().slice(0, 40)}`);
|
|
46
|
+
const key = p.name.getText().replace(/^["']|["']$/g, "");
|
|
47
|
+
out[key] = value(p.initializer, `${where}.${key}`);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`${where}: unsupported value ${node.getText().slice(0, 60)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const out = { _source: "nexus-frontend-platform (generated by nexusquant-cli/scripts/gen_signal_codes.cjs)" };
|
|
55
|
+
for (const [rel, names] of SOURCES) {
|
|
56
|
+
const file = path.join(fe, rel);
|
|
57
|
+
const sf = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true,
|
|
58
|
+
rel.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
59
|
+
const want = new Set(names);
|
|
60
|
+
CONSTS = new Map();
|
|
61
|
+
for (const st of sf.statements) {
|
|
62
|
+
if (ts.isVariableStatement(st))
|
|
63
|
+
for (const d of st.declarationList.declarations)
|
|
64
|
+
if (ts.isIdentifier(d.name) && d.initializer) CONSTS.set(d.name.text, d.initializer);
|
|
65
|
+
}
|
|
66
|
+
ts.forEachChild(sf, function visit(n) {
|
|
67
|
+
if (ts.isVariableDeclaration(n) && want.has(n.name.getText()) && n.initializer) {
|
|
68
|
+
out[n.name.getText()] = value(n.initializer, n.name.getText());
|
|
69
|
+
want.delete(n.name.getText());
|
|
70
|
+
}
|
|
71
|
+
ts.forEachChild(n, visit);
|
|
72
|
+
});
|
|
73
|
+
if (want.size) throw new Error(`${rel}: not found: ${[...want].join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
@@ -446,7 +446,8 @@ def test_embedded_rejects_writes_before_reading_a_token(monkeypatch) -> None:
|
|
|
446
446
|
"close": lambda self: None})())
|
|
447
447
|
for argv in (["investor", "subscribe", "strategy", "x"], ["investor", "update", "settings"],
|
|
448
448
|
["investor", "delete", "agent-session", "ws-1"], ["provider", "list", "strategies"],
|
|
449
|
-
["admin", "
|
|
449
|
+
["admin", "review", "strategy", "x", "--approve"], ["admin", "grant", "agent-access"],
|
|
450
|
+
["investor"]): # admin 读(list/read)现在允许,见 test_signal_explain.py::test_embedded_admin_gate
|
|
450
451
|
assert embedded.main(argv) == 2, argv
|
|
451
452
|
assert read == [] # 拒绝发生在读 token 之前
|
|
452
453
|
|