@@ -2,7 +2,7 @@
|
|||||||
加密货币数据源
|
加密货币数据源
|
||||||
使用 CCXT (Coinbase) 获取数据
|
使用 CCXT (Coinbase) 获取数据
|
||||||
"""
|
"""
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import ccxt
|
import ccxt
|
||||||
|
|
||||||
@@ -21,6 +21,9 @@ class CryptoDataSource(BaseDataSource):
|
|||||||
# 时间周期映射
|
# 时间周期映射
|
||||||
TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP
|
TIMEFRAME_MAP = CCXTConfig.TIMEFRAME_MAP
|
||||||
|
|
||||||
|
# 常见的报价货币列表(按优先级排序)
|
||||||
|
COMMON_QUOTES = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB', 'EUR', 'GBP']
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
config = {
|
config = {
|
||||||
'timeout': CCXTConfig.TIMEOUT,
|
'timeout': CCXTConfig.TIMEOUT,
|
||||||
@@ -44,26 +47,188 @@ class CryptoDataSource(BaseDataSource):
|
|||||||
exchange_class = getattr(ccxt, exchange_id)
|
exchange_class = getattr(ccxt, exchange_id)
|
||||||
self.exchange = exchange_class(config)
|
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]:
|
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get latest ticker for a crypto symbol via CCXT.
|
Get latest ticker for a crypto symbol via CCXT.
|
||||||
|
|
||||||
Accepts common formats:
|
Accepts common formats:
|
||||||
- BTC/USDT
|
- BTC/USDT, BTCUSDT, BTC/USDT:USDT
|
||||||
- BTCUSDT
|
- PI, TRX (will be normalized and searched across exchanges)
|
||||||
- BTC/USDT:USDT (swap-style suffix, will be normalized)
|
- 自动适配不同交易所的符号格式要求
|
||||||
"""
|
"""
|
||||||
sym = (symbol or "").strip()
|
if not symbol or not symbol.strip():
|
||||||
if ":" in sym:
|
return {'last': 0, 'symbol': symbol}
|
||||||
sym = sym.split(":", 1)[0]
|
|
||||||
sym = sym.upper()
|
# 规范化符号
|
||||||
if "/" not in sym:
|
normalized = self._normalize_symbol_for_exchange(symbol)
|
||||||
# Coinbase often uses USD, check if we need to adapt
|
|
||||||
if sym.endswith("USDT") and len(sym) > 4:
|
if not normalized:
|
||||||
sym = f"{sym[:-4]}/USDT"
|
logger.warning(f"Failed to normalize symbol: {symbol}")
|
||||||
elif sym.endswith("USD") and len(sym) > 3:
|
return {'last': 0, 'symbol': symbol}
|
||||||
sym = f"{sym[:-3]}/USD"
|
|
||||||
return self.exchange.fetch_ticker(sym)
|
# 尝试获取 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(
|
def get_kline(
|
||||||
self,
|
self,
|
||||||
@@ -78,11 +243,12 @@ class CryptoDataSource(BaseDataSource):
|
|||||||
try:
|
try:
|
||||||
ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d')
|
ccxt_timeframe = self.TIMEFRAME_MAP.get(timeframe, '1d')
|
||||||
|
|
||||||
# 构建交易对符号
|
# 使用统一的符号规范化方法
|
||||||
if not symbol.endswith('USDT') and not symbol.endswith('USD'):
|
symbol_pair = self._normalize_symbol_for_exchange(symbol)
|
||||||
symbol_pair = f'{symbol}/USDT'
|
|
||||||
else:
|
if not symbol_pair:
|
||||||
symbol_pair = symbol
|
logger.warning(f"Failed to normalize symbol for K-line: {symbol}")
|
||||||
|
return []
|
||||||
|
|
||||||
# logger.info(f"获取加密货币K线: {symbol_pair}, 周期: {ccxt_timeframe}, 条数: {limit}")
|
# logger.info(f"获取加密货币K线: {symbol_pair}, 周期: {ccxt_timeframe}, 条数: {limit}")
|
||||||
|
|
||||||
|
|||||||
@@ -281,11 +281,53 @@ class FastAnalysisService:
|
|||||||
else:
|
else:
|
||||||
price_lower_bound = price_upper_bound = entry_range_low = entry_range_high = 0
|
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.
|
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}
|
{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):
|
📐 TECHNICAL LEVELS (Pre-calculated from chart data):
|
||||||
- Support: ${support} | Resistance: ${resistance} | Pivot: ${pivot}
|
- Support: ${support} | Resistance: ${resistance} | Pivot: ${pivot}
|
||||||
- ATR (14-day): ${atr:.4f} ({volatility.get('pct', 0)}% volatility)
|
- 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}
|
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!
|
5. These levels are based on ATR and support/resistance analysis - use them as reference!
|
||||||
|
|
||||||
📊 YOUR ANALYSIS MUST INCLUDE:
|
📊 YOUR ANALYSIS MUST INCLUDE (ALL factors are important):
|
||||||
1. **Technical Analysis**: Interpret the indicators, explain why support/resistance levels matter
|
1. **Technical Analysis**: Objectively interpret RSI, MACD, MA, support/resistance. Be honest about conflicting signals.
|
||||||
2. **Fundamental Analysis**: Evaluate valuation, growth if data available
|
2. **Macro Environment Analysis**:
|
||||||
3. **Sentiment Analysis**: Assess market mood, news impact, macro factors
|
- Analyze DXY, VIX, interest rates impact on the asset
|
||||||
4. **Risk Assessment**: Explain why the stop loss level is appropriate
|
- Consider geopolitical events and their potential impact
|
||||||
5. **Clear Recommendation**: BUY/SELL/HOLD with entry, stop loss (near suggested), take profit (near suggested)
|
- 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):
|
Output ONLY valid JSON (do NOT include word counts or format hints in your actual response):
|
||||||
{{
|
{{
|
||||||
"decision": "BUY" | "SELL" | "HOLD",
|
"decision": "BUY" | "SELL" | "HOLD",
|
||||||
"confidence": 0-100,
|
"confidence": 0-100,
|
||||||
"summary": "Executive summary in 2-3 sentences",
|
"summary": "Executive summary in 2-3 sentences - be honest about uncertainty if present",
|
||||||
"analysis": {{
|
"analysis": {{
|
||||||
"technical": "Your detailed technical analysis here - interpret RSI, MACD, MA, support/resistance",
|
"technical": "Your detailed technical analysis here - interpret RSI, MACD, MA, support/resistance objectively",
|
||||||
"fundamental": "Your fundamental assessment here - valuation, growth, competitive position",
|
"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"
|
"sentiment": "Your market sentiment analysis here - news impact, macro factors, mood. Don't overreact."
|
||||||
}},
|
}},
|
||||||
"entry_price": number,
|
"entry_price": number,
|
||||||
"stop_loss": 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
|
"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)
|
# Format indicator data for prompt (ensure safe defaults)
|
||||||
rsi_data = indicators.get("rsi") or {}
|
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')}
|
- Market Cap: {fundamental.get('market_cap', 'N/A')}
|
||||||
- 52W High/Low: {fundamental.get('52w_high', 'N/A')} / {fundamental.get('52w_low', 'N/A')}
|
- 52W High/Low: {fundamental.get('52w_high', 'N/A')} / {fundamental.get('52w_low', 'N/A')}
|
||||||
- ROE: {fundamental.get('roe', '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.
|
📊 FINANCIAL STATEMENTS (Latest Quarter):
|
||||||
Provide your analysis now. Remember: all prices must be within 10% of ${current_price}."""
|
{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
|
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:
|
def _format_macro_summary(self, macro: Dict[str, Any], market: str) -> str:
|
||||||
"""格式化宏观数据摘要"""
|
"""格式化宏观数据摘要"""
|
||||||
if not macro:
|
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)
|
llm_time = int((time.time() - llm_start) * 1000)
|
||||||
logger.info(f"LLM call completed in {llm_time}ms")
|
logger.info(f"LLM call completed in {llm_time}ms")
|
||||||
|
|
||||||
# Phase 4: Validate and constrain output
|
# Phase 4: Calculate objective score and determine decision based on score
|
||||||
analysis = self._validate_and_constrain(analysis, current_price)
|
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
|
# Build final result
|
||||||
total_time = int((time.time() - start_time) * 1000)
|
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),
|
"sentiment": analysis.get("sentiment_score", 50),
|
||||||
"overall": self._calculate_overall_score(analysis),
|
"overall": self._calculate_overall_score(analysis),
|
||||||
},
|
},
|
||||||
|
"objective_score": analysis.get("objective_score", {}),
|
||||||
|
"score_based_decision": analysis.get("score_based_decision", "HOLD"),
|
||||||
"market_data": {
|
"market_data": {
|
||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
"change_24h": data["price"].get("changePercent", 0),
|
"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
|
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.
|
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:
|
if not current_price or current_price <= 0:
|
||||||
return analysis
|
return analysis
|
||||||
@@ -651,10 +1006,447 @@ Provide your analysis now. Remember: all prices must be within 10% of ${current_
|
|||||||
else:
|
else:
|
||||||
analysis["decision"] = decision
|
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
|
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:
|
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)
|
tech = analysis.get("technical_score", 50)
|
||||||
fund = analysis.get("fundamental_score", 50)
|
fund = analysis.get("fundamental_score", 50)
|
||||||
sent = analysis.get("sentiment_score", 50)
|
sent = analysis.get("sentiment_score", 50)
|
||||||
|
|||||||
@@ -36,6 +36,50 @@ IBKRClient = None
|
|||||||
MT5Client = 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]:
|
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
|
||||||
"""
|
"""
|
||||||
Returns (side, pos_side, reduce_only)
|
Returns (side, pos_side, reduce_only)
|
||||||
@@ -81,6 +125,9 @@ def place_order_from_signal(
|
|||||||
if mt == "spot" and ("short" in (signal_type or "").lower()):
|
if mt == "spot" and ("short" in (signal_type or "").lower()):
|
||||||
raise LiveTradingError("spot market does not support short signals")
|
raise LiveTradingError("spot market does not support short signals")
|
||||||
|
|
||||||
|
# 规范化符号格式(统一处理裸符号如 PI, TRX 等)
|
||||||
|
symbol = _normalize_symbol_for_order(symbol, market_type=mt)
|
||||||
|
|
||||||
if isinstance(client, BinanceFuturesClient):
|
if isinstance(client, BinanceFuturesClient):
|
||||||
return client.place_market_order(
|
return client.place_market_order(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
|
|||||||
@@ -14,12 +14,28 @@ from typing import Dict, Tuple
|
|||||||
|
|
||||||
|
|
||||||
def _split_base_quote(symbol: str) -> Tuple[str, str]:
|
def _split_base_quote(symbol: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
分割符号为基础货币和报价货币。
|
||||||
|
|
||||||
|
处理各种格式:
|
||||||
|
- BTC/USDT -> (BTC, USDT)
|
||||||
|
- BTCUSDT -> (BTCUSDT, "") - 需要进一步处理
|
||||||
|
- PI, TRX -> (PI, "") - 需要进一步处理
|
||||||
|
"""
|
||||||
s = (symbol or "").strip()
|
s = (symbol or "").strip()
|
||||||
if ":" in s:
|
if ":" in s:
|
||||||
s = s.split(":", 1)[0]
|
s = s.split(":", 1)[0]
|
||||||
if "/" not in s:
|
if "/" not in s:
|
||||||
# Already exchange-specific (best-effort)
|
# 尝试识别报价货币(常见格式:BASEQUOTE)
|
||||||
return s, ""
|
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)
|
base, quote = s.split("/", 1)
|
||||||
return base.strip().upper(), quote.strip().upper()
|
return base.strip().upper(), quote.strip().upper()
|
||||||
|
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ class LLMService:
|
|||||||
|
|
||||||
def call_llm_api(self, messages: list, model: str = None, temperature: float = 0.7,
|
def call_llm_api(self, messages: list, model: str = None, temperature: float = 0.7,
|
||||||
use_fallback: bool = True, provider: LLMProvider = None,
|
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.
|
Call LLM API with the specified or default provider.
|
||||||
|
|
||||||
@@ -324,6 +324,7 @@ class LLMService:
|
|||||||
use_fallback: Whether to try fallback model on failure
|
use_fallback: Whether to try fallback model on failure
|
||||||
provider: Override the service's default provider
|
provider: Override the service's default provider
|
||||||
use_json_mode: Whether to request JSON output format (default True for analysis, False for code generation)
|
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:
|
Returns:
|
||||||
Generated text content
|
Generated text content
|
||||||
@@ -347,11 +348,22 @@ class LLMService:
|
|||||||
api_key = self.get_api_key(p)
|
api_key = self.get_api_key(p)
|
||||||
|
|
||||||
if not api_key:
|
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)
|
base_url = self.get_base_url(p)
|
||||||
|
|
||||||
# Normalize model name for the provider
|
# Normalize model name for the provider
|
||||||
|
original_model = model
|
||||||
model = self._normalize_model_for_provider(model, p)
|
model = self._normalize_model_for_provider(model, p)
|
||||||
|
|
||||||
config = load_addon_config()
|
config = load_addon_config()
|
||||||
@@ -366,6 +378,7 @@ class LLMService:
|
|||||||
models_to_try.append(fallback)
|
models_to_try.append(fallback)
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
|
last_status_code = None
|
||||||
|
|
||||||
for current_model in models_to_try:
|
for current_model in models_to_try:
|
||||||
try:
|
try:
|
||||||
@@ -384,13 +397,25 @@ class LLMService:
|
|||||||
|
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
error_detail = e.response.text if e.response else str(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)
|
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
|
# Check for recoverable errors - try fallback model
|
||||||
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
|
# 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):
|
if status_code in (402, 403, 404, 429):
|
||||||
logger.warning(f"{p.value} returned {e.response.status_code} for model {current_model}; trying fallback...")
|
logger.warning(f"{p.value} returned {status_code} for model {current_model}; trying fallback...")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not use_fallback or current_model == models_to_try[-1]:
|
if not use_fallback or current_model == models_to_try[-1]:
|
||||||
@@ -408,10 +433,51 @@ class LLMService:
|
|||||||
if current_model == models_to_try[-1]:
|
if current_model == models_to_try[-1]:
|
||||||
raise
|
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)
|
logger.error(error_msg)
|
||||||
raise Exception(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
|
# Legacy method for backward compatibility
|
||||||
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
|
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
|
||||||
"""Call LLM API (legacy method name for backward compatibility)."""
|
"""Call LLM API (legacy method name for backward compatibility)."""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from datetime import datetime, timedelta
|
|||||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
|
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
|
||||||
|
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
from app.data_sources import DataSourceFactory
|
from app.data_sources import DataSourceFactory
|
||||||
from app.services.kline import KlineService
|
from app.services.kline import KlineService
|
||||||
@@ -552,10 +553,13 @@ class MarketDataCollector:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
|
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||||
"""美股基本面 - Finnhub + yfinance"""
|
"""
|
||||||
|
美股基本面 - Finnhub + yfinance
|
||||||
|
包括:基础财务指标 + 财报数据(资产负债表、利润表、现金流量表)
|
||||||
|
"""
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
# Finnhub
|
# === 1. 基础财务指标 (Finnhub) ===
|
||||||
if self._finnhub_client:
|
if self._finnhub_client:
|
||||||
try:
|
try:
|
||||||
metrics = self._finnhub_client.company_basic_financials(symbol, 'all')
|
metrics = self._finnhub_client.company_basic_financials(symbol, 'all')
|
||||||
@@ -573,31 +577,193 @@ class MarketDataCollector:
|
|||||||
'roe': m.get('roeTTM'),
|
'roe': m.get('roeTTM'),
|
||||||
'eps': m.get('epsBasicExclExtraItemsTTM'),
|
'eps': m.get('epsBasicExclExtraItemsTTM'),
|
||||||
'revenue_growth': m.get('revenueGrowthTTMYoy'),
|
'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:
|
except Exception as e:
|
||||||
logger.debug(f"Finnhub fundamental failed for {symbol}: {e}")
|
logger.debug(f"Finnhub fundamental failed for {symbol}: {e}")
|
||||||
|
|
||||||
# yfinance 补充
|
# === 2. yfinance 补充基础指标 ===
|
||||||
if not result:
|
try:
|
||||||
try:
|
ticker = yf.Ticker(symbol)
|
||||||
ticker = yf.Ticker(symbol)
|
info = ticker.info or {}
|
||||||
info = ticker.info or {}
|
|
||||||
result.update({
|
# 补充缺失的基础指标
|
||||||
'pe_ratio': info.get('trailingPE') or info.get('forwardPE'),
|
if not result.get('pe_ratio'):
|
||||||
'pb_ratio': info.get('priceToBook'),
|
result['pe_ratio'] = info.get('trailingPE') or info.get('forwardPE')
|
||||||
'market_cap': info.get('marketCap'),
|
if not result.get('pb_ratio'):
|
||||||
'dividend_yield': info.get('dividendYield'),
|
result['pb_ratio'] = info.get('priceToBook')
|
||||||
'beta': info.get('beta'),
|
if not result.get('market_cap'):
|
||||||
'52w_high': info.get('fiftyTwoWeekHigh'),
|
result['market_cap'] = info.get('marketCap')
|
||||||
'52w_low': info.get('fiftyTwoWeekLow'),
|
if not result.get('dividend_yield'):
|
||||||
'roe': info.get('returnOnEquity'),
|
result['dividend_yield'] = info.get('dividendYield')
|
||||||
'eps': info.get('trailingEps'),
|
if not result.get('beta'):
|
||||||
})
|
result['beta'] = info.get('beta')
|
||||||
except Exception as e:
|
if not result.get('52w_high'):
|
||||||
logger.debug(f"yfinance fundamental failed for {symbol}: {e}")
|
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
|
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]]:
|
def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||||
"""加密货币信息 (固定描述为主)"""
|
"""加密货币信息 (固定描述为主)"""
|
||||||
# 常见加密货币的描述
|
# 常见加密货币的描述
|
||||||
|
|||||||
@@ -296,6 +296,32 @@ class PendingOrderWorker:
|
|||||||
pass
|
pass
|
||||||
exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = float(qty_base)
|
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":
|
elif isinstance(client, BitgetMixClient) and market_type == "swap":
|
||||||
product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
product_type = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
||||||
resp = client.get_positions(product_type=product_type)
|
resp = client.get_positions(product_type=product_type)
|
||||||
|
|||||||
Reference in New Issue
Block a user