This commit is contained in:
aaron 2025-09-18 20:45:01 +08:00
parent 77ecaefbc2
commit adcc8844b1
25 changed files with 4282 additions and 31 deletions

188
README_DATABASE.md Normal file
View File

@ -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
- [ ] 添加用户认证和权限管理
- [ ] 实现策略回测结果存储
- [ ] 添加图表可视化功能
- [ ] 支持策略参数在线调整
- [ ] 实现数据导入导出功能

View File

@ -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:
# 实时监控

BIN
data/trading.db Normal file

Binary file not shown.

104
generate_test_data.py Normal file
View File

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

View File

@ -42,3 +42,6 @@ flake8>=6.0.0
# Jupyter notebook support
jupyter>=1.0.0
ipykernel>=6.25.0
# Web framework
flask>=2.3.0

View File

@ -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}的中文名称")

1
src/database/__init__.py Normal file
View File

@ -0,0 +1 @@
# 数据库模块

View File

@ -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("数据库管理器测试完成")

139
src/database/schema.sql Normal file
View File

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

View File

@ -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线时间价格周期等
- 系统日志详细记录
"""

View File

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

44
start_web.py Normal file
View File

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

93
test_all_a_shares.py Normal file
View File

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

View File

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

View File

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

179
test_pullback_feature.py Normal file
View File

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

View File

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

226
web/app.py Normal file
View File

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

537
web/static/css/style.css Normal file
View File

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

376
web/static/js/main.js Normal file
View File

@ -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 = '<div class="loading-overlay"><div class="loading"></div></div>';
$('body').append(loadingHtml);
setTimeout(() => {
$('.loading-overlay').remove();
}, 2000);
}
/**
* 显示提示消息
*/
function showToast(message, type = 'success') {
const toastHtml = `
<div class="toast align-items-center text-white bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
// 创建toast容器如果不存在
if (!$('.toast-container').length) {
$('body').append('<div class="toast-container position-fixed top-0 end-0 p-3"></div>');
}
const $toast = $(toastHtml);
$('.toast-container').append($toast);
// 显示toast
const toast = new bootstrap.Toast($toast[0]);
toast.show();
// 自动移除
$toast.on('hidden.bs.toast', function() {
$(this).remove();
});
}
/**
* 复制到剪贴板
*/
function copyToClipboard(text) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
// 备用方法
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
}
/**
* 初始化表格排序
*/
function initializeTableSorting() {
$('.sortable th').on('click', function() {
const table = $(this).closest('table');
const column = $(this).index();
const order = $(this).hasClass('asc') ? 'desc' : 'asc';
// 移除其他列的排序样式
$(this).siblings().removeClass('asc desc');
// 添加当前列的排序样式
$(this).removeClass('asc desc').addClass(order);
// 排序表格行
sortTable(table, column, order);
});
}
/**
* 表格排序
*/
function sortTable(table, column, order) {
const tbody = table.find('tbody');
const rows = tbody.find('tr').toArray();
rows.sort(function(a, b) {
const aVal = $(a).find('td').eq(column).text().trim();
const bVal = $(b).find('td').eq(column).text().trim();
// 尝试数字比较
if (!isNaN(aVal) && !isNaN(bVal)) {
return order === 'asc' ? aVal - bVal : bVal - aVal;
}
// 字符串比较
return order === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
tbody.empty().append(rows);
}
/**
* 数字格式化
*/
function formatNumbers() {
$('.format-number').each(function() {
const value = parseFloat($(this).text());
if (!isNaN(value)) {
$(this).text(value.toLocaleString());
}
});
$('.format-percentage').each(function() {
const value = parseFloat($(this).text());
if (!isNaN(value)) {
$(this).text(value.toFixed(2) + '%');
}
});
$('.format-currency').each(function() {
const value = parseFloat($(this).text());
if (!isNaN(value)) {
$(this).text('¥' + value.toFixed(2));
}
});
}
/**
* 导出数据
*/
function exportData(format) {
const table = $('.table').first();
if (!table.length) {
showToast('没有可导出的数据', 'warning');
return;
}
switch (format) {
case 'csv':
exportToCSV(table);
break;
case 'excel':
exportToExcel(table);
break;
case 'json':
exportToJSON(table);
break;
default:
showToast('不支持的导出格式', 'error');
}
}
/**
* 导出为CSV
*/
function exportToCSV(table) {
let csv = '';
// 表头
table.find('thead tr').each(function() {
const row = [];
$(this).find('th').each(function() {
row.push('"' + $(this).text().trim() + '"');
});
csv += row.join(',') + '\n';
});
// 数据行
table.find('tbody tr').each(function() {
const row = [];
$(this).find('td').each(function() {
row.push('"' + $(this).text().trim() + '"');
});
csv += row.join(',') + '\n';
});
// 下载文件
downloadFile(csv, 'trading_signals.csv', 'text/csv');
}
/**
* 下载文件
*/
function downloadFile(content, filename, contentType) {
const blob = new Blob([content], { type: contentType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
/**
* 获取API数据
*/
function apiRequest(endpoint, params = {}) {
return $.ajax({
url: '/api/' + endpoint,
method: 'GET',
data: params,
dataType: 'json'
});
}
/**
* 实时更新信号数据
*/
function updateSignals() {
apiRequest('signals', { limit: 10 })
.done(function(response) {
if (response.success && response.data.length > 0) {
updateSignalTable(response.data);
showToast('信号数据已更新');
}
})
.fail(function() {
showToast('更新信号数据失败', 'error');
});
}
/**
* 更新信号表格
*/
function updateSignalTable(signals) {
const tbody = $('.signals-table tbody');
tbody.empty();
signals.forEach(function(signal) {
const row = `
<tr>
<td><span class="badge bg-secondary">${signal.stock_code}</span></td>
<td class="fw-bold">${signal.stock_name || '未知'}</td>
<td><span class="badge bg-info">${signal.strategy_name}</span></td>
<td>${signal.timeframe}</td>
<td>${signal.signal_date}</td>
<td class="text-success fw-bold">${signal.breakout_price.toFixed(2)}</td>
<td>${signal.yin_high.toFixed(2)}</td>
<td><span class="badge bg-success">${signal.breakout_pct.toFixed(2)}%</span></td>
<td>${signal.final_yang_entity_ratio.toFixed(2)}%</td>
<td>${signal.turnover_ratio.toFixed(2)}%</td>
</tr>
`;
tbody.append(row);
});
}
// 全局错误处理
window.addEventListener('error', function(e) {
console.error('JavaScript Error:', e.error);
showToast('页面发生错误,请刷新重试', 'error');
});
// 网络状态监控
window.addEventListener('online', function() {
showToast('网络连接已恢复');
});
window.addEventListener('offline', function() {
showToast('网络连接已断开', 'warning');
});

74
web/templates/base.html Normal file
View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}A股量化交易系统{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- 自定义CSS -->
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="{{ url_for('signals') }}">
<i class="fas fa-chart-line me-2"></i>A股量化交易系统
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'signals' %}active{% endif %}"
href="{{ url_for('signals') }}">
<i class="fas fa-signal me-1"></i>交易信号
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<span class="navbar-text">
<i class="fas fa-clock me-1"></i>{{ current_time }}
</span>
</li>
</ul>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="container mt-4">
{% block content %}{% endblock %}
</main>
<!-- 页脚 -->
<footer class="mt-5 py-4">
<div class="container text-center">
<p class="text-muted mb-0">
<i class="fas fa-robot me-1"></i>A股量化交易系统 |
<i class="fas fa-code me-1"></i>基于Python + Flask + SQLite
</p>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- 自定义JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

31
web/templates/error.html Normal file
View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}错误 - A股量化交易系统{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">
<i class="fas fa-exclamation-circle me-2"></i>系统错误
</h5>
</div>
<div class="card-body text-center">
<i class="fas fa-bug fa-4x text-danger mb-3"></i>
<h4 class="text-danger">抱歉,系统遇到了一个错误</h4>
<p class="text-muted">{{ error }}</p>
<div class="mt-4">
<a href="{{ url_for('index') }}" class="btn btn-primary">
<i class="fas fa-home me-1"></i>返回首页
</a>
<button onclick="history.back()" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>返回上页
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

207
web/templates/index.html Normal file
View File

@ -0,0 +1,207 @@
{% extends "base.html" %}
{% block title %}首页 - A股量化交易系统{% endblock %}
{% block content %}
<!-- 页面标题 -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-1 text-primary fw-bold">
<i class="fas fa-chart-line me-2"></i>交易信号概览
</h1>
<p class="text-muted mb-0">实时监控量化策略筛选结果</p>
</div>
<div class="text-end">
<div class="text-muted small">
<i class="fas fa-clock me-1"></i>最后更新
</div>
<div class="fw-bold text-primary">{{ current_time }}</div>
</div>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="row mb-2">
{% for stat in strategy_stats %}
<div class="col-lg-3 col-md-6 mb-1">
<div class="stats-card hover-lift fade-in">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stats-label text-uppercase mb-1">{{ stat.strategy_name }}</div>
<div class="stats-value">{{ stat.total_signals or 0 }}</div>
<div class="stats-sublabel">
<i class="fas fa-building me-1"></i>{{ stat.unique_stocks or 0 }} 只股票
</div>
</div>
<div class="text-primary" style="opacity: 0.3;">
<i class="fas fa-signal fa-lg"></i>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- 如果没有统计数据,显示默认卡片 -->
{% if not strategy_stats %}
<div class="col-lg-3 col-md-6 mb-1">
<div class="stats-card hover-lift">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stats-label text-uppercase mb-1">K线形态策略</div>
<div class="stats-value">0</div>
<div class="stats-sublabel">
<i class="fas fa-building me-1"></i>0 只股票
</div>
</div>
<div class="text-primary" style="opacity: 0.3;">
<i class="fas fa-signal fa-lg"></i>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- 最新信号表格 -->
<div class="row">
<div class="col-12">
<div class="card fade-in">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0 text-primary fw-bold">
<i class="fas fa-list-ul me-2"></i>最新交易信号
</h5>
<a href="{{ url_for('signals') }}" class="btn btn-primary btn-sm">
<i class="fas fa-arrow-right me-1"></i>查看全部
</a>
</div>
<div class="card-body p-0">
{% if signals %}
<div class="table-container">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>股票</th>
<th>策略</th>
<th>周期</th>
<th>信号日期</th>
<th>突破价格</th>
<th>阴线高点</th>
<th>突破幅度</th>
<th>实体比例</th>
</tr>
</thead>
<tbody>
{% for signal in signals %}
<tr>
<td>
<div class="d-flex align-items-center">
<span class="badge badge-light-primary me-2">{{ signal.stock_code }}</span>
<span class="fw-bold text-truncate" style="max-width: 80px;">{{ signal.stock_name or '未知' }}</span>
</div>
</td>
<td>
<span class="badge badge-light-primary">{{ signal.strategy_name }}</span>
</td>
<td>
<span class="badge bg-{% if signal.timeframe == 'daily' %}primary{% elif signal.timeframe == '1h' %}info{% else %}success{% endif %}">
{{ 'D' if signal.timeframe == 'daily' else ('1H' if signal.timeframe == '1h' else 'W') }}
</span>
</td>
<td class="text-muted">{{ signal.signal_date | datetime_format('%m-%d') }}</td>
<td class="text-success fw-bold">{{ signal.breakout_price | currency }}元</td>
<td class="text-muted">{{ signal.yin_high | currency }}元</td>
<td>
<span class="badge bg-{% if signal.breakout_pct and signal.breakout_pct > 3 %}success{% elif signal.breakout_pct and signal.breakout_pct > 1 %}warning{% else %}secondary{% endif %}">
{{ signal.breakout_pct | percentage }}
</span>
</td>
<td class="text-muted">{{ signal.final_yang_entity_ratio | percentage }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-chart-bar"></i>
<h4>暂无交易信号</h4>
<p>系统将在检测到符合条件的K线形态时显示信号</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 回踩提醒 -->
{% if pullback_alerts %}
<div class="row mt-4">
<div class="col-12">
<div class="card border-warning">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>最近回踩提醒
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>股票代码</th>
<th>股票名称</th>
<th>回踩日期</th>
<th>当前价格</th>
<th>阴线高点</th>
<th>回调幅度</th>
<th>距离高点</th>
</tr>
</thead>
<tbody>
{% for alert in pullback_alerts[:5] %}
<tr>
<td><span class="badge bg-secondary">{{ alert.stock_code }}</span></td>
<td>{{ alert.stock_name }}</td>
<td>{{ alert.pullback_date | datetime_format('%Y-%m-%d') }}</td>
<td class="text-danger">{{ alert.current_price | currency }}元</td>
<td>{{ alert.yin_high | currency }}元</td>
<td>
<span class="badge bg-danger">{{ alert.pullback_pct | percentage }}</span>
</td>
<td>{{ alert.distance_to_yin_high | percentage }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="text-end">
<a href="{{ url_for('pullbacks') }}" class="btn btn-warning btn-sm">
<i class="fas fa-eye me-1"></i>查看全部回踩提醒
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// 自动刷新功能
function autoRefresh() {
setTimeout(function() {
location.reload();
}, 300000); // 5分钟自动刷新
}
$(document).ready(function() {
autoRefresh();
});
</script>
{% endblock %}

View File

@ -0,0 +1,220 @@
{% extends "base.html" %}
{% block title %}回踩监控 - A股量化交易系统{% endblock %}
{% block content %}
<!-- 页面标题和筛选 -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-1 text-primary fw-bold">
<i class="fas fa-exclamation-triangle me-2"></i>回踩监控
</h1>
<p class="text-muted mb-0">监控K线突破后的价格回踩情况</p>
</div>
<!-- 筛选表单 -->
<form method="GET" class="d-flex gap-2 align-items-center">
<select name="days" class="form-select form-select-sm">
<option value="7" {% if days == 7 %}selected{% endif %}>最近7天</option>
<option value="15" {% if days == 15 %}selected{% endif %}>最近15天</option>
<option value="30" {% if days == 30 %}selected{% endif %}>最近30天</option>
<option value="60" {% if days == 60 %}selected{% endif %}>最近60天</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fas fa-filter me-1"></i>筛选
</button>
</form>
</div>
</div>
</div>
<!-- 说明卡片 -->
<div class="row mb-4">
<div class="col-12">
<div class="card fade-in">
<div class="card-body">
<div class="d-flex align-items-start">
<div class="text-primary me-3 mt-1">
<i class="fas fa-info-circle fa-lg"></i>
</div>
<div>
<h6 class="text-primary fw-bold mb-2">回踩监控说明</h6>
<p class="text-muted mb-0">
回踩监控功能会自动监控已触发的"两阳+阴+阳"突破信号,当价格回踩到阴线最高点附近时会发送特殊提醒。
这有助于识别关键支撑位的有效性,为交易决策提供参考。
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 回踩提醒表格 -->
<div class="row">
<div class="col-12">
<div class="card fade-in">
<div class="card-header">
<h5 class="mb-0 text-primary fw-bold">
<i class="fas fa-list-ul me-2"></i>回踩提醒记录
<span class="badge badge-light-primary ms-2">{{ pullback_alerts|length }} 条记录</span>
</h5>
</div>
<div class="card-body p-0">
{% if pullback_alerts %}
<div class="table-container">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>股票</th>
<th>周期</th>
<th>信号日期</th>
<th>回踩日期</th>
<th>天数间隔</th>
<th>突破价格</th>
<th>阴线高点</th>
<th>当前价格</th>
<th>当日最低</th>
<th>回调幅度</th>
<th>距离高点</th>
<th>提醒状态</th>
</tr>
</thead>
<tbody>
{% for alert in pullback_alerts %}
<tr>
<td>
<div class="d-flex align-items-center">
<span class="badge badge-light-primary me-2">{{ alert.stock_code }}</span>
<span class="fw-bold text-truncate" style="max-width: 80px;">{{ alert.stock_name or '未知' }}</span>
</div>
</td>
<td>
<span class="badge bg-{% if alert.timeframe == 'daily' %}primary{% elif alert.timeframe == '1h' %}info{% else %}success{% endif %}">
{{ 'D' if alert.timeframe == 'daily' else ('1H' if alert.timeframe == '1h' else 'W') }}
</span>
</td>
<td class="text-muted">{{ alert.original_signal_date | datetime_format('%Y-%m-%d') }}</td>
<td class="fw-bold">{{ alert.pullback_date | datetime_format('%Y-%m-%d') }}</td>
<td>
<span class="badge bg-info">{{ alert.days_since_signal }}天</span>
</td>
<td class="text-success">{{ alert.original_breakout_price | currency }}元</td>
<td class="text-warning fw-bold">{{ alert.yin_high | currency }}元</td>
<td class="text-danger">{{ alert.current_price | currency }}元</td>
<td class="text-danger">{{ alert.current_low | currency }}元</td>
<td>
<span class="badge bg-{% if alert.pullback_pct and alert.pullback_pct < -10 %}danger{% elif alert.pullback_pct and alert.pullback_pct < -5 %}warning{% else %}secondary{% endif %}">
{{ alert.pullback_pct | percentage }}
</span>
</td>
<td>
<span class="badge bg-{% if alert.distance_to_yin_high and alert.distance_to_yin_high < -5 %}danger{% elif alert.distance_to_yin_high and alert.distance_to_yin_high < 2 %}warning{% else %}success{% endif %}">
{{ alert.distance_to_yin_high | percentage }}
</span>
</td>
<td>
{% if alert.alert_sent %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>已发送
</span>
<div class="text-muted small mt-1">{{ alert.alert_sent_time | datetime_format('%m-%d %H:%M') }}</div>
{% else %}
<span class="badge bg-secondary">未发送</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 统计信息 -->
<div class="row mt-4 px-3 pb-3">
<div class="col-md-3 mb-3">
<div class="stats-card text-center hover-lift">
<div class="stats-value text-danger">{{ pullback_alerts | selectattr('distance_to_yin_high', 'lt', -5) | list | length }}</div>
<div class="stats-label">跌破阴线高点</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stats-card text-center hover-lift">
<div class="stats-value text-warning">{{ pullback_alerts | selectattr('distance_to_yin_high', 'ge', -5) | selectattr('distance_to_yin_high', 'lt', 2) | list | length }}</div>
<div class="stats-label">接近阴线高点</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stats-card text-center hover-lift">
<div class="stats-value text-success">{{ pullback_alerts | selectattr('distance_to_yin_high', 'ge', 2) | list | length }}</div>
<div class="stats-label">上方支撑有效</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stats-card text-center hover-lift">
<div class="stats-value text-info">{{ pullback_alerts | selectattr('alert_sent', 'equalto', true) | list | length }}</div>
<div class="stats-label">已发送提醒</div>
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-shield-alt"></i>
<h4>暂无回踩提醒</h4>
<p>最近{{ days }}天内没有检测到价格回踩阴线最高点的情况</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 风险提示 -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h6 class="mb-0 fw-bold">
<i class="fas fa-exclamation-triangle me-2"></i>风险提示
</h6>
</div>
<div class="card-body">
<ul class="mb-0 text-muted">
<li>回踩阴线最高点可能表明原有突破信号的有效性受到质疑</li>
<li>跌破阴线最高点通常需要重新评估形态的技术有效性</li>
<li>建议结合成交量、其他技术指标和基本面情况综合判断</li>
<li>本系统仅供参考,投资需谨慎,风险需自担</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// 表格行点击高亮
$('tbody tr').on('click', function() {
$(this).toggleClass('table-active');
});
// 根据风险等级添加颜色标识
$('tbody tr').each(function() {
const distanceCell = $(this).find('td:nth-last-child(2)');
const distance = parseFloat(distanceCell.text());
if (distance < -5) {
$(this).addClass('table-danger');
} else if (distance < 2) {
$(this).addClass('table-warning');
}
});
});
</script>
{% endblock %}

201
web/templates/signals.html Normal file
View File

@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}交易信号 - A股量化交易系统{% endblock %}
{% block content %}
<!-- 页面标题和筛选 -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-1 text-primary fw-bold">
<i class="fas fa-signal me-2"></i>交易信号列表
</h1>
<p class="text-muted mb-0">详细的股票筛选信号数据</p>
</div>
<!-- 筛选表单 -->
<form method="GET" class="d-flex gap-2 align-items-center">
<select name="strategy" class="form-select form-select-sm">
<option value="">所有策略</option>
<option value="K线形态策略" {% if strategy_name == 'K线形态策略' %}selected{% endif %}>K线形态策略</option>
</select>
<select name="timeframe" class="form-select form-select-sm">
<option value="">所有周期</option>
<option value="daily" {% if timeframe == 'daily' %}selected{% endif %}>日线</option>
<option value="weekly" {% if timeframe == 'weekly' %}selected{% endif %}>周线</option>
<option value="1h" {% if timeframe == '1h' %}selected{% endif %}>1小时</option>
</select>
<select name="days" class="form-select form-select-sm">
<option value="7" {% if days == 7 %}selected{% endif %}>最近7天</option>
<option value="15" {% if days == 15 %}selected{% endif %}>最近15天</option>
<option value="30" {% if days == 30 %}selected{% endif %}>最近30天</option>
<option value="90" {% if days == 90 %}selected{% endif %}>最近90天</option>
</select>
<select name="per_page" class="form-select form-select-sm">
<option value="20" {% if per_page == 20 %}selected{% endif %}>每页20条</option>
<option value="50" {% if per_page == 50 %}selected{% endif %}>每页50条</option>
<option value="100" {% if per_page == 100 %}selected{% endif %}>每页100条</option>
</select>
<button type="submit" class="btn btn-primary btn-sm text-nowrap">
<i class="fas fa-filter me-1"></i>筛选
</button>
</form>
</div>
</div>
</div>
<!-- 信号表格 -->
<div class="row">
<div class="col-12">
<div class="card fade-in">
<div class="card-header">
<h5 class="mb-0 text-primary fw-bold">
<i class="fas fa-table me-2"></i>详细信号数据
</h5>
</div>
<div class="card-body p-0">
{% if signals_grouped %}
<div class="table-container">
{% for scan_date, signals in signals_grouped.items() %}
<!-- 扫描日期分组标题 -->
<div class="scan-date-header bg-light px-3 py-2 border-bottom">
<h6 class="mb-0 text-primary fw-bold">
<i class="fas fa-calendar-day me-2"></i>扫描日期: {{ scan_date }}
<span class="badge bg-primary ms-2">{{ signals|length }} 条信号</span>
</h6>
</div>
<!-- 该扫描日期下的信号表格 -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>股票</th>
<th>策略</th>
<th>周期</th>
<th>信号日期</th>
<th>突破价格</th>
<th>阴线高点</th>
<th>突破幅度</th>
<th>实体比例</th>
<th>换手率</th>
<th>扫描时间</th>
</tr>
</thead>
<tbody>
{% for signal in signals %}
<tr>
<td>
<div class="d-flex align-items-center">
<span class="badge badge-light-primary me-2">{{ signal.stock_code }}</span>
<span class="fw-bold text-truncate" style="max-width: 80px;">{{ signal.stock_name or '未知' }}</span>
</div>
</td>
<td>
<span class="badge badge-light-primary">{{ signal.strategy_name }}</span>
</td>
<td>
<span class="badge bg-{% if signal.timeframe == 'daily' %}primary{% elif signal.timeframe == '1h' %}info{% else %}success{% endif %}">
{{ 'D' if signal.timeframe == 'daily' else ('1H' if signal.timeframe == '1h' else 'W') }}
</span>
</td>
<td class="text-muted">{{ signal.signal_date | datetime_format('%Y-%m-%d') }}</td>
<td class="text-success fw-bold">{{ signal.breakout_price | currency }}元</td>
<td class="text-muted">{{ signal.yin_high | currency }}元</td>
<td>
<span class="badge bg-{% if signal.breakout_pct and signal.breakout_pct > 3 %}success{% elif signal.breakout_pct and signal.breakout_pct > 1 %}warning{% else %}secondary{% endif %}">
{{ signal.breakout_pct | percentage }}
</span>
</td>
<td class="text-muted">{{ signal.final_yang_entity_ratio | percentage }}</td>
<td class="text-muted">{{ signal.turnover_ratio | percentage }}</td>
<td class="text-muted small">{{ signal.scan_time | datetime_format('%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
<!-- 分页 -->
{% if total_pages > 1 %}
<nav aria-label="信号分页" class="mt-4">
<ul class="pagination justify-content-center">
<!-- 上一页 -->
<li class="page-item {% if not has_prev %}disabled{% endif %}">
<a class="page-link" href="{% if has_prev %}{{ url_for('signals', page=current_page-1, strategy=strategy_name, timeframe=timeframe, days=days, per_page=per_page) }}{% else %}#{% endif %}">
<i class="fas fa-chevron-left"></i> 上一页
</a>
</li>
<!-- 页码 -->
{% set start_page = [1, current_page - 2]|max %}
{% set end_page = [total_pages, current_page + 2]|min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('signals', page=1, strategy=strategy_name, timeframe=timeframe, days=days, per_page=per_page) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
<li class="page-item {% if page_num == current_page %}active{% endif %}">
<a class="page-link" href="{{ url_for('signals', page=page_num, strategy=strategy_name, timeframe=timeframe, days=days, per_page=per_page) }}">{{ page_num }}</a>
</li>
{% endfor %}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ url_for('signals', page=total_pages, strategy=strategy_name, timeframe=timeframe, days=days, per_page=per_page) }}">{{ total_pages }}</a>
</li>
{% endif %}
<!-- 下一页 -->
<li class="page-item {% if not has_next %}disabled{% endif %}">
<a class="page-link" href="{% if has_next %}{{ url_for('signals', page=current_page+1, strategy=strategy_name, timeframe=timeframe, days=days, per_page=per_page) }}{% else %}#{% endif %}">
下一页 <i class="fas fa-chevron-right"></i>
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<i class="fas fa-signal"></i>
<h4>暂无信号数据</h4>
<p>请尝试调整筛选条件或稍后再试</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// 表格行点击高亮
$('tbody tr').on('click', function() {
$(this).toggleClass('table-active');
});
// 工具提示
$('[data-bs-toggle="tooltip"]').tooltip();
});
</script>
{% endblock %}