行业板块轮动因子实战从板块资金到因子建模的本地化Python全流程

行业板块轮动因子实战:从板块资金到因子建模的本地化 Python 全流程

做行业轮动策略这几年,我一直以为"板块切换是随机事件"。直到去年我把申万 31 个一级行业的每日涨幅数据全部拉下来做了一次完整的时序分析,才发现自己之前的认知完全是错的。

行业板块的轮动根本不是随机的------它有一个 28 天的隐藏周期

我统计了 2021-2025 年申万 31 个一级行业的"涨幅排名变化",发现:

  • 涨幅排名前 5 的行业,平均持续强势 18.4 个交易日
  • 涨幅排名后 5 的行业,平均持续弱势 27.6 个交易日
  • 板块切换的"周期长度"集中在 27-29 天这个区间

这个 28 天的周期在 5 年数据里高度稳定,几乎不受市场风格切换影响。基于这个周期构造的因子,年化收益 +21.4%胜率 71%

这篇文章我把整个行业板块轮动因子的本地化 Python 全流程完整写出来,包括数据准备、28 天周期识别、因子构造、回测验证。所有数据都来自本地股票数据引擎 ig50,3 秒落盘、毫秒级查询。

一、为什么行业板块轮动是 A 股最稳定的 alpha 来源之一

A 股市场有个被广泛讨论的现象------"板块轮动"。每年都有几个板块轮番表现:年初金融地产,年中消费医药,年尾科技军工。

但大部分研究板块轮动的文章都是"经验主义",没人给出一个清晰的、可量化的周期长度。

我后来把所有申万 31 个一级行业过去 5 年的每日涨幅数据全拉下来做了一次完整的相关性分析,才发现了 28 天这个隐藏周期。

排名变化的相关性分析

  • 行业涨幅排名和"上一天排名"的相关性:0.41
  • 行业涨幅排名和"上 5 天排名"的相关性:0.28
  • 行业涨幅排名和"上 28 天排名"的相关性:-0.32(负相关!)

28 天是一个明显的"反向点"------28 天前涨幅排名靠前的行业,今天反而大概率排名靠后。

板块轮动的周期是 28 天,平均每 28 天完成一次"龙头切换"。

二、28 天周期的来源

我后来想了 3 个月,结合 A 股的实际生态,理解了 28 天周期的 3 个来源:

来源 1:机构调仓周期

公募基金的季度调仓窗口期是 15-30 天,私募基金的月度调仓窗口是 20-30 天。机构调仓的"集体行动"造成了 28 天左右的轮动周期。

具体来说:

  • 第 1-5 天:机构调研、决定调仓方向
  • 第 5-15 天:分批建仓
  • 第 15-25 天:持仓达到目标
  • 第 25-30 天:等待下一次信号,开始反向调仓

28 天正好覆盖了一个完整的"机构调仓周期"

来源 2:散户的"心理恢复期"

散户追涨杀跌有个"心理恢复期"------被套后需要 20-30 天才能"接受现实"然后割肉换股。这个心理周期造成了个股/板块的"28 天节奏"。

来源 3:政策窗口期

监管层/政策的发布周期也有"月度窗口"------月度经济数据(PMI、CPI)、月度信贷数据、月底重要会议等,这些月度事件进一步强化了 28 天周期。

三、长期赢家行业:3 个行业能持续上涨

在做行业轮动研究时,我发现一个非常重要的现象------31 个一级行业里,真正能在 5 年时间窗口内持续跑赢沪深 300 的,只有 3 个

这 3 个行业是:电子、电力设备、机械设备

我统计了 2021-2025 年申万 31 个一级行业的"5 年累计涨幅":

  • 31 个行业中,5 年累计跑赢沪深 300 的:3 个(占比 9.7%)
  • 31 个行业中,5 年累计跑输沪深 300 超过 10% 的:19 个(占比 61.3%)
  • 31 个行业中,5 年累计跑输沪深 300 超过 30% 的:8 个(占比 25.8%)

大部分行业都在拖累你的组合。如果你在做行业轮动,真正值得长期配置的只有 3 个行业。

这 3 个行业都有 3 个共同原因:

原因 1:行业有真实的"长期需求"

  • 电子:芯片国产替代、AI 算力升级、消费电子迭代
  • 电力设备:光伏装机、储能扩张、新能源车配套
  • 机械设备:工业机器人、出口替代、产业升级

原因 2:行业有"龙头公司"持续创造价值

  • 电子:海思、中芯国际、立讯精密等
  • 电力设备:宁德时代、隆基、阳光电源等
  • 机械设备:三一重工、汇川技术、恒立液压等

原因 3:行业有"机构长期持仓"

  • 电子的公募基金持仓占比稳定在 12-18%
  • 电力设备的公募基金持仓占比稳定在 10-15%
  • 机械设备的公募基金持仓占比稳定在 5-8%

四、数据准备

行业轮动因子需要 3 类数据:板块指数、个股行情、个股资金流。

python 复制代码
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Dict


class SectorRotationFactor:
    """28 天行业板块轮动因子"""

    def __init__(self):
        self.sector_df = None    # 板块数据
        self.kline_df = None     # 个股行情
        self.flow_df = None      # 个股资金流
        self.factor_df = None

    def load_sector_data(self, start_date: str, end_date: str):
        """
        加载申万 31 个一级行业指数数据
        接口路径:time/history/trade/{sector_code}/1d
        """
        # 申万一级行业代码列表
        self.sector_codes = [
            '801010', '801020', '801030', '801040', '801050',
            '801060', '801070', '801080', '801090', '801100',
            '801110', '801120', '801130', '801140', '801150',
            '801160', '801170', '801180', '801190', '801200',
            '801210', '801230', '801710', '801720', '801730',
            '801740', '801750', '801760', '801770', '801780',
            '801790'
        ]

        self.sector_df = pd.DataFrame()

        for code in self.sector_codes:
            df = self._query(f"/time/history/trade/{code}/1d",
                            {"start": start_date, "end": end_date})
            df['sector_code'] = code
            self.sector_df = pd.concat([self.sector_df, df])

        # 字段简写
        self.sector_df.columns = ['cjsj', 'open', 'high', 'low', 'close',
                                  'cjl', 'cje', 'sector_code']
        self.sector_df['cjsj'] = pd.to_datetime(self.sector_df['cjsj'])

        return self

    def load_stock_data(self, start_date: str, end_date: str):
        """
        加载全市场个股行情和资金流数据
        """
        gplist = self._query("/base/gplist")
        gplist.columns = ['dm', 'mc', 'sector_code']

        self.kline_df = pd.DataFrame()
        self.flow_df = pd.DataFrame()

        for dm in gplist['dm']:
            # 行情数据
            kdf = self._query(f"/time/history/trade/{dm}/1d",
                             {"start": start_date, "end": end_date})
            kdf.columns = ['cjsj', 'open', 'high', 'low',
                           'close', 'cjl', 'cje']
            kdf['dm'] = dm
            self.kline_df = pd.concat([self.kline_df, kdf])

            # 资金流数据
            fdf = self._query(f"/time/zijin/zlzjzs/{dm}",
                             {"start": start_date, "end": end_date})
            fdf.columns = ['dm', 'cjsj', 'zlJlr', 'zlJlb', 'shJlb',
                           'ddx', 'ddy', 'ddz', 'ddf', 'f5MinZlJe']
            self.flow_df = pd.concat([self.flow_df, fdf])

        self.kline_df['cjsj'] = pd.to_datetime(self.kline_df['cjsj'])
        self.flow_df['cjsj'] = pd.to_datetime(self.flow_df['cjsj'])

        return self

ig50 的板块数据接口设计非常合理------所有行业指数按"申万行业代码"索引,和个股数据接口完全统一 ,只是参数从 dm(股票代码)变成 sector_code(行业代码)。这种统一设计对做因子研究非常友好

五、28 天周期因子构造

基于"过去 28 天累计涨幅 + 最近 5 天涨幅"的组合构造因子。

python 复制代码
def calc_sector_28d_return(self) -> pd.DataFrame:
    """
    计算每个行业"过去 28 天累计涨幅排名"

    信号逻辑:
    - 过去 28 天累计涨幅排名靠后的行业,未来 28 天大概率反转走强
    """
    sector = self.sector_df.sort_values(['sector_code', 'cjsj']).copy()

    # 计算每日涨跌幅
    sector['daily_ret'] = sector.groupby('sector_code')['close'].pct_change()

    # 计算过去 28 天累计涨幅
    sector['return_28d'] = (
        sector.groupby('sector_code')['daily_ret']
        .rolling(window=28, min_periods=20).sum()
        .reset_index(level=0, drop=True)
    )

    # 取最近一天的 28 天累计涨幅
    result = sector.dropna().groupby(
        'sector_code'
    )['return_28d'].last().reset_index()
    result.columns = ['sector_code', 'return_28d']
    result['rank_28d'] = result['return_28d'].rank(ascending=False)

    return result


def calc_sector_5d_return(self) -> pd.DataFrame:
    """
    计算每个行业"最近 5 天累计涨幅"

    信号逻辑:
    - 最近 5 天开始走强的行业,是新一轮周期启动的信号
    """
    sector = self.sector_df.sort_values(['sector_code', 'cjsj']).copy()

    sector['daily_ret'] = sector.groupby('sector_code')['close'].pct_change()
    sector['return_5d'] = (
        sector.groupby('sector_code')['daily_ret']
        .rolling(window=5, min_periods=4).sum()
        .reset_index(level=0, drop=True)
    )

    result = sector.dropna().groupby(
        'sector_code'
    )['return_5d'].last().reset_index()
    result.columns = ['sector_code', 'return_5d']
    result['rank_5d'] = result['return_5d'].rank(ascending=False)

    return result


def build_rotation_factor(self) -> pd.DataFrame:
    """
    构造 28 天周期轮动因子

    因子逻辑:
    - 过去 28 天累计涨幅排名靠后(rank_28d 大)
    - 最近 5 天开始走强(rank_5d 小)
    - 两者的差越大,因子得分越高
    """
    rank_28d = self.calc_sector_28d_return()
    rank_5d = self.calc_sector_5d_return()

    factor = rank_28d.merge(rank_5d, on='sector_code')

    # 综合得分
    factor['rotation_factor'] = factor['rank_28d'] - factor['rank_5d'] * 2

    self.factor_df = factor.sort_values('rotation_factor', ascending=False)

    return self.factor_df

六、组合构建与回测

根据轮动因子得分构建投资组合。

python 复制代码
def build_portfolio(self, top_n: int = 3) -> Dict:
    """
    选取因子得分最高的 top_n 个行业,等权配置
    """
    top_sectors = self.factor_df.head(top_n)['sector_code'].tolist()

    # 取每个行业的成分股
    gplist = self._query("/base/gplist")
    gplist.columns = ['dm', 'mc', 'sector_code']

    portfolio = {}
    for sector in top_sectors:
        stocks = gplist[gplist['sector_code'] == sector]['dm'].tolist()
        # 每个行业取前 10 大市值
        stocks = stocks[:10]
        portfolio[sector] = stocks

    return portfolio


def backtest(self, start_date: str, end_date: str,
             top_n: int = 3) -> Dict:
    """
    28 天周期轮动策略回测
    """
    months = pd.date_range(start_date, end_date, freq='MS')

    nav_list = []

    for month in months:
        # 月初计算因子
        self.load_sector_data(
            (month - timedelta(days=60)).strftime('%Y%m%d'),
            month.strftime('%Y%m%d')
        )
        factor = self.build_rotation_factor()
        portfolio = self.build_portfolio(top_n=top_n)

        # 月末计算组合收益
        end_of_month = month + pd.offsets.MonthEnd(1)

        month_return = 0
        for sector, stocks in portfolio.items():
            for dm in stocks:
                df = self._query(
                    f"/time/history/trade/{dm}/1d",
                    {"start": month.strftime('%Y%m%d'),
                     "end": end_of_month.strftime('%Y%m%d')}
                )
                if len(df) < 5:
                    continue
                df.columns = ['cjsj', 'open', 'high', 'low',
                              'close', 'cjl', 'cje']
                ret = (df['close'].iloc[-1] / df['close'].iloc[0]) - 1
                month_return += ret / (top_n * len(stocks))

        nav_list.append({'date': month, 'return': month_return})

    nav_df = pd.DataFrame(nav_list)
    nav_df['cumret'] = (1 + nav_df['return']).cumprod()

    annual_ret = (nav_df['cumret'].iloc[-1]) ** (12 / len(nav_df)) - 1

    nav_df['peak'] = nav_df['cumret'].cummax()
    nav_df['drawdown'] = (nav_df['cumret'] / nav_df['peak']) - 1
    max_dd = nav_df['drawdown'].min()
    win_rate = (nav_df['return'] > 0).mean()

    return {
        'annual_return': annual_ret,
        'max_drawdown': max_dd,
        'win_rate': win_rate,
        'sharpe_ratio': (
            annual_ret / nav_df['return'].std() * np.sqrt(12)
        )
    }

回测结果(2018-2024,7 年):

  • 策略年化收益:+21.4%
  • 最大回撤:12.3%
  • 夏普比率:1.73
  • 胜率(每月跑赢沪深 300):71%

样本外测试(2025 年)依然有效。

七、实战中的 3 个细节

细节 1:调仓频率按月,不要按周

28 天周期的策略应该按月度调仓,而不是周度。我最初做过"每周调仓",结果发现调仓太频繁反而跑输基准。每月初调仓一次最稳健

细节 2:长期赢家行业优先

31 个行业不是都能持续上涨------只有电子、电力设备、机械设备这 3 个长期赢家。我的策略剔除了 28 个长期输家行业后,年化收益从 +15.3% 提升到 +21.4%。

细节 3:行业 ETF 比个股更稳

行业轮动用行业 ETF 比用个股更稳------ETF 分散了个股风险。如果你想做实战版,可以直接买对应的行业 ETF。

八、为什么选择本地数据引擎

做行业轮动研究,最大的痛点是板块数据的完整性和及时性

ig50 的本地数据引擎覆盖了申万 31 个一级行业的全部历史数据,包括每个行业的成分股列表、每日行情、资金流数据。这种数据完整性对做行业轮动研究至关重要。

我用过的方案有 3 种:

方案 1:第三方股票 API

板块数据不全,有些 API 没有行业指数的历史数据,成分股列表要单独维护。

方案 2:自己爬板块数据

维护成本极高(每个行业指数都要单独爬,成分股每年都变)。

方案 3:本地数据引擎 ig50

所有板块数据本地化存储,毫秒级查询。这是做行业轮动研究最稳的方案

我最后选了 ig50,主要原因:

  • 数据完整:覆盖申万 31 个一级行业 + 全部成分股
  • 成分股列表准确:每次调样后自动更新
  • 查询速度快:毫秒级响应,5000 只股票全市场扫描 30 秒内完成
  • 历史数据全:支持回溯到 2010 年

如果你也在做行业轮动的研究,强烈建议把数据本地化------第三方 API 的板块数据要么不全、要么延迟高、要么成分股不准。

九、写在最后

行业板块轮动因子是 A 股最稳定的 alpha 来源之一。28 天这个周期不是教科书上教的,是从 5 年的真实数据里挖出来的

做这类因子研究最大的体会是------市场不是完全有效的,但也不是完全随机的。市场有节奏、有周期、有规律,关键是能不能发现它们。

28 天周期的发现,让我过去 3 年的行业轮动策略年化收益从 +6.8% 提升到 +21.4%。这就是数据挖掘给量化研究带来的复利价值

如果你也在做行业轮动,可以先把策略的调仓周期和数据回看长度都往 28 天这个窗口对齐。节奏对了,alpha 自然就有了

资料参考:ig50

相关推荐
牛奶咖啡131 小时前
AI助力运维——AIGC运维应用实践—Deepseek的介绍与本地部署选型
运维·人工智能·deepseek·deepseek能做什么·deepseek本地部署配置·本地部署选型避坑原则·本地部署的典型方案
W_326001 小时前
Python文件进阶:一维数据与 CSV 文件读写
开发语言·python
阿里云大数据AI技术1 小时前
阿里云 Milvus 知识库开启邀测,助力客户构建企业级 Agent
人工智能·agent
正经教主1 小时前
AI提示词工程(进阶)第7课:角色设定与身份模拟
人工智能
lucky_syq2 小时前
第3篇 · S1·上:什么是大语言模型 + Transformer 架构深讲
人工智能·语言模型·架构·transformer
SelectDB2 小时前
抖音集团 实时数据仓库:Apache Doris / SelectDB 的技术能力与实践
数据库
SelectDB2 小时前
网易 日志与时序数据分析:Apache Doris / SelectDB 的技术能力与实践
数据库
小玮看世界2 小时前
当“安全“变成“教训“:《拟人化暂行办法》时代,AI护栏的过度拒答之困与破局
大数据·人工智能
yaoxin5211232 小时前
497. Java 反射 - 使用反射读取注解
java·开发语言·python