最近的市场行情,指数涨得很漂亮,可身边的朋友却感叹,大盘3700点,个人收益却不如3000点。
相比起挑个股的不确定性,ETF这种覆盖面广、透明度高的“指数篮子”,成了许多投资者眼中的“避风港”。
而其中,ETF轮动,更是在理财社区和量化圈火得一塌糊涂。
一张张净值曲线在社交平台刷屏,“自动轮动”、“低回撤”、“策略优于人性”这些关键词,不断刺激着普通投资者的好奇心。看起来好像不需要太多经验,只要有规则,就能实现资产在不同ETF之间的切换,从而稳定跑出收益。
但热度之下,真正能理解并自己跑起来的人,其实并不多。很多人虽然听说过“轮动”,但并不清楚它的核心逻辑是什么,更不知道从哪一步开始动手去实践。
很多朋友在问:
ETF轮动到底怎么做?我不懂编程也能操作吗?是不是很难?
其实轮动策略没有你想的那么玄乎,本质上就是:按规则定期挑选表现最强的ETF,自动替换掉弱的,实在没有强的就持有债券/货币类ETF过渡。
这篇文章,我会用实操流程+附带代码模板(文末下载),带你从0理解轮动策略,并用Ptrade 平台把它真正跑起来。
看懂、照做、跑一遍,你就能实现从0到1的跨越,然后从1到2就更容易了。
一句话理解什么是ETF轮动策略
ETF轮动听起来像是个高级名词,其实本质非常朴素:就是在一组ETF里,定期做个小测验——看看谁最近表现好,就买它;谁不行了,就换掉。没找到“合格选手”?那就暂时持有更稳妥的债券/货币类ETF,先避一避。
这背后的逻辑,是用规则代替情绪。比如用“过去20天的涨幅”作为参考指标,我们只挑出最强势的那一两个ETF上场,其他的先靠边站。每隔固定时间(比如每天或每周),我们就像换岗一样复查一次,看看有没有新面孔符合标准,有就替换。
相比凭感觉选基金、盲目追涨杀跌,这种基于数据和规则的轮动方法,虽然不一定跑得最快,但胜在稳定和可复制。更重要的是,它为没有金融背景、也不懂编程的新手,提供了一条“看得懂、学得会、跑得动”的量化入门路径。
为什么轮动是适合普通投资者的选择?
如果你曾被个股的暴涨暴跌搞得心态炸裂,或者在牛市末尾一头扎进去结果被深套,那么ETF轮动可能正是你在找的东西。
首先,相比个股操作,ETF本身就具备天然的分散性。你买的是一个板块、一个指数,背后可能是几十上百只股票,不用担心踩中“爆雷”个股。这已经是在降低风险了。
其次,市场永远在“风格轮换”:有时是科技成长领跑,有时是大金融主导;有时候是A股强势,有时候是美股、港股领跑。这种变化,对于普通投资者几乎不可能做到精准把握。而轮动策略的思路就是——我不预测谁会强,但我只跟着谁正在变强。
更关键的是,轮动策略是规则先行。你不用天天看新闻、刷股吧、猜政策。系统设定好后,只要按节奏执行就行,极大减少了情绪干扰。这种“按规矩来”的方式,对普通人来说,不仅省事,也更容易坚持下去。
轮动三要素:池子、信号、频率
轮动策略看似复杂,其实核心就三件事,搞懂这三点,基本就能搭出一个“能跑起来”的轮动框架。
第一是池子
池子就是你备选的ETF名单。可以是宽基指数(比如沪深300、创业板)、行业主题(比如券商、新能源、消费)、跨境ETF(如美股、港股ETF),甚至加入债券或货币类ETF,用于“兜底”保守持仓。选池子时的关键在于:覆盖面广、风格多元、流动性好。
第二是信号
也就是用什么指标判断“谁强谁弱”。最常见的是动量指标,比如过去20日涨幅。当然也可以加其他辅助信号,如5日涨幅、年化波动率、夏普比等。简单说,就是找个靠谱的参照物,告诉你“现在谁最有潜力”。
第三是频率
也就是多久评估一次、换仓一次。可以每天、每周、每月,关键是频率一旦定下,就要保持一致。别因为市场涨了几天就心痒想提前换,也别因为下跌就临时退出。频率的设置,会直接影响策略的“灵敏度”和“稳定性”。
这三件套的顺序也很重要:先定池子,再设信号,最后设换仓节奏。这是ETF轮动的“基本功”。
一个简单的轮动策略逻辑是怎样的?
我们来搭一个简单的可运行的轮动策略,用来“跑通流程”,直接用它来测试整个策略的执行链条是否顺畅(此策略仅用于学习示例,不要直接用于实盘)。
策略目标:每次只持有一个“表现最强”的ETF;如果没有合适的标的,就退守到一个稳定的货币ETF当“避风港”。
策略规则如下(基于日线复权价):
-
排序指标:按照近20日涨幅,从高到低排列;
-
买入条件(下面3条需同时满足):
a)当前在涨幅排名前5名;
b)过去15日涨幅大于5%(说明“正在走强”);
c)过去5日涨幅小于5%(防止“过热”)。
-
卖出条件(下面3条满足任意一条即触发):
a)排名跌出前6;
b)15日涨幅跌破 -2%(变弱);
c)15日涨幅超过 20%(涨太快考虑落袋)。
-
兜底逻辑:若无标的满足买入条件,就买入“511880”(银华日利ETF)作为临时持仓;
-
换仓频率:每日收盘时执行一次。
这套逻辑的底层思路是这样的:用20日动量捕捉趋势,用15日涨幅确认是否“真强”,用5日涨幅避免追高。买入不是追最热的,而是抓正在变强、尚未过热的阶段;卖出则兼顾“衰弱”和“涨太快”的风险。
这不是一套追求“收益最大化”的策略,而是一套结构清晰、容易复制、适合新手练手的策略框架。它的意义在于:让你完成从“知道轮动”到“亲手跑出一个轮动”的跨越。
用Ptrade跑轮动,从策略想法走向真实执行
当你已经搭好了轮动策略的框架,比如选好了ETF池、定义了动量规则,也理解了买入与卖出的逻辑——接下来的关键一步,就是:如何真正落地执行这个策略?
手动轮动并不现实,每天手动筛选、计算、下单,不仅繁琐,而且容易受情绪干扰,执行不到位。这正是Ptrade能发挥最大作用的地方。
为什么推荐使用 Ptrade?
如果你希望将策略从“纸面模型”变成“实际运作的系统”,Ptrade 是一套适合新手到进阶用户的量化平台工具。它具备以下优势:
-
托管运行:策略部署在券商机房,行情、计算、下单全部自动执行,你本地电脑可关闭;
-
回测与实盘打通:支持日频/分钟级数据,验证完成后可直接复制到实盘端进行部署;
-
复权数据内置:自动处理价格跳点、除权等,动量信号更准确;
-
免配置环境:无需单独安装 Python和配置复杂环境,本地安装软件即可运行;
-
不会编程也能用:平台内置了多种券商自动交易工具,比如网格交易、智能条件单等,照着改参数就能运行(轮动策略还是得写代码)。
如何开始?

下面展示手把手操作步骤,完整代码已放在文末,可直接复制。
1.开通账户:联系支持Ptrade的券商(如国金证券等),一般账户资产门槛为10万元左右(不同券商门槛不一)
2.下载安装平台软件:注意需安装两个版本(记得联系券商经理):
o一个是回测端(用于策略编写与测试)
o一个是实盘交易端(用于真实执行与监控)
3.新建策略:打开软件,进入「量化」→「回测」界面,点击右上角「+」,新建策略项目;代码上方手动设置回测区间、金额、周期(分钟级或者日级)
4.复制轮动代码模板:将文末提供的学习用的轮动策略代码粘贴到编辑器中,保存即可
5.配置参数 & 启动回测:设好起止时间、初始资金等后,点击「开始回测」,系统将生成净值曲线、回撤等指标
6.查看结果并优化:根据持仓、调仓、盈亏等图表,逐步调整你的策略逻辑;
7.验证完成后转入实盘:确认策略表现稳定后,可部署到实盘账户,真正实现“轮动自动化”。
新手注意事项:ETF轮动避坑清单
轮动策略逻辑很简单,但在落地的过程中,细节决定成败。尤其是新手阶段,这7个坑踩一次可能就足以让你对量化策略失去信心。
①前视偏差:回测最容易失真的地方
很多人在盘中使用“当日收盘价”做决策,结果就是——回测特别好看,实盘一塌糊涂。因为你在无意中“偷看了未来”。
✅正确做法:
- 只用上一交易日的数据做判断;
- 若想盘中判断,用实时分钟价替代当日收盘数据。
②非复权数据:让动量失真
ETF遇到分红或拆分时,如果用的是未复权数据,价格会跳,涨幅就会出现偏差。特别是做短周期轮动,这个问题会被放大。
✅建议:统一使用前复权的日线数据计算涨幅。
③成本&滑点:回测时必须算进去
轮动操作频率比传统买入持有策略高,如果忽略佣金、买卖价差、滑点,回测净值会严重高估。
✅在回测框架中,一定要设置真实的交易成本模拟参数。
④流动性不足:买不进去等于白搭
一些冷门ETF虽然涨幅好看,但成交量极低。实际操作中,你可能根本买不进去,或者买入后“挂单吃不到肉”。
✅解决方案:设置流动性过滤,如日均成交额>5000万,价差<0.3%。
⑤跨境和商品ETF:不适合高频切换
跨境ETF有汇率和时差问题,商品ETF还有期货展期损耗,和净值的同步度不高。
✅建议:这类ETF适合中等周期配置。
⑥参数太极限:过拟合 = 实盘踩雷
有些策略看起来回测收益惊艳,背后却是用了“极限参数”把历史行情过度拟合。
✅提醒:优先使用“稳健、可复现”的参数,容错率更高,执行更安心。
⑦跨界个股轮动:会毁掉策略本质
ETF轮动的优势是低波动、强分散,一旦加入个股,就失去了这个体系的稳定性。
✅坚持轮动原则:只在ETF池子里“滚”,不碰个股。
常见的5个问题
即使你已经理解了轮动的基本逻辑,很多人在真正动手前,还是会有些常见疑问。
Q1|窗口期到底用多长?
最常见的是20日、30日、60日、120日等。
短周期(20日)反应灵敏、但更容易频繁换仓;长周期(如120日)信号更稳定,但可能错过短期机会。建议入门从20日起步,后续可以对比回测效果再优化。
Q2|轮动频率设多高合适?别被“懒”或“勤”误导
很多人以为轮动频率就是“你愿意多忙就多勤快”,但其实,它会深刻影响策略的风格和表现——不是越高越好,也不是越低越省心。
如今有Ptrade这类自动化平台,日度轮动早已不是“体力活”,但你仍然要问自己两个问题:
- 你的策略是想“迅速捕捉轮动机会”,还是“平稳地长期跟随趋势”?
- 你所选的ETF资产,是否适合频繁切换?有没有足够的流动性与稳定性支持高频交易?
建议从简单策略开始,等策略跑通后再对比各频率下的表现差异,做适配优化。
Q3|一次买几只ETF比较好?
开始时,一只就够,新手别贪多。
持仓越多,换仓逻辑就越复杂,模拟难度更高。等策略稳定后,再考虑增加到2–3只做均衡配置,降低波动。
Q4|为什么要用债券ETF做兜底?
因为不是每次都有强势ETF值得买。
在市场“无趋势”或普遍回撤的时候,强行入场只会增加风险。设置债券类ETF作为“临时停车场”,可以降低整体回撤,让你更容易坚持下去。
Q5|怎么判断策略“失效”了?
短期回撤 ≠ 失效。
策略是否失效,不能只看一两周的表现。建议至少观察6–12个月的完整市场周期,同时关注两个关键指标:
-
超额收益是否还能持续为正?
-
回撤是否还在你能接受的范围?
如果都偏离了,那可能需要调整参数或换池子了。
⚠️风险提示(请务必阅读)
本文所展示的ETF轮动策略仅为学习交流目的,不构成任何投资建议或收益承诺
策略逻辑及参数均为教学示例,未经优化与实盘验证,请勿直接用于任何实盘操作
投资市场具有不确定性,历史回测表现不代表未来收益
使用Ptrade等量化工具进行策略执行前,建议进行充分测试并小仓试运行
所涉及平台、工具与券商仅为使用示范
投资需谨慎,风险需自担,请根据自身情况理性决策
📌欢迎转发本文进行策略学习,但请勿直接将本文内容用于推荐他人进行实际交易。
import numpy as npimport pandas as pdfrom datetime import datetime# ———————————————————————————————————————————————————————————————# 微信公众号:打工人学量化# 项目:ETF轮动(学习示例)# 风险提示:本代码仅用于学习交流,不构成任何投资建议;切勿用于实盘。# 代码本身还存在诸多缺陷,需要完善和改进。# ———————————————————————————————————————————————————————————————# ===============================================================# 本策略做的事# 1)从一篮子 ETF(UNIVERSE)里,基于“近20/15/5日涨幅”筛选强势标的;# 2)买入条件:排名(按20日涨幅)≤5 且 15日涨幅>5% 且 5日涨幅<5%;# 3)卖出条件:排名>6 或 15日涨幅<-2% 或 15日涨幅>20%;# 4)最多只持 1 只 ETF;没有候选时,预留“兜底”ETF(FALLBACK)。# 5)为避免“前视偏差”(盘中却拿到收盘价),提供了用“1分钟最新价替换当日最后一行”的函数。# ===============================================================# ====================== 参数(可以按需改) ======================RANK_LOOKBACK = 20 # “排序窗口”:用近 20 日涨幅来排名强弱(越高越强)RET_15 = 15 # 计算 15 日涨幅用的窗口长度RET_5 = 5 # 计算 5 日涨幅用的窗口长度# TRADE_TIMES = ['14:58', ]# ↑ 如需“每天固定时点”才执行交易逻辑,可解注这行,并在 handle_data 里打开对应判断# ---- 买卖阈值(小数表示百分比)----BUY_RET15_MIN = 0.05 # 买入条件之一:15 日涨幅 > 5%BUY_RET5_MAX = 0.05 # 买入条件之一:5 日涨幅 < 5%(防“过热追高”)SELL_RET15_LT = -0.02 # 卖出条件之一:15 日涨幅 < -2%(转弱退出)SELL_RET15_GT = 0.20 # 卖出条件之一:15 日涨幅 > 20%(过热落袋)MAX_HOLD = 1 # 最大持仓数:只持 1 只 ETFFALLBACK = '511880.SS' # “兜底”ETF(如货币/现金管理类),无候选时可以考虑持有 # 说明:本文件中对 FALLBACK 的实际买入指令在下方被注释掉了, # 若你希望启用兜底,请按注释提示取消注释。# ---- 轮动标的池(只含 ETF/LOF,不含个股)----UNIVERSE = [ '159941.SZ', # 纳指ETF '518880.SS', # 黄金ETF '512800.SS', # 银行ETF '159949.SZ', # 创业板50ETF '515880.SS', # 通信ETF '512880.SS', # 证券ETF '513010.SS', # 恒生科技ETF易方达 '159996.SZ', # 家电ETF '159920.SZ', # 恒生ETF '159819.SZ', # 人工智能ETF '159928.SZ', # 消费ETF '512170.SS', # 医疗ETF '515000.SS', # 科技ETF '515790.SS', # 光伏ETF '512480.SS', # 半导体ETF '512660.SS', # 军工ETF '162411.SZ', # 华宝油气LOF '512040.SS', # 价值100ETF]# ====================== 初始化 ======================def initialize(context): # 数据频率与回看长度:保证能覆盖 20/15/5 的计算 g.period_type = '1d' # 用日线 g.lookback_window = 60 # 拉 60 根日线,足够计算 20/15/5 日涨幅 g.target_num = 1 # 最大持仓数,和 MAX_HOLD 含义一致 g.total_cash = 20000 # 可选:初始资金(具体以平台为准) g.symbols = list(UNIVERSE) + [FALLBACK] # 交易池=候选池+兜底 # 交易成本/滑点设置(回测/仿真用) if not is_trade(): set_commission(0.0001) # 佣金:万1 set_slippage(0.0002) # 滑点:万2 set_limit_mode(limit_mode='UNLIMITED')# 成交数量限制模式:不限制(按平台定义) print('初始化完成:标的{}(含兜底 {})'.format(len(g.symbols), FALLBACK))# ====================== 工具函数 ======================def _to_wide(df: pd.DataFrame) -> pd.DataFrame: ''' 将“长表”转为“宽表”,便于直接用 shift 计算涨幅。 【长表】index=时间,列包含 ['code','close'],每行是一只标的一天的收盘价; 【宽表】index=时间,列名=代码,每列是一只标的的时间序列。 - 如果输入已经是“宽表”(列名就是代码),则只筛出 g.symbols 中的列并返回; - 如果输入是“长表”,用 pivot 转成“宽表”; - 只保留我们关心的标的列,避免不相关数据干扰。 ''' if df is None or df.empty: return df if 'code' in df.columns and 'close' in df.columns: tmp = df.copy() tmp = tmp.reset_index().rename(columns={tmp.index.name or 'index': 'dt'}) wide = (tmp.pivot_table(index='dt', columns='code', values='close', aggfunc='last') .sort_index()) cols = [c for c in g.symbols if c in wide.columns] return wide[cols] if cols else pd.DataFrame(index=wide.index) # 宽表:直接在列里筛选我们关心的代码 cols = [c for c in g.symbols if c in df.columns] return df[cols].sort_index() if cols else pd.DataFrame(index=df.index)def _replace_last_row_with_latest(wide_daily: pd.DataFrame, wide_1m: pd.DataFrame) -> pd.DataFrame: ''' 在“宽表”上,用 1 分钟最新价替换“最后一行”的收盘价(仅替换列交集部分)。 用途:在盘中做决策时,避免把“当日最终收盘价”当成已知(消除前视偏差)。 注意: - 若在收盘后执行,替不替换都无所谓; - 若两张表没有公共列或有缺失,函数会安全地直接返回原表。 ''' if wide_daily is None or wide_daily.empty or wide_1m is None or wide_1m.empty: return wide_daily try: common = [c for c in wide_daily.columns if c in wide_1m.columns] if not common: return wide_daily wide_daily = wide_daily.copy() wide_daily.loc[wide_daily.index[-1], common] = wide_1m.iloc[-1][common].values except Exception as e: print('1m替换失败(忽略):', e) return wide_dailydef _compute_returns_last_row(wide_close: pd.DataFrame) -> pd.DataFrame: ''' 基于“宽表”的最后一行数据,计算每个标的的: - ret20:近 20 日涨幅 - ret15:近 15 日涨幅 - ret5 :近 5 日涨幅 - last_close:最后一行的价格(用于调试或展示) 返回:DataFrame,index=代码,columns=['ret20','ret15','ret5','last_close'] 说明: - 需要至少 N+1 根数据(例如 20 日涨幅需要 21 根); - dropna() 去掉因数据不足而产生的 NaN。 ''' if wide_close is None or wide_close.empty: return pd.DataFrame(columns=['ret20','ret15','ret5','last_close']) cols = [c for c in UNIVERSE if c in wide_close.columns] if not cols: return pd.DataFrame(columns=['ret20','ret15','ret5','last_close']) df = wide_close[cols] need = max(RANK_LOOKBACK, RET_15, RET_5) + 1 if len(df) < need: return pd.DataFrame(columns=['ret20','ret15','ret5','last_close']) last_close = df.iloc[-1] # 用 shift(N) 直接拿到“t-N”的价格:retN = P(t)/P(t-N)-1 ret20 = last_close / df.shift(RANK_LOOKBACK).iloc[-1] - 1.0 ret15 = last_close / df.shift(RET_15).iloc[-1] - 1.0 ret5 = last_close / df.shift(RET_5).iloc[-1] - 1.0 out = pd.DataFrame({ 'ret20': ret20, 'ret15': ret15, 'ret5' : ret5, 'last_close': last_close }).dropna() return outdef _rank_and_screen(ret_df: pd.DataFrame): ''' 排名与筛选(永远返回三个对象): 返回:(rank_s, buy_candidates, rank_table) - rank_s:每个代码的“20日涨幅排名”(1=最强) - buy_candidates:满足买入条件(rank≤5 且 ret15>5% 且 ret5<5%)的代码列表 - rank_table:包含 ret20/ret15/ret5/rank20 的表(用于调试或展示) ret_df 为空时,返回 (空Series, [], 空DataFrame),避免上层解包报错。 ''' if ret_df is None or ret_df.empty: return pd.Series(dtype=int), [], pd.DataFrame() tab = ret_df.copy() tab['rank20'] = tab['ret20'].rank(ascending=False, method='min').astype(int) tab = tab.sort_values('ret20', ascending=False) mask_buy = ( (tab['rank20'] <= 5) & (tab['ret15'] > BUY_RET15_MIN) & (tab['ret5'] < BUY_RET5_MAX) ) buy_list = tab[mask_buy].index.tolist() return tab['rank20'], buy_list, tabdef _need_sell(code: str, rank_s: pd.Series, ret_df: pd.DataFrame) -> bool: ''' 判断某持仓是否需要卖出(满足任一条件即卖): - 排名 > 6(相对动量下降) - 15 日涨幅 < -2%(转弱) - 15 日涨幅 > 20%(过热,落袋为安) ''' if ret_df is None or ret_df.empty or code not in ret_df.index: return True r15 = float(ret_df.at[code, 'ret15']) rk = int(rank_s.get(code, 10**9)) return (rk > 6) or (r15 < SELL_RET15_LT) or (r15 > SELL_RET15_GT)def _current_holding_list(): ''' 读取当前持仓列表(只返回“有持仓量”的代码)。 平台内置的 get_positions() 返回的是一个 dict,里边含有持仓对象。 ''' holdings = get_positions() return [holdings[p].sid for p in holdings if holdings[p].amount > 0]# ====================== 主流程(每次触发都会跑) ======================def handle_data(context, data): ''' 主交易逻辑(按你当前版本的习惯:不限定具体触发时点)。 如需改为“固定时点交易”,请: 1)上面把 TRADE_TIMES 解注并填好时间; 2)把下方“时间过滤”的三行解注。 ''' # ---- 如需固定时点交易,请解注以下 3 行 ---- # current_time = context.blotter.current_dt.strftime('%H:%M') # if current_time not in TRADE_TIMES: # return # 1) 获取“日线”并统一为宽表(列=代码,行=时间) raw_daily = get_history( count=g.lookback_window, frequency=g.period_type, field='close', security_list=g.symbols, fq='pre', include=True ) wide_daily = _to_wide(raw_daily) if wide_daily is None or wide_daily.empty: print('历史数据为空或列不含标的,跳过。') return # 2) 获取“1分钟最新价”,统一为宽表后,替换到“日线最后一行” # 作用:盘中运行时,用“当前分钟价”替代“最终收盘价”,避免前视 raw_1m = get_history( count=1, frequency='1m', field='close', security_list=g.symbols, fq='pre', include=True ) wide_1m = _to_wide(raw_1m) # 如需检查替换前后的数据,可用下面两行导出 CSV(按需解注): # path = get_research_path() # wide_daily.to_csv(rf'{path}/wide_dayly_ori.csv') wide_daily = _replace_last_row_with_latest(wide_daily, wide_1m) # wide_daily.to_csv(rf'{path}/wide_dayly1_new.csv') # 3) 基于“最后一行”计算 20/15/5 日涨幅 ret_df = _compute_returns_last_row(wide_daily) # 4) 排名+筛选(永远返回3个值,避免解包错误) rank_s, buy_candidates, rank_table = _rank_and_screen(ret_df) # 调试输出(如需观察前几名及其指标,可解注下面代码) # if not rank_table.empty: # topn = (rank_table[['ret20','ret15','ret5','rank20']] # .sort_values('ret20', ascending=False) # .head(8) # .round(4)) # print('n【Top(20日涨幅)】n', topn) print('买入候选:', buy_candidates) # 5) 卖出逻辑(含对“兜底”的处理) holding_list = _current_holding_list() hold_set = set(holding_list) for code in list(hold_set): if code not in g.symbols: continue # 若已有候选出现,则先清掉兜底(为买入腾出仓位) if code == FALLBACK and buy_candidates: order_target_value(code, 0) print('卖出兜底 {}(出现候选)'.format(code)) hold_set.discard(code) continue # 普通持仓:触发卖出条件则卖出 if code != FALLBACK and _need_sell(code, rank_s, ret_df): order_target_value(code, 0) print('卖出 {}(触发卖出规则)'.format(code)) hold_set.discard(code) # 6) 买入逻辑:最多 1 只,取候选列表中的第一名(即相对最强) non_fallback_holds = [c for c in hold_set if c != FALLBACK] if len(non_fallback_holds) < MAX_HOLD: if buy_candidates: target = buy_candidates[0] if target not in hold_set: # 若还持有兜底,先清掉 if FALLBACK in hold_set: order_target_value(FALLBACK, 0) print('清仓兜底 {},准备买入 {}。'.format(FALLBACK, target)) hold_set.discard(FALLBACK) # 全仓买入目标(仅 1 只) cash = context.portfolio.cash if cash > 0: order_target_value(target, cash) print('买入 {},金额约 {:.2f}'.format(target, cash)) hold_set.add(target) else: print('现金不足,无法买入。') else: # 无候选:此处预留“兜底买入”逻辑(默认被注释,按需启用) if FALLBACK not in hold_set: cash = context.portfolio.cash # 如需实现在无候选时买入兜底,请取消下面三行注释: # if cash > 0: # order_target_value(FALLBACK, cash) # print('无候选,买入兜底 {},金额约 {:.2f}'.format(FALLBACK, cash)) # hold_set.add(FALLBACK) # 7) 极端保护:若仍然空仓,也可以选择买入兜底(默认注释) non_fallback_holds = [c for c in hold_set if c != FALLBACK] if not non_fallback_holds and FALLBACK not in hold_set: cash = context.portfolio.cash # 如需开启“极端保护兜底”,请取消下面三行注释: # if cash > 0: # order_target_value(FALLBACK, cash) # print('保护性持仓:买入兜底 {},金额约 {:.2f}'.format(FALLBACK, cash)) # else: # print('保护性持仓失败:现金为0。')