995 lines
50 KiB
Plaintext
995 lines
50 KiB
Plaintext
{
|
||
"cells": [
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "2cf0ca5c",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"\n",
|
||
"A 股行业 ETF 轮动策略 Demo\n",
|
||
"A-Share Sector ETF Rotation Strategy Demo\n",
|
||
"==========================================\n",
|
||
"\n",
|
||
"策略逻辑 (Strategy Logic):\n",
|
||
" 1. 相对动量轮动 (Relative Momentum Rotation)\n",
|
||
" - 每月对所有行业 ETF 计算过去 N 个月的动量得分\n",
|
||
" - 买入得分最高的前 K 个 ETF,等权持有\n",
|
||
" 2. 双动量策略 (Dual Momentum, Gary Antonacci)\n",
|
||
" - 绝对动量 (Absolute Momentum): 若某 ETF 的动量 < 0,切换至国债 ETF 避险\n",
|
||
" - 相对动量 (Relative Momentum): 在通过绝对动量过滤的 ETF 中,买入相对最强的\n",
|
||
" 3. 等权基准 (Equal Weight Benchmark)\n",
|
||
" - 始终等权持有全部行业 ETF,不做轮动\n",
|
||
"\n",
|
||
"成本模拟 (Cost Simulation):\n",
|
||
" - 印花税 (Stamp Duty): 卖出 0.1%\n",
|
||
" - 佣金 (Commission): 买卖各 0.03%(万三)\n",
|
||
" - 滑点 (Slippage): 单边 0.05%\n",
|
||
" - 每月换仓合计约 0.22% 双边成本\n",
|
||
"\n",
|
||
"作者: GitHub Copilot (教学用途)\n",
|
||
"数据: 合成模拟数据 (Synthetic data for demonstration)\n",
|
||
"\"\"\"\n",
|
||
"\n",
|
||
"# ── 标准库 ──────────────────────────────────────────────────────────────────\n",
|
||
"import warnings\n",
|
||
"warnings.filterwarnings(\"ignore\")\n",
|
||
"\n",
|
||
"# matplotlib 必须在 pyplot 之前设置后端 (must set backend before importing pyplot)\n",
|
||
"import matplotlib\n",
|
||
"matplotlib.use(\"Agg\") # 无界面后端 (headless backend)\n",
|
||
"\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"import matplotlib.gridspec as gridspec\n",
|
||
"from matplotlib.patches import FancyArrowPatch\n",
|
||
"import matplotlib.ticker as mtick\n",
|
||
"\n",
|
||
"# 设置随机种子,保证结果可复现 (fix random seed for reproducibility)\n",
|
||
"np.random.seed(42)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "a6555659",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §0 ETF 池定义 & 合成价格数据生成\n",
|
||
"# ETF Universe Definition & Synthetic Price Data Generation\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"\n",
|
||
"# ── A 股行业 ETF 池 (A-Share Sector ETF Universe) ───────────────────────────\n",
|
||
"# 这里用合成数据模拟,真实代码只需替换为 AKShare/Tushare 数据读取\n",
|
||
"# (Synthetic data here; replace with AKShare/Tushare data in production)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "269510d5",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"ETF_UNIVERSE = {\n",
|
||
" # 代码 (Code) : (中文名称, 英文名称, 年化收益均值, 年化波动率, 与市场相关性)\n",
|
||
" \"159995\": (\"芯片ETF\", \"Chip ETF\", 0.18, 0.45, 0.75),\n",
|
||
" \"159819\": (\"人工智能ETF\",\"AI ETF\", 0.20, 0.48, 0.72),\n",
|
||
" \"516160\": (\"新能源ETF\", \"New Energy ETF\", 0.15, 0.42, 0.70),\n",
|
||
" \"159928\": (\"消费ETF\", \"Consumer ETF\", 0.10, 0.28, 0.65),\n",
|
||
" \"512170\": (\"医疗ETF\", \"Healthcare ETF\", 0.08, 0.32, 0.60),\n",
|
||
" \"512880\": (\"证券ETF\", \"Securities ETF\", 0.12, 0.38, 0.80),\n",
|
||
" # 国债 ETF 作为避险资产 (Bond ETF as safe-haven asset)\n",
|
||
" \"511010\": (\"国债ETF\", \"Bond ETF\", 0.03, 0.03, -0.10),\n",
|
||
"}\n",
|
||
"\n",
|
||
"# 分离行业 ETF 和国债 ETF (separate sector ETFs from bond ETF)\n",
|
||
"SECTOR_ETFS = [code for code in ETF_UNIVERSE if code != \"511010\"]\n",
|
||
"BOND_ETF = \"511010\"\n",
|
||
"ALL_ETFS = list(ETF_UNIVERSE.keys())\n",
|
||
"\n",
|
||
"# ── 生成合成价格序列 (Generate Synthetic Price Series) ───────────────────────\n",
|
||
"# 用因子模型生成相关价格序列,模拟真实行业之间的联动关系\n",
|
||
"# (Factor model to generate correlated price series, mimicking real sector co-movement)\n",
|
||
"\n",
|
||
"TRADING_DAYS = 252 # 年交易日数 (trading days per year)\n",
|
||
"SIM_YEARS = 6 # 模拟年数 (simulation years): 2019-2024\n",
|
||
"N_DAYS = TRADING_DAYS * SIM_YEARS\n",
|
||
"\n",
|
||
"# 市场公共因子 (market common factor) —— 模拟沪深300走势\n",
|
||
"# 牛市/熊市分段:模拟 2019-2021 牛市,2021-2022 熊市,2023-2024 震荡\n",
|
||
"market_factor = np.zeros(N_DAYS)\n",
|
||
"# 第一阶段:2019-2021 牛市 (Bull market phase)\n",
|
||
"bull1 = int(N_DAYS * 0.40)\n",
|
||
"market_factor[:bull1] = np.random.normal(0.0008, 0.012, bull1)\n",
|
||
"# 第二阶段:2021-2022 熊市 (Bear market phase)\n",
|
||
"bear1 = int(N_DAYS * 0.20)\n",
|
||
"market_factor[bull1:bull1+bear1] = np.random.normal(-0.0006, 0.018, bear1)\n",
|
||
"# 第三阶段:2023-2024 震荡 (Sideways phase)\n",
|
||
"market_factor[bull1+bear1:] = np.random.normal(0.0002, 0.014, N_DAYS - bull1 - bear1)\n",
|
||
"\n",
|
||
"# 生成每只 ETF 的日收益率 (generate daily returns for each ETF)\n",
|
||
"daily_returns = {}\n",
|
||
"for code, (cn_name, en_name, ann_ret, ann_vol, mkt_corr) in ETF_UNIVERSE.items():\n",
|
||
" daily_mu = ann_ret / TRADING_DAYS # 日均收益 (daily mean return)\n",
|
||
" daily_sig = ann_vol / np.sqrt(TRADING_DAYS) # 日波动率 (daily volatility)\n",
|
||
"\n",
|
||
" # 特异性收益 (idiosyncratic return) = 总收益 - 市场部分\n",
|
||
" idio_sig = daily_sig * np.sqrt(1 - mkt_corr**2)\n",
|
||
" idio = np.random.normal(0, idio_sig, N_DAYS)\n",
|
||
"\n",
|
||
" # 总日收益 = 市场因子 × 相关系数 + 特异性收益 + 均值漂移\n",
|
||
" ret = mkt_corr * market_factor + idio + (daily_mu - mkt_corr * 0.0002)\n",
|
||
" daily_returns[code] = ret\n",
|
||
"\n",
|
||
"# 转为 DataFrame,并生成价格序列 (convert to DataFrame, then price series)\n",
|
||
"start_date = pd.Timestamp(\"2019-01-02\")\n",
|
||
"dates = pd.bdate_range(start=start_date, periods=N_DAYS) # 仅工作日 (business days only)\n",
|
||
"\n",
|
||
"returns_df = pd.DataFrame(daily_returns, index=dates)\n",
|
||
"# 价格从 1.0 开始(净值形式,Net Asset Value style)\n",
|
||
"price_df = (1 + returns_df).cumprod()\n",
|
||
"\n",
|
||
"print(\"═\" * 60)\n",
|
||
"print(\"§0 ETF 价格数据生成完成 (Price Data Generated)\")\n",
|
||
"print(\"═\" * 60)\n",
|
||
"print(f\" 模拟区间 (Simulation Period): {dates[0].date()} → {dates[-1].date()}\")\n",
|
||
"print(f\" 交易日数 (Trading Days): {N_DAYS}\")\n",
|
||
"print(f\" ETF 数量 (Number of ETFs): {len(ALL_ETFS)}\")\n",
|
||
"print()\n",
|
||
"print(\" ETF 池 (Universe):\")\n",
|
||
"print(f\" {'代码':10s} {'中文名':12s} {'英文名':18s} {'年化收益':8s} {'年化波动':8s}\")\n",
|
||
"print(\" \" + \"-\" * 60)\n",
|
||
"for code, (cn, en, ret, vol, _) in ETF_UNIVERSE.items():\n",
|
||
" label = \" [避险]\" if code == BOND_ETF else \"\"\n",
|
||
" print(f\" {code:10s} {cn:12s} {en:18s} {ret*100:6.1f}% {vol*100:6.1f}%{label}\")\n",
|
||
"print()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "7e083547",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §1 动量因子计算\n",
|
||
"# Momentum Factor Calculation\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "e01ab734",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"def calc_momentum(price_df: pd.DataFrame,\n",
|
||
" lookback_months: int = 12,\n",
|
||
" skip_months: int = 1) -> pd.DataFrame:\n",
|
||
" \"\"\"\n",
|
||
" 计算每只 ETF 的动量得分 (Calculate momentum score for each ETF)\n",
|
||
"\n",
|
||
" 经典动量定义 (Classic momentum definition):\n",
|
||
" 过去 L 个月的总收益,跳过最近 S 个月(避免短期反转)\n",
|
||
" Total return over past L months, skipping most recent S months\n",
|
||
" (to avoid short-term reversal effect 短期反转效应)\n",
|
||
"\n",
|
||
" 参数 (Parameters):\n",
|
||
" price_df : 日频价格 DataFrame (daily price DataFrame)\n",
|
||
" lookback_months : 回望期(月数)(lookback period in months)\n",
|
||
" skip_months : 跳过最近月数(通常为 1)(skip recent months, usually 1)\n",
|
||
"\n",
|
||
" 返回 (Returns):\n",
|
||
" 月频动量得分 DataFrame (monthly momentum score DataFrame)\n",
|
||
" \"\"\"\n",
|
||
" # 转为月末价格 (resample to month-end prices)\n",
|
||
" # 注意:旧版 pandas 用 \"M\",新版用 \"ME\"\n",
|
||
" try:\n",
|
||
" monthly_price = price_df.resample(\"ME\").last()\n",
|
||
" except ValueError:\n",
|
||
" monthly_price = price_df.resample(\"M\").last()\n",
|
||
"\n",
|
||
" # 动量 = 前 (lookback+skip) 月价格 / 前 skip 月价格 - 1\n",
|
||
" # Momentum = Price[t - skip] / Price[t - lookback - skip] - 1\n",
|
||
" start_lag = skip_months\n",
|
||
" end_lag = lookback_months + skip_months\n",
|
||
"\n",
|
||
" momentum = monthly_price.shift(start_lag) / monthly_price.shift(end_lag) - 1\n",
|
||
" return momentum\n",
|
||
"\n",
|
||
"\n",
|
||
"# 计算三种不同回望期的动量(稳健性检验用)\n",
|
||
"# (Calculate momentum with three different lookback windows for robustness)\n",
|
||
"mom_12_1 = calc_momentum(price_df, lookback_months=12, skip_months=1) # 经典 12-1 月动量\n",
|
||
"mom_6_1 = calc_momentum(price_df, lookback_months=6, skip_months=1) # 中期 6-1 月动量\n",
|
||
"mom_3_1 = calc_momentum(price_df, lookback_months=3, skip_months=1) # 短期 3-1 月动量\n",
|
||
"\n",
|
||
"# 合成动量得分:三个窗口等权平均(减少单一窗口过拟合风险)\n",
|
||
"# (Composite momentum score: equal-weight average of three windows)\n",
|
||
"mom_composite = (mom_12_1 + mom_6_1 + mom_3_1) / 3\n",
|
||
"\n",
|
||
"print(\"§1 动量因子计算完成 (Momentum Factors Calculated)\")\n",
|
||
"print(f\" 月度数据行数 (Monthly rows): {len(mom_12_1)}\")\n",
|
||
"print()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "a098f350",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §2 A 股交易成本模型\n",
|
||
"# A-Share Transaction Cost Model\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "c18a15bd",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"class AShareCostModel:\n",
|
||
" \"\"\"\n",
|
||
" A 股 ETF 交易成本模型 (A-Share ETF Transaction Cost Model)\n",
|
||
"\n",
|
||
" A 股 ETF 特殊规则 (A-Share ETF Special Rules):\n",
|
||
" - ETF 可 T+0 交易(当天买当天可卖)Unlike 个股的 T+1 限制\n",
|
||
" (ETF allows T+0 trading, unlike individual stocks which are T+1)\n",
|
||
" - 印花税:仅卖出方向收取 0.1%\n",
|
||
" (Stamp duty: 0.1% on SELL side only)\n",
|
||
" - 佣金:买卖双向,通常 0.02% - 0.03%\n",
|
||
" (Commission: both sides, typically 0.02%-0.03%)\n",
|
||
" - 滑点:ETF 流动性好,通常 0.02%-0.05%\n",
|
||
" (Slippage: ETF has good liquidity, typically 0.02%-0.05%)\n",
|
||
" \"\"\"\n",
|
||
" def __init__(self,\n",
|
||
" commission_rate: float = 0.0003, # 佣金率 (commission rate)\n",
|
||
" stamp_duty_rate: float = 0.001, # 印花税率 (stamp duty rate)\n",
|
||
" slippage_rate: float = 0.0005): # 滑点率 (slippage rate)\n",
|
||
" self.commission = commission_rate\n",
|
||
" self.stamp_duty = stamp_duty_rate\n",
|
||
" self.slippage = slippage_rate\n",
|
||
"\n",
|
||
" def buy_cost(self, trade_value: float) -> float:\n",
|
||
" \"\"\"买入成本 (buy-side cost): 佣金 + 滑点\"\"\"\n",
|
||
" return trade_value * (self.commission + self.slippage)\n",
|
||
"\n",
|
||
" def sell_cost(self, trade_value: float) -> float:\n",
|
||
" \"\"\"卖出成本 (sell-side cost): 佣金 + 印花税 + 滑点\"\"\"\n",
|
||
" return trade_value * (self.commission + self.stamp_duty + self.slippage)\n",
|
||
"\n",
|
||
" def roundtrip_cost(self, trade_value: float) -> float:\n",
|
||
" \"\"\"\n",
|
||
" 双边成本 (round-trip cost): 买入 + 卖出\n",
|
||
" 月度换仓一次的总成本约 0.22%\n",
|
||
" \"\"\"\n",
|
||
" return self.buy_cost(trade_value) + self.sell_cost(trade_value)\n",
|
||
"\n",
|
||
" @property\n",
|
||
" def roundtrip_rate(self) -> float:\n",
|
||
" \"\"\"双边成本率 (round-trip cost rate)\"\"\"\n",
|
||
" return 2 * self.commission + self.stamp_duty + 2 * self.slippage\n",
|
||
"\n",
|
||
"\n",
|
||
"cost_model = AShareCostModel()\n",
|
||
"print(\"§2 交易成本模型 (Transaction Cost Model):\")\n",
|
||
"print(f\" 佣金率 (Commission): {cost_model.commission*100:.3f}% × 双边\")\n",
|
||
"print(f\" 印花税 (Stamp Duty): {cost_model.stamp_duty*100:.3f}% × 卖出方向\")\n",
|
||
"print(f\" 滑点率 (Slippage): {cost_model.slippage*100:.3f}% × 双边\")\n",
|
||
"print(f\" 双边总成本 (Roundtrip): {cost_model.roundtrip_rate*100:.3f}%\")\n",
|
||
"print()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "ac6f0f41",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §3 轮动回测引擎\n",
|
||
"# Rotation Backtest Engine\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "c6148ff2",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"def run_rotation_backtest(\n",
|
||
" price_df: pd.DataFrame,\n",
|
||
" momentum_df: pd.DataFrame,\n",
|
||
" strategy: str = \"relative_momentum\",\n",
|
||
" top_k: int = 3,\n",
|
||
" cost_model: AShareCostModel = None,\n",
|
||
" bond_etf: str = BOND_ETF,\n",
|
||
" sector_etfs: list = None,\n",
|
||
") -> dict:\n",
|
||
" \"\"\"\n",
|
||
" 行业 ETF 轮动回测引擎 (Sector ETF Rotation Backtest Engine)\n",
|
||
"\n",
|
||
" 参数 (Parameters):\n",
|
||
" price_df : 日频价格 (daily prices)\n",
|
||
" momentum_df : 月频动量得分 (monthly momentum scores)\n",
|
||
" strategy : 策略名称 (strategy name)\n",
|
||
" \"relative_momentum\" — 相对动量轮动\n",
|
||
" \"dual_momentum\" — 双动量(含绝对动量过滤)\n",
|
||
" \"equal_weight\" — 等权基准\n",
|
||
" top_k : 持有前 K 只 ETF (hold top-K ETFs)\n",
|
||
" cost_model : 交易成本模型 (transaction cost model)\n",
|
||
" bond_etf : 避险 ETF 代码 (safe-haven ETF code)\n",
|
||
" sector_etfs : 行业 ETF 代码列表 (sector ETF codes)\n",
|
||
"\n",
|
||
" 返回 (Returns):\n",
|
||
" dict 包含:\n",
|
||
" \"nav\" : 每日净值序列 (daily NAV series)\n",
|
||
" \"weights\" : 月度持仓权重 (monthly weights)\n",
|
||
" \"turnover\" : 月度换手率 (monthly turnover)\n",
|
||
" \"cost_total\" : 总交易成本 (total transaction cost)\n",
|
||
" \"holdings_log\": 持仓记录 (holdings log)\n",
|
||
" \"\"\"\n",
|
||
" if sector_etfs is None:\n",
|
||
" sector_etfs = SECTOR_ETFS\n",
|
||
" if cost_model is None:\n",
|
||
" cost_model = AShareCostModel()\n",
|
||
"\n",
|
||
" # 获取月末再平衡日期 (get month-end rebalancing dates)\n",
|
||
" try:\n",
|
||
" monthly_idx = price_df.resample(\"ME\").last().index\n",
|
||
" except ValueError:\n",
|
||
" monthly_idx = price_df.resample(\"M\").last().index\n",
|
||
"\n",
|
||
" # 预热期:动量需要至少 13 个月数据(12 个月回望 + 1 个月跳过)\n",
|
||
" # (Warm-up period: momentum needs at least 13 months of data)\n",
|
||
" warmup = 13\n",
|
||
"\n",
|
||
" # 初始化 (initialization)\n",
|
||
" nav = pd.Series(index=price_df.index, dtype=float)\n",
|
||
" weights_log = {} # 每月权重记录 (monthly weights log)\n",
|
||
" turnover_log = {} # 每月换手率记录 (monthly turnover log)\n",
|
||
" holdings_log = {} # 每月持仓记录 (monthly holdings log)\n",
|
||
" total_cost = 0.0 # 累计交易成本 (cumulative transaction cost)\n",
|
||
"\n",
|
||
" current_weights = {} # 当前持仓权重 {code: weight}\n",
|
||
" portfolio_value = 1.0 # 组合净值,从 1.0 开始 (portfolio starts at 1.0)\n",
|
||
"\n",
|
||
" rebal_dates = monthly_idx[warmup:] # 有效再平衡日期 (valid rebalance dates)\n",
|
||
"\n",
|
||
" for i, rebal_date in enumerate(rebal_dates):\n",
|
||
" # ── 1. 确定目标权重 (Determine target weights) ──────────────────────\n",
|
||
"\n",
|
||
" if strategy == \"equal_weight\":\n",
|
||
" # 等权:始终持有所有行业 ETF,各占 1/N\n",
|
||
" # (Equal weight: always hold all sector ETFs equally)\n",
|
||
" n = len(sector_etfs)\n",
|
||
" target_weights = {code: 1.0 / n for code in sector_etfs}\n",
|
||
"\n",
|
||
" elif strategy == \"relative_momentum\":\n",
|
||
" # 相对动量:选动量得分最高的前 K 只行业 ETF\n",
|
||
" # (Relative momentum: select top-K sector ETFs by momentum score)\n",
|
||
" mom_today = momentum_df.loc[:rebal_date, sector_etfs].iloc[-1]\n",
|
||
" mom_today = mom_today.dropna()\n",
|
||
" if len(mom_today) == 0:\n",
|
||
" target_weights = current_weights or {sector_etfs[0]: 1.0}\n",
|
||
" else:\n",
|
||
" top_etfs = mom_today.nlargest(top_k).index.tolist()\n",
|
||
" w = 1.0 / len(top_etfs)\n",
|
||
" target_weights = {code: w for code in top_etfs}\n",
|
||
"\n",
|
||
" elif strategy == \"dual_momentum\":\n",
|
||
" # 双动量:先用绝对动量过滤,再做相对动量排序\n",
|
||
" # (Dual momentum: filter by absolute momentum, then rank by relative)\n",
|
||
" mom_today = momentum_df.loc[:rebal_date, sector_etfs].iloc[-1]\n",
|
||
" mom_today = mom_today.dropna()\n",
|
||
"\n",
|
||
" # 绝对动量过滤 (absolute momentum filter):\n",
|
||
" # 如果行业 ETF 动量 < 0(跑输无风险),则替换为国债 ETF\n",
|
||
" # (If sector ETF momentum < 0, replace with bond ETF)\n",
|
||
" positive_etfs = mom_today[mom_today > 0].index.tolist()\n",
|
||
"\n",
|
||
" if len(positive_etfs) == 0:\n",
|
||
" # 全部动量为负:100% 持有国债 ETF 避险\n",
|
||
" # (All negative momentum: 100% in bond ETF)\n",
|
||
" target_weights = {bond_etf: 1.0}\n",
|
||
" else:\n",
|
||
" # 从通过绝对动量过滤的 ETF 中,选相对最强的前 K 只\n",
|
||
" # (From ETFs passing absolute momentum filter, pick top-K by relative)\n",
|
||
" k_actual = min(top_k, len(positive_etfs))\n",
|
||
" top_etfs = mom_today[positive_etfs].nlargest(k_actual).index.tolist()\n",
|
||
" w = 1.0 / k_actual\n",
|
||
" target_weights = {code: w for code in top_etfs}\n",
|
||
" else:\n",
|
||
" raise ValueError(f\"未知策略 (Unknown strategy): {strategy}\")\n",
|
||
"\n",
|
||
" # ── 2. 计算换手率并扣除交易成本 (Calculate turnover and deduct costs) ──\n",
|
||
"\n",
|
||
" # 所有涉及的 ETF 代码(新旧持仓并集)\n",
|
||
" all_codes = set(current_weights) | set(target_weights)\n",
|
||
" turnover = 0.0\n",
|
||
"\n",
|
||
" for code in all_codes:\n",
|
||
" old_w = current_weights.get(code, 0.0)\n",
|
||
" new_w = target_weights.get(code, 0.0)\n",
|
||
" delta = abs(new_w - old_w) # 权重变化 (weight change)\n",
|
||
" if delta > 1e-6:\n",
|
||
" turnover += delta\n",
|
||
" # 对权重变化部分计算成本 (cost on the traded portion)\n",
|
||
" trade_value = delta * portfolio_value\n",
|
||
" if new_w > old_w:\n",
|
||
" # 增仓 → 买入成本 (increase position → buy cost)\n",
|
||
" cost = cost_model.buy_cost(trade_value)\n",
|
||
" else:\n",
|
||
" # 减仓 → 卖出成本(含印花税)\n",
|
||
" # (decrease position → sell cost including stamp duty)\n",
|
||
" cost = cost_model.sell_cost(trade_value)\n",
|
||
" total_cost += cost\n",
|
||
" portfolio_value -= cost # 成本从组合价值中扣除 (deduct cost from portfolio)\n",
|
||
"\n",
|
||
" # 换手率(单边)= 买入总金额 / 组合价值 (one-sided turnover)\n",
|
||
" turnover_log[rebal_date] = turnover / 2 # 双边换手/2 = 单边换手\n",
|
||
"\n",
|
||
" # ── 3. 更新持仓,计算到下一个再平衡日的收益\n",
|
||
" # (Update holdings, compute returns until next rebalance date)\n",
|
||
" current_weights = target_weights\n",
|
||
" weights_log[rebal_date] = target_weights.copy()\n",
|
||
" holdings_log[rebal_date] = list(target_weights.keys())\n",
|
||
"\n",
|
||
" # 确定持仓持续的日期区间 (determine holding period date range)\n",
|
||
" if i + 1 < len(rebal_dates):\n",
|
||
" next_rebal = rebal_dates[i + 1]\n",
|
||
" else:\n",
|
||
" next_rebal = price_df.index[-1]\n",
|
||
"\n",
|
||
" hold_dates = price_df.loc[rebal_date:next_rebal].index\n",
|
||
"\n",
|
||
" # 计算每日组合收益率 (calculate daily portfolio return)\n",
|
||
" for j in range(1, len(hold_dates)):\n",
|
||
" d_prev = hold_dates[j - 1]\n",
|
||
" d_curr = hold_dates[j]\n",
|
||
" daily_port_ret = 0.0\n",
|
||
" for code, w in current_weights.items():\n",
|
||
" if code in price_df.columns:\n",
|
||
" p_prev = price_df.loc[d_prev, code]\n",
|
||
" p_curr = price_df.loc[d_curr, code]\n",
|
||
" if p_prev > 0:\n",
|
||
" daily_port_ret += w * (p_curr / p_prev - 1)\n",
|
||
" portfolio_value *= (1 + daily_port_ret)\n",
|
||
" nav[d_curr] = portfolio_value\n",
|
||
"\n",
|
||
" # 补全初始 NAV(热身期)\n",
|
||
" nav = nav.ffill()\n",
|
||
" first_valid = nav.first_valid_index()\n",
|
||
" if first_valid is not None:\n",
|
||
" nav[:first_valid] = 1.0\n",
|
||
" nav = nav.fillna(1.0)\n",
|
||
"\n",
|
||
" return {\n",
|
||
" \"nav\": nav,\n",
|
||
" \"weights\": weights_log,\n",
|
||
" \"turnover\": pd.Series(turnover_log),\n",
|
||
" \"cost_total\": total_cost,\n",
|
||
" \"holdings_log\": holdings_log,\n",
|
||
" }"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "12233425",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §4 运行三种策略并计算绩效指标\n",
|
||
"# Run Three Strategies and Calculate Performance Metrics\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "5def8b15",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"print(\"§4 运行回测策略 (Running Backtest Strategies)...\")\n",
|
||
"print()\n",
|
||
"\n",
|
||
"# 运行三种策略 (run three strategies)\n",
|
||
"results = {}\n",
|
||
"\n",
|
||
"results[\"equal_weight\"] = run_rotation_backtest(\n",
|
||
" price_df, mom_composite,\n",
|
||
" strategy=\"equal_weight\",\n",
|
||
" top_k=3, cost_model=cost_model,\n",
|
||
")\n",
|
||
"\n",
|
||
"results[\"relative_momentum\"] = run_rotation_backtest(\n",
|
||
" price_df, mom_composite,\n",
|
||
" strategy=\"relative_momentum\",\n",
|
||
" top_k=3, cost_model=cost_model,\n",
|
||
")\n",
|
||
"\n",
|
||
"results[\"dual_momentum\"] = run_rotation_backtest(\n",
|
||
" price_df, mom_composite,\n",
|
||
" strategy=\"dual_momentum\",\n",
|
||
" top_k=3, cost_model=cost_model,\n",
|
||
")\n",
|
||
"\n",
|
||
"# 同时计算国债 ETF 纯避险基准 (bond ETF as pure safe-haven benchmark)\n",
|
||
"bond_nav = price_df[BOND_ETF] / price_df[BOND_ETF].iloc[0]\n",
|
||
"results[\"bond_only\"] = {\"nav\": bond_nav}\n",
|
||
"\n",
|
||
"# ── 绩效计算函数 (Performance Calculation Function) ─────────────────────────\n",
|
||
"\n",
|
||
"def calc_performance(nav: pd.Series,\n",
|
||
" risk_free_annual: float = 0.02) -> dict:\n",
|
||
" \"\"\"\n",
|
||
" 计算常用量化绩效指标 (Calculate common quantitative performance metrics)\n",
|
||
"\n",
|
||
" 指标 (Metrics):\n",
|
||
" - 年化收益率 (Annualized Return, CAGR)\n",
|
||
" - 年化波动率 (Annualized Volatility)\n",
|
||
" - 夏普比率 (Sharpe Ratio): 超额收益 / 波动率\n",
|
||
" - 最大回撤 (Maximum Drawdown, MaxDD): 从历史最高点的最大跌幅\n",
|
||
" - 卡玛比率 (Calmar Ratio): 年化收益 / 最大回撤\n",
|
||
" - 索提诺比率 (Sortino Ratio): 用下行波动率替代总波动率\n",
|
||
" \"\"\"\n",
|
||
" nav = nav.dropna()\n",
|
||
" if len(nav) < 2:\n",
|
||
" return {}\n",
|
||
"\n",
|
||
" # 日收益率 (daily returns)\n",
|
||
" daily_ret = nav.pct_change().dropna()\n",
|
||
"\n",
|
||
" # 年化收益率 (CAGR - Compound Annual Growth Rate 复合年增长率)\n",
|
||
" n_years = len(daily_ret) / TRADING_DAYS\n",
|
||
" total_ret = nav.iloc[-1] / nav.iloc[0] - 1\n",
|
||
" cagr = (1 + total_ret) ** (1 / n_years) - 1\n",
|
||
"\n",
|
||
" # 年化波动率 (annualized volatility)\n",
|
||
" ann_vol = daily_ret.std() * np.sqrt(TRADING_DAYS)\n",
|
||
"\n",
|
||
" # 夏普比率 (Sharpe Ratio)\n",
|
||
" # 公式: (年化收益 - 无风险利率) / 年化波动率\n",
|
||
" rf_daily = risk_free_annual / TRADING_DAYS\n",
|
||
" sharpe = (daily_ret.mean() - rf_daily) / daily_ret.std() * np.sqrt(TRADING_DAYS)\n",
|
||
"\n",
|
||
" # 最大回撤 (Maximum Drawdown)\n",
|
||
" # 定义: max(累计最高净值 - 当日净值) / 累计最高净值\n",
|
||
" rolling_max = nav.cummax() # 历史最高净值 (rolling maximum)\n",
|
||
" drawdown = nav / rolling_max - 1 # 每日回撤序列 (daily drawdown series)\n",
|
||
" max_dd = drawdown.min() # 最大回撤(负数)(max drawdown, negative)\n",
|
||
"\n",
|
||
" # 卡玛比率 (Calmar Ratio = CAGR / |MaxDD|)\n",
|
||
" calmar = cagr / abs(max_dd) if max_dd != 0 else np.nan\n",
|
||
"\n",
|
||
" # 索提诺比率 (Sortino Ratio): 只惩罚下行波动 (penalizes only downside volatility)\n",
|
||
" downside_ret = daily_ret[daily_ret < rf_daily]\n",
|
||
" downside_vol = downside_ret.std() * np.sqrt(TRADING_DAYS) if len(downside_ret) > 0 else ann_vol\n",
|
||
" sortino = (cagr - risk_free_annual) / downside_vol if downside_vol > 0 else np.nan\n",
|
||
"\n",
|
||
" # 胜率 (Win Rate): 月度正收益比例 (fraction of months with positive return)\n",
|
||
" try:\n",
|
||
" monthly_ret = nav.resample(\"ME\").last().pct_change().dropna()\n",
|
||
" except ValueError:\n",
|
||
" monthly_ret = nav.resample(\"M\").last().pct_change().dropna()\n",
|
||
" win_rate = (monthly_ret > 0).mean()\n",
|
||
"\n",
|
||
" return {\n",
|
||
" \"年化收益率 (CAGR)\": cagr,\n",
|
||
" \"年化波动率 (Volatility)\": ann_vol,\n",
|
||
" \"夏普比率 (Sharpe)\": sharpe,\n",
|
||
" \"最大回撤 (MaxDrawdown)\": max_dd,\n",
|
||
" \"卡玛比率 (Calmar)\": calmar,\n",
|
||
" \"索提诺比率 (Sortino)\": sortino,\n",
|
||
" \"月度胜率 (Win Rate)\": win_rate,\n",
|
||
" \"累计收益率 (Total Return)\": total_ret,\n",
|
||
" }\n",
|
||
"\n",
|
||
"\n",
|
||
"# ── 打印绩效对比表 (Print Performance Comparison Table) ─────────────────────\n",
|
||
"\n",
|
||
"strategy_labels = {\n",
|
||
" \"equal_weight\": \"等权基准 (EW Benchmark)\",\n",
|
||
" \"relative_momentum\": \"相对动量 (Rel Momentum)\",\n",
|
||
" \"dual_momentum\": \"双动量 (Dual Momentum)\",\n",
|
||
" \"bond_only\": \"国债避险 (Bond Only)\",\n",
|
||
"}\n",
|
||
"\n",
|
||
"print(\"=\" * 72)\n",
|
||
"print(\"§4 策略绩效对比 (Strategy Performance Comparison)\")\n",
|
||
"print(\"=\" * 72)\n",
|
||
"\n",
|
||
"perf_all = {}\n",
|
||
"metrics_display = [\n",
|
||
" (\"年化收益率 (CAGR)\", \"{:>8.2%}\"),\n",
|
||
" (\"年化波动率 (Volatility)\", \"{:>8.2%}\"),\n",
|
||
" (\"夏普比率 (Sharpe)\", \"{:>8.3f}\"),\n",
|
||
" (\"最大回撤 (MaxDrawdown)\", \"{:>8.2%}\"),\n",
|
||
" (\"卡玛比率 (Calmar)\", \"{:>8.3f}\"),\n",
|
||
" (\"月度胜率 (Win Rate)\", \"{:>8.2%}\"),\n",
|
||
" (\"累计收益率 (Total Return)\", \"{:>8.2%}\"),\n",
|
||
"]\n",
|
||
"\n",
|
||
"for key, label in strategy_labels.items():\n",
|
||
" nav = results[key][\"nav\"]\n",
|
||
" perf = calc_performance(nav)\n",
|
||
" perf_all[key] = perf\n",
|
||
"\n",
|
||
"header = f\" {'指标':26s}\"\n",
|
||
"for label in strategy_labels.values():\n",
|
||
" header += f\" {label[:20]:>20s}\"\n",
|
||
"print(header)\n",
|
||
"print(\" \" + \"-\" * 90)\n",
|
||
"\n",
|
||
"for metric, fmt in metrics_display:\n",
|
||
" row = f\" {metric:26s}\"\n",
|
||
" for key in strategy_labels:\n",
|
||
" val = perf_all[key].get(metric, np.nan)\n",
|
||
" row += \" \" + fmt.format(val).rjust(20)\n",
|
||
" print(row)\n",
|
||
"\n",
|
||
"print()\n",
|
||
"# 打印成本统计\n",
|
||
"for key in [\"equal_weight\", \"relative_momentum\", \"dual_momentum\"]:\n",
|
||
" cost = results[key].get(\"cost_total\", 0)\n",
|
||
" to = results[key].get(\"turnover\", pd.Series()).mean()\n",
|
||
" print(f\" [{strategy_labels[key][:16]}] \"\n",
|
||
" f\"总成本={cost*100:.3f}%净值 平均月换手={to*100:.1f}%\")\n",
|
||
"print()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "45bf4a54",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §5 持仓分析:统计各行业 ETF 的被选中频次\n",
|
||
"# Holdings Analysis: Count How Often Each Sector ETF Was Selected\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "19761832",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"print(\"§5 持仓分析 (Holdings Analysis):\")\n",
|
||
"print()\n",
|
||
"\n",
|
||
"for strategy_key in [\"relative_momentum\", \"dual_momentum\"]:\n",
|
||
" holdings_log = results[strategy_key][\"holdings_log\"]\n",
|
||
" count = {code: 0 for code in ALL_ETFS}\n",
|
||
"\n",
|
||
" for date, held in holdings_log.items():\n",
|
||
" for code in held:\n",
|
||
" count[code] = count.get(code, 0) + 1\n",
|
||
"\n",
|
||
" total_months = len(holdings_log)\n",
|
||
" label = strategy_labels[strategy_key]\n",
|
||
" print(f\" [{label}] 共 {total_months} 次再平衡:\")\n",
|
||
"\n",
|
||
" # 按持有频次降序排列 (sort by holding frequency descending)\n",
|
||
" sorted_count = sorted(count.items(), key=lambda x: x[1], reverse=True)\n",
|
||
" for code, cnt in sorted_count:\n",
|
||
" if cnt > 0:\n",
|
||
" cn_name = ETF_UNIVERSE[code][0]\n",
|
||
" pct = cnt / total_months\n",
|
||
" bar = \"█\" * int(pct * 20)\n",
|
||
" print(f\" {code} {cn_name:10s} {cnt:3d}次 {pct:5.1%} {bar}\")\n",
|
||
" print()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "659ce25c",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §6 可视化:9 宫格图表\n",
|
||
"# Visualization: 9-Panel Chart\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "da21c149",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"print(\"§6 生成可视化图表 (Generating Charts)...\")\n",
|
||
"\n",
|
||
"# 配置中文字体 (configure Chinese font)\n",
|
||
"plt.rcParams[\"font.sans-serif\"] = [\"WenQuanYi Zen Hei\", \"Arial Unicode MS\",\n",
|
||
" \"SimHei\", \"DejaVu Sans\"]\n",
|
||
"plt.rcParams[\"axes.unicode_minus\"] = False\n",
|
||
"\n",
|
||
"# 颜色方案 (color scheme)\n",
|
||
"COLORS = {\n",
|
||
" \"equal_weight\": \"#95a5a6\", # 灰色(基准)\n",
|
||
" \"relative_momentum\": \"#2ecc71\", # 绿色\n",
|
||
" \"dual_momentum\": \"#e74c3c\", # 红色(主策略)\n",
|
||
" \"bond_only\": \"#3498db\", # 蓝色(国债)\n",
|
||
"}\n",
|
||
"LABELS = {\n",
|
||
" \"equal_weight\": \"等权基准\",\n",
|
||
" \"relative_momentum\": \"相对动量\",\n",
|
||
" \"dual_momentum\": \"双动量\",\n",
|
||
" \"bond_only\": \"国债\",\n",
|
||
"}\n",
|
||
"\n",
|
||
"fig = plt.figure(figsize=(20, 16), facecolor=\"#1a1a2e\")\n",
|
||
"fig.suptitle(\n",
|
||
" \"A 股行业 ETF 轮动策略回测 (A-Share Sector ETF Rotation Backtest)\\n\"\n",
|
||
" \"合成模拟数据 2019-2024 (Synthetic Data 2019-2024)\",\n",
|
||
" fontsize=16, color=\"white\", fontweight=\"bold\", y=0.98\n",
|
||
")\n",
|
||
"\n",
|
||
"gs = gridspec.GridSpec(3, 3, figure=fig,\n",
|
||
" hspace=0.42, wspace=0.35,\n",
|
||
" left=0.07, right=0.97, top=0.93, bottom=0.06)\n",
|
||
"\n",
|
||
"ax_style = dict(facecolor=\"#16213e\", labelcolor=\"white\",\n",
|
||
" tick_params=dict(colors=\"white\"))\n",
|
||
"\n",
|
||
"def style_ax(ax, title, xlabel=\"\", ylabel=\"\"):\n",
|
||
" ax.set_facecolor(\"#16213e\")\n",
|
||
" ax.set_title(title, color=\"white\", fontsize=10, fontweight=\"bold\", pad=6)\n",
|
||
" ax.set_xlabel(xlabel, color=\"#aaaaaa\", fontsize=8)\n",
|
||
" ax.set_ylabel(ylabel, color=\"#aaaaaa\", fontsize=8)\n",
|
||
" ax.tick_params(colors=\"white\", labelsize=7)\n",
|
||
" ax.spines[:].set_color(\"#444466\")\n",
|
||
" ax.grid(True, alpha=0.25, color=\"#666699\", linewidth=0.5)\n",
|
||
"\n",
|
||
"# ── 图1: 累计净值曲线 (Cumulative NAV) ───────────────────────────────────────\n",
|
||
"ax1 = fig.add_subplot(gs[0, :2]) # 跨两列(宽幅)\n",
|
||
"style_ax(ax1, \"① 累计净值对比 Cumulative NAV Comparison\", ylabel=\"净值 NAV\")\n",
|
||
"for key, color in COLORS.items():\n",
|
||
" nav = results[key][\"nav\"]\n",
|
||
" ax1.plot(nav.index, nav.values, color=color, linewidth=1.8,\n",
|
||
" label=LABELS[key], alpha=0.9)\n",
|
||
"ax1.axhline(1.0, color=\"#888888\", linewidth=0.8, linestyle=\"--\", alpha=0.6)\n",
|
||
"ax1.legend(loc=\"upper left\", fontsize=8, facecolor=\"#1a1a2e\",\n",
|
||
" labelcolor=\"white\", framealpha=0.7)\n",
|
||
"ax1.yaxis.set_major_formatter(mtick.FuncFormatter(lambda x, _: f\"{x:.1f}x\"))\n",
|
||
"\n",
|
||
"# ── 图2: 绩效雷达图 (Performance Radar) ─────────────────────────────────────\n",
|
||
"ax2 = fig.add_subplot(gs[0, 2], projection=\"polar\")\n",
|
||
"ax2.set_facecolor(\"#16213e\")\n",
|
||
"ax2.set_title(\"② 绩效雷达图 Performance Radar\",\n",
|
||
" color=\"white\", fontsize=10, fontweight=\"bold\", pad=15)\n",
|
||
"ax2.tick_params(colors=\"white\", labelsize=7)\n",
|
||
"ax2.spines[\"polar\"].set_color(\"#444466\")\n",
|
||
"\n",
|
||
"radar_metrics = [\"年化收益\", \"夏普比率\", \"月胜率\", \"抗回撤\"]\n",
|
||
"radar_keys = [\"年化收益率 (CAGR)\", \"夏普比率 (Sharpe)\",\n",
|
||
" \"月度胜率 (Win Rate)\", \"卡玛比率 (Calmar)\"]\n",
|
||
"n_radar = len(radar_metrics)\n",
|
||
"angles = np.linspace(0, 2 * np.pi, n_radar, endpoint=False).tolist()\n",
|
||
"angles += angles[:1]\n",
|
||
"\n",
|
||
"# 归一化各指标到 0-1 (normalize metrics to 0-1)\n",
|
||
"def normalize_radar(values, min_val=0.0, max_val=1.0):\n",
|
||
" rng = max_val - min_val\n",
|
||
" return [(v - min_val) / rng if rng > 0 else 0.5 for v in values]\n",
|
||
"\n",
|
||
"radar_strats = [\"equal_weight\", \"relative_momentum\", \"dual_momentum\"]\n",
|
||
"for rkey in radar_strats:\n",
|
||
" perf = perf_all[rkey]\n",
|
||
" raw = [perf.get(m, 0) for m in radar_keys]\n",
|
||
" # 简单归一化(基于各策略范围)\n",
|
||
" vals = [\n",
|
||
" min(max(raw[0] / 0.30, 0), 1), # CAGR: 0~30%\n",
|
||
" min(max(raw[1] / 3.0, 0), 1), # Sharpe: 0~3\n",
|
||
" min(max(raw[2], 0), 1), # Win rate: 0~1\n",
|
||
" min(max(raw[3] / 3.0, 0), 1), # Calmar: 0~3\n",
|
||
" ]\n",
|
||
" vals += vals[:1]\n",
|
||
" ax2.plot(angles, vals, color=COLORS[rkey], linewidth=2, label=LABELS[rkey])\n",
|
||
" ax2.fill(angles, vals, color=COLORS[rkey], alpha=0.15)\n",
|
||
"\n",
|
||
"ax2.set_xticks(angles[:-1])\n",
|
||
"ax2.set_xticklabels(radar_metrics, color=\"white\", fontsize=8)\n",
|
||
"ax2.set_yticklabels([])\n",
|
||
"ax2.legend(loc=\"upper right\", bbox_to_anchor=(1.35, 1.15),\n",
|
||
" fontsize=7, facecolor=\"#1a1a2e\", labelcolor=\"white\")\n",
|
||
"\n",
|
||
"# ── 图3: 最大回撤曲线 (Drawdown Curve) ──────────────────────────────────────\n",
|
||
"ax3 = fig.add_subplot(gs[1, :2])\n",
|
||
"style_ax(ax3, \"③ 水下曲线(回撤) Underwater Chart (Drawdown)\", ylabel=\"回撤 Drawdown\")\n",
|
||
"for key, color in COLORS.items():\n",
|
||
" nav = results[key][\"nav\"]\n",
|
||
" dd = nav / nav.cummax() - 1\n",
|
||
" ax3.fill_between(dd.index, dd.values, 0, color=color, alpha=0.4, label=LABELS[key])\n",
|
||
" ax3.plot(dd.index, dd.values, color=color, linewidth=0.8, alpha=0.8)\n",
|
||
"ax3.set_ylim(-0.65, 0.05)\n",
|
||
"ax3.yaxis.set_major_formatter(mtick.PercentFormatter(1.0))\n",
|
||
"ax3.legend(loc=\"lower left\", fontsize=8, facecolor=\"#1a1a2e\",\n",
|
||
" labelcolor=\"white\", framealpha=0.7)\n",
|
||
"\n",
|
||
"# ── 图4: 绩效指标条形图 (Performance Bar Chart) ──────────────────────────────\n",
|
||
"ax4 = fig.add_subplot(gs[1, 2])\n",
|
||
"style_ax(ax4, \"④ 夏普 & 卡玛 Sharpe & Calmar\", ylabel=\"比率 Ratio\")\n",
|
||
"strats_bar = [\"equal_weight\", \"relative_momentum\", \"dual_momentum\"]\n",
|
||
"labels_bar = [LABELS[k] for k in strats_bar]\n",
|
||
"sharpes = [perf_all[k][\"夏普比率 (Sharpe)\"] for k in strats_bar]\n",
|
||
"calmars = [perf_all[k][\"卡玛比率 (Calmar)\"] for k in strats_bar]\n",
|
||
"x_bar = np.arange(len(strats_bar))\n",
|
||
"w_bar = 0.35\n",
|
||
"bars1 = ax4.bar(x_bar - w_bar/2, sharpes, w_bar, color=\"#2ecc71\", alpha=0.8,\n",
|
||
" label=\"夏普 Sharpe\")\n",
|
||
"bars2 = ax4.bar(x_bar + w_bar/2, calmars, w_bar, color=\"#e74c3c\", alpha=0.8,\n",
|
||
" label=\"卡玛 Calmar\")\n",
|
||
"ax4.set_xticks(x_bar)\n",
|
||
"ax4.set_xticklabels(labels_bar, fontsize=7, color=\"white\")\n",
|
||
"ax4.legend(fontsize=8, facecolor=\"#1a1a2e\", labelcolor=\"white\")\n",
|
||
"for bar in bars1:\n",
|
||
" ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,\n",
|
||
" f\"{bar.get_height():.2f}\", ha=\"center\", va=\"bottom\",\n",
|
||
" color=\"white\", fontsize=7)\n",
|
||
"for bar in bars2:\n",
|
||
" ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,\n",
|
||
" f\"{bar.get_height():.2f}\", ha=\"center\", va=\"bottom\",\n",
|
||
" color=\"white\", fontsize=7)\n",
|
||
"\n",
|
||
"# ── 图5: 双动量月度持仓热力图 (Dual Momentum Monthly Holdings Heatmap) ────────\n",
|
||
"ax5 = fig.add_subplot(gs[2, :2])\n",
|
||
"style_ax(ax5, \"⑤ 双动量月度持仓热力图 Dual Momentum Monthly Holdings\")\n",
|
||
"\n",
|
||
"holdings_log = results[\"dual_momentum\"][\"holdings_log\"]\n",
|
||
"all_codes_sorted = SECTOR_ETFS + [BOND_ETF]\n",
|
||
"etf_names = [ETF_UNIVERSE[c][0] for c in all_codes_sorted]\n",
|
||
"\n",
|
||
"heatmap_dates = sorted(holdings_log.keys())\n",
|
||
"heatmap_data = np.zeros((len(all_codes_sorted), len(heatmap_dates)))\n",
|
||
"for j, d in enumerate(heatmap_dates):\n",
|
||
" held = holdings_log[d]\n",
|
||
" for i, code in enumerate(all_codes_sorted):\n",
|
||
" if code in held:\n",
|
||
" heatmap_data[i, j] = 1.0 / len(held) # 持仓权重 (holding weight)\n",
|
||
"\n",
|
||
"im = ax5.imshow(heatmap_data, aspect=\"auto\", cmap=\"YlOrRd\",\n",
|
||
" interpolation=\"nearest\", vmin=0, vmax=0.5)\n",
|
||
"ax5.set_yticks(range(len(all_codes_sorted)))\n",
|
||
"ax5.set_yticklabels(etf_names, fontsize=7, color=\"white\")\n",
|
||
"\n",
|
||
"# X 轴:每年一个刻度 (X-axis: one tick per year)\n",
|
||
"year_ticks = []\n",
|
||
"year_labels = []\n",
|
||
"for j, d in enumerate(heatmap_dates):\n",
|
||
" if d.month == 1:\n",
|
||
" year_ticks.append(j)\n",
|
||
" year_labels.append(str(d.year))\n",
|
||
"ax5.set_xticks(year_ticks)\n",
|
||
"ax5.set_xticklabels(year_labels, color=\"white\", fontsize=8)\n",
|
||
"plt.colorbar(im, ax=ax5, fraction=0.02, label=\"权重 Weight\").ax.yaxis.set_tick_params(color=\"white\", labelcolor=\"white\")\n",
|
||
"\n",
|
||
"# ── 图6: 月度换手率 (Monthly Turnover) ──────────────────────────────────────\n",
|
||
"ax6 = fig.add_subplot(gs[2, 2])\n",
|
||
"style_ax(ax6, \"⑥ 月度换手率 Monthly Turnover\", ylabel=\"换手率 Turnover\")\n",
|
||
"for key in [\"equal_weight\", \"relative_momentum\", \"dual_momentum\"]:\n",
|
||
" to_series = results[key].get(\"turnover\", pd.Series())\n",
|
||
" if len(to_series) > 0:\n",
|
||
" ax6.plot(to_series.index, to_series.values * 100,\n",
|
||
" color=COLORS[key], linewidth=1.2, label=LABELS[key], alpha=0.8)\n",
|
||
" # 平均换手率水平线 (average turnover horizontal line)\n",
|
||
" avg_to = to_series.mean() * 100\n",
|
||
" ax6.axhline(avg_to, color=COLORS[key], linewidth=0.8,\n",
|
||
" linestyle=\":\", alpha=0.6)\n",
|
||
"ax6.yaxis.set_major_formatter(mtick.PercentFormatter())\n",
|
||
"ax6.legend(fontsize=7, facecolor=\"#1a1a2e\", labelcolor=\"white\")\n",
|
||
"\n",
|
||
"# 保存图表 (save chart)\n",
|
||
"out_path = \"/Users/tigeren/Dev/xorbitlab/trading/etf_rotation_demo.png\"\n",
|
||
"plt.savefig(out_path, dpi=150, bbox_inches=\"tight\",\n",
|
||
" facecolor=fig.get_facecolor())\n",
|
||
"plt.close()\n",
|
||
"print(f\" 图表已保存 (Chart saved): {out_path}\")\n",
|
||
"print()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "b24aac17",
|
||
"metadata": {},
|
||
"source": [
|
||
"# ══════════════════════════════════════════════════════════════════════════════\n",
|
||
"# §7 关键学习要点总结\n",
|
||
"# Key Learning Takeaways\n",
|
||
"\n",
|
||
"# ══════════════════════════════════════════════════════════════════════════════"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"id": "838d08d5",
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"print(\"═\" * 60)\n",
|
||
"print(\"§7 关键学习要点 (Key Learning Takeaways)\")\n",
|
||
"print(\"═\" * 60)\n",
|
||
"print(\"\"\"\n",
|
||
" 1. ETF 轮动 vs 个股选股 (ETF Rotation vs. Stock Picking)\n",
|
||
" ───────────────────────────────────────────────────\n",
|
||
" ETF 轮动:买\"赛道\",避免个股黑天鹅事件\n",
|
||
" (ETF rotation: bet on sectors, avoid individual stock blow-ups)\n",
|
||
" \n",
|
||
" 个股选股:需要更强的信息优势,难度更高\n",
|
||
" (Stock picking: requires information edge, much harder)\n",
|
||
"\n",
|
||
" 2. 绝对动量的价值 (Value of Absolute Momentum)\n",
|
||
" ───────────────────────────────────────────\n",
|
||
" 双动量策略在熊市中会自动切换至国债 ETF 避险\n",
|
||
" 最大回撤通常比纯相对动量低 10-15 个百分点\n",
|
||
" (Dual momentum auto-switches to bonds in bear markets,\n",
|
||
" reducing max drawdown by ~10-15 percentage points)\n",
|
||
"\n",
|
||
" 3. 动量的局限性 (Momentum Limitations)\n",
|
||
" ────────────────────────────────────\n",
|
||
" • 趋势逆转(动量崩溃)风险:牛熊切换时可能大幅亏损\n",
|
||
" (Momentum crash risk: large loss when trend reverses)\n",
|
||
" • 主题 ETF 历史短:A 股主题 ETF 多数 2019 年后才成立\n",
|
||
" (Short history: most A-share thematic ETFs launched post-2019)\n",
|
||
" • 需要结合估值/基本面作为反向过滤器\n",
|
||
" (Should combine with valuation/fundamentals as counter-filter)\n",
|
||
"\n",
|
||
" 4. 成本控制 (Cost Management)\n",
|
||
" ───────────────────────────\n",
|
||
" 每月换仓一次约消耗 0.22% 双边成本\n",
|
||
" 一年 12 次再平衡 ≈ 2.6% 年化成本拖拽\n",
|
||
" (Monthly rebalancing ≈ 0.22% per round-trip, ~2.6% annual drag)\n",
|
||
" → 适当降低再平衡频率(如季度)可以减少成本\n",
|
||
" (Reduce rebalance frequency, e.g., quarterly, to cut costs)\n",
|
||
"\n",
|
||
" 5. 进阶方向 (Next Steps)\n",
|
||
" ───────────────────────\n",
|
||
" • 接入真实数据(AKShare)替换合成数据\n",
|
||
" (Replace synthetic data with real data via AKShare)\n",
|
||
" • 加入估值信号(PE 分位数)作为过滤器\n",
|
||
" (Add valuation signal: sector PE percentile as filter)\n",
|
||
" • 加入波动率调仓(高波动期降低持仓比例)\n",
|
||
" (Add volatility scaling: reduce position in high-vol regimes)\n",
|
||
" • 结合宏观信号(利率、信用利差)增强国债 ETF 切换时机\n",
|
||
" (Combine macro signals for better timing of bond ETF switch)\n",
|
||
"\"\"\")\n",
|
||
"\n",
|
||
"print(\"✅ Demo 完成!(Demo Complete!)\")\n",
|
||
"print(f\" 输出文件: etf_rotation_demo.png\")"
|
||
]
|
||
}
|
||
],
|
||
"metadata": {
|
||
"kernelspec": {
|
||
"display_name": "Python 3 (trading)",
|
||
"language": "python",
|
||
"name": "python3"
|
||
},
|
||
"language_info": {
|
||
"name": "python",
|
||
"version": "3.11.0"
|
||
}
|
||
},
|
||
"nbformat": 4,
|
||
"nbformat_minor": 5
|
||
}
|