feat: 初始化黄小瓜AI助手记忆仓库
- 核心配置: IDENTITY, USER, SOUL, AGENTS, TOOLS, HEARTBEAT, MEMORY - memory/: 每日总结和临时记录 - skills/: 所有已安装技能 - notes/: 语音配置笔记
This commit is contained in:
249
skills/stock-monitor-pro/scripts/analyser.py
Normal file
249
skills/stock-monitor-pro/scripts/analyser.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stock Monitor Pro - 智能分析引擎
|
||||
集成:新闻、资金流向、龙虎榜、宏观关联分析
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
class StockAnalyser:
|
||||
"""股票智能分析器 - 结合多维度数据给出建议"""
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
})
|
||||
|
||||
# ========== 1. 新闻舆情 ==========
|
||||
|
||||
def fetch_eastmoney_news(self, symbol: str, name: str, limit: int = 5) -> List[Dict]:
|
||||
"""获取东方财富个股新闻"""
|
||||
url = f"https://searchapi.eastmoney.com/api/suggest/get"
|
||||
params = {
|
||||
"input": name,
|
||||
"type": 14,
|
||||
"count": limit
|
||||
}
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
news_list = []
|
||||
for item in data.get("QuotationCodeTable", {}).get("Data", []):
|
||||
news_list.append({
|
||||
"title": item.get("Title", ""),
|
||||
"url": item.get("Url", ""),
|
||||
"time": item.get("ShowTime", "")
|
||||
})
|
||||
return news_list
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def fetch_sina_news(self, symbol: str, name: str) -> List[Dict]:
|
||||
"""获取新浪财经个股新闻"""
|
||||
# 新浪新闻搜索接口
|
||||
url = f"https://search.sina.com.cn/?q={name}&c=news&sort=time"
|
||||
try:
|
||||
resp = self.session.get(url, timeout=10)
|
||||
# 这里可以做更精细的HTML解析
|
||||
# 简化返回示例
|
||||
return [{"title": f"新浪财经-{name}相关新闻", "source": "新浪"}]
|
||||
except:
|
||||
return []
|
||||
|
||||
def analyze_sentiment(self, news_list: List[Dict]) -> Dict:
|
||||
"""简单情感分析"""
|
||||
positive_words = ['利好', '增长', '突破', '买入', '增持', '涨停', '超预期', '业绩大增']
|
||||
negative_words = ['利空', '减持', '下跌', '卖出', '亏损', '暴雷', '跌停', '不及预期']
|
||||
|
||||
sentiment = {"positive": 0, "negative": 0, "neutral": 0, "summary": []}
|
||||
|
||||
for news in news_list:
|
||||
title = news.get("title", "")
|
||||
p_count = sum(1 for w in positive_words if w in title)
|
||||
n_count = sum(1 for w in negative_words if w in title)
|
||||
|
||||
if p_count > n_count:
|
||||
sentiment["positive"] += 1
|
||||
elif n_count > p_count:
|
||||
sentiment["negative"] += 1
|
||||
else:
|
||||
sentiment["neutral"] += 1
|
||||
|
||||
# 生成情感摘要
|
||||
if sentiment["positive"] > sentiment["negative"]:
|
||||
sentiment["overall"] = "偏多"
|
||||
elif sentiment["negative"] > sentiment["positive"]:
|
||||
sentiment["overall"] = "偏空"
|
||||
else:
|
||||
sentiment["overall"] = "中性"
|
||||
|
||||
return sentiment
|
||||
|
||||
# ========== 2. 资金流向 ==========
|
||||
|
||||
def fetch_fund_flow(self, symbol: str, market: str = "sz") -> Dict:
|
||||
"""获取个股资金流向 (新浪财经)"""
|
||||
# 新浪资金流向接口
|
||||
code = f"{market}{symbol}"
|
||||
url = f"https://quotes.sina.cn/cn/api/quotes.php?symbol={code}&source=sina"
|
||||
|
||||
try:
|
||||
resp = self.session.get(url, timeout=10)
|
||||
# 解析返回数据
|
||||
return {
|
||||
"main_inflow": "数据获取中...",
|
||||
"retail_inflow": "数据获取中...",
|
||||
"net_inflow": "数据获取中..."
|
||||
}
|
||||
except:
|
||||
return {"error": "获取失败"}
|
||||
|
||||
def fetch_northbound_flow(self) -> Dict:
|
||||
"""获取北向资金 (沪深股通) 流向"""
|
||||
url = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||
params = {"secid": "1.000001", "fields": "f170"} # 简化示例
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
return {"northbound": "北向资金数据获取中..."}
|
||||
except:
|
||||
return {}
|
||||
|
||||
# ========== 3. 龙虎榜 ==========
|
||||
|
||||
def fetch_dragon_tiger(self, date: str = None) -> List[Dict]:
|
||||
"""获取龙虎榜数据"""
|
||||
if not date:
|
||||
date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
url = f"http://datacenter-web.eastmoney.com/api/data/v1/get"
|
||||
params = {
|
||||
"sortColumns": "NET_BUY_AMT",
|
||||
"sortTypes": "-1",
|
||||
"pageSize": "50",
|
||||
"pageNumber": "1",
|
||||
"reportName": "RPT_DMSK_TS",
|
||||
"columns": "ALL",
|
||||
"filter": f"(TRADE_DATE='{date}')"
|
||||
}
|
||||
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
return data.get("result", {}).get("data", [])
|
||||
except:
|
||||
return []
|
||||
|
||||
# ========== 4. 宏观关联分析 ==========
|
||||
|
||||
def analyze_gold_correlation(self, gold_price: float, stocks: List[Dict]) -> str:
|
||||
"""分析金价与持仓股票的关联"""
|
||||
# 江西铜业等有色股与金价正相关
|
||||
correlation_map = {
|
||||
"600362": "强正相关", # 江西铜业
|
||||
"601318": "弱相关", # 中国平安
|
||||
"513180": "弱负相关", # 恒生科技
|
||||
"159892": "弱相关", # 恒生医疗
|
||||
}
|
||||
|
||||
analysis = []
|
||||
for stock in stocks:
|
||||
code = stock.get("code")
|
||||
corr = correlation_map.get(code, "未知")
|
||||
if corr in ["强正相关", "中等正相关"]:
|
||||
analysis.append(f"📈 {stock['name']}: 与金价{corr},金价上涨可能带动该股")
|
||||
|
||||
return "\n".join(analysis) if analysis else "暂无强关联标的"
|
||||
|
||||
# ========== 5. 综合分析 ==========
|
||||
|
||||
def generate_insight(self, stock: Dict, price_data: Dict, alerts: List) -> str:
|
||||
"""生成综合分析报告"""
|
||||
code = stock['code']
|
||||
name = stock['name']
|
||||
|
||||
# 1. 获取新闻
|
||||
news_list = self.fetch_eastmoney_news(code, name)
|
||||
sentiment = self.analyze_sentiment(news_list)
|
||||
|
||||
# 2. 资金流向
|
||||
fund_flow = self.fetch_fund_flow(code, stock.get('market', 'sz'))
|
||||
|
||||
# 3. 构建报告
|
||||
report = f"""📊 <b>{name} ({code}) 深度分析</b>
|
||||
|
||||
💰 <b>价格异动:</b>
|
||||
• 当前: {price_data.get('price', 'N/A')} ({price_data.get('change_pct', 0):+.2f}%)
|
||||
• 触发: {', '.join([a[1] for a in alerts])}
|
||||
|
||||
📰 <b>舆情分析 ({sentiment.get('overall', '未知')}):</b>
|
||||
• 最近新闻: {len(news_list)} 条
|
||||
• 正面: {sentiment.get('positive', 0)} | 负面: {sentiment.get('negative', 0)}
|
||||
"""
|
||||
|
||||
# 添加最新新闻标题
|
||||
if news_list:
|
||||
report += "\n<b>最新动态:</b>\n"
|
||||
for n in news_list[:2]:
|
||||
report += f"• {n.get('title', '无标题')[:30]}...\n"
|
||||
|
||||
# 4. 给出建议
|
||||
suggestion = self._generate_suggestion(sentiment, alerts)
|
||||
report += f"\n💡 <b>Kimi建议:</b>\n{suggestion}"
|
||||
|
||||
return report
|
||||
|
||||
def _generate_suggestion(self, sentiment: Dict, alerts: List) -> str:
|
||||
"""基于数据生成建议"""
|
||||
alert_types = [a[0] for a in alerts]
|
||||
overall = sentiment.get("overall", "中性")
|
||||
|
||||
# 价格下跌 + 舆情偏空 = 谨慎
|
||||
if "below" in alert_types and overall == "偏空":
|
||||
return "⚠️ 价格跌破支撑位,且舆情偏空,建议观察等待,不急于抄底。"
|
||||
|
||||
# 价格下跌 + 舆情偏多 = 可能是机会
|
||||
if "below" in alert_types and overall == "偏多":
|
||||
return "🔍 价格下跌但舆情偏多,可能是情绪错杀,关注是否有反弹机会。"
|
||||
|
||||
# 价格突破 + 舆情偏多 = 确认趋势
|
||||
if "above" in alert_types and overall == "偏多":
|
||||
return "🚀 价格突破且舆情配合,趋势可能延续,可考虑顺势而为。"
|
||||
|
||||
# 大涨
|
||||
if "pct_up" in alert_types:
|
||||
return "📈 短期涨幅较大,注意获利了结风险。"
|
||||
|
||||
# 大跌
|
||||
if "pct_down" in alert_types:
|
||||
return "📉 短期跌幅较大,关注是否超跌反弹,但勿急于抄底。"
|
||||
|
||||
return "⏳ 建议保持观察,等待更明确信号。"
|
||||
|
||||
|
||||
# ========== 测试 ==========
|
||||
if __name__ == '__main__':
|
||||
analyser = StockAnalyser()
|
||||
|
||||
# 测试新闻抓取
|
||||
print("=== 新闻测试 ===")
|
||||
news = analyser.fetch_eastmoney_news("600362", "江西铜业")
|
||||
print(f"获取到 {len(news)} 条新闻")
|
||||
for n in news[:3]:
|
||||
print(f" - {n.get('title', 'N/A')[:40]}...")
|
||||
|
||||
# 测试情感分析
|
||||
print("\n=== 情感分析测试 ===")
|
||||
sentiment = analyser.analyze_sentiment(news)
|
||||
print(f"整体情绪: {sentiment.get('overall')}")
|
||||
print(f"正面: {sentiment.get('positive')}, 负面: {sentiment.get('negative')}")
|
||||
|
||||
# 测试金价关联
|
||||
print("\n=== 宏观关联测试 ===")
|
||||
stocks = [{"code": "600362", "name": "江西铜业"}]
|
||||
corr = analyser.analyze_gold_correlation(2743, stocks)
|
||||
print(corr)
|
||||
64
skills/stock-monitor-pro/scripts/control.sh
Normal file
64
skills/stock-monitor-pro/scripts/control.sh
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Stock Monitor 一键启动脚本
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOG_DIR="$HOME/.stock_monitor"
|
||||
PID_FILE="$LOG_DIR/monitor.pid"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
|
||||
echo "⚠️ 监控进程已在运行 (PID: $(cat $PID_FILE))"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 启动 Stock Monitor 后台进程..."
|
||||
mkdir -p "$LOG_DIR"
|
||||
nohup python3 "$SCRIPT_DIR/monitor_v2.py" > "$LOG_DIR/monitor.log" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
echo "✅ 已启动 (PID: $!)"
|
||||
echo "📋 日志: $LOG_DIR/monitor.log"
|
||||
;;
|
||||
|
||||
stop)
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "🛑 停止监控进程 (PID: $PID)..."
|
||||
kill "$PID"
|
||||
rm "$PID_FILE"
|
||||
echo "✅ 已停止"
|
||||
else
|
||||
echo "⚠️ 进程不存在"
|
||||
rm "$PID_FILE"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 没有运行中的进程"
|
||||
fi
|
||||
;;
|
||||
|
||||
status)
|
||||
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
|
||||
echo "✅ 监控运行中 (PID: $(cat $PID_FILE))"
|
||||
echo "📋 最近日志:"
|
||||
tail -5 "$LOG_DIR/monitor.log" 2>/dev/null || echo " 暂无日志"
|
||||
else
|
||||
echo "⏹️ 监控未运行"
|
||||
fi
|
||||
;;
|
||||
|
||||
log)
|
||||
tail -f "$LOG_DIR/monitor.log"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Stock Monitor 控制脚本"
|
||||
echo ""
|
||||
echo "用法: ./control.sh [start|stop|status|log]"
|
||||
echo ""
|
||||
echo " start - 启动后台监控"
|
||||
echo " stop - 停止监控"
|
||||
echo " status - 查看状态"
|
||||
echo " log - 查看实时日志"
|
||||
;;
|
||||
esac
|
||||
629
skills/stock-monitor-pro/scripts/monitor.py
Normal file
629
skills/stock-monitor-pro/scripts/monitor.py
Normal file
@@ -0,0 +1,629 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
自选股监控预警工具 - OpenClaw集成版
|
||||
支持 A股、ETF 及 国际现货黄金 (伦敦金)
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ============ 配置区 ============
|
||||
|
||||
# 监控列表 - 长期挂机通用配置
|
||||
# 注意: 伦敦金使用新浪hf_XAU接口,价格为 人民币/克 (约4800元/克 = $2740/盎司)
|
||||
#
|
||||
# 预警规则设计原则 (适合长期挂机):
|
||||
# 1. 成本百分比预警: 基于持仓成本设置 ±10%/±15% 预警,比固定价格更合理
|
||||
# 2. 单日涨跌幅预警:
|
||||
# - 个股 ±3%~5% (波动大)
|
||||
# - ETF ±1.5%~2.5% (波动小)
|
||||
# - 黄金 ±2%~3% (24H特殊)
|
||||
# 3. 防骚扰: 同类预警30分钟内只发一次
|
||||
|
||||
# 标的类型定义
|
||||
STOCK_TYPE = {
|
||||
"INDIVIDUAL": "individual", # 个股
|
||||
"ETF": "etf", # ETF
|
||||
"GOLD": "gold" # 黄金/贵金属
|
||||
}
|
||||
|
||||
WATCHLIST = [
|
||||
# ===== Eave的持仓ETF =====
|
||||
{
|
||||
"code": "159142",
|
||||
"name": "科创创业人工智能ETF",
|
||||
"market": "sz",
|
||||
"type": "etf",
|
||||
"cost": 1.158,
|
||||
"alerts": {
|
||||
"cost_pct_above": 10.0, # 盈利10%提醒(降低,先回本)
|
||||
"cost_pct_below": -15.0, # 亏损15%提醒(放宽,等补仓机会)
|
||||
"target_buy": 0.98, # 目标补仓价 ¥0.98(对应成本-15%)
|
||||
"change_pct_above": 3.0, # 日内大涨3%提醒
|
||||
"change_pct_below": -3.0, # 日内大跌3%提醒
|
||||
"volume_surge": 2.0, # 放量2倍提醒
|
||||
"ma_monitor": True,
|
||||
"rsi_monitor": True,
|
||||
"gap_monitor": True,
|
||||
"trailing_stop": False # 关闭动态止盈(先解套)
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "159213",
|
||||
"name": "机器人ETF汇添富",
|
||||
"market": "sz",
|
||||
"type": "etf",
|
||||
"cost": 1.307,
|
||||
"alerts": {
|
||||
"cost_pct_above": 10.0, # 盈利10%提醒
|
||||
"cost_pct_below": -15.0, # 亏损15%提醒
|
||||
"target_buy": 1.11, # 目标补仓价 ¥1.11(强支撑位)
|
||||
"change_pct_above": 3.0,
|
||||
"change_pct_below": -3.0,
|
||||
"volume_surge": 2.0,
|
||||
"ma_monitor": True,
|
||||
"rsi_monitor": True,
|
||||
"gap_monitor": True,
|
||||
"trailing_stop": False
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "159828",
|
||||
"name": "医疗ETF",
|
||||
"market": "sz",
|
||||
"type": "etf",
|
||||
"cost": 0.469,
|
||||
"note": "策略:涨到¥0.45减仓50%,跌破¥0.40止损",
|
||||
"alerts": {
|
||||
"cost_pct_above": 10.0, # 盈利10%提醒
|
||||
"cost_pct_below": -14.7, # 亏损14.7%提醒(对应¥0.40止损线)
|
||||
"stop_loss": 0.40, # 明确止损价 ¥0.40
|
||||
"target_reduce": 0.45, # 目标减仓价 ¥0.45(减仓50%)
|
||||
"change_pct_above": 3.0,
|
||||
"change_pct_below": -3.0,
|
||||
"volume_surge": 2.0,
|
||||
"ma_monitor": True,
|
||||
"rsi_monitor": True,
|
||||
"gap_monitor": True,
|
||||
"trailing_stop": False
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# 智能频率配置
|
||||
SMART_SCHEDULE = {
|
||||
"market_open": {"hours": [(9, 30), (11, 30), (13, 0), (15, 0)], "interval": 300}, # 交易时间: 5分钟
|
||||
"after_hours": {"interval": 1800}, # 收盘后: 30分钟
|
||||
"night": {"hours": [(0, 0), (8, 0)], "interval": 3600}, # 凌晨: 1小时(仅伦敦金)
|
||||
}
|
||||
|
||||
# ============ 核心代码 ============
|
||||
|
||||
class StockAlert:
|
||||
def __init__(self):
|
||||
self.prev_data = {}
|
||||
self.alert_log = []
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"User-Agent": "Mozilla/5.0"})
|
||||
|
||||
def should_run_now(self):
|
||||
"""智能频率控制: 判断当前是否应该执行监控 (基于北京时间)"""
|
||||
# 服务器在纽约(EST),中国股市用北京时间(CST = EST + 13小时)
|
||||
from datetime import timedelta
|
||||
now = datetime.now() + timedelta(hours=13) # 转换成北京时间
|
||||
hour, minute = now.hour, now.minute
|
||||
time_val = hour * 100 + minute
|
||||
weekday = now.weekday()
|
||||
|
||||
# 周末只监控伦敦金
|
||||
if weekday >= 5: # 周六日
|
||||
return {"run": True, "mode": "weekend", "stocks": [s for s in WATCHLIST if s['market'] == 'fx']}
|
||||
|
||||
# 交易时间 (9:30-11:30, 13:00-15:00)
|
||||
morning_session = 930 <= time_val <= 1130
|
||||
afternoon_session = 1300 <= time_val <= 1500
|
||||
|
||||
if morning_session or afternoon_session:
|
||||
return {"run": True, "mode": "market", "stocks": WATCHLIST, "interval": 300}
|
||||
|
||||
# 午休 (11:30-13:00)
|
||||
if 1130 < time_val < 1300:
|
||||
return {"run": True, "mode": "lunch", "stocks": WATCHLIST, "interval": 600} # 10分钟
|
||||
|
||||
# 收盘后 (15:00-24:00)
|
||||
if 1500 <= time_val <= 2359:
|
||||
return {"run": True, "mode": "after_hours", "stocks": WATCHLIST, "interval": 1800} # 30分钟
|
||||
|
||||
# 凌晨 (0:00-9:30)
|
||||
if 0 <= time_val < 930:
|
||||
return {"run": True, "mode": "night", "stocks": [s for s in WATCHLIST if s['market'] == 'fx'], "interval": 3600} # 1小时
|
||||
|
||||
return {"run": False}
|
||||
|
||||
def fetch_eastmoney_kline(self, symbol, market):
|
||||
"""获取最新日K线数据 (收盘后也能获取收盘价)"""
|
||||
secid = f"{market}.{symbol}"
|
||||
url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61',
|
||||
'klt': '101', # 日线
|
||||
'fqt': '0',
|
||||
'end': '20500101',
|
||||
'lmt': '2' # 取最近2天,用于计算涨跌幅
|
||||
}
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
klines = data.get('data', {}).get('klines', [])
|
||||
if len(klines) >= 1:
|
||||
# 格式: 日期,开盘,收盘,最高,最低,成交量,成交额,振幅,涨跌幅,涨跌额,换手率
|
||||
today = klines[-1].split(',')
|
||||
prev_close = float(today[2]) # 昨收
|
||||
if len(klines) >= 2:
|
||||
prev_close = float(klines[-2].split(',')[2]) # 前一天收盘
|
||||
return {
|
||||
'name': data.get('data', {}).get('name', symbol),
|
||||
'price': float(today[2]), # 收盘
|
||||
'prev_close': prev_close,
|
||||
'volume': int(float(today[5])),
|
||||
'amount': float(today[6]),
|
||||
'date': today[0],
|
||||
'time': '15:00:00'
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"东财K线获取失败 {symbol}: {e}")
|
||||
return None
|
||||
|
||||
def fetch_volume_ma5(self, symbol, market):
|
||||
"""获取5日平均成交量"""
|
||||
secid = f"{market}.{symbol}"
|
||||
url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61',
|
||||
'klt': '101',
|
||||
'fqt': '0',
|
||||
'end': '20500101',
|
||||
'lmt': '6' # 取最近6天(今天+前5天)
|
||||
}
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
klines = data.get('data', {}).get('klines', [])
|
||||
if len(klines) >= 2:
|
||||
# 计算前5日平均成交量(不含今天)
|
||||
volumes = []
|
||||
for k in klines[:-1]: # 排除最后一天(今天)
|
||||
p = k.split(',')
|
||||
volumes.append(float(p[5])) # 成交量
|
||||
return sum(volumes) / len(volumes) if volumes else 0
|
||||
except Exception as e:
|
||||
print(f"获取均量失败 {symbol}: {e}")
|
||||
return 0
|
||||
|
||||
def fetch_ma_data(self, symbol, market):
|
||||
"""获取均线数据 (MA5, MA10, MA20) 和 RSI"""
|
||||
secid = f"{market}.{symbol}"
|
||||
url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61',
|
||||
'klt': '101',
|
||||
'fqt': '0',
|
||||
'end': '20500101',
|
||||
'lmt': '30' # 取最近30天计算MA20和RSI
|
||||
}
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
klines = data.get('data', {}).get('klines', [])
|
||||
if len(klines) >= 20:
|
||||
closes = []
|
||||
for k in klines:
|
||||
p = k.split(',')
|
||||
closes.append(float(p[2])) # 收盘价
|
||||
|
||||
# 计算均线
|
||||
ma5 = sum(closes[-5:]) / 5
|
||||
ma10 = sum(closes[-10:]) / 10
|
||||
ma20 = sum(closes[-20:]) / 20
|
||||
|
||||
# 判断均线趋势
|
||||
prev_ma5 = sum(closes[-6:-1]) / 5
|
||||
prev_ma10 = sum(closes[-11:-1]) / 10
|
||||
|
||||
# 计算RSI(14)
|
||||
rsi = self._calculate_rsi(closes, 14)
|
||||
|
||||
return {
|
||||
'MA5': ma5,
|
||||
'MA10': ma10,
|
||||
'MA20': ma20,
|
||||
'MA5_trend': 'up' if ma5 > prev_ma5 else 'down',
|
||||
'MA10_trend': 'up' if ma10 > prev_ma10 else 'down',
|
||||
'golden_cross': prev_ma5 <= prev_ma10 and ma5 > ma10,
|
||||
'death_cross': prev_ma5 >= prev_ma10 and ma5 < ma10,
|
||||
'RSI': rsi,
|
||||
'RSI_overbought': rsi > 70 if rsi else False,
|
||||
'RSI_oversold': rsi < 30 if rsi else False
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"获取均线失败 {symbol}: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_rsi(self, closes, period=14):
|
||||
"""计算RSI指标"""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
gains = []
|
||||
losses = []
|
||||
|
||||
for i in range(1, period + 1):
|
||||
change = closes[-i] - closes[-i-1]
|
||||
if change > 0:
|
||||
gains.append(change)
|
||||
losses.append(0)
|
||||
else:
|
||||
gains.append(0)
|
||||
losses.append(abs(change))
|
||||
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100
|
||||
|
||||
rs = avg_gain / avg_loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return round(rsi, 2)
|
||||
|
||||
def fetch_sina_realtime(self, stocks):
|
||||
"""获取实时行情 (优先实时,收盘后用日K)"""
|
||||
stock_list = [s for s in stocks if s['market'] != 'fx']
|
||||
fx_list = [s for s in stocks if s['market'] == 'fx']
|
||||
results = {}
|
||||
|
||||
# 1. A股/ETF - 尝试实时接口
|
||||
if stock_list:
|
||||
codes = [f"{s['market']}{s['code']}" for s in stock_list]
|
||||
url = f"https://hq.sinajs.cn/list={','.join(codes)}"
|
||||
try:
|
||||
resp = self.session.get(url, headers={'Referer': 'https://finance.sina.com.cn'}, timeout=10)
|
||||
resp.encoding = 'gb18030'
|
||||
for line in resp.text.strip().split(';'):
|
||||
if 'hq_str_' not in line or '=' not in line: continue
|
||||
key = line.split('=')[0].split('_')[-1]
|
||||
if len(key) < 8: continue
|
||||
data_str = line[line.index('"')+1 : line.rindex('"')]
|
||||
p = data_str.split(',')
|
||||
if len(p) > 30 and float(p[3]) > 0:
|
||||
# 新浪数据格式: 名称,今日开盘,昨日收盘,当前价,今日最高,今日最低,竞买价,竞卖价,成交量,成交额...
|
||||
# 保存昨日最高最低价用于跳空检测 (用昨日收盘近似,或用均线数据补充)
|
||||
results[key[2:]] = {
|
||||
'name': p[0],
|
||||
'price': float(p[3]),
|
||||
'prev_close': float(p[2]),
|
||||
'open': float(p[1]), # 今日开盘
|
||||
'high': float(p[4]), # 今日最高
|
||||
'low': float(p[5]), # 今日最低
|
||||
'volume': int(p[8]),
|
||||
'amount': float(p[9]),
|
||||
'date': p[30],
|
||||
'time': p[31],
|
||||
'prev_high': float(p[2]) * 1.02, # 估算昨日最高 (昨收+2%)
|
||||
'prev_low': float(p[2]) * 0.98 # 估算昨日最低 (昨收-2%)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"实时行情获取失败: {e}")
|
||||
|
||||
# 2. 如果实时接口返回空或0,用日K线补数据
|
||||
for stock in stock_list:
|
||||
code = stock['code']
|
||||
if code not in results or results[code]['price'] <= 0:
|
||||
kline_data = self.fetch_eastmoney_kline(code, 1 if stock['market'] == 'sh' else 0)
|
||||
if kline_data:
|
||||
results[code] = kline_data
|
||||
print(f" {stock['name']}: 使用日K收盘价 {kline_data['price']}")
|
||||
|
||||
# 3. 伦敦金 (新浪hf_XAU接口,人民币/克)
|
||||
if fx_list:
|
||||
url = "https://hq.sinajs.cn/list=hf_XAU"
|
||||
try:
|
||||
resp = self.session.get(url, headers={'Referer': 'https://finance.sina.com.cn'}, timeout=10)
|
||||
line = resp.text.strip()
|
||||
if '"' in line:
|
||||
data_str = line[line.index('"')+1 : line.rindex('"')]
|
||||
p = data_str.split(',')
|
||||
if len(p) >= 13:
|
||||
# 新浪hf_XAU: 人民币/克 (约4800=2740美元/盎司)
|
||||
price = float(p[0])
|
||||
results['XAU'] = {
|
||||
'name': '伦敦金',
|
||||
'price': price,
|
||||
'prev_close': float(p[7]),
|
||||
'volume': 0, 'amount': 0,
|
||||
'date': p[11] if len(p) > 11 else datetime.now().strftime('%Y-%m-%d'),
|
||||
'time': p[6]
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"伦敦金获取失败: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def check_alerts(self, stock_config, data):
|
||||
"""检查预警条件 (支持成本百分比、单日涨跌幅、分级预警)"""
|
||||
alerts = []
|
||||
alert_weights = [] # 用于计算预警级别
|
||||
code = stock_config['code']
|
||||
cfg = stock_config.get('alerts', {})
|
||||
cost = stock_config.get('cost', 0)
|
||||
stock_type = stock_config.get('type', 'individual')
|
||||
price, prev_close = data['price'], data['prev_close']
|
||||
change_pct = (price - prev_close) / prev_close * 100 if prev_close else 0
|
||||
|
||||
# 1. 基于成本的百分比预警 (权重: 高)
|
||||
if cost > 0:
|
||||
cost_change_pct = (price - cost) / cost * 100
|
||||
|
||||
if 'cost_pct_above' in cfg and cost_change_pct >= cfg['cost_pct_above']:
|
||||
target_price = cost * (1 + cfg['cost_pct_above']/100)
|
||||
if not self._alerted_recently(code, 'cost_above'):
|
||||
alerts.append(('cost_above', f"🎯 盈利 {cfg['cost_pct_above']:.0f}% (目标价 ¥{target_price:.2f})"))
|
||||
alert_weights.append(3) # 高权重
|
||||
|
||||
if 'cost_pct_below' in cfg and cost_change_pct <= cfg['cost_pct_below']:
|
||||
target_price = cost * (1 + cfg['cost_pct_below']/100)
|
||||
if not self._alerted_recently(code, 'cost_below'):
|
||||
alerts.append(('cost_below', f"🛑 亏损 {abs(cfg['cost_pct_below']):.0f}% (止损价 ¥{target_price:.2f})"))
|
||||
alert_weights.append(3) # 高权重
|
||||
|
||||
# 2. 基于固定价格的预警 (权重: 中)
|
||||
if 'price_above' in cfg and price >= cfg['price_above'] and not self._alerted_recently(code, 'above'):
|
||||
alerts.append(('above', f"🚀 价格突破 ¥{cfg['price_above']}"))
|
||||
alert_weights.append(2)
|
||||
if 'price_below' in cfg and price <= cfg['price_below'] and not self._alerted_recently(code, 'below'):
|
||||
alerts.append(('below', f"📉 价格跌破 ¥{cfg['price_below']}"))
|
||||
alert_weights.append(2)
|
||||
|
||||
# 3. 单日涨跌幅预警 (权重: 根据幅度)
|
||||
if 'change_pct_above' in cfg and change_pct >= cfg['change_pct_above'] and not self._alerted_recently(code, 'pct_up'):
|
||||
alerts.append(('pct_up', f"📈 日内大涨 {change_pct:+.2f}%"))
|
||||
# 异动越大权重越高
|
||||
if change_pct >= 7:
|
||||
alert_weights.append(3) # 涨停附近
|
||||
elif change_pct >= 5:
|
||||
alert_weights.append(2) # 大涨
|
||||
else:
|
||||
alert_weights.append(1) # 一般异动
|
||||
|
||||
if 'change_pct_below' in cfg and change_pct <= cfg['change_pct_below'] and not self._alerted_recently(code, 'pct_down'):
|
||||
alerts.append(('pct_down', f"📉 日内大跌 {change_pct:+.2f}%"))
|
||||
if change_pct <= -7:
|
||||
alert_weights.append(3) # 跌停附近
|
||||
elif change_pct <= -5:
|
||||
alert_weights.append(2) # 大跌
|
||||
else:
|
||||
alert_weights.append(1) # 一般异动
|
||||
|
||||
# 4. 成交量异动检测 (仅股票和ETF)
|
||||
if stock_type != 'gold' and 'volume_surge' in cfg:
|
||||
current_volume = data.get('volume', 0)
|
||||
if current_volume > 0:
|
||||
# 尝试获取5日均量
|
||||
ma5_volume = self.fetch_volume_ma5(code, 1 if stock_config['market'] == 'sh' else 0)
|
||||
if ma5_volume > 0:
|
||||
volume_ratio = current_volume / ma5_volume
|
||||
threshold = cfg['volume_surge']
|
||||
|
||||
if volume_ratio >= threshold and not self._alerted_recently(code, 'volume_surge'):
|
||||
alerts.append(('volume_surge', f"📊 放量 {volume_ratio:.1f}倍 (5日均量)"))
|
||||
alert_weights.append(2) # 中等权重
|
||||
elif volume_ratio <= 0.5 and not self._alerted_recently(code, 'volume_shrink'):
|
||||
alerts.append(('volume_shrink', f"📉 缩量 {volume_ratio:.1f}倍 (5日均量)"))
|
||||
alert_weights.append(1) # 低权重
|
||||
|
||||
# 5. 均线系统 (MA金叉死叉)
|
||||
if stock_type != 'gold' and cfg.get('ma_monitor', True):
|
||||
ma_data = self.fetch_ma_data(code, 1 if stock_config['market'] == 'sh' else 0)
|
||||
if ma_data:
|
||||
# 金叉: MA5上穿MA10 (短期转强)
|
||||
if ma_data.get('golden_cross') and not self._alerted_recently(code, 'ma_golden'):
|
||||
alerts.append(('ma_golden', f"🌟 均线金叉 (MA5¥{ma_data['MA5']:.2f}上穿MA10¥{ma_data['MA10']:.2f})"))
|
||||
alert_weights.append(3) # 高权重
|
||||
|
||||
# 死叉: MA5下穿MA10 (短期转弱)
|
||||
if ma_data.get('death_cross') and not self._alerted_recently(code, 'ma_death'):
|
||||
alerts.append(('ma_death', f"⚠️ 均线死叉 (MA5¥{ma_data['MA5']:.2f}下穿MA10¥{ma_data['MA10']:.2f})"))
|
||||
alert_weights.append(3) # 高权重
|
||||
|
||||
# RSI超买超卖检测
|
||||
rsi = ma_data.get('RSI')
|
||||
if rsi:
|
||||
if ma_data.get('RSI_overbought') and not self._alerted_recently(code, 'rsi_high'):
|
||||
alerts.append(('rsi_high', f"🔥 RSI超买 ({rsi}),可能回调"))
|
||||
alert_weights.append(2)
|
||||
elif ma_data.get('RSI_oversold') and not self._alerted_recently(code, 'rsi_low'):
|
||||
alerts.append(('rsi_low', f"❄️ RSI超卖 ({rsi}),可能反弹"))
|
||||
alert_weights.append(2)
|
||||
|
||||
# 5. 跳空缺口检测 (需要昨日数据)
|
||||
if stock_type != 'gold':
|
||||
prev_high = data.get('prev_high', 0)
|
||||
prev_low = data.get('prev_low', 0)
|
||||
current_open = data.get('open', price) # 当前价近似开盘价
|
||||
|
||||
# 向上跳空: 今日开盘 > 昨日最高
|
||||
if prev_high > 0 and current_open > prev_high * 1.01: # 1%以上算跳空
|
||||
gap_pct = (current_open - prev_high) / prev_high * 100
|
||||
if not self._alerted_recently(code, 'gap_up'):
|
||||
alerts.append(('gap_up', f"⬆️ 向上跳空 {gap_pct:.1f}%"))
|
||||
alert_weights.append(2)
|
||||
|
||||
# 向下跳空: 今日开盘 < 昨日最低
|
||||
elif prev_low > 0 and current_open < prev_low * 0.99:
|
||||
gap_pct = (prev_low - current_open) / prev_low * 100
|
||||
if not self._alerted_recently(code, 'gap_down'):
|
||||
alerts.append(('gap_down', f"⬇️ 向下跳空 {gap_pct:.1f}%"))
|
||||
alert_weights.append(2)
|
||||
|
||||
# 6. 动态止盈/移动止损 (当盈利达到一定幅度后启动)
|
||||
if cost > 0:
|
||||
profit_pct = (price - cost) / cost * 100
|
||||
|
||||
# 当盈利 >= 10% 时,启动移动止盈
|
||||
if profit_pct >= 10:
|
||||
# 计算回撤幅度 (从最高点回撤)
|
||||
high_since_cost = data.get('high', price)
|
||||
drawdown = (high_since_cost - price) / high_since_cost * 100 if high_since_cost > cost else 0
|
||||
|
||||
# 回撤5%提醒减仓
|
||||
if drawdown >= 5 and not self._alerted_recently(code, 'trailing_stop_5'):
|
||||
alerts.append(('trailing_stop_5', f"📉 利润回撤 {drawdown:.1f}%,建议减仓保护利润"))
|
||||
alert_weights.append(2)
|
||||
|
||||
# 回撤10%提醒清仓
|
||||
elif drawdown >= 10 and not self._alerted_recently(code, 'trailing_stop_10'):
|
||||
alerts.append(('trailing_stop_10', f"🚨 利润回撤 {drawdown:.1f}%,建议清仓止损"))
|
||||
alert_weights.append(3)
|
||||
|
||||
# 6. 计算预警级别
|
||||
level = self._calculate_alert_level(alerts, alert_weights, stock_type)
|
||||
|
||||
return alerts, level
|
||||
|
||||
def _calculate_alert_level(self, alerts, weights, stock_type):
|
||||
"""计算预警级别: info(提醒) / warning(警告) / critical(紧急)"""
|
||||
if not alerts:
|
||||
return None
|
||||
|
||||
total_weight = sum(weights)
|
||||
alert_count = len(alerts)
|
||||
|
||||
# 紧急: 多条件共振 或 高权重单一条件
|
||||
if total_weight >= 5 or alert_count >= 3:
|
||||
return "critical"
|
||||
|
||||
# 警告: 中等权重 或 2个条件
|
||||
if total_weight >= 3 or alert_count >= 2:
|
||||
return "warning"
|
||||
|
||||
# 提醒: 单一低权重条件
|
||||
return "info"
|
||||
|
||||
def _alerted_recently(self, code, atype):
|
||||
now = time.time()
|
||||
self.alert_log = [l for l in self.alert_log if now - l['t'] < 1800] # 30分钟有效期
|
||||
for l in self.alert_log:
|
||||
if l['c'] == code and l['a'] == atype: return True
|
||||
return False
|
||||
|
||||
def record_alert(self, code, atype):
|
||||
self.alert_log.append({'c': code, 'a': atype, 't': time.time()})
|
||||
|
||||
def fetch_news(self, symbol):
|
||||
"""抓取个股最近新闻 (新浪/东财聚合) - 简化版"""
|
||||
try:
|
||||
# 使用东财个股新闻API
|
||||
url = f"https://emweb.securities.eastmoney.com/PC_HSF10/CompanySurvey/CompanySurveyAjax"
|
||||
params = {"code": symbol}
|
||||
resp = self.session.get(url, params=params, timeout=5)
|
||||
return ["新闻模块已就绪 (市场收盘中)"]
|
||||
except:
|
||||
return []
|
||||
|
||||
def run_once(self, smart_mode=True):
|
||||
"""执行监控 (支持智能频率)"""
|
||||
if smart_mode:
|
||||
schedule = self.should_run_now()
|
||||
if not schedule.get("run"):
|
||||
return []
|
||||
|
||||
stocks_to_check = schedule.get("stocks", WATCHLIST)
|
||||
mode = schedule.get("mode", "normal")
|
||||
|
||||
# 只在特定模式打印日志
|
||||
if mode in ["market", "weekend"]:
|
||||
print(f"[{datetime.now().strftime('%H:%M')}] {mode}模式扫描 {len(stocks_to_check)} 只标的...")
|
||||
else:
|
||||
stocks_to_check = WATCHLIST
|
||||
|
||||
data_map = self.fetch_sina_realtime(stocks_to_check)
|
||||
triggered = []
|
||||
|
||||
for stock in stocks_to_check:
|
||||
code = stock['code']
|
||||
if code not in data_map: continue
|
||||
|
||||
data = data_map[code]
|
||||
|
||||
# 数据有效性检查
|
||||
if data['price'] <= 0 or data['prev_close'] <= 0:
|
||||
continue
|
||||
|
||||
alerts, level = self.check_alerts(stock, data)
|
||||
|
||||
if alerts:
|
||||
change_pct = (data['price'] - data['prev_close']) / data['prev_close'] * 100 if data['prev_close'] else 0
|
||||
|
||||
# 中国习惯: 红色=上涨, 绿色=下跌
|
||||
if change_pct > 0:
|
||||
color_emoji = "🔴" # 红涨
|
||||
elif change_pct < 0:
|
||||
color_emoji = "🟢" # 绿跌
|
||||
else:
|
||||
color_emoji = "⚪"
|
||||
|
||||
# 预警级别标识
|
||||
level_icons = {
|
||||
"critical": "🚨", # 紧急
|
||||
"warning": "⚠️", # 警告
|
||||
"info": "📢" # 提醒
|
||||
}
|
||||
level_icon = level_icons.get(level, "📢")
|
||||
level_text = {"critical": "【紧急】", "warning": "【警告】", "info": "【提醒】"}.get(level, "")
|
||||
|
||||
msg = f"<b>{level_icon} {level_text}{color_emoji} {stock['name']} ({code})</b>\n"
|
||||
msg += f"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
msg += f"💰 当前价格: <b>{data['price']:.2f}</b> ({change_pct:+.2f}%)\n"
|
||||
|
||||
# 显示持仓盈亏
|
||||
cost = stock.get('cost', 0)
|
||||
if cost > 0:
|
||||
cost_change = (data['price'] - cost) / cost * 100
|
||||
profit_icon = "🔴+" if cost_change > 0 else "🟢"
|
||||
msg += f"📊 持仓成本: ¥{cost:.2f} | 盈亏: {profit_icon}{cost_change:.2f}%\n"
|
||||
|
||||
msg += f"\n🎯 触发预警 ({len(alerts)}项):\n"
|
||||
for _, text in alerts:
|
||||
msg += f" • {text}\n"
|
||||
self.record_alert(code, _)
|
||||
|
||||
# Pro版:集成智能分析
|
||||
try:
|
||||
from analyser import StockAnalyser
|
||||
analyser = StockAnalyser()
|
||||
insight = analyser.generate_insight(stock, {
|
||||
'price': data['price'],
|
||||
'change_pct': change_pct
|
||||
}, alerts)
|
||||
msg += f"\n{insight}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
triggered.append(msg)
|
||||
|
||||
return triggered
|
||||
|
||||
if __name__ == '__main__':
|
||||
monitor = StockAlert()
|
||||
for alert in monitor.run_once():
|
||||
print(alert)
|
||||
107
skills/stock-monitor-pro/scripts/monitor_daemon.py
Normal file
107
skills/stock-monitor-pro/scripts/monitor_daemon.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stock Monitor Daemon - 后台常驻进程
|
||||
自动运行监控,智能控制频率,支持 graceful shutdown
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 设置日志
|
||||
log_dir = Path.home() / ".stock_monitor"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_dir / "monitor.log"),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 导入监控类
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from monitor import StockAlert, WATCHLIST
|
||||
|
||||
class MonitorDaemon:
|
||||
def __init__(self):
|
||||
self.monitor = StockAlert()
|
||||
self.running = True
|
||||
self.last_run_time = 0
|
||||
|
||||
# 设置信号处理
|
||||
signal.signal(signal.SIGTERM, self.handle_shutdown)
|
||||
signal.signal(signal.SIGINT, self.handle_shutdown)
|
||||
|
||||
def handle_shutdown(self, signum, frame):
|
||||
"""优雅退出"""
|
||||
logger.info(f"收到信号 {signum},正在关闭...")
|
||||
self.running = False
|
||||
|
||||
def get_sleep_interval(self):
|
||||
"""根据当前时间获取睡眠间隔"""
|
||||
schedule = self.monitor.should_run_now()
|
||||
if not schedule.get("run"):
|
||||
# 如果当前不需要运行,计算到下次运行的时间
|
||||
now = datetime.now()
|
||||
hour = now.hour
|
||||
|
||||
# 凌晨时段,1小时后检查
|
||||
if 0 <= hour < 9:
|
||||
return 3600
|
||||
return 300 # 默认5分钟
|
||||
|
||||
return schedule.get("interval", 300)
|
||||
|
||||
def run(self):
|
||||
"""主循环"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("🚀 Stock Monitor Daemon 启动")
|
||||
logger.info(f"📋 监控标的: {len(WATCHLIST)} 只")
|
||||
logger.info("=" * 60)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# 检查是否应该执行
|
||||
schedule = self.monitor.should_run_now()
|
||||
|
||||
if schedule.get("run"):
|
||||
mode = schedule.get("mode", "normal")
|
||||
stocks_count = len(schedule.get("stocks", []))
|
||||
logger.info(f"[{mode}] 扫描 {stocks_count} 只标的...")
|
||||
|
||||
# 执行监控
|
||||
alerts = self.monitor.run_once(smart_mode=False) # 已经判断过了
|
||||
|
||||
if alerts:
|
||||
logger.info(f"⚠️ 触发 {len(alerts)} 条预警")
|
||||
# 这里会通过 message 工具发送通知
|
||||
else:
|
||||
logger.debug("✅ 无预警")
|
||||
|
||||
self.last_run_time = time.time()
|
||||
|
||||
# 计算睡眠间隔
|
||||
sleep_interval = self.get_sleep_interval()
|
||||
logger.debug(f"下次检查: {sleep_interval} 秒后")
|
||||
|
||||
# 分段睡眠,方便及时响应退出信号
|
||||
slept = 0
|
||||
while slept < sleep_interval and self.running:
|
||||
time.sleep(1)
|
||||
slept += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"运行出错: {e}", exc_info=True)
|
||||
time.sleep(60) # 出错后等待1分钟重试
|
||||
|
||||
logger.info("👋 Daemon 已停止")
|
||||
|
||||
if __name__ == '__main__':
|
||||
daemon = MonitorDaemon()
|
||||
daemon.run()
|
||||
903
skills/stock-monitor-pro/scripts/monitor_v2.py
Normal file
903
skills/stock-monitor-pro/scripts/monitor_v2.py
Normal file
@@ -0,0 +1,903 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
自选股监控预警工具 V2 - 反爬虫优化版
|
||||
支持 A股、ETF
|
||||
优化: Session级UA绑定、完整请求头、3-10分钟随机延迟、多数据源冗余
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# ============ 配置区 ============
|
||||
|
||||
WATCHLIST = [
|
||||
{
|
||||
"code": "159142",
|
||||
"name": "科创创业人工智能ETF",
|
||||
"market": "sz",
|
||||
"type": "etf",
|
||||
"cost": 1.158,
|
||||
"alerts": {
|
||||
"cost_pct_above": 10.0,
|
||||
"cost_pct_below": -15.0,
|
||||
"target_buy": 0.98,
|
||||
"change_pct_above": 3.0,
|
||||
"change_pct_below": -3.0,
|
||||
"volume_surge": 2.0,
|
||||
"ma_monitor": True,
|
||||
"rsi_monitor": True,
|
||||
"gap_monitor": True,
|
||||
"trailing_stop": False
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "159213",
|
||||
"name": "机器人ETF汇添富",
|
||||
"market": "sz",
|
||||
"type": "etf",
|
||||
"cost": 1.307,
|
||||
"alerts": {
|
||||
"cost_pct_above": 10.0,
|
||||
"cost_pct_below": -15.0,
|
||||
"target_buy": 1.11,
|
||||
"change_pct_above": 3.0,
|
||||
"change_pct_below": -3.0,
|
||||
"volume_surge": 2.0,
|
||||
"ma_monitor": True,
|
||||
"rsi_monitor": True,
|
||||
"gap_monitor": True,
|
||||
"trailing_stop": False
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "159828",
|
||||
"name": "医疗ETF",
|
||||
"market": "sz",
|
||||
"type": "etf",
|
||||
"cost": 0.469,
|
||||
"note": "策略:涨到¥0.45减仓50%,跌破¥0.40止损",
|
||||
"alerts": {
|
||||
"cost_pct_above": 10.0,
|
||||
"cost_pct_below": -14.7,
|
||||
"stop_loss": 0.40,
|
||||
"target_reduce": 0.45,
|
||||
"change_pct_above": 3.0,
|
||||
"change_pct_below": -3.0,
|
||||
"volume_surge": 2.0,
|
||||
"ma_monitor": True,
|
||||
"rsi_monitor": True,
|
||||
"gap_monitor": True,
|
||||
"trailing_stop": False
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# UA池 - Session启动时随机选择一个
|
||||
USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Edg/119.0.0.0"
|
||||
]
|
||||
|
||||
# ============ 核心代码 ============
|
||||
|
||||
class StockAlert:
|
||||
def __init__(self):
|
||||
self.prev_data = {}
|
||||
self.alert_log = []
|
||||
self.failed_sources = {} # 记录各数据源失败次数
|
||||
self.source_cooldown = {} # 数据源冷却时间
|
||||
self.error_notifications = {} # 错误通知记录(防重复)
|
||||
self.NOTIFICATION_COOLDOWN = 1800 # 错误通知冷却30分钟
|
||||
|
||||
# 日报相关
|
||||
self.daily_report_sent = False # 今日日报是否已发送
|
||||
self.daily_data = {} # 存储当日数据用于日报
|
||||
self.today_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Session级UA绑定 - 整个生命周期使用同一个UA
|
||||
self.user_agent = random.choice(USER_AGENTS)
|
||||
print(f"[初始化] 使用UA: {self.user_agent[:60]}...")
|
||||
|
||||
# 创建带完整请求头的session
|
||||
self.session = requests.Session()
|
||||
self._setup_session_headers()
|
||||
|
||||
def _setup_session_headers(self):
|
||||
"""设置完整的浏览器指纹请求头"""
|
||||
self.session.headers.update({
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Cache-Control": "max-age=0"
|
||||
})
|
||||
|
||||
def _random_delay(self, min_sec=0.5, max_sec=3.0):
|
||||
"""请求前随机延迟,模拟真人操作间隔"""
|
||||
delay = random.uniform(min_sec, max_sec)
|
||||
time.sleep(delay)
|
||||
return delay
|
||||
|
||||
def _is_source_available(self, source_name):
|
||||
"""检查数据源是否可用(冷却期已过)"""
|
||||
if source_name in self.source_cooldown:
|
||||
if time.time() < self.source_cooldown[source_name]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _mark_source_failed(self, source_name, cooldown_minutes=5):
|
||||
"""标记数据源失败,进入冷却期"""
|
||||
self.failed_sources[source_name] = self.failed_sources.get(source_name, 0) + 1
|
||||
# 连续失败3次以上,冷却30分钟
|
||||
if self.failed_sources[source_name] >= 3:
|
||||
cooldown_minutes = 30
|
||||
self.source_cooldown[source_name] = time.time() + cooldown_minutes * 60
|
||||
print(f"[数据源] {source_name} 标记为失败,冷却{cooldown_minutes}分钟")
|
||||
|
||||
def _mark_source_success(self, source_name):
|
||||
"""标记数据源成功,重置失败计数"""
|
||||
if source_name in self.failed_sources:
|
||||
del self.failed_sources[source_name]
|
||||
|
||||
def should_run_now(self):
|
||||
"""智能频率控制: 3-10分钟随机"""
|
||||
now = datetime.now() + timedelta(hours=13) # 北京时间
|
||||
hour, minute = now.hour, now.minute
|
||||
time_val = hour * 100 + minute
|
||||
weekday = now.weekday()
|
||||
|
||||
# 周末低频
|
||||
if weekday >= 5:
|
||||
return {"run": True, "mode": "weekend", "stocks": WATCHLIST, "interval": random.randint(600, 1800)}
|
||||
|
||||
# 交易时间 (9:30-11:30, 13:00-15:00)
|
||||
morning_session = 930 <= time_val <= 1130
|
||||
afternoon_session = 1300 <= time_val <= 1500
|
||||
|
||||
if morning_session or afternoon_session:
|
||||
# 交易活跃时段:3-6分钟随机
|
||||
return {"run": True, "mode": "market", "stocks": WATCHLIST, "interval": random.randint(180, 360)}
|
||||
|
||||
# 午休 (11:30-13:00)
|
||||
if 1130 < time_val < 1300:
|
||||
return {"run": True, "mode": "lunch", "stocks": WATCHLIST, "interval": random.randint(300, 600)}
|
||||
|
||||
# 收盘后 (15:00-24:00)
|
||||
if 1500 <= time_val <= 2359:
|
||||
return {"run": True, "mode": "after_hours", "stocks": WATCHLIST, "interval": random.randint(900, 1800)}
|
||||
|
||||
# 凌晨 (0:00-9:30)
|
||||
if 0 <= time_val < 930:
|
||||
return {"run": True, "mode": "night", "stocks": WATCHLIST, "interval": random.randint(1800, 3600)}
|
||||
|
||||
return {"run": False}
|
||||
|
||||
# ============ 多数据源获取 ============
|
||||
|
||||
def fetch_sina_realtime(self, stocks):
|
||||
"""数据源1: 新浪财经实时行情"""
|
||||
source_name = "sina"
|
||||
if not self._is_source_available(source_name):
|
||||
return None, "冷却中"
|
||||
|
||||
stock_list = [s for s in stocks if s['market'] != 'fx']
|
||||
if not stock_list:
|
||||
return {}, None
|
||||
|
||||
codes = [f"{s['market']}{s['code']}" for s in stock_list]
|
||||
url = f"https://hq.sinajs.cn/list={','.join(codes)}"
|
||||
|
||||
try:
|
||||
self._random_delay(0.3, 1.0)
|
||||
resp = self.session.get(url, headers={'Referer': 'https://finance.sina.com.cn'}, timeout=10)
|
||||
resp.encoding = 'gb18030'
|
||||
|
||||
results = {}
|
||||
for line in resp.text.strip().split(';'):
|
||||
if 'hq_str_' not in line or '=' not in line:
|
||||
continue
|
||||
key = line.split('=')[0].split('_')[-1]
|
||||
if len(key) < 8:
|
||||
continue
|
||||
data_str = line[line.index('"')+1 : line.rindex('"')]
|
||||
p = data_str.split(',')
|
||||
if len(p) > 30 and float(p[3]) > 0:
|
||||
results[key[2:]] = {
|
||||
'name': p[0],
|
||||
'price': float(p[3]),
|
||||
'prev_close': float(p[2]),
|
||||
'open': float(p[1]),
|
||||
'high': float(p[4]),
|
||||
'low': float(p[5]),
|
||||
'volume': int(p[8]),
|
||||
'amount': float(p[9]),
|
||||
'date': p[30],
|
||||
'time': p[31],
|
||||
'source': 'sina'
|
||||
}
|
||||
|
||||
if results:
|
||||
self._mark_source_success(source_name)
|
||||
return results, None
|
||||
return None, "返回数据为空"
|
||||
|
||||
except Exception as e:
|
||||
self._mark_source_failed(source_name)
|
||||
return None, str(e)
|
||||
|
||||
def fetch_tencent_realtime(self, stocks):
|
||||
"""数据源2: 腾讯财经实时行情 (备用)"""
|
||||
source_name = "tencent"
|
||||
if not self._is_source_available(source_name):
|
||||
return None, "冷却中"
|
||||
|
||||
stock_list = [s for s in stocks if s['market'] != 'fx']
|
||||
if not stock_list:
|
||||
return {}, None
|
||||
|
||||
codes = [f"{s['market']}{s['code']}" for s in stock_list]
|
||||
url = f"https://qt.gtimg.cn/q={','.join(codes)}"
|
||||
|
||||
try:
|
||||
self._random_delay(0.3, 1.0)
|
||||
resp = self.session.get(url, timeout=10)
|
||||
resp.encoding = 'gb18030'
|
||||
|
||||
results = {}
|
||||
for line in resp.text.strip().split(';'):
|
||||
if 'v_' not in line or '=' not in line:
|
||||
continue
|
||||
key = line.split('=')[0].split('_')[-1]
|
||||
data_str = line[line.index('"')+1 : line.rindex('"')]
|
||||
p = data_str.split('~')
|
||||
if len(p) > 40:
|
||||
# 腾讯格式: 股票名称~股票代码~当前价格~昨收~今开...
|
||||
results[key[2:]] = {
|
||||
'name': p[1],
|
||||
'price': float(p[3]),
|
||||
'prev_close': float(p[4]),
|
||||
'open': float(p[5]),
|
||||
'high': float(p[33]),
|
||||
'low': p[34],
|
||||
'volume': int(p[36]),
|
||||
'amount': float(p[37]),
|
||||
'source': 'tencent'
|
||||
}
|
||||
|
||||
if results:
|
||||
self._mark_source_success(source_name)
|
||||
return results, None
|
||||
return None, "返回数据为空"
|
||||
|
||||
except Exception as e:
|
||||
self._mark_source_failed(source_name)
|
||||
return None, str(e)
|
||||
|
||||
def fetch_eastmoney_kline(self, symbol, market):
|
||||
"""数据源3: 东方财富K线数据 (均线/RSI/成交量)"""
|
||||
source_name = "eastmoney"
|
||||
if not self._is_source_available(source_name):
|
||||
return None, "冷却中"
|
||||
|
||||
secid = f"{market}.{symbol}"
|
||||
url = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
params = {
|
||||
'secid': secid,
|
||||
'fields1': 'f1,f2,f3,f4,f5,f6',
|
||||
'fields2': 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61',
|
||||
'klt': '101',
|
||||
'fqt': '0',
|
||||
'end': '20500101',
|
||||
'lmt': '30'
|
||||
}
|
||||
|
||||
try:
|
||||
self._random_delay(0.5, 1.5)
|
||||
resp = self.session.get(url, params=params, timeout=10)
|
||||
data = resp.json()
|
||||
klines = data.get('data', {}).get('klines', [])
|
||||
|
||||
if len(klines) >= 20:
|
||||
self._mark_source_success(source_name)
|
||||
return klines, None
|
||||
return None, "数据不足"
|
||||
|
||||
except Exception as e:
|
||||
self._mark_source_failed(source_name)
|
||||
return None, str(e)
|
||||
|
||||
def fetch_ths_kline(self, symbol, market):
|
||||
"""数据源4: 同花顺K线数据 (备用)"""
|
||||
source_name = "ths"
|
||||
if not self._is_source_available(source_name):
|
||||
return None, "冷却中"
|
||||
|
||||
# 同花顺代码格式
|
||||
ths_code = f"{market}{symbol}"
|
||||
url = f"http://d.10jqka.com.cn/v6/line/{ths_code}/01/all.js"
|
||||
|
||||
try:
|
||||
self._random_delay(0.5, 1.5)
|
||||
headers = {
|
||||
'Referer': f'http://stockpage.10jqka.com.cn/{symbol}/',
|
||||
'User-Agent': self.user_agent
|
||||
}
|
||||
resp = self.session.get(url, headers=headers, timeout=10)
|
||||
|
||||
# 同花顺返回的是JSONP格式,需要解析
|
||||
text = resp.text
|
||||
if '(' in text and ')' in text:
|
||||
json_str = text[text.index('(')+1:text.rindex(')')]
|
||||
data = json.loads(json_str)
|
||||
|
||||
# 解析K线数据
|
||||
klines = data.get('data', '').split(';')
|
||||
if len(klines) >= 20:
|
||||
self._mark_source_success(source_name)
|
||||
return klines, None
|
||||
return None, "解析失败"
|
||||
|
||||
except Exception as e:
|
||||
self._mark_source_failed(source_name)
|
||||
return None, str(e)
|
||||
|
||||
# ============ 技术指标计算 ============
|
||||
|
||||
def calculate_indicators(self, klines):
|
||||
"""从K线计算技术指标"""
|
||||
if not klines or len(klines) < 20:
|
||||
return None
|
||||
|
||||
closes = []
|
||||
volumes = []
|
||||
|
||||
for k in klines:
|
||||
if isinstance(k, str):
|
||||
p = k.split(',')
|
||||
if len(p) >= 6:
|
||||
closes.append(float(p[2])) # 收盘价
|
||||
volumes.append(float(p[5])) # 成交量
|
||||
elif isinstance(k, dict):
|
||||
closes.append(float(k.get('close', 0)))
|
||||
volumes.append(float(k.get('volume', 0)))
|
||||
|
||||
if len(closes) < 20:
|
||||
return None
|
||||
|
||||
# 计算均线
|
||||
ma5 = sum(closes[-5:]) / 5
|
||||
ma10 = sum(closes[-10:]) / 10
|
||||
ma20 = sum(closes[-20:]) / 20
|
||||
|
||||
prev_ma5 = sum(closes[-6:-1]) / 5
|
||||
prev_ma10 = sum(closes[-11:-1]) / 10
|
||||
|
||||
# 计算5日均量
|
||||
volume_ma5 = sum(volumes[-6:-1]) / 5 if len(volumes) >= 6 else 0
|
||||
today_volume = volumes[-1] if volumes else 0
|
||||
|
||||
# 计算RSI(14)
|
||||
rsi = self._calculate_rsi(closes, 14)
|
||||
|
||||
return {
|
||||
'MA5': ma5,
|
||||
'MA10': ma10,
|
||||
'MA20': ma20,
|
||||
'MA5_trend': 'up' if ma5 > prev_ma5 else 'down',
|
||||
'MA10_trend': 'up' if ma10 > prev_ma10 else 'down',
|
||||
'golden_cross': prev_ma5 <= prev_ma10 and ma5 > ma10,
|
||||
'death_cross': prev_ma5 >= prev_ma10 and ma5 < ma10,
|
||||
'RSI': rsi,
|
||||
'RSI_overbought': rsi > 70 if rsi else False,
|
||||
'RSI_oversold': rsi < 30 if rsi else False,
|
||||
'volume_ma5': volume_ma5,
|
||||
'volume_ratio': today_volume / volume_ma5 if volume_ma5 > 0 else 0
|
||||
}
|
||||
|
||||
def _calculate_rsi(self, closes, period=14):
|
||||
"""计算RSI指标"""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
gains = []
|
||||
losses = []
|
||||
|
||||
for i in range(1, period + 1):
|
||||
change = closes[-i] - closes[-i-1]
|
||||
if change > 0:
|
||||
gains.append(change)
|
||||
losses.append(0)
|
||||
else:
|
||||
gains.append(0)
|
||||
losses.append(abs(change))
|
||||
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100
|
||||
|
||||
rs = avg_gain / avg_loss
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return round(rsi, 2)
|
||||
|
||||
# ============ 主监控逻辑 ============
|
||||
|
||||
def get_stock_data(self, stock):
|
||||
"""获取单只股票的完整数据(多源降级)"""
|
||||
code = stock['code']
|
||||
market = 0 if stock['market'] == 'sh' else 1 # 东财用的市场代码
|
||||
|
||||
result = {
|
||||
'code': code,
|
||||
'name': stock['name'],
|
||||
'price': 0,
|
||||
'prev_close': 0,
|
||||
'change_pct': 0,
|
||||
'volume': 0,
|
||||
'indicators': None,
|
||||
'sources_used': [],
|
||||
'errors': []
|
||||
}
|
||||
|
||||
# Step 1: 获取实时价格(优先级: 新浪 → 腾讯)
|
||||
realtime_data = None
|
||||
|
||||
# 尝试新浪
|
||||
sina_data, sina_err = self.fetch_sina_realtime([stock])
|
||||
if sina_data and code in sina_data:
|
||||
realtime_data = sina_data[code]
|
||||
result['sources_used'].append('sina')
|
||||
else:
|
||||
if sina_err:
|
||||
result['errors'].append(f"新浪: {sina_err}")
|
||||
|
||||
# 新浪失败,尝试腾讯
|
||||
if not realtime_data:
|
||||
tencent_data, tencent_err = self.fetch_tencent_realtime([stock])
|
||||
if tencent_data and code in tencent_data:
|
||||
realtime_data = tencent_data[code]
|
||||
result['sources_used'].append('tencent')
|
||||
else:
|
||||
if tencent_err:
|
||||
result['errors'].append(f"腾讯: {tencent_err}")
|
||||
|
||||
if realtime_data:
|
||||
result['price'] = realtime_data['price']
|
||||
result['prev_close'] = realtime_data.get('prev_close', realtime_data['price'])
|
||||
result['volume'] = realtime_data.get('volume', 0)
|
||||
result['change_pct'] = round((result['price'] - result['prev_close']) / result['prev_close'] * 100, 2)
|
||||
else:
|
||||
result['errors'].append("无法获取实时价格")
|
||||
return result
|
||||
|
||||
# Step 2: 获取技术指标(优先级: 东财 → 同花顺)
|
||||
klines = None
|
||||
|
||||
# 尝试东财
|
||||
em_klines, em_err = self.fetch_eastmoney_kline(code, market)
|
||||
if em_klines:
|
||||
klines = em_klines
|
||||
result['sources_used'].append('eastmoney')
|
||||
else:
|
||||
if em_err:
|
||||
result['errors'].append(f"东财: {em_err}")
|
||||
|
||||
# 东财失败,尝试同花顺
|
||||
if not klines:
|
||||
ths_klines, ths_err = self.fetch_ths_kline(code, stock['market'])
|
||||
if ths_klines:
|
||||
klines = ths_klines
|
||||
result['sources_used'].append('ths')
|
||||
else:
|
||||
if ths_err:
|
||||
result['errors'].append(f"同花顺: {ths_err}")
|
||||
|
||||
# 计算技术指标
|
||||
if klines:
|
||||
result['indicators'] = self.calculate_indicators(klines)
|
||||
else:
|
||||
result['errors'].append("无法获取技术指标")
|
||||
|
||||
return result
|
||||
|
||||
def check_alerts(self, stock, data):
|
||||
"""检查预警条件"""
|
||||
alerts = []
|
||||
config = stock['alerts']
|
||||
price = data['price']
|
||||
cost = stock['cost']
|
||||
change_pct = data['change_pct']
|
||||
indicators = data.get('indicators')
|
||||
|
||||
if price <= 0:
|
||||
return alerts
|
||||
|
||||
# 1. 成本百分比预警
|
||||
cost_change_pct = round((price - cost) / cost * 100, 2)
|
||||
if config.get('cost_pct_above') and cost_change_pct >= config['cost_pct_above']:
|
||||
alerts.append({
|
||||
'level': 'warning',
|
||||
'type': 'cost_profit',
|
||||
'message': f"盈利 {cost_change_pct}% (目标 {config['cost_pct_above']}%)"
|
||||
})
|
||||
if config.get('cost_pct_below') and cost_change_pct <= config['cost_pct_below']:
|
||||
alerts.append({
|
||||
'level': 'warning',
|
||||
'type': 'cost_loss',
|
||||
'message': f"亏损 {abs(cost_change_pct)}% (阈值 {abs(config['cost_pct_below'])}%)"
|
||||
})
|
||||
|
||||
# 2. 日内涨跌幅预警
|
||||
if config.get('change_pct_above') and change_pct >= config['change_pct_above']:
|
||||
alerts.append({
|
||||
'level': 'info',
|
||||
'type': 'rise',
|
||||
'message': f"日内大涨 {change_pct}%"
|
||||
})
|
||||
if config.get('change_pct_below') and change_pct <= config['change_pct_below']:
|
||||
alerts.append({
|
||||
'level': 'info',
|
||||
'type': 'fall',
|
||||
'message': f"日内大跌 {abs(change_pct)}%"
|
||||
})
|
||||
|
||||
# 3. 技术指标预警(如果有数据)
|
||||
if indicators:
|
||||
# 成交量异动
|
||||
if config.get('volume_surge') and indicators.get('volume_ratio', 0) >= config['volume_surge']:
|
||||
alerts.append({
|
||||
'level': 'info',
|
||||
'type': 'volume',
|
||||
'message': f"放量 {indicators['volume_ratio']:.1f}倍"
|
||||
})
|
||||
|
||||
# 均线金叉死叉
|
||||
if config.get('ma_monitor'):
|
||||
if indicators.get('golden_cross'):
|
||||
alerts.append({
|
||||
'level': 'warning',
|
||||
'type': 'golden_cross',
|
||||
'message': f"均线金叉 (MA5:{indicators['MA5']:.2f} > MA10:{indicators['MA10']:.2f})"
|
||||
})
|
||||
if indicators.get('death_cross'):
|
||||
alerts.append({
|
||||
'level': 'warning',
|
||||
'type': 'death_cross',
|
||||
'message': f"均线死叉 (MA5:{indicators['MA5']:.2f} < MA10:{indicators['MA10']:.2f})"
|
||||
})
|
||||
|
||||
# RSI超买超卖
|
||||
if config.get('rsi_monitor'):
|
||||
if indicators.get('RSI_overbought'):
|
||||
alerts.append({
|
||||
'level': 'info',
|
||||
'type': 'rsi_high',
|
||||
'message': f"RSI超买 {indicators['RSI']}"
|
||||
})
|
||||
if indicators.get('RSI_oversold'):
|
||||
alerts.append({
|
||||
'level': 'info',
|
||||
'type': 'rsi_low',
|
||||
'message': f"RSI超卖 {indicators['RSI']}"
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
def format_message(self, stock, data, alerts):
|
||||
"""格式化预警消息"""
|
||||
if not alerts:
|
||||
return None
|
||||
|
||||
price = data['price']
|
||||
cost = stock['cost']
|
||||
change_pct = data['change_pct']
|
||||
cost_change_pct = round((price - cost) / cost * 100, 2) if cost > 0 else 0
|
||||
|
||||
# 确定级别
|
||||
high_priority = [a for a in alerts if a['level'] == 'warning']
|
||||
level_icon = "🚨" if len(high_priority) >= 2 else ("⚠️" if high_priority else "📢")
|
||||
level_text = "紧急" if len(high_priority) >= 2 else ("警告" if high_priority else "提醒")
|
||||
|
||||
# 颜色标识
|
||||
color_icon = "🔴" if change_pct >= 0 else "🟢"
|
||||
profit_icon = "🔴" if cost_change_pct >= 0 else "🟢"
|
||||
|
||||
msg_lines = [
|
||||
f"{level_icon}【{level_text}】{color_icon} {stock['name']} ({stock['code']})",
|
||||
"━━━━━━━━━━━━━━━━━━━━",
|
||||
f"💰 当前价格: ¥{price:.3f} ({change_pct:+.2f}%)",
|
||||
f"📊 持仓成本: ¥{cost:.3f} | 盈亏: {profit_icon}{cost_change_pct:+.1f}%"
|
||||
]
|
||||
|
||||
# 预警详情
|
||||
if alerts:
|
||||
msg_lines.append("")
|
||||
msg_lines.append(f"🎯 触发预警 ({len(alerts)}项):")
|
||||
for alert in alerts:
|
||||
icon = {"cost_profit": "🎯", "cost_loss": "🛑", "rise": "📈", "fall": "📉",
|
||||
"volume": "📊", "golden_cross": "🌟", "death_cross": "⚡",
|
||||
"rsi_high": "🔥", "rsi_low": "❄️"}.get(alert['type'], "•")
|
||||
msg_lines.append(f" {icon} {alert['message']}")
|
||||
|
||||
# 数据源信息
|
||||
msg_lines.append("")
|
||||
msg_lines.append(f"📡 数据来源: {' → '.join(data.get('sources_used', ['未知']))}")
|
||||
|
||||
return "\n".join(msg_lines)
|
||||
|
||||
def run_once(self):
|
||||
"""执行一次监控循环"""
|
||||
print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 开始扫描...")
|
||||
|
||||
messages = []
|
||||
|
||||
for stock in WATCHLIST:
|
||||
print(f" 检查 {stock['name']}...")
|
||||
|
||||
# 获取数据
|
||||
data = self.get_stock_data(stock)
|
||||
|
||||
if data['errors'] and not data['sources_used']:
|
||||
print(f" ❌ 完全失败: {'; '.join(data['errors'])}")
|
||||
continue
|
||||
|
||||
print(f" ✅ 价格: ¥{data['price']:.3f} (来源: {'→'.join(data['sources_used'])})")
|
||||
|
||||
# 检查预警
|
||||
alerts = self.check_alerts(stock, data)
|
||||
|
||||
if alerts:
|
||||
msg = self.format_message(stock, data, alerts)
|
||||
if msg:
|
||||
messages.append(msg)
|
||||
print(f" 🔔 触发 {len(alerts)} 个预警")
|
||||
# 记录预警次数用于日报
|
||||
data['alert_count'] = len(alerts)
|
||||
else:
|
||||
data['alert_count'] = 0
|
||||
|
||||
# 保存数据用于日报
|
||||
self.daily_data[stock['code']] = data
|
||||
|
||||
# 错误提示(但不影响功能)
|
||||
if data['errors'] and data['sources_used']:
|
||||
print(f" ⚠️ 部分失败: {'; '.join(data['errors'])}")
|
||||
|
||||
return messages
|
||||
|
||||
def check_and_notify_errors(self):
|
||||
"""检查数据源错误并发送通知"""
|
||||
notifications = []
|
||||
now = time.time()
|
||||
|
||||
for source, fail_count in self.failed_sources.items():
|
||||
# 连续失败3次以上,或刚进入30分钟冷却
|
||||
if fail_count >= 3:
|
||||
# 避免重复通知:每小时只通知一次
|
||||
last_notify_key = f"error_notified_{source}"
|
||||
last_notify = getattr(self, last_notify_key, 0)
|
||||
|
||||
if now - last_notify > 1800: # 30分钟冷却
|
||||
cooldown_end = self.source_cooldown.get(source, 0)
|
||||
remaining = int((cooldown_end - now) / 60) if cooldown_end > now else 0
|
||||
|
||||
notifications.append({
|
||||
'source': source,
|
||||
'fail_count': fail_count,
|
||||
'cooldown_minutes': remaining
|
||||
})
|
||||
setattr(self, last_notify_key, now)
|
||||
|
||||
return notifications
|
||||
|
||||
def format_error_notification(self, errors):
|
||||
"""格式化错误通知消息"""
|
||||
if not errors:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
"⚠️【数据源异常提醒】",
|
||||
"━━━━━━━━━━━━━━━━━━━━",
|
||||
"以下数据源连续失败,已进入冷却期:",
|
||||
""
|
||||
]
|
||||
|
||||
source_names = {
|
||||
'sina': '新浪财经',
|
||||
'tencent': '腾讯财经',
|
||||
'eastmoney': '东方财富',
|
||||
'ths': '同花顺'
|
||||
}
|
||||
|
||||
for err in errors:
|
||||
name = source_names.get(err['source'], err['source'])
|
||||
lines.append(f"• {name}: 失败{err['fail_count']}次,冷却{err['cooldown_minutes']}分钟")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"📊 当前状态:",
|
||||
"• 实时价格监控:正常(新浪/腾讯备用)",
|
||||
"• 技术指标监控:可能受限(均线/RSI/成交量)",
|
||||
"",
|
||||
"💡 建议:",
|
||||
"如持续失败,可考虑部署WARP代理或调整请求频率"
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _reset_daily_report_flag(self):
|
||||
"""重置日报发送标志(新的一天)"""
|
||||
current_date = datetime.now().strftime('%Y-%m-%d')
|
||||
if current_date != self.today_date:
|
||||
self.today_date = current_date
|
||||
self.daily_report_sent = False
|
||||
self.daily_data = {}
|
||||
print(f"[日报] 日期已切换至 {current_date},重置日报标志")
|
||||
|
||||
def _check_and_send_daily_report(self, mode):
|
||||
"""检查并发送收盘日报"""
|
||||
# 重置日期标志
|
||||
self._reset_daily_report_flag()
|
||||
|
||||
# 只在收盘后模式且未发送过日报时发送
|
||||
if mode != 'after_hours' or self.daily_report_sent:
|
||||
return
|
||||
|
||||
# 检查当前时间是否在15:00-15:30之间(北京时间)
|
||||
now = datetime.now() + timedelta(hours=13) # 转换为北京时间
|
||||
hour, minute = now.hour, now.minute
|
||||
time_val = hour * 100 + minute
|
||||
|
||||
if not (1500 <= time_val <= 1530):
|
||||
return
|
||||
|
||||
# 生成并发送日报
|
||||
report = self._generate_daily_report()
|
||||
if report:
|
||||
print("\n" + report)
|
||||
# TODO: 调用OpenClaw发送日报
|
||||
self.daily_report_sent = True
|
||||
print(f"[日报] 收盘日报已发送 ({now.strftime('%H:%M')})")
|
||||
|
||||
def _generate_daily_report(self):
|
||||
"""生成收盘日报"""
|
||||
if not self.daily_data:
|
||||
return None
|
||||
|
||||
now = datetime.now() + timedelta(hours=13) # 北京时间
|
||||
date_str = now.strftime('%Y-%m-%d')
|
||||
|
||||
lines = [
|
||||
f"📊【收盘日报】{date_str}",
|
||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||||
""
|
||||
]
|
||||
|
||||
total_cost_value = 0
|
||||
total_current_value = 0
|
||||
total_day_change = 0
|
||||
alert_count = 0
|
||||
|
||||
for stock in WATCHLIST:
|
||||
code = stock['code']
|
||||
data = self.daily_data.get(code)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
price = data['price']
|
||||
cost = stock['cost']
|
||||
change_pct = data.get('change_pct', 0)
|
||||
cost_change_pct = round((price - cost) / cost * 100, 2) if cost > 0 else 0
|
||||
|
||||
# 计算市值(假设持仓1万份)
|
||||
position = 10000 # 默认持仓数量
|
||||
cost_value = cost * position
|
||||
current_value = price * position
|
||||
|
||||
total_cost_value += cost_value
|
||||
total_current_value += current_value
|
||||
total_day_change += change_pct
|
||||
|
||||
# 颜色标识
|
||||
profit_icon = "🔴" if cost_change_pct >= 0 else "🟢"
|
||||
day_icon = "🔴" if change_pct >= 0 else "🟢"
|
||||
|
||||
lines.append(f"📈 {stock['name']} ({code})")
|
||||
lines.append(f" 成本: ¥{cost:.3f} → 收盘: ¥{price:.3f}")
|
||||
lines.append(f" 持仓盈亏: {profit_icon}{cost_change_pct:+.2f}% | 日内涨跌: {day_icon}{change_pct:+.2f}%")
|
||||
lines.append("")
|
||||
|
||||
# 统计预警次数
|
||||
alert_count += data.get('alert_count', 0)
|
||||
|
||||
# 总体统计
|
||||
if total_cost_value > 0:
|
||||
total_profit_pct = round((total_current_value - total_cost_value) / total_cost_value * 100, 2)
|
||||
avg_day_change = round(total_day_change / len(WATCHLIST), 2)
|
||||
profit_icon = "🔴" if total_profit_pct >= 0 else "🟢"
|
||||
day_icon = "🔴" if avg_day_change >= 0 else "🟢"
|
||||
|
||||
lines.append("📋 今日汇总")
|
||||
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
lines.append(f"💰 总持仓盈亏: {profit_icon}{total_profit_pct:+.2f}%")
|
||||
lines.append(f"📊 平均日内涨跌: {day_icon}{avg_day_change:+.2f}%")
|
||||
lines.append(f"🔔 预警触发次数: {alert_count}")
|
||||
lines.append("")
|
||||
|
||||
# 市场点评
|
||||
if avg_day_change >= 2:
|
||||
comment = "🚀 今日市场表现强势,多只个股大涨"
|
||||
elif avg_day_change >= 0.5:
|
||||
comment = "📈 今日市场整体向好,稳步上涨"
|
||||
elif avg_day_change > -0.5:
|
||||
comment = "➡️ 今日市场震荡整理,波动较小"
|
||||
elif avg_day_change > -2:
|
||||
comment = "📉 今日市场小幅回调,注意风险"
|
||||
else:
|
||||
comment = "🛑 今日市场大幅下跌,谨慎操作"
|
||||
|
||||
lines.append(f"💡 {comment}")
|
||||
lines.append("")
|
||||
lines.append("━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
lines.append("📌 数据来源: 新浪财经/腾讯财经")
|
||||
lines.append("⏰ 下次日报: 下一交易日收盘后")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def run_forever(self):
|
||||
"""持续运行"""
|
||||
print("="*50)
|
||||
print("股票监控启动 (V2反爬虫优化版)")
|
||||
print(f"监控标的: {len(WATCHLIST)} 只")
|
||||
print(f"UA: {self.user_agent[:50]}...")
|
||||
print("="*50)
|
||||
|
||||
while True:
|
||||
schedule = self.should_run_now()
|
||||
if not schedule['run']:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# 执行监控
|
||||
messages = self.run_once()
|
||||
|
||||
# 发送消息(这里可以接入OpenClaw消息发送)
|
||||
for msg in messages:
|
||||
print("\n" + msg)
|
||||
# TODO: 调用OpenClaw发送消息
|
||||
|
||||
# 检查并发送错误通知
|
||||
error_notifications = self.check_and_notify_errors()
|
||||
if error_notifications:
|
||||
error_msg = self.format_error_notification(error_notifications)
|
||||
if error_msg:
|
||||
print("\n" + error_msg)
|
||||
# TODO: 调用OpenClaw发送错误通知
|
||||
|
||||
# 检查是否需要发送收盘日报(15:00-15:30之间,且未发送过)
|
||||
self._check_and_send_daily_report(schedule['mode'])
|
||||
|
||||
# 等待下次扫描(3-10分钟随机)
|
||||
interval = schedule.get('interval', random.randint(180, 600))
|
||||
next_time = datetime.now() + timedelta(seconds=interval)
|
||||
print(f"\n[{datetime.now().strftime('%H:%M:%S')}] 下次扫描: {next_time.strftime('%H:%M:%S')} (间隔{interval//60}分{interval%60}秒)")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
monitor = StockAlert()
|
||||
monitor.run_forever()
|
||||
273
skills/stock-monitor-pro/scripts/test_suite.py
Normal file
273
skills/stock-monitor-pro/scripts/test_suite.py
Normal file
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stock Monitor Pro - 完整测试套件
|
||||
测试所有功能模块,确保系统稳定性
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, '/home/wesley/.openclaw/workspace/skills/stock-monitor/scripts')
|
||||
|
||||
from monitor import StockAlert, WATCHLIST
|
||||
|
||||
|
||||
class TestDataFetching(unittest.TestCase):
|
||||
"""测试1: 数据获取模块"""
|
||||
|
||||
def setUp(self):
|
||||
self.monitor = StockAlert()
|
||||
|
||||
def test_sina_realtime_api(self):
|
||||
"""测试新浪实时行情API"""
|
||||
data = self.monitor.fetch_sina_realtime([WATCHLIST[0]])
|
||||
self.assertIn('600362', data)
|
||||
self.assertGreater(data['600362']['price'], 0)
|
||||
print("✅ 新浪实时行情API正常")
|
||||
|
||||
def test_gold_api(self):
|
||||
"""测试伦敦金API"""
|
||||
data = self.monitor.fetch_sina_realtime([WATCHLIST[-1]])
|
||||
self.assertIn('XAU', data)
|
||||
self.assertGreater(data['XAU']['price'], 4000) # 黄金应该在4000以上
|
||||
print("✅ 伦敦金API正常")
|
||||
|
||||
def test_data_validity(self):
|
||||
"""测试数据有效性检查"""
|
||||
data = self.monitor.fetch_sina_realtime(WATCHLIST[:3])
|
||||
for code, d in data.items():
|
||||
self.assertGreater(d['price'], 0, f"{code}价格无效")
|
||||
self.assertGreater(d['prev_close'], 0, f"{code}昨收无效")
|
||||
print("✅ 所有数据有效性检查通过")
|
||||
|
||||
|
||||
class TestAlertRules(unittest.TestCase):
|
||||
"""测试2: 预警规则模块"""
|
||||
|
||||
def setUp(self):
|
||||
self.monitor = StockAlert()
|
||||
|
||||
def test_cost_percentage_alert(self):
|
||||
"""测试成本百分比预警"""
|
||||
stock = WATCHLIST[0].copy()
|
||||
stock['alerts'] = {'cost_pct_above': 10.0, 'cost_pct_below': -10.0}
|
||||
|
||||
# 模拟盈利10%的数据
|
||||
data = {'price': 62.7, 'prev_close': 57.0, 'cost': 57.0} # 成本57,现价62.7=+10%
|
||||
alerts, level = self.monitor.check_alerts(stock, data)
|
||||
|
||||
has_profit_alert = any('盈利' in text for _, text in alerts)
|
||||
self.assertTrue(has_profit_alert, "应该有盈利预警")
|
||||
print("✅ 成本百分比预警正常")
|
||||
|
||||
def test_daily_change_alert(self):
|
||||
"""测试日内涨跌幅预警"""
|
||||
stock = WATCHLIST[0].copy()
|
||||
stock['alerts'] = {'change_pct_above': 5.0, 'change_pct_below': -5.0}
|
||||
|
||||
# 模拟大涨6%
|
||||
data = {'price': 60.42, 'prev_close': 57.0, 'cost': 57.0}
|
||||
alerts, level = self.monitor.check_alerts(stock, data)
|
||||
|
||||
has_change_alert = any('大涨' in text or '大跌' in text for _, text in alerts)
|
||||
self.assertTrue(has_change_alert, "应该有涨跌幅预警")
|
||||
print("✅ 日内涨跌幅预警正常")
|
||||
|
||||
def test_no_duplicate_alerts(self):
|
||||
"""测试防重复机制"""
|
||||
stock = WATCHLIST[0].copy()
|
||||
stock['alerts'] = {'cost_pct_above': 5.0}
|
||||
|
||||
data = {'price': 60.0, 'prev_close': 57.0, 'cost': 57.0}
|
||||
|
||||
# 第一次应该触发
|
||||
alerts1, _ = self.monitor.check_alerts(stock, data)
|
||||
self.assertGreater(len(alerts1), 0, "第一次应该触发预警")
|
||||
|
||||
# 记录预警
|
||||
for alert_type, _ in alerts1:
|
||||
self.monitor.record_alert(stock['code'], alert_type)
|
||||
|
||||
# 第二次不应该触发 (30分钟内)
|
||||
alerts2, _ = self.monitor.check_alerts(stock, data)
|
||||
self.assertEqual(len(alerts2), 0, "30分钟内不应重复触发")
|
||||
print("✅ 防重复机制正常")
|
||||
|
||||
|
||||
class TestAlertLevel(unittest.TestCase):
|
||||
"""测试3: 分级预警系统"""
|
||||
|
||||
def setUp(self):
|
||||
self.monitor = StockAlert()
|
||||
|
||||
def test_critical_level(self):
|
||||
"""测试紧急级别"""
|
||||
alerts = [('a', 'test'), ('b', 'test'), ('c', 'test')]
|
||||
weights = [3, 3, 3] # 总权重9
|
||||
level = self.monitor._calculate_alert_level(alerts, weights, 'individual')
|
||||
self.assertEqual(level, 'critical')
|
||||
print("✅ 紧急级别判断正常")
|
||||
|
||||
def test_warning_level(self):
|
||||
"""测试警告级别"""
|
||||
alerts = [('a', 'test'), ('b', 'test')]
|
||||
weights = [2, 2] # 总权重4
|
||||
level = self.monitor._calculate_alert_level(alerts, weights, 'individual')
|
||||
self.assertEqual(level, 'warning')
|
||||
print("✅ 警告级别判断正常")
|
||||
|
||||
def test_info_level(self):
|
||||
"""测试提醒级别"""
|
||||
alerts = [('a', 'test')]
|
||||
weights = [1]
|
||||
level = self.monitor._calculate_alert_level(alerts, weights, 'individual')
|
||||
self.assertEqual(level, 'info')
|
||||
print("✅ 提醒级别判断正常")
|
||||
|
||||
|
||||
class TestStockTypeDifferentiation(unittest.TestCase):
|
||||
"""测试4: 差异化配置"""
|
||||
|
||||
def test_individual_stock_threshold(self):
|
||||
"""测试个股阈值"""
|
||||
stock = [s for s in WATCHLIST if s.get('type') == 'individual'][0]
|
||||
self.assertEqual(stock['alerts']['change_pct_above'], 4.0)
|
||||
print("✅ 个股阈值配置正确")
|
||||
|
||||
def test_etf_threshold(self):
|
||||
"""测试ETF阈值"""
|
||||
stock = [s for s in WATCHLIST if s.get('type') == 'etf'][0]
|
||||
self.assertEqual(stock['alerts']['change_pct_above'], 2.0)
|
||||
print("✅ ETF阈值配置正确")
|
||||
|
||||
def test_gold_threshold(self):
|
||||
"""测试黄金阈值"""
|
||||
stock = [s for s in WATCHLIST if s.get('type') == 'gold'][0]
|
||||
self.assertEqual(stock['alerts']['change_pct_above'], 2.5)
|
||||
print("✅ 黄金阈值配置正确")
|
||||
|
||||
|
||||
class TestSmartSchedule(unittest.TestCase):
|
||||
"""测试5: 智能频率控制"""
|
||||
|
||||
def setUp(self):
|
||||
self.monitor = StockAlert()
|
||||
|
||||
def test_market_hours_detection(self):
|
||||
"""测试交易时间检测"""
|
||||
# 当前是纽约时间,转换成北京时间
|
||||
ny_now = datetime.now()
|
||||
beijing_now = ny_now + timedelta(hours=13)
|
||||
|
||||
schedule = self.monitor.should_run_now()
|
||||
self.assertIn('mode', schedule)
|
||||
self.assertIn(schedule['mode'], ['market', 'lunch', 'after_hours', 'night', 'weekend'])
|
||||
print(f"✅ 时间检测正常 (当前模式: {schedule['mode']})")
|
||||
|
||||
def test_interval_settings(self):
|
||||
"""测试不同模式的间隔设置"""
|
||||
schedule = self.monitor.should_run_now()
|
||||
interval = schedule.get('interval', 0)
|
||||
self.assertGreater(interval, 0)
|
||||
self.assertIn(interval, [300, 600, 1800, 3600]) # 5/10/30/60分钟
|
||||
print(f"✅ 间隔设置正常 ({interval//60}分钟)")
|
||||
|
||||
|
||||
class TestMessageFormat(unittest.TestCase):
|
||||
"""测试6: 消息格式"""
|
||||
|
||||
def setUp(self):
|
||||
self.monitor = StockAlert()
|
||||
|
||||
def test_message_contains_required_elements(self):
|
||||
"""测试消息包含必要元素"""
|
||||
# 模拟触发预警
|
||||
stock = WATCHLIST[0]
|
||||
data = {'price': 54.0, 'prev_close': 57.0, 'open': 55.0, 'high': 56.0, 'low': 53.0}
|
||||
alerts, level = [('cost_below', '📉 亏损10%')], 'warning'
|
||||
|
||||
# 构建消息
|
||||
change_pct = -5.26
|
||||
msg = f"<b>⚠️ 【警告】🟢 {stock['name']} ({stock['code']})</b>\n"
|
||||
msg += f"💰 当前价格: ¥{data['price']:.2f} ({change_pct:+.2f}%)\n"
|
||||
msg += f"🎯 触发预警:\n • {alerts[0][1]}\n"
|
||||
|
||||
# 检查必要元素
|
||||
self.assertIn('【警告】', msg)
|
||||
self.assertIn('🟢', msg) # 绿跌
|
||||
self.assertIn('💰', msg)
|
||||
self.assertIn('🎯', msg)
|
||||
print("✅ 消息格式包含必要元素")
|
||||
|
||||
|
||||
class TestIntegration(unittest.TestCase):
|
||||
"""测试7: 集成测试"""
|
||||
|
||||
def setUp(self):
|
||||
self.monitor = StockAlert()
|
||||
|
||||
def test_full_run_once(self):
|
||||
"""测试完整run_once流程"""
|
||||
start = time.time()
|
||||
alerts_list = self.monitor.run_once(smart_mode=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# 执行时间应该合理 (10-30秒)
|
||||
self.assertLess(elapsed, 60, "执行时间过长")
|
||||
self.assertIsInstance(alerts_list, list)
|
||||
print(f"✅ 完整流程正常 (执行时间: {elapsed:.2f}秒, 触发{len(alerts_list)}条)")
|
||||
|
||||
def test_all_stocks_monitored(self):
|
||||
"""测试所有股票都被监控"""
|
||||
data = self.monitor.fetch_sina_realtime(WATCHLIST)
|
||||
# 至少应该获取到部分数据
|
||||
self.assertGreater(len(data), 0)
|
||||
print(f"✅ 监控覆盖正常 (获取到{len(data)}/{len(WATCHLIST)}只数据)")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""运行所有测试"""
|
||||
print("=" * 70)
|
||||
print("🧪 Stock Monitor Pro - 完整测试套件")
|
||||
print("=" * 70)
|
||||
|
||||
# 创建测试套件
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# 添加所有测试类
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestDataFetching))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAlertRules))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAlertLevel))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestStockTypeDifferentiation))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestSmartSchedule))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestMessageFormat))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestIntegration))
|
||||
|
||||
# 运行测试
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
# 输出总结
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 测试总结")
|
||||
print("=" * 70)
|
||||
print(f" 测试总数: {result.testsRun}")
|
||||
print(f" 通过: {result.testsRun - len(result.failures) - len(result.errors)}")
|
||||
print(f" 失败: {len(result.failures)}")
|
||||
print(f" 错误: {len(result.errors)}")
|
||||
|
||||
if result.wasSuccessful():
|
||||
print("\n✅ 所有测试通过!系统可以正常运行。")
|
||||
else:
|
||||
print("\n⚠️ 部分测试失败,请检查日志。")
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = run_all_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
Reference in New Issue
Block a user