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线形态策略
|
||||
min_entity_ratio: 0.55 # 前两根阳线实体最小占振幅比例(55%)
|
||||
final_yang_min_ratio: 0.40 # 最后阳线实体最小占振幅比例(40%)
|
||||
timeframes: ["daily", "weekly"] # 支持的时间周期
|
||||
scan_stocks_count: 1000 # 扫描股票数量限制
|
||||
timeframes: ["1h", "daily", "weekly"] # 支持的时间周期
|
||||
scan_stocks_count: 5000 # 扫描股票数量限制
|
||||
analysis_days: 60 # 分析的历史天数
|
||||
|
||||
# 回踩监控配置
|
||||
pullback_tolerance: 0.02 # 回踩容忍度(2%),价格接近阴线最高点的阈值
|
||||
monitor_days: 30 # 监控回踩的天数(信号触发后30天内监控)
|
||||
|
||||
# 监控配置
|
||||
monitor:
|
||||
# 实时监控
|
||||
|
||||
BIN
data/trading.db
Normal file
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()
|
||||
@ -41,4 +41,7 @@ flake8>=6.0.0
|
||||
|
||||
# Jupyter notebook support
|
||||
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):
|
||||
"""初始化数据获取器"""
|
||||
self.client = adata
|
||||
|
||||
# 股票名称缓存机制
|
||||
self._stock_name_cache = {}
|
||||
self._stock_list_cache = None
|
||||
self._hot_stocks_cache = None
|
||||
self._east_stocks_cache = None
|
||||
self._cache_timestamp = None
|
||||
self._cache_duration = 3600 # 缓存1小时
|
||||
|
||||
logger.info("AData客户端初始化完成")
|
||||
|
||||
def get_stock_list(self, market: str = "A") -> pd.DataFrame:
|
||||
@ -37,6 +46,259 @@ class ADataFetcher:
|
||||
logger.error(f"获取股票列表失败: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def get_filtered_a_share_list(self, exclude_st: bool = True, exclude_bj: bool = True, min_market_cap: float = 2000000000) -> pd.DataFrame:
|
||||
"""
|
||||
获取过滤后的A股股票列表
|
||||
|
||||
Args:
|
||||
exclude_st: 是否排除ST股票
|
||||
exclude_bj: 是否排除北交所股票
|
||||
min_market_cap: 最小市值要求(元),默认20亿
|
||||
|
||||
Returns:
|
||||
过滤后的股票列表DataFrame
|
||||
"""
|
||||
try:
|
||||
# 获取完整股票列表
|
||||
all_stocks = self.get_stock_list()
|
||||
|
||||
if all_stocks.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
filtered_stocks = all_stocks.copy()
|
||||
original_count = len(filtered_stocks)
|
||||
|
||||
# 排除北交所股票
|
||||
if exclude_bj:
|
||||
before_count = len(filtered_stocks)
|
||||
filtered_stocks = filtered_stocks[filtered_stocks['exchange'] != 'BJ']
|
||||
bj_excluded = before_count - len(filtered_stocks)
|
||||
logger.info(f"排除北交所股票: {bj_excluded}只")
|
||||
|
||||
# 排除ST股票(包含ST、*ST、PT、退市等)
|
||||
if exclude_st:
|
||||
before_count = len(filtered_stocks)
|
||||
# 排除包含ST、*ST、PT、退等字符的股票
|
||||
st_pattern = r'(\*?ST|PT|退|暂停)'
|
||||
filtered_stocks = filtered_stocks[~filtered_stocks['short_name'].str.contains(st_pattern, na=False, case=False)]
|
||||
st_excluded = before_count - len(filtered_stocks)
|
||||
logger.info(f"排除ST等风险股票: {st_excluded}只")
|
||||
|
||||
# 基于实际市值的筛选
|
||||
if min_market_cap > 0:
|
||||
before_count = len(filtered_stocks)
|
||||
filtered_stocks = self._filter_by_real_market_cap(filtered_stocks, min_market_cap)
|
||||
cap_excluded = before_count - len(filtered_stocks)
|
||||
logger.info(f"排除小市值股票(基于实际市值): {cap_excluded}只")
|
||||
|
||||
# 统计最终结果
|
||||
final_count = len(filtered_stocks)
|
||||
excluded_count = original_count - final_count
|
||||
|
||||
# 添加完整股票代码(带交易所后缀)
|
||||
if not filtered_stocks.empty and 'exchange' in filtered_stocks.columns:
|
||||
filtered_stocks['full_stock_code'] = filtered_stocks.apply(
|
||||
lambda row: f"{row['stock_code']}.{row['exchange']}", axis=1
|
||||
)
|
||||
|
||||
exchange_counts = filtered_stocks['exchange'].value_counts().to_dict()
|
||||
exchange_detail = " | ".join([f"{k}: {v}只" for k, v in exchange_counts.items()])
|
||||
logger.info(f"✅ 获取过滤后A股列表成功")
|
||||
logger.info(f"📊 原始股票: {original_count}只 | 过滤后: {final_count}只 | 排除: {excluded_count}只")
|
||||
logger.info(f"📈 交易所分布: {exchange_detail}")
|
||||
|
||||
return filtered_stocks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取过滤A股列表失败: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def _filter_by_real_market_cap(self, stock_df: pd.DataFrame, min_market_cap: float) -> pd.DataFrame:
|
||||
"""
|
||||
基于实际市值筛选股票
|
||||
由于API限制,先使用启发式规则预筛选,再对部分股票进行实际市值验证
|
||||
|
||||
Args:
|
||||
stock_df: 股票列表DataFrame
|
||||
min_market_cap: 最小市值要求(元)
|
||||
|
||||
Returns:
|
||||
过滤后的股票DataFrame
|
||||
"""
|
||||
if stock_df.empty:
|
||||
return stock_df
|
||||
|
||||
logger.info(f"开始基于市值筛选股票,阈值: {min_market_cap/100000000:.0f}亿元")
|
||||
|
||||
# 步骤1: 使用启发式规则进行预筛选,减少API调用量
|
||||
logger.info("步骤1: 使用启发式规则预筛选...")
|
||||
pre_filtered = self._filter_by_market_cap_proxy(stock_df, min_market_cap)
|
||||
|
||||
if pre_filtered.empty:
|
||||
logger.warning("启发式预筛选后无股票,返回空结果")
|
||||
return pd.DataFrame()
|
||||
|
||||
logger.info(f"启发式预筛选完成: {len(pre_filtered)}/{len(stock_df)} 只股票")
|
||||
|
||||
# 步骤2: 对预筛选的股票进行小批量实际市值验证
|
||||
logger.info("步骤2: 对预筛选股票进行实际市值验证...")
|
||||
|
||||
# 限制验证数量以避免API超时
|
||||
max_verify_count = min(500, len(pre_filtered)) # 最多验证500只
|
||||
stocks_to_verify = pre_filtered.head(max_verify_count)
|
||||
|
||||
logger.info(f"将验证 {len(stocks_to_verify)} 只股票的实际市值")
|
||||
|
||||
valid_stocks = []
|
||||
total_to_verify = len(stocks_to_verify)
|
||||
|
||||
for idx, (_, stock) in enumerate(stocks_to_verify.iterrows()):
|
||||
stock_code = stock['stock_code']
|
||||
exchange = stock['exchange']
|
||||
full_stock_code = f"{stock_code}.{exchange}"
|
||||
|
||||
try:
|
||||
# 获取股本信息
|
||||
shares_info = self.client.stock.info.get_stock_shares(stock_code=stock_code)
|
||||
|
||||
if not shares_info.empty and 'total_share' in shares_info.columns:
|
||||
total_shares = shares_info.iloc[0]['total_share']
|
||||
|
||||
# 获取当前股价
|
||||
current_price = None
|
||||
try:
|
||||
market_data = self.client.stock.market.get_market(full_stock_code)
|
||||
if not market_data.empty and 'close' in market_data.columns:
|
||||
current_price = market_data.iloc[0]['close']
|
||||
except:
|
||||
pass
|
||||
|
||||
# 计算市值
|
||||
if current_price is not None and total_shares > 0:
|
||||
market_cap = total_shares * 10000 * current_price # 万股转换为股
|
||||
|
||||
if market_cap >= min_market_cap:
|
||||
stock_with_cap = stock.copy()
|
||||
stock_with_cap['market_cap'] = market_cap
|
||||
stock_with_cap['total_shares'] = total_shares
|
||||
stock_with_cap['current_price'] = current_price
|
||||
valid_stocks.append(stock_with_cap)
|
||||
|
||||
logger.debug(f"{full_stock_code}: 市值{market_cap/100000000:.1f}亿元 {'✓' if market_cap >= min_market_cap else '✗'}")
|
||||
else:
|
||||
# 如果无法获取实际市值,且预筛选通过,则保留
|
||||
valid_stocks.append(stock)
|
||||
logger.debug(f"{full_stock_code}: 无市值数据,保留预筛选结果")
|
||||
|
||||
except Exception as e:
|
||||
# 如果API调用失败,且预筛选通过,则保留
|
||||
valid_stocks.append(stock)
|
||||
logger.debug(f"{full_stock_code}: API失败,保留预筛选结果: {e}")
|
||||
|
||||
# 显示进度
|
||||
if (idx + 1) % 50 == 0 or idx + 1 == total_to_verify:
|
||||
logger.info(f"市值验证进度: {idx + 1}/{total_to_verify} ({(idx + 1)/total_to_verify*100:.1f}%)")
|
||||
|
||||
# 添加延时以避免API限制
|
||||
if idx % 10 == 9: # 每10个请求休息0.1秒
|
||||
time.sleep(0.1)
|
||||
|
||||
# 步骤3: 对于剩余未验证的股票,直接使用预筛选结果
|
||||
if len(pre_filtered) > max_verify_count:
|
||||
remaining_stocks = pre_filtered.iloc[max_verify_count:].copy()
|
||||
for _, stock in remaining_stocks.iterrows():
|
||||
valid_stocks.append(stock)
|
||||
|
||||
logger.info(f"保留 {len(remaining_stocks)} 只未验证股票(基于预筛选结果)")
|
||||
|
||||
# 转换为DataFrame
|
||||
if valid_stocks:
|
||||
result_df = pd.DataFrame(valid_stocks)
|
||||
|
||||
# 确保没有重复
|
||||
if 'stock_code' in result_df.columns:
|
||||
result_df = result_df.drop_duplicates(subset=['stock_code'], keep='first')
|
||||
|
||||
logger.info(f"✅ 市值筛选完成: {len(result_df)}/{len(stock_df)} 只股票符合要求")
|
||||
|
||||
# 统计实际验证vs预筛选的结果
|
||||
verified_count = min(max_verify_count, len(stocks_to_verify))
|
||||
unverified_count = len(result_df) - verified_count
|
||||
logger.info(f"📊 验证详情: 实际验证{verified_count}只, 预筛选保留{unverified_count}只")
|
||||
|
||||
return result_df
|
||||
else:
|
||||
logger.warning("⚠️ 没有股票通过市值筛选")
|
||||
return pd.DataFrame()
|
||||
|
||||
def _filter_by_market_cap_proxy(self, stock_df: pd.DataFrame, min_market_cap: float) -> pd.DataFrame:
|
||||
"""
|
||||
基于股票代码的启发式规则筛选大市值股票
|
||||
|
||||
Args:
|
||||
stock_df: 股票列表DataFrame
|
||||
min_market_cap: 最小市值要求(元)
|
||||
|
||||
Returns:
|
||||
过滤后的股票DataFrame
|
||||
"""
|
||||
if stock_df.empty:
|
||||
return stock_df
|
||||
|
||||
# 由于无法直接获取市值数据,使用启发式规则进行筛选
|
||||
# 注意:这只是一个近似筛选,真实的市值筛选需要实际的市值数据
|
||||
|
||||
def is_likely_large_cap(stock_code: str, exchange: str) -> bool:
|
||||
"""判断股票是否可能是大市值股票"""
|
||||
code_num = stock_code
|
||||
|
||||
if exchange == 'SH': # 上交所
|
||||
# 主板: 600xxx, 601xxx, 603xxx, 605xxx (通常市值较大)
|
||||
if code_num.startswith(('600', '601', '603', '605')):
|
||||
return True
|
||||
# 科创板: 688xxx (新兴科技公司,市值相对较大)
|
||||
elif code_num.startswith('688'):
|
||||
return True
|
||||
# 其他上交所股票
|
||||
return False
|
||||
|
||||
elif exchange == 'SZ': # 深交所
|
||||
# 主板: 000xxx, 001xxx (老牌蓝筹,通常市值较大)
|
||||
if code_num.startswith(('000', '001')):
|
||||
return True
|
||||
# 中小板: 002xxx (部分有大市值公司)
|
||||
elif code_num.startswith('002'):
|
||||
# 002开头的前1000只股票(002000-002999),上市较早,可能市值较大
|
||||
try:
|
||||
code_suffix = int(code_num[3:])
|
||||
return code_suffix <= 999 # 002000-002999
|
||||
except:
|
||||
return False
|
||||
# 创业板: 300xxx, 301xxx (部分成长为大市值)
|
||||
elif code_num.startswith(('300', '301')):
|
||||
# 300开头的前500只股票,上市较早,部分已成长为大市值
|
||||
try:
|
||||
if code_num.startswith('300'):
|
||||
code_suffix = int(code_num[3:])
|
||||
return code_suffix <= 499 # 300000-300499
|
||||
else: # 301xxx较新,市值相对较小
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
return False
|
||||
|
||||
return False # 其他情况默认排除
|
||||
|
||||
# 应用筛选规则
|
||||
if min_market_cap >= 2000000000: # 20亿以上
|
||||
logger.info(f"应用大市值筛选规则(≥{min_market_cap/100000000:.0f}亿元)")
|
||||
mask = stock_df.apply(lambda row: is_likely_large_cap(row['stock_code'], row['exchange']), axis=1)
|
||||
return stock_df[mask]
|
||||
else:
|
||||
# 小于20亿的筛选条件暂时不实施严格筛选
|
||||
logger.info(f"市值筛选阈值较低({min_market_cap/100000000:.1f}亿元),保留所有股票")
|
||||
return stock_df
|
||||
|
||||
def get_realtime_data(self, stock_codes: Union[str, List[str]]) -> pd.DataFrame:
|
||||
"""
|
||||
获取实时行情数据
|
||||
@ -72,7 +334,7 @@ class ADataFetcher:
|
||||
stock_code: 股票代码
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
period: 数据周期 ('daily', 'weekly', 'monthly')
|
||||
period: 数据周期 ('1h', 'daily', 'weekly', 'monthly')
|
||||
|
||||
Returns:
|
||||
历史行情DataFrame
|
||||
@ -86,6 +348,7 @@ class ADataFetcher:
|
||||
|
||||
# 根据周期设置k_type参数
|
||||
k_type_map = {
|
||||
'1h': 60, # 1小时线(60分钟)
|
||||
'daily': 1, # 日线
|
||||
'weekly': 2, # 周线
|
||||
'monthly': 3 # 月线
|
||||
@ -355,9 +618,52 @@ class ADataFetcher:
|
||||
# 返回空DataFrame作为后备
|
||||
return pd.DataFrame()
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""检查缓存是否有效"""
|
||||
if self._cache_timestamp is None:
|
||||
return False
|
||||
|
||||
import time
|
||||
return (time.time() - self._cache_timestamp) < self._cache_duration
|
||||
|
||||
def _update_stock_name_cache(self):
|
||||
"""更新股票名称缓存"""
|
||||
try:
|
||||
import time
|
||||
|
||||
# 检查缓存是否有效
|
||||
if self._is_cache_valid():
|
||||
return
|
||||
|
||||
logger.info("🔄 更新股票名称缓存...")
|
||||
|
||||
# 获取热门股票数据并缓存
|
||||
self._hot_stocks_cache = self.get_hot_stocks_ths(limit=100)
|
||||
self._east_stocks_cache = self.get_popular_stocks_east(limit=100)
|
||||
|
||||
# 清空名称缓存并重新构建
|
||||
self._stock_name_cache.clear()
|
||||
|
||||
# 从热门股票数据中构建缓存
|
||||
for df, source in [(self._hot_stocks_cache, '同花顺'), (self._east_stocks_cache, '东财')]:
|
||||
if not df.empty and 'stock_code' in df.columns and 'short_name' in df.columns:
|
||||
for _, row in df.iterrows():
|
||||
stock_code = row['stock_code']
|
||||
stock_name = row['short_name']
|
||||
if stock_code not in self._stock_name_cache:
|
||||
self._stock_name_cache[stock_code] = stock_name
|
||||
|
||||
# 更新缓存时间戳
|
||||
self._cache_timestamp = time.time()
|
||||
|
||||
logger.info(f"✅ 股票名称缓存更新完成,共缓存 {len(self._stock_name_cache)} 只股票")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"更新股票名称缓存失败: {e}")
|
||||
|
||||
def get_stock_name(self, stock_code: str) -> str:
|
||||
"""
|
||||
获取股票中文名称
|
||||
获取股票中文名称(带缓存机制)
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
@ -366,24 +672,20 @@ class ADataFetcher:
|
||||
股票中文名称,如果获取失败返回股票代码
|
||||
"""
|
||||
try:
|
||||
# 尝试从热股数据中获取名称
|
||||
hot_stocks = self.get_hot_stocks_ths(limit=100)
|
||||
if not hot_stocks.empty and 'stock_code' in hot_stocks.columns and 'short_name' in hot_stocks.columns:
|
||||
match = hot_stocks[hot_stocks['stock_code'] == stock_code]
|
||||
if not match.empty:
|
||||
return match.iloc[0]['short_name']
|
||||
# 更新缓存(如果需要)
|
||||
self._update_stock_name_cache()
|
||||
|
||||
# 尝试从东财数据中获取名称
|
||||
east_stocks = self.get_popular_stocks_east(limit=100)
|
||||
if not east_stocks.empty and 'stock_code' in east_stocks.columns and 'short_name' in east_stocks.columns:
|
||||
match = east_stocks[east_stocks['stock_code'] == stock_code]
|
||||
if not match.empty:
|
||||
return match.iloc[0]['short_name']
|
||||
# 从缓存中查找
|
||||
if stock_code in self._stock_name_cache:
|
||||
return self._stock_name_cache[stock_code]
|
||||
|
||||
# 尝试搜索功能
|
||||
# 缓存中没有,尝试搜索功能
|
||||
search_results = self.search_stocks(stock_code)
|
||||
if not search_results.empty and 'short_name' in search_results.columns:
|
||||
return search_results.iloc[0]['short_name']
|
||||
stock_name = search_results.iloc[0]['short_name']
|
||||
# 添加到缓存
|
||||
self._stock_name_cache[stock_code] = stock_name
|
||||
return stock_name
|
||||
|
||||
# 如果都失败,返回股票代码
|
||||
logger.debug(f"未能获取{stock_code}的中文名称")
|
||||
|
||||
1
src/database/__init__.py
Normal file
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 ..utils.notification import NotificationManager
|
||||
from ..database.database_manager import DatabaseManager
|
||||
|
||||
|
||||
class KLinePatternStrategy:
|
||||
"""K线形态策略类"""
|
||||
|
||||
def __init__(self, data_fetcher: ADataFetcher, notification_manager: NotificationManager, config: Dict[str, Any]):
|
||||
def __init__(self, data_fetcher: ADataFetcher, notification_manager: NotificationManager,
|
||||
config: Dict[str, Any], db_manager: DatabaseManager = None):
|
||||
"""
|
||||
初始化K线形态策略
|
||||
|
||||
@ -24,18 +26,37 @@ class KLinePatternStrategy:
|
||||
data_fetcher: 数据获取器
|
||||
notification_manager: 通知管理器
|
||||
config: 策略配置
|
||||
db_manager: 数据库管理器
|
||||
"""
|
||||
self.data_fetcher = data_fetcher
|
||||
self.notification_manager = notification_manager
|
||||
self.config = config
|
||||
self.db_manager = db_manager or DatabaseManager()
|
||||
|
||||
# 策略参数
|
||||
self.strategy_name = "K线形态策略"
|
||||
self.min_entity_ratio = config.get('min_entity_ratio', 0.55) # 前两根阳线实体部分最小比例
|
||||
self.final_yang_min_ratio = config.get('final_yang_min_ratio', 0.40) # 最后阳线实体部分最小比例
|
||||
self.max_turnover_ratio = config.get('max_turnover_ratio', 40.0) # 最后阳线最大换手率(%)
|
||||
self.timeframes = config.get('timeframes', ['daily', 'weekly']) # 支持的时间周期
|
||||
|
||||
logger.info("K线形态策略初始化完成")
|
||||
# 回踩监控参数
|
||||
self.pullback_tolerance = config.get('pullback_tolerance', 0.02) # 回踩容忍度(2%)
|
||||
self.monitor_days = config.get('monitor_days', 30) # 监控回踩的天数
|
||||
|
||||
# 存储已触发的信号,用于监控回踩
|
||||
# 格式: {stock_code: {'signals': [signal_dict], 'last_check_date': date}}
|
||||
self.triggered_signals = {}
|
||||
|
||||
# 确保策略在数据库中存在
|
||||
self.strategy_id = self.db_manager.create_or_update_strategy(
|
||||
strategy_name=self.strategy_name,
|
||||
strategy_type="kline_pattern",
|
||||
description="两阳线+阴线+阳线突破形态识别策略",
|
||||
config=config
|
||||
)
|
||||
|
||||
logger.info(f"K线形态策略初始化完成 (策略ID: {self.strategy_id})")
|
||||
|
||||
def calculate_kline_features(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
@ -187,7 +208,8 @@ class KLinePatternStrategy:
|
||||
|
||||
return signals
|
||||
|
||||
def analyze_stock(self, stock_code: str, stock_name: str = None, days: int = 60) -> Dict[str, List[Dict[str, Any]]]:
|
||||
def analyze_stock(self, stock_code: str, stock_name: str = None, days: int = 60,
|
||||
session_id: Optional[int] = None) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
分析单只股票的K线形态
|
||||
|
||||
@ -206,11 +228,18 @@ class KLinePatternStrategy:
|
||||
stock_name = self.data_fetcher.get_stock_name(stock_code)
|
||||
|
||||
try:
|
||||
# 计算开始日期
|
||||
# 计算开始日期,针对不同周期调整时间范围
|
||||
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
for timeframe in self.timeframes:
|
||||
# 针对1小时周期调整分析天数,避免数据量过大
|
||||
if timeframe == '1h':
|
||||
# 1小时数据只分析最近7天
|
||||
analysis_days = min(days, 7)
|
||||
else:
|
||||
analysis_days = days
|
||||
|
||||
start_date = (datetime.now() - timedelta(days=analysis_days)).strftime('%Y-%m-%d')
|
||||
logger.info(f"🔍 分析股票: {stock_code}({stock_name}) | 周期: {timeframe}")
|
||||
|
||||
# 获取历史数据 - 直接使用adata的原生周期支持
|
||||
@ -233,6 +262,22 @@ class KLinePatternStrategy:
|
||||
signal['stock_name'] = stock_name
|
||||
signal['timeframe'] = timeframe
|
||||
|
||||
# 将信号添加到监控列表
|
||||
self.add_triggered_signal(signal)
|
||||
|
||||
# 保存信号到数据库(如果提供了session_id)
|
||||
if session_id is not None:
|
||||
try:
|
||||
signal_id = self.db_manager.save_stock_signal(
|
||||
session_id=session_id,
|
||||
strategy_id=self.strategy_id,
|
||||
signal=signal
|
||||
)
|
||||
signal['signal_id'] = signal_id
|
||||
logger.debug(f"信号已保存到数据库: {stock_code} (ID: {signal_id})")
|
||||
except Exception as e:
|
||||
logger.error(f"保存信号到数据库失败: {e}")
|
||||
|
||||
results[timeframe] = signals
|
||||
|
||||
# 美化信号统计日志
|
||||
@ -250,6 +295,201 @@ class KLinePatternStrategy:
|
||||
|
||||
return results
|
||||
|
||||
def check_pullback_signals(self, stock_code: str, current_data: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
检查已触发信号的价格回踩情况
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码
|
||||
current_data: 当前K线数据
|
||||
|
||||
Returns:
|
||||
回踩提醒信号列表
|
||||
"""
|
||||
pullback_alerts = []
|
||||
|
||||
if stock_code not in self.triggered_signals:
|
||||
return pullback_alerts
|
||||
|
||||
signals = self.triggered_signals[stock_code]['signals']
|
||||
current_date = datetime.now().date()
|
||||
|
||||
if current_data.empty:
|
||||
return pullback_alerts
|
||||
|
||||
# 获取最新价格
|
||||
latest_price = current_data.iloc[-1]['close']
|
||||
latest_low = current_data.iloc[-1]['low']
|
||||
latest_date = current_data.iloc[-1].get('trade_date', current_data.index[-1])
|
||||
|
||||
if isinstance(latest_date, str):
|
||||
latest_date = pd.to_datetime(latest_date).date()
|
||||
elif hasattr(latest_date, 'date'):
|
||||
latest_date = latest_date.date()
|
||||
|
||||
for signal in signals:
|
||||
# 检查信号是否在监控期内
|
||||
signal_date = signal['date']
|
||||
if isinstance(signal_date, str):
|
||||
signal_date = pd.to_datetime(signal_date).date()
|
||||
elif hasattr(signal_date, 'date'):
|
||||
signal_date = signal_date.date()
|
||||
|
||||
days_since_signal = (current_date - signal_date).days
|
||||
if days_since_signal > self.monitor_days:
|
||||
continue
|
||||
|
||||
yin_high = signal['yin_high'] # 阴线最高点
|
||||
breakout_price = signal['breakout_price'] # 突破时价格
|
||||
|
||||
# 检查是否发生回踩
|
||||
# 条件1: 最低价接近或跌破阴线最高点
|
||||
pullback_to_yin_high = latest_low <= (yin_high * (1 + self.pullback_tolerance))
|
||||
|
||||
# 条件2: 当前价格相比突破价格有明显回调
|
||||
significant_pullback = latest_price < (breakout_price * 0.95) # 回调超过5%
|
||||
|
||||
if pullback_to_yin_high and significant_pullback:
|
||||
# 检查是否已经发送过此类提醒(避免重复)
|
||||
alert_key = f"{stock_code}_{signal_date}_{latest_date}"
|
||||
if not hasattr(self, '_sent_pullback_alerts'):
|
||||
self._sent_pullback_alerts = set()
|
||||
|
||||
if alert_key not in self._sent_pullback_alerts:
|
||||
pullback_alert = {
|
||||
'stock_code': stock_code,
|
||||
'stock_name': signal.get('stock_name', ''),
|
||||
'signal_date': signal_date,
|
||||
'current_date': latest_date,
|
||||
'timeframe': signal.get('timeframe', 'daily'),
|
||||
'yin_high': yin_high,
|
||||
'breakout_price': breakout_price,
|
||||
'current_price': latest_price,
|
||||
'current_low': latest_low,
|
||||
'pullback_pct': ((latest_price - breakout_price) / breakout_price) * 100,
|
||||
'distance_to_yin_high': ((latest_low - yin_high) / yin_high) * 100,
|
||||
'days_since_signal': days_since_signal,
|
||||
'alert_type': 'pullback_to_yin_high'
|
||||
}
|
||||
|
||||
pullback_alerts.append(pullback_alert)
|
||||
self._sent_pullback_alerts.add(alert_key)
|
||||
|
||||
# 记录回踩提醒日志
|
||||
logger.warning("⚠️" + "="*60)
|
||||
logger.warning(f"📉 价格回踩阴线最高点提醒!")
|
||||
logger.warning(f"📅 原信号时间: {signal_date} | 当前时间: {latest_date}")
|
||||
logger.warning(f"🏷️ 股票: {stock_code}({signal.get('stock_name', '')})")
|
||||
logger.warning(f"📊 周期: {signal.get('timeframe', 'daily')}")
|
||||
logger.warning(f"💰 阴线最高点: {yin_high:.2f}元")
|
||||
logger.warning(f"🚀 当时突破价: {breakout_price:.2f}元")
|
||||
logger.warning(f"💸 当前价格: {latest_price:.2f}元 | 最低: {latest_low:.2f}元")
|
||||
logger.warning(f"📉 回调幅度: {pullback_alert['pullback_pct']:.2f}%")
|
||||
logger.warning(f"📏 距阴线高点: {pullback_alert['distance_to_yin_high']:.2f}%")
|
||||
logger.warning(f"⏰ 信号后经过: {days_since_signal}天")
|
||||
logger.warning("⚠️" + "="*60)
|
||||
|
||||
return pullback_alerts
|
||||
|
||||
def add_triggered_signal(self, signal: Dict[str, Any]):
|
||||
"""
|
||||
添加已触发的信号到监控列表
|
||||
|
||||
Args:
|
||||
signal: 信号字典
|
||||
"""
|
||||
stock_code = signal.get('stock_code')
|
||||
if not stock_code:
|
||||
return
|
||||
|
||||
if stock_code not in self.triggered_signals:
|
||||
self.triggered_signals[stock_code] = {
|
||||
'signals': [],
|
||||
'last_check_date': datetime.now().date()
|
||||
}
|
||||
|
||||
# 添加信号到监控列表
|
||||
self.triggered_signals[stock_code]['signals'].append(signal)
|
||||
|
||||
# 只保留最近的信号(避免内存占用过多)
|
||||
max_signals_per_stock = 10
|
||||
if len(self.triggered_signals[stock_code]['signals']) > max_signals_per_stock:
|
||||
# 按日期排序,保留最新的信号
|
||||
self.triggered_signals[stock_code]['signals'].sort(
|
||||
key=lambda x: pd.to_datetime(x['date']) if isinstance(x['date'], str) else x['date'],
|
||||
reverse=True
|
||||
)
|
||||
self.triggered_signals[stock_code]['signals'] = \
|
||||
self.triggered_signals[stock_code]['signals'][:max_signals_per_stock]
|
||||
|
||||
def monitor_pullback_for_triggered_signals(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
监控所有已触发信号的回踩情况
|
||||
|
||||
Returns:
|
||||
所有回踩提醒信号列表
|
||||
"""
|
||||
all_pullback_alerts = []
|
||||
current_date = datetime.now().date()
|
||||
|
||||
# 清理过期的信号
|
||||
stocks_to_remove = []
|
||||
for stock_code, signal_info in self.triggered_signals.items():
|
||||
# 过滤掉过期的信号
|
||||
valid_signals = []
|
||||
for signal in signal_info['signals']:
|
||||
signal_date = signal['date']
|
||||
if isinstance(signal_date, str):
|
||||
signal_date = pd.to_datetime(signal_date).date()
|
||||
elif hasattr(signal_date, 'date'):
|
||||
signal_date = signal_date.date()
|
||||
|
||||
days_since_signal = (current_date - signal_date).days
|
||||
if days_since_signal <= self.monitor_days:
|
||||
valid_signals.append(signal)
|
||||
|
||||
if valid_signals:
|
||||
self.triggered_signals[stock_code]['signals'] = valid_signals
|
||||
else:
|
||||
stocks_to_remove.append(stock_code)
|
||||
|
||||
# 移除没有有效信号的股票
|
||||
for stock_code in stocks_to_remove:
|
||||
del self.triggered_signals[stock_code]
|
||||
|
||||
logger.info(f"🔍 当前监控中的股票数量: {len(self.triggered_signals)}")
|
||||
|
||||
# 检查每只股票的回踩情况
|
||||
for stock_code in self.triggered_signals.keys():
|
||||
try:
|
||||
# 获取最近几天的数据
|
||||
end_date = current_date.strftime('%Y-%m-%d')
|
||||
start_date = (current_date - timedelta(days=5)).strftime('%Y-%m-%d')
|
||||
|
||||
current_data = self.data_fetcher.get_historical_data(
|
||||
stock_code, start_date, end_date, 'daily'
|
||||
)
|
||||
|
||||
if not current_data.empty:
|
||||
pullback_alerts = self.check_pullback_signals(stock_code, current_data)
|
||||
all_pullback_alerts.extend(pullback_alerts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"监控股票 {stock_code} 回踩情况失败: {e}")
|
||||
|
||||
# 发送回踩提醒通知
|
||||
if all_pullback_alerts:
|
||||
try:
|
||||
success = self.notification_manager.send_pullback_alerts(all_pullback_alerts)
|
||||
if success:
|
||||
logger.info(f"📱 回踩提醒通知发送完成,共{len(all_pullback_alerts)}个提醒")
|
||||
else:
|
||||
logger.warning(f"📱 回踩提醒通知发送失败")
|
||||
except Exception as e:
|
||||
logger.error(f"发送回踩提醒通知失败: {e}")
|
||||
|
||||
return all_pullback_alerts
|
||||
|
||||
def _convert_to_weekly(self, daily_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
将日线数据转换为周线数据
|
||||
@ -328,15 +568,16 @@ class KLinePatternStrategy:
|
||||
logger.error(f"转换月线数据失败: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
def scan_market(self, stock_list: List[str] = None, max_stocks: int = 100, use_hot_stocks: bool = True, use_combined_sources: bool = True) -> Dict[str, Dict[str, List[Dict[str, Any]]]]:
|
||||
def scan_market(self, stock_list: List[str] = None, max_stocks: int = 100, use_hot_stocks: bool = True, use_combined_sources: bool = True, use_all_a_shares: bool = False) -> Dict[str, Dict[str, List[Dict[str, Any]]]]:
|
||||
"""
|
||||
扫描市场中的股票形态
|
||||
|
||||
Args:
|
||||
stock_list: 股票代码列表,如果为None则获取热门股票
|
||||
stock_list: 股票代码列表,如果为None则自动选择股票池
|
||||
max_stocks: 最大扫描股票数量
|
||||
use_hot_stocks: 是否使用热门股票数据,默认True
|
||||
use_combined_sources: 是否使用合并的双数据源(同花顺+东财),默认True
|
||||
use_all_a_shares: 是否使用所有A股股票(排除北交所和ST),优先级最高
|
||||
|
||||
Returns:
|
||||
所有股票的分析结果
|
||||
@ -345,9 +586,48 @@ class KLinePatternStrategy:
|
||||
logger.info("🌍 开始市场K线形态扫描")
|
||||
logger.info("🚀" + "="*70)
|
||||
|
||||
# 创建扫描会话
|
||||
scan_config = {
|
||||
'max_stocks': max_stocks,
|
||||
'use_hot_stocks': use_hot_stocks,
|
||||
'use_combined_sources': use_combined_sources,
|
||||
'use_all_a_shares': use_all_a_shares,
|
||||
'timeframes': self.timeframes
|
||||
}
|
||||
session_id = self.db_manager.create_scan_session(
|
||||
strategy_id=self.strategy_id,
|
||||
scan_config=scan_config
|
||||
)
|
||||
|
||||
if stock_list is None:
|
||||
# 优先使用热门股票数据
|
||||
if use_hot_stocks:
|
||||
# 优先级1: 使用所有A股股票
|
||||
if use_all_a_shares:
|
||||
try:
|
||||
logger.info("📊 获取所有A股股票数据(排除北交所和ST股票)...")
|
||||
filtered_stocks = self.data_fetcher.get_filtered_a_share_list()
|
||||
|
||||
if not filtered_stocks.empty:
|
||||
# 如果max_stocks小于总股票数,随机采样
|
||||
if max_stocks > 0 and max_stocks < len(filtered_stocks):
|
||||
# 按市值排序或随机选择,这里先随机选择
|
||||
selected_stocks = filtered_stocks.sample(max_stocks)
|
||||
stock_list = selected_stocks['full_stock_code'].tolist()
|
||||
logger.info(f"📈 从{len(filtered_stocks)}只A股中随机选择{len(stock_list)}只进行分析")
|
||||
else:
|
||||
stock_list = filtered_stocks['full_stock_code'].tolist()
|
||||
logger.info(f"📈 分析全部{len(stock_list)}只A股股票")
|
||||
|
||||
source_info = "全A股(排除北交所和ST)"
|
||||
else:
|
||||
logger.warning("获取A股列表失败,回退到热门股票")
|
||||
use_all_a_shares = False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取A股列表失败: {e},回退到热门股票")
|
||||
use_all_a_shares = False
|
||||
|
||||
# 优先级2: 使用热门股票数据
|
||||
if not use_all_a_shares and use_hot_stocks:
|
||||
try:
|
||||
if use_combined_sources:
|
||||
# 使用合并的双数据源
|
||||
@ -381,8 +661,8 @@ class KLinePatternStrategy:
|
||||
logger.error(f"获取热门股票失败: {e},回退到全市场股票")
|
||||
use_hot_stocks = False
|
||||
|
||||
# 如果热股获取失败,使用全市场股票列表
|
||||
if not use_hot_stocks:
|
||||
# 优先级3: 如果热股获取失败,使用全市场股票列表
|
||||
if not use_all_a_shares and not use_hot_stocks:
|
||||
try:
|
||||
all_stocks = self.data_fetcher.get_stock_list()
|
||||
if not all_stocks.empty:
|
||||
@ -405,7 +685,7 @@ class KLinePatternStrategy:
|
||||
logger.info(f"⏳ 扫描进度: [{i+1:3d}/{len(stock_list):3d}] 🔍 {stock_code}({stock_name})")
|
||||
|
||||
try:
|
||||
stock_results = self.analyze_stock(stock_code)
|
||||
stock_results = self.analyze_stock(stock_code, session_id=session_id)
|
||||
|
||||
# 统计信号数量
|
||||
stock_signal_count = sum(len(signals) for signals in stock_results.values())
|
||||
@ -417,6 +697,17 @@ class KLinePatternStrategy:
|
||||
logger.error(f"扫描股票 {stock_code} 失败: {e}")
|
||||
continue
|
||||
|
||||
# 更新扫描会话统计
|
||||
try:
|
||||
self.db_manager.update_scan_session_stats(
|
||||
session_id=session_id,
|
||||
total_scanned=len(stock_list),
|
||||
total_signals=total_signals
|
||||
)
|
||||
logger.debug(f"扫描会话统计已更新: {session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新扫描会话统计失败: {e}")
|
||||
|
||||
# 美化最终扫描结果
|
||||
logger.info("🎉" + "="*70)
|
||||
logger.info(f"🌍 市场K线形态扫描完成!")
|
||||
@ -424,6 +715,7 @@ class KLinePatternStrategy:
|
||||
logger.info(f" 🔍 总扫描股票: {len(stock_list)} 只")
|
||||
logger.info(f" 🎯 发现信号: {total_signals} 个")
|
||||
logger.info(f" 📈 涉及股票: {len(results)} 只")
|
||||
logger.info(f" 💾 扫描会话ID: {session_id}")
|
||||
|
||||
if results:
|
||||
logger.info(f"📋 信号详情:")
|
||||
@ -438,6 +730,12 @@ class KLinePatternStrategy:
|
||||
|
||||
logger.info("🎉" + "="*70)
|
||||
|
||||
# 监控已触发信号的回踩情况
|
||||
logger.info("🔍 开始监控已触发信号的回踩情况...")
|
||||
pullback_alerts = self.monitor_pullback_for_triggered_signals()
|
||||
if pullback_alerts:
|
||||
logger.info(f"⚠️ 发现 {len(pullback_alerts)} 个回踩提醒")
|
||||
|
||||
# 发送汇总通知
|
||||
if results:
|
||||
# 判断数据源类型
|
||||
@ -476,6 +774,13 @@ K线形态策略 - 两阳线+阴线+阳线突破
|
||||
6. 最后阳线换手率不高于 {self.max_turnover_ratio:.1f}%(流动性约束)
|
||||
7. 支持时间周期:{', '.join(self.timeframes)}
|
||||
|
||||
回踩监控功能:
|
||||
- 自动监控已触发信号后的价格走势
|
||||
- 当价格回踩到阴线最高点附近时发送特殊提醒
|
||||
- 回踩容忍度:{self.pullback_tolerance:.0%}
|
||||
- 监控期限:信号触发后 {self.monitor_days} 天
|
||||
- 提醒条件:价格接近阴线最高点且相比突破价有明显回调
|
||||
|
||||
信号触发条件:
|
||||
- 形态完整匹配
|
||||
- 实体比例达标
|
||||
@ -490,6 +795,7 @@ K线形态策略 - 两阳线+阴线+阳线突破
|
||||
|
||||
通知方式:
|
||||
- 钉钉webhook汇总推送(10个信号一组分批发送)
|
||||
- 价格回踩特殊提醒(5个提醒一组分批发送)
|
||||
- 包含关键信息:代码、股票名称、K线时间、价格、周期等
|
||||
- 系统日志详细记录
|
||||
"""
|
||||
|
||||
@ -414,6 +414,89 @@ class NotificationManager:
|
||||
logger.error(f"发送策略汇总通知异常: {e}")
|
||||
return False
|
||||
|
||||
def send_pullback_alerts(self, pullback_alerts: list) -> bool:
|
||||
"""
|
||||
发送价格回踩阴线最高点的特殊提醒
|
||||
|
||||
Args:
|
||||
pullback_alerts: 回踩提醒信号列表
|
||||
|
||||
Returns:
|
||||
发送是否成功
|
||||
"""
|
||||
if not pullback_alerts or not self.dingtalk_notifier:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 按5个提醒为一组分批发送
|
||||
alerts_per_group = 5
|
||||
import math
|
||||
total_groups = math.ceil(len(pullback_alerts) / alerts_per_group)
|
||||
success_count = 0
|
||||
|
||||
for group_idx in range(total_groups):
|
||||
start_idx = group_idx * alerts_per_group
|
||||
end_idx = min(start_idx + alerts_per_group, len(pullback_alerts))
|
||||
group_alerts = pullback_alerts[start_idx:end_idx]
|
||||
|
||||
# 构建标题
|
||||
title = f"⚠️ 价格回踩阴线最高点提醒 ({group_idx + 1}/{total_groups})"
|
||||
|
||||
# 构建详细信息
|
||||
alert_details = []
|
||||
for i, alert in enumerate(group_alerts, 1):
|
||||
alert_detail = f"""
|
||||
**{start_idx + i}. {alert['stock_code']}({alert['stock_name']})**
|
||||
- 📅 原信号: {alert['signal_date']} | 当前: {alert['current_date']}
|
||||
- ⏰ 间隔: {alert['days_since_signal']}天 | 周期: {alert['timeframe']}
|
||||
- 💰 阴线高点: {alert['yin_high']:.2f}元 | 当时突破价: {alert['breakout_price']:.2f}元
|
||||
- 📉 当前价格: {alert['current_price']:.2f}元 | 今日最低: {alert['current_low']:.2f}元
|
||||
- 📊 回调幅度: {alert['pullback_pct']:.2f}% | 距阴线高点: {alert['distance_to_yin_high']:.2f}%
|
||||
"""
|
||||
alert_details.append(alert_detail)
|
||||
|
||||
# 构建完整的Markdown消息
|
||||
markdown_text = f"""
|
||||
# ⚠️ 价格回踩阴线最高点提醒
|
||||
|
||||
**🚨 重要提醒:** 以下股票在"两阳+阴+阳"形态突破后,价格回踩至阴线最高点附近,请关注支撑情况!
|
||||
|
||||
**📊 本批提醒数量:** {len(group_alerts)}个
|
||||
**🕐 检查时间:** {current_time}
|
||||
|
||||
---
|
||||
|
||||
{''.join(alert_details)}
|
||||
|
||||
---
|
||||
**💡 操作建议:**
|
||||
- 关注是否在阴线最高点获得有效支撑
|
||||
- 如跌破阴线最高点需要重新评估形态有效性
|
||||
- 建议结合成交量和其他技术指标综合判断
|
||||
|
||||
**⚠️ 风险提示:** 本提醒仅供参考,投资需谨慎!
|
||||
"""
|
||||
|
||||
# 发送消息
|
||||
if self.dingtalk_notifier.send_markdown_message(title, markdown_text):
|
||||
success_count += 1
|
||||
logger.info(f"📱 回踩提醒第{group_idx + 1}组发送成功 ({len(group_alerts)}个提醒)")
|
||||
else:
|
||||
logger.error(f"📱 回踩提醒第{group_idx + 1}组发送失败")
|
||||
|
||||
# 避免发送过快,添加短暂延迟
|
||||
if group_idx < total_groups - 1:
|
||||
import time
|
||||
time.sleep(1) # 1秒延迟
|
||||
|
||||
return success_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送回踩提醒通知异常: {e}")
|
||||
return False
|
||||
|
||||
def send_test_message(self) -> bool:
|
||||
"""发送测试消息"""
|
||||
if self.dingtalk_notifier:
|
||||
|
||||
44
start_web.py
Normal file
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