Compare commits

...

60 Commits

Author SHA1 Message Date
3b0688930d docs: 添加ETF跟踪误差计算方法文档
- 完整计算流程(ETF单位净值 vs 基准指数)
- 数据源选择(Tushare指数/期货/Flask API)
- 关键注意事项(unit_nav、标的指数基准、年化因子)
- 与天天基金数据校验结果(平均差异0.009%)
- Python代码示例
2026-06-20 17:06:26 +08:00
09ecac9e56 docs(experiments): add experiment 010 - start year sensitivity analysis
- Reproduce historical results: ca933e4 code achieves 43.20% annual return
- Attribution analysis: crash filter simplification (+4pp) + data extension (+2pp)
- Start year traversal: 2020-2025, all years show 34-57% annual return
- Compare ca933e4 vs HEAD (cabfee2) across different start years
- Add test_start_year_analysis.py for reproducibility
2026-06-17 23:24:17 +08:00
cabfee20b0 docs: add min_hold_days optimization experiment (009) 2026-06-17 19:39:38 +08:00
d657f8506b docs: add execution delay impact experiment (008) 2026-06-15 18:51:13 +08:00
6e7087a543 docs: 添加实验007动量因子回看窗口优化研究
- 研究多周期融合(ensemble)对策略表现的影响
- 结论:多窗口融合不适用于本策略,维持25天单窗口
2026-06-12 12:37:38 +08:00
8c3ae2269a feat: 新增 slope_r2_idm 和 slope_r2_ensemble 动量因子
- slope_r2_idm: slope_r2 × IDM(信息离散动量),惩罚靠少数大涨日撑起来的假动量
- slope_r2_ensemble: 多窗口(63/126/252天) slope_r2 等权融合,捕捉不同周期趋势信号
- 新增 info_dispersal_momentum() 计算正收益天数占比
- 新增 slope_r2_idm_score() 和 slope_r2_ensemble_score() 因子函数
- ensemble 因子需要更长预加载窗口(504天)和计算窗口(252天)
- crash filter 仍使用原始 n_days 窗口
2026-06-12 12:37:29 +08:00
49b623931b chore: 更新SSH隧道脚本密钥路径并提交私钥文件 2026-06-11 22:06:21 +08:00
fe73c0f199 refactor(rotation): simplify crash filter and add min_hold_days support
Changes:
- Simplify is_crash(): remove con2 (consecutive decline) condition, keep only single-day drop > 5%
- Extract _compute_base_momentum() to eliminate factor dispatch duplication
- Add min_hold_days config for forced holding constraint (currently disabled, value=1)

Backtest comparison (2020-01-10 ~ 2026-06-09):
| Metric          | Old (con1 OR con2) | New (con1 only) |
|-----------------|--------------------|-----------------|
| Total Return    | 241.73%            | 271.98%         |
| Annual Return   | 22.10%             | 23.79%          |
| Max Drawdown    | -16.27%            | -16.27%         |
| Sharpe Ratio    | 1.09               | 1.14            |
| Calmar Ratio    | 1.36               | 1.46            |
| Win Rate        | 53.71%             | 53.78%          |
| Rebalances      | 393                | 362             |

Conclusion: Relaxing crash filter improves return (+1.69% annual) with
same drawdown and fewer rebalances.
2026-06-09 22:53:52 +08:00
e2038ae722 chore: 修复 .env 注释格式 & 代码格式化
- .env: 第10行添加 # 注释前缀,修复 source .env 命令报错
- simple_rotation.py: is_crash 判断后添加空行,提升可读性

回测结果 (2020-01-10 ~ 2026-06-09):
- 总收益: 241.73% | 年化: 22.10%
- 最大回撤: -16.27% | Sharpe: 1.09
- 调仓次数: 393
2026-06-09 00:39:27 +08:00
5c4aeb75d2 fix(scheduler): 修复setup_schedule未传递no_detail/no_report参数的问题
setup_schedule() 在定时模式下未将 --no-detail 和 --no-report 参数传递给 daily_task,导致定时任务始终生成 detail JSON
2026-06-09 00:07:01 +08:00
710f3d9d68 chore(config): 启用钉钉机器人群2配置 2026-06-08 23:43:20 +08:00
0c19e45300 chore(config): 恢复 weight 为 rank 模式 2026-06-08 23:07:37 +08:00
e4bb570e5f docs: 更新 kelly 文档 commit hash 2026-06-08 23:05:39 +08:00
8b7bcf206a feat(weight): 实现 Kelly 仓位权重模式
- config_loader.py: WeightType 枚举新增 KELLY
- simple_rotation.py: compute_position_weights 新增 kelly 分支
  - 公式: w_i = max(score_i, 0) / sum(max(score_j, 0))
  - 负分自动排除 (Kelly: 不下注负期望)
  - 全负分时 fallback 到等权
- _generate_signals 传递 scores 给 kelly 模式
- config_simple.yaml: weight 改为 kelly
- 新增策略总结文档: kelly_weight.md

回测对比 (2020-2026):
- equal: 年化 19.88%, 夏普 1.13, 回撤 -14.65%
- rank:  年化 22.90%, 夏普 1.12, 回撤 -16.27%
- kelly: 年化 30.13%, 夏普 1.15, 回撤 -20.44%
2026-06-08 23:05:26 +08:00
844e609ff7 refactor(notify): 将通知模块从归档移至正式位置
- 将 notify.py 和 oss_utils.py 从 archive/legacy_core 移至 core/common/
- 内联钉钉配置读取函数,移除对 config.settings 的依赖
- 删除 config/ 目录(settings.py 不再需要)
- daily_scheduler.py 移除归档路径的 sys.path hack
- 新增 --no-detail 和 --no-report 命令行参数控制导出
- 全标的排名表新增退场日期和退场价格列
2026-06-08 22:34:03 +08:00
c32ce72579 fix(report): 修复报告生成中盈亏显示缺失的多个bug
- 修复 market_opened 检测中 df 变量名冲突导致 KeyError: holdings
- 维持仓位盈亏使用最近可用收盘价计算(不再依赖市场是否开盘)
- 调出标的盈亏:市场已开盘用当天开盘价,未开盘用前日收盘价
- 新调入标的在市场未开盘时正确显示待开盘状态(进场日期/盈亏为空)
2026-06-08 08:35:31 +08:00
4736b64eca feat(report): 全标的排名表新增进场日期列
在状态和持有天数之间插入进场日期列,显示持仓标的的连续持仓起始日期(YYYY-MM-DD格式),调出/未入选标的显示—
2026-06-08 00:55:59 +08:00
d5f35c0273 feat(report): 新增月度收益矩阵热力图面板
在策略绩效对比表下方新增 Panel 2 月度收益矩阵:
- 行:年份(2020~2026),列:1月~12月 + 年度累计
- 单元格显示月度收益率百分比
- 红涨绿跌配色(A股习惯),sqrt非线性映射增强小收益可见性
- 年度列使用几何连乘计算全年累计收益
- 无数据单元格浅灰显示
2026-06-08 00:43:54 +08:00
13c69c2a0b feat(report): 全标的动量排名表替代原调仓信号表
Panel 0 从仅展示持仓调仓扩展为全标的排名表:
- 新增未入选标的行,按动量降序展示
- 新增排名、市场两列
- 表格按调入→维持→调出→未入选顺序排列
- 调出标的也展示真实得分(便于分析调出原因)
- 标题显示当前动态阈值
- 未入选标的浅灰背景区分
2026-06-08 00:12:17 +08:00
6a5ae8efbf fix: generate_report now uses actual position_weights from daily_records
Previously hardcoded equal weight (1/select_num), ignoring config weight type.
Now reads position_weights from last daily_record, correctly showing rank-based weights.
2026-06-07 23:29:27 +08:00
d898ba0fd5 Revert "feat: add HTML report screenshot generation via Playwright"
This reverts commit f370caeff9.
2026-06-07 23:12:21 +08:00
f370caeff9 feat: add HTML report screenshot generation via Playwright
- Add html_report.py module for Playwright-based screenshot generation
- Add generate_html_report() method to SimpleRotationStrategy
- Modify backtest_viewer.html to use window-scoped variables for external injection
- Inject monthly/yearly returns table into screenshot
- Auto-generate HTML report in __main__ after export_results()

Output: simple_rotation_html_report.png with ranking table + monthly returns
2026-06-07 22:43:12 +08:00
06df8767b9 docs: add select_num=1 strategy deep analysis report
- Asset contribution attribution (CL=F 59.1%, N225 -11.8%)
- IC analysis across lookback periods (only CL=F and ChiNext have robust positive IC)
- Hurst exponent analysis and asset classification
- Multi-factor direction recommendations
2026-06-07 12:26:13 +08:00
7b229ced14 docs: add strategy summary snapshot (2026-06-06, ca933e4)
First stage summary documenting core strategy logic, key design
decisions, and select_num/weight backtest comparison results.
Stored in dedicated docs/strategy_summaries/ directory with
date + commit hash naming for reproducibility.
2026-06-06 23:59:41 +08:00
ca933e43e4 fix: lock position weights on rebalance only, not daily ranking changes
Previously, position weights were recalculated every day in _generate_signals,
causing weights to change even when holdings didn't change (only ranking order
shifted). This was incorrect - weights should be locked at rebalance and remain
stable until the next rebalance.

Changes:
- _generate_signals now computes _pending_weights (for signal generation only)
- run() maintains active_weights, updated only on is_rebalance or first day
- _calculate_daily_return uses the locked active_weights
- daily_records stores active_weights in position_weights field

Result: 391 → 318 rebalances, 25.63% → 26.38% CAGR
2026-06-06 23:16:51 +08:00
8d8fd71149 feat(viewer): sort holdings cards by position weight descending
Display largest position first in the holdings panel for better readability
2026-06-06 22:48:40 +08:00
4d9e12886f chore: remove *.html from gitignore to track all HTML files 2026-06-06 22:43:08 +08:00
eb3c82f05b feat(rotation): add position weight to detail JSON and viewer
- Record position_weights in daily_records during backtest run
- Export weight field per held asset in detail JSON
- Display weight percentage in backtest_viewer holdings cards
- Force-add backtest_viewer.html (previously ignored by *.html rule)
2026-06-06 22:39:23 +08:00
4973a9a2a5 feat(rotation): componentize position weighting + fix bond threshold consistency
- Extract compute_position_weights() as pluggable pure function
- Add WeightType enum (equal/rank) and RotationConfig.weight field
- Fix bond threshold dimension mismatch: use configured factor function
  for all assets instead of hardcoded weighted_momentum_score
- Default weight: equal in config, active: rank in config_simple.yaml
2026-06-06 22:28:08 +08:00
44588d5026 refactor(rotation): clean up experimental factor code
Remove slope_snr, slope_snr_r2, james_stein score functions and r2_alpha parameter.
slope_r2_score reverts to simple slope*R² with no alpha parameter.
Minor docstring fix: R^2 → R².
2026-06-06 18:45:11 +08:00
921f84cb6a feat: 新增 standardized_slope (t-statistic) 因子并实验验证
- simple_rotation.py: 新增 standardized_slope_score 函数 (slope/SE)
- config_loader.py: FactorType 枚举新增 STANDARDIZED_SLOPE
- 对比实验结果: standardized_slope 年化 13.73% vs slope_r2 19.84%
- 结论: t-statistic 过度惩罚高波动资产的有效趋势信号,不适合本场景
- 文档更新: 动量因子对比调研报告新增 3.3 节详细分析
2026-06-06 16:40:01 +08:00
aff04318b1 chore: 动量因子对比调研报告移至 docs 目录 2026-06-06 16:19:10 +08:00
40853745c6 docs: 添加动量因子对比调研报告
包含4种因子公式对比、回测结果、slope_r2胜出原因分析、
业界学界方法调研(TSMOM/Baltas&Kosowski/AQR)、
负价格处理机制分析及改进建议
2026-06-06 16:16:42 +08:00
b564a47a1b feat: 新增slope_r2因子并切换为默认因子(年化19.84%, 夏普1.14)
- simple_rotation.py: 新增3种score函数(vol_adjusted_momentum, slope_r2, momentum)
- config_loader.py: FactorType枚举新增VOL_ADJUSTED_MOMENTUM
- config_simple.yaml: factor.type 切换为 slope_r2
- experiments/factor_comparison.py: 4种因子对比实验脚本
- experiments/output: 实验结果(slope_r2全面胜出)
2026-06-06 15:49:22 +08:00
04b858ff09 feat: 添加ETF轮动策略诊断分析实验
新增6维度策略诊断实验脚本和报告:
- task1: 信号产生分析 (调仓频率、无效调仓率)
- task2: 收益计算分析 (T+1执行偏差、溢价问题)
- task3: 调仓逻辑分析 (最小持仓期模拟)
- task4: 资金管理分析 (止损、波动率适配)
- task5: 收益归因分析 (集中度、静态vs轮动)
- task6: 回撤诊断分析 (最大回撤复盘、尾部风险)

输出报告:
- diagnosis_report.md: 完整策略诊断报告
- rebalancing_optimization_experiment.md: 调仓频率优化实验报告

实验结论:
- 发现调仓过于频繁 (405次/1549天)
- No-Trade Region方案可提升年化3%、夏普0.11
- 但改善幅度有限,信号质量是根本瓶颈
2026-06-06 15:00:28 +08:00
f3ba6eb799 docs: add momentum time window research report 2026-06-03 23:56:05 +08:00
55e4cbf108 refactor(archive): move reports/ to archive/reports/ 2026-06-03 23:43:31 +08:00
c905230a40 refactor(archive): move unused modules to archive/
Archive legacy framework and utility modules that are no longer
referenced by the active core (datasource/ and rotation/):

- framework/ -> archive/framework/
- framework_v2/ -> archive/framework_v2/
- strategies/ -> archive/strategies/
- config/ -> archive/config/
- visualization/ -> archive/visualization/
- scripts/ -> archive/scripts/
- tests/ -> archive/tests/
- run_rotation.py, run_us_rotation.py -> archive/single_files/
- compare_*.py, test_api_dates.py -> archive/single_files/
2026-06-03 23:41:46 +08:00
d700bc1dfd fix(rotation): 回测导出JSON序列化NaN/Inf清洗
- simple_rotation.py: 新增 _sanitize_json() 递归替换 NaN/Inf 为 None,
  确保 json.dump 生成合法 JSON(避免前端解析失败)
- .env: 注释掉群2钉钉配置(暂不使用)
2026-06-03 09:14:53 +08:00
4f9e0231bd fix(datasource): yfinance时区标准化与NaN过滤修复
- yfinance_source.py: 用 tz_localize(None) 替代 pd.to_datetime(utc=True),
  避免亚洲/欧洲市场因UTC转换导致日期回退一天(如日经225 5/25→5/24)
- yfinance_source.py: 新增 _normalize_index() 静态方法统一处理时区剥除
- yfinance_source.py: fetch() 增加 close=NaN 行过滤(yfinance未收盘日返回不完整数据)
- flask_api_source.py: 客户端同步增加 close=NaN 过滤防御

验证结果:N225 5/25-6/3 返回7个交易日数据,日期无偏移
2026-06-03 09:14:39 +08:00
972bbbe706 fix(rotation): signal_date改用日历日前一天以捕获外盘假期数据
- 将 signal_date = trading_calendar[i-1] 改为 date - timedelta(days=1)
- 解决A股长假期间美股继续交易但动量计算丢失外盘数据的问题
- 同步修复 export_results 中的 signal_date 映射逻辑
2026-06-03 01:25:09 +08:00
524fa5f513 refactor(rotation): 移除数据缓存 + 修复空值和pct_change警告
- 移除CSV本地缓存(cache_dir、_cache_path、_premium_cache_path、_save_premium_cache)
- 每次运行直接从API获取数据,简化DataCache类
- 修复_get_etf_prices中open/close为None时的空值处理(中证指数API不提供OHLC)
- 修复pct_change的FutureWarning(显式传fill_method=None)
- 更新trade_cost注释
2026-06-03 00:54:48 +08:00
d1139a9ee9 fix(http): 用requests+trust_env=False修复SSL EOF问题
根因:Clash代理(127.0.0.1:7890)在处理TLS 1.3+后量子密钥交换时
不兼容,导致SSL EOF错误。requests默认trust_env=True会读取系统
代理配置,通过代理转发HTTPS请求时触发问题。

修复:使用requests.Session(trust_env=False)绕过系统代理,
直连目标服务器。无需降级urllib3版本。

影响文件:
- rotation/simple_rotation.py
- datasource/flask_api_source.py
2026-06-03 00:35:49 +08:00
a2b4289080 revert(http): 改回串行数据获取
回退并行获取逻辑,恢复简单的串行循环:
- 移除 ThreadPoolExecutor 并行代码
- 移除 concurrent.futures 导入
- 保持简单的 for 循环串行获取
2026-06-03 00:09:29 +08:00
e29f57749d perf(http): 并行获取数据加速数据加载
使用 ThreadPoolExecutor 并行获取多个标的的数据:
- 信号源 (index): 11个标的并行获取
- 交易源 (ETF): 4个标的并行获取
- 溢价率数据: 4个标的并行获取

性能提升:5个标的从 ~15s 串行 → ~4.6s 并行(约 3x 加速)

修改:
- 增大 urllib3 连接池 maxsize=16 支持并行连接
- 使用 concurrent.futures.ThreadPoolExecutor
2026-06-02 22:29:59 +08:00
81045f9d85 fix(http): 用urllib3替代requests修复SSL EOF错误
问题根因:
- Python OpenSSL 3.5.4 + requests 2.32.4 + urllib3 2.5.0 版本不兼容
- requests 2.32.4 内部使用 urllib3 的方式与 urllib3 2.5.0 API 不兼容
- curl(SecureTransport)正常工作,但 Python requests(OpenSSL)失败
- 服务器(Caddy)使用 TLS 1.3 + X25519MLKEM768(后量子密钥交换)

修复方案:
- 用 urllib3.PoolManager 直接发起 HTTP 请求(已验证可正常工作)
- 封装 _http_get() 函数替代 requests.get()
- 替换所有 requests 相关异常类型为 urllib3 异常

修改文件:
- datasource/flask_api_source.py: 核心数据源层
- rotation/simple_rotation.py: 简单轮动策略层
2026-06-02 22:22:36 +08:00
74f0eebef0 docs(experiment): add 1-day holding deep attribution analysis (006)
- Rank decline: 70.3% (45/64), mostly rank=3 entry → rank=4/5 exit
- Threshold breach: 25.0% (16/64), all actual momentum drops
- 38% cases: own momentum rises but outranked by others
- N225/GDAXI highest 1-day rate (22-26%), A-shares lowest (3-6%)
- Optimization: min holding period, confidence filter, rank smoothing
2026-06-02 21:41:34 +08:00
361b82fa4a docs(experiment): add holding duration distribution analysis (006)
- Analyze holding period distribution from simple_rotation_detail.json
- 391 complete holding episodes across 11 assets
- Median holding: 7 days, mean: 10.9 days
- 75% of holdings within 16 days, 16.4% are 1-day switches
- NDX has longest avg holding (17.4d), HSI shortest (6.5d)
- Insight: consider minimum holding period to reduce noise trades
2026-06-02 21:36:46 +08:00
a47af0f0eb docs(experiment): add select_num A/B/C comparison report (005)
- Experiment: select_num = 1, 2, 3 comparison
- Period: 2020-01-10 ~ 2026-06-02 (1546 trading days)
- Key findings:
  - Top-1: highest return (600%), highest drawdown (-25.5%)
  - Top-3: best risk-adjusted return (Calmar 1.73, Sharpe 1.35)
  - Top-2: balanced middle ground (Calmar 1.69)
- Add rotation/experiment_select_num.py experiment script
- Save report to docs/experiments/005_select_num_comparison.md
2026-06-02 01:32:43 +08:00
07d6f1451c fix(rotation): raise RuntimeError on held asset data failure
- Add data integrity check: if any currently held asset is missing
  from factors, raise RuntimeError immediately to prevent false rebalance
- Previously missing data would silently cause incorrect sell signals
- Now fails fast with clear error message identifying the missing assets
  and the date of failure
2026-06-02 01:16:44 +08:00
4791d3cf40 refactor(scheduler): move daily_scheduler.py to rotation/ and add simple_rotation support
- Move scripts/daily_scheduler.py -> rotation/daily_scheduler.py
- Add run_simple_rotation() to execute simple_rotation.py via subprocess
- Add --strategy flag (simple/legacy/all) for flexible strategy selection
- Add --simple-config flag for custom simple rotation config path
- Update Dockerfile and docker-compose.yml path references
- Add configurable title to send_report_to_dingtalk()
2026-06-02 01:16:34 +08:00
5e11b6b690 fix(rotation): 溢价率缓存增加增量更新逻辑
- preload_premium: 检查缓存日期范围,不足时增量拉取
- 新增 _fetch_premium_api: 拉取并合并新溢价率数据
- 调用时传入 end_date 触发增量检查

修复前: premium CSV存在即返回旧数据,明天9点运行时拿不到最新
修复后: 检测 latest_cached < end_date 时自动拉取增量
2026-06-01 23:56:18 +08:00
19f1c63981 fix(rotation): 修复溢价率计算,改用Flask API真实premium_series数据
- _fetch_api: 提取premium_series并存入df.attrs和CSV缓存
- DataCache: 新增premium_data字典、preload_premium方法
- preload_premium: 无缓存时主动请求API获取全量历史溢价率
- _preload_data: 加载ETF后同步调用preload_premium
- _compute_premium(trade_code, date): 从内存缓存按日期查找真实溢价率
- 新增trade_code_to_group映射,确保BOND资产正确识别

修复前: 溢价率 = (ETF价格 - 指数点位) / 指数点位 → -99.9%
修复后: 使用API返回的(ETF价格 - NAV) / NAV → 合理范围
2026-06-01 23:31:36 +08:00
6d0b928894 fix(rotation): 消除前视偏差 + V2兼容detail导出
时序对齐修复:
- 信号生成改用 T-1 收盘数据(9AM信号时T日未开盘)
- entry_price_etf 改用 T 日 open(实际买入价)
- 年化收益: 52.66% → 25.12%(去除约4倍虚高)

V2兼容detail JSON:
- _generate_signals 返回 (holdings, factors, bond_momentum)
- 6个helper方法: build_meta_codes, get_index/etf_close, daily_returns, premium, day_assets
- 每日11资产×16字段完整记录(momentum/rank/holding_days/cum_return等)
- export_results 同步修复 entry_info 时序逻辑

Backtest (2020-01-10 ~ 2026-06-01, 1545天):
- 总收益 295.14%, 年化 25.12%
- 最大回撤 -14.74%, 夏普 1.33, 卡尔马 1.70
2026-06-01 23:13:43 +08:00
451ffa33d2 clean(rotation): add simple rotation strategy and remove unused files
New:
- rotation/simple_rotation.py: daily-iteration rotation strategy (584 lines)
- rotation/config_loader.py: standalone config loader
- rotation/config_simple.yaml: 11 assets, 7 groups
- rotation/README_SIMPLE.md: usage guide
- scripts/get_trading_calendar.py: trading calendar fetcher

Removed:
- rotation/example_usage.py, run_strategy.py (replaced by simple_rotation.py)
- rotation/results/ output files (gitignored)
- scripts/verify_*.py, calculate_returns_from_detail.py (one-off scripts)
- scripts/README_TRADING_CALENDAR.md

Backtest result (2020-01-10 ~ 2026-06-01):
- Total return: 1237.6%, Annual: 52.66%
- Max drawdown: -11.71%, Sharpe: 2.50
2026-06-01 22:28:26 +08:00
3b0208d7d3 docs(viewer): 添加 backtest_viewer.html 到 git 追踪
- 修改 .gitignore 添加 HTML 文件例外规则
- 将 visualization/backtest_viewer.html 纳入版本控制
- 保留回测可视化查看器供团队使用
2026-05-26 23:33:06 +08:00
ee2453f65e fix(rotation): 修复 backtest detail 中指数和 ETF 累计收益计算 bug
- 问题:cum_return_idx 和 cum_return_etf 使用相同的 ETF 价格计算
- 修复:分别使用指数价格(raw)和 ETF 价格(hfq)独立计算
- 验证:72.6% 的持仓记录显示差异(0.06%~0.48%),符合预期
- 新增验证脚本:verify_cum_return_fix.py
2026-05-26 23:22:26 +08:00
6a86a27108 test(scripts): 新增ETF数据获取验证脚本
新增脚本:
- verify_etf_hfq_fix.py: 验证指数使用raw、ETF使用hfq
- compare_index_vs_etf_returns.py: 对比指数收益vs ETF收益的KPI指标

验证内容:
- 指数数据完整性检查
- ETF数据完整性检查
- ETF是否正确使用hfq后复权价格(抽样对比raw和hfq)
- 验证510300.SH等ETF的hfq/raw比值(应>1.0)
2026-05-26 19:55:01 +08:00
2ff48e8d56 refactor(flask_api_fetcher): 暴露adj参数,增强接口透明度和灵活性
改进:
- fetch_indices()添加adj参数,默认'raw',可自定义
- fetch_etf()添加adj参数,默认'hfq',可自定义
- 改进日志输出,显示实际使用的adj参数
- 保持向后兼容,默认值保持原有行为

优势:
- 透明性:调用者清楚知道使用的复权方式
- 灵活性:可按需获取raw/qfq/hfq数据
- 一致性:两个方法接口统一
- 向后兼容:不影响现有代码
2026-05-26 19:54:41 +08:00
d404ddee17 fix(rotation): 修复ETF数据获取逻辑,分别获取指数raw和ETF hfq数据
问题:之前统一使用fetch_indices(adj='raw')导致ETF未使用后复权价格

修复:
- 在GlobalRotationStrategy中覆盖get_data()方法
- 指数数据:调用fetch_indices(adj='raw')获取原始价格
- ETF数据:调用fetch_etf(adj='hfq')获取后复权价格
- 确保指数信号计算和ETF收益计算使用正确的数据源

影响:回测将正确反映ETF的真实收益(包含分红再投资)
2026-05-26 19:54:21 +08:00
146 changed files with 12206 additions and 133 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -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"]

View File

@@ -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)

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
对比指数收益 vs ETF 收益的 KPI 指标
从 backtest_detail_v2.json 中提取:
1. 策略实际持仓
2. 各标的当日指数收益率
3. 各标的当日 ETF 收益率
分别计算两种收益模式下的 KPI
- 指数收益模式:使用 index_return 计算策略净值
- ETF 收益模式:使用 etf_return_ctc 计算策略净值
"""
import json
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))
def load_detail_json(json_path: str) -> dict:
"""加载 detail JSON"""
print(f"[1] 加载 JSON: {json_path}")
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" 天数: {len(data['days'])}")
print(f" 标的: {len(data['meta']['codes'])}")
return data
def calculate_returns_from_detail(data: dict, return_field: str, trade_cost: float = 0.001) -> tuple:
"""
从 detail JSON 计算策略收益率(与 rotation.py 完全对齐)
逻辑(与 rotation.py 第 354-362 行一致):
1. T+1 执行:今天的持仓信号,明天才产生收益
2. 等权分配仓位
3. 扣除交易成本0.1%
Args:
data: detail JSON 数据
return_field: 收益率字段名 ('index_return''etf_return_ctc')
trade_cost: 交易成本(默认 0.1%
Returns:
(策略收益率序列, 调仓次数)
"""
dates = []
strategy_returns = []
positions = [] # 记录每日仓位,用于计算调仓次数
for i, day in enumerate(data['days']):
date = day['date']
holdings = day['holdings']
dates.append(pd.Timestamp(date))
if not holdings:
# 空仓
positions.append({})
if i == 0:
strategy_returns.append(0.0)
else:
# T+1昨天空仓今天收益为 0
strategy_returns.append(0.0)
else:
# 记录仓位(等权)
n_holdings = len(holdings)
pos = {code: 1.0 / n_holdings for code in holdings}
positions.append(pos)
if i == 0:
# 第一天T+1 执行,收益为 0
strategy_returns.append(0.0)
else:
# T+1 执行:用昨天的仓位 × 今天的收益率
daily_return = 0.0
for code, weight in positions[i-1].items():
if code in day['assets']:
asset = day['assets'][code]
ret = asset.get(return_field, 0.0)
if ret is None:
ret = 0.0
daily_return += weight * ret
strategy_returns.append(daily_return)
# 转换为 Series
returns_series = pd.Series(strategy_returns, index=dates, name='strategy_returns')
# 计算调仓次数(与 rotation.py 第 425 行一致)
# 检测持仓变化
rebalance_count = 0
for i in range(1, len(positions)):
if positions[i] != positions[i-1]:
rebalance_count += 1
# 扣除交易成本(与 rotation.py 第 429 行一致)
if trade_cost > 0 and rebalance_count > 0:
# 检测调仓日
position_changes = []
for i in range(1, len(positions)):
position_changes.append(positions[i] != positions[i-1])
position_changes.insert(0, False) # 第一天
# 在调仓日扣除成本
for i, is_change in enumerate(position_changes):
if is_change:
returns_series.iloc[i] -= trade_cost
return returns_series, rebalance_count
def calculate_kpi(strategy_returns: pd.Series, mode_name: str, rebalance_count: int) -> dict:
"""
计算 KPI 指标(与 rotation.py 第 383-394 行完全一致)
Args:
strategy_returns: 策略收益率序列
mode_name: 模式名称(用于打印)
rebalance_count: 调仓次数
Returns:
KPI 字典
"""
# 净值曲线(与 rotation.py 第 365 行一致)
equity_curve = (1 + strategy_returns).cumprod()
# 总收益(与 rotation.py 第 384 行一致)
total_return = equity_curve.iloc[-1] / equity_curve.iloc[0] - 1
# 年化收益(与 rotation.py 第 385-386 行一致,使用 252 天)
n_days = len(strategy_returns)
annual_return = (1 + total_return) ** (252 / n_days) - 1 if n_days > 0 else 0
# 最大回撤(与 rotation.py 第 388-391 行一致)
cumulative_max = equity_curve.cummax()
drawdown = (equity_curve - cumulative_max) / cumulative_max
max_drawdown = drawdown.min()
# 夏普比率(与 rotation.py 第 394 行一致,使用 252 天)
sharpe = (strategy_returns.mean() / strategy_returns.std() * np.sqrt(252)
if strategy_returns.std() > 0 else 0)
kpi = {
'mode': mode_name,
'total_return': total_return,
'annual_return': annual_return,
'max_drawdown': max_drawdown,
'sharpe_ratio': sharpe,
'n_days': n_days,
'rebalance_count': rebalance_count,
'final_nav': equity_curve.iloc[-1],
}
return kpi
def print_kpi_comparison(index_kpi: dict, etf_kpi: dict):
"""打印 KPI 对比表"""
print("\n" + "=" * 80)
print(" KPI 指标对比:指数收益 vs ETF 收益")
print("=" * 80)
# 表头
print(f"\n{'指标':<20} {'指数收益':>15} {'ETF 收益':>15} {'差异':>15}")
print("-" * 80)
# 数据行
metrics = [
('总收益', 'total_return', '{:.2%}'),
('年化收益', 'annual_return', '{:.2%}'),
('最大回撤', 'max_drawdown', '{:.2%}'),
('夏普比率', 'sharpe_ratio', '{:.2f}'),
('最终净值', 'final_nav', '{:.4f}'),
('交易天数', 'n_days', '{:.0f}'),
('调仓次数', 'rebalance_count', '{:.0f}'),
]
for label, key, fmt in metrics:
idx_val = index_kpi[key]
etf_val = etf_kpi[key]
diff = etf_val - idx_val
# 特殊处理百分比格式的差异
if 'return' in key or 'drawdown' in key:
diff_fmt = '{:+.2%}'.format(diff)
else:
diff_fmt = '{:+.2f}'.format(diff) if key != 'n_days' and key != 'rebalance_count' else '{:+.0f}'.format(diff)
print(f"{label:<20} {fmt.format(idx_val):>15} {fmt.format(etf_val):>15} {diff_fmt:>15}")
print("=" * 80)
def save_comparison_csv(index_returns: pd.Series, etf_returns: pd.Series, output_path: str):
"""保存对比数据到 CSV"""
df = pd.DataFrame({
'date': index_returns.index,
'index_return': index_returns.values,
'etf_return': etf_returns.values,
'index_nav': (1 + index_returns).cumprod().values,
'etf_nav': (1 + etf_returns).cumprod().values,
})
df['nav_diff'] = df['etf_nav'] - df['index_nav']
df['return_diff'] = df['etf_return'] - df['index_return']
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(output_path, index=False)
print(f"\n[3] 对比数据已保存: {output_path}")
print(f" 行数: {len(df)}")
def main():
print("=" * 80)
print(" 指数收益 vs ETF 收益 KPI 对比分析")
print("=" * 80)
# 1. 加载数据
json_path = project_root / 'framework_v2' / 'results' / 'backtest_detail_v2.json'
if not json_path.exists():
print(f"错误: JSON 文件不存在: {json_path}")
sys.exit(1)
data = load_detail_json(str(json_path))
# 2. 计算两种模式的收益率
print("\n[2] 计算收益率(与 rotation.py 完全对齐)...")
print(" - 指数收益模式 (index_return)")
print(" T+1 执行 + 等权仓位 + 交易成本 0.1%")
index_returns, index_rebalance = calculate_returns_from_detail(data, 'index_return', trade_cost=0.001)
print(f" 调仓次数: {index_rebalance}")
print(" - ETF 收益模式 (etf_return_ctc)")
print(" T+1 执行 + 等权仓位 + 交易成本 0.1%")
etf_returns, etf_rebalance = calculate_returns_from_detail(data, 'etf_return_ctc', trade_cost=0.001)
print(f" 调仓次数: {etf_rebalance}")
# 3. 计算 KPI
print("\n计算 KPI 指标(使用 252 天/年,与 rotation.py 一致)...")
index_kpi = calculate_kpi(index_returns, '指数收益', index_rebalance)
etf_kpi = calculate_kpi(etf_returns, 'ETF 收益', etf_rebalance)
# 4. 打印对比
print_kpi_comparison(index_kpi, etf_kpi)
# 5. 保存 CSV
csv_path = project_root / 'framework_v2' / 'results' / 'kpi_comparison_index_vs_etf.csv'
save_comparison_csv(index_returns, etf_returns, str(csv_path))
# 6. 分析差异来源
print("\n[4] 差异分析...")
daily_diff = (etf_returns - index_returns).abs()
large_diff_days = (daily_diff > 0.001).sum() # 差异 > 0.1%
print(f" 差异 > 0.1% 的天数: {large_diff_days} / {len(index_returns)}")
print(f" 平均日差异: {daily_diff.mean():.6f} ({daily_diff.mean()*100:.4f}%)")
print(f" 最大日差异: {daily_diff.max():.6f} ({daily_diff.max()*100:.4f}%)")
print("\n" + "=" * 80)
print(" 分析完成!")
print("=" * 80)
if __name__ == '__main__':
main()

View 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()

View 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")

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
验证 ETF 数据获取修复
测试点:
1. 指数数据使用 adj='raw'
2. ETF 数据使用 adj='hfq'
3. 数据字典中同时包含指数和 ETF
"""
import sys
from pathlib import Path
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
from framework_v2.config import load_config
from framework_v2.strategies.rotation.rotation import GlobalRotationStrategy
def main():
print("=" * 70)
print(" 验证 ETF 数据获取修复")
print("=" * 70)
# 加载配置
config_path = project_root / 'framework_v2' / 'config' / 'rotation_global.yaml'
print(f"\n加载配置: {config_path}")
config = load_config(str(config_path))
# 初始化策略
strategy = GlobalRotationStrategy(config)
# 获取数据
print("\n" + "=" * 70)
print("获取数据...")
print("=" * 70)
data = strategy.get_data()
# 分析数据结构
print("\n" + "=" * 70)
print("数据结构分析")
print("=" * 70)
# 获取映射关系
signal_to_trade = config.asset_pools.get_signal_to_trade_mapping()
signal_codes = config.asset_pools.get_signal_codes()
trade_codes = set(signal_to_trade.values())
print(f"\n信号标的(指数): {len(signal_codes)}")
for code in sorted(signal_codes):
if code in data:
df = data[code]
has_hfq = 'close_hfq' in df.columns if 'close' in df.columns else False
print(f"{code}: {len(df)} 条, 有 close_hfq: {has_hfq}")
else:
print(f"{code}: 数据缺失")
print(f"\n交易标的ETF: {len(trade_codes)}")
for code in sorted(trade_codes):
if code in data:
df = data[code]
has_nav = 'nav' in df.attrs
has_premium = 'premium_series' in df.attrs
print(f"{code}: {len(df)}")
print(f" close (最新): {df['close'].iloc[-1]:.4f}")
print(f" 有 nav: {has_nav}")
print(f" 有 premium: {has_premium}")
else:
print(f"{code}: 数据缺失")
# 验证关键指标
print("\n" + "=" * 70)
print("验证结果")
print("=" * 70)
# 检查指数数据
index_ok = all(code in data for code in signal_codes)
print(f"\n指数数据完整性: {'✓ 全部获取' if index_ok else '✗ 部分缺失'}")
# 检查 ETF 数据
etf_ok = all(code in data for code in trade_codes)
print(f"ETF 数据完整性: {'✓ 全部获取' if etf_ok else '✗ 部分缺失'}")
# 检查 ETF 是否使用 hfq对比 raw 和 hfq 的价格差异)
print("\n验证 ETF 是否使用 hfq抽样检查...")
from framework_v2.shared.data import FlaskAPIFetcher
fetcher = FlaskAPIFetcher()
etf_hfq_verified = 0
sample_codes = list(trade_codes)[:3] # 抽样前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')
for code in sample_codes:
if code in data:
hfq_close = data[code]['close'].iloc[-1]
# 获取 raw 数据对比
raw_df = fetcher._source.fetch(code, start, end, adj='raw', asset_type='china_etf')
if raw_df is not None:
raw_close = raw_df['close'].iloc[-1]
ratio = hfq_close / raw_close if raw_close > 0 else 1
if ratio > 1.01: # 差异超过1%说明使用了 hfq
print(f"{code}: raw={raw_close:.4f}, hfq={hfq_close:.4f}, 倍数={ratio:.4f} (正确)")
etf_hfq_verified += 1
else:
print(f"{code}: raw={raw_close:.4f}, hfq={hfq_close:.4f}, 倍数={ratio:.4f} (错误)")
print(f"ETF 使用 hfq: {etf_hfq_verified}/{len(sample_codes)} {'✓ 正确' if etf_hfq_verified == len(sample_codes) else '✗ 错误'}")
# 总结
print("\n" + "=" * 70)
if index_ok and etf_ok and etf_hfq_verified == len(sample_codes):
print("✓ 验证通过:数据获取逻辑正确")
print(" - 指数使用 raw原始价格")
print(" - ETF 使用 hfq后复权价格")
else:
print("✗ 验证失败:数据获取存在问题")
print("=" * 70)
if __name__ == '__main__':
main()

View File

@@ -60,7 +60,8 @@ class FlaskAPIFetcher(DataFetcher):
self,
codes: List[str],
start: str,
end: str
end: str,
adj: str = 'raw'
) -> Dict[str, pd.DataFrame]:
"""
获取指数 OHLCV 数据
@@ -69,6 +70,7 @@ class FlaskAPIFetcher(DataFetcher):
codes: 指数代码列表 ["000300.SH", "000905.SH"]
start: 开始日期 (YYYY-MM-DD)
end: 结束日期 (YYYY-MM-DD)
adj: 复权类型默认 'raw'指数通常用原始价格
Returns:
{code: DataFrame} 字典DataFrame 包含 OHLCV
@@ -82,7 +84,7 @@ class FlaskAPIFetcher(DataFetcher):
... )
>>> print(data["000300.SH"].head())
"""
print(f"\n[FlaskAPI] 获取 {len(codes)} 只指数数据...")
print(f"\n[FlaskAPI] 获取 {len(codes)} 只指数数据adj='{adj}'...")
results = {}
for i, code in enumerate(codes, 1):
@@ -92,7 +94,7 @@ class FlaskAPIFetcher(DataFetcher):
code=code,
start_date=start,
end_date=end,
adj='raw' # 指数通常用原始价格
adj=adj # 使用传入的 adj 参数
)
if df is not None:
@@ -110,7 +112,8 @@ class FlaskAPIFetcher(DataFetcher):
self,
codes: List[str],
start: str,
end: str
end: str,
adj: str = 'hfq'
) -> Dict[str, pd.DataFrame]:
"""
获取 ETF 数据价格 + 净值
@@ -119,6 +122,7 @@ class FlaskAPIFetcher(DataFetcher):
codes: ETF 代码列表 ["510300.SH", "159919.SZ"]
start: 开始日期 (YYYY-MM-DD)
end: 结束日期 (YYYY-MM-DD)
adj: 复权类型默认 'hfq'ETF 收益计算推荐后复权
Returns:
{code: DataFrame} 字典
@@ -128,15 +132,23 @@ class FlaskAPIFetcher(DataFetcher):
示例:
>>> fetcher = FlaskAPIFetcher()
>>> # 默认使用 hfq后复权
>>> data = fetcher.fetch_etf(
... ["510300.SH", "159919.SZ"],
... "2024-01-01",
... "2024-12-31"
... )
>>> # 或者显式指定 raw原始价格用于计算溢价率
>>> data_raw = fetcher.fetch_etf(
... ["510300.SH"],
... "2024-01-01",
... "2024-12-31",
... adj='raw'
... )
>>> # 访问净值
>>> nav = data["510300.SH"].attrs.get('nav')
"""
print(f"\n[FlaskAPI] 获取 {len(codes)} 只 ETF 数据...")
print(f"\n[FlaskAPI] 获取 {len(codes)} 只 ETF 数据adj='{adj}'...")
results = {}
for i, code in enumerate(codes, 1):
@@ -146,7 +158,7 @@ class FlaskAPIFetcher(DataFetcher):
code=code,
start_date=start,
end_date=end,
adj='hfq', # ETF 收益计算必须使用后复权价格(处理份额拆分)
adj=adj, # 使用传入的 adj 参数
asset_type='china_etf' # 强制指定 ETF 类型
)

View File

@@ -115,6 +115,68 @@ class GlobalRotationStrategy(StrategyBase):
return list(codes)
def get_data(self) -> Dict[str, pd.DataFrame]:
"""
获取数据分别获取指数和 ETF使用不同的复权方式
指数数据使用 raw原始价格用于信号计算
ETF 数据使用 hfq后复权价格用于收益计算
Returns:
数据字典 {code: DataFrame}
"""
if self._data_fetcher is None:
self._data_fetcher = self._create_data_fetcher()
# 获取信号→交易映射
signal_to_trade = self.config.asset_pools.get_signal_to_trade_mapping()
# 处理 end_date 为 None 的情况(使用今天)
from datetime import date
start = self.config.backtest.start_date
end = self.config.backtest.end_date
if end is None:
end = date.today().strftime('%Y-%m-%d')
data = {}
# 1. 获取指数数据(信号标的,使用 raw
signal_codes = set(self.config.asset_pools.get_signal_codes())
if self.use_dynamic_threshold and self.bond_code:
signal_codes.add(self.bond_code)
if signal_codes:
print(f"\n[数据] 获取 {len(signal_codes)} 只指数数据adj='raw'...")
try:
index_data = self._data_fetcher.fetch_indices(
codes=list(signal_codes),
start=start,
end=end,
adj='raw' # 指数使用原始价格
)
data.update(index_data)
print(f" ✓ 指数数据: {len(index_data)}")
except Exception as e:
print(f" ✗ 指数数据获取失败: {e}")
# 2. 获取 ETF 数据(交易标的,使用 hfq
trade_codes = list(set(signal_to_trade.values()))
if trade_codes:
print(f"\n[数据] 获取 {len(trade_codes)} 只 ETF 数据adj='hfq'...")
try:
etf_data = self._data_fetcher.fetch_etf(
codes=trade_codes,
start=start,
end=end,
adj='hfq' # ETF 使用后复权价格
)
data.update(etf_data)
print(f" ✓ ETF 数据: {len(etf_data)}")
except Exception as e:
print(f" ✗ ETF 数据获取失败: {e}")
return data
def compute_factors(self, data: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]:
"""
计算动量因子只使用信号标的的数据
@@ -329,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(
@@ -587,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:
@@ -594,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
# 对齐因子
@@ -733,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

View 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
View File

@@ -0,0 +1 @@
# 核心模块

Some files were not shown because too many files have changed in this diff Show More