聚宽精品--四大搅屎棍策略

148 阅读17分钟

今天,我们来分享@Mario 蔡博在聚宽社区发布精品的策略——“四大搅屎棍”策略。这个名字乍一听有些戏谑,但其内核却是一个逻辑清晰、步骤严谨的、值得深入研究的行业轮动结合价值选股的量化模型。它试图解决一个核心问题: ** 如何在避开某些对市场情绪影响巨大的“巨无霸”(避险)行业的同时,捕捉其他行业的轮动机会? ** 更多优质量化策略,请移步公众号:量化学习实战笔记,获取

**

**

** 1. 策略背景与核心思想 **

在A股市场,银行、有色金属、钢铁、煤炭这几个行业板块,因其市值大、周期性强、对宏观经济和政策极为敏感,常常成为影响市场整体走向的关键力量,被戏称为“四大搅屎棍”。该策略的作者认为,当这些“巨无霸”行业不在市场领涨前列时,市场资金可能更活跃地流向其他板块,从而创造出更好的个股机会。

** 核心思想 ** 可以概括为: ** 先看市场整体宽度mp.weixin.qq.com/s/GHo4kqqRg… **

  1. ** 市场宽度判断 ** :计算所有申万一级行业中,股价站在20日均线以上的股票比例,以此衡量市场的整体强度和广度。
  2. ** 排除“搅屎棍” ** :观察上述“四大行业”是否处于领涨地位。若它们不在宽度最高的几个行业中,则认为市场环境相对友好,可以开仓。
  3. ** 价值选股 ** :在“安全”的环境下,从中小板指成分股中,筛选出 ** 净资产收益率(ROE)>15%、总资产收益率(ROA)>10% ** 的优质公司,并优先买入其中 ** 市值最小 ** 的10只股票,押注小盘价值股的修复或成长机会。

该策略 ** 适用于非极端“二八行情”(即资金只涌向少数大盘股)的市场环境 ** ,旨在捕捉市场热度扩散阶段,中小盘价值股的表现。

** 2. 策略原理拆解(重点) **

该策略的运行可分为每日准备、每周调仓和盘中监控三个主要部分。

** 第一步:每日开盘前准备( prepare_stock_list ) **

  • ** 做什么 ** :每日早晨9:05,识别当前持仓中, ** 昨日收盘涨停 ** 的股票列表。
  • ** 为什么 ** :为后续调仓和盘中监控做准备。策略对涨停股有特殊处理规则:昨日涨停的持仓股,除非盘中打开涨停,否则当日不予卖出,给予了强势股一定的持有宽容度。

** 第二步:每周调仓选股核心( weekly_adjustment 中调用 get_stock_list ) ** 这是策略的“大脑”,每周一运行,决定买什么、卖什么。

  1. ** 计算行业市场宽度 ** :
    • 以中证全指(000985.XSHG)为初选股票池。
    • 计算每只股票当前价是否高于其20日均线( close > ma20 ),得到一个布尔值。
    • 按申万一级行业分类,统计每个行业内“股价高于20日均线”的股票 ** 占比 ** 。这个比例就是该行业的“市场宽度”,宽度越高,说明该行业趋势越强、普涨效应越好。
  2. ** 识别领涨行业并过滤“搅屎棍” ** :
    • 选取市场宽度最高的前N个行业(N= g.num ,默认为1)。
    • ** 关键风控 ** :检查这前N个行业中,是否包含银行(801780)、有色金属(801050)、煤炭(801950)、钢铁(801040)。只要包含其中任何一个,本周就 ** 空仓不交易 ** 。
    • 如果不包含,则认为市场环境健康,进入选股步骤。
  3. ** 价值选股与过滤 ** :
    • ** 选股池 ** :切换到 ** 中小板指(399101.XSHE) ** ,专注于中小市值股票。
    • ** 基本面过滤 ** :在中小板指成分股中,筛选出 ROE > 15% ROA > 10% 的优质公司。
    • ** 排序与选取 ** :将上述优质公司按 ** 总市值从小到大 ** 排序,买入排名最靠前的10只( g.stock_num )。
    • ** 交易性过滤 ** :在最终买入前,还会过滤掉 ** 停牌、ST、涨停、跌停、次新股(上市未满375天)以及科创板和北交所 ** 的股票,确保可交易性。

** 第三步:调仓执行与盘中监控 **

  • ** 调仓卖出 ** :每周一,卖出当前持仓中 ** 不在新目标买入列表里 ** 且 ** 不属于“昨日涨停” ** 的股票。
  • ** 调仓买入 ** :将可用现金平均分配给目标买入列表中的新股票,直至达到持仓上限(10只)。
  • ** 盘中监控( check_limit_up ) ** :每天下午14:00,检查持仓中“昨日涨停”的股票。如果其在今日盘中 ** 打开涨停板 ** ,则立即卖出;若仍封死涨停,则继续持有。

** 3. 策略逻辑流程图 **

以下是策略每周调仓的核心决策流程:

开始每周调仓  
    ├─ 步骤1: 计算市场宽度  
    │    └─ 计算全市场各行业股价高于20日线占比  
    ├─ 步骤2: 识别领涨行业  
    │    └─ 选取宽度值最高的行业  
    ├─ 步骤3: 关键风控检查  
    │    └─ 判断领涨行业是否包含“四大搅屎棍”(银行/有色/煤炭/钢铁)?  
    │         ├─ 是 ➔ 本周空仓,流程结束  
    │         └─ 否 ➔ 进入选股流程  
    ├─ 步骤4: 切换选股池  
    │    └─ 将基础股票池切换至中小板指成分股  
    ├─ 步骤5: 基本面初筛  
    │    └─ 筛选ROE>15%且ROA>10%的股票  
    ├─ 步骤6: 排序与精选  
    │    └─ 将股票按市值从小到大排序,选取前10名  
    ├─ 步骤7: 交易性过滤  
    │    └─ 过滤停牌、ST、涨跌停、次新、科创板、北交所股票  
    ├─ 步骤8: 调仓卖出  
    │    └─ 卖出持仓中不在新名单且昨日未涨停的股票  
    └─ 步骤9: 调仓买入  
         └─ 等权买入新股票名单,直至满仓(10只)  

** 4. 策略优势 **

  1. ** 逻辑清晰,易于理解 ** :核心思想“避大抓小,优中选优”非常直观,行业宽度、排除法、财务指标、市值排序等步骤环环相扣,没有使用复杂难懂的因子。
  2. ** 交易频率低,执行友好 ** :每周调仓一次,降低了交易成本和换手率,对普通投资者来说更易于跟踪和执行。
  3. ** 嵌入主动风控思想 ** :将“四大行业”是否领涨作为开仓阀门,体现了对市场风格的主动判断和风险规避,并非无脑全时段运行。
  4. ** 涨停板特殊处理 ** :对强势股给予额外持有时间,避免过早卖出可能连续涨停的股票,符合趋势交易的部分原理。
  5. ** 聚焦中小盘价值 ** :在“安全”环境下,专门挖掘中小板中基本面优异(高ROE/ROA)的小市值公司,瞄准了特定风格(小盘价值)的潜在超额收益。

** 5. 策略潜在风险 **

  1. ** 风格失效风险 ** :策略核心是“避开大盘周期,买入中小价值”。如果市场长期处于“大象起舞”的行情(如2014年底、2017年),该策略可能会持续空仓或表现不佳,错失大盘股牛市。
  2. ** 财务指标滞后性 ** :ROE和ROA基于季度或年度财报,数据存在滞后性,可能买入基本面即将恶化的“价值陷阱”。
  3. ** 小市值因子波动大 ** :小市值股票虽然长期可能有超额收益,但波动率和回撤通常也更大,投资体验可能比较刺激。
  4. ** 行业宽度因子的局限性 ** :单纯以“股价高于20日线”的占比衡量行业强度,较为简单。在震荡市中可能产生大量伪信号,导致频繁开仓、平仓。
  5. ** 参数敏感性 ** :策略中“选取前N个领涨行业”( g.num )、“持有股票数量”( g.stock_num )以及财务指标的阈值(15%,10%)均为固定参数,其效果可能随市场环境变化。

** 6. 策略优化方向 **

  1. ** 引入更全面的市场状态判断 ** :
    • ** 为什么有效 ** :仅用“四大行业”是否领涨作为风控略显单一。可以增加比如 ** 全市场宽度阈值 ** (宽度低于某值时空仓,避免普跌市)、 ** 大盘趋势指标 ** (如指数位于某均线下方)等条件进行综合判断,使开仓条件更稳健。
  2. ** 优化选股因子与加权方式 ** :
    • ** 为什么有效 ** :当前选股仅用市值排序。可以引入 ** 复合因子 ** ,如结合估值(PE、PB)、成长性(净利润增长率)、质量(毛利率、现金流)等,对股票进行综合打分。买入时也可以采用 ** 市值加权或因子得分加权 ** ,而非简单的等权重。
  3. ** 动态调整“排除行业”列表 ** :
    • ** 为什么有效 ** :“四大搅屎棍”是作者的主观经验。可以尝试根据历史数据回测,动态识别在不同市场阶段(如牛市、熊市、震荡市)中,哪些行业的领涨会对后续小盘股行情产生负面影响,从而建立更科学的“排除清单”。
  4. ** 优化调仓时机与涨停股处理 ** :
    • ** 为什么有效 ** :固定每周一调仓可能不是最优。可以改为在 ** 开仓信号出现的次日 ** 调仓,或结合 ** 盘中波动率 ** 选择更平缓的时段交易。对于涨停股,可以设置更灵活的止盈策略,如根据封单量、打开次数等动态决策。
  5. ** 增加行业与风格中性化约束 ** :
    • ** 为什么有效 ** :虽然策略主动避开某些行业,但最终选股仍可能在某些行业上过度暴露。可以在选股后,对持仓组合进行简单的行业权重约束(使其与基准指数行业权重偏离不大),或控制市值、估值等风格因子的暴露,以降低组合波动。

** 7. 适用人群与使用建议 **

本策略适合以下类型的投资者:

  • ** 对行业轮动和风格切换有兴趣的量化初学者 ** :策略结构完整,是学习如何将主观投资逻辑(如避开特定行业)量化的优秀案例。
  • ** 倾向于低频交易、注重风控的稳健型投资者 ** :周度调仓和主动空仓机制,符合“多看少动”的投资哲学。
  • ** 相信中小盘价值风格长期有效的投资者 ** 。

** 使用建议 ** :

  • ** 深入回测 ** :在实盘前,务必在不同时间区间(特别是包含各种市场风格的周期)进行深入回测,检验策略的稳定性和最大回撤是否在自身承受范围内。
  • ** 考虑结合 ** :可以将此策略作为一个“卫星策略”,与核心的宽基指数定投等策略相结合,构成投资组合。
  • ** 谨慎优化 ** :如果尝试上述优化方向,记住“每次只改变一个变量”,并做好严格的样本内外测试,避免过度拟合。

** 8. 总结 **

“四大搅屎棍”策略巧妙地融合了 ** 市场广度分析、行业轮动逻辑与基础价值选股 ** ,其价值在于提供了一个结构清晰、具备主动风险规避意识的量化框架。它提醒我们,有时候通过排除少数“坏情况”,比精准预测所有“好机会”更为重要。

** 思考点 ** :你认为在当前的市场环境下,“四大搅屎棍”的名单需要更新吗?除了银行、有色、煤炭、钢铁,还有哪些行业的异动可能会显著影响市场的整体风格与赚钱效应?


** 互动话题: **

  1. 你对策略中“给予涨停股持有宽容度”这个设计怎么看?在你的交易体系中,会如何对待涨停的持仓股?
  2. 除了ROE和ROA,你认为在中小盘股票中,还有哪些财务或量价因子对于筛选优质公司至关重要?
  3. 如果让你在这个策略的基础上增加一个风控条件,你会加入什么?为什么?

📌 ** 策略完整源码(附录) **

# 风险及免责提示:该策略由聚宽用户在聚宽社区分享,仅供学习交流使用。  
# 原文一般包含策略说明,如有疑问请到原文和作者交流讨论。  
# 原文网址:https://www.joinquant.com/post/49085  
# 标题:四大搅屎棍策略  
# 作者:MarioC  
# 原回测条件:2012-01-01 到 2024-07-11, ¥100000, 每天  
  
from jqdata import *  
from jqfactor import *  
import numpy as np  
import pandas as pd  
import pickle  
from six import StringIO,BytesIO # py3的环境,使用BytesIO  
import talib  
  
# 初始化函数  
def initialize(context):  
    # 设定基准  
    set_benchmark('000985.XSHG')  
    # 用真实价格交易  
    set_option('use_real_price', True)  
    # 打开防未来函数  
    set_option("avoid_future_data", True)  
    # 将滑点设置为0  
    set_slippage(FixedSlippage(0))  
    # 设置交易成本万分之三,不同滑点影响可在归因分析中查看  
    set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.0003, close_commission=0.0003,  
                             close_today_commission=0, min_commission=5), type='stock')  
    # 过滤order中低于error级别的日志  
    log.set_level('order', 'error')  
    # 初始化全局变量  
    g.stock_num = 10  
    g.hold_list = []  # 当前持仓的全部股票  
    g.yesterday_HL_list = []  # 记录持仓中昨日涨停的股票  
    g.num=1  
    # 设置交易运行时间  
    run_daily(prepare_stock_list, '9:05')  
    run_weekly(weekly_adjustment, 1, '9:30')  
    run_daily(check_limit_up, '14:00')  # 检查持仓中的涨停股是否需要卖出  
  
  
SW1 = {  
    '801010': '农林牧渔I',  
    '801020': '采掘I',  
    '801030': '化工I',  
    '801040': '钢铁I',  
    '801050': '有色金属I',  
    '801060': '建筑建材I',  
    '801070': '机械设备I',  
    '801080': '电子I',  
    '801090': '交运设备I',  
    '801100': '信息设备I',  
    '801110': '家用电器I',  
    '801120': '食品饮料I',  
    '801130': '纺织服装I',  
    '801140': '轻工制造I',  
    '801150': '医药生物I',  
    '801160': '公用事业I',  
    '801170': '交通运输I',  
    '801180': '房地产I',  
    '801190': '金融服务I',  
    '801200': '商业贸易I',  
    '801210': '休闲服务I',  
    '801220': '信息服务I',  
    '801230': '综合I',  
    '801710': '建筑材料I',  
    '801720': '建筑装饰I',  
    '801730': '电气设备I',  
    '801740': '国防军工I',  
    '801750': '计算机I',  
    '801760': '传媒I',  
    '801770': '通信I',  
    '801780': '银行I',  
    '801790': '非银金融I',  
    '801880': '汽车I',  
    '801890': '机械设备I',  
    '801950': '煤炭I',  
    '801960': '石油石化I',  
    '801970': '环保I',  
    '801980': '美容护理I'  
}  
  
# 1-1 准备股票池  
def prepare_stock_list(context):  
    # 获取已持有列表  
    g.hold_list = []  
    for position in list(context.portfolio.positions.values()):  
        stock = position.security  
        g.hold_list.append(stock)  
    # 获取昨日涨停列表  
    if g.hold_list != []:  
        df = get_price(g.hold_list, end_date=context.previous_date, frequency='daily', fields=['close', 'high_limit'],  
                       count=1, panel=False, fill_paused=False)  
        df = df[df['close'] == df['high_limit']]  
        g.yesterday_HL_list = list(df.code)  
    else:  
        g.yesterday_HL_list = []  
  
industry_code = ['801010','801020','801030','801040','801050','801080','801110','801120','801130','801140','801150',\  
                    '801160','801170','801180','801200','801210','801230','801710','801720','801730','801740','801750',\  
                   '801760','801770','801780','801790','801880','801890']  
  
def industry(stockList,industry_code,date):  
    i_Constituent_Stocks={}  
    for i in industry_code:  
        temp = get_industry_stocks(i, date)  
        i_Constituent_Stocks[i] = list(set(temp).intersection(set(stockList)))  
    count_dict = {}  
    for name, content_list in i_Constituent_Stocks.items():  
        count = len(content_list)  
        count_dict[name] = count  
    return count_dict  
      
def getStockIndustry(p_stocks, p_industries_type, p_day):  
    dict_stk_2_ind = {}  
    stocks_industry_dict = get_industry(p_stocks, date=p_day)  
    for stock in stocks_industry_dict:  
        if p_industries_type in stocks_industry_dict[stock]:  
            dict_stk_2_ind[stock] = stocks_industry_dict[stock][p_industries_type]['industry_code']  
    return pd.Series(dict_stk_2_ind)  
# 1-2 选股模块  
def get_stock_list(context):  
    # 指定日期防止未来数据  
    yesterday = context.previous_date  
    today = context.current_dt  
    final_list =[]  
    # 获取初始列表  
    initial_list = get_index_stocks('000985.XSHG', today)  
    p_count=1  
    p_industries_type='sw_l1'  
    h = get_price(initial_list, end_date=yesterday, frequency='1d', fields=['close'], count=p_count + 20, panel=False)  
    h['date'] = pd.DatetimeIndex(h.time).date  
    df_close = h.pivot(index='code', columns='date', values='close').dropna(axis=0)  
    df_ma20 = df_close.rolling(window=20, axis=1).mean().iloc[:, -p_count:]  
    df_bias = (df_close.iloc[:, -p_count:] > df_ma20)   
    s_stk_2_ind = getStockIndustry(p_stocks=initial_list, p_industries_type=p_industries_type, p_day=yesterday)  
    df_bias['industry_code'] = s_stk_2_ind  
    df_ratio = ((df_bias.groupby('industry_code').sum() * 100.0) / df_bias.groupby(  
        'industry_code').count()).round()    
    column_names = df_ratio.columns.tolist()  
    top_values = df_ratio[datetime.date(yesterday.year, yesterday.month, yesterday.day)].nlargest(g.num)  
    I   =  top_values.index.tolist()  
    sum_of_top_values = df_ratio.sum()  
    TT = sum_of_top_values[datetime.date(yesterday.year, yesterday.month, yesterday.day)]  
    name_list = [SW1[code] for code in I]  
    print(name_list)  
    print('全市场宽度:',np.array(df_ratio.sum(axis=0).mean()))  
    if'801780'notin I and'801050'notin I and'801950'notin I and'801040'notin I:  
        #《银行、有色金属、钢铁、煤炭》搅屎棍不在,开仓  
        S_stocks = get_index_stocks('399101.XSHE', today)  
        stocks = filter_kcbj_stock(S_stocks)  
        choice = filter_st_stock(stocks)  
        choice = filter_new_stock(context, choice)  
        BIG_stock_list = get_fundamentals(query(  
                valuation.code,  
            ).filter(  
                valuation.code.in_(choice),  
                indicator.roe > 0.15,  
                indicator.roa > 0.10,  
            ).order_by(  
        valuation.market_cap.asc()).limit(g.stock_num)).set_index('code').index.tolist()  
        BIG_stock_list = filter_paused_stock(BIG_stock_list)  
        BIG_stock_list = filter_limitup_stock(context,BIG_stock_list)  
        L = filter_limitdown_stock(context,BIG_stock_list)  
    else:  
        print('跑')  
        L=[]  
    return L  
  
# 1-3 整体调整持仓  
def weekly_adjustment(context):  
    target_B = get_stock_list(context)  
    # 调仓卖出  
    for stock in g.hold_list:  
        if (stock notin target_B) and (stock notin g.yesterday_HL_list):  
            position = context.portfolio.positions[stock]  
            close_position(position)  
    position_count = len(context.portfolio.positions)  
    target_num = len(target_B)  
    if target_num > position_count:  
        buy_num = min(len(target_B), g.stock_num*g.num - position_count)  
        value = context.portfolio.cash / buy_num  
        for stock in target_B:  
            if stock notin list(context.portfolio.positions.keys()):  
                if open_position(stock, value):  
                    if len(context.portfolio.positions) == target_num:  
                        break  
  
def check_limit_up(context):  
    now_time = context.current_dt  
    if g.yesterday_HL_list != []:  
        # 对昨日涨停股票观察到尾盘如不涨停则提前卖出,如果涨停即使不在应买入列表仍暂时持有  
        for stock in g.yesterday_HL_list:  
            current_data = get_price(stock, end_date=now_time, frequency='1m', fields=['close', 'high_limit'],  
                                     skip_paused=False, fq='pre', count=1, panel=False, fill_paused=True)  
            if current_data.iloc[0, 0] < current_data.iloc[0, 1]:  
                log.info("[%s]涨停打开,卖出" % (stock))  
                position = context.portfolio.positions[stock]  
                close_position(position)  
            else:  
                log.info("[%s]涨停,继续持有" % (stock))  
  
# 3-1 交易模块-自定义下单  
def order_target_value_(security, value):  
    if value == 0:  
        log.debug("Selling out %s" % (security))  
    else:  
        log.debug("Order %s to value %f" % (security, value))  
    return order_target_value(security, value)  
  
  
# 3-2 交易模块-开仓  
def open_position(security, value):  
    order = order_target_value_(security, value)  
    if order != Noneand order.filled > 0:  
        returnTrue  
    returnFalse  
  
  
# 3-3 交易模块-平仓  
def close_position(position):  
    security = position.security  
    order = order_target_value_(security, 0)  # 可能会因停牌失败  
    if order != None:  
        if order.status == OrderStatus.held and order.filled == order.amount:  
            returnTrue  
    returnFalse  
  
  
# 2-1 过滤停牌股票  
def filter_paused_stock(stock_list):  
    current_data = get_current_data()  
    return [stock for stock in stock_list ifnot current_data[stock].paused]  
  
  
# 2-2 过滤ST及其他具有退市标签的股票  
def filter_st_stock(stock_list):  
    current_data = get_current_data()  
    return [stock for stock in stock_list  
            ifnot current_data[stock].is_st  
            and'ST'notin current_data[stock].name  
            and'*'notin current_data[stock].name  
            and'退'notin current_data[stock].name]  
  
  
# 2-3 过滤科创北交股票  
def filter_kcbj_stock(stock_list):  
    for stock in stock_list[:]:  
        if stock[0] == '4'or stock[0] == '8'or stock[:2] == '68'or stock[0] == '3':  
            stock_list.remove(stock)  
    return stock_list  
  
  
# 2-4 过滤涨停的股票  
def filter_limitup_stock(context, stock_list):  
    last_prices = history(1, unit='1m', field='close', security_list=stock_list)  
    current_data = get_current_data()  
    return [stock for stock in stock_list if stock in context.portfolio.positions.keys()  
            or last_prices[stock][-1] < current_data[stock].high_limit]  
  
  
# 2-5 过滤跌停的股票  
def filter_limitdown_stock(context, stock_list):  
    last_prices = history(1, unit='1m', field='close', security_list=stock_list)  
    current_data = get_current_data()  
    return [stock for stock in stock_list if stock in context.portfolio.positions.keys()  
            or last_prices[stock][-1] > current_data[stock].low_limit]  
  
  
# 2-6 过滤次新股  
def filter_new_stock(context, stock_list):  
    yesterday = context.previous_date  
    return [stock for stock in stock_list if  
            not yesterday - get_security_info(stock).start_date < datetime.timedelta(days=375)]  

** 免责声明 ** :本文分享策略仅供学习交流,不构成任何投资建议。市场有风险,投资需谨慎。策略回测表现不代表未来收益,实盘前请充分测试验证。