tools.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. """Step 2: 股票分析工具 — akshare (Sina/Tencent 源) 真实数据 + 技术指标"""
  2. import time
  3. import numpy as np
  4. import pandas as pd
  5. import akshare as ak
  6. from datetime import datetime, timedelta
  7. from typing import Dict, Any
  8. class ToolExecutor:
  9. """工具注册与执行中心"""
  10. def __init__(self):
  11. self.tools: Dict[str, Dict[str, Any]] = {}
  12. def registerTool(self, name: str, description: str, func: callable):
  13. self.tools[name] = {"description": description, "func": func}
  14. print(f" [工具] {name} 已注册")
  15. def getTool(self, name: str) -> callable:
  16. return self.tools.get(name, {}).get("func")
  17. def getAvailableTools(self) -> str:
  18. return "\n".join([
  19. f"- {name}: {info['description']}"
  20. for name, info in self.tools.items()
  21. ])
  22. # ==================== 辅助函数 ====================
  23. def _to_sina_code(code: str) -> str:
  24. """将纯数字代码转换为 Sina 格式 (sh600519 / sz000001)"""
  25. code = code.strip()
  26. if code.startswith("6"):
  27. return f"sh{code}"
  28. elif code.startswith(("0", "3")):
  29. return f"sz{code}"
  30. return code
  31. def _resolve_symbol(query: str) -> str:
  32. """解析股票代码:支持名称搜索,返回纯数字代码"""
  33. query = query.strip()
  34. if query.isdigit() and len(query) == 6:
  35. return query
  36. # 尝试使用 akshare stock_info_a_code_name 映射
  37. try:
  38. import akshare as ak
  39. stock_info = ak.stock_info_a_code_name()
  40. # 匹配名称
  41. match = stock_info[stock_info["name"] == query]
  42. if not match.empty:
  43. return match["code"].values[0]
  44. # 模糊匹配名称
  45. fuzzy_match = stock_info[stock_info["name"].str.contains(query, na=False)]
  46. if not fuzzy_match.empty:
  47. return fuzzy_match["code"].values[0]
  48. except Exception:
  49. pass
  50. # 尝试通过新闻接口反查(间接方式)
  51. try:
  52. time.sleep(1)
  53. info = ak.stock_individual_info_em(symbol=query) if query.isdigit() else None
  54. if info is not None and len(info) > 0:
  55. return query
  56. except Exception:
  57. pass
  58. return query
  59. def _safe_fetch(func, *args, **kwargs):
  60. """带重试的数据获取"""
  61. import random
  62. for attempt in range(3):
  63. try:
  64. time.sleep(2 + random.random())
  65. return func(*args, **kwargs)
  66. except Exception as e:
  67. if attempt < 2:
  68. time.sleep(4 + random.random() * 2)
  69. else:
  70. return f"数据获取失败: {e}"
  71. # ==================== 工具函数 ====================
  72. def get_realtime_quote(query: str) -> str:
  73. """
  74. 获取A股最新行情。输入: 股票代码(如"600519")或部分名称。
  75. 数据源: 东方财富个股信息 + Sina 日线最新一条。
  76. """
  77. print(f" [查询实时行情] {query}")
  78. symbol = _resolve_symbol(query)
  79. # 使用 Sina 日线获取最新价格
  80. try:
  81. sina_code = _to_sina_code(symbol)
  82. df = _safe_fetch(ak.stock_zh_a_daily,
  83. symbol=sina_code,
  84. start_date=(datetime.now() - timedelta(days=10)).strftime("%Y%m%d"),
  85. end_date=datetime.now().strftime("%Y%m%d"),
  86. adjust="qfq")
  87. if isinstance(df, str) or df is None or df.empty:
  88. df = _safe_fetch(ak.stock_zh_a_hist, symbol=symbol, period="daily", start_date=(datetime.now() - timedelta(days=10)).strftime("%Y%m%d"), end_date=datetime.now().strftime("%Y%m%d"), adjust="qfq")
  89. if isinstance(df, str) or df is None or df.empty:
  90. return f"未找到 {symbol} 的行情数据"
  91. if df is not None and not df.empty:
  92. # 统一列名为英文以适配下游逻辑
  93. rename_map = {
  94. "日期": "date", "开盘": "open", "收盘": "close",
  95. "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount"
  96. }
  97. df = df.rename(columns=rename_map)
  98. except Exception as e:
  99. return f"获取行情失败: {e}"
  100. latest = df.iloc[-1]
  101. prev = df.iloc[-2] if len(df) > 1 else latest
  102. # 尝试获取个股信息(名称、PE等)
  103. name = symbol
  104. pe = "N/A"
  105. try:
  106. time.sleep(2)
  107. info = ak.stock_individual_info_em(symbol=symbol)
  108. name_row = info[info["item"] == "股票简称"]
  109. if not name_row.empty:
  110. name = name_row["value"].values[0]
  111. pe_row = info[info["item"] == "市盈率-动态"]
  112. if not pe_row.empty:
  113. pe = pe_row["value"].values[0]
  114. except Exception:
  115. pass
  116. chg_pct = (latest["close"] - prev["close"]) / prev["close"] * 100
  117. return (
  118. f"{name}({symbol})\n"
  119. f" 最新价: {latest['close']:.2f} 涨跌幅: {chg_pct:+.2f}%\n"
  120. f" 今开: {latest['open']:.2f} 最高: {latest['high']:.2f} 最低: {latest['low']:.2f}\n"
  121. f" 成交量: {latest.get('volume', 'N/A')}手 成交额: {latest.get('amount', 'N/A')}元\n"
  122. f" 市盈率(动态): {pe}"
  123. )
  124. def get_historical_data(query: str) -> str:
  125. """
  126. 获取历史K线数据。输入格式: "symbol|period|days"
  127. period: daily/weekly/monthly(日/周/月), days: 最近多少个周期(默认60)
  128. 示例: "600519|daily|30"
  129. 数据源: Sina
  130. """
  131. print(f" [查询历史数据] {query}")
  132. parts = query.strip().split("|")
  133. symbol = _resolve_symbol(parts[0].strip())
  134. period = parts[1].strip() if len(parts) > 1 else "daily"
  135. try:
  136. days = int(parts[2]) if len(parts) > 2 else 60
  137. except ValueError:
  138. days = 60
  139. end = datetime.now().strftime("%Y%m%d")
  140. start = (datetime.now() - timedelta(days=days * 30)).strftime("%Y%m%d") if period != "daily" else (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
  141. try:
  142. sina_code = _to_sina_code(symbol)
  143. period_map = {"daily": "daily", "weekly": "weekly", "monthly": "monthly"}
  144. ak_period = period_map.get(period, "daily")
  145. hist = _safe_fetch(ak.stock_zh_a_hist,
  146. symbol=symbol, period=ak_period, start_date=start,
  147. end_date=end, adjust="qfq")
  148. if isinstance(hist, str) or hist is None or hist.empty:
  149. hist = _safe_fetch(ak.stock_zh_a_daily,
  150. symbol=sina_code, start_date=start,
  151. end_date=end, adjust="qfq")
  152. if isinstance(hist, str):
  153. # 尝试 Tencent 源
  154. time.sleep(2)
  155. hist = ak.stock_zh_a_hist_tx(symbol=sina_code,
  156. start_date=start, end_date=end)
  157. if isinstance(hist, str) or hist is None or hist.empty:
  158. return f"未找到 {symbol} 的历史数据"
  159. # Tencent 列名映射
  160. hist = hist.rename(columns={
  161. "date": "date", "open": "open", "close": "close",
  162. "high": "high", "low": "low", "amount": "volume"
  163. })
  164. elif hist is not None and not hist.empty:
  165. # 统一列名为英文以适配下游逻辑
  166. rename_map = {
  167. "日期": "date", "开盘": "open", "收盘": "close",
  168. "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount"
  169. }
  170. hist = hist.rename(columns=rename_map)
  171. except Exception as e:
  172. return f"获取历史数据失败: {e}"
  173. if hist is None or hist.empty:
  174. return f"未找到 {symbol} 的历史数据"
  175. hist = hist.tail(days)
  176. latest = hist.iloc[-1]
  177. first = hist.iloc[0]
  178. change = (latest["close"] - first["close"]) / first["close"] * 100
  179. date_col = "date" if "date" in hist.columns else hist.columns[0]
  180. close_col = "close"
  181. lines = [f"{symbol} daily K线 (近{len(hist)}条, {hist.iloc[0][date_col]} ~ {hist.iloc[-1][date_col]})"]
  182. lines.append(f" 区间涨跌: {change:.2f}%")
  183. lines.append(f" 最新: O={latest['open']:.2f} H={latest['high']:.2f} L={latest['low']:.2f} C={latest[close_col]:.2f}")
  184. lines.append(f" 区间最高: {hist['high'].max():.2f} 区间最低: {hist['low'].min():.2f}")
  185. closes = [f"{x:.2f}" for x in hist[close_col].tail(5).tolist()]
  186. lines.append(f" 近5日收盘: {' -> '.join(closes)}")
  187. return "\n".join(lines)
  188. def get_financial_data(symbol: str) -> str:
  189. """
  190. 获取核心财务指标。输入: 股票代码(如"600519")
  191. 数据源: akshare stock_financial_abstract (Sina)
  192. 返回: 净利润、营收、ROE、毛利率、增长率等关键指标。
  193. """
  194. print(f" [查询财务数据] {symbol}")
  195. symbol = symbol.strip()
  196. try:
  197. df = _safe_fetch(ak.stock_financial_abstract, symbol=symbol)
  198. if isinstance(df, str) or df is None or df.empty:
  199. return f"未找到 {symbol} 的财务数据"
  200. except Exception as e:
  201. return f"获取财务数据失败: {e}"
  202. # 取最近两期季度数据列
  203. date_cols = [c for c in df.columns if c.isdigit() and len(c) == 8]
  204. if len(date_cols) < 2:
  205. return f"{symbol} 财务数据不足"
  206. latest_col = date_cols[0]
  207. prev_col = date_cols[1]
  208. lines = [f"{symbol} 核心财务数据 (最新: {latest_col} vs 上期: {prev_col})"]
  209. # 关键指标映射
  210. key_metrics = [
  211. ("归母净利润", "归母净利润", "元"),
  212. ("营业总收入", "营业总收入", "元"),
  213. ("净利润", "净利润", "元"),
  214. ("扣非净利润", "扣非净利润", "元"),
  215. ("基本每股收益", "基本每股收益", "元"),
  216. ("每股净资产", "每股净资产", "元"),
  217. ("净资产收益率", "净资产收益率", "%"),
  218. ("总资产收益率", "总资产收益率", "%"),
  219. ("销售毛利率", "销售毛利率", "%"),
  220. ("销售净利率", "销售净利率", "%"),
  221. ("营收同比增长", "营业总收入同比增长", "%"),
  222. ("归母净利润同比增长", "归属母公司股东的净利润同比增长", "%"),
  223. ("资产负债率", "资产负债率", "%"),
  224. ("流动比率", "流动比率", ""),
  225. ("速动比率", "速动比率", ""),
  226. ]
  227. for label, metric_name, unit in key_metrics:
  228. row = df[df["指标"] == metric_name]
  229. if row.empty:
  230. continue
  231. val = row[latest_col].values[0]
  232. prev_val = row[prev_col].values[0] if prev_col in row.columns else None
  233. if pd.isna(val):
  234. continue
  235. try:
  236. if unit == "元" and abs(float(val)) > 1e8:
  237. val_str = f"{float(val)/1e8:.2f}亿"
  238. if prev_val is not None and not pd.isna(prev_val) and abs(float(prev_val)) > 1e8:
  239. prev_str = f"{float(prev_val)/1e8:.2f}亿"
  240. else:
  241. prev_str = None
  242. elif unit == "%":
  243. val_str = f"{float(val):.2f}%"
  244. prev_str = f"{float(prev_val):.2f}%" if prev_val is not None and not pd.isna(prev_val) else None
  245. else:
  246. val_str = f"{float(val):.4f}"
  247. prev_str = f"{float(prev_val):.4f}" if prev_val is not None and not pd.isna(prev_val) else None
  248. except (ValueError, TypeError):
  249. val_str = str(val)
  250. prev_str = str(prev_val) if prev_val is not None else None
  251. line = f" {label}: {val_str}"
  252. if prev_str:
  253. try:
  254. trend = "[+]" if float(val) > float(prev_val) else "[-]"
  255. line += f" {trend} (上期: {prev_str})"
  256. except (ValueError, TypeError):
  257. line += f" (上期: {prev_str})"
  258. lines.append(line)
  259. return "\n".join(lines)
  260. def calc_indicators(query: str) -> str:
  261. """
  262. 计算技术指标。输入格式: "symbol|daily|days"
  263. 返回: MA5/10/20/60, MACD(DIF/DEA/柱), RSI14, 布林带, 支撑压力位。
  264. 数据源: Sina
  265. """
  266. print(f" [计算技术指标] {query}")
  267. parts = query.strip().split("|")
  268. symbol = parts[0].strip()
  269. try:
  270. days = min(int(parts[2]), 365) if len(parts) > 2 else 120
  271. except ValueError:
  272. days = 120
  273. end = datetime.now().strftime("%Y%m%d")
  274. start = (datetime.now() - timedelta(days=days * 2)).strftime("%Y%m%d")
  275. try:
  276. sina_code = _to_sina_code(symbol)
  277. df = _safe_fetch(ak.stock_zh_a_daily,
  278. symbol=sina_code, start_date=start,
  279. end_date=end, adjust="qfq")
  280. if isinstance(df, str) or df is None or df.empty:
  281. # 尝试新版 API fallback
  282. df = _safe_fetch(ak.stock_zh_a_hist, symbol=symbol, period="daily", start_date=start, end_date=end, adjust="qfq")
  283. if isinstance(df, str) or df is None or df.empty:
  284. return f"未找到 {symbol} 的数据"
  285. if df is not None and not df.empty:
  286. # 统一列名为英文以适配下游逻辑
  287. rename_map = {
  288. "日期": "date", "开盘": "open", "收盘": "close",
  289. "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount"
  290. }
  291. df = df.rename(columns=rename_map)
  292. except Exception as e:
  293. return f"获取数据失败: {e}"
  294. df = df.tail(days).reset_index(drop=True)
  295. close = df["close"].astype(float)
  296. high = df["high"].astype(float)
  297. low = df["low"].astype(float)
  298. latest_close = close.iloc[-1]
  299. lines = [f"{symbol} 技术指标分析 (基于近{len(df)}条日K线)"]
  300. lines.append(f" 最新收盘价: {latest_close:.2f}")
  301. lines.append("")
  302. # --- 移动均线 ---
  303. lines.append("--- 移动均线 ---")
  304. ma_signals = []
  305. for ma in [5, 10, 20, 60]:
  306. if len(close) >= ma:
  307. ma_val = close.rolling(window=ma).mean().iloc[-1]
  308. relation = "[+]多头" if latest_close > ma_val else "[-]空头"
  309. lines.append(f" MA{ma:>2}: {ma_val:.2f} ({relation})")
  310. ma_signals.append(latest_close > ma_val)
  311. if ma_signals:
  312. bullish = sum(ma_signals)
  313. lines.append(f" 均线综合: {bullish}/{len(ma_signals)} 条支撑 "
  314. f"({'偏多' if bullish >= 3 else '偏空' if bullish <= 1 else '震荡'})")
  315. # --- MACD ---
  316. lines.append("")
  317. lines.append("--- MACD (12,26,9) ---")
  318. ema12 = close.ewm(span=12).mean()
  319. ema26 = close.ewm(span=26).mean()
  320. dif = ema12 - ema26
  321. dea = dif.ewm(span=9).mean()
  322. macd_bar = 2 * (dif - dea)
  323. lines.append(f" DIF: {dif.iloc[-1]:.3f} DEA: {dea.iloc[-1]:.3f}")
  324. bar_color = "红柱" if macd_bar.iloc[-1] > 0 else "绿柱"
  325. lines.append(f" MACD柱: {macd_bar.iloc[-1]:.3f} ({bar_color})")
  326. if len(dif) >= 2:
  327. if dif.iloc[-1] > dea.iloc[-1] and dif.iloc[-2] <= dea.iloc[-2]:
  328. lines.append(" [!] 信号: 金叉(买入信号)")
  329. elif dif.iloc[-1] < dea.iloc[-1] and dif.iloc[-2] >= dea.iloc[-2]:
  330. lines.append(" [!] 信号: 死叉(卖出信号)")
  331. else:
  332. trend = "多头" if dif.iloc[-1] > dea.iloc[-1] else "空头"
  333. lines.append(f" 趋势: {trend}持续")
  334. else:
  335. trend = "多头" if dif.iloc[-1] > dea.iloc[-1] else "空头"
  336. lines.append(f" 趋势: {trend}持续")
  337. # --- RSI ---
  338. lines.append("")
  339. lines.append("--- RSI (14) ---")
  340. delta = close.diff()
  341. gain = delta.clip(lower=0)
  342. loss = (-delta).clip(lower=0)
  343. avg_gain = gain.ewm(alpha=1/14).mean()
  344. avg_loss = loss.ewm(alpha=1/14).mean()
  345. rs = avg_gain / avg_loss
  346. rsi = 100 - (100 / (1 + rs))
  347. rsi_val = rsi.iloc[-1]
  348. if rsi_val > 80:
  349. rsi_status = "严重超买"
  350. elif rsi_val > 70:
  351. rsi_status = "超买区域"
  352. elif rsi_val < 20:
  353. rsi_status = "严重超卖"
  354. elif rsi_val < 30:
  355. rsi_status = "超卖区域"
  356. else:
  357. rsi_status = "中性"
  358. lines.append(f" RSI: {rsi_val:.1f} ({rsi_status})")
  359. # --- 布林带 ---
  360. lines.append("")
  361. lines.append("--- 布林带 (20,2) ---")
  362. bb_mid = close.rolling(window=20).mean()
  363. bb_std = close.rolling(window=20).std()
  364. bb_upper = bb_mid + 2 * bb_std
  365. bb_lower = bb_mid - 2 * bb_std
  366. lines.append(f" 上轨: {bb_upper.iloc[-1]:.2f}")
  367. lines.append(f" 中轨: {bb_mid.iloc[-1]:.2f}")
  368. lines.append(f" 下轨: {bb_lower.iloc[-1]:.2f}")
  369. bb_pos = (latest_close - bb_lower.iloc[-1]) / (bb_upper.iloc[-1] - bb_lower.iloc[-1])
  370. if bb_pos > 0.9:
  371. lines.append(f" 价格位于布林带上沿附近,注意压力")
  372. elif bb_pos < 0.1:
  373. lines.append(f" 价格位于布林带下沿附近,关注支撑")
  374. else:
  375. lines.append(f" 价格位于布林带中轨附近")
  376. # --- 支撑/压力位 ---
  377. lines.append("")
  378. lines.append("--- 关键价位 ---")
  379. recent_high = high.tail(20).max()
  380. recent_low = low.tail(20).min()
  381. lines.append(f" 近20日最高: {recent_high:.2f} (压力位)")
  382. lines.append(f" 近20日最低: {recent_low:.2f} (支撑位)")
  383. if len(close) >= 60:
  384. ma60 = close.rolling(60).mean().iloc[-1]
  385. lines.append(f" MA60: {ma60:.2f} (长期支撑/压力)")
  386. return "\n".join(lines)
  387. def get_news(symbol: str) -> str:
  388. """
  389. 获取近期新闻。输入: 股票代码(如"600519")
  390. 返回最新5条新闻标题。
  391. """
  392. print(f" [查询新闻] {symbol}")
  393. symbol = symbol.strip()
  394. try:
  395. news_df = _safe_fetch(ak.stock_news_em, symbol=symbol)
  396. if isinstance(news_df, str) or news_df is None or news_df.empty:
  397. return f"未找到 {symbol} 的相关新闻"
  398. except Exception as e:
  399. return f"获取新闻失败: {e}"
  400. recent = news_df.head(5)
  401. lines = [f"{symbol} 近期新闻:"]
  402. for i, (_, row) in enumerate(recent.iterrows(), 1):
  403. title = row.get("新闻标题", "N/A")
  404. dt = row.get("发布时间", "")
  405. content = row.get("新闻内容", "")
  406. summary = content[:80] + "..." if isinstance(content, str) and len(content) > 80 else str(content or "")
  407. lines.append(f" {i}. [{dt}] {title}")
  408. if summary:
  409. lines.append(f" {summary}")
  410. return "\n".join(lines)