增加选股策略
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
低价擒牛策略监控服务
|
||||
定时扫描股票,检测卖出信号
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from low_price_bull_monitor import low_price_bull_monitor
|
||||
from notification_service import notification_service
|
||||
|
||||
|
||||
class LowPriceBullService:
|
||||
"""低价擒牛策略监控服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.scan_interval = 60 # 默认扫描间隔(秒)
|
||||
self.holding_days_limit = 5 # 持股天数限制
|
||||
|
||||
# 从环境变量读取配置
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""从环境变量加载配置"""
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# 扫描间隔
|
||||
interval = os.getenv('LOW_PRICE_BULL_SCAN_INTERVAL', '60')
|
||||
self.scan_interval = int(interval)
|
||||
|
||||
# 持股天数限制
|
||||
days = os.getenv('LOW_PRICE_BULL_HOLDING_DAYS', '5')
|
||||
self.holding_days_limit = int(days)
|
||||
|
||||
# TDX API配置
|
||||
self.tdx_api_url = os.getenv('TDX_BASE_URL', 'http://127.0.0.1:5000')
|
||||
|
||||
self.logger.info(f"监控配置: 扫描间隔={self.scan_interval}秒, 持股天数限制={self.holding_days_limit}天")
|
||||
self.logger.info(f"TDX API: {self.tdx_api_url}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"加载配置失败,使用默认值: {e}")
|
||||
|
||||
def start(self):
|
||||
"""启动监控服务"""
|
||||
if self.running:
|
||||
self.logger.warning("监控服务已在运行")
|
||||
return False
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
self.logger.info("低价擒牛监控服务已启动")
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""停止监控服务"""
|
||||
if not self.running:
|
||||
return False
|
||||
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
self.logger.info("低价擒牛监控服务已停止")
|
||||
return True
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""监控循环"""
|
||||
while self.running:
|
||||
try:
|
||||
self._scan_stocks()
|
||||
time.sleep(self.scan_interval)
|
||||
except Exception as e:
|
||||
self.logger.error(f"监控循环错误: {e}")
|
||||
time.sleep(self.scan_interval)
|
||||
|
||||
def _scan_stocks(self):
|
||||
"""扫描所有监控的股票"""
|
||||
try:
|
||||
# 更新持有天数
|
||||
low_price_bull_monitor.update_holding_days()
|
||||
|
||||
# 获取监控列表
|
||||
stocks = low_price_bull_monitor.get_monitored_stocks()
|
||||
|
||||
if not stocks:
|
||||
return
|
||||
|
||||
self.logger.info(f"开始扫描 {len(stocks)} 只股票")
|
||||
|
||||
for stock in stocks:
|
||||
try:
|
||||
self._check_stock(stock)
|
||||
except Exception as e:
|
||||
self.logger.error(f"检查股票 {stock['stock_code']} 失败: {e}")
|
||||
|
||||
# 处理提醒
|
||||
self._process_alerts()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"扫描股票失败: {e}")
|
||||
|
||||
def _check_stock(self, stock: dict):
|
||||
"""
|
||||
检查单只股票的卖出信号
|
||||
|
||||
Args:
|
||||
stock: 股票信息字典
|
||||
"""
|
||||
stock_code = stock['stock_code']
|
||||
stock_name = stock['stock_name']
|
||||
holding_days = stock['holding_days']
|
||||
|
||||
# 检查1: 持股天数
|
||||
if holding_days >= self.holding_days_limit:
|
||||
# 添加提醒
|
||||
low_price_bull_monitor.add_sell_alert(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
alert_type='holding_days',
|
||||
alert_reason=f'持股满{self.holding_days_limit}天,建议卖出',
|
||||
holding_days=holding_days
|
||||
)
|
||||
self.logger.info(f"{stock_code} 持股满{self.holding_days_limit}天,生成卖出提醒")
|
||||
return
|
||||
|
||||
# 检查2: MA5下穿MA20
|
||||
current_price, ma5, ma20 = self._get_stock_data(stock_code)
|
||||
|
||||
if current_price and ma5 and ma20:
|
||||
if ma5 < ma20:
|
||||
# MA5下穿MA20,添加提醒
|
||||
low_price_bull_monitor.add_sell_alert(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
alert_type='ma_cross',
|
||||
alert_reason='MA5下穿MA20,技术信号卖出',
|
||||
current_price=current_price,
|
||||
ma5=ma5,
|
||||
ma20=ma20,
|
||||
holding_days=holding_days
|
||||
)
|
||||
self.logger.info(f"{stock_code} MA5下穿MA20,生成卖出提醒")
|
||||
|
||||
def _get_stock_data(self, stock_code: str) -> tuple:
|
||||
"""
|
||||
获取股票数据(价格和均线)
|
||||
|
||||
Args:
|
||||
stock_code: 股票代码(可能带后缀,如002259.SZ)
|
||||
|
||||
Returns:
|
||||
(当前价格, MA5, MA20)
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
import pandas as pd
|
||||
|
||||
# 处理股票代码格式:去掉后缀,保留纯数字代码
|
||||
# 例如:002259.SZ -> 002259
|
||||
clean_code = stock_code.split('.')[0] if '.' in stock_code else stock_code
|
||||
|
||||
# 判断市场并添加前缀(TDX API可能需要)
|
||||
# 深圳:0开头、3开头 -> SZ前缀
|
||||
# 上海:6开头 -> SH前缀
|
||||
if clean_code.startswith(('0', '3')):
|
||||
api_code = f"SZ{clean_code}"
|
||||
elif clean_code.startswith('6'):
|
||||
api_code = f"SH{clean_code}"
|
||||
else:
|
||||
api_code = clean_code
|
||||
|
||||
# 调用TDX API获取K线数据
|
||||
url = f"{self.tdx_api_url}/api/kline"
|
||||
params = {
|
||||
'code': api_code,
|
||||
'type': 'day' # 日K线
|
||||
}
|
||||
|
||||
self.logger.debug(f"请求TDX API: code={stock_code} -> api_code={api_code}")
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
self.logger.warning(f"获取 {stock_code} K线数据失败: HTTP {response.status_code}")
|
||||
self.logger.warning(f"请求URL: {url}?code={api_code}&type=day")
|
||||
|
||||
# 尝试使用纯数字代码重试
|
||||
if api_code != clean_code:
|
||||
self.logger.info(f"尝试使用纯数字代码重试: {clean_code}")
|
||||
params['code'] = clean_code
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
self.logger.warning(f"重试失败: HTTP {response.status_code}")
|
||||
return None, None, None
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 检查数据格式
|
||||
# 支持两种格式:
|
||||
# 1. 直接返回数组: [{date, open, high, low, close, volume}, ...]
|
||||
# 2. 嵌套格式: {code: 0, message: "success", data: {List: [...]}}
|
||||
if isinstance(data, dict) and 'data' in data:
|
||||
# 嵌套格式
|
||||
if data.get('code') != 0:
|
||||
self.logger.warning(f"{stock_code} API返回错误: {data.get('message')}")
|
||||
return None, None, None
|
||||
|
||||
data_obj = data.get('data', {})
|
||||
kline_list = data_obj.get('List', [])
|
||||
|
||||
if not kline_list or len(kline_list) < 20:
|
||||
self.logger.warning(f"{stock_code} K线数据不足,需要至少20天,当前{len(kline_list)}天")
|
||||
return None, None, None
|
||||
|
||||
# 转换为DataFrame,字段名需要映射
|
||||
# API返回:{Time, Open, High, Low, Close, Volume, Amount}
|
||||
# 需要:{date, open, high, low, close, volume}
|
||||
df = pd.DataFrame(kline_list)
|
||||
|
||||
# 重命名字段(大小写转换)
|
||||
if 'Time' in df.columns:
|
||||
df['date'] = df['Time']
|
||||
if 'Open' in df.columns:
|
||||
df['open'] = df['Open']
|
||||
if 'High' in df.columns:
|
||||
df['high'] = df['High']
|
||||
if 'Low' in df.columns:
|
||||
df['low'] = df['Low']
|
||||
if 'Close' in df.columns:
|
||||
df['close'] = df['Close']
|
||||
if 'Volume' in df.columns:
|
||||
df['volume'] = df['Volume']
|
||||
|
||||
elif isinstance(data, list):
|
||||
# 直接数组格式
|
||||
if len(data) < 20:
|
||||
self.logger.warning(f"{stock_code} K线数据不足,需要至少20天")
|
||||
return None, None, None
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
else:
|
||||
self.logger.warning(f"{stock_code} K线数据格式错误")
|
||||
return None, None, None
|
||||
|
||||
# 确保有close列
|
||||
if 'close' not in df.columns:
|
||||
self.logger.warning(f"{stock_code} K线数据缺少close字段")
|
||||
return None, None, None
|
||||
|
||||
# 转换为浮点数
|
||||
df['close'] = pd.to_numeric(df['close'], errors='coerce')
|
||||
|
||||
# 计算MA5和MA20
|
||||
df['MA5'] = df['close'].rolling(window=5).mean()
|
||||
df['MA20'] = df['close'].rolling(window=20).mean()
|
||||
|
||||
# 获取最新数据
|
||||
latest = df.iloc[-1]
|
||||
current_price = latest['close']
|
||||
ma5 = latest['MA5']
|
||||
ma20 = latest['MA20']
|
||||
|
||||
# 检查是否有效
|
||||
if pd.isna(current_price) or pd.isna(ma5) or pd.isna(ma20):
|
||||
self.logger.warning(f"{stock_code} 数据包含NaN值")
|
||||
return None, None, None
|
||||
|
||||
self.logger.info(f"{stock_code} 数据: 价格={current_price:.2f}, MA5={ma5:.2f}, MA20={ma20:.2f}")
|
||||
return current_price, ma5, ma20
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"请求TDX API失败 {stock_code}: {e}")
|
||||
self.logger.error(f"请检查.env中的TDX_BASE_URL配置: {self.tdx_api_url}")
|
||||
return None, None, None
|
||||
except Exception as e:
|
||||
self.logger.error(f"获取股票数据失败 {stock_code}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None, None, None
|
||||
|
||||
def _process_alerts(self):
|
||||
"""处理待发送的提醒"""
|
||||
try:
|
||||
alerts = low_price_bull_monitor.get_pending_alerts()
|
||||
|
||||
if not alerts:
|
||||
return
|
||||
|
||||
self.logger.info(f"处理 {len(alerts)} 条卖出提醒")
|
||||
|
||||
for alert in alerts:
|
||||
try:
|
||||
# 发送通知
|
||||
self._send_alert_notification(alert)
|
||||
|
||||
# 标记已发送
|
||||
low_price_bull_monitor.mark_alert_sent(alert['id'])
|
||||
|
||||
# 自动移除股票
|
||||
low_price_bull_monitor.remove_stock(
|
||||
alert['stock_code'],
|
||||
reason=alert['alert_reason']
|
||||
)
|
||||
|
||||
self.logger.info(f"已处理提醒并移除股票: {alert['stock_code']}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理提醒失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理提醒失败: {e}")
|
||||
|
||||
def _send_alert_notification(self, alert: dict):
|
||||
"""
|
||||
发送卖出提醒通知
|
||||
|
||||
Args:
|
||||
alert: 提醒信息字典
|
||||
"""
|
||||
try:
|
||||
# 构建消息
|
||||
keyword = notification_service.config.get('webhook_keyword', 'aiagents通知')
|
||||
|
||||
message_text = f"### {keyword} - 低价擒牛卖出提醒\n\n"
|
||||
message_text += f"**股票代码**: {alert['stock_code']}\n\n"
|
||||
message_text += f"**股票名称**: {alert['stock_name']}\n\n"
|
||||
message_text += f"**提醒类型**: {self._get_alert_type_name(alert['alert_type'])}\n\n"
|
||||
message_text += f"**提醒原因**: {alert['alert_reason']}\n\n"
|
||||
|
||||
# 添加详细信息(确保数据类型正确)
|
||||
current_price = alert.get('current_price')
|
||||
if current_price is not None:
|
||||
try:
|
||||
price_val = float(current_price)
|
||||
message_text += f"**当前价格**: {price_val:.2f}元\n\n"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
ma5 = alert.get('ma5')
|
||||
ma20 = alert.get('ma20')
|
||||
if ma5 is not None and ma20 is not None:
|
||||
try:
|
||||
ma5_val = float(ma5)
|
||||
ma20_val = float(ma20)
|
||||
message_text += f"**MA5**: {ma5_val:.2f}\n\n"
|
||||
message_text += f"**MA20**: {ma20_val:.2f}\n\n"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
holding_days = alert.get('holding_days')
|
||||
if holding_days is not None:
|
||||
try:
|
||||
days_val = int(holding_days)
|
||||
message_text += f"**持有天数**: {days_val}天\n\n"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
message_text += f"**提醒时间**: {alert['alert_time']}\n\n"
|
||||
message_text += "---\n\n"
|
||||
message_text += "**建议**: 开盘时卖出该股票\n\n"
|
||||
message_text += "_此消息由AI股票分析系统自动发送_"
|
||||
|
||||
# 发送钉钉通知
|
||||
if notification_service.config['webhook_enabled']:
|
||||
import requests
|
||||
|
||||
data = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": f"{keyword} - 卖出提醒",
|
||||
"text": message_text
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
notification_service.config['webhook_url'],
|
||||
json=data,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
self.logger.info(f"卖出提醒已发送: {alert['stock_code']}")
|
||||
else:
|
||||
self.logger.error(f"发送提醒失败: HTTP {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"发送通知失败: {e}")
|
||||
|
||||
def _get_alert_type_name(self, alert_type: str) -> str:
|
||||
"""获取提醒类型名称"""
|
||||
type_map = {
|
||||
'holding_days': '持股到期',
|
||||
'ma_cross': 'MA均线死叉'
|
||||
}
|
||||
return type_map.get(alert_type, alert_type)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""获取服务状态"""
|
||||
stocks = low_price_bull_monitor.get_monitored_stocks()
|
||||
alerts = low_price_bull_monitor.get_pending_alerts()
|
||||
|
||||
return {
|
||||
'running': self.running,
|
||||
'scan_interval': self.scan_interval,
|
||||
'holding_days_limit': self.holding_days_limit,
|
||||
'monitored_count': len(stocks),
|
||||
'pending_alerts': len(alerts)
|
||||
}
|
||||
|
||||
|
||||
# 全局服务实例
|
||||
low_price_bull_service = LowPriceBullService()
|
||||
Reference in New Issue
Block a user