简介:这份2022年美赛C题F奖项目资料面向备战美赛、关注金融时间序列建模的学习者,围绕比特币交易策略给出论文、代码与数据的一体化参考。其核心内容包括小波降噪预处理、GRU神经网络价格预测,以及结合动态规划求解买卖时机的策略设计,适合用来理解从数据清洗、模型预测到优化决策的完整链路。压缩包约6.81MB,文件总数及主要类型明细上游暂未提供,可结合标题中的论文、代码、数据线索按模块核对。相关页面显示已有357人学习,具备一定参考热度。对于希望复现F奖方案、学习预测与优化组合思路的建模者,可重点研读模型假设、参数设置与策略评价部分,并借助代码和数据验证小波降噪、GRU预测及动态规划在交易决策中的实际效果。
1. 从十亿美元的日更决策说起:2022 美赛 C 题这份 F 奖资源到底交付了什么
手里攥着十亿美元,从 2016 年 9 月到 2021 年 9 月,每天开盘前必须决定这笔钱里多少压黄金、多少压比特币——这是 2022 美赛 C 题给出的决策场景,也是这份 F 奖资源要回答的全部问题。它不是一份公式堆完就交差的建模稿,而是一整套能跑起来的东西:两份原始价格序列、一套 Python 回测代码、若干组参数实验记录,以及最终提交的论文。适合三类人:建模队想复现获奖论文的完整链路,量化初学者想找一个不依赖重型框架的最小回测骨架,课程设计需要带数据带代码的完整案例。拿到手建议先翻论文看结论,再回到代码里找对应数字,比从头啃源码快得多。
2. 项目结构拆解:数据、源码、实验、论文四层怎么分工
这份资源最容易劝退人的地方,是它一解压出来一堆文件夹,看着像随手扔的。实际上分层是有讲究的——原始数据只读、清洗结果可重建、实验参数外置、论文图表由脚本生成,四层各管一段,任何一层出问题都能单独回滚。看懂这个分层,后面改代码才不会牵一发动全身。
2.1 目录树与各层职责
先看整体骨架,下面这棵树是我整理后的形态,和资源里的组织方式基本一致:
project/ ├── data/ │ ├── raw/ # 原始 CSV,只读,任何时候都不改 │ │ ├── gold_prices.csv # 黄金每日价格(含周末与节假日缺行) │ │ └── btc_prices.csv # 比特币每日价格(7×24 连续) │ └── processed/ # 清洗对齐后的宽表,由脚本生成 ├── src/ │ ├── data_loader.py # 读取 + 日期对齐 + 缺失值处理 │ ├── signals.py # 信号层:动量、波动率、仓位映射 │ ├── backtest.py # 回测层:逐日再平衡 + 交易成本 │ ├── metrics.py # 指标层:年化、夏普、最大回撤 │ └── run.py # 主入口,串起以上四步 ├── experiments/ # 参数扫描结果,一次实验一个 csv ├── figures/ # 论文插图,全部由脚本产出 ├── paper/ # 论文源文件与最终 PDF └── requirements.txtdata/raw是唯一不允许写操作的地方,这一点看着像洁癖,其实是保命的。很多人跑崩了第一反应是回到原始 CSV 上手工删几行,删完当时能跑,两周后论文里数字对不上,就再也找不回现场了——这种后悔药没处买。data/processed允许随时删,删完重跑run.py就能重建。experiments存的是每次参数组合的指标快照,论文里的敏感性分析表就是从这里汇总出来的。
2.2 技术选型:为什么没上重型量化框架
整套代码只依赖 pandas、numpy、scipy 和 matplotlib,没有 backtrader、没有 zipline、没有数据库。这不是偷懒,是这类比赛项目的合理选择。原因有三条:
第一,阅卷人看的是模型逻辑,不是工程完备度。一个三十行的每日再平衡循环,评委扫一眼就知道你在干什么;换成某个框架的事件驱动引擎,回调函数套三层,反而要把读论文的耐心耗光。
第二,回测频率是日频,资产只有两个,计算量小到用不着任何加速。五年大约 1800 个交易日,两列价格,全部塞进内存也就几十 KB,用数据库属于给自行车装涡轮。
第三,也是血泪经验——越少的外部依赖,越容易在别人的机器上跑起来。你用了一个三年前的小众库,评委或者队友 clone 下来装不上,这份代码的实际价值就折半了。requirements 里那几个包,随便一台装了 Python 的机器都能起。
提示:如果确实想接机器学习模型(比如用树模型预测次日方向),把 sklearn 加进去没问题,但要保证「不装 sklearn 也能跑通基线策略」,让基线结果和增强结果分开复现。
2.3 环境准备与三条命令跑出净值曲线
Python 版本建议 3.8 以上,pandas 和 numpy 用 1.x 稳定版即可,不必追新。装依赖、跑主流程、出图,三步:
# 1. 建虚拟环境并装依赖 python -m venv .venv && source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # 2. 跑主流程:清洗 -> 信号 -> 回测 -> 指标,结果写进 experiments/ python src/run.py --start 2016-09-11 --end 2021-09-10 \ --cost-gold 0.01 --cost-btc 0.02 \ --init-capital 1e9 --out experiments/base.csv # 3. 生成论文用的净值曲线和回撤图 python src/run.py --plot --out figures/nav_curve.png--start/--end是回测区间,必须和论文口径一致;--cost-gold/--cost-btc是单边交易费率,题目给定的是黄金 1%、比特币 2% 这个量级,改这两个数会直接改变策略偏好——成本一高,高频调仓的策略立刻现原形。--init-capital只影响净值绝对值,不影响收益率和夏普,但对最大回撤的百分比没有影响,所以调它主要为了和论文里的金额对齐。
run.py内部是顺序调用四个模块,没有并行、没有缓存,好处是出错时 traceback 直指具体行号。跑完在experiments/base.csv里应该能看到一行指标记录,包括总收益率、年化收益、夏普比率、最大回撤、换手率。这五个数就是后面核对论文的锚点。
3. 数据清洗与日期对齐:黄金休市、比特币不休市的合并逻辑
真正让复现翻车的地方,八成不在策略,在数据。两份原始序列的时间语义根本不一样:黄金跟着交易所的交易日走,周末和节假日整行缺失;比特币 7×24 连续交易,一天不落。直接把两份表按行号拼起来,或者简单concat,得到的净值曲线会漂亮得不像话——因为你在拿未来信息给过去定价。这一章把对齐逻辑掰开讲。
3.1 两份序列的字段与量纲差异
| 文件 | 关键字段 | 频率 | 单位 | 典型缺失 |
|---|---|---|---|---|
| gold_prices.csv | Date, USD (PM) | 交易日 | 美元/盎司 | 周末、节假日、个别停市日 |
| btc_prices.csv | Date, Value | 自然日 | 美元/枚 | 极少,偶有交易所数据断档 |
字段名不统一是第一个小坑:一个叫USD (PM),一个叫Value,直接按列位置取会在换数据源时静默出错。我一般的做法是在 loader 里显式重命名成gold/btc,后面所有模块只认这两个名字。单位不同不影响,因为我们只用到价格的比例变化,不做实物换算。
另一个容易忽略的点是日期格式。原始文件里的日期是9/11/2016这种美式月/日/年格式,pd.to_datetime默认解析在个别日期上会歧义(比如 11/12/2016 到底是 11 月 12 日还是 12 月 11 日),必须显式给format="%m/%d/%Y",别偷懒。
3.2 对齐策略:向前填充是唯一不引入未来函数的选择
对齐的核心原则只有一句:任一时点能拿到的价格,只能是该时点及之前已经发生的价格。黄金周六周日没有报价,那这两天的黄金持仓就按周五收盘价估值;比特币每天都有,用它自己的当日价。这就决定了填充方向只能是ffill(向前填充),绝不能bfill(向后填充)或者线性插值——后两者都会把未来的价格泄露到过去。
具体做法是先把两张表按日期做外连接,再重采样到自然日频率,最后对两列分别前向填充。这里有个细节:起始日之前不能填充,所以第一天如果某列是 NaN,要么往前多取几天数据做预热,要么在这天把该资产权重强制设为 0,两种处理都可以,但必须在论文里写清楚,否则复现的人对不上。
还有一个隐蔽的坑——黄金的节假日缺失如果发生在回测区间中间,前向填充会让黄金价格在假期里"冻结"若干天,导致这几天的黄金日收益率为 0,进而压低波动率估计。波动率一旦被低估,按波动率倒数配仓的策略就会给黄金超配。这个偏差在五年尺度上不算致命,但在做敏感性分析时必须知道它存在。
3.3 清洗代码逐段拆
import pandas as pd import numpy as np def load_prices(gold_path: str, btc_path: str) -> pd.DataFrame: """读取两份原始价格,输出按自然日对齐的宽表。 返回列:gold, btc(均为当日或最近可得收盘价), 以及 gold_is_filled 标记,便于后续统计假期占比。 """ gold = pd.read_csv(gold_path, usecols=["Date", "USD (PM)"]) gold.columns = ["date", "gold"] btc = pd.read_csv(btc_path, usecols=["Date", "Value"]) btc.columns = ["date", "btc"] # 显式指定美式日期格式,避免 11/12 这类歧义 for df in (gold, btc): df["date"] = pd.to_datetime(df["date"], format="%m/%d/%Y") df.drop_duplicates(subset="date", keep="last", inplace=True) # 外连接保留任一方有数据的日期,再重采样到自然日 df = pd.merge(gold, btc, on="date", how="outer").sort_values("date") df = df.set_index("date").asfreq("D") # 标记哪些天是填充出来的,方便事后统计 df["gold_is_filled"] = df["gold"].isna() # 只允许向前填充:任一时点只能用已发生的信息 df["gold"] = df["gold"].ffill() df["btc"] = df["btc"].ffill() # 开头若仍有空值(预热期不足),直接丢弃 df = df.dropna(subset=["gold", "btc"]) return df这段代码有三个关键决策。drop_duplicates(keep="last")是因为原始数据里偶尔出现同一天两条记录(数据供应商修订),保留最后一条等于用修订后价格,属于合理做法。asfreq("D")把索引强制拉成自然日,这一步会把原本缺失的周末行显式补成 NaN,而不是让日期跳跃——日期跳跃是回测里最阴的 bug 之一,pct_change()会跨过整个周末算一次收益,波动率直接被放大。gold_is_filled这个标记列看着多余,但在排查"为什么我的波动率和论文差那么多"时,统计一下填充比例就能立刻定位。
注意:如果你的数据源把节假日那天的黄金价格写成 0 而不是缺失,
ffill救不了你,必须先做一次「价格 <= 0 视为缺失」的清洗,否则收益率会出现 -100% 的假信号。
4. 策略与回测核心:从信号生成到含交易成本的净值曲线
数据对齐之后,整个项目真正的建模部分其实只有三个函数:一个把价格变成目标权重,一个把目标权重变成净值,一个把净值变成指标。三段加起来不到一百行,但每一步都有能让结果差出十几个百分点的细节。
4.1 信号层:动量打分与仓位映射
这份资源里的主策略走的是「趋势跟随 + 波动率目标」的路线,逻辑朴素但站得住:过去 N 天涨得好的资产给更高权重,同时用波动率倒数做缩放,避免高波动资产吃掉全部风险预算。之所以选这条路,是因为在只有两个资产、五年日频数据的场景下,复杂模型(LSTM、因子挖掘)的过拟合风险远大于收益增量,论文里也不好解释。
def target_weights(prices: pd.DataFrame, lookback: int = 60, vol_window: int = 30, max_weight: float = 0.9) -> pd.DataFrame: """生成每日目标权重,权重在 [0, max_weight] 之间,两资产之和为 1。 lookback: 动量回看窗口,衡量趋势方向 vol_window: 波动率估计窗口,用于风险缩放 max_weight: 单资产权重上限,防止全仓单押 """ ret = prices[["gold", "btc"]].pct_change() # 动量分数:过去 lookback 天的累计对数收益 momentum = np.log(prices[["gold", "btc"]]).diff(lookback) # 风险缩放:波动率越低,给的原始分越高 vol = ret.rolling(vol_window).std() raw = momentum / vol.replace(0, np.nan) # 只做多:负分直接截断为 0 score = raw.clip(lower=0) total = score.sum(axis=1) # 两只都不涨时退到空仓(全放现金),避免强行分配 w = score.div(total.replace(0, np.nan), axis=0) w = w.fillna(0.0) # 单资产封顶后重新归一,保证权重和为 1 w = w.clip(upper=max_weight) w = w.div(w.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0) return wmomentum用对数收益差而不是简单收益差,是为了让不同价位的资产可比。vol.replace(0, np.nan)处理波动率为 0 的极端情况(数据冻结时会出现),不处理的话除零会产出 inf,一路污染到净值。clip(lower=0)意味着策略不做空——题目给的场景是长期配置,做空在成本和解释上都麻烦。最后那次重新归一容易被漏掉:clip(upper=0.9)之后两只资产权重和可能小于 1,剩下的部分隐式变成现金,如果不归一,净值计算会凭空多出一块未投资资金,收益率被稀释。
回看窗口lookback=60是个经验值,大约三个月。改成 20 会更灵敏但换手率高,交易成本吃掉收益;改成 120 更稳但反应迟钝,2020 年那波急跌里会扛很久。论文里应该有这组参数的敏感性对比,没有的话自己补一组,这是二次开发最容易出彩的地方。
4.2 回测层:把交易成本扣进每一天
回测最容易糊弄也最容易被抓包的地方就是成本。很多复现结果比论文好看,一查都是没扣成本,或者只在换仓当天扣一次固定值。真实做法是按成交金额比例扣,且每天的权重漂移本身就会产生隐性换手。
def backtest(prices: pd.DataFrame, weights: pd.DataFrame, cost_gold: float = 0.01, cost_btc: float = 0.02, init_capital: float = 1e9) -> pd.DataFrame: """逐日再平衡回测,返回含净值与换手率的明细表。""" assets = ["gold", "btc"] cost = {"gold": cost_gold, "btc": cost_btc} ret = prices[assets].pct_change().fillna(0.0) nav = init_capital prev_w = pd.Series({"gold": 0.0, "btc": 0.0}) records = [] for dt in prices.index: # 1) 先按昨日权重吃今天的涨跌 port_ret = float((prev_w * ret.loc[dt]).sum()) nav *= (1 + port_ret) # 2) 涨跌之后权重自然漂移,算出漂移后的实际权重 drifted = prev_w * (1 + ret.loc[dt]) drifted = drifted / drifted.sum() if drifted.sum() > 0 else drifted # 3) 与今日目标权重比较,差额即换手 target = weights.loc[dt] turnover = (target - drifted).abs() fee = sum(turnover[a] * cost[a] for a in assets) nav *= (1 - fee) records.append({"date": dt, "nav": nav, "turnover": float(turnover.sum()), "fee": fee}) prev_w = target return pd.DataFrame(records).set_index("date")这段循环的顺序不能乱。先吃涨跌、再算漂移权重、再和目�标比、最后扣费——如果先扣费再吃涨跌,等于假设你在开盘前就完成了调仓并且当天不承担成本,回测结果会系统性偏乐观。drifted那一步是很多人省掉的:他们直接拿prev_w和target相减算换手,忽略了价格变动本身已经改变了仓位比例,导致换手率被低估,成本也就扣少了。
fee用加权求和而不是简单平均,因为黄金和比特币的费率不同(1% 对 2%),把比特币的换手按黄金费率算,一年下来能差出好几个点。这个细节在论文的方法部分值得单独写一句,是区分「认真做过」和「抄了个模板」的地方。
4.3 指标层:年化、夏普、最大回撤一次算清
def metrics(nav: pd.Series, freq: int = 365) -> dict: """从净值序列算核心指标。日频数据用 365 而非 252, 因为组合里含 7×24 交易的比特币。""" ret = nav.pct_change().dropna() total_return = nav.iloc[-1] / nav.iloc[0] - 1 years = len(ret) / freq annual = (1 + total_return) ** (1 / years) - 1 sharpe = ret.mean() / ret.std() * np.sqrt(freq) if ret.std() > 0 else 0.0 dd = nav / nav.cummax() - 1 return { "total_return": total_return, "annual_return": annual, "sharpe": sharpe, "max_drawdown": dd.min(), "calmar": annual / abs(dd.min()) if dd.min() < 0 else np.nan, }年化因子的选择是个小玄学。传统股票回测用 252,但这里比特币全年无休,组合实际是 365 天都在承担风险,用 252 会把夏普比率算高约 20%。这份资源统一用 365,和净值序列的自然日索引保持一致。最大回撤用nav / nav.cummax() - 1取最小值,注意cummax是从头开始的累计最大值,不是滚动窗口,这样得到的才是真正的峰谷回撤。
4.4 结果核对:怎么确认自己跑出来的数和论文对得上
复现不是看一眼曲线形状像就完事,要落到数字上。我的做法是先对四个锚点:总收益率、最大回撤、夏普比率、总换手率。前三个容忍 1%~2% 的偏差(数据源微调、节假日处理差异都会带来这点误差),总换手率如果差出 10% 以上,基本可以确定是 4.2 节里漂移权重那步没做对。
核对顺序建议是:先只跑买入持有(权重恒定 50/50),确认净值和手算一致;再关掉交易成本跑一遍,确认净值单调不降;最后打开成本跑完整策略。三步都过了,再去和论文比。跳过前两步直接比最终结果,一旦对不上你根本不知道该查数据还是查策略。
5. 避坑与排查:跑不出论文数字时先查这五处
复现类项目最气人的不是报错,是代码一声不吭跑完了,结果和论文差一大截。下面五条是我自己踩过、也在别人代码里见过的翻车记录,按出现频率排序。
5.1 五条高频翻车记录
第一条:净值曲线异常平滑,回撤小得不像话。现象是最大回撤只有个位数,夏普高到 2 以上。原因八成是用了bfill或者interpolate处理缺失值,未来价格泄露到了过去,等于开了上帝视角。解决办法是把data_loader.py里所有填充方式改成ffill,然后 grep 一遍全项目有没有bfill、interpolate、shift(-这三个关键词,有就逐个确认是否合理。
第二条:策略在震荡市里持续跑输买入持有。现象是净值曲线在 2018 年下半年和 2019 年上半年明显落后。原因通常是动量窗口太短(20 天),在无趋势市场里反复被打脸,换手率高但每次都亏。解决办法是把lookback加到 60 或 90,同时把换手率指标打出来看——如果年换手率超过 30 倍,说明信号在噪声里来回横跳,加个换手惩罚项或者给信号做几天平滑都能缓解。这类参数调整一定要在论文里留下表格,别只写结论。
第三条:同样的代码,队友机器上结果不同。现象是两人跑同一份代码,总收益差 3 个点。原因多半是 pandas 版本不同导致pct_change对 NaN 的处理有差异,或者某一方没清data/processed里的旧缓存。解决办法是 requirements 里把 pandas 固定到大版本,run.py开头加一句「processed 目录存在则先清空」,并且在输出里打印数据行数和起止日期,让每次运行自带指纹。
第四条:最大回撤算出来是 0 或者 NaN。现象是指标表里 drawdown 一列异常。原因是净值序列里有 NaN 或者 0,cummax遇到 NaN 会一路传播。解决办法是在metrics函数入口加一句nav = nav.dropna(),并且断言(nav > 0).all()。这类断言写起来一行,省下的是半小时的print调试。
第五条:论文图表和代码输出对不上。现象是论文里的净值曲线和figures/下新生成的图长得不一样。原因通常是论文插图是某次实验后手工截图存的,之后代码改了但图没重跑。解决办法是所有图强制由脚本生成、文件名带参数哈希,比如nav_lookback60_vol30.png,论文里引用文件名而不是直接贴图。这条听着啰嗦,但它是唯一能让「论文 = 代码」长期成立的办法。
5.2 一份自查清单与日志埋点
与其每次出问题从头查,不如在run.py里埋几个固定输出,跑完自动打印。下面这段是我现在每个回测项目都会加的:
def sanity_check(prices, weights, nav_df): """跑完回测先过一遍自检,任何一条不满足都直接抛异常。""" assert prices.index.is_monotonic_increasing, "日期索引未排序" assert not prices[["gold", "btc"]].isna().any().any(), "价格存在缺失" assert (prices[["gold", "btc"]] > 0).all().all(), "存在非正价格" assert abs(weights.sum(axis=1) - 1).max() < 1e-6 or \ (weights.sum(axis=1) == 0).any(), "权重和不为 1" assert nav_df["nav"].notna().all(), "净值出现 NaN" # 打印指纹:行数、起止日期、总换手、总费用占比 print(f"样本 {len(prices)} 天,{prices.index[0].date()} ~ {prices.index[-1].date()}") print(f"累计换手 {nav_df['turnover'].sum():.2f}," f"费用侵蚀 {nav_df['fee'].sum():.2%}")sanity_check放在回测之后、指标计算之前。前四条断言覆盖了数据层的绝大多数问题,最后打印的两个数字是策略层的体温计:累计换手超过 50 说明调仓过于频繁,费用侵蚀超过 15% 说明成本假设可能过重或者策略失效。这两个数不用和谁比,横向对比自己不同参数下的实验就能看出异常。
再补一条经验:把experiments/下的每次结果都保留,文件名带时间戳和参数。做敏感性分析时直接pd.concat读进来画热力图,比事后回忆「那次改的是哪个参数」省太多事。这种习惯性的后悔药,属于做一次管一辈子。
6. 二次开发:换因子、换参数、换论文图表的一套改法
这份资源真正值钱的地方不是那份论文,是它把「想法 → 代码 → 数字 → 图表」这条链路打通了,你要换个思路验证,只需要动其中一段。接口约定是这样的:signals.py里的函数签名统一为f(prices, **params) -> DataFrame,输出列必须是gold和btc两列、索引和prices一致、每行和为 1 或全 0。只要守住这个约定,回测层和指标层一行都不用改。
换因子最省事的路径是把新信号作为一列塞进prices,然后写一个 wrapper:
def dual_momentum_with_trend_filter(prices, lookback=60, ma_window=200, **kw): """在原始动量权重上叠加长期均线过滤:跌破均线则清仓该资产。""" base = target_weights(prices, lookback=lookback, **kw) ma = prices[["gold", "btc"]].rolling(ma_window).mean() alive = (prices[["gold", "btc"]] > ma).astype(float) w = base * alive return w.div(w.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)alive那个布尔矩阵是过滤开关,跌破 200 日均线的资产权重归零,剩下的重新归一。这种改法不用碰回测引擎,跑完直接和基线比指标。做参数扫描时,把lookback、ma_window、vol_window三者的组合丢进网格,每次跑完把metrics()的结果追加写进experiments/sweep.csv,最后按夏普排序挑前几名——注意不要只挑第一名,前十里如果参数跳跃很大,说明结果不稳,反而要选参数邻域内表现都还行的那个。
| 改造方向 | 动哪个文件 | 需要同步改论文的地方 |
|---|---|---|
| 换信号逻辑 | src/signals.py | 方法章节、敏感性分析表 |
| 改交易成本 | run.py的命令行参数 | 假设说明、结论讨论 |
| 加资产(如债券) | data_loader.py+signals.py | 数据描述、权重图 |
| 换指标口径 | src/metrics.py | 结果表格的脚注 |
验证方法很土但有效:任何一次改动后,先跑买入持有基线,确认净值没变;再跑原策略,确认和改动前的 csv 逐行对比误差在 1e-9 以内;最后才跑新策略。前两步是回归测试,省下来的时间远大于写它们的时间。
我最早做这类复现时,改完代码直接看最终结果,对了就写进论文,错了就怀疑数据——来来回回折腾了好几天。从那以后我每次动策略代码,都强制先跑一遍基线回归,确认老结果一个数没变,才敢看新数字。这套笨办法救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取