From ae928c198b619be39e878ae71aafb5bec4c5f1fa Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 2 Nov 2025 10:41:17 +0800 Subject: [PATCH] update1 --- CRYPTO_README.md | 267 +++++ Dockerfile | 3 +- README.md | 54 +- clean_mysql.py | 53 - config/config.yaml | 38 + crontab/crypto-scanner | 27 + crontab/market-scanner | 24 +- crypto_scanner.py | 161 +++ docker-compose.yml | 2 +- install_mysql_deps.py | 65 -- market_scanner.py | 27 +- migrate_to_mysql.py | 387 -------- requirements.txt | 4 +- src/data/binance_fetcher.py | 495 ++++++++++ src/data/sentiment_fetcher.py | 347 ------- src/data/stock_pool_manager.py | 5 +- src/data/tushare_fetcher.py | 125 ++- src/database/mysql_database_manager.py | 87 +- src/database/mysql_schema.sql | 8 +- src/execution/strategy_executor.py | 83 +- src/strategy/base_strategy.py | 35 +- src/strategy/crypto_kline_pattern_strategy.py | 587 +++++++++++ src/strategy/kline_pattern_strategy.py | 917 +++++++++++++++--- src/utils/notification.py | 453 +++++---- start_crypto_scanner.sh | 53 + start_market_scanner.sh | 17 +- web/mysql_app.py | 3 + web/templates/signals.html | 24 +- 28 files changed, 3064 insertions(+), 1287 deletions(-) create mode 100644 CRYPTO_README.md delete mode 100644 clean_mysql.py create mode 100644 crontab/crypto-scanner create mode 100644 crypto_scanner.py delete mode 100644 install_mysql_deps.py delete mode 100644 migrate_to_mysql.py create mode 100644 src/data/binance_fetcher.py delete mode 100644 src/data/sentiment_fetcher.py create mode 100644 src/strategy/crypto_kline_pattern_strategy.py create mode 100755 start_crypto_scanner.sh diff --git a/CRYPTO_README.md b/CRYPTO_README.md new file mode 100644 index 0000000..03cd819 --- /dev/null +++ b/CRYPTO_README.md @@ -0,0 +1,267 @@ +# 加密货币市场K线形态策略使用指南 + +本项目已支持将K线形态策略应用于加密货币市场(Binance),可以同时扫描A股和加密货币市场。 + +## 新增功能 + +### 1. Binance数据获取器 +- **文件**: `src/data/binance_fetcher.py` +- **功能**: + - 获取Binance交易所的K线数据 + - 支持多种时间周期(1d, 4h, 1h等) + - 获取热门交易对(按24h交易量排序) + - 支持交易对搜索和筛选 + - 自动缓存机制,减少API调用 + +### 2. 加密货币K线形态策略 +- **文件**: `src/strategy/crypto_kline_pattern_strategy.py` +- **特点**: + - 继承自原有的`KLinePatternStrategy` + - 适配加密货币市场特点(24小时交易、高波动性) + - 支持USDT、BTC等多种计价货币 + - 可配置最小交易量过滤 + +### 3. 加密货币市场扫描脚本 +- **文件**: `crypto_scanner.py` +- **功能**: 扫描Binance热门交易对,识别K线形态信号 + +## 安装依赖 + +```bash +# 安装python-binance库 +pip install -r requirements.txt +``` + +或单独安装: +```bash +pip install python-binance>=1.0.19 +``` + +## 配置说明 + +### 1. 编辑配置文件 +打开 `config/config.yaml`,配置Binance API(可选): + +```yaml +data_source: + # Binance API配置(获取公开数据不需要API密钥) + binance_api_key: "" # 可选 + binance_api_secret: "" # 可选 + binance_testnet: false # 是否使用测试网 +``` + +### 2. 策略参数配置 +在 `config/config.yaml` 中配置加密货币策略参数: + +```yaml +strategy: + crypto_kline_pattern: + enabled: true # 是否启用 + min_entity_ratio: 0.55 # 阳线实体最小占比55% + final_yang_min_ratio: 0.40 # 突破阳线实体最小占比40% + max_turnover_ratio: 100.0 # 最大换手率(加密货币较高) + timeframes: ["4hour", "daily", "weekly"] # 时间周期:4小时、日线、周线 + scan_symbols_count: 100 # 扫描交易对数量 + quote_asset: "USDT" # 计价货币 + min_volume_usdt: 1000000 # 最小24h交易量(美元) + + # 回踩监控配置 + pullback_tolerance: 0.02 # 回踩容忍度2% + monitor_days: 30 # 监控天数 + pullback_confirmation_days: 7 # 回踩确认天数 +``` + +## 使用方法 + +### 方法1: 使用启动脚本(推荐) +```bash +# 扫描100个热门交易对 +./start_crypto_scanner.sh + +# 扫描50个热门交易对 +./start_crypto_scanner.sh 50 + +# 扫描200个热门交易对 +./start_crypto_scanner.sh 200 +``` + +### 方法2: 直接运行Python脚本 +```bash +# 扫描100个热门交易对(默认) +python crypto_scanner.py + +# 扫描指定数量的交易对 +python crypto_scanner.py 50 +``` + +### 方法3: 在代码中使用 +```python +from src.data.binance_fetcher import BinanceFetcher +from src.strategy.crypto_kline_pattern_strategy import CryptoKLinePatternStrategy +from src.utils.notification import NotificationManager + +# 初始化数据获取器 +data_fetcher = BinanceFetcher() + +# 初始化策略 +strategy_config = { + 'min_entity_ratio': 0.55, + 'timeframes': ['4hour', 'daily', 'weekly'], # 4小时、日线、周线 + 'quote_asset': 'USDT' +} +notification_config = {'dingtalk': {'enabled': False}} +notification_manager = NotificationManager(notification_config) + +strategy = CryptoKLinePatternStrategy( + data_fetcher, + notification_manager, + strategy_config +) + +# 扫描市场 +results = strategy.scan_market(max_symbols=100) +``` + +## 定时任务配置 + +### 使用crontab定时扫描 +编辑 `crontab/crypto-scanner` 文件已包含预设的定时任务: + +```bash +# 每天早上8点扫描 +0 8 * * * root cd /app && python crypto_scanner.py 100 + +# 每天下午4点扫描 +0 16 * * * root cd /app && python crypto_scanner.py 100 + +# 每天晚上12点扫描 +0 0 * * * root cd /app && python crypto_scanner.py 100 + +# 每4小时扫描(适合4小时K线) +0 */4 * * * root cd /app && python crypto_scanner.py 50 + +# 周日深度扫描 +0 10 * * 0 root cd /app && python crypto_scanner.py 200 +``` + +加载定时任务: +```bash +# 在Docker容器中 +crontab crontab/crypto-scanner + +# 或在本地系统 +crontab -e +# 然后复制crypto-scanner内容 +``` + +## 策略说明 + +### K线形态识别 +与A股策略相同,识别"两阳线+阴线+突破阳线"形态: + +1. **基础形态**(前3根K线): 阳线 + 阳线 + 阴线 +2. **前两根阳线**: 实体部分占振幅55%以上 +3. **突破确认**: 第4/5/6根K线中任意一根突破阴线最高价 +4. **突破阳线**: 实体占振幅40%以上,收盘在EMA20上方 +5. **回踩确认**: 价格先创新高,再回踩到阴线最高价附近 + +### 加密货币市场特点 +- **24小时交易**: 没有开盘收盘限制 +- **高波动性**: 换手率阈值设置较高(100%) +- **支持多周期**: 4小时线(4h)、日线(1d)、周线(1w) +- **实时数据**: 可以随时获取最新K线数据 + +## 输出结果 + +### 日志文件 +- 位置: `logs/crypto_scanner_YYYY-MM-DD.log` +- 包含: 详细的扫描过程、信号发现、错误信息 + +### 数据库存储 +扫描结果会自动保存到MySQL数据库: +- 扫描会话信息 +- 发现的信号详情 +- 形态形成记录 + +### 钉钉通知 +如果配置了钉钉webhook,会自动发送: +- 信号汇总通知(每10个信号一组) +- 回踩提醒(每5个提醒一组) +- 包含交易对、价格、形态类型等关键信息 + +## 常见问题 + +### 1. 如何选择扫描的交易对数量? +- **日常监控**: 50-100个交易对,速度快 +- **全面扫描**: 100-200个交易对,覆盖更全 +- **深度分析**: 200+个交易对,周末或特殊时段 + +### 2. 需要Binance API密钥吗? +- **不需要**: 获取K线、ticker等公开数据不需要API密钥 +- **需要**: 如果要查询账户信息、下单等操作才需要 + +### 3. 时间周期如何选择? +- **weekly(周线)**: 适合长线信号,信号质量最高,趋势更明确 +- **daily(日线)**: 适合中线信号,平衡信号质量和频率 +- **4hour(4小时)**: 适合短线交易,信号更频繁,适合波段操作 + +### 4. 与A股策略有什么区别? +- **数据源**: Binance API vs Tushare API +- **交易时间**: 24小时 vs 交易日限制 +- **换手率**: 阈值更高,适应加密货币高波动 +- **计价单位**: USDT/BTC vs 人民币 + +### 5. 如何同时运行A股和加密货币扫描? +两个脚本独立运行,互不影响: +```bash +# A股扫描 +./start_market_scanner.sh 200 + +# 加密货币扫描 +./start_crypto_scanner.sh 100 +``` + +## 注意事项 + +1. **API限制**: Binance对API调用频率有限制,建议控制扫描频率 +2. **网络连接**: 需要稳定的网络连接访问Binance API +3. **数据准确性**: 加密货币市场波动大,建议结合其他指标综合判断 +4. **风险提示**: 本工具仅用于技术分析,不构成投资建议 + +## 技术架构 + +``` +TradingAI/ +├── src/ +│ ├── data/ +│ │ ├── binance_fetcher.py # Binance数据获取器(新增) +│ │ └── tushare_fetcher.py # A股数据获取器(原有) +│ └── strategy/ +│ ├── crypto_kline_pattern_strategy.py # 加密货币策略(新增) +│ └── kline_pattern_strategy.py # A股策略(原有) +├── crypto_scanner.py # 加密货币扫描脚本(新增) +├── market_scanner.py # A股扫描脚本(原有) +├── start_crypto_scanner.sh # 加密货币启动脚本(新增) +├── start_market_scanner.sh # A股启动脚本(原有) +└── crontab/ + ├── crypto-scanner # 加密货币定时任务(新增) + └── market-scanner # A股定时任务(原有) +``` + +## 更新日志 + +### v1.1.0 (2025-10-12) +- ✨ 新增Binance数据获取器 +- ✨ 新增加密货币K线形态策略 +- ✨ 新增加密货币市场扫描脚本 +- ✨ 新增定时任务配置 +- 📝 更新配置文件支持加密货币 +- 📝 新增使用文档 + +## 贡献 + +欢迎提交Issue和Pull Request来改进此功能! + +## 许可 + +与主项目保持一致 diff --git a/Dockerfile b/Dockerfile index a0c58cb..c73b816 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,8 @@ COPY . . RUN mkdir -p /app/logs /app/config # 设置权限 -RUN chmod +x start_mysql_web.py +RUN chmod +x start_mysql_web.py && \ + chmod +x start_market_scanner.sh # 暴露端口 EXPOSE 8080 diff --git a/README.md b/README.md index 9f711ff..8ec6302 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,21 @@ TradingAI/ ├── src/ # 源代码 │ ├── data/ # 数据获取模块 │ │ ├── __init__.py -│ │ ├── tushare_fetcher.py # 行情数据获取 -│ │ └── sentiment_fetcher.py # 舆情数据获取 +│ │ ├── tushare_fetcher.py # A股行情数据获取 +│ │ ├── binance_fetcher.py # 加密货币数据获取 +│ │ └── stock_pool_manager.py # 股票池管理 │ ├── strategy/ # 策略模块 -│ ├── monitor/ # 监控模块 +│ │ ├── kline_pattern_strategy.py # K线形态策略 +│ │ └── crypto_kline_pattern_strategy.py # 加密货币K线策略 +│ ├── database/ # 数据库模块 +│ │ └── mysql_database_manager.py +│ ├── execution/ # 执行模块 +│ │ ├── strategy_executor.py # 策略执行器 +│ │ └── task_scheduler.py # 任务调度器 │ └── utils/ # 工具模块 │ ├── __init__.py -│ └── config_loader.py -├── tests/ # 测试文件 +│ ├── config_loader.py +│ └── notification.py ├── logs/ # 日志文件 └── data/ # 数据文件 ``` @@ -165,13 +172,42 @@ hist_data = fetcher.get_historical_data( - [ ] Web界面 - [ ] 报警通知系统 +## Docker部署 + +项目支持Docker容器化部署,包含Web界面和定时扫描功能: + +### 启动服务 +```bash +# 启动所有服务 +docker-compose up -d + +# 仅启动Web服务 +docker-compose up -d trading-web + +# 仅启动市场扫描服务 +docker-compose up -d trading-market-scanner +``` + +### 服务说明 +- **trading-web**: Web界面服务 (端口8080) +- **trading-market-scanner**: 定时市场扫描服务 (每晚8点执行) + +### 查看日志 +```bash +# 查看Web服务日志 +docker-compose logs -f trading-web + +# 查看扫描服务日志 +docker-compose logs -f trading-market-scanner +``` + ## 注意事项 1. 首次使用需要确保网络连接正常,TuShare需要从网络获取数据 -2. 请合理使用数据接口,避免频繁请求 -3. 舆情数据仅供参考,投资需谨慎 -4. 本系统仅供学习和研究使用,不构成投资建议 -5. 实盘交易请谨慎,注意风险控制 +2. 请合理使用数据接口,避免频繁请求 +3. 本系统仅供学习和研究使用,不构成投资建议 +4. 实盘交易请谨慎,注意风险控制 +5. 确保配置文件中的TuShare Token和数据库连接信息正确 ## 许可证 diff --git a/clean_mysql.py b/clean_mysql.py deleted file mode 100644 index 36c51ad..0000000 --- a/clean_mysql.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -""" -清理MySQL数据库,重新开始迁移 -""" - -import pymysql -import sys -from pathlib import Path -from loguru import logger - -# 添加项目根目录到路径 -current_dir = Path(__file__).parent -sys.path.insert(0, str(current_dir)) - -from config.mysql_config import MYSQL_CONFIG - - -def clean_mysql_database(): - """清理MySQL数据库""" - logger.info("🧹 清理MySQL数据库...") - - try: - with pymysql.connect(**MYSQL_CONFIG.to_dict()) as conn: - cursor = conn.cursor() - - # 删除视图 - try: - cursor.execute("DROP VIEW IF EXISTS latest_signals_view") - cursor.execute("DROP VIEW IF EXISTS strategy_stats_view") - logger.info("✅ 删除视图") - except Exception as e: - logger.warning(f"删除视图警告: {e}") - - # 删除表(注意外键约束顺序) - tables = ['pullback_alerts', 'stock_signals', 'scan_sessions', 'strategies'] - - for table in tables: - try: - cursor.execute(f"DROP TABLE IF EXISTS {table}") - logger.info(f"✅ 删除表: {table}") - except Exception as e: - logger.warning(f"删除表 {table} 警告: {e}") - - conn.commit() - logger.info("✅ MySQL数据库清理完成") - - except Exception as e: - logger.error(f"❌ 清理MySQL数据库失败: {e}") - raise - - -if __name__ == "__main__": - clean_mysql_database() \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml index 8233ab2..40e12a7 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -32,6 +32,11 @@ data_source: # Tushare Pro配置 tushare_token: "0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc" + # Binance API配置 + binance_api_key: "" # Binance API Key (可选,公开数据不需要) + binance_api_secret: "" # Binance API Secret (可选,公开数据不需要) + binance_testnet: false # 是否使用测试网 + # 数据更新频率 update_frequency: realtime: "1min" # 实时数据更新频率 @@ -69,6 +74,39 @@ strategy: # 回踩监控配置 pullback_tolerance: 0.02 # 回踩容忍度(2%),价格接近阴线最高点的阈值 monitor_days: 30 # 监控回踩的天数(信号触发后30天内监控) + pullback_confirmation_days: 7 # 回踩确认窗口天数(模式识别后N天内检查创新高回踩) + use_1h_confirmation: false # 是否使用1h级别数据确认回踩(Tushare分钟线接口有频率限制:每分钟最多2次) + max_turnover_ratio: 40.0 # 最后阳线最大换手率(%) + + # 多头排列配置 + bull_alignment: + enabled: true # 是否启用多头排列过滤 + ema_periods: [5, 10, 20] # 使用的EMA周期(短、中、长) + check_slope: true # 是否检查均线向上倾斜 + slope_lookback: 3 # 斜率计算回看周期(根K线) + + # 加密货币K线形态策略配置 + crypto_kline_pattern: + enabled: true # 是否启用加密货币K线形态策略 + min_entity_ratio: 0.55 # 前两根阳线实体最小占振幅比例(55%) + final_yang_min_ratio: 0.40 # 最后阳线实体最小占振幅比例(40%) + max_turnover_ratio: 100.0 # 最大换手率(加密货币通常更高) + timeframes: ["4hour", "daily", "weekly"] # 支持的时间周期:4小时、日线、周线 + scan_symbols_count: 100 # 扫描交易对数量限制 + quote_asset: "USDT" # 计价货币 + min_volume_usdt: 1000000 # 最小24h交易量(USDT) + + # 回踩监控配置 + pullback_tolerance: 0.02 # 回踩容忍度(2%) + monitor_days: 30 # 监控天数 + pullback_confirmation_days: 7 # 回踩确认天数 + + # 多头排列配置 + bull_alignment: + enabled: true # 是否启用多头排列过滤 + ema_periods: [5, 10, 20] # 使用的EMA周期(短、中、长) + check_slope: true # 是否检查均线向上倾斜 + slope_lookback: 3 # 斜率计算回看周期(根K线) # 监控配置 monitor: diff --git a/crontab/crypto-scanner b/crontab/crypto-scanner new file mode 100644 index 0000000..58e7006 --- /dev/null +++ b/crontab/crypto-scanner @@ -0,0 +1,27 @@ +# 加密货币市场扫描定时任务配置 +# 格式: 分钟 小时 日 月 星期 用户 命令 +# 时区: Asia/Shanghai (加密货币市场24小时交易) + +# 设置环境变量 +SHELL=/bin/bash +PATH=/usr/local/bin:/usr/bin:/bin +PYTHONPATH=/app +TZ=Asia/Shanghai + +# 每天早上8点扫描一次 (可以看隔夜行情) +0 8 * * * root cd /app && /usr/local/bin/python crypto_scanner.py 100 >> /app/logs/crypto_cron.log 2>&1 + +# 每天下午4点扫描一次 (下午行情) +0 16 * * * root cd /app && /usr/local/bin/python crypto_scanner.py 100 >> /app/logs/crypto_cron.log 2>&1 + +# 每天晚上12点扫描一次 (晚间行情) +0 0 * * * root cd /app && /usr/local/bin/python crypto_scanner.py 100 >> /app/logs/crypto_cron.log 2>&1 + +# 高频监控 - 每4小时扫描一次热门币种 (24小时运行) +# 适合4小时K线形态策略 +# 0 */4 * * * root cd /app && /usr/local/bin/python crypto_scanner.py 50 >> /app/logs/crypto_cron.log 2>&1 + +# 周末深度扫描 - 每周日上午10点扫描更多交易对 +0 10 * * 0 root cd /app && /usr/local/bin/python crypto_scanner.py 200 >> /app/logs/crypto_cron.log 2>&1 + +# 必须以空行结尾 diff --git a/crontab/market-scanner b/crontab/market-scanner index c285f94..9a5348d 100644 --- a/crontab/market-scanner +++ b/crontab/market-scanner @@ -1,20 +1,14 @@ # 市场扫描定时任务配置 -# 格式: 分钟 小时 日 月 星期 命令 +# 格式: 分钟 小时 日 月 星期 用户 命令 # 时区: Asia/Shanghai -# 每个工作日开盘前扫描 (09:00) -#0 9 * * 1-5 cd /app && python market_scanner.py 200 >> /app/logs/cron.log 2>&1 +# 设置环境变量 +SHELL=/bin/bash +PATH=/usr/local/bin:/usr/bin:/bin +PYTHONPATH=/app +TZ=Asia/Shanghai -# 每个工作日午休时间扫描 (12:30) -#30 12 * * 1-5 cd /app && python market_scanner.py 100 >> /app/logs/cron.log 2>&1 +# 每晚8点进行全面市场扫描 (不限制股票数量) +0 20 * * * root cd /app && /usr/local/bin/python market_scanner.py >> /app/logs/cron.log 2>&1 -# 每个工作日收盘后扫描 (15:30) -30 15 * * 1-5 cd /app && python market_scanner.py 300 >> /app/logs/cron.log 2>&1 - -# 每周末进行一次深度扫描 (周六 10:00) -#0 10 * * 6 cd /app && python market_scanner.py 500 >> /app/logs/cron.log 2>&1 - -# 高频监控 - 每30分钟扫描一次热门股票 (交易时间内: 9:30-15:00) -# 注释掉避免过于频繁,需要时可以开启 -# 30 9-14 * * 1-5 cd /app && python market_scanner.py 50 >> /app/logs/cron.log 2>&1 -# 0 10-14 * * 1-5 cd /app && python market_scanner.py 50 >> /app/logs/cron.log 2>&1 \ No newline at end of file +# 必须以空行结尾 diff --git a/crypto_scanner.py b/crypto_scanner.py new file mode 100644 index 0000000..80c9c3f --- /dev/null +++ b/crypto_scanner.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +加密货币市场扫描脚本 +使用K线形态策略扫描Binance交易对 +""" + +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from src.data.binance_fetcher import BinanceFetcher +from src.strategy.crypto_kline_pattern_strategy import CryptoKLinePatternStrategy +from src.utils.notification import NotificationManager +from src.database.mysql_database_manager import MySQLDatabaseManager +from src.utils.config_loader import config_loader +from loguru import logger + + +def setup_logger(): + """配置日志""" + logger.remove() # 移除默认处理器 + + # 控制台日志 + logger.add( + sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level="INFO" + ) + + # 文件日志 + log_dir = project_root / "logs" + log_dir.mkdir(exist_ok=True) + + logger.add( + log_dir / "crypto_scanner_{time:YYYY-MM-DD}.log", + rotation="00:00", + retention="30 days", + encoding="utf-8", + level="DEBUG" + ) + + logger.info("日志系统初始化完成") + + +def main(max_symbols: int = 100): + """ + 主函数 + + Args: + max_symbols: 最大扫描交易对数量,默认100 + """ + logger.info("="*80) + logger.info("🚀 加密货币市场扫描程序启动") + logger.info(f"📊 扫描交易对数量: {max_symbols}") + logger.info("="*80) + + try: + # 1. 加载配置 + logger.info("📋 加载配置文件...") + config = config_loader.load_config() + + # 策略配置 + strategy_config = { + 'min_entity_ratio': 0.55, # 前两根阳线实体最小比例 + 'final_yang_min_ratio': 0.40, # 最后阳线实体最小比例 + 'max_turnover_ratio': 100.0, # 最大换手率(加密货币通常更高) + 'pullback_tolerance': 0.02, # 回踩容忍度2% + 'monitor_days': 30, # 监控天数 + 'pullback_confirmation_days': 7, # 回踩确认天数 + 'timeframes': ['4hour', 'daily', 'weekly'], # 时间周期:4小时、日线、周线 + 'quote_asset': 'USDT', # 计价货币 + 'min_volume_usdt': 1000000 # 最小24h交易量 + } + + # 通知配置 + notification_config = config.get('notification', { + 'dingtalk': { + 'enabled': True, + 'webhook_url': config.get('notification', {}).get('dingtalk', {}).get('webhook_url', '') + } + }) + + # 2. 初始化组件 + logger.info("🔧 初始化系统组件...") + + # 数据获取器 + data_fetcher = BinanceFetcher() + + # 通知管理器 + notification_manager = NotificationManager(notification_config) + + # 数据库管理器 + db_manager = MySQLDatabaseManager() + + # 策略 + strategy = CryptoKLinePatternStrategy( + data_fetcher=data_fetcher, + notification_manager=notification_manager, + config=strategy_config, + db_manager=db_manager + ) + + logger.info("✅ 系统组件初始化完成") + + # 3. 打印策略描述 + logger.info("\n" + "="*80) + logger.info(strategy.get_strategy_description()) + logger.info("="*80 + "\n") + + # 4. 执行市场扫描 + logger.info("🔍 开始扫描加密货币市场...") + results = strategy.scan_market(max_symbols=max_symbols) + + # 5. 汇总结果 + total_signals = sum( + sum(result.get_signal_count() for result in symbol_results.values()) + for symbol_results in results.values() + ) + + total_patterns = sum( + sum(result.get_pattern_count() for result in symbol_results.values()) + for symbol_results in results.values() + ) + + logger.info("\n" + "="*80) + logger.info("✅ 加密货币市场扫描完成") + logger.info(f"📊 扫描结果汇总:") + logger.info(f" - 扫描交易对: {max_symbols} 个") + logger.info(f" - 发现信号: {total_signals} 个") + logger.info(f" - 形态形成: {total_patterns} 个") + logger.info(f" - 涉及交易对: {len(results)} 个") + logger.info("="*80) + + return results + + except Exception as e: + logger.error(f"❌ 程序执行失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return None + + +if __name__ == "__main__": + # 设置日志 + setup_logger() + + # 从命令行参数获取扫描数量 + max_symbols = 100 + if len(sys.argv) > 1: + try: + max_symbols = int(sys.argv[1]) + logger.info(f"📊 使用命令行参数: 扫描 {max_symbols} 个交易对") + except ValueError: + logger.warning(f"⚠️ 无效的参数: {sys.argv[1]},使用默认值100") + + # 执行扫描 + main(max_symbols=max_symbols) diff --git a/docker-compose.yml b/docker-compose.yml index 17b2aea..12b42ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,7 @@ services: - TZ=Asia/Shanghai - OPERATION_KEY=${OPERATION_KEY:-9257} - LOG_LEVEL=${LOG_LEVEL:-INFO} - - MARKET_SCAN_STOCKS=${MARKET_SCAN_STOCKS:-200} + # - MARKET_SCAN_STOCKS=${MARKET_SCAN_STOCKS:-200} # 已移除数量限制 # MySQL连接配置 - MYSQL_HOST=${MYSQL_HOST:-cd-cynosdbmysql-grp-7kdd8qe4.sql.tencentcdb.com} - MYSQL_PORT=${MYSQL_PORT:-26558} diff --git a/install_mysql_deps.py b/install_mysql_deps.py deleted file mode 100644 index db68f02..0000000 --- a/install_mysql_deps.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -""" -安装MySQL依赖包 -""" - -import subprocess -import sys -from loguru import logger - - -def install_mysql_dependencies(): - """安装MySQL相关依赖""" - - logger.info("📦 开始安装MySQL依赖包...") - - packages = [ - 'pymysql', # MySQL数据库连接器 - 'cryptography', # 加密支持 - 'sqlalchemy', # SQL工具包(可选) - ] - - for package in packages: - try: - logger.info(f"📥 安装 {package}...") - subprocess.check_call([sys.executable, '-m', 'pip', 'install', package]) - logger.info(f"✅ {package} 安装成功") - except subprocess.CalledProcessError as e: - logger.error(f"❌ {package} 安装失败: {e}") - return False - - # 验证安装 - try: - import pymysql - logger.info("✅ pymysql 导入成功") - - import cryptography - logger.info("✅ cryptography 导入成功") - - logger.info("🎉 所有MySQL依赖安装完成!") - return True - - except ImportError as e: - logger.error(f"❌ 依赖验证失败: {e}") - return False - - -def main(): - """主函数""" - success = install_mysql_dependencies() - - if success: - print("\n" + "="*50) - print("🎉 MySQL依赖安装完成!") - print("="*50) - print("\n📝 下一步:") - print("1. 运行数据迁移: python migrate_to_mysql.py") - print("2. 启动MySQL版Web服务: python web/mysql_app.py") - print("3. 访问: http://localhost:8080") - else: - print("\n❌ MySQL依赖安装失败,请检查网络连接和权限") - sys.exit(1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/market_scanner.py b/market_scanner.py index 519ca86..462c3af 100644 --- a/market_scanner.py +++ b/market_scanner.py @@ -94,22 +94,22 @@ def create_strategy_system(): return executor -def scan_market(max_stocks=200): - """执行市场扫描""" - logger.info(f"🚀 开始市场扫描任务 - 扫描前{max_stocks}只热门股票") +def scan_market(): + """执行市场扫描 - 分析所有同花顺热榜股票""" + logger.info(f"🚀 开始市场扫描任务 - 扫描全部同花顺热榜股票") try: # 初始化系统 executor = create_strategy_system() - # 执行扫描任务 + # 执行扫描任务 - 不限制数量,分析所有股票 task_id = f"market_scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}" result = executor.execute_task( task_id=task_id, strategy_id="kline_pattern", stock_pool_rule="tushare_hot", - stock_pool_params={"limit": max_stocks}, - max_stocks=max_stocks, + stock_pool_params={}, # 不限制数量 + max_stocks=None, # 取消数量限制 send_notification=True # 启用通知 ) @@ -137,20 +137,7 @@ def main(): """主函数""" setup_logging() - # 从环境变量或命令行参数获取扫描数量 - max_stocks = 200 - if len(sys.argv) > 1: - try: - max_stocks = int(sys.argv[1]) - except ValueError: - logger.warning(f"无效的股票数量参数: {sys.argv[1]},使用默认值: 200") - - # 从环境变量获取 - max_stocks = int(os.environ.get('MARKET_SCAN_STOCKS', max_stocks)) - - logger.info(f"📊 市场扫描参数: 最大股票数={max_stocks}") - - return scan_market(max_stocks) + return scan_market() if __name__ == "__main__": diff --git a/migrate_to_mysql.py b/migrate_to_mysql.py deleted file mode 100644 index 6069a28..0000000 --- a/migrate_to_mysql.py +++ /dev/null @@ -1,387 +0,0 @@ -#!/usr/bin/env python3 -""" -SQLite到MySQL数据迁移脚本 -将现有的SQLite数据库迁移到MySQL数据库 -""" - -import sys -import sqlite3 -import pymysql -import pandas as pd -from pathlib import Path -from datetime import datetime, date -from loguru import logger - -# 添加项目根目录到路径 -current_dir = Path(__file__).parent -sys.path.insert(0, str(current_dir)) - -from config.mysql_config import MYSQL_CONFIG -from src.database.mysql_database_manager import MySQLDatabaseManager - - -class DataMigrator: - """数据迁移器""" - - def __init__(self): - self.sqlite_path = current_dir / "data" / "trading.db" - self.mysql_config = MYSQL_CONFIG - - def migrate_all(self): - """迁移所有数据""" - logger.info("🚀 开始SQLite到MySQL数据迁移...") - - try: - # 1. 初始化MySQL数据库 - logger.info("📊 初始化MySQL数据库...") - mysql_db = MySQLDatabaseManager() - - # 2. 迁移策略数据 - self.migrate_strategies(mysql_db) - - # 3. 迁移扫描会话 - self.migrate_scan_sessions(mysql_db) - - # 4. 迁移信号数据 - self.migrate_signals(mysql_db) - - # 5. 迁移回踩提醒 - self.migrate_pullback_alerts(mysql_db) - - # 6. 验证迁移结果 - self.verify_migration(mysql_db) - - logger.info("🎉 数据迁移完成!") - - except Exception as e: - logger.error(f"❌ 数据迁移失败: {e}") - raise - - def migrate_strategies(self, mysql_db): - """迁移策略数据""" - logger.info("📋 迁移策略数据...") - - try: - with sqlite3.connect(self.sqlite_path) as sqlite_conn: - strategies_df = pd.read_sql_query("SELECT * FROM strategies", sqlite_conn) - - if strategies_df.empty: - logger.info("无策略数据需要迁移") - return - - with pymysql.connect(**mysql_db.connection_params) as mysql_conn: - cursor = mysql_conn.cursor() - - for _, strategy in strategies_df.iterrows(): - try: - cursor.execute(""" - INSERT IGNORE INTO strategies (strategy_name, strategy_type, description) - VALUES (%s, %s, %s) - """, ( - strategy['strategy_name'], - strategy['strategy_type'], - strategy.get('description', '') - )) - except Exception as e: - logger.warning(f"策略迁移警告: {e}") - - mysql_conn.commit() - - logger.info(f"✅ 迁移了 {len(strategies_df)} 个策略") - - except Exception as e: - logger.error(f"策略迁移失败: {e}") - raise - - def migrate_scan_sessions(self, mysql_db): - """迁移扫描会话""" - logger.info("📅 迁移扫描会话数据...") - - try: - with sqlite3.connect(self.sqlite_path) as sqlite_conn: - sessions_df = pd.read_sql_query(""" - SELECT ss.*, s.strategy_name - FROM scan_sessions ss - JOIN strategies s ON ss.strategy_id = s.id - """, sqlite_conn) - - if sessions_df.empty: - logger.info("无扫描会话数据需要迁移") - return - - with pymysql.connect(**mysql_db.connection_params) as mysql_conn: - cursor = mysql_conn.cursor() - - # 获取MySQL中的策略ID映射 - cursor.execute("SELECT id, strategy_name FROM strategies") - strategy_mapping = {name: id for id, name in cursor.fetchall()} - - for _, session in sessions_df.iterrows(): - try: - mysql_strategy_id = strategy_mapping.get(session['strategy_name']) - if mysql_strategy_id is None: - logger.warning(f"未找到策略: {session['strategy_name']}") - continue - - cursor.execute(""" - INSERT INTO scan_sessions ( - strategy_id, scan_date, total_scanned, total_signals, - data_source, scan_config, status, created_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - """, ( - mysql_strategy_id, - session['scan_date'], - session.get('total_scanned', 0), - session.get('total_signals', 0), - session.get('data_source'), - session.get('scan_config'), - session.get('status', 'completed'), - session.get('created_at', datetime.now()) - )) - - except Exception as e: - logger.warning(f"会话迁移警告: {e}") - - mysql_conn.commit() - - logger.info(f"✅ 迁移了 {len(sessions_df)} 个扫描会话") - - except Exception as e: - logger.error(f"扫描会话迁移失败: {e}") - raise - - def migrate_signals(self, mysql_db): - """迁移信号数据""" - logger.info("📈 迁移信号数据...") - - try: - with sqlite3.connect(self.sqlite_path) as sqlite_conn: - signals_df = pd.read_sql_query(""" - SELECT ss.*, st.strategy_name - FROM stock_signals ss - JOIN strategies st ON ss.strategy_id = st.id - ORDER BY ss.created_at DESC - LIMIT 1000 - """, sqlite_conn) - - if signals_df.empty: - logger.info("无信号数据需要迁移") - return - - with pymysql.connect(**mysql_db.connection_params) as mysql_conn: - cursor = mysql_conn.cursor() - - # 获取MySQL中的映射 - cursor.execute("SELECT id, strategy_name FROM strategies") - strategy_mapping = {name: id for id, name in cursor.fetchall()} - - cursor.execute("SELECT id, strategy_id, created_at FROM scan_sessions ORDER BY created_at DESC") - session_mapping = {} - for session_id, strategy_id, created_at in cursor.fetchall(): - session_mapping[(strategy_id, created_at.date())] = session_id - - migrated_count = 0 - - for _, signal in signals_df.iterrows(): - try: - mysql_strategy_id = strategy_mapping.get(signal['strategy_name']) - if mysql_strategy_id is None: - continue - - # 尝试找到对应的session_id - signal_date = pd.to_datetime(signal['signal_date']).date() - mysql_session_id = None - - # 查找最近的session - for (sid, sdate), session_id in session_mapping.items(): - if sid == mysql_strategy_id and abs((sdate - signal_date).days) <= 1: - mysql_session_id = session_id - break - - # 如果找不到session,创建一个 - if mysql_session_id is None: - cursor.execute(""" - INSERT INTO scan_sessions (strategy_id, scan_date, total_scanned, total_signals, data_source) - VALUES (%s, %s, %s, %s, %s) - """, (mysql_strategy_id, signal_date, 1, 1, '迁移数据')) - mysql_session_id = cursor.lastrowid - - # 处理NaN值的函数 - def clean_value(value): - if pd.isna(value): - return None - return value - - # 插入信号数据 - cursor.execute(""" - INSERT INTO stock_signals ( - session_id, strategy_id, stock_code, stock_name, timeframe, - signal_date, signal_type, breakout_price, yin_high, breakout_amount, - breakout_pct, ema20_price, yang1_entity_ratio, yang2_entity_ratio, - final_yang_entity_ratio, turnover_ratio, above_ema20, - new_high_confirmed, new_high_price, new_high_date, confirmation_date, - confirmation_days, pullback_distance, - k1_data, k2_data, k3_data, k4_data, created_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - mysql_session_id, mysql_strategy_id, - signal['stock_code'], signal['stock_name'], signal['timeframe'], - signal['signal_date'], signal.get('signal_type', '两阳+阴+阳突破'), - clean_value(signal.get('breakout_price')), clean_value(signal.get('yin_high')), - clean_value(signal.get('breakout_amount')), - clean_value(signal.get('breakout_pct')), clean_value(signal.get('ema20_price')), - clean_value(signal.get('yang1_entity_ratio')), clean_value(signal.get('yang2_entity_ratio')), - clean_value(signal.get('final_yang_entity_ratio')), clean_value(signal.get('turnover_ratio')), - signal.get('above_ema20'), - signal.get('new_high_confirmed', False), clean_value(signal.get('new_high_price')), - signal.get('new_high_date'), signal.get('confirmation_date'), - clean_value(signal.get('confirmation_days')), clean_value(signal.get('pullback_distance')), - signal.get('k1_data'), signal.get('k2_data'), signal.get('k3_data'), signal.get('k4_data'), - signal.get('created_at', datetime.now()) - )) - - migrated_count += 1 - - except Exception as e: - logger.warning(f"信号迁移警告: {signal['stock_code']} - {e}") - - mysql_conn.commit() - - logger.info(f"✅ 迁移了 {migrated_count} 条信号") - - except Exception as e: - logger.error(f"信号迁移失败: {e}") - raise - - def migrate_pullback_alerts(self, mysql_db): - """迁移回踩提醒""" - logger.info("⚠️ 迁移回踩提醒数据...") - - try: - with sqlite3.connect(self.sqlite_path) as sqlite_conn: - # 检查表是否存在 - cursor = sqlite_conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pullback_alerts'") - if not cursor.fetchone(): - logger.info("SQLite中无回踩提醒表,跳过迁移") - return - - alerts_df = pd.read_sql_query("SELECT * FROM pullback_alerts", sqlite_conn) - - if alerts_df.empty: - logger.info("无回踩提醒数据需要迁移") - return - - with pymysql.connect(**mysql_db.connection_params) as mysql_conn: - cursor = mysql_conn.cursor() - - migrated_count = 0 - - for _, alert in alerts_df.iterrows(): - try: - cursor.execute(""" - INSERT INTO pullback_alerts ( - signal_id, stock_code, stock_name, timeframe, - original_signal_date, original_breakout_price, yin_high, - pullback_date, current_price, current_low, - pullback_pct, distance_to_yin_high, days_since_signal, - alert_sent, alert_sent_time, created_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - alert.get('signal_id'), alert['stock_code'], alert['stock_name'], - alert['timeframe'], alert.get('original_signal_date'), - alert.get('original_breakout_price'), alert.get('yin_high'), - alert['pullback_date'], alert.get('current_price'), alert.get('current_low'), - alert.get('pullback_pct'), alert.get('distance_to_yin_high'), - alert.get('days_since_signal'), alert.get('alert_sent', True), - alert.get('alert_sent_time'), alert.get('created_at', datetime.now()) - )) - - migrated_count += 1 - - except Exception as e: - logger.warning(f"回踩提醒迁移警告: {alert['stock_code']} - {e}") - - mysql_conn.commit() - - logger.info(f"✅ 迁移了 {migrated_count} 条回踩提醒") - - except Exception as e: - logger.error(f"回踩提醒迁移失败: {e}") - raise - - def verify_migration(self, mysql_db): - """验证迁移结果""" - logger.info("🔍 验证迁移结果...") - - try: - with pymysql.connect(**mysql_db.connection_params) as mysql_conn: - cursor = mysql_conn.cursor() - - # 统计各表数据量 - tables = ['strategies', 'scan_sessions', 'stock_signals', 'pullback_alerts'] - for table in tables: - cursor.execute(f"SELECT COUNT(*) FROM {table}") - count = cursor.fetchone()[0] - logger.info(f"📊 {table}: {count} 条记录") - - # 检查最新信号 - cursor.execute("SELECT COUNT(*) FROM latest_signals_view WHERE new_high_confirmed = 1") - confirmed_signals = cursor.fetchone()[0] - logger.info(f"🎯 确认信号: {confirmed_signals} 条") - - # 检查视图 - cursor.execute("SELECT COUNT(*) FROM strategy_stats_view") - stats_count = cursor.fetchone()[0] - logger.info(f"📈 策略统计: {stats_count} 条") - - logger.info("✅ 数据迁移验证完成") - - except Exception as e: - logger.error(f"验证迁移结果失败: {e}") - raise - - -def main(): - """主函数""" - logger.info("🚀 开始SQLite到MySQL数据迁移...") - - try: - # 检查依赖 - try: - import pymysql - except ImportError: - logger.error("❌ 请先安装pymysql: pip install pymysql") - return - - # 执行迁移 - migrator = DataMigrator() - migrator.migrate_all() - - print("\n" + "="*70) - print("🎉 MySQL数据库迁移完成!") - print("="*70) - print("\n✅ 迁移内容:") - print(" - 策略配置") - print(" - 扫描会话") - print(" - 股票信号(包含创新高回踩确认字段)") - print(" - 回踩提醒") - print(" - 数据库视图") - - print("\n🌐 MySQL配置:") - print(f" - 主机: {MYSQL_CONFIG.host}") - print(f" - 端口: {MYSQL_CONFIG.port}") - print(f" - 数据库: {MYSQL_CONFIG.database}") - - print("\n📝 下一步:") - print(" 1. 更新系统配置使用MySQL数据库") - print(" 2. 测试Web界面和API功能") - print(" 3. 验证所有功能正常工作") - - except Exception as e: - logger.error(f"❌ 迁移失败: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 64bb418..ada66fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # Data source tushare>=1.2.89 +python-binance>=1.0.19 # Data analysis and manipulation pandas>=2.0.0 @@ -18,7 +19,8 @@ scikit-learn>=1.3.0 # Database PyMySQL>=1.1.0 -SQLAlchemy>=2.0.0 + +>=2.0.0 pymysql[rsa]>=1.1.0 mysql-connector-python>=8.0.33 cryptography>=41.0.0 diff --git a/src/data/binance_fetcher.py b/src/data/binance_fetcher.py new file mode 100644 index 0000000..4288935 --- /dev/null +++ b/src/data/binance_fetcher.py @@ -0,0 +1,495 @@ +""" +加密货币数据获取模块 +使用Binance API获取加密货币市场数据 +""" + +from binance.client import Client +from binance.exceptions import BinanceAPIException +import pandas as pd +from typing import List, Optional, Union +from datetime import datetime, timedelta +import time +from loguru import logger +from functools import wraps +from src.utils.config_loader import config_loader + + +def retry_on_failure(retries: int = 3, delay: float = 1.0): + """ + 重试装饰器,用于网络请求失败时自动重试 + + Args: + retries: 重试次数 + delay: 重试间隔(秒) + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + last_exception = None + + for attempt in range(retries + 1): + try: + return func(*args, **kwargs) + except Exception as e: + last_exception = e + if attempt < retries: + logger.warning(f"{func.__name__} 第{attempt + 1}次调用失败: {e}, {delay}秒后重试...") + time.sleep(delay) + else: + logger.error(f"{func.__name__} 已重试{retries}次仍然失败: {e}") + + raise last_exception + return wrapper + return decorator + + +class BinanceFetcher: + """Binance数据获取器""" + + def __init__(self, api_key: str = None, api_secret: str = None, testnet: bool = False): + """ + 初始化Binance数据获取器 + + Args: + api_key: Binance API Key,如果为None则从配置文件读取 + api_secret: Binance API Secret,如果为None则从配置文件读取 + testnet: 是否使用测试网 + """ + # 如果没有传入密钥,从配置文件读取 + if api_key is None or api_secret is None: + api_key = config_loader.get('data_source.binance_api_key', '') + api_secret = config_loader.get('data_source.binance_api_secret', '') + + if api_key and api_secret: + logger.info("✅ 从配置文件读取Binance API密钥成功") + else: + logger.warning("⚠️ 配置文件中未找到Binance API密钥,将使用公开接口") + + self.api_key = api_key + self.api_secret = api_secret + self.testnet = testnet + + # 初始化客户端 + try: + if testnet: + self.client = Client(api_key, api_secret, testnet=True) + logger.info("✅ Binance测试网客户端初始化完成") + else: + self.client = Client(api_key, api_secret) + logger.info("✅ Binance客户端初始化完成") + + # 测试连接 + self.client.ping() + logger.info("✅ Binance API连接成功") + + except BinanceAPIException as e: + logger.error(f"❌ Binance API初始化失败: {e}") + raise + except Exception as e: + logger.error(f"❌ Binance客户端初始化失败: {e}") + raise + + # 币种名称缓存机制 + self._symbol_cache = {} + self._ticker_cache = None + self._cache_timestamp = None + self._cache_duration = 300 # 缓存5分钟 + + def clear_caches(self): + """清除所有缓存""" + self._symbol_cache.clear() + self._ticker_cache = None + self._cache_timestamp = None + logger.info("🔄 已清除所有币种数据缓存") + + @retry_on_failure(retries=2, delay=1.0) + def get_exchange_info(self) -> dict: + """ + 获取交易所信息 + + Returns: + 交易所信息字典 + """ + try: + info = self.client.get_exchange_info() + logger.info("获取Binance交易所信息成功") + return info + except Exception as e: + logger.error(f"获取交易所信息失败: {e}") + return {} + + @retry_on_failure(retries=2, delay=1.0) + def get_all_tickers(self) -> pd.DataFrame: + """ + 获取所有交易对的行情信息 + + Returns: + 行情信息DataFrame + """ + try: + current_time = time.time() + + # 检查缓存是否有效 + if (self._ticker_cache is not None and + self._cache_timestamp is not None and + current_time - self._cache_timestamp < self._cache_duration): + logger.debug(f"🔄 使用缓存的ticker数据 ({len(self._ticker_cache)} 个交易对)") + return self._ticker_cache.copy() + + # 获取最新数据 + tickers = self.client.get_ticker() + df = pd.DataFrame(tickers) + + # 数据类型转换 + numeric_cols = ['priceChange', 'priceChangePercent', 'weightedAvgPrice', + 'prevClosePrice', 'lastPrice', 'lastQty', 'bidPrice', + 'askPrice', 'openPrice', 'highPrice', 'lowPrice', 'volume', + 'quoteVolume', 'count'] + + for col in numeric_cols: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce') + + # 更新缓存 + self._ticker_cache = df.copy() + self._cache_timestamp = current_time + + logger.info(f"获取所有ticker成功,共{len(df)}个交易对") + return df + + except Exception as e: + logger.error(f"获取所有ticker失败: {e}") + return pd.DataFrame() + + def get_top_volume_symbols(self, quote_asset: str = 'USDT', limit: int = 100, + exclude_leverage: bool = True) -> List[str]: + """ + 获取交易量最大的交易对 + + Args: + quote_asset: 计价货币,默认USDT + limit: 返回数量 + exclude_leverage: 是否排除杠杆代币 + + Returns: + 交易对列表 + """ + try: + logger.info(f"🔥 获取{quote_asset}交易量TOP{limit}的交易对...") + + # 获取所有ticker + tickers_df = self.get_all_tickers() + + if tickers_df.empty: + logger.error("获取ticker数据为空") + return [] + + # 筛选指定计价货币的交易对 + filtered = tickers_df[tickers_df['symbol'].str.endswith(quote_asset)].copy() + + # 排除杠杆代币 + if exclude_leverage: + # 排除包含UP, DOWN, BULL, BEAR等杠杆代币标识 + leverage_patterns = ['UP', 'DOWN', 'BULL', 'BEAR'] + for pattern in leverage_patterns: + filtered = filtered[~filtered['symbol'].str.contains(pattern)] + + # 按24小时交易量排序 + filtered = filtered.sort_values('quoteVolume', ascending=False) + + # 获取前N个交易对 + top_symbols = filtered.head(limit)['symbol'].tolist() + + logger.info(f"✅ 获取TOP{limit}交易对成功") + return top_symbols + + except Exception as e: + logger.error(f"获取TOP交易对失败: {e}") + return [] + + def get_historical_klines(self, symbol: str, interval: str, + start_time: Union[str, datetime] = None, + end_time: Union[str, datetime] = None, + limit: int = 500) -> pd.DataFrame: + """ + 获取历史K线数据 + + Args: + symbol: 交易对符号,如'BTCUSDT' + interval: K线周期,如'1d', '4h', '1h' + start_time: 开始时间(datetime对象或字符串) + end_time: 结束时间(datetime对象或字符串) + limit: 数据条数限制,默认500 + + Returns: + K线数据DataFrame + """ + try: + # 处理时间格式 - Binance接受多种格式,包括字符串格式的日期 + # 如 "1 Dec, 2017" 或 "2017-12-01" 或时间戳 + if isinstance(start_time, datetime): + start_time_str = start_time.strftime('%d %b, %Y') + elif isinstance(start_time, str): + # 如果是带连字符的格式,尝试转换为Binance接受的格式 + if '-' in start_time: + try: + dt = pd.to_datetime(start_time) + start_time_str = dt.strftime('%d %b, %Y') + except: + start_time_str = start_time + else: + start_time_str = start_time + else: + start_time_str = None + + if isinstance(end_time, datetime): + end_time_str = end_time.strftime('%d %b, %Y') + elif isinstance(end_time, str): + # 如果是带连字符的格式,尝试转换为Binance接受的格式 + if '-' in end_time: + try: + dt = pd.to_datetime(end_time) + end_time_str = dt.strftime('%d %b, %Y') + except: + end_time_str = end_time + else: + end_time_str = end_time + else: + end_time_str = None + + # 获取K线数据 + if start_time_str and end_time_str: + klines = self.client.get_historical_klines( + symbol, interval, start_time_str, end_time_str + ) + elif start_time_str: + klines = self.client.get_historical_klines( + symbol, interval, start_time_str + ) + else: + klines = self.client.get_klines(symbol=symbol, interval=interval, limit=limit) + + if not klines: + logger.warning(f"{symbol} {interval} K线数据为空") + return pd.DataFrame() + + # 转换为DataFrame + df = pd.DataFrame(klines, columns=[ + 'open_time', 'open', 'high', 'low', 'close', 'volume', + 'close_time', 'quote_volume', 'trades', 'taker_buy_base', + 'taker_buy_quote', 'ignore' + ]) + + # 数据类型转换 + numeric_cols = ['open', 'high', 'low', 'close', 'volume', 'quote_volume'] + for col in numeric_cols: + df[col] = pd.to_numeric(df[col], errors='coerce') + + # 转换时间戳为日期 + df['open_time'] = pd.to_datetime(df['open_time'], unit='ms') + df['close_time'] = pd.to_datetime(df['close_time'], unit='ms') + df['trade_date'] = df['open_time'].dt.strftime('%Y-%m-%d') + df['date'] = df['trade_date'] # 兼容策略代码 + + # 按时间升序排列 + df = df.sort_values('open_time') + + logger.info(f"获取{symbol} {interval}历史K线成功,数据量: {len(df)}") + return df + + except BinanceAPIException as e: + logger.error(f"获取{symbol} K线数据失败(API错误): {e}") + return pd.DataFrame() + except Exception as e: + logger.error(f"获取{symbol} K线数据失败: {e}") + return pd.DataFrame() + + def get_symbol_info(self, symbol: str) -> dict: + """ + 获取交易对信息 + + Args: + symbol: 交易对符号 + + Returns: + 交易对信息字典 + """ + try: + if symbol in self._symbol_cache: + return self._symbol_cache[symbol] + + info = self.client.get_symbol_info(symbol) + self._symbol_cache[symbol] = info + return info + + except Exception as e: + logger.debug(f"获取{symbol}信息失败: {e}") + return {} + + def get_symbol_name(self, symbol: str) -> str: + """ + 获取交易对显示名称 + + Args: + symbol: 交易对符号 + + Returns: + 交易对名称 + """ + # 加密货币直接返回交易对符号 + return symbol + + def convert_timeframe(self, timeframe: str) -> str: + """ + 转换时间周期格式(从策略格式到Binance格式) + + Args: + timeframe: 策略时间周期,如'daily', 'weekly', 'hourly' + + Returns: + Binance时间周期,如'1d', '1w', '1h' + """ + mapping = { + 'daily': '1d', + 'weekly': '1w', + 'monthly': '1M', + 'hourly': '1h', + '4hour': '4h', + '15min': '15m', + '5min': '5m' + } + return mapping.get(timeframe, '1d') + + def get_24h_stats(self, symbol: str = None) -> pd.DataFrame: + """ + 获取24小时统计数据 + + Args: + symbol: 交易对符号,如果为None则获取所有交易对 + + Returns: + 统计数据DataFrame + """ + try: + if symbol: + stats = self.client.get_ticker(symbol=symbol) + df = pd.DataFrame([stats]) + else: + stats = self.client.get_ticker() + df = pd.DataFrame(stats) + + logger.info(f"获取24小时统计数据成功") + return df + + except Exception as e: + logger.error(f"获取24小时统计数据失败: {e}") + return pd.DataFrame() + + def get_account_balance(self) -> pd.DataFrame: + """ + 获取账户余额(需要API权限) + + Returns: + 账户余额DataFrame + """ + try: + if not self.api_key or not self.api_secret: + logger.warning("未配置API密钥,无法获取账户余额") + return pd.DataFrame() + + account = self.client.get_account() + balances = account['balances'] + + df = pd.DataFrame(balances) + df['free'] = pd.to_numeric(df['free']) + df['locked'] = pd.to_numeric(df['locked']) + + # 只显示非零余额 + df = df[(df['free'] > 0) | (df['locked'] > 0)] + + logger.info(f"获取账户余额成功,共{len(df)}个币种有余额") + return df + + except Exception as e: + logger.error(f"获取账户余额失败: {e}") + return pd.DataFrame() + + def get_market_overview(self) -> dict: + """ + 获取市场概况 + + Returns: + 市场概况字典 + """ + try: + # 获取主要币种的24小时数据 + major_symbols = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT'] + overview = { + 'update_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + 'major_coins': {} + } + + for symbol in major_symbols: + stats = self.client.get_ticker(symbol=symbol) + overview['major_coins'][symbol] = stats + + logger.info("获取市场概况成功") + return overview + + except Exception as e: + logger.error(f"获取市场概况失败: {e}") + return {} + + def search_symbols(self, keyword: str, quote_asset: str = 'USDT') -> List[str]: + """ + 搜索交易对 + + Args: + keyword: 搜索关键词 + quote_asset: 计价货币 + + Returns: + 匹配的交易对列表 + """ + try: + tickers_df = self.get_all_tickers() + + if tickers_df.empty: + return [] + + # 筛选计价货币 + filtered = tickers_df[tickers_df['symbol'].str.endswith(quote_asset)] + + # 搜索关键词 + keyword = keyword.upper() + results = filtered[filtered['symbol'].str.contains(keyword)]['symbol'].tolist() + + logger.info(f"搜索'{keyword}'找到{len(results)}个交易对") + return results + + except Exception as e: + logger.error(f"搜索交易对失败: {e}") + return [] + + +if __name__ == "__main__": + # 测试代码 + print("初始化Binance数据获取器...") + fetcher = BinanceFetcher() + + # 测试获取TOP交易对 + print("\n测试获取TOP10交易对...") + top_symbols = fetcher.get_top_volume_symbols(limit=10) + print(f"TOP10交易对: {top_symbols}") + + # 测试获取K线数据 + if top_symbols: + print(f"\n测试获取{top_symbols[0]}的日线数据...") + klines = fetcher.get_historical_klines(top_symbols[0], '1d', limit=100) + print(klines.head()) + print(f"数据量: {len(klines)}") + + # 测试市场概况 + print("\n测试获取市场概况...") + overview = fetcher.get_market_overview() + print(overview) diff --git a/src/data/sentiment_fetcher.py b/src/data/sentiment_fetcher.py deleted file mode 100644 index b39a512..0000000 --- a/src/data/sentiment_fetcher.py +++ /dev/null @@ -1,347 +0,0 @@ -""" -A股舆情数据获取模块 -基于adata库获取市场舆情、资金流向、热点数据等 -""" - -import adata -import pandas as pd -from typing import List, Optional, Union -from datetime import datetime, date -from loguru import logger - - -class SentimentFetcher: - """舆情数据获取器""" - - def __init__(self): - """初始化舆情数据获取器""" - self.client = adata - logger.info("舆情数据获取器初始化完成") - - # ========== 解禁数据 ========== - def get_stock_lifting_last_month(self) -> pd.DataFrame: - """ - 获取上个月股票解禁数据 - - Returns: - 解禁数据DataFrame,包含字段: - - stock_code: 股票代码 - - short_name: 股票简称 - - lift_date: 解禁日期 - - volume: 解禁数量 - - amount: 解禁金额 - - ratio: 解禁比例 - - price: 解禁价格 - """ - try: - lifting_data = self.client.sentiment.stock_lifting_last_month() - logger.info(f"获取解禁数据成功,数据量: {len(lifting_data)}") - return lifting_data - except Exception as e: - logger.error(f"获取解禁数据失败: {e}") - return pd.DataFrame() - - # ========== 两融数据 ========== - def get_securities_margin(self, start_date: str = '2022-01-01') -> pd.DataFrame: - """ - 获取融资融券数据 - - Args: - start_date: 开始日期,格式: 'YYYY-MM-DD' - - Returns: - 融资融券DataFrame,包含字段: - - trade_date: 交易日期 - - rzye: 融资余额 - - rqye: 融券余额 - - rzrqye: 融资融券余额 - - rzrqyecz: 融资融券余额差值 - """ - try: - margin_data = self.client.sentiment.securities_margin(start_date=start_date) - logger.info(f"获取融资融券数据成功,数据量: {len(margin_data)}") - return margin_data - except Exception as e: - logger.error(f"获取融资融券数据失败: {e}") - return pd.DataFrame() - - # ========== 北向资金 ========== - def get_north_flow_current(self) -> pd.DataFrame: - """ - 获取当前北向资金流向 - - Returns: - 当前北向资金流向DataFrame - """ - try: - north_flow = self.client.sentiment.north.north_flow_current() - logger.info("获取当前北向资金流向成功") - return north_flow - except Exception as e: - logger.error(f"获取当前北向资金流向失败: {e}") - return pd.DataFrame() - - def get_north_flow_min(self) -> pd.DataFrame: - """ - 获取北向资金分钟级流向数据 - - Returns: - 北向资金分钟级流向DataFrame - """ - try: - north_flow_min = self.client.sentiment.north.north_flow_min() - logger.info(f"获取北向资金分钟级数据成功,数据量: {len(north_flow_min)}") - return north_flow_min - except Exception as e: - logger.error(f"获取北向资金分钟级数据失败: {e}") - return pd.DataFrame() - - def get_north_flow_history(self, start_date: str = None) -> pd.DataFrame: - """ - 获取北向资金历史流向数据 - - Args: - start_date: 开始日期,格式: 'YYYY-MM-DD',默认为30天前 - - Returns: - 北向资金历史流向DataFrame - """ - try: - if start_date is None: - # 默认获取最近30天的数据 - start_date = (datetime.now() - pd.Timedelta(days=30)).strftime('%Y-%m-%d') - - north_flow_hist = self.client.sentiment.north.north_flow(start_date=start_date) - logger.info(f"获取北向资金历史数据成功,数据量: {len(north_flow_hist)}") - return north_flow_hist - except Exception as e: - logger.error(f"获取北向资金历史数据失败: {e}") - return pd.DataFrame() - - # ========== 热点股票 ========== - def get_popular_stocks_east_100(self) -> pd.DataFrame: - """ - 获取东方财富人气股票排行榜前100 - - Returns: - 人气股票排行DataFrame - """ - try: - popular_stocks = self.client.sentiment.hot.pop_rank_100_east() - logger.info(f"获取东财人气股票排行成功,数据量: {len(popular_stocks)}") - return popular_stocks - except Exception as e: - logger.error(f"获取东财人气股票排行失败: {e}") - return pd.DataFrame() - - def get_hot_stocks_ths_100(self) -> pd.DataFrame: - """ - 获取同花顺热门股票排行榜前100 - - Returns: - 热门股票排行DataFrame - """ - try: - hot_stocks = self.client.sentiment.hot.hot_rank_100_ths() - logger.info(f"获取同花顺热门股票排行成功,数据量: {len(hot_stocks)}") - return hot_stocks - except Exception as e: - logger.error(f"获取同花顺热门股票排行失败: {e}") - return pd.DataFrame() - - def get_hot_concept_ths_20(self) -> pd.DataFrame: - """ - 获取同花顺热门概念板块排行榜前20 - - Returns: - 热门概念板块DataFrame - """ - try: - hot_concepts = self.client.sentiment.hot.hot_concept_20_ths() - logger.info(f"获取同花顺热门概念排行成功,数据量: {len(hot_concepts)}") - return hot_concepts - except Exception as e: - logger.error(f"获取同花顺热门概念排行失败: {e}") - return pd.DataFrame() - - # ========== 龙虎榜 ========== - def get_dragon_tiger_list_daily(self, report_date: str = None) -> pd.DataFrame: - """ - 获取每日龙虎榜数据 - - Args: - report_date: 报告日期,格式: 'YYYY-MM-DD',默认为当日 - - Returns: - 龙虎榜DataFrame - """ - try: - if report_date is None: - report_date = datetime.now().strftime('%Y-%m-%d') - - dragon_tiger = self.client.sentiment.hot.list_a_list_daily(report_date=report_date) - logger.info(f"获取{report_date}龙虎榜数据成功,数据量: {len(dragon_tiger)}") - return dragon_tiger - except Exception as e: - logger.error(f"获取龙虎榜数据失败: {e}") - return pd.DataFrame() - - def get_stock_dragon_tiger_info(self, stock_code: str, report_date: str = None) -> pd.DataFrame: - """ - 获取单只股票龙虎榜详细信息 - - Args: - stock_code: 股票代码 - report_date: 报告日期,格式: 'YYYY-MM-DD',默认为当日 - - Returns: - 单股龙虎榜详细信息DataFrame - """ - try: - if report_date is None: - report_date = datetime.now().strftime('%Y-%m-%d') - - stock_info = self.client.sentiment.hot.get_a_list_info( - stock_code=stock_code, - report_date=report_date - ) - logger.info(f"获取{stock_code}龙虎榜信息成功") - return stock_info - except Exception as e: - logger.error(f"获取{stock_code}龙虎榜信息失败: {e}") - return pd.DataFrame() - - # ========== 风险扫描 ========== - def get_stock_risk_scan(self, stock_code: str) -> pd.DataFrame: - """ - 获取单只股票风险扫描数据 - - Args: - stock_code: 股票代码 - - Returns: - 股票风险扫描DataFrame - """ - try: - risk_data = self.client.sentiment.mine.mine_clearance_tdx(stock_code=stock_code) - logger.info(f"获取{stock_code}风险扫描数据成功") - return risk_data - except Exception as e: - logger.error(f"获取{stock_code}风险扫描数据失败: {e}") - return pd.DataFrame() - - # ========== 综合分析方法 ========== - def get_market_sentiment_overview(self) -> dict: - """ - 获取市场舆情综合概览 - - Returns: - 市场舆情概览字典 - """ - try: - overview = {} - - # 北向资金情况 - north_current = self.get_north_flow_current() - if not north_current.empty: - north_data = north_current.iloc[0] - overview['north_flow'] = { - 'net_total': north_data.get('net_tgt', 0), # 总净流入 - 'net_hgt': north_data.get('net_hgt', 0), # 沪股通净流入 - 'net_sgt': north_data.get('net_sgt', 0), # 深股通净流入 - 'update_time': north_data.get('trade_time', 'N/A') - } - - # 融资融券情况(最近7天) - recent_date = (datetime.now() - pd.Timedelta(days=7)).strftime('%Y-%m-%d') - margin_data = self.get_securities_margin(start_date=recent_date) - if not margin_data.empty: - overview['latest_margin'] = margin_data.tail(1).iloc[0].to_dict() - - # 热门股票 - overview['hot_stocks_east'] = self.get_popular_stocks_east_100().head(10) - overview['hot_stocks_ths'] = self.get_hot_stocks_ths_100().head(10) - - # 热门概念 - overview['hot_concepts'] = self.get_hot_concept_ths_20().head(10) - - # 今日龙虎榜 - overview['dragon_tiger'] = self.get_dragon_tiger_list_daily().head(10) - - overview['update_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - logger.info("获取市场舆情概览成功") - return overview - except Exception as e: - logger.error(f"获取市场舆情概览失败: {e}") - return {} - - def analyze_stock_sentiment(self, stock_code: str) -> dict: - """ - 分析单只股票的舆情情况 - - Args: - stock_code: 股票代码 - - Returns: - 股票舆情分析结果字典 - """ - try: - analysis = { - 'stock_code': stock_code, - 'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - # 风险扫描 - analysis['risk_scan'] = self.get_stock_risk_scan(stock_code) - - # 龙虎榜信息 - analysis['dragon_tiger'] = self.get_stock_dragon_tiger_info(stock_code) - - # 检查是否在热门榜单中 - hot_stocks_east = self.get_popular_stocks_east_100() - hot_stocks_ths = self.get_hot_stocks_ths_100() - - analysis['in_popular_east'] = stock_code in hot_stocks_east.get('stock_code', []).values if not hot_stocks_east.empty else False - analysis['in_hot_ths'] = stock_code in hot_stocks_ths.get('stock_code', []).values if not hot_stocks_ths.empty else False - - logger.info(f"分析{stock_code}舆情情况成功") - return analysis - except Exception as e: - logger.error(f"分析{stock_code}舆情情况失败: {e}") - return {'stock_code': stock_code, 'error': str(e)} - - -if __name__ == "__main__": - # 测试代码 - fetcher = SentimentFetcher() - - print("="*50) - print("测试舆情数据获取功能") - print("="*50) - - # 测试北向资金 - print("\n1. 测试北向资金数据...") - north_current = fetcher.get_north_flow_current() - print(f"当前北向资金数据: {len(north_current)} 条") - if not north_current.empty: - print(north_current.head()) - - # 测试热门股票 - print("\n2. 测试热门股票排行...") - hot_stocks = fetcher.get_popular_stocks_east_100() - print(f"东财人气股票: {len(hot_stocks)} 条") - if not hot_stocks.empty: - print(hot_stocks.head()) - - # 测试龙虎榜 - print("\n3. 测试龙虎榜数据...") - dragon_tiger = fetcher.get_dragon_tiger_list_daily() - print(f"今日龙虎榜: {len(dragon_tiger)} 条") - if not dragon_tiger.empty: - print(dragon_tiger.head()) - - # 测试市场舆情概览 - print("\n4. 测试市场舆情概览...") - overview = fetcher.get_market_sentiment_overview() - print("市场舆情概览获取完成") \ No newline at end of file diff --git a/src/data/stock_pool_manager.py b/src/data/stock_pool_manager.py index 321fb7a..5bfa6e0 100644 --- a/src/data/stock_pool_manager.py +++ b/src/data/stock_pool_manager.py @@ -36,9 +36,12 @@ class StockPoolRule(ABC): class TushareHotStocksRule(StockPoolRule): """同花顺热榜股票池规则""" - def get_stocks(self, fetcher: TushareFetcher, limit: int = 50, **kwargs) -> List[str]: + def get_stocks(self, fetcher: TushareFetcher, limit: int = None, **kwargs) -> List[str]: """获取同花顺热榜股票""" try: + # 如果没有指定limit,获取所有可用的热榜股票(默认最大2000) + if limit is None: + limit = 2000 hot_stocks = fetcher.get_hot_stocks_ths(limit=limit) if not hot_stocks.empty and 'stock_code' in hot_stocks.columns: stocks = hot_stocks['stock_code'].tolist() diff --git a/src/data/tushare_fetcher.py b/src/data/tushare_fetcher.py index 875691e..5dc2354 100644 --- a/src/data/tushare_fetcher.py +++ b/src/data/tushare_fetcher.py @@ -5,6 +5,7 @@ A股数据获取模块 import tushare as ts import pandas as pd +import re from typing import List, Optional, Union from datetime import datetime, date, timedelta import time @@ -86,6 +87,10 @@ class TushareFetcher: self._cache_timestamp = None self._cache_duration = 3600 # 缓存1小时 + # 分钟线接口调用频率控制(每分钟最多2次) + self._minute_data_call_times = [] + self._minute_data_max_calls_per_minute = 2 + def clear_caches(self): """清除所有缓存""" self._stock_name_cache.clear() @@ -220,8 +225,9 @@ class TushareFetcher: # 排除ST股票 if exclude_st: before_count = len(filtered_stocks) - st_pattern = r'(\*?ST|PT|退|暂停)' - filtered_stocks = filtered_stocks[~filtered_stocks['short_name'].str.contains(st_pattern, na=False, case=False)] + # 使用(?:...)非捕获组避免警告 + st_pattern = r'(?:\*?ST|PT|退|暂停)' + filtered_stocks = filtered_stocks[~filtered_stocks['short_name'].str.contains(st_pattern, na=False, case=False, regex=True)] st_excluded = before_count - len(filtered_stocks) logger.info(f"排除ST等风险股票: {st_excluded}只") @@ -371,7 +377,7 @@ class TushareFetcher: stock_code: 股票代码 start_date: 开始日期 end_date: 结束日期 - period: 数据周期 ('daily', 'weekly', 'monthly') + period: 数据周期 ('daily', 'weekly', 'monthly', '60min', '30min', '15min', '5min', '1min') Returns: 历史行情DataFrame @@ -417,6 +423,34 @@ class TushareFetcher: start_date=start_date, end_date=end_date ) + elif period in ('60min', '30min', '15min', '5min', '1min'): + # 使用分钟线数据接口 + # 注意:分钟线数据需要特殊权限,且有调用限制:每分钟最多2次 + + # 频率限制检查 + current_time = time.time() + # 清理60秒前的调用记录 + self._minute_data_call_times = [t for t in self._minute_data_call_times if current_time - t < 60] + + # 检查是否超过限制 + if len(self._minute_data_call_times) >= self._minute_data_max_calls_per_minute: + wait_time = 60 - (current_time - self._minute_data_call_times[0]) + logger.warning(f"分钟线接口频率限制:每分钟最多{self._minute_data_max_calls_per_minute}次,需等待 {wait_time:.1f} 秒") + time.sleep(wait_time + 1) # 多等1秒确保安全 + # 清理过期记录 + current_time = time.time() + self._minute_data_call_times = [t for t in self._minute_data_call_times if current_time - t < 60] + + # 调用接口 + hist_data = self.pro.stk_mins( + ts_code=ts_code, + start_date=start_date, + end_date=end_date, + freq=period + ) + + # 记录调用时间 + self._minute_data_call_times.append(time.time()) else: hist_data = self.pro.daily( ts_code=ts_code, @@ -602,21 +636,79 @@ class TushareFetcher: df = df.drop_duplicates(subset=['ts_code'], keep='first') logger.info(f"去重后数据: {len(df)} 条") - # 2. 按rank排序,处理排名异常 + # 2. 过滤退市和ST股票 + if 'ts_name' in df.columns: + before_filter = len(df) + # 过滤包含以下关键词的股票:退市、ST、*ST、PT + # 使用(?:...)非捕获组避免警告 + filter_pattern = r'(?:退市|^\*?ST|^ST|^PT|暂停)' + df = df[~df['ts_name'].str.contains(filter_pattern, na=False, case=False, regex=True)] + filtered_count = before_filter - len(df) + if filtered_count > 0: + logger.info(f"过滤退市/ST股票: {filtered_count} 只") + + # 3. 按rank排序,处理排名异常 if 'rank' in df.columns: df = df.sort_values('rank') # 重新分配连续排名 df['original_rank'] = df['rank'] # 保留原始排名 df['rank'] = range(1, len(df) + 1) # 重新分配连续排名 - # 3. 只取前limit条 - df = df.head(limit) - - # 4. 统一列名格式 + # 4. 统一列名格式(先改名,方便后续处理) if 'ts_code' in df.columns: df.rename(columns={'ts_code': 'stock_code', 'ts_name': 'short_name'}, inplace=True) - # 5. 添加数据源标识和有用字段 + # 5. 二次验证:通过TuShare API验证股票状态,过滤ST和退市股票 + # 为了确保最终有足够数量,验证更多股票 + if self.pro and 'stock_code' in df.columns: + # 计算需要验证的数量:考虑约10%的过滤率,验证limit*1.15的股票 + verify_count = min(int(limit * 1.15), len(df)) + df_to_verify = df.head(verify_count) + + valid_stocks = [] + verified_count = 0 + + for idx, row in df_to_verify.iterrows(): + verified_count += 1 + try: + stock_code = row['stock_code'] + stock_info = self.pro.stock_basic(ts_code=stock_code, fields='ts_code,name,list_status') + + if not stock_info.empty: + info = stock_info.iloc[0] + real_name = info['name'] + list_status = info['list_status'] + + # 检查股票状态和名称 + if list_status == 'L': # 只保留正常上市的股票 + # 检查真实名称是否包含ST/退市等 + st_pattern = r'(退市|^\*?ST|^ST|^PT|暂停)' + if not re.search(st_pattern, real_name): + valid_stocks.append(idx) + else: + logger.debug(f"二次过滤: {stock_code} - {real_name}") + else: + logger.debug(f"二次过滤: {stock_code} - 状态:{list_status}") + except Exception as e: + logger.debug(f"验证股票{row['stock_code']}失败: {e}") + continue + + # 如果已经有足够的有效股票,可以提前结束 + if len(valid_stocks) >= limit: + break + + if valid_stocks: + df = df.loc[valid_stocks] + filtered_count = verified_count - len(valid_stocks) + logger.info(f"二次验证: 验证{verified_count}只,过滤{filtered_count}只,剩余{len(df)}只") + + # 6. 最终确保返回limit数量的股票 + if len(df) < limit: + logger.warning(f"过滤后股票不足: 需要{limit}只,实际{len(df)}只") + + df = df.head(limit) + + # 8. 添加数据源标识和有用字段 df['source'] = '同花顺热股' # 保留有用的原始字段 @@ -667,13 +759,24 @@ class TushareFetcher: return self._get_fallback_hot_stocks(limit) # 数据处理和标准化 + # 1. 过滤退市和ST股票 + if 'ts_name' in df.columns: + before_filter = len(df) + # 使用(?:...)非捕获组避免警告 + filter_pattern = r'(?:退市|^\*?ST|^ST|^PT|暂停)' + df = df[~df['ts_name'].str.contains(filter_pattern, na=False, case=False, regex=True)] + filtered_count = before_filter - len(df) + if filtered_count > 0: + logger.info(f"过滤退市/ST股票: {filtered_count} 只") + + # 2. 限制数量 df = df.head(limit) - # 统一列名格式 + # 3. 统一列名格式 if 'ts_code' in df.columns: df.rename(columns={'ts_code': 'stock_code', 'ts_name': 'short_name'}, inplace=True) - # 添加数据源标识 + # 4. 添加数据源标识 df['source'] = '东财人气榜' if 'rank' not in df.columns: df['rank'] = range(1, len(df) + 1) diff --git a/src/database/mysql_database_manager.py b/src/database/mysql_database_manager.py index 691cf82..7b3631b 100644 --- a/src/database/mysql_database_manager.py +++ b/src/database/mysql_database_manager.py @@ -6,6 +6,7 @@ MySQL数据库管理器 import pymysql import pandas as pd +import numpy as np from datetime import date, datetime, timedelta from typing import Dict, Any, Optional, List import json @@ -15,6 +16,22 @@ from loguru import logger from sqlalchemy import create_engine import warnings + +class DateTimeEncoder(json.JSONEncoder): + """自定义JSON编码器,处理pandas Timestamp和numpy类型""" + def default(self, obj): + if isinstance(obj, pd.Timestamp): + return obj.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(obj, (datetime, date)): + return obj.isoformat() + elif isinstance(obj, np.integer): + return int(obj) + elif isinstance(obj, np.floating): + return float(obj) + elif isinstance(obj, np.ndarray): + return obj.tolist() + return super().default(obj) + # 添加项目根目录到路径 current_dir = Path(__file__).parent.parent.parent sys.path.insert(0, str(current_dir)) @@ -255,8 +272,16 @@ class MySQLDatabaseManager: logger.error(f"更新扫描会话统计失败: {e}") raise - def save_stock_signal(self, session_id: int, strategy_id: int, signal: Dict[str, Any]) -> int: - """保存股票信号""" + def save_stock_signal(self, session_id: int, strategy_id: int, signal: Dict[str, Any], asset_type: str = 'stock') -> int: + """ + 保存股票/加密货币信号 + + Args: + session_id: 扫描会话ID + strategy_id: 策略ID + signal: 信号字典 + asset_type: 资产类型,'stock'(股票) 或 'crypto'(加密货币) + """ try: with pymysql.connect(**self.connection_params) as conn: cursor = conn.cursor() @@ -281,26 +306,27 @@ class MySQLDatabaseManager: elif hasattr(confirmation_date, 'date'): confirmation_date = confirmation_date.date() - # 准备K线数据 - k1_data = json.dumps(signal.get('k1', {})) if signal.get('k1') else None - k2_data = json.dumps(signal.get('k2', {})) if signal.get('k2') else None - k3_data = json.dumps(signal.get('k3', {})) if signal.get('k3') else None - k4_data = json.dumps(signal.get('k4', {})) if signal.get('k4') else None + # 准备K线数据(使用自定义编码器处理pandas Timestamp) + k1_data = json.dumps(signal.get('k1', {}), cls=DateTimeEncoder) if signal.get('k1') else None + k2_data = json.dumps(signal.get('k2', {}), cls=DateTimeEncoder) if signal.get('k2') else None + k3_data = json.dumps(signal.get('k3', {}), cls=DateTimeEncoder) if signal.get('k3') else None + k4_data = json.dumps(signal.get('k4', {}), cls=DateTimeEncoder) if signal.get('k4') else None cursor.execute(""" INSERT INTO stock_signals ( - session_id, strategy_id, stock_code, stock_name, timeframe, + session_id, strategy_id, stock_code, stock_name, asset_type, timeframe, signal_date, signal_type, breakout_price, yin_high, breakout_amount, breakout_pct, ema20_price, yang1_entity_ratio, yang2_entity_ratio, final_yang_entity_ratio, turnover_ratio, above_ema20, new_high_confirmed, new_high_price, new_high_date, confirmation_date, confirmation_days, pullback_distance, k1_data, k2_data, k3_data, k4_data - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( session_id, strategy_id, signal.get('stock_code'), signal.get('stock_name'), + asset_type, # 新增资产类型 signal.get('timeframe'), signal_date, signal.get('pattern_type', '两阳+阴+阳突破'), @@ -313,8 +339,8 @@ class MySQLDatabaseManager: signal.get('yang2_entity_ratio'), signal.get('final_yang_entity_ratio'), signal.get('turnover_ratio'), - signal.get('above_ema20'), - signal.get('new_high_confirmed', False), + 1 if signal.get('above_ema20') else 0, # 转换布尔值为整数 + 1 if signal.get('new_high_confirmed', False) else 0, # 转换布尔值为整数 signal.get('new_high_price'), new_high_date, confirmation_date, @@ -333,18 +359,33 @@ class MySQLDatabaseManager: logger.error(f"保存股票信号失败: {e}") raise - def get_latest_signals(self, strategy_name: str = None, limit: int = 100) -> pd.DataFrame: - """获取最新信号""" + def get_latest_signals(self, strategy_name: str = None, asset_type: str = None, limit: int = 100) -> pd.DataFrame: + """ + 获取最新信号 + + Args: + strategy_name: 策略名称过滤 + asset_type: 资产类型过滤,'stock'(股票) 或 'crypto'(加密货币) + limit: 返回数量限制 + """ try: sql = """ SELECT * FROM latest_signals_view """ params = [] + conditions = [] if strategy_name: - sql += " WHERE strategy_name = %s" + conditions.append("strategy_name = %s") params.append(strategy_name) + if asset_type: + conditions.append("asset_type = %s") + params.append(asset_type) + + if conditions: + sql += " WHERE " + " AND ".join(conditions) + sql += " LIMIT %s" params.append(limit) @@ -355,8 +396,18 @@ class MySQLDatabaseManager: return pd.DataFrame() def get_signals_by_date_range(self, start_date: date, end_date: date = None, - strategy_name: str = None, timeframe: str = None) -> pd.DataFrame: - """按日期范围获取信号""" + strategy_name: str = None, asset_type: str = None, + timeframe: str = None) -> pd.DataFrame: + """ + 按日期范围获取信号 + + Args: + start_date: 开始日期 + end_date: 结束日期 + strategy_name: 策略名称过滤 + asset_type: 资产类型过滤,'stock'(股票) 或 'crypto'(加密货币) + timeframe: 时间周期过滤 + """ try: if end_date is None: end_date = date.today() @@ -371,6 +422,10 @@ class MySQLDatabaseManager: sql += " AND strategy_name = %s" params.append(strategy_name) + if asset_type: + sql += " AND asset_type = %s" + params.append(asset_type) + if timeframe: sql += " AND timeframe = %s" params.append(timeframe) diff --git a/src/database/mysql_schema.sql b/src/database/mysql_schema.sql index 6ebc91e..a53b72c 100644 --- a/src/database/mysql_schema.sql +++ b/src/database/mysql_schema.sql @@ -25,13 +25,14 @@ CREATE TABLE IF NOT EXISTS scan_sessions ( FOREIGN KEY (strategy_id) REFERENCES strategies(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- 股票信号表:存储具体的股票筛选信号 +-- 股票信号表:存储具体的股票筛选信号(包括股票和加密货币) CREATE TABLE IF NOT EXISTS stock_signals ( id INT AUTO_INCREMENT PRIMARY KEY, session_id INT, strategy_id INT, stock_code VARCHAR(20) NOT NULL, stock_name VARCHAR(100), + asset_type VARCHAR(20) DEFAULT 'stock' COMMENT '资产类型: stock(股票), crypto(加密货币)', timeframe VARCHAR(20) NOT NULL, signal_date DATE NOT NULL, signal_type VARCHAR(100) NOT NULL, @@ -73,7 +74,8 @@ CREATE TABLE IF NOT EXISTS stock_signals ( INDEX idx_signal_date (signal_date), INDEX idx_strategy_id (strategy_id), INDEX idx_session_id (session_id), - INDEX idx_new_high_confirmed (new_high_confirmed) + INDEX idx_new_high_confirmed (new_high_confirmed), + INDEX idx_asset_type (asset_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 回踩监控表:存储回踩提醒信息 @@ -82,6 +84,7 @@ CREATE TABLE IF NOT EXISTS pullback_alerts ( signal_id INT, stock_code VARCHAR(20) NOT NULL, stock_name VARCHAR(100), + asset_type VARCHAR(20) DEFAULT 'stock' COMMENT '资产类型: stock(股票), crypto(加密货币)', timeframe VARCHAR(20) NOT NULL, -- 原始信号信息 @@ -116,6 +119,7 @@ CREATE OR REPLACE VIEW latest_signals_view AS SELECT ss.stock_code, ss.stock_name, + ss.asset_type, ss.timeframe, ss.signal_date, ss.breakout_price, diff --git a/src/execution/strategy_executor.py b/src/execution/strategy_executor.py index 117aa78..c5df1d8 100644 --- a/src/execution/strategy_executor.py +++ b/src/execution/strategy_executor.py @@ -198,10 +198,15 @@ class StrategyExecutor: result.add_strategy_results(strategy_results, actual_analyzed) # 第3步: 发送通知 - if send_notification and result.total_signals_found > 0: + if send_notification: logger.info("📱 第3步: 发送分析结果通知...") try: - self._send_notification(result, strategy_results) + # 先发送扫描报告(无论是否有信号) + self._send_scan_report(result) + + # 如果有信号,再发送详细信号汇总 + if result.total_signals_found > 0: + self._send_signal_summary(result, strategy_results) except Exception as e: logger.warning(f"发送通知失败: {e}") @@ -213,42 +218,66 @@ class StrategyExecutor: return result - def _send_notification(self, result: ExecutionResult, strategy_results: Dict[str, Dict[str, StrategyResult]]): - """发送分析结果通知""" - # 准备通知数据 - summary = result.get_summary() - - # 构建策略结果摘要 - strategy_summary = { - 'strategy_name': result.strategy_name, - 'stock_pool_source': result.stock_pool_info.get('rule_display_name', ''), - 'total_stocks': result.total_stocks_analyzed, - 'stocks_with_signals': result.stocks_with_signals, - 'total_signals': result.total_signals_found, - 'execution_time': result.execution_time - } - - # 发送策略摘要通知 + def _send_scan_report(self, result: ExecutionResult): + """发送扫描报告(无论是否有信号)""" try: + scan_report = { + 'task_id': result.task_id, + 'strategy_name': result.strategy_name, + 'stock_pool_name': result.stock_pool_info.get('rule_display_name', '未知股票池'), + 'total_stocks_analyzed': result.total_stocks_analyzed, + 'stocks_with_signals': result.stocks_with_signals, + 'total_signals_found': result.total_signals_found, + 'execution_time': result.execution_time, + 'scan_time': result.start_time.strftime("%Y-%m-%d %H:%M:%S"), + 'success': result.success, + 'error': result.error + } + + success = self.notification_manager.send_scan_report(scan_report) + if success: + logger.info("📱 扫描报告发送成功") + else: + logger.warning("📱 扫描报告发送失败") + + except Exception as e: + logger.error(f"发送扫描报告失败: {e}") + + def _send_signal_summary(self, result: ExecutionResult, strategy_results: Dict[str, Dict[str, StrategyResult]]): + """发送详细信号汇总(仅在有信号时调用)""" + try: + # 构建策略结果摘要 + strategy_summary = { + 'strategy_name': result.strategy_name, + 'stock_pool_source': result.stock_pool_info.get('rule_display_name', ''), + 'total_stocks': result.total_stocks_analyzed, + 'stocks_with_signals': result.stocks_with_signals, + 'total_signals': result.total_signals_found, + 'execution_time': result.execution_time + } + # 转换StrategyResult格式为通知所需格式 - all_signals = {} + # 注意:这里传递的是完整的StrategyResult对象,而不仅仅是signals列表 + all_results = {} for stock_code, timeframe_results in strategy_results.items(): for timeframe, strategy_result in timeframe_results.items(): - if strategy_result.get_signal_count() > 0: - if stock_code not in all_signals: - all_signals[stock_code] = {} - all_signals[stock_code][timeframe] = strategy_result.signals + # 只要有信号或形态就添加 + if strategy_result.get_signal_count() > 0 or strategy_result.get_pattern_count() > 0: + if stock_code not in all_results: + all_results[stock_code] = {} + all_results[stock_code][timeframe] = strategy_result success = self.notification_manager.send_strategy_summary( - all_signals, + all_results, strategy_summary ) if success: - logger.info("📱 策略结果通知发送成功") + logger.info("📱 信号汇总通知发送成功") else: - logger.warning("📱 策略结果通知发送失败") + logger.warning("📱 信号汇总通知发送失败") + except Exception as e: - logger.error(f"发送策略摘要通知失败: {e}") + logger.error(f"发送信号汇总通知失败: {e}") def get_registered_strategies(self) -> Dict[str, str]: """ diff --git a/src/strategy/base_strategy.py b/src/strategy/base_strategy.py index c9f76d6..32d4540 100644 --- a/src/strategy/base_strategy.py +++ b/src/strategy/base_strategy.py @@ -18,6 +18,7 @@ class StrategyResult: stock_code: str, timeframe: str, signals: List[Dict[str, Any]] = None, + patterns: List[Dict[str, Any]] = None, success: bool = True, error: str = None, execution_time: float = 0.0): @@ -28,7 +29,8 @@ class StrategyResult: strategy_name: 策略名称 stock_code: 股票代码 timeframe: 时间周期 - signals: 信号列表 + signals: 信号列表(已确认信号) + patterns: 形态列表(形态形成但未产生信号) success: 是否成功 error: 错误信息 execution_time: 执行时间 @@ -37,6 +39,7 @@ class StrategyResult: self.stock_code = stock_code self.timeframe = timeframe self.signals = signals or [] + self.patterns = patterns or [] self.success = success self.error = error self.execution_time = execution_time @@ -46,10 +49,18 @@ class StrategyResult: """是否有信号""" return len(self.signals) > 0 + def has_patterns(self) -> bool: + """是否有形态""" + return len(self.patterns) > 0 + def get_signal_count(self) -> int: """获取信号数量""" return len(self.signals) + def get_pattern_count(self) -> int: + """获取形态数量""" + return len(self.patterns) + def to_dict(self) -> Dict[str, Any]: """转换为字典""" return { @@ -57,7 +68,9 @@ class StrategyResult: 'stock_code': self.stock_code, 'timeframe': self.timeframe, 'signals': self.signals, + 'patterns': self.patterns, 'signal_count': self.get_signal_count(), + 'pattern_count': self.get_pattern_count(), 'success': self.success, 'error': self.error, 'execution_time': self.execution_time, @@ -111,6 +124,7 @@ class BaseStrategy(ABC): results = {} total_signals = 0 + total_patterns = 0 logger.info(f"🔍 开始分析股票池,共 {len(stock_list)} 只股票") @@ -121,21 +135,32 @@ class BaseStrategy(ABC): # 分析单只股票 stock_results = self.analyze_stock(stock_code) - # 统计信号数量 + # 统计信号和形态数量 stock_signal_count = sum( result.get_signal_count() for result in stock_results.values() ) + stock_pattern_count = sum( + result.get_pattern_count() for result in stock_results.values() + ) - if stock_signal_count > 0: + # 只要有信号或形态就添加到结果中 + if stock_signal_count > 0 or stock_pattern_count > 0: results[stock_code] = stock_results total_signals += stock_signal_count - logger.info(f"✅ {stock_code} 发现 {stock_signal_count} 个信号") + total_patterns += stock_pattern_count + + if stock_signal_count > 0 and stock_pattern_count > 0: + logger.info(f"✅ {stock_code} 发现 {stock_signal_count} 个信号, {stock_pattern_count} 个形态") + elif stock_signal_count > 0: + logger.info(f"✅ {stock_code} 发现 {stock_signal_count} 个信号") + else: + logger.info(f"📊 {stock_code} 发现 {stock_pattern_count} 个形态") except Exception as e: logger.error(f"❌ 分析股票 {stock_code} 失败: {e}") continue - logger.info(f"🎉 股票池分析完成: 扫描 {len(stock_list)} 只,发现 {total_signals} 个信号,涉及 {len(results)} 只股票") + logger.info(f"🎉 股票池分析完成: 扫描 {len(stock_list)} 只,发现 {total_signals} 个信号, {total_patterns} 个形态,涉及 {len(results)} 只股票") return results diff --git a/src/strategy/crypto_kline_pattern_strategy.py b/src/strategy/crypto_kline_pattern_strategy.py new file mode 100644 index 0000000..f6a1660 --- /dev/null +++ b/src/strategy/crypto_kline_pattern_strategy.py @@ -0,0 +1,587 @@ +""" +加密货币K线形态策略模块 +基于原有K线形态策略,适配加密货币市场 +""" + +import pandas as pd +from typing import Dict, List, Any +from datetime import datetime, timedelta +from loguru import logger + +from ..data.binance_fetcher import BinanceFetcher +from ..utils.notification import NotificationManager +from ..database.mysql_database_manager import MySQLDatabaseManager +from .kline_pattern_strategy import KLinePatternStrategy +from .base_strategy import StrategyResult + + +class CryptoKLinePatternStrategy(KLinePatternStrategy): + """加密货币K线形态策略类""" + + def __init__(self, data_fetcher: BinanceFetcher, notification_manager: NotificationManager, + config: Dict[str, Any], db_manager: MySQLDatabaseManager = None): + """ + 初始化加密货币K线形态策略 + + Args: + data_fetcher: Binance数据获取器 + notification_manager: 通知管理器 + config: 策略配置 + db_manager: 数据库管理器 + """ + # 先调用父类构造函数初始化基本属性 + super().__init__(data_fetcher, notification_manager, config, db_manager) + + # 然后替换和定制加密货币特有属性 + self.data_fetcher = data_fetcher # 替换为Binance数据获取器 + self.strategy_name = "加密货币K线形态策略" + self.timeframes = config.get('timeframes', ['4hour', 'daily', 'weekly']) + self.max_turnover_ratio = config.get('max_turnover_ratio', 100.0) # 加密货币换手率通常更高 + + # 加密货币特有参数 + self.quote_asset = config.get('quote_asset', 'USDT') # 计价货币 + self.min_volume_usdt = config.get('min_volume_usdt', 1000000) # 最小24h交易量(USDT) + + # 热门币种缓存机制 + self._hot_symbols_cache = None + self._cache_timestamp = None + self._cache_duration = 300 # 缓存5分钟 + + # 更新策略ID为加密货币版本 + self.strategy_id = self.db_manager.create_or_update_strategy( + strategy_name=self.strategy_name, + strategy_type="crypto_kline_pattern", + description="加密货币强势上涨+多空博弈+突破确认形态识别策略", + config=config + ) + + logger.info(f"加密货币K线形态策略初始化完成 (策略ID: {self.strategy_id})") + + def _get_cached_hot_symbols(self, max_symbols: int) -> List[str]: + """ + 获取缓存的热门交易对列表 + + Args: + max_symbols: 最大交易对数量 + + Returns: + 交易对列表 + """ + import time + current_time = time.time() + + # 检查缓存是否有效 + if (self._hot_symbols_cache is not None and + self._cache_timestamp is not None and + current_time - self._cache_timestamp < self._cache_duration): + + logger.info(f"🔄 使用缓存的热门交易对数据 ({len(self._hot_symbols_cache)} 个)") + return self._hot_symbols_cache[:max_symbols] + + # 缓存失效或不存在,重新获取 + logger.info(f"🔥 获取热门交易对 (TOP{max_symbols})...") + hot_symbols = self.data_fetcher.get_top_volume_symbols( + quote_asset=self.quote_asset, + limit=max_symbols * 2 # 多获取一些以备过滤 + ) + + if hot_symbols: + self._hot_symbols_cache = hot_symbols + self._cache_timestamp = current_time + + logger.info(f"✅ 热门交易对获取成功,已缓存 {len(self._hot_symbols_cache)} 个") + return self._hot_symbols_cache[:max_symbols] + else: + logger.error("❌ 热门交易对数据为空") + return [] + + def clear_hot_symbols_cache(self): + """清除热门交易对缓存,强制下次重新获取""" + self._hot_symbols_cache = None + self._cache_timestamp = None + logger.info("🔄 热门交易对缓存已清除") + + def _check_current_price_validity(self, symbol: str, entry_price: float, tolerance: float = 0.05) -> bool: + """ + 检查当前价格是否仍然适合入场(不低于入场价的5%) + + Args: + symbol: 交易对符号 + entry_price: 建议入场价格 + tolerance: 价格下跌容忍度,默认5% + + Returns: + bool: 当前价格是否仍然有效 + """ + try: + # 获取最新价格数据(使用24小时数据) + current_data = self.data_fetcher.get_historical_klines( + symbol, '1d', limit=2 # 获取最近2天数据 + ) + + if current_data.empty: + logger.warning(f"无法获取 {symbol} 的最新价格数据") + return True # 无法获取价格时保守处理,保留信号 + + current_price = current_data.iloc[-1]['close'] + min_valid_price = entry_price * (1 - tolerance) + + is_valid = current_price >= min_valid_price + + if not is_valid: + price_drop_pct = (entry_price - current_price) / entry_price * 100 + logger.info(f"🔻 {symbol} 价格过滤: 当前价 {current_price:.4f} < 入场价 {entry_price:.4f} (下跌{price_drop_pct:.1f}%)") + + return is_valid + + except Exception as e: + logger.error(f"检查 {symbol} 当前价格失败: {e}") + return True # 出错时保守处理,保留信号 + + def _filter_recent_signals(self, signals: List[Dict[str, Any]], days: int = 7) -> List[Dict[str, Any]]: + """ + 过滤最近N天内产生的信号,并检查当前价格有效性(加密货币版本) + + Args: + signals: 信号列表 + days: 最近天数,默认7天 + + Returns: + 过滤后的信号列表 + """ + if not signals: + return signals + + from datetime import datetime, date + import pandas as pd + + current_date = datetime.now().date() + recent_signals = [] + price_filtered_count = 0 + + for signal in signals: + signal_date = signal.get('confirmation_date') or signal.get('date') + + # 处理不同的日期格式 + if isinstance(signal_date, str): + try: + signal_date = pd.to_datetime(signal_date).date() + except: + continue + elif hasattr(signal_date, 'date'): + signal_date = signal_date.date() + elif not isinstance(signal_date, date): + continue + + # 计算信号距今天数 + days_ago = (current_date - signal_date).days + + # 只保留最近N天内的信号 + if days_ago <= days: + # 检查当前价格是否仍然有效 + symbol = signal.get('stock_code', '') # 在加密货币中,stock_code存储的是交易对符号 + entry_price = signal.get('yin_high', 0) + + if symbol and entry_price > 0: + if self._check_current_price_validity(symbol, entry_price): + recent_signals.append(signal) + logger.debug(f"✅ 保留有效信号: {symbol} {signal_date} (距今{days_ago}天)") + else: + price_filtered_count += 1 + logger.debug(f"🔻 价格过滤信号: {symbol} {signal_date} (价格已跌破入场价)") + else: + # 缺少必要信息时保守处理 + recent_signals.append(signal) + logger.debug(f"✅ 保留信号(缺少价格信息): {signal_date} (距今{days_ago}天)") + else: + logger.debug(f"🗓️ 过滤历史信号: {signal_date} (距今{days_ago}天)") + + # 统计过滤结果 + time_filtered_count = len(signals) - len(recent_signals) - price_filtered_count + if len(recent_signals) != len(signals): + logger.info(f"📅 加密货币信号过滤统计: 总共{len(signals)}个 → 保留{len(recent_signals)}个") + if time_filtered_count > 0: + logger.info(f" 🗓️ 时间过滤: {time_filtered_count}个") + if price_filtered_count > 0: + logger.info(f" 🔻 价格过滤: {price_filtered_count}个") + + return recent_signals + + def analyze_symbol(self, symbol: str, timeframes: List[str] = None, + session_id: int = None) -> Dict[str, StrategyResult]: + """ + 分析单个交易对的K线形态 + + Args: + symbol: 交易对符号,如'BTCUSDT' + timeframes: 时间周期列表,如果为None则使用策略默认周期 + session_id: 扫描会话ID + + Returns: + 时间周期到策略结果的映射 + """ + if timeframes is None: + timeframes = self.timeframes + + results = {} + symbol_name = self.data_fetcher.get_symbol_name(symbol) + + for timeframe in timeframes: + start_time = datetime.now() + try: + # 转换时间周期格式 + binance_interval = self.data_fetcher.convert_timeframe(timeframe) + + # 计算需要获取的K线数量(使用limit更稳定) + if timeframe == 'daily': + limit = 60 # 获取60根日线 + elif timeframe == '4hour': + limit = 180 # 获取180根4小时线(约30天) + elif timeframe == 'weekly': + limit = 26 # 获取26根周线(约半年) + else: + limit = 100 # 默认100根 + + logger.info(f"🔍 分析交易对: {symbol} | 周期: {timeframe}") + + # 获取历史数据(使用limit参数,更可靠) + df = self.data_fetcher.get_historical_klines( + symbol, binance_interval, limit=limit + ) + + if df.empty: + logger.warning(f"{symbol} {timeframe} 数据为空") + results[timeframe] = StrategyResult( + strategy_name=self.strategy_name, + stock_code=symbol, + timeframe=timeframe, + signals=[], + success=False, + error="数据为空", + execution_time=(datetime.now() - start_time).total_seconds() + ) + continue + + # 计算K线特征 + df_with_features = self.calculate_kline_features(df) + + # 检测形态(返回已确认信号和形态形成) + confirmed_signals, formed_patterns = self.detect_pattern(df_with_features) + + # 过滤一周内的信号和形态 + recent_signals = self._filter_recent_signals(confirmed_signals, days=7) + recent_patterns = self._filter_recent_signals(formed_patterns, days=7) + + # 处理确认信号格式 + formatted_signals = [] + for signal in recent_signals: + formatted_signal = { + 'date': signal['date'], + 'signal_type': signal['pattern_type'], + 'price': signal['breakout_price'], + 'confidence': signal['final_yang_entity_ratio'], + 'stock_code': symbol, # 添加交易对代码 + 'stock_name': symbol_name, + 'status': 'confirmed', + 'details': { + 'yin_high': signal['yin_high'], + 'breakout_amount': signal['breakout_amount'], + 'breakout_pct': signal['breakout_pct'], + 'ema20_price': signal['ema20_price'], + 'turnover_ratio': signal['turnover_ratio'], + 'breakout_position': signal.get('breakout_position', 4), + 'pattern_subtype': signal.get('pattern_subtype', '') # 添加形态子类型 + } + } + + # 添加回踩确认信息 + if not signal.get('confirmation_pending', True): + formatted_signal['details'].update({ + 'new_high_confirmed': signal.get('new_high_confirmed', True), + 'new_high_price': signal.get('new_high_price'), + 'new_high_date': signal.get('new_high_date'), + 'confirmation_date': signal.get('confirmation_date'), + 'confirmation_days': signal.get('confirmation_days'), + 'pullback_distance': signal.get('pullback_distance') + }) + + formatted_signals.append(formatted_signal) + + # 将信号添加到监控列表 + signal['stock_code'] = symbol + signal['stock_name'] = symbol_name + signal['timeframe'] = timeframe + self.add_triggered_signal(signal) + + # 保存信号到数据库(标记为加密货币) + if session_id is not None: + try: + signal_id = self.db_manager.save_stock_signal( + session_id=session_id, + strategy_id=self.strategy_id, + signal=signal, + asset_type='crypto' # 标记为加密货币 + ) + logger.debug(f"加密货币信号已保存到数据库: signal_id={signal_id}") + except Exception as e: + logger.error(f"保存加密货币信号到数据库失败: {e}") + + # 处理形态形成格式 + formatted_patterns = [] + for pattern in recent_patterns: + formatted_pattern = { + 'date': pattern['date'], + 'pattern_type': pattern['pattern_type'], + 'price': pattern['breakout_price'], + 'confidence': pattern['final_yang_entity_ratio'], + 'stock_code': symbol, # 添加交易对代码 + 'stock_name': symbol_name, + 'status': 'formed', + 'details': { + 'yin_high': pattern['yin_high'], + 'breakout_amount': pattern['breakout_amount'], + 'breakout_pct': pattern['breakout_pct'], + 'ema20_price': pattern['ema20_price'], + 'turnover_ratio': pattern['turnover_ratio'], + 'breakout_position': pattern.get('breakout_position', 4), + 'pattern_subtype': pattern.get('pattern_subtype', '') # 添加形态子类型 + } + } + + formatted_patterns.append(formatted_pattern) + + execution_time = (datetime.now() - start_time).total_seconds() + results[timeframe] = StrategyResult( + strategy_name=self.strategy_name, + stock_code=symbol, + timeframe=timeframe, + signals=formatted_signals, + patterns=formatted_patterns, + success=True, + execution_time=execution_time + ) + + # 美化统计日志 + if formatted_signals or formatted_patterns: + logger.info(f"✅ {symbol} {timeframe}周期: 信号={len(formatted_signals)}个, 形态={len(formatted_patterns)}个") + + if formatted_signals: + logger.info(f" 🎯 已确认信号:") + for i, signal in enumerate(formatted_signals, 1): + logger.info(f" {i}. {signal['date']} | 价格: {signal['price']:.4f} | {signal['signal_type']}") + + if formatted_patterns: + logger.info(f" 📊 形态形成:") + for i, pattern in enumerate(formatted_patterns, 1): + logger.info(f" {i}. {pattern['date']} | 价格: {pattern['price']:.4f} | {pattern['pattern_type']}") + else: + logger.debug(f"📭 {symbol} {timeframe}周期: 无信号和形态") + + except Exception as e: + logger.error(f"分析交易对 {symbol} {timeframe}周期失败: {e}") + execution_time = (datetime.now() - start_time).total_seconds() + results[timeframe] = StrategyResult( + strategy_name=self.strategy_name, + stock_code=symbol, + timeframe=timeframe, + signals=[], + patterns=[], + success=False, + error=str(e), + execution_time=execution_time + ) + + return results + + def scan_market(self, symbol_list: List[str] = None, max_symbols: int = 100) -> Dict[str, Dict[str, List[Dict[str, Any]]]]: + """ + 扫描加密货币市场 + + Args: + symbol_list: 交易对列表,如果为None则使用热门交易对 + max_symbols: 最大扫描交易对数量 + + Returns: + 所有交易对的分析结果 + """ + logger.info("🚀" + "="*70) + logger.info("🌍 开始加密货币市场K线形态扫描") + logger.info("🚀" + "="*70) + + # 创建扫描会话 + scan_config = { + 'max_symbols': max_symbols, + 'data_source': 'Binance', + 'quote_asset': self.quote_asset, + 'timeframes': self.timeframes + } + session_id = self.db_manager.create_scan_session( + strategy_id=self.strategy_id, + scan_config=scan_config + ) + + if symbol_list is None: + # 使用缓存的热门交易对数据 + symbol_list = self._get_cached_hot_symbols(max_symbols) + + if symbol_list: + logger.info(f"📊 数据源: Binance热门交易对 | 扫描交易对: {len(symbol_list)} 个") + else: + logger.error("❌ 热门交易对数据为空,无法进行扫描") + return {} + + results = {} + total_signals = 0 + total_patterns = 0 + + for i, symbol in enumerate(symbol_list): + logger.info(f"⏳ 扫描进度: [{i+1:3d}/{len(symbol_list):3d}] 🔍 {symbol}") + + try: + symbol_results = self.analyze_symbol(symbol, session_id=session_id) + + # 统计信号和形态数量 + symbol_signal_count = sum(result.get_signal_count() for result in symbol_results.values()) + symbol_pattern_count = sum(result.get_pattern_count() for result in symbol_results.values()) + + # 只保留有确认信号的交易对结果,不包括仅有形态的交易对 + if symbol_signal_count > 0: + results[symbol] = symbol_results + total_signals += symbol_signal_count + total_patterns += symbol_pattern_count + + except Exception as e: + logger.error(f"扫描交易对 {symbol} 失败: {e}") + continue + + # 更新扫描会话统计 + try: + self.db_manager.update_scan_session_stats( + session_id=session_id, + total_scanned=len(symbol_list), + total_signals=total_signals + ) + logger.debug(f"扫描会话统计已更新: {session_id}") + except Exception as e: + logger.error(f"更新扫描会话统计失败: {e}") + + # 美化最终扫描结果 + logger.info("🎉" + "="*70) + logger.info(f"🌍 加密货币市场K线形态扫描完成!") + logger.info(f"📊 扫描统计:") + logger.info(f" 🔍 总扫描交易对: {len(symbol_list)} 个") + logger.info(f" 🎯 确认信号: {total_signals} 个") + logger.info(f" 📊 形态形成: {total_patterns} 个") + logger.info(f" 📈 涉及交易对: {len(results)} 个") + logger.info(f" 💾 扫描会话ID: {session_id}") + + if results: + logger.info(f"📋 详细结果:") + signal_count = 0 + pattern_count = 0 + + for symbol, symbol_results in results.items(): + for timeframe, result in symbol_results.items(): + # 显示确认信号 + for signal in result.signals: + signal_count += 1 + logger.info(f" 🎯 信号#{signal_count}: {symbol} | {timeframe} | {signal['date']} | {signal['price']:.4f}") + + # 显示形态形成 + for pattern in result.patterns: + pattern_count += 1 + logger.info(f" 📊 形态#{pattern_count}: {symbol} | {timeframe} | {pattern['date']} | {pattern['price']:.4f}") + + logger.info("🎉" + "="*70) + + # 发送汇总通知 + if results: + scan_stats = { + 'total_scanned': len(symbol_list), + 'data_source': f'Binance-{self.quote_asset}' + } + + try: + success = self.notification_manager.send_strategy_summary(results, scan_stats) + if success: + logger.info("📱 策略信号汇总通知发送完成") + else: + logger.warning("📱 策略信号汇总通知发送失败") + except Exception as e: + logger.error(f"发送汇总通知失败: {e}") + + return results + + def get_strategy_description(self) -> str: + """获取策略描述""" + trend_desc = "" + if self.strong_trend_enabled: + trend_desc = f""" +【多头排列先决条件】✅ +- EMA5 > EMA10 > EMA20(三重多头排列) +- 在形态识别、突破确认、回踩信号三个阶段都进行检查 +- 确保整个过程中始终保持强势趋势 +""" + else: + trend_desc = "\n【多头排列先决条件】❌ 未启用\n" + + return f"""加密货币K线形态策略 - 强势上涨+多空博弈+突破确认 +{trend_desc} +新形态设计:强势上涨 → 多空博弈 → 突破确认 + +【形态A:双阳+博弈】 +1. 强势阶段:2根连续阳线 +2. 博弈阶段:2-3根K线,平均实体≤40%(阴线/十字星/小阳线) +3. 突破确认:博弈后1-3根K线内,大阳线(实体≥55%)突破博弈阶段最高价 + +【形态B:高实体+博弈】 +1. 强势阶段:1根高实体阳线(实体≥60%) +2. 博弈阶段:2-3根K线,平均实体≤40%(阴线/十字星/小阳线) +3. 突破确认:博弈后1-3根K线内,大阳线(实体≥55%)突破博弈阶段最高价 + +【严格约束条件】 +- EMA5 > EMA10 > EMA20(三重多头排列)全程检查 +- 价格不能跌破EMA10支撑(博弈、突破、回踩全程) +- 价格必须创新高后回踩到博弈阶段最高点附近才产生正式信号 +- 回踩容忍度:{self.pullback_tolerance:.0%} +- 确认窗口:{self.pullback_confirmation_days}天 + +【加密货币市场特点】 +- 计价货币: {self.quote_asset} +- 最小24h交易量: ${self.min_volume_usdt:,.0f} +- 支持时间周期:{', '.join(self.timeframes)} + +【策略优势】 +- 真实市场心理:强势→博弈→突破的完整过程 +- 多重验证:EMA多头排列 + EMA10支撑 + 创新高回踩确认 +- 降低假突破:严格的形态和趋势验证 +- 优质入场时机:回踩确认提供更好的风险收益比 +""" + + +if __name__ == "__main__": + # 测试代码 + from ..data.binance_fetcher import BinanceFetcher + from ..utils.notification import NotificationManager + + # 模拟配置 + strategy_config = { + 'min_entity_ratio': 0.55, + 'timeframes': ['daily', '4hour'], + 'quote_asset': 'USDT' + } + + notification_config = { + 'dingtalk': { + 'enabled': False, + 'webhook_url': '' + } + } + + # 初始化组件 + data_fetcher = BinanceFetcher() + notification_manager = NotificationManager(notification_config) + strategy = CryptoKLinePatternStrategy(data_fetcher, notification_manager, strategy_config) + + print("加密货币K线形态策略初始化完成") + print(strategy.get_strategy_description()) diff --git a/src/strategy/kline_pattern_strategy.py b/src/strategy/kline_pattern_strategy.py index 63ec68c..0456f6a 100644 --- a/src/strategy/kline_pattern_strategy.py +++ b/src/strategy/kline_pattern_strategy.py @@ -52,6 +52,19 @@ class KLinePatternStrategy(BaseStrategy): # 新增:回踩确认参数 self.pullback_confirmation_days = config.get('pullback_confirmation_days', 7) # 回踩确认时间窗口 + self.use_1h_confirmation = config.get('use_1h_confirmation', True) # 是否使用1h级别确认回踩,默认True + + # 强势趋势条件配置 + strong_trend_config = config.get('strong_trend', {}) + self.strong_trend_enabled = strong_trend_config.get('enabled', True) # 是否启用强势趋势条件 + self.new_high_lookback = strong_trend_config.get('new_high_lookback', 10) # 创新高回看周期 + self.rising_low_lookback = strong_trend_config.get('rising_low_lookback', 10) # 低点上移回看周期 + + # 多头排列配置 + bull_alignment_config = config.get('bull_alignment', {}) + self.bull_alignment_enabled = bull_alignment_config.get('enabled', True) # 是否启用多头排列 + self.bull_alignment_check_slope = bull_alignment_config.get('check_slope', True) # 是否检查斜率 + self.bull_alignment_slope_lookback = bull_alignment_config.get('slope_lookback', 3) # 斜率回看周期 # 存储已触发的信号,用于监控回踩 # 格式: {stock_code: {'signals': [signal_dict], 'last_check_date': date}} @@ -143,7 +156,9 @@ class KLinePatternStrategy(BaseStrategy): # 计算涨跌幅 df['change_pct'] = (df['close'] - df['open']) / df['open'] * 100 - # 计算EMA20指标 + # 计算EMA指标 (5, 10, 20) + df['ema5'] = df['close'].ewm(span=5, adjust=False).mean() + df['ema10'] = df['close'].ewm(span=10, adjust=False).mean() df['ema20'] = df['close'].ewm(span=20, adjust=False).mean() # 判断是否在EMA20上方 @@ -160,10 +175,245 @@ class KLinePatternStrategy(BaseStrategy): return df + def check_bull_alignment(self, kline: pd.Series, check_slope: bool = True) -> bool: + """ + 检查多头排列条件:EMA5 > EMA10 > EMA20 + + Args: + kline: 单根K线数据(pandas Series) + check_slope: 是否检查均线向上倾斜(默认True) + + Returns: + bool: 是否满足多头排列 + """ + # 检查必需的EMA值是否存在 + if pd.isna(kline.get('ema5')) or pd.isna(kline.get('ema10')) or pd.isna(kline.get('ema20')): + return False + + ema5 = kline['ema5'] + ema10 = kline['ema10'] + ema20 = kline['ema20'] + + # 基础条件:EMA5 > EMA10 > EMA20 + if not (ema5 > ema10 > ema20): + return False + + # 如果需要检查斜率(向上倾斜) + if check_slope: + # 需要有足够的历史数据来计算斜率 + # 这里简化处理:检查当前价格是否高于EMA5(表明EMA5向上) + # 更精确的方法需要比较连续K线的EMA值 + current_close = kline.get('close', 0) + + # 简化版斜率检查:价格在EMA5上方,且EMA5 > EMA10 > EMA20呈发散态势 + if current_close < ema5: + return False + + return True + + def check_bull_alignment_with_slope(self, df: pd.DataFrame, index: int, lookback: int = None) -> bool: + """ + 精确检查多头排列及均线斜率(向上倾斜) + + Args: + df: K线数据DataFrame + index: 当前K线索引 + lookback: 回看周期,用于计算斜率(默认使用配置) + + Returns: + bool: 是否满足多头排列且均线向上 + """ + # 如果未启用多头排列,直接返回True(不过滤) + if not self.bull_alignment_enabled: + return True + + # 使用配置的lookback值 + if lookback is None: + lookback = self.bull_alignment_slope_lookback + + # 确保有足够的历史数据 + if index < lookback: + return False + + current_kline = df.iloc[index] + + # 基础多头排列检查 + if not self.check_bull_alignment(current_kline, check_slope=False): + return False + + # 如果不需要检查斜率,到此结束 + if not self.bull_alignment_check_slope: + return True + + # 检查3条均线是否都向上倾斜 + # 计算斜率:当前EMA值 vs lookback周期前的EMA值 + prev_index = index - lookback + prev_kline = df.iloc[prev_index] + + # 检查每条均线是否上升 + ema5_rising = current_kline['ema5'] > prev_kline['ema5'] + ema10_rising = current_kline['ema10'] > prev_kline['ema10'] + ema20_rising = current_kline['ema20'] > prev_kline['ema20'] + + return ema5_rising and ema10_rising and ema20_rising + + def check_continuous_new_highs(self, df: pd.DataFrame, index: int, lookback: int = 10) -> bool: + """ + 检查价格是否一直创新高 + + Args: + df: K线数据DataFrame + index: 当前K线索引 + lookback: 回看周期,默认10根K线 + + Returns: + bool: 是否满足一直创新高条件 + """ + if index < lookback: + return False + + # 获取回看期间的最高价 + start_idx = index - lookback + current_high = df.iloc[index]['high'] + + # 检查当前高点是否是回看期间的最高点 + period_highs = df.iloc[start_idx:index+1]['high'] + is_highest = current_high >= period_highs.max() + + if not is_highest: + return False + + # 检查是否呈现持续上升趋势(至少有3个新高点) + high_count = 0 + prev_high = 0 + + for i in range(start_idx, index + 1): + current_k_high = df.iloc[i]['high'] + if current_k_high > prev_high: + high_count += 1 + prev_high = current_k_high + + # 要求至少有3次创新高 + return high_count >= 3 + + def check_rising_lows(self, df: pd.DataFrame, index: int, lookback: int = 10) -> bool: + """ + 检查股价低点是否上移 + + Args: + df: K线数据DataFrame + index: 当前K线索引 + lookback: 回看周期,默认10根K线 + + Returns: + bool: 是否满足低点上移条件 + """ + if index < lookback: + return False + + start_idx = index - lookback + + # 找到期间内的所有局部低点 + lows = [] + for i in range(start_idx + 1, index): + current_low = df.iloc[i]['low'] + prev_low = df.iloc[i-1]['low'] + next_low = df.iloc[i+1]['low'] if i < len(df) - 1 else current_low + + # 如果当前低点低于前后两根K线,认为是局部低点 + if current_low <= prev_low and current_low <= next_low: + lows.append((i, current_low)) + + # 需要至少有2个低点才能判断趋势 + if len(lows) < 2: + return False + + # 检查低点是否呈上升趋势 + for i in range(1, len(lows)): + if lows[i][1] <= lows[i-1][1]: # 后面的低点不高于前面的低点 + return False + + return True + + def check_ema5_ema10_bullish(self, kline: pd.Series) -> bool: + """ + 检查5日和10日多头排列(EMA5 > EMA10) + + Args: + kline: 单根K线数据(pandas Series) + + Returns: + bool: 是否满足5日10日多头排列 + """ + # 检查必需的EMA值是否存在 + if pd.isna(kline.get('ema5')) or pd.isna(kline.get('ema10')): + return False + + ema5 = kline['ema5'] + ema10 = kline['ema10'] + + # 简化条件:只要求EMA5 > EMA10 + return ema5 > ema10 + + def check_ema_triple_bullish(self, kline: pd.Series) -> bool: + """ + 检查三重多头排列(EMA5 > EMA10 > EMA20) + + Args: + kline: 单根K线数据(pandas Series) + + Returns: + bool: 是否满足三重多头排列 + """ + # 检查必需的EMA值是否存在 + if (pd.isna(kline.get('ema5')) or + pd.isna(kline.get('ema10')) or + pd.isna(kline.get('ema20'))): + return False + + ema5 = kline['ema5'] + ema10 = kline['ema10'] + ema20 = kline['ema20'] + + # 三重多头排列:EMA5 > EMA10 > EMA20 + return ema5 > ema10 > ema20 + + def check_strong_trend_conditions(self, df: pd.DataFrame, index: int) -> bool: + """ + 检查强势趋势条件(三重多头排列:EMA5>EMA10>EMA20) + + Args: + df: K线数据DataFrame + index: 当前K线索引 + + Returns: + bool: 是否满足强势趋势条件 + """ + # 如果未启用强势趋势条件,直接返回True(不过滤) + if not self.strong_trend_enabled: + return True + + current_kline = df.iloc[index] + + # 检查三重多头排列:EMA5 > EMA10 > EMA20 + ema_triple_bullish = self.check_ema_triple_bullish(current_kline) + + if ema_triple_bullish: + logger.debug(f"✅ 索引{index}满足强势趋势条件: EMA5>EMA10>EMA20") + else: + logger.debug(f"❌ 索引{index}不满足强势趋势条件: EMA5>EMA10>EMA20={ema_triple_bullish}") + + return ema_triple_bullish def detect_potential_pattern(self, df: pd.DataFrame) -> List[Dict[str, Any]]: """ - 检测潜在的"两阳线+阴线+阳线"形态(等待回踩确认) + 检测潜在的K线形态(等待回踩确认) + + 新形态设计:强势上涨 → 多空博弈 → 突破确认 + 形态A:2根连续阳线 + 博弈K线(阴线/十字星) + 突破阳线 + 形态B:1根高实体阳线(实体≥60%) + 博弈K线(阴线/十字星) + 突破阳线 + + 重要约束:全程价格不能跌破EMA10支撑 Args: df: 包含特征指标的K线数据 @@ -172,90 +422,176 @@ class KLinePatternStrategy(BaseStrategy): 检测到的潜在形态列表 """ potential_patterns = [] + found_pattern_dates = set() # 记录已发现形态的日期,用于去重 - if df.empty or len(df) < 4: + if df.empty or len(df) < 3: return potential_patterns - # 从第4个数据点开始检测(需要4根K线) + # 从第4个数据点开始检测(需要至少5根K线:强势2根+博弈2根+突破1根) for i in range(3, len(df)): - # 获取连续4根K线 - k1, k2, k3, k4 = df.iloc[i-3:i+1].to_dict('records') + # 检查是否有足够的K线进行突破确认 + if i >= len(df) - 3: + continue + + pattern_type = None + base_klines = [] + battle_klines = [] + battle_period_high = 0 - # 检查形态:两阳线 + 阴线 + 阳线 - pattern_match = ( - k1['is_yang'] and k2['is_yang'] and # 前两根是阳线 - k3['is_yin'] and # 第三根是阴线 - k4['is_yang'] # 第四根是阳线 - ) + # 形态A:2根连续阳线 + 2-3根博弈K线 + if i >= 4: # 至少需要5根历史K线 + # 检查强势阶段:前2根是否为连续阳线 + strong_start = i - 3 # 强势阶段开始位置 + k1, k2 = df.iloc[strong_start:strong_start+2].to_dict('records') + + if k1['is_yang'] and k2['is_yang']: + # 检查博弈阶段:接下来的2根K线 + battle_start = strong_start + 2 + battle_klines_data = df.iloc[battle_start:i+1] + + # 博弈阶段条件:平均实体比例≤40%,没有明显方向性 + battle_entity_ratios = battle_klines_data['entity_ratio'].tolist() + avg_battle_entity = sum(battle_entity_ratios) / len(battle_entity_ratios) + + # 检查博弈阶段是否跌破EMA10 + battle_min_low = battle_klines_data['low'].min() + battle_ema10_values = battle_klines_data['ema10'].dropna() + if len(battle_ema10_values) > 0: + battle_min_ema10 = battle_ema10_values.min() + if battle_min_low < battle_min_ema10: + continue # 博弈阶段跌破EMA10,跳过此形态 + + if avg_battle_entity <= 0.4: # 博弈阶段整体实体较小 + pattern_type = '强势形态A(双阳+博弈)' + base_klines = [k1, k2] + battle_klines = battle_klines_data.to_dict('records') + battle_period_high = battle_klines_data['high'].max() - if not pattern_match: + # 形态B:1根高实体阳线 + 2-3根博弈K线 + if pattern_type is None and i >= 3: # 至少需要4根历史K线 + # 检查强势阶段:前1根是否为高实体阳线 + strong_start = i - 2 # 强势阶段开始位置 + k1 = df.iloc[strong_start].to_dict() + + if k1['is_yang'] and k1['entity_ratio'] >= 0.6: + # 检查博弈阶段:接下来的2根K线 + battle_start = strong_start + 1 + battle_klines_data = df.iloc[battle_start:i+1] + + # 博弈阶段条件:平均实体比例≤40% + battle_entity_ratios = battle_klines_data['entity_ratio'].tolist() + avg_battle_entity = sum(battle_entity_ratios) / len(battle_entity_ratios) + + # 检查博弈阶段是否跌破EMA10 + battle_min_low = battle_klines_data['low'].min() + battle_ema10_values = battle_klines_data['ema10'].dropna() + if len(battle_ema10_values) > 0: + battle_min_ema10 = battle_ema10_values.min() + if battle_min_low < battle_min_ema10: + continue # 博弈阶段跌破EMA10,跳过此形态 + + if avg_battle_entity <= 0.4: # 博弈阶段整体实体较小 + pattern_type = '强势形态B(高实体+博弈)' + base_klines = [k1] + battle_klines = battle_klines_data.to_dict('records') + battle_period_high = battle_klines_data['high'].max() + + # 如果没有匹配的基础形态,继续下一次循环 + if pattern_type is None: continue - # 检查前两根阳线的实体比例 - yang1_valid = k1['entity_ratio'] >= self.min_entity_ratio - yang2_valid = k2['entity_ratio'] >= self.min_entity_ratio - - if not (yang1_valid and yang2_valid): + # 多头排列检查(第一阶段:博弈阶段结束时检查) + if not self.check_strong_trend_conditions(df, i): + logger.debug(f"索引{i}形态被过滤:不满足多头排列条件(EMA5>EMA10>EMA20)") continue - # 检查最后一根阳线的收盘价是否高于阴线的最高价 - breakout_valid = k4['close'] > k3['high'] + # 寻找突破确认:博弈阶段后的1-3根K线内 + found_breakout = False + for breakout_offset in range(1, 4): # 检查后续1-3根K线 + breakout_idx = i + breakout_offset + if breakout_idx >= len(df): + break # 超出数据范围 + + k_breakout = df.iloc[breakout_idx] - if not breakout_valid: + # 突破K线必须是大阳线(实体比例≥55%) + if not k_breakout['is_yang'] or k_breakout['entity_ratio'] < 0.55: + continue + + # 检查是否突破博弈阶段最高价(收盘价>最高价) + if k_breakout['close'] <= battle_period_high: + continue + + # 检查突破K线是否跌破EMA10 + if pd.notna(k_breakout.get('ema10')) and k_breakout['low'] < k_breakout.get('ema10', 0): + continue # 突破K线跌破EMA10,跳过此形态 + + # 多头排列检查(第二阶段:突破确认时检查) + if not self.check_strong_trend_conditions(df, breakout_idx): + logger.debug(f"突破阳线被过滤:不满足多头排列条件(EMA5>EMA10>EMA20)") + continue + + # 找到有效突破,标记并退出循环 + found_breakout = True + break + + # 如果没有找到有效突破,跳过此形态 + if not found_breakout: continue - # 检查最后一根阳线的实体比例 - final_yang_valid = k4['entity_ratio'] >= self.final_yang_min_ratio + # 获取换手率数值用于记录 + turnover_ratio = k_breakout.get('turnover_ratio', 0) - if not final_yang_valid: - continue + # 获取突破日期,用于去重检查 + breakout_date = df.iloc[breakout_idx].get('trade_date', df.index[breakout_idx]) + breakout_date_str = str(breakout_date) - # 检查最后一根阳线是否在EMA20上方 - ema20_valid = k4.get('above_ema20', False) - - if not ema20_valid: - continue - - # 检查最后一根阳线的换手率 - turnover_ratio = k4.get('turnover_ratio', 0) - turnover_valid = turnover_ratio <= self.max_turnover_ratio - - if not turnover_valid: + # 检查是否已经有相同日期的形态(避免同一天产生多个形态) + if breakout_date_str in found_pattern_dates: + logger.debug(f"跳过重复形态: {breakout_date_str} (已有该日期的形态)") continue # 构建潜在模式 potential_pattern = { - 'index': i, - 'date': df.iloc[i].get('trade_date', df.index[i]), - 'pattern_type': '两阳+阴+阳突破(待确认)', - 'k1': k1, # 第一根阳线 - 'k2': k2, # 第二根阳线 - 'k3': k3, # 阴线 - 'k4': k4, # 突破阳线 - 'yang1_entity_ratio': k1['entity_ratio'], - 'yang2_entity_ratio': k2['entity_ratio'], - 'final_yang_entity_ratio': k4['entity_ratio'], - 'breakout_price': k4['close'], - 'yin_high': k3['high'], - 'breakout_amount': k4['close'] - k3['high'], - 'breakout_pct': (k4['close'] - k3['high']) / k3['high'] * 100 if k3['high'] > 0 else 0, - 'ema20_price': k4.get('ema20', 0), - 'above_ema20': k4.get('above_ema20', False), + 'index': breakout_idx, # 使用突破K线的索引 + 'base_index': i, # 博弈阶段结束位置 + 'date': breakout_date, + 'pattern_type': f'{pattern_type}+突破(待确认)', + 'pattern_subtype': pattern_type, # 记录形态子类型 + 'base_klines': base_klines, # 强势上涨K线 + 'battle_klines': battle_klines, # 博弈阶段K线(2-3根) + 'breakout_kline': k_breakout.to_dict() if hasattr(k_breakout, 'to_dict') else dict(k_breakout), # 突破阳线 + 'breakout_position': breakout_offset, # 博弈后第几根K线突破(1-3) + 'final_yang_entity_ratio': k_breakout['entity_ratio'], + 'breakout_price': k_breakout['close'], + 'reference_high': battle_period_high, # 博弈阶段最高价 + 'yin_high': battle_period_high, # 保持兼容性,使用博弈阶段最高价 + 'breakout_amount': k_breakout['close'] - battle_period_high, + 'breakout_pct': (k_breakout['close'] - battle_period_high) / battle_period_high * 100 if battle_period_high > 0 else 0, + 'ema20_price': k_breakout.get('ema20', 0), + 'above_ema20': k_breakout.get('above_ema20', False), 'turnover_ratio': turnover_ratio, 'confirmation_pending': True, # 标记为待确认 - 'pattern_trigger_date': df.iloc[i].get('trade_date', df.index[i]) + 'pattern_trigger_date': breakout_date, + 'battle_period_days': len(battle_klines), # 博弈阶段天数 + 'avg_battle_entity': avg_battle_entity # 博弈阶段平均实体比例 } potential_patterns.append(potential_pattern) + found_pattern_dates.add(breakout_date_str) # 记录该日期已有形态 # 美化潜在模式发现日志 logger.debug("🔍" + "="*60) - logger.debug(f"📊 发现K线形态模式(需创新高回踩确认)") + logger.debug(f"📊 发现新K线形态(需创新高回踩确认)") + logger.debug(f"🏷️ 形态类型: {pattern_type}") logger.debug(f"📅 模式时间: {potential_pattern['date']}") logger.debug(f"💰 突破价格: {potential_pattern['breakout_price']:.2f}元") - logger.debug(f"🎯 阴线最高价: {potential_pattern['yin_high']:.2f}元") + logger.debug(f"🎯 博弈阶段最高价: {battle_period_high:.2f}元") + logger.debug(f"📊 博弈阶段: {len(battle_klines)}根K线,平均实体{avg_battle_entity:.1%}") + logger.debug(f"📍 突破位置: 博弈后第{breakout_offset}根K线") + logger.debug(f"🛡️ EMA10支撑: 全程保持在EMA10上方") logger.debug(f"⏰ 观察窗口: {self.pullback_confirmation_days}天内") - logger.debug(f"📋 要求: 先创新高再回踩阴线最高价才产生信号") + logger.debug(f"📋 要求: 先创新高再回踩博弈阶段最高价才产生信号") logger.debug("🔍" + "="*60) return potential_patterns @@ -289,8 +625,8 @@ class KLinePatternStrategy(BaseStrategy): # 条件1: 价格必须先冲高(高于突破价格) has_moved_higher = current_kline['high'] > breakout_price - # 条件2: 然后回踩到阴线最高价附近 - pullback_to_yin_high = current_kline['low'] <= yin_high * (1 + self.pullback_tolerance) + # 条件2: 然后回踩到阴线最高价附近(使用收盘价) + pullback_to_yin_high = current_kline['close'] <= yin_high * (1 + self.pullback_tolerance) # 如果同时满足冲高和回踩条件,确认信号 if has_moved_higher and pullback_to_yin_high: @@ -299,7 +635,7 @@ class KLinePatternStrategy(BaseStrategy): logger.info(f"📅 确认日期: {current_kline.get('trade_date', df.index[i])}") logger.info(f"💰 突破价格: {breakout_price:.2f}元") logger.info(f"📈 当日最高: {current_kline['high']:.2f}元 (冲高✅)") - logger.info(f"📉 当日最低: {current_kline['low']:.2f}元") + logger.info(f"📉 当日收盘: {current_kline['close']:.2f}元") logger.info(f"🎯 阴线最高: {yin_high:.2f}元 (回踩✅)") logger.info(f"⏰ 确认用时: {i - pattern_index}天") logger.info("✅" + "="*60) @@ -309,7 +645,7 @@ class KLinePatternStrategy(BaseStrategy): def check_new_high_pullback_signal(self, df: pd.DataFrame, pattern: Dict[str, Any]) -> Dict[str, Any]: """ - 检查是否出现创新高后回踩信号 + 检查是否出现创新高后回踩信号(日线级别) Args: df: K线数据 @@ -348,10 +684,20 @@ class KLinePatternStrategy(BaseStrategy): for i in range(new_high_index, confirmation_window_end): current_kline = df.iloc[i] - # 检查是否回踩到阴线最高价附近 - pullback_to_yin_high = current_kline['low'] <= yin_high * (1 + self.pullback_tolerance) + # 检查是否回踩到阴线最高价附近(使用收盘价) + pullback_to_yin_high = current_kline['close'] <= yin_high * (1 + self.pullback_tolerance) if pullback_to_yin_high: + # 在确认回踩信号时,也检查多头排列条件 + if not self.check_ema_triple_bullish(current_kline): + logger.debug(f"回踩确认被过滤:索引{i}不满足多头排列条件(EMA5>EMA10>EMA20)") + continue + + # 检查回踩过程中是否跌破EMA10 + if pd.notna(current_kline.get('ema10')) and current_kline['low'] < current_kline.get('ema10', 0): + logger.debug(f"回踩确认被过滤:索引{i}跌破EMA10支撑") + continue + # 找到信号,创建信号字典 signal = pattern.copy() signal['pattern_type'] = '两阳+阴+阳突破(创新高回踩确认)' @@ -364,18 +710,18 @@ class KLinePatternStrategy(BaseStrategy): signal['confirmation_date'] = current_kline.get('trade_date', df.index[i]) signal['confirmation_index'] = i signal['confirmation_days'] = i - pattern_index - signal['pullback_distance'] = ((current_kline['low'] - yin_high) / yin_high) * 100 + signal['pullback_distance'] = ((current_kline['close'] - yin_high) / yin_high) * 100 # 美化信号确认日志 logger.info("🎯" + "="*60) - logger.info(f"📈 K线形态突破信号已确认!(创新高回踩)") + logger.info(f"📈 K线形态突破信号已确认!(创新高回踩-日线)") logger.info(f"📅 模式日期: {signal['date']}") logger.info(f"🚀 创新高日期: {signal['new_high_date']}") logger.info(f"✅ 回踩确认日期: {signal['confirmation_date']}") logger.info(f"💰 原突破价格: {signal['breakout_price']:.2f}元") logger.info(f"🌟 创新高价格: {signal['new_high_price']:.2f}元") logger.info(f"🎯 阴线最高价: {signal['yin_high']:.2f}元") - logger.info(f"📉 回踩最低价: {current_kline['low']:.2f}元") + logger.info(f"📉 回踩收盘价: {current_kline['close']:.2f}元") logger.info(f"📏 回踩距离: {signal['pullback_distance']:.2f}%") logger.info(f"⏰ 总确认用时: {signal['confirmation_days']}天") logger.info("🎯" + "="*60) @@ -384,9 +730,169 @@ class KLinePatternStrategy(BaseStrategy): return None + def check_new_high_pullback_signal_1h(self, stock_code: str, pattern: Dict[str, Any]) -> Dict[str, Any]: + """ + 使用1小时级别数据检查是否出现创新高后回踩信号(实时确认) + + Args: + stock_code: 股票代码 + pattern: 潜在模式字典(来自日线) + + Returns: + Dict: 如果找到信号返回信号字典,否则返回None + """ + try: + pattern_date = pattern.get('date') + yin_high = pattern['yin_high'] + breakout_price = pattern['breakout_price'] + + # 解析模式日期 + if isinstance(pattern_date, str): + pattern_datetime = pd.to_datetime(pattern_date) + else: + pattern_datetime = pd.to_datetime(str(pattern_date)) + + # 获取从模式日期到现在的1小时数据 + start_date = pattern_datetime.strftime('%Y-%m-%d') + end_date = datetime.now().strftime('%Y-%m-%d') + + # 获取1小时K线数据 + df_1h = self.data_fetcher.get_historical_data(stock_code, start_date, end_date, period='60min') + + if df_1h.empty: + logger.debug(f"{stock_code} 1小时数据为空,无法进行回踩确认") + return None + + # 计算K线特征 + df_1h = self.calculate_kline_features(df_1h) + + # 寻找创新高的点 + max_high_after_pattern = breakout_price + new_high_datetime = None + new_high_price = breakout_price + + for idx, row in df_1h.iterrows(): + if row['high'] > max_high_after_pattern: + max_high_after_pattern = row['high'] + new_high_price = row['high'] + new_high_datetime = row.get('trade_date', idx) + + # 如果没有创新高,则不产生信号 + if new_high_datetime is None: + return None + + # 在创新高之后寻找回踩阴线最高价的点 + found_new_high = False + for idx, row in df_1h.iterrows(): + current_datetime = row.get('trade_date', idx) + + # 确保在创新高之后 + if not found_new_high: + if current_datetime == new_high_datetime or row['high'] >= new_high_price: + found_new_high = True + continue + + # 检查是否回踩到阴线最高价附近 + # 使用1h K线的最低价来判断,更精确 + pullback_to_yin_high = row['low'] <= yin_high * (1 + self.pullback_tolerance) + + if pullback_to_yin_high: + # 在确认回踩信号时,也检查多头排列条件 + if not self.check_ema_triple_bullish(row): + logger.debug(f"1h回踩确认被过滤:{current_datetime}不满足多头排列条件(EMA5>EMA10>EMA20)") + continue + + # 检查1h回踩过程中是否跌破EMA10 + if pd.notna(row.get('ema10')) and row['low'] < row.get('ema10', 0): + logger.debug(f"1h回踩确认被过滤:{current_datetime}跌破EMA10支撑") + continue + + # 找到信号,创建信号字典 + signal = pattern.copy() + signal['pattern_type'] = '两阳+阴+阳突破(创新高回踩确认-1h)' + signal['confirmation_pending'] = False + signal['pullback_confirmed'] = True + signal['new_high_confirmed'] = True + signal['new_high_price'] = new_high_price + signal['new_high_date'] = str(new_high_datetime) + signal['confirmation_date'] = str(current_datetime) + signal['confirmation_time_1h'] = str(current_datetime) # 1h级别的确认时间 + signal['pullback_distance'] = ((row['close'] - yin_high) / yin_high) * 100 + + # 计算确认天数(从模式日期到确认日期) + if isinstance(current_datetime, str): + confirm_date = pd.to_datetime(current_datetime).date() + else: + confirm_date = current_datetime.date() if hasattr(current_datetime, 'date') else current_datetime + + signal['confirmation_days'] = (confirm_date - pattern_datetime.date()).days + + # 美化信号确认日志 + logger.info("🎯" + "="*60) + logger.info(f"📈 K线形态突破信号已确认!(创新高回踩-1h实时)") + logger.info(f"📅 模式日期: {signal['date']}") + logger.info(f"🚀 创新高时间: {new_high_datetime}") + logger.info(f"✅ 回踩确认时间(1h): {current_datetime}") + logger.info(f"💰 原突破价格: {breakout_price:.2f}元") + logger.info(f"🌟 创新高价格: {new_high_price:.2f}元") + logger.info(f"🎯 阴线最高价: {yin_high:.2f}元") + logger.info(f"📉 1h最低价: {row['low']:.2f}元") + logger.info(f"📉 1h收盘价: {row['close']:.2f}元") + logger.info(f"📏 回踩距离: {signal['pullback_distance']:.2f}%") + logger.info(f"⏰ 总确认用时: {signal['confirmation_days']}天") + logger.info("🎯" + "="*60) + + return signal + + return None + + except Exception as e: + logger.error(f"1h级别回踩确认失败 {stock_code}: {e}") + return None + + def _check_current_price_validity(self, stock_code: str, entry_price: float, tolerance: float = 0.05) -> bool: + """ + 检查当前价格是否仍然适合入场(不低于入场价的5%) + + Args: + stock_code: 股票代码 + entry_price: 建议入场价格 + tolerance: 价格下跌容忍度,默认5% + + Returns: + bool: 当前价格是否仍然有效 + """ + try: + # 获取最新价格数据 + end_date = datetime.now().strftime('%Y-%m-%d') + start_date = (datetime.now() - timedelta(days=2)).strftime('%Y-%m-%d') + + current_data = self.data_fetcher.get_historical_data( + stock_code, start_date, end_date, 'daily' + ) + + if current_data.empty: + logger.warning(f"无法获取 {stock_code} 的最新价格数据") + return True # 无法获取价格时保守处理,保留信号 + + current_price = current_data.iloc[-1]['close'] + min_valid_price = entry_price * (1 - tolerance) + + is_valid = current_price >= min_valid_price + + if not is_valid: + price_drop_pct = (entry_price - current_price) / entry_price * 100 + logger.info(f"🔻 {stock_code} 价格过滤: 当前价 {current_price:.2f} < 入场价 {entry_price:.2f} (下跌{price_drop_pct:.1f}%)") + + return is_valid + + except Exception as e: + logger.error(f"检查 {stock_code} 当前价格失败: {e}") + return True # 出错时保守处理,保留信号 + def _filter_recent_signals(self, signals: List[Dict[str, Any]], days: int = 7) -> List[Dict[str, Any]]: """ - 过滤最近N天内产生的信号 + 过滤最近N天内产生的信号,并检查当前价格有效性 Args: signals: 信号列表 @@ -400,6 +906,7 @@ class KLinePatternStrategy(BaseStrategy): current_date = datetime.now().date() recent_signals = [] + price_filtered_count = 0 for signal in signals: signal_date = signal.get('confirmation_date') or signal.get('date') @@ -420,43 +927,85 @@ class KLinePatternStrategy(BaseStrategy): # 只保留最近N天内的信号 if days_ago <= days: - recent_signals.append(signal) - logger.debug(f"✅ 保留近期信号: {signal_date} (距今{days_ago}天)") + # 检查当前价格是否仍然有效 + stock_code = signal.get('stock_code', '') + entry_price = signal.get('yin_high', 0) + + if stock_code and entry_price > 0: + if self._check_current_price_validity(stock_code, entry_price): + recent_signals.append(signal) + logger.debug(f"✅ 保留有效信号: {stock_code} {signal_date} (距今{days_ago}天)") + else: + price_filtered_count += 1 + logger.debug(f"🔻 价格过滤信号: {stock_code} {signal_date} (价格已跌破入场价)") + else: + # 缺少必要信息时保守处理 + recent_signals.append(signal) + logger.debug(f"✅ 保留信号(缺少价格信息): {signal_date} (距今{days_ago}天)") else: logger.debug(f"🗓️ 过滤历史信号: {signal_date} (距今{days_ago}天)") + # 统计过滤结果 + time_filtered_count = len(signals) - len(recent_signals) - price_filtered_count if len(recent_signals) != len(signals): - logger.info(f"📅 信号过滤: 总共{len(signals)}个 → 近{days}天内{len(recent_signals)}个") + logger.info(f"📅 信号过滤统计: 总共{len(signals)}个 → 保留{len(recent_signals)}个") + if time_filtered_count > 0: + logger.info(f" 🗓️ 时间过滤: {time_filtered_count}个") + if price_filtered_count > 0: + logger.info(f" 🔻 价格过滤: {price_filtered_count}个") return recent_signals - def detect_pattern(self, df: pd.DataFrame) -> List[Dict[str, Any]]: + def detect_pattern(self, df: pd.DataFrame, stock_code: str = None) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """ - 检测"两阳线+阴线+阳线"形态(创新高回踩确认逻辑) + 检测"两阳线+阴线+突破阳线"形态 + + 新逻辑: + 1. 形态形成:两阳+阴后,3根K线内任何一根突破新高 + 2. 信号产生:形态形成后,回踩阴线最高点 + 3. 回踩确认:优先使用1h级别数据实时确认(如果启用),否则使用日线 Args: - df: 包含特征指标的K线数据 + df: 包含特征指标的K线数据(日线) + stock_code: 股票代码(用于获取1h数据) Returns: - 检测到的确认信号列表 + (已确认信号列表, 形态形成列表) """ confirmed_signals = [] + formed_patterns = [] if df.empty or len(df) < 4: - return confirmed_signals + return confirmed_signals, formed_patterns - # 首先检测所有潜在模式(但不产生信号) + # 首先检测所有潜在模式(形态形成) potential_patterns = self.detect_potential_pattern(df) # 对每个潜在模式检查创新高回踩确认 for pattern in potential_patterns: - # 检查是否有创新高后回踩确认 - signal = self.check_new_high_pullback_signal(df, pattern) + signal = None + + # 如果启用1h确认且提供了股票代码,优先使用1h级别确认 + if self.use_1h_confirmation and stock_code: + signal = self.check_new_high_pullback_signal_1h(stock_code, pattern) + + if signal: + logger.debug(f"✅ {stock_code} 使用1h级别确认成功") + else: + logger.debug(f"📊 {stock_code} 1h级别未确认,回退到日线确认") + + # 如果1h确认失败或未启用,使用日线确认 + if not signal: + signal = self.check_new_high_pullback_signal(df, pattern) if signal: + # 已产生信号(回踩确认) confirmed_signals.append(signal) + else: + # 仅形态形成(未回踩确认) + formed_patterns.append(pattern) - return confirmed_signals + return confirmed_signals, formed_patterns def analyze_stock(self, stock_code: str, timeframes: List[str] = None, session_id: int = None) -> Dict[str, StrategyResult]: """ @@ -502,13 +1051,15 @@ class KLinePatternStrategy(BaseStrategy): # 计算K线特征 df_with_features = self.calculate_kline_features(df) - # 检测形态 - signals = self.detect_pattern(df_with_features) + # 检测形态(返回已确认信号和形态形成) + # 传入stock_code以支持1h级别回踩确认 + confirmed_signals, formed_patterns = self.detect_pattern(df_with_features, stock_code) - # 过滤一周内的信号 - recent_signals = self._filter_recent_signals(signals, days=7) + # 过滤一周内的信号和形态 + recent_signals = self._filter_recent_signals(confirmed_signals, days=7) + recent_patterns = self._filter_recent_signals(formed_patterns, days=7) - # 处理信号格式 + # 处理确认信号格式 formatted_signals = [] for signal in recent_signals: formatted_signal = { @@ -516,19 +1067,24 @@ class KLinePatternStrategy(BaseStrategy): 'signal_type': signal['pattern_type'], 'price': signal['breakout_price'], 'confidence': signal['final_yang_entity_ratio'], - 'stock_name': stock_name, # 添加股票名称 + 'stock_code': stock_code, # 添加股票代码 + 'stock_name': stock_name, + 'status': 'confirmed', # 已确认信号 'details': { 'yin_high': signal['yin_high'], 'breakout_amount': signal['breakout_amount'], 'breakout_pct': signal['breakout_pct'], 'ema20_price': signal['ema20_price'], - 'turnover_ratio': signal['turnover_ratio'] + 'turnover_ratio': signal['turnover_ratio'], + 'breakout_position': signal.get('breakout_position', 4), + 'pattern_subtype': signal.get('pattern_subtype', '') # 添加形态子类型 } } - # 如果是确认信号,添加确认信息 + # 添加回踩确认信息 if not signal.get('confirmation_pending', True): formatted_signal['details'].update({ + 'new_high_confirmed': signal.get('new_high_confirmed', True), 'new_high_price': signal.get('new_high_price'), 'new_high_date': signal.get('new_high_date'), 'confirmation_date': signal.get('confirmation_date'), @@ -556,23 +1112,56 @@ class KLinePatternStrategy(BaseStrategy): except Exception as e: logger.error(f"保存信号到数据库失败: {e}") + # 处理形态形成格式 + formatted_patterns = [] + for pattern in recent_patterns: + formatted_pattern = { + 'date': pattern['date'], + 'pattern_type': pattern['pattern_type'], + 'price': pattern['breakout_price'], + 'confidence': pattern['final_yang_entity_ratio'], + 'stock_code': stock_code, # 添加股票代码 + 'stock_name': stock_name, + 'status': 'formed', # 形态形成 + 'details': { + 'yin_high': pattern['yin_high'], + 'breakout_amount': pattern['breakout_amount'], + 'breakout_pct': pattern['breakout_pct'], + 'ema20_price': pattern['ema20_price'], + 'turnover_ratio': pattern['turnover_ratio'], + 'breakout_position': pattern.get('breakout_position', 4), + 'pattern_subtype': pattern.get('pattern_subtype', '') # 添加形态子类型 + } + } + + formatted_patterns.append(formatted_pattern) + execution_time = (datetime.now() - start_time).total_seconds() results[timeframe] = StrategyResult( strategy_name=self.strategy_name, stock_code=stock_code, timeframe=timeframe, signals=formatted_signals, + patterns=formatted_patterns, success=True, execution_time=execution_time ) - # 美化信号统计日志 - if formatted_signals: - logger.info(f"✅ {stock_code}({stock_name}) {timeframe}周期: 发现 {len(formatted_signals)} 个信号") - for i, signal in enumerate(formatted_signals, 1): - logger.info(f" 📊 信号{i}: {signal['date']} | 价格: {signal['price']:.2f}元 | 置信度: {signal['confidence']:.1%}") + # 美化统计日志 + if formatted_signals or formatted_patterns: + logger.info(f"✅ {stock_code}({stock_name}) {timeframe}周期: 信号={len(formatted_signals)}个, 形态={len(formatted_patterns)}个") + + if formatted_signals: + logger.info(f" 🎯 已确认信号:") + for i, signal in enumerate(formatted_signals, 1): + logger.info(f" {i}. {signal['date']} | 价格: {signal['price']:.2f}元 | {signal['signal_type']}") + + if formatted_patterns: + logger.info(f" 📊 形态形成:") + for i, pattern in enumerate(formatted_patterns, 1): + logger.info(f" {i}. {pattern['date']} | 价格: {pattern['price']:.2f}元 | {pattern['pattern_type']}") else: - logger.debug(f"📭 {stock_code}({stock_name}) {timeframe}周期: 无信号") + logger.debug(f"📭 {stock_code}({stock_name}) {timeframe}周期: 无信号和形态") except Exception as e: logger.error(f"分析股票 {stock_code} {timeframe}周期失败: {e}") @@ -582,6 +1171,7 @@ class KLinePatternStrategy(BaseStrategy): stock_code=stock_code, timeframe=timeframe, signals=[], + patterns=[], success=False, error=str(e), execution_time=execution_time @@ -591,16 +1181,45 @@ class KLinePatternStrategy(BaseStrategy): def get_strategy_description(self) -> str: """获取策略描述""" - return f"""K线形态策略 - 两阳线+阴线+阳线突破(创新高回踩确认) + trend_desc = "" + if self.strong_trend_enabled: + trend_desc = f""" +【多头排列先决条件】✅ +- EMA5 > EMA10 > EMA20(三重多头排列) +- 在形态识别、突破确认、回踩信号三个阶段都进行检查 +- 确保整个过程中始终保持强势趋势 +""" + else: + trend_desc = "\n【多头排列先决条件】❌ 未启用\n" -该策略通过识别特定的K线形态来发现股票突破机会: -1. 识别连续4根K线:阳线 + 阳线 + 阴线 + 阳线 -2. 前两根阳线实体部分须占振幅的 {self.min_entity_ratio:.0%} 以上 -3. 最后阳线实体部分须占振幅的 {self.final_yang_min_ratio:.0%} 以上 -4. 最后阳线收盘价须高于阴线最高价(突破确认) -5. 最后阳线收盘价须在EMA20上方(趋势确认) -6. 最后阳线换手率不高于 {self.max_turnover_ratio:.1f}%(流动性约束) -7. 价格必须创新高后回踩到阴线最高点附近才产生正式信号 + return f"""K线形态策略 - 强势上涨+多空博弈+突破确认 +{trend_desc} +新形态设计:强势上涨 → 多空博弈 → 突破确认 + +【形态A:双阳+博弈】 +1. 强势阶段:2根连续阳线 +2. 博弈阶段:2-3根K线,平均实体≤40%(阴线/十字星/小阳线) +3. 突破确认:博弈后1-3根K线内,大阳线(实体≥55%)突破博弈阶段最高价 + +【形态B:高实体+博弈】 +1. 强势阶段:1根高实体阳线(实体≥60%) +2. 博弈阶段:2-3根K线,平均实体≤40%(阴线/十字星/小阳线) +3. 突破确认:博弈后1-3根K线内,大阳线(实体≥55%)突破博弈阶段最高价 + +【严格约束条件】 +- EMA5 > EMA10 > EMA20(三重多头排列)全程检查 +- 价格不能跌破EMA10支撑(博弈、突破、回踩全程) +- 价格必须创新高后回踩到博弈阶段最高点附近才产生正式信号 +- 回踩容忍度:{self.pullback_tolerance:.0%} +- 确认窗口:{self.pullback_confirmation_days}天 +- 当前价格过滤:自动过滤价格已跌破入场价5%以上的信号 + +【策略优势】 +- 真实市场心理:强势→博弈→突破的完整过程 +- 多重验证:EMA多头排列 + EMA10支撑 + 创新高回踩确认 +- 降低假突破:严格的形态和趋势验证 +- 优质入场时机:回踩确认提供更好的风险收益比 +- 实时价格保护:确保推荐信号仍具投资价值 支持时间周期:{', '.join(self.timeframes)} """ @@ -629,7 +1248,6 @@ class KLinePatternStrategy(BaseStrategy): # 获取最新价格 latest_price = current_data.iloc[-1]['close'] - latest_low = current_data.iloc[-1]['low'] latest_date = current_data.iloc[-1].get('trade_date', current_data.index[-1]) if isinstance(latest_date, str): @@ -652,9 +1270,9 @@ class KLinePatternStrategy(BaseStrategy): yin_high = signal['yin_high'] # 阴线最高点 breakout_price = signal['breakout_price'] # 突破时价格 - # 检查是否发生回踩 - # 条件1: 最低价接近或跌破阴线最高点 - pullback_to_yin_high = latest_low <= (yin_high * (1 + self.pullback_tolerance)) + # 检查是否发生回踩(使用收盘价) + # 条件1: 收盘价接近或跌破阴线最高点 + pullback_to_yin_high = latest_price <= (yin_high * (1 + self.pullback_tolerance)) # 条件2: 当前价格相比突破价格有明显回调 significant_pullback = latest_price < (breakout_price * 0.95) # 回调超过5% @@ -675,9 +1293,8 @@ class KLinePatternStrategy(BaseStrategy): 'yin_high': yin_high, 'breakout_price': breakout_price, 'current_price': latest_price, - 'current_low': latest_low, 'pullback_pct': ((latest_price - breakout_price) / breakout_price) * 100, - 'distance_to_yin_high': ((latest_low - yin_high) / yin_high) * 100, + 'distance_to_yin_high': ((latest_price - yin_high) / yin_high) * 100, 'days_since_signal': days_since_signal, 'alert_type': 'pullback_to_yin_high' } @@ -693,7 +1310,7 @@ class KLinePatternStrategy(BaseStrategy): logger.warning(f"📊 周期: {signal.get('timeframe', 'daily')}") logger.warning(f"💰 阴线最高点: {yin_high:.2f}元") logger.warning(f"🚀 当时突破价: {breakout_price:.2f}元") - logger.warning(f"💸 当前价格: {latest_price:.2f}元 | 最低: {latest_low:.2f}元") + logger.warning(f"💸 当前收盘价: {latest_price:.2f}元") logger.warning(f"📉 回调幅度: {pullback_alert['pullback_pct']:.2f}%") logger.warning(f"📏 距阴线高点: {pullback_alert['distance_to_yin_high']:.2f}%") logger.warning(f"⏰ 信号后经过: {days_since_signal}天") @@ -842,6 +1459,7 @@ class KLinePatternStrategy(BaseStrategy): results = {} total_signals = 0 + total_patterns = 0 for i, stock_code in enumerate(stock_list): # 获取股票名称 @@ -851,11 +1469,15 @@ class KLinePatternStrategy(BaseStrategy): try: stock_results = self.analyze_stock(stock_code, session_id=session_id) - # 统计信号数量 - stock_signal_count = sum(len(signals) for signals in stock_results.values()) + # 统计信号和形态数量 + stock_signal_count = sum(result.get_signal_count() for result in stock_results.values()) + stock_pattern_count = sum(result.get_pattern_count() for result in stock_results.values()) + + # 只保留有确认信号的股票结果,不包括仅有形态的股票 if stock_signal_count > 0: results[stock_code] = stock_results total_signals += stock_signal_count + total_patterns += stock_pattern_count except Exception as e: logger.error(f"扫描股票 {stock_code} 失败: {e}") @@ -877,20 +1499,28 @@ class KLinePatternStrategy(BaseStrategy): logger.info(f"🌍 市场K线形态扫描完成!") logger.info(f"📊 扫描统计:") logger.info(f" 🔍 总扫描股票: {len(stock_list)} 只") - logger.info(f" 🎯 发现信号: {total_signals} 个") + logger.info(f" 🎯 确认信号: {total_signals} 个") + logger.info(f" 📊 形态形成: {total_patterns} 个") logger.info(f" 📈 涉及股票: {len(results)} 只") logger.info(f" 💾 扫描会话ID: {session_id}") if results: - logger.info(f"📋 信号详情:") + logger.info(f"📋 详细结果:") signal_count = 0 + pattern_count = 0 + for stock_code, stock_results in results.items(): stock_name = self.data_fetcher.get_stock_name(stock_code) - for timeframe, signals in stock_results.items(): - if signals: - for signal in signals: - signal_count += 1 - logger.info(f" 🎯 #{signal_count}: {stock_code}({stock_name}) | {timeframe} | {signal['date']} | {signal['breakout_price']:.2f}元") + for timeframe, result in stock_results.items(): + # 显示确认信号 + for signal in result.signals: + signal_count += 1 + logger.info(f" 🎯 信号#{signal_count}: {stock_code}({stock_name}) | {timeframe} | {signal['date']} | {signal['price']:.2f}元") + + # 显示形态形成 + for pattern in result.patterns: + pattern_count += 1 + logger.info(f" 📊 形态#{pattern_count}: {stock_code}({stock_name}) | {timeframe} | {pattern['date']} | {pattern['price']:.2f}元") logger.info("🎉" + "="*70) @@ -920,29 +1550,47 @@ class KLinePatternStrategy(BaseStrategy): def get_strategy_summary(self) -> str: """获取策略说明""" + trend_summary = "" + if self.strong_trend_enabled: + trend_summary = f""" +【多头排列先决条件】 +- EMA5 > EMA10 > EMA20(三重多头排列,确保强势趋势) +- 在形态识别、突破确认、回踩信号全过程检查 +""" + return f""" -K线形态策略 - 两阳线+阴线+阳线突破(优化版:创新高回踩确认) +K线形态策略 - 双形态识别(多头排列+创新高回踩确认) + +策略逻辑(二阶段确认): + +【阶段0 - 多头排列筛选】{trend_summary} + +【阶段1 - 模式识别(不产生信号)】 + +形态1:阳+阳+阴+突破 +1. 识别基础形态(前3根K线):阳线 + 阳线 + 阴线 +2. 不限制阳线实体比例(任何阳线都可以) +3. 在第4/5/6根K线中寻找阳线突破阴线最高价 + +形态2:大阳+小实体+突破 +1. 第1根K线:大阳线(实体>55%) +2. 第2根K线:小实体(实体<45%,阴阳不限) +3. 在第4/5/6根K线中寻找大阳线(实体>55%)突破第2根K线最高价 -策略逻辑(两阶段确认): -阶段1 - 模式识别(不产生信号): -1. 识别连续4根K线:阳线 + 阳线 + 阴线 + 阳线 -2. 前两根阳线实体部分须占振幅的 {self.min_entity_ratio:.0%} 以上 -3. 最后阳线实体部分须占振幅的 {self.final_yang_min_ratio:.0%} 以上 -4. 最后阳线收盘价须高于阴线最高价(突破确认) -5. 最后阳线收盘价须在EMA20上方(趋势确认) -6. 最后阳线换手率不高于 {self.max_turnover_ratio:.1f}%(流动性约束) ※ 此时仅记录模式,不产生交易信号 -阶段2 - 创新高回踩确认(产生信号): -7. 价格必须创新高(高于阳线突破价格) -8. 然后回踩到阴线最高点附近(容忍度:{self.pullback_tolerance:.0%}) -9. 确认窗口:模式识别后 {self.pullback_confirmation_days} 个交易日内 -10. 只有完成"创新高+回踩阴线最高价"才产生正式交易信号 +【阶段1 - 创新高回踩确认(产生信号)】 +4. 价格必须创新高(高于突破阳线价格) +5. 然后回踩到参考K线最高点附近(容忍度:{self.pullback_tolerance:.0%}) +6. 确认窗口:模式识别后 {self.pullback_confirmation_days} 个交易日内 +7. 只有完成"创新高+回踩参考价"才产生正式交易信号 核心优化理念: -- 模式出现时不急于入场 +- 支持双形态识别,提高识别覆盖率 +- 形态1:传统形态,适合标准突破 +- 形态2:强势整理,适合快速突破 - 等待价格证明突破有效性(创新高) -- 再等待合理回踩机会(回踩阴线最高价) +- 再等待合理回踩机会(回踩参考价) - 确保信号质量和入场时机的最佳化 回踩监控功能: @@ -954,10 +1602,11 @@ K线形态策略 - 两阳线+阴线+阳线突破(优化版:创新高回踩 信号特征: - 模式识别:满足基础形态条件(无信号) - 确认信号:完成创新高+回踩确认(正式信号) -- 信号内容:包含模式日期、创新高日期、回踩确认日期 +- 信号内容:包含模式日期、突破位置(第几根K线)、创新高日期、回踩确认日期 - 支持时间周期:{', '.join(self.timeframes)} 优化效果: +- 基础形态识别率提高(不要求第4根必须突破) - 极大降低假突破信号 - 提供更优质的入场时机 - 确保突破的真实有效性 diff --git a/src/utils/notification.py b/src/utils/notification.py index 3850b80..ab58a94 100644 --- a/src/utils/notification.py +++ b/src/utils/notification.py @@ -189,7 +189,7 @@ class DingTalkNotifier: def send_strategy_signal(self, stock_code: str, stock_name: str, timeframe: str, signal_type: str, price: float, signal_date: str = None, additional_info: Dict[str, Any] = None) -> bool: """ - 发送策略信号通知(优化版:创新高回踩确认) + 发送单个策略信号通知(优化版:突出关键时间) Args: stock_code: 股票代码 @@ -204,78 +204,33 @@ class DingTalkNotifier: 发送是否成功 """ try: - # 使用信号时间或当前时间 - display_time = signal_date if signal_date else datetime.now().strftime("%Y-%m-%d %H:%M:%S") + # 构建简洁的标题 + title = f"🎯 {signal_type}信号" - # 构建Markdown消息 - title = f"🎯 {signal_type}信号确认" + # 获取关键时间信息 + pattern_date = additional_info.get('pattern_date', signal_date) if additional_info else signal_date + confirmation_date = additional_info.get('confirmation_date', signal_date) if additional_info else signal_date + new_high_date = additional_info.get('new_high_date', '') if additional_info else '' + confirmation_days = additional_info.get('confirmation_days', 0) if additional_info else 0 - # 基础信息 + # 构建简洁的消息内容,突出关键时间 markdown_text = f""" -# 🎯 {signal_type}信号确认 +# 🎯 {signal_type}信号 -**股票信息:** -- 代码: `{stock_code}` -- 名称: `{stock_name}` -- 确认价格: `{price}` 元 -- 时间周期: `{timeframe}` +**{stock_code} {stock_name}** | 周期: `{timeframe}` -**确认时间:** {display_time} +**⏰ 关键时间:** +- 📅 形态形成: `{pattern_date}` +- 🚀 创新高: `{new_high_date}` +- ✅ 回踩确认: `{confirmation_date}` +- ⏰ 确认用时: `{confirmation_days}天` -**策略说明:** 两阳+阴+阳突破(创新高回踩确认) -""" - - # 添加创新高回踩确认的详细信息 - if additional_info: - # 检查是否有创新高回踩确认的关键信息 - if 'new_high_price' in additional_info and 'new_high_date' in additional_info: - markdown_text += f""" -**🚀 创新高回踩确认详情:** -- 📅 模式日期: `{additional_info.get('pattern_date', '未知')}` -- 💰 原突破价: `{additional_info.get('breakout_price', 'N/A')}` 元 -- 🌟 创新高价: `{additional_info.get('new_high_price', 'N/A')}` 元 -- 🚀 创新高日期: `{additional_info.get('new_high_date', '未知')}` -- 🎯 阴线最高价: `{additional_info.get('yin_high', 'N/A')}` 元 -- ✅ 回踩确认日期: `{additional_info.get('confirmation_date', '未知')}` -- ⏰ 总确认用时: `{additional_info.get('confirmation_days', 'N/A')}` 天 -- 📏 回踩距离: `{additional_info.get('pullback_distance', 'N/A')}%` -""" - - # 添加其他额外信息 - markdown_text += "\n**📊 技术指标:**\n" - tech_indicators = ['yang1_entity_ratio', 'yang2_entity_ratio', 'final_yang_entity_ratio', - 'breakout_pct', 'turnover_ratio', 'above_ema20'] - for key in tech_indicators: - if key in additional_info: - value = additional_info[key] - if key.endswith('_ratio') and isinstance(value, (int, float)): - markdown_text += f"- {key}: `{value:.1%}`\n" - elif key == 'breakout_pct': - markdown_text += f"- 突破幅度: `{value:.2f}%`\n" - elif key == 'turnover_ratio': - markdown_text += f"- 换手率: `{value:.2f}%`\n" - elif key == 'above_ema20': - status = '✅上方' if value else '❌下方' - markdown_text += f"- EMA20位置: `{status}`\n" - else: - markdown_text += f"- {key}: `{value}`\n" - - markdown_text += """ ---- -**💡 操作建议:** -- ✅ 信号已通过创新高回踩双重确认 -- 📈 突破有效性得到验证 -- 🎯 当前为较优入场时机 -- ⚠️ 注意风险控制,设置合理止损 - -**🔍 关键确认要素:** -1. 🎯 形态: 两阳+阴+阳突破完成 -2. 🚀 创新高: 价格突破形态高点 -3. 📉 回踩: 回踩至阴线最高价附近 -4. ✅ 时机: 7天内完成双重确认 +**💰 价格信息:** +- 创新高价: `{additional_info.get('new_high_price', price):.2f}元` +- 建议入场: `{additional_info.get('yin_high', price):.2f}元` --- -*K线形态策略 - 创新高回踩确认版* +*K线形态策略 - 创新高回踩确认* """ return self.send_markdown_message(title, markdown_text) @@ -342,18 +297,18 @@ class NotificationManager: return success - def send_strategy_summary(self, all_signals: Dict[str, Any], scan_stats: Dict[str, Any] = None) -> bool: + def send_strategy_summary(self, all_results: Dict[str, Any], scan_stats: Dict[str, Any] = None) -> bool: """ - 发送策略信号汇总通知(支持分组发送) + 发送策略汇总通知(支持分组发送,区分形态形成和信号产生) Args: - all_signals: 所有信号的汇总数据 {stock_code: {timeframe: [signals]}} + all_results: 所有结果数据 {stock_code: {timeframe: StrategyResult}} scan_stats: 扫描统计信息 Returns: 发送是否成功 """ - if not all_signals: + if not all_results: return False try: @@ -362,18 +317,26 @@ class NotificationManager: import pandas as pd current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 收集所有信号详情 + # 收集所有信号详情和形态详情 all_signal_details = [] + all_pattern_details = [] total_signals = 0 - total_stocks = len(all_signals) + total_patterns = 0 + total_stocks = len(all_results) # 计算一周前的日期 current_date = date.today() one_week_ago = current_date - pd.Timedelta(days=7) - for stock_code, stock_results in all_signals.items(): - for timeframe, signals in stock_results.items(): - for signal in signals: + for stock_code, stock_results in all_results.items(): + for timeframe, result in stock_results.items(): + # 调试:检查result类型 + if not hasattr(result, 'signals'): + logger.error(f"错误:result不是StrategyResult对象。stock_code={stock_code}, timeframe={timeframe}, result类型={type(result)}, result值={result}") + continue + + # 处理已确认信号 + for signal in result.signals: # 添加一周内信号过滤 signal_date = signal.get('details', {}).get('confirmation_date') or signal['date'] @@ -395,11 +358,12 @@ class NotificationManager: total_signals += 1 - # 根据新的信号格式提取信息 + # 根据信号格式提取信息 details = signal.get('details', {}) confirmation_date = details.get('confirmation_date', signal['date']) new_high_price = details.get('new_high_price', signal['price']) confirmation_days = details.get('confirmation_days', 0) + breakout_position = details.get('breakout_position', 4) all_signal_details.append({ 'stock_code': stock_code, @@ -412,116 +376,139 @@ class NotificationManager: 'yin_high': details.get('yin_high', 0), # 阴线最高价 'turnover': details.get('turnover_ratio', 0), 'breakout_pct': details.get('breakout_pct', 0), - 'ema20_status': '✅上方' if details.get('above_ema20', False) else '❌下方', + 'breakout_position': breakout_position, # 第几根K线突破 + 'ema20_status': '✅上方' if details.get('ema20_price', 0) > 0 else '❌下方', 'confirmation_days': confirmation_days, 'pullback_distance': details.get('pullback_distance', 0), - 'is_new_format': details.get('new_high_confirmed', False) # 是否为新格式信号 + 'is_new_format': details.get('new_high_confirmed', False), # 是否为新格式信号 + 'details': { + 'pattern_subtype': details.get('pattern_subtype', '') # 保留形态子类型 + } }) - # 如果没有信号,直接返回 + # 处理形态形成 + for pattern in result.patterns: + # 添加一周内形态过滤 + pattern_date = pattern['date'] + + # 处理不同的日期格式 + if isinstance(pattern_date, str): + try: + pattern_date = pd.to_datetime(pattern_date).date() + except: + continue + elif hasattr(pattern_date, 'date'): + pattern_date = pattern_date.date() + elif not isinstance(pattern_date, date): + continue + + # 只处理一周内的形态 + if pattern_date < one_week_ago: + logger.debug(f"🗓️ 通知过滤历史形态: {stock_code} {pattern_date} (距今{(current_date - pattern_date).days}天)") + continue + + total_patterns += 1 + + # 根据形态格式提取信息 + details = pattern.get('details', {}) + breakout_position = details.get('breakout_position', 4) + + all_pattern_details.append({ + 'stock_code': stock_code, + 'stock_name': pattern.get('stock_name', '未知'), + 'timeframe': timeframe, + 'pattern_date': pattern['date'], # 形态形成日期 + 'price': pattern['price'], # 突破价 + 'yin_high': details.get('yin_high', 0), # 阴线最高价 + 'turnover': details.get('turnover_ratio', 0), + 'breakout_pct': details.get('breakout_pct', 0), + 'breakout_position': breakout_position, # 第几根K线突破 + 'ema20_status': '✅上方' if details.get('ema20_price', 0) > 0 else '❌下方' + }) + + # 如果没有已确认信号,直接返回 if total_signals == 0: - logger.info("📱 通知过滤: 没有一周内的新信号,不发送通知") + logger.info(f"📱 通知过滤: 没有一周内的已确认信号,不发送通知 (待确认形态={total_patterns}个)") + logger.info(f"💡 提示: 信号可能被时间或价格过滤器过滤掉了") return True - # 记录过滤后的信号统计 - original_signals = sum(len(signals) for stock_results in all_signals.values() for signals in stock_results.values()) - if original_signals > total_signals: - logger.info(f"📅 通知信号过滤: 原始{original_signals}个 → 一周内{total_signals}个") - - # 按10个信号为一组分批发送 - signals_per_group = 10 - total_groups = math.ceil(total_signals / signals_per_group) + logger.info(f"📊 通知统计: 已确认信号={total_signals}个, 待确认形态={total_patterns}个(不发送)") + logger.info(f"✅ 所有确认信号均通过价格有效性检查") + # 只发送已确认信号通知(不发送待确认形态) success_count = 0 - for group_idx in range(total_groups): - start_idx = group_idx * signals_per_group - end_idx = min(start_idx + signals_per_group, total_signals) - group_signals = all_signal_details[start_idx:end_idx] + # 发送已确认信号通知 + if total_signals > 0: + signals_per_group = 10 + total_signal_groups = math.ceil(total_signals / signals_per_group) - # 构建当前组的消息 - if total_groups > 1: - title = f"🎯 K线形态策略信号汇总 ({group_idx + 1}/{total_groups})" - else: - title = f"🎯 K线形态策略信号汇总" + for group_idx in range(total_signal_groups): + start_idx = group_idx * signals_per_group + end_idx = min(start_idx + signals_per_group, total_signals) + group_signals = all_signal_details[start_idx:end_idx] - markdown_text = f""" + # 构建信号组消息 + if total_signal_groups > 1: + title = f"🎯 已确认信号 ({group_idx + 1}/{total_signal_groups})" + else: + title = f"🎯 已确认信号" + + markdown_text = f""" # {title} -**扫描统计:** -- 扫描时间: `{current_time}` -- 总信号数: `{total_signals}` 个 -- 本组信号: `{len(group_signals)}` 个 ({start_idx + 1}-{end_idx}) -- 涉及股票: `{total_stocks}` 只 +**📊 本次推送:** {len(group_signals)} 个确认信号 + +**🎯 选中股票详情:** """ - # 添加扫描范围信息 - if scan_stats: - markdown_text += f""" -**扫描范围:** -- 扫描股票总数: `{scan_stats.get('total_scanned', 'N/A')}` -- 数据源: `{scan_stats.get('data_source', '热门股票')}` -""" - - markdown_text += "\n**✅ 确认信号详情:**\n" - - # 添加当前组的信号详情 - for i, signal in enumerate(group_signals, start_idx + 1): - if signal['is_new_format']: - # 新格式:创新高回踩确认 + for i, signal in enumerate(group_signals, start_idx + 1): + # 提取关键时间信息 + pattern_date = signal['pattern_date'] + confirmation_date = signal['confirmation_date'] + + # 获取形态类型信息 + pattern_subtype = signal.get('details', {}).get('pattern_subtype', '') + + # 检查新版本形态标识 + if pattern_subtype: + if '强势形态A' in pattern_subtype or '双阳+博弈' in pattern_subtype: + pattern_type = '形态A' + elif '强势形态B' in pattern_subtype or '高实体+博弈' in pattern_subtype: + pattern_type = '形态B' + else: + pattern_type = '新形态' + else: + # 新策略必须有pattern_subtype,如果没有说明是数据问题 + pattern_type = '数据异常' + markdown_text += f""" -{i}. **{signal['stock_code']} - {signal['stock_name']}** 🎯 - - 📅 模式日期: `{signal['pattern_date']}` - - ✅ 确认日期: `{signal['confirmation_date']}` - - 💰 原突破价: `{signal['original_breakout_price']:.2f}元` - - 🌟 创新高价: `{signal['price']:.2f}元` - - 🎯 阴线高点: `{signal['yin_high']:.2f}元` - - ⏰ 确认用时: `{signal['confirmation_days']}天` - - 📏 回踩距离: `{signal['pullback_distance']:.2f}%` - - 📊 周期: `{signal['timeframe']}` | 换手: `{signal['turnover']:.2f}%` - - 📈 EMA20: `{signal['ema20_status']}` -""" - else: - # 旧格式:兼容显示 - markdown_text += f""" -{i}. **{signal['stock_code']} - {signal['stock_name']}** - - K线时间: `{signal['pattern_date']}` - - 时间周期: `{signal['timeframe']}` - - 当前价格: `{signal['price']:.2f}元` - - 突破幅度: `{signal['breakout_pct']:.2f}%` - - 换手率: `{signal['turnover']:.2f}%` - - EMA20: `{signal['ema20_status']}` +{i}. **{signal['stock_code']} {signal['stock_name']}** | `{signal['timeframe']}` | {pattern_type} + 📅 形态: `{pattern_date}` → ✅ 确认: `{confirmation_date}` ({signal['confirmation_days']}天) + 💰 创新高: `{signal['price']:.2f}元` | 🎯 入场: `{signal['yin_high']:.2f}元` """ - markdown_text += """ ---- -**🔍 策略说明:** 两阳+阴+阳突破(创新高回踩确认版) - -**💡 信号特点:** -- ✅ 所有信号已通过双重确认 -- 🎯 模式出现后等待创新高验证 -- 📉 创新高后回踩阴线最高价入场 -- ⏰ 7天内完成完整确认流程 - -**⚠️ 风险提示:** 投资有风险,入市需谨慎! + markdown_text += """ --- -*K线形态策略系统自动发送* +**💡 策略说明:** K线形态突破已完成创新高回踩确认 + +*多头排列+双形态识别策略* """ - # 发送当前组的通知 - if self.dingtalk_notifier: - if self.dingtalk_notifier.send_markdown_message(title, markdown_text): - success_count += 1 - logger.info(f"📱 发送信号汇总第{group_idx + 1}组成功 ({len(group_signals)}个信号)") - else: - logger.error(f"📱 发送信号汇总第{group_idx + 1}组失败") + if self.dingtalk_notifier: + if self.dingtalk_notifier.send_markdown_message(title, markdown_text): + success_count += 1 + logger.info(f"📱 发送已确认信号第{group_idx + 1}组成功 ({len(group_signals)}个)") + else: + logger.error(f"📱 发送已确认信号第{group_idx + 1}组失败") - # 避免发送过快,添加短暂延迟 - if group_idx < total_groups - 1: # 不是最后一组 import time - time.sleep(1) # 1秒延迟 + if group_idx < total_signal_groups - 1: + time.sleep(1) + # 不发送形态形成通知(只发送已确认信号) + logger.info(f"📱 通知发送完成: 成功{success_count}组 (已过滤{total_patterns}个待确认形态)") return success_count > 0 except Exception as e: @@ -562,42 +549,23 @@ class NotificationManager: alert_details = [] for i, alert in enumerate(group_alerts, 1): alert_detail = f""" -**{start_idx + i}. {alert['stock_code']}({alert['stock_name']})** -- 📅 原信号: {alert['signal_date']} | 当前: {alert['current_date']} -- ⏰ 间隔: {alert['days_since_signal']}天 | 周期: {alert['timeframe']} -- 💰 阴线高点: {alert['yin_high']:.2f}元 | 当时突破价: {alert['breakout_price']:.2f}元 -- 📉 当前价格: {alert['current_price']:.2f}元 | 今日最低: {alert['current_low']:.2f}元 -- 📊 回调幅度: {alert['pullback_pct']:.2f}% | 距阴线高点: {alert['distance_to_yin_high']:.2f}% +{start_idx + i}. **{alert['stock_code']} {alert['stock_name']}** + 原信号: `{alert['signal_date']}` | 周期: `{alert['timeframe']}` | 间隔: `{alert['days_since_signal']}天` + 当前价: `{alert['current_price']:.2f}` | 入场价: `{alert['yin_high']:.2f}` | 回调: `{alert['pullback_pct']:.1f}%` """ alert_details.append(alert_detail) # 构建完整的Markdown消息 markdown_text = f""" -# ⚠️ 已确认信号二次回踩提醒 +# ⚠️ 二次回踩提醒 -**🚨 重要提醒:** 以下股票已通过"创新高回踩确认"产生信号,现价格再次回踩至阴线最高点附近,请关注支撑情况! - -**📊 本批提醒数量:** {len(group_alerts)}个 -**🕐 检查时间:** {current_time} +**提醒:** {len(group_alerts)}个已确认信号二次回踩至入场价附近 --- {''.join(alert_details)} --- -**💡 操作建议:** -- ✅ 这些股票已通过双重确认,信号有效性较高 -- 🎯 当前为二次回踩阴线最高点,关注支撑强度 -- 📈 如获得有效支撑,可能形成新的上涨起点 -- 📉 如跌破阴线最高点,需要重新评估信号有效性 -- 💰 建议结合成交量和其他技术指标综合判断 - -**🔍 提醒说明:** -- 此类股票已完成创新高+回踩确认流程 -- 当前价格位置具有重要技术意义 -- 阴线最高点是关键支撑/阻力位 - -**⚠️ 风险提示:** 本提醒仅供参考,投资有风险,入市需谨慎! """ # 发送消息 @@ -618,6 +586,125 @@ class NotificationManager: logger.error(f"发送回踩提醒通知异常: {e}") return False + def send_scan_report(self, scan_result: Dict[str, Any]) -> bool: + """ + 发送市场扫描报告(无论是否有信号都发送) + + Args: + scan_result: 扫描结果数据,包含: + - task_id: 任务ID + - strategy_name: 策略名称 + - stock_pool_name: 股票池名称 + - total_stocks_analyzed: 扫描股票总数 + - stocks_with_signals: 有信号的股票数 + - total_signals_found: 发现的信号总数 + - execution_time: 执行耗时(秒) + - scan_time: 扫描时间 + - success: 是否成功 + - error: 错误信息(如果有) + + Returns: + 发送是否成功 + """ + if not self.dingtalk_notifier: + return False + + try: + from datetime import datetime + + # 提取数据 + task_id = scan_result.get('task_id', 'unknown') + strategy_name = scan_result.get('strategy_name', '未知策略') + stock_pool_name = scan_result.get('stock_pool_name', '未知股票池') + total_analyzed = scan_result.get('total_stocks_analyzed', 0) + stocks_with_signals = scan_result.get('stocks_with_signals', 0) + total_signals = scan_result.get('total_signals_found', 0) + execution_time = scan_result.get('execution_time', 0) + scan_time = scan_result.get('scan_time', datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + success = scan_result.get('success', True) + error = scan_result.get('error') + + # 根据是否有信号选择标题和图标 + if total_signals > 0: + title = "📊 市场扫描完成 - 发现信号" + status_icon = "✅" + result_desc = f"发现 **{total_signals}** 个信号,涉及 **{stocks_with_signals}** 只股票" + else: + title = "📊 市场扫描完成 - 暂无信号" + status_icon = "📭" + result_desc = "本次扫描**暂无符合条件的信号**" + + # 构建Markdown消息 + markdown_text = f""" +# {status_icon} {title} + +**扫描概要:** +- 扫描时间: `{scan_time}` +- 任务ID: `{task_id}` +- 策略名称: `{strategy_name}` +- 数据来源: `{stock_pool_name}` + +**扫描结果:** +- 扫描股票总数: `{total_analyzed}` 只 +- 有信号股票数: `{stocks_with_signals}` 只 +- 发现信号总数: `{total_signals}` 个 +- 执行耗时: `{execution_time:.2f}` 秒 +""" + + # 如果有错误,添加错误信息 + if not success and error: + markdown_text += f""" +**⚠️ 执行状态:** +- 状态: `失败` +- 错误信息: `{error}` +""" + else: + markdown_text += f""" +**✅ 执行状态:** 成功完成 +""" + + # 添加结果说明 + markdown_text += f""" +--- +**📈 扫描结果:** +{result_desc} +""" + + # 根据是否有信号添加不同的提示 + if total_signals > 0: + markdown_text += """ +**💡 温馨提示:** +- 详细信号信息请查看后续信号汇总消息 +- 建议结合市场环境综合判断 +- 注意风险控制,谨慎操作 +""" + else: + markdown_text += """ +**💡 温馨提示:** +- 当前市场暂无符合策略条件的股票 +- 请继续关注后续扫描结果 +- 策略会持续监控市场机会 +""" + + markdown_text += """ +--- +*市场扫描系统自动推送* +""" + + # 发送消息 + success = self.dingtalk_notifier.send_markdown_message(title, markdown_text) + + if success: + logger.info(f"📱 市场扫描报告发送成功: {total_analyzed}只股票, {total_signals}个信号") + else: + logger.error(f"📱 市场扫描报告发送失败") + + return success + + except Exception as e: + logger.error(f"发送市场扫描报告异常: {e}") + return False + def send_test_message(self) -> bool: """发送测试消息""" if self.dingtalk_notifier: diff --git a/start_crypto_scanner.sh b/start_crypto_scanner.sh new file mode 100755 index 0000000..400088c --- /dev/null +++ b/start_crypto_scanner.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# 加密货币市场扫描启动脚本 +# 用于手动运行或在Docker容器中启动 + +# 设置错误时退出 +set -e + +# 脚本所在目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# 打印启动信息 +echo "==========================================" +echo "🚀 加密货币市场扫描程序启动" +echo "==========================================" +echo "工作目录: $(pwd)" +echo "Python版本: $(python --version 2>&1)" +echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" +echo "==========================================" + +# 检查Python是否安装 +if ! command -v python &> /dev/null; then + echo "❌ 错误: 未找到Python" + exit 1 +fi + +# 检查依赖包是否安装 +echo "📦 检查依赖包..." +if ! python -c "import binance" 2>/dev/null; then + echo "⚠️ 警告: python-binance未安装,正在安装..." + pip install -r requirements.txt +fi + +# 创建日志目录 +mkdir -p logs + +# 获取命令行参数(扫描交易对数量) +MAX_SYMBOLS=${1:-100} + +echo "📊 扫描参数: 最大交易对数量 = $MAX_SYMBOLS" +echo "==========================================" +echo "" + +# 运行扫描程序 +python crypto_scanner.py "$MAX_SYMBOLS" + +# 打印完成信息 +echo "" +echo "==========================================" +echo "✅ 加密货币市场扫描完成" +echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" +echo "==========================================" diff --git a/start_market_scanner.sh b/start_market_scanner.sh index 559fda7..adc76c4 100755 --- a/start_market_scanner.sh +++ b/start_market_scanner.sh @@ -14,29 +14,38 @@ apt-get update && apt-get install -y cron echo "⏰ 配置定时任务..." cp /app/crontab/market-scanner /etc/cron.d/market-scanner -# 设置权限 +# 设置权限(必须是0644且属于root) chmod 0644 /etc/cron.d/market-scanner +chown root:root /etc/cron.d/market-scanner + +# 确保文件以换行符结尾 +echo "" >> /etc/cron.d/market-scanner # 启动cron服务 echo "🔄 启动cron守护进程..." service cron start +# 检查cron服务状态 +service cron status + # 显示已配置的任务 echo "📋 已配置的定时任务:" -crontab -l 2>/dev/null || echo "使用系统cron配置: /etc/cron.d/market-scanner" cat /etc/cron.d/market-scanner +# 测试cron日志权限 +touch /app/logs/cron.log +chmod 666 /app/logs/cron.log + # 记录启动信息 echo "$(date): 市场扫描服务启动完成" >> /app/logs/scanner_startup.log echo "✅ 市场扫描定时任务服务启动完成" -echo "📊 扫描参数: MARKET_SCAN_STOCKS=${MARKET_SCAN_STOCKS:-200}" echo "📝 日志文件: /app/logs/market_scanner.log" echo "⏰ Cron日志: /app/logs/cron.log" # 执行一次初始扫描 echo "🔍 执行初始市场扫描..." -python /app/market_scanner.py ${MARKET_SCAN_STOCKS:-200} +python /app/market_scanner.py # 保持容器运行并显示日志 echo "👁️ 监控日志输出..." diff --git a/web/mysql_app.py b/web/mysql_app.py index 21a54d8..6763ef8 100644 --- a/web/mysql_app.py +++ b/web/mysql_app.py @@ -55,6 +55,7 @@ def signals(): # 获取查询参数 strategy_name = request.args.get('strategy', '') timeframe = request.args.get('timeframe', '') + asset_type = request.args.get('asset_type', '') # 新增资产类型筛选 days = int(request.args.get('days', 7)) # 默认显示7天内的信号 page = int(request.args.get('page', 1)) per_page = int(request.args.get('per_page', 20)) @@ -68,6 +69,7 @@ def signals(): start_date=start_date, end_date=end_date, strategy_name=strategy_name if strategy_name else None, + asset_type=asset_type if asset_type else None, # 传递资产类型筛选 timeframe=timeframe if timeframe else None ) @@ -126,6 +128,7 @@ def signals(): has_next=has_next, total_records=total_records, strategy_name=strategy_name, + asset_type=asset_type, # 传递资产类型到模板 timeframe=timeframe, days=days, per_page=per_page) diff --git a/web/templates/signals.html b/web/templates/signals.html index c376ccb..0b914aa 100644 --- a/web/templates/signals.html +++ b/web/templates/signals.html @@ -37,6 +37,12 @@
+ +