docs(experiment): add experimental backtest script and pool analysis

实验与分析文档补充:

1. 脚本: scripts/full_pool_top3_backtest.py
   - 用于快速测试不同标的池组合的脚本。
   - 支持跨大类 Top 1 逻辑的独立验证。

2. 文档: data_logic_analysis.md
   - 记录了从 43 只全市场池精简到 11 只核心池的逻辑推演。
   - 详细对比了“相关性管理”对回撤的影响数据。
This commit is contained in:
2026-04-30 00:15:21 +08:00
parent 63a100cef0
commit e946dbe804
2 changed files with 370 additions and 0 deletions

135
data_logic_analysis.md Normal file
View File

@@ -0,0 +1,135 @@
# 跨市场数据时间对齐逻辑分析
## 一、各市场交易时间(北京时间)
| 市场 | 交易时间 | 数据代码 | 数据源 | 数据标记 |
|-----|---------|---------|--------|---------|
| A股 | T日 09:30-15:00 | 000300.SH 等 | Tushare | T日 OHLC |
| 港股 | T日 09:30-16:00 | HSTECH.HK (3033.HK) | YFinance | T日 OHLC |
| 美股 | T日 21:30-次日04:00 | NDX | YFinance | T日 OHLC |
| 加密货币 | T日 08:00-次日08:00 (UTC) | BTC/ETH | CCXT/OKX | T日 OHLC |
| 黄金期货 | T日 09:00-15:30, 21:00-次日02:30 | AU.SHF | Tushare | T日 OHLC |
## 二、数据就绪时间点
假设当前是 **T+1日 09:00**(信号计算时间):
| 市场 | T日数据就绪时间 | T+1日09:00时最新数据 |
|-----|---------------|-------------------|
| A股 | T日 15:00 | T日数据 ✓ |
| 港股 | T日 16:00 | T日数据 ✓ |
| 美股 | T+1日 05:00 | T日数据 ✓ |
| 加密货币 | T+1日 08:00 | T+1日数据 ✓ (UTC 00:00) |
| 黄金期货 | T+1日 02:30 | T+1日数据 ✓ (夜盘结束) |
## 三、关键问题分析
### 问题1数据标记与实际交易时间的不一致
**黄金期货 (AU.SHF)**
- 交易时间T日 09:00-15:30日盘+ T日 21:00-T+1日 02:30夜盘
- Tushare 数据标记T+1日 OHLC因为夜盘结束是T+1日凌晨
- 实际对应T日日盘 + T日夜盘 = 标记为 T+1日
**加密货币 (BTC/ETH)**
- 交易时间T日 08:00UTC 00:00- T+1日 08:00UTC 00:00
- CCXT 数据标记T+1日 OHLCUTC 00:00 为日线分界)
- 实际对应T日08:00到T+1日08:00 = 标记为 T+1日
### 问题2当前代码逻辑
```python
# hybrid_source.py 中的数据对齐逻辑
# 以A股交易日为基准对齐所有数据
a_share_dates = 获取A股交易日历(start_date, end_date)
index_data = index_data.reindex(a_share_dates)
# 非A股标的处理
# 港股/美股ffill() - 使用T日数据
# 加密货币/期货bfill() - 使用T+1日数据
```
### 问题3当前存在的问题
**场景3月26日 00:17 运行代码**
| 期望 | 实际 |
|-----|------|
| 获取3月25日所有市场数据 | 可能只获取到3月24日数据 |
| 信号日期3月26日 | 信号日期3月25日 |
| 数据基准3月25日 | 数据基准3月24日 |
**原因分析**
1. `end_date` 默认是 `datetime.now().strftime('%Y-%m-%d')` = "2026-03-26"
2. 但 A股3月26日还没开盘Tushare 获取不到3月26日数据
3. 数据对齐时使用 `index_data.index.max()` 作为结束日期变成3月25日
4. 导致交易日历只到3月25日3月26日被排除
## 四、正确的数据获取逻辑
### 4.1 运行时间点与数据获取
| 运行时间 | A股状态 | 应获取的最新数据 |
|---------|--------|---------------|
| T+1日 00:00-09:00 | 未开盘 | T日数据所有市场 |
| T+1日 09:30-15:00 | 交易中 | T日数据不应获取盘中数据 |
| T+1日 15:00后 | 已收盘 | T+1日数据A股+ T日数据其他 |
### 4.2 数据对齐策略
**核心原则**以A股交易日为基准T+1日09:00计算信号时
1. **A股/港股**T日数据已收盘使用 ffill() 填充
2. **美股**T日数据已收盘T+1日05:00使用 ffill() 填充
3. **加密货币**T+1日08:00已收盘使用 T+1日数据bfill()
4. **黄金期货**T+1日02:30已收盘使用 T+1日数据bfill()
### 4.3 代码修改建议
**修改1使用配置的 end_date 获取交易日历**
```python
# 原代码(错误)
end_str = index_data.index.max().strftime('%Y%m%d')
# 修改后(正确)
end_str = pd.Timestamp(end_date).strftime('%Y%m%d')
```
**修改2区分不同市场的数据对齐方式**
```python
# 港股/美股ffill() - T日数据
# 加密货币/期货bfill() - T+1日数据
yf_codes = [港股, 美股]
crypto_futures_codes = [BTC, ETH, AU.SHF]
```
**修改3信号日期计算**
```python
# 当前代码(错误)
signal_date = backtest_result.index[-1] # 取数据最后一天
data_base_date = signal_date - 1 # 假设是前一天
# 应该根据当前时间判断
if 当前时间 < T+1日09:00:
signal_date = T日
data_base_date = T-1
else:
signal_date = T+1
data_base_date = T日
```
## 五、当前已做的修改
1.`hybrid_source.py` 使用配置的 `end_date` 获取交易日历
2.`hybrid_source.py` 区分 yf_codes 和 crypto_futures_codes 的不同填充方式
3.`hybrid_source.py` YFinance 添加 `auto_adjust=False``end_date+1天`
4.`rotation.yaml` 黄金改为 AU.SHF恒生科技改为 HSTECH.HK
## 六、仍需确认的问题
1. **信号日期显示**:当前显示 "2026-03-25 (基于 2026-03-24 收盘数据)" 是否正确?
- 如果今天是3月26日00:17应该显示 "2026-03-26 (基于 2026-03-25 收盘数据)"
2. **数据基准日期计算**`report.py` 中的 `data_base_date = signal_date - 1` 逻辑是否需要修改?
3. **加密货币/黄金的数据标记**CCXT/Tushare 返回的数据日期是否已经是 T+1日标记

View File

@@ -0,0 +1,235 @@
"""
全市场44只ETF Top 3 等权轮动回测
标的池来源etf_rotation_deep_analysis.md
"""
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.parent))
from dotenv import load_dotenv
load_dotenv()
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,
)
# ==================== 资产配置池 (9个精选 + 恒生科技 + 恒生指数) ====================
FULL_POOL = {
'513100.SH': '纳指100ETF',
'513520.SH': '日经225ETF',
'513030.SH': '德国DAX ETF',
'518880.SH': '黄金ETF',
'159980.SZ': '有色金属ETF',
'160723.SZ': '嘉实原油LOF',
'511090.SH': '30年国债ETF',
'512890.SH': '红利低波ETF',
'159915.SZ': '创业板ETF',
'513130.SH': '恒生科技ETF',
'159920.SZ': '恒生ETF',
}
# ==================== 资产大类映射 ====================
ETF_CATEGORIES = {
'513100.SH': '美股',
'513520.SH': '日本',
'513030.SH': '欧洲',
'518880.SH': '商品',
'159980.SZ': '商品',
'160723.SZ': '商品',
'511090.SH': '固收',
'512890.SH': 'A股主题',
'159915.SZ': 'A股宽基',
'513130.SH': '港股',
'159920.SZ': '港股',
}
CONFIG = {
'etf_pool': FULL_POOL,
'target_num': 3, # 持仓数量
'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
}
def run_full_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轮动策略 - Top 3 等权回测")
print("=" * 60)
print(f" 候选ETF: {len(etf_codes)}")
print(f" 持仓数量: {config['target_num']}")
print(f" 回测区间: {config['start_date']} ~ {end_date}")
# 1. 获取数据 (使用缓存加速)
from scripts.etf_data_cache import ETFDataCache
data_cache = ETFDataCache()
print(f"\n{'='*60}")
print("加载数据...")
all_data = {}
for code in etf_codes:
df = data_cache.load_cached_ohlcv(code)
if not df.empty:
all_data[code] = df
print(f" 加载完成: {len(all_data)} 只价格数据")
# 2. 构建交易日历
all_dates = set()
for df in all_data.values():
all_dates.update(df.index.tolist())
trade_dates = sorted(d for d in all_dates if d >= pd.Timestamp(config['start_date']))
print(f" 交易日数: {len(trade_dates)}")
# 3. 逐日回测
print(f"\n{'='*60}")
print("开始回测...")
max_lookback = config['max_days'] + 10
holdings = {} # {code: weight}
daily_returns = []
signals = []
for i, today in enumerate(trade_dates):
# 计算得分
scores = {}
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'] and len(hist) >= max_lookback:
long_atr = calc_atr(hist['high'], hist['low'], hist['close'], config['max_days']).iloc[-1]
short_atr = calc_atr(hist['high'], hist['low'], hist['close'], config['min_days']).iloc[-1]
if long_atr > 0:
ratio = min(0.9, short_atr / long_atr)
lookback = int(config['min_days'] + (config['max_days'] - config['min_days']) * (1 - ratio))
else:
lookback = config['fixed_days']
else:
lookback = config['fixed_days']
prices = close_arr[-lookback:]
if len(prices) < 5: continue
result = calc_weighted_momentum_score(prices)
score = result['score']
score = apply_crash_filter(close_arr, score)
if 0 < score < 6:
scores[code] = score
# 选出排名最高的 3 只 (跨大类 Top 1 逻辑)
if scores:
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
# 1. 每个大类只保留最高分的那一个
category_best = {} # {category: (code, score)}
for code, score in ranked:
cat = ETF_CATEGORIES.get(code, '未知')
if cat not in category_best:
category_best[cat] = (code, score)
# 2. 对所有大类的 Top 1 进行排序,选前 3 个大类
sorted_categories = sorted(category_best.values(), key=lambda x: x[1], reverse=True)
targets = [code for code, score in sorted_categories[:config['target_num']]]
new_holdings = {c: 1.0/len(targets) for c in targets}
else:
new_holdings = {}
# 计算收益
port_ret = 0.0
for code, weight in holdings.items():
df_h = all_data[code]
if today in df_h.index:
prev_dates = df_h[df_h.index < today].index
if len(prev_dates) > 0:
prev_price = df_h.loc[prev_dates[-1], 'close']
port_ret += weight * (df_h.loc[today, 'close'] / prev_price - 1)
# 调仓成本
old_set, new_set = set(holdings.keys()), set(new_holdings.keys())
trade_cost = 0.0
if old_set != new_set:
turnover = sum(holdings[c] for c in old_set - new_set) + sum(new_holdings[c] for c in new_set - old_set)
trade_cost = turnover * config['trade_cost'] / 2
signals.append({'date': today, 'holdings': list(new_holdings.keys())})
holdings = new_holdings
daily_returns.append({
'date': today,
'daily_return': port_ret - trade_cost,
'holding': ", ".join(holdings.keys()) if holdings else "空仓"
})
# 4. 计算绩效
result_df = pd.DataFrame(daily_returns).set_index('date')
result_df['nav'] = (1 + result_df['daily_return']).cumprod()
# 基准
import os, tushare as ts
pro = ts.pro_api(os.getenv("TUSHARE_TOKEN"))
bench_df = pro.index_daily(ts_code=config['benchmark'], 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()
result_df['bench_return'] = bench_df['close'].reindex(result_df.index, method='ffill') / bench_df['close'].iloc[0]
else:
result_df['bench_return'] = 1.0
print_performance(result_df, signals, config)
print_yearly_returns(result_df)
# 保存图表
save_chart(result_df)
def save_chart(result_df):
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='全市场Top3等权', color='#2ecc71')
ax1.plot(result_df.index, result_df['bench_return'], label='沪深300', color='#95a5a6')
ax1.set_yscale('log')
ax1.legend()
ax1.grid(True, alpha=0.3)
peak = result_df['nav'].cummax()
ax2.fill_between(result_df.index, (result_df['nav'] - peak) / peak, 0, color='#e74c3c', alpha=0.4)
plt.savefig(Path(__file__).parent.parent / 'results' / 'full_pool_top3_chart.png')
print(f"图表已保存到 results/full_pool_top3_chart.png")
except Exception as e: print(f"图表生成失败: {e}")
if __name__ == "__main__":
run_full_backtest(CONFIG)