最近的市场行情,指数涨得很漂亮,可身边的朋友却感叹,大盘3700,个人收益却不如3000点。

相比起挑个股的不确定性,ETF这种覆盖面广、透明度高的指数篮子,成了许多投资者眼中的避风港

而其中,ETF轮动,更是在理财社区和量化圈火得一塌糊涂。

一张张净值曲线在社交平台刷屏,自动轮动低回撤策略优于人性这些关键词,不断刺激着普通投资者的好奇心。看起来好像不需要太多经验,只要有规则,就能实现资产在不同ETF之间的切换,从而稳定跑出收益。

但热度之下,真正能理解并自己跑起来的人,其实并不多。很多人虽然听说过轮动,但并不清楚它的核心逻辑是什么,更不知道从哪一步开始动手去实践。


很多朋友在问:

ETF轮动到底怎么做?我不懂编程也能操作吗?是不是很难?

其实轮动策略没有你想的那么玄乎,本质上就是:按规则定期挑选表现最强的ETF,自动替换掉弱的,实在没有强的就持有债券/货币类ETF过渡。

这篇文章,我会用实操流程+附带代码模板(文末下载),带你从0理解轮动策略,并用Ptrade 平台把它真正跑起来。

看懂、照做、跑一遍,你就能实现从01的跨越,然后从12就更容易了。


一句话理解什么是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和配置复杂环境,本地安装软件即可运行;

  • 不会编程也能用:平台内置了多种券商自动交易工具,比如网格交易、智能条件单等,照着改参数就能运行(轮动策略还是得写代码)

如何开始?

ETF轮动小白入门:从0跑通你的第一个量化策略

下面展示手把手操作步骤,完整代码已放在文末,可直接复制。

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等量化工具进行策略执行前,建议进行充分测试并小仓试运行

所涉及平台、工具与券商仅为使用示范

投资需谨慎,风险需自担,请根据自身情况理性决策

📌欢迎转发本文进行策略学习,但请勿直接将本文内容用于推荐他人进行实际交易。

ETF轮动示例代码:
在下方直接复制好像存在代码缩进的问题,可以在公众号主页直接获取.py文件(菜单栏:ETF策略)。
如果对Ptrade也感兴趣,可在公众号主页 菜单栏 点击“粉丝福利”,享受“双低”福利(低yj+低门槛)
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 > 6or (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。')