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 0000000..9f9194f Binary files /dev/null and b/data/trading.db differ 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 = ` +实时监控量化策略筛选结果
+| 股票 | +策略 | +周期 | +信号日期 | +突破价格 | +阴线高点 | +突破幅度 | +实体比例 | +
|---|---|---|---|---|---|---|---|
|
+
+ {{ 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 }} | +
系统将在检测到符合条件的K线形态时显示信号
+| 股票代码 | +股票名称 | +回踩日期 | +当前价格 | +阴线高点 | +回调幅度 | +距离高点 | +
|---|---|---|---|---|---|---|
| {{ 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 }} | +
监控K线突破后的价格回踩情况
++ 回踩监控功能会自动监控已触发的"两阳+阴+阳"突破信号,当价格回踩到阴线最高点附近时会发送特殊提醒。 + 这有助于识别关键支撑位的有效性,为交易决策提供参考。 +
+| 股票 | +周期 | +信号日期 | +回踩日期 | +天数间隔 | +突破价格 | +阴线高点 | +当前价格 | +当日最低 | +回调幅度 | +距离高点 | +提醒状态 | +
|---|---|---|---|---|---|---|---|---|---|---|---|
|
+
+ {{ 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 }} + + | ++ + | +
+ {% if alert.alert_sent %}
+
+ 已发送
+
+ {{ alert.alert_sent_time | datetime_format('%m-%d %H:%M') }}
+ {% else %}
+ 未发送
+ {% endif %}
+ |
+
最近{{ days }}天内没有检测到价格回踩阴线最高点的情况
+详细的股票筛选信号数据
+| 股票 | +策略 | +周期 | +信号日期 | +突破价格 | +阴线高点 | +突破幅度 | +实体比例 | +换手率 | +扫描时间 | +
|---|---|---|---|---|---|---|---|---|---|
|
+
+ {{ 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') }} | +
请尝试调整筛选条件或稍后再试
+