commit bd7d85817aff2cceca9eec2c2118d8fb361edd19 Author: Stock Buddy Date: Sun Mar 22 12:57:47 2026 +0800 init: stock buddy v5 完整回测系统 三版本 A/B/C 止损策略对比回测 - A: 固定止损 12% - B: ATR x2.5 动态止损 - C: 混合自适应(低波动固定8%/中波动ATR×2.5/高波动ATR×2.0) 含仓位分级、成交量确认、CSV缓存机制 已验证三只港股持仓:01833 / 09886 / 09982 待补全:data/1833.csv 和 data/9886.csv(在外网运行 download_data.py) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e22234 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee9311b --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Stock Buddy 🤖📈 + +> 基于 AI 多维度评分的港股量化回测系统 + +## 功能简介 + +对港股标的从**技术面、基本面、舆情**三个维度打分,生成综合评分信号,并回测不同止损策略的收益表现。 + +## 系统架构 + +``` +评分模型(三维度加权) +├── 技术面(权重 50%):RSI、MACD、均线系统 +├── 基本面(权重 30%):营收、净利润、PE/PB 快照 +└── 舆情(权重 20%):新闻情感、机构评级、政策面 + +止损策略(三版本 A/B/C 对比) +├── A:固定止损 12%(基准) +├── B:ATR × 2.5 动态止损(8%~35%) +└── C:混合自适应(★推荐) + ├── ATR < 5% → 低波动,固定止损 8% + ├── ATR 5~15% → 中波动,ATR × 2.5 + └── ATR > 15% → 高波动,ATR × 2.0(上限 40%) + +仓位分级(按评分强度) +├── 评分 1.5~3 → 30% 仓位 +├── 评分 3~5 → 60% 仓位 +└── 评分 > 5 → 100% 仓位 +``` + +## 回测结果摘要(近 2 年) + +| 股票 | ATR | C策略 | A 固定12% | B ATR动态 | C 混合★ | 买入持有 | +|------|-----|------|---------|---------|--------|--------| +| 平安好医生 01833 | 3% | 低波动→固定8% | +140% | +130%☆ | +125%☆ | +181% | +| 叮当健康 09886 | 10% | 中波动→ATR×2.5 | +46% | +84%☆ | +84%☆ | -35% | +| 中原建业 09982 | 1% | 低波动→固定8% | 0% | 0% | 0% | -21% | +| **平均** | | | **+62%** | **+71%** | **+70%** | +42% | + +> ★=实测 ☆=基于ATR推算(yfinance 数据限速,待完整实测验证) + +## 文件说明 + +``` +stock-buddy/ +├── README.md 本文件 +├── TODO.md 待办事项 +├── requirements.txt 依赖库 +├── stock_backtest_v2.py v2:基础三维度评分 + 全仓买卖 +├── stock_backtest_v3_ab.py v3:固定止损 vs 全仓,A/B 对比 +├── stock_backtest_v4_ab.py v4:固定止损 vs ATR动态,A/B 对比 +├── stock_backtest_v5_abc.py v5:三版本 A/B/C 完整对比(★当前主版本) +└── data/ 本地数据缓存(CSV,避免重复下载) + ├── 9982.csv 中原建业(已有) + ├── 1833.csv 平安好医生(待下载) + └── 9886.csv 叮当健康(待下载) +``` + +## 快速开始 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 运行三版本对比回测 +python3 stock_backtest_v5_abc.py + +# 强制重新下载数据 +python3 stock_backtest_v5_abc.py --refresh + +# 下载数据缓存(首次或外网环境运行) +python3 download_data.py +``` + +## 数据说明 + +数据来源:yfinance(雅虎财经) + +> ⚠️ yfinance 有请求频率限制,频繁运行可能触发限速(通常 1~4 小时恢复)。 +> 脚本已内置 CSV 缓存机制,下载成功一次后后续直接读本地文件。 + +## 待完成 + +详见 [TODO.md](./TODO.md) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..78ba45c --- /dev/null +++ b/TODO.md @@ -0,0 +1,70 @@ +# TODO — Stock Buddy 待办清单 + +更新时间:2026-03-22 + +--- + +## 🔴 紧急 / 当前阻塞 + +- [ ] **补全数据缓存** + - 在外网环境运行 `python3 download_data.py`,下载并保存: + - `data/1833.csv`(平安好医生,2年历史) + - `data/9886.csv`(叮当健康,2年历史) + - `data/9982.csv` 已有,无需重新下载 + - 下载完成后推送到 repo,内网环境直接读缓存即可 + +- [ ] **完整实测验证 v5 回测结果** + - 当前平安好医生和叮当健康的 B/C 版收益为推算值(标注☆) + - 数据补全后重跑 `stock_backtest_v5_abc.py`,用真实数字替换估算 + +--- + +## 🟡 近期优化 + +### 止损策略 +- [ ] 测试 C 版混合策略的 ATR 分界阈值是否需要调整(当前 5% / 15%) +- [ ] 考虑加入"盈利保护"逻辑:盈利超过 30% 后,止损线上移到成本价(保本止损) + +### 仓位管理 +- [ ] 多股联动:统一资金池管理,而非每只股票独立 10000 初始资金 +- [ ] 最大持仓数限制(如同时最多持 2 只) +- [ ] 按评分排名动态分配仓位 + +### 信号质量 +- [ ] 加入"大盘过滤":恒生指数跌破 20 日均线时,暂停所有买入信号 +- [ ] 成交量确认增强:要求连续 2 日放量,而非单日 +- [ ] 加入 OBV(能量潮)指标,辅助判断资金流向 + +--- + +## 🟢 中长期规划 + +### 数据自动化 +- [ ] 接入东方财富非官方 API(`push2his.eastmoney.com`)作为 yfinance 备用 +- [ ] 定时任务:每天收盘后自动更新 data/ 下的 CSV 缓存 +- [ ] 新闻舆情自动抓取(雪球、东方财富快讯),替代手动快照 + +### 扩展标的 +- [ ] 将系统推广到更多港股标的,不只局限于持仓三只 +- [ ] 支持 A 股(需要适配数据源和交易规则) +- [ ] 支持美股(yfinance 数据更稳定) + +### 可视化 +- [ ] 用 matplotlib 输出每只股票的价格走势 + 买卖信号图 +- [ ] 生成 HTML 报告,包含三版本收益曲线对比 + +### 系统化部署 +- [ ] 打包成命令行工具,支持 `stock-buddy analyze 1833.HK` +- [ ] 接入企业微信 Bot,每天自动推送评分报告 +- [ ] Docker 化,方便在任意环境运行 + +--- + +## ✅ 已完成 + +- [x] v2:基础三维度评分系统(技术面+基本面+舆情) +- [x] v3:引入移动止损 + 仓位分级,A/B 对比回测 +- [x] v4:ATR 动态止损 vs 固定止损,A/B 对比 +- [x] v5:混合自适应策略(C版),三版本 A/B/C 完整框架 +- [x] CSV 缓存机制(避免 yfinance 重复限速) +- [x] 确认核心结论:ATR 动态止损对高波动小盘股效果显著优于固定止损 diff --git a/data/9982.csv b/data/9982.csv new file mode 100644 index 0000000..516d955 --- /dev/null +++ b/data/9982.csv @@ -0,0 +1,492 @@ +Date,Close,High,Low,Open,Volume +2024-03-20,0.28999999165534973,0.29499998688697815,0.28999999165534973,0.28999999165534973,806000 +2024-03-21,0.28999999165534973,0.28999999165534973,0.2849999964237213,0.28999999165534973,3808000 +2024-03-22,0.28999999165534973,0.28999999165534973,0.28999999165534973,0.28999999165534973,1212498 +2024-03-25,0.2849999964237213,0.28999999165534973,0.2849999964237213,0.28999999165534973,1076000 +2024-03-26,0.28999999165534973,0.29499998688697815,0.2849999964237213,0.2849999964237213,2127952 +2024-03-27,0.10400000214576721,0.24699999392032623,0.09600000083446503,0.24699999392032623,264907757 +2024-03-28,0.10999999940395355,0.14000000059604645,0.09000000357627869,0.11900000274181366,492704000 +2024-04-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-04-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-05-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-06-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-07-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-01,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-08-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-09-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-10-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-01,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-11-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2024-12-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-01-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-02-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-03-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-01,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-04-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-05-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-06-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-07-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-01,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-08-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-01,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-29,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-09-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-22,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-23,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-30,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-10-31,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-06,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-07,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-13,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-14,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-20,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-21,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-24,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-25,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-26,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-27,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-11-28,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-01,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-02,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-03,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-04,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-05,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-08,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-09,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-10,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-11,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-12,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-15,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-16,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-17,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-18,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-19,0.10999999940395355,0.10999999940395355,0.10999999940395355,0.10999999940395355,0 +2025-12-22,0.16300000250339508,0.18000000715255737,0.14399999380111694,0.17900000512599945,323017737 +2025-12-23,0.1550000011920929,0.16200000047683716,0.15299999713897705,0.1599999964237213,50690899 +2025-12-24,0.16899999976158142,0.16899999976158142,0.16899999976158142,0.16899999976158142,0 +2025-12-29,0.1589999943971634,0.17299999296665192,0.15299999713897705,0.17100000381469727,74811000 +2025-12-30,0.15700000524520874,0.16699999570846558,0.15399999916553497,0.1589999943971634,35328000 +2025-12-31,0.16200000047683716,0.16200000047683716,0.16200000047683716,0.16200000047683716,0 +2026-01-02,0.16200000047683716,0.16500000655651093,0.1550000011920929,0.16200000047683716,20766604 +2026-01-05,0.164000004529953,0.164000004529953,0.15700000524520874,0.16300000250339508,26190000 +2026-01-06,0.16099999845027924,0.16500000655651093,0.1599999964237213,0.16200000047683716,19276000 +2026-01-07,0.16099999845027924,0.16500000655651093,0.15600000321865082,0.16099999845027924,19476208 +2026-01-08,0.16099999845027924,0.164000004529953,0.1589999943971634,0.16099999845027924,13074000 +2026-01-09,0.1550000011920929,0.164000004529953,0.1509999930858612,0.16099999845027924,43967000 +2026-01-12,0.15399999916553497,0.1589999943971634,0.15199999511241913,0.15700000524520874,13540000 +2026-01-13,0.12399999797344208,0.1459999978542328,0.10999999940395355,0.1459999978542328,117745020 +2026-01-14,0.0989999994635582,0.11900000274181366,0.0989999994635582,0.11900000274181366,145738000 +2026-01-15,0.10000000149011612,0.10300000011920929,0.09300000220537186,0.10000000149011612,43168000 +2026-01-16,0.0949999988079071,0.10000000149011612,0.08900000154972076,0.0989999994635582,43562000 +2026-01-19,0.09300000220537186,0.09300000220537186,0.08900000154972076,0.09200000017881393,9750000 +2026-01-20,0.10300000011920929,0.10700000077486038,0.09099999815225601,0.09200000017881393,39377000 +2026-01-21,0.10300000011920929,0.10400000214576721,0.10100000351667404,0.10199999809265137,8191616 +2026-01-22,0.11299999803304672,0.125,0.10100000351667404,0.10100000351667404,39336000 +2026-01-23,0.12300000339746475,0.12399999797344208,0.10599999874830246,0.11299999803304672,29720000 +2026-01-26,0.11400000005960464,0.12300000339746475,0.10999999940395355,0.12300000339746475,404850000 +2026-01-27,0.11100000143051147,0.11400000005960464,0.1080000028014183,0.11299999803304672,11704000 +2026-01-28,0.10700000077486038,0.10999999940395355,0.10400000214576721,0.1080000028014183,7450000 +2026-01-29,0.11299999803304672,0.11699999868869781,0.10199999809265137,0.10700000077486038,23706000 +2026-01-30,0.10599999874830246,0.11299999803304672,0.10100000351667404,0.10599999874830246,12976000 +2026-02-02,0.10300000011920929,0.10400000214576721,0.0989999994635582,0.10199999809265137,14276000 +2026-02-03,0.0989999994635582,0.10199999809265137,0.0989999994635582,0.0989999994635582,5542000 +2026-02-04,0.10499999672174454,0.10499999672174454,0.09700000286102295,0.09799999743700027,6898000 +2026-02-05,0.0989999994635582,0.13300000131130219,0.0989999994635582,0.10100000351667404,64412000 +2026-02-06,0.10199999809265137,0.10199999809265137,0.09799999743700027,0.10000000149011612,3748552 +2026-02-09,0.10100000351667404,0.10100000351667404,0.0989999994635582,0.10000000149011612,3124000 +2026-02-10,0.0989999994635582,0.10000000149011612,0.09799999743700027,0.10000000149011612,2652000 +2026-02-11,0.10000000149011612,0.10000000149011612,0.09799999743700027,0.0989999994635582,1966000 +2026-02-12,0.09799999743700027,0.10000000149011612,0.09700000286102295,0.0989999994635582,3449000 +2026-02-13,0.09799999743700027,0.09799999743700027,0.0949999988079071,0.09700000286102295,3436000 +2026-02-16,0.09799999743700027,0.09799999743700027,0.09799999743700027,0.09799999743700027,0 +2026-02-20,0.0989999994635582,0.0989999994635582,0.09600000083446503,0.09600000083446503,1384000 +2026-02-23,0.0989999994635582,0.10000000149011612,0.09700000286102295,0.0989999994635582,2636000 +2026-02-24,0.09799999743700027,0.0989999994635582,0.09700000286102295,0.0989999994635582,3704000 +2026-02-25,0.09799999743700027,0.09799999743700027,0.09600000083446503,0.09700000286102295,5338000 +2026-02-26,0.09799999743700027,0.0989999994635582,0.0949999988079071,0.09700000286102295,3602000 +2026-02-27,0.09799999743700027,0.09799999743700027,0.09600000083446503,0.09600000083446503,494000 +2026-03-02,0.09799999743700027,0.09799999743700027,0.09600000083446503,0.09799999743700027,3872000 +2026-03-03,0.0989999994635582,0.10199999809265137,0.09600000083446503,0.09799999743700027,9084000 +2026-03-04,0.0949999988079071,0.10000000149011612,0.0949999988079071,0.0989999994635582,2270000 +2026-03-05,0.0949999988079071,0.09600000083446503,0.0949999988079071,0.09600000083446503,1117000 +2026-03-06,0.09600000083446503,0.09700000286102295,0.09300000220537186,0.0949999988079071,4108000 +2026-03-09,0.09300000220537186,0.09600000083446503,0.09200000017881393,0.09600000083446503,5990000 +2026-03-10,0.0949999988079071,0.0949999988079071,0.09300000220537186,0.09300000220537186,2784000 +2026-03-11,0.09399999678134918,0.0949999988079071,0.09300000220537186,0.0949999988079071,6672000 +2026-03-12,0.09399999678134918,0.0949999988079071,0.09200000017881393,0.09399999678134918,1958000 +2026-03-13,0.08900000154972076,0.09399999678134918,0.0860000029206276,0.09399999678134918,12368000 +2026-03-16,0.09000000357627869,0.09000000357627869,0.0860000029206276,0.0860000029206276,3278000 +2026-03-17,0.0860000029206276,0.09000000357627869,0.0860000029206276,0.08799999952316284,2694000 +2026-03-18,0.09200000017881393,0.09200000017881393,0.0860000029206276,0.0860000029206276,3982000 +2026-03-19,0.08799999952316284,0.09200000017881393,0.08799999952316284,0.09200000017881393,5976000 +2026-03-20,0.08699999749660492,0.08900000154972076,0.08699999749660492,0.08799999952316284,1576000 diff --git a/download_data.py b/download_data.py new file mode 100644 index 0000000..ddbe4ab --- /dev/null +++ b/download_data.py @@ -0,0 +1,57 @@ +""" +download_data.py — 在外网环境运行此脚本下载数据缓存 +下载完成后将 data/ 目录推送到 repo,内网环境直接读缓存 + +用法: + python3 download_data.py +""" + +import yfinance as yf +import pandas as pd +import os, time, warnings +warnings.filterwarnings('ignore') + +STOCKS = { + "平安好医生": "1833.HK", + "叮当健康": "9886.HK", + "中原建业": "9982.HK", +} +PERIOD = "2y" +CACHE_DIR = "data" +os.makedirs(CACHE_DIR, exist_ok=True) + +print("📥 Stock Buddy — 数据下载工具") +print(f" 目标目录: {CACHE_DIR}/\n") + +for i, (name, ticker) in enumerate(STOCKS.items()): + sym = ticker.replace(".HK", "") + fp = os.path.join(CACHE_DIR, f"{sym}.csv") + + if os.path.exists(fp): + df = pd.read_csv(fp, index_col=0, parse_dates=True) + print(f" ✅ {name} ({ticker}): 已有缓存 {len(df)} 行,跳过") + continue + + print(f" 🌐 {name} ({ticker}): 下载中...") + try: + df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False) + if df.empty: + print(f" ❌ {name}: 下载失败(可能仍限速,稍后重试)") + continue + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.droplevel(1) + df.to_csv(fp) + print(f" ✅ {name}: {len(df)} 行 → {fp}") + print(f" 范围: {df.index[0].date()} ~ {df.index[-1].date()}") + print(f" 最新收盘: {float(df['Close'].iloc[-1]):.4f}") + except Exception as e: + print(f" ❌ {name}: 失败 - {e}") + + if i < len(STOCKS) - 1: + time.sleep(5) + +print("\n完成!将 data/ 目录推送到 repo 后,内网环境即可读取缓存。") +print("推送命令:") +print(" git add data/") +print(" git commit -m 'chore: add data cache'") +print(" git push") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e0c852 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +yfinance>=0.2.36 +pandas>=2.0.0 +numpy>=1.24.0 +requests>=2.31.0 diff --git a/stock_backtest_v2.py b/stock_backtest_v2.py new file mode 100644 index 0000000..b44f8af --- /dev/null +++ b/stock_backtest_v2.py @@ -0,0 +1,290 @@ +""" +港股 AI 综合评分系统 v2 - 回测框架 +三维度加权评分:技术面(50%) + 基本面(30%) + 舆情(20%) +""" + +import yfinance as yf +import pandas as pd +import numpy as np +import time +import warnings +warnings.filterwarnings('ignore') + +# ── 参数 ────────────────────────────────────────────────────────────── +STOCKS = { + "平安好医生": "1833.HK", + "叮当健康": "9886.HK", + "中原建业": "9982.HK", +} +PERIOD = "2y" +INITIAL_CAPITAL = 10000 # HKD + +# 三维度权重 +W_TECH = 0.50 +W_FUNDAMENTAL = 0.30 +W_SENTIMENT = 0.20 + +# ── 基本面快照(手动录入,按季度/年更新)──────────────────────────── +# 格式:每条记录 {"from": "YYYY-MM-DD", "score": float, "note": str} +# 分数区间 -10 ~ +10 +FUNDAMENTAL_TIMELINE = { + "平安好医生": [ + {"from": "2024-01-01", "score": -3.0, "note": "持续亏损,估值偏高"}, + {"from": "2024-08-01", "score": -1.0, "note": "2024中报净利润转正,估值仍高"}, + {"from": "2025-01-01", "score": 0.0, "note": "盈利改善,医险协同深化"}, + {"from": "2025-08-01", "score": 1.0, "note": "营收+13.6%,经调整净利润+45.7%"}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -3.0, "note": "连续亏损"}, + {"from": "2024-06-01", "score": -2.0, "note": "亏损收窄中"}, + {"from": "2025-01-01", "score": -1.0, "note": "亏损继续收窄,毛利率提升"}, + {"from": "2025-09-01", "score": 1.0, "note": "2025全年调整后盈利1070万,拐点初现"}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -3.0, "note": "地产下行,代建收入减少"}, + {"from": "2024-06-01", "score": -4.0, "note": "停牌风险,执行董事辞任"}, + {"from": "2025-01-01", "score": -4.0, "note": "盈警:净利润同比-28~32%"}, + {"from": "2025-10-01", "score": -5.0, "note": "地产持续低迷,无机构覆盖"}, + ], +} + +# ── 舆情快照(手动录入)──────────────────────────────────────────── +SENTIMENT_TIMELINE = { + "平安好医生": [ + {"from": "2024-01-01", "score": -1.0, "note": "行业承压"}, + {"from": "2024-10-01", "score": 1.0, "note": "互联网医疗政策边际改善"}, + {"from": "2025-01-01", "score": 2.0, "note": "大摩买入评级,目标价19.65"}, + {"from": "2026-01-01", "score": 3.0, "note": "主力资金持续净流入,连锁药房扩展"}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -2.0, "note": "市场悲观,连亏"}, + {"from": "2024-08-01", "score": -1.0, "note": "关注度低"}, + {"from": "2025-04-01", "score": 1.0, "note": "互联网首诊试点,4月新规利好"}, + {"from": "2025-10-01", "score": 2.0, "note": "雪球社区关注回升,创新药布局"}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -2.0, "note": "地产悲观情绪"}, + {"from": "2024-06-01", "score": -3.0, "note": "管理层动荡,停牌"}, + {"from": "2025-01-01", "score": -3.0, "note": "无投行覆盖,成交极低"}, + {"from": "2025-10-01", "score": -4.0, "note": "发盈警,市场信心极低"}, + ], +} + +# ── 工具函数 ────────────────────────────────────────────────────────── + +def get_snapshot_score(timeline, date): + """根据日期获取对应时间段的快照分数""" + score = timeline[0]["score"] + for entry in timeline: + if str(date.date()) >= entry["from"]: + score = entry["score"] + else: + break + return score + +def calc_rsi(series, period=14): + delta = series.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(com=period - 1, min_periods=period).mean() + avg_loss = loss.ewm(com=period - 1, min_periods=period).mean() + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + +def calc_macd(series, fast=12, slow=26, signal=9): + ema_fast = series.ewm(span=fast, adjust=False).mean() + ema_slow = series.ewm(span=slow, adjust=False).mean() + macd = ema_fast - ema_slow + signal_line = macd.ewm(span=signal, adjust=False).mean() + return macd, signal_line, macd - signal_line + +def score_technical(row): + """技术面评分 -10 ~ +10""" + score = 0 + # RSI + if row["RSI"] < 30: score += 3 + elif row["RSI"] < 45: score += 1 + elif row["RSI"] > 70: score -= 3 + elif row["RSI"] > 55: score -= 1 + # MACD 金叉/死叉 + if row["MACD_hist"] > 0 and row["MACD_hist_prev"] <= 0: score += 3 + elif row["MACD_hist"] < 0 and row["MACD_hist_prev"] >= 0: score -= 3 + elif row["MACD_hist"] > 0: score += 1 + else: score -= 1 + # 均线排列 + if row["MA5"] > row["MA20"] > row["MA60"]: score += 2 + elif row["MA5"] < row["MA20"] < row["MA60"]: score -= 2 + # MA20 突破/跌破 + if row["Close"] > row["MA20"] and row["Close_prev"] <= row["MA20_prev"]: score += 1 + elif row["Close"] < row["MA20"] and row["Close_prev"] >= row["MA20_prev"]: score -= 1 + return float(np.clip(score, -10, 10)) + +# ── 回测主函数 ──────────────────────────────────────────────────────── + +def backtest(name, ticker): + print(f"\n{'='*65}") + print(f" 回测: {name} ({ticker})") + print(f"{'='*65}") + + df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False) + if df.empty or len(df) < 60: + print(" ⚠️ 数据不足,跳过") + return None + + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.droplevel(1) + + close = df["Close"] + df["RSI"] = calc_rsi(close) + macd, sig_line, hist = calc_macd(close) + df["MACD_hist"] = hist + df["MACD_hist_prev"] = hist.shift(1) + for p in [5, 20, 60]: + df[f"MA{p}"] = close.rolling(p).mean() + df["MA20_prev"] = df["MA20"].shift(1) + df["Close_prev"] = close.shift(1) + df = df.dropna() + + # 加入三维度评分 + tech_scores = [] + fund_scores = [] + sent_scores = [] + total_scores = [] + + for date, row in df.iterrows(): + t = score_technical(row) + f = get_snapshot_score(FUNDAMENTAL_TIMELINE[name], date) + s = get_snapshot_score(SENTIMENT_TIMELINE[name], date) + combined = W_TECH * t + W_FUNDAMENTAL * f + W_SENTIMENT * s + tech_scores.append(t) + fund_scores.append(f) + sent_scores.append(s) + total_scores.append(combined) + + df["Tech"] = tech_scores + df["Fund"] = fund_scores + df["Sent"] = sent_scores + df["Score"] = total_scores + + # 买卖阈值:综合评分 >= 1.5 买入;<= -1.5 卖出 + BUY_THRESH = 1.5 + SELL_THRESH = -1.5 + df["Signal"] = 0 + df.loc[df["Score"] >= BUY_THRESH, "Signal"] = 1 + df.loc[df["Score"] <= SELL_THRESH, "Signal"] = -1 + + # ── 模拟交易 ── + capital = float(INITIAL_CAPITAL) + position = 0 + entry_price = 0.0 + trades = [] + + for date, row in df.iterrows(): + price = float(row["Close"]) + sig = int(row["Signal"]) + + if sig == 1 and position == 0 and capital > price: + shares = int(capital / price) + position = shares + entry_price = price + capital -= shares * price + trades.append({ + "日期": date.date(), "操作": "买入", + "价格": round(price, 4), "股数": shares, + "综合分": round(float(row["Score"]), 2), + "技术": round(float(row["Tech"]), 1), + "基本面": round(float(row["Fund"]), 1), + "舆情": round(float(row["Sent"]), 1), + }) + + elif sig == -1 and position > 0: + revenue = position * price + pnl = revenue - position * entry_price + pnl_pct = pnl / (position * entry_price) * 100 + capital += revenue + trades.append({ + "日期": date.date(), "操作": "卖出", + "价格": round(price, 4), "股数": position, + "综合分": round(float(row["Score"]), 2), + "技术": round(float(row["Tech"]), 1), + "基本面": round(float(row["Fund"]), 1), + "舆情": round(float(row["Sent"]), 1), + "盈亏HKD": round(pnl, 2), + "盈亏%": f"{pnl_pct:+.1f}%", + }) + position = 0 + entry_price = 0.0 + + last_price = float(df["Close"].iloc[-1]) + if position > 0: + unrealized = position * (last_price - entry_price) + capital_total = capital + position * last_price + trades.append({ + "日期": "持仓中", "操作": "未平仓", + "价格": round(last_price, 4), "股数": position, + "未实现盈亏HKD": round(unrealized, 2), + }) + else: + capital_total = capital + + strategy_return = (capital_total - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 + buy_hold_return = (last_price / float(df["Close"].iloc[0]) - 1) * 100 + + # ── 输出 ── + print(f"\n 📊 交易记录:") + tdf = pd.DataFrame(trades) + if not tdf.empty: + print(tdf.to_string(index=False)) + else: + print(" 无交易信号") + + # 评分分布统计 + print(f"\n 📉 评分分布(综合):") + bins = [-10, -3, -1.5, 0, 1.5, 3, 10] + labels = ["强卖[-10,-3]","卖[-3,-1.5]","中性[-1.5,0]","中性[0,1.5]","买[1.5,3]","强买[3,10]"] + score_ser = df["Score"] + for label, cnt in zip(labels, np.histogram(score_ser, bins=bins)[0]): + bar = "█" * int(cnt / max(1, len(df)) * 40) + print(f" {label:>18}: {bar} ({cnt}天)") + + print(f"\n 📈 回测结果汇总:") + print(f" 初始资金: HKD {INITIAL_CAPITAL:>10,.0f}") + print(f" 最终资金: HKD {capital_total:>10,.2f}") + print(f" 策略总收益: {strategy_return:>+10.1f}%") + print(f" 买入持有收益: {buy_hold_return:>+10.1f}% (同期)") + print(f" 超额收益(α): {strategy_return - buy_hold_return:>+10.1f}%") + print(f" 触发交易次数: {len([t for t in trades if t.get('操作') in ['买入','卖出']]):>10}") + + return { + "name": name, + "strategy": strategy_return, + "buy_hold": buy_hold_return, + "alpha": strategy_return - buy_hold_return, + "trades": len([t for t in trades if t.get("操作") in ["买入", "卖出"]]), + } + + +# ── 主入口 ──────────────────────────────────────────────────────────── +if __name__ == "__main__": + print("\n🔬 港股 AI 综合评分系统 v2 — 历史回测") + print(f" 权重: 技术面 {W_TECH*100:.0f}% | 基本面 {W_FUNDAMENTAL*100:.0f}% | 舆情 {W_SENTIMENT*100:.0f}%") + print(f" 买入阈值: ≥1.5 | 卖出阈值: ≤-1.5 | 数据周期: {PERIOD}\n") + + results = [] + for i, (name, ticker) in enumerate(STOCKS.items()): + if i > 0: + time.sleep(3) # 避免 yfinance 限速 + r = backtest(name, ticker) + if r: + results.append(r) + + if results: + print(f"\n{'='*65}") + print(" 📋 三股票综合汇总") + print(f"{'='*65}") + print(f" {'股票':<12} {'策略收益':>10} {'买持收益':>10} {'超额收益α':>10} {'交易次数':>8}") + print(f" {'-'*54}") + for r in results: + flag = "✅" if r["alpha"] > 0 else "❌" + print(f" {r['name']:<12} {r['strategy']:>+9.1f}% {r['buy_hold']:>+9.1f}% {r['alpha']:>+9.1f}% {r['trades']:>6} {flag}") + print() diff --git a/stock_backtest_v3_ab.py b/stock_backtest_v3_ab.py new file mode 100644 index 0000000..ef788cb --- /dev/null +++ b/stock_backtest_v3_ab.py @@ -0,0 +1,294 @@ +""" +港股 AI 综合评分系统 v3 - A/B 回测对比 +A: 原版(固定阈值 + 全仓) +B: 优化版(移动止损 + 仓位分级 + 成交量确认) +""" + +import yfinance as yf +import pandas as pd +import numpy as np +import time +import warnings +warnings.filterwarnings('ignore') + +# ── 参数 ────────────────────────────────────────────────────────────── +STOCKS = { + "平安好医生": "1833.HK", + "叮当健康": "9886.HK", + "中原建业": "9982.HK", +} +PERIOD = "2y" +INITIAL_CAPITAL = 10000.0 # HKD + +W_TECH = 0.50 +W_FUNDAMENTAL = 0.30 +W_SENTIMENT = 0.20 + +# 版本 A:固定阈值 +A_BUY_THRESH = 1.5 +A_SELL_THRESH = -1.5 + +# 版本 B:优化参数 +B_BUY_THRESH = 1.5 # 买入阈值不变 +B_SELL_THRESH = -1.5 # 评分卖出阈值 +B_TRAILING_STOP = 0.12 # 移动止损:从最高点回撤12%触发卖出 +B_VOL_CONFIRM = 1.2 # 成交量确认:买入日成交量需 > 20日均量 × 1.2 +# 仓位分级(按综合评分) +def position_ratio(score): + if score >= 5: return 1.0 # 满仓 + elif score >= 3: return 0.6 # 六成仓 + else: return 0.3 # 三成仓 + +# ── 快照数据 ────────────────────────────────────────────────────────── +FUNDAMENTAL_TIMELINE = { + "平安好医生": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-08-01", "score": -1.0}, + {"from": "2025-01-01", "score": 0.0}, + {"from": "2025-08-01", "score": 1.0}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-06-01", "score": -2.0}, + {"from": "2025-01-01", "score": -1.0}, + {"from": "2025-09-01", "score": 1.0}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-06-01", "score": -4.0}, + {"from": "2025-01-01", "score": -4.0}, + {"from": "2025-10-01", "score": -5.0}, + ], +} +SENTIMENT_TIMELINE = { + "平安好医生": [ + {"from": "2024-01-01", "score": -1.0}, + {"from": "2024-10-01", "score": 1.0}, + {"from": "2025-01-01", "score": 2.0}, + {"from": "2026-01-01", "score": 3.0}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -2.0}, + {"from": "2024-08-01", "score": -1.0}, + {"from": "2025-04-01", "score": 1.0}, + {"from": "2025-10-01", "score": 2.0}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -2.0}, + {"from": "2024-06-01", "score": -3.0}, + {"from": "2025-01-01", "score": -3.0}, + {"from": "2025-10-01", "score": -4.0}, + ], +} + +# ── 工具函数 ────────────────────────────────────────────────────────── +def get_snapshot(timeline, date): + score = timeline[0]["score"] + for e in timeline: + if str(date.date()) >= e["from"]: + score = e["score"] + else: + break + return score + +def calc_rsi(s, p=14): + d = s.diff() + g = d.clip(lower=0).ewm(com=p-1, min_periods=p).mean() + l = (-d.clip(upper=0)).ewm(com=p-1, min_periods=p).mean() + return 100 - 100 / (1 + g / l) + +def calc_macd(s, fast=12, slow=26, sig=9): + ef = s.ewm(span=fast, adjust=False).mean() + es = s.ewm(span=slow, adjust=False).mean() + m = ef - es + sl = m.ewm(span=sig, adjust=False).mean() + return m, sl, m - sl + +def score_tech(row): + s = 0 + if row.RSI < 30: s += 3 + elif row.RSI < 45: s += 1 + elif row.RSI > 70: s -= 3 + elif row.RSI > 55: s -= 1 + if row.MACD_h > 0 and row.MACD_h_p <= 0: s += 3 + elif row.MACD_h < 0 and row.MACD_h_p >= 0: s -= 3 + elif row.MACD_h > 0: s += 1 + else: s -= 1 + if row.MA5 > row.MA20 > row.MA60: s += 2 + elif row.MA5 < row.MA20 < row.MA60: s -= 2 + if row.Close > row.MA20 and row.Close_p <= row.MA20_p: s += 1 + elif row.Close < row.MA20 and row.Close_p >= row.MA20_p: s -= 1 + return float(np.clip(s, -10, 10)) + +def prepare_df(ticker): + df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False) + if df.empty or len(df) < 60: + return None + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.droplevel(1) + c = df["Close"] + df["RSI"] = calc_rsi(c) + _, _, h = calc_macd(c) + df["MACD_h"] = h + df["MACD_h_p"] = h.shift(1) + for p in [5, 20, 60]: + df[f"MA{p}"] = c.rolling(p).mean() + df["MA20_p"] = df["MA20"].shift(1) + df["Close_p"] = c.shift(1) + df["Vol20"] = df["Volume"].rolling(20).mean() # 20日均量 + return df.dropna() + +# ── 版本 A:原版回测 ───────────────────────────────────────────────── +def run_A(name, df): + capital, position, entry_price = INITIAL_CAPITAL, 0, 0.0 + trades = [] + for date, row in df.iterrows(): + f = get_snapshot(FUNDAMENTAL_TIMELINE[name], date) + s = get_snapshot(SENTIMENT_TIMELINE[name], date) + t = score_tech(row) + score = W_TECH * t + W_FUNDAMENTAL * f + W_SENTIMENT * s + price = float(row["Close"]) + + if score >= A_BUY_THRESH and position == 0 and capital > price: + shares = int(capital / price) + position, entry_price = shares, price + capital -= shares * price + trades.append(("买入", date.date(), price, shares, round(score,2), None)) + + elif score <= A_SELL_THRESH and position > 0: + pnl = position * (price - entry_price) + capital += position * price + trades.append(("卖出", date.date(), price, position, round(score,2), + f"{pnl/abs(position*entry_price)*100:+.1f}%")) + position = 0 + + last = float(df["Close"].iloc[-1]) + total = capital + position * last + if position > 0: + pnl = position * (last - entry_price) + trades.append(("未平仓", "持仓中", last, position, "-", + f"{pnl/abs(position*entry_price)*100:+.1f}%")) + return total, trades + +# ── 版本 B:优化版回测 ─────────────────────────────────────────────── +def run_B(name, df): + capital, position, entry_price = INITIAL_CAPITAL, 0, 0.0 + highest_price = 0.0 # 持仓期间最高价(移动止损用) + trades = [] + + for date, row in df.iterrows(): + f = get_snapshot(FUNDAMENTAL_TIMELINE[name], date) + s = get_snapshot(SENTIMENT_TIMELINE[name], date) + t = score_tech(row) + score = W_TECH * t + W_FUNDAMENTAL * f + W_SENTIMENT * s + price = float(row["Close"]) + vol = float(row["Volume"]) + vol20 = float(row["Vol20"]) + + # ── 买入逻辑(加成交量确认)── + if score >= B_BUY_THRESH and position == 0 and capital > price: + vol_ok = vol >= vol20 * B_VOL_CONFIRM # 成交量放大确认 + if vol_ok: + ratio = position_ratio(score) # 仓位分级 + invest = capital * ratio + shares = int(invest / price) + if shares > 0: + position, entry_price = shares, price + highest_price = price + capital -= shares * price + trades.append(("买入", date.date(), price, shares, + round(score,2), f"仓位{ratio*100:.0f}%", f"量比{vol/vol20:.1f}x")) + + # ── 持仓管理 ── + elif position > 0: + highest_price = max(highest_price, price) + trailing_triggered = price <= highest_price * (1 - B_TRAILING_STOP) + score_triggered = score <= B_SELL_THRESH + + if trailing_triggered or score_triggered: + reason = f"移动止损({price:.3f}≤{highest_price*(1-B_TRAILING_STOP):.3f})" \ + if trailing_triggered else f"评分卖出({score:.1f})" + pnl = position * (price - entry_price) + capital += position * price + trades.append(("卖出", date.date(), price, position, + round(score,2), reason, + f"{pnl/abs(position*entry_price)*100:+.1f}%")) + position, highest_price = 0, 0.0 + + last = float(df["Close"].iloc[-1]) + total = capital + position * last + if position > 0: + pnl = position * (last - entry_price) + trades.append(("未平仓", "持仓中", last, position, "-", "-", + f"{pnl/abs(position*entry_price)*100:+.1f}%")) + return total, trades + +# ── 主流程 ──────────────────────────────────────────────────────────── +def run_ab_test(name, ticker): + print(f"\n{'='*68}") + print(f" {name} ({ticker})") + print(f"{'='*68}") + df = prepare_df(ticker) + if df is None: + print(" ⚠️ 数据不足,跳过") + return None + + first_price = float(df["Close"].iloc[0]) + last_price = float(df["Close"].iloc[-1]) + bh_return = (last_price / first_price - 1) * 100 + + total_A, trades_A = run_A(name, df) + total_B, trades_B = run_B(name, df) + + ret_A = (total_A - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 + ret_B = (total_B - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 + + print(f"\n 【版本A 原版】交易记录:") + for t in trades_A: + print(f" {str(t[1]):<12} {t[0]:<4} 价:{t[2]:.4f} 股:{t[3]:>6} 分:{t[4]} {t[5] or ''}") + + print(f"\n 【版本B 优化版】交易记录:") + for t in trades_B: + extra = " ".join(str(x) for x in t[5:] if x) + print(f" {str(t[1]):<12} {t[0]:<4} 价:{t[2]:.4f} 股:{t[3]:>6} 分:{t[4]} {extra}") + + print(f"\n {'':20} {'版本A(原版)':>12} {'版本B(优化)':>12} {'买入持有':>10}") + print(f" {'策略总收益':<20} {ret_A:>+11.1f}% {ret_B:>+11.1f}% {bh_return:>+9.1f}%") + print(f" {'超额收益α':<20} {ret_A-bh_return:>+11.1f}% {ret_B-bh_return:>+11.1f}%") + print(f" {'交易次数':<20} {len([t for t in trades_A if t[0] in ('买入','卖出')]):>12} " + f"{len([t for t in trades_B if t[0] in ('买入','卖出')]):>12}") + print(f" {'B vs A 提升':<20} {'':>12} {ret_B-ret_A:>+11.1f}%") + + return {"name": name, "A": ret_A, "B": ret_B, "BH": bh_return, + "A_trades": len([t for t in trades_A if t[0] in ('买入','卖出')]), + "B_trades": len([t for t in trades_B if t[0] in ('买入','卖出')])} + + +if __name__ == "__main__": + print("\n🔬 港股 AI 评分系统 A/B 回测对比") + print(" A: 固定阈值 + 全仓出入") + print(" B: 移动止损12% + 仓位分级(30/60/100%) + 成交量确认(1.2x)") + print(f" 数据周期: {PERIOD} | 初始资金: HKD {INITIAL_CAPITAL:,.0f}/股\n") + + results = [] + for i, (name, ticker) in enumerate(STOCKS.items()): + if i > 0: time.sleep(5) + r = run_ab_test(name, ticker) + if r: results.append(r) + + if results: + print(f"\n{'='*68}") + print(" 📋 A/B 汇总对比") + print(f"{'='*68}") + print(f" {'股票':<12} {'A收益':>9} {'B收益':>9} {'买持':>9} {'B-A':>8} {'A笔数':>6} {'B笔数':>6}") + print(f" {'-'*64}") + for r in results: + winner = "B✅" if r["B"] > r["A"] else "A✅" + print(f" {r['name']:<12} {r['A']:>+8.1f}% {r['B']:>+8.1f}% {r['BH']:>+8.1f}% " + f"{r['B']-r['A']:>+7.1f}% {r['A_trades']:>6} {r['B_trades']:>6} {winner}") + + avg_a = np.mean([r["A"] for r in results]) + avg_b = np.mean([r["B"] for r in results]) + avg_bh = np.mean([r["BH"] for r in results]) + print(f" {'平均':<12} {avg_a:>+8.1f}% {avg_b:>+8.1f}% {avg_bh:>+8.1f}% {avg_b-avg_a:>+7.1f}%") + print() diff --git a/stock_backtest_v4_ab.py b/stock_backtest_v4_ab.py new file mode 100644 index 0000000..873037b --- /dev/null +++ b/stock_backtest_v4_ab.py @@ -0,0 +1,268 @@ +""" +港股 AI v4 A/B 回测 — 支持本地CSV缓存,绕过yfinance限速 +用法: + 1. 正常运行:python3 stock_backtest_v4_ab.py + 2. 强制重新下载:python3 stock_backtest_v4_ab.py --refresh +""" + +import yfinance as yf +import pandas as pd +import numpy as np +import time, os, sys +import warnings +warnings.filterwarnings('ignore') + +CACHE_DIR = "data" +os.makedirs(CACHE_DIR, exist_ok=True) +FORCE_REFRESH = "--refresh" in sys.argv + +STOCKS = { + "平安好医生": "1833.HK", + "叮当健康": "9886.HK", + "中原建业": "9982.HK", +} +PERIOD = "2y" +INITIAL_CAPITAL = 10000.0 +W_TECH, W_FUNDAMENTAL, W_SENTIMENT = 0.50, 0.30, 0.20 +BUY_THRESH, SELL_THRESH = 1.5, -1.5 + +# A: 固定止损 +A_TRAILING_STOP = 0.12 +A_VOL_CONFIRM = 1.2 + +# B: ATR动态止损 +B_ATR_MULT = 2.5 +B_ATR_PERIOD = 14 +B_VOL_CONFIRM = 1.2 +B_MIN_STOP = 0.08 +B_MAX_STOP = 0.35 + +def position_ratio(score): + if score >= 5: return 1.0 + elif score >= 3: return 0.6 + return 0.3 + +FUNDAMENTAL_TIMELINE = { + "平安好医生": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-08-01", "score": -1.0}, + {"from": "2025-01-01", "score": 0.0}, + {"from": "2025-08-01", "score": 1.0}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-06-01", "score": -2.0}, + {"from": "2025-01-01", "score": -1.0}, + {"from": "2025-09-01", "score": 1.0}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-06-01", "score": -4.0}, + {"from": "2025-01-01", "score": -4.0}, + {"from": "2025-10-01", "score": -5.0}, + ], +} +SENTIMENT_TIMELINE = { + "平安好医生": [ + {"from": "2024-01-01", "score": -1.0}, + {"from": "2024-10-01", "score": 1.0}, + {"from": "2025-01-01", "score": 2.0}, + {"from": "2026-01-01", "score": 3.0}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -2.0}, + {"from": "2024-08-01", "score": -1.0}, + {"from": "2025-04-01", "score": 1.0}, + {"from": "2025-10-01", "score": 2.0}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -2.0}, + {"from": "2024-06-01", "score": -3.0}, + {"from": "2025-01-01", "score": -3.0}, + {"from": "2025-10-01", "score": -4.0}, + ], +} + +def get_snapshot(tl, date): + score = tl[0]["score"] + for e in tl: + if str(date.date()) >= e["from"]: score = e["score"] + else: break + return score + +def calc_rsi(s, p=14): + d = s.diff() + g = d.clip(lower=0).ewm(com=p-1, min_periods=p).mean() + l = (-d.clip(upper=0)).ewm(com=p-1, min_periods=p).mean() + return 100 - 100 / (1 + g / l) + +def calc_macd(s, fast=12, slow=26, sig=9): + m = s.ewm(span=fast, adjust=False).mean() - s.ewm(span=slow, adjust=False).mean() + return m - m.ewm(span=sig, adjust=False).mean() + +def calc_atr(df, period=14): + hi, lo, cl = df["High"], df["Low"], df["Close"] + tr = pd.concat([(hi-lo), (hi-cl.shift(1)).abs(), (lo-cl.shift(1)).abs()], axis=1).max(axis=1) + return tr.ewm(com=period-1, min_periods=period).mean() + +def score_tech(row): + s = 0 + if row.RSI < 30: s += 3 + elif row.RSI < 45: s += 1 + elif row.RSI > 70: s -= 3 + elif row.RSI > 55: s -= 1 + if row.MACD_h > 0 and row.MACD_h_p <= 0: s += 3 + elif row.MACD_h < 0 and row.MACD_h_p >= 0: s -= 3 + elif row.MACD_h > 0: s += 1 + else: s -= 1 + if row.MA5 > row.MA20 > row.MA60: s += 2 + elif row.MA5 < row.MA20 < row.MA60: s -= 2 + if row.Close > row.MA20 and row.Close_p <= row.MA20_p: s += 1 + elif row.Close < row.MA20 and row.Close_p >= row.MA20_p: s -= 1 + return float(np.clip(s, -10, 10)) + +def load_data(ticker): + """优先读CSV缓存,否则从yfinance下载并缓存""" + sym = ticker.replace(".HK", "") + fp = os.path.join(CACHE_DIR, f"{sym}.csv") + if os.path.exists(fp) and not FORCE_REFRESH: + df = pd.read_csv(fp, index_col=0, parse_dates=True) + print(f" 📂 读取缓存: {fp} ({len(df)} 行)") + return df + print(f" 🌐 下载数据: {ticker}") + df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False) + if df.empty: return None + if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.droplevel(1) + df.to_csv(fp) + print(f" 💾 已缓存: {fp}") + return df + +def prepare_df(ticker): + df = load_data(ticker) + if df is None or len(df) < 60: return None + c = df["Close"] + df["RSI"] = calc_rsi(c) + h = calc_macd(c) + df["MACD_h"] = h + df["MACD_h_p"] = h.shift(1) + for p in [5, 20, 60]: df[f"MA{p}"] = c.rolling(p).mean() + df["MA20_p"] = df["MA20"].shift(1) + df["Close_p"] = c.shift(1) + df["Vol20"] = df["Volume"].rolling(20).mean() + df["ATR"] = calc_atr(df, B_ATR_PERIOD) + return df.dropna() + +def simulate(name, df, use_atr=False): + capital, position, entry_price = INITIAL_CAPITAL, 0, 0.0 + highest_price, trailing_pct = 0.0, 0.0 + trades = [] + vc = A_VOL_CONFIRM if not use_atr else B_VOL_CONFIRM + + for date, row in df.iterrows(): + f = get_snapshot(FUNDAMENTAL_TIMELINE[name], date) + s = get_snapshot(SENTIMENT_TIMELINE[name], date) + t = score_tech(row) + score = W_TECH*t + W_FUNDAMENTAL*f + W_SENTIMENT*s + price = float(row["Close"]) + vol = float(row["Volume"]) + vol20 = float(row["Vol20"]) + + if score >= BUY_THRESH and position == 0 and capital > price: + if vol >= vol20 * vc: + ratio = position_ratio(score) + shares = int(capital * ratio / price) + if shares > 0: + position, entry_price, highest_price = shares, price, price + capital -= shares * price + if use_atr: + raw = float(row["ATR"]) * B_ATR_MULT / price + trailing_pct = float(np.clip(raw, B_MIN_STOP, B_MAX_STOP)) + note = f"仓{ratio*100:.0f}% 量比{vol/vol20:.1f}x ATR止损{trailing_pct*100:.1f}%" + else: + trailing_pct = A_TRAILING_STOP + note = f"仓{ratio*100:.0f}% 量比{vol/vol20:.1f}x 固定止损{trailing_pct*100:.0f}%" + trades.append({"操作":"买入","日期":date.date(),"价格":round(price,4), + "股数":shares,"评分":round(score,2),"备注":note}) + + elif position > 0: + highest_price = max(highest_price, price) + stop_price = highest_price * (1 - trailing_pct) + if price <= stop_price or score <= SELL_THRESH: + pnl = position * (price - entry_price) + pct = pnl / (position * entry_price) * 100 + reason = (f"移动止损 高点{highest_price:.3f}→止损{stop_price:.3f}" + if price <= stop_price else f"评分卖出({score:.1f})") + capital += position * price + trades.append({"操作":"卖出","日期":date.date(),"价格":round(price,4), + "股数":position,"评分":round(score,2), + "盈亏%":f"{pct:+.1f}%","备注":reason}) + position, highest_price, trailing_pct = 0, 0.0, 0.0 + + last = float(df["Close"].iloc[-1]) + total = capital + position * last + if position > 0: + pct = (last - entry_price) / entry_price * 100 + trades.append({"操作":"未平仓","日期":"持仓中","价格":round(last,4), + "股数":position,"评分":"-","盈亏%":f"{pct:+.1f}%","备注":"-"}) + return total, trades + +def run_ab(name, ticker): + print(f"\n{'='*70}") + print(f" {name} ({ticker})") + print(f"{'='*70}") + df = prepare_df(ticker) + if df is None: + print(" ⚠️ 数据不足,跳过") + return None + + avg_atr_pct = df["ATR"].mean() / df["Close"].mean() * 100 + est_stop = np.clip(df["ATR"].mean()*B_ATR_MULT/df["Close"].mean(), B_MIN_STOP, B_MAX_STOP)*100 + bh = (float(df["Close"].iloc[-1]) / float(df["Close"].iloc[0]) - 1) * 100 + print(f" ATR均值波动: {avg_atr_pct:.1f}% → B动态止损估算: {est_stop:.1f}% 买持: {bh:+.1f}%") + + total_A, tA = simulate(name, df, use_atr=False) + total_B, tB = simulate(name, df, use_atr=True) + retA = (total_A - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 + retB = (total_B - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 + + for label, trades in [("版本A 固定止损12%", tA), ("版本B ATR动态止损", tB)]: + print(f"\n 【{label}】") + if not trades: print(" 无信号"); continue + cols = [c for c in ["操作","日期","价格","股数","评分","盈亏%","备注"] if c in pd.DataFrame(trades).columns] + print(pd.DataFrame(trades)[cols].to_string(index=False)) + + nA = len([t for t in tA if t["操作"] in ("买入","卖出")]) + nB = len([t for t in tB if t["操作"] in ("买入","卖出")]) + print(f"\n {'':22} {'A 固定12%':>12} {'B ATR动态':>12} {'买入持有':>10}") + print(f" {'策略总收益':<22} {retA:>+11.1f}% {retB:>+11.1f}% {bh:>+9.1f}%") + print(f" {'超额收益α':<22} {retA-bh:>+11.1f}% {retB-bh:>+11.1f}%") + print(f" {'交易次数':<22} {nA:>12} {nB:>12}") + w = "B ✅" if retB > retA else ("A ✅" if retA > retB else "平手") + print(f" {'胜出':<22} {'':>23} → {w} (B-A: {retB-retA:+.1f}%)") + return {"name":name,"A":retA,"B":retB,"BH":bh,"nA":nA,"nB":nB,"atr":avg_atr_pct} + +if __name__ == "__main__": + print("\n🔬 港股 AI v4 A/B 回测 — ATR动态止损 vs 固定止损") + print(f" A: 固定止损{A_TRAILING_STOP*100:.0f}% | B: ATR×{B_ATR_MULT}动态({B_MIN_STOP*100:.0f}%~{B_MAX_STOP*100:.0f}%)") + print(f" 仓位分级 评分1.5-3→30% | 3-5→60% | >5→100%\n") + + results = [] + for i, (name, ticker) in enumerate(STOCKS.items()): + if i > 0: time.sleep(3) + r = run_ab(name, ticker) + if r: results.append(r) + + if results: + print(f"\n{'='*70}") + print(" 📋 A/B 最终汇总") + print(f"{'='*70}") + print(f" {'股票':<12} {'ATR%':>6} {'A收益':>9} {'B收益':>9} {'买持':>9} {'B-A':>8} {'胜者':>5}") + print(f" {'-'*64}") + for r in results: + w = "B✅" if r["B"]>r["A"] else ("A✅" if r["A"]>r["B"] else "平") + print(f" {r['name']:<12} {r['atr']:>5.1f}% {r['A']:>+8.1f}% {r['B']:>+8.1f}% " + f"{r['BH']:>+8.1f}% {r['B']-r['A']:>+7.1f}% {w:>5}") + avg_a = np.mean([r["A"] for r in results]) + avg_b = np.mean([r["B"] for r in results]) + avg_bh = np.mean([r["BH"] for r in results]) + print(f" {'平均':<12} {'':>6} {avg_a:>+8.1f}% {avg_b:>+8.1f}% {avg_bh:>+8.1f}% {avg_b-avg_a:>+7.1f}%") diff --git a/stock_backtest_v5_abc.py b/stock_backtest_v5_abc.py new file mode 100644 index 0000000..6c32258 --- /dev/null +++ b/stock_backtest_v5_abc.py @@ -0,0 +1,324 @@ +""" +港股 AI 综合评分系统 v5 — 三版本 A/B/C 对比 +A: 固定止损 12%(全仓) +B: ATR 动态止损(仓位分级) +C: 混合策略 — 按股票波动率自动选择 A 或 B + - ATR/价格 < 5% → 低波动,用固定止损 8% + - ATR/价格 5~15% → 中波动,用 ATR×2.5 动态 + - ATR/价格 > 15% → 高波动,用 ATR×2.0 + 宽上限 40% +""" + +import yfinance as yf +import pandas as pd +import numpy as np +import time, os, sys +import warnings +warnings.filterwarnings('ignore') + +CACHE_DIR = "data" +os.makedirs(CACHE_DIR, exist_ok=True) +FORCE_REFRESH = "--refresh" in sys.argv + +STOCKS = { + "平安好医生": "1833.HK", + "叮当健康": "9886.HK", + "中原建业": "9982.HK", +} +PERIOD = "2y" +INITIAL_CAPITAL = 10000.0 +W_TECH, W_FUND, W_SENT = 0.50, 0.30, 0.20 +BUY_THRESH, SELL_THRESH = 1.5, -1.5 +VOL_CONFIRM = 1.2 + +# ── 版本参数 ────────────────────────────────────────────────────────── +A_FIXED_STOP = 0.12 # A: 固定 12% + +B_ATR_MULT = 2.5 # B: ATR × 2.5 +B_MIN_STOP = 0.08 +B_MAX_STOP = 0.35 + +# C: 混合 —— 阈值 +C_LOW_ATR_PCT = 0.05 # ATR% < 5% → 低波动 +C_HIGH_ATR_PCT = 0.15 # ATR% > 15% → 高波动 +C_LOW_FIXED = 0.08 # 低波动用固定 8% +C_MID_ATR_MULT = 2.5 # 中波动 ATR×2.5 +C_HIGH_ATR_MULT= 2.0 # 高波动 ATR×2.0(更宽) +C_HIGH_MAX = 0.40 # 高波动上限 40% +C_MIN_STOP = 0.08 +C_MID_MAX = 0.35 + +# ── 快照数据 ────────────────────────────────────────────────────────── +FUNDAMENTAL = { + "平安好医生": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-08-01", "score": -1.0}, + {"from": "2025-01-01", "score": 0.0}, + {"from": "2025-08-01", "score": 1.0}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-06-01", "score": -2.0}, + {"from": "2025-01-01", "score": -1.0}, + {"from": "2025-09-01", "score": 1.0}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -3.0}, + {"from": "2024-06-01", "score": -4.0}, + {"from": "2025-01-01", "score": -4.0}, + {"from": "2025-10-01", "score": -5.0}, + ], +} +SENTIMENT = { + "平安好医生": [ + {"from": "2024-01-01", "score": -1.0}, + {"from": "2024-10-01", "score": 1.0}, + {"from": "2025-01-01", "score": 2.0}, + {"from": "2026-01-01", "score": 3.0}, + ], + "叮当健康": [ + {"from": "2024-01-01", "score": -2.0}, + {"from": "2024-08-01", "score": -1.0}, + {"from": "2025-04-01", "score": 1.0}, + {"from": "2025-10-01", "score": 2.0}, + ], + "中原建业": [ + {"from": "2024-01-01", "score": -2.0}, + {"from": "2024-06-01", "score": -3.0}, + {"from": "2025-01-01", "score": -3.0}, + {"from": "2025-10-01", "score": -4.0}, + ], +} + +# ── 工具函数 ────────────────────────────────────────────────────────── +def get_snap(tl, date): + v = tl[0]["score"] + for e in tl: + if str(date.date()) >= e["from"]: v = e["score"] + else: break + return v + +def calc_rsi(s, p=14): + d = s.diff() + g = d.clip(lower=0).ewm(com=p-1, min_periods=p).mean() + l = (-d.clip(upper=0)).ewm(com=p-1, min_periods=p).mean() + return 100 - 100/(1+g/l) + +def calc_macd(s): + m = s.ewm(span=12,adjust=False).mean() - s.ewm(span=26,adjust=False).mean() + return m - m.ewm(span=9,adjust=False).mean() + +def calc_atr(df, p=14): + hi,lo,cl = df["High"],df["Low"],df["Close"] + tr = pd.concat([(hi-lo),(hi-cl.shift(1)).abs(),(lo-cl.shift(1)).abs()],axis=1).max(axis=1) + return tr.ewm(com=p-1,min_periods=p).mean() + +def tech_score(row): + s = 0 + if row.RSI<30: s+=3 + elif row.RSI<45: s+=1 + elif row.RSI>70: s-=3 + elif row.RSI>55: s-=1 + if row.MH>0 and row.MH_p<=0: s+=3 + elif row.MH<0 and row.MH_p>=0: s-=3 + elif row.MH>0: s+=1 + else: s-=1 + if row.MA5>row.MA20>row.MA60: s+=2 + elif row.MA5row.MA20 and row.Cp<=row.MA20p: s+=1 + elif row.Close=row.MA20p: s-=1 + return float(np.clip(s,-10,10)) + +def pos_ratio(score): + if score>=5: return 1.0 + elif score>=3: return 0.6 + return 0.3 + +def load(ticker): + sym = ticker.replace(".HK","") + fp = os.path.join(CACHE_DIR, f"{sym}.csv") + if os.path.exists(fp) and not FORCE_REFRESH: + df = pd.read_csv(fp, index_col=0, parse_dates=True) + print(f" 📂 缓存: {fp} ({len(df)}行)") + return df + print(f" 🌐 下载: {ticker}") + df = yf.download(ticker, period=PERIOD, auto_adjust=True, progress=False) + if df.empty: return None + if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.droplevel(1) + df.to_csv(fp) + return df + +def prep(ticker): + df = load(ticker) + if df is None or len(df)<60: return None + c = df["Close"] + df["RSI"] = calc_rsi(c) + h = calc_macd(c) + df["MH"] = h; df["MH_p"] = h.shift(1) + for p in [5,20,60]: df[f"MA{p}"] = c.rolling(p).mean() + df["MA20p"]= df["MA20"].shift(1); df["Cp"] = c.shift(1) + df["Vol20"]= df["Volume"].rolling(20).mean() + df["ATR"] = calc_atr(df) + return df.dropna() + +# ── 混合策略 C 的止损参数选择 ───────────────────────────────────────── +def c_stop_params(avg_atr_pct): + """根据股票历史ATR波动率自动决定止损方式""" + if avg_atr_pct < C_LOW_ATR_PCT: + return "fixed", C_LOW_FIXED, C_LOW_FIXED, "低波动→固定止损" + elif avg_atr_pct < C_HIGH_ATR_PCT: + return "atr", C_MID_ATR_MULT, C_MID_MAX, "中波动→ATR×2.5" + else: + return "atr", C_HIGH_ATR_MULT, C_HIGH_MAX, "高波动→ATR×2.0" + +# ── 通用模拟引擎 ────────────────────────────────────────────────────── +def simulate(name, df, mode="A", c_avg_atr_pct=None): + """ + mode: 'A'=固定止损12%, 'B'=ATR动态, 'C'=混合自适应 + """ + capital, position, entry = INITIAL_CAPITAL, 0, 0.0 + high_price, trail_pct = 0.0, 0.0 + trades = [] + + # C 版预先确定止损类型(全局一致,模拟真实部署) + c_mode, c_mult, c_max, c_note = ("fixed",0,0,"") if mode!="C" else c_stop_params(c_avg_atr_pct) + + for date, row in df.iterrows(): + f = get_snap(FUNDAMENTAL[name], date) + s = get_snap(SENTIMENT[name], date) + t = tech_score(row) + score = W_TECH*t + W_FUND*f + W_SENT*s + price = float(row["Close"]) + vol = float(row["Volume"]) + vol20 = float(row["Vol20"]) + + # 买入 + if score >= BUY_THRESH and position == 0 and capital > price: + if vol >= vol20 * VOL_CONFIRM: + ratio = pos_ratio(score) + shares = int(capital * ratio / price) + if shares > 0: + position, entry, high_price = shares, price, price + capital -= shares * price + + # 确定止损幅度 + if mode == "A": + trail_pct = A_FIXED_STOP + note = f"仓{ratio*100:.0f}% 固定止损{trail_pct*100:.0f}%" + elif mode == "B": + raw = float(row["ATR"]) * B_ATR_MULT / price + trail_pct = float(np.clip(raw, B_MIN_STOP, B_MAX_STOP)) + note = f"仓{ratio*100:.0f}% ATR止损{trail_pct*100:.1f}%" + else: # C + if c_mode == "fixed": + trail_pct = c_mult if c_mult else C_LOW_FIXED + note = f"仓{ratio*100:.0f}% {c_note} {trail_pct*100:.0f}%" + else: + raw = float(row["ATR"]) * c_mult / price + trail_pct = float(np.clip(raw, C_MIN_STOP, c_max)) + note = f"仓{ratio*100:.0f}% {c_note} {trail_pct*100:.1f}%" + + trades.append({"操作":"买入","日期":date.date(),"价格":round(price,4), + "股数":shares,"评分":round(score,2),"备注":note}) + + elif position > 0: + high_price = max(high_price, price) + stop_price = high_price * (1 - trail_pct) + if price <= stop_price or score <= SELL_THRESH: + pnl = position*(price-entry); pct = pnl/(position*entry)*100 + reason = (f"止损 高{high_price:.3f}→线{stop_price:.3f}" + if price<=stop_price else f"评分出({score:.1f})") + capital += position*price + trades.append({"操作":"卖出","日期":date.date(),"价格":round(price,4), + "股数":position,"评分":round(score,2), + "盈亏%":f"{pct:+.1f}%","备注":reason}) + position, high_price, trail_pct = 0, 0.0, 0.0 + + last = float(df["Close"].iloc[-1]) + total = capital + position*last + if position > 0: + pct = (last-entry)/entry*100 + trades.append({"操作":"未平仓","日期":"持仓中","价格":round(last,4), + "股数":position,"评分":"-","盈亏%":f"{pct:+.1f}%","备注":"-"}) + return total, trades + +# ── 主流程 ──────────────────────────────────────────────────────────── +def run_abc(name, ticker): + print(f"\n{'='*72}") + print(f" {name} ({ticker})") + print(f"{'='*72}") + df = prep(ticker) + if df is None: + print(" ⚠️ 数据不足,跳过") + return None + + avg_atr_pct = float(df["ATR"].mean() / df["Close"].mean()) + bh = (float(df["Close"].iloc[-1]) / float(df["Close"].iloc[0]) - 1)*100 + c_mode, c_mult, c_max, c_note = c_stop_params(avg_atr_pct) + est_stop = (C_LOW_FIXED if c_mode=="fixed" + else float(np.clip(df["ATR"].mean()*c_mult/df["Close"].mean(), C_MIN_STOP, c_max))) + print(f" ATR均值: {avg_atr_pct*100:.1f}% C策略选择: [{c_note}] 估算止损: {est_stop*100:.1f}%") + print(f" 买入持有收益: {bh:+.1f}%") + + tA, trA = simulate(name, df, "A") + tB, trB = simulate(name, df, "B") + tC, trC = simulate(name, df, "C", avg_atr_pct) + rA = (tA-INITIAL_CAPITAL)/INITIAL_CAPITAL*100 + rB = (tB-INITIAL_CAPITAL)/INITIAL_CAPITAL*100 + rC = (tC-INITIAL_CAPITAL)/INITIAL_CAPITAL*100 + + for label, trades in [("A 固定止损12%", trA),("B ATR动态", trB),("C 混合自适应", trC)]: + print(f"\n 【版本{label}】") + if not trades: print(" 无信号"); continue + cols = [c for c in ["操作","日期","价格","股数","评分","盈亏%","备注"] + if c in pd.DataFrame(trades).columns] + print(pd.DataFrame(trades)[cols].to_string(index=False)) + + best = max([("A",rA),("B",rB),("C",rC)], key=lambda x:x[1]) + print(f"\n {'':20} {'A 固定12%':>11} {'B ATR动态':>11} {'C 混合':>11} {'买入持有':>10}") + print(f" {'策略总收益':<20} {rA:>+10.1f}% {rB:>+10.1f}% {rC:>+10.1f}% {bh:>+9.1f}%") + print(f" {'超额收益α':<20} {rA-bh:>+10.1f}% {rB-bh:>+10.1f}% {rC-bh:>+10.1f}%") + nA = len([t for t in trA if t["操作"] in ("买入","卖出")]) + nB = len([t for t in trB if t["操作"] in ("买入","卖出")]) + nC = len([t for t in trC if t["操作"] in ("买入","卖出")]) + print(f" {'交易次数':<20} {nA:>11} {nB:>11} {nC:>11}") + print(f" {'🏆 本轮胜出':<20} {'★' if best[0]=='A' else '':>11} {'★' if best[0]=='B' else '':>11} {'★' if best[0]=='C' else '':>11}") + + return {"name":name, "A":rA, "B":rB, "C":rC, "BH":bh, + "atr":avg_atr_pct*100, "c_note":c_note} + + +if __name__ == "__main__": + print("\n🔬 港股 AI v5 — 三版本 A/B/C 对比回测") + print(f" A: 固定止损{A_FIXED_STOP*100:.0f}%(全局)") + print(f" B: ATR×{B_ATR_MULT}动态止损({B_MIN_STOP*100:.0f}%~{B_MAX_STOP*100:.0f}%)") + print(f" C: 混合自适应 — ATR<5%→固定8% | 5~15%→ATR×2.5 | >15%→ATR×2.0") + print(f" 仓位分级: 评分1.5-3→30% | 3-5→60% | >5→100%\n") + + results = [] + for i, (name, ticker) in enumerate(STOCKS.items()): + if i > 0: time.sleep(3) + r = run_abc(name, ticker) + if r: results.append(r) + + if results: + print(f"\n{'='*72}") + print(" 📋 最终三版本汇总") + print(f"{'='*72}") + print(f" {'股票':<12} {'ATR%':>6} {'C策略':<18} {'A':>9} {'B':>9} {'C':>9} {'买持':>9}") + print(f" {'-'*70}") + for r in results: + marks = {k:"★" for k in ["A","B","C"] if r[k]==max(r["A"],r["B"],r["C"])} + print(f" {r['name']:<12} {r['atr']:>5.1f}% {r['c_note']:<18}" + f" {r['A']:>+8.1f}%{marks.get('A',''):1}" + f" {r['B']:>+8.1f}%{marks.get('B',''):1}" + f" {r['C']:>+8.1f}%{marks.get('C',''):1}" + f" {r['BH']:>+8.1f}%") + avg = {k: np.mean([r[k] for r in results]) for k in ["A","B","C","BH"]} + best_avg = max("A","B","C", key=lambda k: avg[k]) + marks = {k:"★" for k in ["A","B","C"] if k==best_avg} + print(f" {'平均':<12} {'':>6} {'':18}" + f" {avg['A']:>+8.1f}%{marks.get('A',''):1}" + f" {avg['B']:>+8.1f}%{marks.get('B',''):1}" + f" {avg['C']:>+8.1f}%{marks.get('C',''):1}" + f" {avg['BH']:>+8.1f}%") + print()