真正让“龙虾”跑通股市数据后,我发现这玩意儿是真能顶大用
年初那阵子,我手头攒了一堆历史行情数据要处理,每天收盘之后的工作流程基本是:打开行情软件,手动导出不同周期的K线,再复制到Excel里改格式、补字段、算涨跌幅。数据一多,Excel卡到风扇狂转,公式错位、日期格式串掉、前复权和后复权混用,最后统计出来的结果自己都不敢信。后来花了两个周末,把之前一直吃灰的“龙虾”框架拾起来,专门针对股市数据这条管线做了一次彻底的改造,让它每天自动跑完整个流程。跑通那一刻我突然意识到,这玩意儿是真能顶大用。
这篇东西不是讲怎么炒股,更不是荐股,纯粹聊聊我怎么用一套自动化数据管线把从行情源到入库、清洗、计算、落盘这一整条链路打通,以及在真实环境里跑数据时踩过的坑。适合被手动整理行情数据折磨过、想自己做量化分析或复盘工具、又不想整天复制粘贴Excel的朋友参考。代码量不大,思路比代码更重要。
1. 为什么折腾“龙虾”来跑数据:手工处理早就到极限了
先说下最原始的痛点。我日常接触的行情数据大概有三类:日线级别的OHLCV(开高低收量)、分钟级别的日内切片、以及各类财务/指标数据的对齐。看起来不复杂,但一旦涉及跨周期、跨标的、跨时间的关联操作,手工流程就会迅速膨胀到失控。
1.1 手工处理行情数据的三大困境
第一个困境是格式不一致。不同来源导出的数据,列名、日期格式、小数位数完全各搞一套。有的日期是2024/01/05,有的变成了20240105,有的干脆给个Excel序列号。每一份新数据进来,都要重新写一遍转换逻辑。
第二个困境是复权口径的混乱。拿日线数据来说,不复权、前复权、后复权,三者算出来的涨跌幅和均线完全不是一回事。我在早期手工分析时,经常出现同一只票今天用前复权、明天用后复权,最后趋势图对不上的情况。这还只是分析阶段,到回测阶段复权口径不一致,基本等于白算。
第三个困境是重复劳动。每天收盘后跑一遍相同的流程:拉数据、存Excel、算指标、画图、归档。周而复始,出错率还高。我印象特别深的一次,是手动把一个除权日的价格数据填错了,导致后续评估指标整段失真,排查了两天才定位到问题。
1.2 为什么选择“龙虾”而不是现成的商业软件
市面上其实有不少行情终端自带数据导出功能,甚至有的还提供Python接口。但问题在于:这类工具的数据口径往往是黑盒状态,你拿到的数据到底怎么复权的,官方文档经常语焉不详;而且历史数据批量导出有各种隐式限制,拉长周期时容易断流。
“龙虾”本质上是我自己维护的一套数据管道框架,它不解决策略逻辑的问题,只解决一件事:把杂乱无章的数据变成规整、可信、可随时重算的本地数据集。选择它而不是现成软件,核心原因是可控——每一道处理逻辑都摊在明面上,出了问题能查、能改、能重跑。
1.3 定下目标:一套能自动运转的数据管线
在动工之前,我给这套改造案定了几个明确目标:
- 一键全量重建:任何时候都能把全历史数据重新拉一遍并清洗入库。
- 增量更新:每个交易日收盘后,只拉取当天新增数据,避免全量IO。
- 口径统一:所有入库数据使用统一的前复权基准和数据字典。
- 可追溯:每张表都有数据版本号,出了异常能快速定位到“哪批数据、哪个环节”。
这几个目标定完之后,后面所有的架构决策都围绕它们展开,也避免了一边写一边改方向的老毛病。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. “龙虾”的数据管线架构:抓取、清洗、存储三个环节的拆解
一套数据管线的本质是数据的流转和加工。我把整条链路拆成三层:抓取层、清洗层、存储层。每一层只干自己该干的事,层级之间通过标准数据格式对接,不搞隐式耦合。
2.1 抓取层:多源接入与限频策略
抓取层负责从外部数据源获取原始行情。这里有几个关键点:
第一,多源冗余。单一数据源风险很高,一旦对方接口变动或限流,整条管线就瘫了。我在“龙虾”里做了一层适配器抽象,不同来源的数据统一转成内部格式,某个源挂了可以快速切换,不影响下游。
第二,限频与重试。第三方行情接口普遍有每分钟请求次数限制,如果闷头猛拉,很快会被封IP。我的做法是设置一个简单的请求间隔控制:
python复制import time
import random
def throttled_request(func, min_interval=0.5):
last_call = [0.0]
def wrapper(*args, **kwargs):
elapsed = time.time() - last_call[0]
if elapsed < min_interval:
time.sleep(min_interval - elapsed + random.uniform(0.1, 0.3))
result = func(*args, **kwargs)
last_call[0] = time.time()
return result
return wrapper
这样每两次请求之间至少隔500毫秒,又加上了一点随机抖动,避免形成周期性请求特征。
第三,断线续传。历史数据拉取往往耗时很长,一次网络抖动就全盘重来太浪费。我在抓取层里维护了一个游标记录每个标的拉到了哪个日期,中断后从游标处继续,而不是从头开始。
2.2 清洗层:规整字段与K线合成
原始数据拿到手之后,必须经过清洗才能入库。清洗层做的事情比想象中琐碎:统一日期格式、处理停牌导致的空值、识别异常价格跳变、校验成交量非负、以及把不同频率的数据对齐到统一时间轴。
一个比较典型的场景是分钟数据合成日线。如果原始数据只有1分钟K线,要合成日线,就得注意最后一根K线的归属,以及避免切片切出“半根K线”。我的处理逻辑是先按交易日分组,再对每个组内数据依次累加:
python复制import pandas as pd
def resample_minute_to_daily(df):
df = df.copy()
df['date'] = df['datetime'].dt.date
daily = df.groupby('date').agg(
open=('open', 'first'),
high=('high', 'max'),
low=('low', 'min'),
close=('close', 'last'),
volume=('volume', 'sum'),
amount=('amount', 'sum')
).reset_index()
return daily
这个逻辑看着简单,真正要命的是时区问题。国内市场的自然日和市场交易日不是一回事,如果直接用dt.date,遇到夜盘品种(比如某些期货)就会错乱。股票场景稍微好一些,但节假日补班、半天交易日这类特殊日历,还是得靠交易日历表来切分,不能依赖自然日。
2.3 存储层:为什么我选用了Parquet分区存储
清洗后的数据落到哪里,直接影响后续的分析效率。我早期用CSV,后来数据量上来之后读起来明显吃力;也试过SQLite,并发查询和列式聚合性能一般。最终选择了Parquet格式,按“标的分区+时间分区”的方式落盘:
- 每个标的一层目录
- 每一年一个Parquet文件
- 每次全量重建会生成一个新版本目录,方便回滚
Parquet是列式存储,算均线、算收益率这类按列聚合的操作,效率远超CSV。而且它天然支持压缩,同样的行情数据,磁盘占用大概只有CSV的三分之一。
我另外用SQLite只存元数据和任务运行日志,比如“某标的最近一次更新到哪天”“某次全量重建耗时多少”。这样热数据和冷数据分离,存储逻辑清晰,排查问题也快很多。
3. 让“龙虾”自己跑起来:调度、增量更新与稳定性设计
数据管线跑通一次不难,难的是让它日复一日自动跑,跑完不重不漏、出错了能自己恢复。这一章是我认为最有工程价值的部分。
3.1 定时任务选型:轻量优先,别一上来就上重型框架
有些朋友一聊到调度就直接上Airflow,但对我来说杀鸡用牛刀。单机管线上,Python自带的schedule库配合系统cron完全够用。
python复制import schedule
import time
def daily_job():
print("开始执行每日增量更新...")
run_incremental_update()
schedule.every().monday.at("16:30").do(daily_job)
schedule.every().tuesday.at("16:30").do(daily_job)
# ... 其他交易日
while True:
schedule.run_pending()
time.sleep(60)
要特别注意:A股收盘是15:00,但行情源的数据往往要过一段时间才稳定。我实测下来,15:30之后拉数据成功率才比较理想,太早会有部分标的当天数据缺失。所以调度时间要留出足够缓冲。
3.2 增量更新:判断“今天是交易日”比想象中麻烦
增量更新的第一步不是拉数据,而是判断今天到底是不是交易日。直接用自然周一到周五判断,会把国庆、春节、调休补班全部搞错。我的做法是维护一张交易日历表,来源可以是交易所官方公布的休市安排,也可以由一个简单规则生成再手工修正:
python复制import datetime
import pandas as pd
def load_trading_calendar(start_year, end_year):
# 此处伪代码:从数据源获取交易所日历,或读取本地csv
df = pd.read_csv("trading_days.csv", parse_dates=["date"])
return set(df["date"].dt.date)
def is_trading_day(d):
return d.date() in TRADING_DAYS
有了交易日历之后,增量更新的逻辑就非常直接:
- 检查当前时间是否在稳定窗口内。
- 读数据库,找出每个标的的最后更新日期。
- 只拉取该日期到今天的K线数据。
- 本地去重后入库,更新元数据。
增量更新最大的价值是快。全市场日线数据全量重拉可能要几十分钟,增量更新基本两三分钟就能搞定。每天收盘后跑一次,数据基本是新鲜的。
3.3 幂等设计与异常恢复:允许重跑,但不允许重跑出脏数据
工程上有个词叫“幂等”,意思是同一个操作执行一次和执行多次,结果一样。这在数据管线里极其重要。
拿写入来说,如果某天增量更新跑了一半崩了,第二天重新跑,就必须保证之前已写入的数据不会重复,或者即便重复了也能被识别剔除。我的做法是给每批次数据生成一个批次ID,写入时带上批次号和日期范围。下次跑增量时,先检查目标区间是否已有批次号,有就直接跳过或覆盖,而不是傻乎乎地再插一遍。
python复制def upsert_klines(conn, df):
# 使用INSERT OR REPLACE,以标的+日期为主键
df.to_sql("daily_kline", conn, if_exists="append", index=False)
当然,if_exists="append"本身不解决重复问题,更稳妥的方式是建唯一索引(symbol, date),插入时捕获主键冲突。
3.4 数据质量校验:防止脏数据悄悄溜进分析流程
数据入库不等于数据可信。我还给“龙虾”加了几道校验规则,每次写入后自动跑:
- 价格非负:高开低收都应该大于0,特殊情况允许为0但需标记。
- 成交量突变预警:当日成交量较5日均值放大100倍以上,很可能是数据错误。
- 价格跳变预警:当日涨幅超过20%(按市场规则限制),标记人工复核。
- 日期连续性:日线数据不允许出现连续缺3天以上的交易日,除非在停牌名单里。
这些校验逻辑不复杂,但能拦截掉绝大多数“看起来不对劲”的数据。我用的是简单的告警日志,不自动修数——因为自动修正可能掩盖真正的问题,宁可先告警再人工判断。
4. 跑通之后,这些数据到底能拿来干什么
管线跑起来之后,最直观的感受是:以前花一晚上整理数据,现在数据自己躺在那里,随时可以拿去算东西。“龙虾”真正开始发挥价值,是在数据稳定产出之后,我基于它做了几个实用的小应用。
4.1 指标计算的批量化
过去想算某只股票的历史均线,要打开软件、选参数、调周期、截图。现在直接写一段脚本,全市场所有标的的均线、MACD、RSI都能批量算完,结果落到新的列里。
python复制def compute_indicators(df):
df = df.sort_values("date")
df["ma5"] = df["close"].rolling(5).mean()
df["ma10"] = df["close"].rolling(10).mean()
df["ma20"] = df["close"].rolling(20).mean()
df["daily_return"] = df["close"].pct_change()
df["volatility20"] = df["daily_return"].rolling(20).std()
return df
这些指标算完之后,可以进一步做股票池的筛选。比如“最近20日波动率低于某个阈值的标的”“连续放量上涨的标的”等等,几秒钟就出一份报告。注意,这跟投资建议没有关系,纯粹是数据筛选的工具化应用。
4.2 策略回测前的数据对齐:最容易被忽视的坑
真正让我觉得这套管线“顶大用”的,是回测环节。回测最怕什么?怕数据里有未来函数,怕前复权价格在历史区间内穿越,怕停牌日数据缺失导致收益计算失真。“龙虾”把数据加工成规整的本地库之后,回测脚本的数据获取变得异常简单:
python复制def load_data(symbol, start_date, end_date):
df = pd.read_parquet(f"data/{symbol}/{start_date[:4]}_{end_date[:4]}.parquet")
df = df[(df["date"] >= start_date) & (df["date"] <= end_date)]
return df.reset_index(drop=True)
由于我是按自然年份落盘,所以跨年回测时只需要读两个文件拼接,不需要全表扫描。数据量小时感受不明显,数据量大了之后这个设计带来的提速非常可观。
4.3 把可视化报表变成每天的固定产出
管线稳定后,我每天会生成一份简单的HTML报表,包含当天的大盘概览、行业板块涨跌分布、个股异动清单。所有这些数据都来自本地库,生成过程不到十秒。报表的作用是帮助我快速复盘当天发生了什么,而不是被行情软件的信息流轰炸。
5. 这一路踩过的坑:数据源、时区、复权与连续性的教训
如果说前面是“怎么做”,那这一章就是“千万别这么做”。很多东西不跑到真实数据上,看文档是看不出来的。
5.1 前复权数据的“未来函数”陷阱
复权处理是行情数据最阴的坑之一。很多数据接口默认返回前复权价格,也就是以最新价格为基准倒推历史价格。这带来的问题是:每当标的发生一次除权除息,历史上所有前复权价格都会被重新计算一遍。
如果你在2024年1月下载了一份前复权历史数据,放到2025年再打开,同一段历史价格可能已经变了。这意味着什么?意味着你用旧数据做的回测结果,在新数据上是无法复现的。这在量化领域叫“前视偏差”,属于回测大忌。
我的解法是:原始数据统一存不复权价格,同时在单独的表里存每次除权除息的因子。做分析时按需动态复权,而不是直接依赖接口返回的前复权结果。这样历史数据永远可复现,怎么算都不会变。
5.2 停牌日与缺失值:填0还是跳过,取决于用途
另一个真实场景是停牌。一只股票停牌一周,那几天没有交易数据。这时候数据是“缺失”的,不是“价格为0”也不是“涨跌幅为0”。
我见过有人为了图省事,把缺失日期的价格填成前一天的收盘价,成交量填0。这样在画趋势线时表面上连续了,但计算收益率时会大量出现0,导致波动率被严重低估;回测时又会出现“想买买不进、想卖卖不出”的虚假流动性。正确做法是保留缺失状态,在回测引擎里显式处理“不可交易”状态。
5.3 交易日历不能用自然日代替
前文提了一嘴,但这里必须单独说,因为它真的太容易错了。我一开始用pd.bdate_range(start, end)来生成日期序列,结果遇到国庆长假、春节长假,数据对齐全部错位——有的标的日期落在假期里,成交量异常放大。后来改成交易所日历,问题一次性解决:
python复制def get_trading_days(start, end):
# 从本地交易日历表取
mask = (TRADING_DAYS >= start) & (TRADING_DAYS <= end)
return TRADING_DAYS[mask]
这个坑的教训是:任何跟中国市场相关的数据,日期维度一律用交易日历,不要自己拍脑袋判断周几开盘。
5.4 数据源的隐性限频与请求随机化
某个数据源在连续请求超过一定次数后,会返回空数据而不是报错。如果程序不做校验,就会把空数据当作正常结果,静默写入库。在“龙虾”的抓取层里,我加了一个“空数据即异常”的规则:如果请求成功但返回了零行数据,重试三次,仍为空就告警。绝不能把空结果当作合法响应直接入库。
另外,我还给请求间隔加了一些随机抖动,避免请求时间序列呈现周期性特征。这对降低被误判为爬虫的概率很有帮助。
5.5 浮点精度问题:别小看收盘价的0.000001
行情数据里的价格,有时候会出现0.4999999999这种浮点残差。比如某些数据源返回的52.35,在内存里存的是52.349999999999994。如果不处理,求和、比较时会出现莫名其妙的bug。我的方案是所有价格字段在入库前做Decimal量化,统一保留4位小数:
python复制from decimal import Decimal, ROUND_HALF_UP
def normalize_price(value, precision="0.0001"):
return Decimal(str(value)).quantize(Decimal(precision), rounding=ROUND_HALF_UP)
别小看这一步,它省掉了后面无数个“为什么对不上”的排查夜。
6. 性能瓶颈排查与后续还能往哪扩展
管线跑通只是开始,真正用起来之后,随着数据量增长,性能问题会逐渐暴露。我说下实际遇到的两个瓶颈和解决办法。
6.1 写入性能:逐条insert是最慢的,没有之一
最早版本的入库逻辑是逐条INSERT OR REPLACE,几百个标的、每个几千行,跑一次要半小时。后来改成批量写入,用pandas.DataFrame.to_sql配合method="multi",速度提升了一个数量级。再后来换成Parquet分区落盘,把“全量写入”变成了“写一个新文件再切换目录”,基本上不再有写入瓶颈。
6.2 并行抓取:加线程不如控制好限频
我一度尝试用多线程并行拉取数据,结果被数据源限频教育了。后来改为“多标的排队+单标的多页请求交织”,也就是把并发放在单标的的历史分页请求上,而不是放在不同标的之间。这样既不触发全局限频,又能利用网络IO等待时间。
ThreadPoolExecutor配合信号量控制并发数,是简单好用的方案:
python复制from concurrent.futures import ThreadPoolExecutor
import threading
semaphore = threading.Semaphore(5)
def fetch_with_limit(symbol):
with semaphore:
return fetch_symbol_data(symbol)
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(fetch_with_limit, symbols))
当然,具体并发数要实测调整,每个数据源的容忍度不一样。
6.3 后续扩展方向:分钟级数据、数据服务化与增量因子表
目前“龙虾”主要处理日线数据,下一步我想把分钟级数据也纳入管线。分钟级的数据量比日线大两个数量级,存储格式和读取逻辑都需要重新设计,但原理是一致的。
还有一个方向是把数据做成服务化,让策略研究脚本通过本地HTTP接口请求数据,而不是直接读文件。这样策略代码和数据存储解耦,后续加字段、换存储格式都不会影响策略层。
另外,除权除息因子表值得单独维护,因为它是复权计算的基础,历史上任何一次分红送股都会影响全链路的复权结果。这个表必须保证绝对完整、绝对准确,值得定期人工核对。
最后说点实在的。这套“龙虾”管线跑起来之后,我最大的感受不是“自动化省了多少时间”,而是“终于敢对自己的数据负责了”。以前用别人打包好的数据,出了对不上账的问题根本不知道源头在哪;现在每一行数据从哪个源、哪一天、哪个批次进来,全部有迹可循,查问题基本能在五分钟内定位到具体环节。如果你也在被行情数据整理折磨,与其继续手工复制粘贴,不如花两个周末搭一条最小可用的自动化管线,把“拉数据、清洗、入库”这三大件先打通。跑通那一刻,你大概率也会有跟我一样的感慨:这玩意儿,是真能顶大用。
