



















最近在聚宽社区翻到一个叫"菜场大妈"的策略,名字接地气,回测成绩还挺能打。今天来拆解一下它的核心逻辑,顺便做点优化。
花了不少时间研究量化,看文章、调参数、研究各种多因子模型,每天盯着 Alpha 信号、因子暴露、信息比率……然后呢?
年化跑不赢沪深 300 。
有一天我妈跟我说,她闺蜜在菜场摆摊,顺手买了几只"便宜的、但公司还在赚钱的"小股票放着,几年下来收益还挺好。
我当场沉默了三分钟。
大妈炒股的逻辑,其实就三条:
听起来很土?但这三条,其实就是"小市值低价策略"的核心逻辑。
整个策略的核心思路,可以用一套"去菜场买菜"来解释。
大妈第一眼看菜,先排除烂掉的、发霉的、一看就没法吃的。
股票里的"烂菜叶子"就是:
# 过滤 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
光便宜不行,还得有真材实料。
大妈挑肉的标准很简单:
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 元的股票
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 只。
捡漏心理:专挑摊位最边角没人注意的那几样,够小、够便宜、胜在没人哄抬价格。
目标市值区间: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')
这就是菜场的"摊位费":你看着菜很便宜,结果各种税费加起来,利润被切走一大块。
结论:纸面富贵不算数,扣完成本才是真收益。
这个坑不是代码 bug ,是人的 bug 。
接手这个策略的时候,它已经配了一段"组合配置"的逻辑——在小市值股票之外,还额外指定了几只 ETF:
黄金 ETF( 518880 )、纳斯达克 ETF( 513100 )、芯片 ETF( 159995 )……
配置理由写得头头是道:分散化、降回撤、对冲 A 股风险……
一跑回测:2016 年到 2025 年,年化亮瞎眼,曲线好看得像 PPT 配图。
我当时觉得自己是天才。
然后仔细一看,冷汗下来了——
这几只 ETF 是谁选的?按什么选的?
黄金,2024 年大涨;纳斯达克,2023 年翻倍反弹;芯片,也有过自己的高光时刻……
这些都是已经发生的历史。策略里写死了"就买这几只",然后用历史回测来"验证"它们表现好——
这不是量化,这是开卷考试然后说自己考满分。
把这几只硬编码的 ETF 全部剔除之后,换成动态筛选的逻辑——
收益率啪啪往下掉。
曲线一下子瘦了一大圈,以前那段"漂亮区间"直接垮掉一半。
这就是过拟合的最朴素版本:用上帝视角挑菜,当然买的全是好菜。但你在真实菜场里没有上帝视角,前一天猪肉涨价了你也不知道。
结论:回测数据越漂亮,越要多问一句"为什么"——是策略真的好,还是你亲手喂了它正确答案?
这个坑藏得很深,或者说——回测系统藏得太好了。
策略里有一段逻辑,要在 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 下单,在这个系统里就是一个幻觉。**
这是原版策略最让我不安的一个设计——它没有止损。
大妈的哲学是:买来的菜,就算有点蔫,也不扔。泡泡水,明天还能吃。
策略里只有两种卖出情形:
跌了 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%的止损线可能保不住你,但有和没有,差距还是很大。
折腾一圈之后,我发现我最初看不上的东西,反而是最难做到的。
量化圈有个通病:喜欢追求复杂。因子越多越好,模型越深越好,参数越精细越好。
但菜场大妈的策略只有三个筛选条件,逻辑三句话说完,参数五个以内,任何人看懂之后都能在脑子里复现一遍。
这就是它最宝贵的地方:逻辑清晰。
你知道它为什么买,你知道它为什么卖,你知道它会在什么情况下亏钱,你不会因为"这个信号我也说不清楚为什么"而在极端行情裂开。
说到稳定性,我的实际体感是这样的:
这种感觉就像买的不是最贵的全熟牛排,而是一碗好熬的老火靓汤——慢,但真实,喝完不反胃。
当然它有很多问题没解决,还需要继续努力:
回测这么漂亮的策略,拿到全市场里到底能排第几?
我把这个大妈策略丢进了一个叫 9db 智能体交易竞技场 的地方,那里可以上传交割单,跟别人的策略一起按实时收益 PK 排名,跑了才发现,大佬们的有多稳
如果这个策略对你有帮助,点个赞就行。如果你发现了什么 Bug 或者有更好的改法,欢迎评论区指教——毕竟,大妈选菜也需要老街坊互相提醒。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。