简介:围绕2022年美赛C题比特币交易策略形成的F奖项目资料包,面向准备数学建模竞赛、希望进阶金融时间序列分析与优化决策的学习者。内容以代码、数据和论文为主体,串联小波降噪预处理、GRU价格预测与动态规划买卖策略,帮助读者理解从噪声抑制、趋势预测到交易决策的完整建模链路。包体为7z压缩包,大小约6.81MB;上游暂未提供有效文件总数与类型明细,需以实际解压内容为准。已有357人学习或浏览,说明该赛题方案在建模群体中具有一定参考热度。读者可据此对照F奖论文梳理建模假设、模型衔接与结果表达,结合代码和数据复现实验,体会小波降噪、GRU长期依赖捕捉及动态规划全局最优在金融场景中的落地方式,并积累排错与改进思路。
1. 两条价格曲线,五年时间,每天只回答一次买、卖还是不动
2022 美赛 C 题给的条件非常克制:比特币和黄金各一条日频收盘价,时间从 2016 年 9 月 11 日到 2021 年 9 月 10 日,让你在每个交易日只做一个决策,买、卖还是持有,最后拿 1000 美元本金跑出一条资产曲线,再写一页给交易员的备忘录。这份 F 奖资源包把整套流程拆开交付了——原始数据、预处理脚本、特征工程、LSTM 预测、动态规划定仓、回测与灵敏度分析,外加成稿论文和全部插图。适合两类人:一类是要在几天内复现一份完整建模作品的学生,另一类是手里有行情数据、想搭个策略实验台练手的开发者。它不承诺赚钱,但它给了一条从数据到策略再到结论的可验证链路。
2. 项目全景与技术选型:为什么是预测打分加动态规划定仓
这套代码的整体思路很朴素,先把“明天涨还是跌”变成一个回归问题,再把“今天该拿多少仓位”变成一个最优化问题。两件事分开做,好处是每一段都能单独验证、单独替换。很多同类作品失败的地方在于,把预测和决策混在一张网络里,端到端训练,结果既说不清模型学到了什么,也没法在论文里做灵敏度分析。
具体链路是:原始 CSV → 面板数据(比特币与黄金按日期对齐)→ 技术指标与统计特征 → LSTM 输出次日对数收益率 → 动态规划在离散仓位网格上求最优路径 → 回测引擎折算成资产曲线 → 灵敏度扫描手续费、网格密度、回看窗口。论文里的每一张图,都能对应到某个脚本的一个输出文件,这是这份资源最值得学的工程习惯。
2.1 交付物与目录结构
拿到包先别急着跑,先把目录扫一遍,知道每个文件夹对应论文的哪一节,后面改代码才不会迷路。
2022-C-Trading/ ├── data/ │ ├── raw/ # 原始行情:BTC.csv、GOLD.csv,字段为 Date/Close │ └── processed/ # 清洗对齐后的面板数据 daily_panel.csv ├── src/ │ ├── preprocess.py # 读 CSV、对齐日期、算基础收益率 │ ├── features.py # MA、波动率、RSI、MACD、金银比 │ ├── model_lstm.py # 滑窗样本构造 + walk-forward 训练与预测 │ ├── strategy_dp.py # 离散仓位网格上的动态规划 │ ├── backtest.py # 资产曲线、回撤、夏普 │ └── sensitivity.py # 参数扫描,产出论文第 5 节的表 ├── notebooks/ │ └── eda.ipynb # 探索性分析,论文图 2、图 3 的来源 ├── paper/ │ ├── main.tex # 论文正文,含摘要、假设、模型、灵敏度、memo │ ├── figures/ # 所有插图,文件名与正文引用一一对应 │ └── paper.pdf # 成稿 ├── outputs/ # 预测结果、策略文件、资产曲线 CSV └── requirements.txt这个结构里最关键的一条约定是:src/下每个脚本都只做一件事,中间产物统一落到outputs/或data/processed/,靠命令行参数串起来。这样你在改模型时不用动回测,改回测时不用重训模型,调试成本直接砍一半。
2.2 数据面板:两条曲线的对齐是整个项目的地基
比特币 7×24 交易,黄金按交易所日历走,周末和节假日没有报价。直接按行 concat 是最常见的翻车点,日期错位一天,后面的收益率、特征、回测全部跟着错。正确做法是按日期做外连接,再前向填充,并且填充必须发生在切片之后,保证第 t 行只包含第 t 天及以前的信息。
面板里除了两条价格,还预存了两列日收益率。这两列不只是特征,回测和评估都靠它们,所以要在预处理阶段就算好、存下来,别在每个脚本里重算一遍,口径不一致会带来很难查的偏差。
2.3 技术栈与版本约束
| 组件 | 版本建议 | 在项目里的角色 |
|---|---|---|
| Python | 3.8 ~ 3.10 | 主运行时,3.11 上部分老版本 torch 轮子缺失 |
| pandas / numpy | 1.3+ / 1.21+ | 面板数据、滚动窗口、收益率计算 |
| PyTorch | 1.10+ | LSTM 回归器,CPU 就能跑完,不必等显卡 |
| scikit-learn | 1.0+ | 标准化、指标计算、基准模型对照 |
| matplotlib | 3.4+ | 论文全部插图,统一字号与配色 |
| LaTeX | TeX Live 2021+ | 论文编译,含中文需 xelatex |
没有用 TA-Lib,指标全部用 pandas 的rolling和ewm手写。这是有意的:TA-Lib 在 Windows 上装起来要编译 C 库,队友机器上十有八九卡在这一步,手写三十行反而省事,而且公式写在论文里能对得上。
3. 核心模块拆解之一:从原始行情到次日收益率预测
这一段是整份代码里最有复用价值的部分。哪怕你完全不做交易策略,滑窗样本构造加 walk-forward 验证这套写法,放到任何时间序列预测任务里都能直接用。
3.1 preprocess.py:三行代码决定后面全对不对
预处理看着简单,实际是整个项目里最容易埋雷的地方。下面这段是核心,重点看日期处理和前向填充的顺序。
import pandas as pd def load_series(path, date_col, price_col, name): """读单个行情文件,统一成 date 索引 + 单列价格""" df = pd.read_csv(path) df[date_col] = pd.to_datetime(df[date_col]) df = df[[date_col, price_col]].rename(columns={date_col: "date", price_col: name}) df = df.drop_duplicates("date").sort_values("date") return df.set_index("date") def build_panel(btc_path, gold_path, start="2016-09-11", end="2021-09-10"): btc = load_series(btc_path, "Date", "Close", "btc") gold = load_series(gold_path, "Date", "Close", "gold") # 外连接保留双方全部交易日,黄金周末缺失留给 ffill panel = btc.join(gold, how="outer").sort_index() # 先切片再填充,保证不会把区间外的价格带进来 panel = panel.loc[start:end].ffill().dropna() panel["ret_btc"] = panel["btc"].pct_change() panel["ret_gold"] = panel["gold"].pct_change() return panel.dropna()how="outer"是关键参数,用 inner 会丢掉比特币在周末的行情,样本量直接少掉近三成。ffill()放在loc[start:end]之后,是为了避免用区间外的历史价格去填区间开头的空值——这种“未来数据”一旦混进来,回测曲线会好看得离谱,而你根本查不出来。dropna()放在最后,是为了吃掉第一条收益率必然为 NaN 的那一行。
pct_change()默认按行位置做差,前向填充之后每一行都对应一个自然交易日,所以这里不用额外传periods。如果你想改成对数收益率,换成np.log(panel["btc"]).diff(),后面的动态规划里用的就是一阶近似,两者口径要一致,别一半用简单收益率一半用对数收益率。
3.2 features.py:指标要“滞后”,不然全是未来函数
特征构造这一层,我一般坚持两条:所有滚动统计量算完之后整体shift(1),任何用到全样本的统计(均值、方差)都放到训练窗口内部去做。
import numpy as np import pandas as pd def rsi(series, window=14): """Wilder RSI:用 ewm 近似平滑,避免前 window 行全 NaN""" delta = series.diff() up = delta.clip(lower=0).ewm(alpha=1 / window, adjust=False).mean() down = (-delta.clip(upper=0)).ewm(alpha=1 / window, adjust=False).mean() rs = up / down.replace(0, np.nan) return 100 - 100 / (1 + rs) def add_features(panel, windows=(5, 10, 20)): out = panel.copy() for w in windows: # 价格相对均线偏离,无量纲,比绝对价格更适合喂网络 out[f"ma_dev_{w}"] = out["btc"].rolling(w).mean() / out["btc"] - 1 out[f"vol_{w}"] = out["ret_btc"].rolling(w).std() out["rsi_btc"] = rsi(out["btc"]) ema12 = out["btc"].ewm(span=12, adjust=False).mean() ema26 = out["btc"].ewm(span=26, adjust=False).mean() out["macd"] = (ema12 - ema26) / out["btc"] out["gold_ratio"] = out["btc"] / out["gold"] # 金银比,刻画相对强弱 out["ret_gold_lag1"] = out["ret_gold"].shift(1) # 统一滞后一天:第 t 行特征只能包含 t-1 及以前的信息 feat_cols = [c for c in out.columns if c not in ("btc", "gold", "ret_btc", "ret_gold")] out[feat_cols] = out[feat_cols].shift(1) return out.dropna()ma_dev用“均线除以现价减一”而不是直接给均线值,是为了让特征在不同价格量级下保持可比——比特币从几百美元走到六万美元,绝对价格喂进网络,模型只会记住最后那段。vol_*用收益率标准差而不是价格标准差,同理。
shift(1)这一行是保命操作。特征里的rolling(20).mean()在 pandas 里的语义是“包含当前行在内的过去 20 行”,也就是说第 t 行的均线已经用到了第 t 天的收盘价,而你又要预测第 t+1 天——这在实盘里做不到,因为你在收盘前就得下单。整体后移一行,等于强制模型只能用昨天及以前的信息,回测结果会明显变难看,但那是真的。
3.3 model_lstm.py:预测收益率,不预测价格
网络本身很小,两层就够。真正决定成败的是训练方式:扩窗 walk-forward,每隔 30 个交易日重训一次,绝不用未来数据做标准化。
import numpy as np import torch import torch.nn as nn class ReturnLSTM(nn.Module): def __init__(self, n_feat, hidden=32, layers=1, dropout=0.1): super().__init__() self.lstm = nn.LSTM(n_feat, hidden, layers, batch_first=True, dropout=dropout) self.head = nn.Sequential(nn.Linear(hidden, 8), nn.ReLU(), nn.Linear(8, 2)) def forward(self, x): out, _ = self.lstm(x) return self.head(out[:, -1, :]) # 只取最后时间步,输出 btc/gold 两个收益率 def make_windows(feat, targets, lookback=30): X, y = [], [] for i in range(lookback, len(feat)): X.append(feat[i - lookback:i]) y.append(targets[i]) return np.asarray(X, dtype=np.float32), np.asarray(y, dtype=np.float32) def walk_forward(panel, feat_cols, lookback=30, retrain_every=30, epochs=40, seed=42): torch.manual_seed(seed); np.random.seed(seed) feat = panel[feat_cols].values targ = panel[["ret_btc", "ret_gold"]].values preds = np.full_like(targ, np.nan) for split in range(lookback + 250, len(panel), retrain_every): # 只用 split 之前的数据训练,均值方差也在训练段内拟合 mu, sd = feat[:split].mean(0), feat[:split].std(0) + 1e-8 Xtr, ytr = make_windows((feat[:split] - mu) / sd, targ[:split], lookback) model = ReturnLSTM(len(feat_cols)) opt = torch.optim.Adam(model.parameters(), lr=2e-3) loss_fn = nn.MSELoss() for _ in range(epochs): model.train() perm = torch.randperm(len(Xtr)) for k in range(0, len(perm), 64): idx = perm[k:k + 64] opt.zero_grad() loss = loss_fn(model(torch.from_numpy(Xtr[idx])), torch.from_numpy(ytr[idx])) loss.backward(); opt.step() model.eval() # 只预测接下来 retrain_every 天,逐日滑窗 for t in range(split, min(split + retrain_every, len(panel))): x = ((feat[t - lookback:t] - mu) / sd)[None, :, :] with torch.no_grad(): preds[t] = model(torch.from_numpy(x.astype(np.float32))).numpy()[0] return preds几个参数值得说清楚。lookback=30是回看窗口,样本量够大时可以拉到 60;拉到 120 以上,训练集样本数会掉到几百,LSTM 直接过拟合。retrain_every=30控制重训频率,越小越贴近实盘,但训练时间线性增长,CPU 上 30 已经要跑十几分钟。mu/sd用训练段统计量,是防止信息泄漏的最后一道闸——如果用全样本标准化,测试期的均值会悄悄渗进训练特征里,这在论文里是硬伤。
输出维度是 2,对应比特币和黄金的次日收益率。预测黄金收益率的目的不是交易黄金,是给动态规划提供另一条腿的期望,让模型在比特币信号弱的时候有地方躲。
4. 核心模块拆解之二:从预测值到一条能跑的资产曲线
有了每天的期望收益率,下一步是决定仓位。很多人在这里直接上阈值法——预测收益大于 0 就满仓,小于 0 就清仓。这种写法在论文里能跑出结果,但一加手续费就崩,因为阈值附近的抖动会导致高频调仓。
4.1 strategy_dp.py:把仓位离散化,把决策交给动态规划
把总资产在比特币上的占比切成 11 档(0、0.1 … 1.0),状态就是“第 t 天、当前在第 i 档”,动作是“跳到第 j 档”,代价是换手带来的手续费。目标函数是期末对数财富最大。
import numpy as np def solve_dp(mu_btc, mu_gold, grid=11, fee=0.002): """返回每个时间点、每个持仓档位下的最优目标档位""" positions = np.linspace(0.0, 1.0, grid) # 0=全黄金, 1=全比特币 T = len(mu_btc) V = np.zeros((T + 1, grid)) # 期末价值为 0,只剩当日收益 policy = np.zeros((T, grid), dtype=int) for t in range(T - 1, -1, -1): for i, w in enumerate(positions): best_val, best_j = -np.inf, i for j, w_new in enumerate(positions): turnover = abs(w_new - w) # 本次调仓比例 cost = turnover * fee # 手续费按调仓金额计 exp_ret = w_new * mu_btc[t] + (1 - w_new) * mu_gold[t] # 对数收益的一阶近似,cost 直接扣在收益里 val = np.log1p(exp_ret - cost) + V[t + 1, j] if val > best_val: best_val, best_j = val, j V[t, i] = best_val policy[t, i] = best_j return positions, policy, Vgrid=11是精度和耗时的折中。11 档时单次求解 T×11×11 ≈ 20 万次内层循环,秒级出结果;拉到 51 档,耗时涨到二十多倍,而资产曲线的形状基本不变,边际收益很低。fee按单边计,调仓 50% 的仓位就扣 0.1%,这个参数放进配置文件,是灵敏度分析里最敏感的一个。
对数收益的近似要注意:log1p(exp_ret - cost)在exp_ret接近 -1 时会炸,实际上日收益率不会到这个量级,但如果你的模型预测发散了,这里会第一个报错,反而成了个不错的数据检查点。
policy是二维表,行是时间、列是当前档位,回测时拿着当前档位去查表就行,不需要重新算。
4.2 backtest.py:用份额记账,不要用收益率累乘
回测最容易写错的地方是用“每日收益率连乘”来算资产曲线。那样算出来的曲线没法处理中途调仓,手续费也没地方扣。正确做法是记账到份额。
import pandas as pd def backtest(policy, positions, panel, fee=0.002, capital=1000.0): i = 0 btc_u = capital * positions[i] / panel["btc"].iloc[0] gold_u = capital * (1 - positions[i]) / panel["gold"].iloc[0] curve = [] for t in range(len(panel)): pb, pg = panel["btc"].iloc[t], panel["gold"].iloc[t] value = btc_u * pb + gold_u * pg curve.append(value) if t == len(panel) - 1: break j = policy[t, i] # 查表得到目标档位 if j != i: value -= value * abs(positions[j] - positions[i]) * fee btc_u = value * positions[j] / pb # 按当日收盘价折算份额 gold_u = value * (1 - positions[j]) / pg i = j return pd.Series(curve, index=panel.index, name="asset") def metrics(curve): ret = curve.pct_change().dropna() total = curve.iloc[-1] / curve.iloc[0] - 1 mdd = (curve / curve.cummax() - 1).min() sharpe = ret.mean() / (ret.std() + 1e-12) * (252 ** 0.5) return {"total_return": total, "max_drawdown": mdd, "sharpe": sharpe}policy[t, i]下标顺序必须和solve_dp的输出一致,这是最容易搞反的地方,反了之后回测还能跑,只是资产曲线变成一条噪声。capital=1000是题目给定的初始资金,改这个参数不会影响收益率和夏普,只会等比缩放曲线,做灵敏度分析时没必要扫它。
metrics里夏普按 252 个交易日年化,日频策略的标准做法。最大回撤用cummax算,注意它是负数,写进论文时取绝对值并注明口径。
4.3 sensitivity.py:论文第五节的表是扫出来的,不是想出来的
灵敏度分析在这一类题目里权重很高,评委看的就是你知不知道自己的模型哪里脆。资源包里扫了三个维度:手续费 0 / 0.2% / 0.5%、网格密度 5/11/21 档、回看窗口 20/30/60 天。
import itertools import pandas as pd from strategy_dp import solve_dp from backtest import backtest, metrics def sweep(panel, preds, fees=(0.0, 0.002, 0.005), grids=(5, 11, 21), lookbacks=(20, 30, 60)): rows = [] for fee, grid, lb in itertools.product(fees, grids, lookbacks): pos, pol, _ = solve_dp(preds[:, 0], preds[:, 1], grid=grid, fee=fee) curve = backtest(pol, pos, panel, fee=fee) m = metrics(curve) rows.append({"fee": fee, "grid": grid, "lookback": lb, **m}) return pd.DataFrame(rows).sort_values("sharpe", ascending=False)这张表跑出来通常会发现两件事:手续费从 0 提到 0.5%,总收益可能腰斩;网格从 11 拉到 21,收益几乎不变但回撤变小。这两个结论比“我们的策略收益 300%”有价值得多,因为它们说明模型对什么敏感、在什么条件下失效。
5. 避坑与排查:跑通这套代码时最容易翻车的五处
5.1 从零跑通的四步
环境干净的话,四条命令能一路跑到资产曲线。建议新建虚拟环境,别在系统 Python 里装。
python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt # 1) 生成面板数据,输出到 data/processed/daily_panel.csv python src/preprocess.py --raw data/raw --out data/processed/daily_panel.csv # 2) 训练并输出滚动预测,CPU 上约 10~20 分钟 python src/model_lstm.py --panel data/processed/daily_panel.csv --epochs 40 --out outputs/pred.csv # 3) 求解策略,产出 policy.npz python src/strategy_dp.py --pred outputs/pred.csv --fee 0.002 --grid 11 --out outputs/policy.npz # 4) 回测并打印指标 python src/backtest.py --panel data/processed/daily_panel.csv --policy outputs/policy.npz --capital 1000第三步依赖第二步的pred.csv,第四步依赖第三步的policy.npz,顺序不能换。想跳过训练直接看回测效果,outputs/里存了一份预先跑好的预测结果,把它拷到对应路径就能直接跑第三、四步。
5.2 五条踩坑记录
现象:回测总收益 800%,资产曲线几乎不回调。原因:特征里存在未来函数。最常见的是滚动统计量没shift,或者标准化用了全样本的均值和方差。 解决:在features.py末尾统一后移一行,标准化只在训练窗口内fit。改完收益会掉一大截,但那是真实水平。
现象:模型预测的 R² 有 0.98,但资产曲线一路阴跌。原因:直接回归价格水平。价格是非平稳序列,模型学到的最优解就是“明天约等于今天”,R² 当然高,但对交易毫无信息量。 解决:改成回归对数收益率或简单收益率,目标量级降到 1e-3,配合 MSE 和标准化,训练时把学习率调到 2e-3 左右。
现象:join之后大量 NaN,dropna之后样本只剩一半。原因:黄金周末和节假日没有报价,用 inner join 会把比特币周末行情全丢掉。 解决:改how="outer"加ffill(),并且确认填充在切片之后做。
现象:同样的命令跑两次,资产曲线差出几十个百分点,论文里的数字复现不出来。原因:随机种子没固定,PyTorch 的 cuDNN 在 GPU 上是非确定性的,DataLoader 的 shuffle 也带随机。 解决:torch.manual_seed、np.random.seed、torch.use_deterministic_algorithms(True)三件套,种子写进配置文件而不是散在代码里。
现象:手续费参数从 0 改成 0.002,策略从赚钱变成亏钱。原因:11 档网格加上逐日重决策,会导致频繁小幅调仓,换手率被低估。 解决:网格降到 5 档,或者在solve_dp里给换手加一个二次惩罚项turnover ** 2 * gamma,gamma取 1e-3 量级就能明显压住抖动。
6. 二次开发:把这份作品改成你自己的策略实验台
这套代码最大的价值不在结论,在骨架。它把“数据—特征—预测—决策—回测—灵敏度”切成六个互不耦合的脚本,每一段都能单独换掉。
最省事的改法是换资产。把data/raw/下的两个 CSV 换成你关心的标的,preprocess.py里改一下列名映射,其余代码一行不用动。如果你想做多标的,solve_dp里的仓位向量要从标量扩成向量,状态数会指数增长,11 档两资产是 121 个状态,三资产就 1331 个,还在可接受范围,四资产就该考虑用连续优化的方法了。
换模型更简单。model_lstm.py的输出接口是(N, 2)的数组,只要你的模型能吐出同样形状的预测,直接替换walk_forward就行。想试梯度提升树,把make_windows的(N, lookback, F)拉平成(N, lookback*F),接一个LightGBMRegressor,十分钟能跑出对照结果。想做强化学习,把solve_dp换成 PPO,状态是最近 30 天的特征窗口加当前仓位,动作是 11 档离散动作,奖励直接用对数收益减手续费——但要注意,RL 在小样本上极不稳定,做论文的话得跑多个种子取均值,工作量比 DP 大得多。
验证方法上,我建议至少加两个对照。一个是随机策略基准:每天以 0.5 的概率随机切档位,跑 100 次取收益分布,你的策略得显著落在分布右尾才有说服力。另一个是打乱检验:把预测序列随机打乱后重新求解 DP,如果打乱后的收益和原序列差不多,说明你的“预测”根本没提供信息,收益全是 DP 在历史价格上拟合出来的。这个检验很残酷,但能省掉很多自我感动。
| 改造方向 | 需要动的文件 | 工作量 | 论文里能加什么 |
|---|---|---|---|
| 换标的 | preprocess.py、配置 | 半天 | 跨市场稳健性 |
| 换预测模型 | model_lstm.py | 一天 | 模型对比表 |
| 加滑点与冲击成本 | backtest.py、strategy_dp.py | 一天 | 更真实的成本敏感性 |
| 多标的组合 | strategy_dp.py | 三天 | 状态空间与求解效率 |
| 强化学习替代 DP | 新增 rl_agent.py | 一周 | 与 DP 的对照实验 |
我踩过一次坑,是在论文快定稿时才发现预测序列和回测用的日期错开了一天——preds数组是按panel的行号索引的,中间有人插了一次dropna(),行号整体前移。那条 300% 的曲线后来重跑成了 90%,摘要全部重写。从那以后我每次合并预测和回测数据,都会强制打一遍assert (pred.index == panel.index).all(),一行代码,省一晚上。希望帮到你。
本文还有配套的精品资源,点击获取