feat: 移除 akshare 依赖,全部替换为直连 HTTP API

所有 18 个 akshare 调用替换为东财 datacenter/push2、新浪财经、
同花顺 10jqka、财联社 cls.cn 等直连 HTTP API。
彻底消除 akshare+pandas3.0+pyarrow 的 ArrowInvalid 崩溃,
以及 akshare 与 mootdx 的 httpx 版本冲突。

函数签名和返回格式保持不变,对上层 Agent 透明。
This commit is contained in:
simon 2026-05-17 17:26:39 +08:00
parent 9de2004fb2
commit f3c0fe4cfd
6 changed files with 470 additions and 248 deletions

View File

@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Breaking changes within the 0.x line are called out explicitly.
## [0.2.5] — 2026-05-17
### Breaking Changes
- **移除 akshare 依赖**`akshare>=1.18.0``pyproject.toml` 中删除。
所有原 akshare 调用已替换为直接 HTTP API东财 datacenter、新浪财经、
同花顺 10jqka、财联社 cls.cn、百度股市通
### Changed
- `tradingagents/dataflows/a_stock.py` 全面重构数据获取层:
- `get_stock_data()` → 新浪 JSON K线 API + push2.eastmoney 实时行情
- `get_stock_info()` → push2.eastmoney 个股基本信息
- `get_stock_news()` → 东财 np-weblist 滚动新闻(已有,无变化)
- `get_financial_data()` → 新浪财经财报三表 API
- `get_market_news()` → 财联社 cls.cn 快讯 + 东财 np-weblist
- `get_analyst_forecast()` → 同花顺 10jqka EPS 一致预期
- `get_dragon_tiger_board()` → 东财 datacenter RPT_DAILYBILLBOARD
- `get_restricted_release()` → 东财 datacenter RPT_LIFT_STAGE
- `get_industry_overview()` → push2.eastmoney 板块行情
- 新增内部 helper`_eastmoney_datacenter()``_ths_eps_forecast()``_sina_kline_fallback()`
- 所有函数签名和返回格式保持不变,对上层 Agent 透明
### Fixed
- 彻底消除 akshare + pandas 3.0 + pyarrow 的 `ArrowInvalid` 崩溃问题
- 消除 akshare 与 mootdx 的 httpx 版本冲突
## [0.2.4] — 2026-04-25
### Added

View File

@ -6,18 +6,22 @@
- **仓库**: https://github.com/simonlin1212/TradingAgents-astock
- **协议**: Apache 2.0
- **Python**: >=3.10
- **当前版本**: 0.2.4
- **当前版本**: 0.2.5
## 架构
### 数据层
### 数据层v0.2.5 全部直连 HTTP零第三方数据库依赖
| 来源 | 协议 | 数据 |
|------|------|------|
| mootdx | TCP 7709 | OHLCV K线、财务快照、F10 文本 |
| 腾讯财经 | HTTP (qt.gtimg.cn) | PE/PB/市值/换手率 |
| 东方财富 | HTTP (直连 API) | 个股新闻 |
| akshare | Python 库 | 财报三表、股票信息、一致预期 EPS |
| 同花顺 | HTTP | 热股题材归因、北向资金 |
| 东方财富 datacenter | HTTP (datacenter-web) | 龙虎榜、限售解禁、板块行情 |
| 东方财富 push2 | HTTP (push2.eastmoney) | 实时行情、个股信息、板块列表 |
| 东方财富 np-weblist | HTTP | 滚动新闻 |
| 新浪财经 | HTTP (money.finance.sina) | K线历史、财报三表 |
| 同花顺 10jqka | HTTP | EPS 一致预期、热股题材 |
| 财联社 cls.cn | HTTP | 全球财经快讯 |
| 百度股市通 | HTTP (gushitong.baidu) | 备用行情 |
### Agent 角色7 个)
原版 4 个(市场/情绪/新闻/基本面)+ A 股特化 3 个(政策分析师/游资追踪/解禁监控)
@ -37,8 +41,8 @@
### 依赖冲突
mootdx 锁死 httpx==0.25.2,与 langchain-google-genai 的 httpx>=0.28.1 冲突。不用 Google 模型时可 `pip install mootdx --no-deps` 绕过。
### akshare 上游 bug
akshare 在 pandas 3.0 + pyarrow 环境下,`stock_news_em``str.replace(r" ", "", regex=True)` 会崩。用户需升级 akshare 或手动改 `regex=False`。已在项目中增加东方财富直连 API 作为替代方案
### akshare 已移除v0.2.5
v0.2.5 起完全移除 akshare 依赖,所有数据通过直连 HTTP API 获取。彻底消除了 akshare + pandas 3.0 + pyarrow 的 `ArrowInvalid` 崩溃问题,也消除了 akshare 与 mootdx 的 httpx 版本冲突
### 模型兼容性
deepseek-v4-flash 等模型在 tool call 时可能返回中文股票名而非 6 位代码。`safe_ticker_component` 已加兜底自动转码,但不同模型表现仍有差异。

View File

@ -46,7 +46,7 @@
| 维度 | 原版 | 本 Fork |
|------|------|---------|
| **数据源** | Yahoo Finance / Alpha Vantage | mootdx + 腾讯财经 + akshare全免费 |
| **数据源** | Yahoo Finance / Alpha Vantage | mootdx + 东财 + 新浪 + 同花顺(全免费直连 |
| **Analyst 角色** | 4 个(市场/情绪/新闻/基本面) | **7 个**+政策分析师/游资追踪/解禁监控) |
| **交易规则** | 美股T+0、无涨跌停 | A 股T+1、涨跌停、最小手数、交易时段 |
| **输出语言** | 英文 | 中文报告(内部辩论保持英文以保证推理质量) |
@ -60,7 +60,7 @@
|------|-------------------|-------------|
| 许可证 | Apache 2.0 | **全 Apache 2.0** |
| 部署依赖 | pip install | **开箱即用** |
| A 股数据 | ❌ | **mootdx + 腾讯 + akshare免费** |
| A 股数据 | ❌ | **mootdx + 东财 + 新浪 + 同花顺(直连 HTTP** |
| A 股特化角色 | ❌ | **政策/游资/解禁 3 个深度角色** |
| A 股交易约束 | ❌ | **T+1/涨跌停/手数/ST 全覆盖** |
@ -130,7 +130,10 @@
|------|------|---------|
| **mootdx** | TCP 7709 | OHLCV K 线、财务快照、F10 文本 |
| **腾讯财经** | HTTP (`qt.gtimg.cn`) | PE / PB / 市值 / 换手率(实时) |
| **akshare** | Python 库 | 新闻、财报三表sina 源)、股票信息 |
| **东方财富** | HTTP (datacenter / push2) | 龙虎榜、限售解禁、板块行情、个股信息 |
| **新浪财经** | HTTP | K 线历史、财报三表 |
| **同花顺** | HTTP (10jqka) | EPS 一致预期 |
| **财联社** | HTTP (cls.cn) | 全球财经快讯 |
> 完全不依赖 Tushare积分墙、Alpha Vantage海外 API、Yahoo Finance不支持 A 股)。
@ -297,7 +300,7 @@ TradingAgents-Astock/
│ │ ├── trader/ # TraderA 股交易约束)
│ │ └── utils/ # 状态定义、工具函数
│ ├── dataflows/
│ │ ├── a_stock.py # A 股数据 vendormootdx + 腾讯 + akshare
│ │ ├── a_stock.py # A 股数据 vendor直连 HTTP API零第三方库
│ │ ├── interface.py # 数据接口抽象层
│ │ └── ...
│ └── graph/

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tradingagents-astock"
version = "0.2.4"
version = "0.2.5"
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
readme = "README.md"
requires-python = ">=3.10"
@ -44,7 +44,6 @@ dependencies = [
"fpdf2>=2.8.0",
"python-dotenv>=1.1.0",
"mootdx>=0.10.0",
"akshare>=1.18.0",
]
[project.scripts]

View File

@ -15,7 +15,7 @@ config["deep_think_llm"] = "claude-sonnet-4-6" # Kimi maps internally
config["quick_think_llm"] = "claude-sonnet-4-6"
config["backend_url"] = "https://api.kimi.com/coding/"
# --- Data: A-stock vendor (mootdx + tencent + akshare) ---
# --- Data: A-stock vendor (mootdx + tencent + eastmoney + sina) ---
config["data_vendors"] = {
"core_stock_apis": "a_stock",
"technical_indicators": "a_stock",
@ -33,7 +33,7 @@ print("TradingAgents-Astock E2E Test")
print("Ticker: 688017")
print("Trade date: 2026-04-30")
print("LLM: Kimi 2.6 via Anthropic API")
print("Data: a_stock (mootdx + tencent + akshare)")
print("Data: a_stock (mootdx + tencent + eastmoney + sina)")
print("=" * 60)
ta = TradingAgentsGraph(debug=True, config=config)

View File

@ -1,15 +1,15 @@
"""A-stock (China mainland) data vendor for TradingAgents.
Bridges EP11's a-stock-data Skill (mootdx + Tencent Finance + akshare + 同花顺)
into TradingAgents' plugin architecture. 12 vendor methods implemented.
Zero third-party data dependency (no akshare). All sources are direct HTTP APIs
or mootdx TCP.
Data sources:
- mootdx (TCP 7709): OHLCV K-lines, financial snapshots, F10 text
- Tencent Finance (HTTP GBK): PE/PB/market cap/turnover
- akshare (Python): financial statements, stock info, consensus EPS
- 东方财富 (direct HTTP): individual stock news
- 新浪财经 (HTTP): stock news (backup)
- 同花顺 (HTTP): hot stocks topic attribution, northbound capital flow
- 东方财富 push2 / datacenter-web (direct HTTP): stock info, dragon-tiger, lockup
- 新浪财经 (direct HTTP): K-line fallback, financial statements
- 同花顺 (direct HTTP): consensus EPS, hot stocks, northbound capital flow
- 财联社 (direct HTTP): global news wire
"""
from __future__ import annotations
@ -197,6 +197,120 @@ def _tencent_quote(codes: list[str]) -> dict[str, dict]:
return result
# ---------------------------------------------------------------------------
# Eastmoney Datacenter unified helper (龙虎榜/解禁 etc.)
# ---------------------------------------------------------------------------
_DATACENTER_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get"
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
def _eastmoney_datacenter(
report_name: str,
columns: str = "ALL",
filter_str: str = "",
page_size: int = 50,
sort_columns: str = "",
sort_types: str = "-1",
) -> list[dict]:
"""东财数据中心统一查询 — 龙虎榜/解禁 共用."""
params = {
"reportName": report_name,
"columns": columns,
"filter": filter_str,
"pageNumber": "1",
"pageSize": str(page_size),
"sortColumns": sort_columns,
"sortTypes": sort_types,
"source": "WEB",
"client": "WEB",
}
r = _requests.get(
_DATACENTER_URL, params=params, headers={"User-Agent": _UA}, timeout=15
)
d = r.json()
if d.get("result") and d["result"].get("data"):
return d["result"]["data"]
return []
# ---------------------------------------------------------------------------
# 同花顺 EPS forecast helper (direct HTTP, no akshare)
# ---------------------------------------------------------------------------
def _ths_eps_forecast(code: str) -> pd.DataFrame:
"""Fetch consensus EPS forecast from 同花顺 (direct HTTP).
Returns DataFrame with columns roughly: 年度, 预测机构数, 最小值, 均值, 最大值.
"""
url = f"https://basic.10jqka.com.cn/new/{code}/worth.html"
headers = {
"User-Agent": _UA,
"Referer": "https://basic.10jqka.com.cn/",
}
r = _requests.get(url, headers=headers, timeout=15)
r.encoding = "gbk"
dfs = pd.read_html(r.text)
# Find the table containing EPS data
for df in dfs:
cols = [str(c) for c in df.columns]
if any("每股收益" in c or "均值" in c for c in cols):
return df
# Fallback: return first table if exists
return dfs[0] if dfs else pd.DataFrame()
# ---------------------------------------------------------------------------
# Sina K-line fallback helper (direct HTTP, no akshare)
# ---------------------------------------------------------------------------
def _sina_kline_fallback(code: str, start_date: str = None, end_date: str = None) -> pd.DataFrame:
"""Fetch daily K-line from Sina HTTP API as mootdx fallback.
Returns DataFrame with columns: Date, Open, High, Low, Close, Volume.
"""
prefix = "sh" if code.startswith("6") else "sz"
url = (
"http://money.finance.sina.com.cn/quotes_service/api/json_v2.php/"
"CN_MarketData.getKLineData"
)
params = {
"symbol": f"{prefix}{code}",
"scale": "240", # daily
"ma": "no",
"datalen": "800",
}
r = _requests.get(url, params=params, timeout=15)
r.raise_for_status()
data = _json.loads(r.text)
if not data:
return pd.DataFrame()
rows = []
for item in data:
rows.append({
"Date": item["day"],
"Open": float(item["open"]),
"High": float(item["high"]),
"Low": float(item["low"]),
"Close": float(item["close"]),
"Volume": int(item["volume"]),
})
df = pd.DataFrame(rows)
df["Date"] = pd.to_datetime(df["Date"])
if start_date:
df = df[df["Date"] >= pd.to_datetime(start_date)]
if end_date:
df = df[df["Date"] <= pd.to_datetime(end_date)]
return df
# ---------------------------------------------------------------------------
# OHLCV loading with cache (mootdx -> CSV)
# ---------------------------------------------------------------------------
@ -250,32 +364,14 @@ def _load_ohlcv_astock(symbol: str, curr_date: str) -> pd.DataFrame:
df = df[["Date", "Open", "High", "Low", "Close", "Volume"]]
df["Date"] = pd.to_datetime(df["Date"])
except Exception as e:
logger.warning("mootdx OHLCV failed for %s: %s, trying akshare fallback", code, e)
# Fallback: akshare sina source
logger.warning("mootdx OHLCV failed for %s: %s, trying sina HTTP fallback", code, e)
# Fallback: Sina direct HTTP API
try:
import akshare as ak
prefix = "sh" if code.startswith("6") else "sz"
sina_symbol = f"{prefix}{code}"
# Calculate date range for akshare query (~3 years back)
end_dt = datetime.now()
start_dt = end_dt - relativedelta(years=3)
df_ak = ak.stock_zh_a_daily(
symbol=sina_symbol,
start_date=start_dt.strftime("%Y%m%d"),
end_date=end_dt.strftime("%Y%m%d"),
adjust="qfq",
)
if df_ak is None or df_ak.empty:
raise ValueError(f"No OHLCV data from akshare for {code}")
df_ak = df_ak.rename(columns={
"date": "Date", "open": "Open", "close": "Close",
"high": "High", "low": "Low", "volume": "Volume",
})
df_ak["Date"] = pd.to_datetime(df_ak["Date"])
df = df_ak[["Date", "Open", "High", "Low", "Close", "Volume"]].copy()
df = _sina_kline_fallback(code)
if df.empty:
raise ValueError(f"No OHLCV data from sina for {code}")
except Exception:
raise ValueError(f"No OHLCV data from mootdx for {code}")
raise ValueError(f"No OHLCV data from mootdx/sina for {code}")
# Cache to disk
df.to_csv(cache_file, index=False, encoding="utf-8")
@ -329,33 +425,15 @@ def get_stock_data(
df["Date"] = pd.to_datetime(df["Date"])
except Exception as e:
logger.warning("mootdx K-line failed for %s: %s, trying akshare fallback", code, e)
# Fallback: akshare sina source
logger.warning("mootdx K-line failed for %s: %s, trying sina HTTP fallback", code, e)
# Fallback: Sina direct HTTP API
try:
import akshare as ak
prefix = "sh" if code.startswith("6") else "sz"
sina_symbol = f"{prefix}{code}"
start_str = start_date.replace("-", "")
end_str = end_date.replace("-", "")
df_ak = ak.stock_zh_a_daily(
symbol=sina_symbol,
start_date=start_str,
end_date=end_str,
adjust="qfq",
)
if df_ak is not None and not df_ak.empty:
df_ak = df_ak.rename(columns={
"date": "Date", "open": "Open", "close": "Close",
"high": "High", "low": "Low", "volume": "Volume",
})
df_ak["Date"] = pd.to_datetime(df_ak["Date"])
df = df_ak
data_source = "akshare (sina fallback)"
else:
return "K线数据获取失败mootdx和akshare备用源均不可用请检查网络连接"
df = _sina_kline_fallback(code, start_date, end_date)
if df.empty:
return "K线数据获取失败mootdx和新浪备用源均不可用请检查网络连接"
data_source = "sina HTTP (fallback)"
except Exception:
return "K线数据获取失败mootdx和akshare备用源均不可用,请检查网络连接"
return "K线数据获取失败mootdx和新浪备用源均不可用请检查网络连接"
# Filter by date range
start_dt = pd.to_datetime(start_date)
@ -473,7 +551,7 @@ def get_fundamentals(
ticker: Annotated[str, "A-stock code"],
curr_date: Annotated[str, "current date"] = None,
) -> str:
"""Get company fundamentals from Tencent + mootdx + akshare."""
"""Get company fundamentals from Tencent + mootdx + Eastmoney + 同花顺."""
code = _normalize_ticker(ticker)
try:
@ -528,42 +606,63 @@ def get_fundamentals(
except Exception as e:
logger.warning("mootdx finance failed for %s: %s", code, e)
# --- akshare: basic stock info ---
# --- Eastmoney push2: basic stock info (direct HTTP) ---
try:
import akshare as ak
info_df = ak.stock_individual_info_em(symbol=code)
if info_df is not None and not info_df.empty:
for _, row in info_df.iterrows():
item = str(row.get("item", ""))
value = str(row.get("value", ""))
if item and value and value != "nan":
lines.append(f"{item}: {value}")
except Exception as e:
logger.warning("akshare stock_individual_info_em failed for %s: %s", code, e)
# --- akshare: consensus EPS forecast (同花顺) ---
try:
import akshare as ak
forecast_df = ak.stock_profit_forecast_ths(
symbol=code, indicator="预测年报每股收益"
market_code = 1 if code.startswith("6") else 0
_info_url = "https://push2.eastmoney.com/api/qt/stock/get"
_info_params = {
"fltt": "2",
"invt": "2",
"fields": "f57,f58,f84,f85,f127,f116,f117,f189,f43",
"secid": f"{market_code}.{code}",
}
r = _requests.get(
_info_url, params=_info_params,
headers={"User-Agent": _UA}, timeout=10,
)
d = r.json().get("data", {})
if d:
if d.get("f127"):
lines.append(f"行业: {d['f127']}")
if d.get("f84"):
lines.append(f"总股本: {d['f84']}")
if d.get("f85"):
lines.append(f"流通股本: {d['f85']}")
if d.get("f116"):
lines.append(f"总市值: {d['f116']}")
if d.get("f117"):
lines.append(f"流通市值: {d['f117']}")
if d.get("f189"):
lines.append(f"上市日期: {d['f189']}")
except Exception as e:
logger.warning("eastmoney push2 stock info failed for %s: %s", code, e)
# --- 同花顺 direct HTTP: consensus EPS forecast ---
try:
forecast_df = _ths_eps_forecast(code)
if forecast_df is not None and not forecast_df.empty:
lines.append("\n--- Consensus EPS Forecast (同花顺) ---")
eps_by_year = {}
for _, row in forecast_df.iterrows():
year = str(row.get("年度", ""))
mean_eps = float(row.get("均值", 0))
count = int(row.get("预测机构数", 0))
min_eps = row.get("最小值", "N/A")
max_eps = row.get("最大值", "N/A")
year = str(row.iloc[0]) if len(row) > 0 else ""
mean_eps_val = row.iloc[3] if len(row) > 3 else 0
count_val = row.iloc[1] if len(row) > 1 else 0
min_eps_val = row.iloc[2] if len(row) > 2 else "N/A"
max_eps_val = row.iloc[4] if len(row) > 4 else "N/A"
try:
mean_eps = float(mean_eps_val)
except (ValueError, TypeError):
mean_eps = 0
try:
count = int(count_val)
except (ValueError, TypeError):
count = 0
lines.append(
f"FY{year}: EPS={mean_eps} "
f"(range {min_eps}~{max_eps}, {count} analysts)"
f"(range {min_eps_val}~{max_eps_val}, {count} analysts)"
)
if count < 3:
lines.append(f" Warning: low coverage (<3 analysts)")
lines.append(" Warning: low coverage (<3 analysts)")
eps_by_year[year] = mean_eps
# Forward PE / PEG / PE digestion
@ -635,18 +734,37 @@ def _sina_stock_code(code: str) -> str:
def _get_financial_report_sina(
code: str, report_type: str, freq: str, curr_date: str = None,
) -> pd.DataFrame:
"""Shared helper: fetch financial report via akshare sina source.
"""Shared helper: fetch financial report via Sina direct HTTP API.
report_type: '资产负债表' | '利润表' | '现金流量表'
"""
import akshare as ak
_report_type_map = {
"资产负债表": "fzb",
"利润表": "lrb",
"现金流量表": "llb",
}
source_type = _report_type_map.get(report_type, "lrb")
sina_code = _sina_stock_code(code)
df = ak.stock_financial_report_sina(stock=sina_code, symbol=report_type)
prefix = "sh" if code.startswith("6") else "sz"
paper_code = f"{prefix}{code}"
url = "https://quotes.sina.cn/cn/api/openapi.php/CompanyFinanceService.getFinanceReport2022"
params = {
"paperCode": paper_code,
"source": source_type,
"type": "0",
"page": "1",
"num": "20",
}
r = _requests.get(url, params=params, headers={"User-Agent": _UA}, timeout=15)
d = r.json()
if df is None or df.empty:
result = d.get("result", {}).get("data", {})
items = result.get(source_type, [])
if not isinstance(items, list) or not items:
return pd.DataFrame()
df = pd.DataFrame(items)
# Filter by curr_date
if curr_date and "报告日" in df.columns:
df["报告日"] = pd.to_datetime(df["报告日"], errors="coerce")
@ -666,7 +784,7 @@ def get_balance_sheet(
freq: Annotated[str, "frequency: 'annual' or 'quarterly'"] = "quarterly",
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None,
) -> str:
"""Get balance sheet via akshare (sina source)."""
"""Get balance sheet via Sina direct HTTP API."""
code = _normalize_ticker(ticker)
try:
@ -678,7 +796,7 @@ def get_balance_sheet(
csv_string = df.to_csv(index=False)
header = f"# Balance Sheet for {code} (A-stock, {freq})\n"
header += "# Data source: akshare (sina)\n"
header += "# Data source: sina direct HTTP\n"
header += (
f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
)
@ -697,7 +815,7 @@ def get_cashflow(
freq: Annotated[str, "frequency: 'annual' or 'quarterly'"] = "quarterly",
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None,
) -> str:
"""Get cash flow statement via akshare (sina source)."""
"""Get cash flow statement via Sina direct HTTP API."""
code = _normalize_ticker(ticker)
try:
@ -709,7 +827,7 @@ def get_cashflow(
csv_string = df.to_csv(index=False)
header = f"# Cash Flow for {code} (A-stock, {freq})\n"
header += "# Data source: akshare (sina)\n"
header += "# Data source: sina direct HTTP\n"
header += (
f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
)
@ -728,7 +846,7 @@ def get_income_statement(
freq: Annotated[str, "frequency: 'annual' or 'quarterly'"] = "quarterly",
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None,
) -> str:
"""Get income statement via akshare (sina source)."""
"""Get income statement via Sina direct HTTP API."""
code = _normalize_ticker(ticker)
try:
@ -740,7 +858,7 @@ def get_income_statement(
csv_string = df.to_csv(index=False)
header = f"# Income Statement for {code} (A-stock, {freq})\n"
header += "# Data source: akshare (sina)\n"
header += "# Data source: sina direct HTTP\n"
header += (
f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
)
@ -918,9 +1036,7 @@ def get_global_news(
look_back_days: Annotated[int, "Days to look back"] = 7,
limit: Annotated[int, "Max articles"] = 10,
) -> str:
"""Get China/global financial news via akshare (CLS + eastmoney)."""
import akshare as ak
"""Get China/global financial news via direct HTTP (CLS + Eastmoney)."""
start_dt = datetime.strptime(curr_date, "%Y-%m-%d") - relativedelta(
days=look_back_days
)
@ -928,41 +1044,56 @@ def get_global_news(
all_news: list[dict] = []
# Source 1: CLS wire (财联社快讯)
# Source 1: CLS wire (财联社快讯) — direct HTTP
try:
df_cls = ak.stock_info_global_cls()
if df_cls is not None and not df_cls.empty:
for _, row in df_cls.head(limit).iterrows():
title = str(row.get("标题", row.get("title", "")))
content = str(row.get("内容", row.get("content", "")))
pub_time = str(row.get("发布时间", row.get("datetime", "")))
all_news.append(
{
"title": title,
"content": content,
"time": pub_time,
"source": "CLS Wire",
}
)
cls_url = "https://www.cls.cn/nodeapi/telegraphList"
cls_params = {"rn": str(limit), "page": "1"}
cls_headers = {"User-Agent": _UA, "Referer": "https://www.cls.cn/"}
r_cls = _requests.get(cls_url, params=cls_params, headers=cls_headers, timeout=10)
d_cls = r_cls.json()
for item in d_cls.get("data", {}).get("roll_data", []):
title = item.get("title", "") or item.get("brief", "")
content = item.get("content", "") or item.get("brief", "")
ctime = item.get("ctime", "")
# ctime is unix timestamp
pub_time = ""
if ctime:
try:
pub_time = datetime.fromtimestamp(int(ctime)).strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError, OSError):
pub_time = str(ctime)
all_news.append({
"title": title,
"content": content,
"time": pub_time,
"source": "CLS Wire",
})
except Exception as e:
logger.warning("CLS news fetch failed: %s", e)
# Source 2: Eastmoney global (东财全球资讯)
# Source 2: Eastmoney global (东财7x24资讯) — direct HTTP
try:
df_em = ak.stock_info_global_em()
if df_em is not None and not df_em.empty:
for _, row in df_em.head(limit).iterrows():
title = str(row.get("标题", row.get("title", "")))
summary = str(row.get("摘要", row.get("summary", "")))
pub_time = str(row.get("发布时间", row.get("datetime", "")))
all_news.append(
{
"title": title,
"content": summary,
"time": pub_time,
"source": "Eastmoney Global",
}
)
em_url = "https://np-weblist.eastmoney.com/comm/web/getFastNewsList"
em_params = {
"client": "web",
"biz": "web_724",
"fastColumn": "102",
"sortEnd": "",
"pageSize": str(limit),
}
em_headers = {"User-Agent": _UA, "Referer": "https://kuaixun.eastmoney.com/"}
r_em = _requests.get(em_url, params=em_params, headers=em_headers, timeout=10)
d_em = r_em.json()
for item in d_em.get("data", {}).get("fastNewsList", []):
title = item.get("title", "")
summary = item.get("summary", "")[:200]
pub_time = item.get("showTime", "")
all_news.append({
"title": title,
"content": summary,
"time": pub_time,
"source": "Eastmoney Global",
})
except Exception as e:
logger.warning("Eastmoney global news fetch failed: %s", e)
@ -1051,37 +1182,40 @@ def get_profit_forecast(
ticker: Annotated[str, "A-stock code"],
curr_date: Annotated[str, "current date (unused, for interface compat)"] = None,
) -> str:
"""Get consensus EPS forecasts with forward valuation (akshare → 同花顺)."""
import akshare as ak
"""Get consensus EPS forecasts with forward valuation (同花顺 direct HTTP)."""
code = _normalize_ticker(ticker)
try:
df = ak.stock_profit_forecast_ths(
symbol=code, indicator="预测年报每股收益"
)
df = _ths_eps_forecast(code)
if df is None or df.empty:
return f"No analyst coverage found for A-stock '{code}'"
lines = [
f"# Consensus EPS Forecast for {code} (A-stock)",
f"# Source: 同花顺 analyst consensus",
f"# Source: 同花顺 analyst consensus (direct HTTP)",
f"# Retrieved: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
]
eps_by_year = {}
for _, row in df.iterrows():
year = str(row.get("年度", ""))
count = int(row.get("预测机构数", 0))
mean_eps = float(row.get("均值", 0))
min_eps = row.get("最小值", "N/A")
max_eps = row.get("最大值", "N/A")
industry_avg = row.get("行业平均数", "N/A")
year = str(row.iloc[0]) if len(row) > 0 else ""
count_val = row.iloc[1] if len(row) > 1 else 0
mean_eps_val = row.iloc[3] if len(row) > 3 else 0
min_eps_val = row.iloc[2] if len(row) > 2 else "N/A"
max_eps_val = row.iloc[4] if len(row) > 4 else "N/A"
try:
count = int(count_val)
except (ValueError, TypeError):
count = 0
try:
mean_eps = float(mean_eps_val)
except (ValueError, TypeError):
mean_eps = 0
lines.append(
f"FY{year}: EPS={mean_eps} (range {min_eps}~{max_eps}), "
f"analysts={count}, industry_avg={industry_avg}"
f"FY{year}: EPS={mean_eps} (range {min_eps_val}~{max_eps_val}), "
f"analysts={count}"
)
if count < 3:
lines.append(" Warning: low coverage (<3 analysts)")
@ -1599,71 +1733,107 @@ def get_dragon_tiger_board(
Formatted text with LHB appearances, top buyer/seller seats,
and institutional activity.
"""
import akshare as ak
code = safe_ticker_component(ticker)
end_dt = datetime.strptime(trade_date, "%Y-%m-%d")
start_dt = end_dt - pd.Timedelta(days=look_back_days)
start_date = start_dt.strftime("%Y%m%d")
end_date = end_dt.strftime("%Y%m%d")
start_date_str = start_dt.strftime("%Y-%m-%d")
lines = [f"# 龙虎榜数据 | {code} | {trade_date} (近{look_back_days}日)"]
# 1. 上榜记录 — eastmoney datacenter direct HTTP
try:
df = ak.stock_lhb_detail_em(start_date=start_date, end_date=end_date)
stock_df = df[df["代码"] == code]
if stock_df.empty:
data = _eastmoney_datacenter(
"RPT_DAILYBILLBOARD_DETAILSNEW",
filter_str=(
f"(TRADE_DATE>='{start_date_str}')"
f"(TRADE_DATE<='{trade_date}')"
f"(SECURITY_CODE=\"{code}\")"
),
page_size=50,
sort_columns="TRADE_DATE",
sort_types="-1",
)
if not data:
lines.append(f"\n{look_back_days}日未上龙虎榜。")
else:
lines.append(f"\n## 上榜记录 ({len(stock_df)} 次)")
lines.append("日期 | 原因 | 净买入(万) | 成交额(万) | 换手率")
for _, row in stock_df.iterrows():
lines.append(f"\n## 上榜记录 ({len(data)} 次)")
lines.append("日期 | 原因 | 净买入(万) | 换手率")
for row in data:
net_buy = round((row.get("BILLBOARD_NET_AMT") or 0) / 10000, 1)
turnover = round(float(row.get("TURNOVERRATE") or 0), 2)
lines.append(
f" {row.get('上榜日', '')} | {row.get('上榜原因', '')} "
f"| {row.get('龙虎榜净买额', 0):.0f} "
f"| {row.get('成交额', 0):.0f} "
f"| {row.get('换手率', 0):.2f}%"
f" {str(row.get('TRADE_DATE', ''))[:10]} "
f"| {row.get('EXPLANATION', '')} "
f"| {net_buy:.0f} "
f"| {turnover:.2f}%"
)
except Exception as e:
lines.append(f"龙虎榜列表查询失败: {e}")
# 2. 最近上榜的买卖席位 — eastmoney datacenter direct HTTP
try:
dates_df = ak.stock_lhb_stock_detail_date_em(symbol=code)
if dates_df is not None and not dates_df.empty:
latest_date = str(dates_df.iloc[0]["交易日"]).split(" ")[0]
if data:
latest_date = str(data[0].get("TRADE_DATE", ""))[:10]
lines.append(f"\n## 最近上榜席位明细 ({latest_date})")
for flag_label in ("买入", "卖出"):
try:
seat_df = ak.stock_lhb_stock_detail_em(
symbol=code, date=latest_date, flag=flag_label
# 买入席位
buy_data = _eastmoney_datacenter(
"RPT_BILLBOARD_DAILYDETAILSBUY",
filter_str=f"(TRADE_DATE='{latest_date}')(SECURITY_CODE=\"{code}\")",
page_size=10,
sort_columns="BUY",
sort_types="-1",
)
if buy_data:
lines.append("\n### 买入席位 TOP5")
lines.append("营业部 | 买入(万) | 卖出(万) | 净额(万)")
for row in buy_data[:5]:
buy_amt = round((row.get("BUY") or 0) / 10000, 1)
sell_amt = round((row.get("SELL") or 0) / 10000, 1)
net = round((row.get("NET") or 0) / 10000, 1)
lines.append(
f" {row.get('OPERATEDEPT_NAME', '')} "
f"| {buy_amt:.0f} | {sell_amt:.0f} | {net:.0f}"
)
# 卖出席位
sell_data = _eastmoney_datacenter(
"RPT_BILLBOARD_DAILYDETAILSSELL",
filter_str=f"(TRADE_DATE='{latest_date}')(SECURITY_CODE=\"{code}\")",
page_size=10,
sort_columns="SELL",
sort_types="-1",
)
if sell_data:
lines.append("\n### 卖出席位 TOP5")
lines.append("营业部 | 买入(万) | 卖出(万) | 净额(万)")
for row in sell_data[:5]:
buy_amt = round((row.get("BUY") or 0) / 10000, 1)
sell_amt = round((row.get("SELL") or 0) / 10000, 1)
net = round((row.get("NET") or 0) / 10000, 1)
lines.append(
f" {row.get('OPERATEDEPT_NAME', '')} "
f"| {buy_amt:.0f} | {sell_amt:.0f} | {net:.0f}"
)
if seat_df is not None and not seat_df.empty:
lines.append(f"\n### {flag_label}席位 TOP5")
lines.append("营业部 | 买入(万) | 卖出(万) | 净额(万)")
for _, row in seat_df.head(5).iterrows():
lines.append(
f" {row.get('交易营业部名称', '')} "
f"| {row.get('买入金额', 0):.0f} "
f"| {row.get('卖出金额', 0):.0f} "
f"| {row.get('净额', 0):.0f}"
)
except Exception:
pass
except Exception:
pass
# 3. 机构动向 — eastmoney datacenter direct HTTP
try:
inst_df = ak.stock_lhb_jgmmtj_em(
start_date=start_date, end_date=end_date
inst_data = _eastmoney_datacenter(
"RPT_ORGANIZATION_BUSSINESS",
filter_str=f"(SECURITY_CODE=\"{code}\")",
page_size=1,
sort_columns="TRADE_DATE",
sort_types="-1",
)
stock_inst = inst_df[inst_df["代码"] == code]
if not stock_inst.empty:
if inst_data:
row = inst_data[0]
lines.append("\n## 机构动向")
for _, row in stock_inst.iterrows():
lines.append(
f" 机构买入 {row.get('买方机构数', 0)}"
f"| 卖出 {row.get('卖方机构数', 0)}"
f"| 净额 {row.get('机构买卖净额', row.get('净额', 0)):.0f}"
)
lines.append(
f" 机构买入 {row.get('BUY_TIMES', 0)}"
f"| 卖出 {row.get('SELL_TIMES', 0)}"
f"| 净额 {round((row.get('NET_BUY_AMT') or 0) / 10000, 1):.0f}"
)
except Exception:
pass
@ -1690,55 +1860,61 @@ def get_lockup_expiry(
Formatted text with historical unlock records and upcoming
expiry calendar with impact metrics.
"""
import akshare as ak
code = safe_ticker_component(ticker)
lines = [f"# 限售解禁日历 | {code} | {trade_date}"]
# 1. 历史解禁记录 — eastmoney datacenter direct HTTP
try:
df = ak.stock_restricted_release_queue_em(symbol=code)
if df is not None and not df.empty:
lines.append(f"\n## 个股解禁记录 (共 {len(df)} 批)")
lines.append(
"解禁时间 | 类型 | 解禁数量 | 占总市值比例 | 解禁前后涨跌幅"
)
for _, row in df.head(15).iterrows():
history_data = _eastmoney_datacenter(
"RPT_LIFT_STAGE",
filter_str=f"(SECURITY_CODE=\"{code}\")",
page_size=15,
sort_columns="FREE_DATE",
sort_types="-1",
)
if history_data:
lines.append(f"\n## 个股解禁记录 (共 {len(history_data)} 批)")
lines.append("解禁时间 | 类型 | 解禁数量 | 占比")
for row in history_data:
lines.append(
f" {row.get('解禁时间', '')} "
f"| {row.get('限售股类型', '')} "
f"| {row.get('实际解禁数量', row.get('解禁数量', ''))} "
f"| {row.get('占总市值比例', '')} "
f"| {row.get('解禁前后20日涨跌幅', '')}"
f" {str(row.get('FREE_DATE', ''))[:10]} "
f"| {row.get('LIMITED_STOCK_TYPE', '')} "
f"| {row.get('FREE_SHARES_NUM', '')} "
f"| {row.get('FREE_RATIO', '')}"
)
else:
lines.append("\n无历史解禁记录。")
except Exception as e:
lines.append(f"个股解禁查询失败: {e}")
# 2. 未来待解禁 — eastmoney datacenter direct HTTP
try:
end_dt = datetime.strptime(trade_date, "%Y-%m-%d") + pd.Timedelta(
days=forward_days
)
df = ak.stock_restricted_release_detail_em(
start_date=trade_date.replace("-", ""),
end_date=end_dt.strftime("%Y%m%d"),
end_str = end_dt.strftime("%Y-%m-%d")
upcoming_data = _eastmoney_datacenter(
"RPT_LIFT_STAGE",
filter_str=(
f"(SECURITY_CODE=\"{code}\")"
f"(FREE_DATE>='{trade_date}')"
f"(FREE_DATE<='{end_str}')"
),
page_size=20,
sort_columns="FREE_DATE",
sort_types="1",
)
if df is not None:
stock_df = df[df["股票代码"] == code]
if not stock_df.empty:
if upcoming_data:
lines.append(f"\n## 未来 {forward_days} 天待解禁")
for row in upcoming_data:
lines.append(
f"\n## 未来 {forward_days} 天待解禁"
f" {str(row.get('FREE_DATE', ''))[:10]} "
f"| {row.get('LIMITED_STOCK_TYPE', '')} "
f"| 数量 {row.get('FREE_SHARES_NUM', '')} "
f"| 占比 {row.get('FREE_RATIO', '')}"
)
for _, row in stock_df.iterrows():
lines.append(
f" {row.get('解禁时间', '')} "
f"| {row.get('限售股类型', '')} "
f"| 数量 {row.get('实际解禁数量', row.get('解禁数量', ''))} "
f"| 占流通 {row.get('占流通市值比例', '')} "
f"| 解禁前收盘 {row.get('解禁前收盘价', '')}"
)
else:
lines.append(f"\n未来 {forward_days} 天无待解禁。")
else:
lines.append(f"\n未来 {forward_days} 天无待解禁。")
except Exception as e:
lines.append(f"解禁日历查询失败: {e}")
@ -1765,33 +1941,45 @@ def get_industry_comparison(
Formatted text with sector performance ranking, highlighting
the sector the target stock belongs to.
"""
import akshare as ak
code = safe_ticker_component(ticker)
lines = [f"# 行业横向对比 | {code} | {trade_date}"]
# 东财 push2 行业板块排名 (direct HTTP, replaces 同花顺 which has 401)
try:
df = ak.stock_board_industry_summary_ths()
if df is not None and not df.empty:
url = "https://push2.eastmoney.com/api/qt/clist/get"
params = {
"pn": "1",
"pz": "100",
"po": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"fs": "m:90+t:2",
"fields": "f2,f3,f4,f12,f13,f14,f104,f105,f128,f136,f140,f141,f207",
}
r = _requests.get(url, params=params, headers={"User-Agent": _UA}, timeout=15)
d = r.json()
items = d.get("data", {}).get("diff", [])
if items:
lines.append(
f"\n## 全行业表现 (同花顺 {len(df)} 个行业)"
f"\n## 全行业表现 (东财 {len(items)} 个行业)"
)
lines.append(
"排名 | 行业 | 涨跌幅 | 成交额(亿) | 净流入(亿) "
"| 上涨 | 下跌 | 领涨股"
"排名 | 行业 | 涨跌幅 | 上涨 | 下跌 | 领涨股"
)
for i, (_, row) in enumerate(df.iterrows()):
change = row.get("涨跌幅", 0)
turnover = row.get("总成交额", 0)
net_flow = row.get("净流入", 0)
for i, item in enumerate(items):
name = item.get("f14", "")
change_pct = item.get("f3", 0)
up_count = item.get("f104", 0)
down_count = item.get("f105", 0)
leader = item.get("f140", "")
lines.append(
f" {i+1}. {row.get('板块', '')} "
f"| {change}% "
f"| {turnover:.1f} "
f"| {net_flow:.1f} "
f"| {row.get('上涨家数', '')} "
f"| {row.get('下跌家数', '')} "
f"| {row.get('领涨股', '')}"
f" {i+1}. {name} "
f"| {change_pct}% "
f"| {up_count} "
f"| {down_count} "
f"| {leader}"
)
if i >= top_n * 2 - 1:
lines.append(f" ... (showing top/bottom {top_n})")