update
This commit is contained in:
parent
8320cb0d69
commit
80f5e65d1d
@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 执行下单(统一调用方式)
|
# 执行下单(统一调用方式)
|
||||||
|
|||||||
364
backend/app/crypto_agent/feature_engine.py
Normal file
364
backend/app/crypto_agent/feature_engine.py
Normal 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'])
|
||||||
@ -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. 先判断日内 regime:trending / ranging / neutral。
|
1. 先判断日内 regime:trending / 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 挤压、ADX、regime 的字典
|
包含压缩、结构切换、方向效率、量价接受度等状态
|
||||||
"""
|
"""
|
||||||
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,
|
||||||
|
|||||||
169
backend/app/crypto_agent/regime_engine.py
Normal file
169
backend/app/crypto_agent/regime_engine.py
Normal 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
|
||||||
119
backend/app/crypto_agent/setup_policy.py
Normal file
119
backend/app/crypto_agent/setup_policy.py
Normal 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"
|
||||||
@ -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'),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
127
backend/tests/test_regime_engine_policy.py
Normal file
127
backend/tests/test_regime_engine_policy.py
Normal 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
|
||||||
116
backend/tests/test_system_console_snapshot.py
Normal file
116
backend/tests/test_system_console_snapshot.py
Normal 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"
|
||||||
@ -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('')}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user