一个策略跑了302笔,胜率34.11%,单笔收益-0.52%,期末权益从100万掉到824456.21元。问题不在参数,而在它把「放量」误认成「主力建仓」。
先说清楚这篇文章不做什么:它不预测下一只牛股,也不把资金流说成庄家底牌。它只记录一次数据层改造如何让一个错误假设暴露出来,以及暴露之后该怎么约束使用。
我给这个策略加了一条过滤:当日主力净流入占成交额的比例不低于8%。同一套入场逻辑重新跑,交易从302笔降到226笔,胜率升到41.15%,单笔收益变成+0.23%,期末权益1013074.82元。最大回撤仍不轻松,从-26.92%收窄到-18.35%。
这不是一个稳赚系统的诞生记录,是一个量价代理被资金流拆穿的过程。放量只说明交易强度,不说明主动买卖方向。这个差别平时藏在噪声里,一到出货日就会变成账户上的亏损。
一、先看反例:涨6.72%,主力流出2.92亿
10月28日的东方精工是这次改动的起点。当天股价上涨6.72%,从量价形态看足够漂亮:价格强、成交活跃,很容易被解读为资金进场。收盘资金流给出的答案相反,主力净流出29207.39万元,约2.92亿元;成交额879216927元。
换算成同一单位后,mf_ratio是-33.2198%。不要被那根阳线带走,负号才是当天订单流给出的核心证据:主力净卖出方向在成交额里已经占了明显的一席。它不是说筹码被清空,而是说这根放量阳线不能被解释成稳定的主动买入。
同一个交易日,同一只股票,K线讲的是进攻,订单流讲的是撤退。这里说的主力也不是内幕真相,而是数据商按订单尺寸或相关口径估算的主力资金。它依然是代理,但至少补上了量价最缺的一维:方向。
这个反例改变了问题的层级。原来我在问,放量阳线之后两日缩量回踩,反弹概率高不高;后来必须改成,放量那天到底是主动买入在抬价,还是持仓者在趁流动性出货。前一个问题只有量价永远答不出来。
二、策略为什么负期望
策略叫放量建仓加两日缩量回踩。直觉很顺:先出现一根放量阳线,说明有人愿意用更高价格吃货;随后两天缩量回踩,说明抛压不重;等价格重新走强,短线资金接力。
回测没有给这个直觉面子。未过滤版本302笔里,只有约三分之一交易盈利。更糟的是单笔期望为负,说明它不是运气差,而是系统性把一种常见形态当成了买入理由。
问题出在「放量建仓」四个字的前半段。成交量是成交达成的强度,不是买方的身份。放量可以来自机构吸筹,可以来自游资拉高,可以来自大资金对倒制造注意力,也可以来自恐慌盘被承接。量价数据只记录结果,不记录意图。
缩量回踩也一样。缩量可以说明抛压减轻,也可以说明买盘消失,只是价格暂时没有跌透。若是后者,后面的反弹不是确认,而是流动性耗尽前的最后一次试探。
《投资最重要的事》里把这叫做第二层次思维:第一层看到好形态就买,第二层要问这个形态为什么出现,谁在对面,价格里已经包含了多少共识。资金流不是终极答案,但至少把对面那个人从K线里拖出来了一点。
三、mf_ratio 不是玄学,是单位工程
这次落进回测矩阵的字段有四个:net_mf_amount是主力净流入额,单位万元,与Tushare原始口径一致;mf_ratio是主力净流入占成交额的比例,百分数值,8就是8%,不是0.08;mf_streak是连续净流入天数,方向反转归零;mf_ratio_5d是五日主力净流入占比均值。四个字段不是四个孤立指标,而是一组从原始额、强度、持续性到均值状态的观察面。
最容易错的是单位。Tushare资金流的net_mf_amount是万元;Tushare原始daily.amount是千元;但仓库内daily.amount已经标准化成元。因此在系统内部,正确公式是净流入万元乘以1000000,再除以以元计的成交额。
单元测试用了一个极简例子:成交额100000000元,净流入800万元。800万元是8000000元,除以100000000元,得到0.08;字段落盘为百分数值,就是8.0。测试同时断言净流入、连续天数、五日均值和存储列顺序。
这个例子不值得炫耀,却值得写下来。它是从真实踩坑里反推出来的,不是教科书里的装饰性断言。更重要的价值在于,它把一个可能悄悄污染所有筛选结果的口径问题,压缩成机器可以反复执行的判断;测试读起来枯燥,放回盘后管道里就是安全带。
这类错误不会抛异常。单位差十倍或一百倍时,代码照样运行,回测照样出图,只是候选集被悄悄换了一批。阈值写8时,如果字段算出0.08,几乎所有满足条件的股票都会被漏掉;如果把0.33当成33%,又会把流出误判成强度。数据工程里最危险的不是缺数,是单位 silently 错位。
四、数据层怎么接入
原来的链路是日线parquet进入指标管道,生成enriched,再由策略声明required_fields,构建MarketDataMatrix,最后matrix_feature按字段名取数。这个设计有个好处:matrix_feature不关心字段业务含义,只要字段进入矩阵fields就能透传。
所以我只做加法,不改matrix_feature行为:
第一,新增data/moneyflow/date=YYYY-MM-DD/part.parquet数据域,保留原始净流入字段。本次回填从2021年1月4日到2026年10月28日,共1408个分区。
第二,enriched增加四列存储。选这一层而不是只在回测路径join,是因为选股页、监控、回测和AI助手都读同一份事实源;如果只在回测前补字段,同一个概念会在不同入口出现两套口径。
第三,策略在required_fields里声明mf_ratio,矩阵按字段集构建,缓存generation包含字段集合,旧缓存自动失效。
第四,把mf_ratio加入矩阵scoring白名单,让生成器知道它是可用特征,而不是允许生成器调用任意未知列。
存储列增加会不会破坏下游?关键在读取侧使用兼容扫描:缺失列插入空值,额外列忽略,旧分区与新分区可以共存。真正要守住的是存储契约和单元测试,而不是永远冻结列数。
独立数据域也比分混进ext_data更干净。资金流不是外部临时试验,也不是日线原始列,它有自己的同步频率、历史起点和缺失语义。原始域保留万元口径,派生字段在enriched里说明单位,回滚时删掉新域和新增列,重建即可。
mf_streak看起来简单,当天净流入为正则昨天连续天数加一,为负则归零。危险在于实现方式。
如果对整列先做累计,再把结果切给历史前缀,某一天的计算可能依赖之后才知道的 reset 点,回测就会引入未来函数。正确做法是按symbol和date排序后做因果递推,只允许今天引用昨天已经落盘的连续天数。
这次实现用reset分组累计来表达递推,测试不是只看最终序列,而是逐个前缀重算:1天、2天、3天,直到全序列,每个前缀的结果必须与全序列同位置一致。只要这个性质成立,历史任意截点都不会偷看未来。
这个测试比「结果看起来对」重要。回测系统的信任不是来自一次跑通,而是来自每个时点都只能使用当时可知的数据。道是避免前视,术是valid_shift和因果递推,势是让所有策略共享同一套时序纪律。
五、回测对比
同一入场框架只加一条mf_ratio不低于8的过滤。交易笔数从302降到226,胜率从34.11%升到41.15%,单笔收益从-0.52%变成+0.23%;总收益由-17.55%转为+1.31%,最大回撤从-26.92%收窄到-18.35%,期末权益从824456.21元回到1013074.82元。数字不是断崖式反转,更像把一批方向相反的样本从候选池里拿掉。
读这组数时要先稳住情绪。它没有把亏损策略变成印钞机,只是把原本混在一起的两类交易拆开了。真正要比较的是样本组成,不是被百分比刺激出的希望;前一组把方向相反的交易混在同一规则下,后一组至少把主力离场的放量从假设里拿掉。这个变化能否延续,要交给滚动样本,而不是交给叙述。
改善是明显的,但我不会把它讲成圣杯。226笔仍然不多;8%阈值来自这次样本内的实践,未经过足够长的walk-forward;胜率41.15%仍低于一半,只是盈亏结构把单笔期望推回正值。
更准确的解读是:资金流过滤剔除了大量「放量但主力离场」的假建仓。它修复的是这个策略的前提,不保证这个前提永远有效。
六、模型思维的条件性
《模型思维》讲过模型的第一个价值:把结论成立的条件写清楚。同一句「放量买入」,在趋势市、流动性扩张、主力口径稳定时可能有效;在风格切换、订单拆分、小单算法交易主导时可能失效。
这次回测真正得到的不是一个固定阈值,而是一个条件判断:当放量日的主力净流入占比为正且足够强时,两日缩量回踩才有更高的确认价值;当放量日主力净流出时,形态再标准也应该降权。
解释力的边界也要停一下。资金流模型原本是为了观察订单尺寸与主动买卖方向的代理关系,不是机构持仓披露。它不知道账户背后的真实策略,也无法识别复杂拆单。若市场结构让大资金刻意拆成小单,mf_ratio的信号质量会下降。此时不能说资金流失效就回到量价,而应该引入更细的成交行为数据或降低策略权重。
七、验收不是只看收益表
第一,现有25个内置策略回测结果必须与改动前完全一致,新增字段是加法,不能改变旧策略的取数、时序和信号。第二,单位测试必须锁死万元、元、百分数值三者的换算,8%就是字段值8,不是0.08。第三,mf_streak必须通过前缀一致性测试,防止递推字段变成未来函数。这三条合起来才构成数据层变更的及格线。
还有一条部署层面的教训:宿主代码、容器代码和数据回填必须指向同一版本。策略文件通过数据卷生效,不代表后端代码也生效。若运行中的容器仍旧使用旧ENRICHED_STORAGE_COLS,盘后重建会把新增列抹掉。回测系统的可复现性不在代码仓库里结束,而在实际执行路径上结束。
八、三情景
基准情景:策略进入观察期,不重新调参。若后续滚动样本中mf_ratio不低于8的交易胜率仍高于未过滤版本,且单笔期望保持为正,说明过滤捕捉的是结构,不是偶然。
上行情景:市场重新进入流动性驱动行情,成交额扩张且主力订单可辨识,资金流过滤降低假信号,同时提高覆盖率和盈亏比。触发条件是成交活跃度与订单口径稳定性同步改善。
下行情景:大资金普遍拆单,或主力资金定义与真实持仓行为脱钩,mf_ratio阈值失效,过滤后交易数继续下降但胜率不再改善。触发条件是样本外胜率回到35%以下。
这句话听起来像免责声明,实际是执行纪律。资金流过滤的意义不是让曲线永远向上,而是给策略一个可退出的边界。执行时还要记录当时的市场环境和数据覆盖率,否则事后只看见参数有效,看不见样本是在什么条件下被选出来的。若边界失效还继续解释,回测系统就从认知工具退化成故事生成器。
证伪条件很清楚:如果未来walk-forward中,过滤版胜率不再显著高于未过滤版本,或者8%阈值附近的收益曲线出现悬崖式变化,这个阈值就是过拟合。策略应退回观察池,而不是继续加过滤条件。
不要把成交量当资金立场。量是势,价是果,订单方向才是当天的证据。你看不到全部真相,但至少可以要求系统回答一个问题:这根放量阳线,大单是在买,还是在卖?
如果这个回答缺失,回测越精确,幻觉越坚固。
九、一个实践者的使用边界
我不会把这次结果直接推进实盘。更合理的用法是把它放进观察池,先冻结参数,再让数据滚动进场。每次触发信号时保留三条记录:放量日的资金方向、回踩两日的成交萎缩程度、入场前主力净流入是否仍为正。三者在回测里构成一个样本,在实盘里构成一份可审计的决策证据。
这样做的代价是行动变慢。你会错过那些资金流数据缺失但走势极强的票,也会遇到资金流为正但第二天继续下跌的票。策略系统的目标从来不是解释每一根K线,而是让自己知道在什么条件下出手,在什么条件下承认证据不足。缺钱流时保持空白,比把NaN填成零更诚实。
这有点像《投资最重要的事》里对风险的提醒:收益表只会显示结果,不会显示你在中间承受了什么。资金流过滤也一样,它不会让每一次入场都变得正确,却能让错误集中在可解释的范围内。若错误来自主力口径失效,就更新数据源;若错误来自阈值过窄,就扩大样本;若错误来自市场风格切换,就降低仓位或暂停。不同错误需要不同处理,混在一起就会把回测调成玄学。
还有一个容易被忽略的使用边界:同一个字段在不同界面上可能代表不同对象。选股页看到的是当日截面,回测矩阵看到的是历史面板,监控脚本看到的是实时增量。若三者没有统一数据血缘,最常见的事故就是选股时用百分数值、回测时用小数、监控时又回到万元绝对额。字段接入不是结束,字段语义的治理才开始。
所以这套改动的实际价值不是多四个列,而是把一个模糊判断拆成可执行的约束:放量必须有方向,回踩必须有因果,过滤必须有样本外检验,部署必须有同一份代码。四条同时成立,策略才值得继续占用注意力;任何一条消失,都应该回到观察,而不是靠解释续命。
这套约束最终服务的是人的判断力。工具越多,越容易把责任推给字段和模型;但决定样本边界、解释失效原因、决定何时停用的仍然是人。量化不是让思考外包给代码,而是让每一步思考留下可复核的痕迹。痕迹足够清楚,失败才有价值;否则失败只会变成下一段更动听的故事。
如果两年前的我看到这组结果,最先被吸引的大概会是胜率从三成多涨到四成多这条线。现在我会先问另一件事:这个改善能否被同样清楚地证伪。答案能写进参数冻结、滚动样本和部署检查,策略才值得继续;答案只能靠市场情绪解释,它就不值得占用真钱。资金流没有把市场变透明,它只是把一个问题交还给交易者:你愿意在什么证据面前承认自己错了。
参考资料
回测对比来自TSP custom_pullback_shrink的2026-10-30验收;东方精工行情与资金流来自Tushare daily与moneyflow;单位换算与因果递推测试来自backend/tests/test_moneyflow_pipeline.py;data/moneyflow覆盖2021-01-04至2026-10-28共1408个分区;书目观点来自霍华德·马克斯《投资最重要的事》和斯科特·佩奇《模型思维》。