""" Technical Indicator Synthesis - Momentum and Volume-Price Analysis Provides structured technical analysis for LLM decision making """ import logging from typing import Dict, Any, List, Optional import pandas as pd import numpy as np from .indicators import TechnicalIndicators from .config import config logger = logging.getLogger(__name__) class MomentumAnalyzer: """ Technical indicator synthesis and divergence detection Provides: 1. Momentum analysis (RSI, MACD, Stochastic) 2. Volume-Price relationship 3. Divergence detection (bullish/bearish) 4. Overbought/Oversold conditions 5. LLM-ready structured output """ @staticmethod def analyze_momentum(df: pd.DataFrame, timeframe: str = '1h') -> Dict[str, Any]: """ Perform comprehensive momentum analysis Args: df: DataFrame with OHLCV data timeframe: Timeframe for context Returns: Complete momentum analysis for LLM """ if df.empty or len(df) < 50: return MomentumAnalyzer._empty_result(timeframe) # Ensure indicators are calculated if 'rsi' not in df.columns: df = TechnicalIndicators.add_all_indicators(df) latest = df.iloc[-1] current_price = float(latest['close']) # 1. RSI Analysis rsi_analysis = MomentumAnalyzer._analyze_rsi(df) # 2. MACD Analysis macd_analysis = MomentumAnalyzer._analyze_macd(df) # 3. Stochastic Analysis stoch_analysis = MomentumAnalyzer._analyze_stochastic(df) # 4. Volume-Price Analysis volume_analysis = MomentumAnalyzer._analyze_volume_price(df) # 5. Divergence Detection divergences = MomentumAnalyzer._detect_divergences(df) # 6. Overall Momentum Assessment assessment = MomentumAnalyzer._assess_overall_momentum( rsi_analysis, macd_analysis, stoch_analysis, volume_analysis, divergences ) # 7. Generate Summary summary = MomentumAnalyzer._generate_summary( rsi_analysis, macd_analysis, volume_analysis, divergences, assessment ) return { 'timeframe': timeframe, 'current_price': round(current_price, 2), # Individual indicators 'rsi': rsi_analysis, 'macd': macd_analysis, 'stochastic': stoch_analysis, # Volume analysis 'volume': volume_analysis, # Divergences 'divergences': divergences, # Overall assessment 'assessment': assessment, # LLM-ready summary 'summary': summary, } @staticmethod def analyze_multi_timeframe_momentum( mtf_data: Dict[str, pd.DataFrame] ) -> Dict[str, Any]: """ Analyze momentum across multiple timeframes Args: mtf_data: Dict mapping timeframe to DataFrame Returns: Multi-timeframe momentum analysis """ results = {} momentum_directions = {} for tf, df in mtf_data.items(): if df.empty: continue momentum_info = MomentumAnalyzer.analyze_momentum(df, tf) results[tf] = momentum_info momentum_directions[tf] = momentum_info['assessment'].get('direction', 'neutral') # Calculate cross-timeframe alignment alignment = MomentumAnalyzer._calculate_mtf_alignment(momentum_directions) # Find divergence confluence divergence_confluence = MomentumAnalyzer._find_divergence_confluence(results) return { 'timeframes': results, 'alignment': alignment, 'divergence_confluence': divergence_confluence, 'summary': MomentumAnalyzer._generate_mtf_summary( results, alignment, divergence_confluence ), } @staticmethod def _analyze_rsi(df: pd.DataFrame) -> Dict[str, Any]: """Analyze RSI indicator""" latest = df.iloc[-1] rsi = float(latest.get('rsi', 50)) # RSI trend (last 5 periods) if len(df) >= 5: rsi_values = df['rsi'].tail(5).values rsi_change = rsi_values[-1] - rsi_values[0] if rsi_change > 5: trend = 'rising' trend_cn = '上升' elif rsi_change < -5: trend = 'falling' trend_cn = '下降' else: trend = 'flat' trend_cn = '平稳' else: trend = 'unknown' trend_cn = '未知' rsi_change = 0 # RSI zone if rsi >= 80: zone = 'extreme_overbought' zone_cn = '极度超买' signal = 'bearish' elif rsi >= 70: zone = 'overbought' zone_cn = '超买' signal = 'bearish_warning' elif rsi >= 60: zone = 'strong' zone_cn = '强势' signal = 'bullish' elif rsi >= 40: zone = 'neutral' zone_cn = '中性' signal = 'neutral' elif rsi >= 30: zone = 'weak' zone_cn = '弱势' signal = 'bearish' elif rsi >= 20: zone = 'oversold' zone_cn = '超卖' signal = 'bullish_warning' else: zone = 'extreme_oversold' zone_cn = '极度超卖' signal = 'bullish' return { 'value': round(rsi, 1), 'zone': zone, 'zone_cn': zone_cn, 'trend': trend, 'trend_cn': trend_cn, 'change': round(rsi_change, 1), 'signal': signal, } @staticmethod def _analyze_macd(df: pd.DataFrame) -> Dict[str, Any]: """Analyze MACD indicator""" if len(df) < 2: return {'signal': 'unknown'} latest = df.iloc[-1] prev = df.iloc[-2] macd_line = float(latest.get('macd', 0)) signal_line = float(latest.get('macd_signal', 0)) histogram = float(latest.get('macd_hist', 0)) prev_histogram = float(prev.get('macd_hist', 0)) # Cross detection if histogram > 0 and prev_histogram <= 0: cross = 'golden_cross' cross_cn = '金叉' elif histogram < 0 and prev_histogram >= 0: cross = 'death_cross' cross_cn = '死叉' else: cross = 'none' cross_cn = '无交叉' # Histogram momentum if histogram > 0: if histogram > prev_histogram: momentum = 'bullish_expanding' momentum_cn = '多头动能增强' else: momentum = 'bullish_contracting' momentum_cn = '多头动能减弱' else: if histogram < prev_histogram: momentum = 'bearish_expanding' momentum_cn = '空头动能增强' else: momentum = 'bearish_contracting' momentum_cn = '空头动能减弱' # Signal if cross == 'golden_cross': signal = 'bullish' elif cross == 'death_cross': signal = 'bearish' elif histogram > 0 and histogram > prev_histogram: signal = 'bullish' elif histogram < 0 and histogram < prev_histogram: signal = 'bearish' else: signal = 'neutral' return { 'macd_line': round(macd_line, 4), 'signal_line': round(signal_line, 4), 'histogram': round(histogram, 4), 'cross': cross, 'cross_cn': cross_cn, 'momentum': momentum, 'momentum_cn': momentum_cn, 'signal': signal, } @staticmethod def _analyze_stochastic(df: pd.DataFrame) -> Dict[str, Any]: """Analyze Stochastic oscillator""" latest = df.iloc[-1] stoch_k = float(latest.get('stoch_k', 50)) stoch_d = float(latest.get('stoch_d', 50)) # Zone if stoch_k >= 80: zone = 'overbought' zone_cn = '超买' elif stoch_k <= 20: zone = 'oversold' zone_cn = '超卖' else: zone = 'neutral' zone_cn = '中性' # Cross if len(df) >= 2: prev = df.iloc[-2] prev_k = float(prev.get('stoch_k', 50)) prev_d = float(prev.get('stoch_d', 50)) if stoch_k > stoch_d and prev_k <= prev_d: cross = 'bullish' cross_cn = '金叉' elif stoch_k < stoch_d and prev_k >= prev_d: cross = 'bearish' cross_cn = '死叉' else: cross = 'none' cross_cn = '无' else: cross = 'unknown' cross_cn = '未知' return { 'k': round(stoch_k, 1), 'd': round(stoch_d, 1), 'zone': zone, 'zone_cn': zone_cn, 'cross': cross, 'cross_cn': cross_cn, } @staticmethod def _analyze_volume_price(df: pd.DataFrame) -> Dict[str, Any]: """Analyze volume-price relationship""" if len(df) < 20: return {'status': 'insufficient_data'} latest = df.iloc[-1] current_volume = float(latest['volume']) avg_volume = float(df['volume'].tail(20).mean()) volume_ratio = current_volume / avg_volume if avg_volume > 0 else 1 # Price change price_change = (float(latest['close']) - float(df.iloc[-2]['close'])) / float(df.iloc[-2]['close']) * 100 # OBV analysis obv = float(latest.get('obv', 0)) if len(df) >= 10: obv_10_ago = float(df.iloc[-10].get('obv', 0)) obv_trend = 'rising' if obv > obv_10_ago else 'falling' if obv < obv_10_ago else 'flat' obv_trend_cn = '上升' if obv_trend == 'rising' else '下降' if obv_trend == 'falling' else '平稳' else: obv_trend = 'unknown' obv_trend_cn = '未知' # Volume status if volume_ratio > 2.0: volume_status = 'extremely_high' volume_status_cn = '异常放量' elif volume_ratio > 1.5: volume_status = 'high' volume_status_cn = '明显放量' elif volume_ratio > 1.1: volume_status = 'above_average' volume_status_cn = '温和放量' elif volume_ratio < 0.5: volume_status = 'very_low' volume_status_cn = '显著缩量' elif volume_ratio < 0.8: volume_status = 'low' volume_status_cn = '温和缩量' else: volume_status = 'normal' volume_status_cn = '正常' # Volume-price confirmation if price_change > 0.5 and volume_ratio > 1.2: confirmation = 'bullish_confirmed' confirmation_cn = '量价齐升(看涨确认)' elif price_change < -0.5 and volume_ratio > 1.2: confirmation = 'bearish_confirmed' confirmation_cn = '放量下跌(看跌确认)' elif price_change > 0.5 and volume_ratio < 0.8: confirmation = 'bullish_weak' confirmation_cn = '缩量上涨(动能不足)' elif price_change < -0.5 and volume_ratio < 0.8: confirmation = 'bearish_exhaustion' confirmation_cn = '缩量下跌(卖压减弱)' else: confirmation = 'neutral' confirmation_cn = '量价中性' return { 'current_volume': round(current_volume, 2), 'avg_volume': round(avg_volume, 2), 'volume_ratio': round(volume_ratio, 2), 'volume_status': volume_status, 'volume_status_cn': volume_status_cn, 'price_change_pct': round(price_change, 2), 'obv_trend': obv_trend, 'obv_trend_cn': obv_trend_cn, 'confirmation': confirmation, 'confirmation_cn': confirmation_cn, } @staticmethod def _detect_divergences(df: pd.DataFrame) -> Dict[str, Any]: """Detect bullish and bearish divergences""" if len(df) < 30: return {'detected': False, 'divergences': []} divergences = [] # RSI divergence rsi_divergence = MomentumAnalyzer._check_rsi_divergence(df) if rsi_divergence: divergences.append(rsi_divergence) # MACD divergence macd_divergence = MomentumAnalyzer._check_macd_divergence(df) if macd_divergence: divergences.append(macd_divergence) # OBV divergence obv_divergence = MomentumAnalyzer._check_obv_divergence(df) if obv_divergence: divergences.append(obv_divergence) # Determine overall divergence signal bullish_count = sum(1 for d in divergences if d['type'] == 'bullish') bearish_count = sum(1 for d in divergences if d['type'] == 'bearish') if bullish_count > bearish_count and bullish_count > 0: overall = 'bullish' overall_cn = '看涨背离' elif bearish_count > bullish_count and bearish_count > 0: overall = 'bearish' overall_cn = '看跌背离' else: overall = 'none' overall_cn = '无明显背离' return { 'detected': len(divergences) > 0, 'divergences': divergences, 'bullish_count': bullish_count, 'bearish_count': bearish_count, 'overall': overall, 'overall_cn': overall_cn, 'strength': 'strong' if len(divergences) >= 2 else 'moderate' if len(divergences) == 1 else 'none', } @staticmethod def _check_rsi_divergence(df: pd.DataFrame, lookback: int = 20) -> Optional[Dict[str, Any]]: """Check for RSI divergence""" recent = df.tail(lookback) prices = recent['close'].values rsi = recent['rsi'].values # Find local extrema price_lows_idx = MomentumAnalyzer._find_local_minima(prices) price_highs_idx = MomentumAnalyzer._find_local_maxima(prices) # Bullish divergence: Price making lower lows, RSI making higher lows if len(price_lows_idx) >= 2: idx1, idx2 = price_lows_idx[-2], price_lows_idx[-1] if prices[idx2] < prices[idx1] and rsi[idx2] > rsi[idx1]: return { 'type': 'bullish', 'type_cn': '看涨背离', 'indicator': 'RSI', 'description': '价格创新低但RSI抬高,可能反转向上', } # Bearish divergence: Price making higher highs, RSI making lower highs if len(price_highs_idx) >= 2: idx1, idx2 = price_highs_idx[-2], price_highs_idx[-1] if prices[idx2] > prices[idx1] and rsi[idx2] < rsi[idx1]: return { 'type': 'bearish', 'type_cn': '看跌背离', 'indicator': 'RSI', 'description': '价格创新高但RSI走低,可能反转向下', } return None @staticmethod def _check_macd_divergence(df: pd.DataFrame, lookback: int = 20) -> Optional[Dict[str, Any]]: """Check for MACD histogram divergence""" recent = df.tail(lookback) prices = recent['close'].values macd_hist = recent['macd_hist'].values price_lows_idx = MomentumAnalyzer._find_local_minima(prices) price_highs_idx = MomentumAnalyzer._find_local_maxima(prices) # Bullish divergence if len(price_lows_idx) >= 2: idx1, idx2 = price_lows_idx[-2], price_lows_idx[-1] if prices[idx2] < prices[idx1] and macd_hist[idx2] > macd_hist[idx1]: return { 'type': 'bullish', 'type_cn': '看涨背离', 'indicator': 'MACD', 'description': '价格创新低但MACD柱状图抬高,动能减弱', } # Bearish divergence if len(price_highs_idx) >= 2: idx1, idx2 = price_highs_idx[-2], price_highs_idx[-1] if prices[idx2] > prices[idx1] and macd_hist[idx2] < macd_hist[idx1]: return { 'type': 'bearish', 'type_cn': '看跌背离', 'indicator': 'MACD', 'description': '价格创新高但MACD柱状图走低,动能减弱', } return None @staticmethod def _check_obv_divergence(df: pd.DataFrame, lookback: int = 20) -> Optional[Dict[str, Any]]: """Check for OBV divergence""" recent = df.tail(lookback) prices = recent['close'].values obv = recent['obv'].values price_lows_idx = MomentumAnalyzer._find_local_minima(prices) price_highs_idx = MomentumAnalyzer._find_local_maxima(prices) # Bullish divergence if len(price_lows_idx) >= 2: idx1, idx2 = price_lows_idx[-2], price_lows_idx[-1] if prices[idx2] < prices[idx1] and obv[idx2] > obv[idx1]: return { 'type': 'bullish', 'type_cn': '看涨背离', 'indicator': 'OBV', 'description': '价格创新低但OBV抬高,资金流入', } # Bearish divergence if len(price_highs_idx) >= 2: idx1, idx2 = price_highs_idx[-2], price_highs_idx[-1] if prices[idx2] > prices[idx1] and obv[idx2] < obv[idx1]: return { 'type': 'bearish', 'type_cn': '看跌背离', 'indicator': 'OBV', 'description': '价格创新高但OBV走低,资金流出', } return None @staticmethod def _find_local_minima(arr: np.ndarray, window: int = 3) -> List[int]: """Find local minima indices""" minima = [] for i in range(window, len(arr) - window): if all(arr[i] <= arr[i-j] for j in range(1, window+1)) and \ all(arr[i] <= arr[i+j] for j in range(1, window+1)): minima.append(i) return minima @staticmethod def _find_local_maxima(arr: np.ndarray, window: int = 3) -> List[int]: """Find local maxima indices""" maxima = [] for i in range(window, len(arr) - window): if all(arr[i] >= arr[i-j] for j in range(1, window+1)) and \ all(arr[i] >= arr[i+j] for j in range(1, window+1)): maxima.append(i) return maxima @staticmethod def _assess_overall_momentum( rsi: Dict[str, Any], macd: Dict[str, Any], stoch: Dict[str, Any], volume: Dict[str, Any], divergences: Dict[str, Any] ) -> Dict[str, Any]: """Assess overall momentum direction and strength""" # Collect signals signals = [] # RSI signal rsi_signal = rsi.get('signal', 'neutral') if rsi_signal in ['bullish', 'bullish_warning']: signals.append(1) elif rsi_signal in ['bearish', 'bearish_warning']: signals.append(-1) else: signals.append(0) # MACD signal macd_signal = macd.get('signal', 'neutral') if macd_signal == 'bullish': signals.append(1) elif macd_signal == 'bearish': signals.append(-1) else: signals.append(0) # Volume confirmation confirmation = volume.get('confirmation', 'neutral') if confirmation in ['bullish_confirmed']: signals.append(1) elif confirmation in ['bearish_confirmed']: signals.append(-1) else: signals.append(0) # Divergence override div_overall = divergences.get('overall', 'none') divergence_weight = 0 if div_overall == 'bullish': divergence_weight = 1 elif div_overall == 'bearish': divergence_weight = -1 # Calculate overall direction avg_signal = np.mean(signals) combined = avg_signal * 0.7 + divergence_weight * 0.3 if combined > 0.3: direction = 'bullish' direction_cn = '看涨' elif combined < -0.3: direction = 'bearish' direction_cn = '看跌' else: direction = 'neutral' direction_cn = '中性' # Strength based on signal consistency bullish_signals = sum(1 for s in signals if s > 0) bearish_signals = sum(1 for s in signals if s < 0) max_aligned = max(bullish_signals, bearish_signals) if max_aligned == len(signals): strength = 'strong' strength_cn = '强' elif max_aligned >= len(signals) - 1: strength = 'moderate' strength_cn = '中等' else: strength = 'weak' strength_cn = '弱' return { 'direction': direction, 'direction_cn': direction_cn, 'strength': strength, 'strength_cn': strength_cn, 'combined_score': round(combined, 2), 'signal_alignment': max_aligned / len(signals) if signals else 0, 'has_divergence_warning': div_overall != 'none', } @staticmethod def _calculate_mtf_alignment(directions: Dict[str, str]) -> Dict[str, Any]: """Calculate multi-timeframe momentum alignment""" if not directions: return {'status': 'no_data'} bullish = sum(1 for d in directions.values() if d == 'bullish') bearish = sum(1 for d in directions.values() if d == 'bearish') total = len(directions) if bullish >= total * 0.7: status = 'aligned_bullish' status_cn = '多周期看涨一致' elif bearish >= total * 0.7: status = 'aligned_bearish' status_cn = '多周期看跌一致' elif bullish > bearish: status = 'mixed_bullish' status_cn = '偏向看涨但有分歧' elif bearish > bullish: status = 'mixed_bearish' status_cn = '偏向看跌但有分歧' else: status = 'conflicting' status_cn = '多空分歧严重' return { 'status': status, 'status_cn': status_cn, 'bullish_count': bullish, 'bearish_count': bearish, 'total': total, 'alignment_score': max(bullish, bearish) / total if total > 0 else 0, } @staticmethod def _find_divergence_confluence(results: Dict[str, Dict]) -> Dict[str, Any]: """Find divergences appearing across multiple timeframes""" bullish_tfs = [] bearish_tfs = [] for tf, data in results.items(): div_info = data.get('divergences', {}) if div_info.get('overall') == 'bullish': bullish_tfs.append(tf) elif div_info.get('overall') == 'bearish': bearish_tfs.append(tf) has_confluence = len(bullish_tfs) >= 2 or len(bearish_tfs) >= 2 if len(bullish_tfs) >= 2: confluence_type = 'bullish' confluence_type_cn = '多周期看涨背离' elif len(bearish_tfs) >= 2: confluence_type = 'bearish' confluence_type_cn = '多周期看跌背离' else: confluence_type = 'none' confluence_type_cn = '无背离共振' return { 'has_confluence': has_confluence, 'type': confluence_type, 'type_cn': confluence_type_cn, 'bullish_timeframes': bullish_tfs, 'bearish_timeframes': bearish_tfs, } @staticmethod def _generate_summary( rsi: Dict[str, Any], macd: Dict[str, Any], volume: Dict[str, Any], divergences: Dict[str, Any], assessment: Dict[str, Any] ) -> str: """Generate LLM-readable summary""" parts = [] # Overall assessment direction_cn = assessment.get('direction_cn', '中性') strength_cn = assessment.get('strength_cn', '弱') parts.append(f"动能: {direction_cn}({strength_cn})") # RSI rsi_value = rsi.get('value', 50) rsi_zone_cn = rsi.get('zone_cn', '中性') parts.append(f"RSI={rsi_value:.0f}({rsi_zone_cn})") # MACD macd_momentum_cn = macd.get('momentum_cn', '') if macd_momentum_cn: parts.append(f"MACD: {macd_momentum_cn}") # Volume confirmation_cn = volume.get('confirmation_cn', '') if confirmation_cn: parts.append(f"量价: {confirmation_cn}") # Divergence if divergences.get('detected'): overall_cn = divergences.get('overall_cn', '') indicators = [d['indicator'] for d in divergences.get('divergences', [])] parts.append(f"背离: {overall_cn}({','.join(indicators)})") return "; ".join(parts) @staticmethod def _generate_mtf_summary( results: Dict[str, Dict], alignment: Dict[str, Any], divergence_confluence: Dict[str, Any] ) -> str: """Generate multi-timeframe summary""" parts = [] # Alignment status_cn = alignment.get('status_cn', '未知') parts.append(f"动能一致性: {status_cn}") # Key timeframe momentum key_tfs = ['1h', '4h', '1d'] tf_parts = [] for tf in key_tfs: if tf in results: direction_cn = results[tf]['assessment'].get('direction_cn', '?') tf_parts.append(f"{tf}={direction_cn}") if tf_parts: parts.append(f"关键周期: {', '.join(tf_parts)}") # Divergence confluence if divergence_confluence.get('has_confluence'): type_cn = divergence_confluence.get('type_cn', '') parts.append(f"重要: {type_cn}") return "; ".join(parts) @staticmethod def _empty_result(timeframe: str) -> Dict[str, Any]: """Return empty result when data is insufficient""" return { 'timeframe': timeframe, 'error': 'insufficient_data', 'rsi': {'value': 50, 'zone_cn': '数据不足'}, 'macd': {'signal': 'unknown'}, 'stochastic': {}, 'volume': {}, 'divergences': {'detected': False}, 'assessment': { 'direction': 'unknown', 'direction_cn': '数据不足', 'strength': 'unknown', }, 'summary': '数据不足,无法分析', }