refactor: 归档旧代码,保留新框架结构
归档内容: - core/ (数据源、因子计算、通用工具) → archive/legacy_core/ - strategies/rotation/engine.py, portfolio.py, report.py → archive/legacy_core/ - scripts/ (run_rotation, daily_scheduler) → archive/legacy_scripts/ - examples/ → archive/legacy_examples/ - tests/ (实验、对比测试) → archive/legacy_tests/ - 单独文件 (fetch_*.py, 动量.py, 全球市场.py等) → archive/single_files/ 保留新结构: - framework/ (抽象接口) - strategies/shared/ (定制组件) - strategies/rotation/strategy.py (新策略) - 外层配置: .env, .dockerignore, build-and-push.sh, hk_ecs.pem, README.md, requirements.txt - Docker相关: Dockerfile, Dockerfile_base, docker-compose.yml 更新README反映新框架架构
This commit is contained in:
38
archive/single_files/Dockerfile.flask
Normal file
38
archive/single_files/Dockerfile.flask
Normal file
@@ -0,0 +1,38 @@
|
||||
# Flask API 服务 Dockerfile
|
||||
# =========================
|
||||
# 用于构建 Universal Data Fetcher API 服务的 Docker 镜像
|
||||
|
||||
FROM index-base:latest
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装依赖
|
||||
RUN uv pip install --system -r requirements.txt
|
||||
|
||||
# 仅复制除 data 目录外的应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建日志目录
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# 设置时区为上海
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 暴露 Flask 服务端口
|
||||
EXPOSE 5000
|
||||
|
||||
# 设置环境变量默认值
|
||||
ENV FLASK_APP=core/datasource/flask_server.py
|
||||
ENV FLASK_ENV=production
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:5000/health')" || exit 1
|
||||
|
||||
# 启动 Flask 服务
|
||||
CMD ["python", "core/datasource/flask_server.py", "--host", "0.0.0.0", "--port", "5000"]
|
||||
109
archive/single_files/fetch_159516_nav.py
Normal file
109
archive/single_files/fetch_159516_nav.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
获取159516 ETF净值数据
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import tushare as ts
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 设置Tushare token
|
||||
def get_tushare_token():
|
||||
# 首先尝试从环境变量获取
|
||||
token = os.environ.get("TUSHARE_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
|
||||
# 尝试从.env文件获取
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
token = os.environ.get("TUSHARE_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 手动读取.env文件
|
||||
env_path = os.path.join(os.path.dirname(__file__), '.env')
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('TUSHARE_TOKEN='):
|
||||
token = line.strip().split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if token:
|
||||
return token
|
||||
|
||||
raise ValueError("请设置 TUSHARE_TOKEN 环境变量或在.env文件中配置")
|
||||
|
||||
|
||||
def fetch_etf_nav(etf_code="159516.SZ", days=30):
|
||||
"""
|
||||
获取ETF净值数据
|
||||
|
||||
Args:
|
||||
etf_code: ETF代码,如 "159516.SZ"
|
||||
days: 获取天数
|
||||
|
||||
Returns:
|
||||
DataFrame: 包含日期和净值
|
||||
"""
|
||||
pro = ts.pro_api(get_tushare_token())
|
||||
|
||||
# 计算日期范围
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days + 5)
|
||||
|
||||
start_str = start_date.strftime('%Y%m%d')
|
||||
end_str = end_date.strftime('%Y%m%d')
|
||||
|
||||
# 转换代码格式 (tushare使用.SH而不是.SS)
|
||||
ts_code = etf_code.replace(".SS", ".SH")
|
||||
|
||||
print(f"获取 {etf_code} 净值数据...")
|
||||
print(f"日期范围: {start_str} ~ {end_str}")
|
||||
|
||||
try:
|
||||
# 获取ETF净值数据
|
||||
nav_df = pro.fund_nav(
|
||||
ts_code=ts_code,
|
||||
start_date=start_str,
|
||||
end_date=end_str
|
||||
)
|
||||
|
||||
if nav_df is None or len(nav_df) == 0:
|
||||
print("未获取到净值数据")
|
||||
return None
|
||||
|
||||
# 排序并处理数据
|
||||
nav_df = nav_df.sort_values('nav_date')
|
||||
|
||||
# 转换日期格式
|
||||
nav_df['date'] = pd.to_datetime(nav_df['nav_date'])
|
||||
nav_df = nav_df.set_index('date')
|
||||
|
||||
print(f"\n获取到 {len(nav_df)} 条净值数据")
|
||||
print(f"最新净值日期: {nav_df.index.max().strftime('%Y-%m-%d')}")
|
||||
print(f"最新净值: {nav_df['unit_nav'].iloc[-1]}")
|
||||
|
||||
# 显示最近10条数据
|
||||
print(f"\n最近10条净值数据:")
|
||||
print(nav_df[['unit_nav']].tail(10).to_string())
|
||||
|
||||
return nav_df
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取净值数据失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 获取159516的净值数据
|
||||
result = fetch_etf_nav("159516.SZ", days=30)
|
||||
|
||||
if result is not None:
|
||||
# 保存到CSV文件
|
||||
output_file = "159516_nav_data.csv"
|
||||
result[['unit_nav']].to_csv(output_file)
|
||||
print(f"\n数据已保存到: {output_file}")
|
||||
183
archive/single_files/fetch_159930.py
Normal file
183
archive/single_files/fetch_159930.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
获取159930 ETF最新10天的收盘价、净值并计算溢价率
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import tushare as ts
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 设置Tushare token
|
||||
def get_tushare_token():
|
||||
# 首先尝试从环境变量获取
|
||||
token = os.environ.get("TUSHARE_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
|
||||
# 尝试从.env文件获取
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
token = os.environ.get("TUSHARE_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 手动读取.env文件
|
||||
env_path = os.path.join(os.path.dirname(__file__), '.env')
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('TUSHARE_TOKEN='):
|
||||
token = line.strip().split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if token:
|
||||
return token
|
||||
|
||||
raise ValueError("请设置 TUSHARE_TOKEN 环境变量或在.env文件中配置")
|
||||
|
||||
|
||||
def fetch_etf_data(etf_code: str, days: int = 10):
|
||||
"""
|
||||
获取ETF最新N天的价格、净值数据
|
||||
|
||||
Args:
|
||||
etf_code: ETF代码,如 "159930.SZ"
|
||||
days: 获取天数
|
||||
|
||||
Returns:
|
||||
DataFrame: 包含日期、收盘价、净值、溢价率
|
||||
"""
|
||||
pro = ts.pro_api(get_tushare_token())
|
||||
|
||||
# 计算日期范围(多取几天确保有足够数据)
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days + 5)
|
||||
|
||||
start_str = start_date.strftime('%Y%m%d')
|
||||
end_str = end_date.strftime('%Y%m%d')
|
||||
|
||||
# 转换代码格式
|
||||
ts_code = etf_code.replace(".SS", ".SH")
|
||||
|
||||
print(f"获取 {etf_code} 数据...")
|
||||
print(f"日期范围: {start_str} ~ {end_str}")
|
||||
|
||||
# 1. 获取ETF价格数据(fund_daily接口)
|
||||
print("\n1. 获取ETF价格数据...")
|
||||
try:
|
||||
price_df = pro.fund_daily(
|
||||
ts_code=ts_code,
|
||||
start_date=start_str,
|
||||
end_date=end_str
|
||||
)
|
||||
if price_df is not None and len(price_df) > 0:
|
||||
price_df = price_df.sort_values('trade_date')
|
||||
print(f" 获取到 {len(price_df)} 条价格数据")
|
||||
print(f" 最新日期: {price_df['trade_date'].max()}")
|
||||
else:
|
||||
print(" 未获取到价格数据")
|
||||
price_df = None
|
||||
except Exception as e:
|
||||
print(f" 获取价格数据失败: {e}")
|
||||
price_df = None
|
||||
|
||||
# 2. 获取ETF净值数据(fund_nav接口)
|
||||
print("\n2. 获取ETF净值数据...")
|
||||
try:
|
||||
# 净值通常滞后,多取一天
|
||||
nav_end_date = end_date + timedelta(days=1)
|
||||
nav_end_str = nav_end_date.strftime('%Y%m%d')
|
||||
|
||||
nav_df = pro.fund_nav(
|
||||
ts_code=ts_code,
|
||||
start_date=start_str,
|
||||
end_date=nav_end_str
|
||||
)
|
||||
if nav_df is not None and len(nav_df) > 0:
|
||||
nav_df = nav_df.sort_values('nav_date')
|
||||
print(f" 获取到 {len(nav_df)} 条净值数据")
|
||||
print(f" 最新日期: {nav_df['nav_date'].max()}")
|
||||
else:
|
||||
print(" 未获取到净值数据")
|
||||
nav_df = None
|
||||
except Exception as e:
|
||||
print(f" 获取净值数据失败: {e}")
|
||||
nav_df = None
|
||||
|
||||
# 3. 合并数据并计算溢价率
|
||||
print("\n3. 合并数据并计算溢价率...")
|
||||
|
||||
if price_df is None:
|
||||
print("错误: 没有价格数据")
|
||||
return None
|
||||
|
||||
# 准备价格数据
|
||||
price_df['date'] = pd.to_datetime(price_df['trade_date'])
|
||||
price_df = price_df.set_index('date')
|
||||
price_series = price_df['close']
|
||||
|
||||
# 准备净值数据
|
||||
if nav_df is not None:
|
||||
nav_df['date'] = pd.to_datetime(nav_df['nav_date'])
|
||||
nav_df = nav_df.set_index('date')
|
||||
nav_series = nav_df['unit_nav']
|
||||
else:
|
||||
nav_series = pd.Series()
|
||||
|
||||
# 创建结果DataFrame
|
||||
result = pd.DataFrame({
|
||||
'收盘价': price_series
|
||||
})
|
||||
|
||||
# 对齐净值数据(按日期)
|
||||
result = result.join(nav_series.rename('净值'), how='left')
|
||||
|
||||
# 计算溢价率
|
||||
result['溢价率'] = (result['收盘价'] - result['净值']) / result['净值'] * 100
|
||||
|
||||
# 取最新N天
|
||||
result = result.tail(days)
|
||||
|
||||
# 格式化输出
|
||||
result['收盘价'] = result['收盘价'].round(3)
|
||||
result['净值'] = result['净值'].round(3)
|
||||
result['溢价率'] = result['溢价率'].round(2)
|
||||
|
||||
# 重置索引,将日期作为列
|
||||
result = result.reset_index()
|
||||
result['日期'] = result['date'].dt.strftime('%Y-%m-%d')
|
||||
result = result[['日期', '收盘价', '净值', '溢价率']]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
etf_code = "159930.SZ"
|
||||
days = 10
|
||||
|
||||
print("=" * 60)
|
||||
print(f"ETF: {etf_code} (中证能源ETF)")
|
||||
print(f"获取最近 {days} 天数据")
|
||||
print("=" * 60)
|
||||
|
||||
df = fetch_etf_data(etf_code, days)
|
||||
|
||||
if df is not None and len(df) > 0:
|
||||
print("\n" + "=" * 60)
|
||||
print("结果表格:")
|
||||
print("=" * 60)
|
||||
print(df.to_string(index=False))
|
||||
|
||||
# 保存到CSV
|
||||
output_file = f"{etf_code.replace('.', '_')}_latest_{days}days.csv"
|
||||
df.to_csv(output_file, index=False, encoding='utf-8-sig')
|
||||
print(f"\n数据已保存到: {output_file}")
|
||||
else:
|
||||
print("\n获取数据失败")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8001
archive/single_files/index_basic.csv
Normal file
8001
archive/single_files/index_basic.csv
Normal file
File diff suppressed because it is too large
Load Diff
2773
archive/single_files/index_fund_info.csv
Normal file
2773
archive/single_files/index_fund_info.csv
Normal file
File diff suppressed because it is too large
Load Diff
127
archive/single_files/start_flask_server.sh
Executable file
127
archive/single_files/start_flask_server.sh
Executable file
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# Flask API 服务启动脚本
|
||||
# =====================
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Universal Data Fetcher API 服务启动脚本${NC}"
|
||||
echo "=========================================="
|
||||
|
||||
# 检查 Python
|
||||
echo -e "\n1. 检查 Python 环境..."
|
||||
if ! command -v python &> /dev/null; then
|
||||
echo -e "${RED}✗ Python 未安装${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Python 已安装: $(python --version)${NC}"
|
||||
|
||||
# 检查依赖
|
||||
echo -e "\n2. 检查依赖..."
|
||||
python -c "import flask" 2>/dev/null || {
|
||||
echo -e "${YELLOW}⚠ Flask 未安装,正在安装...${NC}"
|
||||
pip install flask flask-cors
|
||||
}
|
||||
echo -e "${GREEN}✓ 依赖检查完成${NC}"
|
||||
|
||||
# 检查环境变量
|
||||
echo -e "\n3. 检查环境变量..."
|
||||
if [ -z "$TUSHARE_TOKEN" ]; then
|
||||
if [ -f ".env" ]; then
|
||||
echo -e "${YELLOW}⚠ 从 .env 文件加载环境变量${NC}"
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
else
|
||||
echo -e "${RED}✗ TUSHARE_TOKEN 未设置${NC}"
|
||||
echo " 请在 .env 文件中设置 TUSHARE_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}✓ 环境变量检查完成${NC}"
|
||||
|
||||
# 检查 SSH 配置
|
||||
echo -e "\n4. 检查 SSH 配置..."
|
||||
if [ -f "hk_ecs.pem" ]; then
|
||||
echo -e "${GREEN}✓ SSH 私钥文件存在 (hk_ecs.pem)${NC}"
|
||||
|
||||
# 检查权限
|
||||
PERM=$(stat -f "%Lp" hk_ecs.pem 2>/dev/null || stat -c "%a" hk_ecs.pem 2>/dev/null)
|
||||
if [ "$PERM" != "600" ]; then
|
||||
echo -e "${YELLOW}⚠ 修复 SSH 私钥权限...${NC}"
|
||||
chmod 600 hk_ecs.pem
|
||||
fi
|
||||
echo -e "${GREEN}✓ SSH 私钥权限正确 (600)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ SSH 私钥文件不存在,港美股数据获取将受限${NC}"
|
||||
fi
|
||||
|
||||
# 解析参数
|
||||
HOST="0.0.0.0"
|
||||
PORT="5000"
|
||||
DEBUG=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--host)
|
||||
HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--debug)
|
||||
DEBUG="--debug"
|
||||
shift
|
||||
;;
|
||||
--with-ssh)
|
||||
export SSH_ENABLED=true
|
||||
export SSH_HOST=8.218.167.69
|
||||
export SSH_PORT=22
|
||||
export SSH_USERNAME=root
|
||||
export SSH_KEY_PATH=hk_ecs.pem
|
||||
export SSH_LOCAL_PORT=1080
|
||||
echo -e "${GREEN}✓ SSH 隧道已启用${NC}"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
echo ""
|
||||
echo "用法: ./start_flask_server.sh [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --host HOST 绑定主机 (默认: 0.0.0.0)"
|
||||
echo " --port PORT 绑定端口 (默认: 5000)"
|
||||
echo " --debug 启用调试模式"
|
||||
echo " --with-ssh 启用 SSH 隧道"
|
||||
echo " --help 显示帮助"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./start_flask_server.sh"
|
||||
echo " ./start_flask_server.sh --port 8080"
|
||||
echo " ./start_flask_server.sh --with-ssh"
|
||||
echo " ./start_flask_server.sh --host 127.0.0.1 --port 5000 --debug --with-ssh"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}未知选项: $1${NC}"
|
||||
echo "使用 --help 查看帮助"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 启动服务
|
||||
echo -e "\n5. 启动 Flask 服务..."
|
||||
echo -e " 主机: ${YELLOW}$HOST${NC}"
|
||||
echo -e " 端口: ${YELLOW}$PORT${NC}"
|
||||
echo -e " 调试: ${YELLOW}$([ -n "$DEBUG" ] && echo "是" || echo "否")${NC}"
|
||||
echo -e " SSH: ${YELLOW}$([ "$SSH_ENABLED" = "true" ] && echo "启用" || echo "禁用")${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}✓ 服务启动中...${NC}"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
python core/datasource/flask_server.py --host "$HOST" --port "$PORT" $DEBUG
|
||||
299
archive/single_files/全球市场.py
Normal file
299
archive/single_files/全球市场.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
全球市场ETF轮动策略 - 本地回测版本
|
||||
原始策略来源:聚宽 https://www.joinquant.com/post/1399
|
||||
|
||||
核心逻辑(与动量策略共用):
|
||||
1. 加权线性回归(权重1→2递增)计算趋势得分
|
||||
2. score = 年化收益率 × R²
|
||||
3. ATR动态调整回看窗口(20~60天)
|
||||
4. 崩盘过滤:连续3天任一天跌>5%则得分归零
|
||||
5. 溢价过滤:溢价率≥5%则降权
|
||||
6. 全仓单一品种轮动
|
||||
|
||||
ETF池:全球化配置
|
||||
纳指100 / 日经225 / 德国DAX / 黄金 / 有色金属 /
|
||||
南方原油 / 30年国债 / 红利低波 / 创业板
|
||||
"""
|
||||
|
||||
import sys
|
||||
import math
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
# 添加项目根目录
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 策略配置 ====================
|
||||
CONFIG = {
|
||||
# 全球市场ETF池(聚宽代码 -> Tushare代码映射)
|
||||
'etf_pool': {
|
||||
'513100.SH': '纳指100ETF',
|
||||
'513520.SH': '日经225ETF',
|
||||
'513030.SH': '德国DAX ETF',
|
||||
'518880.SH': '黄金ETF华安',
|
||||
'159980.SZ': '有色金属ETF',
|
||||
'501018.SH': '南方原油LOF',
|
||||
'511090.SH': '30年国债ETF',
|
||||
'512890.SH': '红利低波ETF',
|
||||
'159915.SZ': '创业板ETF易方达',
|
||||
},
|
||||
'target_num': 1, # 持仓数量
|
||||
'auto_day': True, # 是否启用动态周期
|
||||
'fixed_days': 25, # 固定回看天数
|
||||
'min_days': 20, # 动态周期最小值
|
||||
'max_days': 60, # 动态周期最大值
|
||||
'premium_threshold': 5.0, # 溢价率阈值(%)
|
||||
'trade_cost': 0.001, # 单次交易成本(双边)
|
||||
'start_date': '2019-01-01',
|
||||
'benchmark': '000300.SH', # 基准:沪深300
|
||||
}
|
||||
|
||||
# ==================== 复用动量策略核心模块 ====================
|
||||
from 动量 import (
|
||||
fetch_all_etf_data,
|
||||
fetch_etf_nav_data,
|
||||
calc_atr,
|
||||
calc_weighted_momentum_score,
|
||||
apply_crash_filter,
|
||||
calc_premium_rate,
|
||||
print_performance,
|
||||
print_yearly_returns,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 回测引擎 ====================
|
||||
def run_backtest(config: dict):
|
||||
"""执行回测"""
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
etf_pool = config['etf_pool']
|
||||
etf_codes = list(etf_pool.keys())
|
||||
|
||||
print("=" * 60)
|
||||
print(" 全球市场ETF轮动策略 - 本地回测")
|
||||
print("=" * 60)
|
||||
print(f" 候选ETF: {len(etf_codes)} 只")
|
||||
for code, name in etf_pool.items():
|
||||
print(f" {code} {name}")
|
||||
print(f" 持仓数量: {config['target_num']}")
|
||||
print(f" 动态周期: {'开启' if config['auto_day'] else '关闭'}")
|
||||
if config['auto_day']:
|
||||
print(f" 回看范围: {config['min_days']}~{config['max_days']} 天")
|
||||
else:
|
||||
print(f" 固定回看: {config['fixed_days']} 天")
|
||||
print(f" 回测区间: {config['start_date']} ~ {end_date}")
|
||||
|
||||
# 1. 获取数据
|
||||
print(f"\n{'='*60}")
|
||||
print("下载ETF价格数据...")
|
||||
all_data = fetch_all_etf_data(etf_codes, config['start_date'], end_date, etf_pool)
|
||||
|
||||
print("\n下载ETF净值数据...")
|
||||
nav_data = fetch_etf_nav_data(etf_codes, config['start_date'], end_date)
|
||||
print(f" 净值数据: {len(nav_data)} 只")
|
||||
|
||||
if not all_data:
|
||||
print("无数据,退出")
|
||||
return
|
||||
|
||||
# 2. 构建交易日历
|
||||
all_dates = set()
|
||||
for df in all_data.values():
|
||||
all_dates.update(df.index.tolist())
|
||||
trade_dates = sorted(all_dates)
|
||||
trade_dates = [d for d in trade_dates if d >= pd.Timestamp(config['start_date'])]
|
||||
|
||||
print(f"\n交易日数: {len(trade_dates)}")
|
||||
print(f"区间: {trade_dates[0].strftime('%Y-%m-%d')} ~ {trade_dates[-1].strftime('%Y-%m-%d')}")
|
||||
|
||||
# 3. 逐日回测
|
||||
print(f"\n{'='*60}")
|
||||
print("开始回测...")
|
||||
print("=" * 60)
|
||||
|
||||
max_lookback = config['max_days'] + 10
|
||||
holding = None
|
||||
daily_returns = []
|
||||
signals = []
|
||||
|
||||
for i, today in enumerate(trade_dates):
|
||||
# 计算每只ETF的得分
|
||||
scores = {}
|
||||
score_details = {}
|
||||
|
||||
for code in etf_codes:
|
||||
if code not in all_data:
|
||||
continue
|
||||
df = all_data[code]
|
||||
|
||||
hist = df[df.index <= today].tail(max_lookback + 1)
|
||||
if len(hist) < config['min_days']:
|
||||
continue
|
||||
|
||||
close_arr = hist['close'].values
|
||||
|
||||
if config['auto_day']:
|
||||
if len(hist) < max_lookback:
|
||||
lookback = config['fixed_days']
|
||||
else:
|
||||
long_atr = calc_atr(hist['high'], hist['low'], hist['close'],
|
||||
config['max_days'])
|
||||
short_atr = calc_atr(hist['high'], hist['low'], hist['close'],
|
||||
config['min_days'])
|
||||
la = long_atr.iloc[-1]
|
||||
sa = short_atr.iloc[-1]
|
||||
if la > 0 and not np.isnan(la) and not np.isnan(sa):
|
||||
ratio = min(0.9, sa / la)
|
||||
lookback = int(config['min_days'] +
|
||||
(config['max_days'] - config['min_days']) * (1 - ratio))
|
||||
else:
|
||||
lookback = config['fixed_days']
|
||||
prices = close_arr[-lookback:]
|
||||
else:
|
||||
prices = close_arr[-config['fixed_days']:]
|
||||
|
||||
if len(prices) < 5:
|
||||
continue
|
||||
|
||||
result = calc_weighted_momentum_score(prices)
|
||||
score = result['score']
|
||||
score = apply_crash_filter(close_arr, score)
|
||||
|
||||
# 溢价过滤
|
||||
if code in nav_data:
|
||||
nav_df = nav_data[code]
|
||||
nav_row = nav_df[nav_df.index <= today]
|
||||
if not nav_row.empty:
|
||||
nav_val = nav_row.iloc[-1]['nav']
|
||||
etf_price = close_arr[-1]
|
||||
premium = calc_premium_rate(etf_price, nav_val)
|
||||
if premium >= config['premium_threshold']:
|
||||
score -= 1
|
||||
|
||||
if 0 < score < 6:
|
||||
scores[code] = score
|
||||
score_details[code] = result
|
||||
|
||||
# 选出排名最高的标的
|
||||
if scores:
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
target = ranked[0][0]
|
||||
else:
|
||||
target = None
|
||||
|
||||
# 计算当日收益
|
||||
if holding is not None and holding in all_data:
|
||||
df_h = all_data[holding]
|
||||
if today in df_h.index:
|
||||
prev_dates = df_h[df_h.index < today].index
|
||||
if len(prev_dates) > 0:
|
||||
prev_date = prev_dates[-1]
|
||||
prev_price = df_h.loc[prev_date, 'close']
|
||||
today_price = df_h.loc[today, 'close']
|
||||
daily_ret = today_price / prev_price - 1
|
||||
else:
|
||||
daily_ret = 0.0
|
||||
else:
|
||||
daily_ret = 0.0
|
||||
else:
|
||||
daily_ret = 0.0
|
||||
|
||||
# 调仓成本
|
||||
trade_cost = 0.0
|
||||
if target != holding:
|
||||
trade_cost = config['trade_cost']
|
||||
if holding is not None:
|
||||
signals.append({
|
||||
'date': today, 'action': '调仓',
|
||||
'from': holding, 'to': target or '空仓',
|
||||
'score': scores.get(target, 0) if target else 0,
|
||||
})
|
||||
holding = target
|
||||
|
||||
daily_returns.append({
|
||||
'date': today,
|
||||
'daily_return': daily_ret - trade_cost if trade_cost > 0 else daily_ret,
|
||||
'holding': holding or '空仓',
|
||||
})
|
||||
|
||||
# 4. 计算绩效
|
||||
result_df = pd.DataFrame(daily_returns).set_index('date')
|
||||
result_df['nav'] = (1 + result_df['daily_return']).cumprod()
|
||||
|
||||
# 基准数据
|
||||
benchmark_code = config['benchmark']
|
||||
print(f"\n获取基准数据 {benchmark_code}...")
|
||||
import os, tushare as ts
|
||||
pro = ts.pro_api(os.getenv("TUSHARE_TOKEN"))
|
||||
bench_df = pro.index_daily(
|
||||
ts_code=benchmark_code,
|
||||
start_date=config['start_date'].replace('-', ''),
|
||||
end_date=end_date.replace('-', ''),
|
||||
)
|
||||
if bench_df is not None and not bench_df.empty:
|
||||
bench_df['date'] = pd.to_datetime(bench_df['trade_date'])
|
||||
bench_df = bench_df.set_index('date').sort_index()
|
||||
bench_close = bench_df['close'].reindex(result_df.index, method='ffill')
|
||||
result_df['bench_return'] = bench_close / bench_close.iloc[0]
|
||||
else:
|
||||
result_df['bench_return'] = 1.0
|
||||
|
||||
# 5. 输出绩效报告
|
||||
print_performance(result_df, signals, config)
|
||||
|
||||
# 6. 年度收益统计
|
||||
print_yearly_returns(result_df)
|
||||
|
||||
# 7. 生成图表
|
||||
save_chart(result_df, config)
|
||||
|
||||
return result_df
|
||||
|
||||
|
||||
# ==================== 图表生成 ====================
|
||||
def save_chart(result_df: pd.DataFrame, config: dict):
|
||||
"""生成净值曲线图"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
matplotlib.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans']
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), height_ratios=[3, 1],
|
||||
gridspec_kw={'hspace': 0.3})
|
||||
|
||||
ax1.plot(result_df.index, result_df['nav'], label='全球市场轮动', linewidth=1.5, color='#2ecc71')
|
||||
ax1.plot(result_df.index, result_df['bench_return'], label='沪深300', linewidth=1, color='#95a5a6')
|
||||
ax1.set_title('全球市场ETF轮动策略 净值曲线', fontsize=14, fontweight='bold')
|
||||
ax1.legend(loc='upper left')
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.set_ylabel('净值')
|
||||
|
||||
peak = result_df['nav'].cummax()
|
||||
drawdown = (result_df['nav'] - peak) / peak
|
||||
ax2.fill_between(result_df.index, drawdown, 0, alpha=0.4, color='#e74c3c')
|
||||
ax2.set_title('回撤', fontsize=12)
|
||||
ax2.set_ylabel('回撤')
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
chart_path = Path(__file__).parent / 'results' / 'global_market_chart.png'
|
||||
chart_path.parent.mkdir(exist_ok=True)
|
||||
fig.savefig(chart_path, dpi=150, bbox_inches='tight')
|
||||
plt.close(fig)
|
||||
print(f"\n报告图表已保存: {chart_path}")
|
||||
except Exception as e:
|
||||
print(f"\n图表生成失败: {e}")
|
||||
|
||||
|
||||
# ==================== 主入口 ====================
|
||||
if __name__ == "__main__":
|
||||
run_backtest(CONFIG)
|
||||
627
archive/single_files/动量.py
Normal file
627
archive/single_files/动量.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
ETF动量轮动策略 - 本地回测版本
|
||||
原始策略来源:聚宽 https://www.joinquant.com/post/1399
|
||||
|
||||
核心逻辑:
|
||||
1. 加权线性回归(权重1→2递增)计算趋势得分
|
||||
2. score = 年化收益率 × R²
|
||||
3. ATR动态调整回看窗口(20~60天)
|
||||
4. 崩盘过滤:连续3天任一天跌>5%则得分归零
|
||||
5. 溢价过滤:溢价率≥5%则降权
|
||||
6. 全仓单一品种轮动
|
||||
"""
|
||||
|
||||
import sys
|
||||
import math
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
# 添加项目根目录
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# ==================== 策略配置 ====================
|
||||
CONFIG = {
|
||||
# 候选ETF池:
|
||||
# - dict: 手动指定 {ts_code: name}
|
||||
# - 'auto': 使用动态筛选引擎自动构建
|
||||
# - 'latest': 加载最近一次构建结果
|
||||
# - 'dynamic': 回测中定期重建,无前视偏差
|
||||
'etf_pool': 'dynamic',
|
||||
'rebuild_interval': 60, # 动态池重建间隔(交易日)
|
||||
'target_num': 1, # 持仓数量
|
||||
'auto_day': True, # 是否启用动态周期
|
||||
'fixed_days': 25, # 固定回看天数
|
||||
'min_days': 20, # 动态周期最小值
|
||||
'max_days': 60, # 动态周期最大值
|
||||
'premium_threshold': 5.0, # 溢价率阈值(%)
|
||||
'trade_cost': 0.001, # 单次交易成本(双边)
|
||||
'start_date': '2015-01-01',
|
||||
'benchmark': '000300.SH', # 基准:沪深300
|
||||
}
|
||||
|
||||
|
||||
# ==================== 数据获取 ====================
|
||||
def fetch_all_etf_data(etf_codes: list, start_date: str, end_date: str, etf_pool: dict = None) -> dict:
|
||||
"""使用Tushare获取所有ETF的OHLCV数据"""
|
||||
import os
|
||||
import tushare as ts
|
||||
|
||||
token = os.getenv("TUSHARE_TOKEN")
|
||||
if not token:
|
||||
raise ValueError("请设置环境变量 TUSHARE_TOKEN")
|
||||
pro = ts.pro_api(token)
|
||||
|
||||
# 需要额外前置数据用于ATR计算
|
||||
pre_start = (pd.Timestamp(start_date) - pd.Timedelta(days=120)).strftime('%Y%m%d')
|
||||
end_str = end_date.replace('-', '')
|
||||
|
||||
pool_names = etf_pool or {}
|
||||
all_data = {}
|
||||
for code in etf_codes:
|
||||
print(f" 下载 {code} ({pool_names.get(code, '')})...", end=" ")
|
||||
try:
|
||||
df = pro.fund_daily(
|
||||
ts_code=code,
|
||||
start_date=pre_start,
|
||||
end_date=end_str,
|
||||
)
|
||||
if df is None or df.empty:
|
||||
print("✗ 无数据")
|
||||
continue
|
||||
|
||||
df = df.rename(columns={'trade_date': 'date', 'vol': 'volume'})
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
df = df.set_index('date').sort_index()
|
||||
df = df[['open', 'high', 'low', 'close', 'volume']].astype(float)
|
||||
all_data[code] = df
|
||||
print(f"✓ {len(df)} 条")
|
||||
except Exception as e:
|
||||
print(f"✗ {e}")
|
||||
|
||||
return all_data
|
||||
|
||||
|
||||
def fetch_etf_nav_data(etf_codes: list, start_date: str, end_date: str) -> dict:
|
||||
"""获取ETF净值数据(用于溢价率计算)"""
|
||||
import os
|
||||
import tushare as ts
|
||||
|
||||
token = os.getenv("TUSHARE_TOKEN")
|
||||
pro = ts.pro_api(token)
|
||||
|
||||
pre_start = (pd.Timestamp(start_date) - pd.Timedelta(days=120)).strftime('%Y%m%d')
|
||||
end_str = end_date.replace('-', '')
|
||||
|
||||
nav_data = {}
|
||||
for code in etf_codes:
|
||||
try:
|
||||
df = pro.fund_nav(
|
||||
ts_code=code,
|
||||
start_date=pre_start,
|
||||
end_date=end_str,
|
||||
)
|
||||
if df is not None and not df.empty:
|
||||
df = df.rename(columns={'nav_date': 'date', 'unit_nav': 'nav'})
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
df = df.set_index('date').sort_index()
|
||||
nav_data[code] = df[['nav']].astype(float)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return nav_data
|
||||
|
||||
|
||||
# ==================== ATR计算 ====================
|
||||
def calc_atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int) -> pd.Series:
|
||||
"""计算ATR(不依赖talib)"""
|
||||
prev_close = close.shift(1)
|
||||
tr = pd.concat([
|
||||
high - low,
|
||||
(high - prev_close).abs(),
|
||||
(low - prev_close).abs(),
|
||||
], axis=1).max(axis=1)
|
||||
return tr.rolling(window=period, min_periods=period).mean()
|
||||
|
||||
|
||||
# ==================== 核心得分计算 ====================
|
||||
def calc_weighted_momentum_score(prices: np.ndarray) -> dict:
|
||||
"""
|
||||
加权线性回归动量得分
|
||||
|
||||
Args:
|
||||
prices: 价格数组(含当日价格)
|
||||
|
||||
Returns:
|
||||
{'annualized_returns': float, 'r2': float, 'score': float}
|
||||
"""
|
||||
if len(prices) < 5:
|
||||
return {'annualized_returns': 0, 'r2': 0, 'score': 0}
|
||||
|
||||
y = np.log(prices)
|
||||
x = np.arange(len(y))
|
||||
weights = np.linspace(1, 2, len(y)) # 近期权重更高
|
||||
|
||||
# 加权线性回归
|
||||
slope, intercept = np.polyfit(x, y, 1, w=weights)
|
||||
annualized_returns = math.exp(slope * 250) - 1
|
||||
|
||||
# 加权R²
|
||||
y_pred = slope * x + intercept
|
||||
ss_res = np.sum(weights * (y - y_pred) ** 2)
|
||||
ss_tot = np.sum(weights * (y - np.average(y, weights=weights)) ** 2)
|
||||
r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0
|
||||
|
||||
score = annualized_returns * r2
|
||||
|
||||
return {'annualized_returns': annualized_returns, 'r2': r2, 'score': score}
|
||||
|
||||
|
||||
def apply_crash_filter(prices: np.ndarray, score: float) -> float:
|
||||
"""崩盘过滤:连续3天有任一天跌>5%"""
|
||||
if len(prices) < 4:
|
||||
return score
|
||||
|
||||
r1 = prices[-1] / prices[-2]
|
||||
r2 = prices[-2] / prices[-3]
|
||||
r3 = prices[-3] / prices[-4]
|
||||
|
||||
# 条件1:任一天跌>5%
|
||||
con1 = min(r1, r2, r3) < 0.95
|
||||
# 条件2:连续下跌且累计跌>5%
|
||||
con2 = (r1 < 1) and (r2 < 1) and (r3 < 1) and (prices[-1] / prices[-4] < 0.95)
|
||||
|
||||
if con1 or con2:
|
||||
return 0.0
|
||||
return score
|
||||
|
||||
|
||||
def calc_premium_rate(etf_price: float, nav: float) -> float:
|
||||
"""计算溢价率(%)"""
|
||||
if nav is None or nav == 0 or np.isnan(nav):
|
||||
return 0.0
|
||||
return (etf_price - nav) / nav * 100
|
||||
|
||||
|
||||
# ==================== 回测引擎 ====================
|
||||
def resolve_etf_pool(config: dict, ref_date: str = None, data_cache=None) -> dict:
|
||||
"""
|
||||
解析ETF池配置:
|
||||
- dict: 直接返回
|
||||
- 'auto': 调用筛选引擎构建
|
||||
- 'latest': 加载最近一次构建结果
|
||||
- 'dynamic': 用缓存数据在指定日期重建(无前视偏差)
|
||||
"""
|
||||
pool = config['etf_pool']
|
||||
if isinstance(pool, dict):
|
||||
return pool
|
||||
|
||||
from scripts.build_etf_universe import build_universe, load_latest_universe
|
||||
|
||||
if pool == 'latest':
|
||||
print("加载最近一次构建的动态ETF池...")
|
||||
return load_latest_universe()
|
||||
elif pool == 'auto':
|
||||
print("使用筛选引擎构建动态ETF池...")
|
||||
return build_universe()
|
||||
elif pool == 'dynamic':
|
||||
if data_cache is None:
|
||||
from scripts.etf_data_cache import ETFDataCache
|
||||
data_cache = ETFDataCache()
|
||||
date_str = ref_date or datetime.now().strftime('%Y%m%d')
|
||||
return build_universe(ref_date=date_str, data_cache=data_cache)
|
||||
else:
|
||||
raise ValueError(f"不支持的 etf_pool 配置: {pool}")
|
||||
|
||||
|
||||
def run_backtest(config: dict):
|
||||
"""执行回测"""
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
pool_mode = config['etf_pool'] if isinstance(config['etf_pool'], str) else '手动指定'
|
||||
is_dynamic = (pool_mode == 'dynamic')
|
||||
|
||||
# 动态模式: 初始化缓存
|
||||
data_cache = None
|
||||
if is_dynamic:
|
||||
from scripts.etf_data_cache import ETFDataCache
|
||||
data_cache = ETFDataCache()
|
||||
print("动态重建模式: 使用本地缓存数据,无前视偏差")
|
||||
print(f" 重建间隔: {config['rebuild_interval']} 交易日")
|
||||
|
||||
# 解析初始 ETF 池
|
||||
# 动态模式下用 start_date 作为初始重建日期
|
||||
init_ref_date = config['start_date'].replace('-', '') if is_dynamic else None
|
||||
etf_pool = resolve_etf_pool(config, ref_date=init_ref_date, data_cache=data_cache)
|
||||
etf_codes = list(etf_pool.keys())
|
||||
|
||||
print("=" * 60)
|
||||
print(" ETF动量轮动策略 - 本地回测")
|
||||
print("=" * 60)
|
||||
print(f" ETF池模式: {pool_mode}")
|
||||
print(f" 候选ETF: {len(etf_codes)} 只")
|
||||
for code, name in etf_pool.items():
|
||||
print(f" {code} {name}")
|
||||
print(f" 持仓数量: {config['target_num']}")
|
||||
print(f" 动态周期: {'开启' if config['auto_day'] else '关闭'}")
|
||||
if config['auto_day']:
|
||||
print(f" 回看范围: {config['min_days']}~{config['max_days']} 天")
|
||||
else:
|
||||
print(f" 固定回看: {config['fixed_days']} 天")
|
||||
print(f" 回测区间: {config['start_date']} ~ {end_date}")
|
||||
|
||||
# 1. 获取数据
|
||||
print(f"\n{'='*60}")
|
||||
if data_cache is not None:
|
||||
print("从本地缓存加载ETF价格数据...")
|
||||
all_data = {}
|
||||
for code in etf_codes:
|
||||
ohlcv = data_cache.load_cached_ohlcv(code)
|
||||
if not ohlcv.empty:
|
||||
all_data[code] = ohlcv
|
||||
print(f" 加载完成: {len(all_data)} 只")
|
||||
nav_data = {} # 动态模式下暂不用净值数据
|
||||
else:
|
||||
print("下载ETF价格数据...")
|
||||
all_data = fetch_all_etf_data(etf_codes, config['start_date'], end_date, etf_pool)
|
||||
print("\n下载ETF净值数据...")
|
||||
nav_data = fetch_etf_nav_data(etf_codes, config['start_date'], end_date)
|
||||
print(f" 净值数据: {len(nav_data)} 只")
|
||||
|
||||
if not all_data:
|
||||
print("无数据,退出")
|
||||
return
|
||||
|
||||
# 2. 构建交易日历(以A股交易日为准)
|
||||
all_dates = set()
|
||||
for df in all_data.values():
|
||||
all_dates.update(df.index.tolist())
|
||||
trade_dates = sorted(all_dates)
|
||||
trade_dates = [d for d in trade_dates if d >= pd.Timestamp(config['start_date'])]
|
||||
|
||||
print(f"\n交易日数: {len(trade_dates)}")
|
||||
print(f"区间: {trade_dates[0].strftime('%Y-%m-%d')} ~ {trade_dates[-1].strftime('%Y-%m-%d')}")
|
||||
|
||||
# 3. 逐日回测
|
||||
print(f"\n{'='*60}")
|
||||
print("开始回测...")
|
||||
print("=" * 60)
|
||||
|
||||
max_lookback = config['max_days'] + 10
|
||||
holding = None # 当前持仓ETF代码
|
||||
daily_returns = [] # 每日收益率
|
||||
signals = [] # 信号记录
|
||||
last_rebuild_i = -config['rebuild_interval'] # 确保第一天就重建
|
||||
|
||||
for i, today in enumerate(trade_dates):
|
||||
# 动态重建 ETF 池
|
||||
if is_dynamic and (i - last_rebuild_i >= config['rebuild_interval']):
|
||||
ref_str = today.strftime('%Y%m%d')
|
||||
print(f"\n [重建] {ref_str}: 重新构建ETF池...")
|
||||
try:
|
||||
new_pool = resolve_etf_pool(config, ref_date=ref_str, data_cache=data_cache)
|
||||
etf_codes = list(new_pool.keys())
|
||||
# 加载新增 ETF 的数据
|
||||
for code in etf_codes:
|
||||
if code not in all_data and data_cache is not None:
|
||||
ohlcv = data_cache.load_cached_ohlcv(code)
|
||||
if not ohlcv.empty:
|
||||
all_data[code] = ohlcv
|
||||
print(f" [重建] 新池子: {len(etf_codes)} 只")
|
||||
last_rebuild_i = i
|
||||
except Exception as e:
|
||||
print(f" [重建] 失败: {e},继续使用旧池")
|
||||
|
||||
# 计算每只ETF的得分
|
||||
scores = {}
|
||||
score_details = {}
|
||||
|
||||
for code in etf_codes:
|
||||
if code not in all_data:
|
||||
continue
|
||||
df = all_data[code]
|
||||
|
||||
# 获取截至今日的历史数据
|
||||
hist = df[df.index <= today].tail(max_lookback + 1)
|
||||
if len(hist) < config['min_days']:
|
||||
continue
|
||||
|
||||
close_arr = hist['close'].values
|
||||
|
||||
if config['auto_day']:
|
||||
# 动态周期:基于ATR波动率调整
|
||||
if len(hist) < max_lookback:
|
||||
lookback = config['fixed_days']
|
||||
else:
|
||||
long_atr = calc_atr(hist['high'], hist['low'], hist['close'],
|
||||
config['max_days'])
|
||||
short_atr = calc_atr(hist['high'], hist['low'], hist['close'],
|
||||
config['min_days'])
|
||||
la = long_atr.iloc[-1]
|
||||
sa = short_atr.iloc[-1]
|
||||
if la > 0 and not np.isnan(la) and not np.isnan(sa):
|
||||
ratio = min(0.9, sa / la)
|
||||
lookback = int(config['min_days'] +
|
||||
(config['max_days'] - config['min_days']) * (1 - ratio))
|
||||
else:
|
||||
lookback = config['fixed_days']
|
||||
|
||||
prices = close_arr[-lookback:]
|
||||
else:
|
||||
prices = close_arr[-config['fixed_days']:]
|
||||
|
||||
if len(prices) < 5:
|
||||
continue
|
||||
|
||||
# 计算得分
|
||||
result = calc_weighted_momentum_score(prices)
|
||||
score = result['score']
|
||||
|
||||
# 崩盘过滤
|
||||
score = apply_crash_filter(close_arr, score)
|
||||
|
||||
# 溢价过滤
|
||||
if code in nav_data:
|
||||
nav_df = nav_data[code]
|
||||
nav_row = nav_df[nav_df.index <= today]
|
||||
if not nav_row.empty:
|
||||
nav_val = nav_row.iloc[-1]['nav']
|
||||
etf_price = close_arr[-1]
|
||||
premium = calc_premium_rate(etf_price, nav_val)
|
||||
if premium >= config['premium_threshold']:
|
||||
score -= 1
|
||||
|
||||
# 只保留有效得分 (0 < score < 6)
|
||||
if 0 < score < 6:
|
||||
scores[code] = score
|
||||
score_details[code] = result
|
||||
|
||||
# 选出排名最高的标的
|
||||
if scores:
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
target = ranked[0][0] # target_num=1
|
||||
else:
|
||||
target = None
|
||||
|
||||
# 计算当日收益
|
||||
if holding is not None and holding in all_data:
|
||||
df_h = all_data[holding]
|
||||
if today in df_h.index:
|
||||
prev_dates = df_h[df_h.index < today].index
|
||||
if len(prev_dates) > 0:
|
||||
prev_date = prev_dates[-1]
|
||||
prev_price = df_h.loc[prev_date, 'close']
|
||||
today_price = df_h.loc[today, 'close']
|
||||
daily_ret = today_price / prev_price - 1
|
||||
else:
|
||||
daily_ret = 0.0
|
||||
else:
|
||||
daily_ret = 0.0
|
||||
else:
|
||||
daily_ret = 0.0
|
||||
|
||||
# 调仓成本
|
||||
trade_cost = 0.0
|
||||
if target != holding:
|
||||
trade_cost = config['trade_cost']
|
||||
if holding is not None:
|
||||
signals.append({
|
||||
'date': today, 'action': '调仓',
|
||||
'from': holding, 'to': target or '空仓',
|
||||
'score': scores.get(target, 0) if target else 0,
|
||||
})
|
||||
holding = target
|
||||
|
||||
daily_returns.append({
|
||||
'date': today,
|
||||
'daily_return': daily_ret - trade_cost if trade_cost > 0 else daily_ret,
|
||||
'holding': holding or '空仓',
|
||||
})
|
||||
|
||||
# 4. 计算绩效
|
||||
result_df = pd.DataFrame(daily_returns).set_index('date')
|
||||
result_df['nav'] = (1 + result_df['daily_return']).cumprod()
|
||||
|
||||
# 基准数据
|
||||
benchmark_code = config['benchmark']
|
||||
print(f"\n获取基准数据 {benchmark_code}...")
|
||||
import os, tushare as ts
|
||||
pro = ts.pro_api(os.getenv("TUSHARE_TOKEN"))
|
||||
bench_df = pro.index_daily(
|
||||
ts_code=benchmark_code,
|
||||
start_date=config['start_date'].replace('-', ''),
|
||||
end_date=end_date.replace('-', ''),
|
||||
)
|
||||
if bench_df is not None and not bench_df.empty:
|
||||
bench_df['date'] = pd.to_datetime(bench_df['trade_date'])
|
||||
bench_df = bench_df.set_index('date').sort_index()
|
||||
bench_close = bench_df['close'].reindex(result_df.index, method='ffill')
|
||||
result_df['bench_return'] = bench_close / bench_close.iloc[0]
|
||||
else:
|
||||
result_df['bench_return'] = 1.0
|
||||
|
||||
# 5. 输出绩效报告
|
||||
print_performance(result_df, signals, config)
|
||||
|
||||
# 6. 年度收益统计
|
||||
print_yearly_returns(result_df)
|
||||
|
||||
# 7. 生成图表
|
||||
save_chart(result_df, config)
|
||||
|
||||
return result_df
|
||||
|
||||
|
||||
# ==================== 绩效报告 ====================
|
||||
def print_performance(result_df: pd.DataFrame, signals: list, config: dict):
|
||||
"""打印绩效报告"""
|
||||
nav = result_df['nav']
|
||||
total_return = nav.iloc[-1] / nav.iloc[0] - 1
|
||||
|
||||
# 年化收益
|
||||
days = (result_df.index[-1] - result_df.index[0]).days
|
||||
cagr = (1 + total_return) ** (365 / days) - 1 if days > 0 else 0
|
||||
|
||||
# 夏普比率
|
||||
daily_rets = result_df['daily_return']
|
||||
sharpe = daily_rets.mean() / daily_rets.std() * np.sqrt(252) if daily_rets.std() > 0 else 0
|
||||
|
||||
# 最大回撤
|
||||
peak = nav.cummax()
|
||||
drawdown = (nav - peak) / peak
|
||||
max_dd = drawdown.min()
|
||||
dd_end = drawdown.idxmin()
|
||||
dd_start = nav[:dd_end].idxmax()
|
||||
|
||||
# 日胜率
|
||||
win_rate = (daily_rets > 0).sum() / (daily_rets != 0).sum() if (daily_rets != 0).sum() > 0 else 0
|
||||
|
||||
# 基准收益
|
||||
bench_return = result_df['bench_return'].iloc[-1] - 1
|
||||
bench_cagr = (1 + bench_return) ** (365 / days) - 1 if days > 0 else 0
|
||||
|
||||
# 调仓次数
|
||||
n_trades = len(signals)
|
||||
years = days / 365
|
||||
|
||||
# Calmar比率
|
||||
calmar = cagr / abs(max_dd) if max_dd != 0 else 0
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" 绩效评估报告")
|
||||
print(f"{'='*70}")
|
||||
print(f" 回测区间: {result_df.index[0].strftime('%Y-%m-%d')} ~ {result_df.index[-1].strftime('%Y-%m-%d')}")
|
||||
print(f" 交易天数: {len(result_df)}")
|
||||
print(f"{'─'*70}")
|
||||
print(f" {'指标':<30s} {'动量策略':>12s} {'基准(沪深300)':>14s}")
|
||||
print(f"{'─'*70}")
|
||||
print(f" {'累计收益':<28s} {total_return:>11.2%} {bench_return:>13.2%}")
|
||||
print(f" {'CAGR(年化)':<27s} {cagr:>11.2%} {bench_cagr:>13.2%}")
|
||||
print(f" {'年化夏普比率':<26s} {sharpe:>11.2f} {'--':>13s}")
|
||||
print(f" {'最大回撤':<28s} {max_dd:>11.2%} {'--':>13s}")
|
||||
print(f" {'Calmar比率':<27s} {calmar:>11.2f} {'--':>13s}")
|
||||
print(f" {'日胜率':<28s} {win_rate:>11.2%} {'--':>13s}")
|
||||
print(f" {'调仓次数':<28s} {n_trades:>9d}次 {'--':>13s}")
|
||||
if years > 0:
|
||||
print(f" {'年均调仓':<28s} {n_trades/years:>9.1f}次 {'--':>13s}")
|
||||
print(f" {'最大回撤区间':<26s} {dd_start.strftime('%Y-%m-%d')} ~ {dd_end.strftime('%Y-%m-%d')}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# 最新持仓信号
|
||||
last_row = result_df.iloc[-1]
|
||||
print(f"\n 最新持仓: {last_row['holding']}", end="")
|
||||
if last_row['holding'] != '空仓':
|
||||
pool = config['etf_pool'] if isinstance(config['etf_pool'], dict) else {}
|
||||
name = pool.get(last_row['holding'], '')
|
||||
print(f" ({name})", end="")
|
||||
print(f"\n 最新净值: {last_row['nav']:.4f}")
|
||||
|
||||
|
||||
# ==================== 年度收益统计 ====================
|
||||
def print_yearly_returns(result_df: pd.DataFrame):
|
||||
"""按年统计收益"""
|
||||
nav = result_df['nav']
|
||||
bench = result_df['bench_return']
|
||||
|
||||
# 按年分组
|
||||
yearly_data = []
|
||||
for year, group in result_df.groupby(result_df.index.year):
|
||||
year_nav = group['nav']
|
||||
year_ret = year_nav.iloc[-1] / year_nav.iloc[0] - 1
|
||||
|
||||
year_bench = group['bench_return']
|
||||
bench_ret = year_bench.iloc[-1] / year_bench.iloc[0] - 1
|
||||
|
||||
# 年内最大回撤
|
||||
peak = year_nav.cummax()
|
||||
dd = (year_nav - peak) / peak
|
||||
max_dd = dd.min()
|
||||
|
||||
# 年内夏普
|
||||
daily_rets = group['daily_return']
|
||||
sharpe = daily_rets.mean() / daily_rets.std() * np.sqrt(252) if daily_rets.std() > 0 else 0
|
||||
|
||||
# 超额收益
|
||||
excess = year_ret - bench_ret
|
||||
|
||||
yearly_data.append({
|
||||
'year': year,
|
||||
'return': year_ret,
|
||||
'bench_return': bench_ret,
|
||||
'excess': excess,
|
||||
'max_dd': max_dd,
|
||||
'sharpe': sharpe,
|
||||
'trade_days': len(group),
|
||||
})
|
||||
|
||||
print(f"\n{'='*90}")
|
||||
print(f" 年度收益统计")
|
||||
print(f"{'='*90}")
|
||||
print(f" {'年份':<6s} {'策略收益':>10s} {'基准收益':>10s} {'超额收益':>10s} {'最大回撤':>10s} {'夏普比率':>10s} {'交易天数':>10s}")
|
||||
print(f"{'─'*90}")
|
||||
|
||||
for d in yearly_data:
|
||||
print(f" {d['year']:<6d} {d['return']:>9.2%} {d['bench_return']:>9.2%} {d['excess']:>9.2%} {d['max_dd']:>9.2%} {d['sharpe']:>9.2f} {d['trade_days']:>8d}")
|
||||
|
||||
print(f"{'─'*90}")
|
||||
|
||||
# 汇总
|
||||
total_ret = nav.iloc[-1] / nav.iloc[0] - 1
|
||||
total_bench = bench.iloc[-1] / bench.iloc[0] - 1
|
||||
win_years = sum(1 for d in yearly_data if d['return'] > 0)
|
||||
beat_years = sum(1 for d in yearly_data if d['excess'] > 0)
|
||||
total_years = len(yearly_data)
|
||||
|
||||
print(f" {'合计':<6s} {total_ret:>9.2%} {total_bench:>9.2%} {total_ret - total_bench:>9.2%}")
|
||||
print(f" 盈利年份: {win_years}/{total_years} | 跑赢基准年份: {beat_years}/{total_years}")
|
||||
print(f"{'='*90}")
|
||||
|
||||
|
||||
# ==================== 图表生成 ====================
|
||||
def save_chart(result_df: pd.DataFrame, config: dict):
|
||||
"""生成净值曲线图"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
matplotlib.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans']
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), height_ratios=[3, 1],
|
||||
gridspec_kw={'hspace': 0.3})
|
||||
|
||||
# 净值曲线
|
||||
ax1.plot(result_df.index, result_df['nav'], label='动量策略', linewidth=1.5, color='#e74c3c')
|
||||
ax1.plot(result_df.index, result_df['bench_return'], label='沪深300', linewidth=1, color='#95a5a6')
|
||||
ax1.set_title('ETF动量轮动策略 净值曲线', fontsize=14, fontweight='bold')
|
||||
ax1.legend(loc='upper left')
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.set_ylabel('净值')
|
||||
|
||||
# 回撤曲线
|
||||
peak = result_df['nav'].cummax()
|
||||
drawdown = (result_df['nav'] - peak) / peak
|
||||
ax2.fill_between(result_df.index, drawdown, 0, alpha=0.4, color='#e74c3c')
|
||||
ax2.set_title('回撤', fontsize=12)
|
||||
ax2.set_ylabel('回撤')
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
chart_path = Path(__file__).parent / 'results' / 'momentum_chart.png'
|
||||
chart_path.parent.mkdir(exist_ok=True)
|
||||
fig.savefig(chart_path, dpi=150, bbox_inches='tight')
|
||||
plt.close(fig)
|
||||
print(f"\n报告图表已保存: {chart_path}")
|
||||
except Exception as e:
|
||||
print(f"\n图表生成失败: {e}")
|
||||
|
||||
|
||||
# ==================== 主入口 ====================
|
||||
if __name__ == "__main__":
|
||||
run_backtest(CONFIG)
|
||||
Reference in New Issue
Block a user