Files
aiagents-stock/low_price_bull_service.py
T
2025-12-12 19:46:38 +08:00

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