ETF 轮动

目标:在共享现金账户里,每五个交易日比较两只 ETF 的动量,持有较强的一只,并检查换仓、费用与基准。

完成后,你会得到一份可重复运行的策略脚本和 etf-rotation-report/ 报告目录。示例自带固定人工行情,运行后即可逐笔查看换仓,再接入自己的历史数据。

1. 准备可重复的数据

先按安装指南安装 DoriBT。下载strategies.py,把下文代码依次保存到同一目录的 tutorial_etf_rotation.py。不要把自己的脚本命名为 doribt.py 或 strategies.py。

这里复用示例的 synthetic_market()。它创建 ALPHA、BETA 两只人工 ETF、固定价格路径和共享日历,无需网络、账户或随机种子。示例日历按工作日生成,规则为 T+1、100 份申报单位、零印花税和过户费。接入自己的证券时,使用交易所日历和相应的 china_rules 分类。

from pathlib import Path

from strategies import synthetic_market

from doribt import Backtest, Benchmark, Context, Costs, Parameter, ParameterSet, RunConfig

data = synthetic_market()
print("证券顺序:", data.symbols)
print("交易日数:", len(data.sessions))

运行数据先有两个检查点:证券顺序为 ALPHA, BETA,共有 86 个输入交易日。证券顺序也决定同动量时的优先级及资金不足时的买入顺序,研究时应固定它。

2. 把研究问题写成策略

本例提前固定这些选择:

  • 观察最近 20 根已完成日线,用末价/首价减一计算动量。这是 20 个价格点、19 个收益间隔

  • 每五个输入交易日决策一次,不等同于自然周周五

  • 选择动量最高的证券,目标权重 95%,其余持仓清零;即使两者动量都为负,也仍选择较强者

  • 留出 5% 现金缓冲,费用与整手限制仍可能使实际仓位偏离目标

  • 按决策收盘权益定量,下一根 bar 开盘才尝试成交

parameters = ParameterSet(
    {
        "lookback": Parameter(type="int", default=20, minimum=2, maximum=60),
        "every": Parameter(type="int", default=5, minimum=1, maximum=20),
        "allocation": Parameter(type="float", default=0.95, minimum=0, maximum=1),
    }
)


def rotation(ctx: Context, *, lookback: int, every: int, allocation: float) -> None:
    completed = ctx.bar_index + 1
    if completed < lookback or completed % every:
        return
    momentum = {}
    for symbol in ctx.symbols:
        history = ctx.history(symbol, bars=lookback)
        momentum[symbol] = float(history[-1] / history[0] - 1)
    winner = max(momentum, key=lambda symbol: momentum[symbol])
    ctx.record(
        momentum_alpha=momentum["ALPHA"],
        momentum_beta=momentum["BETA"],
        selected_index=ctx.symbols.index(winner),
    )
    ctx.target_weights({winner: allocation}, rebalance=True)


def hold_alpha(ctx: Context) -> None:
    ctx.target_weights({"ALPHA": 0.95})

ctx.history 只含当前及之前已完成的 bar。target_weights 表示整个组合,省略的证券目标为零;rebalance=True 表示每次决策按最新权益重新定量,即使获胜证券没有变化。只调整指定证券的股数时,使用 target_positions。

ctx.record 留下每次决策的动量和选中证券序号,便于解释换仓。非决策日为空,不自动前填。selected_index=0 对应 ALPHA,1 对应 BETA。

3. 用相同费用比较轮动和持有

config = RunConfig(
    initial_cash=100_000,
    costs=Costs(commission=0.0003, minimum_commission=5),
    backend="python",
)
result = Backtest(data, config=config).run(rotation, parameter_schema=parameters)
hold_result = Backtest(data, config=config).run(hold_alpha)

# 价格基准不模拟费用;另跑的 hold_result 才是有相同费用约束的对照账户。
benchmark = Benchmark(
    sessions=data.sessions,
    prices=data.prices("close")[:, 0],
    name="ALPHA 人工价格基准",
    source=data.source,
)
report = result.report(benchmark=benchmark, periods_per_year=252)
print("轮动期末权益:", round(float(result.equity[-1]), 2))
print("持有期末权益:", round(float(hold_result.equity[-1]), 2))
for name in ("total_return", "max_drawdown", "total_fees", "turnover", "excess_total_return"):
    print(name, report.stats[name])
print("实际参数:", result.run_info.to_dict()["parameters"])

两次 run 使用独立账户,便于比较轮动和持有。轮动账户内的 ALPHA 与 BETA 共享 100000 元,换仓先卖后买,成交按 T+1、资金和成交量约束推进。

本例使用默认成交模型,按 252 个交易日年化。你可以改变佣金、最低佣金、滑点和成交量参与率,观察成本对换仓和收益的影响,见分钟回测。

按本页参数,轮动期末权益应为 90046.55 元,持有 ALPHA 的对照账户为 116025.76 元。打开成交记录,可以检查轮动何时换仓,以及与持续持有的差异。

4. 检查成交,再保存结果

for fill in result.fills[:6]:
    print(fill.session, fill.symbol, fill.quantity, fill.price, fill.fees)

blocked = [order for order in result.orders if order.reason.value != "none"]
print("带约束原因的订单:", len(blocked))
for order in blocked[:5]:
    print(order.session, order.symbol, order.filled, order.reason.value)

assert result.symbols == ("ALPHA", "BETA")
assert len(result.fills) > 0
output = result.export(Path("etf-rotation-report"), benchmark=benchmark, daily=True)
print("报告目录:", output)

在脚本所在目录运行:

python tutorial_etf_rotation.py

为每次实验选择一个新报告目录名,即可并排保留结果。先读 fills.csv 的换仓方向、orders.csv 的受阻原因,再读 report.json 的收益和回撤;research.json 保留刚才的动量记录,run.json 保留实际参数与数据来源。详细文件含义见结果与导出。

要导出图表,安装 doribt[plot],并在本次 export 调用中加 plot=True;这会在同一个新报告目录生成 equity.png。

5. 换成真实 ETF 时改哪里

本例的轮动逻辑假定两只证券在整个回测区间始终上市,且每个决策窗口都有完整、有限的价格。对这样的证券池,可保留策略与分析部分,替换 data = synthetic_market()。用数据到报告教程中的 MarketData.from_csv 或 from_records 接入已取得授权的原始行情,同时提供:

  1. 独立交易所日历,包含没有成交的交易日;不要从现有价格行反推日历

  2. 每日、每个证券恰好一行,停牌、上市前及退市后状态明确

  3. 证券身份、经核验的 china_rules profile 或自定义日期规则,以及每日真实涨跌停价

  4. 期间完整公司行动;需要连续研究价时提供可知时点正确的复权因子,并把历史读取改为 adjustment="asof"

研究池包含新上市或退市证券时,先按当时的上市状态和有效价格筛选动量窗口;窗口不足的证券暂缓排名。没有合格标的时,在策略中选择清仓或维持仓位。保留完整行情行,让账户继续处理原有持仓和未完成订单。

真实代码可以替换 ALPHA、BETA;也要同步改掉 ctx.record 中的两处人工名称。扩展到更多证券时,考虑把每只证券的动量记录命名规则写成一个明确的数据映射。

扩展研究时,可以继续比较不同观察窗口、调仓间隔和仓位比例;用按当时信息构成的历史证券池,并在独立时间段检验结果。连续研究价格使用 adjustment="asof",保持每次决策的可知时点。

下一步读策略与目标语义、原始价与研究复权和市场支持矩阵。