Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-02-28 20:34:33 +08:00
parent 3db3bb2a9a
commit 7c067fe61c
7 changed files with 1346 additions and 67 deletions
+186 -20
View File
@@ -2,7 +2,7 @@
加密货币数据源
使用 CCXT (Coinbase) 获取数据
"""
from typing import Dict, List, Any, Optional
from typing import Dict, List, Any, Optional, Tuple
from datetime import datetime, timedelta
import ccxt
@@ -21,6 +21,9 @@ class CryptoDataSource(BaseDataSource):
# 时间周期映射
TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP
# 常见的报价货币列表(按优先级排序)
COMMON_QUOTES = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB', 'EUR', 'GBP']
def __init__(self):
config = {
'timeout': CCXTConfig.TIMEOUT,
@@ -43,27 +46,189 @@ class CryptoDataSource(BaseDataSource):
exchange_class = getattr(ccxt, exchange_id)
self.exchange = exchange_class(config)
# 延迟加载 markets(首次使用时加载)
self._markets_loaded = False
self._markets_cache = None
def _ensure_markets_loaded(self) -> bool:
"""确保 markets 已加载(用于符号验证)"""
if self._markets_loaded and self._markets_cache is not None:
return True
try:
# 某些交易所需要显式加载 markets
if hasattr(self.exchange, 'load_markets'):
self.exchange.load_markets(reload=False)
self._markets_cache = getattr(self.exchange, 'markets', {})
self._markets_loaded = True
return True
except Exception as e:
logger.debug(f"Failed to load markets for {self.exchange.id}: {e}")
return False
def _normalize_symbol(self, symbol: str) -> Tuple[str, str]:
"""
规范化符号格式,返回 (normalized_symbol, base_currency)
处理各种输入格式:
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
- BTC -> BTC/USDT (默认)
- PI, TRX -> PI/USDT, TRX/USDT
"""
if not symbol:
return '', ''
sym = symbol.strip()
# 移除 swap/futures 后缀
if ':' in sym:
sym = sym.split(':', 1)[0]
sym = sym.upper()
# 如果已经有分隔符,直接解析
if '/' in sym:
parts = sym.split('/', 1)
base = parts[0].strip()
quote = parts[1].strip() if len(parts) > 1 else ''
if base and quote:
return f"{base}/{quote}", base
# 尝试从常见报价货币中识别
for quote in self.COMMON_QUOTES:
if sym.endswith(quote) and len(sym) > len(quote):
base = sym[:-len(quote)]
if base:
return f"{base}/{quote}", base
# 如果无法识别,默认使用 USDT
return f"{sym}/USDT", sym
def _find_valid_symbol(self, base: str, preferred_quote: str = 'USDT') -> Optional[str]:
"""
在交易所的 markets 中查找有效的符号
Args:
base: 基础货币(如 'PI', 'TRX'
preferred_quote: 首选的报价货币
Returns:
找到的有效符号,如果找不到则返回 None
"""
if not self._ensure_markets_loaded():
return None
markets = self._markets_cache or {}
if not markets:
return None
# 按优先级尝试不同的报价货币
quotes_to_try = [preferred_quote] + [q for q in self.COMMON_QUOTES if q != preferred_quote]
for quote in quotes_to_try:
candidate = f"{base}/{quote}"
if candidate in markets:
market = markets[candidate]
# 检查市场是否活跃
if market.get('active', True):
return candidate
return None
def _normalize_symbol_for_exchange(self, symbol: str) -> str:
"""
根据交易所特性规范化符号
不同交易所的符号格式要求:
- Binance: BTC/USDT (标准格式)
- OKX: BTC/USDT (标准格式,但某些币种可能不支持)
- Coinbase: BTC/USD (通常使用 USD 而不是 USDT)
- Kraken: XBT/USD (BTC 映射为 XBT)
- Bitfinex: tBTCUST (特殊格式)
"""
normalized, base = self._normalize_symbol(symbol)
if not normalized or not base:
return symbol
exchange_id = getattr(self.exchange, 'id', '').lower()
# 特殊处理:某些交易所的符号映射
if exchange_id == 'coinbase':
# Coinbase 通常使用 USD 而不是 USDT
if normalized.endswith('/USDT'):
usd_version = normalized.replace('/USDT', '/USD')
if self._ensure_markets_loaded():
markets = self._markets_cache or {}
if usd_version in markets:
return usd_version
# 尝试在交易所中查找有效符号
if self._ensure_markets_loaded():
valid_symbol = self._find_valid_symbol(base, normalized.split('/')[1] if '/' in normalized else 'USDT')
if valid_symbol:
return valid_symbol
return normalized
def get_ticker(self, symbol: str) -> Dict[str, Any]:
"""
Get latest ticker for a crypto symbol via CCXT.
Accepts common formats:
- BTC/USDT
- BTCUSDT
- BTC/USDT:USDT (swap-style suffix, will be normalized)
- BTC/USDT, BTCUSDT, BTC/USDT:USDT
- PI, TRX (will be normalized and searched across exchanges)
- 自动适配不同交易所的符号格式要求
"""
sym = (symbol or "").strip()
if ":" in sym:
sym = sym.split(":", 1)[0]
sym = sym.upper()
if "/" not in sym:
# Coinbase often uses USD, check if we need to adapt
if sym.endswith("USDT") and len(sym) > 4:
sym = f"{sym[:-4]}/USDT"
elif sym.endswith("USD") and len(sym) > 3:
sym = f"{sym[:-3]}/USD"
return self.exchange.fetch_ticker(sym)
if not symbol or not symbol.strip():
return {'last': 0, 'symbol': symbol}
# 规范化符号
normalized = self._normalize_symbol_for_exchange(symbol)
if not normalized:
logger.warning(f"Failed to normalize symbol: {symbol}")
return {'last': 0, 'symbol': symbol}
# 尝试获取 ticker
try:
ticker = self.exchange.fetch_ticker(normalized)
if ticker and isinstance(ticker, dict):
return ticker
except Exception as e:
error_msg = str(e).lower()
is_symbol_error = any(keyword in error_msg for keyword in [
'does not have market symbol',
'symbol not found',
'invalid symbol',
'market does not exist',
'trading pair not found'
])
if is_symbol_error:
# 尝试查找替代符号
base = normalized.split('/')[0] if '/' in normalized else normalized
if self._ensure_markets_loaded():
valid_symbol = self._find_valid_symbol(base)
if valid_symbol and valid_symbol != normalized:
try:
logger.debug(f"Trying alternative symbol: {valid_symbol} (original: {symbol}, first attempt: {normalized})")
ticker = self.exchange.fetch_ticker(valid_symbol)
if ticker and isinstance(ticker, dict):
return ticker
except Exception as e2:
logger.debug(f"Alternative symbol {valid_symbol} also failed: {e2}")
# 如果所有尝试都失败,记录警告并返回默认值
logger.warning(
f"Symbol '{symbol}' (normalized: {normalized}) not found on {self.exchange.id}. "
f"Error: {str(e)[:100]}"
)
return {'last': 0, 'symbol': symbol}
def get_kline(
self,
@@ -78,11 +243,12 @@ class CryptoDataSource(BaseDataSource):
try:
ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d')
# 构建交易对符号
if not symbol.endswith('USDT') and not symbol.endswith('USD'):
symbol_pair = f'{symbol}/USDT'
else:
symbol_pair = symbol
# 使用统一的符号规范化方法
symbol_pair = self._normalize_symbol_for_exchange(symbol)
if not symbol_pair:
logger.warning(f"Failed to normalize symbol for K-line: {symbol}")
return []
# logger.info(f"获取加密货币K线: {symbol_pair}, 周期: {ccxt_timeframe}, 条数: {limit}")
+811 -19
View File
@@ -281,11 +281,53 @@ class FastAnalysisService:
else:
price_lower_bound = price_upper_bound = entry_range_low = entry_range_high = 0
# Get technical indicator values for decision constraints
rsi_value = indicators.get("rsi", {}).get("value", 50)
macd_signal = indicators.get("macd", {}).get("signal", "neutral")
ma_trend = indicators.get("moving_averages", {}).get("trend", "sideways")
# Build decision guidance based on technical indicators
decision_guidance = self._build_decision_guidance(rsi_value, macd_signal, ma_trend, change_24h)
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.
You are CONSERVATIVE and OBJECTIVE. Your analysis must be based on DATA, not speculation.
{lang_instruction}
🎯 CRITICAL DECISION RULES (MUST FOLLOW):
1. **Market Context**: This market supports BOTH long (BUY) and short (SELL) positions. SELL signals are VALID trading opportunities, not just risk warnings.
2. **Multi-Factor Analysis** (IMPORTANT - Consider ALL factors):
- **Technical Indicators** (RSI, MACD, MA trends): Provide baseline direction
- **Macro Environment** (DXY, VIX, interest rates, geopolitical events): Can override technical signals
- **Breaking News & Events**: Major news can cause sudden reversals - pay attention!
- **Fundamental Data**: Valuation, growth, financial health matter for medium/long-term
- **Market Sentiment**: News sentiment, fear/greed index, market mood
3. **Decision Priority** (When factors conflict):
- **Major macro events** (war, policy changes, major economic data) > Technical indicators
- **Breaking news** (regulatory changes, major partnerships, scandals) > Short-term technical
- **Technical indicators** > General news sentiment (when no major events)
- **Fundamental data** > Short-term price movements (for long-term decisions)
4. **Balance Your Decisions** (IMPORTANT - Give SELL signals when appropriate):
- BUY: When technical indicators show oversold (RSI < 40), bullish MACD, uptrend, OR strong macro/fundamental catalyst
- SELL: When technical indicators show overbought (RSI > 60), bearish MACD, downtrend, OR major negative macro/news event
- HOLD: Only when signals are truly mixed or unclear - DO NOT default to HOLD just because you're uncertain
- **Remember**: SELL is a valid trading signal for short positions, not just a warning to avoid buying
5. **Confidence Thresholds**:
- BUY requires confidence >= 60 AND (technical support OR macro/fundamental catalyst)
- SELL requires confidence >= 60 AND (technical support OR negative event) - SELL signals are encouraged when indicators suggest downside
- HOLD only when confidence < 60 AND signals are truly unclear
6. **Identify Trading Opportunities**:
- When RSI > 60, MACD bearish, downtrend: Consider SELL (short position opportunity)
- When RSI < 40, MACD bullish, uptrend: Consider BUY (long position opportunity)
- Do NOT default to HOLD when clear technical signals exist
7. **Consider Macro Impact**:
- Strong USD (DXY ↑) usually negative for crypto/commodities → Consider SELL
- High VIX (>30) indicates fear → Consider SELL or HOLD, avoid BUY
- Rising interest rates usually negative for growth assets → Consider SELL
- Geopolitical tensions can cause sudden volatility → Consider SELL if risk-off sentiment
{decision_guidance}
📐 TECHNICAL LEVELS (Pre-calculated from chart data):
- Support: ${support} | Resistance: ${resistance} | Pivot: ${pivot}
- ATR (14-day): ${atr:.4f} ({volatility.get('pct', 0)}% volatility)
@@ -300,22 +342,42 @@ Provide professional, detailed analysis like a Wall Street analyst report.
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)
📊 YOUR ANALYSIS MUST INCLUDE (ALL factors are important):
1. **Technical Analysis**: Objectively interpret RSI, MACD, MA, support/resistance. Be honest about conflicting signals.
2. **Macro Environment Analysis**:
- Analyze DXY, VIX, interest rates impact on the asset
- Consider geopolitical events and their potential impact
- Evaluate how macro trends affect this specific market/symbol
3. **News & Event Analysis**:
- Identify BREAKING NEWS or major events that could cause sudden moves
- Assess news sentiment and its credibility
- Consider regulatory changes, partnerships, scandals, etc.
- Don't ignore major news just because technical indicators look good
4. **Fundamental Analysis**: Evaluate valuation, growth, competitive position if data available. If data is insufficient, say so.
5. **Risk Assessment**:
- Explain why the stop loss level is appropriate
- List ALL significant risks (technical, macro, news, fundamental)
- Consider tail risks from unexpected events
6. **Clear Recommendation**: BUY/SELL/HOLD with entry, stop loss (near suggested), take profit (near suggested)
- **BUY**: For long positions when indicators suggest upside
- **SELL**: For short positions when indicators suggest downside - this is a VALID trading opportunity
- **HOLD**: Only when signals are truly unclear - DO NOT default to HOLD just to be safe
- Your decision should reflect the WEIGHTED importance of ALL factors
- If macro/news factors strongly contradict technical, explain why you prioritize one over the other
7. **Trading Opportunity Recognition**:
- When you see RSI > 60, bearish MACD, downtrend → Give SELL signal (short opportunity)
- When you see RSI < 40, bullish MACD, uptrend → Give BUY signal (long opportunity)
- Only choose HOLD when signals are genuinely mixed or unclear
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",
"summary": "Executive summary in 2-3 sentences - be honest about uncertainty if present",
"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"
"technical": "Your detailed technical analysis here - interpret RSI, MACD, MA, support/resistance objectively",
"fundamental": "Your fundamental assessment here - valuation, growth, competitive position. If data is limited, state that clearly.",
"sentiment": "Your market sentiment analysis here - news impact, macro factors, mood. Don't overreact."
}},
"entry_price": number,
"stop_loss": number,
@@ -329,7 +391,20 @@ Output ONLY valid JSON (do NOT include word counts or format hints in your actua
"sentiment_score": 0-100
}}
⚠️ IMPORTANT: The analysis fields should contain your ACTUAL analysis text, NOT the format description above."""
⚠️ IMPORTANT:
- The analysis fields should contain your ACTUAL analysis text, NOT the format description above.
- Be HONEST and CONSERVATIVE. If you're not confident, choose HOLD with lower confidence.
- Do NOT make up facts or exaggerate. Base everything on the provided data.
📊 OBJECTIVE SCORING SYSTEM (Reference):
The system will calculate an objective score based on technical indicators, fundamentals, sentiment, and macro factors.
- Score >= +40: Bullish signal → BUY recommended
- Score <= -40: Bearish signal → SELL recommended
- Score between -40 and +40: Neutral → HOLD recommended
- Score >= +70: Strong bullish → Strong BUY signal
- Score <= -70: Strong bearish → Strong SELL signal
Your decision should align with this objective score when it's significant (>=40 or <=-40).
When the score is neutral (-40 to +40), you can use your judgment, but still consider giving BUY/SELL if technical indicators are clear."""
# Format indicator data for prompt (ensure safe defaults)
rsi_data = indicators.get("rsi") or {}
@@ -372,12 +447,124 @@ Output ONLY valid JSON (do NOT include word counts or format hints in your actua
- 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')}
- Revenue Growth: {fundamental.get('revenue_growth', 'N/A')}
- Profit Margin: {fundamental.get('profit_margin', 'N/A')}
- Debt to Equity: {fundamental.get('debt_to_equity', 'N/A')}
- Current Ratio: {fundamental.get('current_ratio', 'N/A')}
- Free Cash Flow: {fundamental.get('free_cash_flow', '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}."""
📊 FINANCIAL STATEMENTS (Latest Quarter):
{self._format_financial_statements(fundamental.get('financial_statements', {}))}
📈 EARNINGS DATA:
{self._format_earnings_data(fundamental.get('earnings', {}))}
IMPORTANT:
1. Consider the macro environment (especially DXY, VIX, rates, geopolitical events) when making your recommendation.
2. Pay attention to BREAKING NEWS and international events that could cause sudden market moves.
3. For US stocks, analyze financial statements and earnings trends to assess company health.
4. Provide your analysis now. Remember: all prices must be within 10% of ${current_price}."""
return system_prompt, user_prompt
def _format_financial_statements(self, statements: Dict[str, Any]) -> str:
"""格式化财务报表数据用于提示词"""
if not statements:
return "财务报表数据暂不可用"
lines = []
# 资产负债表
if 'balance_sheet' in statements:
bs = statements['balance_sheet']
lines.append("资产负债表 (Balance Sheet):")
if bs.get('total_assets'):
lines.append(f" - 总资产: ${bs['total_assets']:,.0f}")
if bs.get('total_liabilities'):
lines.append(f" - 总负债: ${bs['total_liabilities']:,.0f}")
if bs.get('total_equity'):
lines.append(f" - 股东权益: ${bs['total_equity']:,.0f}")
if bs.get('cash'):
lines.append(f" - 现金: ${bs['cash']:,.0f}")
if bs.get('debt'):
lines.append(f" - 总债务: ${bs['debt']:,.0f}")
if bs.get('current_assets') and bs.get('current_liabilities'):
current_ratio = bs['current_assets'] / bs['current_liabilities'] if bs['current_liabilities'] > 0 else 0
lines.append(f" - 流动比率: {current_ratio:.2f}")
# 利润表
if 'income_statement' in statements:
is_stmt = statements['income_statement']
lines.append("利润表 (Income Statement):")
if is_stmt.get('total_revenue'):
lines.append(f" - 总收入: ${is_stmt['total_revenue']:,.0f}")
if is_stmt.get('gross_profit'):
lines.append(f" - 毛利润: ${is_stmt['gross_profit']:,.0f}")
if is_stmt.get('operating_income'):
lines.append(f" - 营业利润: ${is_stmt['operating_income']:,.0f}")
if is_stmt.get('net_income'):
lines.append(f" - 净利润: ${is_stmt['net_income']:,.0f}")
if is_stmt.get('eps'):
lines.append(f" - 每股收益: ${is_stmt['eps']:.2f}")
# 现金流量表
if 'cash_flow' in statements:
cf = statements['cash_flow']
lines.append("现金流量表 (Cash Flow):")
if cf.get('operating_cash_flow'):
lines.append(f" - 经营现金流: ${cf['operating_cash_flow']:,.0f}")
if cf.get('free_cash_flow'):
lines.append(f" - 自由现金流: ${cf['free_cash_flow']:,.0f}")
return "\n".join(lines) if lines else "财务报表数据暂不可用"
def _format_earnings_data(self, earnings: Dict[str, Any]) -> str:
"""格式化盈利数据用于提示词"""
if not earnings:
return "盈利数据暂不可用"
lines = []
# 历史盈利
if 'history' in earnings and earnings['history']:
lines.append("历史盈利 (Earnings History):")
for i, hist in enumerate(earnings['history'][:4], 1):
date = hist.get('date', 'N/A')
eps_actual = hist.get('eps_actual')
eps_estimate = hist.get('eps_estimate')
surprise = hist.get('surprise')
if eps_actual is not None:
line = f" {i}. {date}: EPS实际={eps_actual:.2f}"
if eps_estimate is not None:
line += f", 预期={eps_estimate:.2f}"
if surprise is not None:
surprise_str = f"{surprise:+.1f}%"
line += f", 超预期={surprise_str}"
lines.append(line)
# 未来盈利
if 'upcoming' in earnings:
upcoming = earnings['upcoming']
if upcoming.get('next_earnings_date'):
lines.append(f"下次盈利报告: {upcoming['next_earnings_date']}")
if upcoming.get('eps_estimate'):
lines.append(f" - EPS预期: ${upcoming['eps_estimate']:.2f}")
if upcoming.get('revenue_estimate'):
lines.append(f" - 收入预期: ${upcoming['revenue_estimate']:,.0f}")
# 季度盈利
if 'quarterly' in earnings:
q = earnings['quarterly']
if q.get('latest_quarter'):
lines.append(f"最新季度 ({q['latest_quarter']}):")
if q.get('revenue'):
lines.append(f" - 收入: ${q['revenue']:,.0f}")
if q.get('earnings'):
lines.append(f" - 盈利: ${q['earnings']:,.0f}")
return "\n".join(lines) if lines else "盈利数据暂不可用"
def _format_macro_summary(self, macro: Dict[str, Any], market: str) -> str:
"""格式化宏观数据摘要"""
if not macro:
@@ -546,8 +733,50 @@ Provide your analysis now. Remember: all prices must be within 10% of ${current_
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)
# Phase 4: Calculate objective score and determine decision based on score
objective_score = self._calculate_objective_score(data, current_price)
logger.info(f"Objective score calculated: {objective_score['overall_score']:.1f} (Technical: {objective_score['technical_score']:.1f}, Fundamental: {objective_score['fundamental_score']:.1f}, Sentiment: {objective_score['sentiment_score']:.1f}, Macro: {objective_score['macro_score']:.1f})")
# Determine decision based on objective score thresholds
score_based_decision = self._score_to_decision(objective_score['overall_score'])
logger.info(f"Score-based decision: {score_based_decision} (score: {objective_score['overall_score']:.1f})")
# Override LLM decision with score-based decision if they differ significantly
llm_decision = analysis.get("decision", "HOLD")
if llm_decision != score_based_decision:
score_abs = abs(objective_score['overall_score'])
# 降低阈值,因为现在HOLD区间更小了,±40以上的评分就应该覆盖
if score_abs >= 25: # 如果评分达到±25以上,就覆盖LLM决策(因为阈值是±40)
logger.warning(f"LLM decision '{llm_decision}' conflicts with score-based decision '{score_based_decision}' (score: {objective_score['overall_score']:.1f}). Overriding to score-based decision.")
analysis["decision"] = score_based_decision
# Adjust confidence based on score strength
# 评分越高,置信度越高(最高95,最低60)
analysis["confidence"] = min(95, max(60, int(50 + score_abs * 0.45)))
# Update summary to mention score-based decision
original_summary = analysis.get("summary", "")
score_level = "强烈" if score_abs >= 70 else "明显" if score_abs >= 40 else "轻微"
analysis["summary"] = f"{original_summary} [基于客观评分系统:综合评分{objective_score['overall_score']:.1f}分({score_level}{'利多' if objective_score['overall_score'] > 0 else '利空'}),建议{score_based_decision}]"
else:
logger.info(f"LLM decision '{llm_decision}' differs from score-based '{score_based_decision}' but score is close to neutral ({objective_score['overall_score']:.1f}), keeping LLM decision")
# Add objective scores to analysis
analysis["objective_score"] = objective_score
analysis["score_based_decision"] = score_based_decision
# Phase 5: Validate and constrain output (pass indicators for decision validation)
# Check for major news or macro events that could override technical indicators
news_data = data.get("news") or []
macro_data = data.get("macro") or {}
has_major_news = self._has_major_news(news_data)
has_macro_event = self._has_macro_event(macro_data, data.get("market", ""))
analysis = self._validate_and_constrain(
analysis,
current_price,
indicators=data.get("indicators"),
has_major_news=has_major_news,
has_macro_event=has_macro_event
)
# Build final result
total_time = int((time.time() - start_time) * 1000)
@@ -582,6 +811,8 @@ Provide your analysis now. Remember: all prices must be within 10% of ${current_
"sentiment": analysis.get("sentiment_score", 50),
"overall": self._calculate_overall_score(analysis),
},
"objective_score": analysis.get("objective_score", {}),
"score_based_decision": analysis.get("score_based_decision", "HOLD"),
"market_data": {
"current_price": current_price,
"change_24h": data["price"].get("changePercent", 0),
@@ -607,10 +838,134 @@ Provide your analysis now. Remember: all prices must be within 10% of ${current_
return result
def _validate_and_constrain(self, analysis: Dict, current_price: float) -> Dict:
def _build_decision_guidance(self, rsi_value: float, macd_signal: str, ma_trend: str, change_24h: float) -> str:
"""
根据技术指标构建决策指导,帮助AI做出更合理的决策。
强调SELL信号是有效的做空机会。
"""
guidance_parts = []
# RSI 指导 - 更积极地识别做空机会
if rsi_value > 70:
guidance_parts.append("🔴 RSI > 70 (超买): 强烈建议SELL做空,避免BUY")
elif rsi_value > 60:
guidance_parts.append("🟠 RSI > 60 (偏超买): 建议SELL做空,谨慎BUY")
elif rsi_value < 30:
guidance_parts.append("🟢 RSI < 30 (超卖): 建议BUY做多,避免SELL")
elif rsi_value < 40:
guidance_parts.append("🟡 RSI < 40 (偏超卖): 可以考虑BUY做多")
else:
guidance_parts.append("⚪ RSI 40-60 (中性): 技术面中性,需要结合其他指标判断")
# MACD 指导 - 明确做空信号
if macd_signal == "bullish":
guidance_parts.append("🟢 MACD 看涨: 支持BUY做多")
elif macd_signal == "bearish":
guidance_parts.append("🔴 MACD 看跌: 支持SELL做空,这是有效的做空机会")
else:
guidance_parts.append("⚪ MACD 中性: 无明显方向")
# MA 趋势指导 - 识别趋势反转机会
if "uptrend" in ma_trend.lower() or "strong_uptrend" in ma_trend.lower():
if rsi_value > 60:
guidance_parts.append("⚠️ 均线向上但RSI超买: 可能接近顶部,考虑SELL做空")
else:
guidance_parts.append("🟢 均线趋势向上: 支持BUY做多")
elif "downtrend" in ma_trend.lower() or "strong_downtrend" in ma_trend.lower():
guidance_parts.append("🔴 均线趋势向下: 这是SELL做空的良好机会,避免BUY")
else:
guidance_parts.append("⚪ 均线横盘: 趋势不明确")
# 24小时涨跌幅指导 - 识别过度波动
if change_24h > 5:
guidance_parts.append("🔴 24h涨幅 > 5%: 可能已过度上涨,建议SELL做空或获利了结")
elif change_24h < -5:
guidance_parts.append("🟢 24h跌幅 > 5%: 可能已过度下跌,可以考虑BUY做多")
# 综合建议
sell_signals = sum([
rsi_value > 60,
macd_signal == "bearish",
"downtrend" in ma_trend.lower(),
change_24h > 5
])
buy_signals = sum([
rsi_value < 40,
macd_signal == "bullish",
"uptrend" in ma_trend.lower(),
change_24h < -5
])
if sell_signals >= 2:
guidance_parts.append(f"📊 综合判断: {sell_signals}个做空信号,建议考虑SELL")
elif buy_signals >= 2:
guidance_parts.append(f"📊 综合判断: {buy_signals}个做多信号,建议考虑BUY")
else:
guidance_parts.append("📊 综合判断: 信号混合,需要结合宏观和新闻判断")
return "\n".join(guidance_parts) if guidance_parts else "技术指标数据不足,请谨慎判断"
def _has_major_news(self, news_data: List[Dict]) -> bool:
"""
检查是否有重大新闻事件。
重大新闻包括:监管变化、重大合作、丑闻、重大政策等。
"""
if not news_data:
return False
# 检查新闻标题中的关键词
major_keywords = [
"regulation", "regulatory", "ban", "approval", "partnership", "merger", "acquisition",
"scandal", "lawsuit", "investigation", "policy", "government", "central bank",
"监管", "禁令", "批准", "合作", "合并", "收购", "丑闻", "诉讼", "调查", "政策", "政府", "央行"
]
for news in news_data[:5]: # 只检查前5条最新新闻
title = (news.get("title") or news.get("headline") or "").lower()
sentiment = news.get("sentiment", "neutral")
# 如果有重大关键词且情绪强烈(非中性),认为是重大新闻
if any(keyword in title for keyword in major_keywords) and sentiment != "neutral":
return True
return False
def _has_macro_event(self, macro_data: Dict, market: str) -> bool:
"""
检查是否有重大宏观事件。
重大宏观事件包括:VIX异常高、DXY大幅波动、利率政策变化等。
"""
if not macro_data:
return False
# 检查VIX(恐慌指数)
if "VIX" in macro_data:
vix = macro_data["VIX"]
vix_value = vix.get("price", 0)
if vix_value > 30: # VIX > 30 表示极度恐慌
return True
# 检查DXY大幅波动(>1%
if "DXY" in macro_data:
dxy = macro_data["DXY"]
change_pct = abs(dxy.get("changePercent", 0))
if change_pct > 1.0: # 美元指数波动超过1%
return True
# 检查利率变化(对股票和加密货币影响大)
if "TNX" in macro_data and market in ["USStock", "Crypto"]:
tnx = macro_data["TNX"]
change_pct = abs(tnx.get("changePercent", 0))
if change_pct > 2.0: # 利率变化超过2%
return True
return False
def _validate_and_constrain(self, analysis: Dict, current_price: float, indicators: Dict = None,
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
"""
Validate LLM output and constrain prices to reasonable ranges.
This prevents absurd recommendations like "BTC at 95000, buy at 75000".
Also validate decision against technical indicators to prevent absurd recommendations.
"""
if not current_price or current_price <= 0:
return analysis
@@ -651,10 +1006,447 @@ Provide your analysis now. Remember: all prices must be within 10% of ${current_
else:
analysis["decision"] = decision
# 基于技术指标验证决策合理性(允许宏观/新闻因素覆盖)
if indicators:
analysis = self._validate_decision_against_indicators(
analysis, indicators, confidence,
has_major_news=has_major_news,
has_macro_event=has_macro_event
)
return analysis
def _validate_decision_against_indicators(self, analysis: Dict, indicators: Dict, confidence: int,
has_major_news: bool = False, has_macro_event: bool = False) -> Dict:
"""
根据技术指标验证决策的合理性,但允许宏观/新闻因素覆盖技术指标。
Args:
analysis: AI分析结果
indicators: 技术指标数据
confidence: 置信度
has_major_news: 是否有重大新闻事件
has_macro_event: 是否有重大宏观事件
"""
decision = analysis.get("decision", "HOLD")
rsi_data = indicators.get("rsi", {})
macd_data = indicators.get("macd", {})
ma_data = indicators.get("moving_averages", {})
rsi_value = rsi_data.get("value", 50)
macd_signal = macd_data.get("signal", "neutral")
ma_trend = ma_data.get("trend", "sideways")
# 如果置信度太低,强制改为HOLD
if confidence < 60:
if decision != "HOLD":
logger.warning(f"Decision {decision} with low confidence {confidence}, forcing to HOLD")
analysis["decision"] = "HOLD"
analysis["confidence"] = max(confidence, 45) # 降低置信度
return analysis
# 如果有重大新闻或宏观事件,允许覆盖技术指标(但记录警告)
allow_override = has_major_news or has_macro_event
# 检查BUY决策是否与技术指标矛盾
if decision == "BUY":
conflicts = []
# RSI > 70 时不应该BUY(除非有重大利好)
if rsi_value > 70:
conflicts.append(f"RSI {rsi_value:.1f} > 70 (超买)")
# MACD看跌时不应该BUY(除非有重大利好)
if macd_signal == "bearish":
conflicts.append("MACD bearish")
# 均线趋势向下时不应该BUY(除非有重大利好)
if "downtrend" in ma_trend.lower():
conflicts.append(f"MA trend: {ma_trend}")
if conflicts:
if allow_override:
# 允许覆盖,但降低置信度并添加说明
logger.info(f"BUY decision conflicts with indicators but major news/macro event allows override: {', '.join(conflicts)}")
analysis["confidence"] = max(confidence - 15, 50)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]"
else:
# 没有重大事件,强制改为HOLD
logger.warning(f"BUY decision conflicts with indicators and no major event: {', '.join(conflicts)}. Forcing to HOLD")
analysis["decision"] = "HOLD"
analysis["confidence"] = max(confidence - 20, 40)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},建议观望]"
# 检查SELL决策是否与技术指标矛盾(放宽限制,因为SELL是有效的做空机会)
elif decision == "SELL":
conflicts = []
# 只有在强烈看涨信号时才阻止SELL(放宽条件)
# RSI < 30 且 MACD看涨 且 均线向上时,才认为矛盾
if rsi_value < 30 and macd_signal == "bullish" and "uptrend" in ma_trend.lower():
conflicts.append(f"Strong bullish signals (RSI {rsi_value:.1f} < 30, MACD bullish, uptrend)")
# 或者 RSI < 30 且 均线强烈向上
elif rsi_value < 30 and "strong_uptrend" in ma_trend.lower():
conflicts.append(f"Very strong uptrend with oversold RSI {rsi_value:.1f}")
if conflicts:
if allow_override:
# 允许覆盖,但降低置信度并添加说明
logger.info(f"SELL decision conflicts with strong bullish indicators but major news/macro event allows override: {', '.join(conflicts)}")
analysis["confidence"] = max(confidence - 15, 50)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},但重大事件可能改变趋势]"
else:
# 只有在非常强烈的看涨信号时才改为HOLD
logger.warning(f"SELL decision conflicts with very strong bullish indicators: {', '.join(conflicts)}. Forcing to HOLD")
analysis["decision"] = "HOLD"
analysis["confidence"] = max(confidence - 20, 40)
original_summary = analysis.get("summary", "")
analysis["summary"] = f"{original_summary} [注意:技术指标显示{', '.join(conflicts)},建议观望]"
return analysis
def _calculate_objective_score(self, data: Dict[str, Any], current_price: float) -> Dict[str, float]:
"""
基于客观数据计算量化评分系统
返回一个-100到+100的分数:
- +100: 强烈利多(强烈BUY
- +70到+100: 强烈利多(强烈BUY
- +40到+70: 利多(BUY
- -40到+40: 中性(HOLD
- -70到-40: 利空(SELL
- -100到-70: 强烈利空(强烈SELL
- -100: 强烈利空(强烈SELL
"""
indicators = data.get("indicators") or {}
fundamental = data.get("fundamental") or {}
news = data.get("news") or []
macro = data.get("macro") or {}
price_data = data.get("price") or {}
# 1. 技术指标评分 (-100 to +100)
technical_score = self._calculate_technical_score(indicators, price_data)
# 2. 基本面评分 (-100 to +100)
fundamental_score = self._calculate_fundamental_score(fundamental, data.get("market", ""))
# 3. 新闻情绪评分 (-100 to +100)
sentiment_score = self._calculate_sentiment_score(news)
# 4. 宏观环境评分 (-100 to +100)
macro_score = self._calculate_macro_score(macro, data.get("market", ""))
# 5. 综合评分(加权平均)
# 权重:技术40%,基本面25%,情绪20%,宏观15%
overall_score = (
technical_score * 0.40 +
fundamental_score * 0.25 +
sentiment_score * 0.20 +
macro_score * 0.15
)
return {
"technical_score": technical_score,
"fundamental_score": fundamental_score,
"sentiment_score": sentiment_score,
"macro_score": macro_score,
"overall_score": overall_score
}
def _calculate_technical_score(self, indicators: Dict, price_data: Dict) -> float:
"""计算技术指标评分 (-100 to +100)"""
score = 0.0
weight_sum = 0.0
# RSI 评分 (-50 to +50)
rsi_data = indicators.get("rsi", {})
rsi_value = rsi_data.get("value", 50)
if rsi_value > 0:
if rsi_value > 70:
rsi_score = -50 # 超买,强烈利空
elif rsi_value > 60:
rsi_score = -30 # 偏超买,利空
elif rsi_value < 30:
rsi_score = +50 # 超卖,强烈利多
elif rsi_value < 40:
rsi_score = +30 # 偏超卖,利多
else:
rsi_score = (50 - rsi_value) * 0.6 # 40-60之间,线性映射
score += rsi_score * 0.30
weight_sum += 0.30
# MACD 评分 (-40 to +40)
macd_data = indicators.get("macd", {})
macd_signal = macd_data.get("signal", "neutral")
if macd_signal == "bullish":
macd_score = +40
elif macd_signal == "bearish":
macd_score = -40
else:
macd_score = 0
score += macd_score * 0.25
weight_sum += 0.25
# 均线趋势评分 (-40 to +40)
ma_data = indicators.get("moving_averages", {})
ma_trend = ma_data.get("trend", "sideways")
if "strong_uptrend" in ma_trend.lower():
ma_score = +40
elif "uptrend" in ma_trend.lower():
ma_score = +25
elif "strong_downtrend" in ma_trend.lower():
ma_score = -40
elif "downtrend" in ma_trend.lower():
ma_score = -25
else:
ma_score = 0
score += ma_score * 0.25
weight_sum += 0.25
# 24小时涨跌幅评分 (-20 to +20)
change_24h = price_data.get("changePercent", 0)
if change_24h > 10:
change_score = -20 # 过度上涨,利空
elif change_24h > 5:
change_score = -10
elif change_24h < -10:
change_score = +20 # 过度下跌,利多
elif change_24h < -5:
change_score = +10
else:
change_score = change_24h * 2 # 线性映射
score += change_score * 0.20
weight_sum += 0.20
# 归一化到-100到+100
if weight_sum > 0:
score = score / weight_sum * 100
return max(-100, min(100, score))
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
"""计算基本面评分 (-100 to +100)"""
if market != "USStock" or not fundamental:
return 0.0 # 非美股或无基本面数据,返回中性
score = 0.0
factors = 0
# PE Ratio 评分
pe_ratio = fundamental.get("pe_ratio")
if pe_ratio and pe_ratio > 0:
if pe_ratio < 15:
pe_score = +20 # 低PE,利多
elif pe_ratio < 25:
pe_score = +10
elif pe_ratio > 50:
pe_score = -20 # 高PE,利空
elif pe_ratio > 35:
pe_score = -10
else:
pe_score = 0
score += pe_score
factors += 1
# ROE 评分
roe = fundamental.get("roe")
if roe:
if roe > 20:
roe_score = +20 # 高ROE,利多
elif roe > 15:
roe_score = +10
elif roe < 5:
roe_score = -20 # 低ROE,利空
elif roe < 10:
roe_score = -10
else:
roe_score = 0
score += roe_score
factors += 1
# 营收增长评分
revenue_growth = fundamental.get("revenue_growth")
if revenue_growth:
if revenue_growth > 20:
growth_score = +20 # 高增长,利多
elif revenue_growth > 10:
growth_score = +10
elif revenue_growth < -10:
growth_score = -20 # 负增长,利空
elif revenue_growth < 0:
growth_score = -10
else:
growth_score = 0
score += growth_score
factors += 1
# 利润率评分
profit_margin = fundamental.get("profit_margin")
if profit_margin:
if profit_margin > 20:
margin_score = +15 # 高利润率,利多
elif profit_margin > 10:
margin_score = +7
elif profit_margin < 0:
margin_score = -15 # 亏损,利空
elif profit_margin < 5:
margin_score = -7
else:
margin_score = 0
score += margin_score
factors += 1
# 债务权益比评分
debt_to_equity = fundamental.get("debt_to_equity")
if debt_to_equity:
if debt_to_equity < 0.5:
debt_score = +10 # 低负债,利多
elif debt_to_equity > 2.0:
debt_score = -10 # 高负债,利空
else:
debt_score = 0
score += debt_score
factors += 1
# 归一化(如果有多个因素)
if factors > 0:
score = score / factors * 100 / 4 # 最大可能分数是4个因素各20分=80,归一化到100
return max(-100, min(100, score))
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
"""计算新闻情绪评分 (-100 to +100)"""
if not news:
return 0.0 # 无新闻,中性
positive_count = 0
negative_count = 0
neutral_count = 0
for item in news[:10]: # 只看前10条
sentiment = item.get("sentiment", "neutral")
if sentiment == "positive":
positive_count += 1
elif sentiment == "negative":
negative_count += 1
else:
neutral_count += 1
total = positive_count + negative_count + neutral_count
if total == 0:
return 0.0
# 计算净情绪
net_sentiment = (positive_count - negative_count) / total
# 映射到-100到+100
score = net_sentiment * 100
return max(-100, min(100, score))
def _calculate_macro_score(self, macro: Dict, market: str) -> float:
"""计算宏观环境评分 (-100 to +100)"""
if not macro:
return 0.0 # 无宏观数据,中性
score = 0.0
factors = 0
# VIX 评分(恐慌指数)
vix = macro.get("VIX", {})
vix_value = vix.get("price", 0)
if vix_value > 0:
if vix_value > 30:
vix_score = -30 # 高恐慌,利空
elif vix_value > 20:
vix_score = -15
elif vix_value < 15:
vix_score = +15 # 低恐慌,利多
else:
vix_score = 0
score += vix_score
factors += 1
# DXY 评分(美元指数)
dxy = macro.get("DXY", {})
dxy_value = dxy.get("price", 0)
dxy_change = dxy.get("changePercent", 0)
if dxy_value > 0:
# 对于加密货币和商品,强美元通常是利空
if market in ["Crypto", "Forex", "Futures"]:
if dxy_change > 1:
dxy_score = -20 # 美元走强,利空
elif dxy_change < -1:
dxy_score = +20 # 美元走弱,利多
else:
dxy_score = 0
else:
dxy_score = 0 # 对股票影响较小
score += dxy_score
factors += 1
# 利率评分(TNX
tnx = macro.get("TNX", {})
tnx_change = tnx.get("changePercent", 0)
if tnx_change != 0:
# 利率上升对成长股和加密货币通常是利空
if market in ["Crypto", "USStock"]:
if tnx_change > 2:
tnx_score = -20 # 利率大幅上升,利空
elif tnx_change < -2:
tnx_score = +20 # 利率下降,利多
else:
tnx_score = 0
else:
tnx_score = 0
score += tnx_score
factors += 1
# 归一化
if factors > 0:
score = score / factors * 100 / 3 # 最大可能分数是3个因素各30分=90,归一化到100
return max(-100, min(100, score))
def _score_to_decision(self, score: float) -> str:
"""
根据客观评分转换为决策
优化后的阈值(缩小HOLD区间,使决策更明确):
- score >= +40: BUY(利多)
- score <= -40: SELL(利空)
- -40 < score < +40: HOLD(中性)
分级决策(可选,用于更细粒度的判断):
- score >= +70: 强烈BUY
- +40 <= score < +70: BUY
- +10 < score < +40: 弱利多(倾向于BUY,但可HOLD)
- -10 <= score <= +10: 中性HOLD
- -40 < score < -10: 弱利空(倾向于SELL,但可HOLD)
- -70 < score <= -40: SELL
- score <= -70: 强烈SELL
"""
# 使用±40作为主要阈值,缩小HOLD区间
if score >= 40:
return "BUY"
elif score <= -40:
return "SELL"
else:
return "HOLD"
def _calculate_overall_score(self, analysis: Dict) -> int:
"""Calculate weighted overall score."""
"""Calculate weighted overall score (legacy method, now uses objective score if available)."""
# 优先使用客观评分
if "objective_score" in analysis:
objective = analysis["objective_score"]
overall = objective.get("overall_score", 50)
# 转换为0-100格式(原系统使用)
return max(0, min(100, int(50 + overall * 0.5)))
# 降级到LLM评分
tech = analysis.get("technical_score", 50)
fund = analysis.get("fundamental_score", 50)
sent = analysis.get("sentiment_score", 50)
@@ -36,6 +36,50 @@ IBKRClient = None
MT5Client = None
def _normalize_symbol_for_order(symbol: str, market_type: str = "swap") -> str:
"""
规范化符号格式,确保符号符合交易所要求。
处理各种输入格式:
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
- PI, TRX -> PI/USDT, TRX/USDT (默认添加 /USDT)
Args:
symbol: 原始符号
market_type: 市场类型 (spot/swap)
Returns:
规范化后的符号
"""
if not symbol:
return symbol
sym = symbol.strip()
# 移除 swap/futures 后缀
if ':' in sym:
sym = sym.split(':', 1)[0]
sym = sym.upper()
# 如果已经有分隔符,直接返回(假设格式正确)
if '/' in sym:
return sym
# 尝试从常见报价货币中识别
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC']
for quote in common_quotes:
if sym.endswith(quote) and len(sym) > len(quote):
base = sym[:-len(quote)]
if base:
return f"{base}/{quote}"
# 如果无法识别,默认使用 USDT
return f"{sym}/USDT"
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
"""
Returns (side, pos_side, reduce_only)
@@ -80,6 +124,9 @@ def place_order_from_signal(
# Spot does not support short signals in this system.
if mt == "spot" and ("short" in (signal_type or "").lower()):
raise LiveTradingError("spot market does not support short signals")
# 规范化符号格式(统一处理裸符号如 PI, TRX 等)
symbol = _normalize_symbol_for_order(symbol, market_type=mt)
if isinstance(client, BinanceFuturesClient):
return client.place_market_order(
@@ -14,12 +14,28 @@ from typing import Dict, Tuple
def _split_base_quote(symbol: str) -> Tuple[str, str]:
"""
分割符号为基础货币和报价货币。
处理各种格式:
- BTC/USDT -> (BTC, USDT)
- BTCUSDT -> (BTCUSDT, "") - 需要进一步处理
- PI, TRX -> (PI, "") - 需要进一步处理
"""
s = (symbol or "").strip()
if ":" in s:
s = s.split(":", 1)[0]
if "/" not in s:
# Already exchange-specific (best-effort)
return s, ""
# 尝试识别报价货币(常见格式:BASEQUOTE)
s_upper = s.upper()
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB']
for quote in common_quotes:
if s_upper.endswith(quote) and len(s_upper) > len(quote):
base = s_upper[:-len(quote)]
if base:
return base, quote
# 无法识别,返回原符号和空报价
return s_upper, ""
base, quote = s.split("/", 1)
return base.strip().upper(), quote.strip().upper()
+72 -6
View File
@@ -313,7 +313,7 @@ class LLMService:
def call_llm_api(self, messages: list, model: str = None, temperature: float = 0.7,
use_fallback: bool = True, provider: LLMProvider = None,
use_json_mode: bool = True) -> str:
use_json_mode: bool = True, try_alternative_providers: bool = True) -> str:
"""
Call LLM API with the specified or default provider.
@@ -324,6 +324,7 @@ class LLMService:
use_fallback: Whether to try fallback model on failure
provider: Override the service's default provider
use_json_mode: Whether to request JSON output format (default True for analysis, False for code generation)
try_alternative_providers: Whether to try alternative providers when current provider fails with 403/402
Returns:
Generated text content
@@ -347,11 +348,22 @@ class LLMService:
api_key = self.get_api_key(p)
if not api_key:
raise ValueError(f"API key not configured for provider: {p.value}")
# If no API key for current provider, try to find any available provider
if try_alternative_providers:
for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.GROK, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]:
if alt_provider != p and self.get_api_key(alt_provider):
logger.warning(f"No API key for {p.value}, switching to {alt_provider.value}")
p = alt_provider
api_key = self.get_api_key(p)
break
if not api_key:
raise ValueError(f"API key not configured for provider: {p.value}. Please configure at least one LLM provider API key.")
base_url = self.get_base_url(p)
# Normalize model name for the provider
original_model = model
model = self._normalize_model_for_provider(model, p)
config = load_addon_config()
@@ -366,6 +378,7 @@ class LLMService:
models_to_try.append(fallback)
last_error = None
last_status_code = None
for current_model in models_to_try:
try:
@@ -384,13 +397,25 @@ class LLMService:
except requests.exceptions.HTTPError as e:
error_detail = e.response.text if e.response else str(e)
logger.error(f"{p.value} API HTTP error ({current_model}): {error_detail}")
status_code = e.response.status_code if e.response else None
last_status_code = status_code
logger.error(f"{p.value} API HTTP error ({current_model}): {status_code} - {error_detail}")
last_error = str(e)
# 403/402 errors usually mean API key issue - try alternative provider
if status_code in (402, 403) and try_alternative_providers and current_model == models_to_try[-1]:
# Only try alternative providers after all models in current provider failed
logger.warning(f"{p.value} returned {status_code} (likely API key issue). Trying alternative providers...")
return self._try_alternative_providers(
messages, original_model, temperature,
use_json_mode, excluded_provider=p
)
# Check for recoverable errors - try fallback model
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
if e.response and e.response.status_code in (402, 403, 404, 429):
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
if status_code in (402, 403, 404, 429):
logger.warning(f"{p.value} returned {status_code} for model {current_model}; trying fallback...")
continue
if not use_fallback or current_model == models_to_try[-1]:
@@ -408,9 +433,50 @@ class LLMService:
if current_model == models_to_try[-1]:
raise
error_msg = f"All model calls failed. Last error: {last_error}"
error_msg = f"All model calls failed for {p.value}. Last error: {last_error}"
if last_status_code in (402, 403):
error_msg += f"\nStatus {last_status_code} usually means: API key invalid/expired, insufficient balance, or no access to model."
error_msg += f"\nPlease check your {p.value} API key configuration and account balance."
logger.error(error_msg)
raise Exception(error_msg)
def _try_alternative_providers(self, messages: list, model: str, temperature: float,
use_json_mode: bool, excluded_provider: LLMProvider = None) -> str:
"""
Try alternative providers when current provider fails.
Priority: DeepSeek > Grok > OpenAI > Google > OpenRouter
"""
priority_order = [
LLMProvider.DEEPSEEK,
LLMProvider.GROK,
LLMProvider.OPENAI,
LLMProvider.GOOGLE,
LLMProvider.OPENROUTER,
]
for alt_provider in priority_order:
if alt_provider == excluded_provider:
continue
api_key = self.get_api_key(alt_provider)
if not api_key:
continue
logger.info(f"Trying alternative provider: {alt_provider.value}")
try:
return self.call_llm_api(
messages, model, temperature,
use_fallback=True, provider=alt_provider,
use_json_mode=use_json_mode,
try_alternative_providers=False # Prevent infinite recursion
)
except Exception as e:
logger.warning(f"Alternative provider {alt_provider.value} also failed: {str(e)}")
continue
raise Exception(f"All LLM providers failed. Please check your API key configurations.")
# Legacy method for backward compatibility
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
@@ -20,6 +20,7 @@ from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
import yfinance as yf
import pandas as pd
from app.data_sources import DataSourceFactory
from app.services.kline import KlineService
@@ -552,10 +553,13 @@ class MarketDataCollector:
return None
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""美股基本面 - Finnhub + yfinance"""
"""
美股基本面 - Finnhub + yfinance
包括:基础财务指标 + 财报数据(资产负债表、利润表、现金流量表)
"""
result = {}
# Finnhub
# === 1. 基础财务指标 (Finnhub) ===
if self._finnhub_client:
try:
metrics = self._finnhub_client.company_basic_financials(symbol, 'all')
@@ -573,31 +577,193 @@ class MarketDataCollector:
'roe': m.get('roeTTM'),
'eps': m.get('epsBasicExclExtraItemsTTM'),
'revenue_growth': m.get('revenueGrowthTTMYoy'),
'profit_margin': m.get('netProfitMarginTTM'),
'debt_to_equity': m.get('totalDebtToEquityQuarterly'),
'current_ratio': m.get('currentRatioQuarterly'),
'quick_ratio': m.get('quickRatioQuarterly'),
})
except Exception as e:
logger.debug(f"Finnhub fundamental failed for {symbol}: {e}")
# yfinance 补充
if not result:
try:
ticker = yf.Ticker(symbol)
info = ticker.info or {}
result.update({
'pe_ratio': info.get('trailingPE') or info.get('forwardPE'),
'pb_ratio': info.get('priceToBook'),
'market_cap': info.get('marketCap'),
'dividend_yield': info.get('dividendYield'),
'beta': info.get('beta'),
'52w_high': info.get('fiftyTwoWeekHigh'),
'52w_low': info.get('fiftyTwoWeekLow'),
'roe': info.get('returnOnEquity'),
'eps': info.get('trailingEps'),
})
except Exception as e:
logger.debug(f"yfinance fundamental failed for {symbol}: {e}")
# === 2. yfinance 补充基础指标 ===
try:
ticker = yf.Ticker(symbol)
info = ticker.info or {}
# 补充缺失的基础指标
if not result.get('pe_ratio'):
result['pe_ratio'] = info.get('trailingPE') or info.get('forwardPE')
if not result.get('pb_ratio'):
result['pb_ratio'] = info.get('priceToBook')
if not result.get('market_cap'):
result['market_cap'] = info.get('marketCap')
if not result.get('dividend_yield'):
result['dividend_yield'] = info.get('dividendYield')
if not result.get('beta'):
result['beta'] = info.get('beta')
if not result.get('52w_high'):
result['52w_high'] = info.get('fiftyTwoWeekHigh')
if not result.get('52w_low'):
result['52w_low'] = info.get('fiftyTwoWeekLow')
if not result.get('roe'):
result['roe'] = info.get('returnOnEquity')
if not result.get('eps'):
result['eps'] = info.get('trailingEps')
# 补充更多财务指标
result.update({
'revenue': info.get('totalRevenue'),
'gross_profit': info.get('grossProfits'),
'operating_margin': info.get('operatingMargins'),
'profit_margin': result.get('profit_margin') or info.get('profitMargins'),
'ebitda': info.get('ebitda'),
'debt': info.get('totalDebt'),
'cash': info.get('totalCash'),
'free_cash_flow': info.get('freeCashflow'),
'operating_cash_flow': info.get('operatingCashflow'),
'book_value': info.get('bookValue'),
'enterprise_value': info.get('enterpriseValue'),
})
except Exception as e:
logger.debug(f"yfinance fundamental failed for {symbol}: {e}")
# === 3. 获取财报数据(资产负债表、利润表、现金流量表)===
financial_statements = self._get_financial_statements(symbol)
if financial_statements:
result['financial_statements'] = financial_statements
# === 4. 获取盈利报告(Earnings===
earnings_data = self._get_earnings_data(symbol)
if earnings_data:
result['earnings'] = earnings_data
return result if result else None
def _get_financial_statements(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取财务报表数据(资产负债表、利润表、现金流量表)
使用 yfinance 获取,包含最近几个季度的数据
"""
try:
ticker = yf.Ticker(symbol)
statements = {}
# 资产负债表 (Balance Sheet)
try:
balance_sheet = ticker.balance_sheet
if balance_sheet is not None and not balance_sheet.empty:
# 获取最近4个季度
latest_quarters = balance_sheet.columns[:4] if len(balance_sheet.columns) >= 4 else balance_sheet.columns
statements['balance_sheet'] = {
'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None,
'total_assets': float(balance_sheet.loc['Total Assets', latest_quarters[0]]) if 'Total Assets' in balance_sheet.index and len(latest_quarters) > 0 else None,
'total_liabilities': float(balance_sheet.loc['Total Liab', latest_quarters[0]]) if 'Total Liab' in balance_sheet.index and len(latest_quarters) > 0 else None,
'total_equity': float(balance_sheet.loc['Stockholders Equity', latest_quarters[0]]) if 'Stockholders Equity' in balance_sheet.index and len(latest_quarters) > 0 else None,
'cash': float(balance_sheet.loc['Cash', latest_quarters[0]]) if 'Cash' in balance_sheet.index and len(latest_quarters) > 0 else None,
'debt': float(balance_sheet.loc['Total Debt', latest_quarters[0]]) if 'Total Debt' in balance_sheet.index and len(latest_quarters) > 0 else None,
'current_assets': float(balance_sheet.loc['Current Assets', latest_quarters[0]]) if 'Current Assets' in balance_sheet.index and len(latest_quarters) > 0 else None,
'current_liabilities': float(balance_sheet.loc['Current Liabilities', latest_quarters[0]]) if 'Current Liabilities' in balance_sheet.index and len(latest_quarters) > 0 else None,
}
except Exception as e:
logger.debug(f"Balance sheet fetch failed for {symbol}: {e}")
# 利润表 (Income Statement)
try:
income_stmt = ticker.financials
if income_stmt is not None and not income_stmt.empty:
latest_quarters = income_stmt.columns[:4] if len(income_stmt.columns) >= 4 else income_stmt.columns
statements['income_statement'] = {
'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None,
'total_revenue': float(income_stmt.loc['Total Revenue', latest_quarters[0]]) if 'Total Revenue' in income_stmt.index and len(latest_quarters) > 0 else None,
'gross_profit': float(income_stmt.loc['Gross Profit', latest_quarters[0]]) if 'Gross Profit' in income_stmt.index and len(latest_quarters) > 0 else None,
'operating_income': float(income_stmt.loc['Operating Income', latest_quarters[0]]) if 'Operating Income' in income_stmt.index and len(latest_quarters) > 0 else None,
'net_income': float(income_stmt.loc['Net Income', latest_quarters[0]]) if 'Net Income' in income_stmt.index and len(latest_quarters) > 0 else None,
'eps': float(income_stmt.loc['Basic EPS', latest_quarters[0]]) if 'Basic EPS' in income_stmt.index and len(latest_quarters) > 0 else None,
}
except Exception as e:
logger.debug(f"Income statement fetch failed for {symbol}: {e}")
# 现金流量表 (Cash Flow Statement)
try:
cashflow = ticker.cashflow
if cashflow is not None and not cashflow.empty:
latest_quarters = cashflow.columns[:4] if len(cashflow.columns) >= 4 else cashflow.columns
statements['cash_flow'] = {
'latest_date': str(latest_quarters[0]) if len(latest_quarters) > 0 else None,
'operating_cash_flow': float(cashflow.loc['Operating Cash Flow', latest_quarters[0]]) if 'Operating Cash Flow' in cashflow.index and len(latest_quarters) > 0 else None,
'investing_cash_flow': float(cashflow.loc['Capital Expenditure', latest_quarters[0]]) if 'Capital Expenditure' in cashflow.index and len(latest_quarters) > 0 else None,
'financing_cash_flow': float(cashflow.loc['Financing Cash Flow', latest_quarters[0]]) if 'Financing Cash Flow' in cashflow.index and len(latest_quarters) > 0 else None,
'free_cash_flow': float(cashflow.loc['Free Cash Flow', latest_quarters[0]]) if 'Free Cash Flow' in cashflow.index and len(latest_quarters) > 0 else None,
}
except Exception as e:
logger.debug(f"Cash flow statement fetch failed for {symbol}: {e}")
return statements if statements else None
except Exception as e:
logger.debug(f"Financial statements fetch failed for {symbol}: {e}")
return None
def _get_earnings_data(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
获取盈利报告数据(Earnings)
包括:历史盈利、盈利预测、盈利日期等
"""
try:
ticker = yf.Ticker(symbol)
earnings_data = {}
# 历史盈利数据
try:
earnings_history = ticker.earnings_history
if earnings_history is not None and not earnings_history.empty:
# 获取最近4个季度
recent_earnings = earnings_history.head(4)
earnings_data['history'] = []
for _, row in recent_earnings.iterrows():
earnings_data['history'].append({
'date': str(row.get('Date', '')),
'eps_actual': float(row.get('EPS Actual', 0)) if row.get('EPS Actual') is not None else None,
'eps_estimate': float(row.get('EPS Estimate', 0)) if row.get('EPS Estimate') is not None else None,
'surprise': float(row.get('Surprise(%)', 0)) if row.get('Surprise(%)') is not None else None,
})
except Exception as e:
logger.debug(f"Earnings history fetch failed for {symbol}: {e}")
# 盈利日历(未来盈利日期)
try:
earnings_calendar = ticker.calendar
if earnings_calendar is not None and not earnings_calendar.empty:
earnings_data['upcoming'] = {
'next_earnings_date': str(earnings_calendar.index[0]) if len(earnings_calendar.index) > 0 else None,
'eps_estimate': float(earnings_calendar.loc[earnings_calendar.index[0], 'Earnings Estimate']) if len(earnings_calendar.index) > 0 and 'Earnings Estimate' in earnings_calendar.columns else None,
'revenue_estimate': float(earnings_calendar.loc[earnings_calendar.index[0], 'Revenue Estimate']) if len(earnings_calendar.index) > 0 and 'Revenue Estimate' in earnings_calendar.columns else None,
}
except Exception as e:
logger.debug(f"Earnings calendar fetch failed for {symbol}: {e}")
# 季度盈利数据
try:
quarterly_earnings = ticker.quarterly_earnings
if quarterly_earnings is not None and not quarterly_earnings.empty:
latest_q = quarterly_earnings.index[0] if len(quarterly_earnings.index) > 0 else None
if latest_q:
earnings_data['quarterly'] = {
'latest_quarter': str(latest_q),
'revenue': float(quarterly_earnings.loc[latest_q, 'Revenue']) if 'Revenue' in quarterly_earnings.columns else None,
'earnings': float(quarterly_earnings.loc[latest_q, 'Earnings']) if 'Earnings' in quarterly_earnings.columns else None,
}
except Exception as e:
logger.debug(f"Quarterly earnings fetch failed for {symbol}: {e}")
return earnings_data if earnings_data else None
except Exception as e:
logger.debug(f"Earnings data fetch failed for {symbol}: {e}")
return None
def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]:
"""加密货币信息 (固定描述为主)"""
# 常见加密货币的描述
@@ -295,6 +295,32 @@ class PendingOrderWorker:
except Exception:
pass
exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = float(qty_base)
# Extract entry price from OKX position data
# OKX API returns avgPx (average price) or avgPxEp (average price in equity) for positions
try:
# Try avgPx first (average entry price)
avg_px = p.get("avgPx")
if avg_px:
entry_price = float(avg_px)
else:
# Fallback to avgPxEp (average price in equity)
avg_px_ep = p.get("avgPxEp")
if avg_px_ep:
entry_price = float(avg_px_ep)
else:
# Fallback to last price if available
last_px = p.get("last")
entry_price = float(last_px) if last_px else 0.0
if entry_price > 0:
exch_entry_price.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = entry_price
logger.debug(f"[PositionSync] OKX {hb_sym} {side}: entry_price={entry_price} from avgPx={p.get('avgPx')} or avgPxEp={p.get('avgPxEp')}")
else:
logger.warning(f"[PositionSync] OKX {hb_sym} {side}: Could not extract entry price from position data: {p}")
except Exception as e:
logger.warning(f"[PositionSync] Failed to extract entry price for OKX {hb_sym} {side}: {e}")
# Don't set entry_price, will remain 0.0
elif isinstance(client, BitgetMixClient) and market_type == "swap":
product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")