This commit is contained in:
aaron 2026-04-25 14:53:05 +08:00
parent 8320cb0d69
commit 80f5e65d1d
16 changed files with 1990 additions and 458 deletions

View File

@ -91,6 +91,9 @@ def _normalize_platform_position(platform: str, position: Dict[str, Any]) -> Dic
"take_profit": position.get("take_profit"), "take_profit": position.get("take_profit"),
"stop_loss": position.get("stop_loss"), "stop_loss": position.get("stop_loss"),
"liquidation_price": position.get("liquidation_price"), "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"), "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"), "take_profit": order.get("take_profit"),
"signal_grade": order.get("signal_grade"), "signal_grade": order.get("signal_grade"),
"signal_type": order.get("signal_type"), "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")), "confidence": _safe_float(order.get("confidence")),
"created_at": order.get("created_at") or order.get("timestamp"), "created_at": order.get("created_at") or order.get("timestamp"),
} }

View File

@ -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_RETRY_ALERT_THRESHOLD = 3
TP_SL_MAX_RETRY_BEFORE_ERROR = 6 TP_SL_MAX_RETRY_BEFORE_ERROR = 6
TP_SL_ALERT_COOLDOWN_MINUTES = 15 TP_SL_ALERT_COOLDOWN_MINUTES = 15
@ -196,6 +259,7 @@ class CryptoAgent:
self.last_signals: Dict[str, Dict[str, Any]] = {} self.last_signals: Dict[str, Dict[str, Any]] = {}
self.last_execution_preview: Dict[str, Dict[str, Any]] = {} self.last_execution_preview: Dict[str, Dict[str, Any]] = {}
self.signal_cooldown: Dict[str, datetime] = {} self.signal_cooldown: Dict[str, datetime] = {}
self.symbol_trade_cooldown: Dict[str, Dict[str, Any]] = {}
# 账户初始余额持久化(用于计算回撤) # 账户初始余额持久化(用于计算回撤)
self._initial_balances: Dict[str, float] = {} self._initial_balances: Dict[str, float] = {}
@ -288,6 +352,9 @@ class CryptoAgent:
"symbol": symbol or (decision or {}).get("symbol", ""), "symbol": symbol or (decision or {}).get("symbol", ""),
"decision": (decision or {}).get("decision"), "decision": (decision or {}).get("decision"),
"action": (decision or {}).get("action"), "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", ""), "reason": reason or (decision or {}).get("reason") or (decision or {}).get("reasoning", ""),
} }
if extra: if extra:
@ -1234,6 +1301,25 @@ class CryptoAgent:
price_change_24h = self._calculate_price_change(data['1h']) price_change_24h = self._calculate_price_change(data['1h'])
logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})") 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 调用) # 1.5. 波动率检查(节省 LLM 调用)
should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data) should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data)
if not should_analyze: if not should_analyze:
@ -1299,6 +1385,8 @@ class CryptoAgent:
'current_price': current_price 'current_price': current_price
} }
regime_profile = market_signal.get('regime_profile') or {}
# 过滤掉 wait 信号,只保留 buy/sell 信号 # 过滤掉 wait 信号,只保留 buy/sell 信号
signals = market_signal.get('signals', []) signals = market_signal.get('signals', [])
trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']] trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']]
@ -1314,6 +1402,10 @@ class CryptoAgent:
detail="完成分析,无交易信号", detail="完成分析,无交易信号",
extra={"trade_signals": 0, "valid_signals": 0}, extra={"trade_signals": 0, "valid_signals": 0},
) )
blocked_reasons = market_signal.get('blocked_reasons') or []
if blocked_reasons:
logger.info(f"\n⏸️ 结论: 当前市场状态不允许交易 | {''.join(blocked_reasons[:2])}")
else:
logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望") logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望")
return return
@ -1356,7 +1448,13 @@ class CryptoAgent:
if self.settings.paper_trading_enabled: if self.settings.paper_trading_enabled:
logger.info(f"\n📊 【模拟盘】") logger.info(f"\n📊 【模拟盘】")
paper_positions, paper_account, paper_pending = self._get_paper_trading_state() 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: if paper_signal:
logger.info( logger.info(
f" 采用信号: {paper_signal.get('timeframe', 'unknown')} | " f" 采用信号: {paper_signal.get('timeframe', 'unknown')} | "
@ -1383,7 +1481,8 @@ class CryptoAgent:
valid_signals, valid_signals,
'Bitget', 'Bitget',
market_state=market_signal.get('market_state', '中性'), 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(): for account_id in self._iter_bitget_accounts():
logger.info(f"\n🔥 【Bitget:{account_id}") logger.info(f"\n🔥 【Bitget:{account_id}")
@ -1698,6 +1797,13 @@ class CryptoAgent:
fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}')) fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}'))
return fallback 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], async def _execute_decisions(self, paper_decision: Dict[str, Any],
bitget_decisions: Dict[str, Dict[str, Any]], bitget_decisions: Dict[str, Dict[str, Any]],
market_signal: Dict[str, Any], current_price: float): 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]], def _select_signal_for_platform(self, signals: List[Dict[str, Any]],
platform_name: str, platform_name: str,
market_state: 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: if not signals:
return None 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),优先选择日内反转信号 # 震荡市:趋势信号降权(信心 × 0.8),优先选择日内反转信号
adjusted_signals = [] adjusted_signals = []
for signal in signals: for signal in signals:
@ -1995,6 +2112,15 @@ class CryptoAgent:
pass # 趋势市不降权日内信号 pass # 趋势市不降权日内信号
adjusted_signals.append(s) 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 # 逆势信号大幅降权(安全网,主过滤在 _merge_lane_results
if trend_direction in ('uptrend', 'downtrend'): if trend_direction in ('uptrend', 'downtrend'):
forbidden = 'sell' if trend_direction == 'uptrend' else 'buy' forbidden = 'sell' if trend_direction == 'uptrend' else 'buy'
@ -2005,7 +2131,6 @@ class CryptoAgent:
s['_trend_penalized'] = True s['_trend_penalized'] = True
s['_original_confidence'] = original 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]]] = {} by_lane: Dict[str, List[Dict[str, Any]]] = {}
for signal in adjusted_signals: for signal in adjusted_signals:
lane = signal.get('timeframe') or signal.get('type') or 'unknown' lane = signal.get('timeframe') or signal.get('type') or 'unknown'
@ -2043,10 +2168,15 @@ class CryptoAgent:
'reasoning': signal.get('reasoning', ''), 'reasoning': signal.get('reasoning', ''),
'timeframe': signal_type, 'timeframe': signal_type,
'type': 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, 'position_size': position_size,
'current_price': current_price, 'current_price': current_price,
'market_state': market_signal.get('market_state', '中性') if market_signal else '中性', 'market_state': market_signal.get('market_state', '中性') if market_signal else '中性',
'regime': range_metrics.get('regime', ''), 'regime': range_metrics.get('regime', ''),
'regime_profile': market_signal.get('regime_profile', {}) if market_signal else {},
'range_metrics': range_metrics, 'range_metrics': range_metrics,
'market_location': market_location, 'market_location': market_location,
'funding_rate_data': funding_rate_data, '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]: 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' 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]]: 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')] return [order for order in pending_orders if not order.get('is_reduce_only')]
@ -2209,6 +2386,13 @@ class CryptoAgent:
if entry_type != 'limit': if entry_type != 'limit':
return False, "仅 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) signal_price = float(signal.get('entry_price', 0) or 0)
order_price = float(order.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) 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 = best_signal.get('entry_type', 'market')
entry_type_text = '现价入场' if entry_type == 'market' else '挂单等待' entry_type_text = '现价入场' if entry_type == 'market' else '挂单等待'
entry_type_icon = '' 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 保持一致 # 等级(基于信心度映射)- 与 market_signal_analyzer.py 保持一致
# A级(80-100): 量价配合 + 多指标共振 + 多周期确认 # A级(80-100): 量价配合 + 多指标共振 + 多周期确认
@ -2372,6 +2559,13 @@ class CryptoAgent:
f"{entry_type_icon} **入场**: {entry_type_text} | {position_icon} **仓位**: {position_text}", f"{entry_type_icon} **入场**: {entry_type_text} | {position_icon} **仓位**: {position_text}",
f"", 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': 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' 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_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe) 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=市价单或未知 # resting=真的在挂单中, filled=已立即成交, None=市价单或未知
@ -2560,10 +2757,20 @@ class CryptoAgent:
f"{entry_type_icon} **入场方式**: {entry_type_text}", f"{entry_type_icon} **入场方式**: {entry_type_text}",
f"{position_display.replace(' ', ': **')} | 📈 信心度: **{confidence}%**", f"{position_display.replace(' ', ': **')} | 📈 信心度: **{confidence}%**",
f"", 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"💰 **名义仓位**: {position_value_display}",
f"🪙 **保证金 / 杠杆**: ${margin:,.2f} / {leverage}x" if isinstance(margin, (int, float)) and isinstance(leverage, (int, float)) else f"🪙 **保证金**: ${margin:,.2f}", f"🪙 **保证金 / 杠杆**: ${margin:,.2f} / {leverage}x" if isinstance(margin, (int, float)) and isinstance(leverage, (int, float)) else f"🪙 **保证金**: ${margin:,.2f}",
price_display, price_display,
] ])
content_parts = [part for part in content_parts if part is not None]
if isinstance(contracts, (int, float)) and contracts: if isinstance(contracts, (int, float)) and contracts:
content_parts.append(f"📦 **合约张数**: {contracts}") content_parts.append(f"📦 **合约张数**: {contracts}")
@ -3214,6 +3421,7 @@ class CryptoAgent:
max_leverage = account.get('max_total_leverage', 10) max_leverage = account.get('max_total_leverage', 10)
order_leverage = account.get('order_leverage', 10) order_leverage = account.get('order_leverage', 10)
min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0) 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( target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct(
position_size=position_size, position_size=position_size,
@ -3224,6 +3432,10 @@ class CryptoAgent:
default_positions=self.SIGNAL_POSITION_SIZE_DEFAULTS, 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 = signal.get('regime', '')
REGIME_MARGIN_MULTIPLIERS = { REGIME_MARGIN_MULTIPLIERS = {
@ -3243,6 +3455,9 @@ class CryptoAgent:
logger.info(f" 📊 连败降温: 仓位系数={streak_multiplier}") logger.info(f" 📊 连败降温: 仓位系数={streak_multiplier}")
target_margin_pct *= 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( margin, _, budget_reason = calculate_margin_and_position_value(
balance=balance, balance=balance,
available_margin=available, available_margin=available,
@ -3250,7 +3465,7 @@ class CryptoAgent:
max_total_leverage=max_leverage, max_total_leverage=max_leverage,
order_leverage=order_leverage, order_leverage=order_leverage,
target_margin_pct=target_margin_pct, target_margin_pct=target_margin_pct,
max_margin_pct=max_margin_pct, max_margin_pct=effective_max_margin_pct,
min_margin=min_margin, min_margin=min_margin,
min_effective_leverage=min_effective_leverage, min_effective_leverage=min_effective_leverage,
) )
@ -3259,7 +3474,7 @@ class CryptoAgent:
return 0, budget_reason return 0, budget_reason
return margin, ( 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"最小有效杠杆 {min_effective_leverage:.1f}x | "
f"限制后保证金 ${margin:.2f} ({budget_reason})" f"限制后保证金 ${margin:.2f} ({budget_reason})"
) )
@ -3278,6 +3493,10 @@ class CryptoAgent:
signal_price = signal.get('entry_price', 0) signal_price = signal.get('entry_price', 0)
entry_type = signal.get('entry_type', 'market') entry_type = signal.get('entry_type', 'market')
rule = self._get_signal_execution_rule(signal) 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 same_positions = [p for p in positions
@ -3299,8 +3518,18 @@ class CryptoAgent:
if position_protected: if position_protected:
return "HOLD", "同向持仓已进入保本/保护态,不再主动加仓" 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: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓 # 规则1: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓
if better_price and price_diff_pct >= rule['min_add_price_gap_pct'] and pnl_pct >= rule['min_add_profit_pct']: 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}%" return "ADD", f"加仓:价格差{price_diff_pct:.1f}%,盈利{pnl_pct:.1f}%"
# 规则2: 价格距离 < 2% → 忽略 # 规则2: 价格距离 < 2% → 忽略
@ -3332,6 +3561,9 @@ class CryptoAgent:
(signal_side == 'sell' and signal_price > order_price) (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% → 忽略 # 规则5: 价格距离 < 2% → 忽略
if price_diff_pct < 2: if price_diff_pct < 2:
return "IGNORE", f"同向挂单价格差{price_diff_pct:.1f}% < 2%,忽略" return "IGNORE", f"同向挂单价格差{price_diff_pct:.1f}% < 2%,忽略"
@ -3342,10 +3574,10 @@ class CryptoAgent:
return "REPLACE_PENDING", f"同向挂单存在,{replace_reason}" return "REPLACE_PENDING", f"同向挂单存在,{replace_reason}"
# 规则7: 价格距离 >= 2% 且挂单 < 3 → 可再挂一单 # 规则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%,可开新单" return "OPEN", f"同向挂单价格差{price_diff_pct:.1f}% >= 2%,可开新单"
else: else:
return "IGNORE", "同向挂单已达3个,忽略" return "IGNORE", f"同向挂单已达 setup 上限 {max_same_side_pending} 个,忽略"
# 无同向订单 → 正常开仓 # 无同向订单 → 正常开仓
return "OPEN", "无同向订单,正常开仓" return "OPEN", "无同向订单,正常开仓"
@ -3364,6 +3596,8 @@ class CryptoAgent:
opposite_side = 'sell' if signal_side == 'buy' else 'buy' opposite_side = 'sell' if signal_side == 'buy' else 'buy'
confidence = signal.get('confidence', 0) confidence = signal.get('confidence', 0)
rule = self._get_signal_execution_rule(signal) 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 opposite_positions = [p for p in positions
@ -3387,6 +3621,8 @@ class CryptoAgent:
# 规则4: 小亏损 → 平仓 # 规则4: 小亏损 → 平仓
if -1 < pnl_pct < 0: 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}%,平仓" 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': ''} 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], def _check_risk_control(self, signal: Dict[str, Any],
platform_name: str, platform_name: str,
account: Dict[str, Any], account: Dict[str, Any],
@ -3459,6 +3779,22 @@ class CryptoAgent:
(passed, reason) - 是否通过和原因 (passed, reason) - 是否通过和原因
""" """
# 1. 杠杆限制检查 # 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) current_leverage = account.get('current_total_leverage', 0)
max_leverage = account.get('max_total_leverage', 10) max_leverage = account.get('max_total_leverage', 10)
remaining_leverage = max_leverage - current_leverage remaining_leverage = max_leverage - current_leverage

View File

@ -75,6 +75,10 @@ class PaperTradingExecutor(BaseExecutor):
'signal_type': signal_type, 'signal_type': signal_type,
'type': raw_signal_type, 'type': raw_signal_type,
'reason': decision.get('reasoning', decision.get('reason', '')), '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'),
} }
# 执行下单(统一调用方式) # 执行下单(统一调用方式)

View File

@ -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'])

View File

@ -22,6 +22,9 @@ from app.utils.logger import logger
from app.services.llm_service import llm_service from app.services.llm_service import llm_service
from app.services.news_service import get_news_service from app.services.news_service import get_news_service
from app.services.bitget_service import bitget_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: class MarketSignalAnalyzer:
@ -57,7 +60,7 @@ class MarketSignalAnalyzer:
1. 先判断日内 regimetrending / ranging / neutral 1. 先判断日内 regimetrending / ranging / neutral
2. 趋势日内只做顺势回调或突破后的回踩确认不追涨杀跌 2. 趋势日内只做顺势回调或突破后的回踩确认不追涨杀跌
3. 震荡日内只做区间边界附近的反转不在区间中部开仓 3. 震荡日内只做区间边界附近的反转不在区间中部开仓
4. 技术指标只做辅助优先看结构关键位波动率量能VWAP 偏离和位置优势 4. 技术指标只做辅助优先看价格结构供需区量价是否匹配VWAP 偏离和位置优势
5. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通支撑阻力只作补充 5. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通支撑阻力只作补充
6. 没有清晰止损止盈和盈亏比就不交易 6. 没有清晰止损止盈和盈亏比就不交易
7. 本次分析独立进行不参考任何上一轮信号 7. 本次分析独立进行不参考任何上一轮信号
@ -84,8 +87,9 @@ class MarketSignalAnalyzer:
9. 止损止盈距离下限 9. 止损止盈距离下限
- short_term 止损距离至少 0.7% - short_term 止损距离至少 0.7%
- short_term 止盈距离至少 1.2% - short_term 止盈距离至少 1.2%
10. reasoning 必须覆盖四点中的至少三点结构位置量价/波动衍生品拥挤度 10. reasoning 必须覆盖四点中的至少三点结构位置量价关系衍生品拥挤度
11. 如果数据明确显示 `market_location=middle_of_range` `far_from_trade_zone`必须返回空信号 11. 如果数据明确显示 `market_location=middle_of_range` `far_from_trade_zone`必须返回空信号
12. 如果突破没有得到量价确认或回调不是缩量回调必须显著降低做单积极性必要时直接返回空信号
输出 JSON禁止输出解释性正文 输出 JSON禁止输出解释性正文
```json ```json
@ -132,7 +136,7 @@ class MarketSignalAnalyzer:
- 趋势延续4h/1d 趋势明确1h 回踩关键位后确认继续 - 趋势延续4h/1d 趋势明确1h 回踩关键位后确认继续
- 趋势反转4h/1d 结构和 1h 动能同时改善且反转证据充分 - 趋势反转4h/1d 结构和 1h 动能同时改善且反转证据充分
3. 禁止仅凭 15m 噪音逆 4h 开仓 3. 禁止仅凭 15m 噪音逆 4h 开仓
4. 趋势晚期资金费率过热价格过度偏离关键均线或衍生品顺向拥挤时要显著降低开仓积极性 4. 趋势晚期资金费率过热价格过度偏离供需区量价失配或衍生品顺向拥挤时要显著降低开仓积极性
5. 没有清晰位置优势就不交易 5. 没有清晰位置优势就不交易
6. 本次分析独立进行不参考任何上一轮信号 6. 本次分析独立进行不参考任何上一轮信号
7. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通关键位只作补充 7. 优先使用优先支撑 / 优先阻力可交易多头区 / 可交易空头区普通关键位只作补充
@ -154,8 +158,9 @@ class MarketSignalAnalyzer:
9. 止损止盈距离下限 9. 止损止盈距离下限
- medium_term 止损距离至少 1.5% - medium_term 止损距离至少 1.5%
- medium_term 止盈距离至少 3.0% - medium_term 止盈距离至少 3.0%
10. reasoning 必须明确大级别方向1h 入场节奏位置优势拥挤度风险 10. reasoning 必须明确大级别方向1h 入场节奏位置优势量价关系/拥挤度风险
11. 如果价格已经远离优先交易区或趋势方向虽对但没有回踩/反抽确认必须返回空信号 11. 如果价格已经远离优先交易区或趋势方向虽对但没有回踩/反抽确认必须返回空信号
12. 趋势延续单必须尽量体现推进放量回调缩量关键位再接受中的至少两项否则优先空仓
输出 JSON禁止输出解释性正文 输出 JSON禁止输出解释性正文
```json ```json
@ -195,6 +200,9 @@ class MarketSignalAnalyzer:
def __init__(self): def __init__(self):
self.news_service = get_news_service() self.news_service = get_news_service()
self.exchange = bitget_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], async def analyze(self, symbol: str, data: Dict[str, Any],
symbols: List[str] = None, symbols: List[str] = None,
@ -305,6 +313,13 @@ class MarketSignalAnalyzer:
'open_interest': futures_market_data.get('open_interest'), '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 return result
except Exception as e: except Exception as e:
@ -318,15 +333,15 @@ class MarketSignalAnalyzer:
"""准备市场上下文信息""" """准备市场上下文信息"""
current_price = float(data['5m'].iloc[-1]['close']) current_price = float(data['5m'].iloc[-1]['close'])
price_change_24h = self._calculate_price_change_24h(data['1h']) price_change_24h = self._calculate_price_change_24h(data['1h'])
day_open = self._get_session_open(data.get('1h')) day_open = self.feature_engine.get_session_open(data.get('1h'))
session_vwap = self._calculate_session_vwap(data.get('5m')) session_vwap = self.feature_engine.calculate_session_vwap(data.get('5m'))
opening_range = self._calculate_opening_range(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_5m = self.feature_engine.summarize_timeframe_features(data.get('5m'), '5m')
feature_15m = self._summarize_timeframe_features(data.get('15m'), '15m') feature_15m = self.feature_engine.summarize_timeframe_features(data.get('15m'), '15m')
feature_1h = self._summarize_timeframe_features(data.get('1h'), '1h') feature_1h = self.feature_engine.summarize_timeframe_features(data.get('1h'), '1h')
feature_4h = self._summarize_timeframe_features(data.get('4h'), '4h') feature_4h = self.feature_engine.summarize_timeframe_features(data.get('4h'), '4h')
feature_1d = self._summarize_timeframe_features(data.get('1d'), '1d') feature_1d = self.feature_engine.summarize_timeframe_features(data.get('1d'), '1d')
intraday_alignment = self._describe_alignment([feature_5m, feature_15m]) intraday_alignment = self._describe_alignment([feature_5m, feature_15m])
trend_alignment = self._describe_alignment([feature_1h, feature_4h, feature_1d]) trend_alignment = self._describe_alignment([feature_1h, feature_4h, feature_1d])
@ -492,151 +507,10 @@ class MarketSignalAnalyzer:
'market_location': market_location, 'market_location': market_location,
'intraday_structured': intraday_structured, 'intraday_structured': intraday_structured,
'trend_structured': trend_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: def _format_feature_line(self, feature: Dict[str, Any]) -> str:
"""格式化单周期特征摘要""" """格式化单周期特征摘要"""
if not feature.get('available'): if not feature.get('available'):
@ -655,6 +529,11 @@ class MarketSignalAnalyzer:
f"3bar={fmt(feature['momentum_3'])} | 12bar={fmt(feature['momentum_12'])} | " f"3bar={fmt(feature['momentum_3'])} | 12bar={fmt(feature['momentum_12'])} | "
f"RSI={safe(feature['rsi'], 1)} | ATR={safe(feature['atr_pct'], 2)}% | " f"RSI={safe(feature['rsi'], 1)} | ATR={safe(feature['atr_pct'], 2)}% | "
f"量比={safe(feature['volume_ratio'], 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"距EMA20={fmt(feature['distance_to_ema20'])} | "
f"距20bar高点={fmt(feature['distance_to_recent_high'])} | " f"距20bar高点={fmt(feature['distance_to_recent_high'])} | "
f"距20bar低点={fmt(feature['distance_to_recent_low'])} | " f"距20bar低点={fmt(feature['distance_to_recent_low'])} | "
@ -1288,6 +1167,17 @@ class MarketSignalAnalyzer:
'rsi': rounded(feature.get('rsi'), 1), 'rsi': rounded(feature.get('rsi'), 1),
'atr_pct': rounded(feature.get('atr_pct')), 'atr_pct': rounded(feature.get('atr_pct')),
'volume_ratio': rounded(feature.get('volume_ratio')), '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_ema20_pct': rounded(feature.get('distance_to_ema20')),
'distance_to_recent_high_pct': rounded(feature.get('distance_to_recent_high')), 'distance_to_recent_high_pct': rounded(feature.get('distance_to_recent_high')),
'distance_to_recent_low_pct': rounded(feature.get('distance_to_recent_low')), 'distance_to_recent_low_pct': rounded(feature.get('distance_to_recent_low')),
@ -1400,65 +1290,6 @@ class MarketSignalAnalyzer:
'sources': zone.get('sources', [])[:3], '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: async def _get_news_context(self, symbol: str) -> str:
"""获取新闻舆情上下文""" """获取新闻舆情上下文"""
try: try:
@ -1641,6 +1472,7 @@ class MarketSignalAnalyzer:
"先看 JSON 结构块,再用后面的说明性摘要做交叉验证。", "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。",
"重点判断是否存在位置优势,而不是只判断方向。", "重点判断是否存在位置优势,而不是只判断方向。",
"优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone。", "优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone。",
"先明确当前属于哪一种 setup区间边界反转、突破确认、突破后回踩不要混淆。",
] ]
if lane == "intraday" if lane == "intraday"
else [ else [
@ -1648,6 +1480,7 @@ class MarketSignalAnalyzer:
"先看 JSON 结构块,再用后面的说明性摘要做交叉验证。", "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。",
"趋势单必须同时回答四个问题大方向是否清晰、1h 节奏是否支持、位置是否优、拥挤是否可接受。", "趋势单必须同时回答四个问题大方向是否清晰、1h 节奏是否支持、位置是否优、拥挤是否可接受。",
"优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone不接受远离关键位追价。", "优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone不接受远离关键位追价。",
"先明确当前属于哪一种 setup趋势延续回调、深回踩延续、趋势反转不要只给方向。",
] ]
) )
@ -1702,6 +1535,7 @@ class MarketSignalAnalyzer:
prompt_parts.append("2. 方向正确但拥挤过热,也可以不交易。") prompt_parts.append("2. 方向正确但拥挤过热,也可以不交易。")
prompt_parts.append("3. 远离优先交易区、处于区间中部、或已经加速延伸,优先空仓。") prompt_parts.append("3. 远离优先交易区、处于区间中部、或已经加速延伸,优先空仓。")
prompt_parts.append("4. 输出的是可执行 setup不是主观行情评论。") prompt_parts.append("4. 输出的是可执行 setup不是主观行情评论。")
prompt_parts.append("5. setup 必须说得清是反转、确认、回踩还是延续;说不清就空仓。")
prompt_parts.append("") prompt_parts.append("")
prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。") prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。")
@ -1783,6 +1617,93 @@ class MarketSignalAnalyzer:
result['timestamp'] = datetime.now().isoformat() result['timestamp'] = datetime.now().isoformat()
return result 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]]: def _normalize_lane_signals(self, signals: List[Dict[str, Any]], lane_type: str) -> List[Dict[str, Any]]:
"""统一信号时间框架标识""" """统一信号时间框架标识"""
normalized = [] normalized = []
@ -2245,10 +2166,10 @@ class MarketSignalAnalyzer:
def _quantify_ranging_state(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: def _quantify_ranging_state(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
""" """
量化震荡市场指标帮助 LLM 在震荡行情中避免追涨杀跌 使用价格行为与量价关系量化当前更像震荡过渡还是趋势环境
Returns: Returns:
包含 ATR%EMA 收敛度方向效率BB 挤压ADXregime 的字典 包含压缩结构切换方向效率量价接受度等状态
""" """
result = { result = {
'atr_pct': 0.0, 'atr_pct': 0.0,
@ -2257,6 +2178,10 @@ class MarketSignalAnalyzer:
'range_efficiency': 0.0, 'range_efficiency': 0.0,
'bb_squeeze': False, 'bb_squeeze': False,
'adx': 0.0, 'adx': 0.0,
'compression_pct': 0.0,
'swing_flip_count': 0,
'directional_conviction': 0.0,
'volume_price_balance': 'neutral',
'regime': 'unknown', 'regime': 'unknown',
'regime_score': 0, 'regime_score': 0,
} }
@ -2267,20 +2192,19 @@ class MarketSignalAnalyzer:
return result return result
price = float(df_1h['close'].iloc[-1]) 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: if 'atr' in df_1h.columns:
atr = float(df_1h['atr'].iloc[-1]) atr = float(df_1h['atr'].iloc[-1])
result['atr_pct'] = atr / price * 100 if price > 0 else 0 result['atr_pct'] = atr / price * 100 if price > 0 else 0
# ATR 趋势(扩张/收缩)
if len(df_1h) >= 18: if len(df_1h) >= 18:
atr_recent = df_1h['atr'].iloc[-6:].mean() atr_recent = df_1h['atr'].iloc[-6:].mean()
atr_older = df_1h['atr'].iloc[-18:-6].mean() atr_older = df_1h['atr'].iloc[-18:-6].mean()
if atr_older > 0: if atr_older > 0:
result['atr_ratio_trend'] = float((atr_recent - atr_older) / atr_older) 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']): if all(col in df_1h.columns for col in ['ema5', 'ema10', 'ema20']):
ema_short = float(df_1h['ema5'].iloc[-1]) ema_short = float(df_1h['ema5'].iloc[-1])
ema_mid = float(df_1h['ema10'].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)) 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 result['ema_convergence_pct'] = ema_spread / price * 100 if price > 0 else 0
# 方向效率Choppiness Index 近似)
lookback = 14 lookback = 14
if len(df_1h) >= lookback: if len(df_1h) >= lookback:
high_low_range = df_1h['high'].iloc[-lookback:].max() - df_1h['low'].iloc[-lookback:].min() 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]) 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 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: 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_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() / \ bb_width_ma = (df_1h['bb_upper'].iloc[-20:] - df_1h['bb_lower'].iloc[-20:]).mean() / \
df_1h['close'].iloc[-20:].mean() * 100 df_1h['close'].iloc[-20:].mean() * 100
result['bb_squeeze'] = bb_width_ma > 0 and bb_width_current < bb_width_ma * 0.7 result['bb_squeeze'] = bb_width_ma > 0 and bb_width_current < bb_width_ma * 0.7
# ADX
if 'adx' in df_1h.columns: if 'adx' in df_1h.columns:
adx_val = df_1h['adx'].iloc[-1] adx_val = df_1h['adx'].iloc[-1]
if pd.notna(adx_val): if pd.notna(adx_val):
result['adx'] = float(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 score = 0
if result['ema_convergence_pct'] < 0.5: if recent_range_pct <= 4.0:
score += 30 score += 22
elif result['ema_convergence_pct'] < 1.0: 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 score += 20
elif swing_flips >= 4:
if result['range_efficiency'] < 0.2:
score += 30
elif result['range_efficiency'] < 0.4:
score += 15
if result['atr_ratio_trend'] < -0.2:
score += 20
if result['bb_squeeze']:
score += 10 score += 10
if result['adx'] < 20: if pressure_bias == 'neutral':
score += 20
elif result['adx'] < 25:
score += 10 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 result['regime_score'] = int(score)
if score >= 75: if score >= 72:
result['regime'] = 'ranging' result['regime'] = 'ranging'
elif score >= 50: elif score >= 46:
result['regime'] = 'transitional' result['regime'] = 'transitional'
elif score >= 25: elif directional_conviction >= 2.8 and result['range_efficiency'] >= 0.38:
result['regime'] = 'weak_trend'
else:
result['regime'] = 'strong_trend' result['regime'] = 'strong_trend'
else:
result['regime'] = 'weak_trend'
except Exception as e: except Exception as e:
logger.warning(f"震荡市场量化失败: {e}") logger.warning(f"震荡市场量化失败: {e}")
@ -2348,13 +2305,7 @@ class MarketSignalAnalyzer:
def _detect_range_zone(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: def _detect_range_zone(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
""" """
检测震荡区间 - 计算明确的支撑位和压力位 检测震荡区间 - 优先使用价格通道成交密集区与边界响应
使用多种方法综合判断
1. 价格通道最近N根K线的最高/最低价
2. 成交量密集区Volume Profile
3. 布林带
4. EMA支撑/压力
""" """
result = { result = {
'is_ranging': False, 'is_ranging': False,
@ -2369,15 +2320,16 @@ class MarketSignalAnalyzer:
try: try:
df_1h = data.get('1h') df_1h = data.get('1h')
df_15m = data.get('15m')
if df_1h is None or len(df_1h) < 24: # 需要至少24根K线24小时 if df_1h is None or len(df_1h) < 24: # 需要至少24根K线24小时
return result return result
current_price = float(df_1h['close'].iloc[-1]) 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] lookback_periods = [12, 18, 24]
price_channels = [] price_channels = []
@ -2388,7 +2340,6 @@ class MarketSignalAnalyzer:
low = period_data['low'].min() low = period_data['low'].min()
price_channels.append({'high': high, 'low': low, 'width': high - low}) price_channels.append({'high': high, 'low': low, 'width': high - low})
# 选择波动最稳定的通道(宽度变化最小的)
if price_channels: if price_channels:
avg_width = sum(pc['width'] for pc in price_channels) / len(price_channels) avg_width = sum(pc['width'] for pc in price_channels) / len(price_channels)
selected_channel = min(price_channels, selected_channel = min(price_channels,
@ -2398,28 +2349,17 @@ class MarketSignalAnalyzer:
range_width = resistance - support range_width = resistance - support
range_width_pct = (range_width / current_price) * 100 range_width_pct = (range_width / current_price) * 100
# 震荡区间判断标准
is_narrow_range = range_width_pct < 5.0 is_narrow_range = range_width_pct < 5.0
price_in_middle = (current_price - support) / range_width > 0.3 and \ price_in_middle = (current_price - support) / range_width > 0.3 and \
(current_price - support) / range_width < 0.7 (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_support = None
volume_profile_resistance = None volume_profile_resistance = None
volume_profile_mid = None
if len(df_1h) >= 24: if len(df_1h) >= 24:
df_1h_copy = df_1h.iloc[-24:].copy() 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['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) price_bins = pd.cut(df_1h_copy['avg_price'], bins=10)
volume_by_price = df_1h_copy.groupby(price_bins, observed=True)['volume'].sum() 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() max_vol_bin = volume_by_price.idxmax()
if max_vol_bin is not None: if max_vol_bin is not None:
vp_level = (max_vol_bin.left + max_vol_bin.right) / 2 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) volume_profile_support = float(vp_level)
elif vp_level > current_price * 1.02: if vp_level > current_price:
volume_profile_resistance = float(vp_level) 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 = [] support_candidates = []
resistance_candidates = [] resistance_candidates = []
@ -2448,44 +2381,43 @@ class MarketSignalAnalyzer:
support_candidates.append(support) support_candidates.append(support)
if volume_profile_support: if volume_profile_support:
support_candidates.append(volume_profile_support) support_candidates.append(volume_profile_support)
if bb_support:
support_candidates.append(bb_support)
if resistance: if resistance:
resistance_candidates.append(resistance) resistance_candidates.append(resistance)
if volume_profile_resistance: if volume_profile_resistance:
resistance_candidates.append(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_support = np.median(support_candidates) if support_candidates else None
final_resistance = np.median(resistance_candidates) if resistance_candidates else None final_resistance = np.median(resistance_candidates) if resistance_candidates else None
# ========== 5. 计算置信度 ==========
confidence = 0 confidence = 0
reasons = [] reasons = []
if is_narrow_range: if is_narrow_range:
confidence += 30 confidence += 24
reasons.append(f"区间窄({range_width_pct:.1f}%)") reasons.append(f"区间窄({range_width_pct:.1f}%)")
if price_in_middle: if price_in_middle:
confidence += 20 confidence += 20
reasons.append("价格在中部") reasons.append("价格在中部")
if ema_entangled: if range_state.get('swing_flip_count', 0) >= 5:
confidence += 25 confidence += 18
reasons.append("EMA纠缠") reasons.append(f"方向切换频繁({range_state.get('swing_flip_count')}次)")
# 成交量分布检查 if range_state.get('range_efficiency', 1) < 0.3:
if len(df_1h) >= 12: confidence += 16
recent_vol = df_1h['volume'].iloc[-6:].mean() reasons.append("方向效率低")
older_vol = df_1h['volume'].iloc[-12:-6].mean()
if abs(recent_vol - older_vol) / older_vol < 0.3: if pressure_bias == 'neutral' or volume_state in {'neutral', 'high_volume_churn'}:
confidence += 15 confidence += 12
reasons.append("成交量平稳") 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: if final_support and final_resistance:
bounce_count = 0 bounce_count = 0
for i in range(-12, 0): for i in range(-12, 0):
@ -2497,7 +2429,7 @@ class MarketSignalAnalyzer:
bounce_count += 1 bounce_count += 1
if bounce_count >= 2: if bounce_count >= 2:
confidence += 10 confidence += 12
reasons.append(f"边界反弹{bounce_count}") reasons.append(f"边界反弹{bounce_count}")
result.update({ result.update({
@ -2806,12 +2738,7 @@ class MarketSignalAnalyzer:
def _detect_trend_stage(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]: def _detect_trend_stage(self, data: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
""" """
检测趋势阶段早期/中期/晚期 基于价格行为和量价关系识别趋势阶段早期 / 中期 / 晚期
判断标准
1. 早期刚突破关键位均线刚开始排列动能开始释放
2. 中期均线排列稳定价格沿趋势移动量能健康
3. 晚期价格过度延伸RSI极端区量价背离多次假突破
""" """
result = { result = {
'stage': 'unknown', # 'early', 'middle', 'late' 'stage': 'unknown', # 'early', 'middle', 'late'
@ -2822,162 +2749,76 @@ class MarketSignalAnalyzer:
try: try:
df_1h = data.get('1h') 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 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 = [] stage_signals = []
early_score = 0 early_score = 0
middle_score = 0 middle_score = 0
late_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 排列状态 ========== if breakout_quality in {'acceptance_breakout_up', 'acceptance_breakout_down'}:
ema5 = df_1h['ma5'].iloc[-1] if 'ma5' in df_1h.columns else None early_score += 28
ema10 = df_1h['ma10'].iloc[-1] if 'ma10' in df_1h.columns else None stage_signals.append("突破后被市场接受")
ema20 = df_1h['ma20'].iloc[-1] if 'ma20' in df_1h.columns else None elif volume_state in {'bullish_continuation', 'bearish_continuation'} and abs(momentum_12) >= 2.0:
ema50 = df_1h['ma50'].iloc[-1] if 'ma50' in df_1h.columns else None middle_score += 24
stage_signals.append("趋势推进与量价延续同步")
if all([ema5, ema10, ema20, ema50]): if pullback_quality == 'healthy_pullback':
# 检查EMA排列是否形成 middle_score += 18
if ema5 > ema10 > ema20 > ema50: stage_signals.append("回调缩量,趋势结构健康")
# 多头排列 elif pullback_quality in {'heavy_sell_pullback', 'heavy_buy_pullback'}:
# 检查排列刚刚形成(早期)还是已经稳定(中期/晚期) late_score += 16
ema5_cross_ma20 = False stage_signals.append("回调放量,对趋势不利")
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 ema5_cross_ma20: if structure_1h in {'HH/HL', 'LH/LL'} and structure_4h == structure_1h:
early_score += 30 middle_score += 18
stage_signals.append("EMA排列刚形成早期") stage_signals.append("1h 与 4h 结构同向")
else: elif structure_1h in {'HH/HL', 'LH/LL'} and structure_4h and structure_4h != structure_1h:
# 检查EMA间距 early_score += 12
ema_spread = (ema5 - ema20) / ema20 * 100 stage_signals.append("1h 先动4h 尚未完全跟随")
if ema_spread > 3:
late_score += 20
stage_signals.append(f"EMA间距过大({ema_spread:.1f}%) - 可能过度延伸")
else:
middle_score += 20
stage_signals.append("EMA排列稳定中期")
elif ema5 < ema10 < ema20 < ema50: if rejection_signal in {'bullish_rejection', 'bearish_rejection'}:
# 空头排列
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 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排列稳定中期")
# ========== 2. RSI 状态 ==========
if 'rsi' in df_1h.columns:
rsi_current = df_1h['rsi'].iloc[-1]
rsi_prev = df_1h['rsi'].iloc[-5:-1].values
# RSI极端区 - 晚期信号
if rsi_current > 70:
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 early_score += 10
stage_signals.append(f"RSI中性({rsi_current:.0f}) - 可能早期") stage_signals.append("关键位置出现拒绝信号")
# RSI趋势检查 if exhaustion_risk in {'upside_climax', 'downside_climax'}:
if len(rsi_prev) >= 3: late_score += 26
rsi_trend = "up" if rsi_current > rsi_prev[-1] else "down" if rsi_current < rsi_prev[-1] else "flat" stage_signals.append("单边推进出现高潮风险")
if rsi_trend == "flat": elif exhaustion_risk == 'high_volume_churn':
late_score += 10 late_score += 16
stage_signals.append("RSI走平 - 动能衰竭") stage_signals.append("高成交换手,趋势延续性存疑")
# ========== 3. 价格偏离度 ========== if extension_pct <= 0.6:
if ema20: early_score += 8
deviation = abs(current_price - ema20) / ema20 * 100 middle_score += 8
stage_signals.append("价格仍贴近可持续推进区")
elif extension_pct >= 2.5:
late_score += 18
stage_signals.append(f"价格相对近端结构已延伸 {extension_pct:.1f}%")
if deviation > 5: if body_ratio >= 0.65 and (close_pos >= 0.78 or close_pos <= 0.22):
late_score += 30 early_score += 8
stage_signals.append(f"价格偏离EMA20 {deviation:.1f}% - 过度延伸") middle_score += 8
elif deviation > 3: stage_signals.append("实体推进强,收盘靠近极值")
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 = { scores = {
'early': early_score, 'early': early_score,
'middle': middle_score, 'middle': middle_score,

View File

@ -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

View File

@ -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"

View File

@ -137,4 +137,7 @@ class PaperOrder(Base):
'opened_at': format_time(self.opened_at), 'opened_at': format_time(self.opened_at),
'closed_at': format_time(self.closed_at), 'closed_at': format_time(self.closed_at),
'entry_reasons': self.entry_reasons, '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'),
} }

View File

@ -357,7 +357,13 @@ class PaperTradingService:
status=status, status=status,
opened_at=opened_at, opened_at=opened_at,
entry_reasons=[signal.get('reason', '')] if signal.get('reason') else signal.get('reasons', []), 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) db.add(order)

View File

@ -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(): def test_execution_events_are_recorded_and_returned_in_reverse_time_order():
agent = make_agent() 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') agent._record_execution_event('PaperTrading', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold')
events = agent.get_recent_execution_events(limit=10) 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[0]['event_type'] == 'hold'
assert events[1]['platform'] == 'Bitget' assert events[1]['platform'] == 'Bitget'
assert events[1]['reason'] == '余额不足' assert events[1]['reason'] == '余额不足'
assert events[1]['setup_type'] == 'breakout_confirmation'
def test_get_status_contains_last_execution_preview(): def test_get_status_contains_last_execution_preview():

View File

@ -99,8 +99,13 @@ def make_agent():
agent.SIGNAL_POSITION_SIZE_DEFAULTS = {} agent.SIGNAL_POSITION_SIZE_DEFAULTS = {}
agent.SIGNAL_MARGIN_MULTIPLIERS = {} agent.SIGNAL_MARGIN_MULTIPLIERS = {}
agent.PLATFORM_RULES = {'Bitget': {'min_margin': {}, 'max_margin_pct': 0.25}} 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._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._calculate_position_size = MagicMock(return_value=(100.0, 'ok'))
agent._normalize_symbol = lambda symbol: symbol
return agent 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, []) decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, [])
assert decision['decision'] == 'HOLD' 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(): 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'] 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(): def test_runtime_position_state_derives_protection_and_remaining_target():
agent = make_agent() agent = make_agent()

View File

@ -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" 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(): def test_build_analysis_prompt_includes_structured_market_and_derivatives_blocks():
analyzer = make_analyzer() analyzer = make_analyzer()
prompt = analyzer._build_analysis_prompt( prompt = analyzer._build_analysis_prompt(

View File

@ -148,3 +148,20 @@ def test_paper_dynamic_position_uses_equity_pct_instead_of_margin_multiple():
assert margin == pytest.approx(2400.0) assert margin == pytest.approx(2400.0)
assert position_value == pytest.approx(24000.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)

View File

@ -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

View File

@ -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"

View File

@ -2705,6 +2705,22 @@
<div class="stat-chip decision-chip ${paper.tone}"><span class="label">模拟盘</span><span class="value">${paper.label}</span></div> <div class="stat-chip decision-chip ${paper.tone}"><span class="label">模拟盘</span><span class="value">${paper.label}</span></div>
<div class="stat-chip decision-chip ${bitget.tone}"><span class="label">Bitget</span><span class="value">${bitget.label}</span></div> <div class="stat-chip decision-chip ${bitget.tone}"><span class="label">Bitget</span><span class="value">${bitget.label}</span></div>
</div> </div>
${(preview.paper?.setup_type || preview.bitget?.setup_type)
? `
<div style="margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap;">
${preview.paper?.setup_type ? `<span class="event-inline-badge">Paper ${preview.paper.setup_type}</span>` : ''}
${preview.bitget?.setup_type ? `<span class="event-inline-badge">Bitget ${preview.bitget.setup_type}</span>` : ''}
</div>
`
: ''}
${(preview.paper?.entry_basis || preview.paper?.setup_basis || preview.bitget?.entry_basis || preview.bitget?.setup_basis)
? `
<div style="margin-top: 10px;" class="analysis-log-detail">
${preview.paper?.entry_basis || preview.paper?.setup_basis ? `<div>模拟盘: ${preview.paper.entry_basis || preview.paper.setup_basis}</div>` : ''}
${preview.bitget?.entry_basis || preview.bitget?.setup_basis ? `<div>Bitget: ${preview.bitget.entry_basis || preview.bitget.setup_basis}</div>` : ''}
</div>
`
: ''}
<div style="margin-top: 12px; color: var(--muted); font-size: 12px; line-height: 1.6;"> <div style="margin-top: 12px; color: var(--muted); font-size: 12px; line-height: 1.6;">
模拟盘: ${paper.detail} 模拟盘: ${paper.detail}
</div> </div>
@ -2794,8 +2810,15 @@
${event.decision ? `<span class="event-inline-badge">${event.decision}</span>` : ''} ${event.decision ? `<span class="event-inline-badge">${event.decision}</span>` : ''}
${event.action ? `<span class="event-inline-badge">${event.action}</span>` : ''} ${event.action ? `<span class="event-inline-badge">${event.action}</span>` : ''}
${event.signal_timeframe_text ? `<span class="event-inline-badge">${event.signal_timeframe_text}</span>` : ''} ${event.signal_timeframe_text ? `<span class="event-inline-badge">${event.signal_timeframe_text}</span>` : ''}
${event.setup_type ? `<span class="event-inline-badge">${event.setup_type}</span>` : ''}
</div> </div>
<span style="color: var(--muted);">${event.reason || '无说明'}</span> <span style="color: var(--muted);">${event.reason || '无说明'}</span>
${event.setup_basis || event.entry_basis ? `
<div class="analysis-log-detail" style="margin-top: 8px;">
${event.setup_basis ? `<div>Setup: ${event.setup_basis}</div>` : ''}
${event.entry_basis ? `<div>Entry: ${event.entry_basis}</div>` : ''}
</div>
` : ''}
${event.event_type === 'execution_blocked_summary' && Array.isArray(event.blocked_platforms) && event.blocked_platforms.length > 0 ? ` ${event.event_type === 'execution_blocked_summary' && Array.isArray(event.blocked_platforms) && event.blocked_platforms.length > 0 ? `
<div class="blocked-platforms" style="margin-top: 10px;"> <div class="blocked-platforms" style="margin-top: 10px;">
${event.blocked_platforms.map((item) => ` ${event.blocked_platforms.map((item) => `
@ -2860,6 +2883,7 @@
<th>方向</th> <th>方向</th>
<th>入场 / 现价</th> <th>入场 / 现价</th>
<th>仓位 / 杠杆</th> <th>仓位 / 杠杆</th>
<th>Setup</th>
<th>止盈 / 止损</th> <th>止盈 / 止损</th>
<th>未实现盈亏</th> <th>未实现盈亏</th>
<th>盈亏比例</th> <th>盈亏比例</th>
@ -2875,6 +2899,7 @@
<td><span class="side-pill ${item.side === 'long' ? 'long' : 'short'}">${item.side === 'long' ? 'long' : 'short'}</span></td> <td><span class="side-pill ${item.side === 'long' ? 'long' : 'short'}">${item.side === 'long' ? 'long' : 'short'}</span></td>
<td class="inline-mono">${formatMoney(item.entry_price)} / ${formatMoney(item.mark_price)}</td> <td class="inline-mono">${formatMoney(item.entry_price)} / ${formatMoney(item.mark_price)}</td>
<td class="inline-mono">${formatNumber(item.size, 4)} / ${formatNumber(item.leverage, 1)}x</td> <td class="inline-mono">${formatNumber(item.size, 4)} / ${formatNumber(item.leverage, 1)}x</td>
<td>${item.setup_type ? `<div class="inline-mono">${item.setup_type}</div><div class="analysis-log-detail">${item.entry_basis || item.setup_basis || '-'}</div>` : '-'}</td>
<td class="inline-mono">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</td> <td class="inline-mono">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</td>
<td style="color:${(item.unrealized_pnl || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatMoney(item.unrealized_pnl)}</td> <td style="color:${(item.unrealized_pnl || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatMoney(item.unrealized_pnl)}</td>
<td style="color:${(item.pnl_percent || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatPercent(item.pnl_percent, 2)}</td> <td style="color:${(item.pnl_percent || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatPercent(item.pnl_percent, 2)}</td>
@ -2915,6 +2940,7 @@
<th>价格</th> <th>价格</th>
<th>数量 / 杠杆</th> <th>数量 / 杠杆</th>
<th>信号</th> <th>信号</th>
<th>Setup</th>
<th>时间</th> <th>时间</th>
</tr> </tr>
</thead> </thead>
@ -2929,6 +2955,7 @@
<td class="inline-mono">${formatMoney(item.price)}</td> <td class="inline-mono">${formatMoney(item.price)}</td>
<td class="inline-mono">${formatNumber(item.size, 4)} / ${item.leverage ? `${formatNumber(item.leverage, 1)}x` : '-'}</td> <td class="inline-mono">${formatNumber(item.size, 4)} / ${item.leverage ? `${formatNumber(item.leverage, 1)}x` : '-'}</td>
<td class="inline-mono">${item.signal_grade || '-'} ${item.signal_type || ''} ${item.confidence ? `/ ${formatPercent(item.confidence, 1)}` : ''}</td> <td class="inline-mono">${item.signal_grade || '-'} ${item.signal_type || ''} ${item.confidence ? `/ ${formatPercent(item.confidence, 1)}` : ''}</td>
<td>${item.setup_type ? `<div class="inline-mono">${item.setup_type}</div><div class="analysis-log-detail">${item.entry_basis || item.setup_basis || '-'}</div>` : '-'}</td>
<td class="inline-mono">${item.created_at ? relativeTime(item.created_at) : '-'}</td> <td class="inline-mono">${item.created_at ? relativeTime(item.created_at) : '-'}</td>
</tr> </tr>
`).join('')} `).join('')}