Skip to content

Commit 5d6f969

Browse files
committed
feat: call tradingagents cli for hot stock analysis
1 parent 47143ab commit 5d6f969

7 files changed

Lines changed: 171 additions & 71 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# 热门智能通过 TradingAgents 命令行分析
2+
3+
## 背景 / 目标
4+
5+
当前热门智能模块虽然已经接入 `TradingAgents` 适配层,但调用方式仍是 Python 内部导入 `TradingAgentsGraph`。用户要求改为通过命令行方式调用本地项目:
6+
7+
`uv run python -m cli.main analyze --ticker ... --date ... --provider deepseek --quick-model deepseek-chat --deep-model deepseek-reasoner`
8+
9+
并将返回的分析结果放入三池中的第一个池子,同时给出明确的买入/卖出评价。
10+
11+
## 任务分解(checklist)
12+
13+
- [ ]`/Users/jie.feng/work/github/TradingAgents` 支持非交互 `analyze` 命令及参数输入。
14+
- [ ] 增加机器可读输出,方便 Alpha 通过 subprocess 获取分析结果。
15+
- [ ] 修改 Alpha 的 `TradingAgentsAdapter`,改为 shell 调用 `uv run python -m cli.main analyze ...`
16+
- [ ] 将分析结果映射为候选池展示信息,并补充明确的买入/卖出/观望评价字段。
17+
- [ ] 更新前端展示与 README 文档。
18+
- [ ] 补充回归测试。
19+
- [ ] 执行 `./restart.sh`,完成提交与推送。
20+
21+
## 验收标准
22+
23+
- `TradingAgents` 支持非交互 `analyze` 命令。
24+
- Alpha 不再直接 import `TradingAgentsGraph`,而是通过命令行调用本地 `TradingAgents` 项目。
25+
- 热门智能的候选池能展示 `TradingAgents` 返回的结论,并有明确买入/卖出/观望评价。
26+
- 测试通过,服务重启完成或如实说明阻塞原因。
27+
28+
## 风险与回滚
29+
30+
- `TradingAgents` 外部仓库若持续有未提交脏改动,新增 CLI 可能与其本地改动发生冲突。
31+
- 命令行输出若混入第三方日志,JSON 解析需要额外兼容。
32+
- 回滚方式:恢复 Alpha 适配层为 import 调用,并回退 `TradingAgents` 的 CLI 变更。
33+
34+
## 关键决策记录
35+
36+
- 采用命令行调用是为了满足固定调用方式与进程隔离要求。
37+
- 热门智能仍保留三池评分框架;`TradingAgents` 结果主要作为候选池展示与买卖评价来源。

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ Alpha 是一个面向 A 股市场的**自进化量化选股系统**。它不只
137137
- **实时输入**:直接复用 `/api/market/hot-stocks` 的实时热门股接口,默认取前 20
138138
- **逐股分析**:结合热度排名、当日涨幅、趋势位置、20 日量额比和 `Kronos` 三日预测
139139
- **深度讨论**:对命中的热门股调用本地 [TradingAgents](</Users/jie.feng/work/github/TradingAgents/README.md>) 项目,通过 `DeepSeek API` 产出多代理讨论结论,再映射为加减分
140+
- **命令行调用方式**:Alpha 通过 `uv run python -m cli.main analyze --ticker ... --date ... --provider deepseek --quick-model deepseek-chat --deep-model deepseek-reasoner` 调用 `TradingAgents`,不再直接 import 图对象
140141
- **解释性评分**:每只股票都输出分数组成、风险扣分、标签和一段摘要分析
142+
- **候选池评价**`TradingAgents` 返回结果会落到第一池候选池展示,并明确给出 `买入 / 卖出 / 观望` 评价
141143
- **三池拆分**:默认阈值 `8 / 11.5 / 14.5` 分,对应候选 / 重点关注 / 买入候选
142144
- **自动刷新**:默认每 5 分钟自动扫描一次;为保证站点响应,后台自动任务走轻量模式(缩小样本并跳过 `TradingAgents` / `Kronos`),页面手动触发仍执行完整分析
143145
- **点击个股**:直接弹出历史 K 线 + `Kronos` 三日预测详情

app/services/hot_stock_ai_service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,11 @@ async def _execute(self, trigger: str) -> dict[str, Any]:
255255
"tradingagents_repo_path": ta_runtime.get("repo_path", ""),
256256
"tradingagents_provider": ta_runtime.get("provider", DEEPSEEK_PROVIDER),
257257
"tradingagents_backend_url": ta_runtime.get("backend_url", ""),
258+
"tradingagents_command_example": (
259+
"uv run python -m cli.main analyze "
260+
"--ticker NVDA --date 2026-04-22 --provider deepseek "
261+
"--quick-model deepseek-chat --deep-model deepseek-reasoner"
262+
),
258263
"execution_mode": "light_auto" if trigger == "auto" else "full_manual",
259264
"runtime_top_n": int(runtime_cfg["top_n"]),
260265
"thresholds": {
@@ -353,6 +358,8 @@ def _apply_tradingagents_result(item: dict[str, Any], result: dict[str, Any], *,
353358
ta_payload["status"] = "ok"
354359
ta_payload["source"] = source
355360
item["tradingagents"] = ta_payload
361+
item["evaluation_action"] = result.get("decision_action", "watch")
362+
item["evaluation_text"] = result.get("decision_action_text", "观望")
356363
item["base_score"] = round(float(item.get("score", 0.0)), 2)
357364
bonus = float(result.get("score_bonus", 0.0))
358365
item["tradingagents_bonus"] = round(bonus, 2)
@@ -510,4 +517,6 @@ def _build_pools(entries: list[dict[str, Any]], cfg: dict[str, Any]) -> dict[str
510517
pools[POOL_FOCUS].append(item)
511518
elif score >= float(cfg["threshold_candidate"]):
512519
pools[POOL_CANDIDATE].append(item)
520+
elif item.get("tradingagents", {}).get("status") == "ok":
521+
pools[POOL_CANDIDATE].append(item)
513522
return pools

app/services/tradingagents_adapter.py

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
from __future__ import annotations
22

3+
import json
34
import os
45
import re
5-
import sys
6+
import subprocess
7+
import tempfile
68
from pathlib import Path
79
from typing import Any
810

911
DEEPSEEK_PROVIDER = "deepseek"
1012
DEEPSEEK_BACKEND_URL = "https://api.deepseek.com"
1113
DEFAULT_QUICK_MODEL = "deepseek-chat"
12-
DEFAULT_DEEP_MODEL = "deepseek-chat"
14+
DEFAULT_DEEP_MODEL = "deepseek-reasoner"
1315

1416

1517
def _normalize_whitespace(text: str) -> str:
@@ -49,13 +51,7 @@ def to_vendor_symbol(symbol: str) -> str:
4951
def _load_classes(self):
5052
if not self.repo_path.exists():
5153
raise FileNotFoundError(f"TradingAgents repo not found: {self.repo_path}")
52-
repo_str = str(self.repo_path)
53-
if repo_str not in sys.path:
54-
sys.path.insert(0, repo_str)
55-
from tradingagents.graph.trading_graph import TradingAgentsGraph
56-
from tradingagents.default_config import DEFAULT_CONFIG
57-
58-
return TradingAgentsGraph, DEFAULT_CONFIG
54+
return None
5955

6056
@staticmethod
6157
def _decision_bonus(decision: str) -> float:
@@ -68,6 +64,18 @@ def _decision_bonus(decision: str) -> float:
6864
}
6965
return mapping.get(str(decision or "").strip().upper(), 0.0)
7066

67+
@staticmethod
68+
def _decision_action(decision: str) -> tuple[str, str]:
69+
value = str(decision or "").strip().upper()
70+
mapping = {
71+
"BUY": ("buy", "买入"),
72+
"OVERWEIGHT": ("buy", "买入"),
73+
"HOLD": ("watch", "观望"),
74+
"UNDERWEIGHT": ("sell", "卖出"),
75+
"SELL": ("sell", "卖出"),
76+
}
77+
return mapping.get(value, ("watch", "观望"))
78+
7179
def analyze(
7280
self,
7381
symbol: str,
@@ -81,35 +89,48 @@ def analyze(
8189
) -> dict[str, Any]:
8290
if not os.getenv("DEEPSEEK_API_KEY"):
8391
raise RuntimeError("DEEPSEEK_API_KEY 未设置,无法调用 TradingAgents DeepSeek 分析")
84-
85-
TradingAgentsGraph, DEFAULT_CONFIG = self._load_classes()
92+
self._load_classes()
8693
vendor_symbol = self.to_vendor_symbol(symbol)
87-
88-
config = DEFAULT_CONFIG.copy()
89-
config["llm_provider"] = DEEPSEEK_PROVIDER
90-
config["backend_url"] = DEEPSEEK_BACKEND_URL
91-
config["quick_think_llm"] = quick_model or DEFAULT_QUICK_MODEL
92-
config["deep_think_llm"] = deep_model or DEFAULT_DEEP_MODEL
93-
config["output_language"] = output_language
94-
config["max_debate_rounds"] = 1
95-
config["max_risk_discuss_rounds"] = 1
96-
config["results_dir"] = str(self.runtime_root / "logs")
97-
config["data_cache_dir"] = str(self.runtime_root / "cache")
98-
99-
graph = TradingAgentsGraph(
100-
debug=False,
101-
config=config,
102-
selected_analysts=selected_analysts or ["market", "news", "fundamentals"],
103-
)
104-
final_state, decision = graph.propagate(vendor_symbol, trade_date)
105-
decision_text = _normalize_whitespace(final_state.get("final_trade_decision", ""))
106-
investment_plan = _normalize_whitespace(final_state.get("investment_plan", ""))
107-
trader_plan = _normalize_whitespace(final_state.get("trader_investment_plan", ""))
108-
market_report = _normalize_whitespace(final_state.get("market_report", ""))
109-
news_report = _normalize_whitespace(final_state.get("news_report", ""))
110-
fundamentals_report = _normalize_whitespace(final_state.get("fundamentals_report", ""))
94+
analyst_csv = ",".join(selected_analysts or ["market", "news", "fundamentals"])
95+
with tempfile.NamedTemporaryFile(prefix="tradingagents_", suffix=".json", dir=self.runtime_root, delete=False) as fh:
96+
output_path = Path(fh.name)
97+
command = [
98+
"uv", "run", "python", "-m", "cli.main", "analyze",
99+
"--ticker", vendor_symbol,
100+
"--date", trade_date,
101+
"--provider", DEEPSEEK_PROVIDER,
102+
"--quick-model", quick_model or DEFAULT_QUICK_MODEL,
103+
"--deep-model", deep_model or DEFAULT_DEEP_MODEL,
104+
"--analysts", analyst_csv,
105+
"--output-language", output_language,
106+
"--output-file", str(output_path),
107+
]
108+
try:
109+
proc = subprocess.run(
110+
command,
111+
cwd=str(self.repo_path),
112+
capture_output=True,
113+
text=True,
114+
check=False,
115+
timeout=600,
116+
env=os.environ.copy(),
117+
)
118+
if proc.returncode != 0:
119+
raise RuntimeError((proc.stderr or proc.stdout or "").strip() or f"TradingAgents CLI failed with code {proc.returncode}")
120+
payload = json.loads(output_path.read_text(encoding="utf-8"))
121+
finally:
122+
output_path.unlink(missing_ok=True)
123+
124+
decision = str(payload.get("decision") or "").strip().upper()
125+
decision_text = _normalize_whitespace(payload.get("final_trade_decision", ""))
126+
investment_plan = _normalize_whitespace(payload.get("investment_plan", ""))
127+
trader_plan = _normalize_whitespace(payload.get("trader_investment_plan", ""))
128+
market_report = _normalize_whitespace(payload.get("market_report", ""))
129+
news_report = _normalize_whitespace(payload.get("news_report", ""))
130+
fundamentals_report = _normalize_whitespace(payload.get("fundamentals_report", ""))
111131
summary_source = decision_text or trader_plan or investment_plan or market_report
112132
summary = summary_source[:360]
133+
action, action_text = self._decision_action(decision)
113134

114135
return {
115136
"ok": True,
@@ -118,10 +139,13 @@ def analyze(
118139
"trade_date": trade_date,
119140
"provider": DEEPSEEK_PROVIDER,
120141
"backend_url": DEEPSEEK_BACKEND_URL,
121-
"decision": str(decision or "").strip().upper(),
142+
"decision": decision,
143+
"decision_action": action,
144+
"decision_action_text": action_text,
122145
"score_bonus": self._decision_bonus(decision),
123146
"summary": summary,
124147
"discussion": decision_text[:4000],
148+
"command": " ".join(command),
125149
"reports": {
126150
"market": market_report[:1200],
127151
"news": news_report[:1200],

app/static/app.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3274,6 +3274,7 @@ function _hotAiCardHtml(e) {
32743274
const cls = _hotAiScoreCls(score);
32753275
const ta = e.tradingagents || {};
32763276
const decision = String(ta.decision || '').toUpperCase();
3277+
const evaluationText = e.evaluation_text || _hotAiDecisionText(decision);
32773278
const baseScore = Number(e.base_score ?? e.score ?? 0);
32783279
const bonus = Number(e.tradingagents_bonus || 0);
32793280
const bonusSign = bonus > 0 ? '+' : '';
@@ -3323,6 +3324,9 @@ function _hotAiCardHtml(e) {
33233324
<span class="predict-card-metric">预测分 <b>${fmtNum(e.score_breakdown?.prediction, 1)}</b></span>
33243325
<span class="predict-card-metric">风险扣分 <b>${fmtNum(e.score_breakdown?.risk_penalty, 1)}</b></span>
33253326
</div>
3327+
<div class="predict-card-meta">
3328+
<span class="predict-card-metric">评价 <b>${evaluationText}</b></span>
3329+
</div>
33263330
${decisionHtml}
33273331
${taSummary ? `<div class="hot-ai-ta-summary">${taSummary}</div>` : ''}
33283332
<div class="hot-ai-analysis">${e.analysis || '暂无分析摘要'}</div>

tests/test_hot_stock_ai_service.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,15 @@ def analyze(self, symbol: str, trade_date: str, **kwargs) -> dict:
111111
"600002": ("OVERWEIGHT", 1.0, "趋势保持但爆发性略弱,建议增配观察。"),
112112
}
113113
decision, bonus, summary = mapping.get(symbol, ("HOLD", 0.0, "讨论中性。"))
114+
action = "buy" if decision in {"BUY", "OVERWEIGHT"} else "watch"
115+
action_text = "买入" if action == "buy" else "观望"
114116
return {
115117
"ok": True,
116118
"symbol": symbol,
117119
"trade_date": trade_date,
118120
"decision": decision,
121+
"decision_action": action,
122+
"decision_action_text": action_text,
119123
"score_bonus": bonus,
120124
"summary": summary,
121125
"discussion": summary,
@@ -164,6 +168,7 @@ def test_hot_stock_ai_applies_tradingagents_bonus_and_cache(tmp_path: Path):
164168
assert snap1["meta"]["tradingagents_discussed"] == 2
165169
assert entry1["tradingagents"]["decision"] == "BUY"
166170
assert entry1["tradingagents"]["source"] == "fresh"
171+
assert entry1["evaluation_text"] == "买入"
167172
assert entry1["score"] > entry1["base_score"]
168173
assert entry2["tradingagents_bonus"] == 1.0
169174
assert entry3["tradingagents"]["status"] == "skipped"
@@ -251,3 +256,27 @@ def test_hot_stock_ai_meta_exposes_tradingagents_backend(tmp_path: Path):
251256

252257
assert snap["meta"]["tradingagents_backend"] == "TradingAgents + DeepSeek"
253258
assert snap["meta"]["tradingagents_provider"] == "deepseek"
259+
260+
261+
def test_hot_stock_ai_keeps_discussed_result_in_candidate_pool(tmp_path: Path):
262+
adapter = FakeTradingAgentsAdapter()
263+
service = HotStockAIService(
264+
provider=FakeProvider(),
265+
kline_store=FakeKlineStore(),
266+
kronos_service=FakeKronos(),
267+
state_store=SQLiteStateStore(str(tmp_path / "state.db")),
268+
tradingagents_adapter=adapter,
269+
)
270+
service.update_config({
271+
"threshold_candidate": 30.0,
272+
"threshold_focus": 31.0,
273+
"threshold_buy": 32.0,
274+
"tradingagents_top_n": 1,
275+
})
276+
277+
asyncio.run(service.run(trigger="manual"))
278+
snap = service.get_snapshot()
279+
280+
assert snap["pools"]["candidate"]
281+
assert snap["pools"]["candidate"][0]["symbol"] == "600001"
282+
assert snap["pools"]["candidate"][0]["evaluation_text"] == "买入"
Lines changed: 30 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,50 @@
11
from __future__ import annotations
22

3+
import json
4+
import subprocess
5+
36
from app.services.tradingagents_adapter import (
47
DEEPSEEK_BACKEND_URL,
58
DEEPSEEK_PROVIDER,
69
TradingAgentsAdapter,
710
)
811

9-
10-
class _FakeGraph:
11-
last_kwargs = None
12-
last_propagate = None
13-
14-
def __init__(self, *, debug, config, selected_analysts):
15-
_FakeGraph.last_kwargs = {
16-
"debug": debug,
17-
"config": config,
18-
"selected_analysts": selected_analysts,
19-
}
20-
21-
def propagate(self, symbol: str, trade_date: str):
22-
_FakeGraph.last_propagate = {"symbol": symbol, "trade_date": trade_date}
23-
return (
24-
{
25-
"final_trade_decision": "BUY with strong conviction",
26-
"investment_plan": "Accumulate on strength.",
27-
"trader_investment_plan": "Enter in two tranches.",
28-
"market_report": "Market report",
29-
"news_report": "News report",
30-
"fundamentals_report": "Fundamentals report",
31-
},
32-
"BUY",
33-
)
34-
35-
3612
def test_tradingagents_adapter_forces_deepseek(monkeypatch, tmp_path):
3713
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key")
3814

3915
adapter = TradingAgentsAdapter(repo_path=tmp_path / "TradingAgents", runtime_root=tmp_path / "runtime")
4016
adapter.repo_path.mkdir(parents=True, exist_ok=True)
17+
captured = {}
18+
19+
def _fake_run(cmd, cwd, capture_output, text, check, timeout, env):
20+
captured["cmd"] = cmd
21+
captured["cwd"] = cwd
22+
output_file = cmd[cmd.index("--output-file") + 1]
23+
payload = {
24+
"decision": "BUY",
25+
"final_trade_decision": "BUY with strong conviction",
26+
"investment_plan": "Accumulate on strength.",
27+
"trader_investment_plan": "Enter in two tranches.",
28+
"market_report": "Market report",
29+
"news_report": "News report",
30+
"fundamentals_report": "Fundamentals report",
31+
}
32+
with open(output_file, "w", encoding="utf-8") as fh:
33+
json.dump(payload, fh, ensure_ascii=False)
34+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
4135

42-
def _fake_load_classes():
43-
return _FakeGraph, {"llm_provider": "openai", "backend_url": "https://api.openai.com/v1"}
44-
45-
monkeypatch.setattr(adapter, "_load_classes", _fake_load_classes)
36+
monkeypatch.setattr(subprocess, "run", _fake_run)
4637

4738
result = adapter.analyze("600001", "2026-04-22")
4839

49-
assert _FakeGraph.last_kwargs is not None
50-
assert _FakeGraph.last_kwargs["config"]["llm_provider"] == DEEPSEEK_PROVIDER
51-
assert _FakeGraph.last_kwargs["config"]["backend_url"] == DEEPSEEK_BACKEND_URL
52-
assert _FakeGraph.last_propagate == {"symbol": "600001.SS", "trade_date": "2026-04-22"}
40+
assert captured["cwd"] == str(adapter.repo_path)
41+
assert captured["cmd"][:5] == ["uv", "run", "python", "-m", "cli.main"]
42+
assert "--provider" in captured["cmd"]
43+
assert captured["cmd"][captured["cmd"].index("--provider") + 1] == DEEPSEEK_PROVIDER
44+
assert captured["cmd"][captured["cmd"].index("--ticker") + 1] == "600001.SS"
45+
assert captured["cmd"][captured["cmd"].index("--deep-model") + 1] == "deepseek-reasoner"
5346
assert result["provider"] == DEEPSEEK_PROVIDER
5447
assert result["backend_url"] == DEEPSEEK_BACKEND_URL
5548
assert result["decision"] == "BUY"
49+
assert result["decision_action"] == "buy"
50+
assert result["decision_action_text"] == "买入"

0 commit comments

Comments
 (0)