
做量化的人大概都经历过这么一段用均线、布林带、波动率阈值去给市场“分状态”刚开始觉得还挺清晰越用越觉得不对味。均线金叉了说是趋势可金叉之前那一段阴跌算什么波动率爆了才反应过来是“高风险状态”可仓位早就进去了。本质上这些经典工具都在做同一件事——用价格的历史形态去反推此刻的市场状态但推得又慢又模糊。后来我接触到隐马尔可夫模型HMM才意识到市场状态本身就是个“看不见的变量”我们观察到的涨跌、波动只是它表面吐出来的结果。与其自己拍脑袋定义什么算趋势、什么算震荡不如让模型自己把状态序列反推出来。这篇文章是“HMM识别市场结构”系列的第一部分先把原理、建模流程和状态解读讲透适合有Python基础、想往量化方向走一步的读者。咱们不聊虚的直接上逻辑和代码。1. 均线、波动率阈值为什么识别不了“市场状态”1.1 均线其实是个低通滤波器而不是状态机很多人把“价格在均线上方”当成趋势状态但均线本质上只是在做价格序列的平滑相当于一个低通滤波器——它会滤掉高频噪声但也会把转折点拖后一拍。我用沪深300日线做过一次回放把MA20、MA60叠到底部图上发现真正让我舒服的“趋势段”均线给出的进场信号往往已经走完了三分之一甚至一半。问题不在均线参数选得不对而在于它回答的问题错了。均线回答的是“价格比过去N天的平均值高还是低”这是个相对位置的度量。但“市场结构”是一个定性状态它不止包含方向还包含波动环境——同样是上涨低波动慢牛和高波动急涨显然不是一种状态同样是下跌阴跌磨人和放量跳水背后是完全不同的微观结构。均线只有一个维度根本表达不了这些区别。1.2 波动率阈值是“事后确认器”用波动率阈值切状态看起来更合理一些ATR高就是活跃ATR低就是沉闷。但这里有个死结阈值谁定10%分位数还是20%分位数换一段样本区间阈值就要跟着变因为你面对的不是稳态过程波动率本身会漂移。更麻烦的是波动率阈值是“事后确认”逻辑——先有波动阈值才被触发本质上是一次延迟很高的回归。我做过一个实验把沪深300的20日滚动波动率按历史分位数切成三个等级再和行情叠加看。结果发现波动率等级切换的尖峰往往对应的是单日大阴线或大阳线而不是结构性转变。也就是说阈值法则会把“事件”误判成“状态”。趋势的结束往往不是波动率瞬间放大而是一个状态平淡地、悄悄过渡到另一个状态这种过渡里没有什么阈值能被干净地触发。1.3 状态在概率框架里才有意义换个角度市场状态本来就是个不可直接观测的隐变量。你永远不会看到一个叫“震荡”的实体你只能看到当天涨了0.3%成交量缩了振幅收窄。但你可以相信这些观测值背后存在一个状态发生器它决定收益率的均值和方差。我不知道今天处于哪个状态但我可以用概率去推断它——这种推断框架正好是隐马尔可夫模型的看家本领。HMM把问题翻转了不再由我自定义“什么是趋势”而是假设收益率序列由若干个状态轮流生成每个状态有自己的均值、方差和转移概率然后让模型去拟合数据把最可能的状态序列吐出来。这样做的好处是状态边界是数据驱动的而不是人为参数驱动的。我最初看到模型跑出来的状态切分时最大的感受是它分出来的“震荡段”比我手工定义的要窄得多也更贴近真实的市场感知。2. 一个赌场比喻讲透HMM的工作原理2.1 庄家换骰子你只能看到点数理解HMM最经典的切入点是赌场作弊的故事。假设赌场里有两颗骰子一颗正常骰子每面概率各1/6一颗作弊骰子6点出现的概率被调高到50%。庄家会偷偷换骰子——用一段时间正常骰又换作弊骰再换回来。作为玩家你只能看到每一轮掷出的点数完全看不到庄家当前用哪颗骰子。这里的“骰子编号”就是隐藏状态“掷出的点数”就是观测值。庄家换骰子的规律就是状态转移概率每颗骰子掷出不同点数的规律就是发射概率观测概率。市场场景下隐藏状态就是“牛市”“震荡”“熊市”之类的市场结构观测值就是每天的收益率、成交量、波动率这些我们能拿到的数据。这个比喻的价值在于它天然匹配了市场识别的两个关键问题第一状态本身不可见但会影响观测分布第二状态之间有转移规律不是完全随机跳变。换句话说今天处于“震荡”的概率不仅取决于今天的收益率还取决于昨天处于什么状态——这正好抓住了市场结构有连续性的直觉。2.2 五元组定义与符号约定HMM用五个要素描述完整模型状态集合S {s1, s2, ..., sK}对应K种市场状态观测集合O {o1, o2, ..., oT}对应每天的收益率特征初始状态概率π代表最开始时处于各状态的概率状态转移矩阵AA[i][j]表示从状态i转移到状态j的概率发射概率矩阵B或发射概率分布表示在某个状态下观测到各种数据值的概率。数学上给定模型λ (A, B, π)观测序列O的出现概率可以写成对所有可能状态路径求和的形式P(O|λ) Σ_{S} P(O, S|λ)实际计算用的是前向算法避免指数级枚举。这个公式看着有点吓人但背后的直觉是如果我知道一条完整的状态路径比如“牛市→牛市→震荡→熊市”我就能把每条路径上的发射概率乘起来得到该路径产生这段观测序列的得分然后把所有路径得分求和就是整个模型解释这段数据的总置信度。HMM的“训练”就是调整这些概率参数让这个总置信度尽可能高。2.3 三个基本问题与市场结构识别的映射任何HMM应用都绕不开三个经典问题解码问题已知模型参数和观测序列推断最可能的状态序列。用Viterbi算法对应“我今天给历史行情标状态”的需求。评估问题已知模型参数和观测序列计算观测序列出现的概率。用前向算法对应“比较哪个模型更能解释当前行情”。学习问题已知观测序列估计模型参数转移概率、发射概率、初始概率。用Baum-Welch算法本质是EM对应“让模型自己从行情中学会状态划分”。我第一次读到这里时有点绕后来把它对应到实际流程就清楚了先用学习问题训练出模型参数再用解码问题给历史数据标注状态最后用评估问题做模型对比。三个问题覆盖了从建模到应用的全链路。3. 观测变量设计收益率与波动率的配合3.1 单一正态分布描述不了市场收益率如果直接用单一正态分布去拟合股票日收益率你会发现两个问题第一峰度远高于正态分布也就是大涨大跌的极端值比正态预测的多得多第二方差存在明显聚集高位波动之后还是高位波动。这说明收益率数据不是单一分布生成的更像是几个不同均值和方差的分布混合在一起。HMM天然适合这种数据因为它是多个“发射分布”的切换模型。如果你设定K个状态实际上就是在拟合一个K分量混合分布每个分量有自己的均值和方差。但和普通混合模型不同的是HMM把时间先后关系也算进来了不是随机抽取分量而是状态按转移概率在时间轴上切换。这一点对行情识别极其重要因为市场状态明明就有持续性。普通GMM把第1天和第100天当成独立抽样HMM则保留了两者之间的状态关联。3.2 状态均值代表方向偏好方差代表波动环境观测变量的设计直接决定状态的含义。我建议第一版先用日对数收益率作为唯一观测特征维度是1维模型简单、解释清晰某个状态的高斯分布均值是正还是负就是“这个状态倾向涨还是跌”方差大还是小就是“这个状态下市场振幅多大”。在实际项目里我通常会在后面加一个特征当日振幅的对数或者滚动20日波动率。但注意加特征会让状态的经济含义变模糊——均值解释方向方差解释幅度如果同时塞进两个特征EM算法划分出来的状态就不一定再是“方向波动”的干净组合。所以初学者第一版强烈建议老老实实只用1维收益率。等你能熟练解读状态后再扩展特征维度。3.3 要不要标准化取决于你想怎么解释结果机器学习里默认要标准化特征HMM这里要想清楚。如果观测特征是日收益率本身你不要做标准化因为均值本身就携带方向信息标准化后均值为0你没法从状态均值判断这是牛市还是熊市。我的做法是只做异常值截断winsorize把日收益率超出样本2.5倍标准差的值拉到边界防止单日极端行情扭曲状态参数但保留均值和方差的原始量纲。如果你加入的是波动率或成交量这类量纲差异大的特征那可以考虑做标准化但代价是状态解释要从“绝对水平”变成“相对位置”整体解读难度会明显上升。这里没有绝对对错但一定要清楚你的取舍。4. 从数据清洗到模型训练hmmlearn实操4.1 数据准备以沪深300日线为例第一步是拿数据。我用的是沪深300指数日线你可以用上证指数、创业板指甚至个股日线替换。数据获取用akshare或者tushare都可以下面以akshare为例import akshare as ak import pandas as pd import numpy as np df ak.stock_zh_index_daily(symbolsh000300) df[date] pd.to_datetime(df[date]) df df.set_index(date).sort_index() price df[close].astype(float) ret np.log(price / price.shift(1)).dropna()这里用对数收益率而不是简单收益率原因有两个一是对数收益率在时间上可加便于做多期累积二是它在统计上更接近对称分布极端正负值的量级更一致拟合高斯分布时更稳定。洗数据时要注意一个细节指数日线里偶尔会有停牌或数据缺失导致的收益率为0的异常点。你不需要全删但建议把绝对值超过20%的单日对数收益率检查一遍大概率是数据源拼接或者除权未复权造成的错误值这类脏点会严重干扰状态估计。4.2 GaussianHMM参数设置与含义hmmlearn库的GaussianHMM是我们最常用的实现from hmmlearn.hmm import GaussianHMM model GaussianHMM( n_components4, # 状态数量 covariance_typefull, # 协方差形式1维时 full 与 diag 等价 n_iter200, # EM最大迭代次数 tol0.01, # 对数似然提升小于该值则停止 random_state42 # 固定随机种子保证可复现 ) model.fit(ret.values.reshape(-1, 1))这几个参数里n_components是要重点说的。它不是超参数搜索搜出来的最好结合你对市场的理解来定。我常用的四种状态定义是快速下跌、稳步上涨、低波动震荡、高波动下跌。如果你只关心牛熊切换设2个状态也够用如果你想把“高波动上涨”和“低波动上涨”分开就设4个。状态数设太多容易过拟合行情稍微变化就有新的“伪状态”冒出来实战下来我很少用到5个以上。covariance_type你有四个选择spherical球面协方差、diag对角、full完整、tied所有状态共享协方差。1维收益率场景下它们结果基本一样但如果后续扩展到多维特征我建议full因为不同市场状态下各特征的相关关系很可能不同。代价是参数数量变多需要更多数据支撑。4.3 多次随机初始化避免局部最优Baum-Welch算法本质是EM算法对初始值敏感跑出来的可能是局部最优而不是全局最优。我踩过的坑是同一个数据random_state42和random_state99跑出来的状态序列差异很大后验概率都声称“拟合很好”但状态边界完全不同。我的做法是循环尝试多个随机种子选对数似然最高的模型from hmmlearn.hmm import GaussianHMM best_model None best_score -np.inf best_seed None for seed in range(50): m GaussianHMM( n_components4, covariance_typefull, n_iter200, tol0.01, random_stateseed ) m.fit(ret.values.reshape(-1, 1)) score m.score(ret.values.reshape(-1, 1)) if score best_score: best_score score best_model m best_seed seed注意这里用训练集score来选模型是为了解决“同一个初始化问题下的局部最优”不是做交叉验证所以不会引入严重过拟合。如果不同随机种子跑出来的对数似然差异很大说明你的状态数设多了或者数据量不足模型对初始化过于敏感这时候要回头调整n_components而不是强行多跑几次撞运气。4.4 状态解码Viterbi全局最优模型训练完成后给每个交易日打上状态标签states best_model.predict(ret.values.reshape(-1, 1))predict内部走的是Viterbi算法寻找整个序列上最可能的联合状态路径。它利用的是全部数据包括“未来”的信息。这意味着它适合做历史数据的状态回顾、策略复盘、信号分析但不适合直接拿来实盘。这一点很多人一开始没意识到我会在后面的陷阱章节详细展开。如果想看每一天处于某个状态的后验概率用proba best_model.predict_proba(ret.values.reshape(-1, 1))返回的是一个T×K的矩阵第t行第k列代表第t天处于状态k的后验概率。这个矩阵很有用你可以借此观察状态切换是干脆利落还是拖泥带水——如果大部分日子某个状态的后验概率都很接近1说明状态划分清晰如果长期在0.4到0.6之间徘徊说明数据并不能很好地支撑你的状态数设定。5. 四状态模型下沪深300会如何被切分5.1 参数输出的初读先看均值再看方差训练完成后把模型参数打印出来第一步是看每个状态的均值和方差。我用近十年沪深300日线跑出来的典型结果不同数据区间会略有差异大致长这样状态编号日收益率均值日收益率方差大致含义0-0.00230.00035快速下跌 / 恐慌10.00060.00012温和上涨 / 慢牛20.00010.00005低波动震荡3-0.00080.00025阴跌 / 高波动下行状态编号是模型随意排的每次训练可能都不一样所以千万不要用数字去记忆状态含义要用“均值符号方差大小”去判断。均值明显为负的状态就是下跌状态方差大的那个下跌状态就是急跌方差小但均值为负的就是阴跌。我见过不少人拿到模型第一件事就看状态转移图、算转移矩阵但我的建议是先把发射分布的均值和方差理解透。因为转移矩阵是状态之间关系的刻画如果连单个状态的含义都没搞明白后面全是空中楼阁。5.2 用转移矩阵计算状态平均持续期市场结构识别里状态持续期是一个非常实用的指标。它可以从自转移概率推算出来如果状态i的自转移概率是a_ii那么该状态的平均持续时间为1 / (1 - a_ii)天。trans best_model.transmat_ print(trans) for i in range(4): stay_prob trans[i, i] avg_days 1 / (1 - stay_prob) print(fState {i}: 自转移概率 {stay_prob:.3f}平均持续 {avg_days:.1f} 天)这个公式是几何分布的无记忆性质推导出来的它给每个状态一个直观的时间尺度。比如某个状态自转移概率为0.95说明平均持续20个交易日约一个月自转移概率只有0.7说明平均只持续3到4天更接近短促冲击而非结构性状态。拿这个指标对照真实行情会很有意思低波动震荡状态通常自转移概率很高因为它持续时间长恐慌下跌状态通常自转移概率偏低因为急跌难以持续很快会被政策底或超跌反弹打断。如果你的模型中某一个“状态”平均持续只有2到3天大概率它不是稳定的市场结构更像是单日事件堆出来的假状态要警惕。5.3 与真实行情叠加关键点位的手动验证把状态序列和沪深300收盘价画在一起我建议一定做这件事因为底层训练结果只有画在图上你才真正信任它。import matplotlib.pyplot as plt fig, ax plt.subplots(figsize(14, 6)) ax.plot(price.index, price.values, colorblack, alpha0.6, labelclose) ax2 ax.twinx() ax2.scatter(price.index[1:], states, s10, alpha0.5, cmapviridis)你会看到几个很典型的现象2015年股灾那一段模型会在下跌初始切换进入高方差负均值状态但注意它不是在最高点切换的而是在刚开始下跌的一两根K线之后。这个延迟不是bug而是HMM用收益率观测的必然结果——状态必须从数据中“确认”之后才能切换。理解这个延迟是在实盘应用里最重要的心理准备。2020年3月全球流动性危机期间状态标签快速切到高波动下跌但恢复也很快这与市场V型反转的现实是吻合的。震荡市里模型会长时间停留在低波动、均值接近0的状态偶尔穿插几天高波动状态然后迅速回到低波动状态。这种切换节奏正好对应了“震荡市中突然的脉冲”。5.4 后验概率的“钝化”现象另一个值得观察的现象是后验概率的钝化。在一段充分确立的趋势中predict_proba给出的状态概率往往非常接近1模型非常自信。但在状态切换点前后概率会在一段时间内交替波动。如果你把后验概率超过0.6视为状态确认那切换信号与Viterbi标注的时刻会存在几天差异。这个钝化本质上来自HMM对历史数据的平滑——它用前向后向算法迭代任何一个时间点的状态估计都被过去和未来的观测同时影响。状态切换点附近信息量不足概率就会在多个状态之间摊开。实操上我会把后验概率做成观测指标而不是直接当信号用。比如某天下跌状态的后验概率从0.3升到0.8这比单纯看当天阴线更有信息量因为它是综合了历史序列后的输出。6. 实战中的坑与对策6.1 状态标签的语义漂移前面提过状态编号每次跑出来可能都不一致。这个坑在跨样本验证时会更严重。你拿2010到2015年的数据训练出模型状态编号0是“下跌”再拿2015到2023年的数据训练状态编号0可能会变成“上涨”。解决方法是按状态均值排序做语义对齐先定义一个基准排序比如按均值从小到大排均值最小的是“最熊”状态最大的是“最牛”状态每次训练完都按这个规则重新映射编号。这样不同模型的同一编号才真正代表相似的状态。如果不做这一步你后续做状态转移对比、样本外验证都会乱掉。6.2 Viterbi解码的未来信息泄露上面已经提到过predict用的是全局Viterbi它看到的是整段历史加整段未来。如果你拿它做策略信号然后说“这个策略净利润很高”那其实是在作弊因为状态切换点已经隐含使用了未来数据。要做严肃的样本外评估有两种方案一种是滚动式训练每次只在过去的窗口上训练然后用训练好的模型去标注未来一段数据再把窗口往后推另一种是实现在线滤波只用当前时刻及之前的数据计算当前时刻的状态概率。hmmlearn里的predict_proba做的是平滑不是滤波所以它同样有未来信息。关于在线滤波的具体实现我打算在第二部分单独展开这里先提醒大家任何策略回测只要状态标签来自全样本Viterbi解码你的绩效就高估了。6.3 状态数选择的过拟合诱惑n_components设大一点训练集的对数似然一定会更高这是数学上的必然。但你会观察到一种现象状态数多了之后某些状态只有几千个样本里的几十个极端日比如某一次千股跌停被单独劈成“极端恐慌状态”。这在历史回放上很好看但预测价值很低因为这种状态稀有到几乎没有统计意义。我的经验做法是设定2到5个状态用BIC或AIC做一个初步筛选但不要盲从指标。BIC等指标只是参考更重要的是状态的可解释性——每个状态都应该是你能在行情图上指着说“这是牛市”“这是阴跌”“这是窄幅震荡”的程度。如果某个状态你看半天都说不出它代表什么那它就是过拟合的产物直接删掉减少状态数。6.4 极端值对高斯发射分布的影响高斯分布对离群值极其敏感。一个单日暴跌10%的对数收益率会直接把某个状态的方差拉到离谱的水平进而污染状态划分。处理办法不复杂训练前做winsorize把日收益率截断在样本的0.5%到99.5%分位数之间。但有一个度的问题。如果你截断得太猛收益率尾部的信息就丢了模型会把“急跌”和“阴跌”混在一起截断得太轻极端值又抢主导权。我目前的做法是只截断超过3倍标准差的极值并把它拉向边界而不是删除这样既保留了分布形态又抑制了单点过度影响。6.5 状态切换与你的交易系统如何衔接最后说一个战略层面的坑。HMM输出的是状态识别结果不是交易信号。哪怕模型完美识别出“当前处于高波动下跌状态”也不等于你应该立刻做空或者清仓。因为状态识别概率和市场择时胜率之间还有一个预期收益的逻辑链条要打通你要定义清楚在识别出某个状态后下一段时间收益率的条件分布是否值得交易。我个人的经验是HMM最有价值的位置是作为“条件变量”——用它过滤其他策略的信号。比如趋势策略只在“低波动上行状态”期间开仓网格策略只在“低波动震荡状态”期间运行。这样每个子策略面对的都是相对同质的市场微观结构而不是把一套逻辑硬套在所有行情上。这种用法对模型的容错度要求也低一些某个状态偶尔识别错一次只是少做一笔交易不会因为单次误判造成灾难性亏损。如果你试图用状态切换做全仓择时就要考虑识别延迟和状态震荡的代价那是一次严重的误判就会覆盖很多次正确判断的高风险玩法。先聊到这里。这个系列的第一部分重点是建立对HMM的基本认知和训练流程把状态解读的地基打牢。后面我会继续写第二部分内容包括实时滤波概率的实现、多维特征扩展、以及HMM状态与风险平价或趋势策略结合的具体回测框架。在实际项目中HMM给我的最大启发是不要总想着一口吃成胖子把复杂行情一股脑压进一个模型里先把状态这一个环节做扎实后续很多策略都能受益。