-
Notifications
You must be signed in to change notification settings - Fork 930
Expand file tree
/
Copy pathrun.py
More file actions
752 lines (661 loc) · 33.9 KB
/
Copy pathrun.py
File metadata and controls
752 lines (661 loc) · 33.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
#!/usr/bin/env python3
"""UZI-Skill 一键运行入口 — 适用于 Claude Code / Codex / Cursor / 命令行 / 任何 agent。
用法:
python run.py 002273.SZ # 本地分析,浏览器打开
python run.py 600519.SH --remote # 分析完用 Cloudflare Tunnel 映射公网
python run.py AAPL --no-browser # 不打开浏览器(Codex/CI 环境)
python run.py 贵州茅台 --remote # 中文名 + 远程查看
参数:
第一个参数: 股票代码或中文名
--remote 分析完后启动 HTTP 服务 + Cloudflare Tunnel,生成公网链接
--no-browser 不自动打开浏览器(适合无 GUI 的服务器/Codex 环境)
--port PORT HTTP 服务端口(默认 8976)
运行完会输出:
1. HTML 报告本地路径
2. 如果 --remote: 一个 https://xxx.trycloudflare.com 公网链接
"""
from __future__ import annotations # v2.6 · Python 3.9 兼容(默认 macOS python3)
import os
import sys
import argparse
import subprocess
import shutil
import threading
import time
from pathlib import Path
from http.server import HTTPServer, SimpleHTTPRequestHandler
# ─── 编码修复 ───
if sys.platform == "win32":
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# ─── 路径设置(v3.3.1 · 兼容 Hermes layout)───
# run.py 可能出现在两个位置:
# 1) repo 根目录(Claude Code / Codex / Cursor / dev):SCRIPTS_DIR = ROOT/skills/deep-analysis/scripts
# 2) skill 目录里(Hermes `hermes skills install` 后只拉子目录):SCRIPTS_DIR = ROOT/scripts
# 探测两种 layout · 不破坏现有行为
ROOT_DIR = Path(__file__).parent.resolve()
_layout_candidates = [
ROOT_DIR / "skills" / "deep-analysis" / "scripts", # repo root layout
ROOT_DIR / "scripts", # Hermes skill-dir layout
]
SCRIPTS_DIR = next((c for c in _layout_candidates if c.exists()), _layout_candidates[0])
sys.path.insert(0, str(SCRIPTS_DIR))
os.chdir(str(SCRIPTS_DIR))
# ─── .env 加载(v2.3,零依赖,不覆盖已存在的 shell env)──
def _load_dotenv():
"""Load KEY=VALUE pairs from $REPO/.env into os.environ.
Deliberately simple (no quoting games, no variable interpolation) — enough
to pick up MX_APIKEY and friends. Existing shell env vars take precedence,
so `export MX_APIKEY=...` always wins.
"""
env_path = ROOT_DIR / ".env"
if not env_path.exists():
return
try:
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip("'\"") # strip optional quotes
if key and key not in os.environ:
os.environ[key] = val
except Exception as e:
print(f"⚠️ .env 读取失败(忽略): {e}")
_load_dotenv()
def _get_version() -> str:
"""v2.6 · Read version from .claude-plugin/plugin.json so banner stays in sync."""
try:
import json
manifest = ROOT_DIR / ".claude-plugin" / "plugin.json"
if manifest.exists():
return json.loads(manifest.read_text(encoding="utf-8")).get("version", "?")
except Exception:
pass
return "?"
def detect_environment() -> dict:
"""检测当前运行环境。"""
env = {
"has_browser": True,
"has_cloudflared": shutil.which("cloudflared") is not None,
"is_codex": os.environ.get("CODEX") == "1" or os.environ.get("OPENAI_API_KEY") is not None,
"is_ci": os.environ.get("CI") is not None,
"is_docker": Path("/.dockerenv").exists(),
"is_ssh": "SSH_CONNECTION" in os.environ,
"platform": sys.platform,
}
# 无 GUI 环境自动 no-browser
if env["is_codex"] or env["is_ci"] or env["is_docker"] or env["is_ssh"]:
env["has_browser"] = False
# Linux 无 DISPLAY 也不开浏览器
if sys.platform == "linux" and not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
env["has_browser"] = False
return env
# 国内 pypi 镜像按速度排序(清华通常最快;阿里云在清华故障时兜底)
PYPI_MIRRORS = [
("清华大学", "https://pypi.tuna.tsinghua.edu.cn/simple"),
("阿里云", "https://mirrors.aliyun.com/pypi/simple/"),
("中科大", "https://pypi.mirrors.ustc.edu.cn/simple/"),
("豆瓣", "https://pypi.douban.com/simple/"),
]
def _pip_install(args: list, index_url: str | None = None) -> int:
"""Run pip install; return exit code. `index_url=None` means use default pypi."""
cmd = [sys.executable, "-m", "pip", "install"] + args + ["--quiet"]
if index_url:
cmd += ["--index-url", index_url, "--trusted-host", index_url.split("/")[2]]
return subprocess.run(cmd, check=False).returncode
def check_dependencies():
"""检查并安装缺失依赖。pypi 访问不通时自动切国内镜像重试(支持中国大陆网络环境)。"""
required = ["akshare", "requests"]
missing = []
for pkg in required:
try:
__import__(pkg)
except ImportError:
missing.append(pkg)
if not missing:
return
print(f"⚠️ 缺少依赖: {', '.join(missing)}")
req_file = ROOT_DIR / "requirements.txt"
args = ["-r", str(req_file)] if req_file.exists() else missing
# 第一次尝试:默认 pypi(海外/Codex/美国网络最快)
print(f" [1/{len(PYPI_MIRRORS) + 1}] 尝试默认 pypi.org ...")
if _pip_install(args) == 0:
print(" ✓ 依赖安装完成\n")
return
# 失败后:自动切国内镜像(通常是大陆网络环境)
print(f" ⚠️ 默认 pypi 安装失败(可能因网络受限),尝试国内镜像...")
for i, (name, url) in enumerate(PYPI_MIRRORS, start=2):
print(f" [{i}/{len(PYPI_MIRRORS) + 1}] 尝试 {name} 镜像 ({url}) ...")
if _pip_install(args, index_url=url) == 0:
print(f" ✓ 依赖安装完成(via {name})\n")
return
print(f" ❌ 所有镜像都失败了。请手动安装:")
print(f" pip install -r requirements.txt \\")
print(f" -i https://pypi.tuna.tsinghua.edu.cn/simple")
print(f" 或参考 README.md 的\"网络受限环境\"章节\n")
def serve_report(report_path: Path, port: int = 8976) -> HTTPServer:
"""启动 HTTP 服务器托管报告目录。"""
if not (1 <= port <= 65535):
raise ValueError(f"Invalid HTTP port: {port}")
report_dir = report_path.parent
os.chdir(str(report_dir))
handler = SimpleHTTPRequestHandler
httpd = HTTPServer(("127.0.0.1", port), handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
filename = report_path.name
print(f"\n📡 本地 HTTP 服务已启动:")
print(f" http://localhost:{port}/{filename}")
return httpd
def start_cloudflare_tunnel(port: int = 8976, *, install: bool = False):
"""启动 Cloudflare Tunnel,返回公网 URL 和进程。"""
if not (1 <= port <= 65535):
raise ValueError(f"Invalid HTTP port: {port}")
if not shutil.which("cloudflared"):
print("\n⚠️ 未检测到 cloudflared。")
if not install:
print(" 未执行自动安装;如需自动安装,请加 --install-cloudflared 后重试。")
print(" 手动安装: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/")
return None, None
print(" --install-cloudflared 已开启,正在尝试安装...")
if sys.platform == "win32":
print(" 请手动安装: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/")
print(" 或: winget install Cloudflare.cloudflared")
return None, None
elif sys.platform == "darwin":
subprocess.run(["brew", "install", "cloudflared"], check=False)
else:
# Linux
subprocess.run(["bash", "-c",
"curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared && chmod +x /tmp/cloudflared && sudo mv /tmp/cloudflared /usr/local/bin/"],
check=False)
if not shutil.which("cloudflared"):
print(" ❌ cloudflared 安装失败,跳过远程映射")
return None, None
print(" ✓ cloudflared 安装成功")
print(f"\n🌐 正在启动 Cloudflare Tunnel (端口 {port})...")
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://127.0.0.1:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
# 从 stderr 里抓公网 URL(cloudflared 输出在 stderr)
public_url = None
start_time = time.time()
while time.time() - start_time < 30:
line = proc.stderr.readline()
if not line:
time.sleep(0.1)
continue
if "trycloudflare.com" in line or "cfargotunnel.com" in line:
import re
match = re.search(r"(https://[a-zA-Z0-9\-]+\.trycloudflare\.com)", line)
if match:
public_url = match.group(1)
break
if public_url:
print(f" ✅ 公网地址: {public_url}")
print(f" 📱 手机扫码或发送链接即可查看报告")
print(f" ⚠️ 该链接是公网 bearer URL,仅分享给可信对象")
print(f" ⏹ 按 Ctrl+C 停止服务")
else:
print(f" ⚠️ Tunnel 启动中... 请检查 cloudflared 输出")
proc.terminate()
return None, None
return public_url, proc
def wait_for_remote_shutdown(httpd: HTTPServer, tunnel_proc=None) -> None:
"""保持远程报告服务运行,直到用户中断,并清理本地服务/隧道进程。"""
try:
threading.Event().wait()
except KeyboardInterrupt:
print("\n⏹ 服务已停止")
finally:
httpd.shutdown()
httpd.server_close()
if tunnel_proc and tunnel_proc.poll() is None:
tunnel_proc.terminate()
try:
tunnel_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
tunnel_proc.kill()
def _normalize_report_path(path: str | Path, scripts_dir: Path = SCRIPTS_DIR) -> Path:
"""Return an absolute report HTML path from runner output."""
p = Path(path)
return p if p.is_absolute() else (scripts_dir / p).resolve()
def _set_direct_report_path(args, report_path: str | Path) -> None:
"""Mark a generated non-single-stock report for shared post-processing."""
args._direct_report_path = _normalize_report_path(report_path)
def _resolve_report_artifact(args, scripts_dir: Path, ticker: str) -> tuple[Path, Path]:
"""Find the report directory and standalone HTML for any CLI mode."""
direct = getattr(args, "_direct_report_path", None) or getattr(args, "_fund_summary_path", None)
if direct:
standalone = _normalize_report_path(direct, scripts_dir)
return standalone.parent, standalone
from datetime import datetime
from lib.market_router import parse_ticker
ti = parse_ticker(ticker)
date = datetime.now().strftime("%Y%m%d")
report_dir = scripts_dir / "reports" / f"{ti.full}_{date}"
standalone = report_dir / "full-report-standalone.html"
if not standalone.exists():
reports_root = scripts_dir / "reports"
if reports_root.exists():
dirs = sorted(reports_root.glob(f"{ti.full}_*"), reverse=True)
for d in dirs:
candidate = d / "full-report-standalone.html"
if candidate.exists():
return d, candidate
return report_dir, standalone
def _post_process_report(args, env: dict, report_dir: Path, standalone: Path) -> None:
"""Shared output/browser/remote handling for all report-producing modes."""
print(f"\n{'━' * 50}")
print(f"📄 报告路径: {standalone}")
print(f" 大小: {standalone.stat().st_size // 1024} KB")
# v2.11.0 · --output-dir:把报告整目录复制到指定路径,便于 SaaS / 平台集成
if args.output_dir:
import json
from datetime import datetime as _dt
out = Path(args.output_dir).resolve()
out.mkdir(parents=True, exist_ok=True)
try:
for item in report_dir.iterdir():
target = out / item.name
if item.is_dir():
if target.exists():
shutil.rmtree(target)
shutil.copytree(item, target)
else:
shutil.copy2(item, target)
index_target = out / "index.html"
shutil.copy2(standalone, index_target)
one_liner = ""
ol_path = report_dir / "one-liner.txt"
if ol_path.exists():
try:
one_liner = ol_path.read_text(encoding="utf-8").strip()
except Exception:
pass
meta = {
"schema": 1,
"ticker": args.ticker,
"depth": args.depth or os.environ.get("UZI_DEPTH") or "medium",
"generated_at": _dt.utcnow().isoformat() + "Z",
"report_dir": str(report_dir),
"standalone": standalone.name,
"index": "index.html",
"size_kb": standalone.stat().st_size // 1024,
"one_liner": one_liner,
}
(out / "report.meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f" 📦 已导出到: {out}/index.html")
except Exception as _e:
print(f"⚠️ --output-dir 导出失败(不影响本地报告): {_e}")
# 打开浏览器(本地模式)
if env["has_browser"] and not args.no_browser and not args.remote:
import webbrowser
webbrowser.open(standalone.as_uri())
print(f" 🌐 已在浏览器中打开")
# 远程模式: HTTP server + Cloudflare Tunnel
if args.remote:
httpd = serve_report(standalone, args.port)
filename = standalone.name
public_url, tunnel_proc = start_cloudflare_tunnel(
args.port,
install=getattr(args, "install_cloudflared", False),
)
if public_url:
full_url = f"{public_url}/{filename}"
print(f"\n{'━' * 50}")
print(f"📱 远程查看地址:")
print(f" {full_url}")
print(f"{'━' * 50}")
print(f"\n发送这个链接到手机就能看报告;不要转发到公开渠道。")
print(f"按 Ctrl+C 停止服务。\n")
# 如果有浏览器也打开
if env["has_browser"] and not args.no_browser:
import webbrowser
webbrowser.open(full_url)
wait_for_remote_shutdown(httpd, tunnel_proc)
else:
# cloudflared 失败,至少提供本地 HTTP
print(f"\n 本地访问: http://localhost:{args.port}/{filename}")
wait_for_remote_shutdown(httpd)
elif not env["has_browser"] or args.no_browser:
# 无浏览器环境,提示用户
print(f"\n💡 提示: 当前环境无法打开浏览器")
print(f" 方式 1: 下载文件到本地打开")
print(f" 方式 2: python run.py {args.ticker} --remote ← 生成公网链接,手机就能看")
print(f"{'━' * 50}")
print(f"✅ 完成!")
def _maybe_prompt_update() -> None:
"""v2.14.0 · CLI 启动时检测 GitHub 新版本 · interactive y/s/n.
- 非 TTY(CI / Codex sandbox / 管道重定向)自动 skip
- UZI_NO_UPDATE_CHECK=1 env 禁用
- 网络异常 / GH API 限流 → silent skip(不阻塞正常流程)
- 用户选 s · 记到 .cache/_global/update_check.json · 下一版本来之前不再弹
"""
if not sys.stdin.isatty():
return
try:
from lib.update_check import check_for_update, format_prompt, handle_answer
except Exception:
return
try:
info = check_for_update()
if info is None:
return
print(format_prompt(info))
try:
ans = input("请选择 [y/s/n](回车默认 n): ").strip()
except (EOFError, KeyboardInterrupt):
print()
return
print(handle_answer(ans, info.latest))
print()
except Exception as _e:
# update check 永远不阻塞主流程
pass
def main():
parser = argparse.ArgumentParser(
description="游资(UZI)Skills · 个股深度分析",
epilog="示例: python run.py 贵州茅台 --remote",
)
parser.add_argument("ticker", nargs="?", default="002273.SZ",
help="股票代码或中文名 (如 600519.SH / AAPL / 贵州茅台)")
parser.add_argument("--remote", action="store_true",
help="分析完后用 Cloudflare Tunnel 映射公网链接")
parser.add_argument("--install-cloudflared", action="store_true",
help="远程模式缺少 cloudflared 时允许自动安装(默认只提示安装方式,不改系统)")
parser.add_argument("--no-browser", action="store_true",
help="不自动打开浏览器")
parser.add_argument("--port", type=int, default=8976,
help="HTTP 服务端口 (默认 8976)")
parser.add_argument("--force-name", metavar="CODE",
help="绕过中文名纠错直接使用指定代码 (如 --force-name 000582.SZ)")
parser.add_argument("--no-resume", action="store_true",
help="v2.6 · 强制重抓所有 fetcher(默认 resume:复用 .cache/{ticker}/raw_data.json 已有维度)")
parser.add_argument("--from-modeling", action="store_true",
help="从已有 raw_data.json 直接恢复机构建模、评分和报告生成,不重新采集数据")
parser.add_argument("--enable-xueqiu-login", action="store_true",
help="v2.7.1 · 启用 XueQiu Playwright 登录态抓取实盘比赛持仓(首次需 `python -m lib.xueqiu_browser login`)")
parser.add_argument("--depth", choices=["lite", "medium", "deep"], default=None,
help="v2.10.2 · 思考深度 · lite(1-2min) / medium(5-8min · 默认) / deep(15-20min · 含 Bull-Bear 辩论 + Segmental)")
parser.add_argument("--school", choices=["A", "B", "C", "D", "E", "F", "G", "H", "I"], default=None,
help="v3.5.0 · 锁定单一流派视角 · A价值/B成长/C宏观/D技术/E中国价投/F游资/G量化/H科技领袖派/I Serenity卡位猎手 · 其他派评委 skip · 报告顶部标注")
parser.add_argument("--versus", nargs="+", metavar="TICKER",
help="v3.6.0 · 多股横向对比模式 · 接受 2-4 个代码 / 中文名 · 输出单 HTML "
"(如 --versus 600519.SH 000858.SZ · 自动 resume 复用 cache)")
parser.add_argument("--portfolio", metavar="CSV", default=None,
help="v3.6.0 · 组合批量分析 · CSV 列含 ticker / weight / note · "
"输出排名 + 加权评分 + 健康度 · 自动 resume")
parser.add_argument("--output-dir", metavar="DIR", default=None,
help="v2.11.0 · SaaS 集成:把产出(standalone html + 图 + 摘要)拷贝到该目录,并在其中生成 index.html / report.meta.json。建议配合 --no-browser 使用。")
args = parser.parse_args()
args._direct_report_path = None
# v2.10.5 · run.py 是 CLI 直跑入口(agent 流程走 stage1/stage2 直接调用,不经 run.py)。
# 设 UZI_CLI_ONLY=1 让 self_review 对 agent_analysis.json 缺失 / 低 coverage 做宽容处理
# (降级为 warning,仍出报告)。
os.environ.setdefault("UZI_CLI_ONLY", "1")
# v2.14.0 · 自动检测 GitHub 新版本 · interactive prompt(y/s/n) · 非 TTY 静默
_maybe_prompt_update()
# v2.10.2 · 深度选择(优先级: --depth > UZI_DEPTH env > UZI_LITE env > 默认 medium)
try:
sys.path.insert(0, str(Path(__file__).parent / "skills" / "deep-analysis" / "scripts"))
from lib.analysis_profile import get_profile, apply_profile_to_env, format_banner
profile = get_profile(args.depth) if args.depth else get_profile()
apply_profile_to_env(profile)
print(f"\n{format_banner(profile)}\n")
except Exception as _e:
print(f"⚠️ 无法加载 analysis_profile: {_e}")
# v2.3 · --force-name 直接覆盖
if args.force_name:
print(f" [force-name] {args.ticker} → {args.force_name}")
args.ticker = args.force_name
# v2.7.1 · XueQiu login opt-in
if args.enable_xueqiu_login:
os.environ["UZI_XQ_LOGIN"] = "1"
print("🔓 启用 XueQiu 登录态(19_contests 维度抓实盘组合)")
# v3.5.0 · 单一流派视角锁定 · 通过 env 传给 investor_evaluator
if args.school:
os.environ["UZI_SCHOOL"] = args.school
_SCHOOL_NAMES = {"A": "价值派", "B": "成长派", "C": "宏观派", "D": "技术派",
"E": "中国价投", "F": "A 股游资", "G": "量化",
"H": "科技领袖派", "I": "Serenity · AI 卡位/瓶颈猎手"}
print(f"🎯 已锁定 {args.school} 派视角 · {_SCHOOL_NAMES.get(args.school, args.school)} · 其他派评委 skip")
# v3.6.0 · 横向对比模式 · 不走单股分析,但仍进入统一 post-process
if args.versus:
if not (2 <= len(args.versus) <= 4):
print(f"❌ --versus 接受 2-4 个 ticker · 实际 {len(args.versus)}")
sys.exit(2)
from lib.versus_runner import run_versus
result = run_versus(
args.versus,
depth=args.depth or "lite",
auto_open=not args.no_browser and not args.remote,
)
if result.get("status") == "completed":
print(f"✅ 横向对比完成 · {result['report_path']}")
_set_direct_report_path(args, result["report_path"])
args.ticker = "versus"
env = detect_environment()
report_dir, standalone = _resolve_report_artifact(args, SCRIPTS_DIR, args.ticker)
_post_process_report(args, env, report_dir, standalone)
sys.exit(0)
else:
print(f"⚠️ 横向对比未生成 · {result}")
sys.exit(1)
# v3.6.0 · 组合批量分析 · 不走单股分析,但仍进入统一 post-process
if args.portfolio and not args._direct_report_path:
from lib.portfolio_runner import run_portfolio
result = run_portfolio(
args.portfolio,
depth=args.depth or "lite",
auto_open=not args.no_browser and not args.remote,
)
if result.get("status") == "completed":
print(f"✅ 组合分析完成 · {result['report_path']}")
_set_direct_report_path(args, result["report_path"])
args.ticker = "portfolio"
env = detect_environment()
report_dir, standalone = _resolve_report_artifact(args, SCRIPTS_DIR, args.ticker)
_post_process_report(args, env, report_dir, standalone)
sys.exit(0)
else:
print(f"⚠️ 组合分析失败 · {result}")
sys.exit(1)
env = detect_environment()
print()
print("━" * 50)
print(f"🎯 游资(UZI)Skills v{_get_version()} · 深度分析引擎")
print(f" 目标: {args.ticker}")
print(f" 环境: {'Codex' if env['is_codex'] else 'Docker' if env['is_docker'] else 'SSH' if env['is_ssh'] else '本地'}")
print(f" 浏览器: {'✓' if env['has_browser'] and not args.no_browser else '✗ (headless)'}")
print(f" Cloudflare: {'✓ 已安装' if env['has_cloudflared'] else '✗ 未安装'}")
if args.remote:
print(f" 远程模式: ✓ (完成后映射公网)")
print("━" * 50)
print()
# 检查依赖
check_dependencies()
# v2.3 · MX API 状态提示
if os.environ.get("MX_APIKEY"):
print(f"🔑 MX_APIKEY 已设置 · 将优先使用东财妙想 API")
else:
print(f"ℹ️ 未设置 MX_APIKEY · 走默认 akshare/xueqiu 链(可在 .env 里配置)")
# v2.6 · resume 状态提示 + Codex 自适配
cache_root = SCRIPTS_DIR / ".cache" / args.ticker
has_cache = cache_root.exists() and (cache_root / "raw_data.json").exists()
if has_cache and not args.no_resume:
print(f"♻️ resume 模式 · 复用 .cache/{args.ticker}/raw_data.json 已有维度(用 --no-resume 强制重抓)")
elif args.no_resume:
print(f"🔄 --no-resume · 强制重抓所有 22 个 fetcher")
os.environ["UZI_NO_RESUME"] = "1"
if env["is_codex"]:
print(f"⚙️ Codex 环境检测:")
print(f" - mini_racer 锁已启用 (akshare 并行 V8 安全)")
print(f" - per-fetcher 90s timeout 启用")
if not os.environ.get("MX_APIKEY"):
print(f" ⛔ 强烈建议设 MX_APIKEY · push2 在境外环境常被反向限制")
print(f" - resume 默认开启(网络不稳,断了能续)")
# 运行分析(抑制 run_real_test 内部的自动开浏览器)
os.environ["UZI_NO_AUTO_OPEN"] = "1"
# v3.0.0 · pipeline 为主干 · 默认启用
# - UZI_LEGACY=1 → 强制走 legacy stage1+stage2(老路径 · 全量兼容)
# - 不设 env → 走 pipeline.run_pipeline(collect + score + synthesize 全新)
# - pipeline 异常 → 自动回退 legacy · 绝不中断业务
# 原 UZI_PIPELINE=1 仍兼容接受(无操作 · 同默认)
from run_real_test import (
main as run_analysis,
stage1 as _stage1,
stage1_modeling as _stage1_modeling,
stage2 as _stage2,
)
_pipeline_succeeded = False
_modeling_succeeded = False
_force_legacy = os.environ.get("UZI_LEGACY") == "1"
_pipeline_requested = not _force_legacy and not args.from_modeling
if args.from_modeling:
modeling_result = _stage1_modeling(args.ticker)
resolved_ticker = modeling_result["ticker"]
report_path = _stage2(resolved_ticker)
args.ticker = resolved_ticker
_set_direct_report_path(args, report_path)
_modeling_succeeded = True
elif _pipeline_requested:
try:
from lib.pipeline.run import run_pipeline
print("🚀 [run.py] v3.0.0 pipeline · 默认路径")
run_pipeline(args.ticker, resume=not args.no_resume)
_pipeline_succeeded = True
except Exception as e:
print(f"⚠️ [run.py] pipeline 异常 · 回退 legacy: {type(e).__name__}: {str(e)[:100]}")
import traceback
traceback.print_exc()
_pipeline_succeeded = False
# v2.3 · 先过 stage1,捕获中文名解析失败场景,不静默跑出空报告
from lib.market_router import is_chinese_name
if _modeling_succeeded:
print(" → 从 raw_data.json 恢复建模完成 · skip 数据采集")
elif _pipeline_succeeded:
# pipeline 成功 · 报告已生成 · 跳过 legacy · 直接 fallthrough 到 report dir 查找
print(" → 走 pipeline · skip legacy stage1/stage2")
elif is_chinese_name(args.ticker) and not args.force_name:
stage1_result = _stage1(args.ticker)
# v2.10.4 · ETF/指数/可转债早退:stage1 已写 _resolve_error.json + 成分股清单
# v3.4.0 · ETF/LOF 改为持仓循环分析(用户二次确认)
if isinstance(stage1_result, dict) and stage1_result.get("status") == "non_stock_security":
sec_type = stage1_result.get("security_type", "")
if sec_type in ("etf", "lof", "mutual_fund") and stage1_result.get("top_holdings"):
print(f"\n💡 {args.ticker} 是 {stage1_result.get('label', sec_type.upper())} · v3.4.0 起支持循环分析持仓股")
from lib.fund_holdings_runner import confirm_and_run_holdings
auto_yes = os.environ.get("UZI_FUND_AUTO_YES") == "1"
fund_result = confirm_and_run_holdings(
stage1_result.get("ticker", args.ticker),
stage1_result.get("label", "ETF/LOF"),
stage1_result["top_holdings"],
depth=os.environ.get("UZI_DEPTH", "medium"),
auto_yes=auto_yes,
)
if fund_result.get("status") == "completed":
print(f"\n✅ 持仓批量分析完成 · 汇总报告: {fund_result['summary_html']}")
_set_direct_report_path(args, fund_result["summary_html"])
else:
sys.exit(0)
print(f"\n{'━' * 50}")
print(f"🔴 {args.ticker} 是 {stage1_result.get('label', '非个股标的')},已跳过 stage2。")
print(f" 请选择上面列出的成分股之一重跑。")
print(f"{'━' * 50}")
sys.exit(0)
elif isinstance(stage1_result, dict) and stage1_result.get("status") == "name_not_resolved":
cands = stage1_result.get("candidates", [])
print(f"\n{'━' * 50}")
print(f"❌ 无法确定股票: {args.ticker!r}")
if not cands:
print(f" 没有找到相似候选。请用准确的股票代码(如 600519.SH)重试。")
sys.exit(2)
print(f" 找到 {len(cands)} 个候选:")
for i, c in enumerate(cands[:5], 1):
print(f" [{i}] {c['name']:<12s} {c['code']} (距离 {c.get('distance', '?')})")
# 交互式确认(若 TTY)— agent/CI 环境直接退出让上层决策
if sys.stdin.isatty():
try:
choice = input("\n 选择候选编号(1-5),或直接回车取消: ").strip()
if choice and choice.isdigit() and 1 <= int(choice) <= len(cands):
picked = cands[int(choice) - 1]
print(f" ✓ 使用 {picked['name']} ({picked['code']})")
args.ticker = picked["code"]
# 用选定代码重跑 stage1 然后 stage2
_stage1(args.ticker)
_stage2(args.ticker)
else:
print(" 已取消。")
sys.exit(2)
except (EOFError, KeyboardInterrupt):
print("\n 已取消。")
sys.exit(2)
else:
# Non-interactive: surface structured error and exit 2
import json as _json
print(_json.dumps(stage1_result, ensure_ascii=False, indent=2))
sys.exit(2)
else:
# stage1 已经成功跑完 — 用它返回的 resolved ticker 跑 stage2(cache 是以解析后代码命名的)
resolved = stage1_result.get("ticker") if isinstance(stage1_result, dict) else None
_stage2(resolved or args.ticker)
# 对齐 args.ticker 以便后续 report_dir 查找
if resolved:
args.ticker = resolved
else:
result = run_analysis(args.ticker)
# v2.10.4 · ETF/指数/可转债早退(stage1 已输出成分股清单)
# v3.4.0 · ETF/LOF 不再早退 · 改为询问用户是否循环分析持仓
if isinstance(result, dict) and result.get("status") in ("non_stock_security", "name_not_resolved"):
print(f"\n{'━' * 50}")
status = result.get("status")
if status == "non_stock_security":
sec_type = result.get("security_type", "")
# v3.4.0 · ETF/LOF 走持仓循环分析(用户二次确认)· 可转债 / 指数仍 early-exit
if sec_type in ("etf", "lof", "mutual_fund") and result.get("top_holdings"):
print(f"💡 {args.ticker} 是 {result.get('label', sec_type.upper())} · v3.4.0 起支持循环分析持仓股")
print(f"{'━' * 50}")
from lib.fund_holdings_runner import confirm_and_run_holdings
auto_yes = os.environ.get("UZI_FUND_AUTO_YES") == "1"
fund_result = confirm_and_run_holdings(
args.ticker, result.get("label", "ETF/LOF"),
result["top_holdings"],
depth=os.environ.get("UZI_DEPTH", "medium"),
auto_yes=auto_yes,
)
if fund_result.get("status") == "completed":
print(f"\n✅ 持仓批量分析完成 · 汇总报告: {fund_result['summary_html']}")
_set_direct_report_path(args, fund_result["summary_html"])
else:
sys.exit(0)
else:
# 可转债 / 指数 / 拉不到持仓 · 仍 early-exit
print(f"🔴 {args.ticker} 是 {result.get('label', '非个股标的')},已跳过 stage2。")
if result.get("top_holdings"):
print(f" 请从上方列出的成分股里选一只重跑,例如:python run.py {result['top_holdings'][0]['code']}")
else:
print(f" {result.get('what_to_do', '请改用个股代码重跑。')}")
else:
print(f"🔴 {args.ticker} 股票名无法解析")
print(f"{'━' * 50}")
sys.exit(0)
# 找到生成的报告
report_dir, standalone = _resolve_report_artifact(args, SCRIPTS_DIR, args.ticker)
if not standalone.exists():
print(f"\n❌ 报告文件未找到: {standalone}")
return
_post_process_report(args, env, report_dir, standalone)
if __name__ == "__main__":
main()