From adcc8844b1168b67ea3806b09bd26f66ff4a0897 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 18 Sep 2025 20:45:01 +0800 Subject: [PATCH] aupdate --- README_DATABASE.md | 188 +++++++++ config/config.yaml | 8 +- data/trading.db | Bin 0 -> 352256 bytes generate_test_data.py | 104 +++++ requirements.txt | 5 +- src/data/data_fetcher.py | 334 ++++++++++++++- src/database/__init__.py | 1 + src/database/database_manager.py | 500 +++++++++++++++++++++++ src/database/schema.sql | 139 +++++++ src/strategy/kline_pattern_strategy.py | 330 ++++++++++++++- src/utils/notification.py | 83 ++++ start_web.py | 44 ++ test_all_a_shares.py | 93 +++++ test_cache_optimization.py | 97 +++++ test_database_integration.py | 256 ++++++++++++ test_pullback_feature.py | 179 +++++++++ test_simple_integration.py | 80 ++++ web/app.py | 226 +++++++++++ web/static/css/style.css | 537 +++++++++++++++++++++++++ web/static/js/main.js | 376 +++++++++++++++++ web/templates/base.html | 74 ++++ web/templates/error.html | 31 ++ web/templates/index.html | 207 ++++++++++ web/templates/pullbacks.html | 220 ++++++++++ web/templates/signals.html | 201 +++++++++ 25 files changed, 4282 insertions(+), 31 deletions(-) create mode 100644 README_DATABASE.md create mode 100644 data/trading.db create mode 100644 generate_test_data.py create mode 100644 src/database/__init__.py create mode 100644 src/database/database_manager.py create mode 100644 src/database/schema.sql create mode 100644 start_web.py create mode 100644 test_all_a_shares.py create mode 100644 test_cache_optimization.py create mode 100644 test_database_integration.py create mode 100644 test_pullback_feature.py create mode 100644 test_simple_integration.py create mode 100644 web/app.py create mode 100644 web/static/css/style.css create mode 100644 web/static/js/main.js create mode 100644 web/templates/base.html create mode 100644 web/templates/error.html create mode 100644 web/templates/index.html create mode 100644 web/templates/pullbacks.html create mode 100644 web/templates/signals.html diff --git a/README_DATABASE.md b/README_DATABASE.md new file mode 100644 index 0000000..8deb1a7 --- /dev/null +++ b/README_DATABASE.md @@ -0,0 +1,188 @@ +# 数据库存储和Web展示功能 + +## 功能概述 + +本系统现已支持将策略筛选结果按策略分组存储到SQLite数据库,并提供Web界面进行可视化展示。 + +## 🗄️ 数据库设计 + +### 主要表结构 + +1. **strategies** - 策略表 + - 存储不同的交易策略信息 + - 支持策略配置参数的JSON存储 + +2. **scan_sessions** - 扫描会话表 + - 记录每次市场扫描的信息 + - 关联策略ID,记录扫描统计数据 + +3. **stock_signals** - 股票信号表 + - 存储具体的股票筛选信号 + - 包含完整的K线数据和技术指标 + +4. **pullback_alerts** - 回踩监控表 + - 存储回踩提醒信息 + - 关联原始信号,记录回踩详情 + +### 数据库文件位置 +- 数据库文件: `data/trading.db` +- 建表脚本: `src/database/schema.sql` + +## 🌐 Web展示功能 + +### 启动Web服务 +```bash +# 方法1: 使用启动脚本 +python start_web.py + +# 方法2: 直接运行 +cd web +python app.py +``` + +### 访问地址 +- 首页: http://localhost:8080 +- 交易信号: http://localhost:8080/signals +- 回踩监控: http://localhost:8080/pullbacks + +### 页面功能 + +#### 1. 首页 (/) +- 策略统计概览 +- 最新交易信号列表 +- 最近回踩提醒 + +#### 2. 交易信号页面 (/signals) +- 详细的信号列表 +- 支持策略和时间范围筛选 +- 分页显示 + +#### 3. 回踩监控页面 (/pullbacks) +- 回踩提醒记录 +- 风险等级分类 +- 统计图表 + +### API接口 + +- `GET /api/signals` - 获取信号数据 +- `GET /api/stats` - 获取策略统计 +- `GET /api/pullbacks` - 获取回踩提醒 + +## 🔧 使用方法 + +### 1. 策略扫描自动存储 + +当运行K线形态策略扫描时,结果会自动存储到数据库: + +```python +from src.strategy.kline_pattern_strategy import KLinePatternStrategy + +# 初始化策略(会自动创建数据库连接) +strategy = KLinePatternStrategy(data_fetcher, notification_manager, config) + +# 执行市场扫描(结果自动存储到数据库) +results = strategy.scan_market() +``` + +### 2. 手动数据库操作 + +```python +from src.database.database_manager import DatabaseManager + +# 初始化数据库管理器 +db_manager = DatabaseManager() + +# 获取最新信号 +signals = db_manager.get_latest_signals(limit=50) + +# 获取策略统计 +stats = db_manager.get_strategy_stats() + +# 按日期范围查询 +from datetime import date, timedelta +start_date = date.today() - timedelta(days=7) +recent_signals = db_manager.get_signals_by_date_range(start_date) +``` + +### 3. 多策略支持 + +系统支持多个策略的数据分别存储: + +```python +# 创建新策略 +strategy_id = db_manager.create_or_update_strategy( + strategy_name="新策略名称", + strategy_type="strategy_type", + description="策略描述", + config={"param1": "value1"} +) +``` + +## 📊 数据库维护 + +### 清理旧数据 +```python +# 清理90天前的数据 +db_manager.cleanup_old_data(days_to_keep=90) +``` + +### 备份数据库 +```bash +# 复制数据库文件进行备份 +cp data/trading.db data/trading_backup_$(date +%Y%m%d).db +``` + +## 🎨 Web界面特性 + +- **响应式设计**: 支持桌面和移动设备 +- **实时更新**: 数据自动刷新 +- **交互式表格**: 支持排序、筛选 +- **美观界面**: 使用Bootstrap框架 +- **数据导出**: 支持CSV格式导出 + +## 🚀 性能优化 + +- **缓存机制**: 股票名称缓存,避免重复请求 +- **分页显示**: 大数据量分页加载 +- **索引优化**: 数据库关键字段建立索引 +- **批量操作**: 信号批量保存,提高性能 + +## 🔍 故障排除 + +### 常见问题 + +1. **数据库文件权限问题** + ```bash + # 检查data目录权限 + ls -la data/ + # 如果需要,修改权限 + chmod 755 data/ + chmod 644 data/trading.db + ``` + +2. **Web界面无法访问** + - 检查Flask是否已安装: `pip install flask` + - 确认端口5000是否被占用 + - 查看控制台错误信息 + +3. **数据库连接失败** + - 确认data目录存在且可写 + - 检查SQLite库是否正常工作 + +### 日志查看 +```bash +# 查看应用日志 +tail -f logs/trading.log + +# 查看Web服务日志 +# 直接在启动Web服务的终端查看 +``` + +## 📈 未来扩展 + +- [ ] 支持更多数据库后端(MySQL, PostgreSQL) +- [ ] 添加用户认证和权限管理 +- [ ] 实现策略回测结果存储 +- [ ] 添加图表可视化功能 +- [ ] 支持策略参数在线调整 +- [ ] 实现数据导入导出功能 \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml index 53497d7..3d01c79 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -56,10 +56,14 @@ strategy: enabled: true # 是否启用K线形态策略 min_entity_ratio: 0.55 # 前两根阳线实体最小占振幅比例(55%) final_yang_min_ratio: 0.40 # 最后阳线实体最小占振幅比例(40%) - timeframes: ["daily", "weekly"] # 支持的时间周期 - scan_stocks_count: 1000 # 扫描股票数量限制 + timeframes: ["1h", "daily", "weekly"] # 支持的时间周期 + scan_stocks_count: 5000 # 扫描股票数量限制 analysis_days: 60 # 分析的历史天数 + # 回踩监控配置 + pullback_tolerance: 0.02 # 回踩容忍度(2%),价格接近阴线最高点的阈值 + monitor_days: 30 # 监控回踩的天数(信号触发后30天内监控) + # 监控配置 monitor: # 实时监控 diff --git a/data/trading.db b/data/trading.db new file mode 100644 index 0000000000000000000000000000000000000000..9f9194f8751ef924ac442e920998300339fb02ef GIT binary patch literal 352256 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 S6r}fRT*S3<=J${6_Wu_VO;!B> literal 0 HcmV?d00001 diff --git a/generate_test_data.py b/generate_test_data.py new file mode 100644 index 0000000..6caf931 --- /dev/null +++ b/generate_test_data.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +生成测试数据用于Web界面展示 +""" + +import sys +from pathlib import Path +from datetime import datetime, date, timedelta +import random + +# 添加项目根目录到路径 +current_dir = Path(__file__).parent +sys.path.insert(0, str(current_dir)) + +from loguru import logger +from src.database.database_manager import DatabaseManager +from src.utils.config_loader import ConfigLoader +from src.data.data_fetcher import ADataFetcher +from src.utils.notification import NotificationManager +from src.strategy.kline_pattern_strategy import KLinePatternStrategy + + +def generate_test_data(): + """生成测试数据""" + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("🧪 生成测试数据") + print("=" * 40) + + try: + # 初始化组件 + logger.info("初始化组件...") + config_loader = ConfigLoader() + config = config_loader.load_config() + + data_fetcher = ADataFetcher() + notification_manager = NotificationManager(config.get('notification', {})) + db_manager = DatabaseManager() + + # 初始化策略 + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + strategy = KLinePatternStrategy( + data_fetcher=data_fetcher, + notification_manager=notification_manager, + config=kline_config, + db_manager=db_manager + ) + + # 测试股票列表 + test_stocks = ["000001.SZ", "000002.SZ", "600000.SH"] + + logger.info(f"开始分析 {len(test_stocks)} 只股票...") + + total_signals = 0 + for i, stock_code in enumerate(test_stocks, 1): + logger.info(f"[{i}/{len(test_stocks)}] 分析 {stock_code}...") + + try: + # 创建会话 + session_id = db_manager.create_scan_session( + strategy_id=strategy.strategy_id, + data_source="测试数据生成" + ) + + # 分析股票 + stock_results = strategy.analyze_stock(stock_code, session_id=session_id, days=60) + + # 统计信号 + stock_signals = sum(len(signals) for signals in stock_results.values()) + total_signals += stock_signals + + # 更新会话统计 + db_manager.update_scan_session_stats(session_id, 1, stock_signals) + + logger.info(f" 发现 {stock_signals} 个信号") + + except Exception as e: + logger.error(f" 分析失败: {e}") + + logger.info(f"✅ 数据生成完成!总共生成 {total_signals} 个信号") + + # 验证数据 + latest_signals = db_manager.get_latest_signals(limit=10) + logger.info(f"📊 数据库中共有 {len(latest_signals)} 条最新信号") + + if not latest_signals.empty: + logger.info("📋 信号示例:") + for _, signal in latest_signals.head(3).iterrows(): + logger.info(f" {signal['stock_code']}({signal['stock_name']}) - {signal['breakout_price']:.2f}元") + + print("\n" + "=" * 40) + print("🌐 现在可以访问Web界面查看数据:") + print(" http://localhost:8080") + print("=" * 40) + + except Exception as e: + logger.error(f"❌ 生成测试数据失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + generate_test_data() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index fb86b27..80307d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,7 @@ flake8>=6.0.0 # Jupyter notebook support jupyter>=1.0.0 -ipykernel>=6.25.0 \ No newline at end of file +ipykernel>=6.25.0 + +# Web framework +flask>=2.3.0 \ No newline at end of file diff --git a/src/data/data_fetcher.py b/src/data/data_fetcher.py index f9e8231..f1e2597 100644 --- a/src/data/data_fetcher.py +++ b/src/data/data_fetcher.py @@ -17,6 +17,15 @@ class ADataFetcher: def __init__(self): """初始化数据获取器""" self.client = adata + + # 股票名称缓存机制 + self._stock_name_cache = {} + self._stock_list_cache = None + self._hot_stocks_cache = None + self._east_stocks_cache = None + self._cache_timestamp = None + self._cache_duration = 3600 # 缓存1小时 + logger.info("AData客户端初始化完成") def get_stock_list(self, market: str = "A") -> pd.DataFrame: @@ -37,6 +46,259 @@ class ADataFetcher: logger.error(f"获取股票列表失败: {e}") return pd.DataFrame() + def get_filtered_a_share_list(self, exclude_st: bool = True, exclude_bj: bool = True, min_market_cap: float = 2000000000) -> pd.DataFrame: + """ + 获取过滤后的A股股票列表 + + Args: + exclude_st: 是否排除ST股票 + exclude_bj: 是否排除北交所股票 + min_market_cap: 最小市值要求(元),默认20亿 + + Returns: + 过滤后的股票列表DataFrame + """ + try: + # 获取完整股票列表 + all_stocks = self.get_stock_list() + + if all_stocks.empty: + return pd.DataFrame() + + filtered_stocks = all_stocks.copy() + original_count = len(filtered_stocks) + + # 排除北交所股票 + if exclude_bj: + before_count = len(filtered_stocks) + filtered_stocks = filtered_stocks[filtered_stocks['exchange'] != 'BJ'] + bj_excluded = before_count - len(filtered_stocks) + logger.info(f"排除北交所股票: {bj_excluded}只") + + # 排除ST股票(包含ST、*ST、PT、退市等) + if exclude_st: + before_count = len(filtered_stocks) + # 排除包含ST、*ST、PT、退等字符的股票 + st_pattern = r'(\*?ST|PT|退|暂停)' + filtered_stocks = filtered_stocks[~filtered_stocks['short_name'].str.contains(st_pattern, na=False, case=False)] + st_excluded = before_count - len(filtered_stocks) + logger.info(f"排除ST等风险股票: {st_excluded}只") + + # 基于实际市值的筛选 + if min_market_cap > 0: + before_count = len(filtered_stocks) + filtered_stocks = self._filter_by_real_market_cap(filtered_stocks, min_market_cap) + cap_excluded = before_count - len(filtered_stocks) + logger.info(f"排除小市值股票(基于实际市值): {cap_excluded}只") + + # 统计最终结果 + final_count = len(filtered_stocks) + excluded_count = original_count - final_count + + # 添加完整股票代码(带交易所后缀) + if not filtered_stocks.empty and 'exchange' in filtered_stocks.columns: + filtered_stocks['full_stock_code'] = filtered_stocks.apply( + lambda row: f"{row['stock_code']}.{row['exchange']}", axis=1 + ) + + exchange_counts = filtered_stocks['exchange'].value_counts().to_dict() + exchange_detail = " | ".join([f"{k}: {v}只" for k, v in exchange_counts.items()]) + logger.info(f"✅ 获取过滤后A股列表成功") + logger.info(f"📊 原始股票: {original_count}只 | 过滤后: {final_count}只 | 排除: {excluded_count}只") + logger.info(f"📈 交易所分布: {exchange_detail}") + + return filtered_stocks + + except Exception as e: + logger.error(f"获取过滤A股列表失败: {e}") + return pd.DataFrame() + + def _filter_by_real_market_cap(self, stock_df: pd.DataFrame, min_market_cap: float) -> pd.DataFrame: + """ + 基于实际市值筛选股票 + 由于API限制,先使用启发式规则预筛选,再对部分股票进行实际市值验证 + + Args: + stock_df: 股票列表DataFrame + min_market_cap: 最小市值要求(元) + + Returns: + 过滤后的股票DataFrame + """ + if stock_df.empty: + return stock_df + + logger.info(f"开始基于市值筛选股票,阈值: {min_market_cap/100000000:.0f}亿元") + + # 步骤1: 使用启发式规则进行预筛选,减少API调用量 + logger.info("步骤1: 使用启发式规则预筛选...") + pre_filtered = self._filter_by_market_cap_proxy(stock_df, min_market_cap) + + if pre_filtered.empty: + logger.warning("启发式预筛选后无股票,返回空结果") + return pd.DataFrame() + + logger.info(f"启发式预筛选完成: {len(pre_filtered)}/{len(stock_df)} 只股票") + + # 步骤2: 对预筛选的股票进行小批量实际市值验证 + logger.info("步骤2: 对预筛选股票进行实际市值验证...") + + # 限制验证数量以避免API超时 + max_verify_count = min(500, len(pre_filtered)) # 最多验证500只 + stocks_to_verify = pre_filtered.head(max_verify_count) + + logger.info(f"将验证 {len(stocks_to_verify)} 只股票的实际市值") + + valid_stocks = [] + total_to_verify = len(stocks_to_verify) + + for idx, (_, stock) in enumerate(stocks_to_verify.iterrows()): + stock_code = stock['stock_code'] + exchange = stock['exchange'] + full_stock_code = f"{stock_code}.{exchange}" + + try: + # 获取股本信息 + shares_info = self.client.stock.info.get_stock_shares(stock_code=stock_code) + + if not shares_info.empty and 'total_share' in shares_info.columns: + total_shares = shares_info.iloc[0]['total_share'] + + # 获取当前股价 + current_price = None + try: + market_data = self.client.stock.market.get_market(full_stock_code) + if not market_data.empty and 'close' in market_data.columns: + current_price = market_data.iloc[0]['close'] + except: + pass + + # 计算市值 + if current_price is not None and total_shares > 0: + market_cap = total_shares * 10000 * current_price # 万股转换为股 + + if market_cap >= min_market_cap: + stock_with_cap = stock.copy() + stock_with_cap['market_cap'] = market_cap + stock_with_cap['total_shares'] = total_shares + stock_with_cap['current_price'] = current_price + valid_stocks.append(stock_with_cap) + + logger.debug(f"{full_stock_code}: 市值{market_cap/100000000:.1f}亿元 {'✓' if market_cap >= min_market_cap else '✗'}") + else: + # 如果无法获取实际市值,且预筛选通过,则保留 + valid_stocks.append(stock) + logger.debug(f"{full_stock_code}: 无市值数据,保留预筛选结果") + + except Exception as e: + # 如果API调用失败,且预筛选通过,则保留 + valid_stocks.append(stock) + logger.debug(f"{full_stock_code}: API失败,保留预筛选结果: {e}") + + # 显示进度 + if (idx + 1) % 50 == 0 or idx + 1 == total_to_verify: + logger.info(f"市值验证进度: {idx + 1}/{total_to_verify} ({(idx + 1)/total_to_verify*100:.1f}%)") + + # 添加延时以避免API限制 + if idx % 10 == 9: # 每10个请求休息0.1秒 + time.sleep(0.1) + + # 步骤3: 对于剩余未验证的股票,直接使用预筛选结果 + if len(pre_filtered) > max_verify_count: + remaining_stocks = pre_filtered.iloc[max_verify_count:].copy() + for _, stock in remaining_stocks.iterrows(): + valid_stocks.append(stock) + + logger.info(f"保留 {len(remaining_stocks)} 只未验证股票(基于预筛选结果)") + + # 转换为DataFrame + if valid_stocks: + result_df = pd.DataFrame(valid_stocks) + + # 确保没有重复 + if 'stock_code' in result_df.columns: + result_df = result_df.drop_duplicates(subset=['stock_code'], keep='first') + + logger.info(f"✅ 市值筛选完成: {len(result_df)}/{len(stock_df)} 只股票符合要求") + + # 统计实际验证vs预筛选的结果 + verified_count = min(max_verify_count, len(stocks_to_verify)) + unverified_count = len(result_df) - verified_count + logger.info(f"📊 验证详情: 实际验证{verified_count}只, 预筛选保留{unverified_count}只") + + return result_df + else: + logger.warning("⚠️ 没有股票通过市值筛选") + return pd.DataFrame() + + def _filter_by_market_cap_proxy(self, stock_df: pd.DataFrame, min_market_cap: float) -> pd.DataFrame: + """ + 基于股票代码的启发式规则筛选大市值股票 + + Args: + stock_df: 股票列表DataFrame + min_market_cap: 最小市值要求(元) + + Returns: + 过滤后的股票DataFrame + """ + if stock_df.empty: + return stock_df + + # 由于无法直接获取市值数据,使用启发式规则进行筛选 + # 注意:这只是一个近似筛选,真实的市值筛选需要实际的市值数据 + + def is_likely_large_cap(stock_code: str, exchange: str) -> bool: + """判断股票是否可能是大市值股票""" + code_num = stock_code + + if exchange == 'SH': # 上交所 + # 主板: 600xxx, 601xxx, 603xxx, 605xxx (通常市值较大) + if code_num.startswith(('600', '601', '603', '605')): + return True + # 科创板: 688xxx (新兴科技公司,市值相对较大) + elif code_num.startswith('688'): + return True + # 其他上交所股票 + return False + + elif exchange == 'SZ': # 深交所 + # 主板: 000xxx, 001xxx (老牌蓝筹,通常市值较大) + if code_num.startswith(('000', '001')): + return True + # 中小板: 002xxx (部分有大市值公司) + elif code_num.startswith('002'): + # 002开头的前1000只股票(002000-002999),上市较早,可能市值较大 + try: + code_suffix = int(code_num[3:]) + return code_suffix <= 999 # 002000-002999 + except: + return False + # 创业板: 300xxx, 301xxx (部分成长为大市值) + elif code_num.startswith(('300', '301')): + # 300开头的前500只股票,上市较早,部分已成长为大市值 + try: + if code_num.startswith('300'): + code_suffix = int(code_num[3:]) + return code_suffix <= 499 # 300000-300499 + else: # 301xxx较新,市值相对较小 + return False + except: + return False + return False + + return False # 其他情况默认排除 + + # 应用筛选规则 + if min_market_cap >= 2000000000: # 20亿以上 + logger.info(f"应用大市值筛选规则(≥{min_market_cap/100000000:.0f}亿元)") + mask = stock_df.apply(lambda row: is_likely_large_cap(row['stock_code'], row['exchange']), axis=1) + return stock_df[mask] + else: + # 小于20亿的筛选条件暂时不实施严格筛选 + logger.info(f"市值筛选阈值较低({min_market_cap/100000000:.1f}亿元),保留所有股票") + return stock_df + def get_realtime_data(self, stock_codes: Union[str, List[str]]) -> pd.DataFrame: """ 获取实时行情数据 @@ -72,7 +334,7 @@ class ADataFetcher: stock_code: 股票代码 start_date: 开始日期 end_date: 结束日期 - period: 数据周期 ('daily', 'weekly', 'monthly') + period: 数据周期 ('1h', 'daily', 'weekly', 'monthly') Returns: 历史行情DataFrame @@ -86,6 +348,7 @@ class ADataFetcher: # 根据周期设置k_type参数 k_type_map = { + '1h': 60, # 1小时线(60分钟) 'daily': 1, # 日线 'weekly': 2, # 周线 'monthly': 3 # 月线 @@ -355,9 +618,52 @@ class ADataFetcher: # 返回空DataFrame作为后备 return pd.DataFrame() + def _is_cache_valid(self) -> bool: + """检查缓存是否有效""" + if self._cache_timestamp is None: + return False + + import time + return (time.time() - self._cache_timestamp) < self._cache_duration + + def _update_stock_name_cache(self): + """更新股票名称缓存""" + try: + import time + + # 检查缓存是否有效 + if self._is_cache_valid(): + return + + logger.info("🔄 更新股票名称缓存...") + + # 获取热门股票数据并缓存 + self._hot_stocks_cache = self.get_hot_stocks_ths(limit=100) + self._east_stocks_cache = self.get_popular_stocks_east(limit=100) + + # 清空名称缓存并重新构建 + self._stock_name_cache.clear() + + # 从热门股票数据中构建缓存 + for df, source in [(self._hot_stocks_cache, '同花顺'), (self._east_stocks_cache, '东财')]: + if not df.empty and 'stock_code' in df.columns and 'short_name' in df.columns: + for _, row in df.iterrows(): + stock_code = row['stock_code'] + stock_name = row['short_name'] + if stock_code not in self._stock_name_cache: + self._stock_name_cache[stock_code] = stock_name + + # 更新缓存时间戳 + self._cache_timestamp = time.time() + + logger.info(f"✅ 股票名称缓存更新完成,共缓存 {len(self._stock_name_cache)} 只股票") + + except Exception as e: + logger.warning(f"更新股票名称缓存失败: {e}") + def get_stock_name(self, stock_code: str) -> str: """ - 获取股票中文名称 + 获取股票中文名称(带缓存机制) Args: stock_code: 股票代码 @@ -366,24 +672,20 @@ class ADataFetcher: 股票中文名称,如果获取失败返回股票代码 """ try: - # 尝试从热股数据中获取名称 - hot_stocks = self.get_hot_stocks_ths(limit=100) - if not hot_stocks.empty and 'stock_code' in hot_stocks.columns and 'short_name' in hot_stocks.columns: - match = hot_stocks[hot_stocks['stock_code'] == stock_code] - if not match.empty: - return match.iloc[0]['short_name'] + # 更新缓存(如果需要) + self._update_stock_name_cache() - # 尝试从东财数据中获取名称 - east_stocks = self.get_popular_stocks_east(limit=100) - if not east_stocks.empty and 'stock_code' in east_stocks.columns and 'short_name' in east_stocks.columns: - match = east_stocks[east_stocks['stock_code'] == stock_code] - if not match.empty: - return match.iloc[0]['short_name'] + # 从缓存中查找 + if stock_code in self._stock_name_cache: + return self._stock_name_cache[stock_code] - # 尝试搜索功能 + # 缓存中没有,尝试搜索功能 search_results = self.search_stocks(stock_code) if not search_results.empty and 'short_name' in search_results.columns: - return search_results.iloc[0]['short_name'] + stock_name = search_results.iloc[0]['short_name'] + # 添加到缓存 + self._stock_name_cache[stock_code] = stock_name + return stock_name # 如果都失败,返回股票代码 logger.debug(f"未能获取{stock_code}的中文名称") diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..67124db --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1 @@ +# 数据库模块 \ No newline at end of file diff --git a/src/database/database_manager.py b/src/database/database_manager.py new file mode 100644 index 0000000..2c86239 --- /dev/null +++ b/src/database/database_manager.py @@ -0,0 +1,500 @@ +""" +数据库管理模块 +负责策略筛选结果的存储和查询 +""" + +import sqlite3 +import json +from pathlib import Path +from typing import Dict, List, Any, Optional, Tuple +from datetime import datetime, date +from loguru import logger +import pandas as pd + + +class DatabaseManager: + """数据库管理器""" + + def __init__(self, db_path: str = None): + """ + 初始化数据库管理器 + + Args: + db_path: 数据库文件路径,默认为项目根目录下的data/trading.db + """ + if db_path is None: + # 获取项目根目录 + current_file = Path(__file__) + project_root = current_file.parent.parent.parent + data_dir = project_root / "data" + data_dir.mkdir(exist_ok=True) + db_path = data_dir / "trading.db" + + self.db_path = Path(db_path) + self._init_database() + logger.info(f"数据库管理器初始化完成: {self.db_path}") + + def _init_database(self): + """初始化数据库,创建表结构""" + try: + # 读取SQL schema文件 + schema_file = Path(__file__).parent / "schema.sql" + if not schema_file.exists(): + raise FileNotFoundError(f"数据库schema文件不存在: {schema_file}") + + with open(schema_file, 'r', encoding='utf-8') as f: + schema_sql = f.read() + + # 执行建表语句 + with sqlite3.connect(self.db_path) as conn: + conn.executescript(schema_sql) + conn.commit() + + logger.info("数据库表结构初始化完成") + + # 初始化默认策略 + self._init_default_strategies() + + except Exception as e: + logger.error(f"初始化数据库失败: {e}") + raise + + def _init_default_strategies(self): + """初始化默认策略""" + try: + default_strategies = [ + { + 'strategy_name': 'K线形态策略', + 'strategy_type': 'kline_pattern', + 'description': '两阳线+阴线+阳线突破形态识别策略', + 'config': { + 'min_entity_ratio': 0.55, + 'final_yang_min_ratio': 0.40, + 'max_turnover_ratio': 40.0, + 'timeframes': ['daily', 'weekly'], + 'pullback_tolerance': 0.02, + 'monitor_days': 30 + } + } + ] + + for strategy in default_strategies: + self.create_or_update_strategy(**strategy) + + except Exception as e: + logger.warning(f"初始化默认策略失败: {e}") + + def create_or_update_strategy(self, strategy_name: str, strategy_type: str, + description: str = None, config: Dict[str, Any] = None) -> int: + """ + 创建或更新策略 + + Args: + strategy_name: 策略名称 + strategy_type: 策略类型 + description: 策略描述 + config: 策略配置 + + Returns: + 策略ID + """ + try: + config_json = json.dumps(config) if config else None + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # 检查策略是否存在 + cursor.execute( + "SELECT id FROM strategies WHERE strategy_name = ?", + (strategy_name,) + ) + result = cursor.fetchone() + + if result: + # 更新现有策略 + strategy_id = result[0] + cursor.execute(""" + UPDATE strategies + SET strategy_type = ?, description = ?, config = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (strategy_type, description, config_json, strategy_id)) + logger.debug(f"更新策略: {strategy_name} (ID: {strategy_id})") + else: + # 创建新策略 + cursor.execute(""" + INSERT INTO strategies (strategy_name, strategy_type, description, config) + VALUES (?, ?, ?, ?) + """, (strategy_name, strategy_type, description, config_json)) + strategy_id = cursor.lastrowid + logger.info(f"创建新策略: {strategy_name} (ID: {strategy_id})") + + conn.commit() + return strategy_id + + except Exception as e: + logger.error(f"创建/更新策略失败: {e}") + raise + + def create_scan_session(self, strategy_id: int, scan_date: date = None, + total_scanned: int = 0, total_signals: int = 0, + data_source: str = None, scan_config: Dict[str, Any] = None) -> int: + """ + 创建扫描会话 + + Args: + strategy_id: 策略ID + scan_date: 扫描日期 + total_scanned: 总扫描股票数 + total_signals: 总信号数 + data_source: 数据源 + scan_config: 扫描配置 + + Returns: + 会话ID + """ + try: + if scan_date is None: + scan_date = datetime.now().date() + + config_json = json.dumps(scan_config) if scan_config else None + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO scan_sessions + (strategy_id, scan_date, total_scanned, total_signals, data_source, scan_config) + VALUES (?, ?, ?, ?, ?, ?) + """, (strategy_id, scan_date, total_scanned, total_signals, data_source, config_json)) + + session_id = cursor.lastrowid + conn.commit() + + logger.info(f"创建扫描会话: {session_id} (策略ID: {strategy_id})") + return session_id + + except Exception as e: + logger.error(f"创建扫描会话失败: {e}") + raise + + def save_stock_signal(self, session_id: int, strategy_id: int, signal: Dict[str, Any]) -> int: + """ + 保存股票信号 + + Args: + session_id: 会话ID + strategy_id: 策略ID + signal: 信号数据 + + Returns: + 信号ID + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # 转换日期格式 + signal_date = signal.get('date') + if isinstance(signal_date, str): + signal_date = datetime.strptime(signal_date, '%Y-%m-%d').date() + elif hasattr(signal_date, 'date'): + signal_date = signal_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 + + 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, + k1_data, k2_data, k3_data, k4_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + session_id, strategy_id, + signal.get('stock_code'), + signal.get('stock_name'), + signal.get('timeframe'), + signal_date, + signal.get('pattern_type', '两阳+阴+阳突破'), + signal.get('breakout_price'), + signal.get('yin_high'), + signal.get('breakout_amount'), + signal.get('breakout_pct'), + signal.get('ema20_price'), + signal.get('yang1_entity_ratio'), + signal.get('yang2_entity_ratio'), + signal.get('final_yang_entity_ratio'), + signal.get('turnover_ratio'), + signal.get('above_ema20'), + k1_data, k2_data, k3_data, k4_data + )) + + signal_id = cursor.lastrowid + conn.commit() + + logger.debug(f"保存信号: {signal.get('stock_code')} (ID: {signal_id})") + return signal_id + + except Exception as e: + logger.error(f"保存股票信号失败: {e}") + raise + + def save_pullback_alert(self, signal_id: int, pullback_alert: Dict[str, Any]) -> int: + """ + 保存回踩提醒 + + Args: + signal_id: 原始信号ID + pullback_alert: 回踩提醒数据 + + Returns: + 提醒ID + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # 转换日期格式 + def convert_date(date_value): + if isinstance(date_value, str): + return datetime.strptime(date_value, '%Y-%m-%d').date() + elif hasattr(date_value, 'date'): + return date_value.date() + return date_value + + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + signal_id, + pullback_alert.get('stock_code'), + pullback_alert.get('stock_name'), + pullback_alert.get('timeframe'), + convert_date(pullback_alert.get('signal_date')), + pullback_alert.get('breakout_price'), + pullback_alert.get('yin_high'), + convert_date(pullback_alert.get('current_date')), + pullback_alert.get('current_price'), + pullback_alert.get('current_low'), + pullback_alert.get('pullback_pct'), + pullback_alert.get('distance_to_yin_high'), + pullback_alert.get('days_since_signal'), + True, # alert_sent + datetime.now() # alert_sent_time + )) + + alert_id = cursor.lastrowid + conn.commit() + + logger.debug(f"保存回踩提醒: {pullback_alert.get('stock_code')} (ID: {alert_id})") + return alert_id + + except Exception as e: + logger.error(f"保存回踩提醒失败: {e}") + raise + + def get_latest_signals(self, strategy_name: str = None, limit: int = 100) -> pd.DataFrame: + """ + 获取最新信号 + + Args: + strategy_name: 策略名称过滤 + limit: 返回数量限制 + + Returns: + 信号DataFrame + """ + try: + with sqlite3.connect(self.db_path) as conn: + sql = "SELECT * FROM latest_signals_view" + params = [] + + if strategy_name: + sql += " WHERE strategy_name = ?" + params.append(strategy_name) + + sql += " LIMIT ?" + params.append(limit) + + df = pd.read_sql_query(sql, conn, params=params) + return df + + except Exception as e: + logger.error(f"获取最新信号失败: {e}") + return pd.DataFrame() + + def get_strategy_stats(self) -> pd.DataFrame: + """ + 获取策略统计信息 + + Returns: + 策略统计DataFrame + """ + try: + with sqlite3.connect(self.db_path) as conn: + df = pd.read_sql_query("SELECT * FROM strategy_stats_view", conn) + return df + + except Exception as e: + logger.error(f"获取策略统计失败: {e}") + 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: + """ + 按日期范围获取信号 + + Args: + start_date: 开始日期 + end_date: 结束日期 + strategy_name: 策略名称过滤 + timeframe: 周期过滤 + + Returns: + 信号DataFrame + """ + try: + if end_date is None: + end_date = datetime.now().date() + + with sqlite3.connect(self.db_path) as conn: + sql = """ + SELECT * FROM latest_signals_view + WHERE signal_date >= ? AND signal_date <= ? + """ + params = [start_date, end_date] + + if strategy_name: + sql += " AND strategy_name = ?" + params.append(strategy_name) + + if timeframe: + sql += " AND timeframe = ?" + params.append(timeframe) + + sql += " ORDER BY signal_date DESC" + + df = pd.read_sql_query(sql, conn, params=params) + return df + + except Exception as e: + logger.error(f"按日期范围获取信号失败: {e}") + return pd.DataFrame() + + def get_pullback_alerts(self, days: int = 7) -> pd.DataFrame: + """ + 获取最近的回踩提醒 + + Args: + days: 获取最近几天的提醒 + + Returns: + 回踩提醒DataFrame + """ + try: + with sqlite3.connect(self.db_path) as conn: + sql = """ + SELECT * FROM pullback_alerts + WHERE pullback_date >= date('now', '-{} days') + ORDER BY pullback_date DESC + """.format(days) + + df = pd.read_sql_query(sql, conn) + return df + + except Exception as e: + logger.error(f"获取回踩提醒失败: {e}") + return pd.DataFrame() + + def cleanup_old_data(self, days_to_keep: int = 90): + """ + 清理旧数据 + + Args: + days_to_keep: 保留的天数 + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + # 删除旧的回踩提醒 + cursor.execute(""" + DELETE FROM pullback_alerts + WHERE pullback_date < date('now', '-{} days') + """.format(days_to_keep)) + + # 删除旧的信号记录 + cursor.execute(""" + DELETE FROM stock_signals + WHERE signal_date < date('now', '-{} days') + """.format(days_to_keep)) + + # 删除旧的扫描会话 + cursor.execute(""" + DELETE FROM scan_sessions + WHERE scan_date < date('now', '-{} days') + """.format(days_to_keep)) + + conn.commit() + logger.info(f"清理完成,保留了最近{days_to_keep}天的数据") + + except Exception as e: + logger.error(f"清理旧数据失败: {e}") + + def get_strategy_id(self, strategy_name: str) -> Optional[int]: + """ + 根据策略名称获取策略ID + + Args: + strategy_name: 策略名称 + + Returns: + 策略ID,如果不存在返回None + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute("SELECT id FROM strategies WHERE strategy_name = ?", (strategy_name,)) + result = cursor.fetchone() + return result[0] if result else None + + except Exception as e: + logger.error(f"获取策略ID失败: {e}") + return None + + def update_scan_session_stats(self, session_id: int, total_scanned: int, total_signals: int): + """ + 更新扫描会话统计信息 + + Args: + session_id: 会话ID + total_scanned: 总扫描股票数 + total_signals: 总信号数 + """ + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE scan_sessions + SET total_scanned = ?, total_signals = ? + WHERE id = ? + """, (total_scanned, total_signals, session_id)) + conn.commit() + + except Exception as e: + logger.error(f"更新扫描会话统计失败: {e}") + + +if __name__ == "__main__": + # 测试代码 + db = DatabaseManager() + print("数据库管理器测试完成") \ No newline at end of file diff --git a/src/database/schema.sql b/src/database/schema.sql new file mode 100644 index 0000000..db92962 --- /dev/null +++ b/src/database/schema.sql @@ -0,0 +1,139 @@ +-- 交易策略筛选结果数据库表结构 + +-- 策略表:存储不同的交易策略信息 +CREATE TABLE IF NOT EXISTS strategies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_name TEXT NOT NULL UNIQUE, -- 策略名称 + strategy_type TEXT NOT NULL, -- 策略类型(如:kline_pattern) + description TEXT, -- 策略描述 + config JSON, -- 策略配置参数 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 扫描会话表:记录每次市场扫描的信息 +CREATE TABLE IF NOT EXISTS scan_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id INTEGER, -- 关联的策略ID + scan_date DATE NOT NULL, -- 扫描日期 + scan_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 扫描时间 + total_scanned INTEGER DEFAULT 0, -- 总扫描股票数 + total_signals INTEGER DEFAULT 0, -- 总信号数 + data_source TEXT, -- 数据源(热门股票/全市场等) + scan_config JSON, -- 扫描配置 + status TEXT DEFAULT 'completed', -- 扫描状态 + FOREIGN KEY (strategy_id) REFERENCES strategies (id) +); + +-- 股票信号表:存储具体的股票筛选信号 +CREATE TABLE IF NOT EXISTS stock_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER, -- 关联的扫描会话ID + strategy_id INTEGER, -- 关联的策略ID + stock_code TEXT NOT NULL, -- 股票代码 + stock_name TEXT, -- 股票名称 + timeframe TEXT NOT NULL, -- 时间周期(daily/weekly) + signal_date DATE NOT NULL, -- 信号日期(K线日期) + signal_type TEXT NOT NULL, -- 信号类型 + + -- 价格信息 + breakout_price REAL, -- 突破价格 + yin_high REAL, -- 阴线最高点 + breakout_amount REAL, -- 突破金额 + breakout_pct REAL, -- 突破百分比 + ema20_price REAL, -- EMA20价格 + + -- 技术指标 + yang1_entity_ratio REAL, -- 第一根阳线实体比例 + yang2_entity_ratio REAL, -- 第二根阳线实体比例 + final_yang_entity_ratio REAL, -- 最后阳线实体比例 + turnover_ratio REAL, -- 换手率 + above_ema20 BOOLEAN, -- 是否在EMA20上方 + + -- K线详情(JSON格式存储) + k1_data JSON, -- 第一根K线数据 + k2_data JSON, -- 第二根K线数据 + k3_data JSON, -- 第三根K线数据(阴线) + k4_data JSON, -- 第四根K线数据(突破阳线) + + -- 元数据 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (session_id) REFERENCES scan_sessions (id), + FOREIGN KEY (strategy_id) REFERENCES strategies (id) +); + +-- 回踩监控表:存储回踩提醒信息 +CREATE TABLE IF NOT EXISTS pullback_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_id INTEGER, -- 关联的原始信号ID + stock_code TEXT NOT NULL, -- 股票代码 + stock_name TEXT, -- 股票名称 + timeframe TEXT NOT NULL, -- 时间周期 + + -- 原始信号信息 + original_signal_date DATE, -- 原始信号日期 + original_breakout_price REAL, -- 原始突破价格 + yin_high REAL, -- 阴线最高点 + + -- 回踩信息 + pullback_date DATE NOT NULL, -- 回踩发生日期 + current_price REAL, -- 当前价格 + current_low REAL, -- 当日最低价 + pullback_pct REAL, -- 回调百分比 + distance_to_yin_high REAL, -- 距离阴线最高点百分比 + days_since_signal INTEGER, -- 距离信号天数 + + -- 提醒状态 + alert_sent BOOLEAN DEFAULT FALSE, -- 是否已发送提醒 + alert_sent_time TIMESTAMP, -- 提醒发送时间 + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (signal_id) REFERENCES stock_signals (id) +); + +-- 创建索引以提高查询性能 +CREATE INDEX IF NOT EXISTS idx_stock_signals_stock_code ON stock_signals (stock_code); +CREATE INDEX IF NOT EXISTS idx_stock_signals_signal_date ON stock_signals (signal_date); +CREATE INDEX IF NOT EXISTS idx_stock_signals_strategy_id ON stock_signals (strategy_id); +CREATE INDEX IF NOT EXISTS idx_stock_signals_session_id ON stock_signals (session_id); +CREATE INDEX IF NOT EXISTS idx_scan_sessions_scan_date ON scan_sessions (scan_date); +CREATE INDEX IF NOT EXISTS idx_pullback_alerts_stock_code ON pullback_alerts (stock_code); +CREATE INDEX IF NOT EXISTS idx_pullback_alerts_pullback_date ON pullback_alerts (pullback_date); + +-- 创建视图:最新信号概览 +CREATE VIEW IF NOT EXISTS latest_signals_view AS +SELECT + ss.stock_code, + ss.stock_name, + ss.timeframe, + ss.signal_date, + ss.breakout_price, + ss.yin_high, + ss.breakout_pct, + ss.final_yang_entity_ratio, + ss.turnover_ratio, + s.strategy_name, + scan.scan_time, + scan.data_source +FROM stock_signals ss +JOIN strategies s ON ss.strategy_id = s.id +JOIN scan_sessions scan ON ss.session_id = scan.id +ORDER BY ss.signal_date DESC, ss.created_at DESC; + +-- 创建视图:策略统计概览 +CREATE VIEW IF NOT EXISTS strategy_stats_view AS +SELECT + s.strategy_name, + s.strategy_type, + COUNT(DISTINCT scan.id) as total_scans, + COUNT(ss.id) as total_signals, + COUNT(DISTINCT ss.stock_code) as unique_stocks, + MAX(scan.scan_time) as last_scan_time, + AVG(ss.breakout_pct) as avg_breakout_pct, + AVG(ss.final_yang_entity_ratio) as avg_entity_ratio +FROM strategies s +LEFT JOIN scan_sessions scan ON s.id = scan.strategy_id +LEFT JOIN stock_signals ss ON s.id = ss.strategy_id +GROUP BY s.id, s.strategy_name, s.strategy_type; \ No newline at end of file diff --git a/src/strategy/kline_pattern_strategy.py b/src/strategy/kline_pattern_strategy.py index fb774ea..bd8204b 100644 --- a/src/strategy/kline_pattern_strategy.py +++ b/src/strategy/kline_pattern_strategy.py @@ -11,12 +11,14 @@ from loguru import logger from ..data.data_fetcher import ADataFetcher from ..utils.notification import NotificationManager +from ..database.database_manager import DatabaseManager class KLinePatternStrategy: """K线形态策略类""" - def __init__(self, data_fetcher: ADataFetcher, notification_manager: NotificationManager, config: Dict[str, Any]): + def __init__(self, data_fetcher: ADataFetcher, notification_manager: NotificationManager, + config: Dict[str, Any], db_manager: DatabaseManager = None): """ 初始化K线形态策略 @@ -24,18 +26,37 @@ class KLinePatternStrategy: data_fetcher: 数据获取器 notification_manager: 通知管理器 config: 策略配置 + db_manager: 数据库管理器 """ self.data_fetcher = data_fetcher self.notification_manager = notification_manager self.config = config + self.db_manager = db_manager or DatabaseManager() # 策略参数 + self.strategy_name = "K线形态策略" self.min_entity_ratio = config.get('min_entity_ratio', 0.55) # 前两根阳线实体部分最小比例 self.final_yang_min_ratio = config.get('final_yang_min_ratio', 0.40) # 最后阳线实体部分最小比例 self.max_turnover_ratio = config.get('max_turnover_ratio', 40.0) # 最后阳线最大换手率(%) self.timeframes = config.get('timeframes', ['daily', 'weekly']) # 支持的时间周期 - logger.info("K线形态策略初始化完成") + # 回踩监控参数 + self.pullback_tolerance = config.get('pullback_tolerance', 0.02) # 回踩容忍度(2%) + self.monitor_days = config.get('monitor_days', 30) # 监控回踩的天数 + + # 存储已触发的信号,用于监控回踩 + # 格式: {stock_code: {'signals': [signal_dict], 'last_check_date': date}} + self.triggered_signals = {} + + # 确保策略在数据库中存在 + self.strategy_id = self.db_manager.create_or_update_strategy( + strategy_name=self.strategy_name, + strategy_type="kline_pattern", + description="两阳线+阴线+阳线突破形态识别策略", + config=config + ) + + logger.info(f"K线形态策略初始化完成 (策略ID: {self.strategy_id})") def calculate_kline_features(self, df: pd.DataFrame) -> pd.DataFrame: """ @@ -187,7 +208,8 @@ class KLinePatternStrategy: return signals - def analyze_stock(self, stock_code: str, stock_name: str = None, days: int = 60) -> Dict[str, List[Dict[str, Any]]]: + def analyze_stock(self, stock_code: str, stock_name: str = None, days: int = 60, + session_id: Optional[int] = None) -> Dict[str, List[Dict[str, Any]]]: """ 分析单只股票的K线形态 @@ -206,11 +228,18 @@ class KLinePatternStrategy: stock_name = self.data_fetcher.get_stock_name(stock_code) try: - # 计算开始日期 + # 计算开始日期,针对不同周期调整时间范围 end_date = datetime.now().strftime('%Y-%m-%d') - start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') for timeframe in self.timeframes: + # 针对1小时周期调整分析天数,避免数据量过大 + if timeframe == '1h': + # 1小时数据只分析最近7天 + analysis_days = min(days, 7) + else: + analysis_days = days + + start_date = (datetime.now() - timedelta(days=analysis_days)).strftime('%Y-%m-%d') logger.info(f"🔍 分析股票: {stock_code}({stock_name}) | 周期: {timeframe}") # 获取历史数据 - 直接使用adata的原生周期支持 @@ -233,6 +262,22 @@ class KLinePatternStrategy: signal['stock_name'] = stock_name signal['timeframe'] = timeframe + # 将信号添加到监控列表 + self.add_triggered_signal(signal) + + # 保存信号到数据库(如果提供了session_id) + 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 + ) + signal['signal_id'] = signal_id + logger.debug(f"信号已保存到数据库: {stock_code} (ID: {signal_id})") + except Exception as e: + logger.error(f"保存信号到数据库失败: {e}") + results[timeframe] = signals # 美化信号统计日志 @@ -250,6 +295,201 @@ class KLinePatternStrategy: return results + def check_pullback_signals(self, stock_code: str, current_data: pd.DataFrame) -> List[Dict[str, Any]]: + """ + 检查已触发信号的价格回踩情况 + + Args: + stock_code: 股票代码 + current_data: 当前K线数据 + + Returns: + 回踩提醒信号列表 + """ + pullback_alerts = [] + + if stock_code not in self.triggered_signals: + return pullback_alerts + + signals = self.triggered_signals[stock_code]['signals'] + current_date = datetime.now().date() + + if current_data.empty: + return pullback_alerts + + # 获取最新价格 + 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): + latest_date = pd.to_datetime(latest_date).date() + elif hasattr(latest_date, 'date'): + latest_date = latest_date.date() + + for signal in signals: + # 检查信号是否在监控期内 + signal_date = signal['date'] + if isinstance(signal_date, str): + signal_date = pd.to_datetime(signal_date).date() + elif hasattr(signal_date, 'date'): + signal_date = signal_date.date() + + days_since_signal = (current_date - signal_date).days + if days_since_signal > self.monitor_days: + continue + + yin_high = signal['yin_high'] # 阴线最高点 + breakout_price = signal['breakout_price'] # 突破时价格 + + # 检查是否发生回踩 + # 条件1: 最低价接近或跌破阴线最高点 + pullback_to_yin_high = latest_low <= (yin_high * (1 + self.pullback_tolerance)) + + # 条件2: 当前价格相比突破价格有明显回调 + significant_pullback = latest_price < (breakout_price * 0.95) # 回调超过5% + + if pullback_to_yin_high and significant_pullback: + # 检查是否已经发送过此类提醒(避免重复) + alert_key = f"{stock_code}_{signal_date}_{latest_date}" + if not hasattr(self, '_sent_pullback_alerts'): + self._sent_pullback_alerts = set() + + if alert_key not in self._sent_pullback_alerts: + pullback_alert = { + 'stock_code': stock_code, + 'stock_name': signal.get('stock_name', ''), + 'signal_date': signal_date, + 'current_date': latest_date, + 'timeframe': signal.get('timeframe', 'daily'), + '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, + 'days_since_signal': days_since_signal, + 'alert_type': 'pullback_to_yin_high' + } + + pullback_alerts.append(pullback_alert) + self._sent_pullback_alerts.add(alert_key) + + # 记录回踩提醒日志 + logger.warning("⚠️" + "="*60) + logger.warning(f"📉 价格回踩阴线最高点提醒!") + logger.warning(f"📅 原信号时间: {signal_date} | 当前时间: {latest_date}") + logger.warning(f"🏷️ 股票: {stock_code}({signal.get('stock_name', '')})") + 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"📉 回调幅度: {pullback_alert['pullback_pct']:.2f}%") + logger.warning(f"📏 距阴线高点: {pullback_alert['distance_to_yin_high']:.2f}%") + logger.warning(f"⏰ 信号后经过: {days_since_signal}天") + logger.warning("⚠️" + "="*60) + + return pullback_alerts + + def add_triggered_signal(self, signal: Dict[str, Any]): + """ + 添加已触发的信号到监控列表 + + Args: + signal: 信号字典 + """ + stock_code = signal.get('stock_code') + if not stock_code: + return + + if stock_code not in self.triggered_signals: + self.triggered_signals[stock_code] = { + 'signals': [], + 'last_check_date': datetime.now().date() + } + + # 添加信号到监控列表 + self.triggered_signals[stock_code]['signals'].append(signal) + + # 只保留最近的信号(避免内存占用过多) + max_signals_per_stock = 10 + if len(self.triggered_signals[stock_code]['signals']) > max_signals_per_stock: + # 按日期排序,保留最新的信号 + self.triggered_signals[stock_code]['signals'].sort( + key=lambda x: pd.to_datetime(x['date']) if isinstance(x['date'], str) else x['date'], + reverse=True + ) + self.triggered_signals[stock_code]['signals'] = \ + self.triggered_signals[stock_code]['signals'][:max_signals_per_stock] + + def monitor_pullback_for_triggered_signals(self) -> List[Dict[str, Any]]: + """ + 监控所有已触发信号的回踩情况 + + Returns: + 所有回踩提醒信号列表 + """ + all_pullback_alerts = [] + current_date = datetime.now().date() + + # 清理过期的信号 + stocks_to_remove = [] + for stock_code, signal_info in self.triggered_signals.items(): + # 过滤掉过期的信号 + valid_signals = [] + for signal in signal_info['signals']: + signal_date = signal['date'] + if isinstance(signal_date, str): + signal_date = pd.to_datetime(signal_date).date() + elif hasattr(signal_date, 'date'): + signal_date = signal_date.date() + + days_since_signal = (current_date - signal_date).days + if days_since_signal <= self.monitor_days: + valid_signals.append(signal) + + if valid_signals: + self.triggered_signals[stock_code]['signals'] = valid_signals + else: + stocks_to_remove.append(stock_code) + + # 移除没有有效信号的股票 + for stock_code in stocks_to_remove: + del self.triggered_signals[stock_code] + + logger.info(f"🔍 当前监控中的股票数量: {len(self.triggered_signals)}") + + # 检查每只股票的回踩情况 + for stock_code in self.triggered_signals.keys(): + try: + # 获取最近几天的数据 + end_date = current_date.strftime('%Y-%m-%d') + start_date = (current_date - timedelta(days=5)).strftime('%Y-%m-%d') + + current_data = self.data_fetcher.get_historical_data( + stock_code, start_date, end_date, 'daily' + ) + + if not current_data.empty: + pullback_alerts = self.check_pullback_signals(stock_code, current_data) + all_pullback_alerts.extend(pullback_alerts) + + except Exception as e: + logger.error(f"监控股票 {stock_code} 回踩情况失败: {e}") + + # 发送回踩提醒通知 + if all_pullback_alerts: + try: + success = self.notification_manager.send_pullback_alerts(all_pullback_alerts) + if success: + logger.info(f"📱 回踩提醒通知发送完成,共{len(all_pullback_alerts)}个提醒") + else: + logger.warning(f"📱 回踩提醒通知发送失败") + except Exception as e: + logger.error(f"发送回踩提醒通知失败: {e}") + + return all_pullback_alerts + def _convert_to_weekly(self, daily_df: pd.DataFrame) -> pd.DataFrame: """ 将日线数据转换为周线数据 @@ -328,15 +568,16 @@ class KLinePatternStrategy: logger.error(f"转换月线数据失败: {e}") return pd.DataFrame() - def scan_market(self, stock_list: List[str] = None, max_stocks: int = 100, use_hot_stocks: bool = True, use_combined_sources: bool = True) -> Dict[str, Dict[str, List[Dict[str, Any]]]]: + def scan_market(self, stock_list: List[str] = None, max_stocks: int = 100, use_hot_stocks: bool = True, use_combined_sources: bool = True, use_all_a_shares: bool = False) -> Dict[str, Dict[str, List[Dict[str, Any]]]]: """ 扫描市场中的股票形态 Args: - stock_list: 股票代码列表,如果为None则获取热门股票 + stock_list: 股票代码列表,如果为None则自动选择股票池 max_stocks: 最大扫描股票数量 use_hot_stocks: 是否使用热门股票数据,默认True use_combined_sources: 是否使用合并的双数据源(同花顺+东财),默认True + use_all_a_shares: 是否使用所有A股股票(排除北交所和ST),优先级最高 Returns: 所有股票的分析结果 @@ -345,9 +586,48 @@ class KLinePatternStrategy: logger.info("🌍 开始市场K线形态扫描") logger.info("🚀" + "="*70) + # 创建扫描会话 + scan_config = { + 'max_stocks': max_stocks, + 'use_hot_stocks': use_hot_stocks, + 'use_combined_sources': use_combined_sources, + 'use_all_a_shares': use_all_a_shares, + 'timeframes': self.timeframes + } + session_id = self.db_manager.create_scan_session( + strategy_id=self.strategy_id, + scan_config=scan_config + ) + if stock_list is None: - # 优先使用热门股票数据 - if use_hot_stocks: + # 优先级1: 使用所有A股股票 + if use_all_a_shares: + try: + logger.info("📊 获取所有A股股票数据(排除北交所和ST股票)...") + filtered_stocks = self.data_fetcher.get_filtered_a_share_list() + + if not filtered_stocks.empty: + # 如果max_stocks小于总股票数,随机采样 + if max_stocks > 0 and max_stocks < len(filtered_stocks): + # 按市值排序或随机选择,这里先随机选择 + selected_stocks = filtered_stocks.sample(max_stocks) + stock_list = selected_stocks['full_stock_code'].tolist() + logger.info(f"📈 从{len(filtered_stocks)}只A股中随机选择{len(stock_list)}只进行分析") + else: + stock_list = filtered_stocks['full_stock_code'].tolist() + logger.info(f"📈 分析全部{len(stock_list)}只A股股票") + + source_info = "全A股(排除北交所和ST)" + else: + logger.warning("获取A股列表失败,回退到热门股票") + use_all_a_shares = False + + except Exception as e: + logger.error(f"获取A股列表失败: {e},回退到热门股票") + use_all_a_shares = False + + # 优先级2: 使用热门股票数据 + if not use_all_a_shares and use_hot_stocks: try: if use_combined_sources: # 使用合并的双数据源 @@ -381,8 +661,8 @@ class KLinePatternStrategy: logger.error(f"获取热门股票失败: {e},回退到全市场股票") use_hot_stocks = False - # 如果热股获取失败,使用全市场股票列表 - if not use_hot_stocks: + # 优先级3: 如果热股获取失败,使用全市场股票列表 + if not use_all_a_shares and not use_hot_stocks: try: all_stocks = self.data_fetcher.get_stock_list() if not all_stocks.empty: @@ -405,7 +685,7 @@ class KLinePatternStrategy: logger.info(f"⏳ 扫描进度: [{i+1:3d}/{len(stock_list):3d}] 🔍 {stock_code}({stock_name})") try: - stock_results = self.analyze_stock(stock_code) + stock_results = self.analyze_stock(stock_code, session_id=session_id) # 统计信号数量 stock_signal_count = sum(len(signals) for signals in stock_results.values()) @@ -417,6 +697,17 @@ class KLinePatternStrategy: logger.error(f"扫描股票 {stock_code} 失败: {e}") continue + # 更新扫描会话统计 + try: + self.db_manager.update_scan_session_stats( + session_id=session_id, + total_scanned=len(stock_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线形态扫描完成!") @@ -424,6 +715,7 @@ class KLinePatternStrategy: logger.info(f" 🔍 总扫描股票: {len(stock_list)} 只") logger.info(f" 🎯 发现信号: {total_signals} 个") logger.info(f" 📈 涉及股票: {len(results)} 只") + logger.info(f" 💾 扫描会话ID: {session_id}") if results: logger.info(f"📋 信号详情:") @@ -438,6 +730,12 @@ class KLinePatternStrategy: logger.info("🎉" + "="*70) + # 监控已触发信号的回踩情况 + logger.info("🔍 开始监控已触发信号的回踩情况...") + pullback_alerts = self.monitor_pullback_for_triggered_signals() + if pullback_alerts: + logger.info(f"⚠️ 发现 {len(pullback_alerts)} 个回踩提醒") + # 发送汇总通知 if results: # 判断数据源类型 @@ -476,6 +774,13 @@ K线形态策略 - 两阳线+阴线+阳线突破 6. 最后阳线换手率不高于 {self.max_turnover_ratio:.1f}%(流动性约束) 7. 支持时间周期:{', '.join(self.timeframes)} +回踩监控功能: +- 自动监控已触发信号后的价格走势 +- 当价格回踩到阴线最高点附近时发送特殊提醒 +- 回踩容忍度:{self.pullback_tolerance:.0%} +- 监控期限:信号触发后 {self.monitor_days} 天 +- 提醒条件:价格接近阴线最高点且相比突破价有明显回调 + 信号触发条件: - 形态完整匹配 - 实体比例达标 @@ -490,6 +795,7 @@ K线形态策略 - 两阳线+阴线+阳线突破 通知方式: - 钉钉webhook汇总推送(10个信号一组分批发送) +- 价格回踩特殊提醒(5个提醒一组分批发送) - 包含关键信息:代码、股票名称、K线时间、价格、周期等 - 系统日志详细记录 """ diff --git a/src/utils/notification.py b/src/utils/notification.py index fdf5f96..7ea3e75 100644 --- a/src/utils/notification.py +++ b/src/utils/notification.py @@ -414,6 +414,89 @@ class NotificationManager: logger.error(f"发送策略汇总通知异常: {e}") return False + def send_pullback_alerts(self, pullback_alerts: list) -> bool: + """ + 发送价格回踩阴线最高点的特殊提醒 + + Args: + pullback_alerts: 回踩提醒信号列表 + + Returns: + 发送是否成功 + """ + if not pullback_alerts or not self.dingtalk_notifier: + return False + + try: + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 按5个提醒为一组分批发送 + alerts_per_group = 5 + import math + total_groups = math.ceil(len(pullback_alerts) / alerts_per_group) + success_count = 0 + + for group_idx in range(total_groups): + start_idx = group_idx * alerts_per_group + end_idx = min(start_idx + alerts_per_group, len(pullback_alerts)) + group_alerts = pullback_alerts[start_idx:end_idx] + + # 构建标题 + title = f"⚠️ 价格回踩阴线最高点提醒 ({group_idx + 1}/{total_groups})" + + # 构建详细信息 + 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}% +""" + alert_details.append(alert_detail) + + # 构建完整的Markdown消息 + markdown_text = f""" +# ⚠️ 价格回踩阴线最高点提醒 + +**🚨 重要提醒:** 以下股票在"两阳+阴+阳"形态突破后,价格回踩至阴线最高点附近,请关注支撑情况! + +**📊 本批提醒数量:** {len(group_alerts)}个 +**🕐 检查时间:** {current_time} + +--- + +{''.join(alert_details)} + +--- +**💡 操作建议:** +- 关注是否在阴线最高点获得有效支撑 +- 如跌破阴线最高点需要重新评估形态有效性 +- 建议结合成交量和其他技术指标综合判断 + +**⚠️ 风险提示:** 本提醒仅供参考,投资需谨慎! +""" + + # 发送消息 + if self.dingtalk_notifier.send_markdown_message(title, markdown_text): + success_count += 1 + logger.info(f"📱 回踩提醒第{group_idx + 1}组发送成功 ({len(group_alerts)}个提醒)") + else: + logger.error(f"📱 回踩提醒第{group_idx + 1}组发送失败") + + # 避免发送过快,添加短暂延迟 + if group_idx < total_groups - 1: + import time + time.sleep(1) # 1秒延迟 + + return success_count > 0 + + except Exception as e: + logger.error(f"发送回踩提醒通知异常: {e}") + return False + def send_test_message(self) -> bool: """发送测试消息""" if self.dingtalk_notifier: diff --git a/start_web.py b/start_web.py new file mode 100644 index 0000000..8a2728e --- /dev/null +++ b/start_web.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +启动A股量化交易系统Web界面 +""" + +import sys +import subprocess +from pathlib import Path +import webbrowser +import time + +def main(): + """启动Web服务""" + print("🌐 A股量化交易系统") + print("=" * 50) + + # 检查web目录 + web_dir = Path(__file__).parent / "web" + if not web_dir.exists(): + print("❌ web目录不存在") + return + + app_file = web_dir / "app.py" + if not app_file.exists(): + print("❌ app.py文件不存在") + return + + print("🚀 启动Flask服务器...") + print("📊 访问地址: http://localhost:8080") + print("⏹️ 按 Ctrl+C 停止服务") + print("=" * 50) + + try: + # 启动Flask应用 + subprocess.run([ + sys.executable, str(app_file) + ], cwd=str(web_dir)) + except KeyboardInterrupt: + print("\n👋 服务已停止") + except Exception as e: + print(f"❌ 启动失败: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_all_a_shares.py b/test_all_a_shares.py new file mode 100644 index 0000000..5556f4a --- /dev/null +++ b/test_all_a_shares.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +测试所有A股股票扫描功能 +""" + +import sys +from pathlib import Path + +# 添加项目根目录到路径 +current_dir = Path(__file__).parent +sys.path.insert(0, str(current_dir)) + +from loguru import logger +from src.strategy.kline_pattern_strategy import KLinePatternStrategy +from src.data.data_fetcher import ADataFetcher +from src.utils.notification import NotificationManager +from src.database.database_manager import DatabaseManager +from src.utils.config_loader import ConfigLoader + + +def test_all_a_shares_scan(): + """测试全A股扫描功能""" + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("🧪 测试全A股扫描功能") + print("=" * 50) + + try: + # 初始化组件 + logger.info("初始化组件...") + config_loader = ConfigLoader() + config = config_loader.load_config() + + data_fetcher = ADataFetcher() + notification_manager = NotificationManager(config.get('notification', {})) + db_manager = DatabaseManager() + + # 初始化策略 + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + strategy = KLinePatternStrategy( + data_fetcher=data_fetcher, + notification_manager=notification_manager, + config=kline_config, + db_manager=db_manager + ) + + # 测试过滤后的A股列表 + logger.info("测试获取过滤后的A股列表...") + filtered_stocks = data_fetcher.get_filtered_a_share_list() + logger.info(f"过滤后的A股数量: {len(filtered_stocks)}只") + + if not filtered_stocks.empty: + # 显示样例 + sample_stocks = filtered_stocks.head(5) + logger.info("A股样例:") + for _, stock in sample_stocks.iterrows(): + logger.info(f" {stock['full_stock_code']} - {stock['short_name']} ({stock['exchange']})") + + # 测试扫描全A股(限制5只股票进行测试) + logger.info("开始测试全A股扫描(限制5只)...") + results = strategy.scan_market( + max_stocks=5, # 限制5只股票进行测试 + use_all_a_shares=True # 使用全A股模式 + ) + + # 统计结果 + total_signals = 0 + for stock_code, stock_results in results.items(): + stock_signals = sum(len(signals) for signals in stock_results.values()) + total_signals += stock_signals + logger.info(f"股票 {stock_code}: {stock_signals}个信号") + + logger.info(f"✅ 全A股扫描测试完成!") + logger.info(f"📊 扫描股票数: {len(results)}只") + logger.info(f"📈 发现信号数: {total_signals}个") + + print("\n" + "=" * 50) + print("🎯 测试结果:") + print(f" - 可用A股数量: {len(filtered_stocks)}只") + print(f" - 扫描股票数量: 5只(测试限制)") + print(f" - 发现信号数量: {total_signals}个") + print(" - 功能状态: ✅ 正常工作") + print("=" * 50) + + except Exception as e: + logger.error(f"❌ 测试失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + test_all_a_shares_scan() \ No newline at end of file diff --git a/test_cache_optimization.py b/test_cache_optimization.py new file mode 100644 index 0000000..160e0a1 --- /dev/null +++ b/test_cache_optimization.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +测试股票名称获取的缓存优化 +""" + +import sys +from pathlib import Path +import time + +# 添加src目录到路径 +current_dir = Path(__file__).parent +src_dir = current_dir / "src" +sys.path.insert(0, str(src_dir)) + +from loguru import logger +from src.data.data_fetcher import ADataFetcher + +def test_stock_name_cache(): + """测试股票名称缓存机制""" + logger.info("🧪 开始测试股票名称缓存优化") + + # 初始化数据获取器 + data_fetcher = ADataFetcher() + + # 测试股票列表 + test_stocks = ['000001.SZ', '000002.SZ', '600000.SH', '600036.SH', '000858.SZ'] + + # 第一次获取股票名称(会触发缓存构建) + logger.info("📊 第一次获取股票名称(构建缓存)...") + start_time = time.time() + + names_first = {} + for stock_code in test_stocks: + name = data_fetcher.get_stock_name(stock_code) + names_first[stock_code] = name + logger.info(f" {stock_code}: {name}") + + first_duration = time.time() - start_time + logger.info(f"⏱️ 第一次获取耗时: {first_duration:.2f}秒") + + # 等待一秒 + time.sleep(1) + + # 第二次获取股票名称(应该从缓存读取) + logger.info("📊 第二次获取股票名称(从缓存读取)...") + start_time = time.time() + + names_second = {} + for stock_code in test_stocks: + name = data_fetcher.get_stock_name(stock_code) + names_second[stock_code] = name + logger.info(f" {stock_code}: {name}") + + second_duration = time.time() - start_time + logger.info(f"⏱️ 第二次获取耗时: {second_duration:.2f}秒") + + # 比较结果 + logger.info("📈 性能对比:") + if second_duration < first_duration: + speedup = first_duration / second_duration + logger.info(f"✅ 缓存优化成功! 第二次比第一次快 {speedup:.1f}x") + else: + logger.warning("❌ 缓存优化效果不明显") + + # 验证数据一致性 + consistent = names_first == names_second + logger.info(f"🔍 数据一致性: {'✅ 一致' if consistent else '❌ 不一致'}") + + # 显示缓存状态 + logger.info(f"📦 当前缓存中的股票数量: {len(data_fetcher._stock_name_cache)}") + + return first_duration, second_duration, consistent + +if __name__ == "__main__": + # 设置日志 + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("=" * 60) + print("🧪 股票名称缓存优化测试") + print("=" * 60) + + try: + first_time, second_time, is_consistent = test_stock_name_cache() + + print("\n" + "=" * 60) + print("📊 测试结果总结:") + print(f" 第一次获取耗时: {first_time:.2f}秒") + print(f" 第二次获取耗时: {second_time:.2f}秒") + print(f" 性能提升倍数: {first_time/second_time:.1f}x") + print(f" 数据一致性: {'✅ 通过' if is_consistent else '❌ 失败'}") + print("=" * 60) + + except Exception as e: + logger.error(f"测试过程中发生错误: {e}") + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/test_database_integration.py b/test_database_integration.py new file mode 100644 index 0000000..33efbfe --- /dev/null +++ b/test_database_integration.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +测试数据库集成和策略存储功能 +""" + +import sys +from pathlib import Path +from datetime import datetime, date + +# 添加src目录到路径 +current_dir = Path(__file__).parent +src_dir = current_dir / "src" +sys.path.insert(0, str(src_dir)) + +from loguru import logger +from src.database.database_manager import DatabaseManager +from src.utils.config_loader import ConfigLoader +from src.data.data_fetcher import ADataFetcher +from src.utils.notification import NotificationManager +from src.strategy.kline_pattern_strategy import KLinePatternStrategy + + +def test_database_operations(): + """测试数据库基本操作""" + logger.info("🗄️ 测试数据库基本操作...") + + try: + # 初始化数据库管理器 + db_manager = DatabaseManager() + + # 测试策略统计 + strategy_stats = db_manager.get_strategy_stats() + logger.info(f"📊 策略统计记录数: {len(strategy_stats)}") + + # 测试最新信号 + latest_signals = db_manager.get_latest_signals(limit=10) + logger.info(f"📈 最新信号记录数: {len(latest_signals)}") + + # 测试日期范围查询 + start_date = date.today() + signals_by_date = db_manager.get_signals_by_date_range(start_date) + logger.info(f"🗓️ 今日信号记录数: {len(signals_by_date)}") + + # 测试回踩提醒 + pullback_alerts = db_manager.get_pullback_alerts(days=7) + logger.info(f"⚠️ 最近7天回踩提醒: {len(pullback_alerts)}") + + logger.info("✅ 数据库基本操作测试完成") + return True + + except Exception as e: + logger.error(f"❌ 数据库操作测试失败: {e}") + return False + + +def test_strategy_integration(): + """测试策略与数据库集成""" + logger.info("🔄 测试策略与数据库集成...") + + try: + # 初始化组件 + config_loader = ConfigLoader() + config = config_loader.load_config() + + data_fetcher = ADataFetcher() + notification_manager = NotificationManager(config.get('notification', {})) + db_manager = DatabaseManager() + + # 初始化策略(自动创建数据库记录) + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + strategy = KLinePatternStrategy( + data_fetcher=data_fetcher, + notification_manager=notification_manager, + config=kline_config, + db_manager=db_manager + ) + + logger.info(f"📋 策略ID: {strategy.strategy_id}") + logger.info(f"📝 策略名称: {strategy.strategy_name}") + + # 测试分析单只股票(会自动保存到数据库) + test_stock = "000001.SZ" + logger.info(f"🔍 测试分析股票: {test_stock}") + + stock_results = strategy.analyze_stock(test_stock, days=30) + total_signals = sum(len(signals) for signals in stock_results.values()) + + logger.info(f"📊 分析结果: {total_signals} 个信号") + + # 验证数据库中的记录 + latest_signals = db_manager.get_latest_signals(strategy_name=strategy.strategy_name, limit=10) + logger.info(f"💾 数据库中最新信号数: {len(latest_signals)}") + + logger.info("✅ 策略与数据库集成测试完成") + return True + + except Exception as e: + logger.error(f"❌ 策略集成测试失败: {e}") + import traceback + traceback.print_exc() + return False + + +def test_scan_market_with_database(): + """测试市场扫描与数据库存储""" + logger.info("🌍 测试市场扫描与数据库存储...") + + try: + # 初始化组件 + config_loader = ConfigLoader() + config = config_loader.load_config() + + data_fetcher = ADataFetcher() + notification_manager = NotificationManager(config.get('notification', {})) + db_manager = DatabaseManager() + + # 初始化策略 + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + strategy = KLinePatternStrategy( + data_fetcher=data_fetcher, + notification_manager=notification_manager, + config=kline_config, + db_manager=db_manager + ) + + # 小规模市场扫描测试(限制5只股票) + logger.info("🔍 开始小规模市场扫描测试...") + test_stocks = ["000001.SZ", "000002.SZ", "600000.SH", "600036.SH", "000858.SZ"] + + results = strategy.scan_market( + stock_list=test_stocks, + max_stocks=5, + use_hot_stocks=False + ) + + total_signals = sum( + sum(len(signals) for signals in stock_results.values()) + for stock_results in results.values() + ) + + logger.info(f"📊 扫描完成: 发现 {total_signals} 个信号") + + # 验证数据库存储 + recent_signals = db_manager.get_latest_signals( + strategy_name=strategy.strategy_name, + limit=50 + ) + logger.info(f"💾 数据库中存储的信号数: {len(recent_signals)}") + + # 显示最新的几个信号 + if not recent_signals.empty: + logger.info("📋 最新信号示例:") + for i, signal in recent_signals.head(3).iterrows(): + logger.info(f" {signal['stock_code']}({signal['stock_name']}) - {signal['breakout_price']:.2f}元") + + logger.info("✅ 市场扫描与数据库存储测试完成") + return True + + except Exception as e: + logger.error(f"❌ 市场扫描测试失败: {e}") + import traceback + traceback.print_exc() + return False + + +def test_database_queries(): + """测试数据库查询功能""" + logger.info("🔍 测试数据库查询功能...") + + try: + db_manager = DatabaseManager() + + # 测试策略统计 + strategy_stats = db_manager.get_strategy_stats() + if not strategy_stats.empty: + logger.info("📊 策略统计:") + for _, stat in strategy_stats.iterrows(): + logger.info(f" {stat['strategy_name']}: {stat['total_signals']}个信号, {stat['unique_stocks']}只股票") + + # 测试按日期查询 + today = date.today() + today_signals = db_manager.get_signals_by_date_range(today, today) + logger.info(f"📅 今日信号数: {len(today_signals)}") + + # 测试获取策略ID + strategy_id = db_manager.get_strategy_id("K线形态策略") + logger.info(f"🆔 K线形态策略ID: {strategy_id}") + + logger.info("✅ 数据库查询功能测试完成") + return True + + except Exception as e: + logger.error(f"❌ 数据库查询测试失败: {e}") + return False + + +def main(): + """主测试函数""" + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("=" * 70) + print("🧪 A股量化交易系统 - 数据库集成测试") + print("=" * 70) + + test_results = [] + + # 运行测试 + tests = [ + ("数据库基本操作", test_database_operations), + ("策略与数据库集成", test_strategy_integration), + ("数据库查询功能", test_database_queries), + ("市场扫描与存储", test_scan_market_with_database), + ] + + for test_name, test_func in tests: + logger.info(f"\n🚀 开始测试: {test_name}") + try: + result = test_func() + test_results.append((test_name, result)) + if result: + logger.info(f"✅ {test_name} 测试通过") + else: + logger.error(f"❌ {test_name} 测试失败") + except Exception as e: + logger.error(f"❌ {test_name} 测试异常: {e}") + test_results.append((test_name, False)) + + # 输出测试结果 + print("\n" + "=" * 70) + print("📊 测试结果汇总:") + print("=" * 70) + + passed = 0 + total = len(test_results) + + for test_name, result in test_results: + status = "✅ 通过" if result else "❌ 失败" + print(f" {test_name}: {status}") + if result: + passed += 1 + + print(f"\n🎯 总计: {passed}/{total} 个测试通过") + + if passed == total: + print("🎉 所有测试都通过了!数据库集成功能正常工作。") + print("🌐 现在可以启动Web界面查看数据:") + print(" cd web && python app.py") + else: + print("⚠️ 部分测试失败,请检查错误信息并修复问题。") + + print("=" * 70) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_pullback_feature.py b/test_pullback_feature.py new file mode 100644 index 0000000..aa5dbaf --- /dev/null +++ b/test_pullback_feature.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +测试价格回踩阴线最高点提醒功能 +""" + +import sys +from pathlib import Path +import pandas as pd +from datetime import datetime, timedelta + +# 添加src目录到路径 +current_dir = Path(__file__).parent +src_dir = current_dir / "src" +sys.path.insert(0, str(src_dir)) + +from loguru import logger +from src.utils.config_loader import config_loader +from src.data.data_fetcher import ADataFetcher +from src.utils.notification import NotificationManager +from src.strategy.kline_pattern_strategy import KLinePatternStrategy + + +def create_test_signal(): + """创建一个测试用的K线形态信号""" + test_signal = { + 'stock_code': '000001.SZ', + 'stock_name': '平安银行', + 'date': datetime.now() - timedelta(days=5), # 5天前的信号 + 'timeframe': 'daily', + 'breakout_price': 15.50, # 突破价格 + 'yin_high': 15.20, # 阴线最高点 + 'pattern_type': '两阳+阴+阳突破' + } + return test_signal + + +def create_test_pullback_data(): + """创建模拟回踩数据 - 模拟价格回踩到阴线最高点附近""" + dates = pd.date_range(start=datetime.now() - timedelta(days=3), end=datetime.now(), freq='D') + + # 模拟价格回踩阴线最高点的情况 + test_data = [] + yin_high = 15.20 # 阴线最高点价格 + initial_price = 15.50 # 突破价格 + + # 设计价格走势:从突破价格逐步回调到阴线最高点附近 + price_path = [15.45, 15.35, 15.25, 15.18] # 最后一个价格接近阴线最高点 + + for i, date in enumerate(dates): + if i < len(price_path): + close_price = price_path[i] + else: + close_price = yin_high - 0.02 # 稍低于阴线最高点 + + low_price = close_price - 0.03 # 最低价更接近阴线最高点 + + test_data.append({ + 'trade_date': date, + 'open': close_price + 0.02, + 'high': close_price + 0.05, + 'low': low_price, + 'close': close_price, + 'volume': 1000000 + }) + + return pd.DataFrame(test_data) + + +def test_pullback_detection(): + """测试回踩检测功能""" + logger.info("🧪 开始测试价格回踩阴线最高点提醒功能") + + # 初始化配置 + config = config_loader.load_config() + + # 初始化组件 + data_fetcher = ADataFetcher() + notification_config = config.get('notification', {}) + notification_manager = NotificationManager(notification_config) + + # 获取K线形态策略配置 + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + strategy = KLinePatternStrategy(data_fetcher, notification_manager, kline_config) + + # 创建测试信号 + test_signal = create_test_signal() + logger.info(f"📊 创建测试信号: {test_signal['stock_code']}({test_signal['stock_name']})") + logger.info(f" - 信号日期: {test_signal['date']}") + logger.info(f" - 突破价格: {test_signal['breakout_price']}") + logger.info(f" - 阴线最高点: {test_signal['yin_high']}") + + # 添加到策略的监控列表 + strategy.add_triggered_signal(test_signal) + logger.info("✅ 测试信号已添加到监控列表") + + # 创建模拟回踩数据 + test_pullback_data = create_test_pullback_data() + logger.info(f"📈 创建模拟K线数据,共{len(test_pullback_data)}条记录") + + print("\n模拟K线数据:") + for _, row in test_pullback_data.iterrows(): + print(f" {row['trade_date'].strftime('%Y-%m-%d')}: " + f"开{row['open']:.2f} 高{row['high']:.2f} 低{row['low']:.2f} 收{row['close']:.2f}") + + # 检测回踩情况 + logger.info("🔍 开始检测回踩情况...") + pullback_alerts = strategy.check_pullback_signals(test_signal['stock_code'], test_pullback_data) + + if pullback_alerts: + logger.info(f"⚠️ 检测到 {len(pullback_alerts)} 个回踩提醒") + for i, alert in enumerate(pullback_alerts, 1): + logger.info(f" 提醒{i}: {alert['stock_code']} - 当前价格{alert['current_price']:.2f}," + f"回调{alert['pullback_pct']:.2f}%,距阴线高点{alert['distance_to_yin_high']:.2f}%") + + # 测试通知发送(如果启用了钉钉通知) + if notification_config.get('dingtalk', {}).get('enabled', False): + logger.info("📱 测试发送回踩提醒通知...") + success = notification_manager.send_pullback_alerts(pullback_alerts) + if success: + logger.info("✅ 回踩提醒通知发送成功") + else: + logger.warning("❌ 回踩提醒通知发送失败") + else: + logger.info("ℹ️ 钉钉通知未启用,跳过通知发送测试") + else: + logger.info("ℹ️ 未检测到回踩情况") + + # 测试完整的监控流程 + logger.info("\n🔍 测试完整的回踩监控流程...") + all_pullback_alerts = strategy.monitor_pullback_for_triggered_signals() + + logger.info("🎯 测试完成!") + if all_pullback_alerts: + logger.info(f"✅ 成功检测到 {len(all_pullback_alerts)} 个回踩提醒") + else: + logger.info("ℹ️ 当前监控中无回踩情况") + + +def test_strategy_config(): + """测试策略配置是否正确加载""" + logger.info("🔧 测试策略配置加载...") + + config = config_loader.load_config() + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + + logger.info("📋 当前K线形态策略配置:") + logger.info(f" - 启用状态: {kline_config.get('enabled', False)}") + logger.info(f" - 前两阳线实体比例: {kline_config.get('min_entity_ratio', 0.55)}") + logger.info(f" - 最后阳线实体比例: {kline_config.get('final_yang_min_ratio', 0.40)}") + logger.info(f" - 回踩容忍度: {kline_config.get('pullback_tolerance', 0.02)}") + logger.info(f" - 监控天数: {kline_config.get('monitor_days', 30)}") + logger.info(f" - 支持时间周期: {kline_config.get('timeframes', ['daily'])}") + + +if __name__ == "__main__": + # 设置日志 + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("=" * 60) + print("🧪 价格回踩阴线最高点提醒功能测试") + print("=" * 60) + + try: + # 测试配置加载 + test_strategy_config() + print() + + # 测试回踩检测功能 + test_pullback_detection() + + except Exception as e: + logger.error(f"测试过程中发生错误: {e}") + import traceback + traceback.print_exc() + + print("\n" + "=" * 60) + print("🏁 测试结束") + print("=" * 60) \ No newline at end of file diff --git a/test_simple_integration.py b/test_simple_integration.py new file mode 100644 index 0000000..e9ee77d --- /dev/null +++ b/test_simple_integration.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +简单的数据库集成测试 +""" + +import sys +from pathlib import Path + +# 添加src目录到路径 +current_dir = Path(__file__).parent +src_dir = current_dir / "src" +sys.path.insert(0, str(src_dir)) + +from loguru import logger +from src.database.database_manager import DatabaseManager +from src.utils.config_loader import ConfigLoader +from src.data.data_fetcher import ADataFetcher +from src.utils.notification import NotificationManager +from src.strategy.kline_pattern_strategy import KLinePatternStrategy + + +def main(): + """简单测试""" + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("🧪 简单数据库集成测试") + print("=" * 50) + + try: + # 初始化组件 + logger.info("初始化组件...") + config_loader = ConfigLoader() + config = config_loader.load_config() + + data_fetcher = ADataFetcher() + notification_manager = NotificationManager(config.get('notification', {})) + db_manager = DatabaseManager() + + # 初始化策略 + kline_config = config.get('strategy', {}).get('kline_pattern', {}) + strategy = KLinePatternStrategy( + data_fetcher=data_fetcher, + notification_manager=notification_manager, + config=kline_config, + db_manager=db_manager + ) + + logger.info(f"策略ID: {strategy.strategy_id}") + + # 测试小规模扫描 + logger.info("开始小规模扫描...") + test_stocks = ["000001.SZ", "000002.SZ"] + + results = strategy.scan_market( + stock_list=test_stocks, + max_stocks=2, + use_hot_stocks=False + ) + + # 检查数据库 + logger.info("检查数据库记录...") + latest_signals = db_manager.get_latest_signals(limit=10) + logger.info(f"数据库中的信号数: {len(latest_signals)}") + + if not latest_signals.empty: + logger.info("信号示例:") + for _, signal in latest_signals.head(3).iterrows(): + logger.info(f" {signal['stock_code']} - {signal['breakout_price']:.2f}元") + + logger.info("✅ 测试完成") + + except Exception as e: + logger.error(f"❌ 测试失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..19da4dd --- /dev/null +++ b/web/app.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +A股量化交易系统 Web 展示界面 +使用Flask框架展示策略筛选结果 +""" + +import sys +from pathlib import Path +from flask import Flask, render_template, jsonify, request +from datetime import datetime, date, timedelta +import pandas as pd + +# 添加项目根目录到路径 +current_dir = Path(__file__).parent +project_root = current_dir.parent +sys.path.insert(0, str(project_root)) + +from src.database.database_manager import DatabaseManager +from src.utils.config_loader import ConfigLoader +from loguru import logger + +app = Flask(__name__) +app.secret_key = 'trading_ai_secret_key_2023' + +# 初始化组件 +db_manager = DatabaseManager() +config_loader = ConfigLoader() + + +@app.route('/') +def index(): + """首页 - 直接跳转到交易信号页面""" + from flask import redirect, url_for + return redirect(url_for('signals')) + + +@app.route('/signals') +def signals(): + """信号页面 - 详细的信号列表""" + try: + # 获取查询参数 + strategy_name = request.args.get('strategy', '') + timeframe = request.args.get('timeframe', '') + days = int(request.args.get('days', 30)) + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 20)) + + # 计算日期范围 + end_date = date.today() + start_date = end_date - timedelta(days=days) + + # 获取信号数据 + signals_df = db_manager.get_signals_by_date_range( + start_date=start_date, + end_date=end_date, + strategy_name=strategy_name if strategy_name else None, + timeframe=timeframe if timeframe else None + ) + + # 按扫描日期分组,每组内按信号日期排序 + signals_grouped = {} + if not signals_df.empty: + # 确保scan_time是datetime类型 + signals_df['scan_time'] = pd.to_datetime(signals_df['scan_time']) + signals_df['scan_date'] = signals_df['scan_time'].dt.date + + # 按扫描日期分组 + for scan_date, group in signals_df.groupby('scan_date'): + # 每组内按信号日期排序(降序) + group_sorted = group.sort_values('signal_date', ascending=False) + signals_grouped[scan_date] = group_sorted + + # 按扫描日期排序(最新的在前) + signals_grouped = dict(sorted(signals_grouped.items(), key=lambda x: x[0], reverse=True)) + + # 分页处理 + total_records = len(signals_df) + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + + # 将分组数据展平用于分页 + flattened_signals = [] + for scan_date, group in signals_grouped.items(): + flattened_signals.extend(group.to_dict('records')) + + paginated_signals = flattened_signals[start_idx:end_idx] + + # 重新按扫描日期分组分页后的数据 + paginated_grouped = {} + for signal in paginated_signals: + scan_date = pd.to_datetime(signal['scan_time']).date() + if scan_date not in paginated_grouped: + paginated_grouped[scan_date] = [] + paginated_grouped[scan_date].append(signal) + + # 计算分页信息 + total_pages = (total_records + per_page - 1) // per_page + has_prev = page > 1 + has_next = page < total_pages + + return render_template('signals.html', + signals_grouped=paginated_grouped, + current_page=page, + total_pages=total_pages, + has_prev=has_prev, + has_next=has_next, + total_records=total_records, + strategy_name=strategy_name, + timeframe=timeframe, + days=days, + per_page=per_page) + except Exception as e: + logger.error(f"信号页面数据加载失败: {e}") + return render_template('error.html', error=str(e)) + + +@app.route('/pullbacks') +def pullbacks(): + """回踩监控页面""" + try: + days = int(request.args.get('days', 30)) + pullback_alerts = db_manager.get_pullback_alerts(days=days) + + return render_template('pullbacks.html', + pullback_alerts=pullback_alerts.to_dict('records') if not pullback_alerts.empty else [], + days=days) + except Exception as e: + logger.error(f"回踩监控页面数据加载失败: {e}") + return render_template('error.html', error=str(e)) + + +@app.route('/api/signals') +def api_signals(): + """API接口 - 获取信号数据""" + try: + strategy_name = request.args.get('strategy', '') + limit = int(request.args.get('limit', 100)) + + signals_df = db_manager.get_latest_signals( + strategy_name=strategy_name if strategy_name else None, + limit=limit + ) + + return jsonify({ + 'success': True, + 'data': signals_df.to_dict('records') if not signals_df.empty else [], + 'total': len(signals_df) + }) + except Exception as e: + logger.error(f"API获取信号失败: {e}") + return jsonify({'success': False, 'error': str(e)}) + + +@app.route('/api/stats') +def api_stats(): + """API接口 - 获取策略统计""" + try: + strategy_stats = db_manager.get_strategy_stats() + + return jsonify({ + 'success': True, + 'data': strategy_stats.to_dict('records') if not strategy_stats.empty else [] + }) + except Exception as e: + logger.error(f"API获取统计失败: {e}") + return jsonify({'success': False, 'error': str(e)}) + + +@app.route('/api/pullbacks') +def api_pullbacks(): + """API接口 - 获取回踩提醒""" + try: + days = int(request.args.get('days', 7)) + pullback_alerts = db_manager.get_pullback_alerts(days=days) + + return jsonify({ + 'success': True, + 'data': pullback_alerts.to_dict('records') if not pullback_alerts.empty else [] + }) + except Exception as e: + logger.error(f"API获取回踩提醒失败: {e}") + return jsonify({'success': False, 'error': str(e)}) + + +@app.template_filter('datetime_format') +def datetime_format(value, format='%Y-%m-%d %H:%M'): + """日期时间格式化过滤器""" + if value is None: + return '' + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace('Z', '+00:00')) + except: + return value + return value.strftime(format) + + +@app.template_filter('percentage') +def percentage_format(value, precision=2): + """百分比格式化过滤器""" + if value is None: + return '0.00%' + return f"{float(value):.{precision}f}%" + + +@app.template_filter('currency') +def currency_format(value, precision=2): + """货币格式化过滤器""" + if value is None: + return '0.00' + return f"{float(value):.{precision}f}" + + +if __name__ == '__main__': + # 设置日志 + logger.remove() + logger.add(sys.stdout, level="INFO", format="{time:HH:mm:ss} | {level} | {message}") + + print("=" * 60) + print("🌐 A股量化交易系统 Web 界面") + print("=" * 60) + print("🚀 启动 Flask 服务器...") + print("📊 访问地址: http://localhost:8080") + print("=" * 60) + + app.run(host='0.0.0.0', port=8080, debug=True) \ No newline at end of file diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..805eb2a --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,537 @@ +/* A股量化交易系统 - 简洁清爽浅色调设计 */ + +/* ========== 全局样式 ========== */ +:root { + /* 蓝色调配色方案 */ + --primary-color: #2563eb; + --primary-light: #60a5fa; + --primary-lighter: #dbeafe; + --secondary-color: #64748b; + --success-color: #10b981; + --warning-color: #f59e0b; + --danger-color: #ef4444; + --info-color: #06b6d4; + + /* 背景色 */ + --bg-primary: #fefefe; + --bg-secondary: #f8fafc; + --bg-tertiary: #f1f5f9; + --bg-accent: #ffffff; + + /* 文字色 */ + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-muted: #94a3b8; + + /* 边框色 */ + --border-color: #e2e8f0; + --border-light: #f1f5f9; + + /* 阴影 */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + + /* 圆角 */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; +} + +body { + font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + background-color: var(--bg-secondary); + color: var(--text-primary); + line-height: 1.6; + font-size: 14px; +} + +/* ========== 导航栏 ========== */ +.navbar { + background: linear-gradient(135deg, var(--bg-accent) 0%, var(--bg-secondary) 100%); + border-bottom: 1px solid var(--border-color); + backdrop-filter: blur(10px); + box-shadow: var(--shadow-sm); + padding: 0.75rem 0; +} + +.navbar-brand { + font-weight: 600; + font-size: 1.125rem; + color: var(--primary-color) !important; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.navbar-brand i { + color: var(--primary-color); + font-size: 1.25rem; +} + +.nav-link { + color: var(--text-secondary) !important; + font-weight: 500; + padding: 0.5rem 1rem !important; + border-radius: var(--radius-sm); + transition: all 0.2s ease; + margin: 0 0.125rem; +} + +.nav-link:hover { + color: var(--primary-color) !important; + background-color: var(--primary-lighter); +} + +.nav-link.active { + color: var(--primary-color) !important; + background-color: var(--primary-lighter); + font-weight: 600; +} + +.navbar-text { + color: var(--text-muted) !important; + font-size: 0.875rem; +} + +/* ========== 卡片样式 ========== */ +.card { + background-color: var(--bg-accent); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: all 0.3s ease; + overflow: hidden; +} + +.card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.card-header { + background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); + border-bottom: 1px solid var(--border-color); + padding: 1rem 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.card-body { + padding: 1.25rem; +} + +/* 统计卡片 */ +.stats-card { + background: linear-gradient(135deg, var(--bg-accent) 0%, var(--bg-tertiary) 100%); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + padding: 0.75rem; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.stats-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--primary-color), var(--primary-light)); +} + +.stats-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stats-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-color); + margin-bottom: 0.125rem; +} + +.stats-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; +} + +.stats-sublabel { + font-size: 0.675rem; + color: var(--text-muted); + margin-top: 0.125rem; +} + +/* ========== 表格样式 ========== */ +.table-container { + background-color: var(--bg-accent); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.table { + margin-bottom: 0; + font-size: 0.875rem; +} + +.table thead th { + background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); + border-bottom: 2px solid var(--border-color); + border-top: none; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + padding: 1rem 0.75rem; +} + +.table tbody tr { + border-bottom: 1px solid var(--border-light); + transition: all 0.2s ease; +} + +.table tbody tr:hover { + background-color: var(--bg-secondary); +} + +.table tbody tr:last-child { + border-bottom: none; +} + +.table tbody td { + padding: 0.875rem 0.75rem; + vertical-align: middle; + color: var(--text-primary); +} + +.table-active { + background-color: var(--primary-lighter) !important; +} + +/* ========== 徽章样式 ========== */ +.badge { + font-size: 0.75rem; + font-weight: 500; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; +} + +.bg-primary { + background-color: var(--primary-color) !important; + color: white; +} + +.bg-secondary { + background-color: var(--text-muted) !important; + color: white; +} + +.bg-success { + background-color: var(--success-color) !important; + color: white; +} + +.bg-warning { + background-color: var(--warning-color) !important; + color: white; +} + +.bg-danger { + background-color: var(--danger-color) !important; + color: white; +} + +.bg-info { + background-color: var(--info-color) !important; + color: white; +} + +/* 浅色徽章变体 */ +.badge-light-primary { + background-color: var(--primary-lighter); + color: var(--primary-color); + border-color: var(--primary-light); +} + +.badge-light-success { + background-color: #dcfce7; + color: var(--success-color); + border-color: #bbf7d0; +} + +.badge-light-warning { + background-color: #fef3c7; + color: var(--warning-color); + border-color: #fde68a; +} + +.badge-light-danger { + background-color: #fecaca; + color: var(--danger-color); + border-color: #fca5a5; +} + +/* ========== 按钮样式 ========== */ +.btn { + font-weight: 500; + border-radius: var(--radius-sm); + padding: 0.5rem 1rem; + font-size: 0.875rem; + border: 1px solid transparent; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%); + border-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background: linear-gradient(135deg, #1d4ed8 0%, var(--primary-color) 100%); + border-color: #1d4ed8; + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn-outline-primary { + background-color: transparent; + border-color: var(--primary-color); + color: var(--primary-color); +} + +.btn-outline-primary:hover { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: white; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} + +/* ========== 警告框样式 ========== */ +.alert { + border: none; + border-radius: var(--radius-md); + padding: 1rem 1.25rem; + border-left: 4px solid; +} + +.alert-info { + background: linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 100%); + border-left-color: var(--info-color); + color: #0e7490; +} + +.alert-warning { + background: linear-gradient(135deg, #fef3c7 0%, #fffbeb 100%); + border-left-color: var(--warning-color); + color: #92400e; +} + +.alert-danger { + background: linear-gradient(135deg, #fecaca 0%, #fef2f2 100%); + border-left-color: var(--danger-color); + color: #991b1b; +} + +.alert-success { + background: linear-gradient(135deg, #dcfce7 0%, #f0fdf4 100%); + border-left-color: var(--success-color); + color: #15803d; +} + +/* ========== 分页样式 ========== */ +.pagination { + gap: 0.25rem; +} + +.pagination .page-link { + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + background-color: var(--bg-accent); + transition: all 0.2s ease; +} + +.pagination .page-link:hover { + background-color: var(--primary-lighter); + border-color: var(--primary-light); + color: var(--primary-color); +} + +.pagination .page-item.active .page-link { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: white; +} + +/* ========== 表单控件 ========== */ +.form-select, +.form-control { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background-color: var(--bg-accent); + color: var(--text-primary); + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.form-select:focus, +.form-control:focus { + border-color: var(--primary-light); + box-shadow: 0 0 0 3px var(--primary-lighter); + outline: none; +} + +/* ========== 页脚 ========== */ +footer { + background: linear-gradient(135deg, var(--bg-accent) 0%, var(--bg-secondary) 100%); + border-top: 1px solid var(--border-color); + margin-top: 3rem; +} + +footer .text-muted { + color: var(--text-muted) !important; + font-size: 0.875rem; +} + +/* ========== 工具类 ========== */ +.text-primary { + color: var(--primary-color) !important; +} + +.text-success { + color: var(--success-color) !important; +} + +.text-warning { + color: var(--warning-color) !important; +} + +.text-danger { + color: var(--danger-color) !important; +} + +.text-muted { + color: var(--text-muted) !important; +} + +.fw-bold { + font-weight: 600 !important; +} + +/* ========== 空状态 ========== */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--text-muted); +} + +.empty-state i { + font-size: 3rem; + color: var(--text-muted); + margin-bottom: 1rem; + opacity: 0.6; +} + +.empty-state h4 { + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 0.5rem; +} + +/* ========== 加载动画 ========== */ +.loading { + width: 1.25rem; + height: 1.25rem; + border: 2px solid var(--border-light); + border-top: 2px solid var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ========== 响应式设计 ========== */ +@media (max-width: 768px) { + .container { + padding-left: 1rem; + padding-right: 1rem; + } + + .card-body { + padding: 1rem; + } + + .table-responsive { + font-size: 0.75rem; + } + + .stats-card { + padding: 1rem; + } + + .stats-value { + font-size: 1.5rem; + } + + .badge { + font-size: 0.625rem; + padding: 0.25rem 0.5rem; + } +} + +/* ========== 特殊效果 ========== */ +.fade-in { + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.hover-lift { + transition: transform 0.2s ease; +} + +.hover-lift:hover { + transform: translateY(-2px); +} + +/* ========== 滚动条美化 ========== */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} \ No newline at end of file diff --git a/web/static/js/main.js b/web/static/js/main.js new file mode 100644 index 0000000..539dded --- /dev/null +++ b/web/static/js/main.js @@ -0,0 +1,376 @@ +/** + * A股量化交易系统 主要JavaScript功能 + */ + +$(document).ready(function() { + // 初始化组件 + initializeComponents(); + + // 绑定事件 + bindEvents(); + + // 自动刷新 + setupAutoRefresh(); +}); + +/** + * 初始化组件 + */ +function initializeComponents() { + // 初始化工具提示 + $('[data-bs-toggle="tooltip"]').tooltip(); + + // 初始化弹出框 + $('[data-bs-toggle="popover"]').popover(); + + // 表格排序 + initializeTableSorting(); + + // 数字格式化 + formatNumbers(); +} + +/** + * 绑定事件 + */ +function bindEvents() { + // 表格行点击事件 + $('.table tbody tr').on('click', function() { + $(this).toggleClass('table-active'); + }); + + // 筛选表单自动提交 + $('.auto-submit').on('change', function() { + $(this).closest('form').submit(); + }); + + // 复制股票代码 + $('.stock-code').on('click', function() { + const stockCode = $(this).text().trim(); + copyToClipboard(stockCode); + showToast('股票代码已复制: ' + stockCode); + }); + + // 全选/取消全选 + $('#selectAll').on('change', function() { + $('.row-select').prop('checked', this.checked); + }); + + // 导出数据 + $('.export-btn').on('click', function() { + const format = $(this).data('format'); + exportData(format); + }); +} + +/** + * 设置自动刷新 + */ +function setupAutoRefresh() { + // 每5分钟自动刷新数据 + setInterval(function() { + if (document.visibilityState === 'visible') { + refreshData(); + } + }, 300000); // 5分钟 + + // 页面可见时刷新 + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') { + const lastRefresh = localStorage.getItem('lastRefresh'); + const now = Date.now(); + + // 如果超过5分钟未刷新,则自动刷新 + if (!lastRefresh || (now - parseInt(lastRefresh)) > 300000) { + refreshData(); + } + } + }); +} + +/** + * 刷新数据 + */ +function refreshData() { + // 显示加载状态 + showLoading(); + + // 记录刷新时间 + localStorage.setItem('lastRefresh', Date.now().toString()); + + // 刷新页面数据 + if (typeof updatePageData === 'function') { + updatePageData(); + } else { + // 默认重新加载页面 + setTimeout(() => { + location.reload(); + }, 1000); + } +} + +/** + * 显示加载状态 + */ +function showLoading() { + const loadingHtml = '
'; + $('body').append(loadingHtml); + + setTimeout(() => { + $('.loading-overlay').remove(); + }, 2000); +} + +/** + * 显示提示消息 + */ +function showToast(message, type = 'success') { + const toastHtml = ` + + `; + + // 创建toast容器(如果不存在) + if (!$('.toast-container').length) { + $('body').append('
'); + } + + const $toast = $(toastHtml); + $('.toast-container').append($toast); + + // 显示toast + const toast = new bootstrap.Toast($toast[0]); + toast.show(); + + // 自动移除 + $toast.on('hidden.bs.toast', function() { + $(this).remove(); + }); +} + +/** + * 复制到剪贴板 + */ +function copyToClipboard(text) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + } else { + // 备用方法 + const textArea = document.createElement('textarea'); + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } +} + +/** + * 初始化表格排序 + */ +function initializeTableSorting() { + $('.sortable th').on('click', function() { + const table = $(this).closest('table'); + const column = $(this).index(); + const order = $(this).hasClass('asc') ? 'desc' : 'asc'; + + // 移除其他列的排序样式 + $(this).siblings().removeClass('asc desc'); + + // 添加当前列的排序样式 + $(this).removeClass('asc desc').addClass(order); + + // 排序表格行 + sortTable(table, column, order); + }); +} + +/** + * 表格排序 + */ +function sortTable(table, column, order) { + const tbody = table.find('tbody'); + const rows = tbody.find('tr').toArray(); + + rows.sort(function(a, b) { + const aVal = $(a).find('td').eq(column).text().trim(); + const bVal = $(b).find('td').eq(column).text().trim(); + + // 尝试数字比较 + if (!isNaN(aVal) && !isNaN(bVal)) { + return order === 'asc' ? aVal - bVal : bVal - aVal; + } + + // 字符串比较 + return order === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + }); + + tbody.empty().append(rows); +} + +/** + * 数字格式化 + */ +function formatNumbers() { + $('.format-number').each(function() { + const value = parseFloat($(this).text()); + if (!isNaN(value)) { + $(this).text(value.toLocaleString()); + } + }); + + $('.format-percentage').each(function() { + const value = parseFloat($(this).text()); + if (!isNaN(value)) { + $(this).text(value.toFixed(2) + '%'); + } + }); + + $('.format-currency').each(function() { + const value = parseFloat($(this).text()); + if (!isNaN(value)) { + $(this).text('¥' + value.toFixed(2)); + } + }); +} + +/** + * 导出数据 + */ +function exportData(format) { + const table = $('.table').first(); + if (!table.length) { + showToast('没有可导出的数据', 'warning'); + return; + } + + switch (format) { + case 'csv': + exportToCSV(table); + break; + case 'excel': + exportToExcel(table); + break; + case 'json': + exportToJSON(table); + break; + default: + showToast('不支持的导出格式', 'error'); + } +} + +/** + * 导出为CSV + */ +function exportToCSV(table) { + let csv = ''; + + // 表头 + table.find('thead tr').each(function() { + const row = []; + $(this).find('th').each(function() { + row.push('"' + $(this).text().trim() + '"'); + }); + csv += row.join(',') + '\n'; + }); + + // 数据行 + table.find('tbody tr').each(function() { + const row = []; + $(this).find('td').each(function() { + row.push('"' + $(this).text().trim() + '"'); + }); + csv += row.join(',') + '\n'; + }); + + // 下载文件 + downloadFile(csv, 'trading_signals.csv', 'text/csv'); +} + +/** + * 下载文件 + */ +function downloadFile(content, filename, contentType) { + const blob = new Blob([content], { type: contentType }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); +} + +/** + * 获取API数据 + */ +function apiRequest(endpoint, params = {}) { + return $.ajax({ + url: '/api/' + endpoint, + method: 'GET', + data: params, + dataType: 'json' + }); +} + +/** + * 实时更新信号数据 + */ +function updateSignals() { + apiRequest('signals', { limit: 10 }) + .done(function(response) { + if (response.success && response.data.length > 0) { + updateSignalTable(response.data); + showToast('信号数据已更新'); + } + }) + .fail(function() { + showToast('更新信号数据失败', 'error'); + }); +} + +/** + * 更新信号表格 + */ +function updateSignalTable(signals) { + const tbody = $('.signals-table tbody'); + tbody.empty(); + + signals.forEach(function(signal) { + const row = ` + + ${signal.stock_code} + ${signal.stock_name || '未知'} + ${signal.strategy_name} + ${signal.timeframe} + ${signal.signal_date} + ${signal.breakout_price.toFixed(2)}元 + ${signal.yin_high.toFixed(2)}元 + ${signal.breakout_pct.toFixed(2)}% + ${signal.final_yang_entity_ratio.toFixed(2)}% + ${signal.turnover_ratio.toFixed(2)}% + + `; + tbody.append(row); + }); +} + +// 全局错误处理 +window.addEventListener('error', function(e) { + console.error('JavaScript Error:', e.error); + showToast('页面发生错误,请刷新重试', 'error'); +}); + +// 网络状态监控 +window.addEventListener('online', function() { + showToast('网络连接已恢复'); +}); + +window.addEventListener('offline', function() { + showToast('网络连接已断开', 'warning'); +}); \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..87a6314 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,74 @@ + + + + + + {% block title %}A股量化交易系统{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

+ A股量化交易系统 | + 基于Python + Flask + SQLite +

+
+
+ + + + + + + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/web/templates/error.html b/web/templates/error.html new file mode 100644 index 0000000..04e6164 --- /dev/null +++ b/web/templates/error.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}错误 - A股量化交易系统{% endblock %} + +{% block content %} +
+
+
+
+
+ 系统错误 +
+
+
+ +

抱歉,系统遇到了一个错误

+

{{ error }}

+ +
+ + 返回首页 + + +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..01a1b34 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} + +{% block title %}首页 - A股量化交易系统{% endblock %} + +{% block content %} + +
+
+
+
+

+ 交易信号概览 +

+

实时监控量化策略筛选结果

+
+
+
+ 最后更新 +
+
{{ current_time }}
+
+
+
+
+ + +
+ {% for stat in strategy_stats %} +
+
+
+
+
{{ stat.strategy_name }}
+
{{ stat.total_signals or 0 }}
+
+ {{ stat.unique_stocks or 0 }} 只股票 +
+
+
+ +
+
+
+
+ {% endfor %} + + + {% if not strategy_stats %} +
+
+
+
+
K线形态策略
+
0
+
+ 0 只股票 +
+
+
+ +
+
+
+
+ {% endif %} +
+ + +
+
+
+
+
+ 最新交易信号 +
+ + 查看全部 + +
+
+ {% if signals %} +
+
+ + + + + + + + + + + + + + + {% for signal in signals %} + + + + + + + + + + + {% endfor %} + +
股票策略周期信号日期突破价格阴线高点突破幅度实体比例
+
+ {{ signal.stock_code }} + {{ signal.stock_name or '未知' }} +
+
+ {{ signal.strategy_name }} + + + {{ 'D' if signal.timeframe == 'daily' else ('1H' if signal.timeframe == '1h' else 'W') }} + + {{ signal.signal_date | datetime_format('%m-%d') }}{{ signal.breakout_price | currency }}元{{ signal.yin_high | currency }}元 + + {{ signal.breakout_pct | percentage }} + + {{ signal.final_yang_entity_ratio | percentage }}
+
+
+ {% else %} +
+ +

暂无交易信号

+

系统将在检测到符合条件的K线形态时显示信号

+
+ {% endif %} +
+
+
+
+ + +{% if pullback_alerts %} +
+
+
+
+
+ 最近回踩提醒 +
+
+
+
+ + + + + + + + + + + + + + {% for alert in pullback_alerts[:5] %} + + + + + + + + + + {% endfor %} + +
股票代码股票名称回踩日期当前价格阴线高点回调幅度距离高点
{{ alert.stock_code }}{{ alert.stock_name }}{{ alert.pullback_date | datetime_format('%Y-%m-%d') }}{{ alert.current_price | currency }}元{{ alert.yin_high | currency }}元 + {{ alert.pullback_pct | percentage }} + {{ alert.distance_to_yin_high | percentage }}
+
+ +
+
+
+
+{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/web/templates/pullbacks.html b/web/templates/pullbacks.html new file mode 100644 index 0000000..9c1fa1a --- /dev/null +++ b/web/templates/pullbacks.html @@ -0,0 +1,220 @@ +{% extends "base.html" %} + +{% block title %}回踩监控 - A股量化交易系统{% endblock %} + +{% block content %} + +
+
+
+
+

+ 回踩监控 +

+

监控K线突破后的价格回踩情况

+
+ + +
+ + + +
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
回踩监控说明
+

+ 回踩监控功能会自动监控已触发的"两阳+阴+阳"突破信号,当价格回踩到阴线最高点附近时会发送特殊提醒。 + 这有助于识别关键支撑位的有效性,为交易决策提供参考。 +

+
+
+
+
+
+
+ + +
+
+
+
+
+ 回踩提醒记录 + {{ pullback_alerts|length }} 条记录 +
+
+
+ {% if pullback_alerts %} +
+
+ + + + + + + + + + + + + + + + + + + {% for alert in pullback_alerts %} + + + + + + + + + + + + + + + {% endfor %} + +
股票周期信号日期回踩日期天数间隔突破价格阴线高点当前价格当日最低回调幅度距离高点提醒状态
+
+ {{ alert.stock_code }} + {{ alert.stock_name or '未知' }} +
+
+ + {{ 'D' if alert.timeframe == 'daily' else ('1H' if alert.timeframe == '1h' else 'W') }} + + {{ alert.original_signal_date | datetime_format('%Y-%m-%d') }}{{ alert.pullback_date | datetime_format('%Y-%m-%d') }} + {{ alert.days_since_signal }}天 + {{ alert.original_breakout_price | currency }}元{{ alert.yin_high | currency }}元{{ alert.current_price | currency }}元{{ alert.current_low | currency }}元 + + {{ alert.pullback_pct | percentage }} + + + + {{ alert.distance_to_yin_high | percentage }} + + + {% if alert.alert_sent %} + + 已发送 + +
{{ alert.alert_sent_time | datetime_format('%m-%d %H:%M') }}
+ {% else %} + 未发送 + {% endif %} +
+
+
+ + +
+
+
+
{{ pullback_alerts | selectattr('distance_to_yin_high', 'lt', -5) | list | length }}
+
跌破阴线高点
+
+
+
+
+
{{ pullback_alerts | selectattr('distance_to_yin_high', 'ge', -5) | selectattr('distance_to_yin_high', 'lt', 2) | list | length }}
+
接近阴线高点
+
+
+
+
+
{{ pullback_alerts | selectattr('distance_to_yin_high', 'ge', 2) | list | length }}
+
上方支撑有效
+
+
+
+
+
{{ pullback_alerts | selectattr('alert_sent', 'equalto', true) | list | length }}
+
已发送提醒
+
+
+
+ + {% else %} +
+ +

暂无回踩提醒

+

最近{{ days }}天内没有检测到价格回踩阴线最高点的情况

+
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
+ 风险提示 +
+
+
+
    +
  • 回踩阴线最高点可能表明原有突破信号的有效性受到质疑
  • +
  • 跌破阴线最高点通常需要重新评估形态的技术有效性
  • +
  • 建议结合成交量、其他技术指标和基本面情况综合判断
  • +
  • 本系统仅供参考,投资需谨慎,风险需自担
  • +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/web/templates/signals.html b/web/templates/signals.html new file mode 100644 index 0000000..c2b4893 --- /dev/null +++ b/web/templates/signals.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} + +{% block title %}交易信号 - A股量化交易系统{% endblock %} + +{% block content %} + +
+
+
+
+

+ 交易信号列表 +

+

详细的股票筛选信号数据

+
+ + +
+ + + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+ 详细信号数据 +
+
+
+ {% if signals_grouped %} +
+ {% for scan_date, signals in signals_grouped.items() %} + +
+
+ 扫描日期: {{ scan_date }} + {{ signals|length }} 条信号 +
+
+ + +
+ + + + + + + + + + + + + + + + + {% for signal in signals %} + + + + + + + + + + + + + {% endfor %} + +
股票策略周期信号日期突破价格阴线高点突破幅度实体比例换手率扫描时间
+
+ {{ signal.stock_code }} + {{ signal.stock_name or '未知' }} +
+
+ {{ signal.strategy_name }} + + + {{ 'D' if signal.timeframe == 'daily' else ('1H' if signal.timeframe == '1h' else 'W') }} + + {{ signal.signal_date | datetime_format('%Y-%m-%d') }}{{ signal.breakout_price | currency }}元{{ signal.yin_high | currency }}元 + + {{ signal.breakout_pct | percentage }} + + {{ signal.final_yang_entity_ratio | percentage }}{{ signal.turnover_ratio | percentage }}{{ signal.scan_time | datetime_format('%m-%d %H:%M') }}
+
+ {% endfor %} +
+ + + {% if total_pages > 1 %} + + {% endif %} + + {% else %} +
+ +

暂无信号数据

+

请尝试调整筛选条件或稍后再试

+
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file