【Python量化系统工程化 #04】100只股票顺序拉要十分钟?并发批量让速度提升26倍

痛点开场

上篇我们把数据存好了、索引建好了、分区表也切好了。

然后你跑了第一个"全市场"脚本——拉 50 只自选股的历史 K 线,准备算个均线:

for code in codes:
rows = api.stock_history(code=code, period="d", ...)

跑去泡了杯咖啡,回来一看——12 秒。

"50 只就 12 秒,那 5000 只岂不是要 20 分钟?"

是的。顺序请求是工程化最大的隐形杀手——不是代码慢,是网络等一杯咖啡的时间累积了 50 杯。

本文解决的就是这个:用并发和批量把 50 只股票的拉取时间从 12 秒压到 1 秒以内。

本文你将得到什么

  1. 四种拉取模式的实测对比:顺序 / 并发 / 并发+限流 / 批量接口
  2. ThreadPoolExecutor + 共享 Client 的正确用法(比每线程新建 Client 快 3 倍)
  3. threading.Semaphore 信号量限流——防止并发过猛触发服务端保护
  4. 批量接口 stock_ssjy_more 的正确打开方式:一次 20 只、单次计 1 次额度

一、为什么顺序请求这么慢

每次 stock_history() 背后是一次 HTTP 请求:

  1. TCP 三次握手(尤其 HTTPS 还要 TLS 握手)
  2. 发送请求 + 等待服务端处理
  3. 接收响应 + JSON 解析

握手和等待是固定的,不会因为"你只查 1 只股票"就变短。50 只股票 = 50 次握手 + 50 次等待 = 时间线性叠加。

模式 50 只耗时 5000 只预估 核心瓶颈
顺序 ~4-12s ~400-1200s TCP 握手 + 网络往返重复 50 次
并发 5 ~1.5s ~150s 同时 5 条连接,握手复用
批量 20 ~0.1-0.3s ~25s 1 次请求带 20 只,握手仅 3 次

注:顺序耗时波动大(3.9s-12.0s),因连续请求易触发服务端连接限制;并发和批量更稳定。

二、并发:用 ThreadPoolExecutor 共享 Session

最直觉的做法:threading 开 50 个线程,每只一个线程。

但有个坑——每个线程新建一个 mairui.Client(),等于新建一个 requests.Session,TCP 连接不能复用,反而更慢。

正确做法:单 Client 共享给所有线程。实测 mairui.Client 内部基于 requests.Session,线程安全,连接复用后提速明显。

2.1 代码骨架

from concurrent.futures import ThreadPoolExecutor, as_completed
import mairui

api = mairui.Client("LICENCE-66D8-9F96-0C7F0FBCD073") # 演示证书,演示证书(读者复制即用,无需替换)

def fetch_one(code):
rows = api.stock_history(code=code, period="d", st="",
et="20260817", dividend="n", lt=60) or []
return code, len(rows)

with ThreadPoolExecutor(max_workers=5) as ex:
futures = {ex.submit(fetch_one, c): c for c in codes}
for fut in as_completed(futures):
code, n = fut.result()
print(f"{code}: {n} 条")

2.2 关键参数:max_workers

不是越大越好。workers 太多 = 同时 TCP 连接太多 = 触发服务端限流或本地端口耗尽。

经验值:

  • 本地脚本 / 个人机器:5-10
  • 服务端定时任务:3-5(留带宽给其他服务)
  • 有批量接口时:优先用批量,并发作为 fallback

三、信号量限流:防止并发过猛

即使 max_workers=5,如果 5 个线程同时发请求,某些服务端会返回 429(Too Many Requests)或直接断连。

threading.Semaphore 是轻量解决方案:

import threading
from concurrent.futures import ThreadPoolExecutor

sem = threading.Semaphore(3) # 同时最多 3 个请求在飞

def fetch_one(code):
with sem:
rows = api.stock_history(...) or []
return code, len(rows)

with ThreadPoolExecutor(max_workers=5) as ex:
...

max_workers vs Semaphore 的区别:

  • max_workers:限制"同时有多少线程在跑"(包括等待、计算、IO)
  • Semaphore:限制"同时有多少个请求在飞"(纯 IO 限制)

实战中两者配合使用:max_workers=5(线程池大小)+ sem=3(同时请求上限)= 既并发又保守。

四、批量接口:一次拉 20 只,额度只计 1 次

如果服务端提供了批量查询接口——优先用批量,这是最快的路。

麦蕊 SDK 的 stock_ssjy_more 支持逗号分隔多股,一次调用返回 20 只实时快照:

codes_str = ",".join(codes[:20]) # "000001.SZ,600519.SH,..."
rows = api.stock_ssjy_more(stock_codes=codes_str) # 批量实时快照;演示证书可能无数据,请换正式证书

关键优势:

  • 50 只 = 3 次 API 调用(20+20+10),而非 50 次
  • 额度只计 3 次,而非 50 次
  • 无 TCP 握手重复,一次往返带全量

注意:stock_ssjy_more 返回的是实时快照(当前最新价/涨跌幅/成交量),不是历史 K 线。如果需求是"拉历史数据做回测",仍需用 stock_history;如果是"盘中监控当前状态",批量接口是首选。

五、实测对比(50 只股票)

样本:50 只白马股,日线 lt=60。连续运行 3 次取范围:

模式 耗时 vs 顺序 说明
顺序 3.9-12.0s 1x 波动大,连续请求易触发服务端限制
并发 5(共享 Client) 1.5-1.9s 2.7-6.5x 稳定,Session 复用是关键
并发 + 限流(sem=3) 0.7-3.6s 1.1-5.5x 限流后更稳,但峰值不如纯并发
批量 20(ssjy_more) 0.1-0.3s 13-102x 最快,但返回实时快照而非历史 K 线

工程建议:

  • 历史 K 线回测 → 并发 5 + 共享 Client
  • 盘中实时监控 → 批量接口(ssjy_more)
  • 两者结合 → 历史数据每日增量更新用并发,盘中监控用批量

六、常见坑

  1. 每线程新建 Client:ThreadPoolExecutor 里 api = mairui.Client(...) → 每个线程都新建 Session,TCP 不能复用,反而比顺序慢。应在主线程创建后共享
  2. max_workers 不设上限:本地跑 50 workers,服务端返回 429 / 连接重置 → 从"提速"变"降速+报错"。建议本地 ≤10,服务端 ≤5
  3. 忽略异常处理:并发中某只股返回空 / 超时,如果不 try/except,整个 as_completed 会抛异常中断。务必单请求包 try
  4. 批量接口当历史接口用:stock_ssjy_more 返回的是实时快照,没有 t/o/h/l/c 的历史序列,不能用来算均线 / 回测
  5. 并发写 SQLite:SQLite 是单写者模型,多个线程同时 INSERT 会锁库报错 database is locked。并发只适用于"读 API"阶段,写库阶段要串行或用队列

七、小结 + 下篇预告

04 一句话:顺序请求的时间随股票数线性增长,并发把"等待"重叠、批量把"多次"合并;共享 Client + 信号量限流是工程化取数的黄金组合。

下篇(#05)我们聊一个比"快"更底层的问题:网络抖动一报错就全盘失败?用 tenacity 自动重试 + 指数退避。我们会演示怎么让脚本遇到 5xx / 超时时自动重试 3 次、遇到 4xx 直接跳过——而不是崩掉重来。

免责声明

本文所有代码与数据仅用于量化工程方法演示,不构成任何投资建议,亦不构成收益承诺。投资有风险,决策需谨慎。


下篇预告:【Python量化系统工程化 #05】网络抖动一报错就全盘失败?用 tenacity 自动重试 + 指数退避

posted @ 2026-09-16 19:11  量化分析码农  阅读(10)  评论(0)    收藏  举报