简介:2022年美国大学生数学建模竞赛(MCM/ICM)C题杰出奖(O奖)获奖论文,主题聚焦比特币与黄金的日间交易策略。论文以2016年9月至2021年9月历史价格数据为基础,构建融合均值回归与动量理论的加权交易模型,并引入归一化权重因子W以抑制市场噪声,同时针对极端行情设计额外逻辑判断,兼顾交易成本,力求实现利润最大化。适合备赛美赛的学生、数学建模爱好者以及关注量化交易的读者参考。资源共1个PDF文件,压缩包大小978KB,内容为英文全文,包含问题重述、模型建立、算法实现与结果分析等完整环节。已有147人学习浏览,可用于研读O奖论文的写作框架、模型设计思路以及金融数据建模实战技巧,对提升参赛水平与论文写作能力有直接帮助。
1. 2022年美赛C题O奖论文:一场时序预测与交易策略的完整实战
2022 年美赛 C 题(Trading Strategies)要求参赛队伍基于黄金与比特币的历史价格数据设计日频交易策略,这份编号 2229059 的 O 奖论文拿到最高奖,靠的不是堆模型,而是把“数据处理—特征构造—模型预测—仓位分配—回测验证”串成了一条完整可评估的流水线。对 IT 从业者来说,这类获奖论文 PDF 的真正价值不在数学符号,而在于它演示了如何把一个非平稳价格序列变成可交易信号。这篇博文顺着这条路线,用可复现的 Python 代码把关键环节拆开讲,适合正在做数据分析、量化比赛,或者读论文时想快速找到可移植方案的读者。
2. 拆解美赛C题O奖论文:收益率标签与特征工程的取舍
2.1 为什么 O 奖队伍普遍先构造收益率,而不是直接预测价格
在拿到黄金与比特币的历史价格之后,最容易犯的错误是把原始收盘价直接作为监督学习的标签。价格序列是非平稳的,2022 年 C 题的数据区间横跨数年,价格均值和方差在不同年份差异很大,模型很难从绝对价格里学到可迁移的规律;而且训练集和测试集如果时间跨度不同,预测结果自然失真。
常见做法是先用pct_change()把价格转换成日收益率序列,让目标变量近似平稳,再去预测下一个交易日的收益率符号或具体数值。这样做还有两个附带收益:一是不同资产的收益率尺度接近,方便做跨资产比较;二是回测时只需要乘以上一日收盘价就能还原成资金曲线,不用关心价格绝对水平。
2.1.1 收益率序列还存在波动率聚集,需要特征去捕捉
收益率平稳不等于没有结构。金融时间序列有明显的波动率聚集效应——大波动后面跟着大波动,小波动后面跟着小波动。这意味着单纯预测方向还不够,还需要把近期波动率、动量和星期效应作为特征喂给模型。下面这段代码是这类论文里最常见的特征工程骨架。
import numpy as np import pandas as pd def build_features(df: pd.DataFrame, price_col: str) -> pd.DataFrame: """基于日频价格序列构造模型输入特征""" df = df.copy().sort_index() # 收益率是模型直接预测的目标,pct_change 会留下首行 NaN df["ret"] = df[price_col].pct_change() # 滞后 1/3/5/10/20 个交易日的收益率,捕捉短中期动量 for lag in (1, 3, 5, 10, 20): df[f"ret_lag_{lag}"] = df["ret"].shift(lag) # 滚动均值与波动率,刻画趋势与风险状态 for window in (5, 10, 20): df[f"ma_{window}"] = df["ret"].rolling(window).mean() df[f"std_{window}"] = df["ret"].rolling(window).std() # 周几作为类别特征,可观察星期效应 df["weekday"] = df.index.dayofweek return df.dropna()pct_change()计算的是相邻两日简单收益率,lag 特征让模型能看到前一天、前三天、前五天的收益率,滚动窗口的均值和标准差分别表示短周期动量与波动状态。窗口选 5/10/20 是因为日频数据下对应一周、两周、一个月,覆盖常见交易周期;如果你处理的是分钟级数据,应把窗口调整到 60/240/960 这类按交易时长折算的数值。weekday列在 LightGBM 里可以声明为类别特征,用来捕捉星期效应。
2.1.2 黄金和比特币是否要分开建模
两种资产的波动率不在一个量级,比特币的日波动经常是黄金的十倍以上。直接合并训练时,树模型虽然能通过特征分裂自动适应,但叶子节点会被高波动资产的样本主导。实际操作中通常有两种路线,取舍如下:
| 处理方式 | 适用场景 | 主要风险 |
|---|---|---|
| 合并训练 + asset 类别特征 | 样本量较小,希望共享跨资产规律 | 高波动资产主导分裂,黄金子序列拟合不足 |
| 分资产各自建模 | 两种资产行为差异明显,数据量足够 | 每个模型样本减半,需要更保守的参数 |
美赛 C 题只给两种资产,我一般会先做合并训练加asset特征,看特征重要性里asset排第几。如果排得很靠前,说明两类资产规律确实不同,再改成两个独立模型对比验证。这个判断方法同样适用于其他多资产预测任务。
提示:不同资产的交易日历不一致。比特币 365 天都有交易,黄金只有工作日有报价。合并前要按外连接对齐日期,并对缺失价格做前向填充,否则模型会学到大量空值模式而不是真实规律。
2.2 时序数据切分:随机 K 折在这里是错的
特征构造完成后,下一步是切分训练集和验证集。普通机器学习竞赛里常用的随机 K 折交叉验证,在时序预测里是典型错误。原因很简单:随机切分会让训练集混入未来信息,模型等于提前看到了验证集时间段的统计规律,回测收益会显著虚高。
正确做法是按时间顺序切分,例如前 80% 作为训练集,后 20% 作为验证集,再用滚动时间窗口做进一步验证:
cut = int(len(df) * 0.8) train = df.iloc[:cut] test = df.iloc[cut:]这样保证验证集时间永远晚于训练集,和真实预测场景一致。后续所有特征构造、标准化参数都只能在训练集上计算,再把参数应用到验证集。如果先对全量数据做标准化再切分,就会引入所谓的前视偏差。
2.3 特征重要性的解读顺序
用 LightGBM 训练一轮后,先看特征重要性。金融时序特征中,滞后收益率的边际贡献通常有限,真正拉开差距的往往是波动率类和近端收益率类特征。如果std_5和std_20排在前面,说明模型学到的是“波动状态决定未来收益分布”,这符合金融数据的典型规律。如果weekday排名异常高,要警惕是不是交易日历对齐出了问题。
3. 美赛C题O奖论文的模型选型:LightGBM 与 LSTM 的参数落地
3.1 树模型在金融数据上的性价比往往高于深度网络
很多第一次做时序预测的读者会默认选 LSTM,但 2022 年美赛 C 题这类表格型特征场景,梯度提升树往往是更稳妥的起点。原因有三:金融数据信噪比极低,树模型对噪声的鲁棒性更好;LightGBM 原生支持类别特征和缺失值,洗数据成本低;训练速度快,可以在几十分钟内完成数十组参数实验。
O 奖论文里也不一定全用深度学习。常见组合是 LightGBM 作为主力模型预测收益率方向,LSTM 或 GRU 作为辅助模型抓取序列模式,最后在策略层做信号融合。这种“树模型兜底 + 深度模型补充”的结构,在工业界同样适用。
3.2 LightGBM 训练代码与关键参数表
下面给出一个可直接运行的训练流程,沿用第 2 章构造的特征:
import lightgbm as lgb from sklearn.metrics import roc_auc_score features = [c for c in train.columns if c not in ("ret", "close")] model = lgb.LGBMClassifier( n_estimators=800, learning_rate=0.02, num_leaves=31, max_depth=6, feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1, min_child_samples=20, random_state=42 ) model.fit( train[features], (train["ret"] > 0).astype(int), eval_set=[(test[features], (test["ret"] > 0).astype(int))], eval_metric="auc" )目标标签是(train["ret"] > 0).astype(int),即预测下一个交易日是否上涨。预测上涨概率比直接预测收益率的回归任务稳定得多,因为收益率的数值受极端行情影响太大,而方向上相对可学习。
| 参数 | 取值 | 作用 |
|---|---|---|
learning_rate | 0.02 | 学习率调小,配更多棵树,降低单棵树影响 |
num_leaves | 31 | 叶子数,太大容易记住噪声 |
max_depth | 6 | 限制树深,兼顾非线性与泛化 |
feature_fraction | 0.8 | 每棵树随机采样 80% 特征,降低特征间共适应 |
bagging_fraction | 0.8 | 行采样,减轻时序数据局部过拟合 |
min_child_samples | 20 | 叶子节点最少样本数,对低信噪比数据很重要 |
eval_metric="auc"只用于训练监控,真正判断模型好坏要看回测结果。训练完成后,用model.predict_proba(test[features])[:, 1]得到下一交易日上涨概率,这个概率序列就是第 4 章策略回测的输入。
3.3 LSTM 只承担“补充信号”角色的常见用法
LSTM 在金融日频数据上很难单独跑赢 LightGBM,但如果构造得当,它的预测结果和树模型相关性较低,融合后能提升稳定性。更常见的做法是用 LSTM 对收益率做回归,预测未来一天的标准化收益率,再输出成一个补充特征。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from sklearn.preprocessing import StandardScaler def to_sequences(data, seq_len=30): xs, ys = [], [] for i in range(seq_len, len(data)): xs.append(data[i - seq_len:i]) ys.append(data[i, 0]) return np.array(xs), np.array(ys) scaler = StandardScaler() scaled = scaler.fit_transform(df[features].values) X, y = to_sequences(scaled) model = Sequential([ LSTM(32, return_sequences=True, input_shape=(X.shape[1], X.shape[2])), Dropout(0.2), LSTM(16), Dropout(0.2), Dense(1) ]) model.compile(optimizer="adam", loss="mse")这里有两个容易踩的坑:一是标准化参数必须在训练集上fit,再对测试集做transform,如果先在全量数据上fit_transform,验证集信息会泄漏到训练集;二是 LSTM 输出的是标准化后的收益率,回测时要先做逆变换才能和真实价格对齐。seq_len=30表示用过去 30 个交易日预测下一天,这个长度对应大约一个半月的交易信息,对日频数据够用。
3.4 滚动训练与未来函数
固定切分的训练方式有一个问题:模型没有用到最近的数据。金融市场规律随时间变化,三个月前的模式可能已失效。O 奖论文的常见做法是滚动训练——每隔一段时间用最近的数据重新训练一次模型,再预测未来一小段。
train_len, test_len, step = 1200, 100, 100 for start in range(0, len(df) - train_len - test_len, step): train_df = df.iloc[start:start + train_len] test_df = df.iloc[start + train_len:start + train_len + test_len] model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.03, num_leaves=31, max_depth=6, random_state=42 ) model.fit(train_df[features], (train_df["ret"] > 0).astype(int)) prob = model.predict_proba(test_df[features])[:, 1] # 将 prob 与对应日期存入列表,后续回测使用滚动窗口变量train_len=1200大约是 5 年日频数据,test_len=100约 5 个月,step=100表示每预测完 100 天就重新训练一次。实际比赛中每个 O 奖队伍用的窗口不同,但思路一致:测试段永远紧跟在训练段后面,防止未来数据混入。
注意:滚动训练时不要用测试段的数据做任何统计计算。如果发现验证集 AUC 异常高(比如超过 0.6),优先检查特征构造里是否混入了当日及未来信息。
4. 美赛C题O奖论文里的策略回测:从预测信号到风险指标工程实现
4.1 从模型概率到交易信号的决策层
模型输出的上涨概率不是交易信号。O 奖论文里通常会在预测层和交易层之间再加一个决策函数,常用的形式是双阈值映射:概率大于上阈值做多,小于下阈值做空,中间区间空仓观望。这样做的目的是过滤低置信度信号,减少交易次数和手续费损耗。
prob = model.predict_proba(test[features])[:, 1] signal = np.where(prob >= 0.55, 1.0, np.where(prob <= 0.45, -1.0, 0.0))阈值选 0.55/0.45 时,模型只有对方向有较强信心才建仓。阈值越靠近 0.5,交易越频繁,回测夏普可能下降;阈值调宽,信号更少但单笔质量更高。标准做法是回测 3 组阈值(如 0.52/0.48、0.55/0.45、0.60/0.40),观察信号数量与收益曲线的变化,再选出稳定的一组。
4.2 一手回测代码与夏普比率、最大回撤计算
回测是检验策略的最终裁判。下面是一个可运行的回测函数,按收盘价成交,包含单边手续费,支持多头、空头、空仓三态:
def backtest(price, signal, fee=0.001, init_cash=10000.0): cash = init_cash pos = 0.0 equity = [] for i in range(len(price)): if i > 0: target_pos = signal[i - 1] # 今日执行昨日信号 target_value = equity[-1] * (1 + target_pos) / 2 diff = target_value - pos * price[i] # 需要调仓的市值差额 if diff > 0: cash -= diff * (1 + fee) else: cash += (-diff) * (1 - fee) pos = target_value / price[i] equity.append(cash + pos * price[i]) return pd.Series(equity, index=price.index)逻辑说明:signal[i - 1]代表昨日收盘时产生的信号,今日执行,避免使用当日信息。target_value把当前权益按照目标仓位映射成目标市值,多头时为全部权益,空仓时为零,空头时为负值。手续费只在实际调仓时扣除,fee=0.001表示单边千分之一,这是日频交易比较常见的成本假设。
评估指标部分用下面这段代码计算:
equity = backtest(price, signal) ret = equity.pct_change().dropna() sharpe = ret.mean() / ret.std() * np.sqrt(252) max_dd = (equity / equity.cummax() - 1).min() annual_ret = (equity.iloc[-1] / equity.iloc[0]) ** (252 / len(equity)) - 1np.sqrt(252)是因为日频数据按一年 252 个交易日做年化。最大回撤用equity / equity.cummax() - 1的方式逐个时点计算回撤幅度再取最小值,它代表策略从历史峰值跌到谷底的最大损失。
4.3 收益与风险的指标解读
回测跑完,先看三组数字:
| 指标 | 含义 | 参考范围 |
|---|---|---|
| 年化收益率 | 策略资金的复利年化增长 | 需要和买入持有基准对比 |
| 夏普比率 | 每承担一单位波动获得的超额收益 | 大于 1 可接受,大于 2 要警惕过拟合 |
| 最大回撤 | 从峰值到谷底的最大跌幅 | 一般希望控制在 20% 以内 |
这三个指标应该和“买入并持有”策略放在同一张表里对比。如果模型策略年化收益很高但最大回撤也接近 40%,说明策略实际上是在承担极端风险换取收益,这时需要通过调低仓位或者收紧阈值来控制回撤。
4.4 状态识别与仓位分层的进阶做法
O 奖论文里还有一个常见技巧:根据市场状态调整仓位上限。例如用最近 20 日波动率判断当前市场处于高波动还是低波动状态,高波动时降低目标仓位,低波动时适当提高。
vol = df["ret"].rolling(20).std() weight = np.clip(prob - 0.5, -0.5, 0.5) * (0.02 / vol)0.02 / vol表示目标日波动率 2% 对应的仓位比例。如果近期波动率为 1%,仓位是 2 倍;如果波动率上升到 4%,仓位自动降到 0.5 倍。这样策略在波动率放大时会自动降低风险敞口,避免在大行情中遭遇极端回撤。
提示:回测收益高且交易频率也高时,优先检查是否使用了未来数据。快速验证方法是把信号整体向后平移一天再跑一次回测,如果收益几乎没有下降,说明信号里很可能混入了当日信息。
5. 读美赛C题O奖论文 PDF 时,怎么把数学描述还原成可复现代码
5.1 先找变量表,把它当作接口文档
O 奖论文通常有完整的符号表,这是整篇 PDF 里信息密度最高的部分。把符号表当成软件接口文档来读:输入变量、输出变量、约束条件一目了然。看到R_t就对应代码里的ret,看到W_t就对应仓位权重,看到γ就找参数设置。先建立这个映射关系,再去看正文里的公式,效率会高很多。如果论文里没有独立变量表,可以从公式下方的“where”说明里提取同样的信息。
5.2 把公式“翻译”成特征函数,而不是逐行对比符号
美赛论文里的多数公式,落到代码里无非三类操作:滞后平移(shift)、滚动窗口(rolling)、归一化(StandardScaler或MinMaxScaler)。建议先把这三个基础操作封装成几个 10 行以内的工具函数,读到公式时直接按操作类型套上去。遇到状态转移矩阵或者组合权重公式,先不要纠结推导过程,而是把输入输出关系理清楚,然后写一小段测试数据验证形状和边界。
5.3 建议先写好三个脚本,再讨论调参
复现一份获奖论文,最快路径是先把工程骨架搭起来:prepare.py负责读数据、清洗、生成特征、切分训练集和测试集,输出成 parquet 中间文件;train.py负责训练模型、输出逐日预测概率;backtest.py负责读预测概率、生成交易信号、输出权益曲线和指标报表。三条命令跑通之后,再回头逐章复现论文里的公式和参数。复现结果和论文对不上时,优先检查特征定义是否用了前向窗口,以及标准化是否在全量数据上完成——这两类问题占了时序复现失败原因的一半以上。
当你再次打开这份标题带 2229059 的论文 PDF 时,读到的就不再是一行行希腊字母,而是一张可以修改、运行、验证的工程图纸。
本文还有配套的精品资源,点击获取