全市场A股技术指标扫描系统构建与性能优化实践
发布时间:2026/10/22 5:03:28 锦皓数字建站

先交代一下这个项目是怎么来的。我维护的技术面筛选流程之前一直是看几只算几只从行情软件里导出一个股票池逐只算指标大概二三十只票循环里跑半天也不觉得慢。直到某次要求把全市场都过一遍——沪深两市五千多只股票每只都要输出51个技术指标的当前值还要按指标做截面排序我才意识到单票分析和全市场扫描根本是两个物种。接到这个需求第一反应是算算量5000只股票 × 51个指标就是25.5万个指标值。听着吓人但每只股票只算最新一个交易日的话数据量其实很小难点根本不在计算量而在于三个地方一是指标之间有大量重复的底层计算直接逐股跑等于把同一批EMA算了五千遍二是全市场扫描对数据质量极其敏感停牌、新股、ST、除权、一字板全都混在一起清洗规则写不好后面所有指标都是脏的三是跑出来25万个数字怎么落地、怎么查、怎么变成能用的信号也需要单独设计。这篇文章把我从零搭这套扫描系统的完整过程梳理一遍包括指标依赖分层的思路、数据清洗和存取方案、三种计算路线的实测对比、结果存储与信号聚合的做法以及最后对拍核验时踩到的一堆坑。适合正在做或准备做全市场技术面扫描的人参考也适合只想把几百上千只股票批量算指标的人——这里面的很多坑股票一多就会遇到。1. 先想清楚全市场扫描到底难在哪1.1 单票分析和全市场扫描是两个物种单票分析的本质是深挖。你有一只有兴趣的票把它的日线拉到屏幕上鼠标点上几个指标看看形态、看看背离几秒钟就能完成。这个场景里数据是干净的指标是单只股票独立算的你甚至不用关心其他四千多只股票在干什么。全市场扫描的本质则变成了宽筛标准化。同一个指标要在五千多只股票上得到可比的数值然后横向排序、过滤、打分。这个转变带来的问题很直接第一数据结构不一样。每只股票因为上市时间不同、停牌天数不同可用的历史窗口完全不同。次新股上市才20天你给它算250日均线结果当然是空的停牌半年的股票复牌当天跳空指标序列会出现断层。第二计算的粒度不一样。单票分析可以容忍循环里每次都重算一遍MA、EMA这种基础序列全市场扫描如果每个指标都从原始价格重新推导等于把最底层的平滑计算重复执行了几百上千次纯粹是浪费机器和时间。第三结果必须可解释、可追溯。你说今天有87只股票出现MACD金叉这个数字是怎么来的用的哪套参数复权方式是什么NaN是怎么处理的如果答案模糊这个结果就没人敢信。单票分析可以拍脑袋全市场扫描的结论经常要拿去指导决策含糊不得。所以我在动手之前先定了一条规矩整个扫描过程必须幂等、可复现、可对拍。同一份数据不管跑多少遍输出的每只股票每个指标的值必须一模一样。1.2 51个指标不是拍脑袋凑数而是筛选体系的组合这51个指标不是某一天从哪份研报里抄来的而是我从日常筛选逻辑里反推出来的。平时看盘用到的指标大致分五类分类如下指标大类典型成员举例共同依赖的底层序列趋势类MA、EMA、MACD、TRIX、DPO、BBI收盘价的平滑序列摆动类KDJ、RSI、CCI、WR、BIAS、PSY高低价滚动极值、平滑序列波动类ATR、BOLL、布林带宽、标准差通道真实波幅TR、滚动标准差量能类OBV、VR、MFI、量比、均量线成交量、成交额、价量结合辅助类SAR、唐奇安通道、筹码密集区近似值滚动最高最低价粗算下来传统技术分析里常用且有明确公式的指标大概就是四五十个再把参数变体补上凑到51个顺理成章。这一步看似简单其实很重要指标清单一旦确定后面所有工作——依赖分析、计算管线、结果表结构、对拍脚本——都围绕它展开。建议不要今天加一个明天删一个先把清单当合同定下来。1.3 给系统定个合理目标每天收盘后30分钟内出结果A股下午3点收盘数据接口通常在下午4点到6点之间把当日数据补全。我的目标定得很朴素数据到位后30分钟内系统必须输出全市场各指标的当日快照、截面排名和筛选候选清单。这个目标决定了技术选型的方向。半小时看起来宽松但如果采用逐股循环调指标库的方案5000只股票乘51个指标光函数调用就是25万次加上数据读取和结果落盘很容易跑到十几分钟甚至半小时以上。再加上数据清洗、输出Excel这些杂活time budget就非常紧张。所以我在设计之初就把计算速度当作一等公民而不是跑通了再优化。2. 51个指标背后的依赖树先算谁能省一半时间2.1 把指标拆成底座—中层—上层三层很多人写指标扫描喜欢这样ma5 算一遍均线ma10 再算一遍均线macd 从原始价格算EMA12、EMA26……写下来一百来行全是从原始行情出发的独立计算。功能上没错性能上惨不忍睹——同一批EMA被重复算了十几遍。我梳理完这51个指标的公式后发现它们根本不是51个并列的独立计算而是一棵有向树。绝大多数指标都建立在几个非常基础的底座序列上价格平滑底座收盘价的MA、EMA波动底座最高最低价的滚动极值、真实波幅TR、滚动标准差量能底座成交量滚动和、成交额滚动和、价量乘积序列。中层是这些底座的直接产物比如RSV、DIF、DEA、TR、乖离率、强弱比值。上层才是大家真正挂在嘴边的MACD柱、KDJ的K/D/J、ATR、ADX、价格通道。这条推断直接改变了我的实现方式先把5类底座序列全部算好、缓存住任何上层指标一律从底座读取而不是从头算。2.2 一个具体的例子MACD和TRIX共享同一批EMA拿MACD举例。国内行情软件里的标准公式是DIF EMA(CLOSE, 12) - EMA(CLOSE, 26)DEA EMA(DIF, 9)MACD柱 2 × (DIF - DEA)EMA是一个递归平滑公式是EMA_today EMA_yesterday 2/(N1) × (close_today - EMA_yesterday)稍微算一下就知道MACD需要三个EMA序列EMA12、EMA26、EMA9(DIF的平滑)。而TRIX这个指标它的标准算法是TR EMA(EMA(EMA(CLOSE, N), N), N)TRIX (TR_today - TR_yesterday) / TR_yesterday × 100看到没有TRIX内部也是三重EMA。如果逐股循环时每个指标都独立算一遍MACD要算3个EMATRIX又要算3个EMA光这两个指标就重复计算了6次同周期的平滑。全市场5000只股票放大一下纯计算量翻两三倍都不止。所以我在底座层统一提供EMA、MA、TR、滚动极值、滚动标准差的接口上层指标一律调用底座结果。这部分的收益在5000只股票的规模下特别明显后面实测数据里能看得清清楚楚。2.3 中国式指标的方言问题必须单独处理这是全市场扫描里最隐蔽、也最容易让结果对不上号的坑。开源指标库里实现的指标和国际通用教科书版本一致但国内行情软件里的公式有好几个都带中国特色直接调用开源库会得到有偏差甚至完全不同的数值。最典型的是KDJ。国内软件的标准公式是RSV (CLOSE - LLV(LOW, 9)) / (HHV(HIGH, 9) - LLV(LOW, 9)) × 100K SMA(RSV, 3, 1)D SMA(K, 3, 1)J 3 × K - 2 × D这里SMA(X, N, M)不是简单移动平均而是一种带权重的递归平滑Y_today (M × X_today (N - M) × Y_yesterday) / N当M1、N3时就是 Y (X 2 × Y)/3。这个算法和开源库里的SMA简单移动平均完全是两回事如果直接拿开源库的SMA去套KDJ的K值和D值全都会偏。类似的还有MACD柱是否乘以2、RSI的平滑方式、CCI公式里的0.015常数、ATR是用Wilder平滑还是用递归SMA。这些差异单独看都是小数点后的事但放到全市场扫描里含义就差远了——金叉还是死叉的判断可能直接翻转。我的处理办法是凡是国内行情软件有标准公式的指标全部用numpy/pandas手写实现不依赖开源指标库。手写一开始有点工作量但换来的是对拍时干干净净的数值这个账算得过来。3. 数据地基5000只股票的历史数据怎么清洗、复权、存储3.1 日线数据的获取与字段设计全市场扫描的第一步是数据。A股全市场大概5000多只股票单看日线数据量其实不大一只股票一年约250根日K10年2500根5000只股票也就1250万行一个中等配置的笔记本内存完全装得下。数据源我选了开源数据接口价格便宜、覆盖面全但它的日线数据有两个毛病必须处理一是停牌日通常不返回记录行与行之间的日期不连续二是部分历史数据没有完善的复权因子需要自己根据除权除息公告补齐。我的原始行情表设计成四个文件基本信息表、日线行情表、复权因子表、交易日历表。日线行情的字段是date, code, open, high, low, close, volume, amount复权因子表单独存不直接在行情表里改价格。这样设计的好处是计算时想用前复权还是后复权都可以随时现算不用把原始数据改坏了。交易日历表则是全市场统一的交易日索引后面做宽表对齐的时候全靠它。3.2 复权选择算指标用后复权展示用前复权复权是技术指标计算里最容易被忽略、但影响最大的一个环节。股票分红除权那一天价格会跳空比如一只100块的股票10送10除权后价格直接变50。如果你用不复权的数据算MA、BOLL、ATR那一天及之后一段时间的指标全是扭曲的金叉死叉会被硬生生造出来。市场上一般有前复权、后复权两种口径。前复权以最新价格为基准把历史价格往下调后复权以最早上市日为基准把分红累积到价格里。关键是前复权的历史价格会随着最新价的变化一直漂移今天拉出来的历史序列和半年后拉出来的同一段历史数值不一样。这意味着扫描系统里如果存的是前复权数据历史信号就无法稳定复现——今天算出来的2023年5月出现过金叉半年后这个金叉可能就消失了。所以我定了一条铁律指标计算一律用后复权数据后复权序列是固定的历史不漂移。展示和对比价格时才转成前复权那是给人看的不进计算管线。3.3 停牌、新股、ST、一字板一套清洗规则清洗规则我写成了一张表扫全市场之前先过一遍情况处理方式原因停牌日无行情记录保留为缺失不填充停牌期间没有交易任何填充都是造假上市不足N个交易日的次新长周期指标返回NaN不强行补齐MA250这类指标客观上算不出来ST、*ST默认剔除但保留标签列这类股票交易规则限制多信号参考价值低一字涨停标注状态列正常算指标开高低收波动类指标会退化信号要加约束复牌首日标注跳空缺口幅度停牌期间信息堆积首日常极端波动这里面最值得说的是一字板。一字涨停的那一天开盘价、最高价、最低价、收盘价四个值完全相同很多以最高最低价为基础构造的摆动类指标会退化比如KDJ的RSV分母变成0需要加保护。这个不是错误是数据本身的特征信号生成逻辑里必须知道当天存在这种情况否则会选出根本买不进的票。3.4 存储形态与内存估算存储我选用了Parquet格式按交易日期分区每天一个文件。理由很简单扫描是按日进行的每天只需要追加最新一天的数据而分析时要按股票取完整历史Parquet的列式存储和压缩比能让文件小到原始CSV的三分之一左右。计算时则反过来把日线行情按股票组织成宽表。这个转换我认为是全系统最核心的内存策略把date × code × 字段的长表变成date × code的矩阵。一只股票2500个交易日5000只股票一个价格矩阵的规模是5000列 × 2500行每个float64占8字节大约100MB。开高低收再加上成交量五六个矩阵也就500MB左右现代机器完全吃得消。内存不是问题之后全市场扫描就从I/O密集型问题变成了纯计算问题而且计算本身也变得极其简单——后面马上说到。4. 三种计算路线实测从逐股循环到宽表矩阵4.1 路线一逐股循环调指标库最开始我用的是最直觉的写法从股票列表里拿一只读历史数据调用指标库算出51个指标存结果再拿下一只。这几乎就是单票分析的直接放大版。实话说功能是能跑通的。但跑到5000只股票时问题全暴露了函数调用太密集。每只股票51次函数调用5000只就是25.5万次就算每次调用只要零点几毫秒纯调用开销也在分钟级重复计算严重。每只股票独立算MA/EMA/TR没有任何复用数据处理碎片化。每循环一次都要做一次NaN检查、边界处理逻辑分散在循环体内后期加指标越来越难改。这个方案在我机器上实测5000只股票 × 51个指标跑完全程大约85秒加上读数据和落盘稳稳超过2分钟。不算灾难但30分钟的目标虽然能完成迭代空间太小——以后指标加到80个、股票再扩容时间就失控了。4.2 路线二groupby 逐列滚动第二次尝试是长表思维。把5000只股票的历史数据放在一个长表里按股票代码groupby然后在组内做滚动计算。这个思路理论上能减少循环次数但实际体验更差groupby分组本身很快但真正的坑在滚动窗口。pandas的rolling().apply()一旦传入自定义Python函数性能直接掉一个数量级——因为每一行都要回调一次Python函数。5000只股票 × 2500个交易日 × 51个指标哪怕只对其中几十个用自定义函数时间也是灾难级。这条路只适合用内置C实现的roll比如rolling(n).mean()、rolling(n).std()能覆盖的少数指标稍微复杂一点就得自己写加速不划算。4.3 路线三宽表矩阵化也是最终采用的方案宽表思路前面提过把所有股票的价格序列对齐到统一的交易日历上形成多个并列的矩阵然后所有指标计算都变成对整张矩阵的操作。比如算MA20一行代码ma20 close_matrix.rolling(20, axis0).mean()这里close_matrix的形状是(交易日, 股票数)rolling沿着时间轴axis0滑动每一列是一只股票pandas在底层用C语言实现这个滚动均值一气呵成。再比如算EMAema12 close_matrix.ewm(span12, adjustFalse).mean()5000列EMA同时算完底层是C代码速度非常可观。KDJ这种递归指标稍微麻烦一点但卷积化处理也能做到矩阵级运算先用滚动极值算出RSV矩阵再对整个矩阵做两次递归平滑——pandas的ewm虽然不直接支持国内的SMA(X,N,1)递归但这类递归本质上是IIR滤波器用scipy.signal.lfilter一行搞定性能照样是矩阵级的。矩阵化的最大好处不只是快而是代码量大幅减少。51个指标全部算完核心代码不到300行每个指标基本就是一行矩阵运算加必要的NaN处理。维护成本比逐股循环低了一个量级。4.4 实测数据和并行化的取舍同样在我笔记本上5000只股票 × 2000个交易日 × 51个指标三种路线的实测对比方案计算耗时代码复杂度结论逐股循环调指标库约85秒低好上手能跑但升级空间小groupby逐列滚动约40秒内置函数中边界情况多复杂指标会掉进apply坑宽表矩阵化约6秒低逻辑集中最终采用6秒是什么概念加载数据5秒、计算6秒、结果落盘3秒整个每日扫描流程在15秒内结束。加上清洗和自检30分钟的目标余量非常充裕。有人问我为什么不用多进程/多线程。我的实测是宽表矩阵化后单核C代码已经把活干完了再多分进程反而引入序列化成本和内存拷贝开销。真要并行也是把计算切块后扔给几个进程各跑一部分矩阵收益在数据规模还不到10000只股票的情况下并不明显。先把算法从逐股循环优化到矩阵向量化再做并行顺序不能反。5. 扫描结果怎么存、怎么查、怎么变成信号5.1 结果表结构一票一行当天快照计算跑完只是开始。25.5万个指标值如果不设计好存储后面查起来就是灾难。我用的结果表是一票一行的当天快照结构date, code, ma5, ma10, ma20, ma60, ema12, ema26, dif, dea, macd_hist, kdj_k, kdj_d, kdj_j, rsi6, rsi12, rsi24, boll_upper, boll_mid, boll_lower, atr14, obv, vr, mfi, ... (共51个指标列), listed_days, is_st, pct_chg, limit_up, limit_down每天跑完生成当天的快照按日期分区存Parquet。查询时用嵌入式列式数据库直接按日期过滤比如查2024年5月20日全市场MA20金叉MA60的股票就是一个SQLSELECT code, ma20, ma60 FROM daily_snapshot WHERE date 2024-05-20 AND ma20 ma60 AND ma60 ma20_pre这种存储结构的优点是查询简单、语义清晰缺点是每天多占一点磁盘。5000行 × 60列一天也就几MB存十年才几个GB磁盘压力完全可接受。5.2 截面百分位把51个指标统一到同一起跑线全市场扫描的另一个刚需是横向可比。同一只票的RSI是80另一只票的RSI也是80但一个处于长期高位一个刚突破——只看原始值你无法判断谁更极端。我采取的做法是给每个指标再加一层截面百分位每个交易日收盘后把全市场5000只股票某个指标的数值排个序算出它当日在全市场的百分位。这样一来MACD柱、KDJ的J值、ATR这些量纲完全不同的指标全部统一到0到100的百分位上可以直接相加、加权、做共振筛选。截面百分位的价值在于它把绝对数值变成了相对状态。比如BIAS乖离率单看数值你不知道多大算大但看百分位95%就意味着这个乖离水平在全市场排前5%是个明确的极端信号。后面所有信号评分我基本都是基于百分位做的。5.3 多指标共振与每日候选清单有了指标快照和截面百分位信号生成就变成了一组可配置的规则。我写的第一个共振策略长这样趋势过滤收盘价 MA20 MA60且MA20向上动量确认MACD柱为正DIF在DEA上方量能配合当日成交量 5日均量的2倍摆动不极端KDJ的J值百分位低于95排除超买非一字板、非ST、上市满120天。跑一遍全市场候选通常剩三五十只再按截面综合得分排序取前30生成Excel和摘要。这个逻辑看起来简单关键是整个链路上的每个环节——指标值、百分位、过滤条件——都来自同一份快照可复现、可解释。我还专门做了历史回放功能给定任意历史日期重算当天截面输出当时的候选名单。这个功能在复盘信号质量时特别有用能直接验证如果当时按这个策略选股结果怎么样。6. 和行情软件对不上这些校验步骤救了我6.1 指标对拍选10只票、3个日期逐项核对系统第一次跑通的时候我信心满满地把扫描结果拉出来看结果随手抽查一只票的MACD和行情软件上显示的数值对不上。仔细一查柱状图差了正好一倍——国内软件的MACD柱是2×(DIF-DEA)而我按国际惯例输出的是DIF-DEA。这就是之前说的方言问题在真实对拍里立刻现了形。所以我把对拍做成了标准流程随机挑10只风格不同的股票——大盘权重、中小盘、次新、高送转过的——取3个历史日期把MACD、KDJ、RSI、BOLL四个最常用的指标逐项和行情软件比对允许的误差只到0.001。这项工作看起来笨但价值极大。指标计算系统的质量核心不是代码写得漂亮而是和公认口径一致。每修完一个差异我都会把对应的检查项固化到自动化脚本里以后每次改指标逻辑都必须重新跑一遍对拍。6.2 未来函数与信号漂移检查技术指标计算里最隐蔽的问题是未来函数——某个时点的指标值意外地使用了未来数据。最典型的来源有两个一个是复权因子处理不当某天的价格里掺入了后面除权除息日才确定的信息另一个是滚动窗口使用不当比如本应截至当日的窗口不小心包含了次日数据。我的检查方法很简单截断重算。取历史数据的某一天T用截至T日的数据重新算一遍T日的指标值再和用完整历史算出来的T日指标值对比。如果一致说明没有未来函数如果不一致说明窗口里有来自未来的信息。这个测试对后复权数据尤其重要。因为后复权价格本身依赖整个生命周期内的除权事件如果复权因子里用了未来的除权信息那么任何历史时点的指标在重算时都可能漂移。我最后是把复权因子的计算严格限制成截至当日已知的除权公告才算彻底解决这个问题。6.3 NaN传染与边界数据信号生成的隐形杀手再说一个特别容易被忽视的坑NaN是会传染的。rolling窗口宽度是20那只股票哪天突然缺一行数据后面的指标可能连续20天都是NaN新股上市满60天之前MA60永远算不出来用rolling().mean()对含NaN的窗口计算结果一律是NaN而且这个NaN会顺着指标公式一层层扩散到MACD、KDJ的上层指标。我的办法是指标函数全部显式处理NaN并且把上市天数不足这种结构性缺失和数据损坏缺失区分开。前者是正常的信号过滤时直接跳过后者必须报警因为某天成交量数据坏了但没人发现后续所有量能指标都会带病上线。所以每天扫描流程的开头有一个数据自检步骤检查全市场当日行数是否接近预期、关键字段缺失率是否超过阈值、有没有异常的价格跳变。任何一个不通过整个扫描流程就停止并报警绝不拿着脏数据往下跑。这个宁可不跑不可错跑的原则事后看救过我很多次。6.4 边界情况的处理经验清单几个容易反复踩的边界情况我整理成清单放在代码注释里一字板当天开高低收KDJ/RSV遇到分母0要保护停牌复牌当天前一交易日和后一交易日之间没有连续性ATR会被跳空放大——这是真实信息不是错误涨跌停滤波扫描出的信号如果次日是涨停开盘实际成交可能进不去别把信号当成可成交的信号除权日前后后复权价格序列平滑但未复权涨跌幅会突变pct_chg列必须用复权价计算。这些都是算出来容易算对了难的地方。全市场扫描输错一个数值可能只是一个小数点的事但落到筛选结果上就是一批错误的候选股票。7. 落地后的几个运维心得系统稳定跑了几个月后我总结了几条实际运维中的体会可能比上面的技术细节更值钱。第一把系统做成幂等的。同样的输入数据跑多少次输出都必须完全一致。为此我做了两件事所有计算都基于同一份快照数据绝不在计算过程中去实时拉取外部接口所有随机因素如果存在都必须有固定种子。这样一旦结果有疑问随时可以重跑复现。第二指标配置和代码解耦。51个指标的计算逻辑虽然手写成矩阵运算但它们之间的依赖关系、参数配置比如MA的周期、BOLL的倍数全部外置到一个独立的配置文件里。跑批脚本不感知具体指标只是按依赖树调度。后来我把指标从51个扩展到70多个核心代码一行没改只新增了配置和对应的计算函数。第三全量重算比增量更新省心。很多人觉得每天只更新最新一天的数据应该做增量计算。但实际上在宽表矩阵方案里全量重算一次也就6秒增量更新反而要处理窗口衔接、NaN补位这些复杂逻辑出错概率更高。我最后选择了每天全量重算简单、干净、可信。最后分享一个小技巧我给每只候选股生成一份指标快照文件包含它当天51个指标的原始值、截面百分位以及过去20天的指标序列。这样做人工复核时不用再打开行情软件翻来翻去所有判断依据都在一张表里签字确认也方便。这个习惯让筛选结果的争议率大幅下降因为你拿出来的是可追溯的完整证据链不是一句话结论。这套系统从设计到稳定运行最深的体会是全市场技术扫描的难点从来不在算,而在把算这件事做得可靠、可复现、可解释。指标对拍、未来函数检查、数据自检这些环节每一环都是在为可靠性买单。如果把系统比作一台机器指标计算只是它的引擎而数据清洗、依赖编排、结果追溯才是让这台机器每天能按时出工的保障体系。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。