diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 2c65d32..e51d831 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -91,6 +91,9 @@ def _normalize_platform_position(platform: str, position: Dict[str, Any]) -> Dic "take_profit": position.get("take_profit"), "stop_loss": position.get("stop_loss"), "liquidation_price": position.get("liquidation_price"), + "setup_type": position.get("setup_type"), + "setup_basis": position.get("setup_basis"), + "entry_basis": position.get("entry_basis"), "opened_at": position.get("opened_at") or position.get("created_at"), } @@ -128,6 +131,9 @@ def _normalize_platform_order(platform: str, order: Dict[str, Any]) -> Dict[str, "take_profit": order.get("take_profit"), "signal_grade": order.get("signal_grade"), "signal_type": order.get("signal_type"), + "setup_type": order.get("setup_type"), + "setup_basis": order.get("setup_basis"), + "entry_basis": order.get("entry_basis"), "confidence": _safe_float(order.get("confidence")), "created_at": order.get("created_at") or order.get("timestamp"), } diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index f4e405f..0328037 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -126,6 +126,69 @@ class CryptoAgent: }, } + SETUP_EXECUTION_PROFILES = { + 'breakout_confirmation': { + 'margin_multiplier': 0.75, + 'max_margin_pct_cap': 0.12, + 'same_direction_position_policy': 'no_add', + 'same_direction_pending_policy': 'no_replace', + 'max_same_side_pending': 1, + 'opposite_flip_confidence_delta': 3, + 'allow_close_opposite_on_small_loss': False, + }, + 'breakout_pullback': { + 'margin_multiplier': 0.95, + 'max_margin_pct_cap': 0.18, + 'same_direction_position_policy': 'hold', + 'same_direction_pending_policy': 'replace_better', + 'max_same_side_pending': 1, + 'opposite_flip_confidence_delta': 2, + }, + 'trend_continuation_pullback': { + 'margin_multiplier': 1.0, + 'max_margin_pct_cap': 0.18, + 'same_direction_position_policy': 'scale_in', + 'same_direction_pending_policy': 'replace_better', + 'max_same_side_pending': 2, + 'opposite_flip_confidence_delta': 2, + }, + 'deep_pullback_continuation': { + 'margin_multiplier': 0.8, + 'max_margin_pct_cap': 0.12, + 'same_direction_position_policy': 'scale_in_only_if_deep_edge', + 'same_direction_pending_policy': 'replace_better', + 'max_same_side_pending': 1, + 'opposite_flip_confidence_delta': 4, + }, + 'range_reversal': { + 'margin_multiplier': 0.7, + 'max_margin_pct_cap': 0.10, + 'same_direction_position_policy': 'no_add', + 'same_direction_pending_policy': 'single_order_only', + 'max_same_side_pending': 1, + 'opposite_flip_confidence_delta': 5, + 'allow_close_opposite_on_small_loss': False, + }, + 'trend_reversal': { + 'margin_multiplier': 0.55, + 'max_margin_pct_cap': 0.08, + 'same_direction_position_policy': 'no_add', + 'same_direction_pending_policy': 'single_order_only', + 'max_same_side_pending': 1, + 'opposite_flip_confidence_delta': 8, + 'allow_close_opposite_on_small_loss': False, + }, + 'default': { + 'margin_multiplier': 1.0, + 'max_margin_pct_cap': 0.18, + 'same_direction_position_policy': 'scale_in', + 'same_direction_pending_policy': 'replace_better', + 'max_same_side_pending': 2, + 'opposite_flip_confidence_delta': 0, + 'allow_close_opposite_on_small_loss': True, + }, + } + TP_SL_RETRY_ALERT_THRESHOLD = 3 TP_SL_MAX_RETRY_BEFORE_ERROR = 6 TP_SL_ALERT_COOLDOWN_MINUTES = 15 @@ -196,6 +259,7 @@ class CryptoAgent: self.last_signals: Dict[str, Dict[str, Any]] = {} self.last_execution_preview: Dict[str, Dict[str, Any]] = {} self.signal_cooldown: Dict[str, datetime] = {} + self.symbol_trade_cooldown: Dict[str, Dict[str, Any]] = {} # 账户初始余额持久化(用于计算回撤) self._initial_balances: Dict[str, float] = {} @@ -288,6 +352,9 @@ class CryptoAgent: "symbol": symbol or (decision or {}).get("symbol", ""), "decision": (decision or {}).get("decision"), "action": (decision or {}).get("action"), + "setup_type": (decision or {}).get("setup_type"), + "setup_basis": (decision or {}).get("setup_basis"), + "entry_basis": (decision or {}).get("entry_basis"), "reason": reason or (decision or {}).get("reason") or (decision or {}).get("reasoning", ""), } if extra: @@ -1234,6 +1301,25 @@ class CryptoAgent: price_change_24h = self._calculate_price_change(data['1h']) logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})") + paper_symbol_cooldown = None + if self.settings.paper_trading_enabled: + paper_symbol_cooldown = self._refresh_symbol_trade_cooldown('PaperTrading', symbol) + if paper_symbol_cooldown.get('should_cool_down'): + cooldown_until = paper_symbol_cooldown.get('cooldown_until') + cooldown_text = cooldown_until.isoformat() if cooldown_until else '' + self._record_analysis_event( + "symbol_trade_cooldown", + symbol=symbol, + status="warning", + detail=paper_symbol_cooldown.get('reason', '交易对处于冷却中'), + extra={ + "platform": "PaperTrading", + "losing_streak": paper_symbol_cooldown.get('losing_streak', 0), + "cooldown_until": cooldown_text, + }, + ) + logger.info(f"⏸️ 模拟盘交易对冷却: {paper_symbol_cooldown.get('reason')} | until={cooldown_text}") + # 1.5. 波动率检查(节省 LLM 调用) should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data) if not should_analyze: @@ -1299,6 +1385,8 @@ class CryptoAgent: 'current_price': current_price } + regime_profile = market_signal.get('regime_profile') or {} + # 过滤掉 wait 信号,只保留 buy/sell 信号 signals = market_signal.get('signals', []) trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']] @@ -1314,7 +1402,11 @@ class CryptoAgent: detail="完成分析,无交易信号", extra={"trade_signals": 0, "valid_signals": 0}, ) - logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望") + blocked_reasons = market_signal.get('blocked_reasons') or [] + if blocked_reasons: + logger.info(f"\n⏸️ 结论: 当前市场状态不允许交易 | {';'.join(blocked_reasons[:2])}") + else: + logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望") return # 检查是否有达到阈值的交易信号 @@ -1356,7 +1448,13 @@ class CryptoAgent: if self.settings.paper_trading_enabled: logger.info(f"\n📊 【模拟盘】") paper_positions, paper_account, paper_pending = self._get_paper_trading_state() - paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral')) + paper_signal = self._select_signal_for_platform( + valid_signals, + 'PaperTrading', + market_state=market_signal.get('market_state', '中性'), + trend_direction=market_signal.get('trend_direction', 'neutral'), + regime_profile=regime_profile, + ) if paper_signal: logger.info( f" 采用信号: {paper_signal.get('timeframe', 'unknown')} | " @@ -1383,7 +1481,8 @@ class CryptoAgent: valid_signals, 'Bitget', market_state=market_signal.get('market_state', '中性'), - trend_direction=market_signal.get('trend_direction', 'neutral') + trend_direction=market_signal.get('trend_direction', 'neutral'), + regime_profile=regime_profile, ) for account_id in self._iter_bitget_accounts(): logger.info(f"\n🔥 【Bitget:{account_id}】") @@ -1698,6 +1797,13 @@ class CryptoAgent: fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}')) return fallback + def _get_setup_execution_profile(self, signal: Dict[str, Any]) -> Dict[str, Any]: + setup_type = signal.get('setup_type') or 'default' + profile = dict(self.SETUP_EXECUTION_PROFILES['default']) + profile.update(self.SETUP_EXECUTION_PROFILES.get(setup_type, {})) + profile['setup_type'] = setup_type + return profile + async def _execute_decisions(self, paper_decision: Dict[str, Any], bitget_decisions: Dict[str, Dict[str, Any]], market_signal: Dict[str, Any], current_price: float): @@ -1974,11 +2080,22 @@ class CryptoAgent: def _select_signal_for_platform(self, signals: List[Dict[str, Any]], platform_name: str, market_state: str = '中性', - trend_direction: str = 'neutral') -> Optional[Dict[str, Any]]: + trend_direction: str = 'neutral', + regime_profile: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: """根据平台偏好和市场状态选择最适合执行的信号""" if not signals: return None + regime_profile = regime_profile or {} + allowed_lanes = set(regime_profile.get('allowed_lanes') or []) + if allowed_lanes: + signals = [ + signal for signal in signals + if (signal.get('timeframe') or signal.get('type') or 'unknown') in allowed_lanes + ] + if not signals: + return None + # 震荡市:趋势信号降权(信心 × 0.8),优先选择日内反转信号 adjusted_signals = [] for signal in signals: @@ -1995,6 +2112,15 @@ class CryptoAgent: pass # 趋势市不降权日内信号 adjusted_signals.append(s) + preferred_lanes = regime_profile.get('preferred_lanes') or [] + if preferred_lanes: + lane_priority = [ + lane for lane in preferred_lanes + if lane in {sig.get('timeframe') or sig.get('type') or 'unknown' for sig in adjusted_signals} + ] + else: + lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term']) + # 逆势信号大幅降权(安全网,主过滤在 _merge_lane_results) if trend_direction in ('uptrend', 'downtrend'): forbidden = 'sell' if trend_direction == 'uptrend' else 'buy' @@ -2005,7 +2131,6 @@ class CryptoAgent: s['_trend_penalized'] = True s['_original_confidence'] = original - lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term']) by_lane: Dict[str, List[Dict[str, Any]]] = {} for signal in adjusted_signals: lane = signal.get('timeframe') or signal.get('type') or 'unknown' @@ -2043,10 +2168,15 @@ class CryptoAgent: 'reasoning': signal.get('reasoning', ''), 'timeframe': signal_type, 'type': signal_type, + 'setup_type': signal.get('setup_type', 'unknown'), + 'setup_basis': signal.get('setup_basis', ''), + 'entry_basis': signal.get('entry_basis', ''), + 'volume_price_context': signal.get('volume_price_context', {}), 'position_size': position_size, 'current_price': current_price, 'market_state': market_signal.get('market_state', '中性') if market_signal else '中性', 'regime': range_metrics.get('regime', ''), + 'regime_profile': market_signal.get('regime_profile', {}) if market_signal else {}, 'range_metrics': range_metrics, 'market_location': market_location, 'funding_rate_data': funding_rate_data, @@ -2077,7 +2207,54 @@ class CryptoAgent: def _get_signal_execution_rule(self, signal: Dict[str, Any]) -> Dict[str, float]: signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term' - return self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term']) + rule = dict(self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term'])) + profile = self._get_setup_execution_profile(signal) + if 'opposite_flip_confidence_delta' in profile: + rule['flip_confidence'] = min( + 99, + rule.get('flip_confidence', 85) + int(profile.get('opposite_flip_confidence_delta', 0)) + ) + return rule + + def _check_setup_execution_constraints(self, signal: Dict[str, Any]) -> tuple[bool, str]: + setup_type = signal.get('setup_type', 'unknown') + entry_type = signal.get('entry_type', 'market') + market_location = signal.get('market_location') or {} + volume_context = signal.get('volume_price_context') or {} + breakout_quality = volume_context.get('breakout_quality') or signal.get('breakout_quality') + pullback_quality = volume_context.get('pullback_quality') or signal.get('pullback_quality') + rejection_signal = volume_context.get('rejection_signal') or signal.get('rejection_signal') + exhaustion_risk = volume_context.get('exhaustion_risk') or signal.get('exhaustion_risk') + location_tag = market_location.get('location_tag', 'unknown') + + if setup_type == 'breakout_confirmation': + if entry_type != 'market': + return False, "突破确认 setup 只能用 market 执行" + if breakout_quality not in {'acceptance_breakout_up', 'acceptance_breakout_down'}: + return False, "突破确认缺少接受型量价证据" + + if setup_type in {'trend_continuation_pullback', 'deep_pullback_continuation', 'breakout_pullback'}: + if entry_type != 'limit': + return False, f"{setup_type} 应使用 limit 等待回踩/反抽" + if pullback_quality != 'healthy_pullback': + return False, f"{setup_type} 缺少健康缩量回调证据" + if location_tag in {'far_from_trade_zone', 'middle_of_range'}: + return False, f"{setup_type} 当前不在有效回踩交易区" + + if setup_type == 'range_reversal': + if location_tag not in {'near_range_support', 'near_range_resistance'}: + return False, "区间反转 setup 不在区间边界" + if rejection_signal not in {'bullish_rejection', 'bearish_rejection'} and entry_type == 'market': + return False, "区间反转现价执行缺少明确拒绝信号" + + if setup_type == 'trend_reversal': + if rejection_signal not in {'bullish_rejection', 'bearish_rejection'}: + return False, "趋势反转 setup 缺少拒绝/结构切换证据" + + if exhaustion_risk in {'upside_climax', 'downside_climax'} and setup_type != 'trend_reversal': + return False, "当前量价处于高潮风险,非反转 setup 不执行" + + return True, "setup 约束通过" def _get_actionable_pending_orders(self, pending_orders: List[Dict[str, Any]]) -> List[Dict[str, Any]]: return [order for order in pending_orders if not order.get('is_reduce_only')] @@ -2209,6 +2386,13 @@ class CryptoAgent: if entry_type != 'limit': return False, "仅 limit 信号考虑替换挂单" + profile = self._get_setup_execution_profile(signal) + pending_policy = profile.get('same_direction_pending_policy', 'replace_better') + if pending_policy == 'no_replace': + return False, "当前 setup 不主动替换挂单" + if pending_policy == 'single_order_only': + return False, "当前 setup 仅保留单个边界挂单" + signal_price = float(signal.get('entry_price', 0) or 0) order_price = float(order.get('entry_price', 0) or 0) current_price = float(signal.get('current_price', signal_price) or signal_price or 0) @@ -2309,6 +2493,9 @@ class CryptoAgent: entry_type = best_signal.get('entry_type', 'market') entry_type_text = '现价入场' if entry_type == 'market' else '挂单等待' entry_type_icon = '⚡' if entry_type == 'market' else '⏳' + setup_type = best_signal.get('setup_type', 'unknown') + setup_basis = best_signal.get('setup_basis', '') + entry_basis = best_signal.get('entry_basis', '') # 等级(基于信心度映射)- 与 market_signal_analyzer.py 保持一致 # A级(80-100): 量价配合 + 多指标共振 + 多周期确认 @@ -2372,6 +2559,13 @@ class CryptoAgent: f"{entry_type_icon} **入场**: {entry_type_text} | {position_icon} **仓位**: {position_text}", f"", ] + if setup_type and setup_type != 'unknown': + content_parts.append(f"🧩 **Setup**: `{setup_type}`") + if setup_basis: + content_parts.append(f"📌 **Setup依据**: {setup_basis}") + if entry_basis: + content_parts.append(f"🎯 **入场依据**: {entry_basis}") + content_parts.append("") # 入场价格显示 if entry_type == 'limit': @@ -2478,6 +2672,9 @@ class CryptoAgent: signal_timeframe = best_signal.get('timeframe', best_signal.get('type', 'unknown')) if best_signal else 'unknown' timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'} timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe) + setup_type = best_signal.get('setup_type', 'unknown') if best_signal else 'unknown' + setup_basis = best_signal.get('setup_basis', '') if best_signal else '' + entry_basis = best_signal.get('entry_basis', '') if best_signal else '' # 对限价单:用实际订单状态决定显示 # resting=真的在挂单中, filled=已立即成交, None=市价单或未知 @@ -2560,10 +2757,20 @@ class CryptoAgent: f"{entry_type_icon} **入场方式**: {entry_type_text}", f"{position_display.replace(' ', ': **')} | 📈 信心度: **{confidence}%**", f"", + ] + if setup_type and setup_type != 'unknown': + content_parts.extend([ + f"🧩 **Setup**: `{setup_type}`", + f"📌 **Setup依据**: {setup_basis}" if setup_basis else None, + f"🎯 **入场依据**: {entry_basis}" if entry_basis else None, + f"", + ]) + content_parts.extend([ f"💰 **名义仓位**: {position_value_display}", f"🪙 **保证金 / 杠杆**: ${margin:,.2f} / {leverage}x" if isinstance(margin, (int, float)) and isinstance(leverage, (int, float)) else f"🪙 **保证金**: ${margin:,.2f}", price_display, - ] + ]) + content_parts = [part for part in content_parts if part is not None] if isinstance(contracts, (int, float)) and contracts: content_parts.append(f"📦 **合约张数**: {contracts}") @@ -3214,6 +3421,7 @@ class CryptoAgent: max_leverage = account.get('max_total_leverage', 10) order_leverage = account.get('order_leverage', 10) min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0) + setup_profile = self._get_setup_execution_profile(signal) target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct( position_size=position_size, @@ -3224,6 +3432,10 @@ class CryptoAgent: default_positions=self.SIGNAL_POSITION_SIZE_DEFAULTS, ) + setup_margin_multiplier = float(setup_profile.get('margin_multiplier', 1.0) or 1.0) + if setup_margin_multiplier != 1.0: + target_margin_pct *= setup_margin_multiplier + # 市场状态仓位调整:震荡市降低仓位,避免来回被止损 regime = signal.get('regime', '') REGIME_MARGIN_MULTIPLIERS = { @@ -3243,6 +3455,9 @@ class CryptoAgent: logger.info(f" 📊 连败降温: 仓位系数={streak_multiplier}") target_margin_pct *= streak_multiplier + setup_margin_pct_cap = setup_profile.get('max_margin_pct_cap') + effective_max_margin_pct = min(max_margin_pct, setup_margin_pct_cap) if isinstance(setup_margin_pct_cap, (int, float)) else max_margin_pct + margin, _, budget_reason = calculate_margin_and_position_value( balance=balance, available_margin=available, @@ -3250,7 +3465,7 @@ class CryptoAgent: max_total_leverage=max_leverage, order_leverage=order_leverage, target_margin_pct=target_margin_pct, - max_margin_pct=max_margin_pct, + max_margin_pct=effective_max_margin_pct, min_margin=min_margin, min_effective_leverage=min_effective_leverage, ) @@ -3259,7 +3474,7 @@ class CryptoAgent: return 0, budget_reason return margin, ( - f"{sizing_reason} | 平台: {platform_name} | " + f"{sizing_reason} | setup={setup_profile.get('setup_type')} x{setup_margin_multiplier:.2f} | 平台: {platform_name} | " f"最小有效杠杆 {min_effective_leverage:.1f}x | " f"限制后保证金 ${margin:.2f} ({budget_reason})" ) @@ -3278,6 +3493,10 @@ class CryptoAgent: signal_price = signal.get('entry_price', 0) entry_type = signal.get('entry_type', 'market') rule = self._get_signal_execution_rule(signal) + setup_profile = self._get_setup_execution_profile(signal) + position_policy = setup_profile.get('same_direction_position_policy', 'scale_in') + pending_policy = setup_profile.get('same_direction_pending_policy', 'replace_better') + max_same_side_pending = int(setup_profile.get('max_same_side_pending', 2) or 2) # 检查同向持仓 same_positions = [p for p in positions @@ -3299,8 +3518,18 @@ class CryptoAgent: if position_protected: return "HOLD", "同向持仓已进入保本/保护态,不再主动加仓" + if position_policy == 'no_add': + return "HOLD", f"{setup_profile.get('setup_type')} setup 避免同向叠加仓位" + + if position_policy == 'hold': + return "HOLD", f"{setup_profile.get('setup_type')} setup 有同向持仓时优先持有,不追加" + # 规则1: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓 if better_price and price_diff_pct >= rule['min_add_price_gap_pct'] and pnl_pct >= rule['min_add_profit_pct']: + if position_policy == 'scale_in_only_if_deep_edge': + location_tag = (signal.get('market_location') or {}).get('location_tag') + if location_tag not in {'near_long_zone', 'near_short_zone', 'near_range_support', 'near_range_resistance'}: + return "HOLD", "深回踩 continuation 仅在关键交易区边缘允许加仓" return "ADD", f"加仓:价格差{price_diff_pct:.1f}%,盈利{pnl_pct:.1f}%" # 规则2: 价格距离 < 2% → 忽略 @@ -3332,6 +3561,9 @@ class CryptoAgent: (signal_side == 'sell' and signal_price > order_price) ) + if pending_policy == 'single_order_only': + return "HOLD", f"{setup_profile.get('setup_type')} setup 已有同向挂单,保持单一挂单" + # 规则5: 价格距离 < 2% → 忽略 if price_diff_pct < 2: return "IGNORE", f"同向挂单价格差{price_diff_pct:.1f}% < 2%,忽略" @@ -3342,10 +3574,10 @@ class CryptoAgent: return "REPLACE_PENDING", f"同向挂单存在,{replace_reason}" # 规则7: 价格距离 >= 2% 且挂单 < 3 → 可再挂一单 - if len(same_orders) < 3: + if len(same_orders) < max_same_side_pending and pending_policy != 'no_replace' and signal_is_better: return "OPEN", f"同向挂单价格差{price_diff_pct:.1f}% >= 2%,可开新单" else: - return "IGNORE", "同向挂单已达3个,忽略" + return "IGNORE", f"同向挂单已达 setup 上限 {max_same_side_pending} 个,忽略" # 无同向订单 → 正常开仓 return "OPEN", "无同向订单,正常开仓" @@ -3364,6 +3596,8 @@ class CryptoAgent: opposite_side = 'sell' if signal_side == 'buy' else 'buy' confidence = signal.get('confidence', 0) rule = self._get_signal_execution_rule(signal) + setup_profile = self._get_setup_execution_profile(signal) + allow_close_opposite_on_small_loss = bool(setup_profile.get('allow_close_opposite_on_small_loss', True)) # 检查反向持仓 opposite_positions = [p for p in positions @@ -3387,6 +3621,8 @@ class CryptoAgent: # 规则4: 小亏损 → 平仓 if -1 < pnl_pct < 0: + if not allow_close_opposite_on_small_loss: + return "WAIT", f"{setup_profile.get('setup_type')} setup 对反向小亏损仓位保持克制,等待进一步确认" return "CLOSE_OPPOSITE", f"反向持仓小亏损{pnl_pct:.1f}%,平仓" # 检查反向挂单 @@ -3447,6 +3683,90 @@ class CryptoAgent: return {'losing_streak': losing_streak, 'should_cool_down': False, 'margin_multiplier': 1.0, 'reason': ''} + def _get_symbol_cooldown_key(self, platform_name: str, symbol: str) -> str: + return f"{platform_name}:{self._normalize_symbol(symbol)}" + + def _check_symbol_losing_streak(self, platform_name: str, symbol: str, max_lookback: int = 4) -> Dict[str, Any]: + """ + 检查单个交易对近期连续亏损情况。 + + 当前仅对模拟盘启用,实盘后续应由独立执行监管器基于成交回报维护。 + """ + normalized_symbol = self._normalize_symbol(symbol) + recent_orders = [] + + if platform_name == 'PaperTrading' and self.paper_trading: + recent_orders = self.paper_trading.get_order_history(symbol=normalized_symbol, limit=max_lookback) + + if not recent_orders: + return { + 'symbol': normalized_symbol, + 'losing_streak': 0, + 'cooldown_hours': 0, + 'cooldown_until': None, + 'should_cool_down': False, + 'reason': '', + } + + losing_streak = 0 + last_loss_order = None + for order in recent_orders: + pnl = order.get('pnl_amount', 0) or 0 + if pnl < 0: + losing_streak += 1 + if last_loss_order is None: + last_loss_order = order + else: + break + + cooldown_hours = 0 + if losing_streak >= 3: + cooldown_hours = 6 + elif losing_streak >= 2: + cooldown_hours = 2 + + cooldown_until = None + if cooldown_hours > 0 and last_loss_order: + closed_at = last_loss_order.get('closed_at') or last_loss_order.get('updated_at') or last_loss_order.get('created_at') + if closed_at: + try: + closed_at_dt = datetime.fromisoformat(str(closed_at).replace('Z', '+00:00')) + cooldown_until = closed_at_dt + timedelta(hours=cooldown_hours) + except ValueError: + cooldown_until = None + + return { + 'symbol': normalized_symbol, + 'losing_streak': losing_streak, + 'cooldown_hours': cooldown_hours, + 'cooldown_until': cooldown_until, + 'should_cool_down': bool(cooldown_until and datetime.now(cooldown_until.tzinfo) < cooldown_until), + 'reason': ( + f"{normalized_symbol} 最近连续亏损 {losing_streak} 次,暂停新开仓 {cooldown_hours} 小时" + if cooldown_until and cooldown_hours > 0 else '' + ), + } + + def _refresh_symbol_trade_cooldown(self, platform_name: str, symbol: str) -> Dict[str, Any]: + info = self._check_symbol_losing_streak(platform_name, symbol) + key = self._get_symbol_cooldown_key(platform_name, symbol) + if info.get('should_cool_down'): + self.symbol_trade_cooldown[key] = info + else: + self.symbol_trade_cooldown.pop(key, None) + return info + + def _get_symbol_trade_cooldown(self, platform_name: str, symbol: str) -> Optional[Dict[str, Any]]: + key = self._get_symbol_cooldown_key(platform_name, symbol) + cached = self.symbol_trade_cooldown.get(key) + if cached and cached.get('cooldown_until'): + cooldown_until = cached['cooldown_until'] + now = datetime.now(cooldown_until.tzinfo) if getattr(cooldown_until, 'tzinfo', None) else datetime.now() + if now < cooldown_until: + return cached + self.symbol_trade_cooldown.pop(key, None) + return self._refresh_symbol_trade_cooldown(platform_name, symbol) + def _check_risk_control(self, signal: Dict[str, Any], platform_name: str, account: Dict[str, Any], @@ -3459,6 +3779,22 @@ class CryptoAgent: (passed, reason) - 是否通过和原因 """ # 1. 杠杆限制检查 + regime_profile = signal.get('regime_profile') or {} + tradability = regime_profile.get('tradability') + if tradability == 'avoid': + blocked_reasons = regime_profile.get('no_trade_reasons') or ['当前市场状态不允许交易'] + return False, f"市场状态过滤: {';'.join(blocked_reasons[:2])}" + + symbol_cooldown = self._get_symbol_trade_cooldown(platform_name, signal.get('symbol', '')) + if symbol_cooldown and symbol_cooldown.get('should_cool_down'): + cooldown_until = symbol_cooldown.get('cooldown_until') + until_text = cooldown_until.isoformat() if cooldown_until else '' + return False, f"交易对冷却中: {symbol_cooldown.get('reason', '').strip()} {until_text}".strip() + + setup_passed, setup_reason = self._check_setup_execution_constraints(signal) + if not setup_passed: + return False, f"setup 过滤: {setup_reason}" + current_leverage = account.get('current_total_leverage', 0) max_leverage = account.get('max_total_leverage', 10) remaining_leverage = max_leverage - current_leverage diff --git a/backend/app/crypto_agent/executor/paper_trading_executor.py b/backend/app/crypto_agent/executor/paper_trading_executor.py index 8bcdcbc..7f4d93e 100644 --- a/backend/app/crypto_agent/executor/paper_trading_executor.py +++ b/backend/app/crypto_agent/executor/paper_trading_executor.py @@ -75,6 +75,10 @@ class PaperTradingExecutor(BaseExecutor): 'signal_type': signal_type, 'type': raw_signal_type, 'reason': decision.get('reasoning', decision.get('reason', '')), + 'setup_type': decision.get('setup_type'), + 'setup_basis': decision.get('setup_basis'), + 'entry_basis': decision.get('entry_basis'), + 'volume_price_context': decision.get('volume_price_context'), } # 执行下单(统一调用方式) diff --git a/backend/app/crypto_agent/feature_engine.py b/backend/app/crypto_agent/feature_engine.py new file mode 100644 index 0000000..01a6549 --- /dev/null +++ b/backend/app/crypto_agent/feature_engine.py @@ -0,0 +1,364 @@ +from typing import Any, Dict, Optional + +import numpy as np +import pandas as pd + +from app.utils.logger import logger + + +class FeatureEngine: + """将原始 K 线转换为供策略与 LLM 使用的高价值特征。""" + + def get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]: + """获取当前交易日开盘价。""" + if df is None or df.empty: + return None + + try: + if 'open_time' not in df.columns: + return float(df.iloc[-24]['open']) if len(df) >= 24 else float(df.iloc[0]['open']) + + latest_time = pd.to_datetime(df['open_time'].iloc[-1]) + session_start = latest_time.normalize() + today_bars = df[df['open_time'] >= session_start] + if not today_bars.empty: + return float(today_bars.iloc[0]['open']) + except Exception as e: + logger.debug(f"获取交易日开盘价失败: {e}") + + return float(df.iloc[0]['open']) if not df.empty else None + + def calculate_session_vwap(self, df: Optional[pd.DataFrame]) -> Optional[float]: + """计算当前交易日 VWAP。""" + if df is None or df.empty or 'volume' not in df.columns: + return None + + try: + session_df = df + if 'open_time' in df.columns: + latest_time = pd.to_datetime(df['open_time'].iloc[-1]) + session_start = latest_time.normalize() + session_df = df[df['open_time'] >= session_start] + + if session_df.empty: + return None + + typical_price = (session_df['high'] + session_df['low'] + session_df['close']) / 3 + volume = session_df['volume'].replace(0, np.nan) + total_volume = volume.sum() + if pd.isna(total_volume) or total_volume <= 0: + return None + + return float((typical_price * session_df['volume']).sum() / total_volume) + except Exception as e: + logger.debug(f"计算 VWAP 失败: {e}") + return None + + def calculate_opening_range(self, df: Optional[pd.DataFrame], bars: int = 6) -> Optional[Dict[str, float]]: + """计算前 30 分钟开盘区间。""" + if df is None or df.empty or len(df) < bars: + return None + + try: + session_df = df + if 'open_time' in df.columns: + latest_time = pd.to_datetime(df['open_time'].iloc[-1]) + session_start = latest_time.normalize() + session_df = df[df['open_time'] >= session_start] + + session_df = session_df.iloc[:bars] + if session_df.empty: + return None + + return { + 'high': float(session_df['high'].max()), + 'low': float(session_df['low'].min()) + } + except Exception as e: + logger.debug(f"计算开盘区间失败: {e}") + return None + + def summarize_timeframe_features(self, df: Optional[pd.DataFrame], timeframe: str) -> Dict[str, Any]: + """将单个周期的 K 线转换为高价值特征摘要。""" + feature = { + 'timeframe': timeframe, + 'available': False, + 'close': None, + 'ema_alignment': 'neutral', + 'structure': 'unknown', + 'momentum_3': None, + 'momentum_12': None, + 'rsi': None, + 'atr_pct': None, + 'volume_ratio': None, + 'distance_to_ema20': None, + 'distance_to_recent_high': None, + 'distance_to_recent_low': None, + 'is_accelerating': False, + 'adx': None, + 'trend_strength_adx': 'unknown', + 'body_ratio': None, + 'close_position_in_bar': None, + 'upper_wick_ratio': None, + 'lower_wick_ratio': None, + 'range_expansion_ratio': None, + 'pressure_bias': 'neutral', + 'volume_price_state': 'neutral', + 'breakout_quality': 'none', + 'pullback_quality': 'neutral', + 'rejection_signal': 'none', + 'exhaustion_risk': 'low', + } + + if df is None or df.empty or len(df) < 20: + return feature + + latest = df.iloc[-1] + close = float(latest['close']) + ema5 = latest.get('ema5') + ema10 = latest.get('ema10') + ema20 = latest.get('ema20') + rsi = latest.get('rsi') + atr = latest.get('atr') + structure = self.infer_price_structure(df) + candle_context = self.analyze_candle_context(df) + volume_price = self.analyze_volume_price(df, structure=structure, candle_context=candle_context) + + feature.update({ + 'available': True, + 'close': close, + 'rsi': float(rsi) if pd.notna(rsi) else None, + 'atr_pct': float(atr / close * 100) if pd.notna(atr) and close > 0 else None, + 'distance_to_ema20': self.distance_percent(close, ema20), + 'structure': structure, + 'momentum_3': self.window_return(df, 3), + 'momentum_12': self.window_return(df, 12), + 'volume_ratio': self.calculate_volume_ratio(df), + 'is_accelerating': self.is_accelerating(df), + **candle_context, + **volume_price, + }) + + 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' + elif ema5 < ema10 < ema20: + feature['ema_alignment'] = 'bear' + else: + feature['ema_alignment'] = 'mixed' + + recent_window = df.iloc[-20:] + recent_high = float(recent_window['high'].max()) + recent_low = float(recent_window['low'].min()) + feature['distance_to_recent_high'] = self.distance_percent(close, recent_high) + feature['distance_to_recent_low'] = self.distance_percent(close, recent_low) + + return feature + + def analyze_candle_context(self, df: pd.DataFrame, window: int = 20) -> Dict[str, Any]: + """提炼最新 K 线的实体、影线、收盘位置和波动扩张。""" + context = { + 'body_ratio': None, + 'close_position_in_bar': None, + 'upper_wick_ratio': None, + 'lower_wick_ratio': None, + 'range_expansion_ratio': None, + } + + if df is None or df.empty: + return context + + latest = df.iloc[-1] + open_price = self._resolve_open_price(df) + close_price = float(latest['close']) + high_price = float(latest['high']) + low_price = float(latest['low']) + bar_range = max(high_price - low_price, 1e-9) + body_high = max(open_price, close_price) + body_low = min(open_price, close_price) + + avg_range = None + if len(df) > 1: + recent_window = df.iloc[-window:] + avg_range = float((recent_window['high'] - recent_window['low']).mean()) + + context['body_ratio'] = body_ratio = abs(close_price - open_price) / bar_range + context['close_position_in_bar'] = (close_price - low_price) / bar_range + context['upper_wick_ratio'] = (high_price - body_high) / bar_range + context['lower_wick_ratio'] = (body_low - low_price) / bar_range + if avg_range and avg_range > 0: + context['range_expansion_ratio'] = bar_range / avg_range + + return context + + def analyze_volume_price( + self, + df: pd.DataFrame, + *, + structure: str, + candle_context: Optional[Dict[str, Any]] = None, + volume_window: int = 20, + ) -> Dict[str, Any]: + """将量价关系压缩成交易含义明确的状态字段。""" + result = { + 'pressure_bias': 'neutral', + 'volume_price_state': 'neutral', + 'breakout_quality': 'none', + 'pullback_quality': 'neutral', + 'rejection_signal': 'none', + 'exhaustion_risk': 'low', + } + + if df is None or len(df) < 8: + return result + + candle_context = candle_context or self.analyze_candle_context(df) + volume_ratio = self.calculate_volume_ratio(df, window=volume_window) + recent_move = self.window_return(df, 3) or 0.0 + body_ratio = float(candle_context.get('body_ratio') or 0) + close_position = float(candle_context.get('close_position_in_bar') or 0.5) + upper_wick_ratio = float(candle_context.get('upper_wick_ratio') or 0) + lower_wick_ratio = float(candle_context.get('lower_wick_ratio') or 0) + range_expansion = float(candle_context.get('range_expansion_ratio') or 1.0) + close_price = float(df['close'].iloc[-1]) + prior_window = df.iloc[-min(max(volume_window, 8) + 1, len(df)):-1] + prior_high = float(prior_window['high'].max()) if not prior_window.empty else close_price + prior_low = float(prior_window['low'].min()) if not prior_window.empty else close_price + + breakout_up = close_price > prior_high + breakout_down = close_price < prior_low + + if breakout_up: + if volume_ratio >= 1.2 and body_ratio >= 0.55 and close_position >= 0.72: + result['breakout_quality'] = 'acceptance_breakout_up' + else: + result['breakout_quality'] = 'weak_breakout_up' + elif breakout_down: + if volume_ratio >= 1.2 and body_ratio >= 0.55 and close_position <= 0.28: + result['breakout_quality'] = 'acceptance_breakout_down' + else: + result['breakout_quality'] = 'weak_breakout_down' + + if upper_wick_ratio >= 0.4 and close_position <= 0.45 and volume_ratio >= 1.15: + result['rejection_signal'] = 'bearish_rejection' + elif lower_wick_ratio >= 0.4 and close_position >= 0.55 and volume_ratio >= 1.15: + result['rejection_signal'] = 'bullish_rejection' + + if structure == 'HH/HL' and recent_move < 0: + result['pullback_quality'] = 'healthy_pullback' if volume_ratio <= 0.95 else 'heavy_sell_pullback' + elif structure == 'LH/LL' and recent_move > 0: + result['pullback_quality'] = 'healthy_pullback' if volume_ratio <= 0.95 else 'heavy_buy_pullback' + + if abs(recent_move) >= 1.5 and volume_ratio >= 1.8 and body_ratio <= 0.35: + if recent_move > 0: + result['exhaustion_risk'] = 'upside_climax' + elif recent_move < 0: + result['exhaustion_risk'] = 'downside_climax' + elif volume_ratio >= 1.6 and range_expansion <= 0.8: + result['exhaustion_risk'] = 'high_volume_churn' + + if result['breakout_quality'] == 'acceptance_breakout_up': + result['pressure_bias'] = 'bullish' + result['volume_price_state'] = 'bullish_acceptance' + elif result['breakout_quality'] == 'acceptance_breakout_down': + result['pressure_bias'] = 'bearish' + result['volume_price_state'] = 'bearish_acceptance' + elif result['rejection_signal'] == 'bullish_rejection': + result['pressure_bias'] = 'bullish' + result['volume_price_state'] = 'bullish_rejection' + elif result['rejection_signal'] == 'bearish_rejection': + result['pressure_bias'] = 'bearish' + result['volume_price_state'] = 'bearish_rejection' + elif structure == 'HH/HL' and recent_move > 0 and volume_ratio >= 1.05 and close_position >= 0.6: + result['pressure_bias'] = 'bullish' + result['volume_price_state'] = 'bullish_continuation' + elif structure == 'LH/LL' and recent_move < 0 and volume_ratio >= 1.05 and close_position <= 0.4: + result['pressure_bias'] = 'bearish' + result['volume_price_state'] = 'bearish_continuation' + elif result['pullback_quality'] == 'healthy_pullback': + result['volume_price_state'] = 'pullback_on_light_volume' + elif result['pullback_quality'] in {'heavy_sell_pullback', 'heavy_buy_pullback'}: + result['volume_price_state'] = 'counter_pressure_expanding' + + return result + + def infer_price_structure(self, df: pd.DataFrame, lookback: int = 20) -> str: + """根据分段高低点判断 HH/HL / LH/LL / 区间。""" + if df is None or len(df) < lookback: + return "unknown" + + window = df.iloc[-lookback:] + half = max(lookback // 2, 5) + first = window.iloc[:half] + second = window.iloc[-half:] + + prev_high = float(first['high'].max()) + prev_low = float(first['low'].min()) + recent_high = float(second['high'].max()) + recent_low = float(second['low'].min()) + + if recent_high > prev_high and recent_low > prev_low: + return "HH/HL" + if recent_high < prev_high and recent_low < prev_low: + return "LH/LL" + return "range/mixed" + + def window_return(self, df: pd.DataFrame, bars: int) -> Optional[float]: + if df is None or len(df) <= bars: + return None + start_price = float(df['close'].iloc[-bars - 1]) + end_price = float(df['close'].iloc[-1]) + if start_price <= 0: + return None + return (end_price - start_price) / start_price * 100 + + def calculate_volume_ratio(self, df: pd.DataFrame, window: int = 20) -> float: + if df is None or len(df) <= 1: + return 1.0 + lookback = min(window, len(df) - 1) + latest_volume = float(df['volume'].iloc[-1]) + baseline = float(df['volume'].iloc[-(lookback + 1):-1].mean()) + if baseline <= 0: + return 1.0 + return latest_volume / baseline + + def is_accelerating(self, df: pd.DataFrame, bars: int = 3, threshold: float = 0.3) -> bool: + if df is None or len(df) < bars + 1: + return False + closes = df['close'].iloc[-(bars + 1):].values + changes = [ + (closes[i] - closes[i - 1]) / closes[i - 1] * 100 + for i in range(1, len(closes)) + if closes[i - 1] > 0 + ] + if len(changes) < bars: + return False + same_direction = all(change > 0 for change in changes) or all(change < 0 for change in changes) + large_enough = sum(1 for change in changes if abs(change) >= threshold) >= bars - 1 + return same_direction and large_enough + + def distance_percent(self, value: Optional[float], reference: Optional[float]) -> Optional[float]: + if value is None or reference is None or pd.isna(reference) or reference == 0: + return None + return (float(value) - float(reference)) / float(reference) * 100 + + def _resolve_open_price(self, df: pd.DataFrame) -> float: + latest = df.iloc[-1] + open_value = latest.get('open') + if pd.notna(open_value): + return float(open_value) + if len(df) >= 2: + return float(df['close'].iloc[-2]) + return float(latest['close']) diff --git a/backend/app/crypto_agent/market_signal_analyzer.py b/backend/app/crypto_agent/market_signal_analyzer.py index 34e3cb2..ec666e7 100644 --- a/backend/app/crypto_agent/market_signal_analyzer.py +++ b/backend/app/crypto_agent/market_signal_analyzer.py @@ -22,6 +22,9 @@ from app.utils.logger import logger from app.services.llm_service import llm_service from app.services.news_service import get_news_service from app.services.bitget_service import bitget_service +from app.crypto_agent.feature_engine import FeatureEngine +from app.crypto_agent.regime_engine import RegimeEngine +from app.crypto_agent.setup_policy import SetupPolicy class MarketSignalAnalyzer: @@ -57,7 +60,7 @@ class MarketSignalAnalyzer: 1. 先判断日内 regime:trending / ranging / neutral。 2. 趋势日内只做顺势回调或突破后的回踩确认,不追涨杀跌。 3. 震荡日内只做区间边界附近的反转,不在区间中部开仓。 -4. 技术指标只做辅助,优先看结构、关键位、波动率、量能、VWAP 偏离和位置优势。 +4. 技术指标只做辅助,优先看价格结构、供需区、量价是否匹配、VWAP 偏离和位置优势。 5. 优先使用“优先支撑 / 优先阻力”和“可交易多头区 / 可交易空头区”,普通支撑阻力只作补充。 6. 没有清晰止损、止盈和盈亏比就不交易。 7. 本次分析独立进行,不参考任何上一轮信号。 @@ -84,8 +87,9 @@ class MarketSignalAnalyzer: 9. 止损止盈距离下限: - short_term 止损距离至少 0.7% - short_term 止盈距离至少 1.2% -10. reasoning 必须覆盖四点中的至少三点:结构、位置、量价/波动、衍生品拥挤度。 +10. reasoning 必须覆盖四点中的至少三点:结构、位置、量价关系、衍生品拥挤度。 11. 如果数据明确显示 `market_location=middle_of_range` 或 `far_from_trade_zone`,必须返回空信号。 +12. 如果突破没有得到量价确认,或回调不是缩量回调,必须显著降低做单积极性,必要时直接返回空信号。 输出 JSON,禁止输出解释性正文: ```json @@ -132,7 +136,7 @@ class MarketSignalAnalyzer: - 趋势延续:4h/1d 趋势明确,1h 回踩关键位后确认继续 - 趋势反转:4h/1d 结构和 1h 动能同时改善,且反转证据充分 3. 禁止仅凭 15m 噪音逆 4h 开仓。 -4. 趋势晚期、资金费率过热、价格过度偏离关键均线、或衍生品顺向拥挤时,要显著降低开仓积极性。 +4. 趋势晚期、资金费率过热、价格过度偏离供需区、量价失配、或衍生品顺向拥挤时,要显著降低开仓积极性。 5. 没有清晰位置优势就不交易。 6. 本次分析独立进行,不参考任何上一轮信号。 7. 优先使用“优先支撑 / 优先阻力”和“可交易多头区 / 可交易空头区”,普通关键位只作补充。 @@ -154,8 +158,9 @@ class MarketSignalAnalyzer: 9. 止损止盈距离下限: - medium_term 止损距离至少 1.5% - medium_term 止盈距离至少 3.0% -10. reasoning 必须明确:大级别方向、1h 入场节奏、位置优势、拥挤度风险。 +10. reasoning 必须明确:大级别方向、1h 入场节奏、位置优势、量价关系/拥挤度风险。 11. 如果价格已经远离优先交易区,或趋势方向虽对但没有回踩/反抽确认,必须返回空信号。 +12. 趋势延续单必须尽量体现“推进放量、回调缩量、关键位再接受”中的至少两项;否则优先空仓。 输出 JSON,禁止输出解释性正文: ```json @@ -195,6 +200,9 @@ class MarketSignalAnalyzer: def __init__(self): self.news_service = get_news_service() self.exchange = bitget_service + self.feature_engine = FeatureEngine() + self.regime_engine = RegimeEngine() + self.setup_policy = SetupPolicy() async def analyze(self, symbol: str, data: Dict[str, Any], symbols: List[str] = None, @@ -305,6 +313,13 @@ class MarketSignalAnalyzer: 'open_interest': futures_market_data.get('open_interest'), } + result = self.apply_regime_policy( + symbol=symbol, + market_signal=result, + market_context=market_context, + futures_market_data=futures_market_data, + ) + return result except Exception as e: @@ -318,15 +333,15 @@ class MarketSignalAnalyzer: """准备市场上下文信息""" current_price = float(data['5m'].iloc[-1]['close']) price_change_24h = self._calculate_price_change_24h(data['1h']) - day_open = self._get_session_open(data.get('1h')) - session_vwap = self._calculate_session_vwap(data.get('5m')) - opening_range = self._calculate_opening_range(data.get('5m')) + day_open = self.feature_engine.get_session_open(data.get('1h')) + session_vwap = self.feature_engine.calculate_session_vwap(data.get('5m')) + opening_range = self.feature_engine.calculate_opening_range(data.get('5m')) - feature_5m = self._summarize_timeframe_features(data.get('5m'), '5m') - feature_15m = self._summarize_timeframe_features(data.get('15m'), '15m') - 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') + feature_5m = self.feature_engine.summarize_timeframe_features(data.get('5m'), '5m') + feature_15m = self.feature_engine.summarize_timeframe_features(data.get('15m'), '15m') + feature_1h = self.feature_engine.summarize_timeframe_features(data.get('1h'), '1h') + feature_4h = self.feature_engine.summarize_timeframe_features(data.get('4h'), '4h') + feature_1d = self.feature_engine.summarize_timeframe_features(data.get('1d'), '1d') intraday_alignment = self._describe_alignment([feature_5m, feature_15m]) trend_alignment = self._describe_alignment([feature_1h, feature_4h, feature_1d]) @@ -492,151 +507,10 @@ class MarketSignalAnalyzer: 'market_location': market_location, 'intraday_structured': intraday_structured, 'trend_structured': trend_structured, + 'reversal_detection': reversal_detection, + 'trend_stage': trend_stage, } - def _get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]: - """获取当前交易日开盘价""" - if df is None or df.empty: - return None - - try: - if 'open_time' not in df.columns: - return float(df.iloc[-24]['open']) if len(df) >= 24 else float(df.iloc[0]['open']) - - latest_time = pd.to_datetime(df['open_time'].iloc[-1]) - session_start = latest_time.normalize() - today_bars = df[df['open_time'] >= session_start] - if not today_bars.empty: - return float(today_bars.iloc[0]['open']) - except Exception as e: - logger.debug(f"获取交易日开盘价失败: {e}") - - return float(df.iloc[0]['open']) if not df.empty else None - - def _calculate_session_vwap(self, df: Optional[pd.DataFrame]) -> Optional[float]: - """计算当前交易日 VWAP""" - if df is None or df.empty or 'volume' not in df.columns: - return None - - try: - session_df = df - if 'open_time' in df.columns: - latest_time = pd.to_datetime(df['open_time'].iloc[-1]) - session_start = latest_time.normalize() - session_df = df[df['open_time'] >= session_start] - - if session_df.empty: - return None - - typical_price = (session_df['high'] + session_df['low'] + session_df['close']) / 3 - volume = session_df['volume'].replace(0, np.nan) - total_volume = volume.sum() - if pd.isna(total_volume) or total_volume <= 0: - return None - - return float((typical_price * session_df['volume']).sum() / total_volume) - except Exception as e: - logger.debug(f"计算 VWAP 失败: {e}") - return None - - def _calculate_opening_range(self, df: Optional[pd.DataFrame], bars: int = 6) -> Optional[Dict[str, float]]: - """计算前 30 分钟开盘区间""" - if df is None or df.empty or len(df) < bars: - return None - - try: - session_df = df - if 'open_time' in df.columns: - latest_time = pd.to_datetime(df['open_time'].iloc[-1]) - session_start = latest_time.normalize() - session_df = df[df['open_time'] >= session_start] - - session_df = session_df.iloc[:bars] - if session_df.empty: - return None - - return { - 'high': float(session_df['high'].max()), - 'low': float(session_df['low'].min()) - } - except Exception as e: - logger.debug(f"计算开盘区间失败: {e}") - return None - - def _summarize_timeframe_features(self, df: Optional[pd.DataFrame], timeframe: str) -> Dict[str, Any]: - """将单个周期的 K 线转换为高价值特征摘要""" - feature = { - 'timeframe': timeframe, - 'available': False, - 'close': None, - 'ema_alignment': 'neutral', - 'structure': 'unknown', - 'momentum_3': None, - 'momentum_12': None, - 'rsi': None, - 'atr_pct': None, - 'volume_ratio': None, - 'distance_to_ema20': None, - '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: - return feature - - latest = df.iloc[-1] - close = float(latest['close']) - ema5 = latest.get('ema5') - ema10 = latest.get('ema10') - ema20 = latest.get('ema20') - rsi = latest.get('rsi') - atr = latest.get('atr') - - feature.update({ - 'available': True, - 'close': close, - 'rsi': float(rsi) if pd.notna(rsi) else None, - 'atr_pct': float(atr / close * 100) if pd.notna(atr) and close > 0 else None, - 'distance_to_ema20': self._distance_percent(close, ema20), - 'structure': self._infer_price_structure(df), - 'momentum_3': self._window_return(df, 3), - 'momentum_12': self._window_return(df, 12), - 'volume_ratio': self._calculate_volume_ratio(df), - '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' - elif ema5 < ema10 < ema20: - feature['ema_alignment'] = 'bear' - else: - feature['ema_alignment'] = 'mixed' - - recent_window = df.iloc[-20:] - recent_high = float(recent_window['high'].max()) - recent_low = float(recent_window['low'].min()) - feature['distance_to_recent_high'] = self._distance_percent(close, recent_high) - feature['distance_to_recent_low'] = self._distance_percent(close, recent_low) - - return feature - def _format_feature_line(self, feature: Dict[str, Any]) -> str: """格式化单周期特征摘要""" if not feature.get('available'): @@ -655,6 +529,11 @@ class MarketSignalAnalyzer: f"3bar={fmt(feature['momentum_3'])} | 12bar={fmt(feature['momentum_12'])} | " f"RSI={safe(feature['rsi'], 1)} | ATR={safe(feature['atr_pct'], 2)}% | " f"量比={safe(feature['volume_ratio'], 2)} | " + f"量价={feature.get('volume_price_state', 'neutral')} | " + f"突破={feature.get('breakout_quality', 'none')} | " + f"回调={feature.get('pullback_quality', 'neutral')} | " + f"拒绝={feature.get('rejection_signal', 'none')} | " + f"高潮={feature.get('exhaustion_risk', 'low')} | " f"距EMA20={fmt(feature['distance_to_ema20'])} | " f"距20bar高点={fmt(feature['distance_to_recent_high'])} | " f"距20bar低点={fmt(feature['distance_to_recent_low'])} | " @@ -1288,6 +1167,17 @@ class MarketSignalAnalyzer: 'rsi': rounded(feature.get('rsi'), 1), 'atr_pct': rounded(feature.get('atr_pct')), 'volume_ratio': rounded(feature.get('volume_ratio')), + 'body_ratio': rounded(feature.get('body_ratio')), + 'close_position_in_bar': rounded(feature.get('close_position_in_bar')), + 'upper_wick_ratio': rounded(feature.get('upper_wick_ratio')), + 'lower_wick_ratio': rounded(feature.get('lower_wick_ratio')), + 'range_expansion_ratio': rounded(feature.get('range_expansion_ratio')), + 'pressure_bias': feature.get('pressure_bias'), + 'volume_price_state': feature.get('volume_price_state'), + 'breakout_quality': feature.get('breakout_quality'), + 'pullback_quality': feature.get('pullback_quality'), + 'rejection_signal': feature.get('rejection_signal'), + 'exhaustion_risk': feature.get('exhaustion_risk'), 'distance_to_ema20_pct': rounded(feature.get('distance_to_ema20')), 'distance_to_recent_high_pct': rounded(feature.get('distance_to_recent_high')), 'distance_to_recent_low_pct': rounded(feature.get('distance_to_recent_low')), @@ -1400,65 +1290,6 @@ class MarketSignalAnalyzer: 'sources': zone.get('sources', [])[:3], } - def _infer_price_structure(self, df: pd.DataFrame, lookback: int = 20) -> str: - """根据分段高低点判断 HH/HL / LH/LL / 区间""" - if df is None or len(df) < lookback: - return "unknown" - - window = df.iloc[-lookback:] - half = max(lookback // 2, 5) - first = window.iloc[:half] - second = window.iloc[-half:] - - prev_high = float(first['high'].max()) - prev_low = float(first['low'].min()) - recent_high = float(second['high'].max()) - recent_low = float(second['low'].min()) - - if recent_high > prev_high and recent_low > prev_low: - return "HH/HL" - if recent_high < prev_high and recent_low < prev_low: - return "LH/LL" - return "range/mixed" - - def _window_return(self, df: pd.DataFrame, bars: int) -> Optional[float]: - if df is None or len(df) <= bars: - return None - start_price = float(df['close'].iloc[-bars - 1]) - end_price = float(df['close'].iloc[-1]) - if start_price <= 0: - return None - return (end_price - start_price) / start_price * 100 - - def _calculate_volume_ratio(self, df: pd.DataFrame, window: int = 20) -> float: - if df is None or len(df) <= window: - return 1.0 - latest_volume = float(df['volume'].iloc[-1]) - baseline = float(df['volume'].iloc[-window:-1].mean()) - if baseline <= 0: - return 1.0 - return latest_volume / baseline - - def _is_accelerating(self, df: pd.DataFrame, bars: int = 3, threshold: float = 0.3) -> bool: - if df is None or len(df) < bars + 1: - return False - closes = df['close'].iloc[-(bars + 1):].values - changes = [ - (closes[i] - closes[i - 1]) / closes[i - 1] * 100 - for i in range(1, len(closes)) - if closes[i - 1] > 0 - ] - if len(changes) < bars: - return False - same_direction = all(change > 0 for change in changes) or all(change < 0 for change in changes) - large_enough = sum(1 for change in changes if abs(change) >= threshold) >= bars - 1 - return same_direction and large_enough - - def _distance_percent(self, value: Optional[float], reference: Optional[float]) -> Optional[float]: - if value is None or reference is None or pd.isna(reference) or reference == 0: - return None - return (float(value) - float(reference)) / float(reference) * 100 - async def _get_news_context(self, symbol: str) -> str: """获取新闻舆情上下文""" try: @@ -1641,6 +1472,7 @@ class MarketSignalAnalyzer: "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。", "重点判断是否存在位置优势,而不是只判断方向。", "优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone。", + "先明确当前属于哪一种 setup:区间边界反转、突破确认、突破后回踩,不要混淆。", ] if lane == "intraday" else [ @@ -1648,6 +1480,7 @@ class MarketSignalAnalyzer: "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。", "趋势单必须同时回答四个问题:大方向是否清晰、1h 节奏是否支持、位置是否优、拥挤是否可接受。", "优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone,不接受远离关键位追价。", + "先明确当前属于哪一种 setup:趋势延续回调、深回踩延续、趋势反转,不要只给方向。", ] ) @@ -1702,6 +1535,7 @@ class MarketSignalAnalyzer: prompt_parts.append("2. 方向正确但拥挤过热,也可以不交易。") prompt_parts.append("3. 远离优先交易区、处于区间中部、或已经加速延伸,优先空仓。") prompt_parts.append("4. 输出的是可执行 setup,不是主观行情评论。") + prompt_parts.append("5. setup 必须说得清是反转、确认、回踩还是延续;说不清就空仓。") prompt_parts.append("") prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。") @@ -1783,6 +1617,93 @@ class MarketSignalAnalyzer: result['timestamp'] = datetime.now().isoformat() return result + def apply_regime_policy( + self, + symbol: str, + market_signal: Dict[str, Any], + market_context: Dict[str, Any], + futures_market_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """根据 regime 对市场信号做硬约束过滤""" + if not market_signal: + return self._get_empty_signal(symbol) + + range_metrics = market_context.get("range_metrics") or {} + market_location = market_context.get("market_location") or {} + reversal_detection = market_context.get("reversal_detection") or {} + trend_stage = market_context.get("trend_stage") or {} + derivatives_state = self._summarize_derivatives_state(futures_market_data) + + regime_profile = self.regime_engine.classify( + range_metrics=range_metrics, + market_location=market_location, + trend_direction=market_signal.get("trend_direction", "neutral"), + trend_strength=market_signal.get("trend_strength", "weak"), + derivatives_state=derivatives_state, + reversal_detection=reversal_detection, + trend_stage=trend_stage, + ) + + enriched_signals = [] + for signal in market_signal.get("signals", []) or []: + lane = signal.get("timeframe") or signal.get("type") or "short_term" + volume_price_context = self._select_volume_price_context( + market_context=market_context, + lane="trend" if lane == "medium_term" else "intraday", + ) + enriched_signals.append( + { + **signal, + "regime": range_metrics.get("regime", ""), + "market_location": market_location, + "trend_stage": trend_stage, + "volume_price_context": volume_price_context, + "breakout_quality": (volume_price_context or {}).get("breakout_quality"), + "pullback_quality": (volume_price_context or {}).get("pullback_quality"), + "rejection_signal": (volume_price_context or {}).get("rejection_signal"), + "volume_price_state": (volume_price_context or {}).get("volume_price_state"), + } + ) + + filtered_signals, blocked_reasons = self.setup_policy.filter_signals(enriched_signals, regime_profile) + + normalized = dict(market_signal) + normalized["signals"] = filtered_signals + normalized["regime_profile"] = regime_profile + normalized["blocked_reasons"] = blocked_reasons[:6] + + if not filtered_signals and blocked_reasons: + normalized["analysis_summary"] = self._truncate_summary(regime_profile.get("summary") or "当前状态不交易") + + return normalized + + def _select_volume_price_context(self, market_context: Dict[str, Any], lane: str) -> Dict[str, Any]: + structured_key = 'trend_structured' if lane == 'trend' else 'intraday_structured' + structured = market_context.get(structured_key) + if not structured: + return {} + try: + payload = structured.replace("```json", "").replace("```", "").strip() + block = json.loads(payload) + except Exception: + return {} + + preferred_timeframes = ['1h', '4h', '1d'] if lane == 'trend' else ['5m', '15m', '1h'] + timeframes = block.get('timeframes') or {} + for timeframe in preferred_timeframes: + feature = timeframes.get(timeframe) or {} + if feature.get('available'): + return { + 'timeframe': timeframe, + 'volume_price_state': feature.get('volume_price_state'), + 'breakout_quality': feature.get('breakout_quality'), + 'pullback_quality': feature.get('pullback_quality'), + 'rejection_signal': feature.get('rejection_signal'), + 'exhaustion_risk': feature.get('exhaustion_risk'), + 'pressure_bias': feature.get('pressure_bias'), + } + return {} + def _normalize_lane_signals(self, signals: List[Dict[str, Any]], lane_type: str) -> List[Dict[str, Any]]: """统一信号时间框架标识""" normalized = [] @@ -2245,10 +2166,10 @@ class MarketSignalAnalyzer: 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, @@ -2257,6 +2178,10 @@ class MarketSignalAnalyzer: 'range_efficiency': 0.0, 'bb_squeeze': False, 'adx': 0.0, + 'compression_pct': 0.0, + 'swing_flip_count': 0, + 'directional_conviction': 0.0, + 'volume_price_balance': 'neutral', 'regime': 'unknown', 'regime_score': 0, } @@ -2267,20 +2192,19 @@ class MarketSignalAnalyzer: return result price = float(df_1h['close'].iloc[-1]) + feature_1h = self.feature_engine.summarize_timeframe_features(df_1h, '1h') + recent = df_1h.iloc[-12:].copy() - # 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]) @@ -2288,58 +2212,91 @@ class MarketSignalAnalyzer: 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 分类 + recent_high = float(recent['high'].max()) + recent_low = float(recent['low'].min()) + recent_range_pct = ((recent_high - recent_low) / price * 100) if price > 0 else 0 + result['compression_pct'] = recent_range_pct + + close_changes = np.sign(recent['close'].diff().fillna(0).values) + swing_flips = 0 + previous = 0 + for change in close_changes: + if change == 0: + continue + if previous != 0 and change != previous: + swing_flips += 1 + previous = change + result['swing_flip_count'] = int(swing_flips) + + momentum_12 = abs(float(feature_1h.get('momentum_12') or 0)) + body_ratio = float(feature_1h.get('body_ratio') or 0) + close_pos = float(feature_1h.get('close_position_in_bar') or 0.5) + pressure_bias = feature_1h.get('pressure_bias', 'neutral') + volume_state = feature_1h.get('volume_price_state', 'neutral') + + directional_conviction = momentum_12 + if pressure_bias != 'neutral': + directional_conviction += 1.0 + if volume_state in {'bullish_acceptance', 'bearish_acceptance', 'bullish_continuation', 'bearish_continuation'}: + directional_conviction += 1.2 + if body_ratio >= 0.6: + directional_conviction += 0.6 + if close_pos >= 0.75 or close_pos <= 0.25: + directional_conviction += 0.4 + result['directional_conviction'] = round(directional_conviction, 2) + result['volume_price_balance'] = volume_state + score = 0 - if result['ema_convergence_pct'] < 0.5: - score += 30 - elif result['ema_convergence_pct'] < 1.0: + if recent_range_pct <= 4.0: + score += 22 + elif recent_range_pct <= 6.0: + score += 12 + + if result['range_efficiency'] < 0.22: + score += 22 + elif result['range_efficiency'] < 0.38: + score += 12 + + if swing_flips >= 6: 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']: + elif swing_flips >= 4: score += 10 - if result['adx'] < 20: - score += 20 - elif result['adx'] < 25: + if pressure_bias == 'neutral': score += 10 + if volume_state in {'neutral', 'pullback_on_light_volume', 'high_volume_churn'}: + score += 10 + if body_ratio <= 0.35: + score += 8 + if result['atr_ratio_trend'] < -0.15: + score += 8 - result['regime_score'] = score - if score >= 75: + result['regime_score'] = int(score) + if score >= 72: result['regime'] = 'ranging' - elif score >= 50: + elif score >= 46: result['regime'] = 'transitional' - elif score >= 25: - result['regime'] = 'weak_trend' - else: + elif directional_conviction >= 2.8 and result['range_efficiency'] >= 0.38: result['regime'] = 'strong_trend' + else: + result['regime'] = 'weak_trend' except Exception as e: logger.warning(f"震荡市场量化失败: {e}") @@ -2348,13 +2305,7 @@ class MarketSignalAnalyzer: def _detect_range_zone(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: """ - 检测震荡区间 - 计算明确的支撑位和压力位 - - 使用多种方法综合判断: - 1. 价格通道(最近N根K线的最高/最低价) - 2. 成交量密集区(Volume Profile) - 3. 布林带 - 4. EMA支撑/压力 + 检测震荡区间 - 优先使用价格通道、成交密集区与边界响应。 """ result = { 'is_ranging': False, @@ -2369,15 +2320,16 @@ class MarketSignalAnalyzer: try: df_1h = data.get('1h') - df_15m = data.get('15m') if df_1h is None or len(df_1h) < 24: # 需要至少24根K线(24小时) return result current_price = float(df_1h['close'].iloc[-1]) + feature_1h = self.feature_engine.summarize_timeframe_features(df_1h, '1h') + range_state = self._quantify_ranging_state(data) + pressure_bias = feature_1h.get('pressure_bias', 'neutral') + volume_state = feature_1h.get('volume_price_state', 'neutral') - # ========== 1. 价格通道分析 ========== - # 使用最近12-24根K线(12-24小时)计算价格通道 lookback_periods = [12, 18, 24] price_channels = [] @@ -2388,7 +2340,6 @@ class MarketSignalAnalyzer: low = period_data['low'].min() price_channels.append({'high': high, 'low': low, 'width': high - low}) - # 选择波动最稳定的通道(宽度变化最小的) if price_channels: avg_width = sum(pc['width'] for pc in price_channels) / len(price_channels) selected_channel = min(price_channels, @@ -2398,28 +2349,17 @@ class MarketSignalAnalyzer: range_width = resistance - support range_width_pct = (range_width / current_price) * 100 - # 震荡区间判断标准 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_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 - - # ========== 2. 成交量密集区分析 ========== volume_profile_support = None volume_profile_resistance = None + volume_profile_mid = None 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_1h_copy['avg_price'], bins=10) volume_by_price = df_1h_copy.groupby(price_bins, observed=True)['volume'].sum() @@ -2428,19 +2368,12 @@ class MarketSignalAnalyzer: 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 - if vp_level < current_price * 0.98: + volume_profile_mid = float(vp_level) + if vp_level < current_price: volume_profile_support = float(vp_level) - elif vp_level > current_price * 1.02: + if vp_level > current_price: volume_profile_resistance = float(vp_level) - # ========== 3. 布林带支撑/压力 ========== - bb_support = None - bb_resistance = None - 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 = [] @@ -2448,44 +2381,43 @@ class MarketSignalAnalyzer: support_candidates.append(support) if volume_profile_support: support_candidates.append(volume_profile_support) - if bb_support: - support_candidates.append(bb_support) if resistance: resistance_candidates.append(resistance) if volume_profile_resistance: resistance_candidates.append(volume_profile_resistance) - 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 - # ========== 5. 计算置信度 ========== confidence = 0 reasons = [] if is_narrow_range: - confidence += 30 + confidence += 24 reasons.append(f"区间窄({range_width_pct:.1f}%)") if price_in_middle: confidence += 20 reasons.append("价格在中部") - if ema_entangled: - confidence += 25 - reasons.append("EMA纠缠") + if range_state.get('swing_flip_count', 0) >= 5: + confidence += 18 + reasons.append(f"方向切换频繁({range_state.get('swing_flip_count')}次)") - # 成交量分布检查 - 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 range_state.get('range_efficiency', 1) < 0.3: + confidence += 16 + reasons.append("方向效率低") + + if pressure_bias == 'neutral' or volume_state in {'neutral', 'high_volume_churn'}: + confidence += 12 + reasons.append("量价中性/换手") + + if volume_profile_mid and final_support and final_resistance: + if final_support < volume_profile_mid < final_resistance: + confidence += 10 + reasons.append("成交密集区位于区间内部") - # 价格反弹次数 if final_support and final_resistance: bounce_count = 0 for i in range(-12, 0): @@ -2497,7 +2429,7 @@ class MarketSignalAnalyzer: bounce_count += 1 if bounce_count >= 2: - confidence += 10 + confidence += 12 reasons.append(f"边界反弹{bounce_count}次") result.update({ @@ -2806,12 +2738,7 @@ class MarketSignalAnalyzer: def _detect_trend_stage(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: """ - 检测趋势阶段:早期/中期/晚期 - - 判断标准: - 1. 早期:刚突破关键位,均线刚开始排列,动能开始释放 - 2. 中期:均线排列稳定,价格沿趋势移动,量能健康 - 3. 晚期:价格过度延伸,RSI极端区,量价背离,多次假突破 + 基于价格行为和量价关系识别趋势阶段:早期 / 中期 / 晚期。 """ result = { 'stage': 'unknown', # 'early', 'middle', 'late' @@ -2822,162 +2749,76 @@ class MarketSignalAnalyzer: try: df_1h = data.get('1h') + df_4h = data.get('4h') - if df_1h is None or len(df_1h) < 30: + if df_1h is None or len(df_1h) < 24: return result - current_price = float(df_1h['close'].iloc[-1]) - + feature_1h = self.feature_engine.summarize_timeframe_features(df_1h, '1h') + feature_4h = self.feature_engine.summarize_timeframe_features(df_4h, '4h') if df_4h is not None and len(df_4h) >= 20 else {} stage_signals = [] early_score = 0 middle_score = 0 late_score = 0 + structure_1h = feature_1h.get('structure') + structure_4h = feature_4h.get('structure') + volume_state = feature_1h.get('volume_price_state', 'neutral') + breakout_quality = feature_1h.get('breakout_quality', 'none') + pullback_quality = feature_1h.get('pullback_quality', 'neutral') + rejection_signal = feature_1h.get('rejection_signal', 'none') + exhaustion_risk = feature_1h.get('exhaustion_risk', 'low') + body_ratio = float(feature_1h.get('body_ratio') or 0) + close_pos = float(feature_1h.get('close_position_in_bar') or 0.5) + dist_high = abs(float(feature_1h.get('distance_to_recent_high') or 0)) + dist_low = abs(float(feature_1h.get('distance_to_recent_low') or 0)) + extension_pct = min(dist_high, dist_low) + momentum_12 = float(feature_1h.get('momentum_12') or 0) - # ========== 1. EMA 排列状态 ========== - 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 breakout_quality in {'acceptance_breakout_up', 'acceptance_breakout_down'}: + early_score += 28 + stage_signals.append("突破后被市场接受") + elif volume_state in {'bullish_continuation', 'bearish_continuation'} and abs(momentum_12) >= 2.0: + middle_score += 24 + stage_signals.append("趋势推进与量价延续同步") - if all([ema5, ema10, ema20, ema50]): - # 检查EMA排列是否形成 - if ema5 > ema10 > ema20 > ema50: - # 多头排列 - # 检查排列刚刚形成(早期)还是已经稳定(中期/晚期) - ema5_cross_ma20 = False - if len(df_1h) >= 10: - # 检查最近10根内是否发生过金叉 - for i in range(-10, 0): - 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 + if pullback_quality == 'healthy_pullback': + middle_score += 18 + stage_signals.append("回调缩量,趋势结构健康") + elif pullback_quality in {'heavy_sell_pullback', 'heavy_buy_pullback'}: + late_score += 16 + stage_signals.append("回调放量,对趋势不利") - if ema5_cross_ma20: - early_score += 30 - stage_signals.append("EMA排列刚形成(早期)") - else: - # 检查EMA间距 - ema_spread = (ema5 - ema20) / ema20 * 100 - if ema_spread > 3: - late_score += 20 - stage_signals.append(f"EMA间距过大({ema_spread:.1f}%) - 可能过度延伸") - else: - middle_score += 20 - stage_signals.append("EMA排列稳定(中期)") + if structure_1h in {'HH/HL', 'LH/LL'} and structure_4h == structure_1h: + middle_score += 18 + stage_signals.append("1h 与 4h 结构同向") + elif structure_1h in {'HH/HL', 'LH/LL'} and structure_4h and structure_4h != structure_1h: + early_score += 12 + stage_signals.append("1h 先动,4h 尚未完全跟随") - elif ema5 < ema10 < ema20 < ema50: - # 空头排列 - ema5_cross_ma20 = False - if len(df_1h) >= 10: - for i in range(-10, 0): - 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 + if rejection_signal in {'bullish_rejection', 'bearish_rejection'}: + early_score += 10 + stage_signals.append("关键位置出现拒绝信号") - if ema5_cross_ma20: - early_score += 30 - stage_signals.append("EMA排列刚形成(早期)") - else: - ema_spread = (ema20 - ema5) / ema20 * 100 - if ema_spread > 3: - late_score += 20 - stage_signals.append(f"EMA间距过大({ema_spread:.1f}%) - 可能过度延伸") - else: - middle_score += 20 - stage_signals.append("EMA排列稳定(中期)") + if exhaustion_risk in {'upside_climax', 'downside_climax'}: + late_score += 26 + stage_signals.append("单边推进出现高潮风险") + elif exhaustion_risk == 'high_volume_churn': + late_score += 16 + stage_signals.append("高成交换手,趋势延续性存疑") - # ========== 2. RSI 状态 ========== - if 'rsi' in df_1h.columns: - rsi_current = df_1h['rsi'].iloc[-1] - rsi_prev = df_1h['rsi'].iloc[-5:-1].values + if extension_pct <= 0.6: + early_score += 8 + middle_score += 8 + stage_signals.append("价格仍贴近可持续推进区") + elif extension_pct >= 2.5: + late_score += 18 + stage_signals.append(f"价格相对近端结构已延伸 {extension_pct:.1f}%") - # RSI极端区 - 晚期信号 - if rsi_current > 70: - late_score += 25 - stage_signals.append(f"RSI超买({rsi_current:.0f}) - 趋势晚期") - elif rsi_current < 30: - late_score += 25 - stage_signals.append(f"RSI超卖({rsi_current:.0f}) - 趋势晚期") - elif 50 <= rsi_current <= 65: - middle_score += 15 - stage_signals.append(f"RSI健康({rsi_current:.0f}) - 趋势中期") - elif 40 <= rsi_current <= 60: - early_score += 10 - stage_signals.append(f"RSI中性({rsi_current:.0f}) - 可能早期") + if body_ratio >= 0.65 and (close_pos >= 0.78 or close_pos <= 0.22): + early_score += 8 + middle_score += 8 + stage_signals.append("实体推进强,收盘靠近极值") - # RSI趋势检查 - if len(rsi_prev) >= 3: - rsi_trend = "up" if rsi_current > rsi_prev[-1] else "down" if rsi_current < rsi_prev[-1] else "flat" - if rsi_trend == "flat": - late_score += 10 - stage_signals.append("RSI走平 - 动能衰竭") - - # ========== 3. 价格偏离度 ========== - if ema20: - deviation = abs(current_price - ema20) / ema20 * 100 - - if deviation > 5: - late_score += 30 - stage_signals.append(f"价格偏离EMA20 {deviation:.1f}% - 过度延伸") - elif deviation > 3: - late_score += 15 - stage_signals.append(f"价格偏离EMA20 {deviation:.1f}% - 警戒区域") - elif deviation < 1: - if early_score < middle_score: # 只在不是明显早期时加分 - middle_score += 10 - stage_signals.append("价格贴近EMA20 - 趋势稳固") - - # ========== 4. 量价关系 ========== - 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_1h['close'].iloc[-1] - df_1h['close'].iloc[-5]) / df_1h['close'].iloc[-5] * 100 - - # 价格上涨但成交量下降(量价背离)- 晚期信号 - if price_change_5 > 1 and vol_change < -20: - late_score += 20 - stage_signals.append(f"量价背离(涨{price_change_5:.1f}%量减{vol_change:.0f}%)- 可能见顶") - elif price_change_5 < -1 and vol_change < -20: - late_score += 20 - stage_signals.append(f"量价背离(跌{price_change_5:.1f}%量减{vol_change:.0f}%)- 可能见底") - elif price_change_5 > 1 and vol_change > 30: - early_score += 15 - stage_signals.append(f"放量上涨(涨{price_change_5:.1f}%量增{vol_change:.0f}%)- 可能早期") - elif price_change_5 < -1 and vol_change > 30: - early_score += 15 - stage_signals.append(f"放量下跌(跌{price_change_5:.1f}%量增{vol_change:.0f}%)- 可能早期") - - # ========== 5. 波动率状态 ========== - 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: - early_score += 10 - stage_signals.append(f"ATR扩张({atr_change:.0f}%) - 趋势启动") - elif atr_change < -30: - late_score += 10 - stage_signals.append(f"ATR收缩({atr_change:.0f}%) - 动能衰竭") - - # ========== 6. 连续同向K线数量 ========== - 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]) - - if consecutive_up >= 4: - late_score += 15 - stage_signals.append(f"连续{consecutive_up}根阳线 - 可能过度") - elif consecutive_down >= 4: - late_score += 15 - stage_signals.append(f"连续{consecutive_down}根阴线 - 可能过度") - - # ========== 综合判断趋势阶段 ========== scores = { 'early': early_score, 'middle': middle_score, diff --git a/backend/app/crypto_agent/regime_engine.py b/backend/app/crypto_agent/regime_engine.py new file mode 100644 index 0000000..f3ded19 --- /dev/null +++ b/backend/app/crypto_agent/regime_engine.py @@ -0,0 +1,169 @@ +""" +市场状态分类引擎 + +职责: +- 根据量化特征判断当前市场处于哪类 regime +- 输出当前允许的交易行为集合 +- 为 LLM 与执行层提供统一约束 +""" +from typing import Any, Dict, List + + +class RegimeEngine: + """市场状态分类引擎""" + + def classify( + self, + *, + range_metrics: Dict[str, Any] | None, + market_location: Dict[str, Any] | None, + trend_direction: str = "neutral", + trend_strength: str = "weak", + derivatives_state: Dict[str, Any] | None = None, + reversal_detection: Dict[str, Any] | None = None, + trend_stage: Dict[str, Any] | None = None, + ) -> Dict[str, Any]: + range_metrics = range_metrics or {} + market_location = market_location or {} + derivatives_state = derivatives_state or {} + reversal_detection = reversal_detection or {} + trend_stage = trend_stage or {} + + location_tag = str(market_location.get("location_tag") or "unknown") + relative_to_range = str(market_location.get("relative_to_range") or "unknown") + range_regime = str(range_metrics.get("regime") or "unknown") + crowding_regime = str(derivatives_state.get("crowding_regime") or "low") + crowding_bias = str(derivatives_state.get("crowding_bias") or "neutral") + bb_squeeze = bool(range_metrics.get("bb_squeeze")) + no_trade_reasons: List[str] = [] + + profile: Dict[str, Any] = { + "regime_key": "neutral", + "market_state_label": "中性市", + "tradability": "avoid", + "risk_mode": "defensive", + "allowed_lanes": [], + "preferred_lanes": [], + "allowed_setups": [], + "preferred_entry_types": [], + "crowding_bias": crowding_bias, + "crowding_regime": crowding_regime, + "no_trade_reasons": no_trade_reasons, + "summary": "", + } + + if location_tag in {"middle_of_range", "far_from_trade_zone"}: + no_trade_reasons.append(f"位置不佳: {location_tag}") + + if range_regime == "ranging": + profile.update( + { + "regime_key": "range", + "market_state_label": "震荡市", + "tradability": "selective", + "risk_mode": "defensive", + "allowed_lanes": ["short_term"], + "preferred_lanes": ["short_term", "medium_term"], + "allowed_setups": ["range_reversal"], + "preferred_entry_types": ["limit", "market"], + } + ) + if relative_to_range not in {"near_range_support", "near_range_resistance"}: + no_trade_reasons.append("震荡市但不在区间边界") + + elif range_regime == "transitional": + if bb_squeeze and location_tag not in {"far_from_trade_zone", "middle_of_range"}: + profile.update( + { + "regime_key": "breakout_compression", + "market_state_label": "压缩待突破", + "tradability": "selective", + "risk_mode": "defensive", + "allowed_lanes": ["short_term"], + "preferred_lanes": ["short_term", "medium_term"], + "allowed_setups": ["breakout_confirmation", "breakout_pullback"], + "preferred_entry_types": ["market", "limit"], + } + ) + else: + profile.update( + { + "regime_key": "transition", + "market_state_label": "过渡市", + "tradability": "avoid", + "risk_mode": "defensive", + "allowed_lanes": [], + "preferred_lanes": [], + "allowed_setups": [], + "preferred_entry_types": [], + } + ) + no_trade_reasons.append("趋势与震荡切换期,方向优势不足") + + elif range_regime in {"weak_trend", "strong_trend"} and trend_direction in {"uptrend", "downtrend"}: + allow_reversal = bool(reversal_detection.get("is_reversing")) and trend_strength != "strong" + + if crowding_regime == "high": + profile.update( + { + "regime_key": "crowded_trend", + "market_state_label": "拥挤趋势", + "tradability": "selective", + "risk_mode": "defensive", + "allowed_lanes": ["medium_term"], + "preferred_lanes": ["medium_term", "short_term"], + "allowed_setups": ["deep_pullback_continuation"], + "preferred_entry_types": ["limit"], + } + ) + if location_tag not in {"near_long_zone", "near_short_zone"}: + no_trade_reasons.append("趋势拥挤,且不在深回踩/反抽交易区") + else: + profile.update( + { + "regime_key": "trend", + "market_state_label": "趋势市", + "tradability": "tradable", + "risk_mode": "normal" if range_regime == "strong_trend" else "reduced", + "allowed_lanes": ["medium_term", "short_term"], + "preferred_lanes": ["medium_term", "short_term"], + "allowed_setups": ["trend_continuation_pullback"], + "preferred_entry_types": ["limit", "market"], + } + ) + if allow_reversal: + profile["allowed_setups"].append("trend_reversal") + if location_tag in {"far_from_trade_zone", "middle_of_range"}: + profile["tradability"] = "selective" + no_trade_reasons.append("趋势存在,但当前价格没有位置优势") + if str(trend_stage.get("stage") or "") == "late": + profile["tradability"] = "selective" + profile["risk_mode"] = "defensive" + no_trade_reasons.append("趋势晚期,避免追价") + + else: + profile.update( + { + "regime_key": "neutral", + "market_state_label": "中性市", + "tradability": "avoid", + "risk_mode": "defensive", + "allowed_lanes": [], + "preferred_lanes": [], + "allowed_setups": [], + "preferred_entry_types": [], + } + ) + no_trade_reasons.append("无清晰市场结构") + + if no_trade_reasons and profile["tradability"] == "selective": + profile["summary"] = f"{profile['market_state_label']} | 谨慎交易 | {';'.join(no_trade_reasons[:2])}" + elif no_trade_reasons and profile["tradability"] == "avoid": + profile["summary"] = f"{profile['market_state_label']} | 观望优先 | {';'.join(no_trade_reasons[:2])}" + else: + profile["summary"] = ( + f"{profile['market_state_label']} | " + f"允许: {', '.join(profile['allowed_setups']) if profile['allowed_setups'] else '空仓'}" + ) + + return profile diff --git a/backend/app/crypto_agent/setup_policy.py b/backend/app/crypto_agent/setup_policy.py new file mode 100644 index 0000000..67f7114 --- /dev/null +++ b/backend/app/crypto_agent/setup_policy.py @@ -0,0 +1,119 @@ +""" +市场状态到交易行为的硬约束策略 +""" +from typing import Any, Dict, List, Tuple + + +class SetupPolicy: + """交易行为约束策略""" + + RANGE_ONLY_SETUPS = {"range_reversal"} + TREND_ONLY_SETUPS = {"trend_continuation_pullback", "deep_pullback_continuation", "trend_reversal"} + BREAKOUT_ONLY_SETUPS = {"breakout_confirmation", "breakout_pullback"} + + def filter_signals( + self, + signals: List[Dict[str, Any]], + regime_profile: Dict[str, Any], + ) -> Tuple[List[Dict[str, Any]], List[str]]: + allowed_lanes = set(regime_profile.get("allowed_lanes") or []) + allowed_setups = set(regime_profile.get("allowed_setups") or []) + tradability = regime_profile.get("tradability", "avoid") + reasons: List[str] = [] + + if tradability == "avoid" or not allowed_lanes or not allowed_setups: + reasons.extend(regime_profile.get("no_trade_reasons") or ["当前市场状态不允许交易"]) + return [], reasons + + kept: List[Dict[str, Any]] = [] + for signal in signals or []: + lane = signal.get("timeframe") or signal.get("type") or "unknown" + setup_type = self._infer_setup_type(signal) + setup_basis = self._build_setup_basis(signal, setup_type) + entry_basis = self._build_entry_basis(signal, setup_type) + + if lane not in allowed_lanes: + reasons.append(f"{lane} 不在允许交易周期内") + continue + + if setup_type not in allowed_setups: + reasons.append(f"{setup_type} 不在允许 setup 内") + continue + + kept.append({ + **signal, + "setup_type": setup_type, + "setup_basis": setup_basis, + "entry_basis": entry_basis, + }) + + return kept, reasons + + def _infer_setup_type(self, signal: Dict[str, Any]) -> str: + lane = signal.get("timeframe") or signal.get("type") or "medium_term" + action = signal.get("action") + entry_type = signal.get("entry_type", "market") + location_tag = ((signal.get("market_location") or {}).get("location_tag") or "unknown") + regime = signal.get("regime", "") + trend_stage = (signal.get("trend_stage") or {}).get("stage") or "unknown" + volume_context = signal.get("volume_price_context") or {} + breakout_quality = volume_context.get("breakout_quality") or signal.get("breakout_quality") + pullback_quality = volume_context.get("pullback_quality") or signal.get("pullback_quality") + rejection_signal = volume_context.get("rejection_signal") or signal.get("rejection_signal") + volume_price_state = volume_context.get("volume_price_state") or signal.get("volume_price_state") + + if regime == "ranging" or location_tag in {"near_range_support", "near_range_resistance"}: + return "range_reversal" + + if regime == "transitional" and ( + breakout_quality in {"acceptance_breakout_up", "acceptance_breakout_down"} or + volume_price_state in {"bullish_acceptance", "bearish_acceptance"} + ) and entry_type == "market": + return "breakout_confirmation" + if regime == "transitional" and entry_type == "limit": + return "breakout_pullback" + + if lane == "medium_term" and entry_type == "limit" and pullback_quality == "healthy_pullback": + return "trend_continuation_pullback" + if lane == "short_term" and entry_type == "limit" and location_tag in {"near_long_zone", "near_short_zone"} and pullback_quality == "healthy_pullback": + return "deep_pullback_continuation" + if lane == "medium_term" and action in {"buy", "sell"} and ( + rejection_signal in {"bullish_rejection", "bearish_rejection"} or trend_stage == "early" + ): + return "trend_reversal" + + return "unknown" + + def _build_setup_basis(self, signal: Dict[str, Any], setup_type: str) -> str: + market_location = signal.get("market_location") or {} + volume_context = signal.get("volume_price_context") or {} + parts: List[str] = [] + + location_tag = market_location.get("location_tag") + if location_tag and location_tag != "unknown": + parts.append(f"location={location_tag}") + + for key in ("volume_price_state", "breakout_quality", "pullback_quality", "rejection_signal"): + value = volume_context.get(key) or signal.get(key) + if value and value not in {"none", "neutral", "unknown"}: + parts.append(f"{key}={value}") + + if setup_type != "unknown": + parts.insert(0, f"setup={setup_type}") + + return " | ".join(parts[:4]) if parts else f"setup={setup_type}" + + def _build_entry_basis(self, signal: Dict[str, Any], setup_type: str) -> str: + entry_type = signal.get("entry_type", "market") + market_location = signal.get("market_location") or {} + + if setup_type == "breakout_confirmation": + return "breakout_acceptance_follow_through" + if setup_type in {"breakout_pullback", "trend_continuation_pullback", "deep_pullback_continuation"}: + return "pullback_into_trade_zone" if entry_type == "limit" else "pullback_confirmed" + if setup_type == "range_reversal": + location_tag = market_location.get("location_tag", "range_edge") + return f"reversal_from_{location_tag}" + if setup_type == "trend_reversal": + return "rejection_or_structure_shift" + return "generic_entry" diff --git a/backend/app/models/paper_trading.py b/backend/app/models/paper_trading.py index eece0a8..6474969 100644 --- a/backend/app/models/paper_trading.py +++ b/backend/app/models/paper_trading.py @@ -137,4 +137,7 @@ class PaperOrder(Base): 'opened_at': format_time(self.opened_at), 'closed_at': format_time(self.closed_at), 'entry_reasons': self.entry_reasons, + 'setup_type': (self.indicators or {}).get('setup_type'), + 'setup_basis': (self.indicators or {}).get('setup_basis'), + 'entry_basis': (self.indicators or {}).get('entry_basis'), } diff --git a/backend/app/services/paper_trading_service.py b/backend/app/services/paper_trading_service.py index af4c29f..d310829 100644 --- a/backend/app/services/paper_trading_service.py +++ b/backend/app/services/paper_trading_service.py @@ -357,7 +357,13 @@ class PaperTradingService: status=status, opened_at=opened_at, entry_reasons=[signal.get('reason', '')] if signal.get('reason') else signal.get('reasons', []), - indicators=signal.get('indicators', {}) + indicators={ + **(signal.get('indicators', {}) or {}), + 'setup_type': signal.get('setup_type'), + 'setup_basis': signal.get('setup_basis'), + 'entry_basis': signal.get('entry_basis'), + 'volume_price_context': signal.get('volume_price_context'), + } ) db.add(order) diff --git a/backend/tests/test_crypto_agent_platform_halts.py b/backend/tests/test_crypto_agent_platform_halts.py index c06cc4a..575f396 100644 --- a/backend/tests/test_crypto_agent_platform_halts.py +++ b/backend/tests/test_crypto_agent_platform_halts.py @@ -186,7 +186,18 @@ def test_resume_platform_resets_initial_balance_and_clears_halt(): def test_execution_events_are_recorded_and_returned_in_reverse_time_order(): agent = make_agent() - agent._record_execution_event('Bitget', 'open_failed', symbol='ETHUSDT', reason='余额不足', status='error') + agent._record_execution_event( + 'Bitget', + 'open_failed', + symbol='ETHUSDT', + reason='余额不足', + status='error', + decision={ + 'setup_type': 'breakout_confirmation', + 'setup_basis': 'setup=breakout_confirmation | breakout_quality=acceptance_breakout_up', + 'entry_basis': 'breakout_acceptance_follow_through', + }, + ) agent._record_execution_event('PaperTrading', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold') events = agent.get_recent_execution_events(limit=10) @@ -196,6 +207,7 @@ def test_execution_events_are_recorded_and_returned_in_reverse_time_order(): assert events[0]['event_type'] == 'hold' assert events[1]['platform'] == 'Bitget' assert events[1]['reason'] == '余额不足' + assert events[1]['setup_type'] == 'breakout_confirmation' def test_get_status_contains_last_execution_preview(): diff --git a/backend/tests/test_crypto_agent_signal_execution_coordination.py b/backend/tests/test_crypto_agent_signal_execution_coordination.py index a94361f..7a39485 100644 --- a/backend/tests/test_crypto_agent_signal_execution_coordination.py +++ b/backend/tests/test_crypto_agent_signal_execution_coordination.py @@ -99,8 +99,13 @@ def make_agent(): agent.SIGNAL_POSITION_SIZE_DEFAULTS = {} agent.SIGNAL_MARGIN_MULTIPLIERS = {} agent.PLATFORM_RULES = {'Bitget': {'min_margin': {}, 'max_margin_pct': 0.25}} + agent.SIGNAL_EXECUTION_RULES = CryptoAgent.SIGNAL_EXECUTION_RULES + agent.SETUP_EXECUTION_PROFILES = CryptoAgent.SETUP_EXECUTION_PROFILES + agent.symbol_trade_cooldown = {} agent._check_losing_streak = MagicMock(return_value={'should_cool_down': False}) + agent._get_symbol_trade_cooldown = MagicMock(return_value=None) agent._calculate_position_size = MagicMock(return_value=(100.0, 'ok')) + agent._normalize_symbol = lambda symbol: symbol return agent @@ -218,8 +223,140 @@ def test_opposite_position_uses_current_price_to_protect_profitable_medium_term_ decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, []) assert decision['decision'] == 'HOLD' - assert decision['action'] == 'HOLD' - assert '反向持仓盈利' in decision['reason'] + + +def test_regime_profile_avoid_blocks_execution_even_with_valid_signal(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 100.0, + 'stop_loss': 98.0, + 'take_profit': 104.0, + 'confidence': 82, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'regime_profile': { + 'tradability': 'avoid', + 'no_trade_reasons': ['位置不佳: middle_of_range'], + }, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], []) + + assert decision['decision'] == 'HOLD' + assert decision['action'] == 'IGNORE' + assert '市场状态过滤' in decision['reason'] + + +def test_symbol_cooldown_blocks_execution_even_with_valid_signal(): + agent = make_agent() + agent._get_symbol_trade_cooldown = MagicMock(return_value={ + 'should_cool_down': True, + 'reason': 'BTCUSDT 最近连续亏损 2 次,暂停新开仓 2 小时', + 'cooldown_until': None, + }) + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 100.0, + 'stop_loss': 98.0, + 'take_profit': 104.0, + 'confidence': 82, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + + decision = agent.execute_signal_with_rules(signal, 'PaperTrading', account, [], []) + + assert decision['decision'] == 'HOLD' + assert decision['action'] == 'IGNORE' + assert '交易对冷却中' in decision['reason'] + + +def test_breakout_confirmation_requires_market_and_acceptance_context(): + agent = make_agent() + agent._get_symbol_trade_cooldown = MagicMock(return_value=None) + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 100.0, + 'stop_loss': 98.0, + 'take_profit': 104.0, + 'confidence': 84, + 'timeframe': 'short_term', + 'type': 'short_term', + 'setup_type': 'breakout_confirmation', + 'volume_price_context': { + 'breakout_quality': 'weak_breakout_up', + }, + 'position_size': 'light', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], []) + + assert decision['decision'] == 'HOLD' + assert 'setup 过滤' in decision['reason'] + + +def test_trend_continuation_pullback_requires_healthy_pullback_context(): + agent = make_agent() + agent._get_symbol_trade_cooldown = MagicMock(return_value=None) + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 100.0, + 'stop_loss': 97.5, + 'take_profit': 106.0, + 'confidence': 82, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'setup_type': 'trend_continuation_pullback', + 'market_location': {'location_tag': 'near_long_zone'}, + 'volume_price_context': { + 'pullback_quality': 'heavy_sell_pullback', + }, + 'position_size': 'medium', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], []) + + assert decision['decision'] == 'HOLD' + assert 'setup 过滤' in decision['reason'] def test_short_term_super_strong_signal_can_flip_when_opposite_profit_is_small(): @@ -422,6 +559,109 @@ def test_between_trade_zones_short_term_signal_can_replace_pending_order_when_ne assert decision['orders_to_cancel'] == ['old-1'] +def test_range_reversal_will_not_add_when_same_direction_position_exists(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 98.0, + 'current_price': 101.0, + 'stop_loss': 96.0, + 'take_profit': 106.0, + 'confidence': 78, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'setup_type': 'range_reversal', + 'market_location': {'location_tag': 'near_range_support'}, + 'volume_price_context': {'rejection_signal': 'bullish_rejection'}, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + positions = [{ + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 100.0, + 'stop_loss': 98.0, + 'take_profit': 106.0, + 'current_price': 101.0, + }] + account = {'current_total_leverage': 0, 'max_total_leverage': 10, 'available': 1000} + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, []) + + assert decision['decision'] == 'HOLD' + assert '避免同向叠加仓位' in decision['reason'] + + +def test_breakout_confirmation_will_not_replace_existing_same_side_pending_order(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'market', + 'entry_price': 101.0, + 'current_price': 101.0, + 'stop_loss': 99.0, + 'take_profit': 105.0, + 'confidence': 90, + 'timeframe': 'short_term', + 'type': 'short_term', + 'setup_type': 'breakout_confirmation', + 'volume_price_context': {'breakout_quality': 'acceptance_breakout_up'}, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + pending_orders = [{ + 'order_id': 'old-1', + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 99.0, + 'entry_type': 'limit', + 'is_reduce_only': False, + 'created_at': '2026-04-22T10:00:00', + }] + account = {'current_total_leverage': 0, 'max_total_leverage': 10, 'available': 1000} + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders) + + assert decision['decision'] == 'HOLD' + assert '同向挂单已达 setup 上限' in decision['reason'] + + +def test_trend_reversal_waits_instead_of_closing_small_losing_opposite_position(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'market', + 'entry_price': 100.0, + 'current_price': 100.4, + 'stop_loss': 98.0, + 'take_profit': 106.0, + 'confidence': 92, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'setup_type': 'trend_reversal', + 'market_location': {'location_tag': 'near_long_zone'}, + 'volume_price_context': {'rejection_signal': 'bullish_rejection'}, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + positions = [{ + 'symbol': 'BTCUSDT', + 'side': 'sell', + 'entry_price': 100.0, + 'current_price': 100.4, + }] + account = {'current_total_leverage': 0, 'max_total_leverage': 10, 'available': 1000} + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, []) + + assert decision['decision'] == 'HOLD' + assert '反向小亏损仓位保持克制' in decision['reason'] + + def test_runtime_position_state_derives_protection_and_remaining_target(): agent = make_agent() diff --git a/backend/tests/test_market_signal_analyzer_lane_rules.py b/backend/tests/test_market_signal_analyzer_lane_rules.py index 59ba32d..99b011e 100644 --- a/backend/tests/test_market_signal_analyzer_lane_rules.py +++ b/backend/tests/test_market_signal_analyzer_lane_rules.py @@ -214,6 +214,151 @@ def test_market_location_summary_marks_middle_of_range_and_far_from_trade_zone() assert far_location["location_tag"] == "far_from_trade_zone" +def test_serialize_feature_block_includes_volume_price_fields(): + analyzer = make_analyzer() + + block = analyzer._serialize_feature_block( + { + "available": True, + "structure": "HH/HL", + "ema_alignment": "bull", + "momentum_3": 1.2, + "momentum_12": 3.4, + "rsi": 61.5, + "atr_pct": 1.1, + "volume_ratio": 1.35, + "body_ratio": 0.68, + "close_position_in_bar": 0.82, + "upper_wick_ratio": 0.08, + "lower_wick_ratio": 0.1, + "range_expansion_ratio": 1.45, + "pressure_bias": "bullish", + "volume_price_state": "bullish_acceptance", + "breakout_quality": "acceptance_breakout_up", + "pullback_quality": "healthy_pullback", + "rejection_signal": "none", + "exhaustion_risk": "low", + "distance_to_ema20": 2.5, + "distance_to_recent_high": -0.4, + "distance_to_recent_low": 4.2, + "is_accelerating": True, + "adx": 24.8, + "trend_strength_adx": "weak", + } + ) + + assert block["volume_price_state"] == "bullish_acceptance" + assert block["breakout_quality"] == "acceptance_breakout_up" + assert block["pressure_bias"] == "bullish" + assert block["body_ratio"] == 0.68 + assert block["range_expansion_ratio"] == 1.45 + + +def test_feature_engine_detects_bullish_acceptance_breakout(): + analyzer = make_analyzer() + opens = [100 + i * 0.25 for i in range(19)] + [104.8] + closes = [100.2 + i * 0.25 for i in range(19)] + [108.6] + lows = [price - 0.35 for price in opens[:-1]] + [104.6] + highs = [price + 0.45 for price in closes[:-1]] + [108.8] + volumes = [100 + i * 4 for i in range(19)] + [260] + df = pd.DataFrame( + { + "open": opens, + "close": closes, + "low": lows, + "high": highs, + "ema5": [102.5] * 20, + "ema10": [101.8] * 20, + "ema20": [100.8] * 20, + "rsi": [55] * 20, + "atr": [1.0] * 20, + "adx": [28] * 20, + "volume": volumes, + } + ) + + feature = analyzer.feature_engine.summarize_timeframe_features(df, "15m") + + assert feature["volume_price_state"] == "bullish_acceptance" + assert feature["breakout_quality"] == "acceptance_breakout_up" + assert feature["pressure_bias"] == "bullish" + + +def test_quantify_ranging_state_prefers_price_action_compression_and_flip_count(): + analyzer = make_analyzer() + closes = [100.0, 100.4, 100.1, 100.5, 100.2, 100.6, 100.25, 100.55, 100.3, 100.5, + 100.35, 100.45, 100.3, 100.4, 100.32, 100.42, 100.34, 100.41, 100.36, 100.4] + highs = [c + 0.35 for c in closes] + lows = [c - 0.35 for c in closes] + df = pd.DataFrame( + { + "open": [closes[0]] + closes[:-1], + "close": closes, + "high": highs, + "low": lows, + "ema5": [100.35] * 20, + "ema10": [100.33] * 20, + "ema20": [100.31] * 20, + "atr": [0.7] * 20, + "adx": [18] * 20, + "bb_upper": [100.9] * 20, + "bb_lower": [99.9] * 20, + "volume": [100 + (i % 3) * 5 for i in range(20)], + } + ) + + result = analyzer._quantify_ranging_state({"1h": df}) + + assert result["regime"] == "ranging" + assert result["swing_flip_count"] >= 6 + assert result["range_efficiency"] < 0.4 + + +def test_detect_trend_stage_uses_breakout_acceptance_as_early_signal(): + analyzer = make_analyzer() + closes = [100 + i * 0.35 for i in range(23)] + [109.2] + opens = [99.8 + i * 0.35 for i in range(23)] + [105.5] + highs = [c + 0.3 for c in closes[:-1]] + [109.5] + lows = [o - 0.2 for o in opens[:-1]] + [105.2] + volumes = [110 + i * 3 for i in range(23)] + [320] + + df_1h = pd.DataFrame( + { + "open": opens, + "close": closes, + "high": highs, + "low": lows, + "ema5": [105.8] * 24, + "ema10": [104.8] * 24, + "ema20": [103.6] * 24, + "rsi": [58] * 24, + "atr": [1.1] * 24, + "adx": [29] * 24, + "volume": volumes, + } + ) + df_4h = pd.DataFrame( + { + "open": [95 + i * 1.2 for i in range(20)], + "close": [95.5 + i * 1.2 for i in range(20)], + "high": [96 + i * 1.2 for i in range(20)], + "low": [94.6 + i * 1.2 for i in range(20)], + "ema5": [110] * 20, + "ema10": [108.5] * 20, + "ema20": [106] * 20, + "rsi": [60] * 20, + "atr": [2.2] * 20, + "adx": [26] * 20, + "volume": [500 + i * 10 for i in range(20)], + } + ) + + result = analyzer._detect_trend_stage({"1h": df_1h, "4h": df_4h}) + + assert result["stage"] in {"early", "middle"} + assert any("接受" in signal or "先动" in signal for signal in result["signals"]) + + def test_build_analysis_prompt_includes_structured_market_and_derivatives_blocks(): analyzer = make_analyzer() prompt = analyzer._build_analysis_prompt( diff --git a/backend/tests/test_position_sizing_regression.py b/backend/tests/test_position_sizing_regression.py index e2cd1b3..14bd9b4 100644 --- a/backend/tests/test_position_sizing_regression.py +++ b/backend/tests/test_position_sizing_regression.py @@ -148,3 +148,20 @@ def test_paper_dynamic_position_uses_equity_pct_instead_of_margin_multiple(): assert margin == pytest.approx(2400.0) assert position_value == pytest.approx(24000.0) + + +def test_setup_profile_can_cap_margin_budget_more_conservatively(): + module = load_position_sizing_module() + + margin, position_value, _ = module.calculate_margin_and_position_value( + balance=20000, + available_margin=19000, + current_total_leverage=0, + max_total_leverage=10, + order_leverage=10, + target_margin_pct=0.12 * 0.55, + max_margin_pct=0.08, + ) + + assert margin == pytest.approx(1320.0) + assert position_value == pytest.approx(13200.0) diff --git a/backend/tests/test_regime_engine_policy.py b/backend/tests/test_regime_engine_policy.py new file mode 100644 index 0000000..a941025 --- /dev/null +++ b/backend/tests/test_regime_engine_policy.py @@ -0,0 +1,127 @@ +import importlib.util +import sys +import types +from pathlib import Path + + +def load_class(module_rel_path: str, class_name: str): + base = Path(__file__).resolve().parents[1] / "app" / "crypto_agent" + target = base / module_rel_path + + if "app" not in sys.modules: + app_pkg = types.ModuleType("app") + app_pkg.__path__ = [str(base.parents[1] / "app")] + sys.modules["app"] = app_pkg + + if "app.crypto_agent" not in sys.modules: + crypto_pkg = types.ModuleType("app.crypto_agent") + crypto_pkg.__path__ = [str(base)] + sys.modules["app.crypto_agent"] = crypto_pkg + + module_name = f"app.crypto_agent.{target.stem}_test" + spec = importlib.util.spec_from_file_location(module_name, target) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return getattr(module, class_name) + + +def test_regime_engine_blocks_middle_of_range_in_ranging_market(): + RegimeEngine = load_class("regime_engine.py", "RegimeEngine") + engine = RegimeEngine() + + profile = engine.classify( + range_metrics={"regime": "ranging", "bb_squeeze": False}, + market_location={"location_tag": "middle_of_range", "relative_to_range": "middle_of_range"}, + trend_direction="neutral", + trend_strength="weak", + derivatives_state={"crowding_regime": "low", "crowding_bias": "neutral"}, + reversal_detection={}, + trend_stage={}, + ) + + assert profile["regime_key"] == "range" + assert profile["tradability"] == "selective" + assert "range_reversal" in profile["allowed_setups"] + assert any("区间边界" in reason or "位置不佳" in reason for reason in profile["no_trade_reasons"]) + + +def test_setup_policy_allows_only_short_term_range_reversal_in_range_market(): + SetupPolicy = load_class("setup_policy.py", "SetupPolicy") + policy = SetupPolicy() + + profile = { + "tradability": "selective", + "allowed_lanes": ["short_term"], + "allowed_setups": ["range_reversal"], + } + signals = [ + { + "timeframe": "medium_term", + "type": "medium_term", + "action": "sell", + "entry_type": "limit", + "regime": "ranging", + "market_location": {"location_tag": "near_range_resistance"}, + }, + { + "timeframe": "short_term", + "type": "short_term", + "action": "sell", + "entry_type": "limit", + "regime": "ranging", + "market_location": {"location_tag": "near_range_resistance"}, + }, + ] + + filtered, reasons = policy.filter_signals(signals, profile) + + assert len(filtered) == 1 + assert filtered[0]["timeframe"] == "short_term" + assert filtered[0]["setup_type"] == "range_reversal" + assert reasons + + +def test_setup_policy_uses_volume_price_context_for_breakout_and_pullback_setups(): + SetupPolicy = load_class("setup_policy.py", "SetupPolicy") + policy = SetupPolicy() + + profile = { + "tradability": "selective", + "allowed_lanes": ["short_term", "medium_term"], + "allowed_setups": ["breakout_confirmation", "trend_continuation_pullback"], + } + signals = [ + { + "timeframe": "short_term", + "type": "short_term", + "action": "buy", + "entry_type": "market", + "regime": "transitional", + "market_location": {"location_tag": "near_long_zone"}, + "volume_price_context": { + "breakout_quality": "acceptance_breakout_up", + "volume_price_state": "bullish_acceptance", + }, + }, + { + "timeframe": "medium_term", + "type": "medium_term", + "action": "buy", + "entry_type": "limit", + "regime": "weak_trend", + "market_location": {"location_tag": "near_long_zone"}, + "volume_price_context": { + "pullback_quality": "healthy_pullback", + }, + }, + ] + + filtered, reasons = policy.filter_signals(signals, profile) + + assert len(filtered) == 2 + assert filtered[0]["setup_type"] == "breakout_confirmation" + assert filtered[1]["setup_type"] == "trend_continuation_pullback" + assert filtered[0]["entry_basis"] == "breakout_acceptance_follow_through" + assert "setup=breakout_confirmation" in filtered[0]["setup_basis"] + assert not reasons diff --git a/backend/tests/test_system_console_snapshot.py b/backend/tests/test_system_console_snapshot.py new file mode 100644 index 0000000..b40816c --- /dev/null +++ b/backend/tests/test_system_console_snapshot.py @@ -0,0 +1,116 @@ +import importlib.util +import sys +import types +from pathlib import Path + + +def load_system_module(): + system_path = Path(__file__).resolve().parents[1] / "app" / "api" / "system.py" + + if "app" not in sys.modules: + app_pkg = types.ModuleType("app") + app_pkg.__path__ = [str(system_path.parents[2] / "app")] + sys.modules["app"] = app_pkg + + for pkg_name, pkg_path in [ + ("app.api", system_path.parent), + ("app.utils", system_path.parents[1] / "utils"), + ("app.crypto_agent", system_path.parents[1] / "crypto_agent"), + ("app.services", system_path.parents[1] / "services"), + ]: + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [str(pkg_path)] + sys.modules[pkg_name] = pkg + + fastapi_module = types.ModuleType("fastapi") + + class DummyRouter: + def get(self, *args, **kwargs): + def decorator(fn): + return fn + return decorator + + class DummyHTTPException(Exception): + pass + + fastapi_module.APIRouter = lambda: DummyRouter() + fastapi_module.HTTPException = DummyHTTPException + sys.modules["fastapi"] = fastapi_module + + logger_module = types.ModuleType("app.utils.logger") + logger_module.logger = types.SimpleNamespace(error=lambda *a, **k: None, warning=lambda *a, **k: None) + sys.modules["app.utils.logger"] = logger_module + + system_status_module = types.ModuleType("app.utils.system_status") + system_status_module.get_system_monitor = lambda: None + sys.modules["app.utils.system_status"] = system_status_module + + crypto_agent_module = types.ModuleType("app.crypto_agent.crypto_agent") + crypto_agent_module.get_crypto_agent = lambda: None + sys.modules["app.crypto_agent.crypto_agent"] = crypto_agent_module + + signal_db_module = types.ModuleType("app.services.signal_database_service") + signal_db_module.get_signal_db_service = lambda: None + sys.modules["app.services.signal_database_service"] = signal_db_module + + paper_module = types.ModuleType("app.services.paper_trading_service") + paper_module.get_paper_trading_service = lambda: None + sys.modules["app.services.paper_trading_service"] = paper_module + + bitget_module = types.ModuleType("app.services.bitget_live_trading_service") + bitget_module.get_all_bitget_live_services = lambda: {} + bitget_module.get_bitget_live_service = lambda: None + sys.modules["app.services.bitget_live_trading_service"] = bitget_module + + module_name = "app.api.system_console_test" + spec = importlib.util.spec_from_file_location(module_name, system_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def test_normalize_platform_position_preserves_setup_fields(): + module = load_system_module() + item = module._normalize_platform_position( + "paper", + { + "symbol": "BTCUSDT", + "side": "buy", + "entry_price": 100.0, + "mark_price": 101.0, + "size": 1.2, + "take_profit": 106.0, + "stop_loss": 98.0, + "setup_type": "trend_continuation_pullback", + "setup_basis": "setup=trend_continuation_pullback | pullback_quality=healthy_pullback", + "entry_basis": "pullback_into_trade_zone", + }, + ) + + assert item["setup_type"] == "trend_continuation_pullback" + assert "healthy_pullback" in item["setup_basis"] + assert item["entry_basis"] == "pullback_into_trade_zone" + + +def test_normalize_platform_order_preserves_setup_fields(): + module = load_system_module() + item = module._normalize_platform_order( + "bitget", + { + "symbol": "ETHUSDT", + "side": "sell", + "price": 2000.0, + "size": 3, + "status": "pending", + "entry_type": "limit", + "setup_type": "range_reversal", + "setup_basis": "setup=range_reversal | location=near_range_resistance", + "entry_basis": "reversal_from_near_range_resistance", + }, + ) + + assert item["setup_type"] == "range_reversal" + assert "near_range_resistance" in item["setup_basis"] + assert item["entry_basis"] == "reversal_from_near_range_resistance" diff --git a/frontend/console.html b/frontend/console.html index 146409f..548fb08 100644 --- a/frontend/console.html +++ b/frontend/console.html @@ -2705,6 +2705,22 @@
模拟盘${paper.label}
Bitget${bitget.label}
+ ${(preview.paper?.setup_type || preview.bitget?.setup_type) + ? ` +
+ ${preview.paper?.setup_type ? `Paper ${preview.paper.setup_type}` : ''} + ${preview.bitget?.setup_type ? `Bitget ${preview.bitget.setup_type}` : ''} +
+ ` + : ''} + ${(preview.paper?.entry_basis || preview.paper?.setup_basis || preview.bitget?.entry_basis || preview.bitget?.setup_basis) + ? ` +
+ ${preview.paper?.entry_basis || preview.paper?.setup_basis ? `
模拟盘: ${preview.paper.entry_basis || preview.paper.setup_basis}
` : ''} + ${preview.bitget?.entry_basis || preview.bitget?.setup_basis ? `
Bitget: ${preview.bitget.entry_basis || preview.bitget.setup_basis}
` : ''} +
+ ` + : ''}
模拟盘: ${paper.detail}
@@ -2794,8 +2810,15 @@ ${event.decision ? `${event.decision}` : ''} ${event.action ? `${event.action}` : ''} ${event.signal_timeframe_text ? `${event.signal_timeframe_text}` : ''} + ${event.setup_type ? `${event.setup_type}` : ''} ${event.reason || '无说明'} + ${event.setup_basis || event.entry_basis ? ` +
+ ${event.setup_basis ? `
Setup: ${event.setup_basis}
` : ''} + ${event.entry_basis ? `
Entry: ${event.entry_basis}
` : ''} +
+ ` : ''} ${event.event_type === 'execution_blocked_summary' && Array.isArray(event.blocked_platforms) && event.blocked_platforms.length > 0 ? `
${event.blocked_platforms.map((item) => ` @@ -2860,6 +2883,7 @@ 方向 入场 / 现价 仓位 / 杠杆 + Setup 止盈 / 止损 未实现盈亏 盈亏比例 @@ -2875,6 +2899,7 @@ ${item.side === 'long' ? 'long' : 'short'} ${formatMoney(item.entry_price)} / ${formatMoney(item.mark_price)} ${formatNumber(item.size, 4)} / ${formatNumber(item.leverage, 1)}x + ${item.setup_type ? `
${item.setup_type}
${item.entry_basis || item.setup_basis || '-'}
` : '-'} ${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'} ${formatMoney(item.unrealized_pnl)} ${formatPercent(item.pnl_percent, 2)} @@ -2915,6 +2940,7 @@ 价格 数量 / 杠杆 信号 + Setup 时间 @@ -2929,6 +2955,7 @@ ${formatMoney(item.price)} ${formatNumber(item.size, 4)} / ${item.leverage ? `${formatNumber(item.leverage, 1)}x` : '-'} ${item.signal_grade || '-'} ${item.signal_type || ''} ${item.confidence ? `/ ${formatPercent(item.confidence, 1)}` : ''} + ${item.setup_type ? `
${item.setup_type}
${item.entry_basis || item.setup_basis || '-'}
` : '-'} ${item.created_at ? relativeTime(item.created_at) : '-'} `).join('')}