news 2026/10/21 18:04:46

基于Tushare自建MACD数据库:量化回测的稳定数据底座

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Tushare自建MACD数据库:量化回测的稳定数据底座

做A股量化研究的人,早晚会遇到这么一件事:策略写好了,回测框架搭起来了,结果发现每次跑策略都要现场算一遍MACD,而且不同来源算出来的指标还不一样。我当初就被这个问题折磨过一整周——同一个标的,看盘软件上显示的MACD和自己在Python里用现成库算出来的,金叉死叉位置能差出两三个交易日。后来我心一横,干脆用Tushare把日线行情拉下来,自己实现一套MACD计算,落进本地数据库,做成一份可以反复使用的MACD数据库。这篇文章就是完整趟一遍这套流程的记录,从Tushare接入、MACD递推细节,到表结构设计、增量更新,再到验证数据可靠性的土办法,一次性写给同样在做股票量化研究基础功课的朋友。

1. 为什么放着现成的指标库不用,偏要自己建MACD数据库

很多人一听“自己做数据库”就觉得多余,觉得用Python现成的指标库算一下不就行了。但实际上,只要你在量化这条路上多走几步,就会发现现成方案有三个绕不过去的毛病。

1.1 计算口径不一致,才是最坑的地方

先说最让我崩溃的一点:不同指标库算出来的MACD可能根本对不上。原因在于EMA的递推起点、初始值处理、小数保留方式都不同。有的库默认用adjust=True的方式做指数加权,有的行情软件从上市首日就开始递推,有的工具则只从你传入的数据段开头算起。

你可能会说,差异能有多大?实测下来,在数据段前30个交易日内,DIF和DEA可以差出0.1甚至更多。这个差距在日线级别回测里,会让金叉死叉信号整体偏移,最后造成选股结果对不上、复盘记录对不上、换一个人来复现你的研究也对不上。对量化研究来说,口径不一致等于结果不可复现。

1.2 每次回测都现算,浪费时间也浪费情绪

第二个问题是效率。如果你维护着几十个策略,每个策略要跑全市场5000多只股票的历史数据,每次回测前都重新拉行情、算指标、拼特征,那一轮实验下来光是数据预处理就能占掉一多半时间。

把MACD这种通用指标提前算好存进数据库,回测时直接查表,跑策略时候就只需要关心买卖逻辑本身。哪怕一只股票只省0.01秒,全市场跑一遍也能省下一分钟。更重要的是,这种“基础数据固定下来,策略层只读不重算”的模式,能让你的研究流程干净很多。

1.3 自建库真正解决的,是数据与指标的耦合问题

把行情数据和指标计算分开,各自单独维护,这是我做这套东西后最大的收益。行情从Tushare来,指标自己算,存进本地库;哪天发现行情源换了,只要重算一遍指标就行,策略代码、数据库表结构都不用动。反过来,哪天想改MACD参数或增加一个新指标,也不用重新拉行情,只要基于库里已有的日线数据做增量计算。

所以这个项目的定位很清楚:它不是要做一个庞大的行情仓库,而是把“日线行情”和“MACD指标”这对关系先打通,让后续研究工作建立在一个稳定、可复现、增量可更新的数据底座上。适合所有做日线级别策略、不想每次开跑前都折腾一次数据的人。

2. Tushare接入:从token到日线行情拉通的初始化流程

Tushare是A股行情数据接口里最常用的一个,这就不用多说了。我用的是Tushare Pro,它的接口规范清晰、字段完整,比较适合用来搭建自己的数据层。这一章讲的是从零到一拉通日线行情的完整流程,包括几个容易卡住的地方。

2.1 环境准备与token初始化

Tushare Pro的接入方式很简单,第一步去官方平台注册账号,拿到专属token,然后在代码里初始化。

import tushare as ts # 你的token字符串,在个人主页里可以找到 ts.set_token('your_token_here') pro = ts.pro_api()

这里有一个新手容易踩的坑:ts.set_token()只是把token写进本地配置,真正发起接口调用的是ts.pro_api()返回的对象。有些教程只教你ts.set_token(),然后直接用ts.get_xxx(),结果发现是旧版接口或者权限报错。建议统一用pro = ts.pro_api()这条路。

关于权限,Tushare Pro对部分高级接口有积分门槛。MACD数据库需要的核心接口有两个:pro.stock_basic()获取股票列表,ts.pro_bar()获取日线行情。这两个接口的权限门槛不高,一般注册后完成基本任务就能用。如果你调接口时看到权限不足或者打分不足的报错,别慌,那只是积分不够,去官方文档对应用页面看接口的最低积分要求,做个签名打卡之类的日常任务就能搞定。

2.2 pro_bar日线数据拉取的正确姿势

获取单只股票日线,最常用的是ts.pro_bar()。注意这个函数名带ts.前缀,不是pro.,因为它本身就是一个封装好的便捷函数。

# 拉取某只股票的后复权日线,示例代码仅供演示 df = ts.pro_bar( ts_code='600000.SH', # 这里仅作为示例代码演示格式 start_date='20200101', end_date='20240101', adj='hfq', # 复权方式:None/前复权qfq/后复权hfq freq='D' # 日线 ) print(df.head())

df里包含的字段有:ts_code、trade_date、open、high、low、close、pre_close、change、pct_chg、vol、amount。对于MACD计算,我们最关心的是close。

关于复权参数,我建议在指标库里统一用adj='hfq',也就是后复权。原因后面第5章我会专门说,这里先记着:指标计算尽量用后复权价,别用前复权,也别用不复权。

2.3 接口频率限制与全市场拉取的时间估算

Tushare Pro的基础接口有频率限制,限制策略和你的积分挂钩,常见的是每分钟N次、每小时M次。新号积分低时频率限制比较严格,想要提高频率就得攒积分。

既然要做全市场MACD数据库,就绕不开批量拉取。A股目前有5000多只股票,每只股票至少一次ts.pro_bar请求。就算按一只股票一次请求算,5000次请求按“每分钟300次”的频率限制来算,也要17分钟左右跑完,再加上网络耗时和必要的sleep,实际建库时间在半小时到一小时之间。

我的建议是别偷懒,建立一个批量任务,老老实实在循环里加time.sleep(),让请求均匀分布。不要试图把参数调到极限,一旦触发频率限制,接口直接返回报错,整个任务就得中断重来。

import time def fetch_stock_daily(ts_code, start_date='19900101'): df = ts.pro_bar( ts_code=ts_code, start_date=start_date, adj='hfq', freq='D' ) time.sleep(0.2) # 控制请求频率 return df

在跑全市场之前,先拿两三只股票从头到尾跑通流程,确认字段、日期格式、复权方式都符合预期,再上批量。我就是因为一开始跳过单测直接全市场拉,结果跑到三分之一才发现日期格式有的返回20240105有的返回2024-01-05,白白浪费了一次完整建库时间。

3. MACD算法里那些“照抄公式也写不对”的细节

MACD看着简单,核心就是两根EMA线做差再平滑,但真自己实现一遍,你会发现一堆细节陷阱。这一章把算法逻辑和容易出错的地方全都拆开讲清楚。

3.1 DIF、DEA、MACD柱的完整递推逻辑

MACD的计算分三步:

第一步,计算短期EMA和长期EMA。EMA即指数移动平均,和简单移动平均不一样,它对最近的价格赋予更高权重。递推公式写成文字就是:

  • 今天的EMA = 昨天的EMA × (n - 1)/(n + 1) + 今天的价格 × 2/(n + 1)

其中n是周期参数。MACD标准参数是12、26、9,也就是短期EMA用12日,长期EMA用26日,DEA用9日。

第二步,计算DIF:DIF = EMA(12) - EMA(26)。

第三步,计算DEA,也就是DIF的9日EMA。最后MACD柱的值,在国内行情软件上通常是:

  • MACD柱 = (DIF - DEA) × 2

注意这个乘以2,国内的通达信、同花顺都是这么画的,目的是把柱状图放大,看起来更明显。国外有些软件不乘2,直接用DIF减DEA。如果你想把计算结果和看盘软件做对照,必须乘2;如果你只是在自建策略里用,乘以不乘无所谓,但全库统一是一定的。

3.2 初始值、缺失值和上市初期,三个容易被忽略的坎

EMA递推必须有个起点。正常做法是把第一天的EMA值设为当天的收盘价。但你从哪一天开始算,结果会不一样。如果数据库里的某只股票只存了最近一年数据,你从一年前开始递推,和行情软件从上市首日递推,前一阵子的EMA值肯定有偏差。这就是为什么我建库时选择拉全历史数据,而不是只拉近期数据。

缺失值问题也很关键。股票停牌的日子不会有K线数据。在递推EMA时,我要用一个容易犯错的错误示范:先把缺失日期填充成前一天的价格。表面看是“补全数据”,实际上会让EMA在一段停滞期间出现虚假的平滑,和行情软件对不上。正确的做法是让停牌日不参与递推——没有新价格就不更新EMA,相当于递推过程中跳过这一天。

上市初期的问题更隐蔽。一只次新股上市还不到26个交易日时,EMA(26)本身就没有足够的历史支撑,算出来的DEA和MACD柱参考意义很有限。建库时数据照算、照存,但在策略使用端建议过滤掉上市不足60个交易日的股票,避免“拿噪声当信号”。我在数据库旁边建了一张辅助表,记录每只股票的上市日期和可交易天数,用的时候直接关联过滤。

3.3 pandas实现与手写循环的效率对比

如果对几百只股票做循环计算,手写for循环问题不大;但全市场5000多只股票、每只几千条日线,手写循环会慢得让人怀疑人生。正确姿势是用pandas的ewm方法。

import pandas as pd def calc_macd(df, short=12, long=26, mid=9): """ 输入必须包含 trade_date 和 close 列。 输出在原始df基础上增加 dif, dea, macd 三列。 """ df = df.sort_values('trade_date').reset_index(drop=True) close = df['close'] ema_short = close.ewm(span=short, adjust=False).mean() ema_long = close.ewm(span=long, adjust=False).mean() dif = ema_short - ema_long dea = dif.ewm(span=mid, adjust=False).mean() macd = (dif - dea) * 2 df['dif'] = dif.round(4) df['dea'] = dea.round(4) df['macd'] = macd.round(4) return df

这里最关键的就是adjust=False。这个参数决定了EMA的权重计算公式:adjust=False时,alpha = 2/(span + 1),严格对应前面说的递推公式,和国内行情软件的口径一致。如果用默认的adjust=True,pandas会改用另一种归一化加权方式,算出来的EMA在数据前段会有明显差异。

ewm底层的计算效率很高,它本质上也是逐条递推,但优化过的向量化实现比Python手写循环快几十倍。全市场重算一遍,在我自己的普通笔记本上大概几分钟能跑完,这个效率是可以接受的。

4. 把MACD存进数据库:表结构、索引与幂等写入

数据算出来只是个DataFrame,要成为真正能反复使用的库,还得落进数据库。我选择SQLite,理由很直接:个人量化研究、单机运行、读写并发不高,SQLite零配置文件、随开随用,完全够用。如果你以后要多台机器并发读写,或者要和团队共享,再换成PostgreSQL也不迟。

4.1 表结构设计与字段说明

我建的表叫stock_macd,结构如下:

CREATE TABLE IF NOT EXISTS stock_macd ( ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, close REAL, dif REAL, dea REAL, macd REAL, PRIMARY KEY (ts_code, trade_date) );

三个设计细节值得说一下。

第一,trade_date用TEXT类型,存成YYYYMMDD格式。A股日期格式用字符串存,排序、比较、取区间都非常方便,也不需要担心时区问题。

第二,主键设成(ts_code, trade_date)。这个联合主键既保证了同一只股票同一天的数据不会重复写入,又天然是高频查询条件——回测时基本都按股票和日期范围取数,联合主键就充当了索引。

第三,我把close也存进了指标表。按理说行情数据可以单独放一张表,但我故意把收盘价冗余到MACD表里。原因是以后做策略时经常要同时看价格和指标,直接查这一张表就够了,省一次关联查询。代价是多占一点磁盘空间,对于个人研究来说完全值得。

4.2 用INSERT OR REPLACE实现可重复执行的写入

写入数据时,最不希望看到的就是“重复执行一次任务就报主键冲突”。我用SQLite的INSERT OR REPLACE来做幂等写入:

import sqlite3 def upsert_macd(conn, df): """将MACD计算结果写入SQLite,重复执行不会产生重复数据。""" rows = df[['ts_code', 'trade_date', 'close', 'dif', 'dea', 'macd']].values.tolist() conn.executemany( """ INSERT OR REPLACE INTO stock_macd (ts_code, trade_date, close, dif, dea, macd) VALUES (?, ?, ?, ?, ?, ?) """, rows ) conn.commit()

INSERT OR REPLACE的语义是:如果主键已存在,先删旧行再插入新行。这样同一个任务无论跑一次还是十次,库里最终数据相同,彻底避免重复执行造成脏数据。

这个方案有个潜在坑,我也提醒一下:如果以后你给表扩展了字段,并且希望保留旧字段值,INSERT OR REPLACE会把整行替换掉,旧字段如果没提供就会被清空。到那时建议改用INSERT ... ON CONFLICT DO UPDATE,只更新指定字段。就当前这张表来看,字段就六个,REPLACE足够。

4.3 增量更新策略:不要每天全量重算

数据库建好之后,真正天天跑的是每日增量更新。但MACD这类指标有个特点:EMA是递推的,今天的值依赖昨天的EMA,所以不能只拉最新一天的数据从头算。每次更新都需要一个“回看窗口”,把足够多的历史数据重新拉下来、重新计算,再写回数据库。

我的做法是:每次增量更新时,对每只股票重新拉最近400个自然日(约260个交易日左右)的后复权日线,重算MACD,然后把这段窗口内的数据整体REPLACE一遍。这样数据库里更久远的历史记录完全不动,近一年的数据每次都保持一致。400天这个窗口足够EMA收敛到稳定状态,也足够覆盖MACD最长周期(26日)的影响范围。

def incremental_update_one(conn, pro, ts_code): # 1. 查出库里最新交易日 latest = pd.read_sql( "SELECT MAX(trade_date) FROM stock_macd WHERE ts_code = ?", conn, params=(ts_code,) ).iloc[0, 0] # 2. 有记录就从最新日往回推400天,没记录就全量 if latest: start = (pd.Timestamp(latest) - pd.Timedelta(days=400)).strftime('%Y%m%d') else: start = '19900101' # 3. 拉行情、算指标 df = fetch_stock_daily(ts_code, start_date=start) if df is None or df.empty: return df = calc_macd(df) # 4. 整段REPLACE upsert_macd(conn, df)

增量任务脚本可以挂在定时任务里,比如每天收盘后半小时执行一次。收盘后数据更新需要一点时间,我一般设在15点30分以后运行。运行前先调pro.trade_cal()判断当天是不是交易日,非交易日直接跳过,省得白跑。

def is_trade_date(pro, date_str): cal = pro.trade_cal(exchange='SSE', start_date=date_str, end_date=date_str) return cal['is_open'].iloc[0] == 1

5. 实测拉全市场行情时踩过的几个正常现象

数据库架构都搭好后,你一定会经历一段“批量拉数据修修补补”的时间。这里把我踩过的几个坑集中写一下,都是真实会遇到、但网上很少有人提的细节。

5.1 停牌、次新股和ST股的NaN问题

批量拉全市场时,你就会发现A股的数据远没有教科书上那么干净。停牌股票在某段时间没有K线,这是最正常的;次新股上市时间短,能拉到的数据条数很少;ST股、退市整理股的交易状态更是五花八门。

处理原则很简单:不要在计算MACD时强行填NaN。直接用ewm计算时,pandas的ewm().mean()本身不受NaN影响,它会跳过缺失位置继续递推。但如果数据里混入前复权导致的0值,或者因为复权因子计算错误出现的None值,就一定要清洗掉。

批量拉取脚本里,我给每个股票加了一个简单的数据体检:拉下来后检查close列是否有空值、是否有0、日期是否连续,发现问题直接打日志。这些“生病”的股票先跳过,不中断整个任务,回头再单独处理。全市场跑下来,通常有几十只股票需要人工核一下,这很正常。

5.2 前复权数据的历史漂移问题

这一节值得所有做量化的人认真看一下。很多人在Tushare里直接用adj='qfq'前复权拉数据,我也这么干过,后来发现一个严重问题:前复权价格是用“最新复权因子”回溯调整的,一旦这只股票后面发生新的除权除息,再拉历史数据时,历史价格会整体变化。这就是“前复权漂移”。

对回测有什么影响?你今天用前复权价算出MACD,得到一组金叉死叉信号,存进库里。三个月后,这只股票分了一次红,前复权因子变了,你重新拉数,历史close变了,MACD也跟着变,历史信号可能整体失效。用前复权数据做技术指标库,等于让数据库处于“不断改写历史”的状态。

解决办法就是我在第2章说的:指标计算统一用adj='hfq'后复权。后复权价格锚定上市首日,历史价格不会因为未来事件而改变,最适合做指标计算。如果你做回测撮合时需要真实成交价格,那就另外存一份不复权数据,不要把指标计算和交易模拟混在一张表里。

5.3 限流触发与重试机制

批量拉数最怕跑到一半被限流。限流的报错信息很直接,大意是“频率超限,请稍后再试”。这时候整个任务并不会自己恢复,如果你不写重试,脚本就直接崩了。

我在批量脚本里加了个简单重试机制:每个请求最多重试三次,失败后先休息一秒再试。如果重试三次仍失败,就把这只股票记入失败列表,任务结束后统一重新拉一遍。

def fetch_with_retry(ts_code, max_retry=3): for i in range(max_retry): try: df = fetch_stock_daily(ts_code) if df is not None and not df.empty: return df except Exception as e: print(f"{ts_code} 第{i+1}次请求异常: {e}") time.sleep(1) return None

另外一个小技巧:跑批量时在控制台打印进度,比如“第123/5000只,当前股票xxx,已用时xx分钟”。全市场拉数是个长任务,没有进度反馈会让人非常焦虑,有进度条心里才有底。

6. 数据验证:怎样确定自己的MACD库是可信的

数据库建好、增量更新也跑通了,最后一步就是证明数据是对的。我见过有人信心满满跑完批量任务,结果拿最基础的规则一查,错误率高达百分之几。所以验证这一步,千万不能省。

6.1 手工抽样验证:和行情软件互相对表

最土但最有效的验证方法,就是抽几只股票,打开行情软件的MACD副图,肉眼对照DIF、DEA和MACD柱的数值。

抽样时要照顾到不同场景:选一只上市超过5年的老票,选一只上市不到一年的次新股,再选一只经历过多次分红的股票,分别对比。

行情的软件上通常显示两位小数,库里保留四位,互相对照时注意四舍五入。如果发现某一天的DEA对不上,大概率是复权方式或者EMA起点问题。我当初就发现A股票前30天的DIF全部对不上,查到最后是因为我用adj='qfq'建库,而软件默认用不复权价计算指标。换成后复权以后,全对上了。

6.2 用指标自身的数学关系做校验

不需要行情软件,也能用几条硬规则快速检查全库数据质量。

第一条,MACD柱的符号必须和DIF-DEA一致。因为MACD柱=(DIF-DEA)×2,乘以正数不会改变符号。如果某一行出现符号不一致,那一定算错了。

第二条,当DIF上穿DEA时,MACD柱应该从负值变成正值;对应DIF下穿DEA时,MACD柱应该从正值变成负值。这个关系可以直接用SQL查一遍,把违反规则的记录列出来。

第三条,MACD柱的绝对值应该恰好是DIF-DEA差值的两倍,这个直接检查数值也能发现大部分计算错误。

-- 检查MACD数值关系是否成立 SELECT * FROM stock_macd WHERE ABS(ABS(macd) - 2 * ABS(dif - dea)) > 0.001 LIMIT 100;

6.3 建立每日巡检的习惯

数据库更新是有生命的,不是建好一次就一劳永逸。我给自己定了一个简单的每日巡检流程:增量更新跑完后,先统计当天入库的股票数量,和stock_basic里的活跃股票数量做对比,数量对不上就说明有股票更新失败了。

另一个习惯是定期手工抽查一个月前某个交易日的记录,和当时行情软件上的数值对照一遍。这个习惯能抓住一类隐藏问题:复权因子变化导致的历史数据被错误重算。平时增量更新只动最近400天数据,理论上不会影响更早的记录,但万一哪天你改了重算窗口,历史数据可能被悄悄改写,巡检一次能帮你及早发现。

我还在数据库里加了一张update_log表,每次更新写一行日志,记录更新时间、涉及股票数、新增记录数和异常列表。这样哪天数据出问题,能快速定位是哪次任务引入的。

最后分享一个我自己实际用的习惯:建表的时候把vol和amount也顺手存进库。标题是MACD数据库,但做量化研究早晚会遇到量价背离、成交量因子之类的事。哪怕现在用不上,也别等需要的时候再回头补数据。MACD数据库这种东西,一次建好、长期受益,关键是计算口径统一、数据可复现。投资决策的事我讲不了,但数据工程的事,多留一分余地总归没有坏处。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 18:04:46

广义Feistel网络相关密钥差分分析:结构弱点与自动搜索实践

广义Feistel网络(GFN)在分组密码设计里是特别常见的一类结构。它不像经典Feistel那样非要等一轮才能扩散到另一边,而是可以通过分支扩展到多路并行,扩散速度、轮函数复用比例、硬件面积都能灵活调节;但换来的是更难保证…

作者头像 李华
网站建设 2026/10/21 18:04:21

MySQL InnoDB Buffer Pool 深度解析:从LRU链表到生产调优

先说一个我印象挺深的场景。某次帮朋友排查一个订单库的性能问题,业务方反馈高峰期接口响应变慢,我看了一眼监控,磁盘读的曲线倒是没怎么动,但SHOW ENGINE INNODB STATUS里 Buffer Pool 相关的几行数字很不正常——命中率看着有 9…

作者头像 李华
网站建设 2026/10/21 18:04:17

印刷品缺陷检测系统实战:图像处理算法与工程落地全复盘

印刷车间里最容易让人头疼的,不是机器速度不够,也不是油墨调不准,而是质检这一关。一卷纸跑下来,几个老师傅拿着放大镜翻来覆去找刀丝、蹭脏、漏印,眼睛花不说,不同人看的标准还不一样。这套“基于图像处理…

作者头像 李华
网站建设 2026/10/21 18:04:08

向量数据库与普通数据库选型指南:能力边界、混合架构与实战避坑

1. 选型之前先搞清楚两者到底差在哪很多团队在项目初期都会遇到一个很现实的问题:业务需要做语义搜索、推荐召回或者图片相似度匹配,技术选型会上有人提了一句“要不要上向量数据库”,然后会议室就安静了。有人觉得普通数据库加个索引也能凑合…

作者头像 李华
网站建设 2026/10/21 18:03:50

开源热榜日榜深度解读:从星标数据识别真正值得关注的项目

每天上午十点前后,我总会习惯性打开一次热榜日榜,这个习惯保持了挺长时间。2026年10月4日的这期榜单,和最近几个月的大多数日子一样,前排被各类AI辅助工具、开发效率脚本和新兴框架占满,但真正有意思的从来不是谁排第一…

作者头像 李华
网站建设 2026/10/21 18:03:37

计算迁移实战:边缘计算下的任务卸载决策与建模指南

做终端设备的开发者,迟早会遇到一个坎:本地算力不够,但又不能把数据全部丢到云端去算。这时,“计算迁移”(也叫计算卸载)这个思路就提上来了。简单说,计算迁移就是把原本要本机完成的计算任务&a…

作者头像 李华