一、开篇:我,被大妈教育了
最近在聚宽社区翻到一个叫"菜场大妈"的策略,名字接地气,回测成绩还挺能打。今天来拆解一下它的核心逻辑,顺便做点优化。
花了不少时间研究量化,看文章、调参数、研究各种多因子模型,每天盯着Alpha信号、因子暴露、信息比率……然后呢?
年化跑不赢沪深300。
有一天我妈跟我说,她闺蜜在菜场摆摊,顺手买了几只"便宜的、但公司还在赚钱的"小股票放着,几年下来收益还挺好。
我当场沉默了三分钟。
大妈炒股的逻辑,其实就三条:
- 便宜——贵的我买不起,也不放心
- 有肉——不能是只讲故事的空壳子,得有真收入
- 小——大公司牛人多,哪轮到我捡漏
听起来很土?但这三条,其实就是"小市值低价策略"的核心逻辑。
二、大妈的选菜秘籍
整个策略的核心思路,可以用一套"去菜场买菜"来解释。
第一步:扔掉烂菜叶子(基础过滤)
大妈第一眼看菜,先排除烂掉的、发霉的、一看就没法吃的。
股票里的"烂菜叶子"就是:
- ST股票:名字前面贴了黄色标签,绝对不碰,看着便宜,全是坑
- 退市边缘股:名字里带"退"的,大妈直接转身走人
- 停牌股:菜摊今天关门,你掏钱也买不到
# 过滤ST及其他具有退市标签的股票
def filter_st_stock(stock_list):
current_data = get_current_data()
return [stock for stock in stock_list
if not current_data[stock].is_st # 不是ST
and 'ST' not in current_data[stock].name # 名字里没有ST
and '*' not in current_data[stock].name # 没有星号(*ST)
and '退' not in current_data[stock].name] # 没有退字
另外,科创板和北交所也不去——大妈不去高档精品超市,她就逛老菜市场,接地气,看得懂,买得放心。
# 过滤科创北交股票
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[:3] == '300' or stock[:3] == '301':
stock_list.remove(stock)
return stock_list
第二步:检查有没有肉(基本面防雷)
光便宜不行,还得有真材实料。
大妈挑肉的标准很简单:
- 净利润必须大于0——不赚钱的公司,买它干什么?
- 营业收入大于1亿——连1个"小目标"都没有,别浪费咱的买菜钱
q = query(
valuation.code,
valuation.market_cap,
income.np_parent_company_owners, # 归母净利润
income.net_profit, # 净利润
income.operating_revenue # 营业收入
).filter(
valuation.code.in_(stocks),
valuation.market_cap.between(g.min_mv, g.max_mv), # 市值区间
income.np_parent_company_owners > 0, # 有真肉
income.net_profit > 0, # 净利润也得正
income.operating_revenue > 1e8 # 营收过亿才算数
).order_by(valuation.market_cap.asc()) # 市值从小到大排
这个筛选直接干掉了绝大多数"讲故事的仙股"。
第三步:大妈的极简审美——10块以下才考虑
这是大妈最硬核的原则:
超过10块钱的菜,太金贵,不买。
# 过滤股价高于10元的股票
def filter_highprice_stock(context, stock_list):
last_prices = history(1, unit='1m', field='close', security_list=stock_list)
return [stock for stock in stock_list
if stock in context.portfolio.positions.keys()
or last_prices[stock][-1] <10]
原版策略过滤的是9元以上,我稍微放宽到10元——毕竟通货膨胀嘛,大妈也得跟上时代。
第四步:专挑边角料——市值最小的4只
经过前面三轮筛选,剩下的候选菜已经都是"便宜有肉的好货"了。
接下来怎么选?按市值从小到大排,挑最"边角料"的4只。
捡漏心理:专挑摊位最边角没人注意的那几样,够小、够便宜、胜在没人哄抬价格。
目标市值区间:10亿到100亿之间(不能太小,太小容易跑路;不能太大,太大轮不到散户吃肉)。
g.stock_num = 4 # 最多买4只
g.min_mv = 10 # 最小市值10亿
g.max_mv = 1e8 # 最大市值1000亿(写法是万亿单位,实为100亿)
第五步:隔夜馊了,赶紧处理(卖出逻辑)
大妈有一条铁律:隔夜的菜不留。
这里对应的是"昨日涨停股"的处理:
这只股票昨天表现亮眼,涨停了!大妈开心,夸它新鲜。
今天一看,没连板,价格缩回来了。
大妈立刻:"隔夜的,赶紧处理,换新鲜的来。"
def check_limit_up(context):
current_data = get_current_data()
if g.high_limit_list:
for stock in g.high_limit_list: # 昨天涨停的票
if current_data[stock].last_price \
<current_data[stock].high_limit: # 今天没继续涨停
order_target(stock, 0) # 清仓
g.just_sold.append(stock) # 记录已卖,不二次买入
# 卖了之后,如果持仓不够,再候补买入
position_count = len(context.portfolio.positions)
if g.stock_num > position_count and position_count != 0:
my_Trader(context)
psize = context.portfolio.available_cash / (g.stock_num - position_count)
for s in g.choice:
if s not in context.portfolio.positions and s not in g.just_sold:
order_value(s, psize)
if len(context.portfolio.positions) == g.stock_num:
break
注意先卖后买,这是个细节优化——原版是先买后卖,导致卖掉的票要隔天才能补仓,白白空仓一天。
第六步:一周去一次菜场(换仓节奏)
大妈不每天去菜场,那太累了。她每周一早上进一次城,买够就回家。
run_weekly(my_Trader, 1, time='13:50') # 每周第1个交易日,13:50选股
run_weekly(go_Trader, 1, time='14:00') # 每周第1个交易日,14:00下单
每周换一次仓,频率不算高,也省手续费。大妈的核心竞争力之一,就是不天天折腾。
三、翻车现场实录——代码比菜还难伺候
写策略的过程,踩了不少坑,挑几个有代表性的跟大家分享。
坑一:滑点是隐形摊位费
刚开始没设滑点,回测数据漂亮得一塌糊涂。
等我加上 FixedSlippage(0.02) 和真实手续费之后,收益率肉眼可见地往下掉。
set_slippage(FixedSlippage(0.02))
set_order_cost(OrderCost(
close_tax=0.001, # 印花税0.1%(卖出才收)
open_commission=0.0001, # 买入佣金0.01%
close_commission=0.0005, # 卖出佣金0.05%
min_commission=0.1 # 最低5毛
), type='stock')
这就是菜场的"摊位费":你看着菜很便宜,结果各种税费加起来,利润被切走一大块。
结论:纸面富贵不算数,扣完成本才是真收益。
坑二:提前钦定几只ETF,曲线漂亮到怀疑人生——这叫上帝视角,不叫量化
这个坑不是代码bug,是人的bug。
接手这个策略的时候,它已经配了一段"组合配置"的逻辑——在小市值股票之外,还额外指定了几只ETF:
黄金ETF(518880)、纳斯达克ETF(513100)、芯片ETF(159995)……
配置理由写得头头是道:分散化、降回撤、对冲A股风险……
一跑回测:2016年到2025年,年化亮瞎眼,曲线好看得像PPT配图。
我当时觉得自己是天才。
然后仔细一看,冷汗下来了——
这几只ETF是谁选的?按什么选的?
黄金,2024年大涨;纳斯达克,2023年翻倍反弹;芯片,也有过自己的高光时刻……
这些都是已经发生的历史。策略里写死了"就买这几只",然后用历史回测来"验证"它们表现好——
这不是量化,这是开卷考试然后说自己考满分。
把这几只硬编码的ETF全部剔除之后,换成动态筛选的逻辑——
收益率啪啪往下掉。
曲线一下子瘦了一大圈,以前那段"漂亮区间"直接垮掉一半。
这就是过拟合的最朴素版本:用上帝视角挑菜,当然买的全是好菜。但你在真实菜场里没有上帝视角,前一天猪肉涨价了你也不知道。
结论:回测数据越漂亮,越要多问一句"为什么"——是策略真的好,还是你亲手喂了它正确答案?
坑三:9:30下单,大妈两手空空
这个坑藏得很深,或者说——回测系统藏得太好了。
策略里有一段逻辑,要在 9:30 开盘第一分钟执行买卖:
run_daily(check_limit_up, time='9:30')
理论上完全合理——越早下单越好,抢先手嘛。
回测跑下来,成交记录里也都有,数据漂漂亮亮。
但如果一上SHIPAN,就尴尬了。
9:30 开盘的第一分钟,实际上是集合竞价刚刚结束的瞬间。行情数据还没稳定下发,API 还没完全就绪,大量股票这一秒钟根本拿不到有效的实时价格——
于是下单,要么直接报错,要么以 0 价格挂出去被拒单,总之:两手空空,什么都没买到。
回测里的 9:30 是"模拟的9:30",数据是现成的,下单当然成功。
**里的 9:30 是"真实的9:30",数据还在飞,根本接不住。
改成 9:31、9:35 之后,下单终于正常了。
但收益往下掉了。
因为策略里有依赖"开盘价"的买卖判断,哪怕晚了1~5分钟,成交价就不一样了,有些单子该买的没买上,该卖的滑了点。
看起来只差几分钟,代入收益一算,差距比想象中大。
结论:回测的时间刻度是理想化的,****的第一分钟是混沌的。9:30 下单,在这个系统里就是一个幻觉。**
坑四:大妈不止损,一套套到认命
这是原版策略最让我不安的一个设计——它没有止损。
大妈的哲学是:买来的菜,就算有点蔫,也不扔。泡泡水,明天还能吃。
策略里只有两种卖出情形:
- 股票掉出候选池(市值涨太大、财务变差、变ST了)
- 昨天涨停今天没连板
跌了10%?不管。跌了20%?继续拿着。只要它还符合小市值条件,就一直持有。
极端行情一来,这个策略会被套得很难看。2020年春节后复市那天,我看着日志:
[止损] 002112 三变科技 成本=6.94 现价=5.83 亏损=-16.0%,强制清仓
[止损] 600099 林海股份 成本=6.74 现价=5.65 亏损=-16.2%,强制清仓
[止损] 600493 凤竹纺织 成本=5.67 现价=4.77 亏损=-15.9%,强制清仓
等等,这是我加了止损之后的日志,亏损还是达到了15%+。
原因很简单:跌停股票卖不掉。
止损单挂出去,市场全是卖盘没有买盘,当天根本成交不了。第二天继续跌停,继续挂单,继续成交不了——
这就是"跳空"的残酷现实:止损线写的是8%,真正止损的时候可能已经亏了15%。
最后加了两个补丁才勉强解决:
g.stop_loss_set 记录已发止损单的股票,跌停时静默重试,不重复打日志
- 止损后的股票本周不再买回,防止刚割肉就被自动补仓
g.stop_loss_ratio = 0.08 # 止损线8%
g.stop_loss_set = set() # 已发止损单,跌停未成交则次日重试
结论:止损写进代码只是第一步,跌停穿越才是真正的硬伤。极端行情面前,8%的止损线可能保不住你,但有和没有,差距还是很大。
四、最后说一句
折腾一圈之后,我发现我最初看不上的东西,反而是最难做到的。
量化圈有个通病:喜欢追求复杂。因子越多越好,模型越深越好,参数越精细越好。
但菜场大妈的策略只有三个筛选条件,逻辑三句话说完,参数五个以内,任何人看懂之后都能在脑子里复现一遍。
这就是它最宝贵的地方:逻辑清晰。
你知道它为什么买,你知道它为什么卖,你知道它会在什么情况下亏钱,你不会因为"这个信号我也说不清楚为什么"而在极端行情裂开。
说到稳定性,我的实际体感是这样的:
- 它不会让你一夜暴富——4只小市值股票,赶上行情好也就是稳稳地跟上指数
- 它也不容易把你打趴下——每周换仓、市值过滤、加了止损,大的单边亏损会被强制截断
- 回撤控制不完美,极端行情(比如2020年春节复市)照样被打,但不会持续失血
这种感觉就像买的不是最贵的全熟牛排,而是一碗好熬的老火靓汤——慢,但真实,喝完不反胃。
当然它有很多问题没解决,还需要继续努力:
- 没有趋势判断,熊市里也一样买买买
- 止损是硬止损,碰到跌停排队卖不掉的情况还是会穿越
- 换仓频率固定,遇到消息面剧变反应慢
回测这么漂亮的策略,拿到全市场里到底能排第几?
我把这个大妈策略丢进了一个叫 9db智能体交易竞技场 的地方,那里可以上传交割单,跟别人的策略一起按实时收益PK排名,跑了才发现,大佬们的有多稳
如果这个策略对你有帮助,点个赞就行。如果你发现了什么Bug或者有更好的改法,欢迎评论区指教——毕竟,大妈选菜也需要老街坊互相提醒。