diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index ce1ed07..6d2603d 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -3304,7 +3304,7 @@ class CryptoAgent: def _validate_data(self, data: Dict[str, pd.DataFrame]) -> bool: """验证数据完整性""" - required_intervals = ['1m', '5m', '15m', '30m', '1h', '4h'] + required_intervals = ['5m', '15m', '1h', '4h', '1d'] for interval in required_intervals: if interval not in data or data[interval].empty: return False diff --git a/backend/app/crypto_agent/market_signal_analyzer.py b/backend/app/crypto_agent/market_signal_analyzer.py index 1f9b3d5..19e45aa 100644 --- a/backend/app/crypto_agent/market_signal_analyzer.py +++ b/backend/app/crypto_agent/market_signal_analyzer.py @@ -31,8 +31,8 @@ class MarketSignalAnalyzer: TREND_ANALYSIS_TEMPERATURE = 0.10 ANALYSIS_MAX_TOKENS = 1200 LANE_MIN_CONFIDENCE = { - 'short_term': 60, - 'medium_term': 65, + 'short_term': 70, + 'medium_term': 70, } LANE_MIN_RISK_REWARD = { 'short_term': 1.5, @@ -51,7 +51,7 @@ class MarketSignalAnalyzer: INTRADAY_ANALYSIS_PROMPT = """你是一位专业的加密货币日内交易员,只负责生成 short_term 信号。 -你的任务是基于 5m / 15m / 30m、当日开盘、VWAP、开盘区间、关键位、Fib 回撤位和衍生品拥挤度,判断未来 30 分钟到 4 小时内是否存在可执行 setup。 +你的任务是基于 5m / 15m、当日开盘、VWAP、开盘区间、关键位、Fib 回撤位和衍生品拥挤度,判断未来 30 分钟到 4 小时内是否存在可执行 setup。 执行原则: 1. 先判断日内 regime:trending / ranging / neutral。 @@ -75,8 +75,8 @@ class MarketSignalAnalyzer: 8. grade / confidence 约束: - A: 80-100,结构、位置、量价、时机都对齐 - B: 70-79,条件较完整但仍有一项次优 - - C: 60-69,只有轻仓试错级别 - - 60 以下不要输出交易信号 + - C: 70-71,只有轻仓试错级别 + - 70 以下不要输出交易信号 9. 止损止盈距离下限: - short_term 止损距离至少 0.6% - short_term 止盈距离至少 1.0% @@ -118,13 +118,13 @@ class MarketSignalAnalyzer: TREND_ANALYSIS_PROMPT = """你是一位专业的加密货币趋势交易员,只负责生成 medium_term 信号。 -你的任务是基于 1h / 4h、关键位、Fib 回撤/扩展位、趋势阶段、反转检测、衍生品拥挤度和新闻催化,判断未来 4 小时到 3 天内是否存在趋势 setup。 +你的任务是基于 1h / 4h / 1d、关键位、Fib 回撤/扩展位、趋势阶段、反转检测、衍生品拥挤度和新闻催化,判断未来 4 小时到 1 周内是否存在趋势 setup。 执行原则: -1. 4h 决定大方向,1h 决定节奏与入场位置。 +1. 4h/1d 决定大方向,1h 决定节奏与入场位置。 2. 只做两类交易: - - 趋势延续:4h 趋势明确,1h 回踩关键位后确认继续 - - 趋势反转:4h 结构和 1h 动能同时改善,且反转证据充分 + - 趋势延续:4h/1d 趋势明确,1h 回踩关键位后确认继续 + - 趋势反转:4h/1d 结构和 1h 动能同时改善,且反转证据充分 3. 禁止仅凭 15m 噪音逆 4h 开仓。 4. 趋势晚期、资金费率过热或价格过度偏离关键均线时,要显著降低开仓积极性。 5. 没有清晰位置优势就不交易。 @@ -134,16 +134,16 @@ class MarketSignalAnalyzer: 信号要求: 1. 只允许输出 0 或 1 个 medium_term 信号。 2. 盈亏比至少 1:1.8。 -3. 如果 4h 与 1h 明显冲突,优先返回空信号。 +3. 如果 4h/1d 与 1h 明显冲突,优先返回空信号。 4. 反转信号必须比延续信号更严格。 5. 如果趋势处于晚期且没有回踩确认,或反转证据不足,必须返回空信号。 6. 只有在位置优势和方向一致性都充分时才允许开仓。 7. 趋势延续单的 entry 应优先靠近优先支撑/阻力或对应共振区,不在远离关键位的位置追价。 8. grade / confidence 约束: - - A: 82-100,4h/1h 同向且位置优 + - A: 82-100,4h/1d/1h 同向且位置优 - B: 72-81,趋势或反转证据较完整 - - C: 65-71,仅限早期确认不足的轻仓趋势尝试 - - 65 以下不要输出交易信号 + - C: 70-71,仅限早期确认不足的轻仓趋势尝试 + - 70 以下不要输出交易信号 9. 止损止盈距离下限: - medium_term 止损距离至少 1.0% - medium_term 止盈距离至少 2.0% @@ -270,12 +270,12 @@ class MarketSignalAnalyzer: feature_5m = self._summarize_timeframe_features(data.get('5m'), '5m') feature_15m = self._summarize_timeframe_features(data.get('15m'), '15m') - feature_30m = self._summarize_timeframe_features(data.get('30m'), '30m') feature_1h = self._summarize_timeframe_features(data.get('1h'), '1h') feature_4h = self._summarize_timeframe_features(data.get('4h'), '4h') + feature_1d = self._summarize_timeframe_features(data.get('1d'), '1d') - intraday_alignment = self._describe_alignment([feature_5m, feature_15m, feature_30m]) - trend_alignment = self._describe_alignment([feature_1h, feature_4h]) + intraday_alignment = self._describe_alignment([feature_5m, feature_15m]) + trend_alignment = self._describe_alignment([feature_1h, feature_4h, feature_1d]) range_zone = self._detect_range_zone(data) reversal_detection = self._detect_trend_reversal(data) trend_stage = self._detect_trend_stage(data) @@ -304,7 +304,6 @@ class MarketSignalAnalyzer: "## 日内特征", self._format_feature_line(feature_5m), self._format_feature_line(feature_15m), - self._format_feature_line(feature_30m), f"- 日内级别一致性: {intraday_alignment}", ] @@ -312,6 +311,7 @@ class MarketSignalAnalyzer: "## 趋势特征", self._format_feature_line(feature_1h), self._format_feature_line(feature_4h), + self._format_feature_line(feature_1d), f"- 趋势级别一致性: {trend_alignment}", ] @@ -356,11 +356,35 @@ class MarketSignalAnalyzer: else: trend_parts.append("- 反转检测: 无显著反转信号") + # 震荡市场量化 + range_metrics = self._quantify_ranging_state(data) + range_warning = "" + if range_metrics['regime'] in ('ranging', 'transitional'): + range_warning = ( + f"\n## 震荡市场警告\n" + f"- 当前市场状态: {range_metrics['regime']} (score {range_metrics['regime_score']}/100)\n" + f"- ATR(1h): {range_metrics['atr_pct']:.3f}%\n" + f"- EMA收敛度: {range_metrics['ema_convergence_pct']:.3f}%\n" + f"- 方向效率: {range_metrics['range_efficiency']:.2f} (0=纯震荡, 1=纯趋势)\n" + f"- ADX: {range_metrics['adx']:.1f}\n" + f"- 建议: {'避免趋势策略,只做区间边界反转' if range_metrics['regime'] == 'ranging' else '谨慎开仓,降低仓位'}\n" + ) + intraday_parts.append( + f"- ⚠️ 震荡市场: regime={range_metrics['regime']} (score {range_metrics['regime_score']}) | " + f"ATR={range_metrics['atr_pct']:.2f}% | EMA收敛={range_metrics['ema_convergence_pct']:.2f}% | " + f"效率={range_metrics['range_efficiency']:.2f} | ADX={range_metrics['adx']:.1f}" + ) + trend_parts.append( + f"- 市场结构: {range_metrics['regime']} | ADX={range_metrics['adx']:.1f} | " + f"{'BB收口' if range_metrics['bb_squeeze'] else 'BB正常'}" + ) + return { 'snapshot': "\n".join(snapshot_parts), 'intraday': "\n".join(intraday_parts), 'trend': "\n".join(trend_parts), 'levels': "\n".join(levels_parts), + 'range_warning': range_warning, } def _get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]: @@ -449,6 +473,8 @@ class MarketSignalAnalyzer: 'distance_to_recent_high': None, 'distance_to_recent_low': None, 'is_accelerating': False, + 'adx': None, + 'trend_strength_adx': 'unknown', } if df is None or df.empty or len(df) < 20: @@ -475,6 +501,19 @@ class MarketSignalAnalyzer: 'is_accelerating': self._is_accelerating(df), }) + # ADX 趋势强度 + adx = latest.get('adx') + if pd.notna(adx): + feature['adx'] = float(adx) + if adx >= 40: + feature['trend_strength_adx'] = 'strong' + elif adx >= 25: + feature['trend_strength_adx'] = 'moderate' + elif adx >= 20: + feature['trend_strength_adx'] = 'weak' + else: + feature['trend_strength_adx'] = 'ranging' + if pd.notna(ema5) and pd.notna(ema10) and pd.notna(ema20): if ema5 > ema10 > ema20: feature['ema_alignment'] = 'bull' @@ -499,15 +538,21 @@ class MarketSignalAnalyzer: def fmt(value: Optional[float], digits: int = 2) -> str: return "N/A" if value is None else f"{value:+.{digits}f}%" + def safe(value: Optional[float], digits: int = 1) -> str: + return "N/A" if value is None else f"{value:.{digits}f}" + + adx_part = f" | ADX={safe(feature.get('adx'), 1)}({feature['trend_strength_adx']})" if feature.get('adx') else "" + return ( f"- {feature['timeframe']}: 结构={feature['structure']} | EMA={feature['ema_alignment']} | " f"3bar={fmt(feature['momentum_3'])} | 12bar={fmt(feature['momentum_12'])} | " - f"RSI={feature['rsi']:.1f} | ATR={feature['atr_pct']:.2f}% | " - f"量比={feature['volume_ratio']:.2f} | " + f"RSI={safe(feature['rsi'], 1)} | ATR={safe(feature['atr_pct'], 2)}% | " + f"量比={safe(feature['volume_ratio'], 2)} | " f"距EMA20={fmt(feature['distance_to_ema20'])} | " f"距20bar高点={fmt(feature['distance_to_recent_high'])} | " f"距20bar低点={fmt(feature['distance_to_recent_low'])} | " f"加速={'是' if feature['is_accelerating'] else '否'}" + f"{adx_part}" ) def _describe_alignment(self, features: List[Dict[str, Any]]) -> str: @@ -543,7 +588,7 @@ class MarketSignalAnalyzer: if range_zone.get('resistance_level'): resistance_candidates.append(self._make_level_candidate(float(range_zone['resistance_level']), 1.2, "区间上沿")) - for timeframe, count, tf_weight in [('30m', 20, 1.0), ('1h', 20, 1.15), ('4h', 12, 1.3)]: + for timeframe, count, tf_weight in [('15m', 20, 0.95), ('1h', 20, 1.15), ('4h', 12, 1.3), ('1d', 10, 1.5)]: df = data.get(timeframe) if df is None or len(df) < count: continue @@ -603,8 +648,9 @@ class MarketSignalAnalyzer: } fib_specs = [ - ('30m', 48, 'intraday', '日内'), + ('15m', 48, 'intraday', '日内'), ('4h', 60, 'trend', '趋势'), + ('1d', 30, 'trend', '日线'), ] for timeframe, lookback, key, label in fib_specs: @@ -623,7 +669,10 @@ class MarketSignalAnalyzer: if fib_result.get('trade_zone'): summary += f" | 可交易区={fib_result['trade_zone']}" - contexts[key] = summary + if key in contexts and contexts[key]: + contexts[key] += f"\n{summary}" + else: + contexts[key] = summary contexts['support_levels'].extend(fib_result.get('support_levels', [])) contexts['resistance_levels'].extend(fib_result.get('resistance_levels', [])) contexts['support_details'].extend(fib_result.get('support_details', [])) @@ -1187,13 +1236,13 @@ class MarketSignalAnalyzer: lane_scope = ( [ "只根据下面提供的日内结构化特征做判断,不要脑补未提供的数据。", - "重点阅读 5m/15m/30m、当日开盘、VWAP、开盘区间、区间状态、关键位、Fib 回撤位和衍生品过热程度。", + "重点阅读 5m/15m、当日开盘、VWAP、开盘区间、区间状态、关键位、Fib 回撤位和衍生品过热程度。", "优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,不要在远离关键位的位置给 entry。", ] if lane == "intraday" else [ "只根据下面提供的趋势结构化特征做判断,不要脑补未提供的数据。", - "重点阅读 1h/4h、一致性、趋势阶段、反转检测、关键位、Fib 回撤/扩展位、新闻催化和衍生品拥挤度。", + "重点阅读 1h/4h/1d、一致性、趋势阶段、反转检测、关键位、Fib 回撤/扩展位、新闻催化和衍生品拥挤度。", "优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,趋势单必须体现位置优势,不接受远离关键位追价。", ] ) @@ -1222,6 +1271,10 @@ class MarketSignalAnalyzer: prompt_parts.append("") prompt_parts.append(futures_context) + if market_context.get('range_warning'): + prompt_parts.append("") + prompt_parts.append(market_context['range_warning']) + prompt_parts.append("") prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。") @@ -1687,16 +1740,16 @@ class MarketSignalAnalyzer: } def _analyze_volatility(self, data: Dict[str, pd.DataFrame]) -> str: - """分析波动率变化(使用 30m 作为日内主周期)""" - df = data.get('30m') - if df is None or len(df) < 24 or 'atr' not in df.columns: + """分析波动率变化(使用 1h 作为日内主周期)""" + df = data.get('1h') + if df is None or len(df) < 12 or 'atr' not in df.columns: return "" lines = [] # ATR 变化趋势 - recent_atr = df['atr'].iloc[-6:].mean() # 最近 6 根(3小时) - older_atr = df['atr'].iloc[-12:-6].mean() # 之前 6 根 + recent_atr = df['atr'].iloc[-6:].mean() # 最近 6 根(6小时) + older_atr = df['atr'].iloc[-12:-6].mean() # 之前 6 根(6小时) if pd.isna(recent_atr) or pd.isna(older_atr) or older_atr == 0: return "" @@ -1707,7 +1760,7 @@ class MarketSignalAnalyzer: current_price = float(df['close'].iloc[-1]) atr_percent = current_atr / current_price * 100 - lines.append(f"当前 ATR (30m): ${current_atr:.2f} ({atr_percent:.2f}%)") + lines.append(f"当前 ATR (1h): ${current_atr:.2f} ({atr_percent:.2f}%)") if atr_change > 20: lines.append(f"**波动率扩张**: ATR 上升 {atr_change:.0f}%,日内趋势可能启动") @@ -1728,6 +1781,109 @@ class MarketSignalAnalyzer: return "\n".join(lines) + def _quantify_ranging_state(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: + """ + 量化震荡市场指标,帮助 LLM 在震荡行情中避免追涨杀跌 + + Returns: + 包含 ATR%、EMA 收敛度、方向效率、BB 挤压、ADX、regime 的字典 + """ + result = { + 'atr_pct': 0.0, + 'atr_ratio_trend': 0.0, + 'ema_convergence_pct': 0.0, + 'range_efficiency': 0.0, + 'bb_squeeze': False, + 'adx': 0.0, + 'regime': 'unknown', + 'regime_score': 0, + } + + try: + df_1h = data.get('1h') + if df_1h is None or len(df_1h) < 20: + return result + + price = float(df_1h['close'].iloc[-1]) + + # ATR 占价格百分比 + if 'atr' in df_1h.columns: + atr = float(df_1h['atr'].iloc[-1]) + result['atr_pct'] = atr / price * 100 if price > 0 else 0 + + # ATR 趋势(扩张/收缩) + if len(df_1h) >= 18: + atr_recent = df_1h['atr'].iloc[-6:].mean() + atr_older = df_1h['atr'].iloc[-18:-6].mean() + if atr_older > 0: + result['atr_ratio_trend'] = float((atr_recent - atr_older) / atr_older) + + # EMA 收敛度 + if all(col in df_1h.columns for col in ['ema5', 'ema10', 'ema20']): + ema_short = float(df_1h['ema5'].iloc[-1]) + ema_mid = float(df_1h['ema10'].iloc[-1]) + ema_long = float(df_1h['ema20'].iloc[-1]) + ema_spread = (max(ema_short, ema_mid, ema_long) - min(ema_short, ema_mid, ema_long)) + result['ema_convergence_pct'] = ema_spread / price * 100 if price > 0 else 0 + + # 方向效率(Choppiness Index 近似) + lookback = 14 + if len(df_1h) >= lookback: + high_low_range = df_1h['high'].iloc[-lookback:].max() - df_1h['low'].iloc[-lookback:].min() + directional_move = abs(df_1h['close'].iloc[-1] - df_1h['close'].iloc[-lookback]) + result['range_efficiency'] = float(directional_move / high_low_range) if high_low_range > 0 else 0 + + # Bollinger Band 挤压 + if all(col in df_1h.columns for col in ['bb_upper', 'bb_lower']) and len(df_1h) >= 20: + bb_width_current = (df_1h['bb_upper'].iloc[-1] - df_1h['bb_lower'].iloc[-1]) / price * 100 + bb_width_ma = (df_1h['bb_upper'].iloc[-20:] - df_1h['bb_lower'].iloc[-20:]).mean() / \ + df_1h['close'].iloc[-20:].mean() * 100 + result['bb_squeeze'] = bb_width_ma > 0 and bb_width_current < bb_width_ma * 0.7 + + # ADX + if 'adx' in df_1h.columns: + adx_val = df_1h['adx'].iloc[-1] + if pd.notna(adx_val): + result['adx'] = float(adx_val) + + # Regime 分类 + score = 0 + if result['ema_convergence_pct'] < 0.5: + score += 30 + elif result['ema_convergence_pct'] < 1.0: + score += 20 + + if result['range_efficiency'] < 0.2: + score += 30 + elif result['range_efficiency'] < 0.4: + score += 15 + + if result['atr_ratio_trend'] < -0.2: + score += 20 + + if result['bb_squeeze']: + score += 10 + + if result['adx'] < 20: + score += 20 + elif result['adx'] < 25: + score += 10 + + result['regime_score'] = score + if score >= 75: + result['regime'] = 'ranging' + elif score >= 50: + result['regime'] = 'transitional' + elif score >= 25: + result['regime'] = 'weak_trend' + else: + result['regime'] = 'strong_trend' + + except Exception as e: + logger.warning(f"震荡市场量化失败: {e}") + + return result + def _detect_range_zone(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: """ 检测震荡区间 - 计算明确的支撑位和压力位 @@ -1750,23 +1906,22 @@ class MarketSignalAnalyzer: } try: - df_30m = data.get('30m') df_1h = data.get('1h') df_15m = data.get('15m') - if df_30m is None or len(df_30m) < 48: # 需要至少48根K线(24小时) + if df_1h is None or len(df_1h) < 24: # 需要至少24根K线(24小时) return result - current_price = float(df_30m['close'].iloc[-1]) + current_price = float(df_1h['close'].iloc[-1]) # ========== 1. 价格通道分析 ========== - # 使用最近24-48根K线(12-24小时)计算价格通道 - lookback_periods = [24, 36, 48] + # 使用最近12-24根K线(12-24小时)计算价格通道 + lookback_periods = [12, 18, 24] price_channels = [] for period in lookback_periods: - if len(df_30m) >= period: - period_data = df_30m.iloc[-period:] + if len(df_1h) >= period: + period_data = df_1h.iloc[-period:] high = period_data['high'].max() low = period_data['low'].min() price_channels.append({'high': high, 'low': low, 'width': high - low}) @@ -1782,38 +1937,32 @@ class MarketSignalAnalyzer: range_width_pct = (range_width / current_price) * 100 # 震荡区间判断标准 - # 1. 区间宽度 < 5%(震荡市) - # 2. 价格在区间中位数附近 - # 3. EMA 纠缠 is_narrow_range = range_width_pct < 5.0 price_in_middle = (current_price - support) / range_width > 0.3 and \ (current_price - support) / range_width < 0.7 # EMA 纠缠检查 - ema5 = df_30m['ma5'].iloc[-1] if 'ma5' in df_30m.columns else None - ema10 = df_30m['ma10'].iloc[-1] if 'ma10' in df_30m.columns else None - ema20 = df_30m['ma20'].iloc[-1] if 'ma20' in df_30m.columns else None + ema5 = df_1h['ma5'].iloc[-1] if 'ma5' in df_1h.columns else None + ema10 = df_1h['ma10'].iloc[-1] if 'ma10' in df_1h.columns else None + ema20 = df_1h['ma20'].iloc[-1] if 'ma20' in df_1h.columns else None ema_entangled = False if all([ema5, ema10, ema20]): ema_spread = (max(ema5, ema10, ema20) - min(ema5, ema10, ema20)) / current_price * 100 - ema_entangled = ema_spread < 1.0 # EMA 排列差距 < 1% + ema_entangled = ema_spread < 1.0 # ========== 2. 成交量密集区分析 ========== volume_profile_support = None volume_profile_resistance = None - if len(df_30m) >= 48: - # 找出成交量最大的价格区间 - df_30m_copy = df_30m.iloc[-48:].copy() - df_30m_copy['avg_price'] = (df_30m_copy['high'] + df_30m_copy['low'] + df_30m_copy['close']) / 3 - df_30m_copy['volume_weight'] = df_30m_copy['volume'] * df_30m_copy['avg_price'] + if len(df_1h) >= 24: + df_1h_copy = df_1h.iloc[-24:].copy() + df_1h_copy['avg_price'] = (df_1h_copy['high'] + df_1h_copy['low'] + df_1h_copy['close']) / 3 + df_1h_copy['volume_weight'] = df_1h_copy['volume'] * df_1h_copy['avg_price'] - # 按价格分层,找出高成交量区域 - price_bins = pd.cut(df_30m_copy['avg_price'], bins=10) - volume_by_price = df_30m_copy.groupby(price_bins, observed=True)['volume'].sum() + price_bins = pd.cut(df_1h_copy['avg_price'], bins=10) + volume_by_price = df_1h_copy.groupby(price_bins, observed=True)['volume'].sum() if len(volume_by_price) > 0: - # 高成交量区作为支撑/压力 max_vol_bin = volume_by_price.idxmax() if max_vol_bin is not None: vp_level = (max_vol_bin.left + max_vol_bin.right) / 2 @@ -1825,12 +1974,11 @@ class MarketSignalAnalyzer: # ========== 3. 布林带支撑/压力 ========== bb_support = None bb_resistance = None - if 'bb_lower' in df_30m.columns and 'bb_upper' in df_30m.columns: - bb_support = float(df_30m['bb_lower'].iloc[-1]) - bb_resistance = float(df_30m['bb_upper'].iloc[-1]) + if 'bb_lower' in df_1h.columns and 'bb_upper' in df_1h.columns: + bb_support = float(df_1h['bb_lower'].iloc[-1]) + bb_resistance = float(df_1h['bb_upper'].iloc[-1]) # ========== 4. 关键价格点综合 ========== - # 综合多个指标得出最可靠的支撑/压力位 support_candidates = [] resistance_candidates = [] @@ -1848,7 +1996,6 @@ class MarketSignalAnalyzer: if bb_resistance: resistance_candidates.append(bb_resistance) - # 取中位数作为最终的支撑/压力位 final_support = np.median(support_candidates) if support_candidates else None final_resistance = np.median(resistance_candidates) if resistance_candidates else None @@ -1868,24 +2015,22 @@ class MarketSignalAnalyzer: confidence += 25 reasons.append("EMA纠缠") - # 成交量分布检查 - 如果成交量在区间两端较小,说明是有效震荡 - if len(df_30m) >= 24: - recent_vol = df_30m['volume'].iloc[-12:].mean() - older_vol = df_30m['volume'].iloc[-24:-12].mean() + # 成交量分布检查 + if len(df_1h) >= 12: + recent_vol = df_1h['volume'].iloc[-6:].mean() + older_vol = df_1h['volume'].iloc[-12:-6].mean() if abs(recent_vol - older_vol) / older_vol < 0.3: confidence += 15 reasons.append("成交量平稳") - # 价格反弹次数 - 检查在支撑/压力位附近是否有多次反弹 + # 价格反弹次数 if final_support and final_resistance: bounce_count = 0 - for i in range(-24, 0): - if i >= -len(df_30m): - row = df_30m.iloc[i] - # 检查是否在支撑位附近反弹 + for i in range(-12, 0): + if i >= -len(df_1h): + row = df_1h.iloc[i] if abs(row['low'] - final_support) / final_support < 0.005 and row['close'] > row['open']: bounce_count += 1 - # 检查是否在压力位附近回落 if abs(row['high'] - final_resistance) / final_resistance < 0.005 and row['close'] < row['open']: bounce_count += 1 @@ -1932,7 +2077,6 @@ class MarketSignalAnalyzer: try: df_15m = data.get('15m') - df_30m = data.get('30m') df_1h = data.get('1h') if df_15m is None or len(df_15m) < 30: @@ -2118,7 +2262,6 @@ class MarketSignalAnalyzer: # ========== 5. 多周期趋势不一致 ========== trend_15m = self._get_trend_direction(df_15m) - trend_30m = self._get_trend_direction(df_30m) trend_1h = self._get_trend_direction(df_1h) # 小周期反转但大周期未反应 @@ -2216,13 +2359,12 @@ class MarketSignalAnalyzer: } try: - df_30m = data.get('30m') df_1h = data.get('1h') - if df_30m is None or len(df_30m) < 30: + if df_1h is None or len(df_1h) < 30: return result - current_price = float(df_30m['close'].iloc[-1]) + current_price = float(df_1h['close'].iloc[-1]) stage_signals = [] early_score = 0 @@ -2230,10 +2372,10 @@ class MarketSignalAnalyzer: late_score = 0 # ========== 1. EMA 排列状态 ========== - ema5 = df_30m['ma5'].iloc[-1] if 'ma5' in df_30m.columns else None - ema10 = df_30m['ma10'].iloc[-1] if 'ma10' in df_30m.columns else None - ema20 = df_30m['ma20'].iloc[-1] if 'ma20' in df_30m.columns else None - ema50 = df_30m['ma50'].iloc[-1] if 'ma50' in df_30m.columns else None + ema5 = df_1h['ma5'].iloc[-1] if 'ma5' in df_1h.columns else None + ema10 = df_1h['ma10'].iloc[-1] if 'ma10' in df_1h.columns else None + ema20 = df_1h['ma20'].iloc[-1] if 'ma20' in df_1h.columns else None + ema50 = df_1h['ma50'].iloc[-1] if 'ma50' in df_1h.columns else None if all([ema5, ema10, ema20, ema50]): # 检查EMA排列是否形成 @@ -2241,11 +2383,11 @@ class MarketSignalAnalyzer: # 多头排列 # 检查排列刚刚形成(早期)还是已经稳定(中期/晚期) ema5_cross_ma20 = False - if len(df_30m) >= 10: + if len(df_1h) >= 10: # 检查最近10根内是否发生过金叉 for i in range(-10, 0): - if df_30m['ma5'].iloc[i] > df_30m['ma20'].iloc[i]: - if i > -10 and df_30m['ma5'].iloc[i-1] <= df_30m['ma20'].iloc[i-1]: + if df_1h['ma5'].iloc[i] > df_1h['ma20'].iloc[i]: + if i > -10 and df_1h['ma5'].iloc[i-1] <= df_1h['ma20'].iloc[i-1]: ema5_cross_ma20 = True break @@ -2265,10 +2407,10 @@ class MarketSignalAnalyzer: elif ema5 < ema10 < ema20 < ema50: # 空头排列 ema5_cross_ma20 = False - if len(df_30m) >= 10: + if len(df_1h) >= 10: for i in range(-10, 0): - if df_30m['ma5'].iloc[i] < df_30m['ma20'].iloc[i]: - if i > -10 and df_30m['ma5'].iloc[i-1] >= df_30m['ma20'].iloc[i-1]: + if df_1h['ma5'].iloc[i] < df_1h['ma20'].iloc[i]: + if i > -10 and df_1h['ma5'].iloc[i-1] >= df_1h['ma20'].iloc[i-1]: ema5_cross_ma20 = True break @@ -2285,9 +2427,9 @@ class MarketSignalAnalyzer: stage_signals.append("EMA排列稳定(中期)") # ========== 2. RSI 状态 ========== - if 'rsi' in df_30m.columns: - rsi_current = df_30m['rsi'].iloc[-1] - rsi_prev = df_30m['rsi'].iloc[-5:-1].values + if 'rsi' in df_1h.columns: + rsi_current = df_1h['rsi'].iloc[-1] + rsi_prev = df_1h['rsi'].iloc[-5:-1].values # RSI极端区 - 晚期信号 if rsi_current > 70: @@ -2326,12 +2468,12 @@ class MarketSignalAnalyzer: stage_signals.append("价格贴近EMA20 - 趋势稳固") # ========== 4. 量价关系 ========== - if 'volume' in df_30m.columns and len(df_30m) >= 10: - recent_vol = df_30m['volume'].iloc[-5:].mean() - older_vol = df_30m['volume'].iloc[-10:-5].mean() + if 'volume' in df_1h.columns and len(df_1h) >= 10: + recent_vol = df_1h['volume'].iloc[-5:].mean() + older_vol = df_1h['volume'].iloc[-10:-5].mean() vol_change = (recent_vol - older_vol) / older_vol * 100 - price_change_5 = (df_30m['close'].iloc[-1] - df_30m['close'].iloc[-5]) / df_30m['close'].iloc[-5] * 100 + price_change_5 = (df_1h['close'].iloc[-1] - df_1h['close'].iloc[-5]) / df_1h['close'].iloc[-5] * 100 # 价格上涨但成交量下降(量价背离)- 晚期信号 if price_change_5 > 1 and vol_change < -20: @@ -2348,9 +2490,9 @@ class MarketSignalAnalyzer: stage_signals.append(f"放量下跌(跌{price_change_5:.1f}%量增{vol_change:.0f}%)- 可能早期") # ========== 5. 波动率状态 ========== - if 'atr' in df_30m.columns and len(df_30m) >= 20: - recent_atr = df_30m['atr'].iloc[-5:].mean() - older_atr = df_30m['atr'].iloc[-15:-5].mean() + if 'atr' in df_1h.columns and len(df_1h) >= 20: + recent_atr = df_1h['atr'].iloc[-5:].mean() + older_atr = df_1h['atr'].iloc[-15:-5].mean() atr_change = (recent_atr - older_atr) / older_atr * 100 if older_atr > 0 else 0 if atr_change > 30: @@ -2361,8 +2503,8 @@ class MarketSignalAnalyzer: stage_signals.append(f"ATR收缩({atr_change:.0f}%) - 动能衰竭") # ========== 6. 连续同向K线数量 ========== - if len(df_30m) >= 5: - recent_closes = df_30m['close'].iloc[-5:].values + if len(df_1h) >= 5: + recent_closes = df_1h['close'].iloc[-5:].values consecutive_up = sum(1 for i in range(1, len(recent_closes)) if recent_closes[i] > recent_closes[i-1]) consecutive_down = sum(1 for i in range(1, len(recent_closes)) if recent_closes[i] < recent_closes[i-1]) diff --git a/backend/app/services/bitget_service.py b/backend/app/services/bitget_service.py index bb15f7f..6189707 100644 --- a/backend/app/services/bitget_service.py +++ b/backend/app/services/bitget_service.py @@ -14,12 +14,11 @@ class BitgetService: # K线周期映射 - 注意 Bitget 使用大写 H INTERVALS = { - '1m': '1m', '5m': '5m', '15m': '15m', - '30m': '30m', '1h': '1H', # Bitget 大写 '4h': '4H', # Bitget 大写 + '1d': '1D', # Bitget 大写 } # Bitget API 基础 URL @@ -105,26 +104,24 @@ class BitgetService: category: 产品类型,默认 USDT-FUTURES Returns: - 包含 1m, 5m, 15m, 30m, 1h, 4h 数据的字典 + 包含 5m, 15m, 1h, 4h, 1d 数据的字典 """ # 不同周期使用不同的数据量,平衡分析深度和性能 - # 1m: 200根 = 3.3小时(超短线精确入场) - # 5m: 200根 = 16.7小时(日内分析) - # 15m: 200根 = 2.1天(短线分析) - # 30m: 200根 = 4.2天(日内趋势) - # 1h: 300根 = 12.5天(日内主趋势) - # 4h: 180根 = 30天(趋势判断) + # 5m: 200根 = 16.7小时(日内微结构) + # 15m: 200根 = 2.1天(日内主时间级别) + # 1h: 300根 = 12.5天(短期趋势 + 24h 涨跌) + # 4h: 180根 = 30天(趋势确认) + # 1d: 120根 = 120天(宏观趋势,日线 S/R) limits = { - '1m': 200, '5m': 200, '15m': 200, - '30m': 200, '1h': 300, - '4h': 180 + '4h': 180, + '1d': 120, } data = {} - for interval in ['1m', '5m', '15m', '30m', '1h', '4h']: + for interval in ['5m', '15m', '1h', '4h', '1d']: df = self.get_klines(symbol, interval, limit=limits.get(interval, 100), category=category) if not df.empty: @@ -179,14 +176,13 @@ class BitgetService: if df.empty: return df - # 根据周期调整 MA 参数 + # 根据周期调整 MA 参数(每个时间级别有不同的覆盖范围) ma_config = { - '1m': {'ma_short': 5, 'ma_mid': 10, 'ma_long': 20, 'ma_extra': 50}, - '5m': {'ma_short': 5, 'ma_mid': 10, 'ma_long': 20, 'ma_extra': 50}, - '15m': {'ma_short': 5, 'ma_mid': 10, 'ma_long': 20, 'ma_extra': 50}, - '30m': {'ma_short': 5, 'ma_mid': 10, 'ma_long': 20, 'ma_extra': 50}, - '1h': {'ma_short': 5, 'ma_mid': 10, 'ma_long': 20, 'ma_extra': 50}, - '4h': {'ma_short': 5, 'ma_mid': 10, 'ma_long': 20, 'ma_extra': 50}, + '5m': {'ma_short': 8, 'ma_mid': 21, 'ma_long': 55, 'ma_extra': 89}, + '15m': {'ma_short': 7, 'ma_mid': 20, 'ma_long': 50, 'ma_extra': 100}, + '1h': {'ma_short': 7, 'ma_mid': 25, 'ma_long': 60, 'ma_extra': 99}, + '4h': {'ma_short': 7, 'ma_mid': 25, 'ma_long': 50, 'ma_extra': 100}, + '1d': {'ma_short': 5, 'ma_mid': 20, 'ma_long': 60, 'ma_extra': 120}, } config = ma_config.get(interval, ma_config['1h']) @@ -222,6 +218,11 @@ class BitgetService: # ATR df['atr'] = self._calculate_atr(df['high'], df['low'], df['close']) + # ADX (趋势强度) + df['adx'], df['plus_di'], df['minus_di'] = self._calculate_adx( + df['high'], df['low'], df['close'] + ) + # 成交量均线 df['volume_ma5'] = self._calculate_ma(df['volume'], 5) df['volume_ma20'] = self._calculate_ma(df['volume'], 20) @@ -305,6 +306,40 @@ class BitgetService: return atr + @staticmethod + def _calculate_adx(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14): + """ADX 趋势强度指标""" + # True Range + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + + # Directional Movement + up_move = high - high.shift(1) + down_move = low.shift(1) - low + + plus_dm = pd.Series( + np.where((up_move > down_move) & (up_move > 0), up_move, 0.0), + index=high.index + ) + minus_dm = pd.Series( + np.where((down_move > up_move) & (down_move > 0), down_move, 0.0), + index=high.index + ) + + # Wilder's smoothing + atr = tr.ewm(alpha=1/period, adjust=False).mean() + plus_di = 100 * (plus_dm.ewm(alpha=1/period, adjust=False).mean() / atr) + minus_di = 100 * (minus_dm.ewm(alpha=1/period, adjust=False).mean() / atr) + + di_sum = plus_di + minus_di + di_sum = di_sum.replace(0, np.nan) + dx = 100 * abs(plus_di - minus_di) / di_sum + adx = dx.ewm(alpha=1/period, adjust=False).mean() + + return adx, plus_di, minus_di + def get_current_price(self, symbol: str, category: str = None) -> Optional[float]: """ 获取当前价格 diff --git a/backend/app/utils/indicators.py b/backend/app/utils/indicators.py index 06c0346..e813590 100644 --- a/backend/app/utils/indicators.py +++ b/backend/app/utils/indicators.py @@ -144,6 +144,63 @@ def calculate_boll( return upper, middle, lower +def calculate_adx( + high: pd.Series, + low: pd.Series, + close: pd.Series, + period: int = 14 +) -> Tuple[pd.Series, pd.Series, pd.Series]: + """ + 计算 ADX (Average Directional Index) 趋势强度指标 + + ADX 衡量趋势强度(非方向): + - ADX < 20: 震荡 / 无趋势 + - ADX 20-25: 过渡期 + - ADX 25-50: 趋势中 + - ADX > 50: 强趋势 + + Args: + high: 最高价 + low: 最低价 + close: 收盘价 + period: 回溯周期 (默认 14) + + Returns: + (adx, plus_di, minus_di) 元组 + """ + # True Range + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + + # Directional Movement + up_move = high - high.shift(1) + down_move = low.shift(1) - low + + plus_dm = pd.Series( + np.where((up_move > down_move) & (up_move > 0), up_move, 0.0), + index=high.index + ) + minus_dm = pd.Series( + np.where((down_move > up_move) & (down_move > 0), down_move, 0.0), + index=high.index + ) + + # Wilder's smoothing (EWM alpha=1/period) + atr = tr.ewm(alpha=1/period, adjust=False).mean() + plus_di = 100 * (plus_dm.ewm(alpha=1/period, adjust=False).mean() / atr) + minus_di = 100 * (minus_dm.ewm(alpha=1/period, adjust=False).mean() / atr) + + # DX and ADX + di_sum = plus_di + minus_di + di_sum = di_sum.replace(0, np.nan) + dx = 100 * abs(plus_di - minus_di) / di_sum + adx = dx.ewm(alpha=1/period, adjust=False).mean() + + return adx, plus_di, minus_di + + def calculate_volume_ma(volume: pd.Series, period: int = 5) -> pd.Series: """ 计算成交量移动平均