做商品期货量化的人大概都有过这么一个阶段:K线数据随便抓,Wind、同花顺、各种行情源一堆,但一到基本面就卡壳——基差今天多少、交易所库存这周去了多少、仓单昨天注销了多少手、跨期价差现在处在历史什么位置。这些数据散落在交易所公告、资讯站、付费终端里,格式五花八门,想拼成一条能进因子库的序列,光清洗就能耗掉一个人小半个月。AKShare 里的奇货可查系列接口加上交易所公开数据接口,恰好把这几个最硬的指标串成了一条可编程的链路,基差、库存、仓单、套利价差都能用几行 Python 拉下来,对做商品 CTA、期现套利、跨期统计套利的人来说,是个成本极低的起点。这篇内容我按自己实际跑通的顺序写:先讲清楚这三个指标各自解决什么问题,再讲接口怎么探查、怎么调用、字段怎么读,然后是把原始数据加工成能用的因子序列,最后是落库、更新和一堆踩过的坑。无论你是刚学 Python 想找个真实数据集练手,还是已经在做商品策略想补基本面维度,都能直接拿去抄。
1. 先想清楚:基差、库存、仓单各自在回答什么问题
1.1 三个指标其实是同一条供需链条的三个截面
很多人一开始会把基差、库存、仓单当成三个互不相干的因子丢进模型,结果发现相关性高得离谱,还互相打架。原因很简单,它们描述的是同一件事的不同截面。基差是价格信号,现货减期货,反映的是当下实物供需和远期预期之间的裂口;库存是数量信号,告诉你眼下有多少货躺在仓库里;仓单是可交割量信号,告诉你这些货里有多少已经变成了随时能进交割库的标准化凭证。举个例子,某品种基差持续走强、交易所库存连续几周下降、仓单同时出现大额注销,这三件事叠在一起,基本就是在说现货端有人在抢货,或者卖方不愿意注册仓单交货。反过来,基差走弱、库存累积、仓单猛增,那就是另一套故事了。理解这层关系之后,你做因子就不会傻乎乎地把三个高度共线的指标硬塞进同一个线性模型,而是会去想:基差是领先的价格信号,库存是同步的量能确认,仓单是交割环节的边际变化,三者的时间节奏是不一样的。我自己的做法是把基差当主因子,库存和仓单当条件变量或者状态过滤器,比如只在库存分位数低于 30% 的时候才放大基差因子的权重。这种组合逻辑比单纯做多因子加权要稳得多。
1.2 为什么这条链路值得用 AKShare 而不是自己爬
自己写爬虫抓这几个指标,技术上不难,难在维护。交易所页面结构两三年改一次,资讯站的表格偶尔加个合并单元格,你的解析代码就全废,而且这类站点大多有访问频率限制,跑全品种一天的数据要控制节奏,稍不注意就被限流。AKShare 的价值在于它已经把这一层脏活封装掉了,返回的是标准的 pandas DataFrame,字段名统一,你只要关心业务逻辑。当然它也有明显的短板:接口是非官方封装,上游页面一变,函数可能当天就报错;没有 SLA,没有并发保证;部分接口只支持按单日查询,想拉五年历史得自己循环。所以我的定位很清楚——研究和原型阶段用它足够,真上生产必须加一层本地缓存和字段校验,把"接口挂了"这件事变成"数据没更新"的告警,而不是让策略直接读到空表。这个思路在后面第 6 节会展开讲。
1.3 接口全景:哪些函数归到这条链路上
不同版本的 AKShare 函数名会有微调,所以我一般不让别人背函数名,而是给一张按用途分类的对照表,落地前先用dir()过滤一遍确认。下面这张表是我本地版本的实际归类,你在自己环境里跑出来大概率一致,个别名字对不上就以dir(ak)为准。
| 用途 | 典型函数 | 数据频率 | 返回形态 | 稳定性评价 |
|---|---|---|---|---|
| 现货价与基差 | futures_spot_price、futures_spot_price_previous | 日频 | 单表 DataFrame | 较稳,偶尔字段增减 |
| 交易所/社会库存 | futures_inventory_em、futures_inventory_99 | 日频或周频 | 单表 DataFrame | 中等,历史长度有限 |
| 仓单日报 | get_receipt | 日频 | 单表 DataFrame | 较稳,需按品种代码循环 |
| 奇货可查系列 | futures_qhkc_前缀的一批函数 | 日频 | 单表或字典 | 变动相对频繁,需版本锁定 |
| 行情与主力合约 | futures_hist_em、futures_main_sina | 日频/分钟 | 单表 DataFrame | 较稳 |
| 持仓排名 | get_rank_sum_daily | 日频 | 单表 DataFrame | 中等 |
看清楚这张表你会发现一个规律:越是依赖单一资讯站页面的接口,稳定性越差;越是直接对接交易所公开数据的接口,稳定性越好。这个规律直接决定了你的架构——把稳定的当骨架,把不稳定的当增强项,接口挂了策略降级运行而不是直接崩。
2. 环境准备与接口探查的正确姿势
2.1 装库、锁版本,别嫌麻烦
装 AKShare 就一行命令,但我建议顺手把版本钉死。原因很实在:这个库更新频率高,接口字段说改就改,你今天调通的代码,两周后换个环境重装可能就跑不通了。我自己的习惯是新建虚拟环境后先装再立刻 freeze。
python -m venv venv source venv/bin/activate pip install akshare pandas numpy --upgrade -i https://pypi.tuna.tsinghua.edu.cn/simple pip freeze > requirements.txtrequirements.txt里会出现类似akshare==1.x.x的精确版本,把它提交到仓库,后面任何人复现你的环境都不会跑偏。另外强烈建议单独装一个duckdb或者直接用sqlite3,因为基本面数据的历史积累很慢,你不可能每次都全量重拉,本地存一份是刚需,这个在第 6 节细讲。还有一点,pandas 版本也要留意,AKShare 内部有些地方用了较新的 API,pandas 太老会报一些莫名其妙的错,建议 2.x 以上。
2.2 别急着调接口,先摸清函数签名
我见过太多人直接照着某篇教程抄ak.xxx(date="2024-01-01"),结果报TypeError,然后开始怀疑人生。正确做法是先探查。AKShare 的函数数量很多,直接dir输出几百个名字看着头疼,用关键词过滤一下就好。
import akshare as ak import inspect keywords = ["qhkc", "inventory", "receipt", "spot_price", "basis"] candidates = [n for n in dir(ak) if any(k in n.lower() for k in keywords)] for name in candidates: fn = getattr(ak, name) try: sig = inspect.signature(fn) print(f"{name}{sig}") except (TypeError, ValueError): print(f"{name} <无法获取签名>")这段跑完,你会拿到一份带参数列表的清单,比翻文档快得多。我特别推荐用inspect.signature而不是help(),因为help()会打印一大段从网页抓下来的说明文字,噪音很多,而签名能一眼看出必填参数、默认值和参数类型。这一步花五分钟,后面能省两小时。
2.3 返回结构有三种形态,得统一封装
AKShare 的返回值不总是 DataFrame。我实际碰到过三种:直接返回一个 DataFrame;返回一个字典,键是品种名、值是 DataFrame;返回一个列表套字典。这三种形态如果你在业务代码里到处写if isinstance(...),很快就会失控。我的做法是在数据层写一个统一的适配函数,把任何返回都压成(DataFrame, meta)的二元组,meta 里带上接口名、调用参数、抓取时间戳。
import pandas as pd from datetime import datetime def normalize(result, func_name, params): if isinstance(result, pd.DataFrame): df = result.copy() elif isinstance(result, dict): frames = [] for k, v in result.items(): if isinstance(v, pd.DataFrame): tmp = v.copy() tmp["_group"] = k frames.append(tmp) df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() elif isinstance(result, list): df = pd.DataFrame(result) else: df = pd.DataFrame() meta = { "func": func_name, "params": params, "fetched_at": datetime.now().isoformat(timespec="seconds"), "rows": len(df), "cols": list(df.columns), } return df, meta这个封装看着朴素,作用很大:第一,所有接口进来都是 DataFrame,下游不用管来源差异;第二,meta 里的cols字段让你可以做字段漂移监控——今天的列名和昨天不一样,立刻报警,而不是等策略跑出诡异结果才回头查。这套机制我在实际项目里用了很久,抓字段变动一抓一个准。
注意:
fetched_at一定要记,基本面数据有"发布延迟"这个概念,你晚上八点抓到的库存数据,可能只更新到前天。没有时间戳,你根本分不清是数据源没更新还是你的任务没跑。
3. 基差数据:从拉取到构建可用的基差率序列
3.1 接口调用与字段逐列解读
基差接口按单日查询,传一个交易日字符串,返回当天所有品种的现货价、近月合约价、主力合约价以及对应的基差和基差率。
df, meta = normalize( ak.futures_spot_price(date="20240315"), "futures_spot_price", {"date": "20240315"}, ) print(meta["rows"], meta["cols"]) print(df.head(3))返回的典型字段包括品种名、现货价格、近月合约代码及价格、主力合约代码及价格、近月基差、主力基差、近月基差率、主力基差率、日期。这里有两个细节必须说清楚。第一,near_basis和dom_basis是两套东西,近月基差用的是最近月合约,反映的是最迫切的交割压力;主力基差用的是持仓量最大的合约,反映的是市场主流定价。做趋势策略看主力基差,做期现套利必须看近月基差,因为交割是跟着近月走的。第二,基差率的计算口径,通常是(现货 - 期货) / 现货,但有些品种的现货报价本身是含税或者不含税的,跨品种比较之前一定要确认口径一致,否则你算出来的分位数全是噪音。
3.2 主力换月是基差序列最大的坑
如果你直接按日期把dom_basis_rate拼成一条长序列,一定会遇到这个问题:主力合约在换月那天从 2405 切到 2409,两个合约的基差水平本身就有差异,序列上会出现一个明显跳空。很多人误以为这是基本面变化,其实只是合约切换。处理方法有几种,我用的是最土也最稳的那种——在拼接时把合约代码一起保留,遇到合约变更就打个标记,做因子计算时对这个位置做差分处理,或者在滚动窗口里排除跨合约的样本。
def build_basis_panel(start, end, trade_dates): rows = [] for d in trade_dates: try: df, _ = normalize(ak.futures_spot_price(date=d), "futures_spot_price", {"date": d}) except Exception as e: print(f"{d} 拉取失败: {e}") continue if df.empty: continue df["trade_date"] = pd.to_datetime(d) rows.append(df) if not rows: return pd.DataFrame() panel = pd.concat(rows, ignore_index=True) panel = panel.sort_values(["symbol", "trade_date"]) panel["contract_changed"] = ( panel.groupby("symbol")["dominant_contract"].shift(1) != panel["dominant_contract"] ) return panel注意到我用了trade_dates显式传交易日列表,而不是自己在代码里生成自然日序列。为什么?因为非交易日调用接口大概率返回空表或者报错,浪费请求次数还污染日志。交易日历可以从行情接口里取,或者用ak.tool_trade_date_hist_sina()这类工具函数拿一份全量日历再过滤。这个细节很小,但能让你的批量任务从"跑一半崩"变成"稳定跑完"。
3.3 基差率的标准化:不做这一步等于白算
原始基差率在不同品种之间完全不可比。铜的基差率常年在一两个百分点里晃,某些农产品能到十几个百分点,你直接把它们丢进一个横截面模型里排序,结果就是永远只做那几个高波动品种。必须做分品种标准化。我常用的是两种,一种是滚动 z-score,窗口取 252 个交易日;另一种是历史分位数打分,窗口取三年。
def add_basis_features(panel, window=252): panel = panel.sort_values(["symbol", "trade_date"]).copy() g = panel.groupby("symbol")["dom_basis_rate"] panel["basis_ma"] = g.transform(lambda s: s.rolling(window, min_periods=60).mean()) panel["basis_std"] = g.transform(lambda s: s.rolling(window, min_periods=60).std()) panel["basis_z"] = (panel["dom_basis_rate"] - panel["basis_ma"]) / panel["basis_std"] panel["basis_pct"] = g.transform( lambda s: s.rolling(window, min_periods=60).apply( lambda x: (x[-1] > x).mean(), raw=True ) ) return panel这里有两个经验值。min_periods我设成 60,因为很多品种的历史数据长度不够,卡太死会导致一大批品种直接没值,回测里就变成了幸存者偏差。另外 z-score 和分位数我会同时算,z-score 对极端值敏感,适合做阈值触发;分位数对分布形态不敏感,适合做横截面排序。两个都用,信号打架的时候以分位数为主,因为商品基本面的分布经常是厚尾的,z-score 容易在极端行情里给出过于激进的读数。
提示:基差率还存在正负号的方向问题。有些品种习惯用"期货减现货"定义基差,有些用"现货减期货"。AKShare 返回的字段里带
basis_rate的通常是现货减期货,也就是正值代表现货升水。跨数据源拼接前一定先拿一个你能手工核对的日子验一下符号,这个错误一旦混进因子,后面所有结论都是反的。
4. 库存与仓单:把低频数据变成能进模型的因子
4.1 库存接口的品种口径差异要先摸清
库存接口一般按品种名查询,比如传"沪铜""螺纹钢"这类中文名,返回日期、库存量、增减量几列。听起来简单,实际用起来第一个坑就是口径。同样是"铜库存",交易所口径只统计注册在交割库里的量,社会口径会把贸易商和下游的库存也算进去,两者能差出好几倍。你做因子的时候用的是哪个口径,必须在文档里写死,否则不同人跑出来的结果对不上。第二个坑是更新频率。有的品种是日频,有的品种一周才更新一次,你如果按日频去 fillna,会把周频数据错误地前向填充成平滑曲线,看起来库存一直在缓慢变化,实际上是数据本身的颗粒度问题。
inv, meta = normalize( ak.futures_inventory_em(symbol="沪铜"), "futures_inventory_em", {"symbol": "沪铜"}, ) inv["日期"] = pd.to_datetime(inv["日期"]) inv = inv.sort_values("日期").reset_index(drop=True) inv["库存变化"] = inv["库存"].diff() inv["库存变化率"] = inv["库存"].pct_change() print(inv.tail(5))我一般会先画一眼原始序列再决定怎么处理,因为有些品种的库存数据中间会有一段明显的平台期,那不是市场没变化,是数据源那段时间没更新。这种段落如果直接进模型,会被当成"库存稳定",从而给出错误的低波动判断。
4.2 仓单数据:读懂注册和注销背后的动作
仓单接口按日期区间加品种代码查询,返回的是每个品种每天的仓单数量和增减。仓单这个东西跟库存不一样,它带有很强的行为属性。仓单增加,说明有卖方把现货注册成标准仓单,准备用于交割或者质押;仓单减少,通常是有人注销提货,把货拉走。所以在临近交割月的时候,仓单的边际变化比绝对水平更有信息量。
receipt = ak.get_receipt( start_date="20240101", end_date="20240315", vars_list=["CU", "AL", "RB"], ) receipt["var"] = receipt["var"].str.strip() pivot = receipt.pivot_table( index="var", columns="date", values="receipt", aggfunc="sum" ) print(pivot.iloc[:, -5:])这段代码里vars_list传的是品种代码不是中文名,这是个容易搞混的地方,库存接口用中文名,仓单接口用英文代码,两套命名体系并存。我建议在本地维护一张映射表,把中文名、交易所代码、合约乘数、最小变动价位全放进去,后面做价差计算的时候直接用这张表,省得每次现查。
4.3 库存因子的三种构建方式与适用场景
原始库存量本身没什么用,因为不同品种的量级差了几个数量级。真正能进模型的是三种加工方式。第一种是变化率,取近 5 日、20 日的库存变化百分比,捕捉短期去库或累库的速度。第二种是历史分位数,取过去三年的滚动分位,衡量当前库存处在什么位置,这个适合做状态判断。第三种是二阶变化,也就是去库速度的加速度,用来捕捉拐点。这三种我在不同策略里都用过,实测下来分位数最稳,变化率最灵敏但也最容易假信号,二阶变化单独用基本没法看,必须配合基差一起用。
| 因子形式 | 计算方法 | 信号含义 | 适用场景 | 稳定性 |
|---|---|---|---|---|
| 库存变化率 | 5日/20日 pct_change | 短期供需边际变化 | 短周期趋势 | 中,噪音大 |
| 库存历史分位 | 三年滚动分位 | 绝对水位高低 | 状态过滤、仓位调节 | 高 |
| 库存二阶变化 | 变化率的差分 | 去库加速/减速 | 拐点捕捉 | 低,需配合 |
| 虚实盘比 | 持仓量/仓单量 | 可交割量是否紧张 | 临近交割月 | 中高 |
| 仓单注销强度 | 仓单5日净减量/存量 | 卖方交货意愿 | 交割博弈 | 中 |
虚实盘比这个指标值得单独说一句,它的计算是持仓量除以仓单量,反映的是"纸面头寸"相对"实物凭证"的倍数。这个比值越高,说明市场上虚盘越多、可交割实物相对越少,临近交割时容易出现挤仓行情。这个指标不需要额外接口,持仓量从行情接口拿,仓单从仓单接口拿,两个一除就行,但信息量相当大。
5. 套利数据:价差序列的结构化处理
5.1 套利接口给的是什么,不能直接拿来用什么
奇货可查系列的套利相关接口,返回的通常是跨期价差、跨品种价差以及一些统计指标。这里有个认知必须先摆正:接口给的价差是"名义价差",也就是两个合约价格直接相减,没有做合约乘数归一化。铜的合约乘数是 5 吨/手,螺纹钢是 10 吨/手,你把它们的价差放在一起比大小,完全没有意义。跨品种价差必须先乘以各自的合约乘数,换算成"每手合约的元金额",才能横向比较。
CONTRACT_MULTIPLIER = { "CU": 5, # 吨/手 "AL": 5, "RB": 10, "I": 100, "M": 10, "TA": 5, "AU": 1000, # 克/手 } def normalize_spread(df, col_price_a, col_price_b, var_a, var_b): ma = CONTRACT_MULTIPLIER.get(var_a, 1) mb = CONTRACT_MULTIPLIER.get(var_b, 1) df = df.copy() df["spread_value"] = df[col_price_a] * ma - df[col_price_b] * mb return df这张映射表一定要在项目早期就建好,并且写进配置而不是硬编码在函数里。我吃过一次亏,某个品种的乘数记错了,回测里价差序列看着挺正常,实盘一算保证金才发现完全对不上,白跑了两周。
5.2 时间对齐:两个合约的交易日不一定完全重合
跨期价差看起来是同一天两个合约相减,实际上会遇到交易不重合的情况。远月合约在某些日子可能没有成交,接口返回的价格是空的或者沿用上一日结算价。如果你直接 dropna,会丢掉大量样本;如果直接 ffill,会把陈旧价格当成新价格用。我的处理方式是保留原始值,额外加一列标记这个价格是不是"当日真实成交价",回测时对非真实成交的样本降权或者剔除。
def align_spread_legs(df, col_near, col_far): df = df.copy() df["near_valid"] = df[col_near].notna() df["far_valid"] = df[col_far].notna() df["both_valid"] = df["near_valid"] & df["far_valid"] df["spread"] = df[col_far] - df[col_near] df["spread"] = df["spread"].where(df["both_valid"]) return df这段逻辑看着笨,但它让"数据缺失"这件事显式化了,而不是被ffill悄悄抹平。回测的时候,both_valid为 False 的日子直接跳过开仓,实盘的时候这些日子也要降杠杆,因为流动性大概率也不好。
5.3 一个能跑起来的统计套利信号框架
价差序列处理完,接下来是信号。最经典的是 z-score 均值回归,逻辑简单:价差偏离均值超过两个标准差就认为偏贵或偏便宜,做反向。但商品价差有个特点,它有季节性,而且经常出现结构性位移,所以纯 z-score 会频繁被打脸。我自己的改法是加两个过滤条件:一是要求价差分位数也同时极端,二是要求基差方向支持这个判断。
def spread_signal(spread_series, window=120, entry_z=2.0, exit_z=0.5): ma = spread_series.rolling(window, min_periods=40).mean() sd = spread_series.rolling(window, min_periods=40).std() z = (spread_series - ma) / sd signal = pd.Series(0, index=spread_series.index) signal[z > entry_z] = -1 # 价差偏高,做空价差 signal[z < -entry_z] = 1 # 价差偏低,做多价差 signal[z.abs() < exit_z] = 0 # 回归均值平仓 return pd.DataFrame({"spread": spread_series, "z": z, "signal": signal})这个框架只是骨架,真要用还得加止损、加持仓上限、加合约流动性过滤。但骨架先跑通很重要,因为很多人的问题不是策略不够精妙,而是数据链条根本没打通,连一条干净的价差序列都拿不出来。我建议的顺序是:先把单品种的跨期价差算准,跑通一遍信号,再加第二个品种,最后才考虑跨品种。跨品种涉及的相关性估计和协整检验,复杂度是跨期的好几倍,一上来就搞很容易死在数据准备阶段。
注意:套利价差里有一类特殊情况是"跨品种价差存在加工关系",比如原料和成品之间的价差。这类价差的合理区间是有产业逻辑支撑的,不能纯用统计方法做均值回归,否则产业利润修复的时候你会被反向套死。涉及加工关系的品种,一定要在信号里加一个产业利润的上下界判断。
6. 数据落库与自动化更新
6.1 表结构设计:宽表还是窄表
基本面数据量不大,全品种跑十年也就几十万行,所以我倾向用窄表加维度表,也就是一张长表存所有品种的所有指标,字段是日期、品种、指标名、值、来源、抓取时间。窄表的好处是加新指标不用改表结构,坏处是查询的时候要 pivot。用 DuckDB 的话 pivot 成本极低,而且列式存储在按品种和日期过滤时速度很快,我个人现在基本都用它做本地研究库。
CREATE TABLE IF NOT EXISTS futures_fundamental ( trade_date DATE NOT NULL, symbol VARCHAR NOT NULL, indicator VARCHAR NOT NULL, value DOUBLE, source VARCHAR, fetched_at TIMESTAMP, PRIMARY KEY (trade_date, symbol, indicator) );主键设成这三个字段,配合INSERT OR REPLACE就能天然实现幂等,重复跑同一天不会产生重复行。这个设计比"每天一张表"或者"每个指标一张表"都要省心,尤其是在你后面想加新指标的时候,直接改indicator的值就行,不用 alter table。
6.2 增量更新:靠最大日期往前推,别靠文件列表
增量更新的常见错误是按"我上次处理到哪个文件"来判断,一旦中间断了一天,后面全乱。正确做法是每次启动先查库里的最大日期,从那天往前推一个安全边界开始拉,一般推三到五个交易日,因为数据源补数据有延迟,前几天拉的数据可能是残缺的,重拉一遍自动覆盖。
import duckdb def get_last_date(con, symbol, indicator): row = con.execute( "SELECT MAX(trade_date) FROM futures_fundamental WHERE symbol=? AND indicator=?", [symbol, indicator], ).fetchone() return row[0] def upsert(con, rows): if not rows: return 0 con.executemany( """INSERT OR REPLACE INTO futures_fundamental (trade_date, symbol, indicator, value, source, fetched_at) VALUES (?, ?, ?, ?, ?, ?)""", rows, ) return len(rows)配合交易日历,从last_date - 5 个交易日开始跑到今天,就可以了。这套逻辑跑起来以后,每天只需要十几秒就能把全品种更新完,比全量重拉快几个数量级,对接口也更友好。
6.3 定时任务与校验:让数据自己说话
定时任务本身简单,cron 或者调度框架都行。真正有价值的是校验环节。我一般在每次更新后跑三条检查:一是行数检查,今天入库的行数和昨天比不能差太多;二是空值率检查,某个指标突然全空说明接口挂了;三是数值跳变检查,单日变化超过历史 99 分位太多就打标记。这三条里第二条救过我很多次,因为接口挂了往往不报错,只是返回一个空表,如果不检查,策略读到的就是"今天没有任何数据",然后一路降级运行,你自己都不知道。
def sanity_check(con, trade_date, expected_min_rows=50): n = con.execute( "SELECT COUNT(*) FROM futures_fundamental WHERE trade_date=?", [trade_date] ).fetchone()[0] if n < expected_min_rows: print(f"[告警] {trade_date} 入库行数仅 {n},低于阈值 {expected_min_rows}") nulls = con.execute( """SELECT indicator, COUNT(*) FROM futures_fundamental WHERE trade_date=? AND value IS NULL GROUP BY indicator""", [trade_date], ).fetchall() for ind, cnt in nulls: if cnt > 0: print(f"[告警] {trade_date} 指标 {ind} 有 {cnt} 条空值")告警发到哪里都行,邮件、群机器人、日志文件都可以。关键是有人能看到。我见过不少团队把校验写得很漂亮但没接通知,结果数据断了三天才发现,那校验写了等于没写。
7. 常见问题与排查速查
7.1 接口返回空表:八成不是接口的错
返回空表的原因排下来大概有这几种:传了非交易日、传了未来日期、品种名写错、参数格式不对。我调试的时候习惯先拿一个确定有数据的历史日期当基准,比如上一个月的某天,跑通了再换成目标日期。如果基准日能出数据、目标日不能,那基本就是日期问题;如果基准日也出不来,那就是函数签名或者版本问题。另外要注意,有些接口对日期格式很敏感,2024-03-15、20240315、2024/03/15三种写法可能只有一种能用,报错信息还不一定告诉你。
7.2 字段漂移:最隐蔽的那种故障
字段漂移指的是接口正常返回,但列名变了,比如从库存变成库存量,或者单位从吨变成万吨。这种故障最坑,因为不会报错,你的代码里df["库存"]会抛 KeyError 或者干脆被 fillna 成空。防御手段就是前面提到过的 meta 里记列名,每次和基线对比,不一致就告警。我还会额外做一层单位校验,拿一个已知品种的已知数量级做对照,比如铜的交易所库存大概在几万吨到几十万吨这个区间,如果某天突然变成个位数,那肯定是单位换了。
7.3 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 返回空 DataFrame | 非交易日 / 品种名错 / 参数格式错 | 换基准日重试 | 用交易日历过滤,校验参数 |
TypeError参数不匹配 | 库版本变动 | inspect.signature看签名 | 锁版本或改调用方式 |
KeyError字段不存在 | 列名漂移 | 打印df.columns对比基线 | 加字段映射层 |
| 数据长时间不变 | 数据源未更新 | 查fetched_at和最新日期 | 前推几日重拉 |
| 数值量级异常 | 单位变化 | 与历史区间对比 | 加单位校验规则 |
| 批量任务中途崩溃 | 单品种异常未捕获 | 看日志定位品种 | 循环内 try/except 继续 |
| 请求被限流 | 频率过高 | 降低并发、加 sleep | 指数退避重试 |
| 主力基差序列跳变 | 换月 | 看dominant_contract是否变 | 标记换月点做差分 |
| 价差不可比 | 未做乘数归一 | 检查CONTRACT_MULTIPLIER | 先乘乘数再比较 |
| 库存曲线异常平滑 | 周频数据被日频填充 | 看原始日期间隔 | 按真实频率对齐 |
这张表我建议打印出来贴在显示器边上,前三个月会经常用。后面你熟了,这些问题基本能靠直觉定位,但一开始真的会栽在里面。
7.4 几个只有实际跑过才知道的细节
第一个细节,批量拉数的时候一定要在循环里做 try/except,并且对失败的品种记下来,最后统一重试一次。因为这类接口偶尔会抽风,某个品种返回个奇怪的响应,你如果没捕获,整个批次全废,第二天还得重头跑。第二个细节,sleep的值不要设成一样,固定间隔容易被判定为规律请求,随机化到 1 到 3 秒之间更稳。第三个细节,历史数据不要一次性拉太长的区间,按季度分段拉,每段之间存一次盘,这样即使中途断了,前面的成果还在。第四个细节,仓单数据在月初月末会有明显的集中变化,这是交割制度导致的,做因子的时候要把这两天的极端值做缩尾处理,否则分位数会被带偏。第五个细节,如果你要做跨品种的横截面因子,一定要确认参与的品种在同一时间段内都有数据,否则横截面排序的结果会随时间漂移,回测看起来很漂亮,实盘完全对不上。
我自己在实际操作中的体会是,这套数据链路真正的价值不在于接口本身有多强大,而在于它逼着你去把"数据口径、更新频率、字段语义、单位量纲"这些看起来枯燥的东西一条条理清楚。理清楚之后,你换任何一个数据源都能快速迁移,因为你要的从来不是某个函数,而是一套能自洽的基本面数据体系。至于接口今天能跑明天不能跑这件事,就当是提醒你——任何免费数据源都只适合当起点,本地那份已经校验过的库,才是你能长期依赖的东西。