stock-ai-agent/backend/app/stock_agent/market_signal_analyzer.py
2026-02-26 20:46:56 +08:00

872 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
股票市场信号分析器 - 纯市场分析,不包含任何仓位信息
职责:
1. 分析K线、量价、技术指标
2. 分析新闻舆情
3. 输出纯市场信号buy/sell/hold + confidence + reasoning
不负责:
- 仓位管理
- 风险控制
- 具体下单决策
"""
import json
import re
import pandas as pd
from typing import Dict, Any, Optional, List
from datetime import datetime
from app.utils.logger import logger
from app.services.llm_service import llm_service
class StockMarketSignalAnalyzer:
"""股票市场信号分析器 - 只关注市场,输出客观信号"""
# 股票市场分析系统提示词
MARKET_ANALYSIS_PROMPT = """你是一位专业的股票交易员和技术分析师。你的任务是综合分析**趋势方向、技术面K线、量价、技术指标、基本面估值、盈利、成长、新闻舆情**,给出交易信号。
## 核心理念
**趋势是你的朋友,顺势交易是稳定盈利的关键。**
### 🚨 铁律(必须遵守)
1. **先判断趋势,再寻找信号** - 趋势方向错误,信号再强也不做
2. **顺势交易为主** - 上涨趋势只做多或观望,下跌趋势只做空或观望
3. **逆势交易极其谨慎** - 必须有多重反转信号才能考虑逆势
4. **单边行情不逆势** - 强趋势中日线连续3根以上同向K线严禁逆势开仓
### 交易目标
- **稳健为主**:宁可错过,不做错
- **顺势而为**:在大趋势方向上寻找入场点
- **严控风险**每次交易风险不超过本金的2%
## 零、趋势方向判断(第一步,最重要!)
**在分析任何信号之前,先判断当前趋势方向和强度。**
### 趋势判断标准(使用 EMA 和均线系统)
**上升趋势(多头市场)**
- EMA20 > EMA50 > EMA200短中长期均线多头排列
- 价格站稳在 EMA20 之上
- MA5 > MA10 > MA20 > MA50
- 最近高点逐步抬高,低点也逐步抬高
**下降趋势(空头市场)**
- EMA20 < EMA50 < EMA200短中长期均线空头排列
- 价格持续在 EMA20 之下
- MA5 < MA10 < MA20 < MA50
- 最近高点逐步降低,低点也逐步降低
**震荡市(无明确趋势)**
- 均线纠缠,无明显排列
- 价格在 EMA20 上下波动
- 高点低点无规律
- 此时可双向交易,但降低仓位
### 趋势强度判断
- **强趋势**:均线完美排列 + 价格远离均线 + 成交量配合
- **中等趋势**:均线有排列 + 价格偶尔回踩均线
- **弱趋势/震荡**:均线纠缠 + 价格在均线上下反复
### 顺势交易规则(必须执行)
| 当前趋势 | 允许操作 | 条件 |
|---------|---------|------|
| **强上升趋势** | ✅ 只做多 | 回调到支撑位、RSI超卖区、金叉 |
| **强上升趋势** | ❌ 严禁做空 | 除非出现明确的顶背离+放量反转信号 |
| **强下降趋势** | ✅ 只做空 | 反弹到阻力位、RSI超买区、死叉 |
| **强下降趋势** | ❌ 严禁做多 | 除非出现明确的底背离+放量反转信号 |
| **震荡市** | ✅ 双向交易 | 但降低仓位(轻仓),提高止损要求 |
| **趋势不明确** | ⚠️ 观望为主 | 等待趋势明确后再入场 |
### 逆势交易的条件(极其严格)
**只有在满足以下全部条件时,才允许考虑逆势交易:**
1. **多重反转信号**
- 明确的背离(顶背离或底背离)
- 关键形态反转(头肩顶/底、双顶/底、吞没形态)
- 放量突破关键位
2. **多周期确认**周线、日线、1h 三个周期同时出现反转信号
3. **风险收益比合理**潜在盈利至少是风险的3倍以上
4. **基本面支持**:重大利好/利空改变趋势
5. **降低仓位**逆势交易必须轻仓不超过顺势仓位的50%
**如果不符合上述条件,即使有买入/卖出信号,也必须选择 hold观望。**
## 数据说明
你将获得三个维度的数据:
1. **技术面数据**K线、量价、技术指标RSI、MACD、布林带、均线
2. **基本面数据**估值指标PE、PB、盈利能力ROE、净利率、成长性营收增长、盈利增长、财务健康度
3. **新闻舆情**:最新相关新闻
## 分析框架(重要!)
### 优先级排序:
1. **技术面** = 40%K线、量价、技术指标决定入场时机
2. **基本面** = 35%:估值和盈利能力决定信号的长期有效性
3. **新闻** = 25%:重大新闻可能改变短期趋势
### 综合判断规则:
- **技术面强 + 基本面好 + 无负面新闻** → A级信号高置信度
- **技术面强 + 基本面一般** → B级信号中等置信度
- **技术面一般 + 基本面好** → C级信号低置信度观望为主
- **技术面强 + 基本面差 + 有负面新闻** → D级信号不推荐交易
- **技术面弱** → 无论基本面如何,不推荐交易(观望)
## 一、量价分析(最重要)
量价关系是判断趋势真假的核心:
### 1. 健康上涨信号
- **放量上涨**:价格上涨 + 成交量放大(量比>1.5= 上涨有效,可追多
- **缩量回调**:上涨后回调 + 成交量萎缩(量比<0.7= 回调健康,可低吸
- **温和放量**量比在1.2-1.5之间,价格稳步上涨 = 最健康的上涨
### 2. 健康下跌信号
- **放量下跌**:价格下跌 + 成交量放大 = 下跌有效,暂不抄底
- **缩量阴跌**:下跌 + 成交量萎缩 = 抛压逐渐枯竭,关注反弹
- **地量企稳**:极端缩量后价格横盘 = 可能见底
### 3. 量价背离(重要反转信号)
- **顶背离**:价格创新高,但成交量未创新高 → 上涨动能衰竭
- **底背离**:价格创新低,但成交量未创新低 → 下跌动能衰竭
- **天量见顶**单日成交量突然放大2-3倍后价格滞涨 → 主力出货
- **地量见底**:成交量创阶段新低后价格企稳 → 抛压枯竭
### 4. 突破确认
- **有效突破**:突破关键位 + 放量确认(量比>1.3= 真突破
- **假突破**:突破关键位 + 缩量 = 假突破,可能回落
## 二、K线形态分析
### 反转形态
- **锤子线/倒锤子**:下跌趋势中出现,下影线长 = 底部信号
- **吞没形态**:大阳吞没前一根阴线 = 看涨;大阴吞没前一根阳线 = 看跌
- **十字星**:在高位/低位出现 = 变盘信号
- **早晨之星/黄昏之星**三根K线组合的反转信号
### 持续形态
- **三连阳/三连阴**:趋势延续信号
- **旗形整理**:趋势中的健康回调
## 三、技术指标分析
### RSI相对强弱指标
**RSI 是最重要的超买超卖指标:**
- **RSI < 30**:超卖区,关注反弹机会
- RSI 从 30 以下回升,交叉上穿 30买入信号
- RSI 底背离(价格新低但 RSI 未创新低):强买入信号
- **RSI > 70**:超买区,关注回落风险
- RSI 从 70 以上回落,交叉下穿 70卖出信号
- RSI 顶背离(价格新高但 RSI 未创新高):强卖出信号
- **RSI 40-60**:震荡区,观望为主
### MACD
- 金叉DIF 上穿 DEA做多信号
- 死叉DIF 下穿 DEA做空信号
- 零轴上方金叉:强势做多
- 零轴下方死叉:强势做空
- MACD 柱状图背离:重要反转信号
### 布林带
- 触及下轨 + 企稳:反弹做多
- 触及上轨 + 受阻:回落做空
- 布林带收口:即将变盘
- 布林带开口:趋势启动
### 均线系统(重要)
**均线系统是趋势判断的核心:**
- **多头排列**MA5 > MA10 > MA20 > MA50强势上涨趋势回调做多
- **空头排列**MA5 < MA10 < MA20 < MA50强势下跌趋势反弹做空
- **EMA 趋势判断**(比 MA 更平滑,更适合判断长期趋势):
- **多头排列**EMA20 > EMA50 > EMA200长期上涨趋势确立
- **空头排列**EMA20 < EMA50 < EMA200长期下跌趋势确立
- 价格站稳 EMA20 上方:中期上涨趋势
- 价格跌破 EMA20中期转为下跌趋势
- EMA50 是长期趋势的生命线
- **价格与 MA/EMA 的关系**
- 价格站稳 MA5/MA10 上方:短线上涨
- 价格突破 MA20/EMA20中线转多
- 价格跌破 MA20/EMA20中线转空
- MA50/EMA50 是中期趋势的分水岭
- **均线金叉死叉**
- MA5 上穿 MA10短线买入信号
- MA5 下穿 MA10短线卖出信号
- EMA20 上穿 EMA50中线买入信号重要
- EMA20 下穿 EMA50中线卖出信号重要
## 四、多周期共振(关键分析框架)
**多周期共振是提高信号质量的核心方法:**
### 周期层级关系
- **周线(趋势层)**:决定长期大方向
- **日线(主周期)**:主要交易周期
- **1h入场层**:寻找入场时机
### 共振判断标准
**强共振A级信号**
- 所有周期趋势同向(如周线多 + 日线多 + 1h多
- 多周期 RSI 同时超买/超卖后出现背离
- 多周期 MA 同时金叉/死叉
**中等共振B级信号**
- 大周期(周线+日线)同向
- 主周期(日线)技术指标明确
**弱共振C级信号**
- 只有单一周期信号
- 多周期方向不一致
### 实战策略
- **顺势交易**:周线和日线同向时,在 1h 寻找入场点
- **逆势谨慎**:只有日线信号但周线反向时,降低置信度
- **突破交易**:多周期同时突破关键位,信号最强
## 五、基本面分析(重要)
**基本面是判断信号长期有效性的关键:**
### 估值指标
- **PE市盈率**
- PE < 15低估安全边际高
- PE 15-25合理估值
- PE > 40高估风险较大
- **PB市净率**
- PB < 1低于净资产价值投资机会
- PB 1-3合理区间
- PB > 5高估
- **PEG市盈率增长率**
- PEG < 1低估成长性好
- PEG 1-2合理
- PEG > 2高估
### 盈利能力
- **ROE净资产收益率**
- ROE > 20%:优秀
- ROE 15-20%:良好
- ROE < 10%:较差
- **净利率**
- 净利率 > 20%:优秀(通常是科技、消费品牌)
- 净利率 10-20%:良好
- 净利率 < 5%:较低(通常是零售、制造业)
### 成长性
- **营收增长**
- > 30%:高成长
- 20-30%:稳健成长
- < 10%:低成长
- **盈利增长**
- > 30%:高成长
- 10-30%:稳健成长
- < 0%:负增长,警惕
### 财务健康
- **债务股本比**
- < 1健康
- 1-2可控
- > 3高风险
- **流动比率**
- > 2健康
- 1.5-2良好
- < 1流动性风险
### 基本面综合判断
- **基本面优秀**ROE>15%, 营收增长>20%, 财务健康)+ 技术面信号 = 提高置信度
- **基本面一般**ROE 10-15%, 营收增长 10-20%+ 技术面信号 = 正常置信度
- **基本面较差**ROE<10%, 营收增长<10% 或负增长, 高负债)+ 技术面信号 = 降低置信度
- **基本面差**(连续亏损, 高负债, 负增长)= 不建议交易,无论技术面如何
## 六、新闻舆情分析
**新闻会改变短期趋势,需要重点关注:**
### 正面新闻(提高做多置信度)
- 财报超预期
- 重大产品发布
- 业务扩张/并购
- 分析师上调评级
- 行业利好政策
### 负面新闻(提高做空置信度或降低做多置信度)
- 财报不及预期
- 监管调查/处罚
- 管理层变动
- 分析师下调评级
- 行业监管收紧
- 重大安全事故/质量问题
### 新闻综合判断
- **重大正面新闻** + 技术面做多信号 = 提高置信度 10-20%
- **重大负面新闻** + 技术面做多信号 = 降低置信度或转为观望
- **无重大新闻** = 技术面 + 基本面分析为主
## 七、入场方式
根据市场分析综合判断入场方式:
- **market**:现价立即入场
- 信号强烈且明确A级或高置信度B级
- 放量突破关键位,趋势明确
- 多周期共振,等待可能错过机会
- 市场波动大,等待可能价格变化太快
- **limit**:挂单等待入场
- 信号强度中等B级或C级
- 当前价格距离理想入场位有一定距离
- 判断市场可能回调到更好位置
- 希望获得更优成交价格,愿意承担可能无法成交的风险
**重要**
- 必须同时输出 `entry_zone`(建议入场价)和 `entry_type`(入场方式)
- 入场方式由你的市场分析判断,不是简单的价格距离计算
## 输出格式
请严格按照以下 JSON 格式输出:
```json
{
"trend_direction": "uptrend/downtrend/neutral",
"trend_strength": "strong/medium/weak",
"analysis_summary": "简要描述当前市场状态50字以内",
"volume_analysis": "量价分析结论30字以内",
"news_sentiment": "positive/negative/neutral",
"news_impact": "新闻对市场的影响分析30字以内",
"signals": [
{
"type": "short_term/medium_term/long_term",
"action": "buy/sell",
"entry_type": "market/limit",
"confidence": 0-100,
"grade": "A/B/C/D",
"entry_zone": 150.50,
"stop_loss": 148.00,
"take_profit": 155.00,
"reasoning": "详细的入场理由(必须包含趋势判断和量价分析)",
"key_factors": ["关键因素1", "关键因素2"]
}
],
"key_levels": {
"support": [148, 145],
"resistance": [152, 155]
}
}
```
## 重要说明
- **所有价格必须是纯数字**,不要加 $ 符号、逗号或其他格式
- `entry_zone`、`stop_loss`、`take_profit` 必须是数字类型,不要是字符串
- `key_levels` 中的支撑位和阻力位也必须是数字数组
## 信号等级与置信度(综合技术面 + 基本面 + 新闻)
- **A级**80-100量价配合 + 多指标共振 + 多周期确认 + 基本面优秀 + 无负面新闻
- **B级**60-79量价配合 + 主要指标确认 + 基本面良好/一般
- **C级**40-59技术面有机会但基本面一般或基本面好但技术面不够明确
- **D级**<40量价背离或信号矛盾或基本面差或有重大负面新闻
## 注意事项
1. **只在有明确的做多或做空机会时才输出信号**action 为 buy 或 sell
2. 如果市场不明朗,没有明确交易机会,**不要输出任何信号**signals 为空数组 []
3. 信号强度confidence要合理不要随意给高分
4. 60-70分一般信号可轻仓试探
5. 75-85分较强信号可正常仓位
6. 90+分:强信号,但也要控制风险
7. 不要输出 action 为 "wait" 的信号,如果没有交易机会就不输出
8. **必须综合考虑技术面、基本面、新闻三个维度**,不能只看技术面
记住:你只负责分析市场,输出客观的交易信号,不需要考虑仓位管理和风险控制!
"""
def __init__(self):
pass
async def analyze(self, symbol: str, data: Dict[str, Any],
symbols: List[str] = None,
fundamental_data: Dict[str, Any] = None,
news_data: List[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
分析市场并生成信号
Args:
symbol: 股票代码
data: 多周期K线数据
symbols: 所有监控的股票(用于市场对比)
fundamental_data: 基本面数据
news_data: 新闻数据列表
Returns:
市场信号字典
"""
try:
# 1. 准备市场数据(技术面 + 基本面 + 新闻)
market_context = self._prepare_market_context(
symbol, data, symbols,
fundamental_data, news_data
)
# 2. 构建 LLM 提示词
prompt = self._build_analysis_prompt(symbol, market_context)
# 3. 调用 LLM 分析
messages = [
{"role": "system", "content": self.MARKET_ANALYSIS_PROMPT},
{"role": "user", "content": prompt}
]
response = await llm_service.achat(messages)
# 4. 解析结果
result = self._parse_llm_response(response, symbol)
return result
except Exception as e:
logger.error(f"市场信号分析失败: {e}")
import traceback
logger.debug(traceback.format_exc())
return self._get_empty_signal(symbol)
def _prepare_market_context(self, symbol: str, data: Dict,
symbols: List[str] = None,
fundamental_data: Dict[str, Any] = None,
news_data: List[Dict[str, Any]] = None) -> str:
"""准备市场上下文信息(技术面 + 基本面 + 新闻)"""
context_parts = []
# 当前价格和24h变化使用日线数据
df_1d = data.get('1d')
if df_1d is None or len(df_1d) == 0:
df_1h = data.get('1h') # 备用使用1h数据
if df_1d is None or len(df_1d) == 0:
return "" # 没有数据就返回空
current_price = float(df_1d.iloc[-1]['close'])
price_change_24h = self._calculate_price_change_24h(df_1d)
context_parts.append(f"当前价格: ${current_price:,.2f} ({price_change_24h})")
# 多周期数据
for tf_name, df in data.items():
if df is None or len(df) == 0:
continue
latest = df.iloc[-1]
context_parts.append(f"\n## {tf_name} 数据")
context_parts.append(f"开: {latest['open']}, 高: {latest['high']}, 低: {latest['low']}, 收: {latest['close']}")
context_parts.append(f"成交量: {latest.get('volume', 'N/A')}")
# 技术指标
if 'rsi' in df.columns:
rsi = df['rsi'].iloc[-1]
context_parts.append(f"RSI: {rsi:.2f}")
if 'macd' in df.columns:
macd = df['macd'].iloc[-1]
signal = df['macd_signal'].iloc[-1]
context_parts.append(f"MACD: {macd:.4f}, 信号线: {signal:.4f}")
if 'bb_upper' in df.columns:
bb_upper = df['bb_upper'].iloc[-1]
bb_lower = df['bb_lower'].iloc[-1]
context_parts.append(f"布林带: 上轨 {bb_upper:.2f}, 下轨 {bb_lower:.2f}")
# 均线系统(使用日线数据)
context_parts.append(f"\n## 均线系统")
df_1d = data.get('1d')
if df_1d is not None and len(df_1d) > 0:
latest = df_1d.iloc[-1]
context_parts.append(f"MA5: {latest.get('ma5', 'N/A')}")
context_parts.append(f"MA10: {latest.get('ma10', 'N/A')}")
context_parts.append(f"MA20: {latest.get('ma20', 'N/A')}")
context_parts.append(f"MA50: {latest.get('ma50', 'N/A')}")
# EMA 均线(用于趋势判断)
ema20 = latest.get('ema20', None)
ema50 = latest.get('ema50', None)
ema200 = latest.get('ema200', None)
if ema20 is not None:
context_parts.append(f"EMA20: {ema20:.2f}")
if ema50 is not None:
context_parts.append(f"EMA50: {ema50:.2f}")
if ema200 is not None:
context_parts.append(f"EMA200: {ema200:.2f}")
# 判断 MA 排列
ma5 = latest.get('ma5', 0)
ma10 = latest.get('ma10', 0)
ma20 = latest.get('ma20', 0)
ma50 = latest.get('ma50', 0)
if all([ma5, ma10, ma20, ma50]):
if ma5 > ma10 > ma20 > ma50:
context_parts.append("MA排列: 多头排列 📈")
elif ma5 < ma10 < ma20 < ma50:
context_parts.append("MA排列: 空头排列 📉")
else:
context_parts.append("MA排列: 交织,方向不明")
# EMA 排列(更重要的趋势判断)
if all([ema20, ema50, ema200]):
if ema20 > ema50 > ema200:
context_parts.append("EMA排列: 多头排列 (长期趋势向上) 📈")
elif ema20 < ema50 < ema200:
context_parts.append("EMA排列: 空头排列 (长期趋势向下) 📉")
else:
context_parts.append("EMA排列: 交织 (震荡市) ➡️")
# 价格与 EMA20 的关系(趋势判断关键)
if ema20 is not None:
if latest['close'] > ema20:
context_parts.append("价格位置: 站稳 EMA20 之上 (多头强势)")
elif latest['close'] < ema20:
context_parts.append("价格位置: 跌破 EMA20 (空头强势)")
else:
context_parts.append("价格位置: 接近 EMA20")
# 量比分析(使用日线数据)
df_1d = data.get('1d')
if df_1d is not None and len(df_1d) >= 20:
vol_latest = df_1d['volume'].iloc[-1]
vol_ma20 = df_1d['volume'].iloc[-20:-1].mean()
volume_ratio = vol_latest / vol_ma20 if vol_ma20 > 0 else 1
context_parts.append(f"\n## 量价分析")
context_parts.append(f"最新成交量: {vol_latest:.0f}")
context_parts.append(f"20周期均量: {vol_ma20:.0f}")
context_parts.append(f"量比: {volume_ratio:.2f}")
if volume_ratio > 1.5:
context_parts.append("量价状态: 放量 📊")
elif volume_ratio < 0.7:
context_parts.append("量价状态: 缩量 📉")
else:
context_parts.append("量价状态: 平量 ")
# 波动率分析
volatility_analysis = self._analyze_volatility(data)
if volatility_analysis:
context_parts.append(f"\n## 波动率分析")
context_parts.append(volatility_analysis)
# 基本面分析
if fundamental_data:
context_parts.append(f"\n## 基本面分析")
context_parts.append(self._format_fundamental_data(fundamental_data))
# 新闻舆情
if news_data:
context_parts.append(f"\n## 最新新闻")
context_parts.append(self._format_news_data(news_data))
return "\n".join(context_parts)
def _build_analysis_prompt(self, symbol: str, market_context: str) -> str:
"""构建分析提示词"""
return f"""请分析 {symbol} 的市场情况:
{market_context}
请根据以上数据,给出你的市场判断和交易信号。
"""
def _parse_llm_response(self, response: str, symbol: str) -> Dict[str, Any]:
"""解析 LLM 响应"""
try:
# 尝试提取 JSON
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response)
if json_match:
json_str = json_match.group(1)
else:
json_match = re.search(r'\{[\s\S]*\}', response)
if json_match:
json_str = json_match.group(0)
else:
raise ValueError("无法找到 JSON 响应")
# 清理 JSON 字符串
json_str = self._clean_json_string(json_str)
result = json.loads(json_str)
# 向后兼容:确保新字段存在
if 'trend_direction' not in result:
result['trend_direction'] = 'neutral'
if 'trend_strength' not in result:
result['trend_strength'] = 'weak'
if 'news_sentiment' not in result:
result['news_sentiment'] = 'neutral'
if 'news_impact' not in result:
result['news_impact'] = ''
# 清理价格字段 - 转换为 float
result = self._clean_price_fields(result)
# 添加元数据
result['symbol'] = symbol
result['timestamp'] = datetime.now().isoformat()
result['raw_response'] = response
# 兼容处理:确保 signals 中的字段与旧格式一致
if 'signals' in result:
for sig in result['signals']:
if 'type' in sig:
if sig['type'] in ['short_term', 'medium_term', 'long_term']:
sig['timeframe'] = sig.pop('type')
elif sig['type'] in ['buy', 'sell', 'wait']:
sig['action'] = sig.pop('type')
if 'action' not in sig and 'timeframe' in sig:
sig['action'] = 'wait'
if 'grade' not in sig:
confidence = sig.get('confidence', 0)
if confidence >= 80:
sig['grade'] = 'A'
elif confidence >= 60:
sig['grade'] = 'B'
elif confidence >= 40:
sig['grade'] = 'C'
else:
sig['grade'] = 'D'
logger.info(f"✅ 市场信号分析完成: {symbol}")
return result
except Exception as e:
logger.warning(f"解析 LLM 响应失败: {e}")
logger.warning(f"原始响应: {response[:1000]}...")
return self._get_empty_signal(symbol)
def _clean_json_string(self, json_str: str) -> str:
"""清理 JSON 字符串,移除可能导致解析错误的内容"""
import re
json_str = re.sub(r'//.*?(?=\n|$)', '', json_str)
json_str = re.sub(r'/\*[\s\S]*?\*/', '', json_str)
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
return json_str
def _clean_price_fields(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""清理价格字段,转换为 float"""
def clean_price(price_value):
if price_value is None:
return None
if isinstance(price_value, (int, float)):
return float(price_value)
if isinstance(price_value, str):
cleaned = price_value.replace('$', '').replace(',', '').strip()
if cleaned:
try:
return float(cleaned)
except ValueError:
return None
return None
if 'key_levels' in data and data['key_levels']:
key_levels = data['key_levels']
if 'support' in key_levels:
data['key_levels']['support'] = [clean_price(s) for s in key_levels['support']]
if 'resistance' in key_levels:
data['key_levels']['resistance'] = [clean_price(r) for r in key_levels['resistance']]
if 'signals' in data:
for sig in data['signals']:
price_fields = ['entry_zone', 'stop_loss', 'take_profit']
for field in price_fields:
if field in sig:
sig[field] = clean_price(sig[field])
# 验证止损止盈价格的合理性
entry_zone = sig.get('entry_zone')
stop_loss = sig.get('stop_loss')
take_profit = sig.get('take_profit')
action = sig.get('action', '')
if entry_zone and entry_zone > 0:
MAX_REASONABLE_DEVIATION = 0.50 # 50%
has_invalid_price = False
# 检查止损
if stop_loss is not None:
deviation = abs(stop_loss - entry_zone) / entry_zone
if deviation > MAX_REASONABLE_DEVIATION:
logger.warning(f"⚠️ [{data.get('symbol', '')}] 信号止损价格不合理: entry={entry_zone}, stop_loss={stop_loss}, 偏离={deviation*100:.1f}%")
has_invalid_price = True
elif action == 'buy' and stop_loss >= entry_zone:
logger.warning(f"⚠️ [{data.get('symbol', '')}] 做多止损错误: entry={entry_zone}, stop_loss={stop_loss} 应该 < entry")
has_invalid_price = True
elif action == 'sell' and stop_loss <= entry_zone:
logger.warning(f"⚠️ [{data.get('symbol', '')}] 做空止损错误: entry={entry_zone}, stop_loss={stop_loss} 应该 > entry")
has_invalid_price = True
# 检查止盈
if take_profit is not None:
deviation = abs(take_profit - entry_zone) / entry_zone
if deviation > MAX_REASONABLE_DEVIATION:
logger.warning(f"⚠️ [{data.get('symbol', '')}] 信号止盈价格不合理: entry={entry_zone}, take_profit={take_profit}, 偏离={deviation*100:.1f}%")
has_invalid_price = True
elif action == 'buy' and take_profit <= entry_zone:
logger.warning(f"⚠️ [{data.get('symbol', '')}] 做多止盈错误: entry={entry_zone}, take_profit={take_profit} 应该 > entry")
has_invalid_price = True
elif action == 'sell' and take_profit >= entry_zone:
logger.warning(f"⚠️ [{data.get('symbol', '')}] 做空止盈错误: entry={entry_zone}, take_profit={take_profit} 应该 < entry")
has_invalid_price = True
# 如果价格不合理,降低等级为 D
if has_invalid_price:
original_grade = sig.get('grade', 'C')
sig['grade'] = 'D'
sig['confidence'] = 0
# 添加错误说明
if 'reasoning' in sig:
sig['reasoning'] = f"[价格异常] {sig['reasoning']}"
logger.error(f"❌ [{data.get('symbol', '')}] 信号价格异常,等级从 {original_grade} 降为 D止损止盈已清空")
# 清空不合理的价格
sig['stop_loss'] = None
sig['take_profit'] = None
return data
def _calculate_price_change_24h(self, df) -> str:
"""计算24小时涨跌幅"""
try:
if df is None or len(df) < 24:
return "N/A"
current_price = float(df['close'].iloc[-1])
price_24h_ago = float(df['close'].iloc[-24])
change = ((current_price - price_24h_ago) / price_24h_ago) * 100
sign = "+" if change >= 0 else ""
return f"{sign}{change:.2f}%"
except Exception as e:
logger.debug(f"计算24h涨跌失败: {e}")
return "N/A"
def _analyze_volatility(self, data: Dict[str, pd.DataFrame]) -> str:
"""分析波动率变化(使用日线数据)"""
df = data.get('1d')
if df is None or len(df) < 24 or 'atr' not in df.columns:
return ""
lines = []
recent_atr = df['atr'].iloc[-6:].mean()
older_atr = df['atr'].iloc[-12:-6].mean()
if pd.isna(recent_atr) or pd.isna(older_atr) or older_atr == 0:
return ""
atr_change = (recent_atr - older_atr) / older_atr * 100
current_atr = float(df['atr'].iloc[-1])
current_price = float(df['close'].iloc[-1])
atr_percent = current_atr / current_price * 100
lines.append(f"当前 ATR: ${current_atr:.2f} ({atr_percent:.2f}%)")
if atr_change > 20:
lines.append(f"**波动率扩张**: ATR 上升 {atr_change:.0f}%,趋势可能启动")
elif atr_change < -20:
lines.append(f"**波动率收缩**: ATR 下降 {abs(atr_change):.0f}%,可能即将突破")
else:
lines.append(f"波动率稳定: ATR 变化 {atr_change:+.0f}%")
if 'bb_upper' in df.columns and 'bb_lower' in df.columns:
bb_width = (float(df['bb_upper'].iloc[-1]) - float(df['bb_lower'].iloc[-1])) / current_price * 100
bb_width_prev = (float(df['bb_upper'].iloc[-6]) - float(df['bb_lower'].iloc[-6])) / float(df['close'].iloc[-6]) * 100
if bb_width < bb_width_prev * 0.8:
lines.append(f"**布林带收口**: 宽度 {bb_width:.1f}%,变盘信号")
elif bb_width > bb_width_prev * 1.2:
lines.append(f"**布林带开口**: 宽度 {bb_width:.1f}%,趋势延续")
return "\n".join(lines)
def _format_fundamental_data(self, data: Dict[str, Any]) -> str:
"""格式化基本面数据"""
if not data:
return "暂无基本面数据"
lines = []
# 基本信息
company_name = data.get('company_name', 'N/A')
sector = data.get('sector', 'N/A')
lines.append(f"公司: {company_name}")
lines.append(f"行业: {sector}")
# 估值指标
val = data.get('valuation', {})
if val.get('pe_ratio'):
pe = val['pe_ratio']
pb = val.get('pb_ratio')
ps = val.get('ps_ratio')
peg = val.get('peg_ratio')
pb_str = f"{pb:.2f}" if pb is not None else "N/A"
ps_str = f"{ps:.2f}" if ps is not None else "N/A"
peg_str = f"{peg:.2f}" if peg is not None else "N/A"
lines.append(f"估值: PE={pe:.2f} | PB={pb_str} | PS={ps_str} | PEG={peg_str}")
# 盈利能力
prof = data.get('profitability', {})
if prof.get('return_on_equity'):
roe = prof['return_on_equity']
pm = prof.get('profit_margin')
gm = prof.get('gross_margin')
pm_str = f"{pm:.1f}" if pm is not None else "N/A"
gm_str = f"{gm:.1f}" if gm is not None else "N/A"
lines.append(f"盈利: ROE={roe:.2f}% | 净利率={pm_str}% | 毛利率={gm_str}%")
# 成长性
growth = data.get('growth', {})
rg = growth.get('revenue_growth')
eg = growth.get('earnings_growth')
if rg is not None or eg is not None:
rg_str = f"{rg:.1f}" if rg is not None else "N/A"
eg_str = f"{eg:.1f}" if eg is not None else "N/A"
lines.append(f"成长: 营收增长={rg_str}% | 盈利增长={eg_str}%")
# 财务健康
fin = data.get('financial_health', {})
if fin.get('debt_to_equity'):
de = fin['debt_to_equity']
cr = fin.get('current_ratio')
cr_str = f"{cr:.2f}" if cr is not None else "N/A"
lines.append(f"财务: 债务股本比={de:.2f} | 流动比率={cr_str}")
# 分析师建议
analyst = data.get('analyst', {})
if analyst.get('target_price'):
tp = analyst['target_price']
rec = analyst.get('recommendation', 'N/A')
lines.append(f"分析师: 目标价=${tp:.2f} | 评级={rec}")
# 基本面评分
score = data.get('score', {})
if score.get('total'):
lines.append(f"基本面评分: {score['total']:.0f}/100 ({score.get('rating', 'N/A')}级)")
return "\n".join(lines)
def _format_news_data(self, news_list: List[Dict[str, Any]]) -> str:
"""格式化新闻数据"""
if not news_list:
return "暂无相关新闻"
lines = []
for i, news in enumerate(news_list[:5], 1): # 最多5条
title = news.get('title', '')
desc = news.get('description', '')[:150] # 限制描述长度
source = news.get('source', '')
time_str = news.get('time_str', '')
lines.append(f"{i}. [{time_str}] {title}")
if desc:
lines.append(f" {desc}")
if source:
lines.append(f" 来源: {source}")
lines.append("")
return "\n".join(lines)
def _get_empty_signal(self, symbol: str) -> Dict[str, Any]:
"""返回空信号"""
return {
'symbol': symbol,
'trend_direction': 'neutral',
'trend_strength': 'weak',
'analysis_summary': '分析失败',
'volume_analysis': '',
'news_sentiment': 'neutral',
'news_impact': '',
'signals': [],
'key_levels': {},
'timestamp': datetime.now().isoformat(),
'error': '信号分析失败'
}