简介:面向Python量化交易学习者与金融数据开发者的tushare 0.2.8源码包,聚焦理解国内常用金融数据接口库的内部实现,帮助解决数据获取、清洗与落地分析中的使用困惑。压缩包共26个文件,以19个Python源码为主,辅以3个txt说明、2个pkg-info元数据、1个csv样例和1个cfg配置,整体约181KB,体量轻便,便于按模块阅读与本地调试。已有6257人学习,具备一定的参考热度。内容围绕股票等金融数据的请求与解析、日期格式化与数据清洗、CSV/Excel存储、实时行情轮询、MACD/KDJ等技术指标计算及网络与数据异常处理展开,读者可借此熟悉HTTP请求封装、数据转换流程和接口设计思路,并在源码阅读中提升Python工程能力,适合作为量化交易入门与金融数据分析实践的辅助材料。
1. 拿到一份 tushare 量化交易 Python 源码,先别急着点运行
周末翻到一份 tushare 量化交易 Python 源码,数据抓取、因子计算、回测撮合、绩效统计串成一条线,跑起来几分钟就能吐出一份带净值曲线的结果。很多人第一次拿到这种包,第一反应是python main.py看收益,结果不是报限流就是净值飙到年化 300%——后者八成是未来函数在作祟。这类源码真正的价值不在策略本身,而在于它把 A 股的一堆琐碎问题封装好了:交易日历、复权因子、涨跌停、T+1、停牌、手续费。它适合已经会 pandas、想把「能跑」变成「敢用」的人;如果连 groupby 和 rolling 都还生疏,先补两周再回来。下面按数据层、因子层、回测层、排错、上线五段拆开讲。
2. 数据层先立住:tushare 接口的三层封装与限流兜底
2.1 为什么这份源码把数据层放在策略前面
新手拆包习惯从strategy.py读起,老手一般先翻data/目录。原因很朴素:策略写得再花哨,数据错一天,回测结果就全废。A 股数据有五个地方天然不干净——交易日历不连续(春节能停一周)、复权因子每天变、停牌期间没有行情、涨跌停时成交价不可得、退市股票会从接口里消失。这五件事任何一件没处理,回测出来的夏普都是假的。
这份源码把数据层切成三层:接口层负责发请求和重试,存储层负责增量落库和去重,视图层负责复权对齐和宽表转换。分层的好处是排错时能快速定位——净值不对,先看视图层的复权基准日,再看存储层有没有重复行,最后才怀疑策略。
选型上,源码用 SQLite 而不是 MySQL 或 parquet,这个选择对单机回测是对的:零部署、单文件、支持INSERT OR REPLACE做幂等写入。缺点是并发写会锁库,所以定时任务里别开多进程同时写同一个.db文件。数据量再往上(十年全市场日线约 1200 万行)就该换 parquet 分区表,按trade_date分目录,pandas 读起来比 SQLite 快不少。
2.2 token 与重试:最小可用的接口封装
第一段代码是配置和重试。别小看这二十行,源码包里最容易出事的就是这里——token 硬编码提交到仓库、网络抖一下就整个任务崩掉。
# config.py —— 只放三类东西:token、路径、全局参数 import os, time, functools, logging import tushare as ts TS_TOKEN = os.getenv("TS_TOKEN", "") # 从环境变量读,别写死在源码里 pro = ts.pro_api(TS_TOKEN) def retry(times=3, wait=1.0): """接口偶发超时/限流时兜底,不要写 while True 死循环""" def deco(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): last = None for i in range(times): try: return fn(*args, **kwargs) except Exception as e: # 网络抖动、限流、字段变动都会走这里 last = e time.sleep(wait * (2 ** i)) # 指数退避,别固定 1 秒猛打 logging.warning("放弃 %s: %s", fn.__name__, last) return None # 返回 None 让上层跳过,不抛异常 return wrapper return deco逻辑说明:装饰器把「调用—失败—退避—重试」封成一个可复用的壳,业务代码里只写fetch = retry()(pro.daily),调用处不用关心重试细节。参数times=3表示最多试三次,wait=1.0是首次等待秒数,之后按 2 的幂次翻倍(1s、2s、4s)。这个退避策略是血泪经验:早期我用固定 1 秒重试,结果触发限流后越重试越久,整个任务卡死半小时。
返回None而不是抛异常,是个有意的设计取舍。批量拉取几百个交易日时,某一天失败不应该让整个任务归零,上层用if df is None: continue跳过并记日志,第二天补跑即可。代价是失败被静默吞掉,所以日志必须打,且要在任务末尾统计「跳过天数」,超过阈值就告警。
ts.pro_api(TS_TOKEN)传参和ts.set_token()全局设置两种写法效果一样,前者更适合多账户切换。token 有效期和权限绑定积分,积分不够时某些接口直接返回空 DataFrame 而不是报错,这点很坑,后面第 5 章细说。
2.3 增量落库:按交易日循环比分股票循环快在哪
数据抓取有两种循环方式:按股票循环(一天一天拉某只票的历史)和按交易日循环(一天拉全市场的截面)。源码默认用后者,因为请求数差两个数量级。
假设 5000 只股票、5 年约 1200 个交易日。按股票循环要发 5000 次请求,按交易日循环只要 1200 次。更关键的是,截面模式下每次返回的是当日全部股票,天然对齐,不用再做日期补齐。
# loader.py —— 按交易日循环取全市场日线,增量写入 SQLite import sqlite3, pandas as pd from config import pro, retry DDL = """ CREATE TABLE IF NOT EXISTS daily ( ts_code TEXT, trade_date TEXT, open REAL, high REAL, low REAL, close REAL, pre_close REAL, vol REAL, amount REAL, PRIMARY KEY (ts_code, trade_date) ); CREATE INDEX IF NOT EXISTS idx_daily_date ON daily(trade_date); """ def trade_dates(start, end): cal = pro.trade_cal(exchange='SSE', start_date=start, end_date=end, is_open='1') return sorted(cal['cal_date'].tolist()) def upsert(conn, df): if df is None or df.empty: return 0 cols = ['ts_code','trade_date','open','high','low','close','pre_close','vol','amount'] sql = "INSERT OR REPLACE INTO daily VALUES (?,?,?,?,?,?,?,?,?)" conn.executemany(sql, df[cols].itertuples(index=False, name=None)) return len(df) fetch_daily = retry(times=3, wait=1.5)(pro.daily) # 只包装一次,别在循环里创建 if __name__ == "__main__": conn = sqlite3.connect("market.db") conn.executescript(DDL) for d in trade_dates("20200101", "20241231"): df = fetch_daily(trade_date=d) n = upsert(conn, df) conn.commit() time.sleep(0.15) # 主动限速,给其他接口留配额 logging.info("%s 写入 %d 行", d, n) conn.close()逻辑说明:主键(ts_code, trade_date)是这套存储的核心,INSERT OR REPLACE让脚本可以反复跑同一区间而不产生重复行,补数据时直接重跑即可。idx_daily_date索引是为后面按日期取截面准备的,没有它,回测阶段每次切片都全表扫描,十年数据会慢到无法忍受。
参数说明:time.sleep(0.15)对应每分钟约 400 次的保守节奏,具体配额看账号积分档位,别照抄,先小批量试。trade_date模式单次返回有行数上限,全市场 5000 多只票一般够用;如果某天返回行数明显少于预期(比如只有 3000 行),说明触发分页,要改成按offset/limit翻页或者退化成ts_code循环补。
还有一个细节:trade_cal返回的cal_date是字符串,直接当字典键和 SQL 参数都没问题,但和 pandas 的Timestamp混用会静默变成NaT。统一用YYYYMMDD字符串,全链路别转 datetime,能省掉一半对齐问题。
2.4 复权与对齐:三种价格的用途别搞混
复权是回测里翻车率最高的环节。接口原始返回的是不复权价,除权除息当天价格会跳空,直接用会把一次 10 派 3 的分红记成 -25% 的暴跌。复权因子接口按日返回累积因子,用它能把价格还原成连续序列。
| 价格类型 | 计算方式 | 该用来做什么 | 别用来做什么 |
|---|---|---|---|
| 不复权 | 接口原始字段 | 判断涨跌停价、算成交额、算换手率 | 算收益率、画净值曲线 |
| 前复权 | 原价 × 因子 / 最新日因子 | 算因子、算收益、画图 | 判断涨跌停、和真实报价对比 |
| 后复权 | 原价 × 因子 / 首个日因子 | 长周期跨年对比、归因 | 和当前行情报价直接比 |
# adjust.py —— 复权因子对齐与前复权 def add_qfq(daily, adj): m = daily.merge(adj[['ts_code','trade_date','adj_factor']], on=['ts_code','trade_date'], how='left') m = m.sort_values(['ts_code','trade_date']) # 因子缺失(新股上市首日、因子接口延迟)用前值补,不要用 1.0 兜底 m['adj_factor'] = m.groupby('ts_code')['adj_factor'].ffill() base = m.groupby('ts_code')['adj_factor'].transform('last') # 基准日=样本最后一天 for c in ['open','high','low','close','pre_close']: m[c + '_qfq'] = m[c] * m['adj_factor'] / base return m逻辑说明:先 merge 再 ffill,是因为复权因子接口在某些日期会缺行;用1.0兜底会让那一段价格突然跳变,宁可沿用前值。基准日选「样本最后一天」意味着前复权序列会随着新数据下载而变化——今天跑出来的历史价格,明天再跑可能不一样。这一点必须清楚:任何用前复权价算出来的绝对值都不可跨批次比较,只有收益率序列可比。
参数说明:transform('last')依赖排序后的顺序,所以sort_values不能省。如果做的是多股票面板,groupby('ts_code')保证每只票用各自基准,不要用全局基准日,否则老股票的价格会被压得面目全非。
对齐环节还有一步:把长表转成(trade_date, ts_code)的宽表或面板。斜率最高的写法是pivot,但股票多的时候内存吃不消,我一般只 piviot 需要的列,或者干脆保持长表用 groupby 迭代。源码里两种都留了,注释写着「宽表用于画图,长表用于回测」,这个约定挺实用。
3. 因子层:从原始行情到可回测的信号矩阵
3.1 三个硬性约定:时间索引、前视偏差、缺失值
因子计算看着简单,一堆 rolling 就完事,但三个约定不立住,结果一定是假的。
第一条,时间索引必须唯一且有序。groupby('ts_code')之后每个子序列按trade_date升序排列,任何一次sort_values遗漏都会让 rolling 窗口吃到未来数据。稳妥做法是在数据层出口处强制sort_values(['ts_code','trade_date']),之后不再依赖调用方排序。
第二条,前视偏差。选股信号用 T 日收盘数据生成,就只能在 T+1 及以后成交。常见错误是在同一行里既算close.pct_change()又用它当收益,等于用当天收盘买入当天收盘卖出。修复方式有两种:信号统一shift(1),或者回测撮合时强制 T+1 成交。源码用后者,因为更接近真实交易流程。
第三条,缺失值。停牌期间接口不返回行,rolling 窗口会跨过停牌期把停牌前和复牌后的价格连起来算,结果失真。处理方式不是简单dropna——那会把停牌前后的数据都删掉,样本量掉得厉害。合理做法是先用交易日历 reindex 补齐日期,停牌日价格 ffill(限制最多 5 天),成交量置 0,然后标记is_suspended,选股时直接剔除。
提示:停牌超过 5 天还继续 ffill,等于假设股价冻结一个月,这期间的动量因子会趋近于零,反而变成「低波动优选」,是一种隐蔽的偏差。
3.2 均线、动量、波动率的批量实现
源码里的因子不多,就三组:双均线、20 日动量、20 日波动率。够用,也够讲清楚批量计算的写法。
# factors.py —— 三个基础因子,全部只用历史窗口 import numpy as np, pandas as pd def add_factors(df, fast=5, slow=20, mom=20, vol=20): df = df.sort_values(['ts_code', 'trade_date']).copy() g = df.groupby('ts_code', group_keys=False) # 1) 双均线:min_periods 必须等于窗口,否则前 19 天会用半截窗口算出假信号 df['ma_fast'] = g['close_qfq'].transform(lambda s: s.rolling(fast, min_periods=fast).mean()) df['ma_slow'] = g['close_qfq'].transform(lambda s: s.rolling(slow, min_periods=slow).mean()) # 2) 动量:过去 mom 日收益率,shift 保证不含当日 df['mom'] = g['close_qfq'].transform(lambda s: s / s.shift(mom) - 1) # 3) 波动率:日收益标准差年化 df['ret'] = g['close_qfq'].pct_change() df['vol'] = g['ret'].transform(lambda s: s.rolling(vol, min_periods=vol).std() * np.sqrt(252)) # 4) 信号:快线上穿慢线且动量为正,且当日有成交 df['signal'] = ((df['ma_fast'] > df['ma_slow']) & (df['mom'] > 0) & (df['vol'] > 0) & (~df['is_suspended'].fillna(False))).astype(int) return df逻辑说明:三个因子共用同一个groupby对象,避免重复分组开销;transform而不是apply,是因为transform返回对齐索引的 Series,可以直接赋值成新列,apply在股票多的时候慢一个量级。信号用布尔组合而不是嵌套 if,可读性和可调试性都好。
参数说明:fast=5, slow=20是经典组合,改动时注意fast < slow否则信号恒为负。min_periods必须等于窗口长度,这是前视偏差的第一道闸门——默认min_periods=1会让序列开头出现用 3 天数据算出的 20 日均线。mom=20对应一个月,vol=20同理。np.sqrt(252)把日波动年化,年化系数用 252 还是 250 影响不大,但全篇要统一,别一半 252 一半 250。
一个容易被忽略的点:动量因子在除权日附近会失真,这也是为什么必须用前复权价算。用不复权价算动量,一次 10 送 10 会让 20 日动量瞬间变成 -50%,选股直接把好票踢出池子。
3.3 从信号到目标权重:等权、打分还是分层
信号是 0/1,持仓还要变成权重。源码给了三种模式,实际用哪种取决于你能承受多少换手。
等权最简单:每天把信号为 1 的票按动量降序取前 N 只,每只 1/N。缺点是换手率高,一天换几只很常见,手续费会把收益吃干净。
打分加权:按动量排序给线性权重,头部权重高、尾部低。换手略降,但会让组合向少数强势股集中,回撤变大。
分层:按动量分五组,只买第一组,组内等权。这是学术派做法,好处是能直接看出因子单调性,坏处是组内股票数不固定,牛熊切换时仓位忽大忽小。
源码默认等权 + 每日最多换 2 只(用持仓和目标的交集优先保留),这个小约束把年换手从 30 倍压到 8 倍左右,是很务实的工程取舍。实现上就是在生成 target 列表时,先把已持仓的票排前面,再补新票。
4. 回测层:不引第三方框架的手写撮合
4.1 为什么用向量化选股 + 逐日撮合
回测框架分两派:事件驱动(backtrader 那类)和向量化(一次性算出所有持仓再乘收益)。源码走的是中间路线——选股向量化,撮合逐日循环。
纯向量化的写法是weights.shift(1) * returns,三行搞定,快得飞起。但它处理不了涨跌停买不进、停牌卖不掉、资金不足只能买整手这些约束,回测出来的收益偏乐观,实盘一上就原形毕露。
纯事件驱动又太重,为了几十只票的日频策略引入一整套引擎,调试成本比策略本身还高。
逐日循环是折中:外层按交易日迭代,内层用 pandas 做截面运算,一天一次循环,十年数据两千多次迭代,跑完几十秒到几分钟,能接受,且每个约束都能显式写进代码。这是我见过的、在没有真实交易接口的情况下最靠谱的做法。
4.2 T+1、涨跌停、手续费在代码里怎么落地
# backtest.py —— T 日收盘选股,T+1 开盘成交 import pandas as pd def run_backtest(panel, n_hold=10, fee=2.5e-4, tax=1e-3, slip=1.5e-3, init=1e6): panel = panel.sort_values(['trade_date', 'ts_code']) dates = sorted(panel['trade_date'].unique()) cash, pos, nav = init, {}, [] for i in range(len(dates) - 1): t, t1 = dates[i], dates[i + 1] cur = panel[panel['trade_date'] == t].set_index('ts_code') nxt = panel[panel['trade_date'] == t1].set_index('ts_code') # 目标池:T 日信号为 1,次日不停牌、不一字涨停 cand = cur[cur['signal'] == 1].sort_values('mom', ascending=False) target = [c for c in cand.index if c in nxt.index and not bool(nxt.at[c, 'limit_up'])][:n_hold] kept = [c for c in target if c in pos] # 已持仓优先保留,压换手 target = kept + [c for c in target if c not in pos] # 卖出:不在目标池,且次日不跌停、不停牌 for code in list(pos): if code in target or code not in nxt.index or bool(nxt.at[code, 'limit_down']): continue px = nxt.at[code, 'open_qfq'] * (1 - slip) cash += pos.pop(code) * px * (1 - fee - tax) # 买入:等权,整手取整,资金不足就跳过 equity = cash + sum(s * nxt.at[c, 'open_qfq'] for c, s in pos.items() if c in nxt.index) slot = equity / n_hold for code in target: if code in pos: continue px = nxt.at[code, 'open_qfq'] * (1 + slip) shares = int(slot / px // 100 * 100) # A 股 100 股一手 if shares <= 0: continue cost = shares * px * (1 + fee) if cost > cash: continue cash -= cost pos[code] = pos.get(code, 0) + shares # 收盘估值 mv = sum(s * nxt.at[c, 'close_qfq'] for c, s in pos.items() if c in nxt.index) nav.append((t1, cash + mv)) return pd.DataFrame(nav, columns=['trade_date', 'nav']).set_index('trade_date')逻辑说明:整个循环的时序是「T 日收盘算信号 → T+1 开盘成交 → T+1 收盘估值」,这一条链保证了没有任何一处在用未来数据。目标池先保留已持仓再补新票,是降低换手的关键一行。卖出循环放在买入之前,保证资金先回笼再分配,否则会出现「有钱不让买」的假约束。
参数说明:fee=2.5e-4是佣金万 2.5 单边,tax=1e-3是卖出印花税千分之一,只在大 sell 侧扣,slip=1.5e-3是滑点,买入向上、卖出向下。这三个数别照抄,券佣和你的资金量、交易通道都有关,滑点更是要看标的流动性——小盘股 0.15% 都算乐观。
limit_up和limit_down两个布尔列要在因子层算好:涨停价 =round(pre_close * (1 + ratio), 2),主板 ratio 取 0.1,ST 取 0.05,创业板和科创板注册制后取 0.2。判断「一字板」还要加一条high == low,因为盘中开板是可以成交的,只用收盘价判断会误杀。这条我吃过亏,早期版本把所有涨停都当成买不进,回测收益被系统性低估。
4.3 绩效指标:年化、最大回撤、夏普的三个口径问题
| 指标 | 常见写法 | 容易出问题的地方 |
|---|---|---|
| 累计收益 | nav[-1]/nav[0]-1 | 起始日必须是满仓第一天,不能含准备期空仓 |
| 年化收益 | (1+total)**(252/n)-1 | n 用交易日数还是自然日数,跨年样本会差出几个点 |
| 最大回撤 | (nav/nav.cummax()-1).min() | 必须用复权净值,用价格序列会漏掉分红带来的回撤 |
| 夏普 | mean(r-rf/252)/std(r)*sqrt(252) | rf 没换算成日度、std 用了总体标准差 |
| 卡玛 | 年化 / 最大回撤绝对值 | 回撤接近 0 时会爆表,得加下限保护 |
def perf(nav, rf=0.02, periods=252): r = nav['nav'].pct_change().dropna() total = nav['nav'].iloc[-1] / nav['nav'].iloc[0] - 1 years = len(r) / periods ann = (1 + total) ** (1 / years) - 1 if years > 0 else float('nan') mdd = (nav['nav'] / nav['nav'].cummax() - 1).min() sharpe = (r.mean() - rf / periods) / r.std(ddof=1) * (periods ** 0.5) return {'total': round(total, 4), 'ann': round(ann, 4), 'mdd': round(mdd, 4), 'sharpe': round(sharpe, 2), 'calmar': round(ann / max(abs(mdd), 1e-6), 2)}逻辑说明:rf/periods是把年化无风险利率线性摊到日,严格说应该用(1+rf)**(1/252)-1,但两者差在万分位,对结论没影响,写成线性更直观。std(ddof=1)用样本标准差,pandas 默认就是这个,显式写出来是防止有人改成 numpy 的std()(默认 ddof=0)导致夏普虚高。
参数说明:periods=252是 A 股年均交易日,改成 250 会让年化略高。years用交易日数除以 252,样本不足一年时年化会被放大,这时候年化指标没有参考价值,直接看累计收益和回撤更实在。
一个反直觉的结论:同一套策略,用不复权净值算出来的最大回撤往往比复权净值小,因为分红除权那天价格下跌被当成了「没发生」。所以看到某个回测回撤特别漂亮,先查它用的是哪种价格。
5. 避坑与排查:五个跑不通、跑不对的现场
5.1 现象:报「每分钟最多访问该接口 N 次」
原因有三层:一是循环里没限速,几百个交易日一口气打出去;二是重试用了固定间隔,撞上限流后越试越堵;三是retry装饰器在循环里被重复创建,等价于每次都重置退避计数。
解决:主循环里显式sleep,按账号档位算配额,保守值 0.15 秒一次;重试改指数退避;装饰器在循环外创建一次。补数据时先跑一个月试试,稳定了再放开区间。另外别在同一个进程里同时跑日线和复权因子两个任务,配额是共享的。
5.2 现象:回测年化 300%,净值一条近乎直线的上升曲线
原因基本锁定在未来函数。最常见的三种:因子算完没shift,用当日收盘价算的信号当日就成交;pivot之后fillna(method='bfill')把未来价格填到过去;选股用了daily_basic的当日快照数据,但那个数据实际是盘后发布的。
解决:把回测循环里所有loc的日期打印出来,人工核对第一条成交发生在信号日的第二天;全局搜一遍bfill和interpolate;把daily_basic类接口的字段统一shift(1)再用。改完如果年化掉到 15% 以下,别沮丧,那才是正常水平。
5.3 现象:换个股票池,收益从 40% 掉到 -5%
原因通常是幸存者偏差。用当前上市的股票列表回溯五年,等于提前知道哪些票没退市,天然筛掉了失败者。另一个诱因是股票池里混进了 ST 和次新股——次新股上市初期波动巨大,双均线策略在上面表现极好,但那部分收益不可复制。
解决:股票池用stock_basic的在市列表加上退市列表合并,按list_date和delist_date做时间切片,让每只票只在它存续期内出现。同时剔除名称含 ST 的、上市不满 60 个交易日的。这三条加进去,收益一般会掉 5 到 10 个点,但曲线形状会真实很多。
5.4 现象:复权之后价格出现负值或者巨大断层
原因:复权因子 merge 之后有缺失,fillna(1.0)让那一段的因子突然从 10 掉到 1,价格直接除以 10。另一种是基准日选错,用全市场统一基准去复权单只票。
解决:因子缺失一律ffill不用常数兜底;基准日按groupby('ts_code')各算各的;复权完加一条断言,检查每只票的close_qfq是否全部为正、日收益绝对值是否超过 30%(除权日之外超过就是有问题)。这条断言我放在数据层出口,跑一次几秒钟,能挡掉八成脏数据。
5.5 现象:定时任务显示执行成功,但数据停在三天前
原因:retry返回None被上层continue跳过,日志只打了 warning,任务退出码还是 0;或者交易日历接口本身失败,循环体一次都没进,任务「成功」地什么都没做。
解决:任务末尾加数据新鲜度检查,拿最近一个交易日和库里最大日期对比,不一致就非零退出;日志里统计跳过天数,超过 3 天告警。这条检查比任何监控都管用,因为它检查的是结果而不是过程。
6. 从能跑到能用:把回测脚本改成每日流水线
6.1 收盘后的三段式任务与数据新鲜度检查
回测脚本和每日任务的差别不在策略,在幂等和可观测。我一般拆成三段:补数据、算信号、出结果,每段独立可重跑,中间产物落盘。
# daily_job.py —— 收盘后执行,任何一段失败都不影响第二天重跑 import sqlite3, datetime as dt from loader import trade_dates, upsert, fetch_daily, DDL from factors import add_factors from backtest import run_backtest def latest_trade_date(): today = dt.date.today().strftime("%Y%m%d") ds = trade_dates((dt.date.today() - dt.timedelta(days=15)).strftime("%Y%m%d"), today) return ds[-1] if ds else None def check_freshness(conn, expect): got = conn.execute("SELECT MAX(trade_date) FROM daily").fetchone()[0] if got != expect: raise SystemExit(f"数据未更新到 {expect},当前最大 {got}") if __name__ == "__main__": expect = latest_trade_date() conn = sqlite3.connect("market.db") conn.executescript(DDL) for d in trade_dates("20240101", expect)[-30:]: # 只补最近 30 天,够覆盖长假 upsert(conn, fetch_daily(trade_date=d)) conn.commit() check_freshness(conn, expect) # 硬断言,不通过就非零退出 panel = add_factors(read_panel(conn, "20200101", expect)) nav = run_backtest(panel) nav.to_csv(f"output/nav_{expect}.csv")逻辑说明:只补最近 30 个交易日而不是全量重跑,是因为全量重跑既慢又浪费配额,30 天足够覆盖春节和国庆的长假。check_freshness用SystemExit而不是return,是为了让退出码非零,cron 才能捕获。
参数说明:[-30:]这个窗口可以按你的假期长度调,20 到 40 都合理。输出文件名带日期,方便回溯每一天的信号,别用固定文件名覆盖——出了问题想复盘,历史文件就是后悔药。
# crontab:工作日 16:30 触发,日志按天切 30 16 * * 1-5 cd /opt/quant && /usr/bin/python3 daily_job.py >> logs/job_$(date +\%Y\%m\%d).log 2>&1这里有个细节:cron 里%要转义成\%,否则会被当成换行。我第一次写的时候日志文件名变成了空,翻车半小时才反应过来。
6.2 用一段样本外数据做验收,别用回测区间自证
脚本能跑通不等于策略可信。我习惯留最后 6 个月完全不动,等前面所有参数调完、因子定稿,再一次性跑这半年。验收不看收益高低,看四件事:
| 验收项 | 阈值参考 | 不通过说明什么 |
|---|---|---|
| 交易笔数 | 半年不少于 30 笔 | 太少,统计上没意义,可能参数过拟合到几个极端样本 |
| 最大单日亏损 | 不超过回撤区间的 1/3 | 撮合或涨跌停逻辑有漏洞,出现了不可能的单日成交 |
| 换手率 | 年化 5 到 15 倍 | 超过 20 倍,手续费模型一定低估了 |
| 净值与基准相关性 | 与宽基指数相关性低于 0.8 | 太高说明策略只是加杠杆的 beta,没有超额来源 |
这四项里,换手率最容易被忽略。很多人调完参数看到年化 40%,实际年换手 60 倍,按双边万 3 加千一印花税算,光摩擦成本就吃掉 5 个点以上,实盘直接由盈转亏。
我还会做一件事:把成交明细导出,随机抽 10 笔,人工核对当时的价格、涨跌停状态、是否停牌。抽到过一笔「在跌停板上卖出」的记录,排查发现是limit_down列用收盘价反推的,而当天是盘中跌停、尾盘打开,收盘价并不在跌停位。改成用low == limit_down_price判断后,这笔交易消失了。
从那以后我每次拿一份新的量化源码包,都会先跑一遍「最小可信集」:只取 30 只票、一年数据、不带任何参数优化,看净值和成交明细能不能对上常识。这一遍走完,后面才敢加因子、调窗口。贪快直接上全市场,最后返工的时间往往是这一遍的十倍。希望帮到你。
本文还有配套的精品资源,点击获取