Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Chinese A-share stock analysis via natural language using AKShare — real-time quotes, technicals, fundamentals, sectors, and derivatives.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
adapters/akshare_adapter.py
1#!/usr/bin/env python32# -*- coding: utf-8 -*-34from contextlib import redirect_stderr, redirect_stdout5from datetime import datetime, timedelta6from io import StringIO7import os8from typing import Any, Dict, Optional91011class AkshareAdapter:12def __init__(self) -> None:13self._ak = None14self._import_error = None15try:16import akshare as ak # type: ignore1718self._ak = ak19except Exception as exc:20self._import_error = str(exc)2122def _wrap(self, fn_name: str, **payload: Any) -> Dict[str, Any]:23return {24"ok": True,25"source": "akshare",26"api": fn_name,27"data": payload,28}2930def _error(self, fn_name: str, message: str) -> Dict[str, Any]:31return {32"ok": False,33"source": "akshare",34"api": fn_name,35"error": message,36}3738def _ready_or_error(self, fn_name: str) -> Optional[Dict[str, Any]]:39if self._ak is None:40return self._error(fn_name, f"akshare import failed: {self._import_error}")41return None4243def _to_records(self, data: Any, top_n: int = 10) -> Any:44if data is None:45return []4647if hasattr(data, "head") and hasattr(data, "to_dict"):48try:49if top_n and top_n > 0:50return data.head(top_n).to_dict(orient="records")51return data.to_dict(orient="records")52except Exception:53return str(data)5455return data5657def _data_len(self, data: Any) -> int:58try:59return int(len(data))60except Exception:61return 06263def _normalize_trade_date(self, value: Optional[str]) -> str:64if not value or value in {"today", "今日", "今天"}:65return datetime.now().strftime("%Y%m%d")66if value in {"yesterday", "昨日", "昨天"}:67return (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")68return str(value).replace("-", "").replace("/", "")6970def _clean_symbol(self, symbol: Optional[str]) -> str:71if not symbol:72return ""73return str(symbol).lower().replace("sz", "").replace("sh", "").replace("bj", "")7475def _market_from_symbol(self, symbol: str) -> str:76market = "sh"77if symbol.startswith(("0", "3")):78market = "sz"79elif symbol.startswith(("8", "4")):80market = "bj"81return market8283def _filter_records_by_symbol(self, records: list[dict], symbol: str) -> list[dict]:84if not symbol:85return records8687key_pool = ["代码", "股票代码", "证券代码", "symbol", "代码简称"]88filtered = []89for row in records:90if not isinstance(row, dict):91continue92for key in key_pool:93val = row.get(key)94if val is not None and symbol in str(val):95filtered.append(row)96break97return filtered9899def _call_api_candidates(self, candidates: list[tuple[str, list[dict]]]) -> tuple[Optional[str], Any, str]:100errors = []101102for fn_name, kwargs_list in candidates:103func = getattr(self._ak, fn_name, None)104if func is None:105continue106107args_pool = kwargs_list or [{}]108for kwargs in args_pool:109try:110result = func(**kwargs)111return fn_name, result, ""112except Exception as exc:113errors.append(f"{fn_name}({kwargs}): {exc}")114115return None, None, "; ".join(errors) if errors else "no callable api found"116117def index_spot(self, top_n: int = 300) -> Dict[str, Any]:118primary_fn = "stock_zh_index_spot_sina"119err = self._ready_or_error(primary_fn)120if err:121return err122123try:124df = self._ak.stock_zh_index_spot_sina()125return self._wrap(primary_fn, items=self._to_records(df, top_n=top_n))126except Exception as exc:127fallback_fn = "stock_zh_index_spot_em"128try:129df = self._ak.stock_zh_index_spot_em()130return self._wrap(fallback_fn, items=self._to_records(df, top_n=top_n))131except Exception as fallback_exc:132return self._error(primary_fn, f"sina failed: {exc}; em failed: {fallback_exc}")133134def stock_kline(135self,136symbol: str,137period: str = "daily",138start_date: Optional[str] = None,139end_date: Optional[str] = None,140top_n: int = 60,141) -> Dict[str, Any]:142fn_name = "stock_zh_a_hist"143err = self._ready_or_error(fn_name)144if err:145return err146147if not start_date:148end_dt = datetime.now()149if period == "weekly":150days = top_n * 7151elif period == "monthly":152days = top_n * 30153else:154days = top_n155start_dt = end_dt - timedelta(days=days + 50)156start = start_dt.strftime("%Y%m%d")157else:158start = start_date.replace("-", "")159160end = self._normalize_trade_date(end_date)161162try:163df = self._ak.stock_zh_a_hist(164symbol=symbol,165period=period,166start_date=start,167end_date=end,168adjust="",169)170if hasattr(df, "iloc"):171df = df.iloc[::-1]172return self._wrap(173fn_name,174symbol=symbol,175period=period,176start_date=start,177end_date=end,178items=self._to_records(df, top_n=top_n),179)180except Exception as exc:181return self._error(fn_name, str(exc))182183def stock_chart(self, symbol: str, period: str = "daily", days: int = 30) -> Dict[str, Any]:184"""生成股票K线图"""185fn_name = "stock_chart"186err = self._ready_or_error(fn_name)187if err:188return err189190try:191import matplotlib192matplotlib.use('Agg')193import matplotlib.pyplot as plt194import matplotlib.font_manager as fm195196# 设置中文字体197font_paths = [198'/Library/Fonts/Microsoft/SimHei.ttf',199'/Library/Fonts/Microsoft/Microsoft Yahei.ttf',200'/Users/molezz/Library/Fonts/msyh.ttf',201'/System/Library/Fonts/STHeiti Medium.ttc',202]203found_font = None204for fp in font_paths:205if os.path.exists(fp):206found_font = fp207break208if found_font:209fm.fontManager.addfont(found_font)210prop = fm.FontProperties(fname=found_font)211plt.rcParams['font.sans-serif'] = [prop.get_name()]212plt.rcParams['axes.unicode_minus'] = False213214# 计算日期215from datetime import datetime, timedelta216end_date = datetime.now().strftime("%Y%m%d")217start_date = (datetime.now() - timedelta(days=days+30)).strftime("%Y%m%d")218219# 获取数据220df = self._ak.stock_zh_a_hist(symbol=symbol, period=period, start_date=start_date, end_date=end_date)221if df is None or len(df) == 0:222return self._error(fn_name, "无法获取数据")223224# 取最近的数据225df = df.tail(days)226227# 获取股票名称228name = symbol229try:230info = self._ak.stock_individual_info_em(symbol=symbol)231if info is not None and len(info) > 0:232# 尝试获取"股票简称"233name_row = info[info.get('item', '') == '股票简称']234if len(name_row) > 0:235name = name_row.iloc[0].get('value', symbol)236else:237# 如果没有简称,用代码238name = symbol239except:240pass241242# 绘图243plt.figure(figsize=(10, 6))244plt.plot(df['日期'], df['收盘'], 'b-', linewidth=1.5)245plt.title(f'{name}({symbol}) 近期股价走势', fontsize=14)246plt.xlabel('日期')247plt.ylabel('收盘价 (元)')248plt.grid(True, alpha=0.3)249plt.xticks(rotation=45)250plt.tight_layout()251252# 保存253chart_dir = "/tmp/stock_charts"254os.makedirs(chart_dir, exist_ok=True)255filepath = f"{chart_dir}/{symbol}.png"256plt.savefig(filepath, dpi=120)257plt.close()258259return {260"ok": True,261"data": {262"symbol": symbol,263"name": name,264"filepath": filepath,265"period": period,266"days": days,267},268"image_path": filepath269}270except Exception as exc:271return self._error(fn_name, str(exc))272273def stock_intraday(self, symbol: str, period: Optional[str] = None, top_n: int = 30) -> Dict[str, Any]:274fn_name = "stock_intraday"275err = self._ready_or_error(fn_name)276if err:277return err278279minute_error = None280minute_period = period if period in {"1", "5", "15", "30", "60"} else "1"281282try:283df = self._ak.stock_zh_a_minute(symbol=symbol, period=minute_period, adjust="")284if hasattr(df, "iloc"):285df = df.iloc[::-1]286return self._wrap(287"stock_zh_a_minute",288symbol=symbol,289period=minute_period,290items=self._to_records(df, top_n=top_n),291)292except Exception as exc:293minute_error = str(exc)294295try:296df = self._ak.stock_intraday_em(symbol=symbol)297return self._wrap(298"stock_intraday_em",299symbol=symbol,300period="tick",301fallback=minute_error,302items=self._to_records(df, top_n=top_n),303)304except Exception as exc:305if minute_error:306return self._error(fn_name, f"minute failed: {minute_error}; tick failed: {exc}")307return self._error(fn_name, str(exc))308309def limit_pool(self, date: Optional[str] = None, top_n: int = 50) -> Dict[str, Any]:310fn_name = "stock_zt_pool_em"311err = self._ready_or_error(fn_name)312if err:313return err314315trade_date = self._normalize_trade_date(date)316317try:318up_df = self._ak.stock_zt_pool_em(date=trade_date)319up_count = self._data_len(up_df)320up_items = self._to_records(up_df, top_n=top_n)321322down_count = 0323down_items: Any = []324down_api = None325down_errors = []326327for api_name in ["stock_zt_pool_dtgc_em", "stock_dt_pool_em"]:328func = getattr(self._ak, api_name, None)329if func is None:330continue331try:332down_df = func(date=trade_date)333down_count = self._data_len(down_df)334down_items = self._to_records(down_df, top_n=top_n)335down_api = api_name336break337except Exception as exc:338down_errors.append(f"{api_name}: {exc}")339340payload: Dict[str, Any] = {341"date": trade_date,342"up_count": up_count,343"down_count": down_count,344"up_items": up_items,345"down_items": down_items,346"items": up_items,347}348if down_api:349payload["down_api"] = down_api350if down_errors and not down_api:351payload["down_error"] = "; ".join(down_errors)352353return self._wrap(fn_name, **payload)354except Exception as exc:355return self._error(fn_name, str(exc))356357def news(self, top_n: int = 10) -> Dict[str, Any]:358"""财经要闻359360旧实现依赖 akshare.stock_news_em(东财接口),在部分环境下可能长期返回历史日期。361这里改为用 agent-browser 抓取「东方财富财经首页」的最新要闻链接。362363返回字段尽量与原 formatter 兼容:新闻标题/新闻链接/发布时间/文章来源。364"""365366fn_name = "eastmoney_finance_home"367368# 1) Primary: agent-browser scrape369try:370import json371import subprocess372373n = max(1, min(int(top_n or 10), 20))374375# 打开财经首页(复用默认 session,执行很快)376subprocess.run(377["agent-browser", "open", "https://finance.eastmoney.com/"],378capture_output=True,379text=True,380timeout=20,381check=False,382)383384js = r"""385(() => {386const now = new Date();387const pad2 = (x) => String(x).padStart(2, '0');388const today = `${now.getFullYear()}-${pad2(now.getMonth()+1)}-${pad2(now.getDate())}`;389const ymd = `${now.getFullYear()}${pad2(now.getMonth()+1)}${pad2(now.getDate())}`;390const ymdYesterday = (() => {391const d = new Date(now.getTime() - 24*3600*1000);392return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}`;393})();394395const links = Array.from(document.querySelectorAll('a[href]'));396const items = [];397const seen = new Set();398399for (const a of links) {400const href = a.href || '';401if (!href.includes('finance.eastmoney.com/a/')) continue;402// 排除频道页(/a/cxxxx.html)403if (/\/a\/c\w+\.html/.test(href)) continue;404405// 只保留今天/昨天的文章(避免首页混入更早的深链)406const dm = href.match(/\/a\/(\d{8})\d+\.html/);407if (!dm) continue;408const d8 = dm[1];409if (!(d8 === ymd || d8 === ymdYesterday)) continue;410411const title = (a.textContent || '').replace(/\s+/g, ' ').trim();412if (!title || title.length < 6) continue;413if (seen.has(href)) continue;414seen.add(href);415416const publish = `${d8.slice(0,4)}-${d8.slice(4,6)}-${d8.slice(6,8)}`;417418items.push({419'新闻标题': title,420'新闻链接': href,421'发布时间': publish || today,422'文章来源': '东方财富网'423});424425if (items.length >= 80) break;426}427428return JSON.stringify(items);429})();430"""431432p = subprocess.run(433["agent-browser", "eval", js],434capture_output=True,435text=True,436timeout=20,437check=False,438)439440if p.returncode == 0 and p.stdout:441raw = p.stdout.strip()442# agent-browser 可能会把 JSON 字符串再包一层引号,这里统一处理443# agent-browser 的输出有两种形态:444# 1) 直接 JSON 数组:[{...},{...}]445# 2) JSON 字符串(外层带引号):"[{...},{...}]"446data = json.loads(raw)447if isinstance(data, str):448data = json.loads(data)449450items = (data or [])[:n]451return self._wrap(fn_name, items=items)452453# fallthrough to error454err_msg = (p.stderr or p.stdout or "agent-browser eval failed").strip()455return self._error(fn_name, err_msg)456457except Exception as exc:458return self._error(fn_name, str(exc))459460def research_report(self, symbol: str, top_n: int = 10) -> Dict[str, Any]:461fn_name = "stock_research_report_em"462err = self._ready_or_error(fn_name)463if err:464return err465466clean_symbol = self._clean_symbol(symbol)467if not clean_symbol:468return self._error(fn_name, "symbol is required")469470try:471with redirect_stdout(StringIO()), redirect_stderr(StringIO()):472df = self._ak.stock_research_report_em(symbol=clean_symbol)473items = self._to_records(df, top_n=max(1, min(top_n, 10)))474return self._wrap(fn_name, symbol=clean_symbol, items=items)475except Exception as exc:476return self._error(fn_name, str(exc))477478def money_flow(self, symbol: str, top_n: int = 30) -> Dict[str, Any]:479fn_name = "stock_individual_fund_flow"480err = self._ready_or_error(fn_name)481if err:482return err483484clean_symbol = self._clean_symbol(symbol)485market = self._market_from_symbol(clean_symbol)486487try:488df = self._ak.stock_individual_fund_flow(stock=clean_symbol, market=market)489if hasattr(df, "iloc"):490df = df.iloc[::-1]491return self._wrap(492fn_name,493scope="individual",494symbol=clean_symbol,495market=market,496items=self._to_records(df, top_n=top_n),497)498except Exception as exc:499return self._error(fn_name, str(exc))500501def market_money_flow(self, top_n: int = 20, date: Optional[str] = None) -> Dict[str, Any]:502fn_name = "market_money_flow"503err = self._ready_or_error(fn_name)504if err:505return err506507trade_date = self._normalize_trade_date(date)508509candidates = [510("stock_market_fund_flow", [{}]),511("stock_hsgt_fund_flow_summary_em", [{}]),512("stock_hsgt_north_net_flow_in_em", [{}]),513("stock_hsgt_hist_em", [{"symbol": "北向资金"}, {"symbol": "沪股通"}, {"symbol": "深股通"}]),514]515516api_name, df, err_msg = self._call_api_candidates(candidates)517if df is None:518return self._error(fn_name, err_msg)519520if hasattr(df, "iloc"):521try:522df = df.iloc[::-1]523except Exception:524pass525526return self._wrap(527api_name or fn_name,528scope="market",529date=trade_date,530items=self._to_records(df, top_n=top_n),531)532533def sector_money_flow(self, top_n: int = 20) -> Dict[str, Any]:534fn_name = "sector_money_flow"535err = self._ready_or_error(fn_name)536if err:537return err538539candidates = [540(541"stock_sector_fund_flow_rank",542[543{"indicator": "今日", "sector_type": "行业资金流"},544{"indicator": "5日", "sector_type": "行业资金流"},545{"indicator": "10日", "sector_type": "行业资金流"},546{"symbol": "今日", "sector_type": "行业资金流"},547{"sector_type": "行业资金流"},548],549),550("stock_fund_flow_industry", [{"symbol": "今日"}, {"symbol": "即时"}, {}]),551("stock_sector_fund_flow_summary", [{"sector_type": "行业资金流"}, {}]),552]553554api_name, df, err_msg = self._call_api_candidates(candidates)555if df is None:556return self._error(fn_name, err_msg)557558return self._wrap(559api_name or fn_name,560scope="sector",561items=self._to_records(df, top_n=top_n),562)563564def fundamental(self, symbol: str, top_n: int = 20) -> Dict[str, Any]:565fn_name = "fundamental"566err = self._ready_or_error(fn_name)567if err:568return err569570clean_symbol = self._clean_symbol(symbol)571572candidates = [573(574"stock_financial_abstract_ths",575[576{"symbol": clean_symbol, "indicator": "按报告期"},577{"symbol": clean_symbol, "indicator": "按单季度"},578{"symbol": clean_symbol},579{"stock": clean_symbol, "indicator": "按报告期"},580{"stock": clean_symbol},581],582),583(584"stock_financial_analysis_indicator",585[586{"symbol": clean_symbol},587{"stock": clean_symbol},588],589),590]591592api_name, df, err_msg = self._call_api_candidates(candidates)593if df is None:594return self._error(fn_name, err_msg)595596if hasattr(df, "iloc"):597try:598df = df.iloc[::-1]599except Exception:600pass601602items = self._to_records(df, top_n=top_n)603latest = items[0] if isinstance(items, list) and items else {}604605return self._wrap(606api_name or fn_name,607scope="fundamental",608symbol=clean_symbol,609latest=latest,610items=items,611)612613def stock_overview(self, symbol: str) -> Dict[str, Any]:614fn_name = "stock_overview"615clean_symbol = self._clean_symbol(symbol)616617if not clean_symbol:618return self._error(fn_name, "symbol is required")619620sections: Dict[str, Any] = {621"realtime": {"ok": False, "error": "not called"},622"money_flow": {"ok": False, "error": "not called"},623"fundamental": {"ok": False, "error": "not called"},624"limit_stats": {"ok": False, "error": "not called"},625"research_report": {"ok": False, "error": "not called"},626}627628# 1) 实时行情(优先使用分时最新)629try:630rt_res = self.stock_intraday(symbol=clean_symbol, period="1", top_n=1)631if rt_res.get("ok"):632rt_items = rt_res.get("data", {}).get("items", [])633latest = rt_items[0] if isinstance(rt_items, list) and rt_items else {}634sections["realtime"] = {635"ok": True,636"api": rt_res.get("api"),637"latest": latest,638}639else:640sections["realtime"] = {641"ok": False,642"api": rt_res.get("api"),643"error": rt_res.get("error", "unknown error"),644}645except Exception as exc:646sections["realtime"] = {"ok": False, "error": str(exc)}647648# 2) 个股资金流649try:650flow_res = self.money_flow(symbol=clean_symbol, top_n=10)651if flow_res.get("ok"):652flow_data = flow_res.get("data", {})653flow_items = flow_data.get("items", [])654sections["money_flow"] = {655"ok": True,656"api": flow_res.get("api"),657"latest": flow_items[0] if isinstance(flow_items, list) and flow_items else {},658"items": flow_items,659}660else:661sections["money_flow"] = {662"ok": False,663"api": flow_res.get("api"),664"error": flow_res.get("error", "unknown error"),665}666except Exception as exc:667sections["money_flow"] = {"ok": False, "error": str(exc)}668669# 3) 基本面摘要670try:671fundamental_res = self.fundamental(symbol=clean_symbol, top_n=10)672if fundamental_res.get("ok"):673fundamental_data = fundamental_res.get("data", {})674sections["fundamental"] = {675"ok": True,676"api": fundamental_res.get("api"),677"latest": fundamental_data.get("latest") or {},678"items": fundamental_data.get("items") or [],679}680else:681sections["fundamental"] = {682"ok": False,683"api": fundamental_res.get("api"),684"error": fundamental_res.get("error", "unknown error"),685}686except Exception as exc:687sections["fundamental"] = {"ok": False, "error": str(exc)}688689# 4) 近期涨跌停(从近10日池中统计该股出现次数)690limit_up_count = 0691limit_down_count = 0692last_date = None693limit_errors = []694695code_keys = ["代码", "股票代码", "证券代码", "symbol"]696name_keys = ["名称", "股票简称", "证券简称", "简称"]697698for offset in range(0, 10):699trade_date = (datetime.now() - timedelta(days=offset)).strftime("%Y%m%d")700try:701limit_res = self.limit_pool(date=trade_date, top_n=300)702if not limit_res.get("ok"):703limit_errors.append(f"{trade_date}: {limit_res.get('error', 'unknown error')}")704continue705706payload = limit_res.get("data", {})707up_items = payload.get("up_items") or payload.get("items") or []708down_items = payload.get("down_items") or []709if last_date is None:710last_date = payload.get("date") or trade_date711712def _is_target(row: Any) -> bool:713if not isinstance(row, dict):714return False715for key in code_keys:716value = row.get(key)717if value is not None and clean_symbol == self._clean_symbol(str(value)):718return True719for key in name_keys:720value = row.get(key)721if value is not None and str(value) in str(symbol):722return True723return False724725limit_up_count += sum(1 for row in up_items if _is_target(row))726limit_down_count += sum(1 for row in down_items if _is_target(row))727except Exception as exc:728limit_errors.append(f"{trade_date}: {exc}")729730sections["limit_stats"] = {731"ok": True,732"days": 10,733"date": last_date,734"up_count": limit_up_count,735"down_count": limit_down_count,736"error": "; ".join(limit_errors[:3]) if limit_errors else None,737}738739# 5) 研报740try:741report_res = self.research_report(symbol=clean_symbol, top_n=3)742if report_res.get("ok"):743report_data = report_res.get("data", {})744sections["research_report"] = {745"ok": True,746"api": report_res.get("api"),747"items": report_data.get("items", [])[:3],748}749else:750sections["research_report"] = {751"ok": False,752"api": report_res.get("api"),753"error": report_res.get("error", "unknown error"),754}755except Exception as exc:756sections["research_report"] = {"ok": False, "error": str(exc)}757758has_success = any(section.get("ok") for section in sections.values())759if not has_success:760combined_error = "; ".join(761str(section.get("error"))762for section in sections.values()763if section.get("error")764)765return self._error(fn_name, combined_error or "all sub-apis failed")766767return self._wrap(768fn_name,769symbol=clean_symbol,770realtime=sections["realtime"],771money_flow=sections["money_flow"],772fundamental=sections["fundamental"],773limit_stats=sections["limit_stats"],774research_report=sections["research_report"],775)776777def stock_pick(self, top_n: int = 5, sector: str = None) -> Dict[str, Any]:778import warnings779fn_name = "stock_pick"780err = self._ready_or_error(fn_name)781if err:782return err783784def pick(item: dict, keys: list, default: Any = None) -> Any:785for key in keys:786value = item.get(key)787if value not in (None, ""):788return value789return default790791def normalize_code(value: Any) -> str:792if value is None:793return ""794text = str(value).strip().upper()795if not text:796return ""797text = text.replace("SH", "").replace("SZ", "").replace("BJ", "")798digits = "".join(ch for ch in text if ch.isdigit())799if len(digits) >= 6:800return digits[:6]801return text802803# 板块关键词映射804sector_keywords = {805"半导体": ["半导体", "芯片", "集成电路"],806"电子": ["电子", "科技", "计算机"],807"汽车": ["汽车", "新能源车", "整车", "汽配"],808"医药生物": ["医药", "医疗器械", "中药", "生物医药", "医疗", "医药生物"],809"医药": ["医药", "医疗器械", "中药", "生物医药", "医疗", "医药生物"],810"光伏": ["光伏", "光伏发电", "光伏设备"],811"锂电池": ["锂电池", "锂电", "电池", "动力电池"],812"新能源": ["新能源", "储能", "电动车", "电动汽车"],813"银行": ["银行", "银行股"],814"保险": ["保险", "保险股"],815"证券": ["证券", "券商"],816"金融": ["金融", "银行", "保险", "证券"],817"房地产": ["房地产", "地产", "物业"],818"地产": ["房地产", "地产", "物业"],819"电力": ["电力", "电力股", "发电"],820"传媒": ["传媒", "影视", "游戏"],821"军工": ["军工", "航天", "航空", "船舶", "国防"],822"软件": ["软件", "互联网", "计算机", "IT", "软件开发"],823"食品": ["食品", "零食", "食品加工"],824"饮料": ["饮料", "饮品"],825"白酒": ["白酒", "酒", "白酒股"],826"家电": ["家电", "白色家电", "冰洗"],827"纺织": ["纺织", "纺织服装", "服装"],828}829830# 板块关键词映射到接口参数(使用 akshare 实际支持的名称)831sector_map = {832# 常用板块833"半导体": "半导体",834"电子": "电子",835"汽车": "汽车",836"医药生物": "医药生物",837"医药": "医药生物",838"银行": "银行",839"保险": "保险",840"证券": "证券",841"房地产": "房地产",842"锂电池": "锂电池",843"电池": "电池",844"光伏": "光伏设备",845"光伏设备": "光伏设备",846"电力": "电力",847"传媒": "传媒",848"军工": "军工",849"软件": "软件开发",850"食品": "食品",851"饮料": "饮料",852"白酒": "白酒",853"家电": "家电",854"纺织": "纺织",855}856857target_sector = None858target_symbol = None859if sector:860sector_lower = sector.lower()861for key, keywords in sector_keywords.items():862if any(k in sector_lower for k in keywords):863target_sector = key864target_symbol = sector_map.get(key, key)865break866867# 1. 如果指定了板块,获取板块成分股868sector_stocks = []869if target_sector and target_symbol:870try:871with warnings.catch_warnings():872warnings.simplefilter("ignore")873df = self._ak.stock_board_industry_cons_em(symbol=target_symbol)874if hasattr(df, 'to_dict'):875for row in df.to_dict(orient='records'):876if not isinstance(row, dict):877continue878code = normalize_code(pick(row, ["代码", "股票代码"]))879name = pick(row, ["名称", "股票名称"], "")880pct = pick(row, ["涨跌幅"])881if code:882pct_num = _safe_float_local(pct)883sector_stocks.append({884"code": code,885"name": str(name) if name else code,886"pct": pct_num if pct_num else 0,887})888except Exception as e:889pass890891# 如果成功获取到板块成分股,直接用这些数据892if sector_stocks:893sector_stocks.sort(key=lambda x: x.get("pct", 0), reverse=True)894top_candidates = sector_stocks[:top_n]895else:896# 2. 获取热门股票(涨跌幅排行)897try:898import warnings899with warnings.catch_warnings():900warnings.simplefilter("ignore")901hot_df = self._ak.stock_hot_rank_em()902except Exception as e:903return self._error(fn_name, f"热门股票获取失败: {e}")904905hot_items = []906if hasattr(hot_df, 'to_dict'):907records = hot_df.to_dict(orient='records')908for row in records:909if not isinstance(row, dict):910continue911code = normalize_code(pick(row, ["代码", "股票代码", "证券代码", "symbol"]))912name = pick(row, ["股票名称", "名称", "简称", "name"], "")913pct = pick(row, ["涨跌幅", "涨跌幅%"])914if code:915pct_num = _safe_float_local(pct)916hot_items.append({917"code": code,918"name": str(name) if name else code,919"pct": pct_num if pct_num else 0,920})921922if not hot_items:923return self._error(fn_name, "热门股票数据为空")924925hot_items.sort(key=lambda x: x.get("pct", 0), reverse=True)926top_candidates = hot_items[:10]927928# 获取行业资金流929try:930with warnings.catch_warnings():931warnings.simplefilter("ignore")932sector_res = self.sector_money_flow(top_n=15)933except:934sector_res = {"ok": False}935936hot_industries = set()937if sector_res.get("ok"):938sector_items = sector_res.get("data", {}).get("items", [])939for row in sector_items:940if not isinstance(row, dict):941continue942name = pick(row, ["名称", "行业"])943inflow = _safe_float_local(pick(row, ["今日主力净流入-净额", "主力净流入"]))944if name and inflow and inflow > 0:945hot_industries.add(str(name).strip())946947# 3. 简化:只取研报数据(不做个股详细查询)948report_map = {}949try:950with warnings.catch_warnings():951warnings.simplefilter("ignore")952report_df = self._ak.stock_research_report_em()953if hasattr(report_df, 'to_dict'):954report_records = report_df.to_dict(orient='records')[:50]955for row in report_records:956if not isinstance(row, dict):957continue958code = normalize_code(pick(row, ["股票代码", "代码"]))959rating = str(pick(row, ["东财评级", "评级"], ""))960if "买入" in rating and code not in report_map:961report_map[code] = {962"org": pick(row, ["机构"], "机构"),963"rating": rating,964"title": str(pick(row, ["报告名称"], ""))[:20],965}966except:967pass968969# 4. 组装推荐结果970selected = []971for row in top_candidates:972code = row["code"]973name = row["name"]974pct = row["pct"]975976report = report_map.get(code, {})977978selected.append({979"name": name,980"code": code,981"pct": pct,982"report_org": report.get("org", ""),983"report_rating": report.get("rating", ""),984"report_title": report.get("title", ""),985})986987return self._wrap(988fn_name,989items=selected[:top_n],990count=len(selected),991)992993def margin_lhb(self, symbol: Optional[str] = None, date: Optional[str] = None, top_n: int = 10) -> Dict[str, Any]:994fn_name = "margin_lhb"995err = self._ready_or_error(fn_name)996if err:997return err998999clean_symbol = self._clean_symbol(symbol)1000trade_date = self._normalize_trade_date(date)10011002margin_candidates = [1003(1004"stock_margin_detail",1005[1006{"date": trade_date, "symbol": clean_symbol},1007{"date": trade_date, "stock": clean_symbol},1008{"date": trade_date, "code": clean_symbol},1009{"date": trade_date},1010],1011),1012("stock_margin_detail_em", [{"date": trade_date}, {"trade_date": trade_date}, {}]),1013("stock_margin_underlying_info_szse", [{}]),1014("stock_margin_underlying_info_sse", [{}]),1015]10161017margin_api, margin_df, margin_err = self._call_api_candidates(margin_candidates)1018margin_items: list[dict] = []1019if margin_df is not None:1020margin_items = self._to_records(margin_df, top_n=0)1021if isinstance(margin_items, list):1022margin_items = [item for item in margin_items if isinstance(item, dict)]1023margin_items = self._filter_records_by_symbol(margin_items, clean_symbol)1024margin_items = margin_items[:top_n]1025else:1026margin_items = []10271028lhb_candidates = [1029(1030"stock_lhb_detail_em",1031[1032{"start_date": trade_date, "end_date": trade_date},1033{"date": trade_date},1034{},1035],1036),1037("stock_lhb_ggtj_sina", [{"symbol": "5"}, {"symbol": "10"}, {}]),1038]10391040lhb_api, lhb_df, lhb_err = self._call_api_candidates(lhb_candidates)1041lhb_items: list[dict] = []1042if lhb_df is not None:1043lhb_items = self._to_records(lhb_df, top_n=0)1044if isinstance(lhb_items, list):1045lhb_items = [item for item in lhb_items if isinstance(item, dict)]1046lhb_items = self._filter_records_by_symbol(lhb_items, clean_symbol)1047lhb_items = lhb_items[:top_n]1048else:1049lhb_items = []10501051if margin_df is None and lhb_df is None:1052return self._error(fn_name, f"margin failed: {margin_err}; lhb failed: {lhb_err}")10531054return self._wrap(1055fn_name,1056scope="margin_lhb",1057symbol=clean_symbol,1058date=trade_date,1059margin_api=margin_api,1060lhb_api=lhb_api,1061margin_items=margin_items,1062lhb_items=lhb_items,1063margin_error=margin_err if margin_df is None else None,1064lhb_error=lhb_err if lhb_df is None else None,1065)10661067def sector_analysis(self, sector_type: str = "industry", top_n: int = 10) -> Dict[str, Any]:1068fn_name = "stock_sector_name_code"1069err = self._ready_or_error(fn_name)1070if err:1071return err10721073normalized = "概念" if sector_type in {"concept", "概念"} else "行业"1074spot_indicator = "概念" if normalized == "概念" else "新浪行业"1075candidates = [1076("stock_sector_name_code", [{"indicator": "今日涨跌幅", "sector_type": normalized}]),1077("stock_sector_name_code", [{"sector_type": normalized}]),1078("stock_sector_spot", [{"indicator": spot_indicator}]),1079]10801081api_name, df, err_msg = self._call_api_candidates(candidates)1082if df is None:1083return self._error(fn_name, err_msg)10841085records = self._to_records(df, top_n=0)1086if isinstance(records, list):1087records = [item for item in records if isinstance(item, dict)]1088records.sort(1089key=lambda row: _safe_float_local(1090row.get("涨跌幅")1091or row.get("今日涨跌幅")1092or row.get("涨跌幅%")1093or row.get("涨跌")1094)1095or -9999,1096reverse=True,1097)1098top_gain = records[:top_n]1099top_drop = sorted(1100records,1101key=lambda row: _safe_float_local(1102row.get("涨跌幅")1103or row.get("今日涨跌幅")1104or row.get("涨跌幅%")1105or row.get("涨跌")1106)1107or 9999,1108)[:top_n]1109else:1110top_gain = []1111top_drop = []11121113return self._wrap(1114api_name or fn_name,1115scope="sector_analysis",1116sector_type="concept" if normalized == "概念" else "industry",1117top_gain=top_gain,1118top_drop=top_drop,1119items=top_gain,1120)11211122def fund_bond(self, scope: str = "fund", symbol: Optional[str] = None, top_n: int = 10) -> Dict[str, Any]:1123fn_name = "fund_bond"1124err = self._ready_or_error(fn_name)1125if err:1126return err11271128normalized_scope = "bond" if scope in {"bond", "convertible", "cb"} else "fund"11291130if normalized_scope == "fund":1131clean_symbol = self._clean_symbol(symbol)1132default_symbol = clean_symbol or "159915"1133candidates = [1134(1135"fund_etf_hist_em",1136[1137{1138"symbol": default_symbol,1139"period": "daily",1140"start_date": (datetime.now() - timedelta(days=90)).strftime("%Y%m%d"),1141"end_date": datetime.now().strftime("%Y%m%d"),1142"adjust": "",1143}1144],1145),1146("fund_etf_spot_em", [{}]),1147("fund_open_fund_daily_em", [{}]),1148]1149api_name, df, err_msg = self._call_api_candidates(candidates)1150if df is None:1151return self._error(fn_name, err_msg)11521153records = self._to_records(df, top_n=0)1154if isinstance(records, list):1155records = [item for item in records if isinstance(item, dict)]1156if clean_symbol:1157records = self._filter_records_by_symbol(records, clean_symbol) or records1158for item in records:1159if "代码" not in item:1160item["代码"] = default_symbol1161if records and "日期" in records[0]:1162try:1163records = sorted(records, key=lambda r: r.get("日期") or "", reverse=True)1164except Exception:1165pass1166records = records[:top_n]1167else:1168records = []11691170return self._wrap(1171api_name or fn_name,1172scope="fund",1173symbol=default_symbol,1174items=records,1175)11761177candidates = [1178("bond_zh_hs_cov_spot", [{}]),1179("bond_zh_hs_cov_daily", [{"symbol": symbol or "sh113527"}]),1180]11811182api_name, df, err_msg = self._call_api_candidates(candidates)1183if df is None:1184return self._error(fn_name, err_msg)11851186records = self._to_records(df, top_n=0)1187if isinstance(records, list):1188records = [item for item in records if isinstance(item, dict)]1189if symbol:1190records = self._filter_records_by_symbol(records, str(symbol)) or records1191records = records[:top_n]1192else:1193records = []11941195return self._wrap(1196api_name or fn_name,1197scope="bond",1198symbol=symbol,1199items=records,1200)12011202def hk_us_market(self, market: str = "hk", top_n: int = 10, symbol: Optional[str] = None) -> Dict[str, Any]:1203fn_name = "hk_us_market"1204err = self._ready_or_error(fn_name)1205if err:1206return err12071208normalized_market = "us" if market in {"us", "美股", "usa"} else "hk"1209if normalized_market == "hk":1210candidates = [("stock_hk_spot_em", [{}])]1211else:1212candidates = [("stock_us_spot_em", [{}])]12131214api_name, df, err_msg = self._call_api_candidates(candidates)1215if df is None:1216return self._error(fn_name, err_msg)12171218records = self._to_records(df, top_n=0)1219if isinstance(records, list):1220records = [item for item in records if isinstance(item, dict)]1221if symbol:1222records = self._filter_records_by_symbol(records, str(symbol)) or records1223records = records[:top_n]1224else:1225records = []12261227return self._wrap(1228api_name or fn_name,1229scope="hk_us_market",1230market=normalized_market,1231items=records,1232)12331234def derivatives(self, scope: str = "futures", symbol: Optional[str] = None, top_n: int = 10) -> Dict[str, Any]:1235fn_name = "derivatives"1236err = self._ready_or_error(fn_name)1237if err:1238return err12391240normalized_scope = "options" if scope in {"option", "options", "期权"} else "futures"12411242if normalized_scope == "futures":1243candidates = [1244("futures_display_main_sina", [{}]),1245("match_main_contract", [{"symbol": "cffex"}]),1246("futures_main_sina", [{"symbol": "IF0"}, {"symbol": "IH0"}, {"symbol": "IC0"}]),1247]12481249api_name, df, err_msg = self._call_api_candidates(candidates)1250if df is None:1251return self._error(fn_name, err_msg)12521253records = self._to_records(df, top_n=0)1254if isinstance(records, list):1255records = [item for item in records if isinstance(item, dict)]1256if symbol:1257records = self._filter_records_by_symbol(records, str(symbol)) or records1258records = records[:top_n]1259else:1260records = []12611262return self._wrap(1263api_name or fn_name,1264scope="futures",1265symbol=symbol,1266items=records,1267)12681269candidates = [1270("option_current_em", [{}]),1271("option_cffex_hs300_spot_sina", [{}]),1272("option_finance_board", [{"symbol": "华夏上证50ETF期权"}, {}]),1273]12741275api_name, df, err_msg = self._call_api_candidates(candidates)1276if df is None:1277return self._error(fn_name, err_msg)12781279records = self._to_records(df, top_n=0)1280if isinstance(records, list):1281records = [item for item in records if isinstance(item, dict)]1282if symbol:1283records = self._filter_records_by_symbol(records, str(symbol)) or records1284records = records[:top_n]1285else:1286records = []12871288return self._wrap(1289api_name or fn_name,1290scope="options",1291symbol=symbol,1292items=records,1293)129412951296def _safe_float_local(value: Any) -> Optional[float]:1297if value is None:1298return None1299if isinstance(value, str):1300value = value.replace(",", "").replace("%", "").strip()1301try:1302return float(value)1303except Exception:1304return None1305