上一篇我们学会了用打分法在横截面上选股。但选股策略有个隐含前提:市场整体别出大事——2018 年那样的单边熊市里,再好的低波组合也难独善其身。本篇把镜头拉回大盘:能不能先判断市场现在处于什么「状态」,再决定用什么武器? 趋势市用动量追击、震荡市用网格收割、熊市里拿好现金——这就是市场状态识别(Regime Detection)的思想。
本篇目标
- 能用「均线结构 + 波动率状态」搭建一个三态分类器:趋势上 / 震荡 / 趋势下
- 能用 Python 实现状态识别,并把状态序列画在价格图上做可视化着色
- 能统计每个状态的历史出现频率、平均持续时长与后续收益特征
- 能把状态识别接到策略选择上:不同状态挂不同子策略
- 能说清状态识别的滞后本质:它是「确认」不是「预测」
核心概念
三态框架
把市场粗分为三种状态(Regime):
| 状态 | 特征 | 适配策略 |
|---|---|---|
| 趋势上(Bull) | 价格站上长期均线、均线多头排列 | 动量、轮动(第 27/30 篇) |
| 震荡(Range) | 价格围绕中枢反复穿越均线 | 网格、均值回归(第 28/29 篇) |
| 趋势下(Bear) | 价格跌破长期均线、均线空头排列 | 降仓、避险、空仓等待 |
判断规则要事先定死。本篇用最朴素的版本:
- 趋势上:收盘价 > 200 日均线,且 60 日均线 > 200 日均线(双重确认);
- 趋势下:收盘价 < 200 日均线,且 60 日均线 < 200 日均线;
- 其余情况归为震荡(两个条件不同向时,市场多半正在变盘,当作震荡处理更保守)。
为什么用均线结构 + 波动率两条腿
只用价格相对均线的位置,会把「缓慢贴着均线爬」的行情误判成趋势;只用波动率,又分不清「高位缩量下跌」和「低位缩量筑底」。两条腿结合——方向看均线结构、烈度看波动率分位——能区分出「温和的趋势」与「暴烈的震荡」,为仓位轻重提供第二个维度:波动率高时即使看多也应降低仓位(第 48 篇会展开成完整的仓位公式)。
划重点:状态识别是确认器,不是预言家。200 日均线确认熊市时,下跌通常已经走了一段——这是它换来的低误报率。别指望它买在最低卖在最高,它的价值是让你「大部分时间站在正确的一边」。
状态的统计画像
识别出状态序列后,先别急着挂策略,先做三件统计:每个状态历史占比多少(趋势市和震荡市大约几几开)、平均持续多久(决定子策略的最小运行周期)、状态后收益如何(趋势上状态内的日收益均值应显著为正,否则规则本身有问题)。这三个数字是「了解你的敌人」的必修课。
完整代码
本篇依赖:uv pip install pandas numpy matplotlib。数据:akshare 拉沪深 300 指数日线,失败时用固定种子模拟数据(含一段造出来的牛熊转换)。
import numpy as np
import pandas as pd
# ---------- 1. 数据获取(akshare 优先 + 本地 fallback) ----------
def get_index():
try:
import akshare as ak
df = ak.index_zh_a_hist(symbol="000300", period="daily",
start_date="20150101", end_date="20241231")
df = df.rename(columns={"日期": "date", "收盘": "close"})
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date")[["close"]]
except Exception:
rng = np.random.default_rng(33)
n = 2437
# 造三段行情:熊(2015-2018) → 震荡(2019-2021) → 牛(2022-2024)
drift = np.concatenate([np.full(1000, -0.0006),
np.full(700, 0.0),
np.full(737, 0.0008)])
vol = np.concatenate([np.full(1000, 0.018),
np.full(700, 0.010),
np.full(737, 0.014)])
ret = rng.normal(drift, vol)
idx = pd.bdate_range("2015-01-01", periods=n)
return pd.DataFrame({"close": 3000 * np.exp(np.cumsum(ret))}, index=idx)
px = get_index()
# ---------- 2. 三态分类器 ----------
def regime_label(px, long_w=200, mid_w=60, vol_w=60, vol_hi=0.8):
"""返回 DataFrame: state(1=上,0=震荡,-1=下), vol_pct(波动率分位)"""
out = pd.DataFrame(index=px.index)
out["ma_long"] = px["close"].rolling(long_w).mean()
out["ma_mid"] = px["close"].rolling(mid_w).mean()
out["ret"] = px["close"].pct_change()
out["vol"] = out["ret"].rolling(20).std() * np.sqrt(252) # 20日年化波动
out["vol_pct"] = out["vol"].rolling(vol_w).rank(pct=True) # 历史分位
above = (px["close"] > out["ma_long"]) & (out["ma_mid"] > out["ma_long"])
below = (px["close"] < out["ma_long"]) & (out["ma_mid"] < out["ma_long"])
out["state"] = np.where(above, 1, np.where(below, -1, 0))
return out.dropna(subset=["ma_long"])
reg = regime_label(px)
# ---------- 3. 状态统计画像 ----------
def regime_profile(reg):
s = reg["state"]
print("状态占比:", s.value_counts(normalize=True)
.rename({1: "趋势上", 0: "震荡", -1: "趋势下"}).round(3).to_dict())
# 平均持续时长:数连续同状态的段
runs = (s != s.shift()).cumsum()
dur = s.groupby(runs).agg(["first", "size"])
for st, name in [(1, "趋势上"), (0, "震荡"), (-1, "趋势下")]:
d = dur[dur["first"] == st]["size"]
if len(d) > 0:
print(f"{name}: 平均持续 {d.mean():.0f} 个交易日, "
f"最长 {d.max()} 天")
# 状态内日收益特征
for st, name in [(1, "趋势上"), (0, "震荡"), (-1, "趋势下")]:
r = reg.loc[s == st, "ret"]
print(f"{name}: 日均收益(示意) {r.mean():+.3%}, "
f"年化波动 {r.std() * np.sqrt(252):.1%}")
regime_profile(reg)
# ---------- 4. 可视化:价格 + 状态着色 ----------
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 7), sharex=True,
height_ratios=[3, 1])
for st, color in [(1, "tab:red"), (0, "tab:gray"), (-1, "tab:green")]:
mask = reg["state"] == st
ax1.scatter(reg.index[mask], reg["close" if "close" in reg else "ma_long"][mask]
if False else px.loc[reg.index[mask], "close"],
c=color, s=3, label={1: "趋势上", 0: "震荡", -1: "趋势下"}[st])
ax1.plot(reg.index, reg["ma_long"], "b-", lw=0.8, alpha=0.6, label="200日线")
ax1.legend(); ax1.set_title("沪深300 市场状态识别(示意)")
ax2.plot(reg.index, reg["vol_pct"], "k-", lw=0.8)
ax2.axhline(0.8, color="r", ls="--", lw=0.8)
ax2.set_ylabel("波动率分位"); ax2.set_xlabel("日期")
plt.tight_layout(); plt.savefig("regime.png", dpi=120); plt.show()
# ---------- 5. 简单应用:状态驱动的仓位 ----------
def regime_position(reg, base=1.0):
"""趋势上满仓、震荡半仓、趋势下空仓;波动率高再打折"""
pos = reg["state"].map({1: base, 0: base * 0.5, -1: 0.0})
pos = pos.where(reg["vol_pct"] < 0.8, pos * 0.7) # 高波动 → 打7折
return pos.shift(1) # 当日状态 → 次日仓位,无未来函数
pos = regime_position(reg)
strat = (pos * reg["ret"]).dropna()
bh = reg["ret"].dropna()
for name, r in [("状态择时", strat), ("买入持有", bh)]:
nav = (1 + r).cumprod()
dd = (nav / nav.cummax() - 1).min()
print(f"{name}: 总收益(示意) {nav.iloc[-1] - 1:.1%}, "
f"最大回撤 {dd:.1%}")
代码逐段解释
- 数据:模拟数据故意造出熊→震荡→牛三段结构,让状态图有明显的故事可讲;真实数据下 akshare 的
index_zh_a_hist接口可能随版本调整,以官网为准。 - 分类器:200 日与 60 日双均线定方向(双重确认降低误报),20 日年化波动率经 60 日滚动
rank(pct=True)转成「当前波动在自身历史里的分位」。np.where嵌套实现三态:两个条件同向才认趋势,否则震荡。 - 统计画像:
(s != s.shift()).cumsum()是「数连续段」的经典技巧——状态一变段号加一,再按段号 groupby 就能统计每段的长度。占比、时长、状态内收益三个维度共同验证规则是否合理:若「趋势上」状态内日均收益不为正,说明规则有问题而非市场有问题。 - 可视化:上板价格按状态着色(红=趋势上、灰=震荡、绿=趋势下),叠一条 200 日线;下板画波动率分位与 0.8 警戒线。一眼看出状态切换的位置与高波动时段的分布。
- 仓位应用:状态映射仓位(1 / 0.5 / 0),高波动分位再打 7 折,
shift(1)保证当日状态次日生效——这是把识别器变成策略的最小改动。对比买入持有,主要改善通常体现在回撤上。
示例输出(示意,模拟行情结果):
状态占比: {'趋势下': 0.31, '震荡': 0.27, '趋势上': 0.42}
趋势上: 平均持续 121 个交易日, 最长 402 天
震荡: 平均持续 34 个交易日, 最长 96 天
趋势下: 平均持续 98 个交易日, 最长 297 天
趋势上: 日均收益(示意) +0.052%, 年化波动 15.2%
震荡: 日均收益(示意) +0.004%, 年化波动 9.8%
趋势下: 日均收益(示意) -0.061%, 年化波动 21.5%
状态择时: 总收益(示意) 38.9%, 最大回撤 -19.7%
买入持有: 总收益(示意) 21.4%, 最大回撤 -38.6%
示意结果里最该看的是三态的收益画像:趋势上日均 +0.05%、趋势下日均 −0.06%、震荡接近零——方向正确、幅度分层,说明分类器抓的确实是有效结构。择时组合回撤近乎减半,代价是收益确认的滞后(熊市确认时空仓前先吃了段跌幅)。
常见坑与小练习
常见坑
- 参数(均线窗口)反复调:200 日是全球通用的默认值,有其行为金融学基础(一年锚定效应)。为优化结果把它调成 173 天,第 25 篇的教训就会重演。
- 状态切换太频繁导致策略鞭打:加入「最短持续时间」约束(如新状态须连续维持 N 天才确认切换)能有效抑制抖动,代价是更滞后。
- 把状态识别当预测器:它只能确认「已经发生的结构」。用它抄底摸顶,等于用确认器干预言家的活,必翻车。
- 忽视了状态的样本依赖:A 股 2015 前后波动结构差异巨大,只用近 5 年数据标定波动率分位阈值,遇到极端年份会失灵。
- 三个状态挂了策略却没挂退出规则:状态切换是入场逻辑,每个子策略仍需自己的止损(第 35 篇),两层风控缺一不可。
小练习
- 给分类器加「最短持续 10 个交易日」的确认规则:新状态需连续 10 天才生效,对比状态切换次数与收益画像的变化。
思路提示:对
state列做滚动众数(rolling(10).apply(lambda x: x.mode()[0]))或手写计数器。 - 把波动率打折的阈值从 0.8 改成 0.6 / 0.9,观察仓位曲线与回撤的变化,体会「风控松紧」的权衡。
思路提示:只改
regime_position里where的阈值参数,画仓位曲线对比最直观。 - 用状态序列给第 30 篇的二八轮动加一层开关:趋势下状态直接持债券 ETF,其余状态正常运行轮动。
思路提示:在轮动逻辑外面包一个
if state == -1: 持债的判断,注意状态也要shift(1)。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。