aupdate
This commit is contained in:
parent
77ecaefbc2
commit
adcc8844b1
188
README_DATABASE.md
Normal file
188
README_DATABASE.md
Normal 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)
|
||||||
|
- [ ] 添加用户认证和权限管理
|
||||||
|
- [ ] 实现策略回测结果存储
|
||||||
|
- [ ] 添加图表可视化功能
|
||||||
|
- [ ] 支持策略参数在线调整
|
||||||
|
- [ ] 实现数据导入导出功能
|
||||||
@ -56,10 +56,14 @@ strategy:
|
|||||||
enabled: true # 是否启用K线形态策略
|
enabled: true # 是否启用K线形态策略
|
||||||
min_entity_ratio: 0.55 # 前两根阳线实体最小占振幅比例(55%)
|
min_entity_ratio: 0.55 # 前两根阳线实体最小占振幅比例(55%)
|
||||||
final_yang_min_ratio: 0.40 # 最后阳线实体最小占振幅比例(40%)
|
final_yang_min_ratio: 0.40 # 最后阳线实体最小占振幅比例(40%)
|
||||||
timeframes: ["daily", "weekly"] # 支持的时间周期
|
timeframes: ["1h", "daily", "weekly"] # 支持的时间周期
|
||||||
scan_stocks_count: 1000 # 扫描股票数量限制
|
scan_stocks_count: 5000 # 扫描股票数量限制
|
||||||
analysis_days: 60 # 分析的历史天数
|
analysis_days: 60 # 分析的历史天数
|
||||||
|
|
||||||
|
# 回踩监控配置
|
||||||
|
pullback_tolerance: 0.02 # 回踩容忍度(2%),价格接近阴线最高点的阈值
|
||||||
|
monitor_days: 30 # 监控回踩的天数(信号触发后30天内监控)
|
||||||
|
|
||||||
# 监控配置
|
# 监控配置
|
||||||
monitor:
|
monitor:
|
||||||
# 实时监控
|
# 实时监控
|
||||||
|
|||||||
BIN
data/trading.db
Normal file
BIN
data/trading.db
Normal file
Binary file not shown.
104
generate_test_data.py
Normal file
104
generate_test_data.py
Normal 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()
|
||||||
@ -42,3 +42,6 @@ flake8>=6.0.0
|
|||||||
# Jupyter notebook support
|
# Jupyter notebook support
|
||||||
jupyter>=1.0.0
|
jupyter>=1.0.0
|
||||||
ipykernel>=6.25.0
|
ipykernel>=6.25.0
|
||||||
|
|
||||||
|
# Web framework
|
||||||
|
flask>=2.3.0
|
||||||
@ -17,6 +17,15 @@ class ADataFetcher:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""初始化数据获取器"""
|
"""初始化数据获取器"""
|
||||||
self.client = adata
|
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客户端初始化完成")
|
logger.info("AData客户端初始化完成")
|
||||||
|
|
||||||
def get_stock_list(self, market: str = "A") -> pd.DataFrame:
|
def get_stock_list(self, market: str = "A") -> pd.DataFrame:
|
||||||
@ -37,6 +46,259 @@ class ADataFetcher:
|
|||||||
logger.error(f"获取股票列表失败: {e}")
|
logger.error(f"获取股票列表失败: {e}")
|
||||||
return pd.DataFrame()
|
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:
|
def get_realtime_data(self, stock_codes: Union[str, List[str]]) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
获取实时行情数据
|
获取实时行情数据
|
||||||
@ -72,7 +334,7 @@ class ADataFetcher:
|
|||||||
stock_code: 股票代码
|
stock_code: 股票代码
|
||||||
start_date: 开始日期
|
start_date: 开始日期
|
||||||
end_date: 结束日期
|
end_date: 结束日期
|
||||||
period: 数据周期 ('daily', 'weekly', 'monthly')
|
period: 数据周期 ('1h', 'daily', 'weekly', 'monthly')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
历史行情DataFrame
|
历史行情DataFrame
|
||||||
@ -86,6 +348,7 @@ class ADataFetcher:
|
|||||||
|
|
||||||
# 根据周期设置k_type参数
|
# 根据周期设置k_type参数
|
||||||
k_type_map = {
|
k_type_map = {
|
||||||
|
'1h': 60, # 1小时线(60分钟)
|
||||||
'daily': 1, # 日线
|
'daily': 1, # 日线
|
||||||
'weekly': 2, # 周线
|
'weekly': 2, # 周线
|
||||||
'monthly': 3 # 月线
|
'monthly': 3 # 月线
|
||||||
@ -355,9 +618,52 @@ class ADataFetcher:
|
|||||||
# 返回空DataFrame作为后备
|
# 返回空DataFrame作为后备
|
||||||
return pd.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:
|
def get_stock_name(self, stock_code: str) -> str:
|
||||||
"""
|
"""
|
||||||
获取股票中文名称
|
获取股票中文名称(带缓存机制)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stock_code: 股票代码
|
stock_code: 股票代码
|
||||||
@ -366,24 +672,20 @@ class ADataFetcher:
|
|||||||
股票中文名称,如果获取失败返回股票代码
|
股票中文名称,如果获取失败返回股票代码
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 尝试从热股数据中获取名称
|
# 更新缓存(如果需要)
|
||||||
hot_stocks = self.get_hot_stocks_ths(limit=100)
|
self._update_stock_name_cache()
|
||||||
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']
|
|
||||||
|
|
||||||
# 尝试从东财数据中获取名称
|
# 从缓存中查找
|
||||||
east_stocks = self.get_popular_stocks_east(limit=100)
|
if stock_code in self._stock_name_cache:
|
||||||
if not east_stocks.empty and 'stock_code' in east_stocks.columns and 'short_name' in east_stocks.columns:
|
return self._stock_name_cache[stock_code]
|
||||||
match = east_stocks[east_stocks['stock_code'] == stock_code]
|
|
||||||
if not match.empty:
|
|
||||||
return match.iloc[0]['short_name']
|
|
||||||
|
|
||||||
# 尝试搜索功能
|
# 缓存中没有,尝试搜索功能
|
||||||
search_results = self.search_stocks(stock_code)
|
search_results = self.search_stocks(stock_code)
|
||||||
if not search_results.empty and 'short_name' in search_results.columns:
|
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}的中文名称")
|
logger.debug(f"未能获取{stock_code}的中文名称")
|
||||||
|
|||||||
1
src/database/__init__.py
Normal file
1
src/database/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# 数据库模块
|
||||||
500
src/database/database_manager.py
Normal file
500
src/database/database_manager.py
Normal 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
139
src/database/schema.sql
Normal 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;
|
||||||
@ -11,12 +11,14 @@ from loguru import logger
|
|||||||
|
|
||||||
from ..data.data_fetcher import ADataFetcher
|
from ..data.data_fetcher import ADataFetcher
|
||||||
from ..utils.notification import NotificationManager
|
from ..utils.notification import NotificationManager
|
||||||
|
from ..database.database_manager import DatabaseManager
|
||||||
|
|
||||||
|
|
||||||
class KLinePatternStrategy:
|
class KLinePatternStrategy:
|
||||||
"""K线形态策略类"""
|
"""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线形态策略
|
初始化K线形态策略
|
||||||
|
|
||||||
@ -24,18 +26,37 @@ class KLinePatternStrategy:
|
|||||||
data_fetcher: 数据获取器
|
data_fetcher: 数据获取器
|
||||||
notification_manager: 通知管理器
|
notification_manager: 通知管理器
|
||||||
config: 策略配置
|
config: 策略配置
|
||||||
|
db_manager: 数据库管理器
|
||||||
"""
|
"""
|
||||||
self.data_fetcher = data_fetcher
|
self.data_fetcher = data_fetcher
|
||||||
self.notification_manager = notification_manager
|
self.notification_manager = notification_manager
|
||||||
self.config = config
|
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.min_entity_ratio = config.get('min_entity_ratio', 0.55) # 前两根阳线实体部分最小比例
|
||||||
self.final_yang_min_ratio = config.get('final_yang_min_ratio', 0.40) # 最后阳线实体部分最小比例
|
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.max_turnover_ratio = config.get('max_turnover_ratio', 40.0) # 最后阳线最大换手率(%)
|
||||||
self.timeframes = config.get('timeframes', ['daily', 'weekly']) # 支持的时间周期
|
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:
|
def calculate_kline_features(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
@ -187,7 +208,8 @@ class KLinePatternStrategy:
|
|||||||
|
|
||||||
return signals
|
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线形态
|
分析单只股票的K线形态
|
||||||
|
|
||||||
@ -206,11 +228,18 @@ class KLinePatternStrategy:
|
|||||||
stock_name = self.data_fetcher.get_stock_name(stock_code)
|
stock_name = self.data_fetcher.get_stock_name(stock_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 计算开始日期
|
# 计算开始日期,针对不同周期调整时间范围
|
||||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
for timeframe in self.timeframes:
|
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}")
|
logger.info(f"🔍 分析股票: {stock_code}({stock_name}) | 周期: {timeframe}")
|
||||||
|
|
||||||
# 获取历史数据 - 直接使用adata的原生周期支持
|
# 获取历史数据 - 直接使用adata的原生周期支持
|
||||||
@ -233,6 +262,22 @@ class KLinePatternStrategy:
|
|||||||
signal['stock_name'] = stock_name
|
signal['stock_name'] = stock_name
|
||||||
signal['timeframe'] = timeframe
|
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
|
results[timeframe] = signals
|
||||||
|
|
||||||
# 美化信号统计日志
|
# 美化信号统计日志
|
||||||
@ -250,6 +295,201 @@ class KLinePatternStrategy:
|
|||||||
|
|
||||||
return results
|
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:
|
def _convert_to_weekly(self, daily_df: pd.DataFrame) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
将日线数据转换为周线数据
|
将日线数据转换为周线数据
|
||||||
@ -328,15 +568,16 @@ class KLinePatternStrategy:
|
|||||||
logger.error(f"转换月线数据失败: {e}")
|
logger.error(f"转换月线数据失败: {e}")
|
||||||
return pd.DataFrame()
|
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:
|
Args:
|
||||||
stock_list: 股票代码列表,如果为None则获取热门股票
|
stock_list: 股票代码列表,如果为None则自动选择股票池
|
||||||
max_stocks: 最大扫描股票数量
|
max_stocks: 最大扫描股票数量
|
||||||
use_hot_stocks: 是否使用热门股票数据,默认True
|
use_hot_stocks: 是否使用热门股票数据,默认True
|
||||||
use_combined_sources: 是否使用合并的双数据源(同花顺+东财),默认True
|
use_combined_sources: 是否使用合并的双数据源(同花顺+东财),默认True
|
||||||
|
use_all_a_shares: 是否使用所有A股股票(排除北交所和ST),优先级最高
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
所有股票的分析结果
|
所有股票的分析结果
|
||||||
@ -345,9 +586,48 @@ class KLinePatternStrategy:
|
|||||||
logger.info("🌍 开始市场K线形态扫描")
|
logger.info("🌍 开始市场K线形态扫描")
|
||||||
logger.info("🚀" + "="*70)
|
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 stock_list is None:
|
||||||
# 优先使用热门股票数据
|
# 优先级1: 使用所有A股股票
|
||||||
if use_hot_stocks:
|
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:
|
try:
|
||||||
if use_combined_sources:
|
if use_combined_sources:
|
||||||
# 使用合并的双数据源
|
# 使用合并的双数据源
|
||||||
@ -381,8 +661,8 @@ class KLinePatternStrategy:
|
|||||||
logger.error(f"获取热门股票失败: {e},回退到全市场股票")
|
logger.error(f"获取热门股票失败: {e},回退到全市场股票")
|
||||||
use_hot_stocks = False
|
use_hot_stocks = False
|
||||||
|
|
||||||
# 如果热股获取失败,使用全市场股票列表
|
# 优先级3: 如果热股获取失败,使用全市场股票列表
|
||||||
if not use_hot_stocks:
|
if not use_all_a_shares and not use_hot_stocks:
|
||||||
try:
|
try:
|
||||||
all_stocks = self.data_fetcher.get_stock_list()
|
all_stocks = self.data_fetcher.get_stock_list()
|
||||||
if not all_stocks.empty:
|
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})")
|
logger.info(f"⏳ 扫描进度: [{i+1:3d}/{len(stock_list):3d}] 🔍 {stock_code}({stock_name})")
|
||||||
|
|
||||||
try:
|
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())
|
stock_signal_count = sum(len(signals) for signals in stock_results.values())
|
||||||
@ -417,6 +697,17 @@ class KLinePatternStrategy:
|
|||||||
logger.error(f"扫描股票 {stock_code} 失败: {e}")
|
logger.error(f"扫描股票 {stock_code} 失败: {e}")
|
||||||
continue
|
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("🎉" + "="*70)
|
||||||
logger.info(f"🌍 市场K线形态扫描完成!")
|
logger.info(f"🌍 市场K线形态扫描完成!")
|
||||||
@ -424,6 +715,7 @@ class KLinePatternStrategy:
|
|||||||
logger.info(f" 🔍 总扫描股票: {len(stock_list)} 只")
|
logger.info(f" 🔍 总扫描股票: {len(stock_list)} 只")
|
||||||
logger.info(f" 🎯 发现信号: {total_signals} 个")
|
logger.info(f" 🎯 发现信号: {total_signals} 个")
|
||||||
logger.info(f" 📈 涉及股票: {len(results)} 只")
|
logger.info(f" 📈 涉及股票: {len(results)} 只")
|
||||||
|
logger.info(f" 💾 扫描会话ID: {session_id}")
|
||||||
|
|
||||||
if results:
|
if results:
|
||||||
logger.info(f"📋 信号详情:")
|
logger.info(f"📋 信号详情:")
|
||||||
@ -438,6 +730,12 @@ class KLinePatternStrategy:
|
|||||||
|
|
||||||
logger.info("🎉" + "="*70)
|
logger.info("🎉" + "="*70)
|
||||||
|
|
||||||
|
# 监控已触发信号的回踩情况
|
||||||
|
logger.info("🔍 开始监控已触发信号的回踩情况...")
|
||||||
|
pullback_alerts = self.monitor_pullback_for_triggered_signals()
|
||||||
|
if pullback_alerts:
|
||||||
|
logger.info(f"⚠️ 发现 {len(pullback_alerts)} 个回踩提醒")
|
||||||
|
|
||||||
# 发送汇总通知
|
# 发送汇总通知
|
||||||
if results:
|
if results:
|
||||||
# 判断数据源类型
|
# 判断数据源类型
|
||||||
@ -476,6 +774,13 @@ K线形态策略 - 两阳线+阴线+阳线突破
|
|||||||
6. 最后阳线换手率不高于 {self.max_turnover_ratio:.1f}%(流动性约束)
|
6. 最后阳线换手率不高于 {self.max_turnover_ratio:.1f}%(流动性约束)
|
||||||
7. 支持时间周期:{', '.join(self.timeframes)}
|
7. 支持时间周期:{', '.join(self.timeframes)}
|
||||||
|
|
||||||
|
回踩监控功能:
|
||||||
|
- 自动监控已触发信号后的价格走势
|
||||||
|
- 当价格回踩到阴线最高点附近时发送特殊提醒
|
||||||
|
- 回踩容忍度:{self.pullback_tolerance:.0%}
|
||||||
|
- 监控期限:信号触发后 {self.monitor_days} 天
|
||||||
|
- 提醒条件:价格接近阴线最高点且相比突破价有明显回调
|
||||||
|
|
||||||
信号触发条件:
|
信号触发条件:
|
||||||
- 形态完整匹配
|
- 形态完整匹配
|
||||||
- 实体比例达标
|
- 实体比例达标
|
||||||
@ -490,6 +795,7 @@ K线形态策略 - 两阳线+阴线+阳线突破
|
|||||||
|
|
||||||
通知方式:
|
通知方式:
|
||||||
- 钉钉webhook汇总推送(10个信号一组分批发送)
|
- 钉钉webhook汇总推送(10个信号一组分批发送)
|
||||||
|
- 价格回踩特殊提醒(5个提醒一组分批发送)
|
||||||
- 包含关键信息:代码、股票名称、K线时间、价格、周期等
|
- 包含关键信息:代码、股票名称、K线时间、价格、周期等
|
||||||
- 系统日志详细记录
|
- 系统日志详细记录
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -414,6 +414,89 @@ class NotificationManager:
|
|||||||
logger.error(f"发送策略汇总通知异常: {e}")
|
logger.error(f"发送策略汇总通知异常: {e}")
|
||||||
return False
|
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:
|
def send_test_message(self) -> bool:
|
||||||
"""发送测试消息"""
|
"""发送测试消息"""
|
||||||
if self.dingtalk_notifier:
|
if self.dingtalk_notifier:
|
||||||
|
|||||||
44
start_web.py
Normal file
44
start_web.py
Normal 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
93
test_all_a_shares.py
Normal 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()
|
||||||
97
test_cache_optimization.py
Normal file
97
test_cache_optimization.py
Normal 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()
|
||||||
256
test_database_integration.py
Normal file
256
test_database_integration.py
Normal 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
179
test_pullback_feature.py
Normal 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)
|
||||||
80
test_simple_integration.py
Normal file
80
test_simple_integration.py
Normal 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
226
web/app.py
Normal 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
537
web/static/css/style.css
Normal 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
376
web/static/js/main.js
Normal 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
74
web/templates/base.html
Normal 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
31
web/templates/error.html
Normal 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
207
web/templates/index.html
Normal 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 %}
|
||||||
220
web/templates/pullbacks.html
Normal file
220
web/templates/pullbacks.html
Normal 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
201
web/templates/signals.html
Normal 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 %}
|
||||||
Loading…
Reference in New Issue
Block a user