Skip to content

Commit f477e09

Browse files
committed
feat: add market review data sources
1 parent 60d8211 commit f477e09

3 files changed

Lines changed: 422 additions & 0 deletions

File tree

data_provider/akshare_fetcher.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,254 @@ def _get_rank_top_n(df: pd.DataFrame, change_col: str, industry_name: str, n: in
18051805
logger.error(f"[Akshare] 新浪接口获取板块排行也失败: {e}")
18061806
return None
18071807

1808+
def get_concept_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]:
1809+
"""获取概念/题材涨跌榜。"""
1810+
import akshare as ak
1811+
1812+
try:
1813+
self._set_random_user_agent()
1814+
self._enforce_rate_limit()
1815+
1816+
logger.info("[API调用] ak.stock_board_concept_name_em() 获取概念排行...")
1817+
df = ak.stock_board_concept_name_em()
1818+
if df is None or df.empty:
1819+
return None
1820+
1821+
change_col = '涨跌幅'
1822+
name_col = '板块名称'
1823+
if change_col not in df.columns or name_col not in df.columns:
1824+
return None
1825+
1826+
df = df.copy()
1827+
df[change_col] = pd.to_numeric(df[change_col], errors='coerce')
1828+
df = df.dropna(subset=[change_col])
1829+
top = df.nlargest(n, change_col)
1830+
bottom = df.nsmallest(n, change_col)
1831+
return (
1832+
[
1833+
{'name': str(row[name_col]), 'change_pct': float(row[change_col])}
1834+
for _, row in top.iterrows()
1835+
],
1836+
[
1837+
{'name': str(row[name_col]), 'change_pct': float(row[change_col])}
1838+
for _, row in bottom.iterrows()
1839+
],
1840+
)
1841+
except Exception as e:
1842+
logger.warning(f"[Akshare] 获取概念排行失败: {e}")
1843+
return None
1844+
1845+
def get_hot_stocks(self, n: int = 10) -> Optional[List[Dict[str, Any]]]:
1846+
"""获取人气股榜,按免配置热榜数据源降级。"""
1847+
import akshare as ak
1848+
1849+
fetch_attempts = (
1850+
lambda top_n: self._get_eastmoney_hot_stocks(ak, top_n),
1851+
lambda top_n: self._get_eastmoney_hot_up_stocks(ak, top_n),
1852+
lambda top_n: self._get_xueqiu_hot_stocks(ak, top_n),
1853+
)
1854+
last_error = ""
1855+
for fetch in fetch_attempts:
1856+
try:
1857+
rows = fetch(n)
1858+
if rows:
1859+
return rows[:n]
1860+
except Exception as e:
1861+
last_error = str(e)
1862+
logger.debug("[Akshare] 人气股候选源失败: %s", e)
1863+
if last_error:
1864+
logger.warning("[Akshare] 获取人气股全部候选源失败: %s", last_error)
1865+
return None
1866+
1867+
def _get_eastmoney_hot_stocks(self, ak: Any, n: int = 10) -> Optional[List[Dict[str, Any]]]:
1868+
"""获取东方财富人气股榜。"""
1869+
self._set_random_user_agent()
1870+
self._enforce_rate_limit()
1871+
1872+
logger.info("[API调用] ak.stock_hot_rank_em() 获取东方财富人气股...")
1873+
df = ak.stock_hot_rank_em()
1874+
if df is None or df.empty:
1875+
return None
1876+
1877+
rows: List[Dict[str, Any]] = []
1878+
for _, row in df.head(n).iterrows():
1879+
rows.append({
1880+
'rank': self._safe_int(row.get('当前排名')),
1881+
'code': str(row.get('代码', '')).strip(),
1882+
'name': str(row.get('股票名称', '')).strip(),
1883+
'price': self._safe_float(row.get('最新价')),
1884+
'change_pct': self._safe_float(row.get('涨跌幅')),
1885+
'source': '东方财富人气榜',
1886+
})
1887+
return rows
1888+
1889+
def _get_eastmoney_hot_up_stocks(self, ak: Any, n: int = 10) -> Optional[List[Dict[str, Any]]]:
1890+
"""获取东方财富飙升榜。"""
1891+
self._set_random_user_agent()
1892+
self._enforce_rate_limit()
1893+
1894+
logger.info("[API调用] ak.stock_hot_up_em() 获取东方财富飙升榜...")
1895+
df = ak.stock_hot_up_em()
1896+
if df is None or df.empty:
1897+
return None
1898+
1899+
code_col = self._find_first_column(df, ("代码", "股票代码"))
1900+
name_col = self._find_first_column(df, ("股票名称", "名称", "股票简称"))
1901+
rank_col = self._find_first_column(df, ("当前排名", "排名", "序号"))
1902+
price_col = self._find_first_column(df, ("最新价", "现价"))
1903+
change_col = self._find_column_containing(df, ("涨跌幅",))
1904+
if not code_col or not name_col:
1905+
return None
1906+
1907+
rows: List[Dict[str, Any]] = []
1908+
for _, row in df.head(n).iterrows():
1909+
rows.append({
1910+
'rank': self._safe_int(row.get(rank_col)) if rank_col else len(rows) + 1,
1911+
'code': str(row.get(code_col, '')).strip(),
1912+
'name': str(row.get(name_col, '')).strip(),
1913+
'price': self._safe_float(row.get(price_col)) if price_col else None,
1914+
'change_pct': self._safe_float(row.get(change_col)) if change_col else None,
1915+
'source': '东方财富飙升榜',
1916+
})
1917+
return rows
1918+
1919+
def _get_xueqiu_hot_stocks(self, ak: Any, n: int = 10) -> Optional[List[Dict[str, Any]]]:
1920+
"""获取雪球关注榜兜底。该接口较慢,仅在人气榜失败后尝试。"""
1921+
self._set_random_user_agent()
1922+
self._enforce_rate_limit()
1923+
1924+
logger.info("[API调用] ak.stock_hot_follow_xq() 获取雪球关注榜...")
1925+
df = ak.stock_hot_follow_xq(symbol='最热门')
1926+
if df is None or df.empty:
1927+
return None
1928+
1929+
rows: List[Dict[str, Any]] = []
1930+
for idx, (_, row) in enumerate(df.head(n).iterrows(), 1):
1931+
rows.append({
1932+
'rank': idx,
1933+
'code': str(row.get('股票代码', '')).strip(),
1934+
'name': str(row.get('股票简称', '')).strip(),
1935+
'price': self._safe_float(row.get('最新价')),
1936+
'change_pct': None,
1937+
'source': '雪球关注榜',
1938+
})
1939+
return rows
1940+
1941+
def get_limit_up_pool(
1942+
self,
1943+
date: Optional[str] = None,
1944+
n: int = 20,
1945+
) -> Optional[List[Dict[str, Any]]]:
1946+
"""获取涨停池,优先按连板数和封板时间展示。"""
1947+
import akshare as ak
1948+
1949+
query_date = date or datetime.now().strftime('%Y%m%d')
1950+
try:
1951+
self._set_random_user_agent()
1952+
self._enforce_rate_limit()
1953+
1954+
logger.info("[API调用] ak.stock_zt_pool_em(date=%s) 获取涨停池...", query_date)
1955+
df = ak.stock_zt_pool_em(date=query_date)
1956+
if df is None or df.empty:
1957+
return None
1958+
1959+
df = df.copy()
1960+
for col in ('连板数', '封板资金', '成交额', '换手率', '涨跌幅'):
1961+
if col in df.columns:
1962+
df[col] = pd.to_numeric(df[col], errors='coerce')
1963+
if '首次封板时间' in df.columns:
1964+
df['首次封板时间'] = df['首次封板时间'].map(self._normalize_limit_time_value)
1965+
df['_首次封板时间排序'] = df['首次封板时间'].where(df['首次封板时间'] != '', '999999')
1966+
sort_cols = [col for col in ('连板数', '_首次封板时间排序') if col in df.columns]
1967+
if sort_cols:
1968+
ascending = [False if col == '连板数' else True for col in sort_cols]
1969+
df = df.sort_values(sort_cols, ascending=ascending)
1970+
1971+
rows: List[Dict[str, Any]] = []
1972+
for _, row in df.head(n).iterrows():
1973+
rows.append({
1974+
'code': str(row.get('代码', '')).strip(),
1975+
'name': str(row.get('名称', '')).strip(),
1976+
'change_pct': self._safe_float(row.get('涨跌幅')),
1977+
'price': self._safe_float(row.get('最新价')),
1978+
'amount': self._safe_float(row.get('成交额')),
1979+
'turnover_rate': self._safe_float(row.get('换手率')),
1980+
'seal_amount': self._safe_float(row.get('封板资金')),
1981+
'first_limit_time': str(row.get('首次封板时间', '')).strip(),
1982+
'last_limit_time': str(row.get('最后封板时间', '')).strip(),
1983+
'break_count': self._safe_int(row.get('炸板次数')),
1984+
'limit_stat': str(row.get('涨停统计', '')).strip(),
1985+
'consecutive_boards': self._safe_int(row.get('连板数')),
1986+
'industry': str(row.get('所属行业', '')).strip(),
1987+
})
1988+
return rows
1989+
except Exception as e:
1990+
logger.warning(f"[Akshare] 获取涨停池失败: {e}")
1991+
return None
1992+
1993+
@staticmethod
1994+
def _normalize_limit_time_value(value: Any) -> str:
1995+
"""Normalize AkShare HHMMSS-like seal time values to zero-padded HHMMSS."""
1996+
try:
1997+
if pd.isna(value):
1998+
return ""
1999+
except TypeError:
2000+
pass
2001+
2002+
text = str(value).strip()
2003+
if not text or text.lower() in {"nan", "nat", "none", "null", "-", "--"}:
2004+
return ""
2005+
2006+
if ":" in text:
2007+
parts = text.split(":")
2008+
try:
2009+
hour = int(parts[0])
2010+
minute = int(parts[1]) if len(parts) > 1 else 0
2011+
second = int(parts[2]) if len(parts) > 2 else 0
2012+
return f"{hour:02d}{minute:02d}{second:02d}"
2013+
except (TypeError, ValueError):
2014+
return text
2015+
2016+
try:
2017+
return f"{int(float(text)):06d}"
2018+
except (TypeError, ValueError):
2019+
digits = "".join(ch for ch in text if ch.isdigit())
2020+
return digits.zfill(6) if digits else text
2021+
2022+
@staticmethod
2023+
def _safe_float(value: Any) -> Optional[float]:
2024+
try:
2025+
if pd.isna(value):
2026+
return None
2027+
return float(value)
2028+
except (TypeError, ValueError):
2029+
return None
2030+
2031+
@staticmethod
2032+
def _safe_int(value: Any) -> int:
2033+
try:
2034+
if pd.isna(value):
2035+
return 0
2036+
return int(float(value))
2037+
except (TypeError, ValueError):
2038+
return 0
2039+
2040+
@staticmethod
2041+
def _find_first_column(df: pd.DataFrame, candidates: Tuple[str, ...]) -> Optional[str]:
2042+
columns = [str(col) for col in df.columns]
2043+
for candidate in candidates:
2044+
if candidate in columns:
2045+
return candidate
2046+
return None
2047+
2048+
@staticmethod
2049+
def _find_column_containing(df: pd.DataFrame, keywords: Tuple[str, ...]) -> Optional[str]:
2050+
for col in df.columns:
2051+
col_text = str(col)
2052+
if all(keyword in col_text for keyword in keywords):
2053+
return col
2054+
return None
2055+
18082056

18092057
if __name__ == "__main__":
18102058
# 测试代码

data_provider/base.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,38 @@ def get_sector_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dic
325325
"""
326326
return None
327327

328+
def get_concept_rankings(self, n: int = 5) -> Optional[Tuple[List[Dict], List[Dict]]]:
329+
"""
330+
获取概念/题材涨跌榜。
331+
332+
Returns:
333+
Tuple: (领涨概念列表, 领跌概念列表)
334+
"""
335+
return None
336+
337+
def get_hot_stocks(self, n: int = 10) -> Optional[List[Dict[str, Any]]]:
338+
"""
339+
获取市场人气股榜。
340+
341+
Returns:
342+
List[Dict]: 人气股列表
343+
"""
344+
return None
345+
346+
def get_limit_up_pool(
347+
self,
348+
date: Optional[str] = None,
349+
n: int = 20,
350+
) -> Optional[List[Dict[str, Any]]]:
351+
"""
352+
获取涨停池/连板梯队。
353+
354+
Args:
355+
date: YYYYMMDD,默认由具体数据源决定
356+
n: 返回条数
357+
"""
358+
return None
359+
328360
def get_daily_data(
329361
self,
330362
stock_code: str,
@@ -2623,3 +2655,61 @@ def get_sector_rankings(self, n: int = 5) -> Tuple[List[Dict], List[Dict]]:
26232655
return top, bottom
26242656
logger.warning(f"[板块排行] 所有数据源均失败,最终错误: {last_error}")
26252657
return [], []
2658+
2659+
def get_concept_rankings(self, n: int = 5) -> Tuple[List[Dict], List[Dict]]:
2660+
"""获取概念/题材涨跌榜(自动切换数据源)。"""
2661+
last_error = ""
2662+
for fetcher in self._fetchers:
2663+
try:
2664+
data = fetcher.get_concept_rankings(n)
2665+
if data and (data[0] or data[1]):
2666+
logger.info(f"[{fetcher.name}] 获取概念排行成功")
2667+
return data[0] or [], data[1] or []
2668+
last_error = f"{fetcher.name}返回空结果"
2669+
except Exception as e:
2670+
error_type, error_reason = summarize_exception(e)
2671+
last_error = f"{fetcher.name} ({error_type}) {error_reason}"
2672+
logger.warning(f"[{fetcher.name}] 获取概念排行失败: {error_reason}")
2673+
if last_error:
2674+
logger.warning(f"[概念排行] 所有数据源均失败,最终错误: {last_error}")
2675+
return [], []
2676+
2677+
def get_hot_stocks(self, n: int = 10) -> List[Dict[str, Any]]:
2678+
"""获取市场人气股榜(自动切换数据源)。"""
2679+
last_error = ""
2680+
for fetcher in self._fetchers:
2681+
try:
2682+
data = fetcher.get_hot_stocks(n)
2683+
if data:
2684+
logger.info(f"[{fetcher.name}] 获取人气股成功")
2685+
return data[:n]
2686+
last_error = f"{fetcher.name}返回空结果"
2687+
except Exception as e:
2688+
error_type, error_reason = summarize_exception(e)
2689+
last_error = f"{fetcher.name} ({error_type}) {error_reason}"
2690+
logger.warning(f"[{fetcher.name}] 获取人气股失败: {error_reason}")
2691+
if last_error:
2692+
logger.warning(f"[人气股] 所有数据源均失败,最终错误: {last_error}")
2693+
return []
2694+
2695+
def get_limit_up_pool(
2696+
self,
2697+
date: Optional[str] = None,
2698+
n: int = 20,
2699+
) -> List[Dict[str, Any]]:
2700+
"""获取涨停池与连板梯队(自动切换数据源)。"""
2701+
last_error = ""
2702+
for fetcher in self._fetchers:
2703+
try:
2704+
data = fetcher.get_limit_up_pool(date=date, n=n)
2705+
if data:
2706+
logger.info(f"[{fetcher.name}] 获取涨停池成功")
2707+
return data[:n]
2708+
last_error = f"{fetcher.name}返回空结果"
2709+
except Exception as e:
2710+
error_type, error_reason = summarize_exception(e)
2711+
last_error = f"{fetcher.name} ({error_type}) {error_reason}"
2712+
logger.warning(f"[{fetcher.name}] 获取涨停池失败: {error_reason}")
2713+
if last_error:
2714+
logger.warning(f"[涨停池] 所有数据源均失败,最终错误: {last_error}")
2715+
return []

0 commit comments

Comments
 (0)