nexus-agent-cli 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nexus-agent-cli
3
- Version: 0.2.0
3
+ Version: 0.6.0
4
4
  Summary: Nexus internal agent ops CLI — pull run params, push analysis results, send notifications. Admin only.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.27
@@ -61,6 +61,26 @@ nexus-agent push-backtest --ticker tqqq \
61
61
  --conservative conservative_aggregated.json \
62
62
  --normal normal_aggregated.json
63
63
 
64
+ # 回读 S3(均为只读,登录即可):最新回测 / 市场分析报告 / 组合推荐
65
+ nexus-agent get-backtest --ticker tqqq --out tqqq_backtest.json
66
+ nexus-agent get-market-analysis --ticker tqqq --out tqqq_market.json
67
+ nexus-agent get-portfolio --out portfolio.json
68
+
69
+ # 拉取 S3 上最新的 signal_position 基线 config(Position-Research 阶段用)
70
+ nexus-agent pull-config --ticker tqqq --kind normal --out tqqq-normal.json
71
+
72
+ # 历史版本(by-date):列出 → 取指定版本
73
+ nexus-agent pull-config --ticker tqqq --kind normal --list --since 2026-06-01
74
+ nexus-agent pull-config --ticker tqqq --kind normal --date 2026-06-01_to_2026-06-14 --out old.json
75
+ nexus-agent get-market-analysis --ticker tqqq --list
76
+ nexus-agent get-portfolio --list --since 2026-06-01
77
+
78
+ # 回传 Position-Research 产出的 3 套仓位 config(≠ push-backtest 的回测聚合)
79
+ nexus-agent push-config --ticker tqqq --time-range 2026-06-01_2026-06-14 \
80
+ --aggressive tqqq-aggressive.json \
81
+ --conservative tqqq-conservative.json \
82
+ --normal tqqq-normal.json
83
+
64
84
  # 服务端 SES 发通知邮件
65
85
  nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-file summary.txt
66
86
  ```
@@ -73,7 +93,7 @@ nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-fi
73
93
  | `NEXUS_AGENT_API_BASE_URL` | — | 直接覆盖完整 API base(含 `/api`) |
74
94
  | `NEXUS_AGENT_COGNITO_DOMAIN` | `auth.lookatwallstreet.com` | Cognito Hosted UI 域名 |
75
95
  | `NEXUS_AGENT_COGNITO_CLIENT_ID` | (内置) | Cognito app client id |
76
- | `NEXUS_AGENT_REDIRECT_URI` | `http://127.0.0.1:8252/callback` | 本地回调(须 localhost+端口) |
96
+ | `NEXUS_AGENT_REDIRECT_URI` | `http://127.0.0.1:8251/callback` | 本地回调(须 localhost+端口;端口必须已加入 Cognito app client 的 Allowed callback URLs) |
77
97
 
78
98
  例如指向本地后端调试:
79
99
 
@@ -50,6 +50,26 @@ nexus-agent push-backtest --ticker tqqq \
50
50
  --conservative conservative_aggregated.json \
51
51
  --normal normal_aggregated.json
52
52
 
53
+ # 回读 S3(均为只读,登录即可):最新回测 / 市场分析报告 / 组合推荐
54
+ nexus-agent get-backtest --ticker tqqq --out tqqq_backtest.json
55
+ nexus-agent get-market-analysis --ticker tqqq --out tqqq_market.json
56
+ nexus-agent get-portfolio --out portfolio.json
57
+
58
+ # 拉取 S3 上最新的 signal_position 基线 config(Position-Research 阶段用)
59
+ nexus-agent pull-config --ticker tqqq --kind normal --out tqqq-normal.json
60
+
61
+ # 历史版本(by-date):列出 → 取指定版本
62
+ nexus-agent pull-config --ticker tqqq --kind normal --list --since 2026-06-01
63
+ nexus-agent pull-config --ticker tqqq --kind normal --date 2026-06-01_to_2026-06-14 --out old.json
64
+ nexus-agent get-market-analysis --ticker tqqq --list
65
+ nexus-agent get-portfolio --list --since 2026-06-01
66
+
67
+ # 回传 Position-Research 产出的 3 套仓位 config(≠ push-backtest 的回测聚合)
68
+ nexus-agent push-config --ticker tqqq --time-range 2026-06-01_2026-06-14 \
69
+ --aggressive tqqq-aggressive.json \
70
+ --conservative tqqq-conservative.json \
71
+ --normal tqqq-normal.json
72
+
53
73
  # 服务端 SES 发通知邮件
54
74
  nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-file summary.txt
55
75
  ```
@@ -62,7 +82,7 @@ nexus-agent notify --to a@x.com --to b@y.com --subject "Weekly report" --body-fi
62
82
  | `NEXUS_AGENT_API_BASE_URL` | — | 直接覆盖完整 API base(含 `/api`) |
63
83
  | `NEXUS_AGENT_COGNITO_DOMAIN` | `auth.lookatwallstreet.com` | Cognito Hosted UI 域名 |
64
84
  | `NEXUS_AGENT_COGNITO_CLIENT_ID` | (内置) | Cognito app client id |
65
- | `NEXUS_AGENT_REDIRECT_URI` | `http://127.0.0.1:8252/callback` | 本地回调(须 localhost+端口) |
85
+ | `NEXUS_AGENT_REDIRECT_URI` | `http://127.0.0.1:8251/callback` | 本地回调(须 localhost+端口;端口必须已加入 Cognito app client 的 Allowed callback URLs) |
66
86
 
67
87
  例如指向本地后端调试:
68
88
 
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -60,6 +60,21 @@ def get_strategy_data(
60
60
  return get_json("/nexus-agent/strategy-data/", params=params)
61
61
 
62
62
 
63
+ def get_backtest(ticker: str) -> Any:
64
+ """GET /nexus-agent/backtest/ — latest aggregated backtest data for one ticker."""
65
+ return get_json("/nexus-agent/backtest/", params={"ticker": ticker})
66
+
67
+
68
+ def get_market_analysis(ticker: str) -> Any:
69
+ """GET /nexus-agent/market-analysis/ — latest market analysis report for one ticker."""
70
+ return get_json("/nexus-agent/market-analysis/", params={"ticker": ticker})
71
+
72
+
73
+ def get_portfolio_recommendation() -> Any:
74
+ """GET /nexus-agent/portfolio-recommendation/ — latest portfolio recommendation."""
75
+ return get_json("/nexus-agent/portfolio-recommendation/")
76
+
77
+
63
78
  def upload_market_analysis(ticker: str, content: str, language: str = "zh") -> Any:
64
79
  """POST /nexus-agent/upload/market-analysis/"""
65
80
  return post_json(
@@ -85,6 +100,63 @@ def upload_backtest(ticker: str, levels: dict[str, Any]) -> Any:
85
100
  return post_json("/nexus-agent/upload/backtest/", {"ticker": ticker, **levels})
86
101
 
87
102
 
103
+ def get_position_config(
104
+ ticker: str, kind: str = "normal", date_range: str | None = None
105
+ ) -> Any:
106
+ """GET /nexus-agent/position-config/ — signal_position config from S3.
107
+
108
+ Mirrors the worker's download_latest_normal_config_from_s3: the server reads
109
+ configs/signal_position/{ticker}/<date_range>/{ticker}-{kind}.json and returns it
110
+ as JSON. With date_range, fetch that specific historical version; otherwise the
111
+ latest. This is the Position-Research baseline the local skill cannot otherwise read.
112
+ """
113
+ params: dict[str, Any] = {"ticker": ticker, "kind": kind}
114
+ if date_range:
115
+ params["date_range"] = date_range
116
+ return get_json("/nexus-agent/position-config/", params=params)
117
+
118
+
119
+ def list_history(
120
+ artifact_type: str, ticker: str | None = None, kind: str | None = None, since: str | None = None
121
+ ) -> Any:
122
+ """GET /nexus-agent/history/ — list historical versions of an artifact.
123
+
124
+ artifact_type: config / market-analysis / portfolio.
125
+ Returns {type, count, items: [{key, created_at, date_range?}, ...]} newest-first.
126
+ """
127
+ params: dict[str, Any] = {"type": artifact_type}
128
+ if ticker:
129
+ params["ticker"] = ticker
130
+ if kind:
131
+ params["kind"] = kind
132
+ if since:
133
+ params["since"] = since
134
+ return get_json("/nexus-agent/history/", params=params)
135
+
136
+
137
+ def upload_position_config(
138
+ ticker: str, configs: dict[str, Any], time_range: str | None = None
139
+ ) -> Any:
140
+ """POST /nexus-agent/upload/position-config/ — write the 3 position configs to S3.
141
+
142
+ configs: {aggressive/conservative/normal: {<level>: {...}}}. Distinct from
143
+ upload_backtest (which uploads backtest aggregation, not position configs).
144
+ Server writes configs/signal_position/{ticker}/{time_range}/{ticker}-{kind}.json
145
+ and invalidates the dashboard cache.
146
+ """
147
+ body: dict[str, Any] = {"ticker": ticker, **configs}
148
+ if time_range is not None:
149
+ body["time_range"] = time_range
150
+ return post_json("/nexus-agent/upload/position-config/", body)
151
+
152
+
153
+ def upload_position_report(ticker: str, content: str) -> Any:
154
+ """POST /nexus-agent/upload/position-report/ — archive a position-update narrative (md)."""
155
+ return post_json(
156
+ "/nexus-agent/upload/position-report/", {"ticker": ticker, "content": content}
157
+ )
158
+
159
+
88
160
  def notify(
89
161
  to: list[str], subject: str, body_text: str | None = None, body_html: str | None = None
90
162
  ) -> Any:
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.json import JSON
9
+
10
+ from nexus_agent_cli import api_client
11
+
12
+ console = Console()
13
+
14
+
15
+ def _run_err(e: Exception) -> None:
16
+ console.print(f"[red]{e}[/red]")
17
+ raise typer.Exit(1) from e
18
+
19
+
20
+ def register(app: typer.Typer) -> None:
21
+ @app.command("run-config", help="Pull this week's run params (tickers + date range).")
22
+ def run_config(
23
+ tickers: str = typer.Option(
24
+ None, "--tickers", help="Comma-separated override, e.g. TQQQ,SOXL,IBIT."
25
+ ),
26
+ ) -> None:
27
+ try:
28
+ data = api_client.get_run_config(tickers=tickers)
29
+ except Exception as e:
30
+ _run_err(e)
31
+ console.print(JSON(json.dumps(data)))
32
+
33
+ @app.command(
34
+ "strategy-data",
35
+ help="Pull strategy trigger rows for one ticker (Sheets creds stay server-side).",
36
+ )
37
+ def strategy_data(
38
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
39
+ start: str = typer.Option(None, "--start", help="Start date YYYY-MM-DD (optional)."),
40
+ end: str = typer.Option(None, "--end", help="End date YYYY-MM-DD (optional)."),
41
+ out: Path = typer.Option(
42
+ None, "--out", help="Write the JSON response to this file instead of stdout."
43
+ ),
44
+ ) -> None:
45
+ try:
46
+ data = api_client.get_strategy_data(ticker, start_date=start, end_date=end)
47
+ except Exception as e:
48
+ _run_err(e)
49
+ if out is not None:
50
+ out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
51
+ console.print(f"[green]已保存 {data.get('count')} 行 →[/green] {out}")
52
+ else:
53
+ console.print(JSON(json.dumps(data)))
54
+
55
+ @app.command(
56
+ "push-market-analysis", help="Upload a market analysis markdown report for one ticker."
57
+ )
58
+ def push_market_analysis(
59
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
60
+ file: Path = typer.Option(
61
+ ..., "--file", exists=True, readable=True, help="Path to the markdown report."
62
+ ),
63
+ language: str = typer.Option("zh", "--language", help="zh (default) or en."),
64
+ ) -> None:
65
+ try:
66
+ content = file.read_text(encoding="utf-8")
67
+ data = api_client.upload_market_analysis(ticker, content, language=language)
68
+ except Exception as e:
69
+ _run_err(e)
70
+ console.print(f"[green]已上传:[/green] {data.get('key')}")
71
+
72
+ @app.command(
73
+ "push-portfolio", help="Upload a portfolio recommendation report (English + optional CN)."
74
+ )
75
+ def push_portfolio(
76
+ file: Path = typer.Option(
77
+ ..., "--file", exists=True, readable=True, help="English markdown report."
78
+ ),
79
+ file_cn: Path = typer.Option(
80
+ None, "--file-cn", exists=True, readable=True, help="Chinese markdown report (optional)."
81
+ ),
82
+ time_range: str = typer.Option(
83
+ None, "--time-range", help="e.g. 2026-06-01_2026-06-14 (optional, for the S3 key)."
84
+ ),
85
+ ) -> None:
86
+ try:
87
+ content = file.read_text(encoding="utf-8")
88
+ content_cn = file_cn.read_text(encoding="utf-8") if file_cn else None
89
+ data = api_client.upload_portfolio_recommendation(
90
+ content, content_cn=content_cn, time_range=time_range
91
+ )
92
+ except Exception as e:
93
+ _run_err(e)
94
+ console.print(JSON(json.dumps(data)))
95
+
96
+ @app.command("push-backtest", help="Upload aggregated backtest JSON for one ticker.")
97
+ def push_backtest(
98
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
99
+ aggressive: Path = typer.Option(
100
+ None, "--aggressive", exists=True, readable=True, help="aggressive_aggregated.json"
101
+ ),
102
+ conservative: Path = typer.Option(
103
+ None, "--conservative", exists=True, readable=True, help="conservative_aggregated.json"
104
+ ),
105
+ normal: Path = typer.Option(
106
+ None, "--normal", exists=True, readable=True, help="normal_aggregated.json"
107
+ ),
108
+ ) -> None:
109
+ levels: dict[str, object] = {}
110
+ for name, path in (
111
+ ("aggressive", aggressive),
112
+ ("conservative", conservative),
113
+ ("normal", normal),
114
+ ):
115
+ if path is not None:
116
+ try:
117
+ levels[name] = json.loads(path.read_text(encoding="utf-8"))
118
+ except Exception as e:
119
+ _run_err(e)
120
+ if not levels:
121
+ console.print("[red]至少提供一个档位文件: --aggressive / --conservative / --normal[/red]")
122
+ raise typer.Exit(1)
123
+ try:
124
+ data = api_client.upload_backtest(ticker, levels)
125
+ except Exception as e:
126
+ _run_err(e)
127
+ console.print(JSON(json.dumps(data)))
128
+
129
+ @app.command(
130
+ "pull-config",
131
+ help="Pull the latest signal_position config for one ticker from S3 (the "
132
+ "Position-Research baseline; S3 creds stay server-side).",
133
+ )
134
+ def pull_config(
135
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
136
+ kind: str = typer.Option(
137
+ "normal", "--kind", help="normal (default) / aggressive / conservative."
138
+ ),
139
+ list_versions: bool = typer.Option(
140
+ False, "--list", help="List historical config versions instead of fetching one."
141
+ ),
142
+ date_range: str = typer.Option(
143
+ None, "--date", help="Fetch a specific version by its date_range (from --list)."
144
+ ),
145
+ since: str = typer.Option(
146
+ None, "--since", help="With --list: only versions created on/after this ISO date."
147
+ ),
148
+ out: Path = typer.Option(
149
+ None, "--out", help="Write JSON to this file instead of stdout."
150
+ ),
151
+ ) -> None:
152
+ try:
153
+ if list_versions:
154
+ data = api_client.list_history("config", ticker=ticker, kind=kind, since=since)
155
+ else:
156
+ data = api_client.get_position_config(ticker, kind=kind, date_range=date_range)
157
+ except Exception as e:
158
+ _run_err(e)
159
+ _emit(data, out)
160
+
161
+ def _emit(data: object, out) -> None:
162
+ if out is not None:
163
+ out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
164
+ console.print(f"[green]已保存 →[/green] {out}")
165
+ else:
166
+ console.print(JSON(json.dumps(data)))
167
+
168
+ @app.command(
169
+ "get-backtest", help="Fetch the latest aggregated backtest data for one ticker from S3."
170
+ )
171
+ def get_backtest(
172
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
173
+ out: Path = typer.Option(None, "--out", help="Write JSON to this file instead of stdout."),
174
+ ) -> None:
175
+ try:
176
+ data = api_client.get_backtest(ticker)
177
+ except Exception as e:
178
+ _run_err(e)
179
+ _emit(data, out)
180
+
181
+ @app.command(
182
+ "get-market-analysis",
183
+ help="Fetch the latest market analysis report for one ticker from S3.",
184
+ )
185
+ def get_market_analysis(
186
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
187
+ list_versions: bool = typer.Option(
188
+ False, "--list", help="List historical report versions instead of fetching the latest."
189
+ ),
190
+ since: str = typer.Option(
191
+ None, "--since", help="With --list: only versions created on/after this ISO date."
192
+ ),
193
+ out: Path = typer.Option(None, "--out", help="Write JSON to this file instead of stdout."),
194
+ ) -> None:
195
+ try:
196
+ if list_versions:
197
+ data = api_client.list_history("market-analysis", ticker=ticker, since=since)
198
+ else:
199
+ data = api_client.get_market_analysis(ticker)
200
+ except Exception as e:
201
+ _run_err(e)
202
+ _emit(data, out)
203
+
204
+ @app.command(
205
+ "get-portfolio", help="Fetch the latest portfolio recommendation from S3."
206
+ )
207
+ def get_portfolio(
208
+ list_versions: bool = typer.Option(
209
+ False, "--list", help="List historical portfolio versions instead of the latest."
210
+ ),
211
+ since: str = typer.Option(
212
+ None, "--since", help="With --list: only versions created on/after this ISO date."
213
+ ),
214
+ out: Path = typer.Option(None, "--out", help="Write JSON to this file instead of stdout."),
215
+ ) -> None:
216
+ try:
217
+ if list_versions:
218
+ data = api_client.list_history("portfolio", since=since)
219
+ else:
220
+ data = api_client.get_portfolio_recommendation()
221
+ except Exception as e:
222
+ _run_err(e)
223
+ _emit(data, out)
224
+
225
+ @app.command(
226
+ "push-config",
227
+ help="Upload the 3 Position-Research configs for one ticker "
228
+ "(distinct from push-backtest, which uploads backtest aggregation).",
229
+ )
230
+ def push_config(
231
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
232
+ aggressive: Path = typer.Option(
233
+ None, "--aggressive", exists=True, readable=True, help="{ticker}-aggressive.json"
234
+ ),
235
+ conservative: Path = typer.Option(
236
+ None, "--conservative", exists=True, readable=True, help="{ticker}-conservative.json"
237
+ ),
238
+ normal: Path = typer.Option(
239
+ None, "--normal", exists=True, readable=True, help="{ticker}-normal.json"
240
+ ),
241
+ time_range: str = typer.Option(
242
+ None, "--time-range", help="e.g. 2026-06-01_2026-06-14 (for the S3 key)."
243
+ ),
244
+ ) -> None:
245
+ configs: dict[str, object] = {}
246
+ for name, path in (
247
+ ("aggressive", aggressive),
248
+ ("conservative", conservative),
249
+ ("normal", normal),
250
+ ):
251
+ if path is not None:
252
+ try:
253
+ configs[name] = json.loads(path.read_text(encoding="utf-8"))
254
+ except Exception as e:
255
+ _run_err(e)
256
+ if not configs:
257
+ console.print("[red]至少提供一个档位文件: --aggressive / --conservative / --normal[/red]")
258
+ raise typer.Exit(1)
259
+ try:
260
+ data = api_client.upload_position_config(ticker, configs, time_range=time_range)
261
+ except Exception as e:
262
+ _run_err(e)
263
+ console.print(JSON(json.dumps(data)))
264
+
265
+ @app.command(
266
+ "push-position-report",
267
+ help="Archive a position-update narrative markdown (reports/position_updates/).",
268
+ )
269
+ def push_position_report(
270
+ ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
271
+ file: Path = typer.Option(
272
+ ..., "--file", exists=True, readable=True, help="Path to the markdown report."
273
+ ),
274
+ ) -> None:
275
+ try:
276
+ data = api_client.upload_position_report(ticker, file.read_text(encoding="utf-8"))
277
+ except Exception as e:
278
+ _run_err(e)
279
+ console.print(f"[green]已上传:[/green] {data.get('key')}")
280
+
281
+ @app.command("notify", help="Send a notification email via server-side SES.")
282
+ def notify(
283
+ to: list[str] = typer.Option(..., "--to", help="Recipient (repeatable)."),
284
+ subject: str = typer.Option(..., "--subject", help="Email subject."),
285
+ body_file: Path = typer.Option(
286
+ None, "--body-file", exists=True, readable=True, help="Plain-text body file."
287
+ ),
288
+ body: str = typer.Option(None, "--body", help="Inline plain-text body."),
289
+ ) -> None:
290
+ body_text = body
291
+ if body_file is not None:
292
+ body_text = body_file.read_text(encoding="utf-8")
293
+ if not body_text:
294
+ console.print("[red]需要 --body 或 --body-file 之一。[/red]")
295
+ raise typer.Exit(1)
296
+ try:
297
+ data = api_client.notify(list(to), subject, body_text=body_text)
298
+ except Exception as e:
299
+ _run_err(e)
300
+ console.print(f"[green]已发送:[/green] {data}")
@@ -15,8 +15,11 @@ CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
15
15
  DEFAULT_API_ENDPOINT = "https://api.nexusquant.co"
16
16
  DEFAULT_COGNITO_DOMAIN = "auth.lookatwallstreet.com"
17
17
  DEFAULT_COGNITO_CLIENT_ID = "5o7hhd4hn4birr8c29vndehiat"
18
- # 与 nexusquant-cli (8251) 错开端口,便于两套 CLI 同机登录。
19
- DEFAULT_REDIRECT_URI = "http://127.0.0.1:8252/callback"
18
+ # 与 nexusquant-cli 共用同一个 Cognito app client(5o7hhd4hn4birr8c29vndehiat),
19
+ # 该 client 只登记了 8251/callback。沿用 8251 以避免 redirect_mismatch;
20
+ # 两套 CLI 不会同时跑 OAuth 回调,端口冲突可忽略。需要错开时用 NEXUS_AGENT_REDIRECT_URI 覆盖
21
+ # (但所选端口必须已加入 app client 的 Allowed callback URLs)。
22
+ DEFAULT_REDIRECT_URI = "http://127.0.0.1:8251/callback"
20
23
 
21
24
 
22
25
  def _env(name: str, default: str | None = None) -> str | None:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexus-agent-cli"
7
- version = "0.2.0"
7
+ version = "0.6.0"
8
8
  description = "Nexus internal agent ops CLI — pull run params, push analysis results, send notifications. Admin only."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,148 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from pathlib import Path
5
-
6
- import typer
7
- from rich.console import Console
8
- from rich.json import JSON
9
-
10
- from nexus_agent_cli import api_client
11
-
12
- console = Console()
13
-
14
-
15
- def _run_err(e: Exception) -> None:
16
- console.print(f"[red]{e}[/red]")
17
- raise typer.Exit(1) from e
18
-
19
-
20
- def register(app: typer.Typer) -> None:
21
- @app.command("run-config", help="Pull this week's run params (tickers + date range).")
22
- def run_config(
23
- tickers: str = typer.Option(
24
- None, "--tickers", help="Comma-separated override, e.g. TQQQ,SOXL,IBIT."
25
- ),
26
- ) -> None:
27
- try:
28
- data = api_client.get_run_config(tickers=tickers)
29
- except Exception as e:
30
- _run_err(e)
31
- console.print(JSON(json.dumps(data)))
32
-
33
- @app.command(
34
- "strategy-data",
35
- help="Pull strategy trigger rows for one ticker (Sheets creds stay server-side).",
36
- )
37
- def strategy_data(
38
- ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
39
- start: str = typer.Option(None, "--start", help="Start date YYYY-MM-DD (optional)."),
40
- end: str = typer.Option(None, "--end", help="End date YYYY-MM-DD (optional)."),
41
- out: Path = typer.Option(
42
- None, "--out", help="Write the JSON response to this file instead of stdout."
43
- ),
44
- ) -> None:
45
- try:
46
- data = api_client.get_strategy_data(ticker, start_date=start, end_date=end)
47
- except Exception as e:
48
- _run_err(e)
49
- if out is not None:
50
- out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
51
- console.print(f"[green]已保存 {data.get('count')} 行 →[/green] {out}")
52
- else:
53
- console.print(JSON(json.dumps(data)))
54
-
55
- @app.command(
56
- "push-market-analysis", help="Upload a market analysis markdown report for one ticker."
57
- )
58
- def push_market_analysis(
59
- ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
60
- file: Path = typer.Option(
61
- ..., "--file", exists=True, readable=True, help="Path to the markdown report."
62
- ),
63
- language: str = typer.Option("zh", "--language", help="zh (default) or en."),
64
- ) -> None:
65
- try:
66
- content = file.read_text(encoding="utf-8")
67
- data = api_client.upload_market_analysis(ticker, content, language=language)
68
- except Exception as e:
69
- _run_err(e)
70
- console.print(f"[green]已上传:[/green] {data.get('key')}")
71
-
72
- @app.command(
73
- "push-portfolio", help="Upload a portfolio recommendation report (English + optional CN)."
74
- )
75
- def push_portfolio(
76
- file: Path = typer.Option(
77
- ..., "--file", exists=True, readable=True, help="English markdown report."
78
- ),
79
- file_cn: Path = typer.Option(
80
- None, "--file-cn", exists=True, readable=True, help="Chinese markdown report (optional)."
81
- ),
82
- time_range: str = typer.Option(
83
- None, "--time-range", help="e.g. 2026-06-01_2026-06-14 (optional, for the S3 key)."
84
- ),
85
- ) -> None:
86
- try:
87
- content = file.read_text(encoding="utf-8")
88
- content_cn = file_cn.read_text(encoding="utf-8") if file_cn else None
89
- data = api_client.upload_portfolio_recommendation(
90
- content, content_cn=content_cn, time_range=time_range
91
- )
92
- except Exception as e:
93
- _run_err(e)
94
- console.print(JSON(json.dumps(data)))
95
-
96
- @app.command("push-backtest", help="Upload aggregated backtest JSON for one ticker.")
97
- def push_backtest(
98
- ticker: str = typer.Option(..., "--ticker", help="tqqq / soxl / ibit."),
99
- aggressive: Path = typer.Option(
100
- None, "--aggressive", exists=True, readable=True, help="aggressive_aggregated.json"
101
- ),
102
- conservative: Path = typer.Option(
103
- None, "--conservative", exists=True, readable=True, help="conservative_aggregated.json"
104
- ),
105
- normal: Path = typer.Option(
106
- None, "--normal", exists=True, readable=True, help="normal_aggregated.json"
107
- ),
108
- ) -> None:
109
- levels: dict[str, object] = {}
110
- for name, path in (
111
- ("aggressive", aggressive),
112
- ("conservative", conservative),
113
- ("normal", normal),
114
- ):
115
- if path is not None:
116
- try:
117
- levels[name] = json.loads(path.read_text(encoding="utf-8"))
118
- except Exception as e:
119
- _run_err(e)
120
- if not levels:
121
- console.print("[red]至少提供一个档位文件: --aggressive / --conservative / --normal[/red]")
122
- raise typer.Exit(1)
123
- try:
124
- data = api_client.upload_backtest(ticker, levels)
125
- except Exception as e:
126
- _run_err(e)
127
- console.print(JSON(json.dumps(data)))
128
-
129
- @app.command("notify", help="Send a notification email via server-side SES.")
130
- def notify(
131
- to: list[str] = typer.Option(..., "--to", help="Recipient (repeatable)."),
132
- subject: str = typer.Option(..., "--subject", help="Email subject."),
133
- body_file: Path = typer.Option(
134
- None, "--body-file", exists=True, readable=True, help="Plain-text body file."
135
- ),
136
- body: str = typer.Option(None, "--body", help="Inline plain-text body."),
137
- ) -> None:
138
- body_text = body
139
- if body_file is not None:
140
- body_text = body_file.read_text(encoding="utf-8")
141
- if not body_text:
142
- console.print("[red]需要 --body 或 --body-file 之一。[/red]")
143
- raise typer.Exit(1)
144
- try:
145
- data = api_client.notify(list(to), subject, body_text=body_text)
146
- except Exception as e:
147
- _run_err(e)
148
- console.print(f"[green]已发送:[/green] {data}")