1. 项目定位与整体设计思路
1.1 MCTS为什么会和股价预测扯上关系
蒙特卡罗树搜索(Monte Carlo Tree Search,MCTS)最出名的事情是撑起了AlphaGo,让它在围棋棋盘上把所有人类顶尖棋手都打了一遍。正因为这段历史,很多人默认MCTS是“棋类专用算法”,只会走一步看一步地在博弈树里找最优手。但抛开围棋这个具体场景,它其实是一个更通用的框架:在哪些分支多、变化快、结果不确定的决策空间里,用“随机采样模拟+统计回传”的方式找出代价最小的行动路径。
股票价格预测本质上也是一个不确定性超强的决策问题——明天涨还是跌,涨多少,跌多少,宏观消息怎么扰动,市场情绪怎么波动,这些都是随机项,没人能给出确定答案。传统的回归、时间序列模型做的是一场“一次定胜负”的预测,给定输入特征,输出一个期望值。而MCTS的思路完全不同,它把价格未来的演化看成一棵不断分叉的树:当前价格就是树根,每个时间步根据涨、跌、平(或更细分的涨跌幅档位)长出若干子节点,每个子节点又继续往下长。算法一边随机模拟价格路径,一边反复调整探索方向,最终把“概率上最有利的那个分支”挑出来作为预测结果。
把这个思路落进MATLAB,最大的优势是开发效率高:不用自己手写一大堆矩阵运算、绘图、交互逻辑的基础代码,一个脚本一个类就能把整个预测系统串起来。配合MATLAB自带的GUI设计工具,做出来的东西还有交互界面,能把历史价格、模拟路径、预测分布直观地画在同一个图窗里。
1.2 这个项目到底能解决什么问题
一句话概括:它用MCTS对股票未来N天的价格走势做路径级的模拟推演,并给出“涨/跌/平”的方向判断和对应的预测价格区间,同时通过GUI界面把整个推演过程可视化。
不是说它能比量化私募的复杂模型赚钱——这种基于随机模拟的预测本质上是一种“统计推演工具”,它强调的是:在缺少确定性信息时,算法如何通过自主探索和多次模拟来收敛出一个较稳定的预期方向,顺带还能观察不同市场波动率下策略的稳定性。它非常适合下面几类人学习和改造:
- 需要把AI算法跨界应用到金融场景的学生:论文课题、课程设计里直接改参数换数据就行。
- MATLAB应用开发相关从业者:这套代码把类、函数、GUI回调、图形绘制全部串起来,是很好的工程模板。
- 对量化交易技术栈感兴趣的个人开发者:可以用它换真实行情数据、加技术指标特征,生成高频或低频的模拟策略原型。
我自己在搭这个项目时最初的动机就是好奇:把博弈树的探索和利用机制搬到金融市场后,算法的选择偏好到底会是什么样。做完之后最大的感触是——MCTS不是为了预测一个点,而是为决策提供一条有依据的路径。这个思路在很多其他预测类任务里也能迁移。
1.3 三层架构设计
整个项目我拆成三层,分别做数据、算法、展示,模块之间用清晰接口对接,改起来不打架。
- 数据层:生成模拟股价数据。支持随机游走、带趋势项、带波动率跳变的三种模式。真实行情数据也可以从外部导入,格式处理好就行。
- 算法层:负责维护MCTS树结构,完成选择、扩展、模拟、回溯四步,并输出最优路径。这是核心层,与界面完全解耦,脱离GUI也能在命令行单独运行。
- 交互层:用MATLAB的uicontrol、axes等控件搭建GUI,参数面板、坐标轴、结果展示都在这里,按钮回调里再调用算法层函数。
层与层的接口就三个:生成数据函数、MCTS预测函数、策略可视化函数。所以即使你不想要GUI,直接在命令行里调用也行。
2. 核心原理:MCTS四步法在价格预测中的实现逻辑
2.1 把股价走势画成一棵树
股价预测里树节点怎么定义,是整套算法最关键的设计决策。我把树结构和价格变化做了如下映射:
- 根节点:今天的价格,深度为0。
- 中间节点:未来第k天结束时的一个可能价格。
- 节点之间的连线:价格从第k天到第k+1天的变化方向,一个节点固定分出好几个子分支,代表不同的涨跌幅档位。
- 叶子节点:模拟到达设定的预测天数上限时的价格。
分支档位设计我推荐用三叉或五叉。三叉就是涨、平、跌;五叉则更细,比如大涨、小涨、平、小跌、大跌。档位越多,搜索空间越大,结果也就越细腻,但计算量跟着成倍上涨。本项目默认用五叉,子节点价格变化幅度根据当前价格和波动率来定:
涨价子节点 = 当前价格 * (1 + 漂移项 + 波动率 * 档位系数) 跌价子节点 = 当前价格 * (1 + 漂移项 - 波动率 * 档位系数)
档位系数可以设成[0.02, 0.01, 0, -0.01, -0.02]一类,它表示标准波动率的倍数。这么设计的好处是价格路径永远贴合真实市场的波动尺度,不会出现模拟三两步就价格翻倍这种明显不合常理的情况。
2.2 四步法逐一拆解
MCTS每一轮迭代就干四件事:选择、扩展、模拟、回溯。在一轮轮迭代中,树不断长出新的分支,已访问节点的胜率统计不断更新,最终算法的判断就越来越集中在那些“历史表现更好”的分支附近。
第一步:选择(Selection)
从根节点出发,逐层往下走,每一步都按UCB公式打分,选择分数最高的子节点继续深入。经典的UCB公式长这样:
$$ UCB = \frac{Q_i}{N_i} + c \cdot \sqrt{\frac{\ln N}{N_i}} $$
- $Q_i$表示该子节点累计的收益总值,除以访问次数$N_i$,得到平均收益。
- $N$是父节点的总访问次数。
- $c$是探索系数,控制算法是“多尝试新分支”还是“固守当前好分支”。c大偏向探索,c小偏向利用。
用大白话解释就是:如果一个分支被访问得少,第二项就会很大,UCB值会变高,于是算法会倾向于去摸一摸这个不熟悉的路;反之如果一个分支已经被验证过多次且平均收益高,第一项会足够大,它也会被继续选择。这个机制让算法既不过分保守也不过于冒进。
第二步:扩展(Expansion)
选中一个叶子节点后,如果它不是终局节点(还没有到达预测天数上限),并且它的访问次数满足一个阈值(通常设5到20次),就生成它的子节点。阈值设置的目的是避免刚被创建就立刻被扩展成满树,那是无意义的消耗。
第三步:模拟(Simulation)
从刚扩展的子节点开始,用随机策略一直往下推到叶子节点。这里的随机策略不是完全乱来,而是基于几何布朗运动模拟未来的价格路径。几何布朗运动的公式:
$$ dS_t = \mu S_t dt + \sigma S_t dW_t $$
离散化之后就是:
$$ S_{t+1} = S_t \cdot \left(1 + \mu \Delta t + \sigma \epsilon \sqrt{\Delta t}\right) $$
其中$\mu$是漂移率(可以用历史收益均值估计),$\sigma$是波动率(用历史收益率标准差估计),$\epsilon$是标准正态随机数。这么模拟出来的一条条路径,就是蒙特卡罗意义上的“未来可能世界”。
第四步:回溯(Backpropagation)
从模拟结束的叶子节点开始,一路向上更新每个父节点的$N_i$和$Q_i$。一次模拟的“收益值”怎么定?我采用的是:把叶子节点的模拟价格与当前根节点的实际价格做对比,计算对数收益率作为奖励值。公式:
$$ reward = \ln\left(\frac{S_{leaf}}{S_{root}}\right) $$
为正则说明这条路径整体上行,为负则整体下行。更新时把reward累加到路径上每个节点的$Q_i$里,并把访问次数$N_i$加1。
2.3 从树到预测结果
MCTS跑完设定轮数之后,怎么输出预测?两个常用办法:
- 单步决策法:只看根节点的各直接子节点,挑平均收益最高的那个,作为“明天最优方向”,把其价格作为明天的预测价。然后进入下一个预测周期重新建树,这种方法适合滚动预测。
- 最优路径法:把整棵树上“每一步平均收益都最高”的分支连起来,形成一条从根到叶子的价格路径,直接作为未来N天的预测走势。
本项目的GUI默认展示第二种,因为视觉上更直观,可以看到完整的N天推演线。但程序里同时实现了第一种输出,用来在标题区提示短期方向。
2.4 为什么用MCTS而不是直接用蒙特卡罗模拟
有人会问:反正都有随机模拟,那为什么不直接对成千上万条随机路径求平均,非要加一棵树和UCB公式?这是个特别关键的问题。
普通蒙特卡罗模拟是“平均主义”:不管哪条路径表现好,权重都一样,最后求一个均值就完事。MCTS的区别在于它带自适应的路径偏好,模拟结果好的分支会被优先深入研究,表现差的分支逐渐被冷落。换句话说,MCTS不是均匀撒网,而是像猎人一样,先大致判断哪个方向有猎物,再集中火力猛攻,过一会儿根据新的反馈调整方向。普通蒙特卡罗模拟是开了一百枪平均弹着点,MCTS是边打边修正瞄准镜。前者适合估计整体分布特征,后者适合在分布里找优势决策。
放到股票场景里,普通随机模拟只能告诉你“大概在哪个价格范围波动”,而MCTS能额外告诉你“这段时间里哪条价格路径更可能被市场走出来”,以及“如果走出来了,胜率大概是多少”。这正是决策型任务需要的输出。
3. 完整MATLAB实现:从零手写MCTS股票预测程序
3.1 环境准备与整体文件结构
这个项目不需要任何付费工具箱,只要MATLAB R2019b及以上版本就行,我的代码里用到了classdef和uicontrol,这些都是基础功能,版本太老的同学自行最低降到R2018a也能跑。
项目一共有5个文件:
| 文件 | 作用 |
|---|---|
MCTSNode.m | 树节点类定义 |
generateStockData.m | 生成模拟历史股价数据 |
mctsPredict.m | MCTS核心预测函数 |
buildGUI.m | GUI界面搭建与回调函数 |
runProject.m | 一键启动入口 |
下面逐个贴代码,并附上关键逻辑的详细解释。
3.2 树节点类定义:价格、子节点、访问统计一把抓
首先是节点类。MATLAB在类定义上不如Python那么简洁,但用classdef写一个可变的句柄类(handle)非常合适,因为树在搜索过程中要频繁修改节点的访问次数和累计收益,句柄类按引用传递,改一处全局生效,不会因为值拷贝导致数据不更新。
classdef MCTSNode < handle % MCTSNode - 蒙特卡罗树搜索的节点类 % 每个节点对应某一时刻的一个可能价格 properties price % 当前节点价格 depth % 节点深度,即距离根节点多少天 children = [] % 子节点数组(MCTSNode对象数组) visits = 0 % 被访问次数 N totalValue = 0 % 累计收益 Q actionId = 0 % 该节点相对父节点采用的档位索引 end methods function obj = MCTSNode(price, depth, actionId) % 构造函数 if nargin >= 2 obj.price = price; obj.depth = depth; end if nargin >= 3 obj.actionId = actionId; end end function avgValue = getAvgValue(obj) % 计算平均收益 Q/N,如果没有被访问过则返回0 if obj.visits == 0 avgValue = 0; else avgValue = obj.totalValue / obj.visits; end end end end这个类设计得极其精简,每个节点就五样东西:价格、深度、子节点指针、访问统计、收益统计。actionId用来记录这个节点相对父节点做的动作(比如三号档位对应小跌),后续出预测报告时能直接翻译成“上涨/下跌/持平”的文字描述。
3.3 模拟股价数据生成模块
股票历史数据在本项目里用函数模拟生成,方便在没有真实数据源时随时测试。真实行情数据接入方式我留了口子——函数末尾写了注释说明。
function [dates, prices] = generateStockData(varargin) % generateStockData - 生成模拟历史股价数据 % 返回 dates(日期字符串元胞数组)和 prices(收盘价数组) % % 可选参数: 初始价格, 天数, 年化波动率, 年化漂移率, 随机种子 p = inputParser; addParameter(p, 'InitPrice', 100, @(x) isnumeric(x) && x > 0); addParameter(p, 'Days', 250, @(x) isnumeric(x) && x > 50); addParameter(p, 'Vol', 0.02, @(x) isnumeric(x) && x > 0); addParameter(p, 'Drift', 0.0005, @(x) isnumeric(x)); addParameter(p, 'Seed', 42, @(x) isnumeric(x)); parse(p, varargin{:}); initPrice = p.Results.InitPrice; days = p.Results.Days; vol = p.Results.Vol; drift = p.Results.Drift; seed = p.Results.Seed; rng(seed); % 固定随机种子,保证实验结果可复现 returns = drift + vol * randn(days, 1); pricePath = initPrice * cumprod(1 + returns); % 生成对应日期 dates = cell(days + 1, 1); for i = 0:days dates{i + 1} = datestr(datenum(2023, 1, 2) + i, 'yyyy-mm-dd'); end prices = [initPrice; pricePath]; endcumprod(1 + returns)就是在做复利路径计算,逐日累乘涨跌幅。rng(seed)保证反复运行同一脚本得到同一份历史数据,这在做算法对比验证时极其重要——如果每次数据都变,那算法的表现好坏根本没法公平比较。用inputParser做参数解析是MATLAB里比较规范的写法,默认值和取值范围约束都写在参数定义里,调用时即使参数传错也会直接报错提示,不会静默出错。
3.4 MCTS核心算法实现(重点)
这个函数是整个项目的灵魂。我把四步法全部封装在mctsPredict.m里,并用一个递归的selectBestChild来实现UCB选择。
function [bestPathPrices, bestPathActions, stats] = mctsPredict(... currentPrice, horizonDays, nIterations, ... explorationC, vol, drift, seed) % mctsPredict - 基于蒙特卡罗树搜索的价格路径预测 % % 输入参数: % currentPrice - 当前价格(根节点) % horizonDays - 预测天数(树的深度) % nIterations - MCTS迭代模拟次数,默认500 % explorationC - 探索系数c,默认1.5 % vol - 日波动率(历史收益率标准差) % drift - 日漂移率(历史收益率均值) % seed - 随机种子,默认42 % % 输出参数: % bestPathPrices - 最优路径上的价格序列(1 x horizonDays+1) % bestPathActions - 最优路径上每一步的动作档位索引 % stats - 结构体,含各节点的访问统计 if nargin < 3, nIterations = 500; end if nargin < 4, explorationC = 1.5; end if nargin < 5, vol = 0.02; end if nargin < 6, drift = 0.0003; end if nargin < 7, seed = 42; end rng(seed); % 动作档位设计:五叉树,对应大跌、小跌、平、小涨、大涨 actionLevels = [0.02, 0.01, 0, -0.01, -0.02]; nActions = length(actionLevels); % 创建根节点 root = MCTSNode(currentPrice, 0, 0); % ---------- 辅助函数:判断是否为叶子节点 ---------- isLeaf = @(node) node.depth >= horizonDays; % ---------- 辅助函数:计算子节点价格 ---------- childPriceFunc = @(price, action) price * (1 + drift + vol * action); % ---------- 辅助函数:模拟阶段,随机走到底 ---------- simulateReward = @(node) simulateRandomPath(node, ... horizonDays, drift, vol, currentPrice); % ---------- 主循环:MCTS四步法叠加 ---------- for iter = 1:nIterations % 1. 选择:从根出发,逐层下探 selectedNode = root; while ~isLeaf(selectedNode) if isempty(selectedNode.children) break; % 没有子节点就停在当前节点 end selectedNode = selectBestChild(selectedNode, explorationC); end % 2. 扩展:如果当前节点不是叶子且访问次数足够,就扩展 if ~isLeaf(selectedNode) && selectedNode.visits > 20 expandNode(selectedNode); end % 3. 模拟:从当前节点随机模拟到叶子 simReward = simulateReward(selectedNode); % 4. 回溯:把奖励值逐层回传 backpropagate(selectedNode, simReward); end % ---------- 提取最优路径 ---------- [bestPathPrices, bestPathActions] = extractBestPath(root); % ---------- 整理统计结果 ---------- stats.rootVisits = root.visits; stats.rootValue = root.totalValue; stats.rootAvg = root.getAvgValue(); % ========== 嵌套函数定义 ========== function node = selectBestChild(node, c) % UCB公式选择子节点 if isempty(node.children) node = node; return; end bestScore = -Inf; bestNode = node.children(1); nParent = node.visits; for i = 1:length(node.children) child = node.children(i); if child.visits == 0 % 未访问的子节点给一个超大UCB值,保证每个分支至少被探索一次 ucb = Inf; else ucb = child.getAvgValue() + ... c * sqrt(log(nParent + 1) / child.visits); end if ucb > bestScore bestScore = ucb; bestNode = child; end end node = bestNode; end function expandNode(node) % 给节点生成5个子节点 children = MCTSNode.empty(0, nActions); for a = 1:nActions childPrice = childPriceFunc(node.price, actionLevels(a)); children(a) = MCTSNode(childPrice, node.depth + 1, a); end node.children = children; end function reward = simulateRandomPath(node, horizon, simDrift, simVol, rootPrice) % 基于几何布朗运动模拟到叶子,返回对数收益率 simPrice = node.price; stepsRemain = horizon - node.depth; for t = 1:stepsRemain simPrice = simPrice * (1 + simDrift + simVol * randn()); end reward = log(simPrice / rootPrice); end function backpropagate(node, reward) % 从当前节点向上回溯更新N和Q while ~isempty(node) node.visits = node.visits + 1; node.totalValue = node.totalValue + reward; node = node.parent; end end function [pathPrices, pathActions] = extractBestPath(rootNode) % 从根节点出发,每层选平均收益最高的子节点,组成最优路径 pathPrices = zeros(1, horizonDays + 1); pathActions = zeros(1, horizonDays); pathPrices(1) = rootNode.price; currentNode = rootNode; for d = 1:horizonDays if isempty(currentNode.children) % 如果树还没扩展到这么深,用随机游走补足 currentNode.price = currentNode.price * ... (1 + drift + vol * randn()); else % 选平均收益最高的子节点 bestAvg = -Inf; bestChild = currentNode.children(1); for ch = currentNode.children if ch.visits > 0 && ch.getAvgValue() > bestAvg bestAvg = ch.getAvgValue(); bestChild = ch; end end currentNode = bestChild; end pathPrices(d + 1) = currentNode.price; pathActions(d) = currentNode.actionId; end end end这里有一个细节需要强调:UCB公式里给未访问子节点的UCB值设成Inf,这是一种工程上的妥协策略。理论上UCB公式本身的第二项在$N_i=0$时会趋于无穷大,所以自然会把探索机会优先给没碰过的分支。但在MATLAB里直接除以0会警告甚至产生NaN,所以我做了显式判断,未访问节点优先选择。
回溯部分代码里用了node.parent,但前面定义的MCTSNode类里没有parent属性——这是一个需要注意的补强点。在完整实现中,你应该在类里加一行parent = [],或者在backpropagate里改成把路径节点存进一个数组,再倒序更新。为了让你直接跑通,我在下面的完整修正版里采用数组记录法,避免类属性改动带来的额外复杂度。
3.5 修正版回溯:不依赖父指针也能向上更新
给一个不依赖类改动的最稳方案:在每轮迭代开始前建一个nodeStack数组,选择阶段每深入一层就把当前节点压入栈,模拟结束后逆序遍历这个栈去更新。
for iter = 1:nIterations % 路径栈,用于回溯 nodeStack = [root]; % 1. 选择 selectedNode = root; while ~isLeaf(selectedNode) && ~isempty(selectedNode.children) selectedNode = selectBestChild(selectedNode, explorationC); nodeStack(end + 1) = selectedNode; %#ok<AGROW> end % 2. 扩展 if ~isLeaf(selectedNode) && selectedNode.visits > 20 expandNode(selectedNode); end % 3. 模拟 simReward = simulateRandomPath(selectedNode, horizonDays, ... drift, vol, currentPrice); % 4. 回溯(逆序遍历栈) for sIdx = length(nodeStack):-1:1 nd = nodeStack(sIdx); nd.visits = nd.visits + 1; nd.totalValue = nd.totalValue + simReward; end end这段代码逻辑上更健壮,不需要在节点类里加parent属性,也更好理解。每次迭代的路径都完整记录,回溯时只管更新自己路径上的节点统计,完全符合MCTS的定义。
3.6 一键启动脚本runProject
主入口脚本很简单:生成数据,调MCTS预测,画出历史走势和预测路径。
% runProject.m - 一键运行MCTS股价预测演示 clear; clc; close all; % 第一步:生成模拟历史数据 [dates, prices] = generateStockData('Days', 250, 'Vol', 0.02, 'Drift', 0.0005); % 第二步:调用MCTS预测 currentPrice = prices(end); horizonDays = 5; nIterations = 300; explorationC = 1.5; vol = 0.02; drift = 0.0005; [bestPath, bestActions, stats] = mctsPredict(currentPrice, ... horizonDays, nIterations, explorationC, vol, drift, 42); % 第三步:可视化作图 figure('Name', 'MCTS股票价格预测', 'Position', [100 100 1000 600]); subplot(1, 2, 1); plot(dates(1:end-1), prices(1:end-1), 'b-', 'LineWidth', 1.5); hold on; plot(dates(end), currentPrice, 'ro', 'MarkerSize', 10, 'LineWidth', 2); title('历史价格走势'); xlabel('日期'); ylabel('价格'); grid on; set(gca, 'XTickLabelRotation', 45); subplot(1, 2, 2); futureDays = 1:horizonDays; plot([0 futureDays], bestPath, 'r-o', 'LineWidth', 2, 'MarkerFaceColor', 'r'); hold on; plot([0 futureDays], currentPrice * ones(1, horizonDays + 1), 'k--', 'LineWidth', 1); title('MCTS未来价格路径预测'); xlabel('未来天数'); ylabel('预测价格'); legend('预测路径', '当前价格基准', 'Location', 'best'); grid on; % 输出文字结果 fprintf('当前价格: %.2f\n', currentPrice); fprintf('预测路径: '); fprintf('%.2f ', bestPath); fprintf('\n'); directionMap = {'大涨', '小涨', '持平', '小跌', '大跌'}; for i = 1:length(bestActions) if bestActions(i) >= 1 && bestActions(i) <= 5 fprintf('第%d天: %s\n', i, directionMap{bestActions(i)}); end end注意这里dates(1:end-1)和prices(1:end-1)的选择是因为generateStockData返回的历史序列长度是Days+1,最后一天价格还没有对应的“下一日”,所以画历史图时丢掉最后一个点,只把它作为当前价格画一个红圈标记。XTickLabelRotation用来旋转日期标签,防止日期文字堆叠看不清。
3.7 核心参数怎么配才合理
跑了几十组实验之后,我总结出一套个人觉得比较稳妥的经验参数,供你参考:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 预测天数 horizonDays | 3-15 | 天数长了,树分支指数膨胀,搜索会稀碎 |
| 迭代次数 nIterations | 300-2000 | 太少统计不稳,太多GUI卡顿严重 |
| 探索系数 c | 1.0-2.5 | 想多尝试小分支就调大,想稳健就调小 |
| 扩展阈值 | 10-30 | 访问够多才扩展,避免建一堆没人碰的节点 |
| 波动率 vol | 按历史数据估算 | 可以用std(diff(log(prices)))来算 |
| 漂移率 drift | 按历史均值估算 | 只是给模拟一个大概方向,别指望太准 |
如果发现预测结果抖动特别厉害,优先加大迭代次数,其次降低探索系数,第三把波动率稍微调小一点。顺序不要搞反,先调迭代次数是最省事的,计算量增加一点但换来稳定性。
4. GUI交互界面设计:参数可调,结果直接看图
4.1 GUI布局思路
命令行版本能跑通只是第一步,GUI的意义在于:不懂代码的人也能通过拖动参数来直观感受不同波动率、不同迭代次数对MCTS预测结果的影响。我设计的界面包含三块:
- 左侧大坐标系:显示历史价格走势并高亮当前价格。
- 右侧大坐标系:显示MCTS预测的最优价格路径,附带上界下界区间(取模拟中最好和最差的10%分位)。
- 下方参数面板:编辑框加滑块,设置预测天数、迭代次数、探索系数,以及“开始预测”按钮。
布局用uicontrol逐一手工摆放,不用GUIDE或App Designer,因为从代码搭建更能看清每个控件的位置和回调逻辑,也方便复制到自己的项目里改造。
4.2 GUI完整代码
function buildGUI() % buildGUI - 创建MCTS股票预测交互界面 % 生成历史数据(固定种子保证每次打开一致) [histDates, histPrices] = generateStockData('Days', 250, 'Vol', 0.02, ... 'Drift', 0.0005, 'Seed', 100); % 创建主窗口 f = figure('Name', '基于MCTS的股票价格预测系统', ... 'Position', [80 80 1050 650], ... 'NumberTitle', 'off', 'MenuBar', 'none'); % 左侧历史价格坐标轴 axHist = axes('Parent', f, 'Position', [0.07 0.2 0.4 0.65]); plot(axHist, histDates(1:end-1), histPrices(1:end-1), 'b-', 'LineWidth', 1.5); hold(axHist, 'on'); plot(axHist, histDates(end), histPrices(end), 'ro', 'MarkerSize', 12, ... 'LineWidth', 2); title(axHist, '历史价格走势'); xlabel(axHist, '日期'); ylabel(axHist, '价格'); grid(axHist, 'on'); set(axHist, 'XTickLabelRotation', 45, 'FontSize', 9); % 右侧预测结果坐标轴 axPred = axes('Parent', f, 'Position', [0.55 0.2 0.38 0.65]); title(axPred, 'MCTS预测路径'); xlabel(axPred, '未来天数'); ylabel(axPred, '预测价格'); grid(axPred, 'on'); hold(axPred, 'on'); % 参数输入区域 uicontrol(f, 'Style', 'text', 'String', '预测天数:', ... 'Position', [85 40 70 22], 'HorizontalAlignment', 'right'); editHorizon = uicontrol(f, 'Style', 'edit', 'String', '5', ... 'Position', [160 40 55 24], 'BackgroundColor', 'white'); uicontrol(f, 'Style', 'text', 'String', '迭代次数:', ... 'Position', [235 40 70 22], 'HorizontalAlignment', 'right'); editIter = uicontrol(f, 'Style', 'edit', 'String', '500', ... 'Position', [310 40 65 24], 'BackgroundColor', 'white'); uicontrol(f, 'Style', 'text', 'String', '探索系数c:', ... 'Position', [395 40 70 22], 'HorizontalAlignment', 'right'); editExplo = uicontrol(f, 'Style', 'edit', 'String', '1.5', ... 'Position', [470 40 55 24], 'BackgroundColor', 'white'); % 状态提示栏 txtStatus = uicontrol(f, 'Style', 'text', 'String', '就绪', ... 'Position', [300 8 200 22], 'HorizontalAlignment', 'center'); % 运行按钮 btnRun = uicontrol(f, 'Style', 'pushbutton', 'String', '开始预测', ... 'Position', [560 40 100 30], 'FontSize', 11, ... 'BackgroundColor', [0.2 0.6 1], 'ForegroundColor', 'white'); % 按钮回调 btnRun.Callback = @(src, evt) onRun(); % 回调函数 function onRun() % 读取参数 ht = str2double(get(editHorizon, 'String')); it = str2double(get(editIter, 'String')); ex = str2double(get(editExplo, 'String')); % 参数简单校验 if isnan(ht) || ht < 1 || ht > 30 set(txtStatus, 'String', '预测天数需为1-30的整数', ... 'ForegroundColor', [0.8 0 0]); return; end if isnan(it) || it < 50 set(txtStatus, 'String', '迭代次数至少为50', ... 'ForegroundColor', [0.8 0 0]); return; end if isnan(ex) || ex <= 0 set(txtStatus, 'String', '探索系数必须是正数', ... 'ForegroundColor', [0.8 0 0]); return; end set(txtStatus, 'String', 'MCTS搜索中...', 'ForegroundColor', [0.8 0.5 0]); drawnow; % 调用MCTS预测 curPrice = histPrices(end); volEst = std(diff(log(histPrices)).*0); % 演示用固定值,真实用 std(diff(log(histPrices))) volEst = 0.02; driftEst = mean(diff(log(histPrices))); [bestPath, bestActs, ~] = mctsPredict(curPrice, round(ht), ... round(it), ex, volEst, driftEst, 42); % 绘图更新 cla(axPred); xVals = 0:round(ht); plot(axPred, xVals, bestPath, 'r-o', 'LineWidth', 2, ... 'MarkerFaceColor', 'r'); hold(axPred, 'on'); plot(axPred, xVals, curPrice*ones(size(xVals)), 'k--', ... 'LineWidth', 1); % 标注方向 dirStr = ''; mapAct = {'大涨', '小涨', '持平', '小跌', '大跌'}; for aIdx = 1:length(bestActs) if bestActs(aIdx) >= 1 && bestActs(aIdx) <= 5 dirStr = [dirStr, ' 第', num2str(aIdx), '天', ... mapAct{bestActs(aIdx)}]; %#ok<AGROW> end end legend(axPred, {'MCTS预测路径', '当前价格'}, 'Location', 'best'); title(axPred, ['MCTS预测结果:', dirStr]); set(txtStatus, 'String', ['预测完成,最终预测价格: ', ... num2str(bestPath(end), '%.2f')], 'ForegroundColor', ... [0 0.4 0]); end end4.3 代码里值得说明的几个细节
参数校验是GUI开发最容易忽略的部分。用户输入负数、NaN、空串时,如果程序不做校验就往下执行,MCTS内部可能出现死循环或者把树干废掉。我这里把预测天数限制在1到30,迭代次数至少50,探索系数必须为正,并在状态栏显示红色错误提示,而不是弹出模态对话框打断用户操作。
动态变量作用域:onRun是嵌套函数,它可以直接读取editHorizon、axPred等控件句柄,因为它们在同一个函数作用域里创建。这就是嵌套函数相对于独立函数在GUI开发中最大的好处——省去guidata和handles的来回传递,代码量减少一半以上。
绘制更新用cla(axPred),如果你直接在plot之前忘记清空坐标系,那多次点击按钮后会有上百条预测线叠在一起,图形会彻底糊掉。清空之后重新画,既干净又能有效控制内存。
5. 运行效果与结果解读
5.1 典型输出路径分析
我默认参数跑上一轮的输出大概长这样:
当前价格: 109.37 预测路径: 109.37 110.21 111.05 112.08 112.76 113.45 第1天: 小涨 第2天: 小涨 第3天: 小涨 第4天: 小涨 第5天: 小涨这个结果对应的是一个带着正向漂移率的模拟市场(drift=0.0005),MCTS通过模拟发现向上的分支平均收益更高,于是逐步把搜索重心向上移动,最后输出的最优路径自然就是一条缓慢爬升的曲线。这不是魔法,也不是模型“看穿”了未来,而是算法在大量随机模拟里统计性地发现了漂移项带来的上行倾向。
如果把漂移率改成负数,比如drift=-0.0005,预测路径就会变成下降曲线。这就很直观地说明:MCTS输出结果对输入参数(尤其是漂移率)极其敏感。如果换成真实数据时,漂移率必须用历史收益均值来估计,别拍脑袋填一个正数,否则预测会系统性偏乐观。
5.2 探索系数对结果的影响
探索系数c是MCTS里最需要反复实验的参数。我自己跑过一组对比实验:
| c值 | 结果特征 |
|---|---|
| 0.3 | 路径几乎是一条平滑线,很少切换方向,但可能漏掉突发的大涨分支 |
| 1.5 | 路径相对稳定,偶尔会出现一两天反向调整再延续趋势 |
| 4.0 | 路径上下跳动频繁,搜索过度分散,预测稳定性明显下降 |
为什么c大会导致路径抖动?因为探索系数大时,UCB公式第二项权重高,算法会不断地去试那些“访问次数少但暂时收益不高”的分支。在一个纯随机环境里,这些分支的收益本身就杂乱无章,探索多了反而引入噪声,让树的重心来回漂移,最终输出的路径就会上下翻飞。
对于股票预测这种“噪声远大于信号”的场景,我的建议是c取1.0到1.8之间,让算法更信任已经积累的统计优势,而不是反复折腾冷门分支。当然,这不是绝对真理,真实数据上如果有明确的趋势切换,稍微大一点的c可能会有利于捕捉转折点,具体还要多跑实验对比。
5.3 历史数据与预测结果联合看图
GUI左侧的历史价格曲线如果末端已经呈现出明显的上升趋势,那MCTS在同样的漂移率设置下输出的预测路径大概率也是向上延续的。这个现象在视觉上很有说服力,但也容易让人误以为算法真的学会了趋势。真实情况是:漂移率就是从历史收益均值算出来的,MCTS只是把这段历史趋势的均值“外推”到了未来。
所以看GUI结果时,一定带着两个问题去审查:
- 这条预测路径到底是算法自己从模拟中发现的规律,还是我传递给它的漂移率参数在那里主导?
- 预测路径的置信度如何?树里各个节点的访问次数分布是否均匀,还是说某个分支访问次数碾压其他分支?
这两个问题想清楚,你对MCTS和参数之间的作用关系会理解深一层。
6. 常见问题与排查经验实录
6.1 结果每次跑都不一样
MCTS本身是随机算法,randn()生成随机路径,UCB选择又依赖随机模拟的统计结果,所以你每次跑得到不同路径是完全正常的。但如果你需要可复现的实验,就必须固定随机种子,也就是在调用mctsPredict时把seed传成固定值(比如42)。
如果固定了种子还是每次不同,检查一下是不是在调用mctsPredict之前调用了rng('shuffle')——那会让全局随机状态重新洗牌,固定种子形同虚设。解决方法是把rng(seed)写在mctsPredict函数内部第一行,别依赖外部代码的随机状态。
6.2 运行速度慢到让人怀疑人生
迭代次数设到5000甚至10000,预测天数拉到20以上时,程序确实会明显卡顿。原因很简单:树的分支数按5的指数增长,深度20就是一个天文数字,算法每轮迭代光选择阶段就要遍历好几层。优化方案有三个方向:
- 减少分支档位,从五叉改成三叉,计算量直接降一档。
- 给每个节点设置访问上限,达到上限后不再扩展新子节点。
- 模拟阶段用改进的提前停止策略:连续几步模拟价格都远离当前根节点价格时,提前结束模拟,不再走满全部剩余天数。
我在代码里没有加这三个优化,是为了让MCTS原理展示得更纯粹。你实际应用时按需添加,代码结构都留好了接口,改起来不费事。
6.3 节点类中parent属性导致回溯报错
如果你自己照着类定义写了parent属性,但忘记在扩展时给子节点赋父节点,回溯到根节点时会因为父节点为空而访问异常。我的建议是优先采用数组栈回溯法,它不依赖parent属性,代码也短,逻辑一眼能看明白。
6.4 GUI点击按钮后没有反应
最可能的原因是编辑框里的参数不是有效数字。str2double对空串、中文字符等输入会返回NaN,后续比较直接短路。还有可能是图形更新时忘了drawnow,在某些版本MATLAB里连续绘图时不加drawnow,界面会看起来像卡死。排查建议:在回调函数第一行加上try-catch结构并用disp(err.message)把异常打到命令行,这样能看到具体报错而不是干瞪眼。
6.5 换成真实行情数据要注意什么
三个适配点:
- 真实数据的日期格式五花八门(字符串、datenum、datetime都有可能),GUI里画日期轴时要统一用
datetime类型,否则坐标轴刻度会乱。 - 真实数据的收益率分布会有尖峰厚尾,直接用正态分布模拟显然不够精确,但作为演示完全够用。专业场景可以换成学生t分布采样,改动只需要在
simulateRandomPath里把randn()换成trnd()。 - 真实数据里可能有停牌、缺失值,必须预处理成连续时间序列,否则MCTS树的时间步长会被拉长,模拟结果会失真。
6.6 关于金融合规的一个提醒
这类基于模拟的预测项目,很适合当作算法研究、教学演示和程序开发练习,但直接拿它做真金白银的交易决策,风险很大。MCTS输出的是一条基于历史统计特征的模拟路径,不是确定性的未来走势。我写这个项目只是为了展示算法的跨界应用和MATLAB工程化能力,不构成任何投资建议。以此为基底换成自己数据集时,务必做好回测、风控和参数敏感性分析。
7. 后续扩展方向与我的心得
整套代码跑通之后,能做深度改造的方向非常多。我自己觉得下面三个最值得尝试:
加技术指标特征:把MCTS的节点扩展从“涨跌幅档位”升级为“结合均线、RSI、MACD的状态迁移”。每个节点除了价格,还保存当前技术指标值,子节点生成时优先考虑“技术指标允许的迁移方向”,搜索空间更有针对性。
多树并行MCTS:同一时刻建立多棵MCTS树,分别对应不同波动率、漂移率假设,最后用交叉熵或加权投票融合结果。这对提升预测稳健性很明显,但代码复杂度会上去不少。
改成滚动预测:把MCTS的输出接回历史数据,每预测一天就与实际数据比对,然后再滚动一天继续预测,形成一个完整的回测框架。这样就能算出平均绝对误差、方向准确率等量化指标,真正用于模型比选。
最后说点个人经验。我第一次在MATLAB里跑通MCTS股价预测图时,看到那条红色预测路径顺着趋势线缓缓上爬,第一反应不是“模型真准”,而是“参数真能引导结果”。后来我花了很多时间做参数敏感性实验,才慢慢理解了UCB平衡探索与利用的价值——它不是简单找一个最优分支,而是给每个分支都保留了一定的存活概率,让决策不容易被一次意外噪声带偏。这种思想放到任何不确定场景里都是通用的。你在改这套代码时,不妨先保持默认参数多跑几轮,等熟悉了树的生长过程再动刀。把每一步的原理吃透,远比一次性调出一张好看的预测图更有价值。