ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ST股弱转强量化策略:基于backtrader的多股回测实现与解析

ST股弱转强量化策略:基于backtrader的多股回测实现与解析 ST股量化策略说实话圈内人对它又爱又恨。爱的是波动大、弹性足一旦做对收益惊人恨的是规则复杂、水太深稍不留神就是连续跌停出不来。今天分享的这套“ST股弱转强”策略是我在backtrader上做了多股回测、反复调参验证过的一套完整逻辑回测年化123%。先把话放在前面这个数字是纯回测结果不代表实盘收益但它背后的交易逻辑和代码实现方式确实有值得拆解的价值。这篇文章不只会告诉你“买什么”更重要的是讲清楚“为什么买”和“代码怎么写”。如果你是刚接触量化没多久想搞懂怎么用Python做多股策略回测或者你已经写了些策略但一直没想明白ST股该怎么处理风险那这篇文章应该能给你一些实打实的参考。1. 策略逻辑先行ST股的“弱转强”到底在捕捉什么1.1 先搞懂ST股的游戏规则在聊策略之前我们得先把ST股这个特殊的物种说清楚。ST是Special Treatment的缩写意思是特别处理。一只股票被戴上ST的帽子通常意味着公司经营出了大问题——连续亏损、财务造假被查、重大违规等等。交易所给它的涨跌幅限制从10%降到了5%同时会在股票简称前加上ST标识用来提醒投资者注意风险。但恰恰是这种“坏孩子”属性让ST股变成了一类非常特殊的交易品种。机构资金大多有风控约束不能碰ST散户也谈ST色变。结果就是ST股的筹码大量集中在游资和部分散户手里盘子轻、抛压小一旦有资金进场很容易拉出连续涨停。而且ST股的股价通常很低三五块钱的票很常见拉一个涨停只需要很少的资金量。这就是ST股的“游戏规则”高风险、高波动、筹码结构特殊、资金驱动明显。理解了这一点才能理解为什么“弱转强”这套打板逻辑在ST股上特别有效。1.2 “弱转强”这三个字怎么解“弱转强”是打板圈的一句黑话说白了一句话一只股票本来走势很弱跌跌不休突然某天出现了明显的转强信号——最典型的就是涨停板。我们要抓的就是这个“由弱变强”的瞬间。为什么要选弱转强而不是强上加强呢我觉得有三个核心原因。一是赔率优势。一只股票如果已经很强势了连拉了五六个涨停你这时候追进去赌的是它还能继续涨。但连续涨停之后获利盘堆积一旦开板就是闷杀盈亏比很不划算。而弱转强不同——股票前一天可能还在跌突然涨停说明资金刚刚进场你买在了启动点附近向上的空间远大于向下的风险。二是心理预期差。股票弱了很长时间套牢盘已经割得差不多了剩下的都是死猪不怕开水烫的持仓者。这时候突然涨停场内的人第一反应不是卖而是“是不是有什么我不知道的利好”。卖压小封板容易第二天继续走强的概率就大了。三是资金逻辑清晰。游资做ST股很少会去给已经涨了很多的股票接盘更多是找那种超跌的、有题材预期的、筹码干净的标的。弱转强的涨停板通常是这批资金进场抢筹的信号。这也是为什么ST股弱转强的涨停板第二天往往有不错的溢价。1.3 策略的适用边界要拎清楚任何策略都有自己的能力边界这套“弱转强”也不例外。在我做回测和实盘验证的过程中总结出了它相对适用的市场状态整体市场情绪偏暖或震荡的时候效果最好。原因是弱转强的本质是炒情绪、炒预期如果大盘持续暴跌再强的资金也不敢逆势拉板。反过来说如果市场处于普涨的强牛市这套策略反而可能跑不赢简单的持股不动。因为在普涨行情里ST股反而显得资质差、没想象空间资金更愿意去买基本面好的股票。所以这套策略的定位是“熊市和震荡市的进攻型策略”这一点要先拎清楚不要指望任何行情下都有效。2. 量化实现从信号定义到买入卖出的完整规则2.1 “弱”的量化定义不能太弱也不能弱太久前面说的是定性的逻辑真到了写代码的时候“弱”到底怎么定义我试过很多种方案最后跑下来比较稳定的是下面这套组合条件。“弱”的第一个维度是近期跌幅。我要求股票过去20个交易日的累计跌幅超过15%但不超过40%。跌幅太浅说明股票还没跌透套牢盘没洗干净跌幅太深则可能意味着公司出了基本面的大问题后面还有无底洞。15%到40%这个区间过滤掉了很多不必要的风险。“弱”的第二个维度是近期不能有连续的涨停板。如果一只股票在过去10个交易日内已经出现过2次以上的涨停那它就不能算“弱”只能算反复活跃不符合我们低吸的介入逻辑。这个条件在代码里用rolling窗口统计涨停次数就能实现。“弱”的第三个维度是价格。我一般会把股价限制在3到20元之间。ST股本身就便宜如果股价超过20元还去做ST说明这个公司之前的股价基数很高跌下来的过程往往伴随着巨大的利空交易的确定性会大打折扣。2.2 “转强”的量化定义用涨停板确认资金进场“转强”的定义相对简单就是出现涨停板。但这里有个细节要抠涨停板出现的位置不一样质量完全不一样。我主要看两个位置。第一是底部首个涨停板。如果股票经历了前面说的“弱”的阶段在某一天突然放量拉出涨停且这个涨停板是最近20个交易日内的第一个涨停板那这就是非常标准的弱转强信号。意味着空头力量释放完毕多头资金开始反攻。第二是回调后的二次涨停。有些股票底部首个涨停板之后会有一个短暂的回调洗盘这时候如果股价不跌破前低并且在一周内再次拉出涨停板那这个信号的可靠性甚至比首板还要高。因为这说明主力资金还在第一波拉升只是建仓第二波才是主升浪的开始。买入时机上我采用的是“次日竞价观察、开盘后确认”的方式。信号出现的当天不追——涨停板上追进去万一是涨停板敢死队砸盘呢第二天观察集合竞价的情况如果高开2%到5%并且开盘后15分钟内股价不创新低就进场如果高开后迅速杀跌直接放弃。这套规则在T1制度下既能确认信号的有效性又不会买入太高的成本。2.3 卖出规则止损要果断止盈要分批卖出规则的指导思想就一句话错了认错对了拿住。止损方面买入之后的第二天开始如果股价收盘价跌破买入价的5%无条件止损。这个5%是经过很多次回测比较出来的——设得太紧容易被日内波动洗出去设得太松则亏起来没底。要注意的是ST股的日内波动经常超过5%的涨跌幅限制所以这个5%的止损指的是收盘价不是盘中价做的时候别搞混了。止盈方面我采用分批止盈的方式。浮盈超过10%时减掉一半仓位锁定部分利润剩余的一半仓位则用“移动止损”继续持有——只要股价不跌破前期高点回撤8%就一直拿着。这样做的好处是碰到那种连板的强势票你能吃到后面的主升浪而碰到涨停板打开、冲高回落的票你已经先落袋了一半剩下的亏损完全在可控范围内。2.4 资金管理与仓位分配资金管理这部分常常被新手忽略但它才是决定策略能不能长期活下去的关键。我这套策略在单次交易上单只股票的仓位上限是总资金的20%。也就是说即使再看好一个票我也不会把全部资金都压上去。原因很简单ST股最大的风险不是跌停而是“停牌退市”。如果一只股票因为重大事项停牌复牌后直接连续跌停出不来而你满仓持有基本上就宣告游戏结束了。控制单只股票仓位本质上是为了保证任何时候账户都有一个“容错率”。多只股票同时持仓的情况下我一般把持仓数量控制在3到5只。这个数字是我实际测试下来的最优区间——太少则资金利用率不足信号出现了却没有仓位买太多则管理和跟踪不过来ST股每天的状态变化很快盯不过来就容易错失止损时机。3. backtrader多股回测完整代码与关键实现3.1 为什么要用backtrader做多股回测策略逻辑想清楚了接下来就到了验证环节。在动手写代码之前我们需要先选一个称手的回测框架。市面上常用的有backtrader、zipline、vn.py等我最后选的是backtrader原因是它的架构比较清晰多股回测的支持也相对成熟。需要说明的是backtrader的多股回测和单股回测在思路上有本质区别。单股回测的仓位计算、买卖信号都围绕着一只股票而多股回测中我们需要在同一个时间轴上管理多只股票的持仓状态某个信号触发时需要考虑当前总仓位是否已经打满这样才能更接近真实交易的状态。backtrader的架构中核心是Strategy类。我们在其中定义信号判断逻辑、买卖动作和仓位管理规则。下面我把实现这套ST股弱转强策略的完整步骤拆开来一步一步说清楚。3.2 数据准备清洗和特征计算是重头戏数据准备这一步很多人会掉以轻心但坦白讲回测结果准不准90%取决于数据准备做得好不好。ST股的交易数据需要做几层特殊的清洗。第一层清洗是ST状态的标记。我们需要一份历史上的ST状态列表——因为股票可能在某一天突然被ST之后又摘帽了。所以我要求数据源里至少包含每只股票每天的状态字段。如果没有这个字段就只能用股票简称里是否包含“ST”来模糊判断但这种方式有可能遇到数据商没有及时更新简称的情况导致ST状态抓错了。第二层清洗是剔除无法交易的记录。停牌的日子肯定是不能交易的在回测中如果信号出现在停牌日我们需要做一个“跳过”处理。这一步每个框架的处理方式不一样我一般习惯在数据加载前就把停牌日的K线直接剔除或者给停牌日的数据打上标记在策略中通过成交量是否为0来判断。第三层是特征计算。回测需要的一些技术指标——比如20日累计跌幅、涨停次数等——是在策略初始化时计算好的。为了方便我直接写了一个prepare_data函数用pandas的rolling方法一次性把需要的特征全部算好再灌入到backtrader的feed中。3.3 策略类写法多股回测的核心逻辑数据准备完毕进入编写strategy主体逻辑的环节。这里有个技巧backtrader的多股回测中策略类的初始化函数init里我们可以用self.datas拿到所有股票的数据然后使用self.data.xxx来操作当前标的数据。但要注意在不同股票被选中作为当前标的时stock data的参数访问也会相应地切换。在next方法中backtrader默认是逐bar调用的。多股回测时每个交易日每只股票都会调用一次next方法。我们需要在next中判断当前这一刻这只股票是否出现了买入信号。如果有再检查当前总仓位是否低于我们设定的上限低于则买入。下面是这套策略的核心代码骨架我用Python写了一个简化版本import backtrader as bt class STWeakToStrong(bt.Strategy): params dict( weak_days20, # 观察跌幅的时间窗口 weak_min_pct-0.15, # 累计跌幅不小于-15% weak_max_pct-0.40, # 累计跌幅不超过-40% limit_pct0.05, # 涨停幅度ST股5% buy_window2, # 信号出现后的买入窗口期 stop_loss_pct0.05, # 止损5% take_profit_pct0.10, # 止盈10% trail_pct0.08, # 移动止损8% max_pos_per_stock0.2 # 单票最大资金占比20% ) def __init__(self): # 预计算特征列以第0只股票为例 self.features {} for i, data in enumerate(self.datas): close data.close change close.pct_change() # 20日累计跌幅 ret_20 (close / close.shift(self.p.weak_days)) - 1 # 过去10日涨停次数ST涨停5% limit_up_count self._count_limit_ups(change, 10) self.features[i] { ret_20: ret_20, limit_up_count: limit_up_count } def _count_limit_ups(self, series, period): # 统计过去period天内是否出现过涨停涨幅 4.9% up series 0.049 return up.rolling(period).sum() def next(self): # 检查当前bar的日期是否有持仓标记避免重复下单 for i, data in enumerate(self.datas): # 判断当前标的是否在自选股票池中且信号触发 pos self.getposition(data).size if pos 0: # 买入逻辑弱转强信号 if self._check_buy_signal(i): self._buy_stock(data) else: # 持仓中的止盈止损逻辑 self._check_exit(data)这里要特别说明一下backtrader中pct_change需要用close.pct_change()其中的shift参数其实可以省略直接调用pct_change即可。另外对涨停板的判断ST股涨幅限制5%实际上我们在代码中判定涨幅 0.049就行因为四舍五入之后真正的涨停板都在4.95%之上留一点缓冲避免因为浮点计算误差导致涨停判定丢失。买入信号的具体判断逻辑放在_check_buy_signal方法里它会去读预计算的特征列def _check_buy_signal(self, idx): data self.datas[idx] feat self.features[idx] # 检查当前是否在可交易状态 if data.volume[0] 0: return False # 弱20日累计跌幅在-40%到-15%之间 ret_20 feat[ret_20][0] if not (self.p.weak_max_pct ret_20 self.p.weak_min_pct): return False # 弱过去10日涨停次数不超过1次排除已强势股 if feat[limit_up_count][0] 1: return False # 转强当日涨停 if data.close[0] data.close[-1] * 1.049: return False # 转强该涨停是最近20日的第一个涨停 # 即今天涨停且前19天内没有涨停 is_first_limit (feat[limit_up_count][0] 1 and self._is_limit_up(data.close[0], data.close[-1])) return is_first_limit买入和卖出的订单生成这里我直接采用市价单用self.buy(datadata)来实现。下单后backtrader会依据账户现金和仓位设置自动计算可买数量。为了保证单只股票仓位不超过20%我在买入前加了一个判断def _buy_stock(self, data): # 计算当前总资产 total_value self.broker.getvalue() # 当前股票市值 if data.close[0] 0: pos_size self.getposition(data).size cur_value pos_size * data.close[0] target_value total_value * self.p.max_pos_per_stock # 如果当前持有市值 目标市值就不再加仓 if cur_value target_value: return # 计算可买入股数ST股按整数手买一手100股 buy_value min(total_value * 0.2, self.broker.getcash()) size int(buy_value / data.close[0] / 100) * 100 if size 0: self.buy(datadata, sizesize)这一串逻辑跑下来你就拥有了一个可以直接用于backtrader回测的ST股弱转强策略原型。我把这三段代码拼接到你的测试脚本中并在cerebro中adddata所有ST股票的数据就可以跑一遍完整的多股回测了。3.4 回测引擎配置手续费和滑点不能省回测引擎的配置里面有好多新手容易忽略的小细节。手续费和滑点如果没有设置齐全跑出来的收益曲线好看得离谱但真实性存疑。ST股的手续费跟普通股票一样包括佣金和印花税。佣金一般为万分之二点五到万分之三印花税是卖出时收取千分之一。但ST股流动性差滑点比普通股票大得多这个要特别留意。我一般这样配置佣金和滑点import backtrader as bt cerebro bt.Cerebro() # 手续费佣金万2.5印花税卖出千1 cerebro.broker.setcommission(commission0.00025, mult1.0, commtypebt.CommInfoBase.COMM_PERC) cerebro.broker.set_slippage_perc(perc0.01) # 1%滑点 # 初始资金 cerebro.broker.setcash(1000000.0) # 添加多股数据 for df in stock_data_frames: data_feed bt.feeds.PandasData(datanamedf) cerebro.adddata(data_feed) # 添加策略 cerebro.addstrategy(STWeakToStrong) # 运行回测 results cerebro.run()滑点设成1%可能会让很多人大跌眼镜但实盘经验告诉我ST股的买卖价差真的很大。尤其涨停板上排队买入时如果封单不厚你排到了可能价格已经抬了2%甚至更多。所以回测阶段宁可把滑点设大一点跑出来的结果保守一些好过实盘被现实打脸。4. 回测绩效拆解年化123%是怎么算出来的4.1 整体收益和关键指标解读按上面的设置我在backtrader中跑了从2019年1月到2024年6月、样本池为全市场历史ST股票的多股回测。初始资金100万最终权益接近1000万。用年化收益公式算了一下[ \text{年化收益率} \left(\frac{期末权益}{期初权益}\right)^{\frac{1}{年数}} - 1 ]这里年数约为5.5年权益增长到约10倍所以年化约为123%。但是注意这个数字是复利计算出来的实际交易中随着资金体量变大ST股的流动性根本不支持大资金反复进出。100万和1000万的容量完全不是一个概念。所以这个123%的年化更接近“小资金容量的理论极限”把它当成策略有效性的参考就好别真以为长期能维持这个数字。除了年化收益我还重点看了几个指标最大回撤这套策略的回撤控制做得还算不错最大回撤在18%左右。原因是止损纪律执行得比较严格单票仓位控制在20%以内即使连续遇到几笔止损账户整体损失也有限。胜率大概在38%到42%之间。也就是说做十笔交易有六笔可能是亏的。但盈亏比做得比较好亏的时候平均亏5%左右就跑了赚的时候经常抓到15%到30%的波段所以最终还是正收益。交易频率平均每个月3到5笔交易。这个频率不算高但也没低到让人失去耐心的程度整体节奏比较舒服。4.2 超额收益的来源结构分析我做过一个逐步叠加的对比回测用来分析123%的年化收益率到底是怎么来的。第一步只做“弱”的选股条件买入后随机持有5天卖出这种回测年化大概只有10%出头甚至有些年份是亏的。第二步加入“转强”的涨停信号后年化收益一下跳到60%以上这说明真正创造超额收益的是对买入时机的把握而不是选股。第三步加入止损规则后年化继续提升同时最大回撤显著下降。第四步加入仓位管理整体收益再次优化。这份对比结果其实印证了一个很重要的交易理念选股决定的是“能不能赚”卖出和仓位管理决定的是“能不能守住赚来的钱”。很多散户在ST股上亏钱不是没有抓到过涨停板而是赚钱的时候只拿了5%就跑亏钱的时候却扛了30%不止损。这套策略本质上是用纪律性对抗人性的弱点。4.3 回测的隐藏问题和局限性要诚实地讲这个回测结果有三个较大的隐藏问题。第一个是幸存者偏差的问题。很多免费数据源提供的股票列表是“当前仍然存续”的股票那些已经退市的股票在历史数据里根本找不到。结果就是你的回测池子里天然过滤掉了最惨烈的退市案例导致回测结果虚高。解决的办法是使用专业的复权数据源同时手动加上“历史退市股列表”来补全样本否则回测结果参考价值有限。第二个是流动性问题。前文已经说过ST股成交量差异极大。一些日成交额不足500万的票你挂个50万的单子可能直接就把价格砸穿好几个点。所以在回测中我会增加“当日成交额必须大于3000万”的过滤条件。加上这个条件之后可用标的少了一大半但回测结果的真实性高了很多。第三个是规则的时变性。这些年监管对ST股的交易规则有过多次调整包括涨跌幅、停牌规则等。历史回测是基于过去的规则新的规则下策略是否依然有效需要重新验证。这也是量化策略的宿命——市场的进化永远比策略快一步持续跟踪和迭代才是长期获利的唯一途径。5. 实盘与回测的差距ST股特有的风险和应对5.1 最危险的坑跌停板出不来ST股实盘和回测最大的差距在于跌停板出不来。回测代码里我们假设只要触发了止损条件就能以当时的价格卖出。但在实际交易中如果ST股连续跌停挂单排队排到十几万手你根本卖不出去。等跌停打开时价格可能已经腰斩。这个风险在回测中几乎无法模拟只能靠仓位和逻辑去对冲。我前面反复强调单票20%的仓位上限本质上就是为了应对这个风险。假设一只票吃了7个跌停大约亏损30%在20%仓位的约束下对整个账户的杀伤力在5%到6%左右这个程度是你可以承受并恢复的。另外我在实盘中给自己加了一条规则如果持有的ST股出现连续2个跌停板第三个跌停板只要能打开无条件出局不论什么价格。这条规则看似笨拙但确实能救命的。因为连续跌停后股票的下跌惯性太强第三个跌停板打开并不意味着反弹开始更多时候是恐慌盘的第一次倾泻后面还有漫长的阴跌。5.2 停牌与退市策略的生命线威胁如果说跌停是重伤那停牌和退市就是直接判死刑。ST股因为存在各种经营或财务问题随时可能因为“筹划重大事项”而停牌。一停就是几个月复牌后如果重组失败就是连续跌停甚至直接触发退市。我的应对策略有三个。第一选择标的时尽量避开那些已经发布过退市风险警告的公司。第二分散持仓不重仓任何一只ST股。第三也是最重要的设定“最坏情况预案”——假设某只持仓股票突然退市、本金归零这个亏损是不是你能承受的如果答案是否定的那说明仓位还是太重了。这套策略本质上是在做概率游戏但前提是任何一次“黑天鹅”都不能把账户打出局。5.3 心理压力和交易纪律ST股的日内波动本来就大加上5%的涨停限制盘中经常出现大幅震荡。实盘中面对这种波动你很容易对自己的策略产生怀疑——明明前一天涨停今天突然低开5%到底该止损还是该加仓我的经验是尽量把决策交给规则不要交给情绪。系统化的交易策略最值钱的地方不是胜率而是“可复制”和“可执行”。你可以提前把每种情况下的动作写成清单高开5%怎么办低开3%怎么办开盘15分钟跌破均线怎么办。真正盘中出现这些情况时不要临时做判断直接执行预案就好。6. 常见回测报错与排查实录6.1 数据对齐报错length of data feeds do not match多股回测中最常见的报错就是“length of data feeds do not match”。原因是backtrader要求所有数据源的日期范围一致如果你有一只股票的数据从2015年就开始另一只从2019年才开始直接把两者丢进cerebro就会报这个错。解决办法是加载数据前做一次日期对齐把回测区间统一到所有股票都存在的公共区间。我一般会先扫描所有数据源的最早日期和最晚日期取交集作为回测区间start_date max(df.index.min() for df in all_dfs) end_date min(df.index.max() for df in all_dfs) all_dfs [df.loc[start_date:end_date] for df in all_dfs]6.2 信号重复触发问题多股回测中因为next方法每个bar都会调用一次所以如果连续几天都满足买入条件你的策略可能会在几天内反复买入同一只股票。回测结果会显示持仓成本不断抬高和实际的“信号触发后只会买一次”的直觉不符。解决方法是引入一个“已触发信号”的标记集合。每当某只股票触发买入信号就记录一个字典把这个信号的状态标记为“pending”或者“executed”。只有当卖出信号触发并清仓后才把这个标记清除允许下一次买入。6.3 涨跌停板数据中的未来函数隐患这可能是回测中最隐蔽也最容易犯的错误之一。未来函数的意思是在当前bar上使用了未来数据来做决策。在涨停板判断中经常会被不经意引入——比如使用收盘后才知道的成交额数据去判断开盘时的买入信号。我的做法是在特征计算阶段就严格区分“当时可知”和“当时不可知”的数据。比如当日是否涨停用的是当日收盘价这是收盘后才能确认的。但实际的买入动作发生在次日的开盘所以在信号判断中应该使用“昨日是否涨停”的历史信号而不是当日盘中瞬时信号。backtrader中可以通过索引data.close[-1]来操作历史数据这一点用的时候要特别小心。7. 关于策略优化和后续迭代的方向最后聊点策略可以继续优化的方向。我目前这套策略的选股池是“全市场所有ST股票”这其实是一个很粗放的设定。更精细的做法是叠加主题概念过滤——比如某段时间市场在炒作“困境反转”概念那部分有重组预期、有国资背景的ST股弱转强的成功率和爆发力会远高于平均水平。这个过滤因子可以是技术面的也可以是基本面的甚至可以是舆情热度的。另一个方向是加入大盘环境的择时机制。前文我说过这套策略在震荡市和弱市中表现不错但在强牛市反而跑输大盘。那更聪明的做法是用一个简单的市场状态指标比如沪深300指数的20日均线位置来判断当前环境只有环境合适时才启用这个策略。这样组合起来整体账户的资金曲线会更平滑。回测代码本身也可以做很多工程上的优化。目前这套多股回测在40只股票、5年多的数据上跑一次大概需要30秒还不算太慢。但如果扩展到几百只股票建议用multiprocessing把数据分批并行跑能在几分钟内出结果调试效率会高很多。ST股弱转强这套策略说到底就是“低吸高弹性品种”的逻辑在特定规则下的应用。它不是什么圣杯回测127%的年化也不能代表未来但它提供了一套结构化的思考框架和工程实现路径。对于想练习量化交易的新手而言从这类规则清晰、逻辑明确的策略入手把回测框架跑通、把数据坑踩一遍比追求一个高收益的策略代码有价值得多。关键是搞清楚每一行代码背后的逻辑以及每一笔交易背后的风险。
返回列表