431 lines
16 KiB
Python
431 lines
16 KiB
Python
#!/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()
|