ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于LSTM的比特币与黄金价格综合决策模型

基于LSTM的比特币与黄金价格综合决策模型 简介面向计算机专业学生与Python学习者这份项目以比特币和黄金价格走势为场景构建基于LSTM的综合决策模型打通数据清洗、特征构造、模型训练到买卖信号生成的完整流程适合作为课程设计、期末大作业或毕业设计的基础模板。压缩包内共97个文件、大小仅1.39MB其中42个csv文件保存比特币与黄金历史数据及中间计算结果38个py脚本覆盖LSTM预测、MACD决策、RSI指标、灰色预测与各类可视化绘图另有PDF项目报告、README说明和配置文件辅助上手。项目使用真实历史行情数据完成训练与回测代码结构按数据、指标、模型、绘图和决策模块组织方便逐段调试与替换参数。项目代码完整、运行逻辑清晰评审成绩99分能帮助学习者快速理解时间序列预测和量化交易策略的落地实现。目前已有303人学习下载适合想通过完整项目实战提升能力、并在此基础上做二次开发的研究者。1. 用LSTM同时盯两盘棋比特币和黄金价格走势综合决策在解决什么问题比特币难拿黄金也不敢全押的时候只能同时盯两盘棋。比特币和黄金价格预测看起来是两套完全不同的逻辑一个跟着流动性和市场叙事走一个贴着实际利率和避险需求走。这个综合决策模型的出发点不是让单一模型把某个资产预测得分毫不差而是把LSTM对两份资产的预测结果映射到同一套能落地的信号规则里。把日频价格归一化之后两份序列在LSTM眼里其实非常接近——当预测目标从绝对价格变成对数收益率剩下的都是短窗口内的序列依赖这正是LSTM门控结构擅长处理的东西。但真正支撑综合决策四个字的是后面的链路数据怎么对齐、窗口怎么构造、两个信号冲突时听谁的、回测怎么避免未来函数。对正在做比特币量化入门、金融数据建模或中小型交易系统原型的开发者和学生来说这套步骤缺任意一环复现都会偏离预期。所以这篇文章按一条能直接跑通的路径来写从两份CSV构造统一数据集到训练单层LSTM再到信号合成与回测口径。每个环节都给参数、给代码、给判断依据方便对照项目说明里的源码数据三件套逐段核对。2. 数据准备与序列构造构建比特币和黄金统一数据集的最小实现2.1 数据源选择与预处理先解决两份CSV时间对不齐比特币7×24小时连续交易黄金则跟着国际现货和期货市场走周一到周五常规开盘周末停盘。直接把两份CSV按日期横向拼接DataFrame里会出现成片NaNLSTM训练时轻则Loss波动大重则梯度直接发散。常见做法是选黄金的交易日历做主表比特币价格按主表日期对齐周末缺失值用前一交易日收盘价前向填充。这个处理对日频策略是可接受的前提是策略只在黄金交易日开盘决策多数个人量化策略也确实如此。如果做分钟级或高频方案则需要换一套多时区对齐逻辑不能在日频框架里硬套。import pandas as pd gold pd.read_csv(gold_daily.csv, parse_dates[date]).set_index(date) btc pd.read_csv(btc_daily.csv, parse_dates[date]).set_index(date) # 以黄金日期为索引把比特币收盘价对齐过来并前向填充 df gold[[close]].rename(columns{close: gold_close}) df[btc_close] btc[close].reindex(df.index, methodffill) df df.dropna(subset[btc_close])这段代码里真正起作用的只有两行reindex(df.index, methodffill)负责把比特币价格映射到黄金交易日历dropna剔除的是比特币历史数据尚未覆盖的早期日期而不是删除周末。另一个常见坑是同一日期在多份数据里重复出现对齐前最好先执行df df[~df.index.duplicated()]去重。时区问题更隐蔽两个数据源一个用UTC一个用纽约时间日期标签会错开半天稳妥做法是读CSV时统一指定时区后再转成date粒度。2.2 特征工程与归一化从价格水平到可学习的波动结构直接拿收盘价进LSTM是新手最常犯的错。比特币从几千美元涨到几万美元价格水平本身随时间漂移模型学到的往往是价格高就继续涨这类伪规律。正确做法是把价格转换成对数收益率、滚动波动率等相对量让模型学的是走势形态而不是绝对数值。import numpy as np from sklearn.preprocessing import StandardScaler df[btc_return] np.log(df[btc_close]).diff() df[gold_return] np.log(df[gold_close]).diff() df[btc_vol] df[btc_return].rolling(5).std() * np.sqrt(252) # 去掉最前面没有完整特征的几天 df df.dropna() feature_cols [btc_return, gold_return, btc_vol] scaler StandardScaler().fit(df[feature_cols]) scaled scaler.transform(df[feature_cols])对数收益率的优势在于可加性和对极端值的压缩多日累计收益可以直接求和。滚动波动率按252个交易日做年化数值通常在0.3到1.5之间能让LSTM感知当前市场是否处于高波动状态这对后续决策层很有用。这里的StandardScaler直接在全量数据上fit正式项目里应该先切分训练集再fit否则均值和方差混入了测试集信息属于典型的样本外数据泄漏。特征含义计算方式btc_return比特币对数日收益率log(close_t / close_{t-1})gold_return黄金对数日收益率log(close_t / close_{t-1})btc_vol比特币5日波动率年化rolling(5).std() * sqrt(252)2.3 滑动窗口与时间顺序切分LSTM每个样本的真实形状LSTM输入形状固定为(样本数, 时间步数, 特征数)。时间步数也叫lookback窗口是这个模型里影响最大的参数之一。窗口取30天约等于一个交易月对日频序列来说既不只看几天噪声也不会把几个月前的旧波动强行拉进当前预测。def make_windows(data, window30): X, y [], [] for i in range(window, len(data)): X.append(data[i - window:i]) # 过去30天的特征 y.append(data[i]) # 第31天的特征向量 return np.array(X), np.array(y) X, y make_windows(scaled, window30) # 按时间顺序 7:2:1 切分不能做随机 shuffle split1 int(len(X) * 0.7) split2 int(len(X) * 0.9) X_train, y_train X[:split1], y[:split1] X_val, y_val X[split1:split2], y[split1:split2] X_test, y_test X[split2:], y[split2:]y取的是窗口后一天的三维特征向量训练时用均方误差直接比较预测向量与真实向量。切分这里刻意不用train_test_split也不做shuffle因为时间序列一旦打乱顺序模型等于在训练集里看到了未来片段验证时会得到一个虚高的表现换到实盘就现原形。如果要预测未来5日累计收益率而不是次日收益率只需把y改成数据窗口后5天的差分形式窗口构造代码不用大改。3. LSTM模型选型与训练单向优先、hidden边界和梯度防线3.1 为什么选LSTM而不是GRU或Transformer遗忘门输入与复杂度的匹配先回答一个被反复搜索的问题LSTM遗忘门的输入到底是什么。遗忘门接收的是上一时刻隐藏状态h_{t-1}与当前输入x_t拼接后的向量经过sigmoid后输出0到1之间的保留比例再与上一时刻细胞状态c_{t-1}逐元素相乘。这就是LSTM解决长期依赖的机制遗忘门决定旧信息留多少输入门决定新信息写多少两者共享同一份拼接输入但各有独立权重。那为什么不直接用GRUGRU把遗忘门和输入门合并成更新门参数更少训练更快在很多时间序列任务上表现与LSTM接近。但LSTM在金融数据里被反复验证的一个优势是显式的记忆单元c_t模型表现异常时可以盯c_t的变化判断是否出现了记忆丢失。Transformer的问题则出在样本规模上日频数据通常只有几百到一千多个窗口注意力矩阵很难在这么小的样本上学出稳定模式泛化反而不如结构受限的LSTM。项目起步阶段把LSTM当作固定基线之后再对比GRU或Transformer是成本最低的做法。单向还是双向也值得说清。双向LSTM每个时间步同时看到前后文短窗口内信息确实更全但参数量翻倍在样本量不大的金融序列上更容易过拟合。热词里经常出现单向lstm这个检索词说明实际项目里单向是默认选项。这个模型默认保持单向只有在双向版本测试集指标明显胜出时才考虑切换。3.2 模型定义与训练循环配置一套能稳定收敛的LSTM模型结构不必复杂单层LSTM加一个全连接输出头即可作为基线。隐藏单元取64特征数3时间步数30参数量约1.7万规模与样本量匹配。import torch import torch.nn as nn class AssetLSTM(nn.Module): def __init__(self, n_features, hidden64, dropout0.2): super().__init__() self.lstm nn.LSTM( input_sizen_features, hidden_sizehidden, num_layers1, batch_firstTrue ) self.head nn.Sequential( nn.Linear(hidden, 16), nn.ReLU(), nn.Dropout(dropout), nn.Linear(16, n_features) ) def forward(self, x): out, _ self.lstm(x) return self.head(out[:, -1, :]) # 取最后时间步输出batch_firstTrue表示输入形状是(批量, 窗口长度, 特征数)。out[:, -1, :]取的是第30天对应的隐状态因为预测目标正是窗口后一天。head里加一层带ReLU的16维中间层让输出头具备一点非线性能力又不至于引入过多容量。训练循环需要把损失函数、优化器、学习率调度和梯度裁剪组合在一起只堆epoch数跑固定轮次不是工程做法。loss_fn nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience5 ) for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() pred model(xb) loss loss_fn(pred, yb) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() model.eval() with torch.no_grad(): val_loss loss_fn(model(X_val_tensor), y_val_tensor) scheduler.step(val_loss) if epoch % 10 0: print(fepoch {epoch:3d} train {loss.item():.5f} val {val_loss.item():.5f})学习率1e-3在日频序列上并不算小配合factor0.5的衰减和patience5的监控是常见组合。梯度裁剪max_norm1.0是最容易被跳过的防线——收益率序列偶尔出现极值一次大梯度步就可能把隐藏状态推向不可恢复的区域。隐藏单元从64加到128会让训练Loss下降但样本量支撑不住测试集反而变差。参数项推荐值调整方向hidden64样本量小于2000时不建议盲目加大num_layers1堆到2层必须配合dropoutdropout0.2验证Loss降但测试Loss升说明过拟合调到0.3lr1e-3训练震荡时改1e-4batch_size32小批量梯度更稳大批量省时间3.3 早停、预测还原与随机种子把模型输出变回价格的过程固定轮次训练不可取应该监控验证损失并保留最优权重。耐心的设置要在还没收敛就停和记住噪声之间取平衡。best_loss, best_epoch, best_state float(inf), -1, None for epoch in range(200): # 训练和验证逻辑同上这里聚焦早停判断 if val_loss best_loss: best_loss val_loss best_epoch epoch best_state {k: v.clone() for k, v in model.state_dict().items()} elif epoch - best_epoch 15: break # 连续15轮未刷新最优提前终止 model.load_state_dict(best_state)15轮耐心是常见选择耐心太大会让模型记住训练集噪声太小可能停在半山腰。保存best_state而不是最后一轮权重保证加载到的是验证集上的最优状态。实验还要固定随机种子并开启PyTorch确定性模式否则同一份源码每次跑出来的指标不一致项目说明里的复现结果也就失去了意义。预测结束后要把归一化向量还原回收益率再换算成预测价格scaled_pred model(X_test_tensor[:1]).detach().numpy() pred_vec scaler.inverse_transform(scaled_pred) last_close df[btc_close].iloc[-1] pred_btc_return pred_vec[0][0] pred_btc_price last_close * np.exp(pred_btc_return)这条还原逻辑对应前面用对数收益率的设定exp(预测收益率) × 上一收盘价就是预测价格。注意inverse_transform还原的是三维向量取价格时只需要第一列。整个还原过程依赖训练集上fit的scaler对象保存模型权重时要一并保存scaler否则测试阶段无法还原。4. 综合决策模型把两个预测结果翻译成仓位信号4.1 动态阈值给比特币和黄金分别定义值得动的幅度预测结果是一串浮点数不能直接用符号当信号。比特币日波动率经常是黄金的2到4倍同一个固定阈值在黄金上产生过多信号、在比特币上又太迟钝。常见做法是用过去20日收益率标准差乘以系数构成动态阈值让阈值跟随市场波动自动缩放。# 以最近20日波动率为基准构造动态阈值 btc_th df[btc_return].rolling(20).std() * 0.5 gold_th df[gold_return].rolling(20).std() * 0.5 signal pd.DataFrame(indexdf.index) signal[btc] np.sign(pred_btc - btc_th) signal[gold] np.sign(pred_gold - gold_th)系数0.5的含义是预测收益率超过过去20天波动的一半才动作。市场活跃时阈值跟着放大冷清时跟着收窄比固定百分比更适应行情。信号取-1、0、1三种值0表示预测值落在阈值区间内不构成交易条件。调参也很直接想减少交易次数就把系数升到0.8想更敏感就降到0.3也可以针对两个资产分别配置不同系数。4.2 信号冲突仲裁比特币看涨、黄金看跌时仓位听谁的综合决策模型与单一资产预测的本质区别在仲裁机制。最简单的一致性方法只在两者同向时开仓但会浪费大量单边行情。更常用的仲裁规则是同向时直接沿用信号方向反向时让低波动率资产决定仓位因为低波动资产的预测信噪比通常更高。def combine_signal(sig_btc, sig_gold, vol_btc, vol_gold): if sig_btc sig_gold: return sig_btc # 信号矛盾时低波动资产的方向胜出 return sig_btc if vol_btc vol_gold else sig_gold这个规则的本质是波动率倒数加权的信号级简化高波动意味着预测方差大矛盾出现时押注低波动资产方向胜率更高。更进一步可以去掉硬仲裁把仓位直接按连续信号加权比如总仓位等于0.5乘比特币信号加0.5乘黄金信号但信号已经是二值这样做收益不大。硬仲裁的优点是规则透明、回测结果容易解释适合这个项目的定位。资金分配建议从等权起步再对比风险平价方案。风险平价不是两个资产各50%而是按波动率倒数分配权重波动大的一方权重小让两个资产对组合的波动贡献接近。这样做的好处是即使某段时期预测连续出错组合回撤也会小一些。4.3 回测口径与绩效指标信号后移一天结果完全两样回测里最容易犯且最致命的错误是当天的信号当天成交。任何基于收盘数据计算的信号最早只能在下一交易日开盘执行。不把信号后移一天回测等于用未来价格做交易收益曲线会异常漂亮实盘全部失效。# 信号整体后移一个交易日模拟次日开盘执行 positions signal.shift(1).fillna(0) daily_ret positions.mul(df[[btc_return, gold_return]], axis0) portfolio_ret daily_ret.sum(axis1) * 0.5 # 两个资产各半仓 cum (1 portfolio_ret).cumprod() sharpe portfolio_ret.mean() / (portfolio_ret.std() 1e-9) * np.sqrt(252) mdd (cum / cum.cummax() - 1).min()shift(1)之后第t天的仓位由t-1天收盘后的信号决定第t天的收益率按这个持仓计算才是真实的可交易结果。手续费和滑点也要计入按加密货币现货双边0.1%、黄金现货点差约0.15%估算单次交易成本约0.25%。最简单的扣费方法是在仓位变化时按成交金额乘以0.0025直接从收益里扣除。没扣手续费的回测在信号频繁切换时会把年化收益高估一至两成。指标计算口径常见参考年化收益率累计收益^(252/天数) - 1需跑赢简单买入持有基准最大回撤累计净值相对最高点的最大跌幅30%以内相对健康夏普比率日均收益 / 日波动 × sqrt(252)大于1继续细调低于0.5换阈值回测还必须设对照组基准不是随机买卖而是一直持有比特币或一直持有黄金。只有模型策略的夏普和回撤同时优于简单基准综合决策模型才算真正成立。5. Walk-forward验证让样本外结论可靠的滚动重训方法5.1 滚动窗口重训与逐步外推静态切分的训练验证测试集只在开发期够用真要验证一个LSTM时间序列预测策略能不能上线标准做法是walk-forward验证窗口向前滚动每次用最近一段历史重新训练只预测紧邻的下一个片段。def walk_forward(X, y, train_size400, step30, test_len30): i train_size while i test_len len(X): X_tr, y_tr X[i - train_size:i], y[i - train_size:i] X_te, y_te X[i:i test_len], y[i:i test_len] yield X_tr, y_tr, X_te, y_te, i i steptrain_size决定每次训练回溯多少历史日频数据取400到800个窗口比较合理。step是重训间隔表示每隔多少天重新训练一次模型日频策略取30天是常见的中庸选择既不会训练过密又能跟上行情状态变化。把每一轮的样本外预测拼接起来得到的夏普比率和最大回撤才是接近实盘的估计。很多模型在静态测试集上漂亮一进入walk-forward就变平庸问题通常出在两处一是归一化时对全量数据做了fit滚动窗口反复训练时scaler参数被测试信息污染二是保存的权重没有随窗口更新一直在用几个月前的模型预测当前行情。跑完整段walk-forward之后再回看项目说明里的回测报告才知道哪些数字可以信。5.2 预测结果的方向校准比MSE更该先看的指标MSE低不代表方向判断准综合决策模型真正起作用的是预测值的正负和是否越过阈值。所以除了训练用的MSE还要单独统计方向命中率预测为正且实际为正或者预测为负且实际为负占全部样本的比例。预测收益率的中位数接近0但LSTM输出经常存在整体偏移这种偏差在反标准化后更明显。可以把测试集上预测值与真实值做回归看斜率斜率明显小于1说明预测被拉向均值模型在犹豫落到信号层面就表现为阈值频繁归零、整段交易次数太少。这时应该下调4.1节里的波动率系数或者把y从次日收益率改成未来3日累计收益率让预测目标本身就带一点趋势区分度。斜率大于1也不太妙说明预测对短期波动反应过度对应的调整方向是把窗口从30天拉长到45天给LSTM更多上下文做平滑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进