☰
Python实现A股选股系统:三种策略源码与回测避坑指南
2026/10/29 17:15:02 网站建设 项目流程

简介:这是一套面向A股量化选股场景的Python源代码,适合熟悉Python、希望基于TuShare数据接口构建或扩展选股策略的开发者。系统内置多种选股策略,例如均线回踩、平台突破、乌龟交易、低波动率等,可通过策略流程配置灵活组合,兼具行情数据获取、策略回测、结果推送与单元测试模块,降低了从零搭建选股框架的门槛。代码包共36个文件,以20个Python源码文件为主,辅以配置文件、YAML参数、测试用例、说明文档与策略结果统计截图,整体约1.95MB,目录结构清晰,便于按模块阅读和二次开发。运行前需按依赖清单安装组件并配置TA-Lib环境,运行结果会写入日志文件,方便定位问题与验证策略表现。当前已有1972人学习下载,适合希望在A股选股策略方向快速起步的中级Python开发者参考。

1. 选股系统不是黑匣子:这份Python源码把三种A股策略变成了可复现的代码

在A股做选股,最容易踩的坑是回测曲线漂亮、实盘一买就套。很多人把原因归结为市场随机,其实多数时候是策略代码里藏着未来函数、用了不复权数据或者信号本身滞后。我最近拆了一份Python版A股选股系统源代码,它没有把选股包装成玄学,而是用TA-Lib把MACD双底、均线粘合、放量突破这类常见策略写成可以直接跑、直接改参数的代码。你拿到手之后不需要从零搭框架,重点看两个文件:策略模块和信号生成模块。适合有一定Python基础、想研究量化选股套路的从业者,也适合想把“看着涨就选”的直觉变成规则的新手。

2. 搭好A股选股环境:TA-Lib安装、akshare数据源与复权数据的三个前置坑

2.1 指标计算为什么统一走TA-Lib:手写MACD既啰嗦又容易翻车

技术指标里,MACD是最容易手写翻车的一个。它涉及EMA的递推计算,初值怎么给、前多少根K线要不要预热,不同人写出来结果能差出好几行信号。更麻烦的是,手写实现一旦在代码里用上整段数据算指标,再把最后一行的结果当作当天信号,就已经把未来函数请进门了。这套源码里所有指标都走TA-Lib,它不是Python实现的算法,而是编译好的C库,指标口径和主流通用回测框架一致,计算速度快,批量算几千只股票也不吃力。

选TA-Lib还有一个现实原因:社区里的选股、回测代码绝大多数都用它。你用同样的指标参数算出同样的值,才能跟别人交流、对照复现。否则你拿手写MACD跟别人对信号,经常出现“这里应该金叉但你没金叉”的尴尬。我自己的习惯是,无论策略多简单,指标计算只交给TA-Lib,不在pandas里自己撸EMA。

2.2 安装TA-Lib的三种方式:Windows、macOS与Linux的差别

TA-Lib的Python包本体是封装层,真正的主体是C库。很多人pip install TA-Lib失败,报错信息最后一行是“ta_lib.h: No such file or directory”,就是系统里没有先装C库。这里有三个常见做法。

# 方式一:conda 安装,Windows/macOS/Linux 通用,最省事 conda install -c conda-forge ta-lib # 方式二:Windows 下优先去找对应 Python 版本的 whl 文件安装 # 先确认 Python 版本:python --version # 例如 Python 3.8 选 ta_lib-0.4.xx-cp38-cp38-win_amd64.whl pip install ta_lib-0.4.xx-cp38-cp38-win_amd64.whl # 方式三:Linux/macOS 下源码编译 # 需要先安装 C 库,再安装 Python 封装 # ./configure --prefix=/usr && make && sudo make install # 然后 pip install TA-Lib

方式一适合还有余力装Anaconda的用户,conda会自动处理C库依赖。方式二是我在Windows上最常推荐的,省去编译环境配置,但要注意whl文件名里的cp38、cp39必须和你的Python版本完全对应,版本不匹配会报“not a supported wheel on this platform”。方式三适合Linux服务器,编译前需要确认gcc、make已经就位。安装完成后,在Python里跑一下import talib,能正常导入且talib.MACD可以被调用,环境就算通了。

2.3 数据源选型与akshare取数:前复权、过滤停牌与字段对齐

A股日线数据源常见的有tushare、akshare、baostock。tushare pro很多字段需要积分,baostock免费但接口风格偏老,akshare开源、免费、不需要注册token,对新项目友好。这份源码里用的是akshare,取数时直接指定前复权,避免后续除权跳空问题。

import akshare as ak import pandas as pd def load_daily_data(symbol: str, start_date: str = "20200101"): # 用 akshare 获取 A 股日线数据, adjust="qfq" 表示前复权 # symbol 传入的是 6 位代码, 会自动补交易所前缀 df = ak.stock_zh_a_hist(symbol=symbol, period="daily", start_date=start_date, end_date="20500101", adjust="qfq") if df is None or df.empty: raise ValueError(f"{symbol} 没有获取到数据") # akshare 返回的字段是中文, 统一改成量化常用的英文列名 df.columns = ["date", "open", "close", "high", "low", "volume", "amount", "amplitude", "pct_change", "change", "turnover"] df["date"] = pd.to_datetime(df["date"]) df = df.set_index("date").sort_index() return df[["open", "close", "high", "low", "volume"]]

这段代码有三个值得注意的地方。一是stock_zh_a_hist的period="daily"取日线,adjust="qfq"做前复权,这点不能省,否则遇到除权日价格断层,后续所有指标都会被污染。二是返回的列名是中文,直接替换成英文,方便和策略模块对接。三是最后只保留OHLC和成交量五列,后面所有策略只依赖这五列,外部字段越少,信号复现越容易。拿到数据之后,建议先打印df.tail()看一眼日期索引是否连续、有没有停牌导致的空行,再进行下一步。

2.4 数据预处理顺序:先查NaN和dtype再交给策略模块

akshare的数据不是拿来就能用的。dtype不对和NaN残留是两类最常见的翻车现场。TA-Lib内部要求float64数组,如果价格列是object类型,调用MACD直接抛异常;如果K线序列中间有空行,rolling窗口会错位,后面的shift(1)全乱掉。我一般把预处理放在取数函数里,数据进来先统一类型,再检查缺失。

def clean_df(df): # 强制转换数值类型, TA-Lib 要求 float64 for col in ["open", "close", "high", "low", "volume"]: df[col] = df[col].astype(float) # 检查 NaN: 前几行均线指标一定为 NaN, 但价格列不能有 assert df[["open", "close", "high", "low", "volume"]].isna().sum().sum() == 0, \ "存在价格空值, 检查数据源" return df

这里的assert是底线检查,一秒钟跑完,但能拦住大量后续的隐蔽问题。特别是换数据源时,不同平台对停牌日的数据填充方式不同,有的填0,有的填NaN,有的直接缺行。填0会让MA直接砸到零轴,填NaN会让整行信号丢失,缺行则会让日期索引不连续。三种情况处理方式不一样,但第一步都是用clean_df暴露出来。

注意:TA-Lib本身的NaN处理比较严格,输入序列中间如果出现NaN,返回的指标从NaN位置开始全部是NaN。A股数据里除权日前后偶尔会出现复权价异常,这种情况宁可手动修数据也不要让NaN流进策略。此外,Python环境版本建议用3.8以上,pandas用1.3之后的版本,numpy不低于1.20。很多报错不是代码问题,是pandas和numpy的版本不匹配。装好之后跑一遍import talib、import akshare、import pandas,三个都能过,环境才算真的ready。

3. 三种策略的代码拆解:MACD双底、均线粘合与放量突破的实现与参数设置

3.1 策略基类与信号标记:把“选股”变成可以分别测试的模块

整套源码没有把三个策略揉在一个大函数里,而是抽了一个基类,每个策略单独实现generate方法。好处是策略之间互不影响,你可以只开一个策略,也可以三个同时跑然后取交集。基类的核心约定是输入df、输出带signal列的DataFrame,signal=1表示当日收盘后触发买入信号。

from abc import ABC, abstractmethod import pandas as pd class BaseStrategy(ABC): def __init__(self, name: str): self.name = name @abstractmethod def generate(self, df: pd.DataFrame) -> pd.DataFrame: """输入OHLCV的DataFrame, 返回带 signal 列的 DataFrame""" pass

这个基类本身不包含任何策略逻辑,只是定一个接口。实际使用时,每个策略子类只需要关注一件事:今天的数据是否满足买入条件。这样写的好处很多:回测时按天调用、实盘时把当日tick数据换成日线快照即可、新增策略也不用动主流程。

3.2 MACD双底策略:两次金叉与零轴位置的确认条件

MACD双底是“该弱不弱”的一种典型量化表达。基本原理是DIF和DEA在低位第一次金叉后回落,形成第二个低点但没有跌破前低,随后再度金叉,这时就可以认为下跌动能已经衰竭。网上传的“双底”版本很多,真正要落地必须把两个金叉之间的间隔、死叉位置都定义清楚,否则随便两根金叉都算双底,信号会泛滥。

import talib import numpy as np class MacdDoubleBottom(BaseStrategy): def __init__(self, fast=12, slow=26, signal=9, min_gap=5, max_gap=30): super().__init__("macd_double_bottom") self.fast = fast self.slow = slow self.signal = signal self.min_gap = min_gap # 两次金叉的最小K线间隔 self.max_gap = max_gap # 两次金叉的最大K线间隔 def generate(self, df: pd.DataFrame) -> pd.DataFrame: df = df.copy() close = df["close"].to_numpy(dtype="float64") macd, signal_line, _ = talib.MACD(close, fastperiod=self.fast, slowperiod=self.slow, signalperiod=self.signal) df["macd"] = macd df["signal_line"] = signal_line # 金叉: DIF 上穿 DEA, 用 shift(1) 保证只用当天和前一天的数据 cross_up = (macd > signal_line) & (macd.shift(1) <= signal_line.shift(1)) # 死叉: DIF 下穿 DEA cross_down = (macd < signal_line) & (macd.shift(1) >= signal_line.shift(1)) signal = np.zeros(len(df), dtype=int) last_up = -1 last_down = -1 for i in range(1, len(df)): if cross_down.iloc[i]: last_down = i if cross_up.iloc[i]: # 必须满足: 之前出现过金叉, 并在之后出现过死叉 if last_up >= 0 and last_down > last_up: gap = i - last_up if self.min_gap <= gap <= self.max_gap: # 死叉到本次金叉之间的最低点, 不低于前低 2% 视为双底成立 segment_low = df["low"].iloc[last_down:i].min() if segment_low >= df["low"].iloc[last_up] * 0.98: signal[i] = 1 last_up = i df["signal"] = signal return df

这段代码的逻辑分三层。第一层用TA-Lib算出MACD和信号线的整段序列,金叉死叉通过相邻两根K线的位置对比判断,shift(1)确保只用当天和前一天的数据做比较,不碰未来。第二层循环里追踪最近金叉和最近死叉的位置,确保信号序列是“金叉→死叉→金叉”的完整结构,而不是随便两根金叉就算双底。第三层是对形态的确认:从死叉到第二次金叉之间的最低点,不能比第一次金叉时对应的低点低太多,超过2%就认为形态失效。

min_gap和max_gap两个参数很关键。min_gap太小时连续两根K线一上一下也算两次金叉,信号堆成山;max_gap太大时把两段独立行情硬凑成双底,信号滞后严重。我一般先设min_gap=5、max_gap=30,再根据标的波动率上下调整。这个策略用在次新股上效果会很差,因为EMA没走稳;用在上市超过一年的中大盘股上更稳。用np.zeros预分配信号数组,比逐行df.loc赋值快不少,几千只股票批量跑的时候性能差别明显。

3.3 均线粘合策略:MA20与MA60贴近度的阈值怎么定

均线粘合表达的是横盘整理走到尾声,长期均线和短期均线纠缠在一起,一旦方向选择向上,往往就是一段趋势的起点。难的不是计算均线,而是定义“粘合”。贴近度用两条均线的距离除以长期均线值来归一化,避免高价股和低价股阈值不一致。

class MaAdhesion(BaseStrategy): def __init__(self, short=20, long=60, threshold=0.03, volume_ratio=1.5): super().__init__("ma_adhesion") self.short = short self.long = long self.threshold = threshold # 贴近度阈值 self.volume_ratio = volume_ratio # 放量倍数 def generate(self, df: pd.DataFrame) -> pd.DataFrame: df = df.copy() close = df["close"] ma_short = talib.SMA(close, timeperiod=self.short) ma_long = talib.SMA(close, timeperiod=self.long) # 贴近度: 短均线与长均线的相对距离 adhesion = (ma_short - ma_long).abs() / ma_long df["adhesion"] = adhesion # 5日均量 vol_ma5 = df["volume"].rolling(5).mean() df["vol_ratio"] = df["volume"] / vol_ma5 # 当日: 贴近度低于阈值, 且收盘价站上短均线, 且放量 condition = ( (adhesion < self.threshold) & (close > ma_short) & (df["vol_ratio"] > self.volume_ratio) ) df["signal"] = condition.astype(int) return df

threshold=0.03表示短均线和长均线的偏离在3%以内,这个值不是万能参数。小盘股波动大,3%可能一整个月天天都粘合;大盘蓝筹波动小,3%可能永远等不到机会。我一般在沪深300里用0.02,在创业板里放到0.04,跑完看信号数量再回摆。volume_ratio用于过滤“粘合后阴跌”的情况,真正的启动往往伴随成交量放大,这个条件可以帮你挡住无量阴跌的假动作。注意SMA在K线数不足时返回NaN,滚动均值同理,所以策略层不需要额外处理,回测时把NaN行跳过即可。

这个策略最怕的是单边下跌趋势。均线粘合在上涨趋势末端也可能出现,但随后往往向下突破,所以用的时候最好叠加一个大盘环境过滤:指数在MA120上方才启用,否则策略空仓等待。源码里没有强制这个过滤,但你在实盘里加一行判断,信号质量会明显改善。

3.4 放量突破策略:N日新高与量能倍数的组合

放量突破是趋势跟随里最大众的策略,但大众化不代表容易做好。直接写“收盘价创N日新高”会选出大量假突破,很多股票创完新高第二天就回落。兼容做法是叠加量能过滤和最低涨幅过滤,只有放量且实体明显的突破才触发信号。

class VolumeBreakout(BaseStrategy): def __init__(self, n=20, vol_mult=2.0, min_pct=0.03): super().__init__("volume_breakout") self.n = n self.vol_mult = vol_mult # 成交量倍数 self.min_pct = min_pct # 最低实体涨幅 def generate(self, df: pd.DataFrame) -> pd.DataFrame: df = df.copy() # N日最高价, 注意用 shift(1) 表示“此前N日”, 不含当天 high_n = df["high"].shift(1).rolling(self.n).max() close = df["close"] open_price = df["open"] # 突破: 收盘价大于此前N日最高价 breakout = close > high_n # 量能: 成交量至少是5日均量的2倍 vol_ma5 = df["volume"].rolling(5).mean() volume_ok = df["volume"] > vol_ma5 * self.vol_mult # 实体涨幅: 当天涨幅足够, 过滤十字星 body_pct = (close - open_price) / open_price body_ok = body_pct > self.min_pct df["signal"] = (breakout & volume_ok & body_ok).astype(int) return df

关键是high_n用shift(1)。如果直接对当天及之前N天的最高价取最大值,那“突破”就永远不可能发生,因为当天的高点一定会让这个最大值变成当天自己。用shift(1)把当天的数据排除在比较区间之外,才是真正意义上的“突破了此前的压力位”。vol_mult和min_pct是两个过滤旋钮,前者过滤缩量突破,后者过滤那种开盘平开、收盘勉强超过前高的虚假突破。这个策略信号频率低,但单笔信号的质量通常比前两个更值得跟踪。

注册制次新股一定小心,这些票波动率极高,N日高点经常在几天内就被打穿,突破阈值要加大到min_pct=0.05以上。另一个注意点:这个策略天然偏向强势股,所以不适合用在已经连续涨停的标的上,连续涨停后的“突破”大概率是最后一棒。我之前回测时发现加入“过去20日涨幅小于50%”的过滤后,回撤降了将近三分之一,这个改进成本极低,值得加到自己的版本里。

参数含义建议范围
n突破周期15~30
vol_mult量能倍数1.5~3.0
min_pct实体涨幅0.02~0.05

4. 选股避坑指南:未来函数、除权跳空与信号延迟的排查记录

4.1 未来函数:回测胜率虚高的头号元凶

现象:策略在回测里胜率超过70%,拿同样的参数跑实盘,连续几笔亏损,甚至信号日期都对不上。

原因:很多选股代码是先取一整段行情、算完指标、再在最后一天标信号。如果指标计算用到了当天之后的数据,比如用整个区间的最高价作为“N日高点”,那信号天然包含了后续走势的信息。这是回测里最隐蔽的坑,胜率虚高到你自己都会产生幻觉。

解决:所有指标计算要么逐K线滚动,要么像第3章那样用shift(1)把比较基准限定在历史窗口。检查方法很直接:把信号日期打印出来,人工翻开K线图,看信号当天收盘时手上能拿到的数据是否足够算出这个信号。如果发现用了当日收盘价以外、当日尚未公布的信息,就是未来函数,必须改。

4.2 除权除息:分红后股价跳空、MACD和均线全部失真

现象:选股系统某天突然选出大量“低位放量”的股票,点进去看是长期横盘的老票,第二天又全部消失。回测时同样代码在除权日前后的信号完全错位。

原因:股票分红送股之后,价格会发生跳空,但技术指标不知道这个信息。以不复权价格计算,MA20会突然向下弯折,MACD会突然金叉,这些信号不是市场给的,是除权“送”的。除权日一过,数据恢复常态,信号又消失了。

解决:取数阶段统一用前复权,akshare里adjust="qfq"就是干这个的。还有一个习惯要养成:每次换数据源或者换复权方式,先把df["close"]按日期画个图,看有没有突兀的向下跳空。只要有一根K线明显低于前后两根,第一反应不是找策略bug,而是先查复权状态。

4.3 停牌、ST与一字板:选出来的票买不进去

现象:选股结果里出现ST股票;或者某只票信号触发当天成交量接近零,第二天开盘直接涨停板封死,根本不给你买入机会。

原因:选股逻辑只看了价格和量能,没看交易状态。A股ST股涨跌幅限制只有5%,技术形态经常走极端;长期停牌股复牌后第一天没有任何挂单,量比数据全是异常的;一字板涨停时你想买也买不到,回测却默认你能成交。

解决:在数据准备阶段就把不可交易标的过滤掉。ST用名称前缀判断,停牌用成交量为0判断,一字板用“开盘价=收盘价=最高价且成交量极低”判断。

def is_tradable(df: pd.DataFrame, name: str = "") -> bool: if name.startswith("ST") or name.startswith("*ST"): return False last = df.iloc[-1] if last["volume"] <= 0: return False # 一字板: 开高低收基本相等 if abs(last["close"] - last["open"]) < 1e-6 and last["volume"] < 1000: return False return True

这段过滤逻辑放在主流程的股票池构建阶段,每一只票过一遍,过滤掉的就直接不参与策略计算。注意一字板的判断里用了volume < 1000,单位是手,A股最小成交量单位就是手,但这个阈值要按你的输入数据单位来调整,不要照抄。

4.4 信号延迟:按收盘价回测与按次日开盘价成交的矛盾

现象:策略在收盘后出信号,回测时假设当天收盘价买入,收益曲线很漂亮。实际手动操作时,收盘集合竞价来不及挂单,第二天开盘价往往高出一截,利润被吃掉一大块。

原因:A股是T+1市场,信号算出时当天的交易机会已经结束。任何在收盘后生成的signal=1,最早可成交价格只能是次日开盘价,而不是当天收盘价。回测用当天收盘价,等于默认你可以在知道结果的那一刻立刻成交,这是时间差造成的系统性乐观偏差。

解决:回测框架里统一用“次日开盘价”作为买入执行价。做法很简单,把signal做一次shift(1),让信号晚一天生效,再与开盘价对齐。这个改动会让收益曲线难看不少,但真实。源码里没有替你强制做这个延迟,因为有的策略设计是盘中实时信号,有的策略是收盘后信号,你要先搞清楚自己的信号属于哪一种。

4.5 参数过拟合:回测好看、实盘亏钱的隐形陷阱

现象:把min_gap从5改到7,回测年化从30%涨到80%,再把threshold从0.03改成0.02,收益又翻一倍。参数越调越兴奋,实盘跑一个月却不断止损。

原因:选股系统的每个参数都是一个自由度。你连续调参,本质上是在用同一段历史数据拟合噪声。A股风格切换极快,去年适合的贴近度阈值今年可能完全不适用。回测里收益曲线的每一处凸起,都可能是某个参数组合对某段行情的过度记忆。

解决:每个策略最多允许调整三个核心参数,调参时记录每个参数组合在样本内和样本外两段区间的表现。如果样本内优秀但样本外崩盘,说明过拟合;如果两段都稳健,才敢上实盘小仓位验证。我的做法是固定前三年的数据做训练集,最近一年做验证集,定期重算一次,半年更新一次参数。

5. 回测与实盘一致性:用一份逐K线检查清单验证策略有效性

5.1 用逐K线回放验证信号没有漂移

策略写完后的第一件事不是看收益,而是把信号日期逐条列出来。逐K线回放的做法很简单:在循环里从第n根K线开始,每走到一根新的K线,就只用“截至当前这根K线收盘”的数据判断是否触发信号。上面几个策略已经按这个思路写,你只需要再套一层模拟器,把每次触发信号后的次日开盘价记录下来。

def replay(df: pd.DataFrame, strategy: BaseStrategy): df = df.copy() df["signal_raw"] = strategy.generate(df)["signal"] df["signal"] = df["signal_raw"].shift(1) # 信号延迟一天执行 trades = [] for i in range(len(df)): if df["signal"].iloc[i] == 1: entry = df["open"].iloc[i] entry_date = df.index[i] trades.append({"entry_date": entry_date, "entry_price": entry}) return pd.DataFrame(trades)

shift(1)在这里就是后悔药,它把“当天收盘出信号”改成“次日开盘执行”,瞬间把回测拉回真实世界。逐K线回放和整段信号生成的区别在于:整段生成容易在用rolling时不小心吃到未来,逐K线强制你从数据窗口的角度去思考每一行代码。跑完之后,把trades导成CSV,随机抽三笔去行情软件里人工核对K线位置,这一步能过滤90%的隐性bug。

5.2 把信号结果导成CSV做人工抽检

不要只盯着最终收益率。把每次信号的日期、开仓价、当时收盘价、均线状态一起导出,用表格软件打开,肉眼扫一遍信号在K线图上的位置。重点看三类现象:信号是否集中在某一天集体出现,是否总出现在除权日前后,是否出现在一字涨停板上。这些都是系统性问题,不是单个策略参数问题。

抽检项做法正常表现
信号日期与K线图逐根对照每笔都能在当天收盘时算出
复权状态查看信号日前后有无跳空无异常向下跳空
可交易性查看次日是否停牌或一字板次日有正常成交区间

从那以后,我每次改完选股参数,都强制走一遍这个流程:先重放,再导出,再人工抽检三笔。看起来慢,但省掉了无数“回测能赚、实盘亏光”的深夜怀疑。希望这份代码和这套检查习惯能帮到你,选股系统不复杂,复杂的是别让代码自己骗自己。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询