今天,我们来分享@Mario 蔡博在聚宽社区发布精品的策略——“四大搅屎棍”策略。这个名字乍一听有些戏谑,但其内核却是一个逻辑清晰、步骤严谨的、值得深入研究的行业轮动结合价值选股的量化模型。它试图解决一个核心问题: ** 如何在避开某些对市场情绪影响巨大的“巨无霸”(避险)行业的同时,捕捉其他行业的轮动机会? ** 更多优质量化策略,请移步公众号:量化学习实战笔记,获取
**
**
** 1. 策略背景与核心思想 **
在A股市场,银行、有色金属、钢铁、煤炭这几个行业板块,因其市值大、周期性强、对宏观经济和政策极为敏感,常常成为影响市场整体走向的关键力量,被戏称为“四大搅屎棍”。该策略的作者认为,当这些“巨无霸”行业不在市场领涨前列时,市场资金可能更活跃地流向其他板块,从而创造出更好的个股机会。
** 核心思想 ** 可以概括为: ** 先看市场整体宽度mp.weixin.qq.com/s/GHo4kqqRg… **
- ** 市场宽度判断 ** :计算所有申万一级行业中,股价站在20日均线以上的股票比例,以此衡量市场的整体强度和广度。
- ** 排除“搅屎棍” ** :观察上述“四大行业”是否处于领涨地位。若它们不在宽度最高的几个行业中,则认为市场环境相对友好,可以开仓。
- ** 价值选股 ** :在“安全”的环境下,从中小板指成分股中,筛选出 ** 净资产收益率(ROE)>15%、总资产收益率(ROA)>10% ** 的优质公司,并优先买入其中 ** 市值最小 ** 的10只股票,押注小盘价值股的修复或成长机会。
该策略 ** 适用于非极端“二八行情”(即资金只涌向少数大盘股)的市场环境 ** ,旨在捕捉市场热度扩散阶段,中小盘价值股的表现。
** 2. 策略原理拆解(重点) **
该策略的运行可分为每日准备、每周调仓和盘中监控三个主要部分。
** 第一步:每日开盘前准备( prepare_stock_list ) **
- ** 做什么 ** :每日早晨9:05,识别当前持仓中, ** 昨日收盘涨停 ** 的股票列表。
- ** 为什么 ** :为后续调仓和盘中监控做准备。策略对涨停股有特殊处理规则:昨日涨停的持仓股,除非盘中打开涨停,否则当日不予卖出,给予了强势股一定的持有宽容度。
** 第二步:每周调仓选股核心( weekly_adjustment 中调用 get_stock_list ) **
这是策略的“大脑”,每周一运行,决定买什么、卖什么。
- ** 计算行业市场宽度 ** :
- 以中证全指(000985.XSHG)为初选股票池。
- 计算每只股票当前价是否高于其20日均线(
close > ma20),得到一个布尔值。 - 按申万一级行业分类,统计每个行业内“股价高于20日均线”的股票 ** 占比 ** 。这个比例就是该行业的“市场宽度”,宽度越高,说明该行业趋势越强、普涨效应越好。
- ** 识别领涨行业并过滤“搅屎棍” ** :
- 选取市场宽度最高的前N个行业(N=
g.num,默认为1)。 - ** 关键风控 ** :检查这前N个行业中,是否包含银行(801780)、有色金属(801050)、煤炭(801950)、钢铁(801040)。只要包含其中任何一个,本周就 ** 空仓不交易 ** 。
- 如果不包含,则认为市场环境健康,进入选股步骤。
- 选取市场宽度最高的前N个行业(N=
- ** 价值选股与过滤 ** :
- ** 选股池 ** :切换到 ** 中小板指(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. 策略优势 **
- ** 逻辑清晰,易于理解 ** :核心思想“避大抓小,优中选优”非常直观,行业宽度、排除法、财务指标、市值排序等步骤环环相扣,没有使用复杂难懂的因子。
- ** 交易频率低,执行友好 ** :每周调仓一次,降低了交易成本和换手率,对普通投资者来说更易于跟踪和执行。
- ** 嵌入主动风控思想 ** :将“四大行业”是否领涨作为开仓阀门,体现了对市场风格的主动判断和风险规避,并非无脑全时段运行。
- ** 涨停板特殊处理 ** :对强势股给予额外持有时间,避免过早卖出可能连续涨停的股票,符合趋势交易的部分原理。
- ** 聚焦中小盘价值 ** :在“安全”环境下,专门挖掘中小板中基本面优异(高ROE/ROA)的小市值公司,瞄准了特定风格(小盘价值)的潜在超额收益。
** 5. 策略潜在风险 **
- ** 风格失效风险 ** :策略核心是“避开大盘周期,买入中小价值”。如果市场长期处于“大象起舞”的行情(如2014年底、2017年),该策略可能会持续空仓或表现不佳,错失大盘股牛市。
- ** 财务指标滞后性 ** :ROE和ROA基于季度或年度财报,数据存在滞后性,可能买入基本面即将恶化的“价值陷阱”。
- ** 小市值因子波动大 ** :小市值股票虽然长期可能有超额收益,但波动率和回撤通常也更大,投资体验可能比较刺激。
- ** 行业宽度因子的局限性 ** :单纯以“股价高于20日线”的占比衡量行业强度,较为简单。在震荡市中可能产生大量伪信号,导致频繁开仓、平仓。
- ** 参数敏感性 ** :策略中“选取前N个领涨行业”(
g.num)、“持有股票数量”(g.stock_num)以及财务指标的阈值(15%,10%)均为固定参数,其效果可能随市场环境变化。
** 6. 策略优化方向 **
- ** 引入更全面的市场状态判断 ** :
- ** 为什么有效 ** :仅用“四大行业”是否领涨作为风控略显单一。可以增加比如 ** 全市场宽度阈值 ** (宽度低于某值时空仓,避免普跌市)、 ** 大盘趋势指标 ** (如指数位于某均线下方)等条件进行综合判断,使开仓条件更稳健。
- ** 优化选股因子与加权方式 ** :
- ** 为什么有效 ** :当前选股仅用市值排序。可以引入 ** 复合因子 ** ,如结合估值(PE、PB)、成长性(净利润增长率)、质量(毛利率、现金流)等,对股票进行综合打分。买入时也可以采用 ** 市值加权或因子得分加权 ** ,而非简单的等权重。
- ** 动态调整“排除行业”列表 ** :
- ** 为什么有效 ** :“四大搅屎棍”是作者的主观经验。可以尝试根据历史数据回测,动态识别在不同市场阶段(如牛市、熊市、震荡市)中,哪些行业的领涨会对后续小盘股行情产生负面影响,从而建立更科学的“排除清单”。
- ** 优化调仓时机与涨停股处理 ** :
- ** 为什么有效 ** :固定每周一调仓可能不是最优。可以改为在 ** 开仓信号出现的次日 ** 调仓,或结合 ** 盘中波动率 ** 选择更平缓的时段交易。对于涨停股,可以设置更灵活的止盈策略,如根据封单量、打开次数等动态决策。
- ** 增加行业与风格中性化约束 ** :
- ** 为什么有效 ** :虽然策略主动避开某些行业,但最终选股仍可能在某些行业上过度暴露。可以在选股后,对持仓组合进行简单的行业权重约束(使其与基准指数行业权重偏离不大),或控制市值、估值等风格因子的暴露,以降低组合波动。
** 7. 适用人群与使用建议 **
本策略适合以下类型的投资者:
- ** 对行业轮动和风格切换有兴趣的量化初学者 ** :策略结构完整,是学习如何将主观投资逻辑(如避开特定行业)量化的优秀案例。
- ** 倾向于低频交易、注重风控的稳健型投资者 ** :周度调仓和主动空仓机制,符合“多看少动”的投资哲学。
- ** 相信中小盘价值风格长期有效的投资者 ** 。
** 使用建议 ** :
- ** 深入回测 ** :在实盘前,务必在不同时间区间(特别是包含各种市场风格的周期)进行深入回测,检验策略的稳定性和最大回撤是否在自身承受范围内。
- ** 考虑结合 ** :可以将此策略作为一个“卫星策略”,与核心的宽基指数定投等策略相结合,构成投资组合。
- ** 谨慎优化 ** :如果尝试上述优化方向,记住“每次只改变一个变量”,并做好严格的样本内外测试,避免过度拟合。
** 8. 总结 **
“四大搅屎棍”策略巧妙地融合了 ** 市场广度分析、行业轮动逻辑与基础价值选股 ** ,其价值在于提供了一个结构清晰、具备主动风险规避意识的量化框架。它提醒我们,有时候通过排除少数“坏情况”,比精准预测所有“好机会”更为重要。
** 思考点 ** :你认为在当前的市场环境下,“四大搅屎棍”的名单需要更新吗?除了银行、有色、煤炭、钢铁,还有哪些行业的异动可能会显著影响市场的整体风格与赚钱效应?
** 互动话题: **
- 你对策略中“给予涨停股持有宽容度”这个设计怎么看?在你的交易体系中,会如何对待涨停的持仓股?
- 除了ROE和ROA,你认为在中小盘股票中,还有哪些财务或量价因子对于筛选优质公司至关重要?
- 如果让你在这个策略的基础上增加一个风控条件,你会加入什么?为什么?
📌 ** 策略完整源码(附录) **
# 风险及免责提示:该策略由聚宽用户在聚宽社区分享,仅供学习交流使用。
# 原文一般包含策略说明,如有疑问请到原文和作者交流讨论。
# 原文网址: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)]
** 免责声明 ** :本文分享策略仅供学习交流,不构成任何投资建议。市场有风险,投资需谨慎。策略回测表现不代表未来收益,实盘前请充分测试验证。