@@ -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
18092057if __name__ == "__main__" :
18102058 # 测试代码
0 commit comments