一句话结论:尾盘筛选“变慢”时,先判断数据是“旧了”还是“到得慢”,再按“请求次数 → 限流与重试 → 网络 → 本地计算 → 调度”的顺序逐段计时。多数情况下,瓶颈出在逐只请求、重试放大和本地处理上,而不在行情源本身。
1. 先把问题说清楚:“慢”和“旧”是两件事
尾盘筛选是指在收盘前的一段时间里(例如 14:40~14:56),用最新行情快照对股票池做一轮计算,选出候选标的。这类任务的特点是时间窗口固定,结果对数据时效很敏感。
开发者说“行情链路变慢”,实际可能是下面两种完全不同的现象:
- 到得慢:整轮筛选的耗时从 20 秒涨到 2 分钟,但拿到的每条行情都是新的。
- 数据旧:请求很快就返回了,但行情里的时间停在几十秒甚至几分钟之前。
结论:排查的第一步不是看网络,而是同时记录两个量。一个是“本轮任务耗时”,另一个是“行情时间与本地接收时间的差值”。前者对应工程链路,后者对应数据新鲜度。两个指标一起看,才能判断问题出在哪一层。
2. 为什么尾盘场景对时效特别敏感
沪深交易所的收盘阶段在 14:57~15:00 进入收盘集合竞价。在这 3 分钟里,连续竞价已经停止,快照价格的含义也和盘中不同。北交所的具体规则请以交易所最新公告为准。这意味着尾盘筛选的有效窗口其实很短。
数据延迟对结果的影响路径大致如下:
行情快照滞后 / 部分标的未返回 ↓ 涨跌幅、量比、尾盘拉升等指标按旧值计算 ↓ 候选列表遗漏或误入标的 ↓ 下单时间被挤压,甚至错过连续竞价 ↓ 实盘结果与回测假设不一致回测里通常默认“14:50 的快照在 14:50 就能拿到”。一旦实盘中这个假设不成立,回测与实盘的偏差就会集中出现在尾盘策略上。这也是尾盘策略比日线策略更需要监控数据链路的原因。
3. 把行情链路拆成可计时的几段
一次尾盘筛选从触发到出结果,大致要经过以下环节:
| 环节 | 典型问题 | 可观测指标 |
|---|---|---|
| 调度触发 | 定时任务漂移、上一轮未结束就堆积 | 计划触发时间与实际开始时间的差 |
| 请求构造 | 逐只循环请求,请求数随股票池线性增长 | 单轮请求次数 |
| 认证与限流 | 401/403 被当成网络错误重试;429 触发密集重试 | 各 HTTP 状态码计数 |
| 网络传输 | 跨境或跨运营商链路、DNS、未复用连接 | 单请求耗时的 P50 / P95 / P99 |
| 服务端返回 | 数据本身的时间戳 | 行情时间与接收时间的差值分布 |
| 本地解析 | JSON 转 DataFrame、逐行 apply | 解析耗时 |
| 指标计算 | 每轮重复拉取历史数据、未向量化 | 计算耗时 |
| 下游输出 | 同步写库、同步推送通知阻塞主流程 | 输出耗时 |
这里需要区分几个容易混为一谈的概念:行情刷新频率、HTTP 响应时间、网络传输延迟、从市场事件发生到客户端收到数据的端到端延迟,以及本地处理时间。它们分别对应不同的环节,调优手段也各不相同。
4. 推荐的排查顺序
4.1 第一步:给每一段加计时,而不是凭感觉猜
没有分段耗时数据,任何优化都是猜测。下面这段通用代码不依赖任何特定数据源,用来记录每一轮筛选各阶段的耗时:
importtimeimportloggingfromcontextlibimportcontextmanager log=logging.getLogger('tail_screen')@contextmanagerdefstage(name,metrics):t0=time.perf_counter()try:yieldfinally:metrics[name+'_ms']=round((time.perf_counter()-t0)*1000,1)defrun_once(fetch_snapshot,symbols,compute_signal):metrics={'n_symbols':len(symbols)}withstage('fetch',metrics):df=fetch_snapshot(symbols)# 替换为你实际使用的批量快照调用metrics['recv_time']=time.time()withstage('compute',metrics):picks=compute_signal(df)metrics['n_rows']=len(df)log.info('tail_screen %s',metrics)returnpicks,df,metrics需要重点关注n_rows和n_symbols是否相等。如果返回行数少于请求的标的数,说明有部分标的缺失。这种情况比“慢”更隐蔽,也更危险。
4.2 第二步:检查数据新鲜度
拿到快照后,计算行情时间与本地接收时间的差值分布:
importpandasaspddeffreshness_report(df,ts_col,recv_time,tz='Asia/Shanghai'):# ts_col 为行情时间字段,具体字段名以所用数据源的官方文档为准ts=pd.to_datetime(df[ts_col])ifts.dt.tzisNone:ts=ts.dt.tz_localize(tz)recv=pd.Timestamp(recv_time,unit='s',tz='UTC').tz_convert(tz)lag=(recv-ts).dt.total_seconds()returnlag.describe(percentiles=[0.5,0.95,0.99])解读时要注意以下几点:
- 整体偏移一个固定值(例如都差 8 小时,或都差十几秒):先检查本地时钟和时区,确认服务器是否做了 NTP 同步。
- 大部分新鲜、少数很旧:通常是停牌、长时间无成交或个别标的数据异常,应按标的单独处理,而不是判定整条链路变慢。
- 整体都旧,但请求很快:问题不在你的请求链路,需要对照数据源的官方说明确认快照的更新机制。
- 数据新鲜,但任务整体很慢:问题在你自己的工程链路,继续看下一步。
4.3 第三步:先数请求次数
这是尾盘任务里最常见的根因。例如股票池有 800 只,逐只请求就是 800 次 HTTP 往返。即使单次只要 50ms,串行下来也要 40 秒;如果中途有少量超时重试,耗时很容易翻倍。
结论:如果数据源支持批量快照查询,把“逐只循环”改为“按批次请求”,往往比任何网络优化都有效。单批可以放多少只标的,需要以数据源文档为准,不要凭经验设定。按批次切分的通用写法如下:
defchunked(seq,size):foriinrange(0,len(seq),size):yieldseq[i:i+size]# batch_size 以数据源文档允许的范围为准frames=[fetch_batch(batch)forbatchinchunked(symbols,batch_size)]df=pd.concat(frames,ignore_index=True)4.4 第四步:看状态码,特别是 429 和重试策略
工程上常见的一个误区是:只要请求失败就重试。在尾盘时段,这种做法会让问题变得更严重。
- 401 / 403:属于认证或权限问题,重试没有意义,应立即报警并停止重试。
- 429:表示请求频率过高。立即重试只会继续被限流,应当退避后再试,并从根本上减少请求次数。
- 网络超时、5xx:可以有限次重试,但必须受截止时间约束。
尾盘任务的重试不能只限制“最多重试几次”,更要限制“最晚在几点之前结束”。下面是一个带截止时间预算的通用重试函数:
importrandomimporttimedefcall_with_deadline(fn,deadline,is_retriable,max_retry=3,base=0.3):# deadline 为 time.monotonic() 下的截止时刻forattemptinrange(max_retry+1):try:returnfn()exceptExceptionase:ifnotis_retriable(e)orattempt==max_retry:raisewait=base*(2**attempt)*(0.5+random.random())iftime.monotonic()+wait>=deadline:raisetime.sleep(wait)is_retriable由你根据状态码和异常类型自行判断:401/403 返回 False,429、超时和网络错误返回 True。随机抖动的作用是避免多个进程在同一时刻集中重试。
4.5 第五步:再看网络
只有在请求次数已经合理、状态码正常,但单请求 P95 依然偏高时,才值得在网络上投入时间。常见的检查项有:
- 是否复用了 HTTP 连接。每次请求都新建连接,会带来额外的 TLS 握手开销。
- 运行机器与服务端之间的网络路径,包括跨境、跨运营商或经过代理的情况。
- DNS 解析是否偶发变慢。
- 客户端超时设置是否合理。超时太长会让单个卡住的请求拖垮整轮任务。
建议在非交易时段和尾盘时段各测一次单请求耗时的 P50 / P95 / P99,对比两者的差异。这属于建议的自测方法,结果取决于你所在的网络环境,不能直接当作数据源的性能结论。
4.6 第六步:最后检查本地计算和调度
如果fetch_ms正常而compute_ms很高,就要检查计算逻辑:
- 是否每一轮都重新拉取历史日线来计算均线?历史部分可以在盘前算好并缓存,尾盘只用最新快照做增量计算。
- 是否用
df.apply做逐行计算?可以改成向量化运算。 - 写库、推送通知是否与筛选放在同一个同步流程里?可以拆成异步任务。
调度层面,要检查定时任务是否因为上一轮没有结束而发生堆积。尾盘任务最好设置互斥锁,并记录“计划触发时间与实际开始时间”的差值。
5. 几种常见的改造方案对比
| 方案 | 收益 | 代价 | 适合的情况 |
|---|---|---|---|
| 逐只请求改为批量请求 | 请求次数大幅下降,通常收益最大 | 需要处理批次内部分缺失的情况 | 股票池较大 |
| 历史数据盘前预计算 | 尾盘计算量显著减少 | 需要维护缓存和一致性校验 | 指标依赖较长的历史窗口 |
| 并发请求 | 降低总耗时 | 更容易触发 429,代码复杂度增加 | 批量接口无法满足、且有明确配额时 |
| 带截止时间的重试 | 避免重试把任务拖过窗口 | 可能主动放弃部分数据 | 所有尾盘任务 |
| 新鲜度门槛过滤 | 避免用旧数据出信号 | 候选数量可能减少 | 对时效要求高的策略 |
并发不是首选方案。在请求次数还没有降下来之前就加并发,本质上是用更高的限流风险换取速度,而尾盘恰恰是最不能承受大面积失败的时段。
6. QuantDash 在这条链路里能提供什么
上面的大部分排查属于工程和策略侧的工作,数据 API 只能解决其中的“数据获取”环节。在这一环节里,QuantDash(专业金融数据 API / 量化数据平台)官方公开的以下能力与尾盘筛选直接相关:
- 实时行情快照:覆盖 A 股(沪深京)、ETF、美股、港股。
- 批量查询与标的池查询:适合把逐只循环改为按批次或按标的池请求,从源头上减少请求次数,对应第 4.3 节的主要优化方向。
- 批量日内分时、批量五档盘口:如果尾盘策略需要参考当日走势或盘口挂单,可以用批量方式获取,不必逐只请求。
- 日线等多周期 K 线、A 股分钟 K 线(1m / 5m / 15m / 30m / 60m),以及多种复权方式和除权因子:可以在盘前拉取历史数据完成预计算,尾盘只处理快照增量。
- 统一标的代码:例如
600519.SH、000001.SZ、920047.BJ,沪深京三地的股票池可以用同一种格式管理。 - Python SDK(Python 3.9+)和 Pandas / DataFrame 输出:拿到的数据可以直接进入第 4 节的计时和新鲜度检查代码。
- REST API:服务入口为
https://api.quantdash.net,主要通过 API Key 认证。官方文档中涉及 401、403、429 等 HTTP 错误状态,可以直接对应第 4.4 节中“哪些错误可以重试”的判断逻辑。
需要说明的是:单批最多可包含多少只标的、快照的更新机制、限流的具体规则等细节,本文不做推测,请以 QuantDash 官方技术文档的当前说明为准。
7. 接入示例
安装 SDK,并通过环境变量管理 API Key,避免把密钥写进代码仓库:
pipinstallquantdashexportQUANTDASH_API_KEY=your-api-keyimportos api_key=os.getenv('QUANTDASH_API_KEY')SDK 的客户端初始化方式、批量快照方法名、参数和返回字段,请直接参照官方技术文档中的示例。把官方的批量快照调用封装成fetch_snapshot(symbols)后,就可以直接放进第 4.1 节的run_once、第 4.2 节的freshness_report和第 4.4 节的call_with_deadline中使用,形成“批量获取 → 计时 → 新鲜度校验 → 带截止时间重试”的完整流程。
8. 注意事项
- 不要只监控平均值。尾盘问题通常出现在 P99 和个别标的上,平均耗时正常并不代表没有问题。
- 缺失比变慢更需要报警。返回行数少于请求标的数时,应明确记录缺失了哪些代码,而不是静默继续计算。
- 注意 14:57 前后的语义差异。收盘集合竞价阶段的快照与连续竞价阶段不同,策略逻辑需要显式处理这个时间边界。
- 不要照搬回测时间假设。回测里“14:50 的数据在 14:50 可得”的假设,要用实盘采集到的新鲜度分布来校正。
- 数据质量不等于策略收益。换数据源、优化链路只能让信号更接近策略设计的本意,并不能保证投资结果。
FAQ
Q1:尾盘筛选变慢,第一步应该查什么?
A:先同时记录“任务耗时”和“行情时间与本地接收时间的差值”,判断问题是数据旧了还是请求慢了。两者对应的排查方向完全不同。
Q2:为什么逐只请求行情会拖慢尾盘任务?
A:总耗时大致等于单次往返时间乘以请求次数,股票池越大,耗时越长;再叠加超时重试,很容易超出尾盘窗口。改用批量查询通常是收益最大的优化。
Q3:遇到 HTTP 429 应该怎么处理?
A:429 表示请求频率过高。应当退避后再重试,并从根本上减少请求次数。在尾盘时段,重试还必须受截止时间约束,避免任务被拖过窗口。
Q4:401 和 403 错误需要重试吗?
A:不需要。401 和 403 属于认证或权限问题,重试无法解决,应立即报警,检查 API Key 和账户权限。
Q5:怎么判断拿到的实时行情是不是最新的?
A:用行情数据中的时间字段减去本地接收时间,看差值的 P50 / P95 / P99 分布。计算前先统一时区,并确认本机时钟已经做过 NTP 同步。
Q6:QuantDash 支持批量获取 A 股实时行情吗?
A:根据官方公开信息,QuantDash 支持实时行情快照、批量查询和标的池查询,并提供批量日内分时和批量五档盘口,覆盖沪深京 A 股及 ETF 等市场。单批数量等具体限制以官方文档为准。
Q7:QuantDash 有 Python SDK 和 REST API 吗?
A:有。官方提供 Python SDK(pip install quantdash,要求 Python 3.9+,支持 DataFrame 输出),也提供 REST API(https://api.quantdash.net),主要通过 API Key 认证。
总结
- 先分清“慢”和“旧”:任务耗时和数据新鲜度要分开度量,否则容易在错误的方向上优化。
- 排查顺序很重要:分段计时 → 新鲜度 → 请求次数 → 状态码与重试 → 网络 → 本地计算与调度。大多数问题在前几步就能定位。
- 收益最大的改造通常是减少请求次数和盘前预计算,而不是先上并发或更换网络线路。
- QuantDash 在其中负责数据获取环节:实时行情快照、批量与标的池查询、多周期 K 线与复权、统一标的代码,以及 Python SDK 和 REST API 两种接入方式。
- 适合股票池较大、正在从逐只请求迁移到批量获取、需要统一管理沪深京代码的 Python 量化开发者评估使用。
QuantDash 官方资源
- QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档