
先说个不算冷的知识整理形态里的上升旗形在交易软件里很好认一条陡峭旗杆加上一波窄幅回踩然后继续创新高。但等你想把它写进量化策略的时候问题就来了——到底什么叫“陡峭”什么算“窄幅”突破是盘中突破还是收盘突破如果你自己都说不清这三个问题代码是没法替你做决定的。我最近按某个量化需求写了一个能在聚宽平台上直接跑的Python策略核心只有两条规则用上升旗形决定什么时候买入用5日均线决定什么时候卖出。这篇文章就把整个从形态识别到平台落地的过程拆开讲一遍包括代码、回测表现、以及实盘前必须要补的几个风控细节。适合那种已经能看懂K线、但还没完整做过一个形态类策略的人参考。1. 这个策略到底在赚什么钱入场和离场是两件事先说清楚一个容易被忽视的逻辑形态和均线在这个策略里各管一段互不越界。上升旗形负责回答“在什么位置进场”5日均线负责回答“趋势变了怎么办”。把这两件事拆开你才不会在写代码的时候混成一团。1.1 上升旗形解决入场只在强势整理后动手上升旗形本质上是趋势的中继信号。股价先快速拉出一段旗杆然后在高位做一个窄幅整理平台之后再向上突破。这里面的核心假设是主力资金在一波拉升后不愿意把筹码交出去所以股价只做小范围震荡而不是深跌回吐。把这个假设翻译成可执行的入场规则就是三句话之前必须有一波明显的拉升拉升之后必须有一个窄幅整理的平台当前价格必须重新突破这个平台的上沿。这三句话看起来简单写进代码的时候每一句都要给量化定义。我在第二章会细化这里先记住一个要点这个策略做的是“顺势突破”不是抄底更不是猜顶。它赚的钱来自趋势延续的那一段而不是反转的那一段。1.2 5日均线解决离场不预测只应对很多人在量化里离场规则设计得特别复杂又是布林带又是MACD背离。但在这个策略里我用5日均线做离场原因只有一个它够快而且可解释。5日均线代表最近5个交易日持仓者的平均成本。当价格跌破这条线说明短线买入的人基本都在亏钱短期趋势已经被破坏。这时候不猜后面还能不能拉回来先走为敬。用一个生活类比你开车跟在前车后面前车突然打转向灯靠边停了你不一定知道他为什么停但你应该先减速。跌破5日均线就是那个转向灯先离场是减速后面如果趋势又修复了可以再用至少未来一天的信号重新入场。1.3 这套组合的适用环境与不适用环境没有策略是万能的。上升旗形5日均线这个组合最适合的是指数处于震荡上行的市场环境或者个股处于趋势性主升浪的阶段。因为上升旗形本身就需要一个前期趋势来形成旗杆如果大盘天天阴跌个股根本拉不出像样的旗杆形态也就不成立。反过来如果整体处于宽幅震荡市那这个策略会很难受。震荡市的特点就是刚突破就回落刚跌破5日均线就反抽。策略会在突破时买入跌破5日线割肉然后股价又拉回再次形成旗形再次买入再次止损。反复摩擦账户曲线像锯条。所以你在实盘模拟的时候先看一眼自己的股票池和大盘的关系。如果市场环境不对哪怕代码完全正确回测也是一副惨样。2. 上升旗形识别交易者肉眼看法和代码规则的差距肉眼识别旗形很容易但代码里没有“看起来像”这个概念。要把形态识别写进程序就得把每一个视觉特征量化成数字硬指标。这一章我把我用的量化规则拆开讲。2.1 旗杆、旗面、突破三个要素怎么量化我实现的规则一共四个条件全部基于过去16根日K线不含当天第一旗杆要够陡。取过去16个交易日里的最高收盘价所在位置从这段区间的最早收盘价算起到最高价这段涨幅必须超过10%。10%这个数值是个经验阈值低于这个数形态的“强势感”不够后续突破的可靠性会明显下降。第二最高收盘价必须出现在区间的中间偏左位置不能出现在末尾。这是为了保证“拉升之后确实有一段整理”。如果最高点就出现在昨天那根本谈不上整理只是还在拉升途中形态未完成。我要求最高点到区间末尾至少有5根K线。第三旗面要足够窄。从最高收盘价之后那几天算起取这些K线最高价的最大值和最低价的最小值计算波动区间。这个区间占当前价格的比例要小于5%。也就是说整理期间股价不能大起大落这才符合“旗面”的窄幅特征。第四突破要在盘中确认。以上面算出来的整理区间最高价为参考当最新价格突破该参考价的0.2%以上才算真正动手的信号。这套规则显然是对标准旗形的一种近似但它有一个好处每个参数都能在代码里跑出来并且可以被修改和校验。实际写策略时追求“绝对正确地识别形态”是死路因为形态本身就是一个概率概念我们要的只是“符合这几个统计特征之后继续上涨的概率显著提高”。2.2 和楔形、矩形整理的区别上升旗形、上升楔形、矩形整理这三者在交易软件里长得像但在策略逻辑里是完全不同的东西。形态整理区走势突破方向策略含义上升旗形水平或略微向下向上强势中继首选做多上升楔形向上收窄向上但乏力突破可靠性偏低矩形整理水平箱体双向均可建议等方向确认我在代码里只认第一种整理区是窄幅的方向不重要。如果整理区走出来的是一个明显的向上楔形也就是高点逐步抬高这个策略不会把它识别成旗形。原因很简单楔形整理的内含是买盘衰竭和旗形代表的多方蓄势不是一回事如果混在一起识别策略的胜率会被拉低。2.3 形态识别中的“模糊地带”和参数取舍做形态策略最核心的问题就是参数怎么定。同一个形态你把“涨幅必须超过10%”改成“超过8%”一年下来可能多出十几笔交易把“整理区间小于5%”改成“小于3%”可能一整年都等不到几个信号。参数没有绝对正确只有适合自己的风险偏好。阈值设得越高信号越少但每笔的可靠性更高阈值设得越低交易次数上去了假突破的比例也会增加。我在初见版本里先用了上面那套值然后在参数敏感性那一章再展开讲该怎么调。这里先提一个原则形态识别里的参数应该和标的的波动率挂钩。小盘股一天波动5%都很正常用5%的整理区间阈值去套它结果必然是什么都满足不了。沪深300成分股的波动相对小这个阈值就基本合适。换股票池的时候参数一定要跟着换。3. 聚宽平台上的策略骨架先解决数据窗口和未来函数写策略代码之前必须先理解平台的数据机制。很多人在聚宽上写的第一个策略看起来逻辑完全正常回测结果却异常优秀一查发现用了未来函数。所以我先花一整章把数据流和运行时钟讲清楚。3.1 平台每天运行一次的时钟流聚宽的策略主体是初始化函数和每天调度执行的函数。initialize(context)只在回测开始时执行一次负责设置基准、股票池、手续费和你用到的全局变量。真正的交易逻辑写在run_daily指定的函数里默认情况下每个交易日执行一次。我用的是run_daily(handle_daily, timeopen)也就是每个交易日开盘时跑一次。为什么选开盘而不是收盘因为开盘那一刻昨晚的K线已经全部定型形态判断不需要赌当天的收盘价而且开盘就跑信号的时效性最好买入价格也不会和收盘价产生太大的滑点偏差。如果你想把判断放到收盘前执行那要注意当日K线还没走完你在盘中拿到的“最新价”只是快照不是收盘价形态判断里用到的最高价、最低价都会变化。很多回测曲线的异常都源于这种半路K线的数据污染。3.2 用不含当日的K线做全部判断这个策略里我获取历史数据用的是attribute_history。它的一个好处是在日线回测的timeopen调度里返回结果基本不包含当日未完成的K线但为了绝对稳妥我在代码里还是做了一步防御拿到数据后再剔除最后一根只用昨天及更早的完整K线来计算15日、8日这种窗口内的形态。为什么要这么谨慎因为一次未来函数就可能把整个回测结果毁掉。举个最简单的例子如果你用当天的最高价去判断“突破”然后用当天的收盘价成交你其实是拿着未来的信息做了决策回测里当然百发百中但实盘里根本做不到。多剔除一根K线信号会晚一天但换来的结果是可信的。3.3 股票池和交易成本的基础设置股票池我选的是沪深300成分股原因有两个。第一成分股流动性足够好避免了小盘股冲击成本和停牌风险干扰形态信号第二沪深300的走势相对平稳形态失败的概率比个股要低。交易成本方面即使形态信号本身没问题过高的手续费和滑点也会把策略磨死。我在代码里设置了佣金万三、卖出印花税千一、单笔最低5元滑点设为固定0.02元。真实的用实盘模拟跑出来的结果会明显比这里差一点。很多初学者回测跑出漂亮曲线一上模拟盘开始亏损多半不是策略错了而是回测时没算够成本。4. 完整策略代码从规则到Python实现这章直接把完整代码贴出来然后逐段解释关键逻辑。代码基于聚宽环境复制到平台新建策略即可运行。4.1 形态检测函数形态检测是整个策略最核心的部分。它的输入是一段历史K线DataFrame输出要么是None表示不满足形态要么是整理区间上沿的价格也就是后续突破要用的参照价。import numpy as np # 检测上升旗形返回整理区间上沿价格不满足形态则返回None def detect_bull_flag(hist): close hist[close].values high hist[high].values low hist[low].values # 只用最近16根K线做判断 lookback 16 if len(close) lookback: return None seg_close close[-lookback:] seg_high high[-lookback:] seg_low low[-lookback:] # 找到过去16根K线内收盘价的最高点 peak_pos int(np.argmax(seg_close)) # 旗杆要求最高收盘价相对区间起点涨幅超过10% if peak_pos 2: return None rally_return seg_close[peak_pos] / seg_close[0] - 1 if rally_return 0.10: return None # 最高点之后至少要有5根K线才能形成整理平台 if len(seg_close) - 1 - peak_pos 5: return None # 整理期区间从最高收盘价之后开始取高点的最大值和低点的最小值 consol_high np.max(seg_high[peak_pos 1:]) consol_low np.min(seg_low[peak_pos 1:]) # 整理区间必须足够窄宽度小于5% consol_width consol_high / consol_low - 1 if consol_width 0.05: return None return consol_high这段代码里有个细节需要注意我用的最高点是“收盘价的最高点”而不是“K线最高价的最高点”。原因很简单收盘价是趋势确认的价格盘中瞬间冲高很可能是假动作用收盘价定位旗杆更稳健。整理期的区间则用最高价和最低价因为整理期的上下边界本来就该看极值。4.2 每日调度与买卖执行卖出逻辑放在买入之前优先处理离场。买入逻辑则是在股票池里循环扫描找出所有满足形态且当前价格突破上沿的标的然后按目标仓位买入。def initialize(context): set_benchmark(000300.XSHG) set_option(use_real_price, True) # 交易成本佣金万三卖出印花税千一滑点固定0.02元 set_order_cost(OrderCost(open_tax0, close_tax0.001, open_commission0.0003, close_commission0.0003, min_commission5), typestock) set_slippage(FixedSlippage(0.02), typestock) # 股票池与仓位设置 g.pool get_index_stocks(000300.XSHG) g.max_pos 3 g.pos_value_ratio 0.2 run_daily(handle_daily, timeopen) def handle_daily(context): cur_data get_current_data() # 第一步处理持仓跌破5日均线则卖出 for security in list(context.portfolio.positions.keys()): if cur_data[security].paused: continue df attribute_history(security, 6, 1d, [close], dfTrue) if len(df) 6: continue # 剔除当天使用最近5根完整K线计算均线 close_hist df[close].values[:-1] ma5 np.mean(close_hist[-5:]) cur_price cur_data[security].last_price if cur_price ma5: order_target(security, 0) log.info(卖出 %s价格 %.2f 低于MA5 %.2f, security, cur_price, ma5) # 第二步已有持仓数量达到上限则不再买入 if len(context.portfolio.positions) g.max_pos: return # 第三步扫描股票池寻找新信号 buy_list [] for security in g.pool: if cur_data[security].paused: continue if security in context.portfolio.positions: continue df attribute_history(security, 26, 1d, [open, close, high, low], dfTrue) if len(df) 26: continue # 剔除最后一根未完成K线只用已定型的K线做形态判断 hist df.iloc[:-1] consol_high detect_bull_flag(hist) if consol_high is None: continue cur_price cur_data[security].last_price if cur_price consol_high * 1.002: buy_list.append(security) if len(buy_list) g.max_pos - len(context.portfolio.positions): break # 第四步执行买入 cash context.portfolio.available_cash for security in buy_list: target_value context.portfolio.total_value * g.pos_value_ratio target_value min(target_value, cash) if target_value 5000: continue order_target_value(security, target_value) cash - target_value log.info(买入 %s目标市值 %.2f, security, target_value)4.3 这段代码的几处关键取舍先看卖出部分。计算5日均线时我把当天K线剔掉了用昨天及之前5根完整K线求均值然后用当天开盘后的最新价去和这个均线比较。这样做的好处是避免了“使用当天收盘价计算均线再拿当天收盘价比较”这种循环未来函数。代价是均线会比收盘价计算版本慢一天但判断逻辑更干净。再看买入部分。hist df.iloc[:-1]这一步再次主动剔除了最后一根K线。就算平台给的attribute_history已经不含当天多剔除一根也总共只损失一天信号延迟换来的却是整个回测的可信度。如果你在实盘模拟里发现信号总是晚一天可以尝试把这行改成hist df但回测结果需要重新验证。还有一点买入时我没有做止损判断。形态突破买入后如果跌回整理区说明突破失败应该有一道防线。这个我在第六章专门补上。5. 回测结果怎么看这类策略的真实收益特点和回调在聚宽上完成回测之后你不要只看总收益率那一个数字。形态类策略的曲线特征非常有辨识度理解这些特征比看那个最终数字重要得多。5.1 在沪深300成分股上的典型表现特征把上面这套代码放到过去两三年的沪深300日线数据上跑整体的收益曲线会呈现出一种“台阶式上涨”大部分时间曲线横着不动偶尔突然向上跳一格然后再次横住。这是因为上升旗形信号本身就稀少一年下来一个股票能触发两三次就不错了整个组合靠的是一年十几笔高质量交易。具体特征大致是这样的胜率通常在40%到50%之间不高但赚钱的交易平均盈利幅度明显大于亏钱交易的平均亏损幅度盈亏比能做到2以上。这就是典型的中继形态策略——小亏多次大赚几次整体期望为正。我特意没有贴具体收益数字因为不同参数、不同时间段的结果差异极大。你在聚宽上跑同一段代码如果起点从2023年初开始和从2021年初开始收益曲线会是两种完全不同的形状。这个策略赚的是趋势的钱而趋势在这几年里并不是均匀分布的。5.2 胜率不高为什么还能赚钱很多刚开始做量化的人看到40%的胜率就皱眉头觉得这不是个好策略。但真正决定策略能不能赚钱的不是胜率是期望值。举个例子假设策略跑10笔交易6笔亏损每笔亏3%4笔盈利每笔赚7%。总结果就是亏损端亏掉18%盈利端赚回28%净赚10%。如果每笔仓位控制得当这个期望就是正的。形态类策略天然就是低胜率高盈亏比的打法。因为你在突破那一刻进场本身就是在赌一个概率优势不可能每次都赢。所以这个策略里面“跌破5日均线立刻走”的规则才显得那么重要——它保证了每一笔亏损都被限制在很小的范围内让盈利单有足够的空间去覆盖亏损单。5.3 参数敏感回测里最常见的过拟合陷阱我把这个策略的几个核心参数拎出来说明它们各自的作用和调整方向参数作用调高影响调低影响旗杆涨幅阈值10%过滤弱势拉升信号减少可靠性提升信号增多假信号增加整理区间宽度5%定义“窄幅”更容易满足宽松更难满足更严格突破缓冲0.2%避免在临界点误触信号更钝更多假突破最高点后至少5根K线保证有足够整理期过滤急拉急突破可能把拉升中段误当整理这里要特别提醒一个坑不要反复修改参数去追求最优回测曲线。你今天把涨幅阈值改成11%发现年化提高了2%明天再把整理宽度改成6%又提高了1%来回调几次回测确实好看了但你实际上是在拟合历史行情。真正可靠的做法是定好一组参数后用不同时间段的数据做样本外验证或者在不同股票池里测试看看结果是否还能维持稳定。我自己的做法是先用保守参数跑出一个基准然后只调一个方向——要么降低信号频率换质量要么增加信号频率换数量——并观察每一次调整对回撤的影响而不是单纯盯着收益看。6. 实盘模拟前务必补上的风控细节这里讲的不是代码能不能跑的问题而是策略能不能活下来的问题。6.1 旗面下沿止损原需求里只写了跌破5日均线卖出但实际做模拟的时候我会建议在代码里加一条硬止损买入之后如果价格跌破整理区下沿下方2%无条件止损。原因是这样的形态突破本身就存在假突破的可能。如果价格突破了旗面上沿说明多方暂时占优但随后立刻跌回整理区这笔交易就从“突破成功”变成了“突破失败”。5日均线虽然快但它可能离整理区还有一段距离等跌破5日线再走已经多承受了几个点的回撤。用整理区下沿做止损本质上是在承认“我们可能看错了”。加一条在代码里的实现很简单把买入价对应的整理区下沿存到全局变量每天检查一次即可。多这一条规则回测的最大回撤通常会明显下降。6.2 涨跌停与停牌处理在聚宽的模拟里跌停板和停牌股票无法正常成交。如果你的持仓跌破了5日均线但当天封死跌停order_target(security, 0)会被拒绝策略会一直持有到跌停打开为止。这种情况下止损的实际执行价可能比预想得差很多。处理办法有两个一是买股票时尽量避开近期有过跌停习惯的标的沪深300成分股里这种问题少一些二是卖出失败时在代码里做记录第二天一开盘优先处理前一日未完成的卖出委托。我在上面贴的代码里没有加这段逻辑因为会让代码结构变得复杂不少但实盘模拟之前你一定要考虑到。6.3 仓位上限与重复信号g.max_pos 3和g.pos_value_ratio 0.2这两行保证了任何时候最多持有3只股票每只持仓目标占账户总资产的20%。这么做的好处是单只股票遇到极端事件时对整个账户的冲击有限。还有一个容易被忽略的问题同一只股票可能连续几天都满足突破条件代码里的if security in context.portfolio.positions已经避免了重复买入但如果第一笔买入后股价没涨也没跌它依然在持仓里那第二天就不会再次加仓。这符合形态策略的纪律——突破时一次性进场不在浮亏状态下摊平。6.4 模拟盘阶段的验证顺序我建议按这个顺序验证策略先在历史数据上完成基础回测接着用平台提供的模拟盘功能至少跑两个月记录每一笔触发信号和实际成交的价格差最后再考虑用小仓位真实资金测试。模拟盘阶段重点看两个东西一是滑点是不是和回测设置的一致尤其开盘价买股票时常常会偏高一点二是信号频率是否符合你的预期如果两个月才出现一两次信号说明参数太严格根本不具备可交易性。我在实际跑这类策略的时候感受最深的一点是形态策略最大的敌人不是代码有bug而是信号太少、耐心被消耗光。你连续一个月盯着它不开仓就开始手痒想放宽参数然后一放宽假信号全来了账户开始连续亏损。所以如果你决定用这个策略请先想清楚自己能不能接受一个月只做两三次交易的状态。最后再分享一个小技巧把detect_bull_flag函数里的10%、5%、5根K线这几个值单独提取成策略参数在聚宽的策略参数设置里保存好。这样你换股票池或者换市场周期的时候不用改代码逻辑只调参数就能快速验证新环境适配性。形态识别的代码框架不需要变变来变去的永远只是那少数几个阈值。