chore(config): 添加环境变量示例及.gitignore更新
- 新增 .env.example,包含 Tushare API、钉钉机器人和PostgreSQL数据库配置模板 - 更新.gitignore,忽略本地配置文件如 .env.local 和 config_local.py - 添加对报表文件命名规则的支持,保留示例文件不忽略 - 删除废弃的 chart.py 及相关图表模块代码 - 新增 config/settings.py,实现从环境变量读取配置的统一接口 - 设置数据目录及缓存目录,确保目录存在,提高配置管理规范性
This commit is contained in:
0
strategies/rotation/__init__.py
Normal file
0
strategies/rotation/__init__.py
Normal file
249
strategies/rotation/engine.py
Normal file
249
strategies/rotation/engine.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
ETF轮动策略引擎
|
||||
|
||||
整合信号生成和回测逻辑
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
from strategies.base import BacktestStrategy
|
||||
from core.data.tushare_source import TushareDataSource
|
||||
from core.factors.momentum import compute_factors, calculate_daily_return
|
||||
|
||||
|
||||
class RotationStrategy(BacktestStrategy):
|
||||
"""ETF轮动策略"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("ETF轮动策略", config)
|
||||
self.data_source = TushareDataSource(use_cache=config.get("use_cache", True))
|
||||
self.data = None
|
||||
self.signals = None
|
||||
self.backtest_result = None
|
||||
|
||||
def fetch_data(self) -> pd.DataFrame:
|
||||
"""获取数据"""
|
||||
from config.settings import BENCHMARK_CODE
|
||||
|
||||
etf_data, benchmark_data, valid_codes = self.data_source.fetch_all(
|
||||
self.config["code_list"],
|
||||
BENCHMARK_CODE,
|
||||
self.config["start_date"],
|
||||
self.config["end_date"],
|
||||
)
|
||||
|
||||
self.etf_data = etf_data
|
||||
self.benchmark_data = benchmark_data
|
||||
self.valid_codes = valid_codes
|
||||
|
||||
# 计算因子
|
||||
factor_data, valid_codes = compute_factors(
|
||||
etf_data,
|
||||
valid_codes,
|
||||
n=self.config["n_days"],
|
||||
factor_type=self.config["factor_type"],
|
||||
)
|
||||
|
||||
self.data = factor_data
|
||||
self.valid_codes = valid_codes
|
||||
return factor_data
|
||||
|
||||
def generate_signals(self) -> pd.DataFrame:
|
||||
"""生成轮动信号"""
|
||||
if self.data is None:
|
||||
self.fetch_data()
|
||||
|
||||
result = self.data.copy()
|
||||
score_cols = [f"得分_{code}" for code in self.valid_codes]
|
||||
select_num = self.config["select_num"]
|
||||
rebalance_days = self.config["rebalance_days"]
|
||||
rebalance_threshold = self.config["rebalance_threshold"]
|
||||
|
||||
# Step 1: 每日目标组合
|
||||
if select_num == 1:
|
||||
daily_target = (
|
||||
result[score_cols]
|
||||
.idxmax(axis=1)
|
||||
.str.replace("得分_", "", regex=False)
|
||||
)
|
||||
else:
|
||||
def top_n_codes(row):
|
||||
scores = pd.to_numeric(row[score_cols], errors="coerce")
|
||||
top = scores.nlargest(select_num).index.tolist()
|
||||
return ",".join([c.replace("得分_", "") for c in top])
|
||||
daily_target = result.apply(top_n_codes, axis=1)
|
||||
|
||||
# Step 2: 逐日生成信号(调仓周期控制)
|
||||
held_signals = []
|
||||
current_held = None
|
||||
last_rebalance_idx = 0
|
||||
|
||||
for i in range(len(result)):
|
||||
target = daily_target.iloc[i]
|
||||
|
||||
if current_held is None:
|
||||
current_held = target
|
||||
last_rebalance_idx = i
|
||||
held_signals.append(current_held)
|
||||
continue
|
||||
|
||||
days_since = i - last_rebalance_idx
|
||||
if days_since >= rebalance_days:
|
||||
should = self._check_rebalance(
|
||||
result.iloc[i], current_held, target,
|
||||
select_num, rebalance_threshold
|
||||
)
|
||||
if should:
|
||||
current_held = target
|
||||
last_rebalance_idx = i
|
||||
|
||||
held_signals.append(current_held)
|
||||
|
||||
result["信号_raw"] = held_signals
|
||||
result["信号"] = result["信号_raw"].shift(1)
|
||||
result = result.drop(columns=["信号_raw"])
|
||||
result = result.dropna(subset=["信号"])
|
||||
|
||||
self.signals = result
|
||||
self._print_signal_stats(result, select_num, rebalance_days, rebalance_threshold)
|
||||
return result
|
||||
|
||||
def _check_rebalance(self, row, current_held, target, select_num, threshold):
|
||||
"""检查是否应该调仓"""
|
||||
if select_num == 1:
|
||||
if target == current_held:
|
||||
return False
|
||||
new_score = float(row[f"得分_{target}"])
|
||||
old_score = float(row[f"得分_{current_held}"])
|
||||
if old_score > 0:
|
||||
return (new_score / old_score - 1) >= threshold
|
||||
return new_score > 0
|
||||
else:
|
||||
new_codes = target.split(",")
|
||||
old_codes = current_held.split(",")
|
||||
if set(new_codes) == set(old_codes):
|
||||
return False
|
||||
new_total = sum(float(row[f"得分_{c}"]) for c in new_codes)
|
||||
old_total = sum(float(row[f"得分_{c}"]) for c in old_codes)
|
||||
if old_total > 0:
|
||||
return (new_total / old_total - 1) >= threshold
|
||||
return new_total > 0
|
||||
|
||||
def _print_signal_stats(self, result, select_num, rebalance_days, rebalance_threshold):
|
||||
"""打印信号统计"""
|
||||
total_days = len(result)
|
||||
|
||||
if select_num == 1:
|
||||
rebalance_count = (result["信号"] != result["信号"].shift(1)).sum() - 1
|
||||
else:
|
||||
prev = None
|
||||
rebalance_count = 0
|
||||
for s in result["信号"]:
|
||||
if prev is not None and s != prev:
|
||||
if set(s.split(",")) != set(prev.split(",")):
|
||||
rebalance_count += 1
|
||||
prev = s
|
||||
|
||||
rebalance_count = max(rebalance_count, 0)
|
||||
avg_hold = total_days / max(rebalance_count, 1)
|
||||
years = total_days / 252
|
||||
annual_rebalances = rebalance_count / max(years, 0.1)
|
||||
|
||||
print(f"\n信号生成完成:")
|
||||
print(f" 调仓周期: {rebalance_days} 天 | 阈值: {rebalance_threshold:.1%}")
|
||||
print(f" 交易天数: {total_days}")
|
||||
print(f" 调仓次数: {rebalance_count} | 平均持仓: {avg_hold:.1f} 天 | 年均调仓: {annual_rebalances:.1f} 次")
|
||||
|
||||
if select_num == 1:
|
||||
signal_counts = result["信号"].value_counts()
|
||||
print(f" 品种持仓分布 (前10):")
|
||||
for code, count in signal_counts.head(10).items():
|
||||
pct = count / total_days * 100
|
||||
print(f" {code}: {count}天 ({pct:.1f}%)")
|
||||
|
||||
def run_backtest(self) -> pd.DataFrame:
|
||||
"""执行回测"""
|
||||
if self.signals is None:
|
||||
self.generate_signals()
|
||||
|
||||
result = self.signals.copy()
|
||||
select_num = self.config["select_num"]
|
||||
trade_cost = self.config["trade_cost"]
|
||||
|
||||
# 计算策略日收益率
|
||||
if select_num == 1:
|
||||
def calc_return(row):
|
||||
return row[f"日收益率_{row['信号']}"]
|
||||
result["轮动策略日收益率"] = result.apply(calc_return, axis=1)
|
||||
else:
|
||||
def calc_multi_return(row):
|
||||
codes = row["信号"].split(",")
|
||||
returns = [row[f"日收益率_{c}"] for c in codes]
|
||||
return np.mean(returns)
|
||||
result["轮动策略日收益率"] = result.apply(calc_multi_return, axis=1)
|
||||
|
||||
# 扣除交易成本
|
||||
if trade_cost > 0:
|
||||
prev_signal = result["信号"].shift(1)
|
||||
|
||||
if select_num == 1:
|
||||
changed = (result["信号"] != prev_signal) & prev_signal.notna()
|
||||
result.loc[changed, "轮动策略日收益率"] -= trade_cost
|
||||
else:
|
||||
turnover_list = []
|
||||
for curr, prev in zip(result["信号"], prev_signal):
|
||||
if pd.isna(prev) or curr == prev:
|
||||
turnover_list.append(0.0)
|
||||
else:
|
||||
old = set(prev.split(","))
|
||||
new = set(curr.split(","))
|
||||
swapped = len(old - new)
|
||||
turnover_list.append(swapped / len(old))
|
||||
result["换手率"] = turnover_list
|
||||
result["轮动策略日收益率"] -= result["换手率"] * trade_cost
|
||||
|
||||
# 计算净值
|
||||
result["轮动策略净值"] = (1 + result["轮动策略日收益率"]).cumprod()
|
||||
|
||||
# 各ETF单独净值
|
||||
for code in self.valid_codes:
|
||||
first_price = result[code].iloc[0]
|
||||
result[f"净值_{code}"] = result[code] / first_price
|
||||
|
||||
# 基准净值
|
||||
bench_ret = self.benchmark_data.pct_change().dropna()
|
||||
common_dates = result.index.intersection(bench_ret.index)
|
||||
bench_ret = bench_ret.loc[common_dates]
|
||||
|
||||
result["基准日收益率"] = bench_ret.reindex(result.index, fill_value=0)
|
||||
result["基准净值"] = (1 + result["基准日收益率"]).cumprod()
|
||||
|
||||
self.backtest_result = result
|
||||
|
||||
# 打印摘要
|
||||
total_days = len(result)
|
||||
strategy_total_return = result["轮动策略净值"].iloc[-1] - 1
|
||||
benchmark_total_return = result["基准净值"].iloc[-1] - 1
|
||||
|
||||
print(f"\n回测完成:")
|
||||
print(f" 回测区间: {result.index.min().date()} ~ {result.index.max().date()}")
|
||||
print(f" 交易天数: {total_days}")
|
||||
print(f" 策略累计收益: {strategy_total_return:.2%}")
|
||||
print(f" 基准累计收益: {benchmark_total_return:.2%}")
|
||||
|
||||
return result
|
||||
|
||||
def run(self) -> dict:
|
||||
"""运行完整流程"""
|
||||
self.fetch_data()
|
||||
self.generate_signals()
|
||||
self.run_backtest()
|
||||
return self.backtest_result
|
||||
|
||||
def get_signals(self) -> pd.DataFrame:
|
||||
"""获取当前信号"""
|
||||
if self.signals is None:
|
||||
self.generate_signals()
|
||||
return self.signals
|
||||
233
strategies/rotation/portfolio.py
Normal file
233
strategies/rotation/portfolio.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
ETF轮动策略 - 持仓跟踪模块
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def track_positions(
|
||||
backtest_result: pd.DataFrame,
|
||||
code_name_map: dict = None,
|
||||
select_num: int = 1,
|
||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""
|
||||
从回测结果中提取每笔持仓记录
|
||||
|
||||
Args:
|
||||
backtest_result: 回测结果(含 '信号' 列)
|
||||
code_name_map: 代码→名称映射
|
||||
select_num: 每次选中的品种数量
|
||||
|
||||
Returns:
|
||||
tuple: (trades_df, summary_df)
|
||||
"""
|
||||
code_name_map = code_name_map or {}
|
||||
data = backtest_result.copy()
|
||||
dates = data.index.tolist()
|
||||
signals = data["信号"].tolist()
|
||||
trades = []
|
||||
|
||||
if select_num == 1:
|
||||
# 单品种轮动
|
||||
current_code = signals[0]
|
||||
entry_date = dates[0]
|
||||
entry_price = data.loc[entry_date, current_code]
|
||||
entry_nav = data.loc[entry_date, "轮动策略净值"]
|
||||
|
||||
for i in range(1, len(dates)):
|
||||
today_code = signals[i]
|
||||
|
||||
if today_code != current_code:
|
||||
exit_date = dates[i - 1]
|
||||
exit_price = data.loc[exit_date, current_code]
|
||||
exit_nav = data.loc[exit_date, "轮动策略净值"]
|
||||
holding_days = (i - 1) - dates.index(entry_date) + 1
|
||||
trade_return = exit_price / entry_price - 1 if entry_price != 0 else 0
|
||||
nav_contrib = exit_nav - entry_nav
|
||||
|
||||
trades.append({
|
||||
"序号": len(trades) + 1,
|
||||
"品种代码": current_code,
|
||||
"品种名称": code_name_map.get(current_code, current_code),
|
||||
"进场日期": entry_date,
|
||||
"出场日期": exit_date,
|
||||
"持仓天数": holding_days,
|
||||
"仓位占比": "100%",
|
||||
"进场价格": round(entry_price, 2),
|
||||
"出场价格": round(exit_price, 2),
|
||||
"持仓收益": trade_return,
|
||||
"进场净值": round(entry_nav, 4),
|
||||
"出场净值": round(exit_nav, 4),
|
||||
"净值贡献": round(nav_contrib, 4),
|
||||
})
|
||||
|
||||
current_code = today_code
|
||||
entry_date = dates[i]
|
||||
entry_price = data.loc[entry_date, current_code]
|
||||
entry_nav = data.loc[entry_date, "轮动策略净值"]
|
||||
|
||||
# 最后一笔
|
||||
exit_date = dates[-1]
|
||||
exit_price = data.loc[exit_date, current_code]
|
||||
exit_nav = data.loc[exit_date, "轮动策略净值"]
|
||||
holding_days = len(dates) - dates.index(entry_date)
|
||||
trade_return = exit_price / entry_price - 1 if entry_price != 0 else 0
|
||||
nav_contrib = exit_nav - entry_nav
|
||||
|
||||
trades.append({
|
||||
"序号": len(trades) + 1,
|
||||
"品种代码": current_code,
|
||||
"品种名称": code_name_map.get(current_code, current_code),
|
||||
"进场日期": entry_date,
|
||||
"出场日期": exit_date,
|
||||
"持仓天数": holding_days,
|
||||
"仓位占比": "100%",
|
||||
"进场价格": round(entry_price, 2),
|
||||
"出场价格": round(exit_price, 2),
|
||||
"持仓收益": trade_return,
|
||||
"进场净值": round(entry_nav, 4),
|
||||
"出场净值": round(exit_nav, 4),
|
||||
"净值贡献": round(nav_contrib, 4),
|
||||
})
|
||||
|
||||
else:
|
||||
# 多品种等权轮动
|
||||
current_signal = signals[0]
|
||||
entry_date = dates[0]
|
||||
codes = current_signal.split(",")
|
||||
weight = 1.0 / len(codes)
|
||||
entry_prices = {c: data.loc[entry_date, c] for c in codes}
|
||||
entry_nav = data.loc[entry_date, "轮动策略净值"]
|
||||
|
||||
for i in range(1, len(dates)):
|
||||
today_signal = signals[i]
|
||||
|
||||
if today_signal != current_signal:
|
||||
exit_date = dates[i - 1]
|
||||
exit_nav = data.loc[exit_date, "轮动策略净值"]
|
||||
holding_days = (i - 1) - dates.index(entry_date) + 1
|
||||
|
||||
for c in codes:
|
||||
exit_price = data.loc[exit_date, c]
|
||||
ep = entry_prices[c]
|
||||
trade_return = exit_price / ep - 1 if ep != 0 else 0
|
||||
|
||||
trades.append({
|
||||
"序号": len(trades) + 1,
|
||||
"品种代码": c,
|
||||
"品种名称": code_name_map.get(c, c),
|
||||
"进场日期": entry_date,
|
||||
"出场日期": exit_date,
|
||||
"持仓天数": holding_days,
|
||||
"仓位占比": f"{weight:.0%}",
|
||||
"进场价格": round(ep, 2),
|
||||
"出场价格": round(exit_price, 2),
|
||||
"持仓收益": trade_return,
|
||||
"进场净值": round(entry_nav, 4),
|
||||
"出场净值": round(exit_nav, 4),
|
||||
"净值贡献": round((exit_nav - entry_nav) * weight, 4),
|
||||
})
|
||||
|
||||
current_signal = today_signal
|
||||
entry_date = dates[i]
|
||||
codes = current_signal.split(",")
|
||||
weight = 1.0 / len(codes)
|
||||
entry_prices = {c: data.loc[entry_date, c] for c in codes}
|
||||
entry_nav = data.loc[entry_date, "轮动策略净值"]
|
||||
|
||||
# 最后一笔
|
||||
exit_date = dates[-1]
|
||||
exit_nav = data.loc[exit_date, "轮动策略净值"]
|
||||
holding_days = len(dates) - dates.index(entry_date)
|
||||
for c in codes:
|
||||
exit_price = data.loc[exit_date, c]
|
||||
ep = entry_prices[c]
|
||||
trade_return = exit_price / ep - 1 if ep != 0 else 0
|
||||
trades.append({
|
||||
"序号": len(trades) + 1,
|
||||
"品种代码": c,
|
||||
"品种名称": code_name_map.get(c, c),
|
||||
"进场日期": entry_date,
|
||||
"出场日期": exit_date,
|
||||
"持仓天数": holding_days,
|
||||
"仓位占比": f"{weight:.0%}",
|
||||
"进场价格": round(ep, 2),
|
||||
"出场价格": round(exit_price, 2),
|
||||
"持仓收益": trade_return,
|
||||
"进场净值": round(entry_nav, 4),
|
||||
"出场净值": round(exit_nav, 4),
|
||||
"净值贡献": round((exit_nav - entry_nav) * weight, 4),
|
||||
})
|
||||
|
||||
trades_df = pd.DataFrame(trades)
|
||||
summary = _summarize_by_code(trades_df, code_name_map)
|
||||
return trades_df, summary
|
||||
|
||||
|
||||
def _summarize_by_code(trades_df: pd.DataFrame, code_name_map: dict) -> pd.DataFrame:
|
||||
"""按品种汇总持仓统计"""
|
||||
if trades_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
groups = trades_df.groupby("品种代码")
|
||||
rows = []
|
||||
|
||||
for code, grp in groups:
|
||||
total_trades = len(grp)
|
||||
total_days = grp["持仓天数"].sum()
|
||||
avg_days = grp["持仓天数"].mean()
|
||||
win_trades = (grp["持仓收益"] > 0).sum()
|
||||
win_rate = win_trades / total_trades if total_trades > 0 else 0
|
||||
avg_return = grp["持仓收益"].mean()
|
||||
total_return = (1 + grp["持仓收益"]).prod() - 1
|
||||
max_return = grp["持仓收益"].max()
|
||||
min_return = grp["持仓收益"].min()
|
||||
|
||||
rows.append({
|
||||
"品种代码": code,
|
||||
"品种名称": code_name_map.get(code, code),
|
||||
"调仓次数": total_trades,
|
||||
"总持仓天数": total_days,
|
||||
"平均持仓天数": round(avg_days, 1),
|
||||
"胜率": win_rate,
|
||||
"平均收益": avg_return,
|
||||
"累计收益": total_return,
|
||||
"最大单次收益": max_return,
|
||||
"最大单次亏损": min_return,
|
||||
})
|
||||
|
||||
summary = pd.DataFrame(rows)
|
||||
summary = summary.sort_values("总持仓天数", ascending=False).reset_index(drop=True)
|
||||
return summary
|
||||
|
||||
|
||||
def save_trades(
|
||||
trades_df: pd.DataFrame,
|
||||
summary_df: pd.DataFrame,
|
||||
save_path: str = "report",
|
||||
) -> None:
|
||||
"""保存调仓明细和汇总到CSV"""
|
||||
import os
|
||||
os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True)
|
||||
|
||||
trades_out = trades_df.copy()
|
||||
trades_out["持仓收益"] = trades_out["持仓收益"].apply(lambda x: f"{x:.2%}")
|
||||
trades_out["进场日期"] = trades_out["进场日期"].apply(
|
||||
lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)[:10]
|
||||
)
|
||||
trades_out["出场日期"] = trades_out["出场日期"].apply(
|
||||
lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)[:10]
|
||||
)
|
||||
|
||||
trades_path = f"{save_path}_trades.csv"
|
||||
trades_out.to_csv(trades_path, index=False, encoding="utf-8-sig")
|
||||
print(f"\n调仓明细已保存: {trades_path}")
|
||||
|
||||
summary_out = summary_df.copy()
|
||||
for col in ["胜率", "平均收益", "累计收益", "最大单次收益", "最大单次亏损"]:
|
||||
summary_out[col] = summary_out[col].apply(lambda x: f"{x:.2%}")
|
||||
|
||||
summary_path = f"{save_path}_summary.csv"
|
||||
summary_out.to_csv(summary_path, index=False, encoding="utf-8-sig")
|
||||
print(f"品种汇总已保存: {summary_path}")
|
||||
175
strategies/rotation/report.py
Normal file
175
strategies/rotation/report.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
ETF轮动策略 - 绩效报告模块
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from typing import Optional
|
||||
|
||||
from core.common.utils import calculate_cagr, calculate_max_drawdown, calculate_sharpe
|
||||
|
||||
|
||||
def generate_performance_report(
|
||||
backtest_result: pd.DataFrame,
|
||||
code_list: list,
|
||||
code_name_map: dict = None,
|
||||
benchmark_name: str = "沪深300指数",
|
||||
save_path: str = "report",
|
||||
select_num: int = 1,
|
||||
) -> dict:
|
||||
"""
|
||||
生成完整的绩效报告
|
||||
|
||||
Args:
|
||||
backtest_result: 回测结果
|
||||
code_list: ETF代码列表
|
||||
code_name_map: 代码到名称映射
|
||||
benchmark_name: 基准名称
|
||||
save_path: 报告保存路径前缀
|
||||
select_num: 选中数量
|
||||
|
||||
Returns:
|
||||
dict: 绩效指标字典
|
||||
"""
|
||||
import os
|
||||
os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True)
|
||||
|
||||
code_name_map = code_name_map or {}
|
||||
strategy_nav = backtest_result["轮动策略净值"]
|
||||
strategy_ret = backtest_result["轮动策略日收益率"]
|
||||
benchmark_nav = backtest_result["基准净值"]
|
||||
benchmark_ret = backtest_result["基准日收益率"]
|
||||
|
||||
# 计算绩效指标
|
||||
s_cagr_nat = calculate_cagr(strategy_nav, "natural_days")
|
||||
s_cagr_trd = calculate_cagr(strategy_nav, "trading_days")
|
||||
s_total_return = strategy_nav.iloc[-1] - 1
|
||||
s_sharpe = calculate_sharpe(strategy_ret)
|
||||
s_max_dd, s_dd_start, s_dd_end = calculate_max_drawdown(strategy_nav)
|
||||
s_win_rate = (strategy_ret > 0).sum() / len(strategy_ret)
|
||||
s_calmar = s_cagr_nat / abs(s_max_dd) if s_max_dd != 0 else np.inf
|
||||
|
||||
b_cagr_nat = calculate_cagr(benchmark_nav, "natural_days")
|
||||
b_cagr_trd = calculate_cagr(benchmark_nav, "trading_days")
|
||||
b_total_return = benchmark_nav.iloc[-1] - 1
|
||||
b_sharpe = calculate_sharpe(benchmark_ret)
|
||||
b_max_dd, _, _ = calculate_max_drawdown(benchmark_nav)
|
||||
|
||||
# 打印绩效表格
|
||||
print("\n" + "=" * 70)
|
||||
print(" 绩效评估报告")
|
||||
print("=" * 70)
|
||||
print(f" 回测区间: {strategy_nav.index.min().date()} ~ {strategy_nav.index.max().date()}")
|
||||
print(f" 交易天数: {len(strategy_nav)}")
|
||||
print("-" * 70)
|
||||
print(f' {"指标":<25} {"轮动策略":>15} {"基准(" + benchmark_name + ")":>18}')
|
||||
print("-" * 70)
|
||||
print(f' {"累计收益":<25} {s_total_return:>14.2%} {b_total_return:>17.2%}')
|
||||
print(f' {"CAGR(自然日口径)":<25} {s_cagr_nat:>14.2%} {b_cagr_nat:>17.2%}')
|
||||
print(f' {"CAGR(交易日口径)":<25} {s_cagr_trd:>14.2%} {b_cagr_trd:>17.2%}')
|
||||
print(f' {"年化夏普比率":<25} {s_sharpe:>14.2f} {b_sharpe:>17.2f}')
|
||||
print(f' {"最大回撤":<25} {s_max_dd:>14.2%} {b_max_dd:>17.2%}')
|
||||
print(f' {"Calmar比率":<23} {s_calmar:>14.2f} {"--":>17}')
|
||||
print(f' {"日胜率":<25} {s_win_rate:>14.2%} {"--":>17}')
|
||||
print(f' {"最大回撤区间":<22} {str(s_dd_start.date()):>10} ~ {str(s_dd_end.date())}')
|
||||
print("=" * 70)
|
||||
|
||||
# 绘制图表
|
||||
_plot_report_chart(
|
||||
backtest_result, code_list, code_name_map,
|
||||
benchmark_name, save_path, select_num
|
||||
)
|
||||
|
||||
# 返回指标字典
|
||||
return {
|
||||
"累计收益": s_total_return,
|
||||
"CAGR_自然日": s_cagr_nat,
|
||||
"CAGR_交易日": s_cagr_trd,
|
||||
"夏普比率": s_sharpe,
|
||||
"最大回撤": s_max_dd,
|
||||
"Calmar比率": s_calmar,
|
||||
"日胜率": s_win_rate,
|
||||
"基准累计收益": b_total_return,
|
||||
"基准CAGR_自然日": b_cagr_nat,
|
||||
"基准夏普比率": b_sharpe,
|
||||
"基准最大回撤": b_max_dd,
|
||||
}
|
||||
|
||||
|
||||
def _plot_report_chart(
|
||||
backtest_result: pd.DataFrame,
|
||||
code_list: list,
|
||||
code_name_map: dict,
|
||||
benchmark_name: str,
|
||||
save_path: str,
|
||||
select_num: int,
|
||||
):
|
||||
"""绘制报告图表"""
|
||||
plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "DejaVu Sans"]
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
strategy_nav = backtest_result["轮动策略净值"]
|
||||
benchmark_nav = backtest_result["基准净值"]
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 12))
|
||||
|
||||
# 面板1: 净值曲线
|
||||
ax1 = axes[0]
|
||||
ax1.plot(strategy_nav.index, strategy_nav.values,
|
||||
label="轮动策略", linewidth=2, color="#E74C3C")
|
||||
ax1.plot(benchmark_nav.index, benchmark_nav.values,
|
||||
label=benchmark_name, linewidth=1.5, color="#3498DB", alpha=0.8)
|
||||
|
||||
chart_colors = plt.cm.tab20.colors
|
||||
show_legend_n = min(len(code_list), 10)
|
||||
for i, code in enumerate(code_list):
|
||||
name = code_name_map.get(code, code)
|
||||
lbl = name if i < show_legend_n else None
|
||||
ax1.plot(backtest_result.index, backtest_result[f"净值_{code}"].values,
|
||||
label=lbl, linewidth=0.8, alpha=0.4,
|
||||
color=chart_colors[i % len(chart_colors)])
|
||||
|
||||
ax1.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold")
|
||||
ax1.set_ylabel("净值")
|
||||
ax1.legend(loc="upper left", fontsize=8, ncol=2)
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.set_yscale("log")
|
||||
|
||||
# 面板2: 回撤曲线
|
||||
ax2 = axes[1]
|
||||
cummax = strategy_nav.cummax()
|
||||
drawdown = (strategy_nav - cummax) / cummax
|
||||
ax2.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
|
||||
ax2.set_title("策略回撤", fontsize=12)
|
||||
ax2.set_ylabel("回撤")
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
# 面板3: 持仓分布
|
||||
ax3 = axes[2]
|
||||
signal_series = backtest_result["信号"]
|
||||
for i, code in enumerate(code_list):
|
||||
name = code_name_map.get(code, code)
|
||||
if select_num > 1:
|
||||
mask = signal_series.str.contains(code, regex=False, na=False)
|
||||
else:
|
||||
mask = signal_series == code
|
||||
if mask.any():
|
||||
ax3.fill_between(signal_series.index, i, i + 0.8,
|
||||
where=mask, alpha=0.7,
|
||||
color=chart_colors[i % len(chart_colors)],
|
||||
label=name)
|
||||
|
||||
ylabels = [code_name_map.get(c, c) for c in code_list]
|
||||
ax3.set_title("每日持仓分布", fontsize=12)
|
||||
ax3.set_yticks(range(len(ylabels)))
|
||||
ax3.set_yticklabels(ylabels, fontsize=7)
|
||||
ax3.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
chart_path = f"{save_path}_chart.png"
|
||||
plt.savefig(chart_path, dpi=150, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f"\n报告图表已保存: {chart_path}")
|
||||
Reference in New Issue
Block a user