做商品期货量化的人,几乎都绕不开交易所公开的日度行情文件。今天这篇“量化投资从0开始”系列第15篇,我拿郑商所每天收盘后发布的日统计数据来拆一拆。这个文件是研究国内商品期货最“朴素”也最可靠的数据源之一,甚至不夸张地说,很多看起来复杂的量价因子、仓位因子、情绪指标,最后都能从这份纯文本文件里倒推出来。我身边不少做CTA和跨期套利的同行,每天下午固定时间要干的头一件事,就是把它拉到本地,跑一遍更新流程。后面我们就从文件结构、下载方式、字段语义、因子构造到常见坑,完整过一遍。
1. 郑商所日统计数据到底是什么
1.1 这份数据解决了什么问题
郑商所的全称是郑州商品交易所,国内四大期货交易所之一,主要上市品种是农产品和部分化工品,比如白糖、棉花、菜粕、PTA、甲醇、玻璃、纯碱这些。每个交易日收盘之后,交易所会把当天所有合约的成交、持仓、价格信息汇总,整理成一个固定格式的TXT文本,放到官网上,这个就是日统计数据。
从量化角度来说,它能解决一个很基础也很头疼的问题:统一、规范、免费的历史行情怎么来。市面上很多商业数据源要花钱,而且字段封装得五花八门;交易所官网这份文件虽然是“裸数据”,但它的字段定义是交易所官方口径,用来做因子回测和策略校准,天然就是权威参考。
另一个容易被忽视的作用是数据对齐。你做跨品种套利、跨期套利、或者算涨跌停价格,收盘价、结算价、涨跌、持仓量这些字段必须在一个统一的文件里对齐。郑商所这份日统计表恰好就是按“品种+合约+日期”粒度输出的,拉下来直接就能当底表用。
1.2 能用来做什么,适合谁
有这个文件在手,你可以做三件很典型的事:
- 维护自己的日线数据库,替代或者校验商业数据源;
- 计算持仓变化、成交量变化、结算价与收盘价乖离等基础因子;
- 做合约换月、主力连续合约拼接、跨期价差分析的前置数据清洗。
所以这个系列面向的对象很明确:已经会一点Python和Pandas,想开始接触国内商品期货量化的朋友。如果你完全没碰过期货,也没关系,只要理解“每天交易所发布一张表,我们把表读进来变成DataFrame”,后续的事情就顺理成章了。我自己最早接触国内品种数据,就是从这种TXT文件开始的,当时觉得丑,后来觉得是真香。
2. 文件格式、下载地址与字段语义
2.1 文件存放规律与真正的下载地址
郑商所官网公布的历史数据,走的是静态文件方式,路径规律非常工整:
http://www.czce.com.cn/cn/DFSStaticFiles/Future/YYYY/YYYYMMDD/FutureDataDaily.txt其中 YYYY 是年份,YYYYMMDD 是交易日。比如你想下载2024年6月26日的数据,URL就是:
http://www.czce.com.cn/cn/DFSStaticFiles/Future/2024/20240626/FutureDataDaily.txt这个规律的好处是:你不需要解析网页、不需要登录、不需要接口文档,只要用一个循环把日期拼出来,就能批量拉历史数据。坏处是:它确实“太裸”了,文件头、编码、字段宽度都可能因为网站改版而变化,我后面会专门讲怎么避坑。
另外,郑商所还有一种带查询参数的历史数据下载方式,用reqDate参数去取,但那个接口往往只保留最近一段时间的记录,而且字段格式在不同年份也有过调整。日常使用我最推荐的就是上面的静态文件地址,稳定性足够好。
注意:下载时一定要用HTTP的GET请求,同时建议在请求头里带一个正常的 User-Agent,有些时候反爬策略会拦截空UA的请求。别问我是怎么知道的。
2.2 TXT文件内部长什么样
下载回来后,它是一个TXT文本,从最表面看,内容大致是:
品种代码 品种名称 合约代码 前结算价 今开盘 最高价 最低价 今收盘 结算价 涨跌1 涨跌2 成交量(手) 持仓量(手) 持仓量增减 成交额(万元) 交割结算价 SR 白糖 SR607 5301 5301 5301 5301 5301 5301 0 0 0 0 0 0.00 5301 ... 交易所公告:...注意,不同年份这个文件头可能不同,有些版本前面会多个几行说明,有些版本列名会变成中文逗号分隔。所以写解析脚本时,不建议写死表头在第几行,而是应该先读前几行,找到真正的列名行,再往下解析。
数据字段里,最要命的三个字段是“结算价”“今收盘”“涨跌1/涨跌2”。很多人刚上手会默认“今收盘”就是当日结算依据,其实不是。国内商品期货的当日盈亏结算,用的是该合约当日成交价格按成交量加权计算出来的“结算价”,它和收盘价经常有几十个点的差异。
2.3 各字段的量化用途
下面这张表是我自己整理的重点字段速查:
| 字段 | 含义 | 量化用途 |
|---|---|---|
| 品种代码 | 品种缩写,如SR、CF、TA、MA、FG、SA | 分组、跨品种分析 |
| 合约代码 | 具体合约,如SR609 | 区分到期月份 |
| 前结算价 | 上一交易日结算价 | 计算涨跌幅基准 |
| 今开盘/最高/最低/收盘 | 当日OHLC | K线、技术指标 |
| 结算价 | 当日加权均价 | 保证金、盈亏、隔夜跳空基准 |
| 涨跌1 | 今收盘 - 前结算价 | 单日涨跌幅度 |
| 涨跌2 | 今结算价 - 前结算价 | 结算口径涨跌 |
| 成交量(手) | 当日成交总手数 | 流动性、换手因子 |
| 持仓量(手) | 当日收盘后的总持仓 | 仓位、资金流因子 |
| 持仓量增减 | 当日持仓变化 | 增仓/减仓信号 |
| 成交额(万元) | 成交金额 | 活跃度、市场深度 |
我给一个具体的例子。假设你想识别“放量增仓上涨”的合约,那你要看的就是:
- 涨跌1 > 0;
- 成交量较过去5日均值放大;
- 持仓量增减 > 0。
这三条,用这份文件里的字段直接就能算,不需要任何外部数据。
2.4 一个容易搞混的交易日概念
郑商所有夜盘交易,比如白糖、PTA、甲醇这些品种,夜盘是晚上21点到23点。夜盘结束后,数据在会计上归属于下一个交易日。举个例子,周五晚上的夜盘成交,会算进下周一的日统计数据里;周一夜盘才算周二的。这个如果不注意,你自己做“按天聚合”的时候,就会把周五夜盘和周五日盘混在一起,导致因子值序列错乱。
3. 实操过程:从零写一个郑商所日统计解析器
3.1 环境准备
我这里默认用的是 Python 3.9+ 和 Pandas 1.5+,如果你还没有装,直接pip install pandas requests就行。后面所有代码我不整复杂框架,一个脚本打到尾,方便你复制出去改着用。
3.2 第一步:下载当日的TXT文件
先写一个下载函数。核心就是把日期拼进URL,然后加个重试和保存逻辑。
import requests import time from pathlib import Path def download_czce_daily(trade_date: str, save_dir: str = "./data") -> Path: """ trade_date: 格式 YYYYMMDD,例如 '20240626' """ year = trade_date[:4] url = ( f"http://www.czce.com.cn/cn/DFSStaticFiles/Future/" f"{year}/{trade_date}/FutureDataDaily.txt" ) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" } Path(save_dir).mkdir(parents=True, exist_ok=True) local_path = Path(save_dir) / f"{trade_date}.txt" # 简单重试三次 for attempt in range(3): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: resp.encoding = "utf-8" local_path.write_bytes(resp.content) return local_path else: print(f"[{trade_date}] HTTP {resp.status_code}") except Exception as e: print(f"[{trade_date}] 下载异常: {e}") time.sleep(1.5) raise RuntimeError(f"下载失败: {trade_date}")为什么resp.encoding = "utf-8"而不是 GBK?因为这几年郑商所官网做过改版,历史文件很多已经变成UTF-8了,老一点的年份可能是GBK。所以下载时我干脆先原样存字节流,解析时再去判断编码。这里把resp.content直接写文件,就是保留原始字节,避免在下载阶段就做了错误的转码。
3.3 第二步:解析TXT为DataFrame
先别急着pd.read_csv,因为这个文件不是标准CSV,列之间可能是多个空格,也可能有制表符,还可能混入中文表头。稳妥的方式是先读取文本,找到正文的起始位置。
import pandas as pd def parse_czce_daily(file_path: Path) -> pd.DataFrame: # 先读原始字节,探测编码 raw = file_path.read_bytes() for enc in ("utf-8-sig", "gbk", "gb18030"): try: text = raw.decode(enc) break except UnicodeDecodeError: continue else: raise ValueError("无法识别的编码") lines = text.splitlines() # 找列名行:通常包含“合约代码”这四个字 header_idx = None for i, line in enumerate(lines): if "合约代码" in line: header_idx = i break if header_idx is None: raise ValueError("未找到表头行") # 跳过表头之前的内容,用正则按空白符切分 from io import StringIO df = pd.read_csv( StringIO("\n".join(lines[header_idx:])), sep=r"\s+", engine="python", encoding="utf-8", dtype=str, ) # 把列名两边的空格清一下 df.columns = [c.strip() for c in df.columns] return df这里用了dtype=str,因为合并后的列经常有“—”“NaN”之类的占位符,直接读数值会报错。后续要转数值的列再统一pd.to_numeric(..., errors='coerce')。
3.4 第三步:字段清洗与类型转换
拿到原始DataFrame后,需要做这么几件事:
- 去掉行首行尾的空列;
- 把“-”“—”“None”这些占位符变成NaN;
- 成交量、持仓量、价格字段全部转为数值型;
- 增加一列
日期,方便后续拼接历史数据。
def clean_czce_df(df: pd.DataFrame, trade_date: str) -> pd.DataFrame: # 去掉完全为空的列 df = df.dropna(axis=1, how="all") # 去掉完全为空的行 df = df.dropna(axis=0, how="all") numeric_cols = [ "前结算价", "今开盘", "最高价", "最低价", "今收盘", "结算价", "涨跌1", "涨跌2", "成交量(手)", "持仓量(手)", "持仓量增减", "成交额(万元)", "交割结算价" ] for col in numeric_cols: if col in df.columns: df[col] = pd.to_numeric( df[col].replace({"-": None, "—": None, "None": None}), errors="coerce" ) df["品种代码"] = df["品种代码"].astype(str).str.strip() df["合约代码"] = df["合约代码"].astype(str).str.strip() df["日期"] = trade_date return df这个清洗看着简单,但它决定了后续所有因子计算能不能一次跑通。我自己后来把这个逻辑封装成了一个函数,每天定时任务直接调用,极大减少了手工整理的时间。
3.5 第四步:批量拉历史数据
如果你想回测过去两年,每天拼URL、下载、解析,循环跑就行。但是要记得控制频率,毕竟交易所服务器不是给你一个人准备的。
import datetime as dt def make_trade_dates(start: str, end: str): """ 粗略生成交易日序列(只跳过周末,不停用节假日,节假日文件会404) 更严谨的做法是自己维护一个交易日历。 """ s = dt.date.fromisoformat(start) e = dt.date.fromisoformat(end) while s <= e: if s.weekday() < 5: yield s.strftime("%Y%m%d") s += dt.timedelta(days=1) all_dfs = [] for day in make_trade_dates("2024-01-01", "2024-06-30"): try: file_path = download_czce_daily(day, save_dir="./data") df = parse_czce_daily(file_path) df = clean_czce_df(df, day) all_dfs.append(df) except RuntimeError: # 可能是节假日,或者当天数据未发布 continue history = pd.concat(all_dfs, ignore_index=True)注意,这个“跳过周末”的做法并不严谨,因为节假日它也会去请求,然后碰到404。实际生产中,我建议维护一个交易日历,或者直接根据持仓量不为空的文件合集反推交易日。反推的办法就是:哪些日期路径能成功下载到文件,哪些就是交易日。这个方法虽然不是官方日历,但在90%的场景下都够用。
4. 从日统计数据到可用的量化信号
4.1 信号一:结算价与收盘价的乖离
我个人认为,结算价和收盘价之间的差值是这份文件里最被低估的字段组合。结算价是全天成交的加权平均价,而收盘价是最后时刻的成交价。如果收盘价远高于结算价,说明尾盘买盘非常强势,这种情况常出现在资金抢筹的品种上。
构造方法很简单:
history["收盘结算乖离"] = (history["今收盘"] - history["结算价"]) / history["结算价"]你可以按“品种代码 + 日期”做截面排序,把乖离最大的几个品种当成一种短期动量信号。我实测下来,在流动性好的主力合约上,这个因子单独用效果一般,但叠加成交量放大条件后,对趋势跟踪类策略有不错的辅助作用。
4.2 信号二:增仓方向一致性
期货市场有句老话叫“增仓上涨看多、增仓下跌看空、减仓上涨谨慎、减仓下跌有支撑”。这句话翻译成因子就是:把持仓量增减和涨跌方向放在一起看。
history["持仓变化"] = history["持仓量增减"] history["价格变化"] = history["涨跌1"] history["增仓上涨"] = ( (history["持仓变化"] > 0) & (history["价格变化"] > 0) ).astype(int)如果用历史数据统计:在“增仓上涨”这个条件出现后,次日继续上涨的概率是否高于基准,你可以直接在DataFrame上做滚动统计。这就是最朴素的“资金流验证”。当然,单看一个品种容易过拟合,我通常会把全市场所有活跃合约合在一起做横截面统计。
4.3 信号三:成交持仓比
成交持仓比衡量的是合约的换手活跃程度。计算公式是成交量 / 持仓量。这个比值高,说明短线资金活跃;比值低,说明市场沉淀度较高,价格更容易被少量资金推动。
history["成交持仓比"] = history["成交量(手)"] / (history["持仓量(手)"] + 1e-12)这里加1e-12是为了防止除零。用量化人之间的话说,这是一个“必须写进代码里的细节”,因为新上市合约首日可能没有持仓,直接除就会得到inf。
4.4 信号四:主力合约识别与换月
做国内商品期货,最麻烦的一件事就是确定“主力合约”。交易所文件里没有“主力”这个标签,只有合约列表。常用的方法是:每天取同一品种持仓量最大的合约作为当日主力合约。
idx = history.groupby(["日期", "品种代码"])["持仓量(手)"].idxmax() main_contracts = history.loc[idx].copy()然后,如果你要做连续价格序列,还需要处理换月。最简单的方式是:在主力合约切换的当天,用“新主力合约的涨跌幅”来替代拼接,避免价格跳空造成的假信号。这个方法看起来很土,但在日度频率上非常实用。
4.5 进阶:配合会员持仓排名数据
日统计数据里只有总量,没有席位信息。如果你想看得更深一步,比如某品种前20名会员是净多还是净空,那就需要再去抓交易所的“会员持仓排名”文件。郑商所同样有对应的每日排名文件,一般在官网“交易排名”栏目下。把“日统计数据”和“会员持仓排名”按日期、合约做合并之后,就能构造出类似“前20席净持仓变化”的经典资金流因子。这个可以作为本系列后续内容单独展开。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 下载时返回404 | 当天不是交易日,或数据尚未发布 | 先查交易日历,再做时间等待 |
| 文件里中文全部乱码 | 当前文件是GBK编码,代码默认用了UTF-8 | 用gbk或gb18030重试解码 |
| 解析出来的列数不对 | 定宽空格被当成多列,或者表头被拆分 | 先找表头行,再用sep=r"\s+" |
| 数字列变成全NaN | 文件用“-”表示缺失,Pandas默认读成字符串 | 用replace把“-”替换成None |
| 收盘价和结算价对不上 | 把两个字段理解成同一个概念 | 明确结算价是加权均价,收盘价是最后一笔 |
| 周五数据里总有奇怪数字 | 夜盘归属到下一交易日 | 按交易日重采样,不要按自然日重采样 |
| 历史数据缺了一个月 | 仅用reqDate接口拉历史,只能拉到近一年 | 用静态文件URL批量拉取,或每日定时存档 |
5.2 高频踩坑点逐个说
第一个坑:盲目用pd.read_csv直接读TXT。很多早期教程这么写,但因为表头前可能有说明行,文件解析会错位。一定要先定位“合约代码”所在的行,再从这个行开始读。
第二个坑:股票思维带到期货。股票有前复权、后复权,期货不存在复权,但存在合约换月。如果你把不同月份的合约价格拼在一起画曲线,不做换月处理,那画出来的“连续图”一眼假。处理方式是:换月当天只保留新合约,通过涨跌幅连续化。
第三个坑:收盘后才去定时下载,却不知道发布延迟。郑商所日统计数据通常在收盘后几个小时内发布,但具体时间不固定。我自己跑定时任务时,一般会设置“下午5点开始,每10分钟拉一次,直到成功为止”,避免数据没发布就以为网络出问题。
第四个坑:忽略文件编码的年份差异。2020年的文件和2024年的文件,编码可能完全不同。批量拉历史时,必须做编码探测,不能写死一种。我上面的解析函数里已经演示了用for enc in ("utf-8-sig", "gbk", "gb18030")循环尝试。
第五个坑:直接用“持仓量增减”字段做当日变化量,却不校验。这个字段偶尔会出现缺失或异常,尤其是新品种上市、老品种退市时。清洗时建议用今日持仓 - 昨日持仓交叉验证,对不上的再剔除。
第六个坑:合约代码大小写问题。郑商所的合约代码一般是两个大写字母加数字,比如SR609,但某些年份的文件里可能出现小写。解析时最好统一upper()。
5.3 独家技巧:如何自建交易日历
与其到处找交易日历,不如自己造一个。方法是:先跑一遍make_trade_dates,把所有周末之外的日期都尝试下载一次,能下载到文件的就是交易日。把这些日期存成一个CSV,每次回测时直接用这个日历,不进交易所服务器反复请求。这样既省时间,也避免触发频率限制。
我在本地就是这么维护的:每天早上定时任务跑一次下载,同时把成功下载的日期追加到交易日历里。日积月累,日历越来越完整,历史数据也不断在本地积累,以后要回溯策略再也不愁没数据。反过来,如果一开始不存档,等到两三年后发现需要某段历史,再回头去交易所拉,对方挂了404你也只能干瞪眼。
6. 关于自动化和后续扩展的一些个人建议
做这类数据工程,我建议你从第一天就养成“全天候定时存档”的习惯。不管你现在用不用得上历史数据,先把每天的日统计数据拉下来存好,成本极低,收益却在后期呈指数级放大的。我见过太多人一开始只拉自己关注的几个品种,后来换策略方向需要别的品种历史数据,结果发现早期没存,只能手工补,痛苦程度谁补谁知道。
另一点,日统计数据处理熟练之后,建议把目光扩大到其他交易所。上期所、大商所、中金所、广期所的数据发布方式大同小异,但文件格式细节各有各的坑。一个交易所跑通,剩下的其实就是复制粘贴后改字段名的事。还有,郑商所除了每日行情TXT,还提供每天的交割结算价表、仓单日报表、会员持仓排名表,这些都可以用类似的下载方案拿到。
最后说一个我在实际使用中的体会:处理这种“官方裸数据”,最大的障碍从来不是计算本身,而是对字段语义的敬畏。拿到一个字段,先别急着写公式,先把交易所文档翻明白,再用几天的真实数据手工验算一遍,再去上因子。我自己在“结算价和收盘价乖离”这个因子上栽过跟头,就是因为早期直接用收盘价做结算依据,导致止损单经常被莫名其妙的跳空扫掉。后来把文件里的结算价字段对齐到回测逻辑里,整个系统才稳下来。你也动手跑一遍,应该能感受到这种“突然开窍”的时刻。