Compare commits
59 Commits
6a86a27108
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b698857e49 | |||
| a600a71aa3 | |||
| 3b0688930d | |||
| 09ecac9e56 | |||
| cabfee20b0 | |||
| d657f8506b | |||
| 6e7087a543 | |||
| 8c3ae2269a | |||
| 49b623931b | |||
| fe73c0f199 | |||
| e2038ae722 | |||
| 5c4aeb75d2 | |||
| 710f3d9d68 | |||
| 0c19e45300 | |||
| e4bb570e5f | |||
| 8b7bcf206a | |||
| 844e609ff7 | |||
| c32ce72579 | |||
| 4736b64eca | |||
| d5f35c0273 | |||
| 13c69c2a0b | |||
| 6a5ae8efbf | |||
| d898ba0fd5 | |||
| f370caeff9 | |||
| 06df8767b9 | |||
| 7b229ced14 | |||
| ca933e43e4 | |||
| 8d8fd71149 | |||
| 4d9e12886f | |||
| eb3c82f05b | |||
| 4973a9a2a5 | |||
| 44588d5026 | |||
| 921f84cb6a | |||
| aff04318b1 | |||
| 40853745c6 | |||
| b564a47a1b | |||
| 04b858ff09 | |||
| f3ba6eb799 | |||
| 55e4cbf108 | |||
| c905230a40 | |||
| d700bc1dfd | |||
| 4f9e0231bd | |||
| 972bbbe706 | |||
| 524fa5f513 | |||
| d1139a9ee9 | |||
| a2b4289080 | |||
| e29f57749d | |||
| 81045f9d85 | |||
| 74f0eebef0 | |||
| 361b82fa4a | |||
| a47af0f0eb | |||
| 07d6f1451c | |||
| 4791d3cf40 | |||
| 5e11b6b690 | |||
| 19f1c63981 | |||
| 6d0b928894 | |||
| 451ffa33d2 | |||
| 3b0208d7d3 | |||
| ee2453f65e |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -182,20 +182,16 @@ test/
|
||||
|
||||
# Cache and generated files
|
||||
data_cache/
|
||||
*.html
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.svg
|
||||
|
||||
|
||||
# Report files (keep examples)
|
||||
report*.csv
|
||||
report*.html
|
||||
report*.png
|
||||
!example_*.csv
|
||||
!example_*.html
|
||||
!example_*.png
|
||||
|
||||
# Downloaded articles
|
||||
|
||||
@@ -24,4 +24,4 @@ EXPOSE 80
|
||||
CMD ["python", "datasource/flask_server.py", "--host", "0.0.0.0"]
|
||||
|
||||
# 运行定时任务调度器(如需使用Flask服务,取消上面注释并注释掉下面)
|
||||
# CMD ["python", "scripts/daily_scheduler.py", "--time", "09:00"]
|
||||
# CMD ["python", "rotation/daily_scheduler.py", "--time", "09:00"]
|
||||
@@ -72,7 +72,18 @@ def run_backtest():
|
||||
print("=" * 70)
|
||||
|
||||
strategy = GlobalRotationStrategy(config)
|
||||
result = strategy.run()
|
||||
|
||||
# 运行回测并导出 detail
|
||||
output_dir = project_root / "framework_v2" / "results"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
detail_path = output_dir / "backtest_detail_v2.json"
|
||||
print(f"\n导出 detail JSON: {detail_path}")
|
||||
|
||||
result = strategy.run(
|
||||
export_detail=True,
|
||||
detail_path=str(detail_path)
|
||||
)
|
||||
|
||||
# 打印结果
|
||||
print("\n" + "=" * 70)
|
||||
336
archive/framework_v2/scripts/measure_gap_impact.py
Normal file
336
archive/framework_v2/scripts/measure_gap_impact.py
Normal file
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测算 ETF 跳空收益(Gap Return)对策略的影响
|
||||
|
||||
测算目标:
|
||||
1. 量化各 ETF 的跳空特征(幅度、频率、波动率)
|
||||
2. 分析跳空对策略收益的实际影响
|
||||
3. 判断是否需要修改收益计算逻辑
|
||||
|
||||
用法:
|
||||
python framework_v2/scripts/measure_gap_impact.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from framework_v2.config import load_config
|
||||
from framework_v2.strategies.rotation.rotation import GlobalRotationStrategy
|
||||
from framework_v2.shared.data import FlaskAPIFetcher
|
||||
|
||||
|
||||
def fetch_etf_data_with_ohlc(codes, start, end):
|
||||
"""获取 ETF 的 OHLC 数据(hfq)"""
|
||||
fetcher = FlaskAPIFetcher()
|
||||
|
||||
print(f"\n[数据获取] 获取 {len(codes)} 只 ETF 的 OHLC 数据(hfq)...")
|
||||
data = {}
|
||||
|
||||
for i, code in enumerate(codes, 1):
|
||||
print(f" [{i}/{len(codes)}] {code}...")
|
||||
df = fetcher._source.fetch(
|
||||
code=code,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
adj='hfq',
|
||||
asset_type='china_etf'
|
||||
)
|
||||
if df is not None:
|
||||
data[code] = df
|
||||
print(f" ✓ {len(df)} 条")
|
||||
else:
|
||||
print(f" ✗ 获取失败")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def calculate_gap_statistics(etf_data):
|
||||
"""计算各 ETF 的跳空统计"""
|
||||
print("\n" + "=" * 80)
|
||||
print(" 跳空收益统计分析")
|
||||
print("=" * 80)
|
||||
|
||||
stats_list = []
|
||||
|
||||
for code, df in etf_data.items():
|
||||
# 确保按日期排序
|
||||
df = df.sort_index()
|
||||
|
||||
# 计算收益率
|
||||
prev_close = df['close'].shift(1)
|
||||
|
||||
# 跳空收益率:(T_open - T-1_close) / T-1_close
|
||||
gap_return = (df['open'] - prev_close) / prev_close
|
||||
|
||||
# 日内收益率:(T_close - T_open) / T_open
|
||||
intraday_return = (df['close'] - df['open']) / df['open']
|
||||
|
||||
# 验证:总收益率 ≈ 跳空 + 日内
|
||||
total_return = df['close'].pct_change()
|
||||
|
||||
# 统计指标
|
||||
stats = {
|
||||
'ETF': code,
|
||||
'数据天数': len(df),
|
||||
'平均跳空(%)': gap_return.mean() * 100,
|
||||
'跳空波动率(%)': gap_return.std() * 100,
|
||||
'向上跳空比例(%)': (gap_return > 0.0001).sum() / len(gap_return) * 100,
|
||||
'向下跳空比例(%)': (gap_return < -0.0001).sum() / len(gap_return) * 100,
|
||||
'最大向上跳空(%)': gap_return.max() * 100,
|
||||
'最大向下跳空(%)': gap_return.min() * 100,
|
||||
'平均日内收益(%)': intraday_return.mean() * 100,
|
||||
'日内波动率(%)': intraday_return.std() * 100,
|
||||
'跳空>1%天数': (gap_return.abs() > 0.01).sum(),
|
||||
'跳空>2%天数': (gap_return.abs() > 0.02).sum(),
|
||||
}
|
||||
|
||||
stats_list.append(stats)
|
||||
|
||||
# 转换为 DataFrame
|
||||
stats_df = pd.DataFrame(stats_list)
|
||||
|
||||
# 打印统计表格
|
||||
print("\n各 ETF 跳空收益统计:")
|
||||
print("-" * 80)
|
||||
for _, row in stats_df.iterrows():
|
||||
print(f"\n{row['ETF']}:")
|
||||
print(f" 数据天数: {row['数据天数']}")
|
||||
print(f" 平均跳空: {row['平均跳空(%)']:+.3f}% (波动率: {row['跳空波动率(%)']:.2f}%)")
|
||||
print(f" 向上跳空: {row['向上跳空比例(%)']:.1f}% 向下: {row['向下跳空比例(%)']:.1f}%")
|
||||
print(f" 最大跳空: +{row['最大向上跳空(%)']:.2f}% / {row['最大向下跳空(%)']:.2f}%")
|
||||
print(f" 跳空>1%: {row['跳空>1%天数']}天 >2%: {row['跳空>2%天数']}天")
|
||||
print(f" 平均日内收益: {row['平均日内收益(%)']:+.3f}%")
|
||||
|
||||
return stats_df
|
||||
|
||||
|
||||
def analyze_strategy_gap_impact(strategy, etf_data):
|
||||
"""分析跳空对策略的实际影响"""
|
||||
print("\n" + "=" * 80)
|
||||
print(" 策略跳空影响分析")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. 获取策略持仓数据
|
||||
print("\n[1] 获取策略持仓数据...")
|
||||
|
||||
# 运行策略获取信号和仓位
|
||||
from datetime import date
|
||||
config = strategy.config
|
||||
start = config.backtest.start_date
|
||||
end = config.backtest.end_date
|
||||
if end is None:
|
||||
end = date.today().strftime('%Y-%m-%d')
|
||||
|
||||
# 运行策略(不导出 JSON)
|
||||
result = strategy.run(export_detail=False)
|
||||
|
||||
positions = result['positions']
|
||||
trading_calendar = positions.index
|
||||
|
||||
# 2. 计算新旧两种收益
|
||||
print("\n[2] 计算两种收益方法...")
|
||||
|
||||
signal_to_trade = config.asset_pools.get_signal_to_trade_mapping()
|
||||
|
||||
# 准备数据
|
||||
close_dict = {}
|
||||
open_dict = {}
|
||||
|
||||
for signal_code, trade_code in signal_to_trade.items():
|
||||
if trade_code in etf_data:
|
||||
df = etf_data[trade_code]
|
||||
# 对齐到 A 股日历
|
||||
close_dict[signal_code] = df['close'].reindex(trading_calendar, method='ffill')
|
||||
open_dict[signal_code] = df['open'].reindex(trading_calendar, method='ffill')
|
||||
|
||||
close_df = pd.DataFrame(close_dict)
|
||||
open_df = pd.DataFrame(open_dict)
|
||||
|
||||
# 方法 1:旧方法(close-to-close)
|
||||
positions_delayed = positions.shift(1).fillna(0)
|
||||
old_returns_df = close_df.pct_change()
|
||||
old_strategy_returns = (positions_delayed * old_returns_df).sum(axis=1)
|
||||
|
||||
# 方法 2:新方法(分段计算)
|
||||
prev_positions = positions_delayed.shift(1).fillna(0)
|
||||
curr_positions = positions_delayed
|
||||
|
||||
# 检测状态
|
||||
is_buying = (prev_positions == 0) & (curr_positions > 0)
|
||||
is_holding = (prev_positions > 0) & (curr_positions > 0)
|
||||
is_selling = (prev_positions > 0) & (curr_positions == 0)
|
||||
|
||||
# 计算各类收益率
|
||||
buy_returns = (close_df - open_df) / open_df # open-to-close
|
||||
hold_returns = close_df.pct_change() # close-to-close
|
||||
sell_returns = (open_df - close_df.shift(1)) / close_df.shift(1) # close-to-open
|
||||
|
||||
# 组合收益率
|
||||
new_returns_df = pd.DataFrame(0.0, index=close_df.index, columns=close_df.columns)
|
||||
new_returns_df[is_buying] = buy_returns[is_buying]
|
||||
new_returns_df[is_holding] = hold_returns[is_holding]
|
||||
new_returns_df[is_selling] = sell_returns[is_selling]
|
||||
|
||||
new_strategy_returns = (curr_positions * new_returns_df).sum(axis=1)
|
||||
|
||||
# 3. 计算净值曲线和 KPI
|
||||
print("\n[3] 计算净值曲线和 KPI 对比...")
|
||||
|
||||
old_equity = (1 + old_strategy_returns).cumprod()
|
||||
new_equity = (1 + new_strategy_returns).cumprod()
|
||||
|
||||
def calc_kpi(returns, equity, name):
|
||||
total_return = equity.iloc[-1] / equity.iloc[0] - 1
|
||||
n_days = len(returns)
|
||||
annual_return = (1 + total_return) ** (252 / n_days) - 1
|
||||
|
||||
cummax = equity.cummax()
|
||||
drawdown = (equity - cummax) / cummax
|
||||
max_drawdown = drawdown.min()
|
||||
|
||||
sharpe = returns.mean() / returns.std() * np.sqrt(252) if returns.std() > 0 else 0
|
||||
|
||||
print(f"\n {name}:")
|
||||
print(f" 总收益: {total_return * 100:.2f}%")
|
||||
print(f" 年化收益: {annual_return * 100:.2f}%")
|
||||
print(f" 最大回撤: {max_drawdown * 100:.2f}%")
|
||||
print(f" 夏普比率: {sharpe:.2f}")
|
||||
print(f" 交易天数: {n_days}")
|
||||
|
||||
return {
|
||||
'总收益': total_return,
|
||||
'年化收益': annual_return,
|
||||
'最大回撤': max_drawdown,
|
||||
'夏普比率': sharpe,
|
||||
}
|
||||
|
||||
old_kpi = calc_kpi(old_strategy_returns, old_equity, "旧方法(close-to-close)")
|
||||
new_kpi = calc_kpi(new_strategy_returns, new_equity, "新方法(分段计算)")
|
||||
|
||||
# 4. 差异分析
|
||||
print("\n" + "=" * 80)
|
||||
print(" 差异对比")
|
||||
print("=" * 80)
|
||||
|
||||
print(f"\n {'指标':<12} {'旧方法':>12} {'新方法':>12} {'差异':>12}")
|
||||
print(f" {'-'*12} {'-'*12} {'-'*12} {'-'*12}")
|
||||
|
||||
for key in ['总收益', '年化收益', '最大回撤', '夏普比率']:
|
||||
old_val = old_kpi[key]
|
||||
new_val = new_kpi[key]
|
||||
diff = new_val - old_val
|
||||
|
||||
if key == '夏普比率':
|
||||
print(f" {key:<12} {old_val:>12.2f} {new_val:>12.2f} {diff:>+12.2f}")
|
||||
else:
|
||||
print(f" {key:<12} {old_val*100:>11.2f}% {new_val*100:>11.2f}% {diff*100:>+11.2f}%")
|
||||
|
||||
# 5. 调仓日分析
|
||||
print("\n" + "=" * 80)
|
||||
print(" 调仓日跳空分析")
|
||||
print("=" * 80)
|
||||
|
||||
# 识别调仓日
|
||||
position_changes = (positions != positions.shift(1)).any(axis=1)
|
||||
rebalance_dates = positions[position_changes].index
|
||||
|
||||
print(f"\n 总调仓次数: {len(rebalance_dates)}")
|
||||
|
||||
# 分析调仓日的跳空
|
||||
gap_returns_all = []
|
||||
for date in rebalance_dates:
|
||||
if date in close_df.index:
|
||||
# 计算该日的平均跳空(所有持仓 ETF)
|
||||
pos = positions.loc[date]
|
||||
held_codes = pos[pos > 0].index
|
||||
|
||||
if len(held_codes) > 0:
|
||||
# 过滤掉不在 open_df 中的代码(如指数)
|
||||
held_codes = [c for c in held_codes if c in open_df.columns]
|
||||
if len(held_codes) == 0:
|
||||
continue
|
||||
|
||||
day_gap = open_df.loc[date][held_codes]
|
||||
prev_close = close_df.shift(1).loc[date][held_codes]
|
||||
gap = (day_gap - prev_close) / prev_close
|
||||
gap_returns_all.append(gap.mean())
|
||||
|
||||
if gap_returns_all:
|
||||
gap_series = pd.Series(gap_returns_all)
|
||||
print(f"\n 调仓日跳空统计:")
|
||||
print(f" 平均跳空: {gap_series.mean() * 100:+.3f}%")
|
||||
print(f" 跳空标准差: {gap_series.std() * 100:.2f}%")
|
||||
print(f" 最大向上跳空: {gap_series.max() * 100:+.2f}%")
|
||||
print(f" 最大向下跳空: {gap_series.min() * 100:+.2f}%")
|
||||
print(f" 向上跳空天数: {(gap_series > 0).sum()} ({(gap_series > 0).sum() / len(gap_series) * 100:.1f}%)")
|
||||
print(f" 向下跳空天数: {(gap_series < 0).sum()} ({(gap_series < 0).sum() / len(gap_series) * 100:.1f}%)")
|
||||
else:
|
||||
print(f"\n ⚠ 无法计算调仓日跳空(数据缺失)")
|
||||
|
||||
return old_kpi, new_kpi
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print(" ETF 跳空收益影响测算")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. 加载配置
|
||||
config_file = project_root / 'framework_v2' / 'strategies' / 'rotation' / 'config_simple.yaml'
|
||||
print(f"\n[1] 加载配置: {config_file}")
|
||||
config = load_config(str(config_file))
|
||||
|
||||
# 2. 获取 ETF 列表
|
||||
signal_to_trade = config.asset_pools.get_signal_to_trade_mapping()
|
||||
trade_codes = list(set(signal_to_trade.values()))
|
||||
# 过滤掉不是 ETF 的代码(如 931862.CSI)
|
||||
trade_codes = [c for c in trade_codes if not c.endswith('.CSI')]
|
||||
|
||||
print(f" ETF 数量: {len(trade_codes)}")
|
||||
|
||||
# 3. 获取数据
|
||||
from datetime import date
|
||||
start = config.backtest.start_date
|
||||
end = config.backtest.end_date
|
||||
if end is None:
|
||||
end = date.today().strftime('%Y-%m-%d')
|
||||
|
||||
etf_data = fetch_etf_data_with_ohlc(trade_codes, start, end)
|
||||
|
||||
# 4. 计算跳空统计
|
||||
stats_df = calculate_gap_statistics(etf_data)
|
||||
|
||||
# 5. 分析策略影响
|
||||
strategy = GlobalRotationStrategy(config)
|
||||
old_kpi, new_kpi = analyze_strategy_gap_impact(strategy, etf_data)
|
||||
|
||||
# 6. 结论
|
||||
print("\n" + "=" * 80)
|
||||
print(" 结论与建议")
|
||||
print("=" * 80)
|
||||
|
||||
annual_diff = new_kpi['年化收益'] - old_kpi['年化收益']
|
||||
|
||||
if abs(annual_diff) < 0.01: # 差异 < 1%
|
||||
print("\n ✓ 跳空影响较小(< 1%),可以继续使用 close-to-close 简化计算")
|
||||
elif abs(annual_diff) < 0.03: # 差异 1-3%
|
||||
print("\n ⚠ 跳空影响中等(1-3%),建议考虑使用分段计算提高精度")
|
||||
else: # 差异 > 3%
|
||||
print("\n ✗ 跳空影响显著(> 3%),强烈建议使用分段计算")
|
||||
|
||||
print(f"\n 当前年化: {old_kpi['年化收益'] * 100:.2f}%")
|
||||
print(f" 修正后年化: {new_kpi['年化收益'] * 100:.2f}%")
|
||||
print(f" 差异: {annual_diff * 100:+.2f}%")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
71
archive/framework_v2/scripts/verify_cum_return_fix.py
Normal file
71
archive/framework_v2/scripts/verify_cum_return_fix.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
验证 cum_return_idx 和 cum_return_etf 是否独立计算
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# 读取已有的 backtest_detail_v2.json
|
||||
detail_path = project_root / 'framework_v2' / 'results' / 'backtest_detail_v2.json'
|
||||
|
||||
if not detail_path.exists():
|
||||
print(f"❌ 文件不存在: {detail_path}")
|
||||
print("请先运行: python framework_v2/scripts/export_backtest_detail.py")
|
||||
sys.exit(1)
|
||||
|
||||
print("=" * 80)
|
||||
print(" 验证指数和 ETF 累计收益是否独立计算")
|
||||
print("=" * 80)
|
||||
|
||||
with open(detail_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 检查每日数据
|
||||
days = data['days']
|
||||
print(f"\n总天数: {len(days)}")
|
||||
|
||||
# 统计有差异的天数
|
||||
diff_count = 0
|
||||
same_count = 0
|
||||
total_checked = 0
|
||||
|
||||
for day in days[:100]: # 检查前 100 天
|
||||
date = day['date']
|
||||
assets = day.get('assets', {})
|
||||
|
||||
for code, asset in assets.items():
|
||||
if not asset.get('is_held'):
|
||||
continue
|
||||
|
||||
cum_etf = asset.get('cum_return_etf')
|
||||
cum_idx = asset.get('cum_return_idx')
|
||||
|
||||
if cum_etf is not None and cum_idx is not None:
|
||||
total_checked += 1
|
||||
|
||||
if abs(cum_etf - cum_idx) > 0.0001: # 差异超过 0.01%
|
||||
diff_count += 1
|
||||
if diff_count <= 5: # 只显示前 5 个示例
|
||||
print(f"\n✓ {date} - {code}:")
|
||||
print(f" ETF 累计收益: {cum_etf:.4f} ({cum_etf*100:.2f}%)")
|
||||
print(f" 指数累计收益: {cum_idx:.4f} ({cum_idx*100:.2f}%)")
|
||||
print(f" 差异: {abs(cum_etf - cum_idx)*100:.2f}%")
|
||||
else:
|
||||
same_count += 1
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"统计结果(前 100 天,持仓标的):")
|
||||
print(f" 总检查次数: {total_checked}")
|
||||
print(f" 有差异: {diff_count} ({diff_count/total_checked*100:.1f}%)")
|
||||
print(f" 相同: {same_count} ({same_count/total_checked*100:.1f}%)")
|
||||
|
||||
if diff_count > 0:
|
||||
print(f"\n✅ 修复成功!指数和 ETF 累计收益已独立计算")
|
||||
else:
|
||||
print(f"\n❌ 仍有问题:指数和 ETF 累计收益完全相同")
|
||||
print(" 需要重新生成 backtest_detail_v2.json")
|
||||
@@ -391,31 +391,55 @@ class GlobalRotationStrategy(StrategyBase):
|
||||
aligner = CrossMarketAligner(target_calendar=trading_calendar)
|
||||
|
||||
# 提取交易标的的收盘价,并对齐到 A 股日历
|
||||
print(" [对齐] 对齐 ETF 价格到 A 股日历...")
|
||||
close_dict = {}
|
||||
print(" [对齐] 构建可实现价格序列(模拟真实交易)...")
|
||||
executable_close_dict = {}
|
||||
|
||||
for signal_code, trade_code in signal_to_trade.items():
|
||||
if trade_code in data:
|
||||
# 提取收盘价
|
||||
close_series = data[trade_code]['close']
|
||||
# 使用 signal_code 作为键(与 positions 列名一致)
|
||||
close_dict[signal_code] = close_series
|
||||
# 提取开盘价和收盘价
|
||||
etf_df = data[trade_code]
|
||||
open_series = etf_df['open'].reindex(trading_calendar, method='ffill')
|
||||
close_series = etf_df['close'].reindex(trading_calendar, method='ffill')
|
||||
|
||||
# 默认使用收盘价
|
||||
exec_close = close_series.copy()
|
||||
|
||||
# 检测调仓日,调整价格以反映真实交易
|
||||
for i in range(1, len(trading_calendar)):
|
||||
date = trading_calendar[i]
|
||||
prev_date = trading_calendar[i-1]
|
||||
|
||||
# 获取仓位变化
|
||||
prev_pos = positions.loc[prev_date, signal_code] if signal_code in positions.columns else 0
|
||||
curr_pos = positions.loc[date, signal_code] if signal_code in positions.columns else 0
|
||||
|
||||
# 买入日:修改前一天价格为当日开盘价
|
||||
# 这样收益率 = (close[t] - open[t]) / open[t] = 日内收益
|
||||
if pd.isna(prev_pos) or prev_pos == 0:
|
||||
if pd.notna(curr_pos) and curr_pos > 0:
|
||||
exec_close.loc[prev_date] = open_series.loc[date]
|
||||
|
||||
# 卖出日:不需要修改(因为 positions[t]=0,不会计算收益)
|
||||
|
||||
executable_close_dict[signal_code] = exec_close
|
||||
else:
|
||||
print(f" 警告: {trade_code} 数据不存在,跳过")
|
||||
|
||||
# 使用 CrossMarketAligner 对齐多标的收益率
|
||||
# 内部逻辑:先 ffill 价格到 A 股日历,再计算收益率
|
||||
print(" [对齐] 计算收益率(先对齐价格,再计算)...")
|
||||
returns_df = aligner.align_multi_asset(close_dict)
|
||||
print(" [对齐] 计算收益率(使用可实现价格)...")
|
||||
returns_df = aligner.align_multi_asset(executable_close_dict)
|
||||
print(f" [对齐] 收益率数据: {len(returns_df)} 天, {len(returns_df.columns)} 个标的")
|
||||
|
||||
# 对齐 positions 到 A 股日历
|
||||
# 注意:必须先 reindex 再 ffill,因为 reindex(method='ffill') 不会填充已有的 NaN
|
||||
positions = positions.reindex(trading_calendar)
|
||||
positions = positions.ffill()
|
||||
# 卖出日不向前填充(保持 0)
|
||||
positions = positions.ffill().fillna(0)
|
||||
|
||||
# 计算策略收益(仓位加权,T+1 执行)
|
||||
positions_delayed = positions.shift(1).fillna(0)
|
||||
strategy_returns = (positions_delayed * returns_df).sum(axis=1)
|
||||
# 计算策略收益(仓位加权,无需延迟)
|
||||
# 因为 positions[t] 已表示 t 日的实际持仓,且价格已调整为可实现价格
|
||||
strategy_returns = (positions * returns_df).sum(axis=1)
|
||||
|
||||
# 扣除交易成本
|
||||
strategy_returns, rebalance_count = self._apply_trade_cost(
|
||||
@@ -649,6 +673,33 @@ class GlobalRotationStrategy(StrategyBase):
|
||||
index_return_dict = {}
|
||||
etf_return_dict = {}
|
||||
|
||||
# 构建 ETF 可实现价格序列(与回测一致)
|
||||
executable_etf_close = {}
|
||||
for signal_code, trade_code in signal_to_trade.items():
|
||||
if trade_code in self._data:
|
||||
etf_df = self._data[trade_code]
|
||||
open_series = etf_df['open'].reindex(trading_calendar, method='ffill')
|
||||
close_series = etf_df['close'].reindex(trading_calendar, method='ffill')
|
||||
|
||||
# 默认使用 close
|
||||
exec_close = close_series.copy()
|
||||
|
||||
# 检测调仓日,调整价格
|
||||
for i in range(1, len(trading_calendar)):
|
||||
date = trading_calendar[i]
|
||||
prev_date = trading_calendar[i-1]
|
||||
|
||||
# 获取仓位变化
|
||||
prev_pos = positions.loc[prev_date, signal_code] if signal_code in positions.columns else 0
|
||||
curr_pos = positions.loc[date, signal_code] if signal_code in positions.columns else 0
|
||||
|
||||
# 买入日:修改前一天价格为 open
|
||||
if pd.isna(prev_pos) or prev_pos == 0:
|
||||
if pd.notna(curr_pos) and curr_pos > 0:
|
||||
exec_close.loc[prev_date] = open_series.loc[date]
|
||||
|
||||
executable_etf_close[signal_code] = exec_close
|
||||
|
||||
for signal_code, trade_code in signal_to_trade.items():
|
||||
# 指数收益率
|
||||
if signal_code in index_close_dict:
|
||||
@@ -656,10 +707,10 @@ class GlobalRotationStrategy(StrategyBase):
|
||||
idx_return = idx_close.pct_change(fill_method=None).fillna(0)
|
||||
index_return_dict[signal_code] = idx_return
|
||||
|
||||
# ETF 收益率
|
||||
if signal_code in etf_close_dict:
|
||||
etf_close = etf_close_dict[signal_code].reindex(trading_calendar, method='ffill')
|
||||
etf_return = etf_close.pct_change(fill_method=None).fillna(0)
|
||||
# ETF 收益率(使用可实现价格)
|
||||
if signal_code in executable_etf_close:
|
||||
etf_exec = executable_etf_close[signal_code]
|
||||
etf_return = etf_exec.pct_change(fill_method=None).fillna(0)
|
||||
etf_return_dict[signal_code] = etf_return
|
||||
|
||||
# 对齐因子
|
||||
@@ -795,19 +846,29 @@ class GlobalRotationStrategy(StrategyBase):
|
||||
trading_days_held = len(trading_calendar[(trading_calendar >= entry_dt) & (trading_calendar <= date)])
|
||||
asset['holding_days'] = trading_days_held
|
||||
|
||||
# 累计收益
|
||||
# 累计收益(分别使用 ETF 和指数价格计算)
|
||||
if hs['entry_price'] and hs['entry_price'] > 0:
|
||||
# ETF 累计收益
|
||||
if code in etf_close_dict:
|
||||
cur = etf_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
|
||||
if cur and pd.notna(cur):
|
||||
cum_ret = float(cur) / hs['entry_price'] - 1
|
||||
asset['cum_return_etf'] = self._safe_val(cum_ret, 4)
|
||||
asset['cum_return_idx'] = self._safe_val(cum_ret, 4)
|
||||
etf_cur = etf_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
|
||||
if etf_cur and pd.notna(etf_cur):
|
||||
etf_cum_ret = float(etf_cur) / hs['entry_price'] - 1
|
||||
asset['cum_return_etf'] = self._safe_val(etf_cum_ret, 4)
|
||||
else:
|
||||
asset['cum_return_etf'] = None
|
||||
asset['cum_return_idx'] = None
|
||||
else:
|
||||
asset['cum_return_etf'] = None
|
||||
|
||||
# 指数累计收益(独立计算)
|
||||
if code in index_close_dict:
|
||||
idx_cur = index_close_dict[code].reindex(trading_calendar, method='ffill').get(date)
|
||||
idx_entry = index_close_dict[code].reindex(trading_calendar, method='ffill').get(entry_dt)
|
||||
if idx_cur and idx_entry and pd.notna(idx_entry) and float(idx_entry) > 0:
|
||||
idx_cum_ret = float(idx_cur) / float(idx_entry) - 1
|
||||
asset['cum_return_idx'] = self._safe_val(idx_cum_ret, 4)
|
||||
else:
|
||||
asset['cum_return_idx'] = None
|
||||
else:
|
||||
asset['cum_return_idx'] = None
|
||||
else:
|
||||
asset['cum_return_etf'] = None
|
||||
348
archive/scripts/get_trading_calendar.py
Normal file
348
archive/scripts/get_trading_calendar.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
获取 A 股交易日历脚本
|
||||
|
||||
使用 Flask API 交易日历服务获取 A 股交易日历
|
||||
支持多市场、多年份的交易日查询
|
||||
|
||||
用法:
|
||||
python scripts/get_trading_calendar.py
|
||||
python scripts/get_trading_calendar.py --year 2024
|
||||
python scripts/get_trading_calendar.py --start 2024-01-01 --end 2024-12-31
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import pandas as pd
|
||||
|
||||
# 添加项目根目录到路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# 加载环境变量
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# 导入 Flask API 数据源
|
||||
from datasource.flask_api_source import FlaskAPIDataSource
|
||||
|
||||
|
||||
def get_calendar_for_year(source: FlaskAPIDataSource, year: int, market: str = 'A'):
|
||||
"""
|
||||
获取指定年份的交易日历
|
||||
|
||||
Args:
|
||||
source: Flask API 数据源实例
|
||||
year: 年份(如 2024)
|
||||
market: 市场代码('A', 'US', 'HK')
|
||||
|
||||
Returns:
|
||||
pd.DatetimeIndex: 交易日序列
|
||||
"""
|
||||
start_date = f"{year}-01-01"
|
||||
end_date = f"{year}-12-31"
|
||||
|
||||
print(f"\n获取 {year} 年 {market} 市场交易日历...")
|
||||
|
||||
trading_dates = source.get_trading_calendar(
|
||||
market=market,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
if trading_dates is None or len(trading_dates) == 0:
|
||||
print(f"✗ {year} 年 {market} 市场无交易日数据")
|
||||
return None
|
||||
|
||||
return trading_dates
|
||||
|
||||
|
||||
def analyze_calendar(trading_dates: pd.DatetimeIndex, year: int):
|
||||
"""
|
||||
分析交易日历统计信息
|
||||
|
||||
Args:
|
||||
trading_dates: 交易日序列
|
||||
year: 年份
|
||||
"""
|
||||
if trading_dates is None or len(trading_dates) == 0:
|
||||
return
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"{year} 年 A 股交易日历分析")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# 基本统计
|
||||
total_days = len(trading_dates)
|
||||
print(f"\n基本统计:")
|
||||
print(f" 总交易日: {total_days} 天")
|
||||
print(f" 起始日期: {trading_dates.min().strftime('%Y-%m-%d')}")
|
||||
print(f" 结束日期: {trading_dates.max().strftime('%Y-%m-%d')}")
|
||||
|
||||
# 按月份统计
|
||||
print(f"\n按月份统计:")
|
||||
monthly_counts = {}
|
||||
for date in trading_dates:
|
||||
month = date.month
|
||||
monthly_counts[month] = monthly_counts.get(month, 0) + 1
|
||||
|
||||
for month in range(1, 13):
|
||||
count = monthly_counts.get(month, 0)
|
||||
month_name = datetime(2024, month, 1).strftime('%B')
|
||||
print(f" {month:02d}月 ({month_name}): {count} 天")
|
||||
|
||||
# 按季度统计
|
||||
print(f"\n按季度统计:")
|
||||
quarterly_counts = {1: 0, 2: 0, 3: 0, 4: 0}
|
||||
for date in trading_dates:
|
||||
quarter = (date.month - 1) // 3 + 1
|
||||
quarterly_counts[quarter] += 1
|
||||
|
||||
for quarter, count in quarterly_counts.items():
|
||||
print(f" Q{quarter}: {count} 天")
|
||||
|
||||
# 特殊日期统计
|
||||
print(f"\n特殊日期:")
|
||||
first_date = trading_dates.min()
|
||||
last_date = trading_dates.max()
|
||||
print(f" 首个交易日: {first_date.strftime('%Y-%m-%d')} ({first_date.strftime('%A')})")
|
||||
print(f" 最后交易日: {last_date.strftime('%Y-%m-%d')} ({last_date.strftime('%A')})")
|
||||
|
||||
# 查找节假日后的首个交易日(通过间隔判断)
|
||||
gaps = []
|
||||
for i in range(1, len(trading_dates)):
|
||||
prev_date = trading_dates[i-1]
|
||||
curr_date = trading_dates[i]
|
||||
gap_days = (curr_date - prev_date).days
|
||||
if gap_days > 3: # 超过3天视为可能节假日
|
||||
gaps.append({
|
||||
'prev': prev_date,
|
||||
'curr': curr_date,
|
||||
'gap': gap_days
|
||||
})
|
||||
|
||||
if gaps:
|
||||
print(f"\n可能的节假日(间隔 > 3天):")
|
||||
for gap_info in gaps[:5]: # 只显示前5个
|
||||
print(f" {gap_info['prev'].strftime('%Y-%m-%d')} → {gap_info['curr'].strftime('%Y-%m-%d')} "
|
||||
f"(间隔 {gap_info['gap']} 天)")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
|
||||
|
||||
def compare_markets(source: FlaskAPIDataSource, year: int):
|
||||
"""
|
||||
比较不同市场的交易日历
|
||||
|
||||
Args:
|
||||
source: Flask API 数据源实例
|
||||
year: 年份
|
||||
"""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"{year} 年不同市场交易日历对比")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
markets = {
|
||||
'A': 'A股(上交所/深交所)',
|
||||
'US': '美股(NYSE)',
|
||||
'HK': '港股(HKEX)'
|
||||
}
|
||||
|
||||
results = {}
|
||||
for market_code, market_name in markets.items():
|
||||
print(f"\n获取 {market_name} 交易日历...")
|
||||
trading_dates = get_calendar_for_year(source, year, market_code)
|
||||
|
||||
if trading_dates is not None and len(trading_dates) > 0:
|
||||
results[market_code] = {
|
||||
'name': market_name,
|
||||
'dates': trading_dates,
|
||||
'count': len(trading_dates)
|
||||
}
|
||||
|
||||
# 对比统计
|
||||
print(f"\n交易日对比:")
|
||||
print(f"{'市场':<20} {'交易日数':<10} {'起始日期':<12} {'结束日期':<12}")
|
||||
print("-" * 60)
|
||||
|
||||
for market_code, data in results.items():
|
||||
print(f"{data['name']:<20} {data['count']:<10} "
|
||||
f"{data['dates'].min().strftime('%Y-%m-%d'):<12} "
|
||||
f"{data['dates'].max().strftime('%Y-%m-%d'):<12}")
|
||||
|
||||
# 计算差异
|
||||
if len(results) >= 2:
|
||||
print(f"\n交易日差异:")
|
||||
market_codes = list(results.keys())
|
||||
for i in range(len(market_codes)):
|
||||
for j in range(i+1, len(market_codes)):
|
||||
m1 = market_codes[i]
|
||||
m2 = market_codes[j]
|
||||
diff = results[m1]['count'] - results[m2]['count']
|
||||
print(f" {results[m1]['name']} vs {results[m2]['name']}: "
|
||||
f"相差 {abs(diff)} 天 ({'+' if diff > 0 else ''}{diff})")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
|
||||
|
||||
def show_recent_dates(trading_dates: pd.DatetimeIndex, n: int = 10):
|
||||
"""
|
||||
显示最近的交易日
|
||||
|
||||
Args:
|
||||
trading_dates: 交易日序列
|
||||
n: 显示数量
|
||||
"""
|
||||
if trading_dates is None or len(trading_dates) == 0:
|
||||
return
|
||||
|
||||
print(f"\n最近 {n} 个交易日:")
|
||||
recent_dates = trading_dates[-n:] if len(trading_dates) >= n else trading_dates
|
||||
|
||||
for date in recent_dates:
|
||||
weekday = date.strftime('%A')
|
||||
print(f" {date.strftime('%Y-%m-%d')} ({weekday})")
|
||||
|
||||
|
||||
def export_calendar(trading_dates: pd.DatetimeIndex, output_path: str, year: int):
|
||||
"""
|
||||
导出交易日历到 CSV
|
||||
|
||||
Args:
|
||||
trading_dates: 交易日序列
|
||||
output_path: 输出路径
|
||||
year: 年份
|
||||
"""
|
||||
if trading_dates is None or len(trading_dates) == 0:
|
||||
return
|
||||
|
||||
# 创建 DataFrame
|
||||
df = pd.DataFrame({
|
||||
'date': trading_dates,
|
||||
'year': trading_dates.year,
|
||||
'month': trading_dates.month,
|
||||
'quarter': (trading_dates.month - 1) // 3 + 1,
|
||||
'weekday': [d.strftime('%A') for d in trading_dates]
|
||||
})
|
||||
|
||||
# 导出到 CSV
|
||||
filename = f"{output_path}/trading_calendar_A_{year}.csv"
|
||||
df.to_csv(filename, index=False)
|
||||
print(f"\n✓ 交易日历已导出到: {filename}")
|
||||
print(f" 文件包含 {len(df)} 条记录")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='获取 A 股交易日历')
|
||||
|
||||
parser.add_argument(
|
||||
'--year',
|
||||
type=int,
|
||||
default=datetime.now().year,
|
||||
help='年份(默认当前年份)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--start',
|
||||
type=str,
|
||||
help='起始日期 YYYY-MM-DD'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--end',
|
||||
type=str,
|
||||
help='结束日期 YYYY-MM-DD'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--market',
|
||||
type=str,
|
||||
default='A',
|
||||
choices=['A', 'US', 'HK'],
|
||||
help='市场代码(A=A股, US=美股, HK=港股)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--compare',
|
||||
action='store_true',
|
||||
help='对比不同市场交易日历'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--export',
|
||||
action='store_true',
|
||||
help='导出交易日历到 CSV'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=str,
|
||||
default='data',
|
||||
help='导出目录(默认 data)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 初始化 Flask API 数据源
|
||||
print("\n初始化 Flask API 数据源...")
|
||||
source = FlaskAPIDataSource()
|
||||
|
||||
# 检查服务健康状态
|
||||
health = source.get_health()
|
||||
if health.get('status') != 'healthy':
|
||||
print(f"✗ Flask API 服务不可用: {health}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"✓ Flask API 服务可用 ({source.base_url})")
|
||||
|
||||
# 获取交易日历信息
|
||||
calendar_info = source.get_calendar_info()
|
||||
if 'error' not in calendar_info:
|
||||
print(f"\n交易日历服务信息:")
|
||||
print(f" 支持市场: {', '.join(calendar_info.get('markets', []))}")
|
||||
print(f" 数据源: {calendar_info.get('source', 'pandas_market_calendars')}")
|
||||
|
||||
# 执行不同功能
|
||||
if args.compare:
|
||||
# 对比不同市场
|
||||
compare_markets(source, args.year)
|
||||
|
||||
elif args.start and args.end:
|
||||
# 自定义日期范围
|
||||
print(f"\n获取 {args.market} 市场交易日历 ({args.start} ~ {args.end})...")
|
||||
trading_dates = source.get_trading_calendar(
|
||||
market=args.market,
|
||||
start_date=args.start,
|
||||
end_date=args.end
|
||||
)
|
||||
|
||||
if trading_dates is not None:
|
||||
print(f"✓ 获取到 {len(trading_dates)} 个交易日")
|
||||
show_recent_dates(trading_dates)
|
||||
|
||||
if args.export:
|
||||
export_calendar(trading_dates, args.output, args.year)
|
||||
|
||||
else:
|
||||
# 获取指定年份交易日历
|
||||
trading_dates = get_calendar_for_year(source, args.year, args.market)
|
||||
|
||||
if trading_dates is not None:
|
||||
# 分析统计
|
||||
analyze_calendar(trading_dates, args.year)
|
||||
|
||||
# 显示最近交易日
|
||||
show_recent_dates(trading_dates)
|
||||
|
||||
# 导出
|
||||
if args.export:
|
||||
export_calendar(trading_dates, args.output, args.year)
|
||||
|
||||
print("\n✓ 完成!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 核心模块
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user