0b37aa4a67
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
786 lines
34 KiB
Python
786 lines
34 KiB
Python
"""
|
|
Fast Analysis Service 3.0
|
|
系统性重构版本 - 使用统一的数据采集器
|
|
|
|
核心改进:
|
|
1. 数据源统一 - 使用 MarketDataCollector,与K线模块、自选列表完全一致
|
|
2. 宏观数据 - 新增美元指数、VIX、利率等宏观经济指标
|
|
3. 多维新闻 - 使用结构化API,无需深度阅读
|
|
4. 单次LLM调用 - 强约束prompt,输出结构化分析
|
|
"""
|
|
import json
|
|
import time
|
|
from typing import Dict, Any, Optional, List
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
|
|
from app.utils.logger import get_logger
|
|
from app.services.llm import LLMService
|
|
from app.services.market_data_collector import get_market_data_collector
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class FastAnalysisService:
|
|
"""
|
|
快速分析服务 3.0
|
|
|
|
架构:
|
|
1. 数据采集层 - MarketDataCollector (统一数据源)
|
|
2. 分析层 - 单次LLM调用 (强约束prompt)
|
|
3. 记忆层 - 分析历史存储和检索
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.llm_service = LLMService()
|
|
self.data_collector = get_market_data_collector()
|
|
self._memory_db = None # Lazy init
|
|
|
|
# ==================== Data Collection Layer ====================
|
|
|
|
def _collect_market_data(self, market: str, symbol: str, timeframe: str = "1D") -> Dict[str, Any]:
|
|
"""
|
|
使用统一的数据采集器收集市场数据
|
|
|
|
数据层次:
|
|
1. 核心数据: 价格、K线、技术指标
|
|
2. 基本面: 公司信息、财务数据
|
|
3. 宏观数据: DXY、VIX、TNX、黄金等
|
|
4. 情绪数据: 新闻、市场情绪
|
|
"""
|
|
return self.data_collector.collect_all(
|
|
market=market,
|
|
symbol=symbol,
|
|
timeframe=timeframe,
|
|
include_macro=True,
|
|
include_news=True,
|
|
timeout=30
|
|
)
|
|
|
|
def _calculate_indicators(self, kline_data: List[Dict]) -> Dict[str, Any]:
|
|
"""
|
|
Calculate technical indicators using rules (no LLM).
|
|
Returns actionable signals, not raw numbers.
|
|
"""
|
|
if not kline_data or len(kline_data) < 5:
|
|
return {"error": "Insufficient data"}
|
|
|
|
try:
|
|
# Use tools' built-in calculation
|
|
raw_indicators = self.tools.calculate_technical_indicators(kline_data)
|
|
|
|
# Extract key values
|
|
closes = [float(k.get("close", 0)) for k in kline_data if k.get("close")]
|
|
if not closes:
|
|
return {"error": "No close prices"}
|
|
|
|
current_price = closes[-1]
|
|
|
|
# RSI interpretation
|
|
rsi = raw_indicators.get("RSI", 50)
|
|
if rsi < 30:
|
|
rsi_signal = "oversold"
|
|
rsi_action = "potential_buy"
|
|
elif rsi > 70:
|
|
rsi_signal = "overbought"
|
|
rsi_action = "potential_sell"
|
|
else:
|
|
rsi_signal = "neutral"
|
|
rsi_action = "hold"
|
|
|
|
# MACD interpretation
|
|
macd = raw_indicators.get("MACD", 0)
|
|
macd_signal_line = raw_indicators.get("MACD_Signal", 0)
|
|
macd_hist = raw_indicators.get("MACD_Hist", 0)
|
|
|
|
if macd > macd_signal_line and macd_hist > 0:
|
|
macd_signal = "bullish"
|
|
macd_trend = "golden_cross" if macd_hist > 0 and len(kline_data) > 1 else "bullish"
|
|
elif macd < macd_signal_line and macd_hist < 0:
|
|
macd_signal = "bearish"
|
|
macd_trend = "death_cross" if macd_hist < 0 and len(kline_data) > 1 else "bearish"
|
|
else:
|
|
macd_signal = "neutral"
|
|
macd_trend = "consolidating"
|
|
|
|
# Moving averages
|
|
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else current_price
|
|
ma10 = sum(closes[-10:]) / 10 if len(closes) >= 10 else current_price
|
|
ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else current_price
|
|
|
|
if current_price > ma5 > ma10 > ma20:
|
|
ma_trend = "strong_uptrend"
|
|
elif current_price > ma20:
|
|
ma_trend = "uptrend"
|
|
elif current_price < ma5 < ma10 < ma20:
|
|
ma_trend = "strong_downtrend"
|
|
elif current_price < ma20:
|
|
ma_trend = "downtrend"
|
|
else:
|
|
ma_trend = "sideways"
|
|
|
|
# Support/Resistance (simple: recent highs/lows)
|
|
recent_highs = [float(k.get("high", 0)) for k in kline_data[-14:] if k.get("high")]
|
|
recent_lows = [float(k.get("low", 0)) for k in kline_data[-14:] if k.get("low")]
|
|
|
|
resistance = max(recent_highs) if recent_highs else current_price * 1.05
|
|
support = min(recent_lows) if recent_lows else current_price * 0.95
|
|
|
|
# Volatility (ATR-like)
|
|
if len(kline_data) >= 14:
|
|
ranges = []
|
|
for k in kline_data[-14:]:
|
|
h = float(k.get("high", 0))
|
|
l = float(k.get("low", 0))
|
|
if h > 0 and l > 0:
|
|
ranges.append(h - l)
|
|
atr = sum(ranges) / len(ranges) if ranges else 0
|
|
volatility_pct = (atr / current_price * 100) if current_price > 0 else 0
|
|
|
|
if volatility_pct > 5:
|
|
volatility = "high"
|
|
elif volatility_pct > 2:
|
|
volatility = "medium"
|
|
else:
|
|
volatility = "low"
|
|
else:
|
|
volatility = "unknown"
|
|
volatility_pct = 0
|
|
|
|
return {
|
|
"current_price": round(current_price, 6),
|
|
"rsi": {
|
|
"value": round(rsi, 2),
|
|
"signal": rsi_signal,
|
|
"action": rsi_action,
|
|
},
|
|
"macd": {
|
|
"value": round(macd, 6),
|
|
"signal_line": round(macd_signal_line, 6),
|
|
"histogram": round(macd_hist, 6),
|
|
"signal": macd_signal,
|
|
"trend": macd_trend,
|
|
},
|
|
"moving_averages": {
|
|
"ma5": round(ma5, 6),
|
|
"ma10": round(ma10, 6),
|
|
"ma20": round(ma20, 6),
|
|
"trend": ma_trend,
|
|
},
|
|
"levels": {
|
|
"support": round(support, 6),
|
|
"resistance": round(resistance, 6),
|
|
},
|
|
"volatility": {
|
|
"level": volatility,
|
|
"pct": round(volatility_pct, 2),
|
|
},
|
|
"raw": raw_indicators,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Indicator calculation failed: {e}")
|
|
return {"error": str(e)}
|
|
|
|
def _format_news_summary(self, news_data: List[Dict], max_items: int = 5) -> str:
|
|
"""Format news into a concise summary for the prompt."""
|
|
if not news_data:
|
|
return "No recent news available."
|
|
|
|
summaries = []
|
|
for item in news_data[:max_items]:
|
|
title = item.get("title", item.get("headline", ""))
|
|
sentiment = item.get("sentiment", "neutral")
|
|
date = item.get("date", item.get("datetime", ""))[:10] if item.get("date") or item.get("datetime") else ""
|
|
|
|
if title:
|
|
summaries.append(f"- [{sentiment}] {title} ({date})")
|
|
|
|
return "\n".join(summaries) if summaries else "No recent news available."
|
|
|
|
# ==================== Memory Layer ====================
|
|
|
|
def _get_memory_context(self, market: str, symbol: str, current_indicators: Dict) -> str:
|
|
"""
|
|
Retrieve relevant historical analysis for similar market conditions.
|
|
"""
|
|
try:
|
|
from app.services.analysis_memory import get_analysis_memory
|
|
memory = get_analysis_memory()
|
|
|
|
# Get similar patterns
|
|
patterns = memory.get_similar_patterns(market, symbol, current_indicators, limit=3)
|
|
|
|
if not patterns:
|
|
return "No similar historical patterns found in memory."
|
|
|
|
context_lines = ["Historical patterns with similar conditions:"]
|
|
for p in patterns:
|
|
outcome = ""
|
|
if p.get("was_correct") is not None:
|
|
outcome = f" (Outcome: {'Correct' if p['was_correct'] else 'Incorrect'}"
|
|
if p.get("actual_return_pct"):
|
|
outcome += f", Return: {p['actual_return_pct']:.2f}%"
|
|
outcome += ")"
|
|
|
|
context_lines.append(
|
|
f"- Decision: {p['decision']} at ${p.get('price', 'N/A')}{outcome}"
|
|
)
|
|
|
|
return "\n".join(context_lines)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Memory retrieval failed: {e}")
|
|
return "Memory retrieval failed."
|
|
|
|
# ==================== Prompt Engineering ====================
|
|
|
|
def _build_analysis_prompt(self, data: Dict[str, Any], language: str) -> tuple:
|
|
"""
|
|
Build the single, comprehensive analysis prompt.
|
|
Key: Strong constraints to prevent absurd recommendations.
|
|
"""
|
|
price_data = data.get("price") or {}
|
|
current_price = price_data.get("price", 0) if price_data else 0
|
|
change_24h = price_data.get("changePercent", 0) if price_data else 0
|
|
|
|
# Ensure all data fields have safe defaults (may be None from failed fetches)
|
|
indicators = data.get("indicators") or {}
|
|
fundamental = data.get("fundamental") or {}
|
|
company = data.get("company") or {}
|
|
news_summary = self._format_news_summary(data.get("news") or [])
|
|
|
|
# Language instruction - MUST be enforced strictly
|
|
lang_map = {
|
|
'zh-CN': '⚠️ 重要:你必须用简体中文回答所有内容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。',
|
|
'zh-TW': '⚠️ 重要:你必須用繁體中文回答所有內容,包括summary、key_reasons、risks等所有文本字段。不要使用英文。',
|
|
'en-US': '⚠️ IMPORTANT: You MUST answer ALL content in English, including summary, key_reasons, risks, and all text fields. Do NOT use Chinese.',
|
|
'ja-JP': '⚠️ 重要:すべての内容を日本語で回答してください。summary、key_reasons、risksなど、すべてのテキストフィールドを日本語で記述してください。',
|
|
}
|
|
lang_instruction = lang_map.get(language, '⚠️ IMPORTANT: Answer ALL content in English.')
|
|
|
|
# Get pre-calculated trading levels from technical analysis
|
|
levels = indicators.get("levels", {})
|
|
trading_levels = indicators.get("trading_levels", {})
|
|
volatility = indicators.get("volatility", {})
|
|
|
|
support = levels.get("support", current_price * 0.95)
|
|
resistance = levels.get("resistance", current_price * 1.05)
|
|
pivot = levels.get("pivot", current_price)
|
|
|
|
# Use ATR-based suggestions if available, otherwise use percentage
|
|
atr = volatility.get("atr", current_price * 0.02)
|
|
suggested_stop_loss = trading_levels.get("suggested_stop_loss", current_price - 2 * atr)
|
|
suggested_take_profit = trading_levels.get("suggested_take_profit", current_price + 3 * atr)
|
|
risk_reward_ratio = trading_levels.get("risk_reward_ratio", 1.5)
|
|
|
|
# Price bounds (still enforce max 10% deviation)
|
|
if current_price > 0:
|
|
price_lower_bound = round(max(suggested_stop_loss, current_price * 0.90), 6)
|
|
price_upper_bound = round(min(suggested_take_profit, current_price * 1.10), 6)
|
|
entry_range_low = round(current_price * 0.98, 6)
|
|
entry_range_high = round(current_price * 1.02, 6)
|
|
else:
|
|
price_lower_bound = price_upper_bound = entry_range_low = entry_range_high = 0
|
|
|
|
system_prompt = f"""You are QuantDinger's Senior Financial Analyst with 20+ years of experience.
|
|
Provide professional, detailed analysis like a Wall Street analyst report.
|
|
|
|
{lang_instruction}
|
|
|
|
📐 TECHNICAL LEVELS (Pre-calculated from chart data):
|
|
- Support: ${support} | Resistance: ${resistance} | Pivot: ${pivot}
|
|
- ATR (14-day): ${atr:.4f} ({volatility.get('pct', 0)}% volatility)
|
|
- Suggested Stop Loss: ${suggested_stop_loss:.4f} (based on 2x ATR below support)
|
|
- Suggested Take Profit: ${suggested_take_profit:.4f} (based on 3x ATR above resistance)
|
|
- Risk/Reward Ratio: {risk_reward_ratio}
|
|
|
|
⚠️ CRITICAL PRICE RULES:
|
|
1. Current price: ${current_price}
|
|
2. Your stop_loss MUST be near ${suggested_stop_loss:.4f} (range: ${price_lower_bound:.4f} ~ ${current_price})
|
|
3. Your take_profit MUST be near ${suggested_take_profit:.4f} (range: ${current_price} ~ ${price_upper_bound:.4f})
|
|
4. Entry price: ${entry_range_low:.4f} ~ ${entry_range_high:.4f}
|
|
5. These levels are based on ATR and support/resistance analysis - use them as reference!
|
|
|
|
📊 YOUR ANALYSIS MUST INCLUDE:
|
|
1. **Technical Analysis**: Interpret the indicators, explain why support/resistance levels matter
|
|
2. **Fundamental Analysis**: Evaluate valuation, growth if data available
|
|
3. **Sentiment Analysis**: Assess market mood, news impact, macro factors
|
|
4. **Risk Assessment**: Explain why the stop loss level is appropriate
|
|
5. **Clear Recommendation**: BUY/SELL/HOLD with entry, stop loss (near suggested), take profit (near suggested)
|
|
|
|
Output ONLY valid JSON (do NOT include word counts or format hints in your actual response):
|
|
{{
|
|
"decision": "BUY" | "SELL" | "HOLD",
|
|
"confidence": 0-100,
|
|
"summary": "Executive summary in 2-3 sentences",
|
|
"analysis": {{
|
|
"technical": "Your detailed technical analysis here - interpret RSI, MACD, MA, support/resistance",
|
|
"fundamental": "Your fundamental assessment here - valuation, growth, competitive position",
|
|
"sentiment": "Your market sentiment analysis here - news impact, macro factors, mood"
|
|
}},
|
|
"entry_price": number,
|
|
"stop_loss": number,
|
|
"take_profit": number,
|
|
"position_size_pct": 1-100,
|
|
"timeframe": "short" | "medium" | "long",
|
|
"key_reasons": ["First key reason for this decision", "Second key reason", "Third key reason"],
|
|
"risks": ["Primary risk with potential impact", "Secondary risk"],
|
|
"technical_score": 0-100,
|
|
"fundamental_score": 0-100,
|
|
"sentiment_score": 0-100
|
|
}}
|
|
|
|
⚠️ IMPORTANT: The analysis fields should contain your ACTUAL analysis text, NOT the format description above."""
|
|
|
|
# Format indicator data for prompt (ensure safe defaults)
|
|
rsi_data = indicators.get("rsi") or {}
|
|
macd_data = indicators.get("macd") or {}
|
|
ma_data = indicators.get("moving_averages") or {}
|
|
vol_data = indicators.get("volatility") or {}
|
|
levels = indicators.get("levels") or {}
|
|
|
|
# Format macro data
|
|
macro = data.get("macro") or {}
|
|
macro_summary = self._format_macro_summary(macro, data.get("market", ""))
|
|
|
|
user_prompt = f"""Analyze {data['symbol']} in {data['market']} market.
|
|
|
|
📊 REAL-TIME DATA:
|
|
- Current Price: ${current_price}
|
|
- 24h Change: {change_24h}%
|
|
- Support: ${support}
|
|
- Resistance: ${resistance}
|
|
|
|
📈 TECHNICAL INDICATORS:
|
|
- RSI(14): {rsi_data.get('value', 'N/A')} ({rsi_data.get('signal', 'N/A')})
|
|
- MACD: {macd_data.get('signal', 'N/A')} ({macd_data.get('trend', 'N/A')})
|
|
- MA Trend: {ma_data.get('trend', 'N/A')}
|
|
- Volatility: {vol_data.get('level', 'N/A')} ({vol_data.get('pct', 0)}%)
|
|
- Trend: {indicators.get('trend', 'N/A')}
|
|
- Price Position (20d): {indicators.get('price_position', 'N/A')}%
|
|
|
|
🌐 MACRO ENVIRONMENT:
|
|
{macro_summary}
|
|
|
|
📰 MARKET NEWS ({len(data.get('news') or [])} items):
|
|
{news_summary}
|
|
|
|
💼 FUNDAMENTALS:
|
|
- Company: {company.get('name', data['symbol'])}
|
|
- Industry: {company.get('industry', 'N/A')}
|
|
- P/E Ratio: {fundamental.get('pe_ratio', 'N/A')}
|
|
- P/B Ratio: {fundamental.get('pb_ratio', 'N/A')}
|
|
- Market Cap: {fundamental.get('market_cap', 'N/A')}
|
|
- 52W High/Low: {fundamental.get('52w_high', 'N/A')} / {fundamental.get('52w_low', 'N/A')}
|
|
- ROE: {fundamental.get('roe', 'N/A')}
|
|
|
|
IMPORTANT: Consider the macro environment (especially DXY, VIX, rates) when making your recommendation.
|
|
Provide your analysis now. Remember: all prices must be within 10% of ${current_price}."""
|
|
|
|
return system_prompt, user_prompt
|
|
|
|
def _format_macro_summary(self, macro: Dict[str, Any], market: str) -> str:
|
|
"""格式化宏观数据摘要"""
|
|
if not macro:
|
|
return "宏观数据暂不可用"
|
|
|
|
lines = []
|
|
|
|
# 美元指数
|
|
if 'DXY' in macro:
|
|
dxy = macro['DXY']
|
|
direction = "↑" if dxy.get('change', 0) > 0 else "↓"
|
|
lines.append(f"- {dxy.get('name', 'USD Index')}: {dxy.get('price', 'N/A')} ({direction}{abs(dxy.get('changePercent', 0)):.2f}%)")
|
|
# 美元强弱对不同资产的影响
|
|
if market == 'Crypto':
|
|
impact = "利空加密货币" if dxy.get('change', 0) > 0 else "利好加密货币"
|
|
lines.append(f" ⚠️ 美元{direction} {impact}")
|
|
elif market == 'Forex':
|
|
lines.append(f" ⚠️ 美元{direction} 直接影响外汇走势")
|
|
|
|
# VIX恐慌指数
|
|
if 'VIX' in macro:
|
|
vix = macro['VIX']
|
|
vix_value = vix.get('price', 0)
|
|
if vix_value > 30:
|
|
level = "极度恐慌 (>30)"
|
|
elif vix_value > 20:
|
|
level = "较高恐慌 (20-30)"
|
|
elif vix_value > 15:
|
|
level = "正常 (15-20)"
|
|
else:
|
|
level = "低波动 (<15)"
|
|
lines.append(f"- {vix.get('name', 'VIX')}: {vix_value:.2f} - {level}")
|
|
|
|
# 美债收益率
|
|
if 'TNX' in macro:
|
|
tnx = macro['TNX']
|
|
direction = "↑" if tnx.get('change', 0) > 0 else "↓"
|
|
lines.append(f"- {tnx.get('name', '10Y Treasury')}: {tnx.get('price', 'N/A'):.3f}% ({direction})")
|
|
if tnx.get('price', 0) > 4.5:
|
|
lines.append(" ⚠️ 高利率环境,对估值不利")
|
|
|
|
# 黄金
|
|
if 'GOLD' in macro:
|
|
gold = macro['GOLD']
|
|
direction = "↑" if gold.get('change', 0) > 0 else "↓"
|
|
lines.append(f"- {gold.get('name', 'Gold')}: ${gold.get('price', 'N/A'):.2f} ({direction}{abs(gold.get('changePercent', 0)):.2f}%)")
|
|
|
|
# 标普500
|
|
if 'SPY' in macro:
|
|
spy = macro['SPY']
|
|
direction = "↑" if spy.get('change', 0) > 0 else "↓"
|
|
lines.append(f"- {spy.get('name', 'S&P 500')}: ${spy.get('price', 'N/A'):.2f} ({direction}{abs(spy.get('changePercent', 0)):.2f}%)")
|
|
|
|
# 比特币 (作为风险指标)
|
|
if 'BTC' in macro and market != 'Crypto':
|
|
btc = macro['BTC']
|
|
direction = "↑" if btc.get('change', 0) > 0 else "↓"
|
|
lines.append(f"- {btc.get('name', 'BTC')}: ${btc.get('price', 'N/A'):,.0f} ({direction}{abs(btc.get('changePercent', 0)):.2f}%) [风险偏好指标]")
|
|
|
|
return "\n".join(lines) if lines else "宏观数据暂不可用"
|
|
|
|
# ==================== Main Analysis ====================
|
|
|
|
def analyze(self, market: str, symbol: str, language: str = 'en-US',
|
|
model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
|
|
"""
|
|
Run fast single-call analysis.
|
|
|
|
Returns:
|
|
Complete analysis result with actionable recommendations.
|
|
"""
|
|
start_time = time.time()
|
|
|
|
result = {
|
|
"market": market,
|
|
"symbol": symbol,
|
|
"language": language,
|
|
"timeframe": timeframe,
|
|
"analysis_time_ms": 0,
|
|
"error": None,
|
|
}
|
|
|
|
try:
|
|
# Phase 1: Data collection (parallel)
|
|
logger.info(f"Fast analysis starting: {market}:{symbol}")
|
|
data = self._collect_market_data(market, symbol, timeframe)
|
|
|
|
# Validate we have essential data - with fallback to indicators
|
|
current_price = None
|
|
|
|
# 优先从 price 数据获取
|
|
if data.get("price") and data["price"].get("price"):
|
|
current_price = data["price"]["price"]
|
|
|
|
# Fallback: 从 indicators 获取 (如果 K 线成功计算了)
|
|
if not current_price and data.get("indicators"):
|
|
current_price = data["indicators"].get("current_price")
|
|
if current_price:
|
|
logger.info(f"Using price from indicators: ${current_price}")
|
|
# 构建简化的 price 数据
|
|
data["price"] = {
|
|
"price": current_price,
|
|
"change": 0,
|
|
"changePercent": 0,
|
|
"source": "indicators_fallback"
|
|
}
|
|
|
|
# Fallback: 从 kline 最后一根获取
|
|
if not current_price and data.get("kline"):
|
|
klines = data["kline"]
|
|
if klines and len(klines) > 0:
|
|
current_price = float(klines[-1].get("close", 0))
|
|
if current_price > 0:
|
|
logger.info(f"Using price from kline: ${current_price}")
|
|
prev_close = float(klines[-2].get("close", current_price)) if len(klines) > 1 else current_price
|
|
change = current_price - prev_close
|
|
change_pct = (change / prev_close * 100) if prev_close > 0 else 0
|
|
data["price"] = {
|
|
"price": current_price,
|
|
"change": round(change, 6),
|
|
"changePercent": round(change_pct, 2),
|
|
"source": "kline_fallback"
|
|
}
|
|
|
|
if not current_price or current_price <= 0:
|
|
result["error"] = "Failed to fetch current price from all sources"
|
|
logger.error(f"Price fetch failed for {market}:{symbol}, all sources exhausted")
|
|
return result
|
|
|
|
# Phase 2: Build prompt
|
|
system_prompt, user_prompt = self._build_analysis_prompt(data, language)
|
|
|
|
# Phase 3: Single LLM call
|
|
logger.info(f"Calling LLM for analysis...")
|
|
llm_start = time.time()
|
|
|
|
analysis = self.llm_service.safe_call_llm(
|
|
system_prompt,
|
|
user_prompt,
|
|
default_structure={
|
|
"decision": "HOLD",
|
|
"confidence": 50,
|
|
"summary": "Analysis failed",
|
|
"entry_price": current_price,
|
|
"stop_loss": current_price * 0.95,
|
|
"take_profit": current_price * 1.05,
|
|
"position_size_pct": 10,
|
|
"timeframe": "medium",
|
|
"key_reasons": ["Unable to analyze"],
|
|
"risks": ["Analysis error"],
|
|
"technical_score": 50,
|
|
"fundamental_score": 50,
|
|
"sentiment_score": 50,
|
|
},
|
|
model=model
|
|
)
|
|
|
|
llm_time = int((time.time() - llm_start) * 1000)
|
|
logger.info(f"LLM call completed in {llm_time}ms")
|
|
|
|
# Phase 4: Validate and constrain output
|
|
analysis = self._validate_and_constrain(analysis, current_price)
|
|
|
|
# Build final result
|
|
total_time = int((time.time() - start_time) * 1000)
|
|
|
|
# Extract detailed analysis sections
|
|
detailed_analysis = analysis.get("analysis", {})
|
|
if isinstance(detailed_analysis, str):
|
|
# If AI returned a string instead of dict, use it as technical analysis
|
|
detailed_analysis = {"technical": detailed_analysis, "fundamental": "", "sentiment": ""}
|
|
|
|
result.update({
|
|
"decision": analysis.get("decision", "HOLD"),
|
|
"confidence": analysis.get("confidence", 50),
|
|
"summary": analysis.get("summary", ""),
|
|
"detailed_analysis": {
|
|
"technical": detailed_analysis.get("technical", ""),
|
|
"fundamental": detailed_analysis.get("fundamental", ""),
|
|
"sentiment": detailed_analysis.get("sentiment", ""),
|
|
},
|
|
"trading_plan": {
|
|
"entry_price": analysis.get("entry_price"),
|
|
"stop_loss": analysis.get("stop_loss"),
|
|
"take_profit": analysis.get("take_profit"),
|
|
"position_size_pct": analysis.get("position_size_pct", 10),
|
|
"timeframe": analysis.get("timeframe", "medium"),
|
|
},
|
|
"reasons": analysis.get("key_reasons", []),
|
|
"risks": analysis.get("risks", []),
|
|
"scores": {
|
|
"technical": analysis.get("technical_score", 50),
|
|
"fundamental": analysis.get("fundamental_score", 50),
|
|
"sentiment": analysis.get("sentiment_score", 50),
|
|
"overall": self._calculate_overall_score(analysis),
|
|
},
|
|
"market_data": {
|
|
"current_price": current_price,
|
|
"change_24h": data["price"].get("changePercent", 0),
|
|
"support": data["indicators"].get("levels", {}).get("support"),
|
|
"resistance": data["indicators"].get("levels", {}).get("resistance"),
|
|
},
|
|
"indicators": data.get("indicators", {}),
|
|
"analysis_time_ms": total_time,
|
|
"llm_time_ms": llm_time,
|
|
"data_collection_time_ms": data.get("collection_time_ms", 0),
|
|
})
|
|
|
|
# Store in memory for future retrieval and get memory_id for feedback
|
|
memory_id = self._store_analysis_memory(result)
|
|
if memory_id:
|
|
result["memory_id"] = memory_id
|
|
|
|
logger.info(f"Fast analysis completed in {total_time}ms: {market}:{symbol} -> {result['decision']} (memory_id={memory_id})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fast analysis failed: {e}", exc_info=True)
|
|
result["error"] = str(e)
|
|
|
|
return result
|
|
|
|
def _validate_and_constrain(self, analysis: Dict, current_price: float) -> Dict:
|
|
"""
|
|
Validate LLM output and constrain prices to reasonable ranges.
|
|
This prevents absurd recommendations like "BTC at 95000, buy at 75000".
|
|
"""
|
|
if not current_price or current_price <= 0:
|
|
return analysis
|
|
|
|
# Price bounds
|
|
min_price = current_price * 0.90
|
|
max_price = current_price * 1.10
|
|
|
|
# Constrain entry price
|
|
entry = analysis.get("entry_price", current_price)
|
|
if entry and (entry < min_price or entry > max_price):
|
|
logger.warning(f"Entry price {entry} out of bounds, constraining to current price {current_price}")
|
|
analysis["entry_price"] = round(current_price, 6)
|
|
|
|
# Constrain stop loss
|
|
stop_loss = analysis.get("stop_loss", current_price * 0.95)
|
|
if stop_loss and (stop_loss < min_price or stop_loss > current_price):
|
|
analysis["stop_loss"] = round(current_price * 0.95, 6)
|
|
|
|
# Constrain take profit
|
|
take_profit = analysis.get("take_profit", current_price * 1.05)
|
|
if take_profit and (take_profit < current_price or take_profit > max_price):
|
|
analysis["take_profit"] = round(current_price * 1.05, 6)
|
|
|
|
# Constrain confidence
|
|
confidence = analysis.get("confidence", 50)
|
|
analysis["confidence"] = max(0, min(100, int(confidence)))
|
|
|
|
# Constrain scores
|
|
for score_key in ["technical_score", "fundamental_score", "sentiment_score"]:
|
|
score = analysis.get(score_key, 50)
|
|
analysis[score_key] = max(0, min(100, int(score)))
|
|
|
|
# Validate decision
|
|
decision = str(analysis.get("decision", "HOLD")).upper()
|
|
if decision not in ["BUY", "SELL", "HOLD"]:
|
|
analysis["decision"] = "HOLD"
|
|
else:
|
|
analysis["decision"] = decision
|
|
|
|
return analysis
|
|
|
|
def _calculate_overall_score(self, analysis: Dict) -> int:
|
|
"""Calculate weighted overall score."""
|
|
tech = analysis.get("technical_score", 50)
|
|
fund = analysis.get("fundamental_score", 50)
|
|
sent = analysis.get("sentiment_score", 50)
|
|
|
|
# Weights: technical 40%, fundamental 35%, sentiment 25%
|
|
overall = tech * 0.40 + fund * 0.35 + sent * 0.25
|
|
|
|
# Adjust based on decision
|
|
decision = analysis.get("decision", "HOLD")
|
|
confidence = analysis.get("confidence", 50)
|
|
|
|
if decision == "BUY":
|
|
overall = overall * 0.6 + (50 + confidence * 0.5) * 0.4
|
|
elif decision == "SELL":
|
|
overall = overall * 0.6 + (50 - confidence * 0.5) * 0.4
|
|
|
|
return max(0, min(100, int(overall)))
|
|
|
|
def _store_analysis_memory(self, result: Dict) -> Optional[int]:
|
|
"""Store analysis result for future learning. Returns memory_id."""
|
|
try:
|
|
from app.services.analysis_memory import get_analysis_memory
|
|
memory = get_analysis_memory()
|
|
memory_id = memory.store(result)
|
|
return memory_id
|
|
except Exception as e:
|
|
logger.warning(f"Memory storage failed: {e}")
|
|
return None
|
|
|
|
# ==================== Backward Compatibility ====================
|
|
|
|
def analyze_legacy_format(self, market: str, symbol: str, language: str = 'en-US',
|
|
model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
|
|
"""
|
|
Returns analysis in legacy multi-agent format for backward compatibility.
|
|
"""
|
|
fast_result = self.analyze(market, symbol, language, model, timeframe)
|
|
|
|
if fast_result.get("error"):
|
|
return {
|
|
"overview": {"report": f"Analysis failed: {fast_result['error']}"},
|
|
"fundamental": {"report": "N/A"},
|
|
"technical": {"report": "N/A"},
|
|
"news": {"report": "N/A"},
|
|
"sentiment": {"report": "N/A"},
|
|
"risk": {"report": "N/A"},
|
|
"error": fast_result["error"],
|
|
}
|
|
|
|
# Convert to legacy format
|
|
decision = fast_result.get("decision", "HOLD")
|
|
confidence = fast_result.get("confidence", 50)
|
|
scores = fast_result.get("scores", {})
|
|
|
|
return {
|
|
"overview": {
|
|
"overallScore": scores.get("overall", 50),
|
|
"recommendation": decision,
|
|
"confidence": confidence,
|
|
"dimensionScores": {
|
|
"fundamental": scores.get("fundamental", 50),
|
|
"technical": scores.get("technical", 50),
|
|
"news": scores.get("sentiment", 50),
|
|
"sentiment": scores.get("sentiment", 50),
|
|
"risk": 100 - confidence, # Inverse of confidence
|
|
},
|
|
"report": fast_result.get("summary", ""),
|
|
},
|
|
"fundamental": {
|
|
"score": scores.get("fundamental", 50),
|
|
"report": f"Fundamental score: {scores.get('fundamental', 50)}/100",
|
|
},
|
|
"technical": {
|
|
"score": scores.get("technical", 50),
|
|
"report": f"Technical score: {scores.get('technical', 50)}/100",
|
|
"indicators": fast_result.get("indicators", {}),
|
|
},
|
|
"news": {
|
|
"score": scores.get("sentiment", 50),
|
|
"report": "See sentiment analysis",
|
|
},
|
|
"sentiment": {
|
|
"score": scores.get("sentiment", 50),
|
|
"report": f"Sentiment score: {scores.get('sentiment', 50)}/100",
|
|
},
|
|
"risk": {
|
|
"score": 100 - confidence,
|
|
"report": "\n".join(fast_result.get("risks", [])),
|
|
},
|
|
"debate": {
|
|
"bull": {"confidence": confidence if decision == "BUY" else 50},
|
|
"bear": {"confidence": confidence if decision == "SELL" else 50},
|
|
"research_decision": fast_result.get("summary", ""),
|
|
},
|
|
"trader_decision": {
|
|
"decision": decision,
|
|
"confidence": confidence,
|
|
"reasoning": fast_result.get("summary", ""),
|
|
"trading_plan": fast_result.get("trading_plan", {}),
|
|
"report": "\n".join(fast_result.get("reasons", [])),
|
|
},
|
|
"risk_debate": {
|
|
"risky": {"recommendation": ""},
|
|
"neutral": {"recommendation": fast_result.get("summary", "")},
|
|
"safe": {"recommendation": ""},
|
|
},
|
|
"final_decision": {
|
|
"decision": decision,
|
|
"confidence": confidence,
|
|
"reasoning": fast_result.get("summary", ""),
|
|
"risk_summary": {
|
|
"risks": fast_result.get("risks", []),
|
|
},
|
|
"recommendation": "\n".join(fast_result.get("reasons", [])),
|
|
},
|
|
"fast_analysis": fast_result, # Include new format for gradual migration
|
|
"error": None,
|
|
}
|
|
|
|
|
|
# Singleton instance
|
|
_fast_analysis_service = None
|
|
|
|
def get_fast_analysis_service() -> FastAnalysisService:
|
|
"""Get singleton FastAnalysisService instance."""
|
|
global _fast_analysis_service
|
|
if _fast_analysis_service is None:
|
|
_fast_analysis_service = FastAnalysisService()
|
|
return _fast_analysis_service
|
|
|
|
|
|
def fast_analyze(market: str, symbol: str, language: str = 'en-US',
|
|
model: str = None, timeframe: str = "1D") -> Dict[str, Any]:
|
|
"""Convenience function for fast analysis."""
|
|
service = get_fast_analysis_service()
|
|
return service.analyze(market, symbol, language, model, timeframe)
|